From e982cbbdf6e488cd8c21cc79e2c955d2bd8a721d Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Sun, 21 Jun 2026 06:56:22 +0000 Subject: [PATCH] refactor(danger-zones): drop min_severity + position_max_age; table snow (grayed/inert) - alert on any hazard touching a node (severity gate removed) - alert regardless of position staleness; only skip nodes with no position - snow tabled: grayed-out in GUI + skipped in correlator pending snowfall+elevation pipeline Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/pages/Notifications.tsx | 49 +++----- work/meshai/config.py | 15 --- work/meshai/mesh_data_store.py | 10 +- .../notifications/danger_zone_correlator.py | 14 +-- work/tests/test_danger_zone_correlator.py | 116 ++++++++---------- 5 files changed, 81 insertions(+), 123 deletions(-) diff --git a/work/dashboard-frontend/src/pages/Notifications.tsx b/work/dashboard-frontend/src/pages/Notifications.tsx index ac7100b..5e49212 100644 --- a/work/dashboard-frontend/src/pages/Notifications.tsx +++ b/work/dashboard-frontend/src/pages/Notifications.tsx @@ -2220,12 +2220,6 @@ export default function Notifications() { const DZ_MONITOR_ROLES = ['CLIENT_BASE', 'ROUTER', 'ROUTER_LATE'] as const -const DZ_SEVERITY_OPTIONS = [ - { value: 'routine', label: 'Routine' }, - { value: 'priority', label: 'Priority' }, - { value: 'immediate', label: 'Immediate' }, -] - const DZ_DELIVERY_OPTIONS = [ { value: 'mesh_dm', label: 'Mesh DM (unicast to nodes)' }, { value: 'mesh_broadcast', label: 'Mesh Broadcast (channel)' }, @@ -2241,10 +2235,11 @@ const DZ_FAMILIES: { description: string Icon: typeof Activity showAcres?: boolean + tabled?: boolean }[] = [ { key: 'fire', label: 'Fire', description: 'Active wildfires (radius from fire perimeter).', Icon: Flame, showAcres: true }, { key: 'weather', label: 'Weather', description: 'Severe weather warnings near a node.', Icon: Cloud }, - { key: 'snow', label: 'Snow (sub-gate of Weather)', description: 'Snow-category weather events.', Icon: Snowflake }, + { key: 'snow', label: 'Snow (sub-gate of Weather)', description: 'Snow-category weather events.', Icon: Snowflake, tabled: true }, { key: 'flood', label: 'Flood (sub-gate of Seismic)', description: 'Stream/flood gauge events.', Icon: Activity }, { key: 'avalanche', label: 'Avalanche', description: 'Avalanche advisories near a node.', Icon: Mountain }, { key: 'seismic', label: 'Seismic', description: 'Earthquakes and seismic events near a node.', Icon: Mountain }, @@ -2253,7 +2248,6 @@ const DZ_FAMILIES: { interface DangerZoneHazardConfig { enabled: boolean buffer_mi: number - min_severity: string min_acres: number } @@ -2261,7 +2255,6 @@ interface DangerZonesConfig { enabled: boolean dry_run: boolean monitor_roles: string[] - position_max_age_hours: number default_buffer_mi: number cooldown_minutes: number fire: DangerZoneHazardConfig @@ -2278,7 +2271,7 @@ interface DangerZonesConfig { } function dzDefaultHazard(): DangerZoneHazardConfig { - return { enabled: false, buffer_mi: 5.0, min_severity: 'priority', min_acres: 0 } + return { enabled: false, buffer_mi: 5.0, min_acres: 0 } } // New-object default. NOTE: delivery_type defaults to mesh_dm (do NOT copy the @@ -2288,7 +2281,6 @@ function dzDefault(): DangerZonesConfig { enabled: false, dry_run: true, monitor_roles: ['ROUTER', 'ROUTER_LATE', 'CLIENT_BASE'], - position_max_age_hours: 72, default_buffer_mi: 5.0, cooldown_minutes: 360, fire: dzDefaultHazard(), @@ -2337,27 +2329,33 @@ function DZSelect({ label, value, onChange, options, info = '' }: { // AlertRuleToggle itself lives in Config.tsx and is NOT exported, so this is a // local equivalent purpose-built for the per-family hazard config. function DZFamilyRow({ meta, cfg, onChange }: { - meta: { key: string; label: string; description: string; Icon: typeof Activity; showAcres?: boolean } + meta: { key: string; label: string; description: string; Icon: typeof Activity; showAcres?: boolean; tabled?: boolean } cfg: DangerZoneHazardConfig onChange: (c: DangerZoneHazardConfig) => void }) { const { Icon } = meta return ( -
+
{meta.label}

{meta.description}

+ {meta.tabled && ( + + Tabled — needs snowfall + elevation pipeline + + )}
- {cfg.enabled && ( -
+ {cfg.enabled && !meta.tabled && ( +
- onChange({ ...cfg, min_severity: v })} - options={DZ_SEVERITY_OPTIONS} - /> {meta.showAcres && (
{DZ_MONITOR_ROLES.map(role => { @@ -2554,14 +2546,7 @@ function DangerZonesPanel() {
{/* Global numeric settings */} -
- upd({ position_max_age_hours: v })} - min={1} - helper="Skip nodes whose last position is older than this" - /> +
0, got " - f"{self.position_max_age_hours}") - - for fam in ("fire", "weather", "snow", "flood", "avalanche", "seismic"): - sub = getattr(self, fam) - if sub.min_severity not in _DZ_VALID_SEVERITIES: - raise ValueError( - f"danger_zones.{fam}.min_severity must be one of " - f"{sorted(_DZ_VALID_SEVERITIES)}, got {sub.min_severity!r}") - @dataclass class Config: diff --git a/work/meshai/mesh_data_store.py b/work/meshai/mesh_data_store.py index 22bea59..44047db 100644 --- a/work/meshai/mesh_data_store.py +++ b/work/meshai/mesh_data_store.py @@ -2116,22 +2116,20 @@ class MeshDataStore: infra_roles = {"ROUTER", "ROUTER_CLIENT", "ROUTER_LATE", "REPEATER"} return [n for n in self._nodes.values() if n.role in infra_roles] - def get_nodes_by_roles(self, roles: set[str], max_age_s: float) -> list[UnifiedNode]: - """Return a SNAPSHOT of nodes whose role is in `roles`, that have a - non-None GPS position, and whose last_heard is within `max_age_s`. + def get_nodes_by_roles(self, roles: set[str]) -> list[UnifiedNode]: + """Return a SNAPSHOT of nodes whose role is in `roles` and that have a + non-None GPS position (lat AND lon). Position staleness is NOT filtered + here — nodes with NO position are the only ones skipped. Used by the danger-zone correlator. CLIENT_BASE-inclusive (do NOT reuse get_infrastructure_nodes, which excludes CLIENT_BASE). Returns a new list so callers can iterate safely across async scheduling. """ - cutoff = time.time() - max_age_s return [ n for n in list(self._nodes.values()) if n.role in roles and n.latitude is not None and n.longitude is not None - and n.last_heard - and n.last_heard >= cutoff ] def get_low_battery_nodes(self, threshold: float = 30.0) -> list[UnifiedNode]: diff --git a/work/meshai/notifications/danger_zone_correlator.py b/work/meshai/notifications/danger_zone_correlator.py index e1f5583..1729ec2 100644 --- a/work/meshai/notifications/danger_zone_correlator.py +++ b/work/meshai/notifications/danger_zone_correlator.py @@ -23,7 +23,6 @@ from meshai.notifications import categories from meshai.notifications.events import make_event, make_payload_from_event from meshai.notifications import channels from meshai.config import NotificationRuleConfig -from meshai.notifications.pipeline.dispatcher import Dispatcher logger = logging.getLogger(__name__) @@ -128,9 +127,11 @@ class DangerZoneCorrelator: if sub is None or not sub.enabled: return - # min_severity gate (use Dispatcher's rank table). - rank = Dispatcher.SEVERITY_RANK - if rank.get(event.severity, 0) < rank.get(sub.min_severity, 0): + # Snow is tabled pending a snowfall + elevation pipeline: a generic + # winter-weather warning over a whole zone is not a per-node hazard + # without elevation/snowfall modeling. Skip snow entirely; general + # (non-snow) weather still correlates. (snow config kept for schema/GUI.) + if fam == "snow": return # Fire radius + acres; non-fire is a point hazard at the event location. @@ -151,10 +152,7 @@ class DangerZoneCorrelator: # Snapshot nodes BEFORE any async scheduling. try: - nodes = self.data_store.get_nodes_by_roles( - set(cfg.monitor_roles), - cfg.position_max_age_hours * 3600, - ) + nodes = self.data_store.get_nodes_by_roles(set(cfg.monitor_roles)) except Exception: logger.exception("danger_zone: get_nodes_by_roles failed") return diff --git a/work/tests/test_danger_zone_correlator.py b/work/tests/test_danger_zone_correlator.py index f8b4334..795d812 100644 --- a/work/tests/test_danger_zone_correlator.py +++ b/work/tests/test_danger_zone_correlator.py @@ -48,21 +48,18 @@ class StubConnector: class FakeDataStore: """Minimal stand-in exposing get_nodes_by_roles with the SAME filter semantics as the real MeshDataStore.get_nodes_by_roles (role membership + - fresh last_heard + non-None position), so freshness/role tests exercise - real filtering rather than a hand-fed list.""" + non-None position only; no freshness filter), so role tests exercise real + filtering rather than a hand-fed list.""" def __init__(self, nodes): self._nodes = list(nodes) - def get_nodes_by_roles(self, roles, max_age_s): - cutoff = time.time() - max_age_s + def get_nodes_by_roles(self, roles): return [ n for n in list(self._nodes) if n.role in roles and n.latitude is not None and n.longitude is not None - and n.last_heard - and n.last_heard >= cutoff ] @@ -80,7 +77,7 @@ def _node(*, node_num, role="ROUTER", lat=42.0, lon=-114.0, def _config(*, enabled=True, dry_run=False, delivery_type="mesh_dm", node_ids=("!aaaa0001",), cooldown_minutes=360, - position_max_age_hours=72, default_buffer_mi=5.0, + default_buffer_mi=5.0, **family_overrides): """Build a Config whose danger_zones is configured per test. @@ -93,7 +90,6 @@ def _config(*, enabled=True, dry_run=False, delivery_type="mesh_dm", delivery_type=delivery_type, node_ids=list(node_ids), cooldown_minutes=cooldown_minutes, - position_max_age_hours=position_max_age_hours, default_buffer_mi=default_buffer_mi, monitor_roles=["ROUTER", "ROUTER_LATE", "CLIENT_BASE"], **family_overrides, @@ -191,27 +187,6 @@ async def test_client_node_not_flagged_but_client_base_is(): assert len(conn.calls) == 1 -# --------------------------------------------------------------------------- -# 4. Freshness -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_stale_node_skipped(): - """A node whose last_heard is older than position_max_age_hours is - skipped (not scanned).""" - conn = StubConnector() - stale = _node(node_num=301, lat=42.0, lon=-114.0, - last_heard=time.time() - 100 * 3600) # 100h old - ds = FakeDataStore([stale]) - cfg = _config(dry_run=False, position_max_age_hours=72) - corr = DangerZoneCorrelator(cfg, ds, conn) - - corr.handle(_avalanche_event(42.0, -114.0)) - await asyncio.sleep(0) - assert conn.calls == [] - - # --------------------------------------------------------------------------- # 5. Cooldown # --------------------------------------------------------------------------- @@ -279,41 +254,10 @@ async def test_disabled_does_nothing(): # --------------------------------------------------------------------------- -# 7. min_severity / family disabled +# 7. family disabled # --------------------------------------------------------------------------- -@pytest.mark.asyncio -async def test_below_min_severity_not_flagged(): - """An event below the family's min_severity -> no flag.""" - conn = StubConnector() - node = _node(node_num=601, lat=42.0, lon=-114.0) - ds = FakeDataStore([node]) - # avalanche min_severity=immediate; event is only 'priority' -> gated out. - cfg = _config(dry_run=False, - avalanche=DangerZoneHazardConfig(min_severity="immediate")) - corr = DangerZoneCorrelator(cfg, ds, conn) - - corr.handle(_avalanche_event(42.0, -114.0, severity="priority")) - await asyncio.sleep(0) - assert conn.calls == [] - - -@pytest.mark.asyncio -async def test_min_severity_met_is_flagged(): - """Sanity counterpart: an event AT min_severity passes the gate.""" - conn = StubConnector() - node = _node(node_num=602, lat=42.0, lon=-114.0) - ds = FakeDataStore([node]) - cfg = _config(dry_run=False, - avalanche=DangerZoneHazardConfig(min_severity="priority")) - corr = DangerZoneCorrelator(cfg, ds, conn) - - corr.handle(_avalanche_event(42.0, -114.0, severity="priority")) - await asyncio.sleep(0) - assert len(conn.calls) == 1 - - @pytest.mark.asyncio async def test_family_disabled_not_flagged(): """The family sub-config disabled -> no flag even on a clear hit.""" @@ -341,7 +285,7 @@ async def test_seismic_event_routes_and_flags(): node = _node(node_num=701, lat=42.0, lon=-114.0) ds = FakeDataStore([node]) cfg = _config(dry_run=False, - seismic=DangerZoneHazardConfig(min_severity="routine")) + seismic=DangerZoneHazardConfig(enabled=True)) corr = DangerZoneCorrelator(cfg, ds, conn) ev = make_event(source="usgs", category="earthquake_event", @@ -350,3 +294,51 @@ async def test_seismic_event_routes_and_flags(): corr.handle(ev) await asyncio.sleep(0) assert len(conn.calls) == 1 + + +# --------------------------------------------------------------------------- +# 8. Snow is tabled -- skipped even when the snow family is enabled; general +# (non-snow) weather still correlates. +# --------------------------------------------------------------------------- + + +def _weather_event(lat, lon, *, event_type, severity="priority", title="Weather"): + """A weather hazard. event_type drives snow-vs-general classification.""" + return make_event( + source="nws", category="weather_warning", severity=severity, + title=title, summary=title, lat=lat, lon=lon, + data={"event_type": event_type}, + ) + + +@pytest.mark.asyncio +async def test_snow_event_is_skipped_even_when_enabled(): + """A snow-classified weather event is SKIPPED (no send) even with the snow + family enabled and a co-located node.""" + conn = StubConnector() + node = _node(node_num=801, lat=42.0, lon=-114.0) + ds = FakeDataStore([node]) + cfg = _config(dry_run=False, + snow=DangerZoneHazardConfig(enabled=True), + weather=DangerZoneHazardConfig(enabled=True)) + corr = DangerZoneCorrelator(cfg, ds, conn) + + corr.handle(_weather_event(42.0, -114.0, event_type="Winter Storm Warning")) + await asyncio.sleep(0) + assert conn.calls == [], "snow events must be skipped (tabled)" + # Correlator must not even record a cooldown for a skipped snow event. + assert corr._last == {} + + +@pytest.mark.asyncio +async def test_non_snow_weather_still_correlates(): + """A general (non-snow) weather event still flags a co-located node.""" + conn = StubConnector() + node = _node(node_num=802, lat=42.0, lon=-114.0) + ds = FakeDataStore([node]) + cfg = _config(dry_run=False, weather=DangerZoneHazardConfig(enabled=True)) + corr = DangerZoneCorrelator(cfg, ds, conn) + + corr.handle(_weather_event(42.0, -114.0, event_type="Severe Thunderstorm Warning")) + await asyncio.sleep(0) + assert len(conn.calls) == 1