mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-11-02 11:02:20 +00:00
This brings the weather slices of my long-running refactor branch to dev ahead of converging wx and gwx, so the convergence PRs build on one copy of the code instead of rewriting it twice. - `modules/weather_common.py` (WeatherCommandMixin): code wx and gwx shared by copy (MQTT and WXSIM source lookup, multi-day sending, high/low formatting, the forecast-option parser, the Open-Meteo model loader). - `modules/nws_alerts.py`: one NWS alert entry parser for wx and the weather service, keeping each caller's own Special Weather Statement rules. - `modules/http_retry.py`: one retrying session builder for the weather HTTP clients. - wx: get_noaa_weather, the hourly, tomorrow and multi-day formatters, _add_period_details and the extract_* readers broken up or made table-driven; NOAA fetches go through `_noaa_fetch`. - gwx: one fetch path; `_open_meteo_current` and `_open_meteo_daily_tail` split out of get_open_meteo_weather. - solarforecast: `_format_forecast` built from one timestamp parser and one day builder (418 → 155 lines). - aurora, rain, wx, gwx: companion and bot location lookups through `modules.location` (which gains optional logging arguments). The changes outside these files that the refactor branch made (the base command's enabled_attr hook, the service base's run_periodic and _cancel_tasks) stay out: aurora and rain keep their can_execute overrides and the weather service its own loops, exactly as on dev. Tests: characterization goldens over real captured NOAA, NWS, Open-Meteo and forecast.solar responses (tests/characterization, fixtures in tests/fixtures), 50 tests and several hundred cases, all of which also pass against dev's unmodified weather code. Each refactor step was additionally checked on the branch with an old-vs-new differential run (tens of thousands of generated inputs, including log calls and exceptions) and reviewed by codex.
328 lines
14 KiB
Python
328 lines
14 KiB
Python
"""Parse entries of the NWS active-alerts Atom feed (api.weather.gov/alerts/active.atom).
|
|
|
|
Shared by the wx command and the weather service. The two classify Special
|
|
Weather Statements differently, so each passes its own ``SpecialStatementRules``
|
|
(``WX_SPECIAL_RULES`` or ``SERVICE_SPECIAL_RULES``); everything else is common.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass
|
|
from typing import Any, Callable, Optional
|
|
|
|
SPECIAL_EVENTS = ("special", "special weather")
|
|
|
|
_HEADLINE_SKIP_WORDS = {
|
|
'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by', 'will', 'lead',
|
|
'increased', 'threat', 'remains', 'effect', 'until', 'during', 'last', 'week', 'including', 'today',
|
|
}
|
|
|
|
# (keywords in the lowercased child tag, field); first match wins, as an elif chain
|
|
_CAP_CHILD_FIELDS = (
|
|
(("event",), "event"),
|
|
(("severity",), "severity"),
|
|
(("urgency",), "urgency"),
|
|
(("certainty",), "certainty"),
|
|
(("effective",), "effective"),
|
|
(("expires",), "expires"),
|
|
(("areadesc", "area"), "area_desc"),
|
|
)
|
|
|
|
# localName -> (field, value meaning "not found yet")
|
|
_CAP_LOCAL_NAMES = {
|
|
'event': ('event', ''),
|
|
'severity': ('severity', 'Unknown'),
|
|
'urgency': ('urgency', 'Unknown'),
|
|
'certainty': ('certainty', 'Unknown'),
|
|
'effective': ('effective', ''),
|
|
'expires': ('expires', ''),
|
|
'areadesc': ('area_desc', ''),
|
|
'area': ('area_desc', ''),
|
|
}
|
|
|
|
|
|
def node_value(node: Any) -> str:
|
|
"""Joined text of a DOM node's direct text children."""
|
|
if not node or not node.childNodes:
|
|
return ""
|
|
text_parts = []
|
|
for child in node.childNodes:
|
|
if child.nodeType == child.TEXT_NODE or hasattr(child, 'nodeValue') and child.nodeValue:
|
|
text_parts.append(child.nodeValue)
|
|
return " ".join(text_parts).strip()
|
|
|
|
|
|
def _first_child_value(entry: Any, tag: str) -> str:
|
|
elems = entry.getElementsByTagName(tag)
|
|
if elems and elems[0].childNodes:
|
|
return elems[0].childNodes[0].nodeValue if elems[0].childNodes[0].nodeValue else ""
|
|
return ""
|
|
|
|
|
|
def entry_title(entry: Any) -> Optional[str]:
|
|
"""The entry's <title> text ("" when absent; None when its first child is not text)."""
|
|
title_elem = entry.getElementsByTagName("title")
|
|
return title_elem[0].childNodes[0].nodeValue if title_elem and title_elem[0].childNodes else ""
|
|
|
|
|
|
def entry_summary(entry: Any) -> str:
|
|
"""The entry's <summary> text, falling back to <content>."""
|
|
return _first_child_value(entry, "summary") or _first_child_value(entry, "content")
|
|
|
|
|
|
def entry_nws_headline(entry: Any) -> str:
|
|
"""The NWSheadline CAP parameter (cap:parameter, or parameter without the prefix)."""
|
|
params = entry.getElementsByTagName("cap:parameter")
|
|
if not params:
|
|
params = entry.getElementsByTagName("parameter")
|
|
for param in params:
|
|
value_name_elem = param.getElementsByTagName("valueName")
|
|
value_elem = param.getElementsByTagName("value")
|
|
if value_name_elem and value_elem and value_name_elem[0].childNodes and value_elem[0].childNodes:
|
|
value_name = value_name_elem[0].childNodes[0].nodeValue if value_name_elem[0].childNodes[0].nodeValue else ""
|
|
if value_name == "NWSheadline":
|
|
return value_elem[0].childNodes[0].nodeValue if value_elem[0].childNodes[0].nodeValue else ""
|
|
return ""
|
|
|
|
|
|
def _first_meaningful_headline_word(headline_lower: str) -> Optional[str]:
|
|
meaningful_words = [w for w in headline_lower.split() if w not in _HEADLINE_SKIP_WORDS and len(w) > 3]
|
|
return meaningful_words[0].capitalize() if meaningful_words else None
|
|
|
|
|
|
def _rain_unless_other_threat(headline_lower: str) -> Optional[str]:
|
|
# Rain mentioned alongside another threat keeps the generic event
|
|
if not any(word in headline_lower for word in ['landslide', 'flood', 'wind', 'snow']):
|
|
return "Rainfall"
|
|
return None
|
|
|
|
|
|
def wx_special_headline_event(headline_lower: str) -> Optional[str]:
|
|
"""wx: event name for a Special Weather Statement from its NWSheadline, or None to keep it generic."""
|
|
if any(phrase in headline_lower for phrase in ['debris flow', 'mudslide']):
|
|
return "Debris Flow"
|
|
if 'landslide' in headline_lower:
|
|
return "Landslide (Burn)" if ('burn' in headline_lower or 'burned area' in headline_lower) else "Landslide"
|
|
if any(phrase in headline_lower for phrase in ['flash flood', 'river flood']) or 'flood' in headline_lower or 'flooding' in headline_lower:
|
|
return "Flood"
|
|
if any(phrase in headline_lower for phrase in ['high wind', 'strong wind', 'damaging wind']) or 'wind' in headline_lower or 'gust' in headline_lower:
|
|
return "Wind"
|
|
if any(phrase in headline_lower for phrase in ['heavy rain', 'excessive rain']):
|
|
return "Heavy Rain"
|
|
if 'rain' in headline_lower or 'rainfall' in headline_lower or 'precipitation' in headline_lower:
|
|
return _rain_unless_other_threat(headline_lower)
|
|
if any(phrase in headline_lower for phrase in ['heavy snow', 'blizzard', 'winter storm']) or 'snow' in headline_lower or 'winter' in headline_lower:
|
|
return "Snow"
|
|
if any(phrase in headline_lower for phrase in ['dense fog', 'low visibility']):
|
|
return "Fog"
|
|
if 'fog' in headline_lower or 'visibility' in headline_lower:
|
|
return "Visibility"
|
|
if any(phrase in headline_lower for phrase in ['extreme heat', 'excessive heat']):
|
|
return "Heat"
|
|
if 'heat' in headline_lower or 'temperature' in headline_lower:
|
|
return "Temperature"
|
|
if any(phrase in headline_lower for phrase in ['storm surge', 'coastal flood']) or 'marine' in headline_lower or 'coastal' in headline_lower:
|
|
return "Marine"
|
|
return _first_meaningful_headline_word(headline_lower)
|
|
|
|
|
|
def service_special_headline_event(headline_lower: str) -> Optional[str]:
|
|
"""Weather service: event name for a Special Weather Statement from its NWSheadline, or None."""
|
|
if any(phrase in headline_lower for phrase in ['debris flow', 'mudslide']):
|
|
return "Debris Flow"
|
|
if 'landslide' in headline_lower:
|
|
return "Landslide (Burn)" if ('burn' in headline_lower or 'burned area' in headline_lower) else "Landslide"
|
|
if any(phrase in headline_lower for phrase in ['flash flood', 'river flood', 'flood', 'flooding']):
|
|
return "Flood"
|
|
if any(phrase in headline_lower for phrase in ['high wind', 'strong wind', 'damaging wind', 'wind', 'gust']):
|
|
return "Wind"
|
|
if any(phrase in headline_lower for phrase in ['heavy rain', 'excessive rain', 'rain', 'rainfall', 'precipitation']):
|
|
return _rain_unless_other_threat(headline_lower)
|
|
if any(phrase in headline_lower for phrase in ['heavy snow', 'blizzard', 'winter storm', 'snow', 'winter']):
|
|
return "Snow"
|
|
if any(phrase in headline_lower for phrase in ['dense fog', 'low visibility', 'fog', 'visibility']):
|
|
return "Fog" if 'fog' in headline_lower else "Visibility"
|
|
if any(phrase in headline_lower for phrase in ['extreme heat', 'excessive heat', 'heat', 'temperature']):
|
|
return "Heat" if 'heat' in headline_lower else "Temperature"
|
|
if any(phrase in headline_lower for phrase in ['storm surge', 'coastal flood', 'marine', 'coastal']):
|
|
return "Marine"
|
|
return _first_meaningful_headline_word(headline_lower)
|
|
|
|
|
|
_COMMON_SUMMARY_KEYWORDS: tuple[tuple[tuple[str, ...], str], ...] = (
|
|
(('landslide', 'debris flow', 'mudslide'), "Landslide"),
|
|
(('hydrologic', 'river', 'flood', 'stream'), "Hydrologic"),
|
|
(('marine', 'coastal', 'beach', 'surf'), "Marine"),
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SpecialStatementRules:
|
|
"""How a caller names a generic Special Weather Statement."""
|
|
|
|
headline_event: Callable[[str], Optional[str]]
|
|
summary_keywords: tuple[tuple[tuple[str, ...], str], ...]
|
|
# wx also reads CAP fields by DOM localName after the tag-name scan
|
|
scan_local_names: bool
|
|
|
|
|
|
WX_SPECIAL_RULES = SpecialStatementRules(
|
|
headline_event=wx_special_headline_event,
|
|
summary_keywords=_COMMON_SUMMARY_KEYWORDS + (
|
|
(('avalanche', 'snow', 'mountain'), "Avalanche"),
|
|
(('air quality', 'smoke', 'pollution'), "Air Quality"),
|
|
(('wind', 'gust'), "Wind"),
|
|
(('rain', 'precipitation', 'shower', 'rainfall'), "Rainfall"),
|
|
(('temperature', 'heat', 'cold', 'freeze'), "Temperature"),
|
|
(('visibility', 'fog', 'haze'), "Visibility"),
|
|
),
|
|
scan_local_names=True,
|
|
)
|
|
|
|
SERVICE_SPECIAL_RULES = SpecialStatementRules(
|
|
headline_event=service_special_headline_event,
|
|
summary_keywords=_COMMON_SUMMARY_KEYWORDS + (
|
|
(('wind', 'gust'), "Wind"),
|
|
(('rain', 'precipitation', 'shower', 'rainfall'), "Rainfall"),
|
|
),
|
|
scan_local_names=False,
|
|
)
|
|
|
|
|
|
def _special_statement_event(event: str, title_lower: str, nws_headline: str, summary: str,
|
|
rules: SpecialStatementRules) -> str:
|
|
if event.lower() in SPECIAL_EVENTS and nws_headline:
|
|
event = rules.headline_event(nws_headline.lower()) or event
|
|
if event.lower() in SPECIAL_EVENTS and summary:
|
|
summary_lower = summary.lower()
|
|
for words, name in rules.summary_keywords:
|
|
if any(word in summary_lower for word in words):
|
|
event = name
|
|
break
|
|
if event.lower() in SPECIAL_EVENTS:
|
|
event = "Weather" if "weather" in title_lower else "Special"
|
|
return event
|
|
|
|
|
|
def _event_from_title(title: str, nws_headline: str, summary: str, rules: SpecialStatementRules) -> tuple[str, str]:
|
|
"""(event_type, event) from a title such as "High Wind Warning issued ... by NWS Seattle WA"."""
|
|
title_lower = title.lower()
|
|
for event_type, pattern in (
|
|
("Warning", r'^([^W]+?)\s+Warning'),
|
|
("Watch", r'^([^W]+?)\s+Watch'),
|
|
("Advisory", r'^([^A]+?)\s+Advisory'),
|
|
):
|
|
if event_type.lower() in title_lower:
|
|
event_match = re.search(pattern, title, re.IGNORECASE)
|
|
return event_type, event_match.group(1).strip() if event_match else ""
|
|
if "statement" in title_lower:
|
|
event_match = re.search(r'^([^S]+?)\s+Statement', title, re.IGNORECASE)
|
|
event = event_match.group(1).strip() if event_match else "Special"
|
|
return "Statement", _special_statement_event(event, title_lower, nws_headline, summary, rules)
|
|
return "Unknown", title.split()[0] if title else ""
|
|
|
|
|
|
def _times_and_office_from_title(title: str) -> tuple[str, str, str]:
|
|
"""(effective, expires, office) from "issued X until Y by OFFICE"."""
|
|
effective = expires = office = ""
|
|
issued_match = re.search(r'issued\s+([^u]+?)\s+until\s+(.+?)\s+by', title, re.IGNORECASE)
|
|
if issued_match:
|
|
effective = issued_match.group(1).strip()
|
|
expires = issued_match.group(2).strip()
|
|
else:
|
|
until_match = re.search(r'until\s+(.+?)\s+by', title, re.IGNORECASE)
|
|
if until_match:
|
|
expires = until_match.group(1).strip()
|
|
office_match = re.search(r'by\s+(.+?)$', title, re.IGNORECASE)
|
|
if office_match:
|
|
office = office_match.group(1).strip()
|
|
return effective, expires, office
|
|
|
|
|
|
def _apply_cap_children(entry: Any, fields: dict[str, str]) -> None:
|
|
"""Override fields from the entry's direct CAP children (cap:event, cap:severity, ...)."""
|
|
for child in entry.childNodes:
|
|
if not hasattr(child, 'tagName'):
|
|
continue
|
|
tag_lower = child.tagName.lower()
|
|
for keywords, field in _CAP_CHILD_FIELDS:
|
|
if not any(k in tag_lower for k in keywords):
|
|
continue
|
|
if field == "event" and fields["event"]:
|
|
continue
|
|
value = node_value(child)
|
|
if value:
|
|
fields[field] = value
|
|
break
|
|
|
|
|
|
def _apply_cap_local_names(entry: Any, fields: dict[str, str]) -> None:
|
|
"""Fill still-unset fields from any descendant by DOM localName."""
|
|
try:
|
|
for node in entry.getElementsByTagName("*"):
|
|
if hasattr(node, 'localName'):
|
|
local_name = node.localName.lower()
|
|
node_val = node_value(node)
|
|
if node_val and local_name in _CAP_LOCAL_NAMES:
|
|
field, unset = _CAP_LOCAL_NAMES[local_name]
|
|
if fields[field] == unset:
|
|
fields[field] = node_val
|
|
except Exception:
|
|
pass # Namespace-aware attributes may not be available
|
|
|
|
|
|
def parse_alert_fields(entry: Any, title: str, summary: str, nws_headline: str,
|
|
rules: SpecialStatementRules) -> dict[str, str]:
|
|
"""Alert dict (title through office) for one feed entry; raises on malformed input."""
|
|
event_type, event = _event_from_title(title, nws_headline, summary, rules)
|
|
effective, expires, office = _times_and_office_from_title(title)
|
|
fields = {
|
|
'event': event,
|
|
'severity': "Unknown",
|
|
'urgency': "Unknown",
|
|
'certainty': "Unknown",
|
|
'effective': effective,
|
|
'expires': expires,
|
|
'area_desc': "",
|
|
}
|
|
_apply_cap_children(entry, fields)
|
|
if rules.scan_local_names:
|
|
_apply_cap_local_names(entry, fields)
|
|
|
|
event = fields['event']
|
|
severity = fields['severity']
|
|
if severity == "Unknown":
|
|
if any(word in event.lower() for word in ['extreme', 'tornado', 'hurricane', 'blizzard']):
|
|
severity = "Extreme"
|
|
elif any(word in event.lower() for word in ['severe', 'warning']):
|
|
severity = "Severe"
|
|
elif any(word in event.lower() for word in ['advisory', 'moderate']):
|
|
severity = "Moderate"
|
|
else:
|
|
severity = "Minor"
|
|
|
|
urgency = fields['urgency']
|
|
if urgency == "Unknown":
|
|
if event_type == "Warning":
|
|
urgency = "Immediate"
|
|
elif event_type == "Watch":
|
|
urgency = "Expected"
|
|
else:
|
|
urgency = "Future"
|
|
|
|
return {
|
|
'title': title,
|
|
'summary': summary,
|
|
'nws_headline': nws_headline,
|
|
'event': event,
|
|
'event_type': event_type,
|
|
'severity': severity,
|
|
'urgency': urgency,
|
|
'certainty': fields['certainty'],
|
|
'effective': fields['effective'],
|
|
'expires': fields['expires'],
|
|
'area_desc': fields['area_desc'],
|
|
'office': office,
|
|
}
|