mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 09:21:33 +00:00
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:
parent
f06bf3ce92
commit
4e23fc737d
96 changed files with 7000 additions and 267 deletions
|
|
@ -1,41 +1,36 @@
|
|||
"""Central avalanche advisory handler (avalanche_org adapter).
|
||||
"""Central avalanche advisory handler — Phase-1 refactored bridge.
|
||||
|
||||
Subscribes to CENTRAL_AVY stream via consumer.py routing.
|
||||
Adapter: avalanche_org
|
||||
Subjects: central.avy.advisory.> (active + tombstones in one consumer)
|
||||
|
||||
Wire format: multi-line, _meshai_precomposed=True (bypasses composer
|
||||
whitespace-collapse). Same pattern as nws_handler / quake_handler.
|
||||
Phase-1 refactor: ALL gating decisions are now delegated to
|
||||
`meshai.notifications.gating.avalanche.decide()`. This function is kept
|
||||
as a thin compatibility bridge so existing call-sites and tests remain stable
|
||||
while the new formatter+decider architecture is established.
|
||||
|
||||
Severity gate: uses danger_level (0-5) from data.data directly.
|
||||
centralseverity → NAADS mapping (tier-b):
|
||||
Central's envelope carries `severity` (= centralseverity) on a COMPRESSED
|
||||
5-point scale where 2=Considerable, 3=High, 4=Extreme (documented in the
|
||||
pre-refactor TODO comment). The canonical danger_level is NAADS 1–5.
|
||||
|
||||
TODO (verify before Central swap, October+):
|
||||
Confirm data.data.danger_level uses the NAADS 5-point scale:
|
||||
1=Low, 2=Moderate, 3=Considerable, 4=High, 5=Extreme
|
||||
The native path uses this scale and min_danger_level=3 means
|
||||
"Considerable and above" — correct for southern Idaho touring.
|
||||
Central's centralseverity uses a COMPRESSED scale (2=Considerable,
|
||||
3=High, 4=Extreme). If Central's danger_level follows centralseverity
|
||||
rather than NAADS, min_danger_level=3 silently becomes "High and above"
|
||||
at flip time, dropping every Considerable advisory.
|
||||
CHECK: read data.data.danger_level from a live CENTRAL_AVY envelope
|
||||
for a zone known to be rated Considerable. If the value is 2 (not 3),
|
||||
either remap min_danger_level=2 at flip time OR normalize inside
|
||||
handle_avy() before the gate comparison.
|
||||
Do not flip feed_source="central" without confirming this first.
|
||||
Do NOT use centralseverity as a gate — Central's scale is higher=more
|
||||
severe (4=Extreme, 3=High, 2=Considerable), which is the inverse of
|
||||
meshai's broadcast priority convention. Gate on danger_level only.
|
||||
NAADS mapping table (see also gating/avalanche.py docstring):
|
||||
centralseverity 0 → NAADS 1 (Low) [INFERRED, needs validation]
|
||||
centralseverity 1 → NAADS 2 (Moderate) [INFERRED, needs validation]
|
||||
centralseverity 2 → NAADS 3 (Considerable) [documented]
|
||||
centralseverity 3 → NAADS 4 (High) [documented]
|
||||
centralseverity 4 → NAADS 5 (Extreme) [documented]
|
||||
other → NAADS 0 (No Rating)
|
||||
|
||||
Off-season note: CENTRAL_AVY is empty June–September. Handler will
|
||||
receive no envelopes during off-season — this is correct and expected.
|
||||
The consumer sits idle; no action needed.
|
||||
⚠ NEEDS LIVE VALIDATION IN-SEASON (October+): confirm by reading a live
|
||||
Central avalanche envelope for a zone known to be rated Considerable
|
||||
and verifying centralseverity == 2.
|
||||
|
||||
Tombstones (central.avy.advisory.removed.*): handler returns None
|
||||
(no broadcast). The env_store's native-path change-detection handles
|
||||
zone retraction on the native path; on the central path, tombstones
|
||||
are consumed and acked silently so they don't pile up in the stream.
|
||||
Future: retraction broadcast ("AVY advisory lifted") could be added here.
|
||||
Strategy: prefer data.data.danger_level (direct from avalanche.org API,
|
||||
NAADS 1–5); fall back to _remap_centralseverity(inner.severity) when
|
||||
data.danger_level is absent or non-numeric.
|
||||
|
||||
Tombstones (central.avy.advisory.removed.*): handler returns None.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
|
@ -50,6 +45,65 @@ from meshai.persistence import get_db
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# NAADS danger level names (for fallback when data.danger_name is absent)
|
||||
_NAADS_NAMES: dict[int, str] = {
|
||||
0: "No Rating",
|
||||
1: "Low",
|
||||
2: "Moderate",
|
||||
3: "Considerable",
|
||||
4: "High",
|
||||
5: "Extreme",
|
||||
}
|
||||
|
||||
|
||||
def _remap_centralseverity(centralseverity: Any) -> int:
|
||||
"""Map Central's compressed severity int → NAADS 1–5 danger_level.
|
||||
|
||||
Mapping table (⚠ needs live validation in-season):
|
||||
0 → 1 (Low) [inferred]
|
||||
1 → 2 (Moderate) [inferred]
|
||||
2 → 3 (Considerable) [documented in pre-refactor avy_handler comments]
|
||||
3 → 4 (High) [documented]
|
||||
4 → 5 (Extreme) [documented]
|
||||
other → 0 (No Rating)
|
||||
|
||||
Formula: naads = centralseverity + 1 for 0 ≤ centralseverity ≤ 4
|
||||
"""
|
||||
try:
|
||||
sev = int(centralseverity)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
if 0 <= sev <= 4:
|
||||
return sev + 1
|
||||
return 0
|
||||
|
||||
|
||||
def _canonical_danger_level(d: dict, inner: dict) -> Optional[int]:
|
||||
"""Derive canonical NAADS danger_level from envelope fields.
|
||||
|
||||
Strategy (in order):
|
||||
1. data.data.danger_level — direct from avalanche.org, should be NAADS.
|
||||
2. _remap_centralseverity(inner.severity) — fallback via mapping table.
|
||||
3. None — caller should suppress.
|
||||
|
||||
Returns int or None.
|
||||
"""
|
||||
# Primary: data.data.danger_level (NAADS from avalanche.org)
|
||||
raw = d.get("danger_level")
|
||||
if isinstance(raw, (int, float)) and raw >= 0:
|
||||
return int(raw)
|
||||
# Fallback: remap from centralseverity
|
||||
fallback = _remap_centralseverity(inner.get("severity"))
|
||||
if fallback > 0:
|
||||
logger.debug(
|
||||
"avy_handler: danger_level absent in data.data — "
|
||||
"remapped centralseverity %r → NAADS %d",
|
||||
inner.get("severity"), fallback,
|
||||
)
|
||||
return fallback
|
||||
return None
|
||||
|
||||
|
||||
def _coerce_severity(sev: Any) -> Optional[str]:
|
||||
if sev is None:
|
||||
return None
|
||||
|
|
@ -67,9 +121,22 @@ def _now() -> int:
|
|||
|
||||
def handle_avy(envelope: dict, subject: str,
|
||||
data: Optional[dict] = None) -> Optional[str]:
|
||||
"""Handle a single CENTRAL_AVY envelope.
|
||||
"""Central path handler for NWAC/CAIC avalanche advisories.
|
||||
|
||||
Returns the wire string when a broadcast should fire, None otherwise.
|
||||
Phase-1 refactor: delegates gating to
|
||||
`meshai.notifications.gating.avalanche.decide()`. Formats via
|
||||
`_render()` for backward-compat callers (existing tests); the registered
|
||||
`formatters.avalanche.format()` re-renders from event.data at dispatch
|
||||
time with any tier-b changes (is_update prefix).
|
||||
|
||||
On broadcast:
|
||||
- Writes canonical data fields into the shared `data` dict so the Event
|
||||
carries structured fields (the formatter reads them at dispatch time).
|
||||
- Applies GateResult.data_patch (is_update, _severity_override).
|
||||
- Attaches _on_broadcast_committed for event_log.handled update.
|
||||
- Returns the _render() wire string for backward-compat.
|
||||
|
||||
On suppress: returns None (default-deny unchanged).
|
||||
"""
|
||||
if not isinstance(envelope, dict):
|
||||
return None
|
||||
|
|
@ -86,30 +153,53 @@ def handle_avy(envelope: dict, subject: str,
|
|||
return None
|
||||
|
||||
d = inner.get("data") or {}
|
||||
geo = inner.get("geo") or {}
|
||||
severity_word = _coerce_severity(inner.get("severity"))
|
||||
|
||||
# Danger level gate — read from data.data, NOT centralseverity.
|
||||
danger_level = d.get("danger_level")
|
||||
if not isinstance(danger_level, (int, float)):
|
||||
# ── Canonical danger_level (NAADS 1–5) ───────────────────────────────────
|
||||
danger_level = _canonical_danger_level(d, inner)
|
||||
if danger_level is None:
|
||||
return None
|
||||
|
||||
min_level = int(adapter_config.avalanche.min_danger_level)
|
||||
if danger_level < min_level:
|
||||
return None
|
||||
danger_name = (
|
||||
d.get("danger_name") or _NAADS_NAMES.get(danger_level, str(danger_level))
|
||||
)
|
||||
zone_name = d.get("zone_name") or "Unknown Zone"
|
||||
center_id = d.get("center_id") or ""
|
||||
travel = (d.get("travel_advice") or "").strip()
|
||||
|
||||
# Field extraction.
|
||||
zone_name = d.get("zone_name") or "Unknown Zone"
|
||||
danger_name = d.get("danger_name") or str(danger_level)
|
||||
center_id = d.get("center_id") or ""
|
||||
travel = (d.get("travel_advice") or "").strip()
|
||||
lat = d.get("latitude")
|
||||
lon = d.get("longitude")
|
||||
# ── Centroid from geo.centroid [lon, lat] ─────────────────────────────────
|
||||
centroid = geo.get("centroid") or []
|
||||
if isinstance(centroid, (list, tuple)) and len(centroid) >= 2:
|
||||
lat, lon = centroid[1], centroid[0]
|
||||
else:
|
||||
lat = d.get("latitude")
|
||||
lon = d.get("longitude")
|
||||
|
||||
# Category → broadcast category for event_log.
|
||||
category_raw = category
|
||||
# ── Build canonical data dict ─────────────────────────────────────────────
|
||||
canonical: dict = {
|
||||
"danger_level": danger_level,
|
||||
"danger_name": danger_name,
|
||||
"zone_name": zone_name,
|
||||
"center_id": center_id,
|
||||
"travel_advice": travel,
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"is_update": False, # Central path: no per-zone trend detection yet
|
||||
}
|
||||
|
||||
# Persist to event_log (store-only, no change-detection needed —
|
||||
# Central deduplicates upstream; we log every envelope we receive).
|
||||
# Optional expires timestamp
|
||||
expires_str = inner.get("expires")
|
||||
if expires_str and isinstance(expires_str, str):
|
||||
from datetime import datetime
|
||||
try:
|
||||
canonical["expires"] = datetime.fromisoformat(
|
||||
expires_str.replace("Z", "+00:00")
|
||||
).timestamp()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Persist to event_log ──────────────────────────────────────────────────
|
||||
conn = get_db()
|
||||
if conn is None:
|
||||
logger.warning("avy_handler: persistence unavailable, skipping")
|
||||
|
|
@ -117,32 +207,83 @@ def handle_avy(envelope: dict, subject: str,
|
|||
|
||||
log_id = _log_event_returning_id(
|
||||
conn, now=_now(), source="avalanche_org",
|
||||
category=category_raw, severity_word=severity_word,
|
||||
category=category, severity_word=severity_word,
|
||||
event_id_external=f"{center_id}:{zone_name}",
|
||||
subject=subject, handled=0,
|
||||
table_name="event_log", table_pk=None,
|
||||
)
|
||||
|
||||
# Render multi-line wire string.
|
||||
wire = _render(
|
||||
danger_level=int(danger_level),
|
||||
# ── Delegate to gating module ─────────────────────────────────────────────
|
||||
from meshai.notifications.gating.avalanche import decide as _gate_decide
|
||||
gate = _gate_decide(canonical, source="avalanche", now=float(_now()))
|
||||
|
||||
if not gate.broadcast:
|
||||
return None
|
||||
|
||||
# ── Write canonical into shared data dict ────────────────────────────────
|
||||
# Cutover gate: `category` here is the envelope category (e.g.
|
||||
# "avalanche_warning" or "avalanche_watch") — same strings registered in
|
||||
# the formatter/gating registries. When cut over, gate.data_patch (which
|
||||
# carries is_update + _severity_override) flows to the live Event; when not
|
||||
# cut over, old-style _attach_commit preserves pre-Phase-1 live behavior.
|
||||
from meshai.notifications.cutover import is_cutover
|
||||
if isinstance(data, dict):
|
||||
data.update(canonical)
|
||||
if is_cutover(category):
|
||||
# NEW PATH: gate.data_patch provides is_update + _severity_override.
|
||||
data.update(gate.data_patch)
|
||||
data["_broadcast_audit"] = {"table": "event_log", "pk": log_id}
|
||||
|
||||
_raw_gate_commit = gate.commit
|
||||
_log_row_id = log_id
|
||||
|
||||
def _on_commit(committed_at: float) -> None:
|
||||
"""Idempotent: mark event_log row handled on confirmed delivery."""
|
||||
if _raw_gate_commit is not None:
|
||||
_raw_gate_commit(committed_at)
|
||||
if _log_row_id is not None:
|
||||
try:
|
||||
c = get_db()
|
||||
c.execute(
|
||||
"UPDATE event_log SET handled=1 WHERE id=?",
|
||||
(int(_log_row_id),),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"avy commit: event_log update failed for log_id=%s",
|
||||
_log_row_id,
|
||||
)
|
||||
|
||||
data["_on_broadcast_committed"] = _on_commit
|
||||
else:
|
||||
# NOT cutover: old-style commit (canonical only, no data_patch).
|
||||
_attach_commit(data, log_id=log_id)
|
||||
|
||||
# ── Return _render() wire (backward compat — existing tests check this) ───
|
||||
# At dispatch time compose_mesh_message() calls the registered formatter
|
||||
# which re-renders from event.data (tier-b: is_update prefix applies there).
|
||||
return _render(
|
||||
danger_level=danger_level,
|
||||
danger_name=danger_name,
|
||||
zone_name=zone_name,
|
||||
center_id=center_id,
|
||||
travel=travel,
|
||||
)
|
||||
|
||||
_attach_commit(data, log_id=log_id)
|
||||
return wire
|
||||
|
||||
|
||||
def _render(*, danger_level: int, danger_name: str, zone_name: str,
|
||||
center_id: str, travel: str) -> str:
|
||||
emoji = "\u26f7"
|
||||
"""Wire string renderer — backward-compat entrypoint for direct callers.
|
||||
|
||||
The registered formatters/avalanche.format() is the canonical render path
|
||||
at dispatch time. This function is kept for existing test callers
|
||||
(test_adapter_avalanche, etc.) and for the handle_avy() return value.
|
||||
"""
|
||||
emoji = "⛷"
|
||||
# Warning for High/Extreme (4-5), Watch for Considerable (3).
|
||||
prefix = "WARNING:" if danger_level >= 4 else "Watch:"
|
||||
|
||||
line1 = f"{emoji} AVY {prefix} {zone_name} \u2014 {danger_name} ({danger_level})"
|
||||
line1 = f"{emoji} AVY {prefix} {zone_name} — {danger_name} ({danger_level})"
|
||||
# Travel advice: FIRST SENTENCE only (up to and including the first
|
||||
# sentence terminator), instead of a fixed character slice.
|
||||
line2 = None
|
||||
|
|
@ -150,13 +291,14 @@ def _render(*, danger_level: int, danger_name: str, zone_name: str,
|
|||
t = travel.strip()
|
||||
m = re.search(r"[.!?]", t)
|
||||
line2 = t[: m.end()] if m else t
|
||||
line3 = f"{center_id} \u00b7 valid today" if center_id else "valid today"
|
||||
line3 = f"{center_id} · valid today" if center_id else "valid today"
|
||||
|
||||
msg = "\n".join(l for l in [line1, line2, line3] if l)
|
||||
return fit_to_budget(msg, budget_for("avalanche"))
|
||||
|
||||
|
||||
def _attach_commit(data: Optional[dict], *, log_id: Optional[int]) -> None:
|
||||
"""Legacy helper — used by pre-refactor callers (kept for import compat)."""
|
||||
if not isinstance(data, dict):
|
||||
return
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
46
work/meshai/env/avalanche.py
vendored
46
work/meshai/env/avalanche.py
vendored
|
|
@ -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
|
||||
|
|
|
|||
51
work/meshai/env/store.py
vendored
51
work/meshai/env/store.py
vendored
|
|
@ -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",
|
||||
|
|
|
|||
17
work/meshai/env/swpc.py
vendored
17
work/meshai/env/swpc.py
vendored
|
|
@ -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')}")
|
||||
|
|
|
|||
57
work/meshai/env/usgs_quake.py
vendored
57
work/meshai/env/usgs_quake.py
vendored
|
|
@ -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')}")
|
||||
|
|
|
|||
58
work/meshai/notifications/cutover.py
Normal file
58
work/meshai/notifications/cutover.py
Normal 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()
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
101
work/meshai/notifications/formatters/avalanche.py
Normal file
101
work/meshai/notifications/formatters/avalanche.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
"""Avalanche advisory formatter — Phase-1 implementation.
|
||||
|
||||
Reads canonical event.data schema:
|
||||
danger_level int NAADS 1–5 (1=Low, 2=Moderate, 3=Considerable,
|
||||
4=High, 5=Extreme)
|
||||
danger_name str human-readable level name (e.g. "Considerable")
|
||||
zone_name str advisory zone (e.g. "Sawtooth Mountains")
|
||||
center_id str forecast center ID (e.g. "SNFAC")
|
||||
travel_advice str full advisory text; line-2 uses first sentence only
|
||||
lat, lon float centroid coordinates
|
||||
expires? float epoch seconds (optional — accepted but not rendered;
|
||||
seam available when now-relative display is needed)
|
||||
(+ decider-injected: is_update, _severity_override)
|
||||
|
||||
Wire format (mirrors avy_handler._render, adds is_update modifier):
|
||||
Line 1: ⛷ AVY {prefix}: {zone_name} — {danger_name} ({danger_level})
|
||||
prefix = "Update" when is_update=True
|
||||
= "WARNING" when danger_level >= 4 (High/Extreme)
|
||||
= "Watch" when danger_level == 3 (Considerable)
|
||||
Line 2: {first sentence of travel_advice} (if present)
|
||||
Line 3: {center_id} · valid today (else "valid today")
|
||||
|
||||
Tier-b wire changes vs avy_handler._render():
|
||||
1. is_update=True produces "AVY Update:" prefix (handler had no is_update path).
|
||||
2. All other formatting is identical (emoji/prefix/zone/name/level/travel/center/·).
|
||||
|
||||
For non-update events the output is byte-identical to _render() so parity
|
||||
tests pass without adjustment.
|
||||
|
||||
Time contract: `now` is accepted as a structural seam but not used for
|
||||
rendering (expires display would use it in future). All time reads MUST
|
||||
go through meshai.notifications.clock (clock.now / clock.now_dt) — never
|
||||
the stdlib equivalents — so golden-file tests can freeze the clock via
|
||||
monkeypatch.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from meshai.notifications.formatters._budget import fit_to_budget
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from meshai.notifications.events import Event
|
||||
|
||||
|
||||
def format(event: "Event", *, now: float, budget: int) -> str:
|
||||
"""Render the avalanche advisory wire string from canonical event.data.
|
||||
|
||||
Args:
|
||||
event: Pipeline Event — reads from event.data (canonical schema).
|
||||
now: Frozen-clock epoch (seam; available for future expires display).
|
||||
budget: Mesh-packet character budget (from budget_for("avalanche")).
|
||||
|
||||
Returns:
|
||||
UTF-8 string fitting within *budget* characters.
|
||||
"""
|
||||
d = event.data or {}
|
||||
|
||||
# ── Danger level ─────────────────────────────────────────────────────────
|
||||
danger_level = d.get("danger_level")
|
||||
if isinstance(danger_level, float):
|
||||
danger_level = int(danger_level)
|
||||
elif not isinstance(danger_level, int):
|
||||
danger_level = 0
|
||||
|
||||
danger_name = d.get("danger_name") or str(danger_level)
|
||||
zone_name = d.get("zone_name") or "Unknown Zone"
|
||||
center_id = (d.get("center_id") or "").strip()
|
||||
travel = (d.get("travel_advice") or "").strip()
|
||||
|
||||
# ── Prefix: Update vs WARNING vs Watch ───────────────────────────────────
|
||||
# is_update is injected by decide() data_patch (native: from EnvironmentalStore
|
||||
# change-detection; Central: defaults False until trend detection lands).
|
||||
is_update = bool(d.get("is_update", False))
|
||||
if is_update:
|
||||
prefix = "Update"
|
||||
elif danger_level >= 4:
|
||||
prefix = "WARNING"
|
||||
else:
|
||||
prefix = "Watch"
|
||||
|
||||
# ── Build lines ──────────────────────────────────────────────────────────
|
||||
emoji = "⛷" # ⛷
|
||||
|
||||
# Line 1: emoji + AVY + prefix + zone + danger level (always present)
|
||||
line1 = (
|
||||
f"{emoji} AVY {prefix}: {zone_name} — {danger_name} ({danger_level})"
|
||||
)
|
||||
|
||||
# Line 2: travel advice — first sentence only (identical to _render)
|
||||
line2: str | None = None
|
||||
if travel:
|
||||
m = re.search(r"[.!?]", travel)
|
||||
line2 = travel[: m.end()] if m else travel
|
||||
|
||||
# Line 3: center ID + validity (identical to _render)
|
||||
line3 = f"{center_id} · valid today" if center_id else "valid today"
|
||||
|
||||
msg = "\n".join(ln for ln in [line1, line2, line3] if ln)
|
||||
return fit_to_budget(msg, budget)
|
||||
130
work/meshai/notifications/formatters/quake.py
Normal file
130
work/meshai/notifications/formatters/quake.py
Normal 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)
|
||||
116
work/meshai/notifications/formatters/swpc.py
Normal file
116
work/meshai/notifications/formatters/swpc.py
Normal 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)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
161
work/meshai/notifications/gating/avalanche.py
Normal file
161
work/meshai/notifications/gating/avalanche.py
Normal 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 1–5 (1=Low … 5=Extreme)
|
||||
danger_name str
|
||||
zone_name str
|
||||
center_id str
|
||||
travel_advice str
|
||||
lat, lon float (optional)
|
||||
expires? float (optional)
|
||||
is_update? bool (native path: set by EnvironmentalStore change-
|
||||
detection; Central path: defaults False)
|
||||
|
||||
Emitted data_patch keys:
|
||||
is_update bool — propagated from incoming data or False
|
||||
_severity_override str|None — "priority" for High/Extreme (4-5);
|
||||
None for Considerable (3) and below
|
||||
(envelope severity governs those)
|
||||
|
||||
commit(now: float) -> None:
|
||||
None — there is no avalanche-specific DB table; event_log tracking
|
||||
is managed by handle_avy() for the Central path. Native path events
|
||||
do not persist broadcast timestamps (they re-gate on every poll).
|
||||
|
||||
Gate logic (mirrors avy_handler danger-level gate verbatim):
|
||||
danger_level >= min_danger_level → broadcast
|
||||
else → suppress
|
||||
|
||||
min_danger_level defaults to 3 (Considerable) from adapter_config.
|
||||
|
||||
centralseverity → NAADS 1–5 mapping (tier-b, documented here; remap
|
||||
lives in handle_avy() — by the time decide() is called, danger_level
|
||||
is already NAADS):
|
||||
|
||||
┌─────────────────────┬───────────────────────┬───────────────────┐
|
||||
│ centralseverity int │ NAADS danger_level int │ danger_name │
|
||||
├─────────────────────┼───────────────────────┼───────────────────┤
|
||||
│ 0 │ 1 │ Low │
|
||||
│ 1 │ 2 │ Moderate │
|
||||
│ 2 │ 3 │ Considerable │ ← documented
|
||||
│ 3 │ 4 │ High │ ← documented
|
||||
│ 4 │ 5 │ Extreme │ ← documented
|
||||
│ other │ 0 │ No Rating │
|
||||
└─────────────────────┴───────────────────────┴───────────────────┘
|
||||
|
||||
Values 0 and 1 are INFERRED (avy_handler.py comments document only
|
||||
2=Considerable, 3=High, 4=Extreme). The remap formula is:
|
||||
naads = centralseverity + 1 for 0 ≤ centralseverity ≤ 4
|
||||
naads = 0 otherwise
|
||||
|
||||
⚠ NEEDS LIVE VALIDATION IN-SEASON (October+): read a live Central
|
||||
avalanche envelope for a zone known to be rated Considerable and
|
||||
confirm centralseverity == 2. If it's a different value, update
|
||||
the mapping table in handle_avy._remap_centralseverity() and add
|
||||
a comment here.
|
||||
|
||||
First-sighting / trend:
|
||||
Avalanche does NOT suppress re-broadcasts the way quake uses the
|
||||
no-Update rule. The danger-level gate is the sole broadcast gate.
|
||||
is_update is propagated from the incoming data dict for display only
|
||||
(the formatter renders "AVY Update:" vs "AVY WARNING:").
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from meshai.adapter_config import adapter_config
|
||||
from meshai.notifications.gating.base import GateResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _severity_override_for(danger_level: int) -> Optional[str]:
|
||||
"""Derive _severity_override from NAADS danger level.
|
||||
|
||||
High (4) / Extreme (5) → "priority" (avoid terrain; widespread activity)
|
||||
Considerable (3) → None (let envelope severity govern)
|
||||
Low / Moderate / other → None (suppressed by gate; not reached)
|
||||
"""
|
||||
if danger_level >= 4:
|
||||
return "priority"
|
||||
return None
|
||||
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
def decide(data: dict, *, source: str, now: float) -> GateResult:
|
||||
"""Gate + trend decision for avalanche_warning / avalanche_watch.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data:
|
||||
Canonical Event.data dict. danger_level must be NAADS 1–5 (handle_avy
|
||||
performs the centralseverity→NAADS remap before calling here).
|
||||
source:
|
||||
Adapter source name, e.g. "avalanche".
|
||||
now:
|
||||
Current epoch (clock.now()) — determinism seam, not used internally.
|
||||
|
||||
Returns
|
||||
-------
|
||||
GateResult with:
|
||||
broadcast=True lifecycle="new" or "update" data_patch set
|
||||
broadcast=False lifecycle="suppress" data_patch={}
|
||||
"""
|
||||
danger_level = data.get("danger_level")
|
||||
if isinstance(danger_level, float):
|
||||
danger_level = int(danger_level)
|
||||
if not isinstance(danger_level, int):
|
||||
return GateResult(
|
||||
broadcast=False, lifecycle="suppress",
|
||||
reason="danger_level missing or non-numeric in canonical data",
|
||||
)
|
||||
|
||||
# ── Danger-level gate (mirrors avy_handler verbatim) ─────────────────────
|
||||
try:
|
||||
min_level = int(adapter_config.avalanche.min_danger_level)
|
||||
except Exception:
|
||||
min_level = 3 # default: Considerable and above
|
||||
|
||||
if danger_level < min_level:
|
||||
return GateResult(
|
||||
broadcast=False, lifecycle="suppress",
|
||||
reason=f"danger_level={danger_level} below min_danger_level={min_level}",
|
||||
)
|
||||
|
||||
# ── Trend / is_update ─────────────────────────────────────────────────────
|
||||
# Native path: EnvironmentalStore sets _is_update when danger_level rises.
|
||||
# Central path: defaults False (no trend detection yet).
|
||||
is_update = bool(data.get("is_update") or data.get("_is_update", False))
|
||||
lifecycle = "update" if is_update else "new"
|
||||
|
||||
# ── Severity override ─────────────────────────────────────────────────────
|
||||
sev_override = _severity_override_for(danger_level)
|
||||
|
||||
zone_name = data.get("zone_name", "?")
|
||||
center_id = data.get("center_id", "?")
|
||||
|
||||
patch: dict = {
|
||||
"is_update": is_update,
|
||||
"_severity_override": sev_override,
|
||||
}
|
||||
|
||||
# commit=None: no avalanche-specific DB table. event_log tracking lives
|
||||
# in handle_avy() (Central path). Native path re-gates on every poll.
|
||||
return GateResult(
|
||||
broadcast=True,
|
||||
lifecycle=lifecycle,
|
||||
reason=(
|
||||
f"danger_level={danger_level} >= min={min_level} "
|
||||
f"zone={zone_name} center={center_id} is_update={is_update}"
|
||||
),
|
||||
data_patch=patch,
|
||||
commit=None,
|
||||
)
|
||||
263
work/meshai/notifications/gating/quake.py
Normal file
263
work/meshai/notifications/gating/quake.py
Normal 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,
|
||||
)
|
||||
227
work/meshai/notifications/gating/swpc.py
Normal file
227
work/meshai/notifications/gating/swpc.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
41
work/tests/fixtures/avalanche/0000.json
vendored
Normal 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
41
work/tests/fixtures/avalanche/0001.json
vendored
Normal 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
41
work/tests/fixtures/avalanche/0002.json
vendored
Normal 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
42
work/tests/fixtures/avalanche/0003.json
vendored
Normal 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
82
work/tests/fixtures/quake/0000.json
vendored
Normal 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
82
work/tests/fixtures/quake/0001.json
vendored
Normal 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
82
work/tests/fixtures/quake/0002.json
vendored
Normal 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
36
work/tests/fixtures/swpc/0000.json
vendored
Normal 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
36
work/tests/fixtures/swpc/0001.json
vendored
Normal 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
36
work/tests/fixtures/swpc/0002.json
vendored
Normal 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
36
work/tests/fixtures/swpc/0003.json
vendored
Normal 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
36
work/tests/fixtures/swpc/0004.json
vendored
Normal 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
36
work/tests/fixtures/swpc/0005.json
vendored
Normal 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
36
work/tests/fixtures/swpc/0006.json
vendored
Normal 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
36
work/tests/fixtures/swpc/0007.json
vendored
Normal 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
36
work/tests/fixtures/swpc/0008.json
vendored
Normal 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
36
work/tests/fixtures/swpc/0009.json
vendored
Normal 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
36
work/tests/fixtures/swpc/0010.json
vendored
Normal 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
36
work/tests/fixtures/swpc/0011.json
vendored
Normal 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
36
work/tests/fixtures/swpc/0012.json
vendored
Normal 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
36
work/tests/fixtures/swpc/0013.json
vendored
Normal 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
36
work/tests/fixtures/swpc/0014.json
vendored
Normal 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
36
work/tests/fixtures/swpc/0015.json
vendored
Normal 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
36
work/tests/fixtures/swpc/0016.json
vendored
Normal 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
36
work/tests/fixtures/swpc/0017.json
vendored
Normal 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
36
work/tests/fixtures/swpc/0018.json
vendored
Normal 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
36
work/tests/fixtures/swpc/0019.json
vendored
Normal 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
36
work/tests/fixtures/swpc/0020.json
vendored
Normal 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
36
work/tests/fixtures/swpc/0021.json
vendored
Normal 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
36
work/tests/fixtures/swpc/0022.json
vendored
Normal 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
36
work/tests/fixtures/swpc/0023.json
vendored
Normal 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
36
work/tests/fixtures/swpc/0024.json
vendored
Normal 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
36
work/tests/fixtures/swpc/0025.json
vendored
Normal 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
36
work/tests/fixtures/swpc/0026.json
vendored
Normal 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
36
work/tests/fixtures/swpc/0027.json
vendored
Normal 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
36
work/tests/fixtures/swpc/0028.json
vendored
Normal 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
36
work/tests/fixtures/swpc/0029.json
vendored
Normal 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
36
work/tests/fixtures/swpc/0030.json
vendored
Normal 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
36
work/tests/fixtures/swpc/0031.json
vendored
Normal 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
36
work/tests/fixtures/swpc/0032.json
vendored
Normal 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
36
work/tests/fixtures/swpc/0033.json
vendored
Normal 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
36
work/tests/fixtures/swpc/0034.json
vendored
Normal 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
36
work/tests/fixtures/swpc/0035.json
vendored
Normal 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
36
work/tests/fixtures/swpc/0036.json
vendored
Normal 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
36
work/tests/fixtures/swpc/0037.json
vendored
Normal 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
36
work/tests/fixtures/swpc/0038.json
vendored
Normal 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
36
work/tests/fixtures/swpc/0039.json
vendored
Normal 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
35
work/tests/fixtures/swpc_last/0000.json
vendored
Normal 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
35
work/tests/fixtures/swpc_last/0001.json
vendored
Normal 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
35
work/tests/fixtures/swpc_last/0002.json
vendored
Normal 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
35
work/tests/fixtures/swpc_last/0003.json
vendored
Normal 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
35
work/tests/fixtures/swpc_last/0004.json
vendored
Normal 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
35
work/tests/fixtures/swpc_last/0005.json
vendored
Normal 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
35
work/tests/fixtures/swpc_last/0006.json
vendored
Normal 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
35
work/tests/fixtures/swpc_last/0007.json
vendored
Normal 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
35
work/tests/fixtures/swpc_last/0008.json
vendored
Normal 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
35
work/tests/fixtures/swpc_last/0009.json
vendored
Normal 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
35
work/tests/fixtures/swpc_last/0010.json
vendored
Normal 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
35
work/tests/fixtures/swpc_last/0011.json
vendored
Normal 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
35
work/tests/fixtures/swpc_last/0012.json
vendored
Normal 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
35
work/tests/fixtures/swpc_last/0013.json
vendored
Normal 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
35
work/tests/fixtures/swpc_last/0014.json
vendored
Normal 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
35
work/tests/fixtures/swpc_last/0015.json
vendored
Normal 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
35
work/tests/fixtures/swpc_last/0016.json
vendored
Normal 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
35
work/tests/fixtures/swpc_last/0017.json
vendored
Normal 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
35
work/tests/fixtures/swpc_last/0018.json
vendored
Normal 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
35
work/tests/fixtures/swpc_last/0019.json
vendored
Normal 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
35
work/tests/fixtures/swpc_last/0020.json
vendored
Normal 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
36
work/tests/fixtures/swpc_last/0021.json
vendored
Normal 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
36
work/tests/fixtures/swpc_last/0022.json
vendored
Normal 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
|
||||
}
|
||||
768
work/tests/test_avalanche_refactor.py
Normal file
768
work/tests/test_avalanche_refactor.py
Normal 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 first→update
|
||||
trend lifecycle labels.
|
||||
|
||||
4. Schema-conformance: env/avalanche.py to_event() emits all canonical keys;
|
||||
_meshai_precomposed is NOT set.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.harness.goldens import (
|
||||
assert_byte_identical,
|
||||
load_fixtures,
|
||||
pinned_time,
|
||||
)
|
||||
|
||||
# ── Shared clock epoch for deterministic renders ─────────────────────────────
|
||||
_AT = 1_783_200_000.0 # 2026-07-03T00:00:00Z (pinned, same as quake tests)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Helpers
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _make_fake_event(data: dict):
|
||||
"""Minimal fake Event for calling the formatter without the full pipeline."""
|
||||
class _FakeEvent:
|
||||
pass
|
||||
e = _FakeEvent()
|
||||
e.data = data
|
||||
return e
|
||||
|
||||
|
||||
def _canonical_from_fixture(fixture: dict, *, is_update: bool = False) -> dict:
|
||||
"""Extract canonical data dict from a Central avalanche envelope fixture."""
|
||||
inner = fixture["envelope"]["data"]
|
||||
d = inner.get("data") or {}
|
||||
geo = inner.get("geo") or {}
|
||||
centroid = geo.get("centroid") or []
|
||||
lon, lat = (centroid[0], centroid[1]) if len(centroid) >= 2 else (None, None)
|
||||
return {
|
||||
"danger_level": d.get("danger_level"),
|
||||
"danger_name": d.get("danger_name"),
|
||||
"zone_name": d.get("zone_name"),
|
||||
"center_id": d.get("center_id"),
|
||||
"travel_advice": d.get("travel_advice"),
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"is_update": is_update,
|
||||
}
|
||||
|
||||
|
||||
def _avy_render_old(*, danger_level: int, danger_name: str, zone_name: str,
|
||||
center_id: str, travel: str) -> str:
|
||||
"""Capture OLD _render() output from avy_handler for diff comments."""
|
||||
from meshai.central.avy_handler import _render
|
||||
return _render(
|
||||
danger_level=danger_level, danger_name=danger_name,
|
||||
zone_name=zone_name, center_id=center_id, travel=travel,
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 1. Parity (tier-b) — formatter renders from canonical data
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestFormatterParity:
|
||||
"""formatters/avalanche.format() renders correct output from canonical data."""
|
||||
|
||||
def test_fixture_0000_considerable_new_format(self):
|
||||
"""Fixture 0000 (Considerable, NAADS 3) → formatter output matches expected.
|
||||
|
||||
centralseverity=2 maps to NAADS 3 (Considerable).
|
||||
min_danger_level=3 → broadcast.
|
||||
is_update=False → "Watch:" prefix.
|
||||
|
||||
OLD _render() output:
|
||||
'⛷ AVY Watch: Sawtooth Mountains — Considerable (3)
|
||||
Dangerous conditions on steep slopes.
|
||||
SNFAC · valid today'
|
||||
NEW formatter output (identical — no tier-b diff for non-update case):
|
||||
'⛷ AVY Watch: Sawtooth Mountains — Considerable (3)
|
||||
Dangerous conditions on steep slopes.
|
||||
SNFAC · valid today'
|
||||
"""
|
||||
from meshai.notifications.formatters.avalanche import format as avyfmt
|
||||
|
||||
fixtures = load_fixtures("avalanche")
|
||||
fx = next(f for f in fixtures
|
||||
if "considerable" in f["envelope"]["id"] and "update" not in f["envelope"]["id"])
|
||||
canonical = _canonical_from_fixture(fx)
|
||||
|
||||
expected = (
|
||||
"⛷ AVY Watch: Sawtooth Mountains — Considerable (3)"
|
||||
"\nDangerous conditions on steep slopes."
|
||||
"\nSNFAC · valid today"
|
||||
)
|
||||
|
||||
with pinned_time(_AT):
|
||||
result = avyfmt(_make_fake_event(canonical), now=_AT, budget=140)
|
||||
|
||||
assert_byte_identical(result, expected)
|
||||
|
||||
# Confirm old _render() is identical for this case (no tier-b diff)
|
||||
old_wire = _avy_render_old(
|
||||
danger_level=3, danger_name="Considerable",
|
||||
zone_name="Sawtooth Mountains", center_id="SNFAC",
|
||||
travel="Dangerous conditions on steep slopes. Conservative decision-making is advised.",
|
||||
)
|
||||
assert_byte_identical(result, old_wire), (
|
||||
"For fixture 0000 (Considerable, is_update=False) old and new "
|
||||
"outputs must be identical — tier-b diff only appears for is_update=True."
|
||||
)
|
||||
|
||||
def test_fixture_0001_high_warning_prefix(self):
|
||||
"""Fixture 0001 (High, NAADS 4) → formatter uses 'WARNING:' prefix.
|
||||
|
||||
OLD _render() output:
|
||||
'⛷ AVY WARNING: Banner Summit — High (4)
|
||||
Avoid all avalanche terrain today.
|
||||
SNFAC · valid today'
|
||||
NEW formatter output (identical — WARNING prefix unchanged):
|
||||
'⛷ AVY WARNING: Banner Summit — High (4)
|
||||
Avoid all avalanche terrain today.
|
||||
SNFAC · valid today'
|
||||
"""
|
||||
from meshai.notifications.formatters.avalanche import format as avyfmt
|
||||
|
||||
fixtures = load_fixtures("avalanche")
|
||||
fx = next(f for f in fixtures if "banner" in f["envelope"]["id"])
|
||||
canonical = _canonical_from_fixture(fx)
|
||||
|
||||
expected = (
|
||||
"⛷ AVY WARNING: Banner Summit — High (4)"
|
||||
"\nAvoid all avalanche terrain today."
|
||||
"\nSNFAC · valid today"
|
||||
)
|
||||
|
||||
with pinned_time(_AT):
|
||||
result = avyfmt(_make_fake_event(canonical), now=_AT, budget=140)
|
||||
|
||||
assert_byte_identical(result, expected)
|
||||
|
||||
old_wire = _avy_render_old(
|
||||
danger_level=4, danger_name="High",
|
||||
zone_name="Banner Summit", center_id="SNFAC",
|
||||
travel="Avoid all avalanche terrain today. Natural avalanches are likely on steep slopes.",
|
||||
)
|
||||
assert_byte_identical(result, old_wire), (
|
||||
"For High (level=4, is_update=False) old and new must be identical."
|
||||
)
|
||||
|
||||
def test_fixture_0002_extreme_warning_prefix(self):
|
||||
"""Fixture 0002 (Extreme, NAADS 5) → formatter uses 'WARNING:' prefix.
|
||||
|
||||
OLD _render() output:
|
||||
'⛷ AVY WARNING: Soldier Mountains — Extreme (5)
|
||||
Avoid all avalanche terrain!
|
||||
SNFAC · valid today'
|
||||
NEW formatter output (identical):
|
||||
'⛷ AVY WARNING: Soldier Mountains — Extreme (5)
|
||||
Avoid all avalanche terrain!
|
||||
SNFAC · valid today'
|
||||
"""
|
||||
from meshai.notifications.formatters.avalanche import format as avyfmt
|
||||
|
||||
fixtures = load_fixtures("avalanche")
|
||||
fx = next(f for f in fixtures if "soldier" in f["envelope"]["id"])
|
||||
canonical = _canonical_from_fixture(fx)
|
||||
|
||||
expected = (
|
||||
"⛷ AVY WARNING: Soldier Mountains — Extreme (5)"
|
||||
"\nAvoid all avalanche terrain!"
|
||||
"\nSNFAC · valid today"
|
||||
)
|
||||
|
||||
with pinned_time(_AT):
|
||||
result = avyfmt(_make_fake_event(canonical), now=_AT, budget=140)
|
||||
|
||||
assert_byte_identical(result, expected)
|
||||
|
||||
def test_tier_b_update_prefix_rendered(self):
|
||||
"""Tier-b: is_update=True produces 'AVY Update:' prefix.
|
||||
|
||||
OLD _render() output (no is_update path):
|
||||
'⛷ AVY Watch: Sawtooth Mountains — Considerable (3)
|
||||
Dangerous conditions on steep slopes.
|
||||
SNFAC · valid today'
|
||||
NEW formatter output (is_update=True):
|
||||
'⛷ AVY Update: Sawtooth Mountains — Considerable (3)
|
||||
Dangerous conditions on steep slopes.
|
||||
SNFAC · valid today'
|
||||
"""
|
||||
from meshai.notifications.formatters.avalanche import format as avyfmt
|
||||
|
||||
fixtures = load_fixtures("avalanche")
|
||||
fx = next(f for f in fixtures if "update" in f["envelope"]["id"])
|
||||
# Extract with is_update=True
|
||||
canonical = _canonical_from_fixture(fx, is_update=True)
|
||||
|
||||
# OLD _render() output (no is_update support)
|
||||
old_wire = _avy_render_old(
|
||||
danger_level=3, danger_name="Considerable",
|
||||
zone_name="Sawtooth Mountains", center_id="SNFAC",
|
||||
travel="Dangerous conditions on steep slopes. Conservative decision-making is advised.",
|
||||
)
|
||||
expected_old = (
|
||||
"⛷ AVY Watch: Sawtooth Mountains — Considerable (3)"
|
||||
"\nDangerous conditions on steep slopes."
|
||||
"\nSNFAC · valid today"
|
||||
)
|
||||
expected_new = (
|
||||
"⛷ AVY Update: Sawtooth Mountains — Considerable (3)"
|
||||
"\nDangerous conditions on steep slopes."
|
||||
"\nSNFAC · valid today"
|
||||
)
|
||||
|
||||
with pinned_time(_AT):
|
||||
result = avyfmt(_make_fake_event(canonical), now=_AT, budget=140)
|
||||
|
||||
assert_byte_identical(old_wire, expected_old)
|
||||
assert_byte_identical(result, expected_new)
|
||||
assert "Update:" in result
|
||||
assert "Watch:" not in result
|
||||
|
||||
def test_formatter_budget_respected(self):
|
||||
"""Formatter output fits within budget for worst-case zone/advice strings."""
|
||||
from meshai.notifications.formatters.avalanche import format as avyfmt
|
||||
|
||||
canonical = {
|
||||
"danger_level": 5,
|
||||
"danger_name": "Extreme",
|
||||
"zone_name": "A very long zone name that might cause budget overflow",
|
||||
"center_id": "SNFAC",
|
||||
"travel_advice": (
|
||||
"Avoid all avalanche terrain! Very dangerous conditions exist "
|
||||
"across all elevations and aspects. Natural avalanches certain."
|
||||
),
|
||||
"lat": 43.5,
|
||||
"lon": -115.4,
|
||||
"is_update": False,
|
||||
}
|
||||
with pinned_time(_AT):
|
||||
result = avyfmt(_make_fake_event(canonical), now=_AT, budget=140)
|
||||
assert len(result) <= 140, f"{len(result)} chars:\n{result!r}"
|
||||
assert "AVY WARNING" in result
|
||||
|
||||
def test_no_travel_advice_still_renders(self):
|
||||
"""Formatter renders cleanly when travel_advice is absent."""
|
||||
from meshai.notifications.formatters.avalanche import format as avyfmt
|
||||
|
||||
canonical = {
|
||||
"danger_level": 3,
|
||||
"danger_name": "Considerable",
|
||||
"zone_name": "Wood River Valley",
|
||||
"center_id": "SNFAC",
|
||||
"travel_advice": "",
|
||||
"lat": 43.4,
|
||||
"lon": -114.3,
|
||||
"is_update": False,
|
||||
}
|
||||
with pinned_time(_AT):
|
||||
result = avyfmt(_make_fake_event(canonical), now=_AT, budget=140)
|
||||
assert "AVY Watch" in result
|
||||
assert "Wood River Valley" in result
|
||||
assert "SNFAC" in result
|
||||
assert "\n\n" not in result # no blank lines
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 2. Cross-source identity — native and Central produce identical renders
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestCrossSourceIdentity:
|
||||
"""Native to_event() canonical data renders byte-identically to Central path."""
|
||||
|
||||
def test_native_central_render_identical_fixture_0000(self):
|
||||
"""Canonical data from native and Central for fixture 0000 renders identically.
|
||||
|
||||
The Central path extracts from the envelope.
|
||||
The native path produces the same canonical keys via to_event().
|
||||
The formatter reads the same keys from both → same wire.
|
||||
"""
|
||||
from meshai.notifications.formatters.avalanche import format as avyfmt
|
||||
|
||||
# Central-path canonical data (hand-extracted from fixture 0000)
|
||||
central_canonical = {
|
||||
"danger_level": 3,
|
||||
"danger_name": "Considerable",
|
||||
"zone_name": "Sawtooth Mountains",
|
||||
"center_id": "SNFAC",
|
||||
"travel_advice": "Dangerous conditions on steep slopes. Conservative decision-making is advised.",
|
||||
"lat": 43.8,
|
||||
"lon": -114.9,
|
||||
"is_update": False,
|
||||
}
|
||||
|
||||
# Native-path canonical data (as to_event() would populate event.data)
|
||||
native_canonical = {
|
||||
"danger_level": 3,
|
||||
"danger_name": "Considerable",
|
||||
"zone_name": "Sawtooth Mountains",
|
||||
"center_id": "SNFAC",
|
||||
"travel_advice": "Dangerous conditions on steep slopes. Conservative decision-making is advised.",
|
||||
"lat": 43.8,
|
||||
"lon": -114.9,
|
||||
"is_update": False,
|
||||
"event_id": "avy_SNFAC_sawtooth_mountains",
|
||||
}
|
||||
|
||||
with pinned_time(_AT):
|
||||
central_wire = avyfmt(_make_fake_event(central_canonical),
|
||||
now=_AT, budget=140)
|
||||
native_wire = avyfmt(_make_fake_event(native_canonical),
|
||||
now=_AT, budget=140)
|
||||
|
||||
assert_byte_identical(native_wire, central_wire), (
|
||||
f"Native and Central renders must be byte-identical:\n"
|
||||
f" Central: {central_wire!r}\n"
|
||||
f" Native: {native_wire!r}"
|
||||
)
|
||||
|
||||
def test_native_to_event_uses_canonical_keys(self):
|
||||
"""to_event() event.data has the canonical keys the formatter reads."""
|
||||
from unittest.mock import MagicMock
|
||||
from meshai.env.avalanche import AvalancheAdapter
|
||||
|
||||
cfg = MagicMock()
|
||||
cfg.center_ids = ["SNFAC"]
|
||||
cfg.tick_seconds = 1800
|
||||
cfg.season_months = [12, 1, 2, 3, 4]
|
||||
adapter = AvalancheAdapter(cfg)
|
||||
|
||||
import time
|
||||
now = time.time()
|
||||
raw_evt = {
|
||||
"source": "avalanche",
|
||||
"event_id": "avy_SNFAC_sawtooth_mountains",
|
||||
"event_type": "Avalanche Advisory",
|
||||
"severity": "routine",
|
||||
"headline": "Sawtooth Mountains: Considerable avalanche danger",
|
||||
"zone_name": "Sawtooth Mountains",
|
||||
"center": "Sawtooth Avalanche Center",
|
||||
"center_id": "SNFAC",
|
||||
"center_link": "https://www.sawtoothavalanche.com",
|
||||
"forecast_link": "https://www.sawtoothavalanche.com/forecast",
|
||||
"danger": "considerable",
|
||||
"danger_level": 3,
|
||||
"danger_name": "Considerable",
|
||||
"travel_advice": "Dangerous conditions on steep slopes.",
|
||||
"state": "ID",
|
||||
"lat": 43.8,
|
||||
"lon": -114.9,
|
||||
"expires": now + 3600,
|
||||
"fetched_at": now,
|
||||
}
|
||||
|
||||
event = adapter.to_event(raw_evt)
|
||||
assert event is not None, "to_event() must return an Event for valid input"
|
||||
assert event.data is not None, "event.data must not be None"
|
||||
|
||||
canonical_keys = {
|
||||
"danger_level", "danger_name", "zone_name", "center_id",
|
||||
"travel_advice", "lat", "lon", "is_update", "event_id",
|
||||
}
|
||||
missing = canonical_keys - set(event.data.keys())
|
||||
assert not missing, (
|
||||
f"to_event() event.data missing canonical keys: {missing}\n"
|
||||
f"Got keys: {sorted(event.data.keys())}"
|
||||
)
|
||||
|
||||
# Spot-check values
|
||||
assert event.data["danger_level"] == 3
|
||||
assert event.data["danger_name"] == "Considerable"
|
||||
assert event.data["zone_name"] == "Sawtooth Mountains"
|
||||
assert event.data["center_id"] == "SNFAC"
|
||||
assert event.data["is_update"] is False
|
||||
assert event.data["event_id"] == "avy_SNFAC_sawtooth_mountains"
|
||||
|
||||
def test_native_to_event_no_precomposed(self):
|
||||
"""to_event() must NOT set _meshai_precomposed in event.data (formatter governs)."""
|
||||
from unittest.mock import MagicMock
|
||||
from meshai.env.avalanche import AvalancheAdapter
|
||||
|
||||
cfg = MagicMock()
|
||||
cfg.center_ids = ["SNFAC"]
|
||||
cfg.tick_seconds = 1800
|
||||
cfg.season_months = [12, 1, 2, 3, 4]
|
||||
adapter = AvalancheAdapter(cfg)
|
||||
|
||||
import time
|
||||
now = time.time()
|
||||
raw_evt = {
|
||||
"source": "avalanche",
|
||||
"event_id": "avy_SNFAC_sawtooth_mountains",
|
||||
"event_type": "Avalanche Advisory",
|
||||
"severity": "routine",
|
||||
"headline": "test",
|
||||
"zone_name": "Sawtooth Mountains",
|
||||
"center": "SNFAC",
|
||||
"center_id": "SNFAC",
|
||||
"center_link": "",
|
||||
"forecast_link": "",
|
||||
"danger": "considerable",
|
||||
"danger_level": 3,
|
||||
"danger_name": "Considerable",
|
||||
"travel_advice": "Dangerous conditions.",
|
||||
"state": "ID",
|
||||
"lat": 43.8,
|
||||
"lon": -114.9,
|
||||
"expires": now + 3600,
|
||||
"fetched_at": now,
|
||||
}
|
||||
|
||||
event = adapter.to_event(raw_evt)
|
||||
assert event is not None
|
||||
assert not event.data.get("_meshai_precomposed"), (
|
||||
"event.data must NOT have _meshai_precomposed=True — "
|
||||
"the formatter registry governs rendering"
|
||||
)
|
||||
|
||||
def test_handle_avy_writes_canonical_into_data(self):
|
||||
"""handle_avy() writes canonical fields into the shared data dict on broadcast."""
|
||||
from meshai.central.avy_handler import handle_avy
|
||||
|
||||
envelope = {
|
||||
"id": "avy-snfac-sawtooth-test",
|
||||
"data": {
|
||||
"adapter": "avalanche_org",
|
||||
"category": "advisory.us.id",
|
||||
"severity": 2,
|
||||
"geo": {"centroid": [-114.9, 43.8]},
|
||||
"data": {
|
||||
"danger_level": 3,
|
||||
"danger_name": "Considerable",
|
||||
"zone_name": "Sawtooth Mountains",
|
||||
"center_id": "SNFAC",
|
||||
"travel_advice": "Dangerous conditions.",
|
||||
},
|
||||
},
|
||||
}
|
||||
data: dict = {}
|
||||
wire = handle_avy(envelope, "central.avy.advisory.us.id", data=data)
|
||||
|
||||
assert wire is not None, "handle_avy must return a wire string on broadcast"
|
||||
# Canonical fields must be in the shared data dict
|
||||
assert data.get("danger_level") == 3
|
||||
assert data.get("danger_name") == "Considerable"
|
||||
assert data.get("zone_name") == "Sawtooth Mountains"
|
||||
assert data.get("center_id") == "SNFAC"
|
||||
assert "_on_broadcast_committed" in data
|
||||
assert "_broadcast_audit" in data
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 3. Gate-sequence — danger-level gate + first→update trend
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestGateSequence:
|
||||
"""gating.avalanche.decide() gate + lifecycle decisions."""
|
||||
|
||||
def _decide(self, danger_level: int, *, is_update: bool = False):
|
||||
from meshai.notifications.gating.avalanche import decide
|
||||
data = {
|
||||
"danger_level": danger_level,
|
||||
"danger_name": {1: "Low", 2: "Moderate", 3: "Considerable",
|
||||
4: "High", 5: "Extreme"}.get(danger_level, "?"),
|
||||
"zone_name": "Test Zone",
|
||||
"center_id": "SNFAC",
|
||||
"travel_advice": "Test travel advice.",
|
||||
"lat": 43.8,
|
||||
"lon": -114.9,
|
||||
"is_update": is_update,
|
||||
}
|
||||
return decide(data, source="avalanche", now=_AT)
|
||||
|
||||
def test_level_below_threshold_suppressed(self):
|
||||
"""danger_level=2 (Moderate) is below min_danger_level=3 → suppress."""
|
||||
result = self._decide(2)
|
||||
assert result.broadcast is False
|
||||
assert result.lifecycle == "suppress"
|
||||
|
||||
def test_level_at_threshold_broadcast(self):
|
||||
"""danger_level=3 (Considerable) equals min_danger_level=3 → broadcast."""
|
||||
result = self._decide(3)
|
||||
assert result.broadcast is True
|
||||
assert result.lifecycle == "new"
|
||||
|
||||
def test_level_above_threshold_broadcast(self):
|
||||
"""danger_level=4 (High) above min_danger_level=3 → broadcast + priority."""
|
||||
result = self._decide(4)
|
||||
assert result.broadcast is True
|
||||
assert result.lifecycle == "new"
|
||||
assert result.data_patch.get("_severity_override") == "priority"
|
||||
|
||||
def test_extreme_broadcast_priority(self):
|
||||
"""danger_level=5 (Extreme) → broadcast + priority."""
|
||||
result = self._decide(5)
|
||||
assert result.broadcast is True
|
||||
assert result.data_patch.get("_severity_override") == "priority"
|
||||
|
||||
def test_considerable_no_priority_override(self):
|
||||
"""danger_level=3 (Considerable) → broadcast, _severity_override=None."""
|
||||
result = self._decide(3)
|
||||
assert result.broadcast is True
|
||||
assert result.data_patch.get("_severity_override") is None
|
||||
|
||||
def test_first_sighting_lifecycle_new(self):
|
||||
"""First event (is_update=False) → lifecycle='new'."""
|
||||
result = self._decide(3, is_update=False)
|
||||
assert result.broadcast is True
|
||||
assert result.lifecycle == "new"
|
||||
assert result.data_patch.get("is_update") is False
|
||||
|
||||
def test_update_trend_lifecycle_update(self):
|
||||
"""Subsequent event (is_update=True) → lifecycle='update'."""
|
||||
result = self._decide(3, is_update=True)
|
||||
assert result.broadcast is True
|
||||
assert result.lifecycle == "update"
|
||||
assert result.data_patch.get("is_update") is True
|
||||
|
||||
def test_missing_danger_level_suppresses(self):
|
||||
"""Missing danger_level in canonical data → suppress."""
|
||||
from meshai.notifications.gating.avalanche import decide
|
||||
result = decide({"zone_name": "test"}, source="avalanche", now=_AT)
|
||||
assert result.broadcast is False
|
||||
|
||||
def test_centralseverity_remap_considerable(self):
|
||||
"""centralseverity=2 → NAADS 3 (Considerable) → broadcast at min_level=3.
|
||||
|
||||
This tests the remap logic in _remap_centralseverity() as used by
|
||||
handle_avy() when data.data.danger_level is absent.
|
||||
|
||||
⚠ Mapping table (needs live validation in-season Oct+):
|
||||
centralseverity 2 → NAADS 3 (Considerable) [documented]
|
||||
centralseverity 3 → NAADS 4 (High) [documented]
|
||||
centralseverity 4 → NAADS 5 (Extreme) [documented]
|
||||
centralseverity 0 → NAADS 1 (Low) [inferred]
|
||||
centralseverity 1 → NAADS 2 (Moderate) [inferred]
|
||||
"""
|
||||
from meshai.central.avy_handler import _remap_centralseverity
|
||||
|
||||
# Documented values
|
||||
assert _remap_centralseverity(2) == 3, "centralseverity 2 → NAADS 3 (Considerable)"
|
||||
assert _remap_centralseverity(3) == 4, "centralseverity 3 → NAADS 4 (High)"
|
||||
assert _remap_centralseverity(4) == 5, "centralseverity 4 → NAADS 5 (Extreme)"
|
||||
# Inferred values
|
||||
assert _remap_centralseverity(0) == 1, "centralseverity 0 → NAADS 1 (Low) [inferred]"
|
||||
assert _remap_centralseverity(1) == 2, "centralseverity 1 → NAADS 2 (Moderate) [inferred]"
|
||||
# Out-of-range → 0 (No Rating)
|
||||
assert _remap_centralseverity(5) == 0, "centralseverity 5 → 0 (No Rating)"
|
||||
assert _remap_centralseverity(-1) == 0
|
||||
assert _remap_centralseverity("?") == 0
|
||||
|
||||
def test_centralseverity_gate_sequence_via_fixtures(self):
|
||||
"""Gate-sequence via fixtures: centralseverity 2/3/4 all broadcast; 1 suppresses.
|
||||
|
||||
Uses handle_avy to exercise the full Central ingestion path including
|
||||
the centralseverity → NAADS remap and the decide() call.
|
||||
"""
|
||||
from meshai.central.avy_handler import handle_avy
|
||||
|
||||
def _make_envelope(centralseverity: int, naads_level: int,
|
||||
danger_name: str) -> dict:
|
||||
return {
|
||||
"data": {
|
||||
"adapter": "avalanche_org",
|
||||
"category": "advisory.us.id",
|
||||
"severity": centralseverity,
|
||||
"geo": {"centroid": [-114.9, 43.8]},
|
||||
"data": {
|
||||
"danger_level": naads_level,
|
||||
"danger_name": danger_name,
|
||||
"zone_name": "Test Zone",
|
||||
"center_id": "SNFAC",
|
||||
"travel_advice": "Test advice.",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
# centralseverity=1 → NAADS 2 (Moderate) → suppressed (below min_level=3)
|
||||
env_moderate = _make_envelope(1, 2, "Moderate")
|
||||
data_m: dict = {}
|
||||
wire_m = handle_avy(env_moderate, "central.avy.advisory.us.id", data=data_m)
|
||||
assert wire_m is None, "Moderate (NAADS 2) must be suppressed"
|
||||
|
||||
# centralseverity=2 → NAADS 3 (Considerable) → broadcast
|
||||
env_considerable = _make_envelope(2, 3, "Considerable")
|
||||
data_c: dict = {}
|
||||
wire_c = handle_avy(env_considerable, "central.avy.advisory.us.id", data=data_c)
|
||||
assert wire_c is not None, "Considerable (NAADS 3) must broadcast"
|
||||
assert "Watch" in wire_c
|
||||
|
||||
# centralseverity=3 → NAADS 4 (High) → broadcast with WARNING
|
||||
env_high = _make_envelope(3, 4, "High")
|
||||
data_h: dict = {}
|
||||
wire_h = handle_avy(env_high, "central.avy.advisory.us.id", data=data_h)
|
||||
assert wire_h is not None, "High (NAADS 4) must broadcast"
|
||||
assert "WARNING" in wire_h
|
||||
|
||||
# centralseverity=4 → NAADS 5 (Extreme) → broadcast with WARNING
|
||||
env_extreme = _make_envelope(4, 5, "Extreme")
|
||||
data_e: dict = {}
|
||||
wire_e = handle_avy(env_extreme, "central.avy.advisory.us.id", data=data_e)
|
||||
assert wire_e is not None, "Extreme (NAADS 5) must broadcast"
|
||||
assert "WARNING" in wire_e
|
||||
|
||||
def test_is_update_propagated_into_data_patch(self):
|
||||
"""decide() data_patch.is_update reflects the incoming is_update flag."""
|
||||
from meshai.notifications.gating.avalanche import decide
|
||||
|
||||
result_new = decide(
|
||||
{"danger_level": 3, "zone_name": "Z", "center_id": "C",
|
||||
"travel_advice": "", "is_update": False},
|
||||
source="avalanche", now=_AT,
|
||||
)
|
||||
assert result_new.data_patch["is_update"] is False
|
||||
|
||||
result_update = decide(
|
||||
{"danger_level": 3, "zone_name": "Z", "center_id": "C",
|
||||
"travel_advice": "", "is_update": True},
|
||||
source="avalanche", now=_AT,
|
||||
)
|
||||
assert result_update.data_patch["is_update"] is True
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 4. Schema conformance — to_event() canonical data completeness
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestSchemaConformance:
|
||||
"""env/avalanche.py to_event() emits canonical keys without _meshai_precomposed."""
|
||||
|
||||
@pytest.fixture
|
||||
def adapter(self):
|
||||
from unittest.mock import MagicMock
|
||||
from meshai.env.avalanche import AvalancheAdapter
|
||||
cfg = MagicMock()
|
||||
cfg.center_ids = ["SNFAC"]
|
||||
cfg.tick_seconds = 1800
|
||||
cfg.season_months = [12, 1, 2, 3, 4]
|
||||
return AvalancheAdapter(cfg)
|
||||
|
||||
CANONICAL_KEYS = frozenset({
|
||||
"danger_level", "danger_name", "zone_name", "center_id",
|
||||
"travel_advice", "lat", "lon", "is_update", "event_id",
|
||||
})
|
||||
|
||||
def _raw_evt(self, **overrides):
|
||||
import time
|
||||
base = {
|
||||
"source": "avalanche",
|
||||
"event_id": "avy_SNFAC_sawtooth_mountains",
|
||||
"event_type": "Avalanche Advisory",
|
||||
"severity": "routine",
|
||||
"headline": "test",
|
||||
"zone_name": "Sawtooth Mountains",
|
||||
"center": "Sawtooth Avalanche Center",
|
||||
"center_id": "SNFAC",
|
||||
"center_link": "",
|
||||
"forecast_link": "",
|
||||
"danger": "considerable",
|
||||
"danger_level": 3,
|
||||
"danger_name": "Considerable",
|
||||
"travel_advice": "Dangerous conditions on steep slopes.",
|
||||
"state": "ID",
|
||||
"lat": 43.8,
|
||||
"lon": -114.9,
|
||||
"expires": time.time() + 3600,
|
||||
"fetched_at": time.time(),
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
def test_all_canonical_keys_present(self, adapter):
|
||||
"""event.data contains all canonical schema keys."""
|
||||
event = adapter.to_event(self._raw_evt())
|
||||
assert event is not None
|
||||
missing = self.CANONICAL_KEYS - set(event.data.keys())
|
||||
assert not missing, f"Missing canonical keys: {missing}"
|
||||
|
||||
def test_no_precomposed_flag(self, adapter):
|
||||
"""event.data must NOT have _meshai_precomposed — formatter governs."""
|
||||
event = adapter.to_event(self._raw_evt())
|
||||
assert event is not None
|
||||
assert not event.data.get("_meshai_precomposed"), (
|
||||
"event.data['_meshai_precomposed'] must be falsy/absent; "
|
||||
"formatter registry governs dispatch rendering"
|
||||
)
|
||||
|
||||
def test_danger_level_is_int(self, adapter):
|
||||
"""event.data['danger_level'] is a Python int (not float or str)."""
|
||||
event = adapter.to_event(self._raw_evt(danger_level=3))
|
||||
assert event is not None
|
||||
assert isinstance(event.data["danger_level"], int)
|
||||
assert event.data["danger_level"] == 3
|
||||
|
||||
def test_is_update_defaults_false(self, adapter):
|
||||
"""is_update=False when _is_update not set in the raw event dict."""
|
||||
event = adapter.to_event(self._raw_evt())
|
||||
assert event is not None
|
||||
assert event.data["is_update"] is False
|
||||
|
||||
def test_is_update_propagated_from_store(self, adapter):
|
||||
"""_is_update=True from EnvironmentalStore propagates to canonical is_update."""
|
||||
raw = self._raw_evt()
|
||||
raw["_is_update"] = True
|
||||
event = adapter.to_event(raw)
|
||||
assert event is not None
|
||||
assert event.data["is_update"] is True
|
||||
|
||||
def test_event_id_in_canonical_data(self, adapter):
|
||||
"""event.data['event_id'] matches the source event_id."""
|
||||
event = adapter.to_event(self._raw_evt(event_id="avy_test_id"))
|
||||
assert event is not None
|
||||
assert event.data["event_id"] == "avy_test_id"
|
||||
|
||||
def test_formatter_renders_from_canonical_data(self, adapter):
|
||||
"""Formatter reads canonical event.data and renders a valid wire string."""
|
||||
from meshai.notifications.formatters.avalanche import format as avyfmt
|
||||
|
||||
event = adapter.to_event(self._raw_evt())
|
||||
assert event is not None
|
||||
|
||||
with pinned_time(_AT):
|
||||
result = avyfmt(event, now=_AT, budget=140)
|
||||
|
||||
assert result is not None
|
||||
assert len(result) <= 140
|
||||
assert "AVY Watch" in result
|
||||
assert "Sawtooth Mountains" in result
|
||||
assert "Considerable (3)" in result
|
||||
|
||||
def test_formatter_registered_for_avalanche_categories(self):
|
||||
"""Both avalanche_warning and avalanche_watch have registered formatters."""
|
||||
from meshai.notifications.formatters import get_formatter
|
||||
|
||||
fmt_warning = get_formatter("avalanche_warning")
|
||||
assert fmt_warning is not None, "avalanche_warning must have a registered formatter"
|
||||
|
||||
fmt_watch = get_formatter("avalanche_watch")
|
||||
assert fmt_watch is not None, "avalanche_watch must have a registered formatter"
|
||||
|
||||
def test_decider_registered_for_avalanche_categories(self):
|
||||
"""Both avalanche_warning and avalanche_watch have registered deciders."""
|
||||
from meshai.notifications.gating import get_decider
|
||||
|
||||
dec_warning = get_decider("avalanche_warning")
|
||||
assert dec_warning is not None, "avalanche_warning must have a registered decider"
|
||||
|
||||
dec_watch = get_decider("avalanche_watch")
|
||||
assert dec_watch is not None, "avalanche_watch must have a registered decider"
|
||||
347
work/tests/test_cutover_gate.py
Normal file
347
work/tests/test_cutover_gate.py
Normal 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}"
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
742
work/tests/test_quake_refactor.py
Normal file
742
work/tests/test_quake_refactor.py
Normal 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
|
||||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
697
work/tests/test_swpc_refactor.py
Normal file
697
work/tests/test_swpc_refactor.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue