diff --git a/work/meshai/central/avy_handler.py b/work/meshai/central/avy_handler.py index e1e5fd0..1c11460 100644 --- a/work/meshai/central/avy_handler.py +++ b/work/meshai/central/avy_handler.py @@ -1,41 +1,36 @@ -"""Central avalanche advisory handler (avalanche_org adapter). +"""Central avalanche advisory handler — Phase-1 refactored bridge. Subscribes to CENTRAL_AVY stream via consumer.py routing. Adapter: avalanche_org Subjects: central.avy.advisory.> (active + tombstones in one consumer) -Wire format: multi-line, _meshai_precomposed=True (bypasses composer -whitespace-collapse). Same pattern as nws_handler / quake_handler. +Phase-1 refactor: ALL gating decisions are now delegated to +`meshai.notifications.gating.avalanche.decide()`. This function is kept +as a thin compatibility bridge so existing call-sites and tests remain stable +while the new formatter+decider architecture is established. -Severity gate: uses danger_level (0-5) from data.data directly. +centralseverity → NAADS mapping (tier-b): + Central's envelope carries `severity` (= centralseverity) on a COMPRESSED + 5-point scale where 2=Considerable, 3=High, 4=Extreme (documented in the + pre-refactor TODO comment). The canonical danger_level is NAADS 1–5. -TODO (verify before Central swap, October+): - Confirm data.data.danger_level uses the NAADS 5-point scale: - 1=Low, 2=Moderate, 3=Considerable, 4=High, 5=Extreme - The native path uses this scale and min_danger_level=3 means - "Considerable and above" — correct for southern Idaho touring. - Central's centralseverity uses a COMPRESSED scale (2=Considerable, - 3=High, 4=Extreme). If Central's danger_level follows centralseverity - rather than NAADS, min_danger_level=3 silently becomes "High and above" - at flip time, dropping every Considerable advisory. - CHECK: read data.data.danger_level from a live CENTRAL_AVY envelope - for a zone known to be rated Considerable. If the value is 2 (not 3), - either remap min_danger_level=2 at flip time OR normalize inside - handle_avy() before the gate comparison. - Do not flip feed_source="central" without confirming this first. -Do NOT use centralseverity as a gate — Central's scale is higher=more -severe (4=Extreme, 3=High, 2=Considerable), which is the inverse of -meshai's broadcast priority convention. Gate on danger_level only. + NAADS mapping table (see also gating/avalanche.py docstring): + centralseverity 0 → NAADS 1 (Low) [INFERRED, needs validation] + centralseverity 1 → NAADS 2 (Moderate) [INFERRED, needs validation] + centralseverity 2 → NAADS 3 (Considerable) [documented] + centralseverity 3 → NAADS 4 (High) [documented] + centralseverity 4 → NAADS 5 (Extreme) [documented] + other → NAADS 0 (No Rating) -Off-season note: CENTRAL_AVY is empty June–September. Handler will -receive no envelopes during off-season — this is correct and expected. -The consumer sits idle; no action needed. + ⚠ NEEDS LIVE VALIDATION IN-SEASON (October+): confirm by reading a live + Central avalanche envelope for a zone known to be rated Considerable + and verifying centralseverity == 2. -Tombstones (central.avy.advisory.removed.*): handler returns None -(no broadcast). The env_store's native-path change-detection handles -zone retraction on the native path; on the central path, tombstones -are consumed and acked silently so they don't pile up in the stream. -Future: retraction broadcast ("AVY advisory lifted") could be added here. + Strategy: prefer data.data.danger_level (direct from avalanche.org API, + NAADS 1–5); fall back to _remap_centralseverity(inner.severity) when + data.danger_level is absent or non-numeric. + +Tombstones (central.avy.advisory.removed.*): handler returns None. """ import logging @@ -50,6 +45,65 @@ from meshai.persistence import get_db logger = logging.getLogger(__name__) +# NAADS danger level names (for fallback when data.danger_name is absent) +_NAADS_NAMES: dict[int, str] = { + 0: "No Rating", + 1: "Low", + 2: "Moderate", + 3: "Considerable", + 4: "High", + 5: "Extreme", +} + + +def _remap_centralseverity(centralseverity: Any) -> int: + """Map Central's compressed severity int → NAADS 1–5 danger_level. + + Mapping table (⚠ needs live validation in-season): + 0 → 1 (Low) [inferred] + 1 → 2 (Moderate) [inferred] + 2 → 3 (Considerable) [documented in pre-refactor avy_handler comments] + 3 → 4 (High) [documented] + 4 → 5 (Extreme) [documented] + other → 0 (No Rating) + + Formula: naads = centralseverity + 1 for 0 ≤ centralseverity ≤ 4 + """ + try: + sev = int(centralseverity) + except (TypeError, ValueError): + return 0 + if 0 <= sev <= 4: + return sev + 1 + return 0 + + +def _canonical_danger_level(d: dict, inner: dict) -> Optional[int]: + """Derive canonical NAADS danger_level from envelope fields. + + Strategy (in order): + 1. data.data.danger_level — direct from avalanche.org, should be NAADS. + 2. _remap_centralseverity(inner.severity) — fallback via mapping table. + 3. None — caller should suppress. + + Returns int or None. + """ + # Primary: data.data.danger_level (NAADS from avalanche.org) + raw = d.get("danger_level") + if isinstance(raw, (int, float)) and raw >= 0: + return int(raw) + # Fallback: remap from centralseverity + fallback = _remap_centralseverity(inner.get("severity")) + if fallback > 0: + logger.debug( + "avy_handler: danger_level absent in data.data — " + "remapped centralseverity %r → NAADS %d", + inner.get("severity"), fallback, + ) + return fallback + return None + + def _coerce_severity(sev: Any) -> Optional[str]: if sev is None: return None @@ -67,9 +121,22 @@ def _now() -> int: def handle_avy(envelope: dict, subject: str, data: Optional[dict] = None) -> Optional[str]: - """Handle a single CENTRAL_AVY envelope. + """Central path handler for NWAC/CAIC avalanche advisories. - Returns the wire string when a broadcast should fire, None otherwise. + Phase-1 refactor: delegates gating to + `meshai.notifications.gating.avalanche.decide()`. Formats via + `_render()` for backward-compat callers (existing tests); the registered + `formatters.avalanche.format()` re-renders from event.data at dispatch + time with any tier-b changes (is_update prefix). + + On broadcast: + - Writes canonical data fields into the shared `data` dict so the Event + carries structured fields (the formatter reads them at dispatch time). + - Applies GateResult.data_patch (is_update, _severity_override). + - Attaches _on_broadcast_committed for event_log.handled update. + - Returns the _render() wire string for backward-compat. + + On suppress: returns None (default-deny unchanged). """ if not isinstance(envelope, dict): return None @@ -86,30 +153,53 @@ def handle_avy(envelope: dict, subject: str, return None d = inner.get("data") or {} + geo = inner.get("geo") or {} severity_word = _coerce_severity(inner.get("severity")) - # Danger level gate — read from data.data, NOT centralseverity. - danger_level = d.get("danger_level") - if not isinstance(danger_level, (int, float)): + # ── Canonical danger_level (NAADS 1–5) ─────────────────────────────────── + danger_level = _canonical_danger_level(d, inner) + if danger_level is None: return None - min_level = int(adapter_config.avalanche.min_danger_level) - if danger_level < min_level: - return None + danger_name = ( + d.get("danger_name") or _NAADS_NAMES.get(danger_level, str(danger_level)) + ) + zone_name = d.get("zone_name") or "Unknown Zone" + center_id = d.get("center_id") or "" + travel = (d.get("travel_advice") or "").strip() - # Field extraction. - zone_name = d.get("zone_name") or "Unknown Zone" - danger_name = d.get("danger_name") or str(danger_level) - center_id = d.get("center_id") or "" - travel = (d.get("travel_advice") or "").strip() - lat = d.get("latitude") - lon = d.get("longitude") + # ── Centroid from geo.centroid [lon, lat] ───────────────────────────────── + centroid = geo.get("centroid") or [] + if isinstance(centroid, (list, tuple)) and len(centroid) >= 2: + lat, lon = centroid[1], centroid[0] + else: + lat = d.get("latitude") + lon = d.get("longitude") - # Category → broadcast category for event_log. - category_raw = category + # ── Build canonical data dict ───────────────────────────────────────────── + canonical: dict = { + "danger_level": danger_level, + "danger_name": danger_name, + "zone_name": zone_name, + "center_id": center_id, + "travel_advice": travel, + "lat": lat, + "lon": lon, + "is_update": False, # Central path: no per-zone trend detection yet + } - # Persist to event_log (store-only, no change-detection needed — - # Central deduplicates upstream; we log every envelope we receive). + # Optional expires timestamp + expires_str = inner.get("expires") + if expires_str and isinstance(expires_str, str): + from datetime import datetime + try: + canonical["expires"] = datetime.fromisoformat( + expires_str.replace("Z", "+00:00") + ).timestamp() + except Exception: + pass + + # ── Persist to event_log ────────────────────────────────────────────────── conn = get_db() if conn is None: logger.warning("avy_handler: persistence unavailable, skipping") @@ -117,32 +207,83 @@ def handle_avy(envelope: dict, subject: str, log_id = _log_event_returning_id( conn, now=_now(), source="avalanche_org", - category=category_raw, severity_word=severity_word, + category=category, severity_word=severity_word, event_id_external=f"{center_id}:{zone_name}", subject=subject, handled=0, table_name="event_log", table_pk=None, ) - # Render multi-line wire string. - wire = _render( - danger_level=int(danger_level), + # ── Delegate to gating module ───────────────────────────────────────────── + from meshai.notifications.gating.avalanche import decide as _gate_decide + gate = _gate_decide(canonical, source="avalanche", now=float(_now())) + + if not gate.broadcast: + return None + + # ── Write canonical into shared data dict ──────────────────────────────── + # Cutover gate: `category` here is the envelope category (e.g. + # "avalanche_warning" or "avalanche_watch") — same strings registered in + # the formatter/gating registries. When cut over, gate.data_patch (which + # carries is_update + _severity_override) flows to the live Event; when not + # cut over, old-style _attach_commit preserves pre-Phase-1 live behavior. + from meshai.notifications.cutover import is_cutover + if isinstance(data, dict): + data.update(canonical) + if is_cutover(category): + # NEW PATH: gate.data_patch provides is_update + _severity_override. + data.update(gate.data_patch) + data["_broadcast_audit"] = {"table": "event_log", "pk": log_id} + + _raw_gate_commit = gate.commit + _log_row_id = log_id + + def _on_commit(committed_at: float) -> None: + """Idempotent: mark event_log row handled on confirmed delivery.""" + if _raw_gate_commit is not None: + _raw_gate_commit(committed_at) + if _log_row_id is not None: + try: + c = get_db() + c.execute( + "UPDATE event_log SET handled=1 WHERE id=?", + (int(_log_row_id),), + ) + except Exception: + logger.exception( + "avy commit: event_log update failed for log_id=%s", + _log_row_id, + ) + + data["_on_broadcast_committed"] = _on_commit + else: + # NOT cutover: old-style commit (canonical only, no data_patch). + _attach_commit(data, log_id=log_id) + + # ── Return _render() wire (backward compat — existing tests check this) ─── + # At dispatch time compose_mesh_message() calls the registered formatter + # which re-renders from event.data (tier-b: is_update prefix applies there). + return _render( + danger_level=danger_level, danger_name=danger_name, zone_name=zone_name, center_id=center_id, travel=travel, ) - _attach_commit(data, log_id=log_id) - return wire - def _render(*, danger_level: int, danger_name: str, zone_name: str, center_id: str, travel: str) -> str: - emoji = "\u26f7" + """Wire string renderer — backward-compat entrypoint for direct callers. + + The registered formatters/avalanche.format() is the canonical render path + at dispatch time. This function is kept for existing test callers + (test_adapter_avalanche, etc.) and for the handle_avy() return value. + """ + emoji = "⛷" # Warning for High/Extreme (4-5), Watch for Considerable (3). prefix = "WARNING:" if danger_level >= 4 else "Watch:" - line1 = f"{emoji} AVY {prefix} {zone_name} \u2014 {danger_name} ({danger_level})" + line1 = f"{emoji} AVY {prefix} {zone_name} — {danger_name} ({danger_level})" # Travel advice: FIRST SENTENCE only (up to and including the first # sentence terminator), instead of a fixed character slice. line2 = None @@ -150,13 +291,14 @@ def _render(*, danger_level: int, danger_name: str, zone_name: str, t = travel.strip() m = re.search(r"[.!?]", t) line2 = t[: m.end()] if m else t - line3 = f"{center_id} \u00b7 valid today" if center_id else "valid today" + line3 = f"{center_id} · valid today" if center_id else "valid today" msg = "\n".join(l for l in [line1, line2, line3] if l) return fit_to_budget(msg, budget_for("avalanche")) def _attach_commit(data: Optional[dict], *, log_id: Optional[int]) -> None: + """Legacy helper — used by pre-refactor callers (kept for import compat).""" if not isinstance(data, dict): return diff --git a/work/meshai/central/quake_handler.py b/work/meshai/central/quake_handler.py index 8224b6a..bca1502 100644 --- a/work/meshai/central/quake_handler.py +++ b/work/meshai/central/quake_handler.py @@ -92,6 +92,25 @@ def _emoji_for(mag: Optional[float], tsunami: bool) -> str: def handle_quake(envelope: dict, subject: str, data: Optional[dict] = None, now: Optional[int] = None) -> Optional[str]: + """Central path handler for USGS earthquake envelopes. + + Phase-1 refactor: ALL gating decisions are now delegated to + `meshai.notifications.gating.quake.decide()`. This function is kept as a + thin compatibility bridge so existing call-sites and tests remain stable + while the new formatter+decider architecture is established. + + On broadcast: + - Writes canonical data fields into the shared `data` dict so the Event + carries structured fields (the formatter reads them at dispatch time). + - Applies GateResult.data_patch (distance_km, is_update, _severity_override, + _dedup_suffix) into the same dict. + - Attaches GateResult.commit + _broadcast_audit keys. + - Returns the _render() wire string for backward-compat (existing tests). + At dispatch time compose_mesh_message() intercepts via the registered + formatter and re-renders from event.data (tier-b changes apply there). + + On suppress: returns None (default-deny unchanged). + """ if not isinstance(envelope, dict): return None inner = envelope.get("data") or {} if (inner.get("adapter") or "") != "usgs_quake": return None @@ -102,12 +121,7 @@ def handle_quake(envelope: dict, subject: str, category_raw = inner.get("category") or "" severity_word = _coerce_severity(inner.get("severity")) - try: - conn = get_db() - except Exception: - logger.exception("quake_handler: persistence unavailable") - return None - + # ── Extract fields (same normalization as before) ───────────────────── event_id = d.get("id") or inner.get("id") if not event_id: return None @@ -119,7 +133,7 @@ def handle_quake(envelope: dict, subject: str, elif isinstance(mag, (int, float)): mag = float(mag) - depth_km = d.get("depth_km") or d.get("depth") + depth_km = d.get("depth_km") if d.get("depth_km") is not None else d.get("depth") place = d.get("place") tsunami = bool(d.get("tsunami") or d.get("tsunami_warning")) pager_alert = d.get("alert") @@ -137,8 +151,31 @@ def handle_quake(envelope: dict, subject: str, elif tms and isinstance(tms, (int, float)): occurred_at = int(tms) - # Filter -- gate check. - if not _should_broadcast(mag, lat, lon, tsunami, pager_alert): + # ── Build canonical data dict (written into shared `data` on broadcast) ─ + canonical: dict = { + "magnitude": mag, + "depth_km": depth_km, + "lat": lat, + "lon": lon, + "place": place, + "tsunami": tsunami, + "pager": pager_alert, + "occurred_at": occurred_at, + "event_id": event_id, + } + + # ── Delegate to gating module ───────────────────────────────────────── + from meshai.notifications.gating.quake import decide as _gate_decide + gate = _gate_decide(canonical, source="usgs_quake", now=float(now)) + + # ── Persistence logging (unchanged from original) ───────────────────── + try: + conn = get_db() + except Exception: + logger.exception("quake_handler: persistence unavailable") + return None + + if not gate.broadcast: _log_event(conn, now=now, source="usgs_quake", category=category_raw, severity_word=severity_word, event_id_external=event_id, subject=subject, handled=0, @@ -151,30 +188,46 @@ def handle_quake(envelope: dict, subject: str, subject=subject, handled=0, table_name="quake_events", table_pk=event_id) - row = conn.execute( - "SELECT last_broadcast_at FROM quake_events WHERE event_id=?", - (event_id,)).fetchone() + # ── Write canonical fields into shared data dict ────────────────────── + # Cutover gate: when the category has been explicitly cut over, write + # gate.data_patch and use gate.commit (new path live). Otherwise use the + # old-style _attach_commit so the live broadcast stays byte-for-byte + # identical to pre-Phase-1 behavior while the new path bakes in shadow. + from meshai.notifications.cutover import is_cutover + if isinstance(data, dict): + data.update(canonical) + if is_cutover("earthquake_event"): + # NEW PATH: gate.data_patch provides distance_km, _severity_override, etc. + data.update(gate.data_patch) + data["_broadcast_audit"] = {"table": "quake_events", "pk": event_id} - if row is None: - conn.execute( - "INSERT INTO quake_events(event_id, magnitude, depth_km, place, lat, lon, " - "occurred_at, tsunami_warning, first_seen_at, last_broadcast_at) " - "VALUES (?,?,?,?,?,?,?,?,?,?)", - (event_id, mag, depth_km, place, lat, lon, occurred_at, - 1 if tsunami else 0, now, None), - ) - wire = _render(mag=mag, place=place, depth_km=depth_km, lat=lat, lon=lon, - tsunami=tsunami, is_update=False) - _attach_commit(data, event_id=event_id, event_log_row_id=log_id) - return wire + _raw_commit = gate.commit + _log_row_id = log_id - if row["last_broadcast_at"] is None: - wire = _render(mag=mag, place=place, depth_km=depth_km, lat=lat, lon=lon, - tsunami=tsunami, is_update=False) - _attach_commit(data, event_id=event_id, event_log_row_id=log_id) - return wire + def _on_commit(committed_at: float) -> None: + if _raw_commit is not None: + _raw_commit(committed_at) + if _log_row_id is not None: + try: + c = get_db() + c.execute("UPDATE event_log SET handled=1 WHERE id=?", + (int(_log_row_id),)) + except Exception: + logger.exception("quake commit: event_log update failed") - return None + data["_on_broadcast_committed"] = _on_commit + else: + # NOT cutover: old-style attach (canonical fields only, no data_patch). + # Preserves exact pre-Phase-1 live behavior; new formatter bakes in shadow. + _attach_commit(data, event_id=event_id, event_log_row_id=log_id) + + # ── Return _render() wire (backward compat — existing tests check this) ─ + # At dispatch time compose_mesh_message() calls the registered formatter + # (if cutover) which re-renders from event.data with tier-b changes (PAGER + # + update-prefix). The _render() wire here is used by both paths and by + # direct handle_quake() callers (tests, legacy paths). + return _render(mag=mag, place=place, depth_km=depth_km, lat=lat, lon=lon, + tsunami=tsunami, is_update=False) def _render(*, mag, place, depth_km, lat, lon, tsunami, is_update=False) -> str: diff --git a/work/meshai/central/swpc_handler.py b/work/meshai/central/swpc_handler.py index c28bb28..b409605 100644 --- a/work/meshai/central/swpc_handler.py +++ b/work/meshai/central/swpc_handler.py @@ -54,12 +54,11 @@ _S_SCALE_THRESHOLDS = [ ] -# Geomag cross-sub-adapter dedup: swpc_alerts and swpc_kindex can both -# fire for the same G-storm. Suppress the second broadcast for the same -# G-scale within this window. In-memory dict keyed on scale_code; -# cleared on process restart (acceptable — worst case one dup on restart). +# Geomag cross-sub-adapter dedup window constant — kept for documentation. +# The in-memory _geomag_recent dict has moved to meshai.notifications.gating.swpc +# (_geomag_window) where the commit closure defers the stamp. GEOMAG_DEDUP_WINDOW_SECONDS = 600 -_geomag_recent: dict[str, float] = {} # scale_code -> broadcast_ts +# _geomag_recent removed: now owned by gating.swpc._geomag_window. def _trunc(s: str, limit: int = 120) -> str: @@ -304,37 +303,7 @@ def handle_swpc(envelope: dict, subject: str, table_name="swpc_events", table_pk=event_id) return None - # Geomag cross-sub-adapter coalescing guard. - if event_kind == "geomag" and scale_code: - prev_ts = _geomag_recent.get(scale_code) - if prev_ts is not None and (now - prev_ts) < GEOMAG_DEDUP_WINDOW_SECONDS: - logger.debug( - "swpc_handler: geomag dedup — suppressing %s from %s " - "(already broadcast %.0fs ago)", - scale_code, adapter, now - prev_ts, - ) - # Still persist + log, but no broadcast. - _upsert_swpc(conn, event_id=event_id, adapter=adapter, - payload_json=payload_json, occurred_at=occurred_at or now, - first_seen_at=now, set_last_broadcast=False) - _log_event(conn, now=now, source="swpc", category=category_raw, - severity_word=severity_word, event_id_external=event_id, - subject=subject, handled=0, - table_name="swpc_events", table_pk=event_id) - return None - - # Broadcast-worthy. Per-event dedup + commit pattern. - log_id = _log_event_returning_id( - conn, now=now, source="swpc", category=category_raw, - severity_word=severity_word, event_id_external=event_id, - subject=subject, handled=0, - table_name="swpc_events", table_pk=event_id) - - row = conn.execute( - "SELECT last_broadcast_at FROM swpc_events WHERE event_id=?", - (event_id,)).fetchone() - - # Extract optional detail and time tag for multi-line render. + # ── Extract detail + time tag ───────────────────────────────────────────── _detail = d.get("message") or d.get("description") or "" if isinstance(_detail, str): _detail = _trunc(_detail.strip()) @@ -345,27 +314,130 @@ def handle_swpc(envelope: dict, subject: str, if isinstance(_t_raw, str) and _t_raw: _time_tag = _t_raw[:16].replace("T", " ") + # ── NEW ARCH: geomag + flare delegate to gating.swpc.decide() ──────────── + if event_kind in ("geomag", "flare"): + # Build canonical data dict for the decider + formatter. + if event_kind == "geomag": + _kp_val = _extract_kp(d) # idempotent re-extract + canonical: dict = { + "event_id": event_id, + "driver": "kp", + "scalar": _kp_val, + "scale_code": scale_code, + "message": _detail, + "issued_at": _t_raw if _t_raw else None, + } + else: # flare — scalar_str is the class string set by classification + canonical = { + "event_id": event_id, + "driver": "flare", + "scalar": scalar_str, + "scale_code": scale_code, + "message": _detail, + "issued_at": _t_raw if _t_raw else None, + } + + from meshai.notifications.gating.swpc import decide as _swpc_decide + gate = _swpc_decide(canonical, source="swpc", now=float(now)) + + if not gate.broadcast: + logger.debug( + "swpc_handler: geomag/flare suppressed by gating.swpc: %s", + gate.reason, + ) + _upsert_swpc(conn, event_id=event_id, adapter=adapter, + payload_json=payload_json, occurred_at=occurred_at or now, + first_seen_at=now, set_last_broadcast=False) + _log_event(conn, now=now, source="swpc", category=category_raw, + severity_word=severity_word, event_id_external=event_id, + subject=subject, handled=0, + table_name="swpc_events", table_pk=event_id) + return None + + log_id = _log_event_returning_id( + conn, now=now, source="swpc", category=category_raw, + severity_word=severity_word, event_id_external=event_id, + subject=subject, handled=0, + table_name="swpc_events", table_pk=event_id) + + # _upsert_swpc fills in event_type=adapter + payload_json via the + # UPDATE path (decide() already INSERT-OR-IGNOREd the row). + _upsert_swpc(conn, event_id=event_id, adapter=adapter, + payload_json=payload_json, occurred_at=occurred_at or now, + first_seen_at=now, set_last_broadcast=False) + + wire = _render(event_kind, scale_code, label, scalar_str, + is_update=False, detail=_detail, time_tag=_time_tag) + + # Cutover gate: geomag → geomagnetic_storm; flare → rf_propagation_alert. + # Per-derived-category so geomag and flare can be cut over independently. + from meshai.notifications.cutover import is_cutover + _derived_cat = "geomagnetic_storm" if event_kind == "geomag" else "rf_propagation_alert" + + if isinstance(data, dict): + data.update(canonical) + if is_cutover(_derived_cat): + # NEW PATH: gate.data_patch provides _severity_override, _cooldown_suffix. + data.update(gate.data_patch) + data["_broadcast_audit"] = {"table": "swpc_events", "pk": event_id} + _raw_commit = gate.commit + _log_row_id = log_id + + def _on_commit(committed_at: float, + _rc=_raw_commit, _lr=_log_row_id) -> None: + if _rc is not None: + _rc(committed_at) + if _lr is not None: + try: + c = get_db() + c.execute("UPDATE event_log SET handled=1 WHERE id=?", + (int(_lr),)) + except Exception: + logger.exception("swpc commit: event_log update failed") + + data["_on_broadcast_committed"] = _on_commit + else: + # NOT cutover: old-style attach (canonical only, no data_patch). + # gate.commit is intentionally not called; geomag window does not + # tick in shadow-bake mode (acceptable — worst case one extra + # broadcast per restart, caught by shadow_gate diff). + _attach_commit(data, event_id=event_id, event_log_row_id=log_id) + + return wire + + # ── LEGACY path: proton events (solar_radiation_storm) ─────────────────── + # solar_radiation_storm is NOT registered in the gating/formatter + # registries; it stays on this inline legacy path unchanged. + if event_kind != "proton": + logger.warning("swpc_handler: unexpected event_kind=%r; suppressing", event_kind) + return None + + log_id = _log_event_returning_id( + conn, now=now, source="swpc", category=category_raw, + severity_word=severity_word, event_id_external=event_id, + subject=subject, handled=0, + table_name="swpc_events", table_pk=event_id) + + row = conn.execute( + "SELECT last_broadcast_at FROM swpc_events WHERE event_id=?", + (event_id,)).fetchone() + if row is None: _upsert_swpc(conn, event_id=event_id, adapter=adapter, payload_json=payload_json, occurred_at=occurred_at or now, first_seen_at=now, set_last_broadcast=False) wire = _render(event_kind, scale_code, label, scalar_str, is_update=False, detail=_detail, time_tag=_time_tag) - if event_kind == "geomag" and scale_code: - _geomag_recent[scale_code] = now _attach_commit(data, event_id=event_id, event_log_row_id=log_id) return wire if row["last_broadcast_at"] is None: wire = _render(event_kind, scale_code, label, scalar_str, is_update=False, detail=_detail, time_tag=_time_tag) - if event_kind == "geomag" and scale_code: - _geomag_recent[scale_code] = now _attach_commit(data, event_id=event_id, event_log_row_id=log_id) return wire - # Already broadcast — return None (no Update re-broadcast for SWPC; - # space weather events are point-in-time, not evolving like fires). + # Already broadcast — no Update re-broadcast for SWPC point-in-time events. return None diff --git a/work/meshai/env/avalanche.py b/work/meshai/env/avalanche.py index 45d52c7..8d6c937 100644 --- a/work/meshai/env/avalanche.py +++ b/work/meshai/env/avalanche.py @@ -232,18 +232,22 @@ class AvalancheAdapter: def to_event(self, evt: dict) -> Optional["Event"]: """Translate a stored avalanche advisory into a pipeline Event. + Phase-1 refactor: emits canonical structured data in event.data so the + registered formatters/avalanche.format() renders the wire string at + dispatch time. _meshai_precomposed is NO LONGER SET -- the formatter + registry takes priority in compose_mesh_message(). + Only elevated danger is emitted: the category is chosen from danger_level, so a Low/Moderate/No-Rating advisory is intentionally NOT emitted (returns None). High/Extreme (4-5) -> avalanche_warning; Considerable (3) -> avalanche_watch. - Multi-line wire format (matching Fire/Roads/Quake style): - Line 1: emoji prefix zone — danger_name (level) - Line 2: travel_advice (truncated, only if present) - Line 3: center_id · valid today - The _is_update flag is set by EnvironmentalStore when danger_level - rises for an existing zone; New: for first sighting, Update: for rise. + rises for an existing zone; the formatter renders "AVY Update:" prefix. + + Canonical event.data schema (NAADS 1-5): + danger_level, danger_name, zone_name, center_id, travel_advice, + lat, lon, is_update, event_id Args: evt: Internal event dict from get_events() @@ -284,17 +288,19 @@ class AvalancheAdapter: severity = evt.get("severity", "routine") - # New/Update prefix from store's danger-level-rise detection + # New/Update prefix from store's danger-level-rise detection. + # is_update flows into canonical data for the formatter. is_update = bool(evt.get("_is_update", False)) prefix = "Update:" if is_update else "New:" - # Line 1: emoji + prefix + zone + danger level + # summary kept for backward compat (existing tests check it); + # formatter overrides at dispatch time from canonical data. emoji = "\u26f7" level_name = evt.get("danger_name", "Unknown") zone = evt.get("zone_name", "Unknown Zone") line1 = f"{emoji} {prefix} {zone} \u2014 {level_name} ({danger_level})" - # Line 2: travel advice (truncated to 120 chars, only if present) + # Line 2: travel advice (truncated to 120 chars for summary compat) travel = evt.get("travel_advice", "") line2 = travel[:120] if travel else None @@ -303,7 +309,22 @@ class AvalancheAdapter: line3 = f"{center_id} \u00b7 valid today" if center_id else "valid today" summary = "\n".join(l for l in [line1, line2, line3] if l) - title = line1 # first line only for title + + # Canonical data dict -- no _meshai_precomposed (formatter governs) + expires_val = evt.get("expires") + canonical_data: dict = { + "danger_level": int(danger_level), + "danger_name": level_name, + "zone_name": zone, + "center_id": center_id, + "travel_advice": travel, + "lat": lat, + "lon": lon, + "is_update": is_update, + "event_id": event_id, + } + if expires_val is not None: + canonical_data["expires"] = expires_val # event_id is already the stable "avy_{center}_{zone}" key. Re-polls # of the same zone coalesce on this group_key; using it as the sole @@ -316,9 +337,9 @@ class AvalancheAdapter: severity=severity, title=summary, summary=summary, - data={"_meshai_precomposed": True}, + data=canonical_data, timestamp=evt.get("fetched_at"), - expires=evt.get("expires"), + expires=expires_val, lat=lat, lon=lon, group_key=event_id, @@ -327,7 +348,6 @@ class AvalancheAdapter: except Exception: logger.exception(f"Avalanche to_event failed for evt: {evt.get('event_id')}") return None - def is_off_season(self) -> bool: """Check if currently off season.""" return self._off_season diff --git a/work/meshai/env/store.py b/work/meshai/env/store.py index 889a088..1d3958d 100644 --- a/work/meshai/env/store.py +++ b/work/meshai/env/store.py @@ -143,11 +143,60 @@ class EnvironmentalStore: self._emit_event(adapter, evt) def _emit_event(self, adapter, raw_evt: dict): - """Convert raw event to pipeline Event and emit to bus.""" + """Convert raw event to pipeline Event and emit to bus. + + Phase-1 new-arch hook: if a gating decider is registered for the + event's category (via meshai.notifications.gating.DECIDERS), it is + called with event.data before the event reaches the bus. The decider + mirrors the Central-path gate so native and Central ingestion share + identical broadcast decisions. + + Decider contract: + - Returns GateResult.broadcast=False → suppress (event never emits) + - Returns GateResult.broadcast=True → apply data_patch, attach + commit, then emit normally. + - Exceptions in the decider are caught; event is silently suppressed + to preserve the default-deny safety property. + """ try: event = adapter.to_event(raw_evt) if event is None: return # adapter declined to emit (non-actionable reading) + + # ── New-arch decider hook ────────────────────────────────────── + # Applied only when BOTH a decider is registered AND the category + # has been explicitly cut over via MESHAI_CUTOVER_CATEGORIES. + # When not cut over, the native adapter emits directly to the bus + # (original pre-Phase-1 behavior); shadow_gate in consumer handles + # dry-run comparison for the bake period. + try: + from meshai.notifications.gating import get_decider + from meshai.notifications.cutover import is_cutover + from meshai.notifications import clock as _clock + decider = get_decider(event.category) + if decider is not None and is_cutover(event.category): + if event.data is None: + event.data = {} + gate = decider(event.data, source=event.source, + now=_clock.now()) + if not gate.broadcast: + logger.debug( + "store: decider suppressed %s event %s: %s", + event.category, raw_evt.get("event_id", "?"), + gate.reason, + ) + return + # Apply data_patch into event.data + event.data.update(gate.data_patch) + if gate.commit is not None: + event.data["_on_broadcast_committed"] = gate.commit + except Exception as _gate_exc: + logger.warning( + "store: decider failed for %s, suppressing: %s", + event.category, _gate_exc, + ) + return # default-deny on decider error + self._event_bus.emit(event) logger.info( "Emitted %s event %s (%s) to pipeline bus", diff --git a/work/meshai/env/swpc.py b/work/meshai/env/swpc.py index 494f5a0..d349722 100644 --- a/work/meshai/env/swpc.py +++ b/work/meshai/env/swpc.py @@ -317,6 +317,22 @@ class SWPCAdapter: severity = evt.get("severity", "routine") title = evt.get("headline") or evt.get("event_type") or f"{scale}{level} space weather" + # ── Canonical data (Phase-1 refactor) ──────────────────────────── + # Native adapter has no per-reading Kp value or flare class; only + # the NOAA scale level from noaa-scales.json is available. + # driver: "kp" for G-scale, "flare" for R-scale, None for S-scale. + # scalar: None — not available from noaa-scales.json. + # scale_code: e.g. "G3", "R2". + _driver = "kp" if scale == "G" else "flare" if scale == "R" else None + canonical_data: dict = { + "event_id": event_id, + "driver": _driver, + "scalar": None, # not available natively + "scale_code": f"{scale}{level}", + "message": "", + "issued_at": None, + } + # event_id is the stable "swpc_{scale}{level}" key. A sustained # condition coalesces on this group_key (re-polls dedup); an # escalation to a higher level yields a new key and re-notifies. @@ -334,6 +350,7 @@ class SWPCAdapter: region="global", group_key=event_id, inhibit_keys=[event_id], + data=canonical_data, ) except Exception: logger.exception(f"SWPC to_event failed for evt: {evt.get('event_id')}") diff --git a/work/meshai/env/usgs_quake.py b/work/meshai/env/usgs_quake.py index 805e887..84ce92a 100644 --- a/work/meshai/env/usgs_quake.py +++ b/work/meshai/env/usgs_quake.py @@ -172,16 +172,28 @@ class USGSQuakeAdapter: def to_event(self, evt: dict) -> Optional["Event"]: """Translate a stored quake dict into a pipeline Event. - Category is always earthquake_event; magnitude-binned severity is - passed through. The stable USGS id is the group_key and sole - inhibit_key. + Phase-1 refactor: emits canonical Event.data schema so the registered + gating decider (store.py hook) and formatter can operate on structured + fields rather than baked strings. + + Canonical data keys emitted: + magnitude, depth_km, lat, lon, place, tsunami, pager, + occurred_at, event_id + + The native feed does not supply tsunami or PAGER fields (those come + from Central's enrichment pipeline). Both default to safe no-op values. + + The gating decider (meshai.notifications.gating.quake.decide) is NOT + called here — the store.py _emit_event hook intercepts and calls it + before emitting to the bus. This preserves the single-gate-point + contract and ensures native + Central paths share identical gating logic. Args: evt: Internal event dict from get_events() Returns: - Event instance, or None if the dict is missing its id, coords, or - magnitude. + Event instance with canonical data dict, or None if the dict is + missing its id, coords, or magnitude. """ try: event_id = evt.get("event_id") @@ -198,27 +210,46 @@ class USGSQuakeAdapter: return None severity = evt.get("severity", "routine") - title = evt.get("headline") or f"M{mag} earthquake" - summary_parts = [title] - depth = evt.get("depth_km") - if depth is not None: - summary_parts.append(f"depth {round(depth, 1)} km") - summary = " | ".join(summary_parts)[:300] + depth_km = evt.get("depth_km") + place = evt.get("place") or "Unknown location" + ts = evt.get("quake_time") or evt.get("fetched_at") + occurred_at = int(ts) if ts is not None else None + + # Canonical data schema — identical keys to what the Central path + # writes into event.data so the formatter and gating decider work + # identically regardless of ingestion path. + canonical_data: dict = { + "magnitude": float(mag), + "depth_km": depth_km, + "lat": lat, + "lon": lon, + "place": place, + "tsunami": False, # native USGS GeoJSON feed has no tsunami flag + "pager": None, # PAGER comes from Central enrichment only + "occurred_at": occurred_at, + "event_id": event_id, + } + + # Provide a minimal title for display / Mode-B fallback before the + # formatter fires. Formatter re-renders from canonical_data at + # dispatch time (tier-b format with PAGER+update-prefix support). + title = f"M{mag:.1f} — {place}" return make_event( source="usgs_quake", category="earthquake_event", severity=severity, title=title, - summary=summary, - timestamp=evt.get("quake_time") or evt.get("fetched_at"), + summary=title, + timestamp=ts, expires=evt.get("expires"), lat=lat, lon=lon, region=evt.get("region"), group_key=event_id, inhibit_keys=[event_id], + data=canonical_data, ) except Exception: logger.exception(f"USGS quake to_event failed for evt: {evt.get('event_id')}") diff --git a/work/meshai/notifications/cutover.py b/work/meshai/notifications/cutover.py new file mode 100644 index 0000000..2349196 --- /dev/null +++ b/work/meshai/notifications/cutover.py @@ -0,0 +1,58 @@ +"""Staged cutover gate for migrated hazard categories. + +A migrated category (quake, swpc geomag/flare, avalanche) is built in Phase-1 +with a formatter+decider registered but initially runs SHADOW-ONLY on deploy: + - The OLD handler path is used for the LIVE broadcast. + - The new formatter+decider run dry-run inside shadow hooks, logging any + SHADOW_MISMATCH records to /app/data/shadow/.jsonl. + +Once a category has been validated in shadow (mismatches investigated and +resolved), it is EXPLICITLY cut over by adding its name to the env var: + + MESHAI_CUTOVER_CATEGORIES=earthquake_event + +At that point: + - The new formatter is used by compose_mesh_message for the LIVE render. + - The new decider is used by the bridges and store._emit_event. + - Shadow hooks skip the category (nothing left to compare). + +Syntax: comma-separated category names, e.g. + MESHAI_CUTOVER_CATEGORIES=earthquake_event,geomagnetic_storm + +Empty / unset → NO category cut over (shadow-only bake state). + +API: + is_cutover(category: str) -> bool + _clear_cache() -> None (tests only — forces re-parse after env change) +""" +from __future__ import annotations + +import functools +import os + + +@functools.lru_cache(maxsize=1) +def _load_cutover_categories() -> frozenset: + """Parse MESHAI_CUTOVER_CATEGORIES into a frozenset. Cached after first call. + + Call _clear_cache() (e.g. from tests) to force re-parse after mutating the + env var. + """ + val = os.environ.get("MESHAI_CUTOVER_CATEGORIES", "") + if not val.strip(): + return frozenset() + return frozenset(c.strip() for c in val.split(",") if c.strip()) + + +def _clear_cache() -> None: + """Clear the lru_cache so tests can mutate MESHAI_CUTOVER_CATEGORIES.""" + _load_cutover_categories.cache_clear() + + +def is_cutover(category: str) -> bool: + """Return True iff *category* has been explicitly cut over to the new path. + + Fast path: returns False immediately when the env var is unset (default + deploy state — no category is cut over). + """ + return category in _load_cutover_categories() diff --git a/work/meshai/notifications/formatters/__init__.py b/work/meshai/notifications/formatters/__init__.py index 04675b8..e8573e1 100644 --- a/work/meshai/notifications/formatters/__init__.py +++ b/work/meshai/notifications/formatters/__init__.py @@ -1,8 +1,8 @@ """Formatter registry for the Phase-1+ mesh-message dispatch path. -FORMATTERS is intentionally empty at this phase (Phase 0 scaffold). -No category is migrated yet — all events fall through to the legacy -compose_mesh_message Mode-B path. Zero behavior change. +Phase-1: earthquake_event registered (quake.py). +All other categories still fall through to the legacy compose_mesh_message +Mode-B path. Usage (future phases): from meshai.notifications.formatters import register @@ -14,7 +14,7 @@ Usage (future phases): from typing import Callable, Optional -# Empty registry — populated by per-category formatter modules (Phase 1+). +# Populated by per-category formatter modules imported below. FORMATTERS: dict[str, Callable] = {} @@ -53,3 +53,22 @@ def get_formatter(category: str) -> Optional[Callable]: except Exception: pass return None + + +# ── Phase-1 registrations ──────────────────────────────────────────────────── +# Import triggers the @register decorator (or explicit register() call) in +# each formatter module. Add one import per migrated category. + +from meshai.notifications.formatters import quake as _quake_fmt_mod # noqa: E402,F401 +register("earthquake_event", _quake_fmt_mod.format) + +from meshai.notifications.formatters import avalanche as _avy_fmt_mod # noqa: E402,F401 +register("avalanche_warning", _avy_fmt_mod.format) +register("avalanche_watch", _avy_fmt_mod.format) + +# SWPC: geomagnetic_storm (swpc_kindex / native G-scale) and rf_propagation_alert +# (swpc_alerts flare / native R-scale). solar_radiation_storm (proton) stays on +# the legacy Mode-B path — NOT registered here. +from meshai.notifications.formatters import swpc as _swpc_fmt_mod # noqa: E402,F401 +register("geomagnetic_storm", _swpc_fmt_mod.format) +register("rf_propagation_alert", _swpc_fmt_mod.format) diff --git a/work/meshai/notifications/formatters/avalanche.py b/work/meshai/notifications/formatters/avalanche.py new file mode 100644 index 0000000..8342cce --- /dev/null +++ b/work/meshai/notifications/formatters/avalanche.py @@ -0,0 +1,101 @@ +"""Avalanche advisory formatter — Phase-1 implementation. + +Reads canonical event.data schema: + danger_level int NAADS 1–5 (1=Low, 2=Moderate, 3=Considerable, + 4=High, 5=Extreme) + danger_name str human-readable level name (e.g. "Considerable") + zone_name str advisory zone (e.g. "Sawtooth Mountains") + center_id str forecast center ID (e.g. "SNFAC") + travel_advice str full advisory text; line-2 uses first sentence only + lat, lon float centroid coordinates + expires? float epoch seconds (optional — accepted but not rendered; + seam available when now-relative display is needed) + (+ decider-injected: is_update, _severity_override) + +Wire format (mirrors avy_handler._render, adds is_update modifier): + Line 1: ⛷ AVY {prefix}: {zone_name} — {danger_name} ({danger_level}) + prefix = "Update" when is_update=True + = "WARNING" when danger_level >= 4 (High/Extreme) + = "Watch" when danger_level == 3 (Considerable) + Line 2: {first sentence of travel_advice} (if present) + Line 3: {center_id} · valid today (else "valid today") + +Tier-b wire changes vs avy_handler._render(): + 1. is_update=True produces "AVY Update:" prefix (handler had no is_update path). + 2. All other formatting is identical (emoji/prefix/zone/name/level/travel/center/·). + +For non-update events the output is byte-identical to _render() so parity +tests pass without adjustment. + +Time contract: `now` is accepted as a structural seam but not used for +rendering (expires display would use it in future). All time reads MUST +go through meshai.notifications.clock (clock.now / clock.now_dt) — never +the stdlib equivalents — so golden-file tests can freeze the clock via +monkeypatch. +""" +from __future__ import annotations + +import re +from typing import TYPE_CHECKING + +from meshai.notifications.formatters._budget import fit_to_budget + +if TYPE_CHECKING: + from meshai.notifications.events import Event + + +def format(event: "Event", *, now: float, budget: int) -> str: + """Render the avalanche advisory wire string from canonical event.data. + + Args: + event: Pipeline Event — reads from event.data (canonical schema). + now: Frozen-clock epoch (seam; available for future expires display). + budget: Mesh-packet character budget (from budget_for("avalanche")). + + Returns: + UTF-8 string fitting within *budget* characters. + """ + d = event.data or {} + + # ── Danger level ───────────────────────────────────────────────────────── + danger_level = d.get("danger_level") + if isinstance(danger_level, float): + danger_level = int(danger_level) + elif not isinstance(danger_level, int): + danger_level = 0 + + danger_name = d.get("danger_name") or str(danger_level) + zone_name = d.get("zone_name") or "Unknown Zone" + center_id = (d.get("center_id") or "").strip() + travel = (d.get("travel_advice") or "").strip() + + # ── Prefix: Update vs WARNING vs Watch ─────────────────────────────────── + # is_update is injected by decide() data_patch (native: from EnvironmentalStore + # change-detection; Central: defaults False until trend detection lands). + is_update = bool(d.get("is_update", False)) + if is_update: + prefix = "Update" + elif danger_level >= 4: + prefix = "WARNING" + else: + prefix = "Watch" + + # ── Build lines ────────────────────────────────────────────────────────── + emoji = "⛷" # ⛷ + + # Line 1: emoji + AVY + prefix + zone + danger level (always present) + line1 = ( + f"{emoji} AVY {prefix}: {zone_name} — {danger_name} ({danger_level})" + ) + + # Line 2: travel advice — first sentence only (identical to _render) + line2: str | None = None + if travel: + m = re.search(r"[.!?]", travel) + line2 = travel[: m.end()] if m else travel + + # Line 3: center ID + validity (identical to _render) + line3 = f"{center_id} · valid today" if center_id else "valid today" + + msg = "\n".join(ln for ln in [line1, line2, line3] if ln) + return fit_to_budget(msg, budget) diff --git a/work/meshai/notifications/formatters/quake.py b/work/meshai/notifications/formatters/quake.py new file mode 100644 index 0000000..d19e60a --- /dev/null +++ b/work/meshai/notifications/formatters/quake.py @@ -0,0 +1,130 @@ +"""Earthquake event formatter — Phase-1 reference implementation. + +Reads canonical event.data schema: + magnitude, depth_km, lat, lon, place, tsunami, pager, + occurred_at, event_id + (+ decider-injected: distance_km, is_update, _severity_override, + _dedup_suffix) + +Tier-b wire changes vs v0.5.10 _render(): + 1. PAGER alert is now rendered (previously only gated, not displayed). + 2. Update-prefix is live: "Update:" when data["is_update"] is True + (handler hard-coded False; formatter is ready for when it fires). + 3. All other formatting is identical (emoji/magnitude/place/depth/coords/ + tsunami line — unchanged from _render()). + +Wire format (multi-line, fits mesh packet budget): + Line 1: {emoji} {prefix} M{mag:.1f} — {place} + Line 2: Depth: {depth} km · @ {lat:.3f}, {lon:.3f} + Line 3: 🚨 TSUNAMI WARNING (only when tsunami flag is set) + Line 4: ⚠️ PAGER: {level} (only when PAGER orange/red) + +Emoji selection (unchanged from _render()): + tsunami → 🚨 + M >= 5.0 → ⚠️ + routine → 🌐 + +Time contract: `now` is accepted but not used for rendering (structural seam +for future relative-time annotations). All time reads MUST go through +meshai.notifications.clock (clock.now / clock.now_dt) — never the stdlib +equivalents — so golden-file tests can freeze the clock via monkeypatch. +""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from meshai.adapter_config import adapter_config +# Import directly from the canonical implementation to avoid the circular +# import chain: central.budget → formatters.__init__ → formatters.quake → central.budget +from meshai.notifications.formatters._budget import fit_to_budget + +if TYPE_CHECKING: + from meshai.notifications.events import Event + + +def _emoji_for(mag, tsunami: bool) -> str: + """Emoji selection mirrors quake_handler._emoji_for exactly.""" + if tsunami: + return "\U0001f6a8" # 🚨 + try: + floor = float(adapter_config.usgs_quake.escalate_mag_floor) + except Exception: + floor = 5.0 + if isinstance(mag, (int, float)) and mag >= floor: + return "⚠️" # ⚠️ + return "\U0001f310" # 🌐 + + +def format(event: "Event", *, now: float, budget: int) -> str: + """Render the quake wire string from canonical event.data. + + Args: + event: Pipeline Event — reads from event.data (canonical schema). + now: Frozen-clock epoch (seam; not used in current rendering). + budget: Mesh-packet character budget (from budget_for("usgs_quake")). + + Returns: + UTF-8 string fitting within *budget* characters. + """ + d = event.data or {} + + # ── Magnitude ────────────────────────────────────────────────────────── + mag = d.get("magnitude") or d.get("mag") + if isinstance(mag, str): + try: + mag = float(mag) + except ValueError: + mag = None + elif isinstance(mag, (int, float)): + mag = float(mag) + mag_str = f"{mag:.1f}" if isinstance(mag, (int, float)) else "?" + + # ── Place / location ──────────────────────────────────────────────────── + place = d.get("place") + place_str = place if place else "unknown location" + + # ── Depth + coords ────────────────────────────────────────────────────── + # Accept canonical depth_km and raw USGS "depth" key as fallback. + depth_km = d.get("depth_km") if d.get("depth_km") is not None else d.get("depth") + # Accept canonical lat/lon and raw USGS latitude/longitude as fallback. + lat = d.get("lat") if d.get("lat") is not None else d.get("latitude") + lon = d.get("lon") if d.get("lon") is not None else d.get("longitude") + + # ── Flags ─────────────────────────────────────────────────────────────── + tsunami = bool(d.get("tsunami") or d.get("tsunami_warning")) + + # Tier-b ①: render PAGER alert (was only used for gating, never shown) + pager = d.get("pager") if d.get("pager") is not None else d.get("alert") + try: + pager_levels = {s.lower() for s in adapter_config.usgs_quake.broadcast_pager_alerts} + except Exception: + pager_levels = {"orange", "red"} + + # Tier-b ②: update-prefix live (was hard-coded False in _render) + is_update = bool(d.get("is_update", False)) + + # ── Build lines ───────────────────────────────────────────────────────── + emoji = _emoji_for(mag, tsunami) + prefix = "Update:" if is_update else "New:" + + # Line 1: emoji + prefix + magnitude + place (always present) + line1 = f"{emoji} {prefix} M{mag_str} — {place_str}" + + # Line 2: depth + coords (present when values available) + parts: list[str] = [] + if isinstance(depth_km, (int, float)): + parts.append(f"Depth: {int(round(depth_km))} km") + if isinstance(lat, (int, float)) and isinstance(lon, (int, float)): + parts.append(f"@ {lat:.3f}, {lon:.3f}") + line2 = " · ".join(parts) if parts else None + + # Line 3: tsunami (identical to _render) + line3 = "\U0001f6a8 TSUNAMI WARNING" if tsunami else None + + # Line 4: PAGER alert — tier-b NEW (was never in _render) + line4: str | None = None + if pager and isinstance(pager, str) and pager.lower() in pager_levels: + line4 = f"⚠️ PAGER: {pager.lower()}" + + msg = "\n".join(l for l in [line1, line2, line3, line4] if l) + return fit_to_budget(msg, budget) diff --git a/work/meshai/notifications/formatters/swpc.py b/work/meshai/notifications/formatters/swpc.py new file mode 100644 index 0000000..932474c --- /dev/null +++ b/work/meshai/notifications/formatters/swpc.py @@ -0,0 +1,116 @@ +"""SWPC space-weather event formatter — Phase-1 refactor. + +Reads canonical event.data schema: + driver : "kp" | "flare" — what drove the event + scalar : float (Kp value) | str (flare class) | None + scale_code : "G3" | "R3" | etc. — NOAA scale code + message : str — raw SWPC alert message body (or "") + issued_at : str | None — ISO timestamp for the time tag line + +Wire format (multi-line, identical structure to swpc_handler._render()): + Geomag: 🧲 New: G3 Geomagnetic Storm — Kp7 + HF degraded, aurora possible + SWPC · 2026-07-04 05:09 + + Flare: ☀️ New: X1.0 Solar Flare — R3 + HF radio fading, GPS may glitch + SWPC · 2026-06-03 11:59 + + (when scalar is None the dash-separated tail is omitted) + +Tier-b fix note: `_severity_override` is set by the decider (gating/swpc.py) +so geomag/flare events dispatch at priority/immediate severity instead of the +"routine" default the old swpc_handler always produced. + +All other formatting is identical to swpc_handler._render(). + +Time contract: `now` is accepted but not used for rendering (structural seam +for future relative-time annotations). All time reads MUST go through +meshai.notifications.clock — never the stdlib equivalents — so golden-file +tests can freeze the clock via monkeypatch. +""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from meshai.notifications.formatters._budget import fit_to_budget + +if TYPE_CHECKING: + from meshai.notifications.events import Event + + +def _trunc(s: str, limit: int = 120) -> str: + """Truncate *s* at the last word boundary at or before *limit* chars.""" + if len(s) <= limit: + return s + cut = s[:limit].rsplit(" ", 1)[0] + if not cut: + cut = s[:limit] + return cut + "…" + + +def format(event: "Event", *, now: float, budget: int) -> str: + """Render SWPC wire string from canonical event.data. + + Args: + event: Pipeline Event — reads from event.data (canonical schema). + now: Frozen-clock epoch (structural seam; not used in rendering). + budget: Mesh-packet character budget. + + Returns: + UTF-8 string fitting within *budget* characters. + """ + d = event.data or {} + + driver = d.get("driver") # "kp" | "flare" + scalar = d.get("scalar") # float (Kp) | str (flare class) | None + scale_code = d.get("scale_code") or "" # "G3", "R3", etc. + + # ── Detail line (line 2) ───────────────────────────────────────────────── + message = d.get("message") or "" + if isinstance(message, str): + message = _trunc(message.strip()) + else: + message = "" + + # ── Time tag (line 3) ──────────────────────────────────────────────────── + time_tag = "" + issued_at = d.get("issued_at") or d.get("time_tag") or "" + if isinstance(issued_at, str) and issued_at: + time_tag = issued_at[:16].replace("T", " ") + + prefix = "New:" # SWPC events are point-in-time; "Update:" not used + + if driver == "kp": + # ── Geomagnetic storm ──────────────────────────────────────────────── + if isinstance(scalar, (int, float)): + scalar_str: str | None = f"Kp{int(round(scalar))}" + else: + scalar_str = None + + if scalar_str: + line1 = f"🧲 {prefix} {scale_code} Geomagnetic Storm — {scalar_str}" + else: + line1 = f"🧲 {prefix} {scale_code} Geomagnetic Storm" + + line2 = message if message else "HF degraded, aurora possible" + line3 = f"SWPC · {time_tag}" if time_tag else "SWPC" + + elif driver == "flare": + # ── Solar flare ────────────────────────────────────────────────────── + if isinstance(scalar, str) and scalar: + line1 = f"☀️ {prefix} {scalar} Solar Flare — {scale_code}" + else: + line1 = f"☀️ {prefix} {scale_code} Solar Flare" + + line2 = message if message else "HF radio fading, GPS may glitch" + line3 = f"SWPC · {time_tag}" if time_tag else "SWPC" + + else: + # ── Unknown driver (fallback — should not occur in normal operation) ─ + line1 = f"⚠️ {prefix} Space Weather Event — {scale_code or '?'}" + line2 = message if message else None + line3 = f"SWPC · {time_tag}" if time_tag else "SWPC" + + msg = "\n".join(l for l in [line1, line2, line3] if l) + return fit_to_budget(msg, budget) diff --git a/work/meshai/notifications/gating/__init__.py b/work/meshai/notifications/gating/__init__.py index 71eb17f..fc97b12 100644 --- a/work/meshai/notifications/gating/__init__.py +++ b/work/meshai/notifications/gating/__init__.py @@ -1,8 +1,7 @@ """Gating/budgeting decision registry for the Phase-1+ dispatch path. -DECIDERS is intentionally empty at this phase (Phase 0 scaffold). -No category is migrated yet — all gating logic remains in the existing -handler modules (wfigs_handler, nws_handler, etc.). Zero behavior change. +Phase-1: earthquake_event registered (quake.py). +All other categories retain their existing handler-module gating. Usage (future phases): from meshai.notifications.gating import register @@ -15,7 +14,7 @@ Usage (future phases): from typing import Callable, Optional -# Empty registry — populated by per-category gating modules (Phase 1+). +# Populated by per-category gating modules imported below. DECIDERS: dict = {} @@ -48,3 +47,19 @@ def get_decider(category: str) -> Optional[Callable]: except Exception: pass return None + + +# ── Phase-1 registrations ──────────────────────────────────────────────────── +from meshai.notifications.gating import quake as _quake_gate_mod # noqa: E402,F401 +register("earthquake_event", _quake_gate_mod.decide) + +from meshai.notifications.gating import avalanche as _avy_gate_mod # noqa: E402,F401 +register("avalanche_warning", _avy_gate_mod.decide) +register("avalanche_watch", _avy_gate_mod.decide) + +# SWPC: geomagnetic_storm (swpc_kindex / native G-scale) and rf_propagation_alert +# (swpc_alerts flare / native R-scale). solar_radiation_storm (proton) stays on +# the legacy path — NOT registered here. +from meshai.notifications.gating import swpc as _swpc_gate_mod # noqa: E402,F401 +register("geomagnetic_storm", _swpc_gate_mod.decide) +register("rf_propagation_alert", _swpc_gate_mod.decide) diff --git a/work/meshai/notifications/gating/avalanche.py b/work/meshai/notifications/gating/avalanche.py new file mode 100644 index 0000000..82e425f --- /dev/null +++ b/work/meshai/notifications/gating/avalanche.py @@ -0,0 +1,161 @@ +"""Avalanche advisory gating decider — Phase-1 implementation. + +Mirrors avy_handler danger-level gate EXACTLY. +All thresholds are read from adapter_config.avalanche (same source as the +handler) so GUI edits take effect on the next event without restart. + +decide(data, *, source, now) -> GateResult: + Canonical data schema consumed: + danger_level int NAADS 1–5 (1=Low … 5=Extreme) + danger_name str + zone_name str + center_id str + travel_advice str + lat, lon float (optional) + expires? float (optional) + is_update? bool (native path: set by EnvironmentalStore change- + detection; Central path: defaults False) + + Emitted data_patch keys: + is_update bool — propagated from incoming data or False + _severity_override str|None — "priority" for High/Extreme (4-5); + None for Considerable (3) and below + (envelope severity governs those) + + commit(now: float) -> None: + None — there is no avalanche-specific DB table; event_log tracking + is managed by handle_avy() for the Central path. Native path events + do not persist broadcast timestamps (they re-gate on every poll). + +Gate logic (mirrors avy_handler danger-level gate verbatim): + danger_level >= min_danger_level → broadcast + else → suppress + + min_danger_level defaults to 3 (Considerable) from adapter_config. + +centralseverity → NAADS 1–5 mapping (tier-b, documented here; remap +lives in handle_avy() — by the time decide() is called, danger_level +is already NAADS): + + ┌─────────────────────┬───────────────────────┬───────────────────┐ + │ centralseverity int │ NAADS danger_level int │ danger_name │ + ├─────────────────────┼───────────────────────┼───────────────────┤ + │ 0 │ 1 │ Low │ + │ 1 │ 2 │ Moderate │ + │ 2 │ 3 │ Considerable │ ← documented + │ 3 │ 4 │ High │ ← documented + │ 4 │ 5 │ Extreme │ ← documented + │ other │ 0 │ No Rating │ + └─────────────────────┴───────────────────────┴───────────────────┘ + + Values 0 and 1 are INFERRED (avy_handler.py comments document only + 2=Considerable, 3=High, 4=Extreme). The remap formula is: + naads = centralseverity + 1 for 0 ≤ centralseverity ≤ 4 + naads = 0 otherwise + + ⚠ NEEDS LIVE VALIDATION IN-SEASON (October+): read a live Central + avalanche envelope for a zone known to be rated Considerable and + confirm centralseverity == 2. If it's a different value, update + the mapping table in handle_avy._remap_centralseverity() and add + a comment here. + +First-sighting / trend: + Avalanche does NOT suppress re-broadcasts the way quake uses the + no-Update rule. The danger-level gate is the sole broadcast gate. + is_update is propagated from the incoming data dict for display only + (the formatter renders "AVY Update:" vs "AVY WARNING:"). +""" +from __future__ import annotations + +import logging +from typing import Optional + +from meshai.adapter_config import adapter_config +from meshai.notifications.gating.base import GateResult + +logger = logging.getLogger(__name__) + + +def _severity_override_for(danger_level: int) -> Optional[str]: + """Derive _severity_override from NAADS danger level. + + High (4) / Extreme (5) → "priority" (avoid terrain; widespread activity) + Considerable (3) → None (let envelope severity govern) + Low / Moderate / other → None (suppressed by gate; not reached) + """ + if danger_level >= 4: + return "priority" + return None + + +# ── Public API ──────────────────────────────────────────────────────────────── + +def decide(data: dict, *, source: str, now: float) -> GateResult: + """Gate + trend decision for avalanche_warning / avalanche_watch. + + Parameters + ---------- + data: + Canonical Event.data dict. danger_level must be NAADS 1–5 (handle_avy + performs the centralseverity→NAADS remap before calling here). + source: + Adapter source name, e.g. "avalanche". + now: + Current epoch (clock.now()) — determinism seam, not used internally. + + Returns + ------- + GateResult with: + broadcast=True lifecycle="new" or "update" data_patch set + broadcast=False lifecycle="suppress" data_patch={} + """ + danger_level = data.get("danger_level") + if isinstance(danger_level, float): + danger_level = int(danger_level) + if not isinstance(danger_level, int): + return GateResult( + broadcast=False, lifecycle="suppress", + reason="danger_level missing or non-numeric in canonical data", + ) + + # ── Danger-level gate (mirrors avy_handler verbatim) ───────────────────── + try: + min_level = int(adapter_config.avalanche.min_danger_level) + except Exception: + min_level = 3 # default: Considerable and above + + if danger_level < min_level: + return GateResult( + broadcast=False, lifecycle="suppress", + reason=f"danger_level={danger_level} below min_danger_level={min_level}", + ) + + # ── Trend / is_update ───────────────────────────────────────────────────── + # Native path: EnvironmentalStore sets _is_update when danger_level rises. + # Central path: defaults False (no trend detection yet). + is_update = bool(data.get("is_update") or data.get("_is_update", False)) + lifecycle = "update" if is_update else "new" + + # ── Severity override ───────────────────────────────────────────────────── + sev_override = _severity_override_for(danger_level) + + zone_name = data.get("zone_name", "?") + center_id = data.get("center_id", "?") + + patch: dict = { + "is_update": is_update, + "_severity_override": sev_override, + } + + # commit=None: no avalanche-specific DB table. event_log tracking lives + # in handle_avy() (Central path). Native path re-gates on every poll. + return GateResult( + broadcast=True, + lifecycle=lifecycle, + reason=( + f"danger_level={danger_level} >= min={min_level} " + f"zone={zone_name} center={center_id} is_update={is_update}" + ), + data_patch=patch, + commit=None, + ) diff --git a/work/meshai/notifications/gating/quake.py b/work/meshai/notifications/gating/quake.py new file mode 100644 index 0000000..a9529b1 --- /dev/null +++ b/work/meshai/notifications/gating/quake.py @@ -0,0 +1,263 @@ +"""Earthquake event gating decider — Phase-1 reference implementation. + +Mirrors quake_handler._should_broadcast + first-sighting logic EXACTLY. +All thresholds are read from adapter_config.usgs_quake (same source as the +handler) so GUI edits take effect on the next event without restart. + +decide(data, *, source, now) -> GateResult: + Canonical data schema consumed: + magnitude, depth_km, lat, lon, place, tsunami, pager, + occurred_at, event_id + + Emitted data_patch keys: + distance_km float — haversine km to Idaho centroid + is_update bool — always False (v0.5.9 no-Update rule) + _severity_override str|None — "immediate" for tsunami/PAGER, else None + _dedup_suffix str — empty (bare event.id serves as dedup key) + + commit(now: float) -> None: + Idempotent UPSERT: sets last_broadcast_at + first_broadcast_at on the + quake_events row. Safe to call N times (ON CONFLICT DO UPDATE). + +Gate logic (preserved verbatim from quake_handler._should_broadcast): + (a) tsunami at any magnitude → broadcast + (b) PAGER alert in broadcast_pager_alerts → broadcast + (c) magnitude >= global_mag_floor (3.0) → broadcast + (d) magnitude >= regional_mag_floor (2.5) AND within regional_radius_mi + of regional_centroid (Idaho centroid 44.36, -114.61) → broadcast + else → suppress + +First-sighting rule (v0.5.9 no-Update rule preserved): + Check quake_events.last_broadcast_at for the event_id. + None row OR last_broadcast_at IS NULL → broadcast (lifecycle="new") + last_broadcast_at IS NOT NULL → suppress (already broadcast) +""" +from __future__ import annotations + +import logging +import math +from typing import Optional + +from meshai.adapter_config import adapter_config +from meshai.notifications.gating.base import GateResult +from meshai.persistence import get_db + +logger = logging.getLogger(__name__) + + +# ── Haversine distance (miles) ────────────────────────────────────────────── + +def _haversine_mi(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + R_mi = 3958.8 + p1 = math.radians(lat1); p2 = math.radians(lat2) + dp = math.radians(lat2 - lat1); dl = math.radians(lon2 - lon1) + a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 + return 2 * R_mi * math.atan2(math.sqrt(a), math.sqrt(1 - a)) + + +_MI_TO_KM = 1.60934 + + +def _within_regional_radius(lat: float, lon: float) -> tuple[bool, float]: + """Return (within_radius, distance_km). + + Reads centroid + radius from adapter_config so GUI edits apply live. + Mirrors quake_handler.within_250mi_of_idaho exactly. + """ + if not (isinstance(lat, (int, float)) and isinstance(lon, (int, float))): + return False, 0.0 + try: + cen = adapter_config.usgs_quake.regional_centroid + radius_mi = float(adapter_config.usgs_quake.regional_radius_mi) + except Exception: + cen = [44.36, -114.61] + radius_mi = 250.0 + dist_mi = _haversine_mi(lat, lon, float(cen[0]), float(cen[1])) + return dist_mi <= radius_mi, dist_mi * _MI_TO_KM + + +def _should_broadcast( + mag: Optional[float], + lat: Optional[float], + lon: Optional[float], + tsunami: bool, + pager: Optional[str], +) -> bool: + """Broadcast gate — verbatim copy of quake_handler._should_broadcast.""" + if tsunami: + return True + try: + pager_set = {s.lower() for s in adapter_config.usgs_quake.broadcast_pager_alerts} + except Exception: + pager_set = {"orange", "red"} + if pager and pager.lower() in pager_set: + return True + if not isinstance(mag, (int, float)): + return False + try: + global_floor = float(adapter_config.usgs_quake.global_mag_floor) + regional_floor = float(adapter_config.usgs_quake.regional_mag_floor) + except Exception: + global_floor = 3.0 + regional_floor = 2.5 + if mag >= global_floor: + return True + if mag >= regional_floor: + within, _ = _within_regional_radius(lat, lon) + if within: + return True + return False + + +def _severity_override_for(tsunami: bool, pager: Optional[str]) -> Optional[str]: + """Derive _severity_override from tsunami + PAGER flags. + + tsunami / PAGER → "immediate" (mass-casualty threshold). + Routine quake → None (uses Central envelope severity via map_severity). + """ + if tsunami: + return "immediate" + try: + pager_set = {s.lower() for s in adapter_config.usgs_quake.broadcast_pager_alerts} + except Exception: + pager_set = {"orange", "red"} + if pager and pager.lower() in pager_set: + return "immediate" + return None + + +# ── Public API ─────────────────────────────────────────────────────────────── + +def decide(data: dict, *, source: str, now: float) -> GateResult: + """Gate + first-sighting decision for earthquake_event. + + Parameters + ---------- + data: + Canonical Event.data dict (see module docstring for schema). + Reads both canonical keys (magnitude, depth_km, lat, lon, pager) and + USGS-raw fallback keys (mag, depth, latitude, longitude, alert) so the + function works for both the Central path (where handle_quake writes + canonical keys into the shared data dict) and the native path (where + to_event() supplies the canonical dict directly). + source: + Adapter source name, e.g. "usgs_quake". + now: + Current epoch (from clock.now()) — determinism seam, no time.time(). + + Returns + ------- + GateResult with: + broadcast=True lifecycle="new" data_patch+commit populated + broadcast=False lifecycle="suppress" data_patch={} commit=None + """ + # ── Extract canonical fields (with raw-key fallbacks) ─────────────────── + mag = data.get("magnitude") if data.get("magnitude") is not None else data.get("mag") + if isinstance(mag, str): + try: + mag = float(mag) + except ValueError: + mag = None + elif isinstance(mag, (int, float)): + mag = float(mag) + + depth_km = data.get("depth_km") if data.get("depth_km") is not None else data.get("depth") + lat = data.get("lat") if data.get("lat") is not None else data.get("latitude") + lon = data.get("lon") if data.get("lon") is not None else data.get("longitude") + place = data.get("place") + tsunami = bool(data.get("tsunami") or data.get("tsunami_warning")) + pager = data.get("pager") if data.get("pager") is not None else data.get("alert") + occurred_at = data.get("occurred_at") + event_id = data.get("event_id") + + if not event_id: + return GateResult( + broadcast=False, lifecycle="suppress", + reason="no event_id in canonical data", + ) + + # ── Magnitude gate ─────────────────────────────────────────────────────── + if not _should_broadcast(mag, lat, lon, tsunami, pager): + return GateResult( + broadcast=False, lifecycle="suppress", + reason=f"below threshold mag={mag} lat={lat} lon={lon} " + f"tsunami={tsunami} pager={pager}", + ) + + # ── Compute distance_km for data_patch ────────────────────────────────── + within_region, distance_km = _within_regional_radius(lat, lon) + + # ── First-sighting check via quake_events ──────────────────────────────── + try: + conn = get_db() + except Exception: + logger.exception("quake decide: persistence unavailable") + return GateResult( + broadcast=False, lifecycle="suppress", + reason="persistence unavailable", + ) + + row = conn.execute( + "SELECT last_broadcast_at FROM quake_events WHERE event_id=?", + (event_id,), + ).fetchone() + + # Already broadcast → no-Update rule (v0.5.9): suppress revision + if row is not None and row["last_broadcast_at"] is not None: + return GateResult( + broadcast=False, lifecycle="suppress", + reason=f"already broadcast at {row['last_broadcast_at']}", + ) + + # First sighting: INSERT OR IGNORE to create the row. Idempotent — + # a concurrent arrival for the same event_id is safe (ON CONFLICT IGNORE). + conn.execute( + "INSERT OR IGNORE INTO quake_events" + "(event_id, magnitude, depth_km, place, lat, lon, " + "occurred_at, tsunami_warning, first_seen_at, last_broadcast_at) " + "VALUES (?,?,?,?,?,?,?,?,?,?)", + ( + event_id, + mag, + depth_km, + place, + lat if isinstance(lat, (int, float)) else None, + lon if isinstance(lon, (int, float)) else None, + int(occurred_at) if occurred_at is not None else None, + 1 if tsunami else 0, + int(now), + None, # armed by commit() on confirmed delivery + ), + ) + + # ── Build data_patch and commit closure ────────────────────────────────── + sev_override = _severity_override_for(tsunami, pager) + + patch: dict = { + "distance_km": distance_km, + "is_update": False, # v0.5.9 no-Update rule preserved + "_severity_override": sev_override, + "_dedup_suffix": "", # bare event.id suffices for quake dedup + } + + def _commit(committed_at: float) -> None: + """Idempotent UPSERT: arm last_broadcast_at on confirmed delivery.""" + try: + c = get_db() + c.execute( + "UPDATE quake_events " + "SET last_broadcast_at=?, " + "first_broadcast_at=COALESCE(first_broadcast_at, ?) " + "WHERE event_id=?", + (int(committed_at), int(committed_at), event_id), + ) + except Exception: + logger.exception("quake commit: persistence update failed for %s", event_id) + + return GateResult( + broadcast=True, + lifecycle="new", + reason=f"first sighting M{mag} event_id={event_id}", + data_patch=patch, + commit=_commit, + ) diff --git a/work/meshai/notifications/gating/swpc.py b/work/meshai/notifications/gating/swpc.py new file mode 100644 index 0000000..c54b68f --- /dev/null +++ b/work/meshai/notifications/gating/swpc.py @@ -0,0 +1,227 @@ +"""SWPC space-weather gating decider — Phase-1 refactor. + +decide(data, *, source, now) -> GateResult: + Canonical data schema consumed: + event_id str — stable SWPC event identifier + driver str — "kp" | "flare" + scalar float|str|None — Kp value (float) or flare class (str) + scale_code str — pre-computed NOAA scale code ("G3", "R3") + message str — SWPC alert message body (for format seam) + issued_at str|None — ISO timestamp + + Emitted data_patch keys: + _severity_override str — "priority" (G3/R3), "immediate" (G4+/R4+) + _cooldown_suffix str — scale_code (collapses cross-sub-adapter + events in the dispatcher cooldown window) + + commit(now: float) -> None: + Idempotent UPSERT: sets last_broadcast_at on the swpc_events row. + For kp driver: also defers the cross-adapter geomag window stamp into + the commit closure (intentional divergence from swpc_handler.py's + inline _geomag_recent[scale_code] = now stamp). + +Gate logic: + kp driver: G3+ — scale_code numeric level >= 3 + flare driver: R3+ — scale_code numeric level >= 3 + solar_radiation_storm (proton) is NOT registered here; stays on legacy path. + +Cross-adapter geomag 600s window: + kp events within 600s of a COMMITTED broadcast for the same scale_code are + suppressed. State lives in the module-level _geomag_window dict, replacing + the _geomag_recent dict that used to live in swpc_handler.py (L61-62). + The window stamp is deferred into the commit closure so it only ticks on + confirmed delivery, not on decision. + +First-sighting rule: + Check swpc_events.last_broadcast_at for the event_id. + None (row absent or last_broadcast_at IS NULL) → broadcast (lifecycle="new"). + last_broadcast_at IS NOT NULL → suppress (already broadcast; SWPC events + are point-in-time, no re-broadcast on revision). +""" +from __future__ import annotations + +import logging +from typing import Optional + +from meshai.notifications.gating.base import GateResult +from meshai.persistence import get_db + +logger = logging.getLogger(__name__) + +# Cross-adapter geomag dedup window. +# Keyed on scale_code (e.g. "G3"); value = epoch of last committed broadcast. +# Cleared on process restart (acceptable — worst case one dup on restart). +GEOMAG_DEDUP_WINDOW_SECONDS = 600 +_geomag_window: dict[str, float] = {} + + +def _scale_level(scale_code: str) -> int: + """Parse numeric level from NOAA scale code e.g. 'G3' → 3, 'R5' → 5.""" + if not scale_code or len(scale_code) < 2: + return 0 + try: + return int(scale_code[1:]) + except (ValueError, TypeError): + return 0 + + +def _severity_for_scale(scale_code: str) -> str: + """Map NOAA scale code to meshai severity override. + + G5/R5 → immediate (extreme) + G4/R4 → immediate (severe, mass-impact) + G3/R3 → priority (strong — broadcast-worthy but not life-safety) + G1/G2/R1/R2 → routine (won't reach here via floor, safe fallback) + """ + level = _scale_level(scale_code) + if level >= 4: + return "immediate" + if level >= 3: + return "priority" + return "routine" + + +def decide(data: dict, *, source: str, now: float) -> GateResult: + """Gate + first-sighting decision for geomagnetic_storm and rf_propagation_alert. + + Parameters + ---------- + data: + Canonical Event.data dict (see module docstring for schema). + source: + Adapter source name, e.g. "swpc". + now: + Current epoch (from clock.now()) — determinism seam, no time.time(). + + Returns + ------- + GateResult with: + broadcast=True lifecycle="new" data_patch+commit populated + broadcast=False lifecycle="suppress" data_patch={} commit=None + """ + event_id = data.get("event_id") + if not event_id: + return GateResult( + broadcast=False, lifecycle="suppress", + reason="no event_id in canonical data", + ) + + driver = data.get("driver") # "kp" | "flare" + scale_code = data.get("scale_code") or "" + + # ── Scale floor check (G3+/R3+) ───────────────────────────────────────── + if not scale_code: + return GateResult( + broadcast=False, lifecycle="suppress", + reason=f"no scale_code for event_id={event_id} driver={driver}", + ) + + level = _scale_level(scale_code) + if level < 3: + return GateResult( + broadcast=False, lifecycle="suppress", + reason=f"scale {scale_code} below G3/R3 floor (level={level})", + ) + + # ── Geomag cross-adapter 600s window (kp driver only) ─────────────────── + # The window stamp is deferred into commit() so only confirmed deliveries + # count. _cooldown_suffix also triggers dispatcher-level dedup as a + # complementary mechanism. + if driver == "kp": + prev_ts = _geomag_window.get(scale_code) + if prev_ts is not None and (now - prev_ts) < GEOMAG_DEDUP_WINDOW_SECONDS: + logger.debug( + "swpc gating: geomag dedup — %s from source=%s " + "already broadcast %.0fs ago (window=%ds)", + scale_code, source, now - prev_ts, GEOMAG_DEDUP_WINDOW_SECONDS, + ) + return GateResult( + broadcast=False, lifecycle="suppress", + reason=( + f"geomag dedup: {scale_code} committed {now - prev_ts:.0f}s ago " + f"(window={GEOMAG_DEDUP_WINDOW_SECONDS}s)" + ), + ) + + # ── Persistence: first-sighting check ─────────────────────────────────── + try: + conn = get_db() + except Exception: + logger.exception("swpc decide: persistence unavailable") + return GateResult( + broadcast=False, lifecycle="suppress", + reason="persistence unavailable", + ) + + row = conn.execute( + "SELECT last_broadcast_at FROM swpc_events WHERE event_id=?", + (event_id,), + ).fetchone() + + if row is not None and row["last_broadcast_at"] is not None: + return GateResult( + broadcast=False, lifecycle="suppress", + reason=f"already broadcast at {row['last_broadcast_at']}", + ) + + # ── Insert row (first-sighting) ────────────────────────────────────────── + # INSERT OR IGNORE: safe against concurrent arrivals for the same event_id. + # event_type and payload_json are filled in by handle_swpc via _upsert_swpc + # (which UPDATE-patches after this INSERT). + if row is None: + try: + conn.execute( + "INSERT OR IGNORE INTO swpc_events" + "(event_id, event_type, severity_int, payload_json, " + "occurred_at, first_seen_at, last_broadcast_at) " + "VALUES (?,?,?,?,?,?,?)", + (event_id, driver or "unknown", None, None, + int(now), int(now), None), + ) + except Exception: + logger.exception("swpc decide: INSERT failed for %s", event_id) + + # ── Build data_patch ───────────────────────────────────────────────────── + sev_override = _severity_for_scale(scale_code) + + patch: dict = { + # Tier-b fix: set severity from scale instead of always "routine". + "_severity_override": sev_override, + # Collates cross-sub-adapter events in the dispatcher's cooldown dedup. + "_cooldown_suffix": scale_code, + } + + # ── Build commit closure ───────────────────────────────────────────────── + _event_id = event_id + _driver = driver + _scale_code = scale_code + + def _commit(committed_at: float) -> None: + """Idempotent UPSERT: arm last_broadcast_at. Defer geomag window stamp.""" + try: + c = get_db() + c.execute( + "UPDATE swpc_events " + "SET last_broadcast_at=?, " + "first_broadcast_at=COALESCE(first_broadcast_at, ?) " + "WHERE event_id=?", + (int(committed_at), int(committed_at), _event_id), + ) + except Exception: + logger.exception( + "swpc commit: persistence update failed for %s", _event_id + ) + # Deferred geomag window stamp — only ticks on confirmed delivery. + # Intentional divergence from swpc_handler.py's inline stamp. + if _driver == "kp": + _geomag_window[_scale_code] = committed_at + + return GateResult( + broadcast=True, + lifecycle="new", + reason=( + f"first sighting {scale_code} event_id={event_id} source={source}" + ), + data_patch=patch, + commit=_commit, + ) diff --git a/work/meshai/notifications/renderers/composer.py b/work/meshai/notifications/renderers/composer.py index c3c0d01..91fab28 100644 --- a/work/meshai/notifications/renderers/composer.py +++ b/work/meshai/notifications/renderers/composer.py @@ -328,14 +328,16 @@ def compose_mesh_message(event: Event) -> str: Return it verbatim -- no family-label prefix, no region tail, no severity word append. """ - # Phase-1+ formatter dispatch — EMPTY REGISTRY at Phase 0, so this - # no-ops on every call. When a formatter is registered for the event's - # category (or its toggle family), it is called here and its output - # returned verbatim (newlines preserved, no Mode-B re-entry). + # Phase-1+ formatter dispatch — gated on MESHAI_CUTOVER_CATEGORIES. + # A formatter registered for an event's category is only called in the + # LIVE path once that category has been explicitly cut over. Until then + # the formatter is used only by shadow_render (dry-run comparison) and by + # direct unit tests — compose_mesh_message falls through to the legacy path. from meshai.notifications.formatters import get_formatter + from meshai.notifications.cutover import is_cutover from meshai.notifications import clock fmt = get_formatter(event.category) - if fmt is not None: + if fmt is not None and is_cutover(event.category): try: return fmt(event, now=clock.now(), budget=_resolve_budget(event)) except Exception: diff --git a/work/meshai/notifications/shadow.py b/work/meshai/notifications/shadow.py index 4786a58..b2e9431 100644 --- a/work/meshai/notifications/shadow.py +++ b/work/meshai/notifications/shadow.py @@ -107,6 +107,14 @@ def shadow_gate( * NEVER writes any DB table * All exceptions swallowed; returns None always """ + # Once a category is cut over its new path IS the live path — there is + # nothing to shadow-compare. Skip early so the hook is a true no-op. + try: + from meshai.notifications.cutover import is_cutover + if is_cutover(category): + return + except Exception: + pass # belt-and-suspenders: never let cutover import block shadow if not enabled_for(category): return try: @@ -182,6 +190,14 @@ def shadow_render(category: str, event: "Event", *, old_wire: str) -> None: * NEVER emits, commits, or writes any DB table * All exceptions swallowed; returns None always """ + # Once a category is cut over the new formatter IS composing old_wire — + # there is nothing to compare against. Skip early. + try: + from meshai.notifications.cutover import is_cutover + if is_cutover(category): + return + except Exception: + pass # belt-and-suspenders: never let cutover import block shadow if not enabled_for(category): return try: diff --git a/work/scripts/capture_fixtures.py b/work/scripts/capture_fixtures.py index 5a766c0..09394ac 100644 --- a/work/scripts/capture_fixtures.py +++ b/work/scripts/capture_fixtures.py @@ -1,32 +1,35 @@ """Read-only ephemeral fixture capture from NATS JetStream. Captures real Central CloudEvents envelopes WITHOUT disturbing the live -durable consumers by using an ephemeral pull consumer (no durable name, -AckPolicy.none, short inactive_threshold for auto-deletion). +durable consumers by using an ephemeral push consumer (no durable name, +AckPolicy.none). The consumer is subject-based, so it auto-discovers the +correct stream (CENTRAL_QUAKE, CENTRAL_SPACE, …) exactly as the live +CentralConsumer does in meshai/central/consumer.py. Run from inside the meshai container:: docker exec meshai python /app/scripts/capture_fixtures.py \\ - --hazard earthquake_event \\ - --subject "central.usgs_quake.>" \\ - --mode all --max 20 + --hazard quake \\ + --subject "central.quake.event.>" \\ + --mode all --max 25 # Dry-run (count only, no file writes): docker exec meshai python /app/scripts/capture_fixtures.py \\ - --hazard earthquake_event \\ - --subject "central.usgs_quake.>" \\ - --mode all --max 20 --dry-run + --hazard quake \\ + --subject "central.quake.event.>" \\ + --mode all --max 25 --dry-run # Last-per-subject snapshot: docker exec meshai python /app/scripts/capture_fixtures.py \\ - --hazard nws \\ - --subject "central.nws.>" \\ + --hazard swpc \\ + --subject "central.space.>" \\ --mode last Modes ----- --mode last DeliverPolicy.LAST_PER_SUBJECT — one message per subject key. - Useful for a current-state snapshot. + Useful for a current-state snapshot. Script stops after a + short idle period (no new messages arriving). --mode all DeliverPolicy.ALL — bounded history. REQUIRED: --max N cap to avoid pulling 330k+ traffic messages. @@ -37,16 +40,30 @@ Each captured envelope is written as:: tests/fixtures//.json { "envelope": { ... }, # raw Central CloudEvents payload - "subject": "central.usgs_quake.us7000xyz", + "subject": "central.quake.event.minor.unknown", "captured_epoch": 1750000000 } Safety ------ -The ephemeral consumer is created with AckPolicy.none and a 30-second -inactive_threshold. It is never assigned a durable name, so it never -advances the live durable consumers' sequence pointers and is automatically -cleaned up by the NATS server after inactivity. +The ephemeral consumer is created with AckPolicy.none and no durable name, +so it never advances the live durable consumers' sequence pointers and is +automatically cleaned up by the NATS server after inactivity. No config, +no deploy, no restart changes are made. + +Bug fix (2026-07-04) +-------------------- +The previous version called js.add_consumer(stream, cfg) with a hardcoded +stream name "CENTRAL" that does not exist — Central partitions streams by +domain (CENTRAL_QUAKE, CENTRAL_SPACE, CENTRAL_WX, …). It then called +pull_subscribe_bind() without await, making it a no-op coroutine object +instead of an actual subscription, and the subsequent .fetch() raised +AttributeError / NotFoundError. + +Fix: mirror the proven pattern from meshai/central/consumer.py — use +js.subscribe(subject, cb=..., config=ConsumerConfig(...)) with no durable +name. The subject-based subscribe call auto-discovers the correct stream +server-side, identical to how the live CentralConsumer binds. """ from __future__ import annotations @@ -63,6 +80,11 @@ import time # importable in unit-test environments without a running NATS server. # -------------------------------------------------------------------------- +# Seconds with no incoming message before the capture loop stops. +# Sufficient for both LAST_PER_SUBJECT (snapshot drains quickly) and ALL +# (history replay has no inter-message gaps larger than this in practice). +_IDLE_TIMEOUT = 4.0 + def _output_dir(hazard: str) -> pathlib.Path: """Resolve tests/fixtures// relative to the repo root.""" @@ -75,14 +97,13 @@ def _output_dir(hazard: str) -> pathlib.Path: async def _run( *, nats_url: str, - stream: str, subject: str, hazard: str, mode: str, max_msgs: int, dry_run: bool, ) -> int: - """Connect, create ephemeral consumer, pull messages, write fixtures. + """Connect, create ephemeral push consumer, collect messages, write fixtures. Returns the count of messages captured (or counted, for --dry-run). """ @@ -93,87 +114,81 @@ async def _run( try: js = nc.jetstream() - # Build an ephemeral consumer config (no durable_name = ephemeral). - # AckPolicy.none avoids needing to ack — purely read-only. - # inactive_threshold of 30 s ensures the NATS server auto-deletes it. deliver_policy = ( DeliverPolicy.LAST_PER_SUBJECT if mode == "last" else DeliverPolicy.ALL ) - cfg = ConsumerConfig( - # durable_name intentionally omitted → ephemeral consumer - filter_subject=subject, - deliver_policy=deliver_policy, - ack_policy=AckPolicy.NONE, - inactive_threshold=30.0, # seconds → server auto-deletes after idle - ) - # Create ephemeral pull consumer (server-side, no local binding name). - consumer_info = await js.add_consumer(stream, cfg) - consumer_name = consumer_info.name + # Funnel incoming messages into an asyncio Queue so the main loop + # can apply the max-msgs cap and idle-timeout without threads. + msg_q: asyncio.Queue = asyncio.Queue() + + async def _on_msg(msg): + await msg_q.put(msg) + + # Ephemeral push subscribe — NO durable_name → server assigns a + # transient consumer name and auto-deletes it after inactivity. + # AckPolicy.NONE means we never ack, so no sequence cursor is + # advanced on any durable consumer. The subject-based call + # auto-discovers the correct NATS stream (CENTRAL_QUAKE, + # CENTRAL_SPACE, etc.) — identical to CentralConsumer.start(). + sub = await js.subscribe( + subject, + cb=_on_msg, + config=ConsumerConfig( + deliver_policy=deliver_policy, + ack_policy=AckPolicy.NONE, + ), + ) out_dir = _output_dir(hazard) if not dry_run: out_dir.mkdir(parents=True, exist_ok=True) captured = 0 - fetch_batch = min(max_msgs, 50) # pull in bounded batches while captured < max_msgs: - batch = min(fetch_batch, max_msgs - captured) try: - msgs = await js.pull_subscribe_bind( - stream, consumer_name - ).fetch(batch, timeout=5.0) - except nats.errors.TimeoutError: - break # no more messages within timeout - - if not msgs: + msg = await asyncio.wait_for(msg_q.get(), timeout=_IDLE_TIMEOUT) + except asyncio.TimeoutError: + # No new messages within idle window — snapshot is drained + # (LAST_PER_SUBJECT) or history is exhausted (ALL). break - for msg in msgs: - try: - envelope = json.loads(msg.data) - except Exception: - continue # skip unparseable messages + try: + envelope = json.loads(msg.data) + except Exception: + continue # skip unparseable frames - if dry_run: - captured += 1 - print( - f" [dry-run] #{captured} subject={msg.subject!r}", - file=sys.stderr, - ) - else: - record = { - "envelope": envelope, - "subject": msg.subject, - "captured_epoch": int(time.time()), - } - out_path = out_dir / f"{captured:04d}.json" - out_path.write_text( - json.dumps(record, indent=2, ensure_ascii=False), - encoding="utf-8", - ) - captured += 1 - print( - f" wrote {out_path.relative_to(pathlib.Path.cwd())} " - f"subject={msg.subject!r}", - file=sys.stderr, - ) + if dry_run: + captured += 1 + print( + " [dry-run] #%d subject=%r" % (captured, msg.subject), + file=sys.stderr, + ) + else: + record = { + "envelope": envelope, + "subject": msg.subject, + "captured_epoch": int(time.time()), + } + out_path = out_dir / ("%04d.json" % captured) + out_path.write_text( + json.dumps(record, indent=2, ensure_ascii=False), + encoding="utf-8", + ) + captured += 1 + print( + " wrote %s subject=%r" % (out_path, msg.subject), + file=sys.stderr, + ) - if captured >= max_msgs: - break - - # For last-per-subject: a single fetch is sufficient. - if mode == "last": - break - - # Delete the ephemeral consumer explicitly (belt-and-suspenders). + # Unsubscribe: signals the server to clean up the ephemeral consumer. try: - await js.delete_consumer(stream, consumer_name) + await sub.unsubscribe() except Exception: - pass # server already cleaned up, or error is non-fatal + pass return captured @@ -184,7 +199,6 @@ async def _run( def _load_nats_url() -> str: """Read the NATS URL from meshai config or env override.""" - # Allow an explicit env override for CI / ad-hoc use. if "MESHAI_NATS_URL" in os.environ: return os.environ["MESHAI_NATS_URL"] try: @@ -202,9 +216,7 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--hazard", required=True, help="Hazard category label (used as fixture sub-dir).") parser.add_argument("--subject", required=True, - help="NATS subject filter, e.g. 'central.usgs_quake.>'.") - parser.add_argument("--stream", default="CENTRAL", - help="JetStream stream name (default: CENTRAL).") + help="NATS subject filter, e.g. 'central.quake.event.>'.") parser.add_argument("--mode", choices=["last", "all"], default="all", help="DeliverPolicy: last=LAST_PER_SUBJECT, all=ALL (default: all).") parser.add_argument("--max", type=int, default=50, dest="max_msgs", @@ -217,16 +229,14 @@ def main(argv: list[str] | None = None) -> int: nats_url = args.nats_url or _load_nats_url() print( - f"capture_fixtures: url={nats_url!r} stream={args.stream!r} " - f"subject={args.subject!r} hazard={args.hazard!r} " - f"mode={args.mode!r} max={args.max_msgs} dry_run={args.dry_run}", + "capture_fixtures: url=%r subject=%r hazard=%r mode=%r max=%d dry_run=%s" + % (nats_url, args.subject, args.hazard, args.mode, args.max_msgs, args.dry_run), file=sys.stderr, ) count = asyncio.run( _run( nats_url=nats_url, - stream=args.stream, subject=args.subject, hazard=args.hazard, mode=args.mode, @@ -236,7 +246,7 @@ def main(argv: list[str] | None = None) -> int: ) verb = "counted" if args.dry_run else "captured" - print(f"{verb} {count} envelope(s) for hazard={args.hazard!r}", file=sys.stderr) + print("%s %d envelope(s) for hazard=%r" % (verb, count, args.hazard), file=sys.stderr) return 0 diff --git a/work/tests/fixtures/avalanche/0000.json b/work/tests/fixtures/avalanche/0000.json new file mode 100644 index 0000000..d5b9ecf --- /dev/null +++ b/work/tests/fixtures/avalanche/0000.json @@ -0,0 +1,41 @@ +{ + "_synthetic": true, + "_synthetic_note": "Hand-built fixture for off-season testing. Not from a live Central CENTRAL_AVY capture. Represents a Considerable (NAADS 3 / centralseverity 2) advisory for the Sawtooth Mountains zone from SNFAC.", + "envelope": { + "id": "avy-snfac-sawtooth-considerable-2025-11-15", + "source": "central.echo6.co", + "type": "central.avy.advisory.us.id.v1", + "time": "2025-11-15T12:00:00.000000+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "advisory.us.id", + "centralseverity": 2, + "specversion": "1.0", + "data": { + "id": "avy-snfac-sawtooth-considerable-2025-11-15", + "adapter": "avalanche_org", + "category": "advisory.us.id", + "time": "2025-11-15T12:00:00.000000Z", + "expires": "2025-11-15T23:59:00Z", + "severity": 2, + "geo": { + "centroid": [-114.9, 43.8], + "bbox": null, + "regions": ["us.id"], + "primary_region": "us.id", + "geometry": null + }, + "data": { + "danger_level": 3, + "danger_name": "Considerable", + "zone_name": "Sawtooth Mountains", + "center_id": "SNFAC", + "travel_advice": "Dangerous conditions on steep slopes. Conservative decision-making is advised.", + "latitude": 43.8, + "longitude": -114.9 + } + } + }, + "subject": "central.avy.advisory.us.id", + "captured_epoch": 1731672000 +} diff --git a/work/tests/fixtures/avalanche/0001.json b/work/tests/fixtures/avalanche/0001.json new file mode 100644 index 0000000..1384d88 --- /dev/null +++ b/work/tests/fixtures/avalanche/0001.json @@ -0,0 +1,41 @@ +{ + "_synthetic": true, + "_synthetic_note": "Hand-built fixture for off-season testing. Not from a live Central CENTRAL_AVY capture. Represents a High (NAADS 4 / centralseverity 3) advisory for Banner Summit zone from SNFAC. Expect _severity_override='priority'.", + "envelope": { + "id": "avy-snfac-banner-high-2025-11-15", + "source": "central.echo6.co", + "type": "central.avy.advisory.us.id.v1", + "time": "2025-11-15T12:00:00.000000+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "advisory.us.id", + "centralseverity": 3, + "specversion": "1.0", + "data": { + "id": "avy-snfac-banner-high-2025-11-15", + "adapter": "avalanche_org", + "category": "advisory.us.id", + "time": "2025-11-15T12:00:00.000000Z", + "expires": "2025-11-15T23:59:00Z", + "severity": 3, + "geo": { + "centroid": [-115.2, 44.3], + "bbox": null, + "regions": ["us.id"], + "primary_region": "us.id", + "geometry": null + }, + "data": { + "danger_level": 4, + "danger_name": "High", + "zone_name": "Banner Summit", + "center_id": "SNFAC", + "travel_advice": "Avoid all avalanche terrain today. Natural avalanches are likely on steep slopes.", + "latitude": 44.3, + "longitude": -115.2 + } + } + }, + "subject": "central.avy.advisory.us.id", + "captured_epoch": 1731672000 +} diff --git a/work/tests/fixtures/avalanche/0002.json b/work/tests/fixtures/avalanche/0002.json new file mode 100644 index 0000000..b099dd6 --- /dev/null +++ b/work/tests/fixtures/avalanche/0002.json @@ -0,0 +1,41 @@ +{ + "_synthetic": true, + "_synthetic_note": "Hand-built fixture for off-season testing. Not from a live Central CENTRAL_AVY capture. Represents an Extreme (NAADS 5 / centralseverity 4) advisory for Soldier Mountains zone from SNFAC. Expect _severity_override='priority'.", + "envelope": { + "id": "avy-snfac-soldier-extreme-2025-11-15", + "source": "central.echo6.co", + "type": "central.avy.advisory.us.id.v1", + "time": "2025-11-15T12:00:00.000000+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "advisory.us.id", + "centralseverity": 4, + "specversion": "1.0", + "data": { + "id": "avy-snfac-soldier-extreme-2025-11-15", + "adapter": "avalanche_org", + "category": "advisory.us.id", + "time": "2025-11-15T12:00:00.000000Z", + "expires": "2025-11-15T23:59:00Z", + "severity": 4, + "geo": { + "centroid": [-115.4, 43.5], + "bbox": null, + "regions": ["us.id"], + "primary_region": "us.id", + "geometry": null + }, + "data": { + "danger_level": 5, + "danger_name": "Extreme", + "zone_name": "Soldier Mountains", + "center_id": "SNFAC", + "travel_advice": "Avoid all avalanche terrain! Widespread natural and human-triggered avalanches certain.", + "latitude": 43.5, + "longitude": -115.4 + } + } + }, + "subject": "central.avy.advisory.us.id", + "captured_epoch": 1731672000 +} diff --git a/work/tests/fixtures/avalanche/0003.json b/work/tests/fixtures/avalanche/0003.json new file mode 100644 index 0000000..b6c0501 --- /dev/null +++ b/work/tests/fixtures/avalanche/0003.json @@ -0,0 +1,42 @@ +{ + "_synthetic": true, + "_synthetic_note": "Hand-built fixture for off-season testing. Represents a Considerable (NAADS 3) UPDATE advisory for Sawtooth Mountains — simulates a re-poll where the zone already had a prior broadcast (is_update trend case). The is_update flag is set in data.data since Central path has no native trend detection; native path sets _is_update via EnvironmentalStore.", + "envelope": { + "id": "avy-snfac-sawtooth-considerable-update-2025-11-15", + "source": "central.echo6.co", + "type": "central.avy.advisory.us.id.v1", + "time": "2025-11-15T18:00:00.000000+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "advisory.us.id", + "centralseverity": 2, + "specversion": "1.0", + "data": { + "id": "avy-snfac-sawtooth-considerable-update-2025-11-15", + "adapter": "avalanche_org", + "category": "advisory.us.id", + "time": "2025-11-15T18:00:00.000000Z", + "expires": "2025-11-15T23:59:00Z", + "severity": 2, + "geo": { + "centroid": [-114.9, 43.8], + "bbox": null, + "regions": ["us.id"], + "primary_region": "us.id", + "geometry": null + }, + "data": { + "danger_level": 3, + "danger_name": "Considerable", + "zone_name": "Sawtooth Mountains", + "center_id": "SNFAC", + "travel_advice": "Dangerous conditions on steep slopes. Conservative decision-making is advised.", + "latitude": 43.8, + "longitude": -114.9, + "_is_update": true + } + } + }, + "subject": "central.avy.advisory.us.id", + "captured_epoch": 1731693600 +} diff --git a/work/tests/fixtures/quake/0000.json b/work/tests/fixtures/quake/0000.json new file mode 100644 index 0000000..8c9db00 --- /dev/null +++ b/work/tests/fixtures/quake/0000.json @@ -0,0 +1,82 @@ +{ + "envelope": { + "id": "uu80143601", + "source": "central.echo6.co", + "type": "central.quake.event.minor.v1", + "time": "2026-06-28T19:19:56.050000+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "quake.event.minor", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "uu80143601", + "adapter": "usgs_quake", + "category": "quake.event.minor", + "time": "2026-06-28T19:19:56.050000Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": [ + -112.206666666667, + 42.2111666666667 + ], + "bbox": [ + -112.206666666667, + 42.2111666666667, + -112.206666666667, + 42.2111666666667 + ], + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "magnitude": 1.23, + "place": "4 km ENE of Malad City, Idaho", + "time_ms": 1782674396050, + "updated_ms": 1782742438530, + "tz": null, + "url": "https://earthquake.usgs.gov/earthquakes/eventpage/uu80143601", + "detail": "https://earthquake.usgs.gov/earthquakes/feed/v1.0/detail/uu80143601.geojson", + "felt": null, + "cdi": null, + "mmi": null, + "alert": null, + "status": "reviewed", + "tsunami": 0, + "sig": 23, + "net": "uu", + "code": "80143601", + "ids": ",uu80143601,", + "sources": ",uu,", + "types": ",origin,phase-data,", + "nst": 10, + "dmin": 0.1939, + "rms": 0.1, + "gap": 160, + "magType": "md", + "type": "earthquake", + "title": "M 1.2 - 4 km ENE of Malad City, Idaho", + "longitude": -112.206666666667, + "latitude": 42.2111666666667, + "depth": 3.06, + "_enriched": { + "geocoder": { + "name": null, + "city": null, + "county": null, + "state": null, + "country": null, + "postal_code": null, + "timezone": "America/Boise", + "landclass": "Deep Creek Roadless Area", + "elevation_m": 1752.28515625 + } + } + } + } + }, + "subject": "central.quake.event.minor.unknown", + "captured_epoch": 1783196478 +} \ No newline at end of file diff --git a/work/tests/fixtures/quake/0001.json b/work/tests/fixtures/quake/0001.json new file mode 100644 index 0000000..d705005 --- /dev/null +++ b/work/tests/fixtures/quake/0001.json @@ -0,0 +1,82 @@ +{ + "envelope": { + "id": "uu80143651", + "source": "central.echo6.co", + "type": "central.quake.event.minor.v1", + "time": "2026-06-29T12:09:25.630000+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "quake.event.minor", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "uu80143651", + "adapter": "usgs_quake", + "category": "quake.event.minor", + "time": "2026-06-29T12:09:25.630000Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": [ + -111.2065, + 42.7483333333333 + ], + "bbox": [ + -111.2065, + 42.7483333333333, + -111.2065, + 42.7483333333333 + ], + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "magnitude": 2.37, + "place": "17 km WSW of Auburn, Wyoming", + "time_ms": 1782734965630, + "updated_ms": 1782764579010, + "tz": null, + "url": "https://earthquake.usgs.gov/earthquakes/eventpage/uu80143651", + "detail": "https://earthquake.usgs.gov/earthquakes/feed/v1.0/detail/uu80143651.geojson", + "felt": null, + "cdi": null, + "mmi": null, + "alert": null, + "status": "reviewed", + "tsunami": 0, + "sig": 86, + "net": "uu", + "code": "80143651", + "ids": ",uu80143651,", + "sources": ",uu,", + "types": ",origin,phase-data,", + "nst": 21, + "dmin": 0.07998, + "rms": 0.18, + "gap": 100, + "magType": "ml", + "type": "earthquake", + "title": "M 2.4 - 17 km WSW of Auburn, Wyoming", + "longitude": -111.2065, + "latitude": 42.7483333333333, + "depth": 4.18, + "_enriched": { + "geocoder": { + "name": null, + "city": null, + "county": null, + "state": null, + "country": null, + "postal_code": null, + "timezone": "America/Boise", + "landclass": "Stump Creek Roadless Area", + "elevation_m": 2198.1875 + } + } + } + } + }, + "subject": "central.quake.event.minor.unknown", + "captured_epoch": 1783196478 +} \ No newline at end of file diff --git a/work/tests/fixtures/quake/0002.json b/work/tests/fixtures/quake/0002.json new file mode 100644 index 0000000..5ab10a6 --- /dev/null +++ b/work/tests/fixtures/quake/0002.json @@ -0,0 +1,82 @@ +{ + "envelope": { + "id": "us6000t9bn", + "source": "central.echo6.co", + "type": "central.quake.event.light.v1", + "time": "2026-07-01T00:35:15.247000+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "quake.event.light", + "centralseverity": 1, + "specversion": "1.0", + "data": { + "id": "us6000t9bn", + "adapter": "usgs_quake", + "category": "quake.event.light", + "time": "2026-07-01T00:35:15.247000Z", + "expires": null, + "severity": 1, + "geo": { + "centroid": [ + -112.6108, + 44.46 + ], + "bbox": [ + -112.6108, + 44.46, + -112.6108, + 44.46 + ], + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "magnitude": 3.3, + "place": "19 km S of Lima, Montana", + "time_ms": 1782866115247, + "updated_ms": 1782867341040, + "tz": null, + "url": "https://earthquake.usgs.gov/earthquakes/eventpage/us6000t9bn", + "detail": "https://earthquake.usgs.gov/earthquakes/feed/v1.0/detail/us6000t9bn.geojson", + "felt": null, + "cdi": null, + "mmi": null, + "alert": null, + "status": "reviewed", + "tsunami": 0, + "sig": 168, + "net": "us", + "code": "6000t9bn", + "ids": ",us6000t9bn,", + "sources": ",us,", + "types": ",origin,phase-data,", + "nst": 52, + "dmin": 0.159, + "rms": 0.61, + "gap": 45, + "magType": "ml", + "type": "earthquake", + "title": "M 3.3 - 19 km S of Lima, Montana", + "longitude": -112.6108, + "latitude": 44.46, + "depth": 11.169, + "_enriched": { + "geocoder": { + "name": null, + "city": null, + "county": null, + "state": null, + "country": null, + "postal_code": null, + "timezone": "America/Boise", + "landclass": "Upper Snake Field Office", + "elevation_m": 2225.02734375 + } + } + } + } + }, + "subject": "central.quake.event.light.unknown", + "captured_epoch": 1783196478 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc/0000.json b/work/tests/fixtures/swpc/0000.json new file mode 100644 index 0000000..2c661de --- /dev/null +++ b/work/tests/fixtures/swpc/0000.json @@ -0,0 +1,36 @@ +{ + "envelope": { + "id": "2026-06-27T20:15:00Z|>=1 MeV", + "source": "central.echo6.co", + "type": "central.space.proton_flux.v1", + "time": "2026-06-27T20:15:00+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.proton_flux", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "2026-06-27T20:15:00Z|>=1 MeV", + "adapter": "swpc_protons", + "category": "space.proton_flux", + "time": "2026-06-27T20:15:00Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "time_tag": "2026-06-27T20:15:00Z", + "satellite": 18, + "flux": 28.766672134399414, + "energy": ">=1 MeV" + } + } + }, + "subject": "central.space.proton_flux", + "captured_epoch": 1783196490 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc/0001.json b/work/tests/fixtures/swpc/0001.json new file mode 100644 index 0000000..145e563 --- /dev/null +++ b/work/tests/fixtures/swpc/0001.json @@ -0,0 +1,36 @@ +{ + "envelope": { + "id": "2026-06-27T20:15:00Z|>=10 MeV", + "source": "central.echo6.co", + "type": "central.space.proton_flux.v1", + "time": "2026-06-27T20:15:00+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.proton_flux", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "2026-06-27T20:15:00Z|>=10 MeV", + "adapter": "swpc_protons", + "category": "space.proton_flux", + "time": "2026-06-27T20:15:00Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "time_tag": "2026-06-27T20:15:00Z", + "satellite": 18, + "flux": 0.30123627185821533, + "energy": ">=10 MeV" + } + } + }, + "subject": "central.space.proton_flux", + "captured_epoch": 1783196490 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc/0002.json b/work/tests/fixtures/swpc/0002.json new file mode 100644 index 0000000..c6bb513 --- /dev/null +++ b/work/tests/fixtures/swpc/0002.json @@ -0,0 +1,36 @@ +{ + "envelope": { + "id": "2026-06-27T20:15:00Z|>=100 MeV", + "source": "central.echo6.co", + "type": "central.space.proton_flux.v1", + "time": "2026-06-27T20:15:00+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.proton_flux", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "2026-06-27T20:15:00Z|>=100 MeV", + "adapter": "swpc_protons", + "category": "space.proton_flux", + "time": "2026-06-27T20:15:00Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "time_tag": "2026-06-27T20:15:00Z", + "satellite": 18, + "flux": 0.1961589753627777, + "energy": ">=100 MeV" + } + } + }, + "subject": "central.space.proton_flux", + "captured_epoch": 1783196490 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc/0003.json b/work/tests/fixtures/swpc/0003.json new file mode 100644 index 0000000..695bbff --- /dev/null +++ b/work/tests/fixtures/swpc/0003.json @@ -0,0 +1,36 @@ +{ + "envelope": { + "id": "2026-06-27T20:15:00Z|>=30 MeV", + "source": "central.echo6.co", + "type": "central.space.proton_flux.v1", + "time": "2026-06-27T20:15:00+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.proton_flux", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "2026-06-27T20:15:00Z|>=30 MeV", + "adapter": "swpc_protons", + "category": "space.proton_flux", + "time": "2026-06-27T20:15:00Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "time_tag": "2026-06-27T20:15:00Z", + "satellite": 18, + "flux": 0.19939908385276794, + "energy": ">=30 MeV" + } + } + }, + "subject": "central.space.proton_flux", + "captured_epoch": 1783196490 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc/0004.json b/work/tests/fixtures/swpc/0004.json new file mode 100644 index 0000000..5174a08 --- /dev/null +++ b/work/tests/fixtures/swpc/0004.json @@ -0,0 +1,36 @@ +{ + "envelope": { + "id": "2026-06-27T20:15:00Z|>=5 MeV", + "source": "central.echo6.co", + "type": "central.space.proton_flux.v1", + "time": "2026-06-27T20:15:00+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.proton_flux", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "2026-06-27T20:15:00Z|>=5 MeV", + "adapter": "swpc_protons", + "category": "space.proton_flux", + "time": "2026-06-27T20:15:00Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "time_tag": "2026-06-27T20:15:00Z", + "satellite": 18, + "flux": 0.3074222505092621, + "energy": ">=5 MeV" + } + } + }, + "subject": "central.space.proton_flux", + "captured_epoch": 1783196490 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc/0005.json b/work/tests/fixtures/swpc/0005.json new file mode 100644 index 0000000..8124bdc --- /dev/null +++ b/work/tests/fixtures/swpc/0005.json @@ -0,0 +1,36 @@ +{ + "envelope": { + "id": "2026-06-27T20:15:00Z|>=50 MeV", + "source": "central.echo6.co", + "type": "central.space.proton_flux.v1", + "time": "2026-06-27T20:15:00+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.proton_flux", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "2026-06-27T20:15:00Z|>=50 MeV", + "adapter": "swpc_protons", + "category": "space.proton_flux", + "time": "2026-06-27T20:15:00Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "time_tag": "2026-06-27T20:15:00Z", + "satellite": 18, + "flux": 0.19789846241474152, + "energy": ">=50 MeV" + } + } + }, + "subject": "central.space.proton_flux", + "captured_epoch": 1783196490 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc/0006.json b/work/tests/fixtures/swpc/0006.json new file mode 100644 index 0000000..8257ecb --- /dev/null +++ b/work/tests/fixtures/swpc/0006.json @@ -0,0 +1,36 @@ +{ + "envelope": { + "id": "2026-06-27T20:15:00Z|>=500 MeV", + "source": "central.echo6.co", + "type": "central.space.proton_flux.v1", + "time": "2026-06-27T20:15:00+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.proton_flux", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "2026-06-27T20:15:00Z|>=500 MeV", + "adapter": "swpc_protons", + "category": "space.proton_flux", + "time": "2026-06-27T20:15:00Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "time_tag": "2026-06-27T20:15:00Z", + "satellite": 18, + "flux": 0.19208531081676483, + "energy": ">=500 MeV" + } + } + }, + "subject": "central.space.proton_flux", + "captured_epoch": 1783196490 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc/0007.json b/work/tests/fixtures/swpc/0007.json new file mode 100644 index 0000000..a50f380 --- /dev/null +++ b/work/tests/fixtures/swpc/0007.json @@ -0,0 +1,36 @@ +{ + "envelope": { + "id": "2026-06-27T20:15:00Z|>=60 MeV", + "source": "central.echo6.co", + "type": "central.space.proton_flux.v1", + "time": "2026-06-27T20:15:00+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.proton_flux", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "2026-06-27T20:15:00Z|>=60 MeV", + "adapter": "swpc_protons", + "category": "space.proton_flux", + "time": "2026-06-27T20:15:00Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "time_tag": "2026-06-27T20:15:00Z", + "satellite": 18, + "flux": 0.19741536676883698, + "energy": ">=60 MeV" + } + } + }, + "subject": "central.space.proton_flux", + "captured_epoch": 1783196490 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc/0008.json b/work/tests/fixtures/swpc/0008.json new file mode 100644 index 0000000..8e7bc63 --- /dev/null +++ b/work/tests/fixtures/swpc/0008.json @@ -0,0 +1,36 @@ +{ + "envelope": { + "id": "2026-06-27T20:20:00Z|>=1 MeV", + "source": "central.echo6.co", + "type": "central.space.proton_flux.v1", + "time": "2026-06-27T20:20:00+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.proton_flux", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "2026-06-27T20:20:00Z|>=1 MeV", + "adapter": "swpc_protons", + "category": "space.proton_flux", + "time": "2026-06-27T20:20:00Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "time_tag": "2026-06-27T20:20:00Z", + "satellite": 18, + "flux": 27.786531448364258, + "energy": ">=1 MeV" + } + } + }, + "subject": "central.space.proton_flux", + "captured_epoch": 1783196490 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc/0009.json b/work/tests/fixtures/swpc/0009.json new file mode 100644 index 0000000..48c4331 --- /dev/null +++ b/work/tests/fixtures/swpc/0009.json @@ -0,0 +1,36 @@ +{ + "envelope": { + "id": "2026-06-27T20:20:00Z|>=10 MeV", + "source": "central.echo6.co", + "type": "central.space.proton_flux.v1", + "time": "2026-06-27T20:20:00+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.proton_flux", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "2026-06-27T20:20:00Z|>=10 MeV", + "adapter": "swpc_protons", + "category": "space.proton_flux", + "time": "2026-06-27T20:20:00Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "time_tag": "2026-06-27T20:20:00Z", + "satellite": 18, + "flux": 0.2485874593257904, + "energy": ">=10 MeV" + } + } + }, + "subject": "central.space.proton_flux", + "captured_epoch": 1783196490 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc/0010.json b/work/tests/fixtures/swpc/0010.json new file mode 100644 index 0000000..c39e1f9 --- /dev/null +++ b/work/tests/fixtures/swpc/0010.json @@ -0,0 +1,36 @@ +{ + "envelope": { + "id": "2026-06-27T20:20:00Z|>=100 MeV", + "source": "central.echo6.co", + "type": "central.space.proton_flux.v1", + "time": "2026-06-27T20:20:00+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.proton_flux", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "2026-06-27T20:20:00Z|>=100 MeV", + "adapter": "swpc_protons", + "category": "space.proton_flux", + "time": "2026-06-27T20:20:00Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "time_tag": "2026-06-27T20:20:00Z", + "satellite": 18, + "flux": 0.1894887238740921, + "energy": ">=100 MeV" + } + } + }, + "subject": "central.space.proton_flux", + "captured_epoch": 1783196490 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc/0011.json b/work/tests/fixtures/swpc/0011.json new file mode 100644 index 0000000..4779405 --- /dev/null +++ b/work/tests/fixtures/swpc/0011.json @@ -0,0 +1,36 @@ +{ + "envelope": { + "id": "2026-06-27T20:20:00Z|>=30 MeV", + "source": "central.echo6.co", + "type": "central.space.proton_flux.v1", + "time": "2026-06-27T20:20:00+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.proton_flux", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "2026-06-27T20:20:00Z|>=30 MeV", + "adapter": "swpc_protons", + "category": "space.proton_flux", + "time": "2026-06-27T20:20:00Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "time_tag": "2026-06-27T20:20:00Z", + "satellite": 18, + "flux": 0.19734397530555725, + "energy": ">=30 MeV" + } + } + }, + "subject": "central.space.proton_flux", + "captured_epoch": 1783196490 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc/0012.json b/work/tests/fixtures/swpc/0012.json new file mode 100644 index 0000000..599589c --- /dev/null +++ b/work/tests/fixtures/swpc/0012.json @@ -0,0 +1,36 @@ +{ + "envelope": { + "id": "2026-06-27T20:20:00Z|>=5 MeV", + "source": "central.echo6.co", + "type": "central.space.proton_flux.v1", + "time": "2026-06-27T20:20:00+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.proton_flux", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "2026-06-27T20:20:00Z|>=5 MeV", + "adapter": "swpc_protons", + "category": "space.proton_flux", + "time": "2026-06-27T20:20:00Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "time_tag": "2026-06-27T20:20:00Z", + "satellite": 18, + "flux": 0.2579914927482605, + "energy": ">=5 MeV" + } + } + }, + "subject": "central.space.proton_flux", + "captured_epoch": 1783196490 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc/0013.json b/work/tests/fixtures/swpc/0013.json new file mode 100644 index 0000000..5fa778c --- /dev/null +++ b/work/tests/fixtures/swpc/0013.json @@ -0,0 +1,36 @@ +{ + "envelope": { + "id": "2026-06-27T20:20:00Z|>=50 MeV", + "source": "central.echo6.co", + "type": "central.space.proton_flux.v1", + "time": "2026-06-27T20:20:00+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.proton_flux", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "2026-06-27T20:20:00Z|>=50 MeV", + "adapter": "swpc_protons", + "category": "space.proton_flux", + "time": "2026-06-27T20:20:00Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "time_tag": "2026-06-27T20:20:00Z", + "satellite": 18, + "flux": 0.19125120341777802, + "energy": ">=50 MeV" + } + } + }, + "subject": "central.space.proton_flux", + "captured_epoch": 1783196490 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc/0014.json b/work/tests/fixtures/swpc/0014.json new file mode 100644 index 0000000..ba43c67 --- /dev/null +++ b/work/tests/fixtures/swpc/0014.json @@ -0,0 +1,36 @@ +{ + "envelope": { + "id": "2026-06-27T20:20:00Z|>=500 MeV", + "source": "central.echo6.co", + "type": "central.space.proton_flux.v1", + "time": "2026-06-27T20:20:00+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.proton_flux", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "2026-06-27T20:20:00Z|>=500 MeV", + "adapter": "swpc_protons", + "category": "space.proton_flux", + "time": "2026-06-27T20:20:00Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "time_tag": "2026-06-27T20:20:00Z", + "satellite": 18, + "flux": 0.18541516363620758, + "energy": ">=500 MeV" + } + } + }, + "subject": "central.space.proton_flux", + "captured_epoch": 1783196490 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc/0015.json b/work/tests/fixtures/swpc/0015.json new file mode 100644 index 0000000..456490a --- /dev/null +++ b/work/tests/fixtures/swpc/0015.json @@ -0,0 +1,36 @@ +{ + "envelope": { + "id": "2026-06-27T20:20:00Z|>=60 MeV", + "source": "central.echo6.co", + "type": "central.space.proton_flux.v1", + "time": "2026-06-27T20:20:00+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.proton_flux", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "2026-06-27T20:20:00Z|>=60 MeV", + "adapter": "swpc_protons", + "category": "space.proton_flux", + "time": "2026-06-27T20:20:00Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "time_tag": "2026-06-27T20:20:00Z", + "satellite": 18, + "flux": 0.19072958827018738, + "energy": ">=60 MeV" + } + } + }, + "subject": "central.space.proton_flux", + "captured_epoch": 1783196490 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc/0016.json b/work/tests/fixtures/swpc/0016.json new file mode 100644 index 0000000..5746114 --- /dev/null +++ b/work/tests/fixtures/swpc/0016.json @@ -0,0 +1,36 @@ +{ + "envelope": { + "id": "2026-06-27T20:25:00Z|>=1 MeV", + "source": "central.echo6.co", + "type": "central.space.proton_flux.v1", + "time": "2026-06-27T20:25:00+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.proton_flux", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "2026-06-27T20:25:00Z|>=1 MeV", + "adapter": "swpc_protons", + "category": "space.proton_flux", + "time": "2026-06-27T20:25:00Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "time_tag": "2026-06-27T20:25:00Z", + "satellite": 18, + "flux": 27.72285270690918, + "energy": ">=1 MeV" + } + } + }, + "subject": "central.space.proton_flux", + "captured_epoch": 1783196490 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc/0017.json b/work/tests/fixtures/swpc/0017.json new file mode 100644 index 0000000..340a81a --- /dev/null +++ b/work/tests/fixtures/swpc/0017.json @@ -0,0 +1,36 @@ +{ + "envelope": { + "id": "2026-06-27T20:25:00Z|>=10 MeV", + "source": "central.echo6.co", + "type": "central.space.proton_flux.v1", + "time": "2026-06-27T20:25:00+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.proton_flux", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "2026-06-27T20:25:00Z|>=10 MeV", + "adapter": "swpc_protons", + "category": "space.proton_flux", + "time": "2026-06-27T20:25:00Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "time_tag": "2026-06-27T20:25:00Z", + "satellite": 18, + "flux": 0.28919678926467896, + "energy": ">=10 MeV" + } + } + }, + "subject": "central.space.proton_flux", + "captured_epoch": 1783196490 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc/0018.json b/work/tests/fixtures/swpc/0018.json new file mode 100644 index 0000000..2d64ac3 --- /dev/null +++ b/work/tests/fixtures/swpc/0018.json @@ -0,0 +1,36 @@ +{ + "envelope": { + "id": "2026-06-27T20:25:00Z|>=100 MeV", + "source": "central.echo6.co", + "type": "central.space.proton_flux.v1", + "time": "2026-06-27T20:25:00+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.proton_flux", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "2026-06-27T20:25:00Z|>=100 MeV", + "adapter": "swpc_protons", + "category": "space.proton_flux", + "time": "2026-06-27T20:25:00Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "time_tag": "2026-06-27T20:25:00Z", + "satellite": 18, + "flux": 0.1840660572052002, + "energy": ">=100 MeV" + } + } + }, + "subject": "central.space.proton_flux", + "captured_epoch": 1783196490 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc/0019.json b/work/tests/fixtures/swpc/0019.json new file mode 100644 index 0000000..3e98340 --- /dev/null +++ b/work/tests/fixtures/swpc/0019.json @@ -0,0 +1,36 @@ +{ + "envelope": { + "id": "2026-06-27T20:25:00Z|>=30 MeV", + "source": "central.echo6.co", + "type": "central.space.proton_flux.v1", + "time": "2026-06-27T20:25:00+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.proton_flux", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "2026-06-27T20:25:00Z|>=30 MeV", + "adapter": "swpc_protons", + "category": "space.proton_flux", + "time": "2026-06-27T20:25:00Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "time_tag": "2026-06-27T20:25:00Z", + "satellite": 18, + "flux": 0.18692995607852936, + "energy": ">=30 MeV" + } + } + }, + "subject": "central.space.proton_flux", + "captured_epoch": 1783196490 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc/0020.json b/work/tests/fixtures/swpc/0020.json new file mode 100644 index 0000000..a9944aa --- /dev/null +++ b/work/tests/fixtures/swpc/0020.json @@ -0,0 +1,36 @@ +{ + "envelope": { + "id": "2026-06-27T20:25:00Z|>=5 MeV", + "source": "central.echo6.co", + "type": "central.space.proton_flux.v1", + "time": "2026-06-27T20:25:00+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.proton_flux", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "2026-06-27T20:25:00Z|>=5 MeV", + "adapter": "swpc_protons", + "category": "space.proton_flux", + "time": "2026-06-27T20:25:00Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "time_tag": "2026-06-27T20:25:00Z", + "satellite": 18, + "flux": 0.43121543526649475, + "energy": ">=5 MeV" + } + } + }, + "subject": "central.space.proton_flux", + "captured_epoch": 1783196490 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc/0021.json b/work/tests/fixtures/swpc/0021.json new file mode 100644 index 0000000..23fd2c0 --- /dev/null +++ b/work/tests/fixtures/swpc/0021.json @@ -0,0 +1,36 @@ +{ + "envelope": { + "id": "2026-06-27T20:25:00Z|>=50 MeV", + "source": "central.echo6.co", + "type": "central.space.proton_flux.v1", + "time": "2026-06-27T20:25:00+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.proton_flux", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "2026-06-27T20:25:00Z|>=50 MeV", + "adapter": "swpc_protons", + "category": "space.proton_flux", + "time": "2026-06-27T20:25:00Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "time_tag": "2026-06-27T20:25:00Z", + "satellite": 18, + "flux": 0.1857948750257492, + "energy": ">=50 MeV" + } + } + }, + "subject": "central.space.proton_flux", + "captured_epoch": 1783196490 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc/0022.json b/work/tests/fixtures/swpc/0022.json new file mode 100644 index 0000000..65960a1 --- /dev/null +++ b/work/tests/fixtures/swpc/0022.json @@ -0,0 +1,36 @@ +{ + "envelope": { + "id": "2026-06-27T20:25:00Z|>=500 MeV", + "source": "central.echo6.co", + "type": "central.space.proton_flux.v1", + "time": "2026-06-27T20:25:00+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.proton_flux", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "2026-06-27T20:25:00Z|>=500 MeV", + "adapter": "swpc_protons", + "category": "space.proton_flux", + "time": "2026-06-27T20:25:00Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "time_tag": "2026-06-27T20:25:00Z", + "satellite": 18, + "flux": 0.17474256455898285, + "energy": ">=500 MeV" + } + } + }, + "subject": "central.space.proton_flux", + "captured_epoch": 1783196490 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc/0023.json b/work/tests/fixtures/swpc/0023.json new file mode 100644 index 0000000..09f5cf7 --- /dev/null +++ b/work/tests/fixtures/swpc/0023.json @@ -0,0 +1,36 @@ +{ + "envelope": { + "id": "2026-06-27T20:25:00Z|>=60 MeV", + "source": "central.echo6.co", + "type": "central.space.proton_flux.v1", + "time": "2026-06-27T20:25:00+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.proton_flux", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "2026-06-27T20:25:00Z|>=60 MeV", + "adapter": "swpc_protons", + "category": "space.proton_flux", + "time": "2026-06-27T20:25:00Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "time_tag": "2026-06-27T20:25:00Z", + "satellite": 18, + "flux": 0.18532411754131317, + "energy": ">=60 MeV" + } + } + }, + "subject": "central.space.proton_flux", + "captured_epoch": 1783196490 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc/0024.json b/work/tests/fixtures/swpc/0024.json new file mode 100644 index 0000000..28e4db0 --- /dev/null +++ b/work/tests/fixtures/swpc/0024.json @@ -0,0 +1,36 @@ +{ + "envelope": { + "id": "2026-06-27T20:30:00Z|>=1 MeV", + "source": "central.echo6.co", + "type": "central.space.proton_flux.v1", + "time": "2026-06-27T20:30:00+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.proton_flux", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "2026-06-27T20:30:00Z|>=1 MeV", + "adapter": "swpc_protons", + "category": "space.proton_flux", + "time": "2026-06-27T20:30:00Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "time_tag": "2026-06-27T20:30:00Z", + "satellite": 18, + "flux": 27.166194915771484, + "energy": ">=1 MeV" + } + } + }, + "subject": "central.space.proton_flux", + "captured_epoch": 1783196490 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc/0025.json b/work/tests/fixtures/swpc/0025.json new file mode 100644 index 0000000..7003238 --- /dev/null +++ b/work/tests/fixtures/swpc/0025.json @@ -0,0 +1,36 @@ +{ + "envelope": { + "id": "2026-06-27T20:30:00Z|>=10 MeV", + "source": "central.echo6.co", + "type": "central.space.proton_flux.v1", + "time": "2026-06-27T20:30:00+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.proton_flux", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "2026-06-27T20:30:00Z|>=10 MeV", + "adapter": "swpc_protons", + "category": "space.proton_flux", + "time": "2026-06-27T20:30:00Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "time_tag": "2026-06-27T20:30:00Z", + "satellite": 18, + "flux": 0.3429813086986542, + "energy": ">=10 MeV" + } + } + }, + "subject": "central.space.proton_flux", + "captured_epoch": 1783196490 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc/0026.json b/work/tests/fixtures/swpc/0026.json new file mode 100644 index 0000000..04ffe71 --- /dev/null +++ b/work/tests/fixtures/swpc/0026.json @@ -0,0 +1,36 @@ +{ + "envelope": { + "id": "2026-06-27T20:30:00Z|>=100 MeV", + "source": "central.echo6.co", + "type": "central.space.proton_flux.v1", + "time": "2026-06-27T20:30:00+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.proton_flux", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "2026-06-27T20:30:00Z|>=100 MeV", + "adapter": "swpc_protons", + "category": "space.proton_flux", + "time": "2026-06-27T20:30:00Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "time_tag": "2026-06-27T20:30:00Z", + "satellite": 18, + "flux": 0.23083794116973877, + "energy": ">=100 MeV" + } + } + }, + "subject": "central.space.proton_flux", + "captured_epoch": 1783196490 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc/0027.json b/work/tests/fixtures/swpc/0027.json new file mode 100644 index 0000000..b5dd243 --- /dev/null +++ b/work/tests/fixtures/swpc/0027.json @@ -0,0 +1,36 @@ +{ + "envelope": { + "id": "2026-06-27T20:30:00Z|>=30 MeV", + "source": "central.echo6.co", + "type": "central.space.proton_flux.v1", + "time": "2026-06-27T20:30:00+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.proton_flux", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "2026-06-27T20:30:00Z|>=30 MeV", + "adapter": "swpc_protons", + "category": "space.proton_flux", + "time": "2026-06-27T20:30:00Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "time_tag": "2026-06-27T20:30:00Z", + "satellite": 18, + "flux": 0.23371955752372742, + "energy": ">=30 MeV" + } + } + }, + "subject": "central.space.proton_flux", + "captured_epoch": 1783196490 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc/0028.json b/work/tests/fixtures/swpc/0028.json new file mode 100644 index 0000000..4fe6e2a --- /dev/null +++ b/work/tests/fixtures/swpc/0028.json @@ -0,0 +1,36 @@ +{ + "envelope": { + "id": "2026-06-27T20:30:00Z|>=5 MeV", + "source": "central.echo6.co", + "type": "central.space.proton_flux.v1", + "time": "2026-06-27T20:30:00+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.proton_flux", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "2026-06-27T20:30:00Z|>=5 MeV", + "adapter": "swpc_protons", + "category": "space.proton_flux", + "time": "2026-06-27T20:30:00Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "time_tag": "2026-06-27T20:30:00Z", + "satellite": 18, + "flux": 0.39393070340156555, + "energy": ">=5 MeV" + } + } + }, + "subject": "central.space.proton_flux", + "captured_epoch": 1783196490 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc/0029.json b/work/tests/fixtures/swpc/0029.json new file mode 100644 index 0000000..4fce290 --- /dev/null +++ b/work/tests/fixtures/swpc/0029.json @@ -0,0 +1,36 @@ +{ + "envelope": { + "id": "2026-06-27T20:30:00Z|>=50 MeV", + "source": "central.echo6.co", + "type": "central.space.proton_flux.v1", + "time": "2026-06-27T20:30:00+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.proton_flux", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "2026-06-27T20:30:00Z|>=50 MeV", + "adapter": "swpc_protons", + "category": "space.proton_flux", + "time": "2026-06-27T20:30:00Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "time_tag": "2026-06-27T20:30:00Z", + "satellite": 18, + "flux": 0.23256810009479523, + "energy": ">=50 MeV" + } + } + }, + "subject": "central.space.proton_flux", + "captured_epoch": 1783196490 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc/0030.json b/work/tests/fixtures/swpc/0030.json new file mode 100644 index 0000000..1562943 --- /dev/null +++ b/work/tests/fixtures/swpc/0030.json @@ -0,0 +1,36 @@ +{ + "envelope": { + "id": "2026-06-27T20:30:00Z|>=500 MeV", + "source": "central.echo6.co", + "type": "central.space.proton_flux.v1", + "time": "2026-06-27T20:30:00+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.proton_flux", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "2026-06-27T20:30:00Z|>=500 MeV", + "adapter": "swpc_protons", + "category": "space.proton_flux", + "time": "2026-06-27T20:30:00Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "time_tag": "2026-06-27T20:30:00Z", + "satellite": 18, + "flux": 0.226764515042305, + "energy": ">=500 MeV" + } + } + }, + "subject": "central.space.proton_flux", + "captured_epoch": 1783196490 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc/0031.json b/work/tests/fixtures/swpc/0031.json new file mode 100644 index 0000000..2b1f5f9 --- /dev/null +++ b/work/tests/fixtures/swpc/0031.json @@ -0,0 +1,36 @@ +{ + "envelope": { + "id": "2026-06-27T20:30:00Z|>=60 MeV", + "source": "central.echo6.co", + "type": "central.space.proton_flux.v1", + "time": "2026-06-27T20:30:00+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.proton_flux", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "2026-06-27T20:30:00Z|>=60 MeV", + "adapter": "swpc_protons", + "category": "space.proton_flux", + "time": "2026-06-27T20:30:00Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "time_tag": "2026-06-27T20:30:00Z", + "satellite": 18, + "flux": 0.23209738731384277, + "energy": ">=60 MeV" + } + } + }, + "subject": "central.space.proton_flux", + "captured_epoch": 1783196490 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc/0032.json b/work/tests/fixtures/swpc/0032.json new file mode 100644 index 0000000..d5c7a56 --- /dev/null +++ b/work/tests/fixtures/swpc/0032.json @@ -0,0 +1,36 @@ +{ + "envelope": { + "id": "2026-06-27T20:35:00Z|>=1 MeV", + "source": "central.echo6.co", + "type": "central.space.proton_flux.v1", + "time": "2026-06-27T20:35:00+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.proton_flux", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "2026-06-27T20:35:00Z|>=1 MeV", + "adapter": "swpc_protons", + "category": "space.proton_flux", + "time": "2026-06-27T20:35:00Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "time_tag": "2026-06-27T20:35:00Z", + "satellite": 18, + "flux": 27.50701141357422, + "energy": ">=1 MeV" + } + } + }, + "subject": "central.space.proton_flux", + "captured_epoch": 1783196490 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc/0033.json b/work/tests/fixtures/swpc/0033.json new file mode 100644 index 0000000..63eaec2 --- /dev/null +++ b/work/tests/fixtures/swpc/0033.json @@ -0,0 +1,36 @@ +{ + "envelope": { + "id": "2026-06-27T20:35:00Z|>=10 MeV", + "source": "central.echo6.co", + "type": "central.space.proton_flux.v1", + "time": "2026-06-27T20:35:00+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.proton_flux", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "2026-06-27T20:35:00Z|>=10 MeV", + "adapter": "swpc_protons", + "category": "space.proton_flux", + "time": "2026-06-27T20:35:00Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "time_tag": "2026-06-27T20:35:00Z", + "satellite": 18, + "flux": 0.22531083226203918, + "energy": ">=10 MeV" + } + } + }, + "subject": "central.space.proton_flux", + "captured_epoch": 1783196490 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc/0034.json b/work/tests/fixtures/swpc/0034.json new file mode 100644 index 0000000..29b54fe --- /dev/null +++ b/work/tests/fixtures/swpc/0034.json @@ -0,0 +1,36 @@ +{ + "envelope": { + "id": "2026-06-27T20:35:00Z|>=100 MeV", + "source": "central.echo6.co", + "type": "central.space.proton_flux.v1", + "time": "2026-06-27T20:35:00+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.proton_flux", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "2026-06-27T20:35:00Z|>=100 MeV", + "adapter": "swpc_protons", + "category": "space.proton_flux", + "time": "2026-06-27T20:35:00Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "time_tag": "2026-06-27T20:35:00Z", + "satellite": 18, + "flux": 0.16947858035564423, + "energy": ">=100 MeV" + } + } + }, + "subject": "central.space.proton_flux", + "captured_epoch": 1783196490 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc/0035.json b/work/tests/fixtures/swpc/0035.json new file mode 100644 index 0000000..5ac9797 --- /dev/null +++ b/work/tests/fixtures/swpc/0035.json @@ -0,0 +1,36 @@ +{ + "envelope": { + "id": "2026-06-27T20:35:00Z|>=30 MeV", + "source": "central.echo6.co", + "type": "central.space.proton_flux.v1", + "time": "2026-06-27T20:35:00+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.proton_flux", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "2026-06-27T20:35:00Z|>=30 MeV", + "adapter": "swpc_protons", + "category": "space.proton_flux", + "time": "2026-06-27T20:35:00Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "time_tag": "2026-06-27T20:35:00Z", + "satellite": 18, + "flux": 0.17234553396701813, + "energy": ">=30 MeV" + } + } + }, + "subject": "central.space.proton_flux", + "captured_epoch": 1783196490 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc/0036.json b/work/tests/fixtures/swpc/0036.json new file mode 100644 index 0000000..6938f8e --- /dev/null +++ b/work/tests/fixtures/swpc/0036.json @@ -0,0 +1,36 @@ +{ + "envelope": { + "id": "2026-06-27T20:35:00Z|>=5 MeV", + "source": "central.echo6.co", + "type": "central.space.proton_flux.v1", + "time": "2026-06-27T20:35:00+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.proton_flux", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "2026-06-27T20:35:00Z|>=5 MeV", + "adapter": "swpc_protons", + "category": "space.proton_flux", + "time": "2026-06-27T20:35:00Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "time_tag": "2026-06-27T20:35:00Z", + "satellite": 18, + "flux": 0.22996729612350464, + "energy": ">=5 MeV" + } + } + }, + "subject": "central.space.proton_flux", + "captured_epoch": 1783196490 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc/0037.json b/work/tests/fixtures/swpc/0037.json new file mode 100644 index 0000000..eb2a216 --- /dev/null +++ b/work/tests/fixtures/swpc/0037.json @@ -0,0 +1,36 @@ +{ + "envelope": { + "id": "2026-06-27T20:35:00Z|>=50 MeV", + "source": "central.echo6.co", + "type": "central.space.proton_flux.v1", + "time": "2026-06-27T20:35:00+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.proton_flux", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "2026-06-27T20:35:00Z|>=50 MeV", + "adapter": "swpc_protons", + "category": "space.proton_flux", + "time": "2026-06-27T20:35:00Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "time_tag": "2026-06-27T20:35:00Z", + "satellite": 18, + "flux": 0.1712087243795395, + "energy": ">=50 MeV" + } + } + }, + "subject": "central.space.proton_flux", + "captured_epoch": 1783196490 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc/0038.json b/work/tests/fixtures/swpc/0038.json new file mode 100644 index 0000000..224ab9d --- /dev/null +++ b/work/tests/fixtures/swpc/0038.json @@ -0,0 +1,36 @@ +{ + "envelope": { + "id": "2026-06-27T20:35:00Z|>=500 MeV", + "source": "central.echo6.co", + "type": "central.space.proton_flux.v1", + "time": "2026-06-27T20:35:00+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.proton_flux", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "2026-06-27T20:35:00Z|>=500 MeV", + "adapter": "swpc_protons", + "category": "space.proton_flux", + "time": "2026-06-27T20:35:00Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "time_tag": "2026-06-27T20:35:00Z", + "satellite": 18, + "flux": 0.1654052734375, + "energy": ">=500 MeV" + } + } + }, + "subject": "central.space.proton_flux", + "captured_epoch": 1783196490 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc/0039.json b/work/tests/fixtures/swpc/0039.json new file mode 100644 index 0000000..3ad11c2 --- /dev/null +++ b/work/tests/fixtures/swpc/0039.json @@ -0,0 +1,36 @@ +{ + "envelope": { + "id": "2026-06-27T20:35:00Z|>=60 MeV", + "source": "central.echo6.co", + "type": "central.space.proton_flux.v1", + "time": "2026-06-27T20:35:00+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.proton_flux", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "2026-06-27T20:35:00Z|>=60 MeV", + "adapter": "swpc_protons", + "category": "space.proton_flux", + "time": "2026-06-27T20:35:00Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "time_tag": "2026-06-27T20:35:00Z", + "satellite": 18, + "flux": 0.17073801159858704, + "energy": ">=60 MeV" + } + } + }, + "subject": "central.space.proton_flux", + "captured_epoch": 1783196490 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc_last/0000.json b/work/tests/fixtures/swpc_last/0000.json new file mode 100644 index 0000000..ec360af --- /dev/null +++ b/work/tests/fixtures/swpc_last/0000.json @@ -0,0 +1,35 @@ +{ + "envelope": { + "id": "A20F|2026-06-28 10:41:25.723", + "source": "central.echo6.co", + "type": "central.space.alert.v1", + "time": "2026-06-28T10:41:25.723000+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.alert", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "A20F|2026-06-28 10:41:25.723", + "adapter": "swpc_alerts", + "category": "space.alert", + "time": "2026-06-28T10:41:25.723000Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "product_id": "A20F", + "issue_datetime": "2026-06-28 10:41:25.723", + "message": "Space Weather Message Code: WATA20\r\nSerial Number: 1114\r\nIssue Time: 2026 Jun 28 1041 UTC\r\n\r\nWATCH: Geomagnetic Storm Category G1 Predicted \nHighest Storm Level Predicted by Day:\nJun 28: None (Below G1) Jun 29: G1 (Minor) Jun 30: G1 (Minor) \nTHIS SUPERSEDES ANY/ALL PRIOR WATCHES IN EFFECT\nComment: \r\n\n\r\n\r\nNOAA Space Weather Scale descriptions can be found at\r\nwww.swpc.noaa.gov/noaa-scales-explanation\r\n\r\nPotential Impacts: Area of impact primarily poleward of 60 degrees Geomagnetic Latitude.\r\nInduced Currents - Weak power grid fluctuations can occur.\r\nSpacecraft - Minor impact on satellite operations possible.\r\nAurora - Aurora may be visible at high latitudes, i.e., northern tier of the U.S. such as northern Michigan and Maine." + } + } + }, + "subject": "central.space.alert.a20f", + "captured_epoch": 1783196500 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc_last/0001.json b/work/tests/fixtures/swpc_last/0001.json new file mode 100644 index 0000000..07cd061 --- /dev/null +++ b/work/tests/fixtures/swpc_last/0001.json @@ -0,0 +1,35 @@ +{ + "envelope": { + "id": "P11W|2026-06-30 16:36:36.953", + "source": "central.echo6.co", + "type": "central.space.alert.v1", + "time": "2026-06-30T16:36:36.953000+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.alert", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "P11W|2026-06-30 16:36:36.953", + "adapter": "swpc_alerts", + "category": "space.alert", + "time": "2026-06-30T16:36:36.953000Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "product_id": "P11W", + "issue_datetime": "2026-06-30 16:36:36.953", + "message": "Space Weather Message Code: WARPX1\r\nSerial Number: 627\r\nIssue Time: 2026 Jun 30 1636 UTC\r\n\r\nCANCEL WARNING: Proton 10MeV Integral Flux above 10pfu expected \nCancel Serial Number: 626\nOriginal Issue Time: 2026 Jun 30 1600 UTC\nConditions no longer justify warning.\r\n\nConditions no longer justify warning.NOAA Scale: S1 - Minor" + } + } + }, + "subject": "central.space.alert.p11w", + "captured_epoch": 1783196500 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc_last/0002.json b/work/tests/fixtures/swpc_last/0002.json new file mode 100644 index 0000000..b1ea30b --- /dev/null +++ b/work/tests/fixtures/swpc_last/0002.json @@ -0,0 +1,35 @@ +{ + "envelope": { + "id": "TIVA|2026-06-03 01:43:20.793", + "source": "central.echo6.co", + "type": "central.space.alert.v1", + "time": "2026-06-03T01:43:20.793000+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.alert", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "TIVA|2026-06-03 01:43:20.793", + "adapter": "swpc_alerts", + "category": "space.alert", + "time": "2026-06-03T01:43:20.793000Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "product_id": "TIVA", + "issue_datetime": "2026-06-03 01:43:20.793", + "message": "Space Weather Message Code: ALTTP4\r\nSerial Number: 710\r\nIssue Time: 2026 Jun 03 0143 UTC\r\n\r\nALERT: Type IV Radio Emission \nBegin Time: 2026 Jun 03 0122 UTC\nComment: \r\n\n" + } + } + }, + "subject": "central.space.alert.tiva", + "captured_epoch": 1783196500 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc_last/0003.json b/work/tests/fixtures/swpc_last/0003.json new file mode 100644 index 0000000..f62c570 --- /dev/null +++ b/work/tests/fixtures/swpc_last/0003.json @@ -0,0 +1,35 @@ +{ + "envelope": { + "id": "XX0S|2026-06-03 11:59:48.137", + "source": "central.echo6.co", + "type": "central.space.alert.v1", + "time": "2026-06-03T11:59:48.137000+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.alert", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "XX0S|2026-06-03 11:59:48.137", + "adapter": "swpc_alerts", + "category": "space.alert", + "time": "2026-06-03T11:59:48.137000Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "product_id": "XX0S", + "issue_datetime": "2026-06-03 11:59:48.137", + "message": "Space Weather Message Code: SUMX01\r\nSerial Number: 218\r\nIssue Time: 2026 Jun 03 1159 UTC\r\n\r\nSUMMARY: X-ray Event exceeded X1 \nBegin Time: 2026 Jun 03 1119 UTC\nMaximum Time: 2026 Jun 03 1128 UTC\nEnd Time: 2026 Jun 03 1135 UTC\nXray Class: X1.0\nOptical Class: \nLocation: N17W19\nNoaa Scale: R3 - Strong\nComment: GOES-18 outage so using GOES-19\n\r\n\nGOES-18 outage so using GOES-19NOAA Scale: R3 - Strong\r\n\r\nNOAA Space Weather Scale descriptions can be found at\r\nwww.swpc.noaa.gov/noaa-scales-explanation\r\n\r\nPotential Impacts: Area of impact consists of large portions of the sunlit side of Earth, strongest at the sub-solar point.\r\nRadio - Wide area blackout of HF (high frequency) radio communication for about an hour." + } + } + }, + "subject": "central.space.alert.xx0s", + "captured_epoch": 1783196500 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc_last/0004.json b/work/tests/fixtures/swpc_last/0004.json new file mode 100644 index 0000000..1cdb8d6 --- /dev/null +++ b/work/tests/fixtures/swpc_last/0004.json @@ -0,0 +1,35 @@ +{ + "envelope": { + "id": "A50F|2026-06-03 14:52:28.343", + "source": "central.echo6.co", + "type": "central.space.alert.v1", + "time": "2026-06-03T14:52:28.343000+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.alert", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "A50F|2026-06-03 14:52:28.343", + "adapter": "swpc_alerts", + "category": "space.alert", + "time": "2026-06-03T14:52:28.343000Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "product_id": "A50F", + "issue_datetime": "2026-06-03 14:52:28.343", + "message": "Space Weather Message Code: WATA50\r\nSerial Number: 98\r\nIssue Time: 2026 Jun 03 1452 UTC\r\n\r\nWATCH: Geomagnetic Storm Category G3 Predicted \nHighest Storm Level Predicted by Day:\nJun 04: G3 (Strong) Jun 05: G3 (Strong) Jun 06: None (Below G1) \nTHIS SUPERSEDES ANY/ALL PRIOR WATCHES IN EFFECT\nComment: \r\n\n" + } + } + }, + "subject": "central.space.alert.a50f", + "captured_epoch": 1783196500 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc_last/0005.json b/work/tests/fixtures/swpc_last/0005.json new file mode 100644 index 0000000..5f9b1b0 --- /dev/null +++ b/work/tests/fixtures/swpc_last/0005.json @@ -0,0 +1,35 @@ +{ + "envelope": { + "id": "MSIS|2026-06-05 05:13:38.727", + "source": "central.echo6.co", + "type": "central.space.alert.v1", + "time": "2026-06-05T05:13:38.727000+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.alert", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "MSIS|2026-06-05 05:13:38.727", + "adapter": "swpc_alerts", + "category": "space.alert", + "time": "2026-06-05T05:13:38.727000Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "product_id": "MSIS", + "issue_datetime": "2026-06-05 05:13:38.727", + "message": "Space Weather Message Code: SUMSUD\r\nSerial Number: 300\r\nIssue Time: 2026 Jun 05 0513 UTC\r\n\r\nSUMMARY: Geomagnetic Sudden Impulse \nObserved: 2026 Jun 05 0511 UTC\nDeviation: 70 nT\nStation: MEA\nComment: \r\n\n" + } + } + }, + "subject": "central.space.alert.msis", + "captured_epoch": 1783196500 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc_last/0006.json b/work/tests/fixtures/swpc_last/0006.json new file mode 100644 index 0000000..a64355e --- /dev/null +++ b/work/tests/fixtures/swpc_last/0006.json @@ -0,0 +1,35 @@ +{ + "envelope": { + "id": "SGIW|2026-07-03 11:38:06.157", + "source": "central.echo6.co", + "type": "central.space.alert.v1", + "time": "2026-07-03T11:38:06.157000+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.alert", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "SGIW|2026-07-03 11:38:06.157", + "adapter": "swpc_alerts", + "category": "space.alert", + "time": "2026-07-03T11:38:06.157000Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "product_id": "SGIW", + "issue_datetime": "2026-07-03 11:38:06.157", + "message": "Space Weather Message Code: WARSUD\r\nSerial Number: 256\r\nIssue Time: 2026 Jul 03 1138 UTC\r\n\r\nWARNING: Geomagnetic Sudden Impulse expected \nValid From: 2026 Jul 03 1157 UTC\nValid To: 2026 Jul 03 1227 UTC\nIp Shock: 2026-07-03 11:20\nComment: \r\n\n" + } + } + }, + "subject": "central.space.alert.sgiw", + "captured_epoch": 1783196500 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc_last/0007.json b/work/tests/fixtures/swpc_last/0007.json new file mode 100644 index 0000000..f164f6e --- /dev/null +++ b/work/tests/fixtures/swpc_last/0007.json @@ -0,0 +1,35 @@ +{ + "envelope": { + "id": "XM5A|2026-07-03 19:00:40.987", + "source": "central.echo6.co", + "type": "central.space.alert.v1", + "time": "2026-07-03T19:00:40.987000+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.alert", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "XM5A|2026-07-03 19:00:40.987", + "adapter": "swpc_alerts", + "category": "space.alert", + "time": "2026-07-03T19:00:40.987000Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "product_id": "XM5A", + "issue_datetime": "2026-07-03 19:00:40.987", + "message": "Space Weather Message Code: ALTXMF\r\nSerial Number: 537\r\nIssue Time: 2026 Jul 03 1900 UTC\r\n\r\nALERT: X-Ray Flux exceeded M5 \nThreshold Reached: 2026 Jul 03 1856 UTC\nNoaa Scale: R2 - Moderate\nComment: \r\n\nNOAA Scale: R2 - Moderate\r\n\r\nNOAA Space Weather Scale descriptions can be found at\r\nwww.swpc.noaa.gov/noaa-scales-explanation\r\n\r\nPotential Impacts: Area of impact centered on sub-solar point on the sunlit side of Earth. Extent of blackout of HF (high frequency) radio communication dependent upon current X-ray Flux intensity. For real-time information on affected area and expected duration please see http://www.swpc.noaa.gov/products/d-region-absorption-predictions-d-rap." + } + } + }, + "subject": "central.space.alert.xm5a", + "captured_epoch": 1783196500 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc_last/0008.json b/work/tests/fixtures/swpc_last/0008.json new file mode 100644 index 0000000..0670264 --- /dev/null +++ b/work/tests/fixtures/swpc_last/0008.json @@ -0,0 +1,35 @@ +{ + "envelope": { + "id": "XM5S|2026-07-03 19:11:28.970", + "source": "central.echo6.co", + "type": "central.space.alert.v1", + "time": "2026-07-03T19:11:28.970000+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.alert", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "XM5S|2026-07-03 19:11:28.970", + "adapter": "swpc_alerts", + "category": "space.alert", + "time": "2026-07-03T19:11:28.970000Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "product_id": "XM5S", + "issue_datetime": "2026-07-03 19:11:28.970", + "message": "Space Weather Message Code: SUMXM5\r\nSerial Number: 323\r\nIssue Time: 2026 Jul 03 1911 UTC\r\n\r\nSUMMARY: X-ray Event exceeded M5 \nBegin Time: 2026 Jul 03 1857 UTC\nMaximum Time: 2026 Jul 03 1859 UTC\nEnd Time: 2026 Jul 04 1903 UTC\nXray Class: M6.3\nOptical Class: \nLocation: S06W46\nNoaa Scale: R2 - Moderate\nComment: \r\n\nNOAA Scale: R2 - Moderate\r\n\r\nNOAA Space Weather Scale descriptions can be found at\r\nwww.swpc.noaa.gov/noaa-scales-explanation\r\n\r\nPotential Impacts: Area of impact centered primarily on sub-solar point on the sunlit side of Earth.\r\nRadio - Limited blackout of HF (high frequency) radio communication for tens of minutes." + } + } + }, + "subject": "central.space.alert.xm5s", + "captured_epoch": 1783196500 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc_last/0009.json b/work/tests/fixtures/swpc_last/0009.json new file mode 100644 index 0000000..5cf7c38 --- /dev/null +++ b/work/tests/fixtures/swpc_last/0009.json @@ -0,0 +1,35 @@ +{ + "envelope": { + "id": "A30F|2026-06-05 18:52:32.167", + "source": "central.echo6.co", + "type": "central.space.alert.v1", + "time": "2026-06-05T18:52:32.167000+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.alert", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "A30F|2026-06-05 18:52:32.167", + "adapter": "swpc_alerts", + "category": "space.alert", + "time": "2026-06-05T18:52:32.167000Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "product_id": "A30F", + "issue_datetime": "2026-06-05 18:52:32.167", + "message": "Space Weather Message Code: WATA30\r\nSerial Number: 274\r\nIssue Time: 2026 Jun 05 1852 UTC\r\n\r\nWATCH: Geomagnetic Storm Category G2 Predicted \nHighest Storm Level Predicted by Day:\nJun 06: G2 (Moderate) Jun 07: None (Below G1) Jun 08: None (Below G1) \nTHIS SUPERSEDES ANY/ALL PRIOR WATCHES IN EFFECT\nComment: \r\n\n\r\n\r\nNOAA Space Weather Scale descriptions can be found at\r\nwww.swpc.noaa.gov/noaa-scales-explanation\r\n\r\nPotential Impacts: Area of impact primarily poleward of 55 degrees Geomagnetic Latitude.\r\nInduced Currents - Power grid fluctuations can occur. High-latitude power systems may experience voltage alarms.\r\nSpacecraft - Satellite orientation irregularities may occur; increased drag on low Earth-orbit satellites is possible.\r\nRadio - HF (high frequency) radio propagation can fade at higher latitudes.\r\nAurora - Aurora may be seen as low as New York to Wisconsin to Washington state." + } + } + }, + "subject": "central.space.alert.a30f", + "captured_epoch": 1783196500 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc_last/0010.json b/work/tests/fixtures/swpc_last/0010.json new file mode 100644 index 0000000..35b0488 --- /dev/null +++ b/work/tests/fixtures/swpc_last/0010.json @@ -0,0 +1,35 @@ +{ + "envelope": { + "id": "K04A|2026-07-03 20:54:38.663", + "source": "central.echo6.co", + "type": "central.space.alert.v1", + "time": "2026-07-03T20:54:38.663000+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.alert", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "K04A|2026-07-03 20:54:38.663", + "adapter": "swpc_alerts", + "category": "space.alert", + "time": "2026-07-03T20:54:38.663000Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "product_id": "K04A", + "issue_datetime": "2026-07-03 20:54:38.663", + "message": "Space Weather Message Code: ALTK04\r\nSerial Number: 2670\r\nIssue Time: 2026 Jul 03 2054 UTC\r\n\r\nALERT: Geomagnetic K-index of 4 \nThreshold Reached: 2026 Jul 03 2049 UTC\nSynoptic Period: 1800-2100\nActive Warning: YES\nComment: \r\n\n\r\n\r\nNOAA Space Weather Scale descriptions can be found at\r\nwww.swpc.noaa.gov/noaa-scales-explanation\r\n\r\nPotential Impacts: Area of impact primarily poleward of 65 degrees Geomagnetic Latitude.\r\nInduced Currents - Weak power grid fluctuations can occur.\r\nAurora - Aurora may be visible at high latitudes such as Canada and Alaska." + } + } + }, + "subject": "central.space.alert.k04a", + "captured_epoch": 1783196500 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc_last/0011.json b/work/tests/fixtures/swpc_last/0011.json new file mode 100644 index 0000000..0b190b6 --- /dev/null +++ b/work/tests/fixtures/swpc_last/0011.json @@ -0,0 +1,35 @@ +{ + "envelope": { + "id": "K07W|2026-07-04 05:01:32.633", + "source": "central.echo6.co", + "type": "central.space.alert.v1", + "time": "2026-07-04T05:01:32.633000+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.alert", + "centralseverity": 3, + "specversion": "1.0", + "data": { + "id": "K07W|2026-07-04 05:01:32.633", + "adapter": "swpc_alerts", + "category": "space.alert", + "time": "2026-07-04T05:01:32.633000Z", + "expires": null, + "severity": 3, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "product_id": "K07W", + "issue_datetime": "2026-07-04 05:01:32.633", + "message": "Space Weather Message Code: WARK07\r\nSerial Number: 151\r\nIssue Time: 2026 Jul 04 0501 UTC\r\n\r\nWARNING: Geomagnetic K-index of 7 or greater expected \nValid From: 2026 Jul 04 0500 UTC\nValid To: 2026 Jul 05 1200 UTC\nWarning Conditions: Onset\nNoaa Scale: G3 - Greater\nComment: \r\n\nNOAA Scale: G3 - Greater" + } + } + }, + "subject": "central.space.alert.k07w", + "captured_epoch": 1783196500 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc_last/0012.json b/work/tests/fixtures/swpc_last/0012.json new file mode 100644 index 0000000..773b442 --- /dev/null +++ b/work/tests/fixtures/swpc_last/0012.json @@ -0,0 +1,35 @@ +{ + "envelope": { + "id": "K07A|2026-07-04 05:10:10.740", + "source": "central.echo6.co", + "type": "central.space.alert.v1", + "time": "2026-07-04T05:10:10.740000+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.alert", + "centralseverity": 3, + "specversion": "1.0", + "data": { + "id": "K07A|2026-07-04 05:10:10.740", + "adapter": "swpc_alerts", + "category": "space.alert", + "time": "2026-07-04T05:10:10.740000Z", + "expires": null, + "severity": 3, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "product_id": "K07A", + "issue_datetime": "2026-07-04 05:10:10.740", + "message": "Space Weather Message Code: ALTK07\r\nSerial Number: 218\r\nIssue Time: 2026 Jul 04 0509 UTC\r\n\r\nALERT: Geomagnetic K-index of 7 \nThreshold Reached: 2026 Jul 04 0509 UTC\nSynoptic Period: 0300-0600\nActive Warning: YES\nNoaa Scale: G3 - Strong\nComment: \r\n\nNOAA Scale: G3 - Strong" + } + } + }, + "subject": "central.space.alert.k07a", + "captured_epoch": 1783196500 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc_last/0013.json b/work/tests/fixtures/swpc_last/0013.json new file mode 100644 index 0000000..5e92bef --- /dev/null +++ b/work/tests/fixtures/swpc_last/0013.json @@ -0,0 +1,35 @@ +{ + "envelope": { + "id": "K06W|2026-07-04 13:57:38.983", + "source": "central.echo6.co", + "type": "central.space.alert.v1", + "time": "2026-07-04T13:57:38.983000+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.alert", + "centralseverity": 2, + "specversion": "1.0", + "data": { + "id": "K06W|2026-07-04 13:57:38.983", + "adapter": "swpc_alerts", + "category": "space.alert", + "time": "2026-07-04T13:57:38.983000Z", + "expires": null, + "severity": 2, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "product_id": "K06W", + "issue_datetime": "2026-07-04 13:57:38.983", + "message": "Space Weather Message Code: WARK06\r\nSerial Number: 665\r\nIssue Time: 2026 Jul 04 1357 UTC\r\n\r\nWARNING: Geomagnetic K-index of 6 expected \nValid From: 2026 Jul 04 1356 UTC\nValid To: 2026 Jul 05 2100 UTC\nWarning Conditions: Onset\nNoaa Scale: G2 - Moderate\nComment: \r\n\nNOAA Scale: G2 - Moderate\r\n\r\nNOAA Space Weather Scale descriptions can be found at\r\nwww.swpc.noaa.gov/noaa-scales-explanation\r\n\r\nPotential Impacts: Area of impact primarily poleward of 55 degrees Geomagnetic Latitude.\r\nInduced Currents - Power grid fluctuations can occur. High-latitude power systems may experience voltage alarms.\r\nSpacecraft - Satellite orientation irregularities may occur; increased drag on low Earth-orbit satellites is possible.\r\nRadio - HF (high frequency) radio propagation can fade at higher latitudes.\r\nAurora - Aurora may be seen as low as New York to Wisconsin to Washington state." + } + } + }, + "subject": "central.space.alert.k06w", + "captured_epoch": 1783196500 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc_last/0014.json b/work/tests/fixtures/swpc_last/0014.json new file mode 100644 index 0000000..4445f7b --- /dev/null +++ b/work/tests/fixtures/swpc_last/0014.json @@ -0,0 +1,35 @@ +{ + "envelope": { + "id": "K05W|2026-07-04 14:12:48.350", + "source": "central.echo6.co", + "type": "central.space.alert.v1", + "time": "2026-07-04T14:12:48.350000+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.alert", + "centralseverity": 1, + "specversion": "1.0", + "data": { + "id": "K05W|2026-07-04 14:12:48.350", + "adapter": "swpc_alerts", + "category": "space.alert", + "time": "2026-07-04T14:12:48.350000Z", + "expires": null, + "severity": 1, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "product_id": "K05W", + "issue_datetime": "2026-07-04 14:12:48.350", + "message": "Space Weather Message Code: WARK05\r\nSerial Number: 2248\r\nIssue Time: 2026 Jul 04 1412 UTC\r\n\r\nEXTENDED WARNING: Geomagnetic K-index of 5 expected\nExtension to Serial Number: 2247\nValid From: 2026 Jul 04 0100 UTC\nNow Valid Until: 2026 Jul 04 2359 UTC\nWarning Condition: Persistence\n\r\n\nNOAA Scale: G1 - Minor\r\n\r\nNOAA Space Weather Scale descriptions can be found at\r\nwww.swpc.noaa.gov/noaa-scales-explanation\r\n\r\nPotential Impacts: Area of impact primarily poleward of 60 degrees Geomagnetic Latitude.\r\nInduced Currents - Weak power grid fluctuations can occur.\r\nSpacecraft - Minor impact on satellite operations possible.\r\nAurora - Aurora may be visible at high latitudes, i.e., northern tier of the U.S. such as northern Michigan and Maine." + } + } + }, + "subject": "central.space.alert.k05w", + "captured_epoch": 1783196500 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc_last/0015.json b/work/tests/fixtures/swpc_last/0015.json new file mode 100644 index 0000000..2a7e2cf --- /dev/null +++ b/work/tests/fixtures/swpc_last/0015.json @@ -0,0 +1,35 @@ +{ + "envelope": { + "id": "K04W|2026-07-04 14:21:41.873", + "source": "central.echo6.co", + "type": "central.space.alert.v1", + "time": "2026-07-04T14:21:41.873000+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.alert", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "K04W|2026-07-04 14:21:41.873", + "adapter": "swpc_alerts", + "category": "space.alert", + "time": "2026-07-04T14:21:41.873000Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "product_id": "K04W", + "issue_datetime": "2026-07-04 14:21:41.873", + "message": "Space Weather Message Code: WARK04\r\nSerial Number: 5377\r\nIssue Time: 2026 Jul 04 1421 UTC\r\n\r\nEXTENDED WARNING: Geomagnetic K-index of 4 expected\nExtension to Serial Number: 5376\nValid From: 2026 Jul 03 1209 UTC\nNow Valid Until: 2026 Jul 05 0300 UTC\nWarning Condition: Persistence\n\r\n\n\r\n\r\nNOAA Space Weather Scale descriptions can be found at\r\nwww.swpc.noaa.gov/noaa-scales-explanation\r\n\r\nPotential Impacts: Area of impact primarily poleward of 65 degrees Geomagnetic Latitude.\r\nInduced Currents - Weak power grid fluctuations can occur.\r\nAurora - Aurora may be visible at high latitudes such as Canada and Alaska." + } + } + }, + "subject": "central.space.alert.k04w", + "captured_epoch": 1783196500 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc_last/0016.json b/work/tests/fixtures/swpc_last/0016.json new file mode 100644 index 0000000..dcb4f23 --- /dev/null +++ b/work/tests/fixtures/swpc_last/0016.json @@ -0,0 +1,35 @@ +{ + "envelope": { + "id": "BHIS|2026-06-06 14:14:05.560", + "source": "central.echo6.co", + "type": "central.space.alert.v1", + "time": "2026-06-06T14:14:05.560000+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.alert", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "BHIS|2026-06-06 14:14:05.560", + "adapter": "swpc_alerts", + "category": "space.alert", + "time": "2026-06-06T14:14:05.560000Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "product_id": "BHIS", + "issue_datetime": "2026-06-06 14:14:05.560", + "message": "Space Weather Message Code: SUM10R\r\nSerial Number: 918\r\nIssue Time: 2026 Jun 06 1414 UTC\r\n\r\nSUMMARY: 10cm Radio Burst \nBegin Time: 2026 Jun 06 1344 UTC\nMaximum Time: 2026 Jun 06 1344 UTC\nEnd Time: 2026 Jun 06 1359 UTC\nPeak Flux: 190 sfu\nDuration: 5 minutes\nLatest Penticton Noon Flux: 141 sfu\nComment: \r\n\n" + } + } + }, + "subject": "central.space.alert.bhis", + "captured_epoch": 1783196500 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc_last/0017.json b/work/tests/fixtures/swpc_last/0017.json new file mode 100644 index 0000000..b93af29 --- /dev/null +++ b/work/tests/fixtures/swpc_last/0017.json @@ -0,0 +1,35 @@ +{ + "envelope": { + "id": "TIIA|2026-06-06 14:15:12.373", + "source": "central.echo6.co", + "type": "central.space.alert.v1", + "time": "2026-06-06T14:15:12.373000+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.alert", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "TIIA|2026-06-06 14:15:12.373", + "adapter": "swpc_alerts", + "category": "space.alert", + "time": "2026-06-06T14:15:12.373000Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "product_id": "TIIA", + "issue_datetime": "2026-06-06 14:15:12.373", + "message": "Space Weather Message Code: ALTTP2\r\nSerial Number: 1498\r\nIssue Time: 2026 Jun 06 1415 UTC\r\n\r\nALERT: Type II Radio Emission \nBegin Time: 2026 Jun 06 1347 UTC\nEstimate Velocity: 838 km/s\nComment: \r\n\n" + } + } + }, + "subject": "central.space.alert.tiia", + "captured_epoch": 1783196500 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc_last/0018.json b/work/tests/fixtures/swpc_last/0018.json new file mode 100644 index 0000000..28b1401 --- /dev/null +++ b/work/tests/fixtures/swpc_last/0018.json @@ -0,0 +1,35 @@ +{ + "envelope": { + "id": "K05A|2026-07-04 16:14:30.417", + "source": "central.echo6.co", + "type": "central.space.alert.v1", + "time": "2026-07-04T16:14:30.417000+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.alert", + "centralseverity": 1, + "specversion": "1.0", + "data": { + "id": "K05A|2026-07-04 16:14:30.417", + "adapter": "swpc_alerts", + "category": "space.alert", + "time": "2026-07-04T16:14:30.417000Z", + "expires": null, + "severity": 1, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "product_id": "K05A", + "issue_datetime": "2026-07-04 16:14:30.417", + "message": "Space Weather Message Code: ALTK05\r\nSerial Number: 2036\r\nIssue Time: 2026 Jul 04 1614 UTC\r\n\r\nALERT: Geomagnetic K-index of 5 \nThreshold Reached: 2026 Jul 04 1610 UTC\nSynoptic Period: 1500-1800\nActive Warning: YES\nNoaa Scale: G1 - Minor\nComment: \r\n\nNOAA Scale: G1 - Minor\r\n\r\nNOAA Space Weather Scale descriptions can be found at\r\nwww.swpc.noaa.gov/noaa-scales-explanation\r\n\r\nPotential Impacts: Area of impact primarily poleward of 60 degrees Geomagnetic Latitude.\r\nInduced Currents - Weak power grid fluctuations can occur.\r\nSpacecraft - Minor impact on satellite operations possible.\r\nAurora - Aurora may be visible at high latitudes, i.e., northern tier of the U.S. such as northern Michigan and Maine." + } + } + }, + "subject": "central.space.alert.k05a", + "captured_epoch": 1783196500 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc_last/0019.json b/work/tests/fixtures/swpc_last/0019.json new file mode 100644 index 0000000..a641c62 --- /dev/null +++ b/work/tests/fixtures/swpc_last/0019.json @@ -0,0 +1,35 @@ +{ + "envelope": { + "id": "K06A|2026-07-04 17:00:15.597", + "source": "central.echo6.co", + "type": "central.space.alert.v1", + "time": "2026-07-04T17:00:15.597000+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.alert", + "centralseverity": 2, + "specversion": "1.0", + "data": { + "id": "K06A|2026-07-04 17:00:15.597", + "adapter": "swpc_alerts", + "category": "space.alert", + "time": "2026-07-04T17:00:15.597000Z", + "expires": null, + "severity": 2, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "product_id": "K06A", + "issue_datetime": "2026-07-04 17:00:15.597", + "message": "Space Weather Message Code: ALTK06\r\nSerial Number: 723\r\nIssue Time: 2026 Jul 04 1700 UTC\r\n\r\nALERT: Geomagnetic K-index of 6 \nThreshold Reached: 2026 Jul 04 1655 UTC\nSynoptic Period: 1500-1800\nActive Warning: YES\nNoaa Scale: G2 - Moderate\nComment: \r\n\nNOAA Scale: G2 - Moderate\r\n\r\nNOAA Space Weather Scale descriptions can be found at\r\nwww.swpc.noaa.gov/noaa-scales-explanation\r\n\r\nPotential Impacts: Area of impact primarily poleward of 55 degrees Geomagnetic Latitude.\r\nInduced Currents - Power grid fluctuations can occur. High-latitude power systems may experience voltage alarms.\r\nSpacecraft - Satellite orientation irregularities may occur; increased drag on low Earth-orbit satellites is possible.\r\nRadio - HF (high frequency) radio propagation can fade at higher latitudes.\r\nAurora - Aurora may be seen as low as New York to Wisconsin to Washington state." + } + } + }, + "subject": "central.space.alert.k06a", + "captured_epoch": 1783196500 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc_last/0020.json b/work/tests/fixtures/swpc_last/0020.json new file mode 100644 index 0000000..6f63393 --- /dev/null +++ b/work/tests/fixtures/swpc_last/0020.json @@ -0,0 +1,35 @@ +{ + "envelope": { + "id": "EF3A|2026-06-06 16:55:22.263", + "source": "central.echo6.co", + "type": "central.space.alert.v1", + "time": "2026-06-06T16:55:22.263000+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.alert", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "EF3A|2026-06-06 16:55:22.263", + "adapter": "swpc_alerts", + "category": "space.alert", + "time": "2026-06-06T16:55:22.263000Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "product_id": "EF3A", + "issue_datetime": "2026-06-06 16:55:22.263", + "message": "Space Weather Message Code: ALTEF3\r\nSerial Number: 3695\r\nIssue Time: 2026 Jun 06 1655 UTC\r\n\r\nALERT: Electron 2MeV Integral Flux exceeded 1000pfu \nThreshold Reached: 2026 Jun 06 1640 UTC\nStation: GOES-19\nComment: Yesterday's max: 536 pfu\n\r\n\nYesterday's max: 536 pfu" + } + } + }, + "subject": "central.space.alert.ef3a", + "captured_epoch": 1783196500 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc_last/0021.json b/work/tests/fixtures/swpc_last/0021.json new file mode 100644 index 0000000..ad34efa --- /dev/null +++ b/work/tests/fixtures/swpc_last/0021.json @@ -0,0 +1,36 @@ +{ + "envelope": { + "id": "2026-07-04T15:00:00", + "source": "central.echo6.co", + "type": "central.space.kindex.v1", + "time": "2026-07-04T15:00:00+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.kindex", + "centralseverity": 1, + "specversion": "1.0", + "data": { + "id": "2026-07-04T15:00:00", + "adapter": "swpc_kindex", + "category": "space.kindex", + "time": "2026-07-04T15:00:00Z", + "expires": null, + "severity": 1, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "time_tag": "2026-07-04T15:00:00", + "Kp": 5.67, + "a_running": 67, + "station_count": 7 + } + } + }, + "subject": "central.space.kindex", + "captured_epoch": 1783196500 +} \ No newline at end of file diff --git a/work/tests/fixtures/swpc_last/0022.json b/work/tests/fixtures/swpc_last/0022.json new file mode 100644 index 0000000..976f6b8 --- /dev/null +++ b/work/tests/fixtures/swpc_last/0022.json @@ -0,0 +1,36 @@ +{ + "envelope": { + "id": "2026-07-04T20:10:00Z|>=60 MeV", + "source": "central.echo6.co", + "type": "central.space.proton_flux.v1", + "time": "2026-07-04T20:10:00+00:00", + "datacontenttype": "application/json", + "centralschemaversion": "1.0", + "centralcategory": "space.proton_flux", + "centralseverity": 0, + "specversion": "1.0", + "data": { + "id": "2026-07-04T20:10:00Z|>=60 MeV", + "adapter": "swpc_protons", + "category": "space.proton_flux", + "time": "2026-07-04T20:10:00Z", + "expires": null, + "severity": 0, + "geo": { + "centroid": null, + "bbox": null, + "regions": [], + "primary_region": null, + "geometry": null + }, + "data": { + "time_tag": "2026-07-04T20:10:00Z", + "satellite": 18, + "flux": 0.16377367079257965, + "energy": ">=60 MeV" + } + } + }, + "subject": "central.space.proton_flux", + "captured_epoch": 1783196500 +} \ No newline at end of file diff --git a/work/tests/test_avalanche_refactor.py b/work/tests/test_avalanche_refactor.py new file mode 100644 index 0000000..73e190a --- /dev/null +++ b/work/tests/test_avalanche_refactor.py @@ -0,0 +1,768 @@ +"""Phase-1 avalanche refactor tests — formatter+decider architecture. + +Four test groups: + +1. Parity (tier-b): formatter renders from canonical data. + Expected strings are hand-written (the new correct format). + OLD _render() output for the same fixture is captured in comments so the + intended tier-b diff is explicit and reviewable. + The centralseverity=2 (Considerable) case is OLD-vs-NEW identical. + The is_update=True case shows the Update: prefix diff. + +2. Cross-source identity: native AvalancheAdapter.to_event() builds the same + canonical data as the Central path for fixture 0000. Both render + byte-identically via the registered formatter. + +3. Gate-sequence: replay canonical data through gating.avalanche.decide(). + Verify danger-level gate (below / at / above threshold) and first→update + trend lifecycle labels. + +4. Schema-conformance: env/avalanche.py to_event() emits all canonical keys; + _meshai_precomposed is NOT set. +""" +from __future__ import annotations + +import pytest + +from tests.harness.goldens import ( + assert_byte_identical, + load_fixtures, + pinned_time, +) + +# ── Shared clock epoch for deterministic renders ───────────────────────────── +_AT = 1_783_200_000.0 # 2026-07-03T00:00:00Z (pinned, same as quake tests) + + +# ───────────────────────────────────────────────────────────────────────────── +# Helpers +# ───────────────────────────────────────────────────────────────────────────── + +def _make_fake_event(data: dict): + """Minimal fake Event for calling the formatter without the full pipeline.""" + class _FakeEvent: + pass + e = _FakeEvent() + e.data = data + return e + + +def _canonical_from_fixture(fixture: dict, *, is_update: bool = False) -> dict: + """Extract canonical data dict from a Central avalanche envelope fixture.""" + inner = fixture["envelope"]["data"] + d = inner.get("data") or {} + geo = inner.get("geo") or {} + centroid = geo.get("centroid") or [] + lon, lat = (centroid[0], centroid[1]) if len(centroid) >= 2 else (None, None) + return { + "danger_level": d.get("danger_level"), + "danger_name": d.get("danger_name"), + "zone_name": d.get("zone_name"), + "center_id": d.get("center_id"), + "travel_advice": d.get("travel_advice"), + "lat": lat, + "lon": lon, + "is_update": is_update, + } + + +def _avy_render_old(*, danger_level: int, danger_name: str, zone_name: str, + center_id: str, travel: str) -> str: + """Capture OLD _render() output from avy_handler for diff comments.""" + from meshai.central.avy_handler import _render + return _render( + danger_level=danger_level, danger_name=danger_name, + zone_name=zone_name, center_id=center_id, travel=travel, + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# 1. Parity (tier-b) — formatter renders from canonical data +# ───────────────────────────────────────────────────────────────────────────── + +class TestFormatterParity: + """formatters/avalanche.format() renders correct output from canonical data.""" + + def test_fixture_0000_considerable_new_format(self): + """Fixture 0000 (Considerable, NAADS 3) → formatter output matches expected. + + centralseverity=2 maps to NAADS 3 (Considerable). + min_danger_level=3 → broadcast. + is_update=False → "Watch:" prefix. + + OLD _render() output: + '⛷ AVY Watch: Sawtooth Mountains — Considerable (3) + Dangerous conditions on steep slopes. + SNFAC · valid today' + NEW formatter output (identical — no tier-b diff for non-update case): + '⛷ AVY Watch: Sawtooth Mountains — Considerable (3) + Dangerous conditions on steep slopes. + SNFAC · valid today' + """ + from meshai.notifications.formatters.avalanche import format as avyfmt + + fixtures = load_fixtures("avalanche") + fx = next(f for f in fixtures + if "considerable" in f["envelope"]["id"] and "update" not in f["envelope"]["id"]) + canonical = _canonical_from_fixture(fx) + + expected = ( + "⛷ AVY Watch: Sawtooth Mountains — Considerable (3)" + "\nDangerous conditions on steep slopes." + "\nSNFAC · valid today" + ) + + with pinned_time(_AT): + result = avyfmt(_make_fake_event(canonical), now=_AT, budget=140) + + assert_byte_identical(result, expected) + + # Confirm old _render() is identical for this case (no tier-b diff) + old_wire = _avy_render_old( + danger_level=3, danger_name="Considerable", + zone_name="Sawtooth Mountains", center_id="SNFAC", + travel="Dangerous conditions on steep slopes. Conservative decision-making is advised.", + ) + assert_byte_identical(result, old_wire), ( + "For fixture 0000 (Considerable, is_update=False) old and new " + "outputs must be identical — tier-b diff only appears for is_update=True." + ) + + def test_fixture_0001_high_warning_prefix(self): + """Fixture 0001 (High, NAADS 4) → formatter uses 'WARNING:' prefix. + + OLD _render() output: + '⛷ AVY WARNING: Banner Summit — High (4) + Avoid all avalanche terrain today. + SNFAC · valid today' + NEW formatter output (identical — WARNING prefix unchanged): + '⛷ AVY WARNING: Banner Summit — High (4) + Avoid all avalanche terrain today. + SNFAC · valid today' + """ + from meshai.notifications.formatters.avalanche import format as avyfmt + + fixtures = load_fixtures("avalanche") + fx = next(f for f in fixtures if "banner" in f["envelope"]["id"]) + canonical = _canonical_from_fixture(fx) + + expected = ( + "⛷ AVY WARNING: Banner Summit — High (4)" + "\nAvoid all avalanche terrain today." + "\nSNFAC · valid today" + ) + + with pinned_time(_AT): + result = avyfmt(_make_fake_event(canonical), now=_AT, budget=140) + + assert_byte_identical(result, expected) + + old_wire = _avy_render_old( + danger_level=4, danger_name="High", + zone_name="Banner Summit", center_id="SNFAC", + travel="Avoid all avalanche terrain today. Natural avalanches are likely on steep slopes.", + ) + assert_byte_identical(result, old_wire), ( + "For High (level=4, is_update=False) old and new must be identical." + ) + + def test_fixture_0002_extreme_warning_prefix(self): + """Fixture 0002 (Extreme, NAADS 5) → formatter uses 'WARNING:' prefix. + + OLD _render() output: + '⛷ AVY WARNING: Soldier Mountains — Extreme (5) + Avoid all avalanche terrain! + SNFAC · valid today' + NEW formatter output (identical): + '⛷ AVY WARNING: Soldier Mountains — Extreme (5) + Avoid all avalanche terrain! + SNFAC · valid today' + """ + from meshai.notifications.formatters.avalanche import format as avyfmt + + fixtures = load_fixtures("avalanche") + fx = next(f for f in fixtures if "soldier" in f["envelope"]["id"]) + canonical = _canonical_from_fixture(fx) + + expected = ( + "⛷ AVY WARNING: Soldier Mountains — Extreme (5)" + "\nAvoid all avalanche terrain!" + "\nSNFAC · valid today" + ) + + with pinned_time(_AT): + result = avyfmt(_make_fake_event(canonical), now=_AT, budget=140) + + assert_byte_identical(result, expected) + + def test_tier_b_update_prefix_rendered(self): + """Tier-b: is_update=True produces 'AVY Update:' prefix. + + OLD _render() output (no is_update path): + '⛷ AVY Watch: Sawtooth Mountains — Considerable (3) + Dangerous conditions on steep slopes. + SNFAC · valid today' + NEW formatter output (is_update=True): + '⛷ AVY Update: Sawtooth Mountains — Considerable (3) + Dangerous conditions on steep slopes. + SNFAC · valid today' + """ + from meshai.notifications.formatters.avalanche import format as avyfmt + + fixtures = load_fixtures("avalanche") + fx = next(f for f in fixtures if "update" in f["envelope"]["id"]) + # Extract with is_update=True + canonical = _canonical_from_fixture(fx, is_update=True) + + # OLD _render() output (no is_update support) + old_wire = _avy_render_old( + danger_level=3, danger_name="Considerable", + zone_name="Sawtooth Mountains", center_id="SNFAC", + travel="Dangerous conditions on steep slopes. Conservative decision-making is advised.", + ) + expected_old = ( + "⛷ AVY Watch: Sawtooth Mountains — Considerable (3)" + "\nDangerous conditions on steep slopes." + "\nSNFAC · valid today" + ) + expected_new = ( + "⛷ AVY Update: Sawtooth Mountains — Considerable (3)" + "\nDangerous conditions on steep slopes." + "\nSNFAC · valid today" + ) + + with pinned_time(_AT): + result = avyfmt(_make_fake_event(canonical), now=_AT, budget=140) + + assert_byte_identical(old_wire, expected_old) + assert_byte_identical(result, expected_new) + assert "Update:" in result + assert "Watch:" not in result + + def test_formatter_budget_respected(self): + """Formatter output fits within budget for worst-case zone/advice strings.""" + from meshai.notifications.formatters.avalanche import format as avyfmt + + canonical = { + "danger_level": 5, + "danger_name": "Extreme", + "zone_name": "A very long zone name that might cause budget overflow", + "center_id": "SNFAC", + "travel_advice": ( + "Avoid all avalanche terrain! Very dangerous conditions exist " + "across all elevations and aspects. Natural avalanches certain." + ), + "lat": 43.5, + "lon": -115.4, + "is_update": False, + } + with pinned_time(_AT): + result = avyfmt(_make_fake_event(canonical), now=_AT, budget=140) + assert len(result) <= 140, f"{len(result)} chars:\n{result!r}" + assert "AVY WARNING" in result + + def test_no_travel_advice_still_renders(self): + """Formatter renders cleanly when travel_advice is absent.""" + from meshai.notifications.formatters.avalanche import format as avyfmt + + canonical = { + "danger_level": 3, + "danger_name": "Considerable", + "zone_name": "Wood River Valley", + "center_id": "SNFAC", + "travel_advice": "", + "lat": 43.4, + "lon": -114.3, + "is_update": False, + } + with pinned_time(_AT): + result = avyfmt(_make_fake_event(canonical), now=_AT, budget=140) + assert "AVY Watch" in result + assert "Wood River Valley" in result + assert "SNFAC" in result + assert "\n\n" not in result # no blank lines + + +# ───────────────────────────────────────────────────────────────────────────── +# 2. Cross-source identity — native and Central produce identical renders +# ───────────────────────────────────────────────────────────────────────────── + +class TestCrossSourceIdentity: + """Native to_event() canonical data renders byte-identically to Central path.""" + + def test_native_central_render_identical_fixture_0000(self): + """Canonical data from native and Central for fixture 0000 renders identically. + + The Central path extracts from the envelope. + The native path produces the same canonical keys via to_event(). + The formatter reads the same keys from both → same wire. + """ + from meshai.notifications.formatters.avalanche import format as avyfmt + + # Central-path canonical data (hand-extracted from fixture 0000) + central_canonical = { + "danger_level": 3, + "danger_name": "Considerable", + "zone_name": "Sawtooth Mountains", + "center_id": "SNFAC", + "travel_advice": "Dangerous conditions on steep slopes. Conservative decision-making is advised.", + "lat": 43.8, + "lon": -114.9, + "is_update": False, + } + + # Native-path canonical data (as to_event() would populate event.data) + native_canonical = { + "danger_level": 3, + "danger_name": "Considerable", + "zone_name": "Sawtooth Mountains", + "center_id": "SNFAC", + "travel_advice": "Dangerous conditions on steep slopes. Conservative decision-making is advised.", + "lat": 43.8, + "lon": -114.9, + "is_update": False, + "event_id": "avy_SNFAC_sawtooth_mountains", + } + + with pinned_time(_AT): + central_wire = avyfmt(_make_fake_event(central_canonical), + now=_AT, budget=140) + native_wire = avyfmt(_make_fake_event(native_canonical), + now=_AT, budget=140) + + assert_byte_identical(native_wire, central_wire), ( + f"Native and Central renders must be byte-identical:\n" + f" Central: {central_wire!r}\n" + f" Native: {native_wire!r}" + ) + + def test_native_to_event_uses_canonical_keys(self): + """to_event() event.data has the canonical keys the formatter reads.""" + from unittest.mock import MagicMock + from meshai.env.avalanche import AvalancheAdapter + + cfg = MagicMock() + cfg.center_ids = ["SNFAC"] + cfg.tick_seconds = 1800 + cfg.season_months = [12, 1, 2, 3, 4] + adapter = AvalancheAdapter(cfg) + + import time + now = time.time() + raw_evt = { + "source": "avalanche", + "event_id": "avy_SNFAC_sawtooth_mountains", + "event_type": "Avalanche Advisory", + "severity": "routine", + "headline": "Sawtooth Mountains: Considerable avalanche danger", + "zone_name": "Sawtooth Mountains", + "center": "Sawtooth Avalanche Center", + "center_id": "SNFAC", + "center_link": "https://www.sawtoothavalanche.com", + "forecast_link": "https://www.sawtoothavalanche.com/forecast", + "danger": "considerable", + "danger_level": 3, + "danger_name": "Considerable", + "travel_advice": "Dangerous conditions on steep slopes.", + "state": "ID", + "lat": 43.8, + "lon": -114.9, + "expires": now + 3600, + "fetched_at": now, + } + + event = adapter.to_event(raw_evt) + assert event is not None, "to_event() must return an Event for valid input" + assert event.data is not None, "event.data must not be None" + + canonical_keys = { + "danger_level", "danger_name", "zone_name", "center_id", + "travel_advice", "lat", "lon", "is_update", "event_id", + } + missing = canonical_keys - set(event.data.keys()) + assert not missing, ( + f"to_event() event.data missing canonical keys: {missing}\n" + f"Got keys: {sorted(event.data.keys())}" + ) + + # Spot-check values + assert event.data["danger_level"] == 3 + assert event.data["danger_name"] == "Considerable" + assert event.data["zone_name"] == "Sawtooth Mountains" + assert event.data["center_id"] == "SNFAC" + assert event.data["is_update"] is False + assert event.data["event_id"] == "avy_SNFAC_sawtooth_mountains" + + def test_native_to_event_no_precomposed(self): + """to_event() must NOT set _meshai_precomposed in event.data (formatter governs).""" + from unittest.mock import MagicMock + from meshai.env.avalanche import AvalancheAdapter + + cfg = MagicMock() + cfg.center_ids = ["SNFAC"] + cfg.tick_seconds = 1800 + cfg.season_months = [12, 1, 2, 3, 4] + adapter = AvalancheAdapter(cfg) + + import time + now = time.time() + raw_evt = { + "source": "avalanche", + "event_id": "avy_SNFAC_sawtooth_mountains", + "event_type": "Avalanche Advisory", + "severity": "routine", + "headline": "test", + "zone_name": "Sawtooth Mountains", + "center": "SNFAC", + "center_id": "SNFAC", + "center_link": "", + "forecast_link": "", + "danger": "considerable", + "danger_level": 3, + "danger_name": "Considerable", + "travel_advice": "Dangerous conditions.", + "state": "ID", + "lat": 43.8, + "lon": -114.9, + "expires": now + 3600, + "fetched_at": now, + } + + event = adapter.to_event(raw_evt) + assert event is not None + assert not event.data.get("_meshai_precomposed"), ( + "event.data must NOT have _meshai_precomposed=True — " + "the formatter registry governs rendering" + ) + + def test_handle_avy_writes_canonical_into_data(self): + """handle_avy() writes canonical fields into the shared data dict on broadcast.""" + from meshai.central.avy_handler import handle_avy + + envelope = { + "id": "avy-snfac-sawtooth-test", + "data": { + "adapter": "avalanche_org", + "category": "advisory.us.id", + "severity": 2, + "geo": {"centroid": [-114.9, 43.8]}, + "data": { + "danger_level": 3, + "danger_name": "Considerable", + "zone_name": "Sawtooth Mountains", + "center_id": "SNFAC", + "travel_advice": "Dangerous conditions.", + }, + }, + } + data: dict = {} + wire = handle_avy(envelope, "central.avy.advisory.us.id", data=data) + + assert wire is not None, "handle_avy must return a wire string on broadcast" + # Canonical fields must be in the shared data dict + assert data.get("danger_level") == 3 + assert data.get("danger_name") == "Considerable" + assert data.get("zone_name") == "Sawtooth Mountains" + assert data.get("center_id") == "SNFAC" + assert "_on_broadcast_committed" in data + assert "_broadcast_audit" in data + + +# ───────────────────────────────────────────────────────────────────────────── +# 3. Gate-sequence — danger-level gate + first→update trend +# ───────────────────────────────────────────────────────────────────────────── + +class TestGateSequence: + """gating.avalanche.decide() gate + lifecycle decisions.""" + + def _decide(self, danger_level: int, *, is_update: bool = False): + from meshai.notifications.gating.avalanche import decide + data = { + "danger_level": danger_level, + "danger_name": {1: "Low", 2: "Moderate", 3: "Considerable", + 4: "High", 5: "Extreme"}.get(danger_level, "?"), + "zone_name": "Test Zone", + "center_id": "SNFAC", + "travel_advice": "Test travel advice.", + "lat": 43.8, + "lon": -114.9, + "is_update": is_update, + } + return decide(data, source="avalanche", now=_AT) + + def test_level_below_threshold_suppressed(self): + """danger_level=2 (Moderate) is below min_danger_level=3 → suppress.""" + result = self._decide(2) + assert result.broadcast is False + assert result.lifecycle == "suppress" + + def test_level_at_threshold_broadcast(self): + """danger_level=3 (Considerable) equals min_danger_level=3 → broadcast.""" + result = self._decide(3) + assert result.broadcast is True + assert result.lifecycle == "new" + + def test_level_above_threshold_broadcast(self): + """danger_level=4 (High) above min_danger_level=3 → broadcast + priority.""" + result = self._decide(4) + assert result.broadcast is True + assert result.lifecycle == "new" + assert result.data_patch.get("_severity_override") == "priority" + + def test_extreme_broadcast_priority(self): + """danger_level=5 (Extreme) → broadcast + priority.""" + result = self._decide(5) + assert result.broadcast is True + assert result.data_patch.get("_severity_override") == "priority" + + def test_considerable_no_priority_override(self): + """danger_level=3 (Considerable) → broadcast, _severity_override=None.""" + result = self._decide(3) + assert result.broadcast is True + assert result.data_patch.get("_severity_override") is None + + def test_first_sighting_lifecycle_new(self): + """First event (is_update=False) → lifecycle='new'.""" + result = self._decide(3, is_update=False) + assert result.broadcast is True + assert result.lifecycle == "new" + assert result.data_patch.get("is_update") is False + + def test_update_trend_lifecycle_update(self): + """Subsequent event (is_update=True) → lifecycle='update'.""" + result = self._decide(3, is_update=True) + assert result.broadcast is True + assert result.lifecycle == "update" + assert result.data_patch.get("is_update") is True + + def test_missing_danger_level_suppresses(self): + """Missing danger_level in canonical data → suppress.""" + from meshai.notifications.gating.avalanche import decide + result = decide({"zone_name": "test"}, source="avalanche", now=_AT) + assert result.broadcast is False + + def test_centralseverity_remap_considerable(self): + """centralseverity=2 → NAADS 3 (Considerable) → broadcast at min_level=3. + + This tests the remap logic in _remap_centralseverity() as used by + handle_avy() when data.data.danger_level is absent. + + ⚠ Mapping table (needs live validation in-season Oct+): + centralseverity 2 → NAADS 3 (Considerable) [documented] + centralseverity 3 → NAADS 4 (High) [documented] + centralseverity 4 → NAADS 5 (Extreme) [documented] + centralseverity 0 → NAADS 1 (Low) [inferred] + centralseverity 1 → NAADS 2 (Moderate) [inferred] + """ + from meshai.central.avy_handler import _remap_centralseverity + + # Documented values + assert _remap_centralseverity(2) == 3, "centralseverity 2 → NAADS 3 (Considerable)" + assert _remap_centralseverity(3) == 4, "centralseverity 3 → NAADS 4 (High)" + assert _remap_centralseverity(4) == 5, "centralseverity 4 → NAADS 5 (Extreme)" + # Inferred values + assert _remap_centralseverity(0) == 1, "centralseverity 0 → NAADS 1 (Low) [inferred]" + assert _remap_centralseverity(1) == 2, "centralseverity 1 → NAADS 2 (Moderate) [inferred]" + # Out-of-range → 0 (No Rating) + assert _remap_centralseverity(5) == 0, "centralseverity 5 → 0 (No Rating)" + assert _remap_centralseverity(-1) == 0 + assert _remap_centralseverity("?") == 0 + + def test_centralseverity_gate_sequence_via_fixtures(self): + """Gate-sequence via fixtures: centralseverity 2/3/4 all broadcast; 1 suppresses. + + Uses handle_avy to exercise the full Central ingestion path including + the centralseverity → NAADS remap and the decide() call. + """ + from meshai.central.avy_handler import handle_avy + + def _make_envelope(centralseverity: int, naads_level: int, + danger_name: str) -> dict: + return { + "data": { + "adapter": "avalanche_org", + "category": "advisory.us.id", + "severity": centralseverity, + "geo": {"centroid": [-114.9, 43.8]}, + "data": { + "danger_level": naads_level, + "danger_name": danger_name, + "zone_name": "Test Zone", + "center_id": "SNFAC", + "travel_advice": "Test advice.", + }, + } + } + + # centralseverity=1 → NAADS 2 (Moderate) → suppressed (below min_level=3) + env_moderate = _make_envelope(1, 2, "Moderate") + data_m: dict = {} + wire_m = handle_avy(env_moderate, "central.avy.advisory.us.id", data=data_m) + assert wire_m is None, "Moderate (NAADS 2) must be suppressed" + + # centralseverity=2 → NAADS 3 (Considerable) → broadcast + env_considerable = _make_envelope(2, 3, "Considerable") + data_c: dict = {} + wire_c = handle_avy(env_considerable, "central.avy.advisory.us.id", data=data_c) + assert wire_c is not None, "Considerable (NAADS 3) must broadcast" + assert "Watch" in wire_c + + # centralseverity=3 → NAADS 4 (High) → broadcast with WARNING + env_high = _make_envelope(3, 4, "High") + data_h: dict = {} + wire_h = handle_avy(env_high, "central.avy.advisory.us.id", data=data_h) + assert wire_h is not None, "High (NAADS 4) must broadcast" + assert "WARNING" in wire_h + + # centralseverity=4 → NAADS 5 (Extreme) → broadcast with WARNING + env_extreme = _make_envelope(4, 5, "Extreme") + data_e: dict = {} + wire_e = handle_avy(env_extreme, "central.avy.advisory.us.id", data=data_e) + assert wire_e is not None, "Extreme (NAADS 5) must broadcast" + assert "WARNING" in wire_e + + def test_is_update_propagated_into_data_patch(self): + """decide() data_patch.is_update reflects the incoming is_update flag.""" + from meshai.notifications.gating.avalanche import decide + + result_new = decide( + {"danger_level": 3, "zone_name": "Z", "center_id": "C", + "travel_advice": "", "is_update": False}, + source="avalanche", now=_AT, + ) + assert result_new.data_patch["is_update"] is False + + result_update = decide( + {"danger_level": 3, "zone_name": "Z", "center_id": "C", + "travel_advice": "", "is_update": True}, + source="avalanche", now=_AT, + ) + assert result_update.data_patch["is_update"] is True + + +# ───────────────────────────────────────────────────────────────────────────── +# 4. Schema conformance — to_event() canonical data completeness +# ───────────────────────────────────────────────────────────────────────────── + +class TestSchemaConformance: + """env/avalanche.py to_event() emits canonical keys without _meshai_precomposed.""" + + @pytest.fixture + def adapter(self): + from unittest.mock import MagicMock + from meshai.env.avalanche import AvalancheAdapter + cfg = MagicMock() + cfg.center_ids = ["SNFAC"] + cfg.tick_seconds = 1800 + cfg.season_months = [12, 1, 2, 3, 4] + return AvalancheAdapter(cfg) + + CANONICAL_KEYS = frozenset({ + "danger_level", "danger_name", "zone_name", "center_id", + "travel_advice", "lat", "lon", "is_update", "event_id", + }) + + def _raw_evt(self, **overrides): + import time + base = { + "source": "avalanche", + "event_id": "avy_SNFAC_sawtooth_mountains", + "event_type": "Avalanche Advisory", + "severity": "routine", + "headline": "test", + "zone_name": "Sawtooth Mountains", + "center": "Sawtooth Avalanche Center", + "center_id": "SNFAC", + "center_link": "", + "forecast_link": "", + "danger": "considerable", + "danger_level": 3, + "danger_name": "Considerable", + "travel_advice": "Dangerous conditions on steep slopes.", + "state": "ID", + "lat": 43.8, + "lon": -114.9, + "expires": time.time() + 3600, + "fetched_at": time.time(), + } + base.update(overrides) + return base + + def test_all_canonical_keys_present(self, adapter): + """event.data contains all canonical schema keys.""" + event = adapter.to_event(self._raw_evt()) + assert event is not None + missing = self.CANONICAL_KEYS - set(event.data.keys()) + assert not missing, f"Missing canonical keys: {missing}" + + def test_no_precomposed_flag(self, adapter): + """event.data must NOT have _meshai_precomposed — formatter governs.""" + event = adapter.to_event(self._raw_evt()) + assert event is not None + assert not event.data.get("_meshai_precomposed"), ( + "event.data['_meshai_precomposed'] must be falsy/absent; " + "formatter registry governs dispatch rendering" + ) + + def test_danger_level_is_int(self, adapter): + """event.data['danger_level'] is a Python int (not float or str).""" + event = adapter.to_event(self._raw_evt(danger_level=3)) + assert event is not None + assert isinstance(event.data["danger_level"], int) + assert event.data["danger_level"] == 3 + + def test_is_update_defaults_false(self, adapter): + """is_update=False when _is_update not set in the raw event dict.""" + event = adapter.to_event(self._raw_evt()) + assert event is not None + assert event.data["is_update"] is False + + def test_is_update_propagated_from_store(self, adapter): + """_is_update=True from EnvironmentalStore propagates to canonical is_update.""" + raw = self._raw_evt() + raw["_is_update"] = True + event = adapter.to_event(raw) + assert event is not None + assert event.data["is_update"] is True + + def test_event_id_in_canonical_data(self, adapter): + """event.data['event_id'] matches the source event_id.""" + event = adapter.to_event(self._raw_evt(event_id="avy_test_id")) + assert event is not None + assert event.data["event_id"] == "avy_test_id" + + def test_formatter_renders_from_canonical_data(self, adapter): + """Formatter reads canonical event.data and renders a valid wire string.""" + from meshai.notifications.formatters.avalanche import format as avyfmt + + event = adapter.to_event(self._raw_evt()) + assert event is not None + + with pinned_time(_AT): + result = avyfmt(event, now=_AT, budget=140) + + assert result is not None + assert len(result) <= 140 + assert "AVY Watch" in result + assert "Sawtooth Mountains" in result + assert "Considerable (3)" in result + + def test_formatter_registered_for_avalanche_categories(self): + """Both avalanche_warning and avalanche_watch have registered formatters.""" + from meshai.notifications.formatters import get_formatter + + fmt_warning = get_formatter("avalanche_warning") + assert fmt_warning is not None, "avalanche_warning must have a registered formatter" + + fmt_watch = get_formatter("avalanche_watch") + assert fmt_watch is not None, "avalanche_watch must have a registered formatter" + + def test_decider_registered_for_avalanche_categories(self): + """Both avalanche_warning and avalanche_watch have registered deciders.""" + from meshai.notifications.gating import get_decider + + dec_warning = get_decider("avalanche_warning") + assert dec_warning is not None, "avalanche_warning must have a registered decider" + + dec_watch = get_decider("avalanche_watch") + assert dec_watch is not None, "avalanche_watch must have a registered decider" diff --git a/work/tests/test_cutover_gate.py b/work/tests/test_cutover_gate.py new file mode 100644 index 0000000..e1e04be --- /dev/null +++ b/work/tests/test_cutover_gate.py @@ -0,0 +1,347 @@ +"""Staged cutover gate — contract tests. + +Three net-behavior states are verified: + +State 1 — Bake (default deploy): + MESHAI_CUTOVER_CATEGORIES unset + MESHAI_SHADOW_CATEGORIES=earthquake_event,... + → compose_mesh_message uses OLD legacy path for LIVE broadcast. + → shadow_gate / shadow_render run dry-run (bake comparison active). + +State 2 — Cutover: + MESHAI_CUTOVER_CATEGORIES=earthquake_event + → compose_mesh_message uses the new formatter for the LIVE broadcast. + → shadow_gate / shadow_render skip (nothing to compare — new path IS live). + +State 3 — Default / tests (both vars unset): + → compose_mesh_message always legacy. + → shadow hooks are fully off. + → Direct formatter/decider unit tests still work (they call formatters + directly, bypassing compose_mesh_message). + +All tests clear the lru_caches around env-var mutations. +""" +from __future__ import annotations + +import os +import pytest + +import meshai.notifications.shadow as shadow_mod +from meshai.notifications.cutover import _clear_cache as _cutover_clear +from meshai.notifications.cutover import is_cutover +from meshai.notifications.events import make_event +from meshai.notifications.renderers.composer import compose_mesh_message + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _reset_caches(): + """Clear lru_caches for both cutover and shadow after env-var changes.""" + _cutover_clear() + shadow_mod._clear_enabled_cache() + + +def _make_quake_event(*, title="M3.3 earthquake near Stanley, ID"): + """Minimal Event for compose_mesh_message testing (earthquake_event).""" + return make_event( + source="usgs_quake", + category="earthquake_event", + severity="routine", + title=title, + data={ + "magnitude": 3.3, + "depth_km": 11.0, + "lat": 44.46, + "lon": -112.61, + "place": "19 km S of Lima, Montana", + "tsunami": False, + "pager": None, + "is_update": False, + "occurred_at": None, + "event_id": "us_cutover_test_001", + "_severity_override": None, + "_dedup_suffix": "", + "distance_km": 160.0, + }, + ) + + +# --------------------------------------------------------------------------- +# 1. is_cutover — env parsing +# --------------------------------------------------------------------------- + +class TestIsCutover: + """is_cutover correctly reads and caches MESHAI_CUTOVER_CATEGORIES.""" + + def setup_method(self): + os.environ.pop("MESHAI_CUTOVER_CATEGORIES", None) + _cutover_clear() + + def teardown_method(self): + os.environ.pop("MESHAI_CUTOVER_CATEGORIES", None) + _cutover_clear() + + def test_unset_returns_false_for_all(self): + assert is_cutover("earthquake_event") is False + assert is_cutover("geomagnetic_storm") is False + assert is_cutover("avalanche_warning") is False + assert is_cutover("") is False + + def test_single_category_parses(self): + os.environ["MESHAI_CUTOVER_CATEGORIES"] = "earthquake_event" + _cutover_clear() + assert is_cutover("earthquake_event") is True + assert is_cutover("geomagnetic_storm") is False + + def test_multiple_categories_parse(self): + os.environ["MESHAI_CUTOVER_CATEGORIES"] = ( + "earthquake_event,geomagnetic_storm,rf_propagation_alert" + ) + _cutover_clear() + assert is_cutover("earthquake_event") is True + assert is_cutover("geomagnetic_storm") is True + assert is_cutover("rf_propagation_alert") is True + assert is_cutover("avalanche_warning") is False + + def test_whitespace_stripped(self): + os.environ["MESHAI_CUTOVER_CATEGORIES"] = " earthquake_event , avalanche_warning " + _cutover_clear() + assert is_cutover("earthquake_event") is True + assert is_cutover("avalanche_warning") is True + + def test_empty_string_all_false(self): + os.environ["MESHAI_CUTOVER_CATEGORIES"] = "" + _cutover_clear() + assert is_cutover("earthquake_event") is False + + def test_cache_cleared_after_env_change(self): + os.environ.pop("MESHAI_CUTOVER_CATEGORIES", None) + _cutover_clear() + assert is_cutover("earthquake_event") is False + + os.environ["MESHAI_CUTOVER_CATEGORIES"] = "earthquake_event" + # Without cache clear, still False (cached). + assert is_cutover("earthquake_event") is False + + # After clear, re-reads env. + _cutover_clear() + assert is_cutover("earthquake_event") is True + + +# --------------------------------------------------------------------------- +# 2. State 3 — Both vars unset (default / test isolation) +# compose_mesh_message falls back to legacy even with formatters registered. +# --------------------------------------------------------------------------- + +class TestBothVarsUnset: + """With no env vars: legacy path for compose, shadow fully off.""" + + def setup_method(self): + os.environ.pop("MESHAI_CUTOVER_CATEGORIES", None) + os.environ.pop("MESHAI_SHADOW_CATEGORIES", None) + _reset_caches() + + def teardown_method(self): + os.environ.pop("MESHAI_CUTOVER_CATEGORIES", None) + os.environ.pop("MESHAI_SHADOW_CATEGORIES", None) + _reset_caches() + + def test_compose_uses_legacy_not_formatter(self): + """compose_mesh_message on a registered-but-not-cutover category must + return the Mode-B legacy output, NOT the new formatter output.""" + from meshai.notifications.formatters import get_formatter + # Confirm the formatter IS registered for earthquake_event. + assert get_formatter("earthquake_event") is not None, ( + "earthquake_event formatter must be registered (Phase-1)" + ) + event = _make_quake_event() + result = compose_mesh_message(event) + # Legacy Mode-B output uses the emoji+LABEL: prefix + title + severity. + # It does NOT start with "🌐 New:" (that's the formatter's line-1 prefix). + assert "New:" not in result, ( + f"Formatter was invoked when not cutover — result: {result!r}" + ) + # Mode-B always appends the severity word and uses the QUAKE label. + assert "QUAKE:" in result or "earthquake" in result.lower() or "routine" in result, ( + f"Unexpected legacy output shape: {result!r}" + ) + + def test_shadow_gate_off(self, tmp_path, monkeypatch): + monkeypatch.setattr(shadow_mod, "_SHADOW_DIR", str(tmp_path / "shadow")) + shadow_mod.shadow_gate( + "earthquake_event", {}, source="usgs_quake", now=0.0, + old_broadcast=True, + ) + assert not (tmp_path / "shadow").exists() + + def test_shadow_render_off(self, tmp_path, monkeypatch): + monkeypatch.setattr(shadow_mod, "_SHADOW_DIR", str(tmp_path / "shadow")) + shadow_mod.shadow_render( + "earthquake_event", + _make_quake_event(), + old_wire="legacy wire", + ) + assert not (tmp_path / "shadow").exists() + + +# --------------------------------------------------------------------------- +# 3. State 1 — Bake: shadow enabled, cutover NOT set +# → compose_mesh_message uses legacy; shadow hooks active +# --------------------------------------------------------------------------- + +class TestBakeState: + """Shadow enabled, cutover unset — bake period behavior.""" + + _SHADOW_CATS = "earthquake_event,geomagnetic_storm,rf_propagation_alert,avalanche_warning,avalanche_watch" + + def setup_method(self): + os.environ.pop("MESHAI_CUTOVER_CATEGORIES", None) + os.environ["MESHAI_SHADOW_CATEGORIES"] = self._SHADOW_CATS + _reset_caches() + + def teardown_method(self): + os.environ.pop("MESHAI_CUTOVER_CATEGORIES", None) + os.environ.pop("MESHAI_SHADOW_CATEGORIES", None) + _reset_caches() + + def test_compose_still_uses_legacy_in_bake(self): + """Bake state: shadow enabled does NOT change the live render path.""" + event = _make_quake_event() + result = compose_mesh_message(event) + assert "New:" not in result, ( + f"Formatter was invoked during bake state — result: {result!r}" + ) + + def test_shadow_enabled_for_listed_category(self): + assert shadow_mod.enabled_for("earthquake_event") is True + + def test_shadow_gate_not_skipped_in_bake(self, tmp_path, monkeypatch): + """shadow_gate should run (not be skipped by cutover) during bake. + + The gate will find a registered decider and attempt a comparison. + We only assert that the cutover check does NOT short-circuit it: + enabled_for returns True and the hook proceeds past both guards. + """ + # Verify: NOT cutover, IS shadow-enabled → hook runs past both guards. + assert is_cutover("earthquake_event") is False + assert shadow_mod.enabled_for("earthquake_event") is True + + +# --------------------------------------------------------------------------- +# 4. State 2 — Cutover: category explicitly cut over +# → compose_mesh_message uses NEW formatter; shadow hooks skip +# --------------------------------------------------------------------------- + +class TestCutoverActive: + """With earthquake_event in MESHAI_CUTOVER_CATEGORIES.""" + + def setup_method(self): + os.environ["MESHAI_CUTOVER_CATEGORIES"] = "earthquake_event" + os.environ.pop("MESHAI_SHADOW_CATEGORIES", None) + _reset_caches() + + def teardown_method(self): + os.environ.pop("MESHAI_CUTOVER_CATEGORIES", None) + os.environ.pop("MESHAI_SHADOW_CATEGORIES", None) + _reset_caches() + + def test_is_cutover_true(self): + assert is_cutover("earthquake_event") is True + assert is_cutover("geomagnetic_storm") is False # not listed + + def test_compose_uses_new_formatter(self): + """Once cut over, compose_mesh_message dispatches to the formatter. + + The quake formatter produces a multi-line "New: M" + string that is byte-for-byte different from Mode-B output. + """ + from tests.harness.goldens import pinned_time + _AT = 1_783_200_000.0 + event = _make_quake_event() + with pinned_time(_AT): + result = compose_mesh_message(event) + # Formatter line-1 format: " New: M3.3 — 19 km S of Lima, Montana" + assert "New:" in result, ( + f"Formatter output expected 'New:' prefix, got: {result!r}" + ) + assert "M3.3" in result, f"Magnitude missing from formatter output: {result!r}" + assert "Lima, Montana" in result, ( + f"Place string missing from formatter output: {result!r}" + ) + + def test_shadow_gate_skips_when_cutover(self, tmp_path, monkeypatch): + """shadow_gate must be a no-op when the category is cut over.""" + monkeypatch.setattr(shadow_mod, "_SHADOW_DIR", str(tmp_path / "shadow")) + # Enable shadow for earthquake_event too — but cutover takes precedence. + monkeypatch.setenv("MESHAI_SHADOW_CATEGORIES", "earthquake_event") + shadow_mod._clear_enabled_cache() + shadow_mod.shadow_gate( + "earthquake_event", + {"_dedup_suffix": "M3.3"}, + source="usgs_quake", + now=1_783_200_000.0, + old_broadcast=True, + ) + # Nothing should have been written — cutover skips before any I/O. + assert not (tmp_path / "shadow").exists(), ( + "shadow_gate wrote JSONL even though category is cut over" + ) + + def test_shadow_render_skips_when_cutover(self, tmp_path, monkeypatch): + """shadow_render must be a no-op when the category is cut over.""" + monkeypatch.setattr(shadow_mod, "_SHADOW_DIR", str(tmp_path / "shadow")) + monkeypatch.setenv("MESHAI_SHADOW_CATEGORIES", "earthquake_event") + shadow_mod._clear_enabled_cache() + shadow_mod.shadow_render( + "earthquake_event", + _make_quake_event(), + old_wire="legacy wire that differs from new formatter", + ) + assert not (tmp_path / "shadow").exists(), ( + "shadow_render wrote JSONL even though category is cut over" + ) + + +# --------------------------------------------------------------------------- +# 5. Partial cutover — only the listed category is live; others stay legacy +# --------------------------------------------------------------------------- + +class TestPartialCutover: + """Only one of several migrated categories is cut over.""" + + def setup_method(self): + # earthquake_event is cut over; geomagnetic_storm is NOT. + os.environ["MESHAI_CUTOVER_CATEGORIES"] = "earthquake_event" + _reset_caches() + + def teardown_method(self): + os.environ.pop("MESHAI_CUTOVER_CATEGORIES", None) + _reset_caches() + + def test_cutover_category_uses_formatter(self): + from tests.harness.goldens import pinned_time + _AT = 1_783_200_000.0 + event = _make_quake_event() + with pinned_time(_AT): + result = compose_mesh_message(event) + assert "New:" in result, f"quake should use formatter: {result!r}" + + def test_non_cutover_category_uses_legacy(self): + """geomagnetic_storm has a formatter but is not cut over → legacy.""" + from meshai.notifications.formatters import get_formatter + assert get_formatter("geomagnetic_storm") is not None + + event = make_event( + source="swpc", + category="geomagnetic_storm", + severity="priority", + title="G3 Geomagnetic Storm", + data={"kp": 7.0, "scale_code": "G3", "driver": "kp"}, + ) + result = compose_mesh_message(event) + # Legacy Mode-B: uses title "G3 Geomagnetic Storm", starts with the RF emoji. + assert "New:" not in result, ( + f"geomagnetic_storm should use legacy path: {result!r}" + ) diff --git a/work/tests/test_formatter_scaffold.py b/work/tests/test_formatter_scaffold.py index 04644b2..91e2774 100644 --- a/work/tests/test_formatter_scaffold.py +++ b/work/tests/test_formatter_scaffold.py @@ -16,17 +16,17 @@ from meshai.notifications.renderers.composer import compose_mesh_message @pytest.mark.parametrize("category", [ "weather_warning", - "earthquake_event", + # earthquake_event removed: Phase-1 registers formatters.quake for it. "wildfire_incident", "road_closure", "battery_critical", ]) def test_get_formatter_returns_none_while_registry_empty(category): - """FORMATTERS is empty; get_formatter must return None for any real category.""" - # Guarantee the registry is empty for these categories (it starts empty at - # module level; tests run in isolation from each other's registrations). + """Un-migrated categories must still return None from get_formatter.""" + # earthquake_event is now migrated (Phase 1); the remaining categories + # here have no formatter yet and must still fall through to Mode-B. assert category not in FORMATTERS, ( - f"Category {category!r} should not be in FORMATTERS at Phase 0" + f"Category {category!r} should not be in FORMATTERS yet (not migrated)" ) assert get_formatter(category) is None @@ -46,7 +46,16 @@ def test_registered_formatter_returns_verbatim_multiline(monkeypatch): """Register a dummy for a synthetic category; compose_mesh_message must return the dummy's multi-line output verbatim — newlines preserved — not re-processed through Mode-B's single-line budget loop. + + Cutover gate: the formatter is only dispatched when the category appears in + MESHAI_CUTOVER_CATEGORIES. This test sets the var to verify the formatter + IS invoked once cut over (the complementary not-cutover case is covered by + test_cutover_gate.py). """ + from meshai.notifications.cutover import _clear_cache as _cutover_clear + # Mark synthetic category as cut over for this test. + monkeypatch.setenv("MESHAI_CUTOVER_CATEGORIES", _SYNTHETIC_CATEGORY) + _cutover_clear() # Register the dummy (clean up afterwards to avoid cross-test pollution). register(_SYNTHETIC_CATEGORY, _dummy_formatter) try: @@ -64,3 +73,4 @@ def test_registered_formatter_returns_verbatim_multiline(monkeypatch): assert "\n" in result, "Newlines must survive the formatter dispatch path" finally: FORMATTERS.pop(_SYNTHETIC_CATEGORY, None) + _cutover_clear() # restore cache for subsequent tests diff --git a/work/tests/test_quake_refactor.py b/work/tests/test_quake_refactor.py new file mode 100644 index 0000000..b3ac555 --- /dev/null +++ b/work/tests/test_quake_refactor.py @@ -0,0 +1,742 @@ +"""Phase-1 quake refactor tests — reference implementation verification. + +Four test groups: + +1. Parity (tier-b): fixture 0002 → canonical data → formatter. + Expected string is hand-written (the new correct format). + The OLD _render() output for the same fixture is captured in a comment so + the intended tier-b diff is explicit and reviewable. + Two synthetic cases show the PAGER + update-prefix diffs explicitly. + +2. Cross-source identity: native adapter builds the same canonical data as + the Central path for fixture 0002. Both render byte-identically. + +3. Gate-sequence: replay four synthetic events through the OLD handle_quake + gating and the NEW gating.quake.decide(); assert broadcast/suppress match. + +4. Schema-conformance: env/usgs_quake.py to_event() emits all canonical keys. +""" +from __future__ import annotations + +import pytest + +from meshai.persistence import close_thread_connection, init_db +from meshai.persistence import db as persistence_db +from tests.harness.goldens import ( + assert_byte_identical, + load_fixtures, + pinned_time, + run_gate_sequence, +) + +# ── Shared clock epoch for deterministic renders ───────────────────────────── +_AT = 1_783_200_000.0 # 2026-07-03T00:00:00Z (pinned) + +# ── DB fixture shared by gate-sequence tests ───────────────────────────────── + +@pytest.fixture +def mem_db(monkeypatch, tmp_path): + db_path = str(tmp_path / "quake-refactor-test.sqlite") + monkeypatch.setenv("MESHAI_DB_PATH", db_path) + persistence_db._initialised.clear() + close_thread_connection() + conn = init_db() + yield conn + close_thread_connection() + persistence_db._initialised.discard(db_path) + + +# ───────────────────────────────────────────────────────────────────────────── +# 1. Parity (tier-b) — formatter renders from canonical data +# ───────────────────────────────────────────────────────────────────────────── + +def _make_fake_event(data: dict): + """Minimal fake Event for calling the formatter without the full pipeline.""" + class _FakeEvent: + pass + e = _FakeEvent() + e.data = data + return e + + +class TestFormatterParity: + """formatter/quake.format() renders correct output from canonical data.""" + + def _render_old(self, *, mag, place, depth_km, lat, lon, tsunami, is_update=False): + """Capture OLD _render() output for diff comments.""" + from meshai.central.quake_handler import _render + from meshai.central.budget import budget_for + return _render(mag=mag, place=place, depth_km=depth_km, lat=lat, + lon=lon, tsunami=tsunami, is_update=is_update) + + def test_fixture_0002_new_format(self): + """Fixture 0002 (M3.3 Lima Montana) → NEW formatter output matches hand-written expected. + + Fixture 0002 has alert=null and no tsunami so the tier-b additions + (PAGER line, update-prefix) are not visible. The old and new outputs + are IDENTICAL for this fixture — which is correct. The hand-written + expected below documents the canonical format; synthetic tests below + show the tier-b additions. + + OLD _render() output (captured for diff transparency): + "🌐 New: M3.3 — 19 km S of Lima, Montana\\nDepth: 11 km · @ 44.460, -112.611" + NEW formatter output (same — no tier-b changes triggered): + "🌐 New: M3.3 — 19 km S of Lima, Montana\\nDepth: 11 km · @ 44.460, -112.611" + """ + from meshai.notifications.formatters.quake import format as qfmt + + fixtures = load_fixtures("quake") + fx = next(f for f in fixtures if f["envelope"]["id"] == "us6000t9bn") + inner = fx["envelope"]["data"] + d = inner["data"] + geo = inner["geo"] + cent = geo["centroid"] # [lon, lat] + + canonical = { + "magnitude": d["magnitude"], # 3.3 + "depth_km": d["depth"], # 11.169 (raw USGS key) + "lat": cent[1], # 44.46 + "lon": cent[0], # -112.6108 + "place": d["place"], # "19 km S of Lima, Montana" + "tsunami": bool(d["tsunami"]), # False + "pager": d.get("alert"), # None + "occurred_at": None, + "event_id": fx["envelope"]["id"], + "is_update": False, + "_severity_override": None, + "_dedup_suffix": "", + "distance_km": 160.0, + } + + # Hand-written new correct format (tier-b changes are invisible here) + expected = ( + "\U0001f310 New: M3.3 — 19 km S of Lima, Montana" + "\nDepth: 11 km · @ 44.460, -112.611" + ) + + with pinned_time(_AT): + result = qfmt(_make_fake_event(canonical), now=_AT, budget=140) + + assert_byte_identical(result, expected) + + # Verify old _render matches new for this fixture (no tier-b diff) + old_wire = self._render_old( + mag=canonical["magnitude"], place=canonical["place"], + depth_km=canonical["depth_km"], lat=canonical["lat"], + lon=canonical["lon"], tsunami=canonical["tsunami"], + is_update=False, + ) + assert_byte_identical(result, old_wire), ( + "For fixture 0002 (null PAGER, is_update=False) old and new " + "outputs must be identical — the tier-b diff only appears when " + "PAGER or is_update are set." + ) + + def test_tier_b_pager_orange_rendered(self): + """Tier-b ①: PAGER=orange is NOW rendered on a 4th line. + + OLD _render() output (captured): + "🌐 New: M2.0 — Off the coast of Oregon\\nDepth: 10 km · @ 44.000, -125.000" + NEW formatter output (tier-b change — PAGER line added): + "🌐 New: M2.0 — Off the coast of Oregon\\nDepth: 10 km · @ 44.000, -125.000\\n⚠️ PAGER: orange" + """ + from meshai.notifications.formatters.quake import format as qfmt + + canonical = { + "magnitude": 2.0, + "depth_km": 10.0, + "lat": 44.0, + "lon": -125.0, + "place": "Off the coast of Oregon", + "tsunami": False, + "pager": "orange", # PAGER set — triggers tier-b line + "is_update": False, + "occurred_at": None, + "event_id": "test_pager_orange", + "_severity_override": "immediate", + "_dedup_suffix": "", + "distance_km": 500.0, + } + + # OLD _render() output (PAGER not rendered) + old_wire = self._render_old( + mag=2.0, place="Off the coast of Oregon", depth_km=10.0, + lat=44.0, lon=-125.0, tsunami=False, is_update=False, + ) + # NEW formatter output (PAGER rendered as 4th line) + expected_new = ( + "\U0001f310 New: M2.0 — Off the coast of Oregon" + "\nDepth: 10 km · @ 44.000, -125.000" + "\n⚠️ PAGER: orange" + ) + + with pinned_time(_AT): + result = qfmt(_make_fake_event(canonical), now=_AT, budget=140) + + assert_byte_identical(result, expected_new) + # Confirm the old wire does NOT have the PAGER line + assert "PAGER" not in old_wire, ( + f"OLD _render() must not contain PAGER line; got: {old_wire!r}" + ) + + def test_tier_b_update_prefix_rendered(self): + """Tier-b ②: is_update=True produces 'Update:' prefix (was hard-coded 'New:'). + + OLD _render() output (is_update always False): + "🌐 New: M3.0 — 5 km NE of Stanley, Idaho\\nDepth: 8 km · @ 44.200, -114.900" + NEW formatter output (is_update=True): + "🌐 Update: M3.0 — 5 km NE of Stanley, Idaho\\nDepth: 8 km · @ 44.200, -114.900" + """ + from meshai.notifications.formatters.quake import format as qfmt + + canonical = { + "magnitude": 3.0, + "depth_km": 8.0, + "lat": 44.2, + "lon": -114.9, + "place": "5 km NE of Stanley, Idaho", + "tsunami": False, + "pager": None, + "is_update": True, # tier-b: update-prefix now live + "occurred_at": None, + "event_id": "test_update_prefix", + "_severity_override": None, + "_dedup_suffix": "", + "distance_km": 10.0, + } + + # OLD _render() always uses is_update=False + old_wire = self._render_old( + mag=3.0, place="5 km NE of Stanley, Idaho", depth_km=8.0, + lat=44.2, lon=-114.9, tsunami=False, is_update=False, + ) + expected_new = ( + "\U0001f310 Update: M3.0 — 5 km NE of Stanley, Idaho" + "\nDepth: 8 km · @ 44.200, -114.900" + ) + expected_old = ( + "\U0001f310 New: M3.0 — 5 km NE of Stanley, Idaho" + "\nDepth: 8 km · @ 44.200, -114.900" + ) + + with pinned_time(_AT): + result = qfmt(_make_fake_event(canonical), now=_AT, budget=140) + + assert_byte_identical(result, expected_new) + assert_byte_identical(old_wire, expected_old) + assert "Update:" in result + assert "New:" not in result + + def test_tsunami_escalation_preserved(self): + """Tsunami escalation (🚨 emoji + TSUNAMI WARNING line) unchanged from _render.""" + from meshai.notifications.formatters.quake import format as qfmt + + canonical = { + "magnitude": 4.5, + "depth_km": 5.0, + "lat": 35.0, + "lon": 141.0, + "place": "off the coast of Japan", + "tsunami": True, + "pager": None, + "is_update": False, + "occurred_at": None, + "event_id": "test_tsunami", + "_severity_override": "immediate", + "_dedup_suffix": "", + "distance_km": 8000.0, + } + + with pinned_time(_AT): + result = qfmt(_make_fake_event(canonical), now=_AT, budget=140) + + old_wire = self._render_old( + mag=4.5, place="off the coast of Japan", depth_km=5.0, + lat=35.0, lon=141.0, tsunami=True, + ) + assert result.startswith("\U0001f6a8"), "Tsunami emoji must be 🚨" + assert "\U0001f6a8 TSUNAMI WARNING" in result + assert_byte_identical(result, old_wire) + + def test_m5_escalation_emoji_preserved(self): + """M5+ uses ⚠️ emoji — unchanged from _render.""" + from meshai.notifications.formatters.quake import format as qfmt + + canonical = { + "magnitude": 5.2, + "depth_km": 12.0, + "lat": 44.0, + "lon": -114.0, + "place": "15 km NW of Mackay, Idaho", + "tsunami": False, + "pager": None, + "is_update": False, + "occurred_at": None, + "event_id": "test_m5", + "_severity_override": None, + "_dedup_suffix": "", + "distance_km": 50.0, + } + + with pinned_time(_AT): + result = qfmt(_make_fake_event(canonical), now=_AT, budget=140) + + assert result.startswith("⚠️"), f"M5.2 must use ⚠️ emoji; got: {result!r}" + + +# ───────────────────────────────────────────────────────────────────────────── +# 2. Cross-source identity — native and Central produce identical renders +# ───────────────────────────────────────────────────────────────────────────── + +class TestCrossSourceIdentity: + """Native to_event() canonical data renders byte-identically to Central path.""" + + def test_native_central_render_identical_fixture_0002(self): + """Build native canonical data for fixture 0002 event, render → byte-identical. + + The Central path produces canonical data by extracting from the + envelope. The native path produces canonical data in to_event(). + The formatter reads the same keys from both → same wire. + """ + from meshai.notifications.formatters.quake import format as qfmt + + # Central-path canonical data (hand-extracted from fixture 0002) + central_canonical = { + "magnitude": 3.3, + "depth_km": 11.169, # normalized from raw "depth" field + "lat": 44.46, + "lon": -112.6108, + "place": "19 km S of Lima, Montana", + "tsunami": False, + "pager": None, + "occurred_at": None, + "event_id": "us6000t9bn", + "is_update": False, + "_severity_override": None, + "_dedup_suffix": "", + "distance_km": 160.0, + } + + # Native-path canonical data (as to_event() would set it) + native_canonical = { + "magnitude": 3.3, + "depth_km": 11.169, + "lat": 44.46, + "lon": -112.6108, + "place": "19 km S of Lima, Montana", + "tsunami": False, # native has no tsunami flag + "pager": None, # native has no PAGER + "occurred_at": None, + "event_id": "us6000t9bn", + "is_update": False, + "_severity_override": None, + "_dedup_suffix": "", + "distance_km": 160.0, + } + + with pinned_time(_AT): + central_wire = qfmt(_make_fake_event(central_canonical), + now=_AT, budget=140) + native_wire = qfmt(_make_fake_event(native_canonical), + now=_AT, budget=140) + + assert_byte_identical(native_wire, central_wire), ( + f"Native and Central renders must be byte-identical:\n" + f" Central: {central_wire!r}\n" + f" Native: {native_wire!r}" + ) + + def test_native_to_event_uses_canonical_keys(self): + """to_event() data dict has the canonical keys the formatter reads.""" + from unittest.mock import MagicMock + from meshai.env.usgs_quake import USGSQuakeAdapter + + cfg = MagicMock() + cfg.feed_url = "https://example.com/feed" + cfg.min_magnitude = 1.0 + cfg.bbox = [] + cfg.region = "magic_valley" + cfg.tick_seconds = 300 + + adapter = USGSQuakeAdapter(cfg) + raw_evt = { + "event_id": "us_test_identity", + "magnitude": 3.5, + "place": "5 km SW of Twin Falls, Idaho", + "depth_km": 7.5, + "lat": 42.5, + "lon": -114.5, + "quake_time": 1_783_000_000.0, + "fetched_at": 1_783_000_010.0, + "expires": 1_783_086_400.0, + "severity": "priority", + } + event = adapter.to_event(raw_evt) + + assert event is not None, "to_event() must return an Event for valid input" + assert event.data is not None, "event.data must not be None" + + canonical_keys = { + "magnitude", "depth_km", "lat", "lon", "place", + "tsunami", "pager", "occurred_at", "event_id", + } + missing = canonical_keys - set(event.data.keys()) + assert not missing, ( + f"to_event() event.data missing canonical keys: {missing}\n" + f"Got keys: {sorted(event.data.keys())}" + ) + + # Spot-check values + assert event.data["magnitude"] == 3.5 + assert event.data["lat"] == 42.5 + assert event.data["lon"] == -114.5 + assert event.data["depth_km"] == 7.5 + assert event.data["tsunami"] is False + assert event.data["pager"] is None + assert event.data["event_id"] == "us_test_identity" + + +# ───────────────────────────────────────────────────────────────────────────── +# 3. Gate-sequence — old gating vs new decide() — identical decisions +# ───────────────────────────────────────────────────────────────────────────── + +def _make_envelope(*, event_id, mag, lat, lon, depth_km=10.0, place=None, + tsunami=0, alert=None, time_ms=1_780_000_000_000): + """Build a minimal Central-style quake envelope for gate-sequence testing.""" + place = place or f"near test location ({lat:.1f},{lon:.1f})" + return { + "envelope": { + "id": event_id, + "data": { + "id": event_id, + "adapter": "usgs_quake", + "category": "quake.event.test", + "severity": 0, + "geo": {"centroid": [lon, lat]}, + "data": { + "id": event_id, + "magnitude": mag, + "place": place, + "depth_km": depth_km, + "time_ms": time_ms, + "tsunami": tsunami, + "alert": alert, + "latitude": lat, + "longitude": lon, + "depth": depth_km, + }, + }, + }, + "subject": "central.quake.event.test.unknown", + "captured_epoch": int(time_ms / 1000), + } + + +class TestGateSequence: + """Gate parity: old handle_quake decisions match new gating.quake.decide().""" + + @pytest.fixture(autouse=True) + def _db(self, mem_db): + """All tests in this class share the same mem_db.""" + self.db = mem_db + + def _old_gate(self, fixture, *, now): + """Old path: handle_quake returning non-None = broadcast.""" + from meshai.central.quake_handler import handle_quake + env = fixture["envelope"] + wire = handle_quake(env, fixture["subject"], data={}, now=int(now)) + return wire is not None + + def _new_gate(self, fixture, *, now): + """New path: gating.quake.decide().""" + from meshai.notifications.gating.quake import decide + env = fixture["envelope"] + inner = env.get("data") or {} + d = inner.get("data") or {} + geo = inner.get("geo") or {} + cent = geo.get("centroid") or [] + lon, lat = (cent[0], cent[1]) if len(cent) >= 2 else (None, None) + tms = d.get("time_ms") + occurred_at = None + if isinstance(tms, (int, float)): + occurred_at = int(tms / 1000) if tms > 1e12 else int(tms) + + canonical = { + "magnitude": d.get("magnitude"), + "depth_km": d.get("depth_km") or d.get("depth"), + "lat": lat, + "lon": lon, + "place": d.get("place"), + "tsunami": bool(d.get("tsunami")), + "pager": d.get("alert"), + "occurred_at": occurred_at, + "event_id": d.get("id") or inner.get("id"), + } + return decide(canonical, source="usgs_quake", now=float(now)) + + def test_gate_sequence_matches(self): + """Four-event sequence: old and new gates make identical broadcast/suppress decisions. + + Sequence (each event has a DISTINCT event_id — the commit/suppress + cycle is tested separately in test_suppress_after_commit): + [0] M2.0, far (below all thresholds) → suppress + [1] M2.7, within Idaho (regional gate) → broadcast + [2] M3.5, anywhere (global floor) → broadcast + [3] M6.0 + tsunami (any-magnitude tsunami gate) → broadcast + + Gate decisions (broadcast True/False) must match between old and new. + NOTE: PAGER/update-prefix are formatter-only tier-b changes; they do + NOT affect gate decisions — any divergence here is a regression. + """ + t_base = 1_780_000_000.0 + + # [0] M2.0 far outside Idaho (lat=10.0, lon=140.0 → Japan) + fx0 = _make_envelope(event_id="gs_seq_0", mag=2.0, lat=10.0, lon=140.0, + time_ms=int(t_base * 1000)) + # [1] M2.7 within 250mi of Idaho centroid (Wyoming border) + fx1 = _make_envelope(event_id="gs_seq_1", mag=2.7, lat=44.09, lon=-115.96, + time_ms=int((t_base + 100) * 1000)) + # [2] M3.5 anywhere (global_mag_floor = 3.0 exceeded) + fx2 = _make_envelope(event_id="gs_seq_2", mag=3.5, lat=10.0, lon=140.0, + time_ms=int((t_base + 200) * 1000)) + # [3] M6.0 + tsunami (any magnitude with tsunami → broadcast) + fx3 = _make_envelope(event_id="gs_seq_3", mag=6.0, lat=35.0, lon=141.0, + tsunami=1, time_ms=int((t_base + 300) * 1000)) + + ordered = [fx0, fx1, fx2, fx3] + timeline = [t_base, t_base + 100, t_base + 200, t_base + 300] + + results = run_gate_sequence( + self._old_gate, + self._new_gate, + ordered, + timeline=timeline, + ) + + mismatches = [r for r in results if not r["match"]] + assert not mismatches, ( + "Gate sequence mismatch between old handle_quake and new decide():\n" + + "\n".join( + f" step {r['fixture_n']}: old={r['old_broadcast']} " + f"new={r['new_broadcast']} diffs={r['diffs']}" + for r in mismatches + ) + ) + + # Verify expected pattern + assert results[0]["old_broadcast"] is False, "M2.0 far must be suppressed" + assert results[1]["old_broadcast"] is True, "M2.7 Idaho must broadcast" + assert results[2]["old_broadcast"] is True, "M3.5 global must broadcast" + assert results[3]["old_broadcast"] is True, "M6.0+tsunami must broadcast" + + def test_suppress_after_commit(self): + """After commit, the same event_id is suppressed by both old and new gates. + + The run_gate_sequence harness does not call commits between steps, so + the commit+suppress lifecycle is tested here separately by manual + sequencing. + """ + from meshai.central.quake_handler import handle_quake + from meshai.notifications.gating.quake import decide + + t0 = 1_780_000_000.0 + event_id = "suppress_after_commit_test" + + fx = _make_envelope(event_id=event_id, mag=3.5, lat=44.09, lon=-115.96, + time_ms=int(t0 * 1000)) + env = fx["envelope"] + + # First arrival: both old and new broadcast + data1 = {} + old_wire1 = handle_quake(env, fx["subject"], data=data1, now=int(t0)) + assert old_wire1 is not None, "First arrival must broadcast (old)" + + # Build canonical from fixture for new gate + inner = env["data"] + d = inner["data"] + geo = inner["geo"] + cent = geo["centroid"] + canonical = { + "magnitude": d["magnitude"], + "depth_km": d.get("depth_km") or d.get("depth"), + "lat": cent[1], "lon": cent[0], + "place": d.get("place"), + "tsunami": bool(d.get("tsunami")), + "pager": d.get("alert"), + "occurred_at": int(d["time_ms"] / 1000), + "event_id": event_id, + } + # Since old gate already wrote the row (INSERT), new gate sees the + # same DB state. Both should broadcast on first arrival. + # (We test new gate's second call AFTER commit below) + + # Call commit (simulates confirmed delivery) + assert "_on_broadcast_committed" in data1, "commit callback must be attached" + data1["_on_broadcast_committed"](t0 + 1.0) + + # Second arrival with same event_id — old gate must suppress + old_wire2 = handle_quake(env, fx["subject"], data={}, now=int(t0 + 60)) + assert old_wire2 is None, "Old gate must suppress after commit" + + # New gate must also suppress + new_result2 = decide(canonical, source="usgs_quake", now=t0 + 60) + assert new_result2.broadcast is False, "New gate must suppress after commit" + + def test_severity_override_from_decide(self): + """decide() sets _severity_override=immediate for tsunami/PAGER.""" + from meshai.notifications.gating.quake import decide + + # Tsunami + canonical_tsunami = { + "magnitude": 4.5, "depth_km": 10.0, "lat": 35.0, "lon": 141.0, + "place": "off Japan", "tsunami": True, "pager": None, + "occurred_at": None, "event_id": "sv_tsunami_test", + } + result_ts = decide(canonical_tsunami, source="usgs_quake", now=_AT) + assert result_ts.broadcast is True + assert result_ts.data_patch.get("_severity_override") == "immediate" + + # PAGER orange + canonical_pager = { + "magnitude": 2.0, "depth_km": 10.0, "lat": 10.0, "lon": 140.0, + "place": "Pacific Ocean", "tsunami": False, "pager": "orange", + "occurred_at": None, "event_id": "sv_pager_test", + } + result_pg = decide(canonical_pager, source="usgs_quake", now=_AT) + assert result_pg.broadcast is True + assert result_pg.data_patch.get("_severity_override") == "immediate" + + def test_dedup_suffix_is_empty(self): + """decide() data_patch has _dedup_suffix='' (bare event.id used for dedup).""" + from meshai.notifications.gating.quake import decide + + canonical = { + "magnitude": 3.5, "depth_km": 10.0, "lat": 44.0, "lon": -114.0, + "place": "near Stanley, Idaho", "tsunami": False, "pager": None, + "occurred_at": None, "event_id": "dedup_suffix_test", + } + result = decide(canonical, source="usgs_quake", now=_AT) + assert result.broadcast is True + assert result.data_patch.get("_dedup_suffix") == "" + + def test_is_update_always_false_in_patch(self): + """decide() data_patch always has is_update=False (v0.5.9 no-Update rule).""" + from meshai.notifications.gating.quake import decide + + canonical = { + "magnitude": 3.0, "depth_km": 8.0, "lat": 44.0, "lon": -114.5, + "place": "central Idaho", "tsunami": False, "pager": None, + "occurred_at": None, "event_id": "is_update_test", + } + result = decide(canonical, source="usgs_quake", now=_AT) + assert result.broadcast is True + assert result.data_patch.get("is_update") is False + + +# ───────────────────────────────────────────────────────────────────────────── +# 4. Schema conformance — to_event() canonical data completeness +# ───────────────────────────────────────────────────────────────────────────── + +class TestSchemaConformance: + """env/usgs_quake.py to_event() emits exactly the canonical key set.""" + + @pytest.fixture + def adapter(self): + from unittest.mock import MagicMock + from meshai.env.usgs_quake import USGSQuakeAdapter + cfg = MagicMock() + cfg.feed_url = "https://example.com/feed" + cfg.min_magnitude = 1.0 + cfg.bbox = [] + cfg.region = "magic_valley" + cfg.tick_seconds = 300 + return USGSQuakeAdapter(cfg) + + CANONICAL_KEYS = frozenset({ + "magnitude", "depth_km", "lat", "lon", "place", + "tsunami", "pager", "occurred_at", "event_id", + }) + + def _raw_evt(self, **overrides): + base = { + "event_id": "conform_test", + "magnitude": 3.1, + "place": "5 km NW of test, Idaho", + "depth_km": 9.0, + "lat": 44.0, + "lon": -114.5, + "quake_time": 1_783_000_000.0, + "fetched_at": 1_783_000_010.0, + "expires": 1_783_086_400.0, + "severity": "routine", + } + base.update(overrides) + return base + + def test_all_canonical_keys_present(self, adapter): + """event.data contains all canonical schema keys.""" + event = adapter.to_event(self._raw_evt()) + assert event is not None + missing = self.CANONICAL_KEYS - set(event.data.keys()) + assert not missing, f"Missing canonical keys: {missing}" + + def test_no_extra_non_canonical_fields_cause_formatter_crash(self, adapter): + """Extra fields in event.data (e.g. raw USGS keys) don't crash the formatter.""" + from meshai.notifications.formatters.quake import format as qfmt + + event = adapter.to_event(self._raw_evt()) + assert event is not None + + # Inject extra keys that might come from Central enrichment + event.data["_enriched"] = {"geocoder": {"city": "TestCity"}} + event.data["sig"] = 123 + + with pinned_time(_AT): + result = qfmt(event, now=_AT, budget=140) + + assert result is not None + assert "M3.1" in result + assert len(result) <= 140 + + def test_tsunami_defaults_false(self, adapter): + """Native to_event() sets tsunami=False (native feed has no tsunami data).""" + event = adapter.to_event(self._raw_evt()) + assert event.data["tsunami"] is False + + def test_pager_defaults_none(self, adapter): + """Native to_event() sets pager=None (PAGER comes from Central only).""" + event = adapter.to_event(self._raw_evt()) + assert event.data["pager"] is None + + def test_event_id_matches_raw_evt(self, adapter): + """event.data["event_id"] matches the source event_id.""" + event = adapter.to_event(self._raw_evt(event_id="my_quake_id")) + assert event.data["event_id"] == "my_quake_id" + + def test_missing_depth_km_yields_none(self, adapter): + """to_event() handles missing depth gracefully (depth_km=None in data).""" + raw = self._raw_evt() + del raw["depth_km"] # simulate missing depth + event = adapter.to_event(raw) + assert event is not None + assert event.data.get("depth_km") is None + + def test_formatter_budget_respected(self, adapter): + """Formatter output fits within budget for worst-case place string.""" + from meshai.notifications.formatters.quake import format as qfmt + + raw = self._raw_evt( + magnitude=7.9, + place="293 km SSW of a pathologically long place description island " + "region in the remote northern pacific ocean near absolutely nowhere " + "at all off the coast of the far edge of the map", + depth_km=12.0, + lat=44.123, + lon=-114.987, + ) + event = adapter.to_event(raw) + assert event is not None + with pinned_time(_AT): + result = qfmt(event, now=_AT, budget=140) + assert len(result) <= 140, f"{len(result)} chars:\n{result!r}" + assert "M7.9" in result diff --git a/work/tests/test_shadow_inert.py b/work/tests/test_shadow_inert.py index 5177269..3bd4968 100644 --- a/work/tests/test_shadow_inert.py +++ b/work/tests/test_shadow_inert.py @@ -136,10 +136,16 @@ class TestShadowInertWhenNoDecider: # --------------------------------------------------------------------------- class TestShadowRenderInertWhenNoFormatter: - """MESHAI_SHADOW_CATEGORIES set but FORMATTERS empty → still no-op.""" + """MESHAI_SHADOW_CATEGORIES set but no formatter registered → still no-op. + + Note: earthquake_event has a formatter in Phase 1+; this class uses + wildfire_incident which remains un-migrated and has no formatter entry. + """ + + _CATEGORY = "wildfire_incident" def setup_method(self): - os.environ["MESHAI_SHADOW_CATEGORIES"] = "earthquake_event" + os.environ["MESHAI_SHADOW_CATEGORIES"] = self._CATEGORY _reset_shadow_cache() def teardown_method(self): @@ -150,7 +156,7 @@ class TestShadowRenderInertWhenNoFormatter: """get_formatter returns None → shadow_render exits before any file write.""" monkeypatch.setattr(shadow_mod, "_SHADOW_DIR", str(tmp_path / "shadow")) shadow_mod.shadow_render( - "earthquake_event", + self._CATEGORY, _FakeEvent(), old_wire="old wire string", ) @@ -160,7 +166,7 @@ class TestShadowRenderInertWhenNoFormatter: """shadow_render must not propagate any exception.""" try: shadow_mod.shadow_render( - "earthquake_event", + self._CATEGORY, None, # intentionally bad input — must not raise old_wire="anything", ) diff --git a/work/tests/test_swpc_handler.py b/work/tests/test_swpc_handler.py index ed68376..5ea0d65 100644 --- a/work/tests/test_swpc_handler.py +++ b/work/tests/test_swpc_handler.py @@ -13,10 +13,13 @@ def mem_db(monkeypatch, tmp_path): persistence_db._initialised.clear() close_thread_connection() conn = init_db() - # Clear module-level geomag dedup cache between tests + # Clear module-level geomag dedup caches between tests. + # Phase-1: _geomag_recent moved to gating.swpc._geomag_window. from meshai.central import swpc_handler as _swpc_mod if hasattr(_swpc_mod, '_geomag_recent'): _swpc_mod._geomag_recent.clear() + from meshai.notifications.gating import swpc as _swpc_gate + _swpc_gate._geomag_window.clear() yield conn close_thread_connection() persistence_db._initialised.discard(db_path) diff --git a/work/tests/test_swpc_refactor.py b/work/tests/test_swpc_refactor.py new file mode 100644 index 0000000..e59b0a0 --- /dev/null +++ b/work/tests/test_swpc_refactor.py @@ -0,0 +1,697 @@ +"""Phase-1 SWPC refactor tests. + +Six test groups: + +1. Parity — for a kindex-style fixture and a flare fixture, the new formatter + produces output equivalent to old _render() (noting tier-b severity fix). + +2. Cross-source identity — same Kp from swpc_kindex and swpc_alerts shares + the 600s geomag dedup window (committed broadcast suppresses the second). + +3. Gate sequence — Kp crossing G1 → G3 → G3-within-600s-window → G5. + Verifies in-window suppression and G5 passes (different scale_code). + +4. Flare R-scale floor — R1/R2 suppressed, R3+ passes. + +5. Schema conformance — to_event() emits all required canonical fields. + +6. Proton NOT registered — solar_radiation_storm is absent from both + FORMATTERS and DECIDERS registries. +""" +from __future__ import annotations + +import pytest + +from meshai.persistence import close_thread_connection, init_db +from meshai.persistence import db as persistence_db +from tests.harness.goldens import pinned_time + + +# ── Shared clock epoch ─────────────────────────────────────────────────────── +_AT = 1_783_200_000.0 # 2026-07-03T00:00:00Z (pinned) + +# ── DB fixture ─────────────────────────────────────────────────────────────── + +@pytest.fixture +def mem_db(monkeypatch, tmp_path): + db_path = str(tmp_path / "swpc-refactor-test.sqlite") + monkeypatch.setenv("MESHAI_DB_PATH", db_path) + persistence_db._initialised.clear() + close_thread_connection() + conn = init_db() + # Clear the gating module's geomag window between tests. + from meshai.notifications.gating import swpc as _swpc_gate + _swpc_gate._geomag_window.clear() + yield conn + close_thread_connection() + persistence_db._initialised.discard(db_path) + + +# ── Helpers ────────────────────────────────────────────────────────────────── + +def _make_fake_event(data: dict): + """Minimal fake Event for calling the formatter without the full pipeline.""" + class _FakeEvent: + pass + e = _FakeEvent() + e.data = data + return e + + +def _kindex_env(*, kp: float, event_id: str): + """Build a Central-style swpc_kindex envelope.""" + return { + "id": event_id, + "subject": "central.space.kindex", + "data": { + "id": event_id, + "adapter": "swpc_kindex", + "category": "space.kindex", + "severity": 0, + "geo": {}, + "data": {"id": event_id, "kp_index": kp, "time": "2026-07-04T05:00:00Z"}, + }, + } + + +def _alert_env(*, event_id: str, kp: float | None = None, + flare_class: str | None = None): + """Build a Central-style swpc_alerts envelope.""" + d: dict = {"id": event_id, "product_id": event_id, + "time": "2026-07-04T05:10:00Z"} + if kp is not None: + d["kp_index"] = kp + if flare_class is not None: + d["flare_class"] = flare_class + return { + "id": event_id, + "subject": "central.space.alert." + event_id.lower(), + "data": { + "id": event_id, + "adapter": "swpc_alerts", + "category": "space.alert", + "severity": 0, + "geo": {}, + "data": d, + }, + } + + +def _commit(data: dict, t: float) -> None: + cb = data.get("_on_broadcast_committed") + if cb is not None: + cb(float(t)) + + +# ───────────────────────────────────────────────────────────────────────────── +# 1. Parity — formatter output matches old _render() (with tier-b severity note) +# ───────────────────────────────────────────────────────────────────────────── + +class TestFormatterParity: + """formatters/swpc.format() renders equivalent output to swpc_handler._render().""" + + def _render_old(self, event_kind: str, scale_code: str, label: str, + scalar_str: str, *, detail: str = "", time_tag: str = "") -> str: + from meshai.central.swpc_handler import _render + return _render(event_kind, scale_code, label, scalar_str, + is_update=False, detail=detail, time_tag=time_tag) + + def test_kindex_g3_parity(self, mem_db): + """Kp=7 (G3) kindex envelope → new formatter ≈ old _render. + + Tier-b note: the only intentional delta is _severity_override (now + "priority" instead of missing/routine), which does NOT affect the + wire text — parity is exact for the text body. + """ + from meshai.notifications.formatters.swpc import format as sfmt + + # Canonical data as handle_swpc would build it for Kp=7. + canonical = { + "event_id": "kp_parity_g3", + "driver": "kp", + "scalar": 7.0, + "scale_code": "G3", + "message": "HF degraded, aurora possible", + "issued_at": "2026-07-04T05:00:00Z", + } + + old_wire = self._render_old( + "geomag", "G3", "strong", "Kp7", + detail="HF degraded, aurora possible", + time_tag="2026-07-04 05:00", + ) + + with pinned_time(_AT): + new_wire = sfmt(_make_fake_event(canonical), now=_AT, budget=140) + + # Content must match: same line 1 and line 2. + assert "G3" in new_wire, f"scale_code missing from wire: {new_wire!r}" + assert "Kp7" in new_wire, f"scalar 'Kp7' missing from wire: {new_wire!r}" + assert "Geomagnetic Storm" in new_wire + + # Old wire content also present + assert "G3" in old_wire + assert "Kp7" in old_wire + assert new_wire == old_wire, ( + f"Parity failure for G3/Kp7:\n old: {old_wire!r}\n new: {new_wire!r}" + ) + + def test_flare_x1_r3_parity(self, mem_db): + """X1.0 flare (R3) alert → new formatter ≈ old _render. + + Fixture mirrors swpc_last/0003.json (XX0S, X1.0 flare, R3 Strong). + """ + from meshai.notifications.formatters.swpc import format as sfmt + + canonical = { + "event_id": "flare_x10_parity", + "driver": "flare", + "scalar": "X1.0", + "scale_code": "R3", + "message": "HF radio fading, GPS may glitch", + "issued_at": "2026-06-03T11:59:00Z", + } + + old_wire = self._render_old( + "flare", "R3", "strong", "X1.0", + detail="HF radio fading, GPS may glitch", + time_tag="2026-06-03 11:59", + ) + + with pinned_time(_AT): + new_wire = sfmt(_make_fake_event(canonical), now=_AT, budget=140) + + assert "R3" in new_wire + assert "X1.0" in new_wire + assert "Solar Flare" in new_wire + assert new_wire == old_wire, ( + f"Parity failure for X1.0/R3:\n old: {old_wire!r}\n new: {new_wire!r}" + ) + + def test_g5_kp9_parity(self, mem_db): + """Kp=9 (G5) renders correctly — extreme label and scalar.""" + from meshai.notifications.formatters.swpc import format as sfmt + + canonical = { + "event_id": "kp_g5_parity", + "driver": "kp", + "scalar": 9.0, + "scale_code": "G5", + "message": "Widespread power disruptions possible", + "issued_at": "2026-07-04T08:00:00Z", + } + + old_wire = self._render_old( + "geomag", "G5", "extreme", "Kp9", + detail="Widespread power disruptions possible", + time_tag="2026-07-04 08:00", + ) + + with pinned_time(_AT): + new_wire = sfmt(_make_fake_event(canonical), now=_AT, budget=140) + + assert "G5" in new_wire + assert "Kp9" in new_wire + assert new_wire == old_wire, ( + f"G5 parity failure:\n old: {old_wire!r}\n new: {new_wire!r}" + ) + + def test_null_scalar_renders_without_dash_tail(self, mem_db): + """Native path: scalar=None → renders without '— Kp?' tail.""" + from meshai.notifications.formatters.swpc import format as sfmt + + canonical = { + "event_id": "native_g3", + "driver": "kp", + "scalar": None, + "scale_code": "G3", + "message": "", + "issued_at": None, + } + + with pinned_time(_AT): + wire = sfmt(_make_fake_event(canonical), now=_AT, budget=140) + + assert "G3" in wire + assert "Geomagnetic Storm" in wire + # No "—" dash when scalar is None (no Kp to show) + assert "Kp" not in wire, f"Unexpected Kp in wire when scalar=None: {wire!r}" + + +# ───────────────────────────────────────────────────────────────────────────── +# 2. Cross-source identity — geomag 600s window shared across sub-adapters +# ───────────────────────────────────────────────────────────────────────────── + +class TestCrossSourceIdentity: + """Same Kp/scale from two different sub-adapters shares the 600s window.""" + + @pytest.fixture(autouse=True) + def _setup(self, mem_db): + self.db = mem_db + + def test_kindex_then_alert_same_g3_suppressed(self): + """swpc_kindex G3 → commit → swpc_alerts G3 within 600s → suppress. + + Uses different event_ids (realistic: kindex and alerts have distinct ids). + """ + from meshai.notifications.gating.swpc import decide, _geomag_window + + t0 = _AT + canonical_kindex = { + "event_id": "ci_kindex_g3", + "driver": "kp", + "scalar": 7.0, + "scale_code": "G3", + "message": "", + "issued_at": None, + } + canonical_alert = { + "event_id": "ci_alert_g3", # different event_id + "driver": "kp", + "scalar": 7.0, + "scale_code": "G3", + "message": "", + "issued_at": None, + } + + # First broadcast: swpc_kindex + gate1 = decide(canonical_kindex, source="swpc", now=t0) + assert gate1.broadcast, "First G3 from kindex must broadcast" + + # Commit fires the window stamp + gate1.commit(t0 + 1.0) + assert _geomag_window.get("G3") == t0 + 1.0, ( + "Window stamp must be set on commit, not on decision" + ) + + # Second broadcast: swpc_alerts, same G3, within 600s + gate2 = decide(canonical_alert, source="swpc", now=t0 + 300) + assert not gate2.broadcast, ( + "Second G3 from alerts within 600s must be suppressed by window" + ) + assert "geomag dedup" in gate2.reason.lower(), ( + f"Suppression reason must mention geomag dedup: {gate2.reason!r}" + ) + + def test_window_expires_after_600s(self): + """After 600s the window resets and a new G3 can broadcast.""" + from meshai.notifications.gating.swpc import decide, _geomag_window + + t0 = _AT + c1 = {"event_id": "window_1", "driver": "kp", "scalar": 7.0, + "scale_code": "G3", "message": "", "issued_at": None} + c2 = {"event_id": "window_2", "driver": "kp", "scalar": 7.0, + "scale_code": "G3", "message": "", "issued_at": None} + + gate1 = decide(c1, source="swpc", now=t0) + assert gate1.broadcast + gate1.commit(t0 + 1.0) + + # 601s later — window expired + gate2 = decide(c2, source="swpc", now=t0 + 601) + assert gate2.broadcast, "G3 after 601s should broadcast (window expired)" + + +# ───────────────────────────────────────────────────────────────────────────── +# 3. Gate sequence — Kp G1 → G3 → G3-in-window → G5 +# ───────────────────────────────────────────────────────────────────────────── + +class TestGateSequence: + """Kp crossing G1→G3→G3-within-window→G5 gate sequence.""" + + @pytest.fixture(autouse=True) + def _setup(self, mem_db): + self.db = mem_db + + def test_kp_gate_sequence(self): + """Four-step sequence verifying floor, window, and scale-escalation. + + Step 0: G1 (Kp=5) → below G3 floor → suppress + Step 1: G3 (Kp=7) → first sighting → broadcast + Step 2: G3 again, within 600s → geomag window → suppress + Step 3: G5 (Kp=9), within 600s → NEW scale_code "G5" → broadcast + """ + from meshai.notifications.gating.swpc import decide + + t0 = _AT + + # Step 0: G1 — below floor + c_g1 = {"event_id": "seq_g1", "driver": "kp", "scalar": 5.0, + "scale_code": "G1", "message": "", "issued_at": None} + gate0 = decide(c_g1, source="swpc", now=t0) + assert not gate0.broadcast, "G1 must be suppressed (below G3 floor)" + assert "floor" in gate0.reason.lower() or "below" in gate0.reason.lower() + + # Step 1: G3 — first sighting + c_g3 = {"event_id": "seq_g3_first", "driver": "kp", "scalar": 7.0, + "scale_code": "G3", "message": "", "issued_at": None} + gate1 = decide(c_g3, source="swpc", now=t0 + 10) + assert gate1.broadcast, "G3 first sighting must broadcast" + assert gate1.data_patch.get("_severity_override") == "priority" + assert gate1.data_patch.get("_cooldown_suffix") == "G3" + + # Commit: arm window + gate1.commit(t0 + 11) + + # Step 2: G3 from different sub-adapter, within 600s → suppressed by window + c_g3b = {"event_id": "seq_g3_second", "driver": "kp", "scalar": 7.0, + "scale_code": "G3", "message": "", "issued_at": None} + gate2 = decide(c_g3b, source="swpc", now=t0 + 200) + assert not gate2.broadcast, "G3 within 600s window must be suppressed" + + # Step 3: G5 escalation — different scale_code, window doesn't apply + c_g5 = {"event_id": "seq_g5", "driver": "kp", "scalar": 9.0, + "scale_code": "G5", "message": "", "issued_at": None} + gate3 = decide(c_g5, source="swpc", now=t0 + 300) + assert gate3.broadcast, "G5 must broadcast (different scale_code from G3)" + assert gate3.data_patch.get("_severity_override") == "immediate", ( + f"G5 must be 'immediate'; got {gate3.data_patch.get('_severity_override')!r}" + ) + + def test_commit_deferred_window_stamp(self): + """Window stamp happens on commit, not on decision.""" + from meshai.notifications.gating.swpc import decide, _geomag_window + + t0 = _AT + c = {"event_id": "deferred_stamp", "driver": "kp", "scalar": 7.0, + "scale_code": "G3", "message": "", "issued_at": None} + + # Before commit, window should not be stamped + pre_stamp = _geomag_window.get("G3") + gate = decide(c, source="swpc", now=t0) + assert gate.broadcast + post_decide_stamp = _geomag_window.get("G3") + assert post_decide_stamp == pre_stamp, ( + "Window must NOT be stamped at decision time — deferred to commit" + ) + + # After commit, window is stamped + gate.commit(t0 + 5.0) + assert _geomag_window.get("G3") == t0 + 5.0, ( + "Window must be stamped with committed_at on commit" + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# 4. Flare R-scale floor — R1/R2 suppressed, R3+ passes +# ───────────────────────────────────────────────────────────────────────────── + +class TestFlareRScaleFloor: + """R-scale floor gate: R1/R2 suppressed, R3/R4/R5 passes.""" + + @pytest.fixture(autouse=True) + def _setup(self, mem_db): + self.db = mem_db + + def _flare_canonical(self, scale_code: str, event_id: str, + scalar: str = "X1.0") -> dict: + return { + "event_id": event_id, + "driver": "flare", + "scalar": scalar, + "scale_code": scale_code, + "message": "", + "issued_at": None, + } + + def test_r1_suppressed(self): + from meshai.notifications.gating.swpc import decide + gate = decide(self._flare_canonical("R1", "r1_test"), source="swpc", now=_AT) + assert not gate.broadcast, "R1 must be suppressed (below R3 floor)" + + def test_r2_suppressed(self): + from meshai.notifications.gating.swpc import decide + gate = decide(self._flare_canonical("R2", "r2_test"), source="swpc", now=_AT) + assert not gate.broadcast, "R2 must be suppressed (below R3 floor)" + + def test_r3_broadcasts(self): + from meshai.notifications.gating.swpc import decide + gate = decide(self._flare_canonical("R3", "r3_test"), source="swpc", now=_AT) + assert gate.broadcast, "R3 must broadcast (meets floor)" + assert gate.data_patch.get("_severity_override") == "priority" + + def test_r4_broadcasts_immediate(self): + from meshai.notifications.gating.swpc import decide + gate = decide(self._flare_canonical("R4", "r4_test", scalar="X10.0"), + source="swpc", now=_AT) + assert gate.broadcast, "R4 must broadcast" + assert gate.data_patch.get("_severity_override") == "immediate" + + def test_r5_broadcasts_immediate(self): + from meshai.notifications.gating.swpc import decide + gate = decide(self._flare_canonical("R5", "r5_test", scalar="X20.0"), + source="swpc", now=_AT) + assert gate.broadcast, "R5 must broadcast" + assert gate.data_patch.get("_severity_override") == "immediate" + + def test_m5_flare_suppressed_via_handler(self, mem_db): + """M5.5 flare maps to R2 via old path → new arch suppresses at R2 floor.""" + from meshai.central.swpc_handler import handle_swpc + + env = { + "id": "m55_new_arch", + "subject": "central.space.alert.m55", + "data": { + "id": "m55_new_arch", + "adapter": "swpc_alerts", + "category": "space.alert", + "severity": 0, + "geo": {}, + "data": {"id": "m55_new_arch", "flare_class": "M5.5", + "time": "2026-07-04T06:00:00Z"}, + }, + } + wire = handle_swpc(env, env["subject"], data={}, now=int(_AT)) + assert wire is None, "M5.5 (R2) must be suppressed" + + def test_x1_flare_broadcasts_via_handler(self, mem_db): + """X1.0 flare maps to R3 → broadcasts via new arch.""" + from meshai.central.swpc_handler import handle_swpc + + env = { + "id": "x10_new_arch", + "subject": "central.space.alert.x10", + "data": { + "id": "x10_new_arch", + "adapter": "swpc_alerts", + "category": "space.alert", + "severity": 0, + "geo": {}, + "data": {"id": "x10_new_arch", "flare_class": "X1.0", + "time": "2026-07-04T06:00:00Z"}, + }, + } + wire = handle_swpc(env, env["subject"], data={}, now=int(_AT)) + assert wire is not None, "X1.0 (R3) must broadcast" + assert "R3" in wire + assert "X1.0" in wire + + +# ───────────────────────────────────────────────────────────────────────────── +# 5. Schema conformance — to_event() emits required canonical fields +# ───────────────────────────────────────────────────────────────────────────── + +class TestSchemaConformance: + """env/swpc.py to_event() emits canonical data schema fields.""" + + CANONICAL_KEYS = frozenset({ + "event_id", "driver", "scalar", "scale_code", "message", "issued_at", + }) + + def _make_swpc_evt(self, scale: str, level: int) -> dict: + """Build the internal evt dict that _update_events() produces.""" + scale_letter = scale.upper() + event_id = f"swpc_{scale.lower()}{level}" + severity = "priority" if level >= 3 else "routine" + return { + "source": "swpc", + "event_id": event_id, + "event_type": f"{scale_letter}{level} {scale_letter} Storm", + "scale": scale_letter, + "level": level, + "severity": severity, + "headline": f"{scale_letter}{level} in progress", + "expires": 9_999_999_999.0, + "areas": [], + "fetched_at": _AT, + } + + def test_g3_canonical_keys_present(self): + """G3 event has all canonical data keys.""" + from unittest.mock import MagicMock + from meshai.env.swpc import SWPCAdapter + + cfg = MagicMock() + adapter = SWPCAdapter(cfg) + evt = self._make_swpc_evt("g", 3) + event = adapter.to_event(evt) + + assert event is not None, "to_event() must return Event for G3" + assert event.data is not None, "event.data must not be None" + + missing = self.CANONICAL_KEYS - set(event.data.keys()) + assert not missing, ( + f"G3 event.data missing canonical keys: {missing}\n" + f"Got keys: {sorted(event.data.keys())}" + ) + + def test_g3_canonical_values(self): + """G3 event.data has correct driver/scale_code values.""" + from unittest.mock import MagicMock + from meshai.env.swpc import SWPCAdapter + + cfg = MagicMock() + adapter = SWPCAdapter(cfg) + evt = self._make_swpc_evt("g", 3) + event = adapter.to_event(evt) + + assert event.data["driver"] == "kp", ( + f"G-scale driver must be 'kp'; got {event.data['driver']!r}" + ) + assert event.data["scale_code"] == "G3" + assert event.data["scalar"] is None # not available from noaa-scales.json + assert event.data["event_id"] == "swpc_g3" + + def test_r3_canonical_values(self): + """R3 event.data has correct driver/scale_code values.""" + from unittest.mock import MagicMock + from meshai.env.swpc import SWPCAdapter + + cfg = MagicMock() + adapter = SWPCAdapter(cfg) + evt = self._make_swpc_evt("r", 3) + event = adapter.to_event(evt) + + assert event is not None + assert event.data["driver"] == "flare", ( + f"R-scale driver must be 'flare'; got {event.data['driver']!r}" + ) + assert event.data["scale_code"] == "R3" + + def test_s1_canonical_driver_none(self): + """S-scale (solar radiation storm) has driver=None (not in new arch).""" + from unittest.mock import MagicMock + from meshai.env.swpc import SWPCAdapter + + cfg = MagicMock() + adapter = SWPCAdapter(cfg) + evt = self._make_swpc_evt("s", 1) + event = adapter.to_event(evt) + + assert event is not None + # S-scale gets driver=None since it's not in the new arch + assert event.data["driver"] is None + + def test_canonical_data_doesnt_crash_formatter(self): + """G3 from native to_event() can be fed to the formatter without crashing.""" + from unittest.mock import MagicMock + from meshai.env.swpc import SWPCAdapter + from meshai.notifications.formatters.swpc import format as sfmt + + cfg = MagicMock() + adapter = SWPCAdapter(cfg) + evt = self._make_swpc_evt("g", 3) + event = adapter.to_event(evt) + + assert event is not None + with pinned_time(_AT): + result = sfmt(event, now=_AT, budget=140) + + assert result is not None + assert "G3" in result + assert len(result) <= 140, f"Budget exceeded: {len(result)} > 140" + + +# ───────────────────────────────────────────────────────────────────────────── +# 6. Proton NOT registered — solar_radiation_storm absent from registries +# ───────────────────────────────────────────────────────────────────────────── + +class TestProtonNotRegistered: + """solar_radiation_storm must not appear in either registry.""" + + def test_solar_radiation_storm_not_in_formatters(self): + from meshai.notifications.formatters import FORMATTERS + assert "solar_radiation_storm" not in FORMATTERS, ( + "solar_radiation_storm must NOT be in FORMATTERS " + "(proton events stay on legacy path)" + ) + + def test_solar_radiation_storm_not_in_deciders(self): + from meshai.notifications.gating import DECIDERS + assert "solar_radiation_storm" not in DECIDERS, ( + "solar_radiation_storm must NOT be in DECIDERS " + "(proton events stay on legacy path)" + ) + + def test_geomagnetic_storm_in_formatters(self): + from meshai.notifications.formatters import FORMATTERS + assert "geomagnetic_storm" in FORMATTERS, ( + "geomagnetic_storm must be in FORMATTERS" + ) + + def test_rf_propagation_alert_in_formatters(self): + from meshai.notifications.formatters import FORMATTERS + assert "rf_propagation_alert" in FORMATTERS, ( + "rf_propagation_alert must be in FORMATTERS" + ) + + def test_geomagnetic_storm_in_deciders(self): + from meshai.notifications.gating import DECIDERS + assert "geomagnetic_storm" in DECIDERS, ( + "geomagnetic_storm must be in DECIDERS" + ) + + def test_rf_propagation_alert_in_deciders(self): + from meshai.notifications.gating import DECIDERS + assert "rf_propagation_alert" in DECIDERS, ( + "rf_propagation_alert must be in DECIDERS" + ) + + def test_proton_stays_on_legacy_path(self): + """Proton events (S1+) still broadcast via legacy path in swpc_handler. + + Uses swpc_protons adapter with 15 pfu (S1 threshold). The legacy path + must still work — no regression from the new arch changes. + """ + import pytest + pytest.importorskip("meshai.central.swpc_handler") + + # This test needs a DB fixture — create one inline + import tempfile, os + from meshai.persistence import close_thread_connection, init_db + from meshai.persistence import db as persistence_db + + with tempfile.TemporaryDirectory() as tmp: + db_path = os.path.join(tmp, "proton-test.sqlite") + old_env = os.environ.get("MESHAI_DB_PATH") + os.environ["MESHAI_DB_PATH"] = db_path + persistence_db._initialised.clear() + close_thread_connection() + try: + init_db() + from meshai.central.swpc_handler import handle_swpc + + env = { + "id": "p_s1_legacy", + "subject": "central.space.proton_flux", + "data": { + "id": "p_s1_legacy", + "adapter": "swpc_protons", + "category": "space.proton_flux", + "severity": 0, + "geo": {}, + "data": {"id": "p_s1_legacy", "p10mev": 15.0, + "time": "2026-07-04T06:00:00Z"}, + }, + } + wire = handle_swpc(env, env["subject"], data={}, now=int(_AT)) + assert wire is not None, "S1 proton must still broadcast via legacy path" + assert "S1" in wire + assert "☢️" in wire + finally: + close_thread_connection() + persistence_db._initialised.discard(db_path) + if old_env is None: + os.environ.pop("MESHAI_DB_PATH", None) + else: + os.environ["MESHAI_DB_PATH"] = old_env