refactor(phase1): quake + swpc(Kp+flare) + avalanche, behind staged-cutover gate (#29)

* fix(fixtures): rewrite capture_fixtures.py to use ephemeral push subscribe

Previous script hardcoded stream="CENTRAL" which does not exist — Central
partitions into domain streams (CENTRAL_QUAKE, CENTRAL_SPACE, etc.).  It
also called pull_subscribe_bind() without await, making the fetch a no-op.

Fix: mirror the proven CentralConsumer.start() pattern — use
js.subscribe(subject, cb=..., AckPolicy.NONE, no durable) which auto-
discovers the correct stream via the subject filter, identical to how the
live consumer binds.  Messages are funnelled through asyncio.Queue with
an idle-timeout to detect drain completion.

Adds live captured fixtures:
- tests/fixtures/quake/  — 3 envelopes (CENTRAL_QUAKE stream, mode=all)
- tests/fixtures/swpc/   — 40 envelopes (CENTRAL_SPACE, mode=all, proton_flux history)
- tests/fixtures/swpc_last/ — 23 envelopes (mode=last: 21 alert variants + kindex + proton_flux)
Avalanche: confirmed empty off-season (CENTRAL_AVY stream, 0 messages).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(phase1): migrate quake, swpc(Kp+flare), avalanche + staged-cutover gate

First hazards on the source-agnostic formatter+decider path, behind a
staged-cutover gate so deploy = shadow-only (old path still broadcasts; new
path dry-run-diffed) until MESHAI_CUTOVER_CATEGORIES flips a category live.

- formatters/{quake,swpc,avalanche}.py + gating/{quake,swpc,avalanche}.py:
  source-agnostic format(event,*,now,budget) + decide(data,*,source,now)->GateResult.
  quake (earthquake_event, tier-b: render PAGER + live update-prefix), swpc
  (geomagnetic_storm + rf_propagation_alert, Kp+flare only; proton/solar_radiation
  _storm stays legacy; geomag 600s window re-homed off the module-global into
  gating/swpc with a deferred stamp; tier-b scale-based severity), avalanche
  (avalanche_warning/watch; centralseverity->NAADS 1-5 remap; synthetic fixtures
  off-season).
- central/{quake,swpc,avy}_handler.py bridges: cutover -> new decide()+canonical
  data; not-cutover -> exact legacy behavior. env/{usgs_quake,swpc,avalanche}.py
  emit canonical Event.data (avalanche stops precomposing). env/store.py generic
  native decider hook (cutover-gated).
- notifications/cutover.py (is_cutover via MESHAI_CUTOVER_CATEGORIES); composer
  dispatch + shadow hooks are cutover-aware (shadow no-ops once a category is live).
- scripts/capture_fixtures.py fixed (per-domain streams e.g. CENTRAL_QUAKE, await
  bind); real quake/swpc fixtures captured; avalanche synthetic.

Tests: +~150 (quake/swpc/avalanche parity+cross-source+gate-sequence+cutover);
0 new failures (34 baseline, 1426 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
malice 2026-07-04 15:58:46 -06:00 committed by GitHub
commit 4e23fc737d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
96 changed files with 7000 additions and 267 deletions

View file

@ -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 15.
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 JuneSeptember. 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 15); 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 15 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 15) ───────────────────────────────────
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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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/<category>.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()

View file

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

View file

@ -0,0 +1,101 @@
"""Avalanche advisory formatter — Phase-1 implementation.
Reads canonical event.data schema:
danger_level int NAADS 15 (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)

View file

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

View file

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

View file

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

View file

@ -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 15 (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 15 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 15 (handle_avy
performs the centralseverityNAADS 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,
)

View file

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

View file

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

View file

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

View file

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

View file

@ -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/<hazard>/<n>.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/<hazard>/ 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

41
work/tests/fixtures/avalanche/0000.json vendored Normal file
View file

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

41
work/tests/fixtures/avalanche/0001.json vendored Normal file
View file

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

41
work/tests/fixtures/avalanche/0002.json vendored Normal file
View file

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

42
work/tests/fixtures/avalanche/0003.json vendored Normal file
View file

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

82
work/tests/fixtures/quake/0000.json vendored Normal file
View file

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

82
work/tests/fixtures/quake/0001.json vendored Normal file
View file

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

82
work/tests/fixtures/quake/0002.json vendored Normal file
View file

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

36
work/tests/fixtures/swpc/0000.json vendored Normal file
View file

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

36
work/tests/fixtures/swpc/0001.json vendored Normal file
View file

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

36
work/tests/fixtures/swpc/0002.json vendored Normal file
View file

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

36
work/tests/fixtures/swpc/0003.json vendored Normal file
View file

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

36
work/tests/fixtures/swpc/0004.json vendored Normal file
View file

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

36
work/tests/fixtures/swpc/0005.json vendored Normal file
View file

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

36
work/tests/fixtures/swpc/0006.json vendored Normal file
View file

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

36
work/tests/fixtures/swpc/0007.json vendored Normal file
View file

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

36
work/tests/fixtures/swpc/0008.json vendored Normal file
View file

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

36
work/tests/fixtures/swpc/0009.json vendored Normal file
View file

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

36
work/tests/fixtures/swpc/0010.json vendored Normal file
View file

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

36
work/tests/fixtures/swpc/0011.json vendored Normal file
View file

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

36
work/tests/fixtures/swpc/0012.json vendored Normal file
View file

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

36
work/tests/fixtures/swpc/0013.json vendored Normal file
View file

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

36
work/tests/fixtures/swpc/0014.json vendored Normal file
View file

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

36
work/tests/fixtures/swpc/0015.json vendored Normal file
View file

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

36
work/tests/fixtures/swpc/0016.json vendored Normal file
View file

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

36
work/tests/fixtures/swpc/0017.json vendored Normal file
View file

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

36
work/tests/fixtures/swpc/0018.json vendored Normal file
View file

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

36
work/tests/fixtures/swpc/0019.json vendored Normal file
View file

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

36
work/tests/fixtures/swpc/0020.json vendored Normal file
View file

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

36
work/tests/fixtures/swpc/0021.json vendored Normal file
View file

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

36
work/tests/fixtures/swpc/0022.json vendored Normal file
View file

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

36
work/tests/fixtures/swpc/0023.json vendored Normal file
View file

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

36
work/tests/fixtures/swpc/0024.json vendored Normal file
View file

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

36
work/tests/fixtures/swpc/0025.json vendored Normal file
View file

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

36
work/tests/fixtures/swpc/0026.json vendored Normal file
View file

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

36
work/tests/fixtures/swpc/0027.json vendored Normal file
View file

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

36
work/tests/fixtures/swpc/0028.json vendored Normal file
View file

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

36
work/tests/fixtures/swpc/0029.json vendored Normal file
View file

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

36
work/tests/fixtures/swpc/0030.json vendored Normal file
View file

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

36
work/tests/fixtures/swpc/0031.json vendored Normal file
View file

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

36
work/tests/fixtures/swpc/0032.json vendored Normal file
View file

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

36
work/tests/fixtures/swpc/0033.json vendored Normal file
View file

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

36
work/tests/fixtures/swpc/0034.json vendored Normal file
View file

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

36
work/tests/fixtures/swpc/0035.json vendored Normal file
View file

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

36
work/tests/fixtures/swpc/0036.json vendored Normal file
View file

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

36
work/tests/fixtures/swpc/0037.json vendored Normal file
View file

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

36
work/tests/fixtures/swpc/0038.json vendored Normal file
View file

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

36
work/tests/fixtures/swpc/0039.json vendored Normal file
View file

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

35
work/tests/fixtures/swpc_last/0000.json vendored Normal file
View file

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

35
work/tests/fixtures/swpc_last/0001.json vendored Normal file
View file

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

35
work/tests/fixtures/swpc_last/0002.json vendored Normal file
View file

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

35
work/tests/fixtures/swpc_last/0003.json vendored Normal file
View file

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

35
work/tests/fixtures/swpc_last/0004.json vendored Normal file
View file

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

35
work/tests/fixtures/swpc_last/0005.json vendored Normal file
View file

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

35
work/tests/fixtures/swpc_last/0006.json vendored Normal file
View file

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

35
work/tests/fixtures/swpc_last/0007.json vendored Normal file
View file

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

35
work/tests/fixtures/swpc_last/0008.json vendored Normal file
View file

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

35
work/tests/fixtures/swpc_last/0009.json vendored Normal file
View file

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

35
work/tests/fixtures/swpc_last/0010.json vendored Normal file
View file

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

35
work/tests/fixtures/swpc_last/0011.json vendored Normal file
View file

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

35
work/tests/fixtures/swpc_last/0012.json vendored Normal file
View file

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

35
work/tests/fixtures/swpc_last/0013.json vendored Normal file
View file

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

35
work/tests/fixtures/swpc_last/0014.json vendored Normal file
View file

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

35
work/tests/fixtures/swpc_last/0015.json vendored Normal file
View file

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

35
work/tests/fixtures/swpc_last/0016.json vendored Normal file
View file

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

35
work/tests/fixtures/swpc_last/0017.json vendored Normal file
View file

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

35
work/tests/fixtures/swpc_last/0018.json vendored Normal file
View file

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

35
work/tests/fixtures/swpc_last/0019.json vendored Normal file
View file

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

35
work/tests/fixtures/swpc_last/0020.json vendored Normal file
View file

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

36
work/tests/fixtures/swpc_last/0021.json vendored Normal file
View file

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

36
work/tests/fixtures/swpc_last/0022.json vendored Normal file
View file

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

View file

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

View file

@ -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<mag> — <place>"
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: "<emoji> 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}"
)

View file

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

View file

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

View file

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

View file

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

View file

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