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:
malice 2026-06-08 21:57:56 -06:00 committed by GitHub
commit e92b51c518
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1823 additions and 1 deletions

View file

@ -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

View file

@ -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),
]
```

View 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;

View 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}"

View 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 %}

View file

@ -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 -%}

View file

@ -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),
]

View file

@ -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

File diff suppressed because it is too large Load diff

309
tests/test_avalanche_org.py Normal file
View 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()

View file

@ -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)",
}