mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
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:
parent
95b1a23ef1
commit
e982cbbdf6
5 changed files with 80 additions and 122 deletions
|
|
@ -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 (
|
||||
<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-start gap-2 flex-1">
|
||||
<Icon size={15} className="text-slate-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<span className="text-sm text-slate-300">{meta.label}</span>
|
||||
<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>
|
||||
<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 ${
|
||||
cfg.enabled ? 'bg-accent' : 'bg-[#1e2a3a]'
|
||||
}`}
|
||||
} ${meta.tabled ? 'cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<span
|
||||
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>
|
||||
</div>
|
||||
{cfg.enabled && (
|
||||
<div className={`grid gap-3 pt-2 border-t border-[#1e2a3a] ${meta.showAcres ? 'grid-cols-3' : 'grid-cols-2'}`}>
|
||||
{cfg.enabled && !meta.tabled && (
|
||||
<div className={`grid gap-3 pt-2 border-t border-[#1e2a3a] ${meta.showAcres ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
||||
<NumberInput
|
||||
label="Buffer (mi)"
|
||||
value={cfg.buffer_mi ?? 0}
|
||||
|
|
@ -2375,12 +2373,6 @@ function DZFamilyRow({ meta, cfg, onChange }: {
|
|||
min={0}
|
||||
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 && (
|
||||
<NumberInput
|
||||
label="Min Acres"
|
||||
|
|
@ -2532,7 +2524,7 @@ function DangerZonesPanel() {
|
|||
<div className="space-y-2">
|
||||
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
|
||||
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>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{DZ_MONITOR_ROLES.map(role => {
|
||||
|
|
@ -2554,14 +2546,7 @@ function DangerZonesPanel() {
|
|||
</div>
|
||||
|
||||
{/* Global numeric settings */}
|
||||
<div className="grid grid-cols-3 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"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<NumberInput
|
||||
label="Default Buffer (mi)"
|
||||
value={cfg.default_buffer_mi}
|
||||
|
|
|
|||
|
|
@ -679,7 +679,6 @@ _DZ_VALID_ROLES = frozenset({
|
|||
"CLIENT", "CLIENT_MUTE", "TRACKER", "TAK",
|
||||
})
|
||||
|
||||
_DZ_VALID_SEVERITIES = frozenset({"routine", "priority", "immediate"})
|
||||
_DZ_VALID_DELIVERY = frozenset({
|
||||
"mesh_broadcast", "mesh_dm", "email", "webhook", "none",
|
||||
})
|
||||
|
|
@ -695,7 +694,6 @@ class DangerZoneHazardConfig:
|
|||
|
||||
enabled: bool = True
|
||||
buffer_mi: float = 5.0
|
||||
min_severity: str = "priority" # routine|priority|immediate
|
||||
min_acres: float = 0.0 # fire-only; ignored by other families
|
||||
|
||||
|
||||
|
|
@ -711,7 +709,6 @@ class DangerZonesConfig:
|
|||
dry_run: bool = True
|
||||
monitor_roles: list[str] = field(
|
||||
default_factory=lambda: ["ROUTER", "ROUTER_LATE", "CLIENT_BASE"])
|
||||
position_max_age_hours: int = 72
|
||||
default_buffer_mi: float = 5.0
|
||||
cooldown_minutes: int = 360
|
||||
|
||||
|
|
@ -754,18 +751,6 @@ class DangerZonesConfig:
|
|||
f"danger_zones parent family {fam!r} is not a valid toggle "
|
||||
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
|
||||
class Config:
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue