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