mirror of
https://github.com/zvx-echo6/central.git
synced 2026-06-10 11:54:37 +02:00
v0.10.10: new avalanche_org adapter — backcountry avalanche advisories (#98)
meshai-requested adapter for avalanche.org's per-center map layers (SNFAC Sawtooth + PAC Payette by default; operator-extensible to any avalanche.org forecast center). Pure passthrough + severity gate, no cross-source fusion, fits Central's adapter pattern cleanly. Adapter surface: - Polls https://api.avalanche.org/v2/public/products/map-layer/{center_id} per configured center; default cadence 1800s (30 min). - Severity gate: only danger_level >= 3 publishes. danger_level 0/1/2 (None/Low/Moderate), -1 ('no rating'), and off_season=true all omitted at adapter level. Idaho summer = all 4 SNFAC + 2 PAC zones yield 0 events; that's correct behavior, verified by the negative-case test against the frozen 2026-06-08 SNFAC fixture. - Severity mapping (corrected from meshai's inverted spec): danger_level 3 (Considerable) → severity 2, 4 (High) → 3, 5 (Extreme) → 4. Matches Central's 4-most-severe convention (nws.SEVERITY_MAP). - Subject: central.avy.advisory.us.{state_lower} — one per state; v0.10.8's category-discriminated Nats-Msg-Id keeps multiple zones in the same state from colliding in JetStream dedup. - Stream: CENTRAL_AVY (central.avy.>); 7-day / 1 GiB retention defaults. - Event.data fields per meshai spec: center_id, zone_name, danger_level, danger_name, travel_advice (truncated to 200 chars), state, valid_date, end_date, off_season=false, latitude/longitude (polygon centroid via shapely), plus geo.geometry passes through as the upstream Polygon. Tests (38 in test_avalanche_org.py): - Pure helpers: _slug (8 cases), _parse_iso (6 cases), _centroid (2 cases). - Severity gate: 3 publish cases (danger 3/4/5 → severity 2/3/4), 4 omit cases (danger -1/0/1/2), off_season=true omit, missing state omit, unparseable geom omit, travel_advice truncation, subject derivation. - Real-fixture negative case: 4-zone SNFAC fixture all omitted off-season. - Real-fixture positive case: same fixture with synthetic winter overrides publishes all 4 with valid centroids on actual Idaho polygons. - End-to-end poll() with mixed severities and the new wiring (streams registry + supervisor family map). - Defensive: empty center_ids list yields nothing without crashing. Wiring + plumbing: - src/central/streams.py: StreamEntry('CENTRAL_AVY', 'central.avy.>') - src/central/supervisor.py: STREAM_CATEGORY_DOMAINS['CENTRAL_AVY']=('avy',) - sql/migrations/035: seed config.streams row (mirror of 019/CENTRAL_SPACE, idempotent ON CONFLICT DO NOTHING). Note: migrations don't auto-run on supervisor restart -- see deferred ops list (schema_migrations cleanup blocks central-migrate from running anything cleanly). - src/central/gui/templates/_event_rows/avalanche_org.html (8 lines) - src/central/gui/templates/_event_summaries/avalanche_org.html (2 lines) Both required by the existing per-adapter template consistency tests. Doc updates (required by existing doc-vs-registry tests): - docs/PRODUCER-INTEGRATION.md §6.1: added 'avy' to top-level-domain list. - docs/PRODUCER-INTEGRATION.md §8: added StreamEntry('CENTRAL_AVY',...) line to the verbatim snippet. - docs/CONSUMER-INTEGRATION.md §3 stream layout table: added CENTRAL_AVY row. - docs/CONSUMER-INTEGRATION.md §6: new '### avalanche_org' subsection with source, subject convention, dedup key, severity gate, Event.data field table, and off-season behavior note. - tests/test_events_feed_frontend.py: added avalanche_org to _SAMPLE_INNER and _EXPECTED_SUBJECT (the events-JSON subject-derivation coverage tests). Budget note: this PR is well over the ~400-line target -- the new-adapter surface picked up downstream consistency tests (doc validators + frontend sample coverage + template partials) I didn't anticipate at probe time. Most of the overrun is the SNFAC fixture (1,135 lines pretty-printed JSON, non-code) and the adapter + tests pair. Stripping the fixture and the required doc/template edits would leave ~620 lines of code; the fixture itself is a frozen snapshot, not a maintenance burden. Full sweep: 1072 passed, 0 failures (+41 from this PR), ruff clean on all new files. One PRE-EXISTING ruff violation in supervisor.py (unused poll_start variable at line 388) surfaces when we touch supervisor.py; confirmed not introduced by this PR via git stash check. Deploy plan (NEW STREAM — archive restart required per [[feedback_new_stream_needs_archive_restart]]): 1. Squash-merge -> tag v0.10.10 -> push. 2. On central: pull main -> systemctl restart central-supervisor -> ALSO systemctl restart central-archive (new event-bearing stream; archive enumerates consumers at startup and doesn't hot-reload). 3. Migration 035 deferred to morning per the schema_migrations cleanup task -- the stream creation itself doesn't depend on it (supervisor creates JetStream streams from the STREAMS registry at startup; the config.streams row is for operator-tunable retention only). 4. Verify: nats stream info CENTRAL_AVY (created), poll log shows yielded=0 / omitted=N (off-season), no positive publishes during summer (correct). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6cbb9378d6
commit
e92b51c518
11 changed files with 1823 additions and 1 deletions
|
|
@ -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/<center_id>`
|
||||
per configured forecast center (defaults: SNFAC Sawtooth, PAC Payette).
|
||||
- **Stream:** `CENTRAL_AVY` (`central.avy.>`)
|
||||
- **Subject:** `central.avy.advisory.us.<state_lower>` — one subject per state;
|
||||
multiple zones in the same state coexist via category-discriminated
|
||||
`Nats-Msg-Id` (v0.10.8).
|
||||
- **Dedup key shape:** `<center_id>_<zone_name_slug>` (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
|
||||
|
|
|
|||
|
|
@ -362,7 +362,7 @@ central.<domain>.<subtype>[.<dimensions>...]
|
|||
```
|
||||
|
||||
- `<domain>` 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.<domain>.>`.
|
||||
- `<subtype>` 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),
|
||||
]
|
||||
```
|
||||
|
|
|
|||
9
sql/migrations/035_add_central_avy_stream.sql
Normal file
9
sql/migrations/035_add_central_avy_stream.sql
Normal file
|
|
@ -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;
|
||||
314
src/central/adapters/avalanche_org.py
Normal file
314
src/central/adapters/avalanche_org.py
Normal file
|
|
@ -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}"
|
||||
8
src/central/gui/templates/_event_rows/avalanche_org.html
Normal file
8
src/central/gui/templates/_event_rows/avalanche_org.html
Normal file
|
|
@ -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 %}<dt>Zone</dt><dd>{{ d.zone_name }}</dd>{% endif %}
|
||||
{% if d.get('center_id') is not none %}<dt>Center</dt><dd>{{ d.center_id }}</dd>{% endif %}
|
||||
{% if d.get('state') is not none %}<dt>State</dt><dd>{{ d.state }}</dd>{% endif %}
|
||||
{% if d.get('danger_level') is not none %}<dt>Danger</dt><dd>{{ d.danger_level }} ({{ d.danger_name or '?' }})</dd>{% endif %}
|
||||
{% if d.get('valid_date') is not none %}<dt>Valid</dt><dd>{{ d.valid_date }}</dd>{% endif %}
|
||||
{% if d.get('travel_advice') is not none %}<dt>Travel advice</dt><dd>{{ d.travel_advice }}</dd>{% endif %}
|
||||
|
|
@ -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 -%}
|
||||
|
|
@ -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),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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",),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
1135
tests/fixtures/avalanche_snfac.json
vendored
Normal file
1135
tests/fixtures/avalanche_snfac.json
vendored
Normal file
File diff suppressed because it is too large
Load diff
309
tests/test_avalanche_org.py
Normal file
309
tests/test_avalanche_org.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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)",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue