meshai/work/meshai/notifications/formatters/swpc.py
malice 4e23fc737d
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>
2026-07-04 15:58:46 -06:00

116 lines
4.8 KiB
Python

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