mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-06-11 01:14:45 +02:00
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) <noreply@anthropic.com>
This commit is contained in:
parent
4feb6a1895
commit
1d35188b98
2 changed files with 272 additions and 1 deletions
77
meshai/env/avalanche.py
vendored
77
meshai/env/avalanche.py
vendored
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue