diff --git a/docs/CONSUMER-INTEGRATION.md b/docs/CONSUMER-INTEGRATION.md index bb32b6a..22b3a5f 100644 --- a/docs/CONSUMER-INTEGRATION.md +++ b/docs/CONSUMER-INTEGRATION.md @@ -134,6 +134,7 @@ Central's archive. | `CENTRAL_TRAFFIC` | `central.traffic.>` | 7 | 1 GiB | ✓ | ✓ | | `CENTRAL_TRAFFIC_FLOW` | `central.traffic_flow.>` | 7 | 1 GiB | ✓ | ✓ | | `CENTRAL_TRAFFIC_CAMERAS` | `central.traffic_cameras.>` | 7 | 1 GiB | ✓ | ✓ | +| `CENTRAL_AVY` | `central.avy.>` | 7 | 1 GiB | ✓ | ✓ | | `CENTRAL_META` | `central.meta.>` | 1 | 1 GiB | — | ✓ | Retention and storage caps are migration-seeded defaults visible in `config.streams`; @@ -1791,6 +1792,45 @@ at parameter `00060`, gage height (ft) at `00065`, water temperature (°C) at \ --- +### avalanche_org — avalanche.org backcountry advisories (v0.10.10) + +- **Source:** `https://api.avalanche.org/v2/public/products/map-layer/` + per configured forecast center (defaults: SNFAC Sawtooth, PAC Payette). +- **Stream:** `CENTRAL_AVY` (`central.avy.>`) +- **Subject:** `central.avy.advisory.us.` — one subject per state; + multiple zones in the same state coexist via category-discriminated + `Nats-Msg-Id` (v0.10.8). +- **Dedup key shape:** `_` (e.g. `SNFAC_banner_summit`). + Slug is the zone name lowercased with non-alphanumeric runs collapsed to `_`. +- **Severity gate (adapter-side):** only `danger_level >= 3` publishes. + `danger_level` of 0/1/2 (None/Low/Moderate), -1 ("no rating"), and + `off_season=true` are all omitted — no Event yielded. +- **Severity mapping (danger_level → Event.severity / centralseverity):** + `3 (Considerable) → 2`, `4 (High) → 3`, `5 (Extreme) → 4`. Higher = more + severe per Central convention. +- **Event.data fields:** + + | Field | Type | Notes | + |---|---|---| + | `center_id` | string | Upstream forecast center identifier (e.g. `SNFAC`) | + | `zone_name` | string | Human-readable zone name (e.g. `Banner Summit`) | + | `danger_level` | int | 3, 4, or 5 (published levels only) | + | `danger_name` | string | Upstream textual label (`Considerable`/`High`/`Extreme`) | + | `travel_advice` | string | Truncated to 200 chars | + | `state` | string | 2-letter state code (uppercase) | + | `valid_date` | string | Upstream `start_date` ISO string (timezone-naive) | + | `end_date` | string | Upstream `end_date` ISO string | + | `off_season` | bool | Always `false` for published events | + | `latitude` / `longitude` | float | Polygon centroid (computed via shapely) | +- **Geometry:** the upstream Polygon passes through as-is in `geo.geometry`. + MultiPolygon also supported defensively; centroid is computed from whichever. +- **Off-season behavior:** during summer all SNFAC/PAC zones return + `off_season=true` + `danger_level=-1` — the adapter yields zero events, + by design. + +\ +--- + ## 7. Fall-off / removal semantics Central adapters fall into three buckets for handling upstream events that diff --git a/docs/PRODUCER-INTEGRATION.md b/docs/PRODUCER-INTEGRATION.md index adc0b2d..39b3e34 100644 --- a/docs/PRODUCER-INTEGRATION.md +++ b/docs/PRODUCER-INTEGRATION.md @@ -362,7 +362,7 @@ central..[....] ``` - `` is one of `wx`, `fire`, `quake`, `space`, `disaster`, `hydro`, - `traffic`, `traffic_flow`, `traffic_cameras`, `meta` (the current set — see [§8](#8-the-streamentry-registry) for adding + `traffic`, `traffic_flow`, `traffic_cameras`, `avy`, `meta` (the current set — see [§8](#8-the-streamentry-registry) for adding one). Operators MUST be able to subscribe to all of one domain with `central..>`. - `` is adapter-driven and identifies the event category within the @@ -553,6 +553,7 @@ STREAMS: list[StreamEntry] = [ StreamEntry("CENTRAL_TRAFFIC", "central.traffic.>"), StreamEntry("CENTRAL_TRAFFIC_FLOW", "central.traffic_flow.>"), StreamEntry("CENTRAL_TRAFFIC_CAMERAS", "central.traffic_cameras.>"), + StreamEntry("CENTRAL_AVY", "central.avy.>"), StreamEntry("CENTRAL_META", "central.meta.>", event_bearing=False), ] ``` diff --git a/sql/migrations/035_add_central_avy_stream.sql b/sql/migrations/035_add_central_avy_stream.sql new file mode 100644 index 0000000..2575f91 --- /dev/null +++ b/sql/migrations/035_add_central_avy_stream.sql @@ -0,0 +1,9 @@ +-- Migration 035: seed CENTRAL_AVY JetStream stream config row (v0.10.10) +-- Backs the central.avy.> subject space populated by the avalanche_org adapter. +-- 7-day retention, 1 GiB max_bytes — mirrors CENTRAL_FIRE / CENTRAL_QUAKE / +-- CENTRAL_SPACE defaults. Operator can re-tune via the /streams GUI page. +-- Idempotent: uses ON CONFLICT DO NOTHING. + +INSERT INTO config.streams (name, max_age_s, max_bytes) +VALUES ('CENTRAL_AVY', 604800, 1073741824) +ON CONFLICT (name) DO NOTHING; diff --git a/src/central/adapters/avalanche_org.py b/src/central/adapters/avalanche_org.py new file mode 100644 index 0000000..77c8513 --- /dev/null +++ b/src/central/adapters/avalanche_org.py @@ -0,0 +1,314 @@ +"""avalanche.org public map-layer adapter — backcountry avalanche advisories. + +Polls ``https://api.avalanche.org/v2/public/products/map-layer/{center_id}`` per +configured center (defaults: SNFAC Sawtooth, PAC Payette). Each response is a +GeoJSON FeatureCollection where every feature is a forecast zone Polygon with +``properties.danger_level`` (1 Low → 5 Extreme; -1 = "no rating" during the +off-season). + +Severity gate (per v0.10.10 meshai spec): only ``danger_level >= 3`` events +publish. ``danger_level < 3``, ``danger_level == -1``, and ``off_season=true`` +are all omitted at adapter level -- no Event yielded, no publish, no record in +``published_ids``. During Idaho summer this means SNFAC + PAC both yield zero +events; that's correct off-season behavior. + +danger_level → Event.severity (= centralseverity) follows Central's +4-most-severe convention (consistent with nws.SEVERITY_MAP): + + 5 (Extreme) → 4 + 4 (High) → 3 + 3 (Considerable) → 2 + +(meshai's original spec had this inverted; corrected after a clarifying check.) + +Subject convention: ``central.avy.advisory.us.{state_lower}`` -- one subject +per state, all zones for that state collapse to it (JetStream dedup is per +(msg_id, stream) and msg_id is now category-discriminated post-v0.10.8 so +multiple zones in the same state coexist cleanly). + +Geometry passes through as the upstream Polygon; centroid computed via +shapely. Latitude/longitude in ``data.data`` mirror the centroid so the +supervisor's enrichment pipeline can geocode if configured. +""" + +from __future__ import annotations + +import asyncio +import logging +import re +import sqlite3 +from collections.abc import AsyncIterator +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import aiohttp +from pydantic import BaseModel +from shapely.geometry import shape as shapely_shape +from tenacity import ( + after_nothing, + retry, + retry_if_exception_type, + stop_after_attempt, + wait_exponential_jitter, +) + +from central.adapter import SourceAdapter +from central.config_models import AdapterConfig +from central.config_store import ConfigStore +from central.models import Event, Geo + +logger = logging.getLogger(__name__) + +_BASE_URL = "https://api.avalanche.org/v2/public/products/map-layer" +_FETCH_TIMEOUT_S = 30 +_TRAVEL_ADVICE_MAX_CHARS = 200 + +# avalanche.org danger_level → Central event.severity (= centralseverity). +# Higher = more severe, matching nws.SEVERITY_MAP convention (Central-wide). +_DANGER_TO_SEVERITY: dict[int, int] = {3: 2, 4: 3, 5: 4} + +_DEDUP_DDL = ( + "CREATE TABLE IF NOT EXISTS published_ids (" + "adapter TEXT NOT NULL, event_id TEXT NOT NULL, " + "first_seen TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, " + "last_seen TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, " + "PRIMARY KEY (adapter, event_id))" +) + + +def _slug(text: str) -> str: + """Lowercase + collapse runs of non-alphanum to single underscores. + + 'Sawtooth & Western Smoky Mtns' → 'sawtooth_western_smoky_mtns'. + """ + return re.sub(r"[^a-zA-Z0-9]+", "_", text or "").strip("_").lower() + + +def _parse_iso(value: Any) -> datetime | None: + """Parse an upstream ISO datetime (possibly timezone-naive) into UTC.""" + if not isinstance(value, str) or not value: + return None + try: + dt = datetime.fromisoformat(value.replace("Z", "+00:00")) + except (ValueError, TypeError): + return None + if dt.tzinfo is None: + # avalanche.org returns naive strings tagged with a separate timezone + # field; treat naive as UTC for our internal timeline -- the precise + # local tz lives in data.data and consumers convert if needed. + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + + +def _centroid(geometry: dict[str, Any] | None) -> tuple[float, float] | None: + """Return (lon, lat) of a GeoJSON Polygon/MultiPolygon centroid; None on bad input.""" + if not geometry: + return None + try: + c = shapely_shape(geometry).centroid + return (float(c.x), float(c.y)) + except Exception: + return None + + +class AvalancheOrgSettings(BaseModel): + """``center_ids`` lists the avalanche center IDs to poll. Defaults are + Sawtooth (SNFAC) + Payette (PAC) for Idaho-region coverage; operator can + extend to any avalanche.org-recognised center (NWAC, CAIC, etc.).""" + + center_ids: list[str] = ["SNFAC", "PAC"] + + +_EXP_WAIT = wait_exponential_jitter(initial=1, max=30) + + +class AvalancheOrgAdapter(SourceAdapter): + """avalanche.org backcountry advisory map-layer poller.""" + + name = "avalanche_org" + display_name = "avalanche.org Forecast Centers" + description = ( + "Backcountry avalanche advisories from avalanche.org's per-center map " + "layers. Severity-gated: only Considerable (3) and above publish; " + "off-season + 'no rating' zones are omitted." + ) + settings_schema = AvalancheOrgSettings + requires_api_key = None + wizard_order = None + default_cadence_s = 1800 # 30 min — matches avalanche.org daily-update cadence + data_class = "event" + enrichment_locations = [("latitude", "longitude")] + + def __init__( + self, + config: AdapterConfig, + config_store: ConfigStore, + cursor_db_path: Path, + ) -> None: + self._config_store = config_store + self._cursor_db_path = cursor_db_path + self._session: aiohttp.ClientSession | None = None + self._db: sqlite3.Connection | None = None + self._center_ids: list[str] = list( + config.settings.get("center_ids") or ["SNFAC", "PAC"] + ) + + async def startup(self) -> None: + self._session = aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=_FETCH_TIMEOUT_S), + headers={"User-Agent": "Central/0.10 (+avalanche_org)"}, + ) + self._db = sqlite3.connect(self._cursor_db_path) + self._db.execute(_DEDUP_DDL) + self._db.execute( + "CREATE INDEX IF NOT EXISTS published_ids_last_seen " + "ON published_ids (last_seen)" + ) + self._db.commit() + logger.info( + "avalanche_org adapter started", + extra={"center_ids": self._center_ids}, + ) + + async def shutdown(self) -> None: + if self._session: + await self._session.close() + self._session = None + if self._db: + self._db.close() + self._db = None + + async def apply_config(self, new_config: AdapterConfig) -> None: + self._center_ids = list( + new_config.settings.get("center_ids") or ["SNFAC", "PAC"] + ) + logger.info( + "avalanche_org config updated", + extra={"center_ids": self._center_ids}, + ) + + @retry( + stop=stop_after_attempt(3), + wait=_EXP_WAIT, + retry=retry_if_exception_type( + (aiohttp.ClientConnectionError, asyncio.TimeoutError, TimeoutError) + ), + reraise=True, + before_sleep=None, + after=after_nothing, + ) + async def _fetch(self, center_id: str) -> dict[str, Any] | None: + """GET one center's map-layer. Returns parsed JSON, or None on permanent error.""" + if self._session is None: + raise RuntimeError("avalanche_org session not started") + async with self._session.get(f"{_BASE_URL}/{center_id}") as resp: + if resp.status == 200: + return await resp.json(content_type=None) + body_preview = (await resp.text())[:200] + logger.warning( + "avalanche_org upstream non-200; skipping center this poll", + extra={"center_id": center_id, "status": resp.status, + "body": body_preview}, + ) + return None + + def _build_event_record( + self, feature: dict[str, Any], center_id: str + ) -> Event | None: + """Build an Event from one map-layer feature; returns None if gated out.""" + props = feature.get("properties") or {} + if props.get("off_season"): + return None + danger_level = props.get("danger_level") + severity = _DANGER_TO_SEVERITY.get(danger_level if isinstance(danger_level, int) else -999) + if severity is None: + # danger_level not in {3,4,5}: omit (covers -1 / 0 / 1 / 2 / unset). + return None + + zone_name = props.get("name") or "unknown" + state = (props.get("state") or "").strip() + if not state: + return None + geometry = feature.get("geometry") + centroid = _centroid(geometry) + if centroid is None: + return None + lon, lat = centroid + + valid_dt = _parse_iso(props.get("start_date")) or datetime.now(timezone.utc) + advice = (props.get("travel_advice") or "")[:_TRAVEL_ADVICE_MAX_CHARS] + + return Event( + id=f"{center_id}_{_slug(zone_name)}", + adapter=self.name, + category=f"avy.advisory.{center_id.lower()}", + time=valid_dt, + expires=_parse_iso(props.get("end_date")), + severity=severity, + geo=Geo( + centroid=(lon, lat), + geometry=geometry, + regions=[f"US-{state}"], + primary_region=f"US-{state}", + ), + data={ + "center_id": center_id, + "zone_name": zone_name, + "danger_level": danger_level, + "danger_name": props.get("danger"), + "travel_advice": advice, + "state": state, + "valid_date": props.get("start_date"), + "end_date": props.get("end_date"), + "off_season": False, + "latitude": lat, + "longitude": lon, + }, + ) + + async def poll(self) -> AsyncIterator[Event]: + if not self._session: + raise RuntimeError("Session not initialized") + + results = await asyncio.gather( + *[self._fetch(c) for c in self._center_ids], return_exceptions=True, + ) + + yielded = 0 + omitted = 0 + for center_id, result in zip(self._center_ids, results): + if isinstance(result, BaseException) or result is None: + if isinstance(result, BaseException): + logger.warning( + "avalanche_org fetch failed", + extra={"center_id": center_id, "error": str(result)}, + ) + continue + features = result.get("features") or [] + for feat in features: + try: + ev = self._build_event_record(feat, center_id) + except Exception: + logger.exception( + "avalanche_org feature parse failed", + extra={"center_id": center_id}, + ) + omitted += 1 + continue + if ev is None: + omitted += 1 + continue + yield ev + yielded += 1 + + self.sweep_old_ids() + logger.info( + "avalanche_org poll completed", + extra={"centers": self._center_ids, + "events_yielded": yielded, "events_omitted": omitted}, + ) + + def subject_for(self, event: Event) -> str: + state = (event.data.get("state") or "").lower() or "unknown" + return f"central.avy.advisory.us.{state}" diff --git a/src/central/gui/templates/_event_rows/avalanche_org.html b/src/central/gui/templates/_event_rows/avalanche_org.html new file mode 100644 index 0000000..2f9d89e --- /dev/null +++ b/src/central/gui/templates/_event_rows/avalanche_org.html @@ -0,0 +1,8 @@ +{# avalanche.org backcountry advisory. Fields from payload->data->data. #} +{% set d = (event.data.get('data') or {}).get('data') or {} %} +{% if d.get('zone_name') is not none %}
Zone
{{ d.zone_name }}
{% endif %} +{% if d.get('center_id') is not none %}
Center
{{ d.center_id }}
{% endif %} +{% if d.get('state') is not none %}
State
{{ d.state }}
{% endif %} +{% if d.get('danger_level') is not none %}
Danger
{{ d.danger_level }} ({{ d.danger_name or '?' }})
{% endif %} +{% if d.get('valid_date') is not none %}
Valid
{{ d.valid_date }}
{% endif %} +{% if d.get('travel_advice') is not none %}
Travel advice
{{ d.travel_advice }}
{% endif %} diff --git a/src/central/gui/templates/_event_summaries/avalanche_org.html b/src/central/gui/templates/_event_summaries/avalanche_org.html new file mode 100644 index 0000000..de23608 --- /dev/null +++ b/src/central/gui/templates/_event_summaries/avalanche_org.html @@ -0,0 +1,2 @@ +{% set d = (event.data.get('data') or {}).get('data') or {} %} +{%- if d.get('zone_name') %}Avalanche advisory — {{ d.zone_name }}{% if d.get('danger_name') %} ({{ d.danger_name }}){% endif %}{% endif -%} diff --git a/src/central/streams.py b/src/central/streams.py index ec2b97e..c3baa7c 100644 --- a/src/central/streams.py +++ b/src/central/streams.py @@ -32,5 +32,6 @@ STREAMS: list[StreamEntry] = [ StreamEntry("CENTRAL_TRAFFIC", "central.traffic.>"), StreamEntry("CENTRAL_TRAFFIC_FLOW", "central.traffic_flow.>"), StreamEntry("CENTRAL_TRAFFIC_CAMERAS", "central.traffic_cameras.>"), + StreamEntry("CENTRAL_AVY", "central.avy.>"), StreamEntry("CENTRAL_META", "central.meta.>", event_bearing=False), ] diff --git a/src/central/supervisor.py b/src/central/supervisor.py index 55dd8c6..99341fc 100644 --- a/src/central/supervisor.py +++ b/src/central/supervisor.py @@ -140,6 +140,7 @@ STREAM_CATEGORY_DOMAINS: dict[str, tuple[str, ...]] = { "CENTRAL_TRAFFIC": ("incident", "closure", "work_zone"), "CENTRAL_TRAFFIC_FLOW": ("flow",), "CENTRAL_TRAFFIC_CAMERAS": ("camera",), + "CENTRAL_AVY": ("avy",), } diff --git a/tests/fixtures/avalanche_snfac.json b/tests/fixtures/avalanche_snfac.json new file mode 100644 index 0000000..7afb0cf --- /dev/null +++ b/tests/fixtures/avalanche_snfac.json @@ -0,0 +1,1135 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "id": 2907, + "properties": { + "name": "Banner Summit", + "center": "Sawtooth Avalanche Center", + "center_link": "https://www.sawtoothavalanche.com/", + "timezone": "America/Denver", + "center_id": "SNFAC", + "state": "ID", + "off_season": true, + "travel_advice": "Watch for signs of unstable snow such as recent avalanches, cracking in the snow, and audible collapsing. Avoid traveling on or under similar slopes.", + "danger": "no rating", + "danger_level": -1, + "color": "#888888", + "stroke": "#104efb", + "font_color": "#ffffff", + "link": "https://www.sawtoothavalanche.com/forecasts/avalanche/banner-summit", + "start_date": "2026-05-04T17:59:00", + "end_date": "2026-11-01T19:00:00", + "fillOpacity": 0.5, + "fillIncrement": 0.1, + "warning": { + "product": null + } + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + -115.138874, + 44.2614528 + ], + [ + -115.1617415, + 44.257419 + ], + [ + -115.1716265, + 44.2831339 + ], + [ + -115.1947313, + 44.301009 + ], + [ + -115.2311048, + 44.3055083 + ], + [ + -115.2457084, + 44.2975165 + ], + [ + -115.2833539, + 44.2947103 + ], + [ + -115.3741316, + 44.2984942 + ], + [ + -115.4061015, + 44.3011989 + ], + [ + -115.4324143, + 44.3073084 + ], + [ + -115.446103, + 44.3212728 + ], + [ + -115.4613095, + 44.3519552 + ], + [ + -115.4727717, + 44.4173564 + ], + [ + -115.4600931, + 44.493862 + ], + [ + -115.4429455, + 44.5110415 + ], + [ + -115.4175206, + 44.5179614 + ], + [ + -115.3018712, + 44.5270106 + ], + [ + -115.2837776, + 44.5246207 + ], + [ + -115.2448394, + 44.5094724 + ], + [ + -115.2315541, + 44.4991822 + ], + [ + -115.2185013, + 44.4958211 + ], + [ + -115.1882942, + 44.4964549 + ], + [ + -115.167121, + 44.5009287 + ], + [ + -115.1178545, + 44.4943391 + ], + [ + -115.081012, + 44.4933686 + ], + [ + -115.0542813, + 44.4851446 + ], + [ + -115.0104882, + 44.4901515 + ], + [ + -114.9729699, + 44.5035704 + ], + [ + -114.9647735, + 44.5007211 + ], + [ + -114.9454779, + 44.4804398 + ], + [ + -114.9416968, + 44.4613191 + ], + [ + -114.9299942, + 44.4448358 + ], + [ + -114.9047814, + 44.4313507 + ], + [ + -114.9044588, + 44.4205692 + ], + [ + -114.8979217, + 44.4078909 + ], + [ + -114.978435, + 44.3985633 + ], + [ + -115.0232961, + 44.3801264 + ], + [ + -115.0855262, + 44.2994481 + ], + [ + -115.1194948, + 44.2703503 + ], + [ + -115.138874, + 44.2614528 + ] + ] + ] + } + }, + { + "type": "Feature", + "id": 2904, + "properties": { + "name": "Galena Summit & Eastern Mtns", + "center": "Sawtooth Avalanche Center", + "center_link": "https://www.sawtoothavalanche.com/", + "timezone": "America/Denver", + "center_id": "SNFAC", + "state": "ID", + "off_season": true, + "travel_advice": "Watch for signs of unstable snow such as recent avalanches, cracking in the snow, and audible collapsing. Avoid traveling on or under similar slopes.", + "danger": "no rating", + "danger_level": -1, + "color": "#888888", + "stroke": "#104efb", + "font_color": "#ffffff", + "link": "https://www.sawtoothavalanche.com/forecasts/avalanche/galena-summit-&-eastern-mtns", + "start_date": "2026-05-04T17:59:00", + "end_date": "2026-11-01T19:00:00", + "fillOpacity": 0.5, + "fillIncrement": 0.1, + "warning": { + "product": null + } + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + -114.8446175, + 44.2608996 + ], + [ + -114.8111421, + 44.2605064 + ], + [ + -114.7552782, + 44.2332183 + ], + [ + -114.6983948, + 44.2200041 + ], + [ + -114.649504, + 44.1995976 + ], + [ + -114.6387335, + 44.1729502 + ], + [ + -114.5966802, + 44.1296575 + ], + [ + -114.5932842, + 44.1188533 + ], + [ + -114.5728534, + 44.093356 + ], + [ + -114.5690375, + 44.0808182 + ], + [ + -114.5794577, + 44.0347533 + ], + [ + -114.579774, + 44.0112541 + ], + [ + -114.5747383, + 43.988 + ], + [ + -114.5537203, + 43.9626577 + ], + [ + -114.5307082, + 43.9532599 + ], + [ + -114.4469656, + 43.9465611 + ], + [ + -114.3875694, + 43.9335669 + ], + [ + -114.34314, + 43.9094639 + ], + [ + -114.3092234, + 43.8797561 + ], + [ + -114.2604248, + 43.8504177 + ], + [ + -114.1809214, + 43.8321735 + ], + [ + -114.1046775, + 43.8067929 + ], + [ + -114.0791159, + 43.8010874 + ], + [ + -114.0559845, + 43.79984 + ], + [ + -114.0167393, + 43.8148133 + ], + [ + -113.9919154, + 43.8206233 + ], + [ + -113.9787403, + 43.8147219 + ], + [ + -113.9727411, + 43.8035239 + ], + [ + -113.9802692, + 43.7934712 + ], + [ + -113.9883941, + 43.7704676 + ], + [ + -113.9709411, + 43.714719 + ], + [ + -113.9719464, + 43.6878206 + ], + [ + -113.9654234, + 43.6628032 + ], + [ + -113.9664323, + 43.6525663 + ], + [ + -113.975844, + 43.6481947 + ], + [ + -114.1018316, + 43.6567446 + ], + [ + -114.1908254, + 43.6767942 + ], + [ + -114.2009513, + 43.6805548 + ], + [ + -114.2335804, + 43.7063231 + ], + [ + -114.2644999, + 43.7204015 + ], + [ + -114.2727186, + 43.7335808 + ], + [ + -114.2931726, + 43.7522993 + ], + [ + -114.366615, + 43.7975177 + ], + [ + -114.3818938, + 43.8064857 + ], + [ + -114.4111343, + 43.8090447 + ], + [ + -114.4351157, + 43.7991251 + ], + [ + -114.4787859, + 43.7884726 + ], + [ + -114.505536, + 43.7714938 + ], + [ + -114.5557865, + 43.7223697 + ], + [ + -114.5846493, + 43.686673 + ], + [ + -114.6076998, + 43.6472265 + ], + [ + -114.6291343, + 43.6192913 + ], + [ + -114.6616927, + 43.6160661 + ], + [ + -114.6802863, + 43.618286 + ], + [ + -114.6724519, + 43.6492057 + ], + [ + -114.6769643, + 43.6558163 + ], + [ + -114.7052024, + 43.6775203 + ], + [ + -114.7245645, + 43.7006522 + ], + [ + -114.7289481, + 43.7196946 + ], + [ + -114.7348271, + 43.7867413 + ], + [ + -114.7530438, + 43.8271988 + ], + [ + -114.7714002, + 43.8870404 + ], + [ + -114.7822398, + 43.9045177 + ], + [ + -114.7994125, + 43.9523218 + ], + [ + -114.8300567, + 44.0204178 + ], + [ + -114.837045, + 44.0552317 + ], + [ + -114.8849117, + 44.1634596 + ], + [ + -114.9176879, + 44.1745117 + ], + [ + -114.9308308, + 44.194099 + ], + [ + -114.9306446, + 44.2134896 + ], + [ + -114.9223221, + 44.2301544 + ], + [ + -114.8966445, + 44.2426661 + ], + [ + -114.8700231, + 44.2488551 + ], + [ + -114.8446175, + 44.2608996 + ] + ] + ] + } + }, + { + "type": "Feature", + "id": 2906, + "properties": { + "name": "Sawtooth & Western Smoky Mtns", + "center": "Sawtooth Avalanche Center", + "center_link": "https://www.sawtoothavalanche.com/", + "timezone": "America/Denver", + "center_id": "SNFAC", + "state": "ID", + "off_season": true, + "travel_advice": "Watch for signs of unstable snow such as recent avalanches, cracking in the snow, and audible collapsing. Avoid traveling on or under similar slopes.", + "danger": "no rating", + "danger_level": -1, + "color": "#888888", + "stroke": "#104efb", + "font_color": "#ffffff", + "link": "https://www.sawtoothavalanche.com/forecasts/avalanche/sawtooth-&-western-smoky-mtns", + "start_date": "2026-05-04T17:59:00", + "end_date": "2026-11-01T19:00:00", + "fillOpacity": 0.5, + "fillIncrement": 0.1, + "warning": { + "product": null + } + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + -115.1759322, + 44.1409253 + ], + [ + -115.1798793, + 44.1689253 + ], + [ + -115.1638028, + 44.2085545 + ], + [ + -115.1631598, + 44.2150458 + ], + [ + -115.1691407, + 44.2311114 + ], + [ + -115.1617415, + 44.257419 + ], + [ + -115.1479193, + 44.2580703 + ], + [ + -115.1249402, + 44.2672598 + ], + [ + -115.0855262, + 44.2994481 + ], + [ + -115.0232961, + 44.3801264 + ], + [ + -114.978435, + 44.3985633 + ], + [ + -114.9014371, + 44.4059358 + ], + [ + -114.8932312, + 44.4031277 + ], + [ + -114.8784142, + 44.3720699 + ], + [ + -114.8512993, + 44.3569823 + ], + [ + -114.8406993, + 44.3472438 + ], + [ + -114.8412851, + 44.3371303 + ], + [ + -114.8322258, + 44.3048933 + ], + [ + -114.8292771, + 44.2776243 + ], + [ + -114.820212, + 44.2658736 + ], + [ + -114.8214223, + 44.26175 + ], + [ + -114.8446175, + 44.2608996 + ], + [ + -114.8700231, + 44.2488551 + ], + [ + -114.8966445, + 44.2426661 + ], + [ + -114.9223221, + 44.2301544 + ], + [ + -114.9306446, + 44.2134896 + ], + [ + -114.9308308, + 44.194099 + ], + [ + -114.9176879, + 44.1745117 + ], + [ + -114.8849117, + 44.1634596 + ], + [ + -114.837045, + 44.0552317 + ], + [ + -114.8300567, + 44.0204178 + ], + [ + -114.7994125, + 43.9523218 + ], + [ + -114.7822398, + 43.9045177 + ], + [ + -114.7714002, + 43.8870404 + ], + [ + -114.7530438, + 43.8271988 + ], + [ + -114.7348271, + 43.7867413 + ], + [ + -114.7289481, + 43.7196946 + ], + [ + -114.7245645, + 43.7006522 + ], + [ + -114.7052024, + 43.6775203 + ], + [ + -114.6769643, + 43.6558163 + ], + [ + -114.6724519, + 43.6492057 + ], + [ + -114.6802863, + 43.618286 + ], + [ + -114.7370655, + 43.6173784 + ], + [ + -114.7954699, + 43.6045019 + ], + [ + -114.859198, + 43.6084087 + ], + [ + -114.9046727, + 43.6068248 + ], + [ + -114.9449101, + 43.6018537 + ], + [ + -114.9814795, + 43.5879138 + ], + [ + -115.0271527, + 43.5889398 + ], + [ + -115.0504963, + 43.5989683 + ], + [ + -115.0771391, + 43.604192 + ], + [ + -115.1128025, + 43.6038817 + ], + [ + -115.1151015, + 43.6390263 + ], + [ + -115.1279352, + 43.7002433 + ], + [ + -115.1221168, + 43.7697045 + ], + [ + -115.125942, + 43.8031331 + ], + [ + -115.1501328, + 43.8477941 + ], + [ + -115.1609606, + 43.8815762 + ], + [ + -115.1692619, + 43.9235166 + ], + [ + -115.1778328, + 43.9387028 + ], + [ + -115.1967583, + 43.9596468 + ], + [ + -115.2012668, + 43.9713154 + ], + [ + -115.2020673, + 43.9893653 + ], + [ + -115.1884887, + 44.0391981 + ], + [ + -115.1844632, + 44.1033017 + ], + [ + -115.1759322, + 44.1409253 + ] + ] + ] + } + }, + { + "type": "Feature", + "id": 2905, + "properties": { + "name": "Soldier & Wood River Valley Mtns", + "center": "Sawtooth Avalanche Center", + "center_link": "https://www.sawtoothavalanche.com/", + "timezone": "America/Denver", + "center_id": "SNFAC", + "state": "ID", + "off_season": true, + "travel_advice": "Watch for signs of unstable snow such as recent avalanches, cracking in the snow, and audible collapsing. Avoid traveling on or under similar slopes.", + "danger": "no rating", + "danger_level": -1, + "color": "#888888", + "stroke": "#104efb", + "font_color": "#ffffff", + "link": "https://www.sawtoothavalanche.com/forecasts/avalanche/soldier-&-wood-river-valley-mtns", + "start_date": "2026-05-04T17:59:00", + "end_date": "2026-11-01T19:00:00", + "fillOpacity": 0.5, + "fillIncrement": 0.1, + "warning": { + "product": null + } + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + -115.1128025, + 43.6038817 + ], + [ + -115.0771391, + 43.604192 + ], + [ + -115.0504963, + 43.5989683 + ], + [ + -115.0271527, + 43.5889398 + ], + [ + -114.9814795, + 43.5879138 + ], + [ + -114.9449101, + 43.6018537 + ], + [ + -114.9046727, + 43.6068248 + ], + [ + -114.859198, + 43.6084087 + ], + [ + -114.7954699, + 43.6045019 + ], + [ + -114.7370655, + 43.6173784 + ], + [ + -114.6616927, + 43.6160661 + ], + [ + -114.6291343, + 43.6192913 + ], + [ + -114.6076998, + 43.6472265 + ], + [ + -114.5846493, + 43.686673 + ], + [ + -114.5557865, + 43.7223697 + ], + [ + -114.505536, + 43.7714938 + ], + [ + -114.4787859, + 43.7884726 + ], + [ + -114.4351157, + 43.7991251 + ], + [ + -114.4111343, + 43.8090447 + ], + [ + -114.3818938, + 43.8064857 + ], + [ + -114.2931726, + 43.7522993 + ], + [ + -114.2727186, + 43.7335808 + ], + [ + -114.2644999, + 43.7204015 + ], + [ + -114.2335804, + 43.7063231 + ], + [ + -114.2009513, + 43.6805548 + ], + [ + -114.1170674, + 43.6588235 + ], + [ + -113.975844, + 43.6481947 + ], + [ + -113.961114, + 43.6406346 + ], + [ + -113.9496878, + 43.6211143 + ], + [ + -113.9371012, + 43.5843687 + ], + [ + -113.9420904, + 43.5652506 + ], + [ + -113.9807011, + 43.5143163 + ], + [ + -113.9736104, + 43.4641059 + ], + [ + -113.952297, + 43.4324763 + ], + [ + -113.9112702, + 43.3912372 + ], + [ + -113.9100808, + 43.3669776 + ], + [ + -113.9235922, + 43.3290544 + ], + [ + -113.9426459, + 43.3074879 + ], + [ + -114.0050062, + 43.2953495 + ], + [ + -114.0285364, + 43.3000146 + ], + [ + -114.0627546, + 43.313214 + ], + [ + -114.1792198, + 43.3642322 + ], + [ + -114.2103143, + 43.3866 + ], + [ + -114.2389029, + 43.3884312 + ], + [ + -114.2695614, + 43.3714135 + ], + [ + -114.3130839, + 43.3626042 + ], + [ + -114.3709392, + 43.3602081 + ], + [ + -114.4407513, + 43.3500491 + ], + [ + -114.4786831, + 43.3480787 + ], + [ + -114.5128729, + 43.3630498 + ], + [ + -114.5821792, + 43.3712775 + ], + [ + -114.6336501, + 43.3817452 + ], + [ + -114.7047, + 43.3884593 + ], + [ + -114.7230237, + 43.3964327 + ], + [ + -114.7886945, + 43.4009317 + ], + [ + -114.8412706, + 43.3794947 + ], + [ + -114.8974125, + 43.3750631 + ], + [ + -114.9234448, + 43.3650553 + ], + [ + -114.9569957, + 43.3580404 + ], + [ + -114.9839125, + 43.3573755 + ], + [ + -115.0343696, + 43.3492485 + ], + [ + -115.0590769, + 43.353868 + ], + [ + -115.0926835, + 43.4033243 + ], + [ + -115.1009177, + 43.4397244 + ], + [ + -115.1042305, + 43.4709534 + ], + [ + -115.1003414, + 43.5035081 + ], + [ + -115.1117719, + 43.5590744 + ], + [ + -115.1128025, + 43.6038817 + ] + ] + ] + } + } + ], + "start_time": null, + "end_time": null +} diff --git a/tests/test_avalanche_org.py b/tests/test_avalanche_org.py new file mode 100644 index 0000000..4be1ed5 --- /dev/null +++ b/tests/test_avalanche_org.py @@ -0,0 +1,309 @@ +"""Tests for the v0.10.10 avalanche_org adapter.""" + +from __future__ import annotations + +import copy +import json +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from central.adapters.avalanche_org import ( + _DANGER_TO_SEVERITY, + AvalancheOrgAdapter, + AvalancheOrgSettings, + _centroid, + _parse_iso, + _slug, +) +from central.config_models import AdapterConfig + + +FIXTURE_PATH = Path(__file__).parent / "fixtures" / "avalanche_snfac.json" + + +@pytest.fixture +def snfac_response() -> dict: + """Frozen 2026-06-08 SNFAC map-layer response. 4 features, all off-season.""" + return json.loads(FIXTURE_PATH.read_text()) + + +@pytest.fixture +def adapter(tmp_path: Path) -> AvalancheOrgAdapter: + cfg = AdapterConfig( + name="avalanche_org", + enabled=True, + cadence_s=1800, + settings={"center_ids": ["SNFAC"]}, + updated_at=datetime.now(timezone.utc), + ) + return AvalancheOrgAdapter(cfg, MagicMock(), tmp_path / "cursors.db") + + +# --- Pure helper tests ------------------------------------------------------ + + +@pytest.mark.parametrize("text, expected", [ + ("Banner Summit", "banner_summit"), + ("Sawtooth & Western Smoky Mtns", "sawtooth_western_smoky_mtns"), + ("Galena Summit & Eastern Mtns", "galena_summit_eastern_mtns"), + ("Soldier & Wood River Valley Mtns", "soldier_wood_river_valley_mtns"), + ("ALL CAPS", "all_caps"), + (" leading/trailing ", "leading_trailing"), + ("hyphens-and_underscores", "hyphens_and_underscores"), + ("", ""), +]) +def test_slug(text, expected): + assert _slug(text) == expected + + +def test_parse_iso_naive_treated_as_utc(): + """avalanche.org returns naive ISO strings; we tag UTC and pass through.""" + dt = _parse_iso("2026-05-04T17:59:00") + assert dt is not None + assert dt.tzinfo == timezone.utc + assert dt.year == 2026 and dt.month == 5 and dt.day == 4 + + +def test_parse_iso_handles_z_suffix(): + dt = _parse_iso("2026-06-08T10:00:00Z") + assert dt is not None + assert dt.hour == 10 + + +@pytest.mark.parametrize("bad", [None, "", "not-a-date", 12345, "2026-99-99"]) +def test_parse_iso_returns_none_on_bad_input(bad): + assert _parse_iso(bad) is None + + +def test_centroid_of_simple_polygon(): + geom = { + "type": "Polygon", + "coordinates": [[[-115, 44], [-114, 44], [-114, 45], [-115, 45], [-115, 44]]], + } + c = _centroid(geom) + assert c is not None + lon, lat = c + assert abs(lon - (-114.5)) < 1e-6 + assert abs(lat - 44.5) < 1e-6 + + +def test_centroid_handles_invalid_geom(): + assert _centroid(None) is None + assert _centroid({"type": "Polygon", "coordinates": "garbage"}) is None + + +def test_danger_severity_map_matches_central_4_most_severe_convention(): + """Anti-regression: meshai's original spec inverted this (5→1). v0.10.10 + corrected to Central-wide convention: higher = more severe. + """ + assert _DANGER_TO_SEVERITY == {3: 2, 4: 3, 5: 4} + + +# --- Build-event severity gate --------------------------------------------- + + +def _base_feature(**overrides) -> dict: + """Minimal valid feature; tests override the properties under test.""" + props = { + "name": "Banner Summit", + "state": "ID", + "off_season": False, + "danger_level": 3, + "danger": "Considerable", + "travel_advice": "Watch for unstable snow.", + "start_date": "2026-12-15T17:59:00", + "end_date": "2026-12-16T19:00:00", + } + props.update(overrides) + return { + "type": "Feature", + "id": 1, + "properties": props, + "geometry": { + "type": "Polygon", + "coordinates": [[[-115.0, 44.0], [-114.0, 44.0], + [-114.0, 45.0], [-115.0, 45.0], [-115.0, 44.0]]], + }, + } + + +@pytest.mark.parametrize("danger_level, expected_severity", [ + (3, 2), # Considerable + (4, 3), # High + (5, 4), # Extreme +]) +def test_publishable_danger_levels_yield_event_with_mapped_severity( + adapter, danger_level, expected_severity +): + ev = adapter._build_event_record(_base_feature(danger_level=danger_level), "SNFAC") + assert ev is not None + assert ev.severity == expected_severity + assert ev.data["danger_level"] == danger_level + assert ev.data["state"] == "ID" + assert ev.data["center_id"] == "SNFAC" + assert ev.data["zone_name"] == "Banner Summit" + assert ev.data["off_season"] is False + assert ev.id == "SNFAC_banner_summit" + assert ev.category == "avy.advisory.snfac" + assert ev.geo.primary_region == "US-ID" + assert ev.geo.geometry is not None + + +@pytest.mark.parametrize("danger_level", [-1, 0, 1, 2]) +def test_low_or_no_rating_danger_levels_are_omitted(adapter, danger_level): + assert adapter._build_event_record( + _base_feature(danger_level=danger_level), "SNFAC" + ) is None + + +def test_off_season_true_omitted_regardless_of_danger_level(adapter): + feat = _base_feature(off_season=True, danger_level=4) + assert adapter._build_event_record(feat, "SNFAC") is None + + +def test_missing_state_omitted(adapter): + feat = _base_feature(state="") + assert adapter._build_event_record(feat, "SNFAC") is None + + +def test_unparseable_geometry_omitted(adapter): + feat = _base_feature() + feat["geometry"] = None + assert adapter._build_event_record(feat, "SNFAC") is None + + +def test_travel_advice_truncated_to_200_chars(adapter): + long = "x" * 500 + ev = adapter._build_event_record(_base_feature(travel_advice=long), "SNFAC") + assert ev is not None + assert len(ev.data["travel_advice"]) == 200 + + +def test_subject_for_uses_state_lowercase(adapter): + ev = adapter._build_event_record(_base_feature(state="ID"), "SNFAC") + assert adapter.subject_for(ev) == "central.avy.advisory.us.id" + + +# --- Real-fixture behavior (the negative case) ------------------------------ + + +def test_real_snfac_fixture_all_zones_omitted_during_off_season(adapter, snfac_response): + """The frozen 2026-06-08 SNFAC fixture has 4 zones, all off-season + danger + -1. The adapter must yield zero events from that response. + """ + yielded = [] + for feat in snfac_response["features"]: + ev = adapter._build_event_record(feat, "SNFAC") + if ev is not None: + yielded.append(ev) + assert len(snfac_response["features"]) == 4 + assert yielded == [] + + +def test_real_snfac_fixture_with_synthetic_winter_overrides_publishes_all( + adapter, snfac_response +): + """Same fixture, but mutate each feature to a winter Considerable state. + Asserts the geometry/centroid path works against the real polygon shapes + avalanche.org actually returns (not our hand-crafted square).""" + winter = copy.deepcopy(snfac_response) + for feat in winter["features"]: + feat["properties"]["off_season"] = False + feat["properties"]["danger_level"] = 3 + feat["properties"]["danger"] = "Considerable" + yielded = [] + for feat in winter["features"]: + ev = adapter._build_event_record(feat, "SNFAC") + if ev is not None: + yielded.append(ev) + assert len(yielded) == 4 + # Each yielded event has a non-(0,0) centroid and unique id slug. + ids = {ev.id for ev in yielded} + assert len(ids) == 4 + for ev in yielded: + lon, lat = ev.geo.centroid + assert -120 < lon < -110, f"unexpected lon: {lon}" + assert 40 < lat < 50, f"unexpected lat: {lat}" + assert ev.geo.geometry["type"] in ("Polygon", "MultiPolygon") + + +# --- Settings + adapter scaffolding ---------------------------------------- + + +def test_default_settings_cover_snfac_and_pac(): + s = AvalancheOrgSettings() + assert "SNFAC" in s.center_ids + assert "PAC" in s.center_ids + + +@pytest.mark.asyncio +async def test_apply_config_swaps_center_ids(adapter): + new_cfg = AdapterConfig( + name="avalanche_org", + enabled=True, + cadence_s=1800, + settings={"center_ids": ["NWAC"]}, + updated_at=datetime.now(timezone.utc), + ) + await adapter.apply_config(new_cfg) + assert adapter._center_ids == ["NWAC"] + + +# --- Stream registry + family mapping (the v0.10.10 wiring) ---------------- + + +def test_central_avy_registered_in_streams(): + from central.streams import STREAMS + avy = [s for s in STREAMS if s.name == "CENTRAL_AVY"] + assert len(avy) == 1 + assert avy[0].subject_filter == "central.avy.>" + assert avy[0].event_bearing is True + + +def test_central_avy_in_supervisor_family_map(): + from central.supervisor import STREAM_CATEGORY_DOMAINS + assert STREAM_CATEGORY_DOMAINS["CENTRAL_AVY"] == ("avy",) + + +@pytest.mark.asyncio +async def test_poll_with_no_centers_yields_nothing(): + """Defensive: empty center_ids must not crash, must yield zero events.""" + cfg = AdapterConfig( + name="avalanche_org", enabled=True, cadence_s=1800, + settings={"center_ids": []}, + updated_at=datetime.now(timezone.utc), + ) + adapter = AvalancheOrgAdapter(cfg, MagicMock(), Path("/tmp/avy_empty.db")) + await adapter.startup() + try: + events = [e async for e in adapter.poll()] + assert events == [] + finally: + await adapter.shutdown() + + +@pytest.mark.asyncio +async def test_poll_yields_only_publishable_features(adapter, snfac_response, monkeypatch): + """End-to-end poll(): inject a mock _fetch returning a mixed winter response; + assert only danger>=3 features are yielded.""" + await adapter.startup() + try: + mixed = copy.deepcopy(snfac_response) + # Feature 0: off-season (omit) Feature 1: danger 1 (omit) + # Feature 2: danger 4 (publish) Feature 3: danger -1 (omit) + for f in mixed["features"]: + f["properties"]["off_season"] = False + mixed["features"][0]["properties"]["off_season"] = True + mixed["features"][1]["properties"]["danger_level"] = 1 + mixed["features"][2]["properties"]["danger_level"] = 4 + mixed["features"][3]["properties"]["danger_level"] = -1 + + monkeypatch.setattr(adapter, "_fetch", AsyncMock(return_value=mixed)) + events = [e async for e in adapter.poll()] + assert len(events) == 1 + assert events[0].severity == 3 # danger 4 → severity 3 + finally: + await adapter.shutdown() diff --git a/tests/test_events_feed_frontend.py b/tests/test_events_feed_frontend.py index 22dedd5..a21fddf 100644 --- a/tests/test_events_feed_frontend.py +++ b/tests/test_events_feed_frontend.py @@ -1147,6 +1147,7 @@ _SAMPLE_INNER = { "tomtom_incidents": {"description": "Roadworks", "from": "Early Road", "to": "Slade Road"}, "itd_511": {"event_type_short": "work_zone", "roadway_name": "I-84"}, "itd_511_cameras": {"location": "I-84 Mountain Home", "camera_id": 42}, + "avalanche_org": {"zone_name": "Banner Summit", "danger_name": "Considerable"}, } # Exact expected subjects for the deterministic adapters. swpc_alerts is omitted @@ -1170,6 +1171,7 @@ _EXPECTED_SUBJECT = { "tomtom_incidents": "Roadworks on Early Road → Slade Road", "itd_511": "Road work on I-84", "itd_511_cameras": "Camera: I-84 Mountain Home", + "avalanche_org": "Avalanche advisory — Banner Summit (Considerable)", }