mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
* 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>
116 lines
4.8 KiB
Python
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)
|