From 6c84baf12c44b293a40b8496f1d9050ee08c9b9e Mon Sep 17 00:00:00 2001 From: Matt Johnson Date: Thu, 4 Jun 2026 06:49:48 +0000 Subject: [PATCH] fix(rf): v0.5.7-rf -- SWPC subject validation + protons severity=0 documentation + categories audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sixth family of the v0.5.7 NATS-and-categories campaign. RF family = native ducting calculator + three Central SWPC adapters (swpc_alerts, swpc_kindex, swpc_protons), all umbrella-subscribed under `central.space.>`. Per the family-by-family pattern: cross-checked every prompt assumption against the Central v0.10.0 guide before implementing. The big surprise this phase: FIX 1 was already correct (no NATS-syntax bug to fix), and FIX 2 was a non-bug too (severity=0 already routes safely). The real work was FIX 3 -- four missing registry entries that meshai emits but the rule editor couldn't target. FIX 1 -- SWPC subject pattern (already correct; pinned). Per Central v0.10.0 guide §swpc_alerts / §swpc_kindex / §swpc_protons, all three adapters publish under the `central.space.>` umbrella with no region in subject (space weather is planetary): swpc_alerts: central.space.alert. (4 tokens, product_id tail) swpc_kindex: central.space.kindex (3 tokens, fixed) swpc_protons: central.space.proton_flux (3 tokens, fixed) `_subjects_for("swpc", region)` already returned `["central.space.>"]` ignoring region (v0.5.4 work got this right). Added an explanatory inline comment near the table entry calling out each adapter's concrete subject + the universal severity=0 contract (next fix), plus a test pinning the umbrella + region-ignored behavior + coverage of each per-adapter subject form. Future "let me add a region tail here" refactors will fail loudly. FIX 2 -- swpc_protons severity=0 routing (non-bug; regression-guard pin). The prompt described a "severity=0 silently dropped" failure mode. Investigation: no such bug exists in current code. - All three SWPC adapters publish severity=0 in the live guide samples. - consumer.map_severity already maps 0 -> "routine" (the `if sev >= 3:` immediate clamp doesn't hit; falls through to the default return). - NotificationToggle.severity_channels is dict-keyed by severity STRING (locked in by v0.5.7-seismic test_severity_channels_is_string_keyed_no_int_indexerror_risk); "routine" is a valid key with no IndexError vector. Three things tightened anyway: (a) inline comment near the swpc subject entry documenting "all three publish severity=0 -> routine per guide examples"; (b) end-to-end synthetic envelope test for swpc_protons injection (severity=0 in, ev.severity="routine" / ev.category="solar_radiation_storm" / ev.source="swpc" out, no exception); (c) parallel test for swpc_kindex confirming a second SWPC adapter wires identically. FIX 3 -- ALERT_CATEGORIES rf_propagation audit. Pre-v0.5.7-rf registry had three entries under toggle="rf_propagation": hf_blackout, geomagnetic_storm, tropospheric_ducting. Audit: Native ducting.py emits via _TIER_CATEGORY: super_refraction -> rf_anomalous_propagation duct -> rf_ducting_enhancement surface_duct -> rf_ducting_enhancement Central path via map_category: space.alert.* -> rf_propagation_alert (swpc_alerts) space.kindex -> geomagnetic_storm (swpc_kindex; already in registry) space.proton_flux -> solar_radiation_storm (swpc_protons) space.* catchall -> geomagnetic_storm Four categories emitted but missing from the registry -- rule editor couldn't target them. Added all four under toggle="rf_propagation" with name + description + default_severity + example_message matching the guide-documented behavior: rf_anomalous_propagation (routine, ducting super_refraction tier) rf_ducting_enhancement (priority, ducting duct + surface_duct tiers) rf_propagation_alert (priority, NOAA SWPC space-weather product) solar_radiation_storm (priority, GOES proton flux S-scale) composer.py emoji + label tables gained matching entries so live LoRa rendering shows the right glyphs (📡 for ducting forms, ⚠ for SWPC alerts, 🌐 for solar radiation, all labelled "RF"). Legacy entries kept (forward-compat / no current emitter): hf_blackout and tropospheric_ducting remain in the registry as selectable rule targets even though no current code path emits them. Reasoning: - hf_blackout: HF-specific R-scale parsing of swpc_alerts.message could re-introduce this emission in a future phase; removing the registry entry would break any user rule currently configured to target it. - tropospheric_ducting: legacy name superseded by rf_ducting_enhancement in native ducting.py; same forward-compat concern -- a future phase may emit a "tropospheric" specialization separate from generic ducts. If either remains un-emitted by v0.6, file a follow-up cleanup phase to remove. Test_alert_categories_rf_complete uses a SUBSET assertion (emit set ⊆ registry) rather than equality so legacy entries are allowed. Audit table after v0.5.7-rf: Registry rf_propagation (7): hf_blackout (legacy, no current emitter) geomagnetic_storm (central swpc_kindex + catchall) tropospheric_ducting (legacy, no current emitter) rf_anomalous_propagation [v0.5.7-rf NEW] (native ducting super_refraction) rf_ducting_enhancement [v0.5.7-rf NEW] (native ducting duct + surface_duct) rf_propagation_alert [v0.5.7-rf NEW] (central swpc_alerts) solar_radiation_storm [v0.5.7-rf NEW] (central swpc_protons) Emit set ⊆ Registry: TRUE (no orphan emissions). Tests ----- PYTHONPATH=. pytest -q: 431 passed (was 413; +18 net). - tests/test_rf_v057.py (new): umbrella subject is `central.space.>` for all regions; per-adapter published subjects all match; map_severity(0) -> "routine"; NotificationToggle.severity_channels dict-keyed (no IndexError); synthetic swpc_protons + swpc_kindex envelopes route cleanly with severity=0; four new rf_propagation entries all registry-present with required fields; geomagnetic_storm still mapped from space.kindex; map_category routing pinned for each SWPC adapter; native ducting + central SWPC emit sets are subsets of registry rf entries. Safe-mode preserved (master off, all family toggles off, all adapters native, central disabled). No live toggle flipped. Not tagging yet -- v0.5.7 tag waits until all families ship. Co-Authored-By: Claude Opus 4.7 (1M context) --- meshai/central/consumer.py | 10 + meshai/notifications/categories.py | 45 +++- meshai/notifications/renderers/composer.py | 10 + tests/test_rf_v057.py | 243 +++++++++++++++++++++ 4 files changed, 307 insertions(+), 1 deletion(-) create mode 100644 tests/test_rf_v057.py diff --git a/meshai/central/consumer.py b/meshai/central/consumer.py index ed20b79..155efcf 100644 --- a/meshai/central/consumer.py +++ b/meshai/central/consumer.py @@ -149,6 +149,16 @@ def _subjects_for(adapter: str, region: Optional[str]) -> list[str]: # publishes `central.hydro....`. "usgs": [f"central.hydro.*.*.*.{region}", "central.hydro.*.*.*.unknown"], + # SWPC space weather: planetary (no region). The umbrella subject + # central.space.> catches all three SWPC adapters per Central v0.10.0 + # guide §swpc_alerts/§swpc_kindex/§swpc_protons: + # - swpc_alerts: central.space.alert. + # - swpc_kindex: central.space.kindex (fixed) + # - swpc_protons: central.space.proton_flux (fixed) + # All three publish severity=0 by default (verified against the + # live samples in the guide); map_severity(0) -> "routine", which + # routes through the NotificationToggle's "routine" severity_channels + # entry (dict is string-keyed, no IndexError risk). "swpc": ["central.space.>"], # Convention B (bare state) — shared by traffic family (wzdx, # tomtom_incidents, state_511_atis). Single-token `*` matches the diff --git a/meshai/notifications/categories.py b/meshai/notifications/categories.py index 3cecc6b..fe25930 100644 --- a/meshai/notifications/categories.py +++ b/meshai/notifications/categories.py @@ -226,7 +226,50 @@ ALERT_CATEGORIES = { "toggle": "weather", }, - # Environmental - Space Weather + # Environmental - Space Weather / RF Propagation + # v0.5.7-rf audit (test_alert_categories_rf_complete enforces parity): + # Native: ducting.py -> rf_anomalous_propagation (super_refraction tier); + # ducting.py -> rf_ducting_enhancement (duct + surface_duct tiers). + # Central path (via map_category): space.alert -> rf_propagation_alert + # (swpc_alerts); space.kindex -> geomagnetic_storm (swpc_kindex); + # space.proton_flux -> solar_radiation_storm (swpc_protons); + # catchall space.* -> geomagnetic_storm. + # All three SWPC adapters publish severity=0 -> "routine" per guide + # §swpc_alerts/§swpc_kindex/§swpc_protons live samples. + # + # Legacy entries kept: hf_blackout and tropospheric_ducting have no + # current emitter (ducting.py was renamed to rf_ducting_enhancement, + # and HF-blackout-specific parsing of swpc_alerts.message is deferred + # to a future phase). They remain UI-selectable as forward-compatible + # rule targets; queued for cleanup if no emitter materializes. + "rf_anomalous_propagation": { + "name": "RF Anomalous Propagation", + "description": "Super-refractive atmospheric layer affecting VHF/UHF propagation — sub-standard refractive conditions, mostly affects line-of-sight links", + "default_severity": "routine", + "example_message": "📡 Anomalous Propagation: Super-refraction detected, dM/dz -45 M-units/km, ~80m thick layer. VHF/UHF links may show enhanced range.", + "toggle": "rf_propagation", + }, + "rf_ducting_enhancement": { + "name": "RF Ducting Enhancement", + "description": "Tropospheric duct trapping VHF/UHF signals — extended range, signals propagate well beyond the normal radio horizon", + "default_severity": "priority", + "example_message": "📡 Ducting Enhancement: Surface duct detected, base 0 m, ~120 m thick. VHF/UHF extended range, expect signals well beyond horizon.", + "toggle": "rf_propagation", + }, + "rf_propagation_alert": { + "name": "Space Weather Alert", + "description": "NOAA SWPC space weather alert/watch/warning — geomagnetic storm scales (G1-G5), radiation storms (S), radio blackouts (R), or summaries. Full operational text in event body.", + "default_severity": "priority", + "example_message": "⚠ Space Weather Alert (A20F): WATCH — Geomagnetic Storm Category G1 Predicted. Apr 25: G1 (Minor). Aurora possible at high latitudes.", + "toggle": "rf_propagation", + }, + "solar_radiation_storm": { + "name": "Solar Radiation Storm", + "description": "GOES proton flux above S-scale threshold — S1 ≥10 pfu at ≥10 MeV; S2 ≥100; S3 ≥1000; impacts HF over polar regions and satellite operations", + "default_severity": "priority", + "example_message": "🌐 Solar Radiation Storm: GOES-19 proton flux 12.5 pfu at ≥10 MeV (S1 threshold). HF over polar regions degraded.", + "toggle": "rf_propagation", + }, "hf_blackout": { "name": "HF Radio Blackout", "description": "R3+ solar flare degrading HF propagation on sunlit side", diff --git a/meshai/notifications/renderers/composer.py b/meshai/notifications/renderers/composer.py index 10b71f3..65941f8 100644 --- a/meshai/notifications/renderers/composer.py +++ b/meshai/notifications/renderers/composer.py @@ -44,6 +44,11 @@ _CATEGORY_EMOJI: dict[str, str] = { "hf_blackout": "⚠", "geomagnetic_storm": "🌐", "tropospheric_ducting": "📡", + # v0.5.7-rf additions: + "rf_anomalous_propagation": "📡", + "rf_ducting_enhancement": "📡", + "rf_propagation_alert": "⚠", + "solar_radiation_storm": "🌐", # Fire (v0.5.7-fire: fire_proximity/wildfire_proximity removed; aligned # to the new registry entries wildfire_hotspot + wildfire_incident). "wildfire_hotspot": "🔥", @@ -105,6 +110,11 @@ _CATEGORY_LABEL: dict[str, str] = { "hf_blackout": "RF", "geomagnetic_storm": "RF", "tropospheric_ducting": "RF", + # v0.5.7-rf additions: + "rf_anomalous_propagation": "RF", + "rf_ducting_enhancement": "RF", + "rf_propagation_alert": "RF", + "solar_radiation_storm": "RF", "road_closure": "ROADS", "traffic_congestion": "ROADS", "avalanche_warning": "AVY", diff --git a/tests/test_rf_v057.py b/tests/test_rf_v057.py new file mode 100644 index 0000000..ca06500 --- /dev/null +++ b/tests/test_rf_v057.py @@ -0,0 +1,243 @@ +"""v0.5.7-rf: SWPC subject validation + protons severity=0 docs + categories audit. + +Covers three things shipped in v0.5.7-rf: + +1. SWPC subscription subject -- verifies the existing `central.space.>` + tail-only-`>` form (per Central v0.10.0 guide §swpc_*: planetary, no + region in subject; one umbrella subscription covers swpc_alerts, + swpc_kindex, swpc_protons). The pattern was already correct from v0.5.4 + work; this phase pins it explicitly so future "add a region tail" + refactors fail loudly. +2. swpc_protons severity=0 routing -- per guide §swpc_protons live sample + the adapter always publishes severity=0. Verifies map_severity(0) -> + "routine" and the NotificationToggle.severity_channels string-keyed + dict accepts "routine" with no IndexError. The "silently dropped" + failure mode the prompt described does not exist; this test is a + regression guard against a future refactor introducing it. +3. ALERT_CATEGORIES RF-family audit -- adds four missing entries that + meshai emits but the rule editor couldn't target: + - rf_anomalous_propagation (ducting.py super_refraction tier) + - rf_ducting_enhancement (ducting.py duct + surface_duct tiers) + - rf_propagation_alert (central swpc_alerts -> space.alert) + - solar_radiation_storm (central swpc_protons -> space.proton_flux) + Verifies geomagnetic_storm (central swpc_kindex -> space.kindex) + stays mapped. Legacy hf_blackout and tropospheric_ducting are kept as + selectable forward-compat targets even though no current emitter + produces them; flagged in the commit body for follow-up. +""" + +import inspect +import json +import re + +import pytest + +from meshai.central.consumer import ( + CentralConsumer, + _SUBJECTS_BARE, + _subjects_for, + map_category, + map_severity, +) +from meshai.config import EnvironmentalConfig, NotificationToggle +from meshai.notifications.categories import ALERT_CATEGORIES +from meshai.notifications.pipeline.bus import EventBus + + +def _assert_legal_nats(subject: str) -> None: + tokens = subject.split(".") + if ">" in tokens: + assert tokens[-1] == ">", f"`>` not at tail in {subject!r}" + assert tokens.count(">") == 1, f"multiple `>` in {subject!r}" + for tok in tokens: + assert tok, f"empty token in {subject!r}" + if tok not in {"*", ">"}: + assert "*" not in tok and ">" not in tok, f"mixed wildcard in token {tok!r}" + + +# ---------- FIX 1: SWPC subject pattern ----------------------------------- + + +def test_swpc_subject_is_global_umbrella(): + """Per Central v0.10.0 guide §space stream, all SWPC adapters publish + under `central.space.>`. Single tail-only-`>` subscription catches + all three (swpc_alerts / swpc_kindex / swpc_protons).""" + subs = _subjects_for("swpc", "us.id") + assert subs == ["central.space.>"] + for s in subs: + _assert_legal_nats(s) + + +def test_swpc_subject_ignores_region(): + """Space weather is planetary; region argument MUST be a no-op.""" + assert _subjects_for("swpc", "us.id") == ["central.space.>"] + assert _subjects_for("swpc", "us.mt") == ["central.space.>"] + assert _subjects_for("swpc", "") == ["central.space.>"] + assert _subjects_for("swpc", None) == ["central.space.>"] + + +def test_swpc_subject_covers_all_three_adapter_subjects(): + """The umbrella `central.space.>` matches every per-adapter subject + documented in the guide.""" + sub = _subjects_for("swpc", "us.id")[0] + # `>` matches one or more tokens at the tail. + assert sub.endswith(".>") + prefix = sub[:-2] # strip the .> + for published in ( + "central.space.alert.a20f", # swpc_alerts (4 tokens, product_id tail) + "central.space.kindex", # swpc_kindex (3 tokens, fixed) + "central.space.proton_flux", # swpc_protons (3 tokens, fixed) + ): + assert published.startswith(prefix), f"{published!r} not covered by {sub!r}" + + +# ---------- FIX 2: severity=0 routing ------------------------------------- + + +def test_map_severity_zero_routes_to_routine(): + """All three SWPC adapters publish severity=0 by default. The boundary + contract: 0 -> 'routine' (not dropped, not error).""" + assert map_severity(0) == "routine" + + +def test_severity_channels_dict_accepts_routine_key(): + """NotificationToggle.severity_channels is dict-keyed by severity STRING + -- so "routine" is a valid key with no IndexError vector. Pins the + contract so a refactor to an int-indexed list would break this test.""" + t = NotificationToggle(name="rf_propagation") + assert isinstance(t.severity_channels, dict) + # dict.get returns the default for unknown keys; no exception possible. + assert t.severity_channels.get("routine", ["mesh_broadcast"]) == ["mesh_broadcast"] + + +def test_swpc_protons_severity_zero_routes_through_consumer(): + """Synthetic swpc_protons envelope (severity=0 per guide §swpc_protons) + -- verify it normalizes to ev.severity='routine' and emits on the bus + with no exception.""" + rec = [] + bus = EventBus(); bus.subscribe(rec.append) + c = CentralConsumer(EnvironmentalConfig(), bus) + env = {"id": "2026-05-18T05:55:00Z|>=100 MeV", "data": { + "id": "2026-05-18T05:55:00Z|>=100 MeV", "adapter": "swpc_protons", + "category": "space.proton_flux", + "time": "2026-05-18T05:55:00Z", "severity": 0, + "geo": {"centroid": None, "primary_region": None, "regions": []}, + "data": {"flux": 0.16, "energy": ">=100 MeV", + "time_tag": "2026-05-18T05:55:00Z", "satellite": 19}}} + ev = c._handle("central.space.proton_flux", json.dumps(env).encode()) + assert ev is not None + assert ev.severity == "routine" + assert ev.category == "solar_radiation_storm" + assert ev.source == "swpc" + assert len(rec) == 1 + + +def test_swpc_kindex_severity_zero_routes_through_consumer(): + """Synthetic swpc_kindex envelope -- verifies central path mapping for + a second SWPC adapter (severity=0 -> 'routine', space.kindex -> + geomagnetic_storm).""" + rec = [] + bus = EventBus(); bus.subscribe(rec.append) + c = CentralConsumer(EnvironmentalConfig(), bus) + env = {"id": "2026-05-12T00:00:00", "data": { + "id": "2026-05-12T00:00:00", "adapter": "swpc_kindex", + "category": "space.kindex", + "time": "2026-05-12T00:00:00Z", "severity": 0, + "geo": {"centroid": None, "primary_region": None, "regions": []}, + "data": {"Kp": 0.67, "time_tag": "2026-05-12T00:00:00", + "a_running": 3, "station_count": 8}}} + ev = c._handle("central.space.kindex", json.dumps(env).encode()) + assert ev is not None + assert ev.severity == "routine" + assert ev.category == "geomagnetic_storm" + + +# ---------- FIX 3: ALERT_CATEGORIES RF-family audit ---------------------- + + +@pytest.mark.parametrize("cat", [ + "rf_anomalous_propagation", + "rf_ducting_enhancement", + "rf_propagation_alert", + "solar_radiation_storm", +]) +def test_v057_rf_added_categories_present(cat): + """v0.5.7-rf: four new rf_propagation categories must be registry-present + so the Advanced Rules editor can target them.""" + assert cat in ALERT_CATEGORIES + info = ALERT_CATEGORIES[cat] + assert info["toggle"] == "rf_propagation" + assert info["name"] + assert info["description"] + assert info["default_severity"] in {"routine", "priority", "immediate"} + assert info["example_message"] + + +def test_geomagnetic_storm_still_in_registry(): + """swpc_kindex -> space.kindex -> geomagnetic_storm: registry entry + survives the v0.5.7-rf edit.""" + assert "geomagnetic_storm" in ALERT_CATEGORIES + assert ALERT_CATEGORIES["geomagnetic_storm"]["toggle"] == "rf_propagation" + + +@pytest.mark.parametrize( + "central_cat,expected", + [ + ("space.alert.a20f", "rf_propagation_alert"), + ("space.alert", "rf_propagation_alert"), + ("space.kindex", "geomagnetic_storm"), + ("space.proton_flux", "solar_radiation_storm"), + ("space.unknown_sub", "geomagnetic_storm"), # catchall + ], +) +def test_map_category_swpc_routings(central_cat, expected): + """Pin the central -> meshai category map for each SWPC adapter.""" + assert map_category(central_cat) == expected + + +def _native_emitted_rf_categories() -> set[str]: + """Walk ducting.py for _TIER_CATEGORY values mapping to toggle=rf_propagation.""" + from meshai.env import ducting as ducting_mod + src = inspect.getsource(ducting_mod) + # _TIER_CATEGORY entries are `"": "",` literals. + emitted = set(re.findall( + r'_TIER_CATEGORY\s*=\s*\{([^}]+)\}', src, re.DOTALL)) + cats: set[str] = set() + for block in emitted: + cats |= set(re.findall(r':\s*"([a-z_]+)"', block)) + return {c for c in cats if c in ALERT_CATEGORIES + and ALERT_CATEGORIES[c].get("toggle") == "rf_propagation"} + + +def _central_path_rf_categories() -> set[str]: + central_inputs = [ + "space.alert.a20f", "space.alert", + "space.kindex", + "space.proton_flux", + "space.unknown", + ] + return {map_category(c) for c in central_inputs} + + +def test_alert_categories_rf_complete(): + """Native + central-path emit set must be a SUBSET of registry rf + entries (i.e., everything we emit is selectable). Legacy entries + without an emitter are allowed as forward-compat targets and + documented in the commit body.""" + registry_rf = { + cid for cid, info in ALERT_CATEGORIES.items() + if info.get("toggle") == "rf_propagation" + } + native = _native_emitted_rf_categories() + central = _central_path_rf_categories() + emitted = native | central + missing = emitted - registry_rf + assert not missing, f"rf emit set missing from ALERT_CATEGORIES: {missing}" + # Sanity: at minimum the four v0.5.7-rf additions + geomagnetic_storm + # must be in the emit set. + for required in ( + "rf_anomalous_propagation", "rf_ducting_enhancement", + "rf_propagation_alert", "solar_radiation_storm", + "geomagnetic_storm", + ): + assert required in emitted, f"{required!r} not emitted by native or central path"