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_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}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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]:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue