feat(notifications): Phase 2.6 NWS adapter pipeline integration

Wires the NWS adapter to the new notification pipeline via EventBus:

- Added fine-grained weather categories: weather_watch, weather_advisory,
  weather_statement (all routine severity) alongside existing weather_warning
- NWSAlertsAdapter._derive_category() maps NWS event type suffix to category:
  "Warning" -> weather_warning, "Watch" -> weather_watch, etc.
- NWSAlertsAdapter.to_event() converts internal event dict to pipeline Event
  with proper group_key (event_id) and inhibit_keys (Warning suppresses Watch)
- EnvironmentalStore accepts optional event_bus parameter
- EnvironmentalStore._ingest() emits new events to bus via _emit_event()
- 22 new tests in test_adapter_nws.py covering category derivation,
  severity mapping, and Event field population

All 119 tests pass.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
K7ZVX 2026-05-15 04:47:31 +00:00
commit 95dc938c2a
4 changed files with 396 additions and 7 deletions

69
meshai/env/nws.py vendored
View file

@ -4,10 +4,12 @@ import json
import logging
import time
from datetime import datetime
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Optional
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from meshai.notifications.events import Event, make_event
if TYPE_CHECKING:
from ..config import NWSConfig
@ -39,6 +41,71 @@ class NWSAlertsAdapter:
else: # moderate, minor, unknown
return "routine"
def _derive_category(self, event_type: str) -> str:
"""Derive notification category from NWS event type suffix.
NWS event types like "Red Flag Warning", "Winter Storm Watch",
"Wind Advisory" map to our fine-grained weather categories.
Args:
event_type: NWS event type string (e.g., "Tornado Warning")
Returns:
Category key: weather_warning, weather_watch, weather_advisory,
or weather_statement
"""
event_type_lower = event_type.lower()
if event_type_lower.endswith("warning"):
return "weather_warning"
elif event_type_lower.endswith("watch"):
return "weather_watch"
elif event_type_lower.endswith("advisory"):
return "weather_advisory"
else:
# Covers "Special Weather Statement", "Short Term Forecast", etc.
return "weather_statement"
def to_event(self, raw: dict) -> Event:
"""Convert internal event dict to pipeline Event.
Args:
raw: Internal event dict from get_events()
Returns:
Event instance ready for EventBus emission
"""
event_type = raw.get("event_type", "Unknown")
category = self._derive_category(event_type)
nws_severity = raw.get("severity", "unknown")
severity = self._map_nws_severity(nws_severity)
# Build group_key for dedup: same alert ID should merge
group_key = raw.get("event_id", "")
# Build inhibit_keys: a Warning supersedes Watch/Advisory for same hazard
inhibit_keys = []
if category == "weather_warning":
# Warning inhibits corresponding Watch/Advisory
base = event_type.rsplit(" ", 1)[0] if " " in event_type else event_type
inhibit_keys = [f"nws:{base} Watch", f"nws:{base} Advisory"]
return make_event(
source="nws",
category=category,
severity=severity,
title=raw.get("headline", event_type),
summary=raw.get("headline", ""),
body=raw.get("description", ""),
effective=raw.get("onset") or None,
expires=raw.get("expires") or None,
lat=raw.get("lat"),
lon=raw.get("lon"),
nws_zones=raw.get("areas", []),
group_key=group_key,
inhibit_keys=inhibit_keys,
data=raw,
)
def tick(self) -> bool:
"""Execute one polling tick.

32
meshai/env/store.py vendored
View file

@ -2,10 +2,11 @@
import logging
import time
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING:
from ..config import EnvironmentalConfig
from ..notifications.pipeline import EventBus
logger = logging.getLogger(__name__)
@ -13,9 +14,15 @@ logger = logging.getLogger(__name__)
class EnvironmentalStore:
"""Cache and tick-driver for all environmental feed adapters."""
def __init__(self, config: "EnvironmentalConfig", region_anchors: list = None):
def __init__(
self,
config: "EnvironmentalConfig",
region_anchors: list = None,
event_bus: Optional["EventBus"] = None,
):
self._adapters = {} # name -> adapter instance
self._events = {} # (source, event_id) -> event dict
self._event_bus = event_bus # Pipeline EventBus for emission
self._swpc_status = {} # Kp/SFI/scales snapshot
self._ducting_status = {} # tropo ducting assessment
self._mesh_zones = config.nws_zones or []
@ -87,12 +94,29 @@ class EnvironmentalStore:
self._swpc_status = adapter.get_status()
# Also ingest any alert events (R-scale >= 3)
for evt in adapter.get_events():
self._events[(evt["source"], evt["event_id"])] = evt
key = (evt["source"], evt["event_id"])
is_new = key not in self._events
self._events[key] = evt
if is_new and self._event_bus and hasattr(adapter, "to_event"):
self._emit_event(adapter, evt)
elif name == "ducting":
self._ducting_status = adapter.get_status()
else:
for evt in adapter.get_events():
self._events[(evt["source"], evt["event_id"])] = evt
key = (evt["source"], evt["event_id"])
is_new = key not in self._events
self._events[key] = evt
if is_new and self._event_bus and hasattr(adapter, "to_event"):
self._emit_event(adapter, evt)
def _emit_event(self, adapter, raw_evt: dict):
"""Convert raw event to pipeline Event and emit to bus."""
try:
event = adapter.to_event(raw_evt)
self._event_bus.emit(event)
logger.debug("Emitted %s event %s to pipeline", event.source, event.id)
except Exception as e:
logger.warning("Failed to emit event to pipeline: %s", e)
def _purge_expired(self):
"""Remove expired events."""