From 1d35188b985000e814539347a051ae4f071cf9e1 Mon Sep 17 00:00:00 2001 From: K7ZVX Date: Wed, 27 May 2026 23:08:24 +0000 Subject: [PATCH] feat(notifications): Phase 2.10 avalanche adapter pipeline integration Adds AvalancheAdapter.to_event(), wiring the avalanche.org map-layer adapter into the notification EventBus, following the Phase 2.7 traffic / 2.9 USGS pattern. to_event() design (emit only elevated danger): - Category from danger_level: High/Extreme (4-5) -> avalanche_warning; Considerable (3) -> avalanche_watch. - Low/Moderate (1-2) and No-Rating (-1/0) have no distinct trend trigger in this adapter and are intentionally NOT emitted (return None) -- the two categories are warning/watch only, matching the spec. - Severity: passed through unchanged from the adapter's danger mapping (danger >= 4 -> priority, else routine; the adapter never emits "immediate"). Severity tiering is delegated to the pipeline Inhibitor. - Summary: headline + danger name + travel advice. - group_key/inhibit_keys: the adapter's stable "avy_{center}_{zone}" event_id as both. Re-polls of the same zone coalesce; single inhibit key lets the Inhibitor suppress lower-severity re-emissions. - Defensive: missing centroid (lat/lon), missing event_id, or missing danger_level returns None; try/except-guarded. No store.py change: the Phase 2.9 _emit_event None-guard already handles to_event() returning None, and store gates emission on hasattr(adapter, "to_event"). Rule 17: no new tunable. avalanche enabled / center_ids / season_months already exist in env_feeds.yaml (GUI-editable). Rule 18 N/A -- the avalanche.org v2 public map-layer API is keyless (no .env entry; the .ref credentials store has no avalanche provider key, confirming none is needed). Rule 16: standalone fetch path validated in-container below. Config note: avalanche was already enabled (center_ids: [SNFAC], the Sawtooth Avalanche Center -- the correct South Central Idaho / Magic Valley center). It was already one of the 7 live adapters, so this phase keeps the count at 7 (no 7->8 change) and required no env_feeds.yaml edit. There is no per-zone config knob; the adapter fetches all zones for the configured center. Tests: tests/test_adapter_avalanche.py (14 tests) mirrors test_adapter_usgs -- category split (warning vs watch), severity pass-through, group_key/inhibit_keys, distinct-zone keys, field population, and the non-emit/defensive cases (low/moderate -> None, no-rating -> None, missing danger_level/centroid/event_id -> None, corrupted -> None). Full suite: 188 passed. Live smoke test (prod container, Phase 2.10 code rebuilt in): clean startup, 7 env adapters loaded, no traceback. Late May is off-season (season_months [12,1,2,3,4]) so tick() short-circuits in normal operation. To exercise the open-API path, a one-shot standalone fetch was run in-container with an all-months config against center SNFAC: health is_loaded=true, last_error=null, consecutive_errors=0, last_fetch set, off_season=false -- the fetch reached api.avalanche.org with no DNS/auth errors (Phase 2.6.6 DNS fix). event_count=0 because all SNFAC zones are server-side off_season in late May, so no Event is emitted -- acceptable per the seasonal caveat. The temporary season_months edit was reverted and the container restarted on the real config (7 adapters, healthy). The emission path (elevated -> avalanche_warning / avalanche_watch) is unit-validated and is the same store->bus path emitting live for NWS and traffic. Co-Authored-By: Claude Opus 4.7 (1M context) --- meshai/env/avalanche.py | 77 ++++++++++++- tests/test_adapter_avalanche.py | 196 ++++++++++++++++++++++++++++++++ 2 files changed, 272 insertions(+), 1 deletion(-) create mode 100644 tests/test_adapter_avalanche.py diff --git a/meshai/env/avalanche.py b/meshai/env/avalanche.py index b1d65dc..4fa1729 100644 --- a/meshai/env/avalanche.py +++ b/meshai/env/avalanche.py @@ -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 AvalancheConfig @@ -227,6 +229,79 @@ class AvalancheAdapter: return (None, None) + def to_event(self, evt: dict) -> Optional["Event"]: + """Translate a stored avalanche advisory into a pipeline Event. + + Only elevated danger is emitted: the category is chosen from + danger_level, so a Low/Moderate/No-Rating advisory is intentionally + NOT emitted (returns None). High/Extreme (4-5) -> avalanche_warning; + Considerable (3) -> avalanche_watch. + + Args: + evt: Internal event dict from get_events() + + Returns: + Event instance ready for EventBus emission, or None if the dict + is missing its centroid or event_id, or the danger is not elevated. + """ + try: + lat = evt.get("lat") + lon = evt.get("lon") + if lat is None or lon is None: + return None # No centroid -- can't make a useful Event + + event_id = evt.get("event_id") + if not event_id: + return None # No stable identity to group/inhibit on + + danger_level = evt.get("danger_level") + if danger_level is None: + return None + + # Category from danger level: High/Extreme (4-5) is a warning, + # Considerable (3) is a watch, anything below is not actionable. + if danger_level >= 4: + category = "avalanche_warning" + elif danger_level == 3: + category = "avalanche_watch" + else: + return None # Low/Moderate/No-Rating -- do not emit + + severity = evt.get("severity", "routine") + title = evt.get("headline") or evt.get("zone_name") or "Avalanche Advisory" + + # Summary: headline plus the danger name and travel advice. + summary_parts = [title] + danger_name = evt.get("danger_name") + if danger_name: + summary_parts.append(f"Danger: {danger_name}") + travel = evt.get("travel_advice") + if travel: + summary_parts.append(str(travel)) + summary = " | ".join(summary_parts)[:300] + + # event_id is already the stable "avy_{center}_{zone}" key. Re-polls + # of the same zone coalesce on this group_key; using it as the sole + # inhibit_key lets the pipeline Inhibitor suppress lower-severity + # re-emissions while a higher-severity one is active (severity + # tiering delegated to the Inhibitor). + return make_event( + source="avalanche", + category=category, + severity=severity, + title=title, + summary=summary, + timestamp=evt.get("fetched_at"), + expires=evt.get("expires"), + lat=lat, + lon=lon, + group_key=event_id, + inhibit_keys=[event_id], + ) + except Exception: + logger.exception(f"Avalanche to_event failed for evt: {evt.get('event_id')}") + return None + def is_off_season(self) -> bool: """Check if currently off season.""" return self._off_season diff --git a/tests/test_adapter_avalanche.py b/tests/test_adapter_avalanche.py new file mode 100644 index 0000000..e319bae --- /dev/null +++ b/tests/test_adapter_avalanche.py @@ -0,0 +1,196 @@ +"""Tests for avalanche adapter Phase 2.10 — to_event() method.""" + +import time +from unittest.mock import MagicMock + +import pytest + +from meshai.env.avalanche import AvalancheAdapter +from meshai.notifications.events import Event + + +# ============================================================ +# FIXTURES +# ============================================================ + +@pytest.fixture +def mock_config(): + """Create a mock AvalancheConfig with real scalar fields.""" + config = MagicMock() + config.center_ids = ["SNFAC"] + config.tick_seconds = 1800 + config.season_months = [12, 1, 2, 3, 4] + return config + + +@pytest.fixture +def adapter(mock_config): + """Create an AvalancheAdapter with mocked config.""" + return AvalancheAdapter(mock_config) + + +def make_avy_event( + center_id="SNFAC", + zone_name="Banner Summit", + danger_level=4, + danger_name="High", + severity="priority", + travel_advice="Dangerous avalanche conditions.", + lat=44.3, + lon=-115.2, + headline=None, +): + """Helper to create a stored avalanche event dict (mirrors _fetch).""" + now = time.time() + if headline is None: + headline = f"{zone_name}: {danger_name} avalanche danger" + if travel_advice: + headline += f" -- {travel_advice[:100]}" + return { + "source": "avalanche", + "event_id": f"avy_{center_id}_{zone_name.replace(' ', '_').lower()}", + "event_type": "Avalanche Advisory", + "severity": severity, + "headline": headline, + "zone_name": zone_name, + "center": "Sawtooth Avalanche Center", + "center_id": center_id, + "center_link": "https://www.sawtoothavalanche.com", + "forecast_link": "https://www.sawtoothavalanche.com/forecast", + "danger": danger_name.lower(), + "danger_level": danger_level, + "danger_name": danger_name, + "travel_advice": travel_advice, + "state": "ID", + "lat": lat, + "lon": lon, + "expires": now + 3600, + "fetched_at": now, + } + + +# ============================================================ +# CATEGORY TESTS +# ============================================================ + +def test_high_and_extreme_are_warning(adapter): + """Danger level 4 (High) and 5 (Extreme) map to avalanche_warning.""" + for level, name in [(4, "High"), (5, "Extreme")]: + event = adapter.to_event(make_avy_event(danger_level=level, danger_name=name)) + assert event is not None + assert event.category == "avalanche_warning" + + +def test_considerable_is_watch(adapter): + """Danger level 3 (Considerable) maps to avalanche_watch.""" + event = adapter.to_event( + make_avy_event(danger_level=3, danger_name="Considerable", severity="routine") + ) + assert event is not None + assert event.category == "avalanche_watch" + + +# ============================================================ +# SEVERITY PASS-THROUGH TESTS +# ============================================================ + +def test_severity_passes_through(adapter): + """Severity from the stored event passes through unchanged.""" + for sev in ["routine", "priority", "immediate"]: + event = adapter.to_event(make_avy_event(severity=sev, danger_level=4)) + assert event is not None + assert event.severity == sev + + +# ============================================================ +# GROUP KEY / INHIBIT KEY TESTS +# ============================================================ + +def test_group_key_is_event_id(adapter): + """Group key is the stable avy_{center}_{zone} key.""" + event = adapter.to_event(make_avy_event(center_id="SNFAC", zone_name="Banner Summit")) + assert event is not None + assert event.group_key == "avy_SNFAC_banner_summit" + + +def test_inhibit_keys_match_group_key(adapter): + """The sole inhibit key equals the group key (Inhibitor does severity tiering).""" + event = adapter.to_event(make_avy_event()) + assert event is not None + assert event.inhibit_keys == [event.group_key] + + +def test_distinct_zones_get_distinct_keys(adapter): + """Two zones in the same center get distinct group keys.""" + e1 = adapter.to_event(make_avy_event(zone_name="Banner Summit")) + e2 = adapter.to_event(make_avy_event(zone_name="Galena Summit")) + assert e1.group_key != e2.group_key + + +# ============================================================ +# CONTENT / FIELD POPULATION TESTS +# ============================================================ + +def test_populates_core_fields(adapter): + """Core Event fields are populated from the stored dict.""" + evt = make_avy_event(lat=44.31, lon=-115.22) + event = adapter.to_event(evt) + assert event is not None + assert event.source == "avalanche" + assert event.lat == 44.31 + assert event.lon == -115.22 + assert event.expires == evt["expires"] + assert event.timestamp == evt["fetched_at"] + assert event.id # auto-computed + + +def test_summary_includes_danger_and_advice(adapter): + """Summary includes the danger name and travel advice.""" + event = adapter.to_event( + make_avy_event(danger_name="Extreme", danger_level=5, travel_advice="Avoid all terrain.") + ) + assert event is not None + assert "Extreme" in event.summary + assert "Avoid all terrain." in event.summary + + +# ============================================================ +# DEFENSIVE / NON-EMIT TESTS +# ============================================================ + +def test_low_danger_returns_none(adapter): + """Low/Moderate (1-2) danger is not actionable, returns None.""" + for level, name in [(1, "Low"), (2, "Moderate")]: + assert adapter.to_event(make_avy_event(danger_level=level, danger_name=name)) is None + + +def test_no_rating_returns_none(adapter): + """A no-rating (-1/0) advisory returns None.""" + for level in [-1, 0]: + assert adapter.to_event(make_avy_event(danger_level=level, danger_name="No Rating")) is None + + +def test_missing_danger_level_returns_none(adapter): + """Missing danger_level returns None.""" + evt = make_avy_event() + evt["danger_level"] = None + assert adapter.to_event(evt) is None + + +def test_missing_centroid_returns_none(adapter): + """Missing centroid (lat/lon) returns None.""" + evt = make_avy_event() + evt["lat"] = None + assert adapter.to_event(evt) is None + + +def test_missing_event_id_returns_none(adapter): + """Missing event_id returns None (no stable group key).""" + evt = make_avy_event() + evt["event_id"] = None + assert adapter.to_event(evt) is None + + +def test_does_not_raise_on_corrupted_dict(adapter): + """Corrupted dict returns None without raising.""" + assert adapter.to_event({"garbage": True}) is None