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) <noreply@anthropic.com>
This commit is contained in:
Ubuntu 2026-06-21 06:56:22 +00:00
commit e982cbbdf6
5 changed files with 80 additions and 122 deletions

View file

@ -2220,12 +2220,6 @@ export default function Notifications() {
const DZ_MONITOR_ROLES = ['CLIENT_BASE', 'ROUTER', 'ROUTER_LATE'] as const 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 = [ const DZ_DELIVERY_OPTIONS = [
{ value: 'mesh_dm', label: 'Mesh DM (unicast to nodes)' }, { value: 'mesh_dm', label: 'Mesh DM (unicast to nodes)' },
{ value: 'mesh_broadcast', label: 'Mesh Broadcast (channel)' }, { value: 'mesh_broadcast', label: 'Mesh Broadcast (channel)' },
@ -2241,10 +2235,11 @@ const DZ_FAMILIES: {
description: string description: string
Icon: typeof Activity Icon: typeof Activity
showAcres?: boolean showAcres?: boolean
tabled?: boolean
}[] = [ }[] = [
{ key: 'fire', label: 'Fire', description: 'Active wildfires (radius from fire perimeter).', Icon: Flame, showAcres: true }, { 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: '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: '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: '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 }, { key: 'seismic', label: 'Seismic', description: 'Earthquakes and seismic events near a node.', Icon: Mountain },
@ -2253,7 +2248,6 @@ const DZ_FAMILIES: {
interface DangerZoneHazardConfig { interface DangerZoneHazardConfig {
enabled: boolean enabled: boolean
buffer_mi: number buffer_mi: number
min_severity: string
min_acres: number min_acres: number
} }
@ -2261,7 +2255,6 @@ interface DangerZonesConfig {
enabled: boolean enabled: boolean
dry_run: boolean dry_run: boolean
monitor_roles: string[] monitor_roles: string[]
position_max_age_hours: number
default_buffer_mi: number default_buffer_mi: number
cooldown_minutes: number cooldown_minutes: number
fire: DangerZoneHazardConfig fire: DangerZoneHazardConfig
@ -2278,7 +2271,7 @@ interface DangerZonesConfig {
} }
function dzDefaultHazard(): DangerZoneHazardConfig { 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 // New-object default. NOTE: delivery_type defaults to mesh_dm (do NOT copy the
@ -2288,7 +2281,6 @@ function dzDefault(): DangerZonesConfig {
enabled: false, enabled: false,
dry_run: true, dry_run: true,
monitor_roles: ['ROUTER', 'ROUTER_LATE', 'CLIENT_BASE'], monitor_roles: ['ROUTER', 'ROUTER_LATE', 'CLIENT_BASE'],
position_max_age_hours: 72,
default_buffer_mi: 5.0, default_buffer_mi: 5.0,
cooldown_minutes: 360, cooldown_minutes: 360,
fire: dzDefaultHazard(), 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 // AlertRuleToggle itself lives in Config.tsx and is NOT exported, so this is a
// local equivalent purpose-built for the per-family hazard config. // local equivalent purpose-built for the per-family hazard config.
function DZFamilyRow({ meta, cfg, onChange }: { 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 cfg: DangerZoneHazardConfig
onChange: (c: DangerZoneHazardConfig) => void onChange: (c: DangerZoneHazardConfig) => void
}) { }) {
const { Icon } = meta const { Icon } = meta
return ( return (
<div className="border border-[#1e2a3a] p-3 space-y-2"> <div className={`border border-[#1e2a3a] p-3 space-y-2 ${meta.tabled ? 'opacity-50' : ''}`}>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-start gap-2 flex-1"> <div className="flex items-start gap-2 flex-1">
<Icon size={15} className="text-slate-400 mt-0.5 flex-shrink-0" /> <Icon size={15} className="text-slate-400 mt-0.5 flex-shrink-0" />
<div className="flex-1"> <div className="flex-1">
<span className="text-sm text-slate-300">{meta.label}</span> <span className="text-sm text-slate-300">{meta.label}</span>
<p className="text-xs text-slate-600">{meta.description}</p> <p className="text-xs text-slate-600">{meta.description}</p>
{meta.tabled && (
<span className="inline-block mt-1 px-2 py-0.5 text-[10px] uppercase tracking-wide rounded bg-slate-700 text-slate-300">
Tabled needs snowfall + elevation pipeline
</span>
)}
</div> </div>
</div> </div>
<button <button
type="button" type="button"
onClick={() => onChange({ ...cfg, enabled: !cfg.enabled })} disabled={meta.tabled}
onClick={() => { if (!meta.tabled) onChange({ ...cfg, enabled: !cfg.enabled }) }}
className={`relative w-11 h-6 rounded-full transition-colors flex-shrink-0 ml-3 ${ className={`relative w-11 h-6 rounded-full transition-colors flex-shrink-0 ml-3 ${
cfg.enabled ? 'bg-accent' : 'bg-[#1e2a3a]' cfg.enabled ? 'bg-accent' : 'bg-[#1e2a3a]'
}`} } ${meta.tabled ? 'cursor-not-allowed' : ''}`}
> >
<span <span
className={`absolute top-1 left-1 w-4 h-4 rounded-full bg-white transition-transform ${ className={`absolute top-1 left-1 w-4 h-4 rounded-full bg-white transition-transform ${
@ -2366,8 +2364,8 @@ function DZFamilyRow({ meta, cfg, onChange }: {
/> />
</button> </button>
</div> </div>
{cfg.enabled && ( {cfg.enabled && !meta.tabled && (
<div className={`grid gap-3 pt-2 border-t border-[#1e2a3a] ${meta.showAcres ? 'grid-cols-3' : 'grid-cols-2'}`}> <div className={`grid gap-3 pt-2 border-t border-[#1e2a3a] ${meta.showAcres ? 'grid-cols-2' : 'grid-cols-1'}`}>
<NumberInput <NumberInput
label="Buffer (mi)" label="Buffer (mi)"
value={cfg.buffer_mi ?? 0} value={cfg.buffer_mi ?? 0}
@ -2375,12 +2373,6 @@ function DZFamilyRow({ meta, cfg, onChange }: {
min={0} min={0}
step={0.5} step={0.5}
/> />
<DZSelect
label="Min Severity"
value={cfg.min_severity || 'priority'}
onChange={(v) => onChange({ ...cfg, min_severity: v })}
options={DZ_SEVERITY_OPTIONS}
/>
{meta.showAcres && ( {meta.showAcres && (
<NumberInput <NumberInput
label="Min Acres" label="Min Acres"
@ -2532,7 +2524,7 @@ function DangerZonesPanel() {
<div className="space-y-2"> <div className="space-y-2">
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide"> <label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
Monitored Roles Monitored Roles
<InfoButton info="Which Meshtastic node roles to correlate against hazards. Only nodes with a fresh GPS position (see Position Max Age) are scanned." /> <InfoButton info="Which Meshtastic node roles to correlate against hazards. Only nodes that have a GPS position are scanned." />
</label> </label>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{DZ_MONITOR_ROLES.map(role => { {DZ_MONITOR_ROLES.map(role => {
@ -2554,14 +2546,7 @@ function DangerZonesPanel() {
</div> </div>
{/* Global numeric settings */} {/* Global numeric settings */}
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-2 gap-4">
<NumberInput
label="Position Max Age (hrs)"
value={cfg.position_max_age_hours}
onChange={(v) => upd({ position_max_age_hours: v })}
min={1}
helper="Skip nodes whose last position is older than this"
/>
<NumberInput <NumberInput
label="Default Buffer (mi)" label="Default Buffer (mi)"
value={cfg.default_buffer_mi} value={cfg.default_buffer_mi}

View file

@ -679,7 +679,6 @@ _DZ_VALID_ROLES = frozenset({
"CLIENT", "CLIENT_MUTE", "TRACKER", "TAK", "CLIENT", "CLIENT_MUTE", "TRACKER", "TAK",
}) })
_DZ_VALID_SEVERITIES = frozenset({"routine", "priority", "immediate"})
_DZ_VALID_DELIVERY = frozenset({ _DZ_VALID_DELIVERY = frozenset({
"mesh_broadcast", "mesh_dm", "email", "webhook", "none", "mesh_broadcast", "mesh_dm", "email", "webhook", "none",
}) })
@ -695,7 +694,6 @@ class DangerZoneHazardConfig:
enabled: bool = True enabled: bool = True
buffer_mi: float = 5.0 buffer_mi: float = 5.0
min_severity: str = "priority" # routine|priority|immediate
min_acres: float = 0.0 # fire-only; ignored by other families min_acres: float = 0.0 # fire-only; ignored by other families
@ -711,7 +709,6 @@ class DangerZonesConfig:
dry_run: bool = True dry_run: bool = True
monitor_roles: list[str] = field( monitor_roles: list[str] = field(
default_factory=lambda: ["ROUTER", "ROUTER_LATE", "CLIENT_BASE"]) default_factory=lambda: ["ROUTER", "ROUTER_LATE", "CLIENT_BASE"])
position_max_age_hours: int = 72
default_buffer_mi: float = 5.0 default_buffer_mi: float = 5.0
cooldown_minutes: int = 360 cooldown_minutes: int = 360
@ -754,18 +751,6 @@ class DangerZonesConfig:
f"danger_zones parent family {fam!r} is not a valid toggle " f"danger_zones parent family {fam!r} is not a valid toggle "
f"({sorted(VALID_TOGGLES)})") f"({sorted(VALID_TOGGLES)})")
if self.position_max_age_hours <= 0:
raise ValueError(
"danger_zones.position_max_age_hours must be > 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 @dataclass
class Config: class Config:

View file

@ -2116,22 +2116,20 @@ class MeshDataStore:
infra_roles = {"ROUTER", "ROUTER_CLIENT", "ROUTER_LATE", "REPEATER"} infra_roles = {"ROUTER", "ROUTER_CLIENT", "ROUTER_LATE", "REPEATER"}
return [n for n in self._nodes.values() if n.role in infra_roles] 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]: def get_nodes_by_roles(self, roles: set[str]) -> list[UnifiedNode]:
"""Return a SNAPSHOT of nodes whose role is in `roles`, that have a """Return a SNAPSHOT of nodes whose role is in `roles` and that have a
non-None GPS position, and whose last_heard is within `max_age_s`. 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 Used by the danger-zone correlator. CLIENT_BASE-inclusive (do NOT
reuse get_infrastructure_nodes, which excludes CLIENT_BASE). Returns a reuse get_infrastructure_nodes, which excludes CLIENT_BASE). Returns a
new list so callers can iterate safely across async scheduling. new list so callers can iterate safely across async scheduling.
""" """
cutoff = time.time() - max_age_s
return [ return [
n for n in list(self._nodes.values()) n for n in list(self._nodes.values())
if n.role in roles if n.role in roles
and n.latitude is not None and n.latitude is not None
and n.longitude 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]: def get_low_battery_nodes(self, threshold: float = 30.0) -> list[UnifiedNode]:

View file

@ -23,7 +23,6 @@ from meshai.notifications import categories
from meshai.notifications.events import make_event, make_payload_from_event from meshai.notifications.events import make_event, make_payload_from_event
from meshai.notifications import channels from meshai.notifications import channels
from meshai.config import NotificationRuleConfig from meshai.config import NotificationRuleConfig
from meshai.notifications.pipeline.dispatcher import Dispatcher
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -128,9 +127,11 @@ class DangerZoneCorrelator:
if sub is None or not sub.enabled: if sub is None or not sub.enabled:
return return
# min_severity gate (use Dispatcher's rank table). # Snow is tabled pending a snowfall + elevation pipeline: a generic
rank = Dispatcher.SEVERITY_RANK # winter-weather warning over a whole zone is not a per-node hazard
if rank.get(event.severity, 0) < rank.get(sub.min_severity, 0): # without elevation/snowfall modeling. Skip snow entirely; general
# (non-snow) weather still correlates. (snow config kept for schema/GUI.)
if fam == "snow":
return return
# Fire radius + acres; non-fire is a point hazard at the event location. # 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. # Snapshot nodes BEFORE any async scheduling.
try: try:
nodes = self.data_store.get_nodes_by_roles( nodes = self.data_store.get_nodes_by_roles(set(cfg.monitor_roles))
set(cfg.monitor_roles),
cfg.position_max_age_hours * 3600,
)
except Exception: except Exception:
logger.exception("danger_zone: get_nodes_by_roles failed") logger.exception("danger_zone: get_nodes_by_roles failed")
return return

View file

@ -48,21 +48,18 @@ class StubConnector:
class FakeDataStore: class FakeDataStore:
"""Minimal stand-in exposing get_nodes_by_roles with the SAME filter """Minimal stand-in exposing get_nodes_by_roles with the SAME filter
semantics as the real MeshDataStore.get_nodes_by_roles (role membership + semantics as the real MeshDataStore.get_nodes_by_roles (role membership +
fresh last_heard + non-None position), so freshness/role tests exercise non-None position only; no freshness filter), so role tests exercise real
real filtering rather than a hand-fed list.""" filtering rather than a hand-fed list."""
def __init__(self, nodes): def __init__(self, nodes):
self._nodes = list(nodes) self._nodes = list(nodes)
def get_nodes_by_roles(self, roles, max_age_s): def get_nodes_by_roles(self, roles):
cutoff = time.time() - max_age_s
return [ return [
n for n in list(self._nodes) n for n in list(self._nodes)
if n.role in roles if n.role in roles
and n.latitude is not None and n.latitude is not None
and n.longitude 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", def _config(*, enabled=True, dry_run=False, delivery_type="mesh_dm",
node_ids=("!aaaa0001",), cooldown_minutes=360, node_ids=("!aaaa0001",), cooldown_minutes=360,
position_max_age_hours=72, default_buffer_mi=5.0, default_buffer_mi=5.0,
**family_overrides): **family_overrides):
"""Build a Config whose danger_zones is configured per test. """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, delivery_type=delivery_type,
node_ids=list(node_ids), node_ids=list(node_ids),
cooldown_minutes=cooldown_minutes, cooldown_minutes=cooldown_minutes,
position_max_age_hours=position_max_age_hours,
default_buffer_mi=default_buffer_mi, default_buffer_mi=default_buffer_mi,
monitor_roles=["ROUTER", "ROUTER_LATE", "CLIENT_BASE"], monitor_roles=["ROUTER", "ROUTER_LATE", "CLIENT_BASE"],
**family_overrides, **family_overrides,
@ -191,27 +187,6 @@ async def test_client_node_not_flagged_but_client_base_is():
assert len(conn.calls) == 1 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 # 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 @pytest.mark.asyncio
async def test_family_disabled_not_flagged(): async def test_family_disabled_not_flagged():
"""The family sub-config disabled -> no flag even on a clear hit.""" """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) node = _node(node_num=701, lat=42.0, lon=-114.0)
ds = FakeDataStore([node]) ds = FakeDataStore([node])
cfg = _config(dry_run=False, cfg = _config(dry_run=False,
seismic=DangerZoneHazardConfig(min_severity="routine")) seismic=DangerZoneHazardConfig(enabled=True))
corr = DangerZoneCorrelator(cfg, ds, conn) corr = DangerZoneCorrelator(cfg, ds, conn)
ev = make_event(source="usgs", category="earthquake_event", ev = make_event(source="usgs", category="earthquake_event",
@ -350,3 +294,51 @@ async def test_seismic_event_routes_and_flags():
corr.handle(ev) corr.handle(ev)
await asyncio.sleep(0) await asyncio.sleep(0)
assert len(conn.calls) == 1 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