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"