mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-21 23:24:44 +02:00
refactor: simplify severity to 3 levels (routine/priority/immediate)
- Replace 6-level system (info/advisory/watch/warning/critical/emergency) with 3-level military precedence (routine/priority/immediate) - Every adapter remapped: NWS, NIFC, FIRMS, USGS, SWPC, avalanche, traffic, 511, mesh alerts - is_critical flag removed — severity covers it - Quiet hours: suppress routine only, priority+immediate always deliver - Dashboard: blue/amber/red for routine/priority/immediate - Fix hex node ID parsing in Mesh DM channel (!23261b70 format)
This commit is contained in:
parent
5b78e38d2e
commit
49f2838048
17 changed files with 3285 additions and 3265 deletions
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -82,28 +82,37 @@ function FeedStatusCard({ feed }: { feed: { source: string; is_loaded: boolean;
|
||||||
function AlertEventCard({ event }: { event: EnvEvent }) {
|
function AlertEventCard({ event }: { event: EnvEvent }) {
|
||||||
const getSeverityStyles = (severity: string) => {
|
const getSeverityStyles = (severity: string) => {
|
||||||
switch (severity.toLowerCase()) {
|
switch (severity.toLowerCase()) {
|
||||||
|
// NWS native severity levels
|
||||||
case 'extreme':
|
case 'extreme':
|
||||||
case 'severe':
|
case 'severe':
|
||||||
|
// Our 3-level system
|
||||||
|
case 'immediate':
|
||||||
return {
|
return {
|
||||||
bg: 'bg-red-500/10',
|
bg: 'bg-red-500/10',
|
||||||
border: 'border-red-500',
|
border: 'border-red-500',
|
||||||
icon: AlertCircle,
|
icon: AlertCircle,
|
||||||
iconColor: 'text-red-500',
|
iconColor: 'text-red-500',
|
||||||
}
|
}
|
||||||
|
// NWS native
|
||||||
case 'moderate':
|
case 'moderate':
|
||||||
case 'warning':
|
case 'warning':
|
||||||
|
// Our 3-level system
|
||||||
|
case 'priority':
|
||||||
return {
|
return {
|
||||||
bg: 'bg-amber-500/10',
|
bg: 'bg-amber-500/10',
|
||||||
border: 'border-amber-500',
|
border: 'border-amber-500',
|
||||||
icon: AlertTriangle,
|
icon: AlertTriangle,
|
||||||
iconColor: 'text-amber-500',
|
iconColor: 'text-amber-500',
|
||||||
}
|
}
|
||||||
|
// NWS native
|
||||||
case 'minor':
|
case 'minor':
|
||||||
|
// Our 3-level system
|
||||||
|
case 'routine':
|
||||||
return {
|
return {
|
||||||
bg: 'bg-yellow-500/10',
|
bg: 'bg-blue-500/10',
|
||||||
border: 'border-yellow-500',
|
border: 'border-blue-500',
|
||||||
icon: Info,
|
icon: Info,
|
||||||
iconColor: 'text-yellow-500',
|
iconColor: 'text-blue-500',
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -98,12 +98,9 @@ interface ChannelTestResult {
|
||||||
|
|
||||||
// Severity levels with descriptions
|
// Severity levels with descriptions
|
||||||
const SEVERITY_OPTIONS = [
|
const SEVERITY_OPTIONS = [
|
||||||
{ value: 'info', label: 'Info', description: 'Routine updates (ducting detected, new router appeared)' },
|
{ value: 'routine', label: 'Routine', description: 'Informational, no time pressure (ducting, new node, weather advisory, battery declining)' },
|
||||||
{ value: 'advisory', label: 'Advisory', description: 'Worth knowing (weather advisory, traffic slow, battery declining)' },
|
{ value: 'priority', label: 'Priority', description: 'Needs attention soon (severe weather, fire nearby, node offline, HF blackout)' },
|
||||||
{ value: 'watch', label: 'Watch', description: 'Pay attention (fire within 50km, weather watch, stream rising)' },
|
{ value: 'immediate', label: 'Immediate', description: 'Act now, drop everything (fire at infrastructure, extreme weather, region blackout)' },
|
||||||
{ value: 'warning', label: 'Warning', description: 'Act now (fire within 25km, severe weather, critical battery)' },
|
|
||||||
{ value: 'critical', label: 'Critical', description: 'Serious issue (critical node down, battery emergency)' },
|
|
||||||
{ value: 'emergency', label: 'Emergency', description: 'Life safety (extreme weather, fire at infrastructure, total blackout)' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
// Notification rule templates
|
// Notification rule templates
|
||||||
|
|
@ -117,7 +114,7 @@ const RULE_TEMPLATES = [
|
||||||
enabled: true,
|
enabled: true,
|
||||||
trigger_type: "condition" as const,
|
trigger_type: "condition" as const,
|
||||||
categories: ["infra_offline", "critical_node_down", "infra_recovery", "battery_warning", "battery_critical", "battery_emergency", "high_utilization", "packet_flood", "mesh_score_low"],
|
categories: ["infra_offline", "critical_node_down", "infra_recovery", "battery_warning", "battery_critical", "battery_emergency", "high_utilization", "packet_flood", "mesh_score_low"],
|
||||||
min_severity: "advisory",
|
min_severity: "routine",
|
||||||
delivery_type: "mesh_broadcast",
|
delivery_type: "mesh_broadcast",
|
||||||
broadcast_channel: 0,
|
broadcast_channel: 0,
|
||||||
cooldown_minutes: 30,
|
cooldown_minutes: 30,
|
||||||
|
|
@ -149,7 +146,7 @@ const RULE_TEMPLATES = [
|
||||||
enabled: true,
|
enabled: true,
|
||||||
trigger_type: "condition" as const,
|
trigger_type: "condition" as const,
|
||||||
categories: ["weather_warning", "fire_proximity", "new_ignition", "stream_flood_warning"],
|
categories: ["weather_warning", "fire_proximity", "new_ignition", "stream_flood_warning"],
|
||||||
min_severity: "warning",
|
min_severity: "priority",
|
||||||
delivery_type: "mesh_broadcast",
|
delivery_type: "mesh_broadcast",
|
||||||
broadcast_channel: 0,
|
broadcast_channel: 0,
|
||||||
cooldown_minutes: 15,
|
cooldown_minutes: 15,
|
||||||
|
|
@ -181,7 +178,7 @@ const RULE_TEMPLATES = [
|
||||||
enabled: true,
|
enabled: true,
|
||||||
trigger_type: "condition" as const,
|
trigger_type: "condition" as const,
|
||||||
categories: ["hf_blackout", "tropospheric_ducting", "geomagnetic_storm"],
|
categories: ["hf_blackout", "tropospheric_ducting", "geomagnetic_storm"],
|
||||||
min_severity: "info",
|
min_severity: "routine",
|
||||||
delivery_type: "mesh_broadcast",
|
delivery_type: "mesh_broadcast",
|
||||||
broadcast_channel: 0,
|
broadcast_channel: 0,
|
||||||
cooldown_minutes: 60,
|
cooldown_minutes: 60,
|
||||||
|
|
@ -213,7 +210,7 @@ const RULE_TEMPLATES = [
|
||||||
enabled: true,
|
enabled: true,
|
||||||
trigger_type: "condition" as const,
|
trigger_type: "condition" as const,
|
||||||
categories: ["road_closure", "traffic_congestion"],
|
categories: ["road_closure", "traffic_congestion"],
|
||||||
min_severity: "warning",
|
min_severity: "routine",
|
||||||
delivery_type: "mesh_broadcast",
|
delivery_type: "mesh_broadcast",
|
||||||
broadcast_channel: 0,
|
broadcast_channel: 0,
|
||||||
cooldown_minutes: 30,
|
cooldown_minutes: 30,
|
||||||
|
|
@ -245,7 +242,7 @@ const RULE_TEMPLATES = [
|
||||||
enabled: true,
|
enabled: true,
|
||||||
trigger_type: "condition" as const,
|
trigger_type: "condition" as const,
|
||||||
categories: [] as string[],
|
categories: [] as string[],
|
||||||
min_severity: "emergency",
|
min_severity: "immediate",
|
||||||
delivery_type: "mesh_broadcast",
|
delivery_type: "mesh_broadcast",
|
||||||
broadcast_channel: 0,
|
broadcast_channel: 0,
|
||||||
cooldown_minutes: 5,
|
cooldown_minutes: 5,
|
||||||
|
|
@ -543,13 +540,13 @@ function SeveritySelector({ value, onChange }: {
|
||||||
onChange: (v: string) => void
|
onChange: (v: string) => void
|
||||||
}) {
|
}) {
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const selected = SEVERITY_OPTIONS.find(s => s.value === value) || SEVERITY_OPTIONS[3]
|
const selected = SEVERITY_OPTIONS.find(s => s.value === value) || SEVERITY_OPTIONS[0]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<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">
|
||||||
Severity Threshold
|
Severity Threshold
|
||||||
<InfoButton info="Only alerts at or above this severity trigger this rule. Lower threshold = more notifications. 'Warning' is recommended for most rules." />
|
<InfoButton info="Only alerts at or above this severity trigger this rule. ROUTINE = informational, PRIORITY = needs attention, IMMEDIATE = act now." />
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
|
|
@ -1431,7 +1428,7 @@ export default function Notifications() {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
trigger_type: 'condition',
|
trigger_type: 'condition',
|
||||||
categories: [],
|
categories: [],
|
||||||
min_severity: 'warning',
|
min_severity: 'routine',
|
||||||
schedule_frequency: 'daily',
|
schedule_frequency: 'daily',
|
||||||
schedule_time: '07:00',
|
schedule_time: '07:00',
|
||||||
schedule_time_2: '19:00',
|
schedule_time_2: '19:00',
|
||||||
|
|
@ -1779,7 +1776,7 @@ export default function Notifications() {
|
||||||
checked={config.quiet_hours_enabled ?? true}
|
checked={config.quiet_hours_enabled ?? true}
|
||||||
onChange={(v) => setConfig({ ...config, quiet_hours_enabled: v })}
|
onChange={(v) => setConfig({ ...config, quiet_hours_enabled: v })}
|
||||||
helper="Suppress non-emergency alerts during sleeping hours"
|
helper="Suppress non-emergency alerts during sleeping hours"
|
||||||
info="When enabled, alerts below emergency severity are held during quiet hours. When disabled, all alerts deliver anytime."
|
info="When enabled, ROUTINE alerts are suppressed during quiet hours. PRIORITY and IMMEDIATE always deliver."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{config.quiet_hours_enabled && (
|
{config.quiet_hours_enabled && (
|
||||||
|
|
|
||||||
|
|
@ -142,7 +142,7 @@ class AlertEngine:
|
||||||
alerts.append(self._make_alert(
|
alerts.append(self._make_alert(
|
||||||
alert_type, name, short, node_num, region,
|
alert_type, name, short, node_num, region,
|
||||||
f"{emoji} {name} went offline in {region_display}.{escalation}",
|
f"{emoji} {name} went offline in {region_display}.{escalation}",
|
||||||
is_critical,
|
"immediate" if is_critical else "priority",
|
||||||
))
|
))
|
||||||
state.fire(now)
|
state.fire(now)
|
||||||
|
|
||||||
|
|
@ -153,7 +153,7 @@ class AlertEngine:
|
||||||
alerts.append(self._make_alert(
|
alerts.append(self._make_alert(
|
||||||
"infra_recovery", name, short, node_num, region,
|
"infra_recovery", name, short, node_num, region,
|
||||||
f"\u2705 {name} is back online in {region_display}.",
|
f"\u2705 {name} is back online in {region_display}.",
|
||||||
is_critical,
|
"immediate" if is_critical else "priority",
|
||||||
))
|
))
|
||||||
state.resolve()
|
state.resolve()
|
||||||
|
|
||||||
|
|
@ -196,7 +196,7 @@ class AlertEngine:
|
||||||
alerts.append(self._make_alert(
|
alerts.append(self._make_alert(
|
||||||
"power_source_change", name, short, node_num, region,
|
"power_source_change", name, short, node_num, region,
|
||||||
f"\u26A1 {name} switched from USB to battery in {region_display}. Possible power outage.",
|
f"\u26A1 {name} switched from USB to battery in {region_display}. Possible power outage.",
|
||||||
is_critical,
|
"immediate" if is_critical else "priority",
|
||||||
))
|
))
|
||||||
state.fire(now)
|
state.fire(now)
|
||||||
elif prev_source == "battery" and current_source == "usb":
|
elif prev_source == "battery" and current_source == "usb":
|
||||||
|
|
@ -217,7 +217,7 @@ class AlertEngine:
|
||||||
alerts.append(self._make_alert(
|
alerts.append(self._make_alert(
|
||||||
"battery_emergency", name, short, node_num, region,
|
"battery_emergency", name, short, node_num, region,
|
||||||
f"\U0001F6A8 {name} battery EMERGENCY at {bat:.0f}% in {region_display}.",
|
f"\U0001F6A8 {name} battery EMERGENCY at {bat:.0f}% in {region_display}.",
|
||||||
is_critical,
|
"immediate" if is_critical else "priority",
|
||||||
))
|
))
|
||||||
state.fire(now)
|
state.fire(now)
|
||||||
|
|
||||||
|
|
@ -229,7 +229,7 @@ class AlertEngine:
|
||||||
alerts.append(self._make_alert(
|
alerts.append(self._make_alert(
|
||||||
"battery_critical", name, short, node_num, region,
|
"battery_critical", name, short, node_num, region,
|
||||||
f"\U0001F50B {name} battery critical at {bat:.0f}% in {region_display}.",
|
f"\U0001F50B {name} battery critical at {bat:.0f}% in {region_display}.",
|
||||||
is_critical,
|
"immediate" if is_critical else "priority",
|
||||||
))
|
))
|
||||||
state.fire(now)
|
state.fire(now)
|
||||||
|
|
||||||
|
|
@ -241,7 +241,7 @@ class AlertEngine:
|
||||||
alerts.append(self._make_alert(
|
alerts.append(self._make_alert(
|
||||||
"battery_warning", name, short, node_num, region,
|
"battery_warning", name, short, node_num, region,
|
||||||
f"\U0001F50B {name} battery low at {bat:.0f}% in {region_display}.",
|
f"\U0001F50B {name} battery low at {bat:.0f}% in {region_display}.",
|
||||||
is_critical,
|
"immediate" if is_critical else "priority",
|
||||||
))
|
))
|
||||||
state.fire(now)
|
state.fire(now)
|
||||||
|
|
||||||
|
|
@ -261,7 +261,7 @@ class AlertEngine:
|
||||||
alerts.append(self._make_alert(
|
alerts.append(self._make_alert(
|
||||||
"battery_trend", name, short, node_num, region,
|
"battery_trend", name, short, node_num, region,
|
||||||
f"\U0001F50B {name} battery declining: {trend['start']:.0f}% \u2192 {trend['end']:.0f}% over 7 days ({trend['rate']:.1f}%/day) in {region_display}.",
|
f"\U0001F50B {name} battery declining: {trend['start']:.0f}% \u2192 {trend['end']:.0f}% over 7 days ({trend['rate']:.1f}%/day) in {region_display}.",
|
||||||
is_critical,
|
"immediate" if is_critical else "priority",
|
||||||
))
|
))
|
||||||
state.fire(now)
|
state.fire(now)
|
||||||
|
|
||||||
|
|
@ -283,7 +283,7 @@ class AlertEngine:
|
||||||
alerts.append(self._make_alert(
|
alerts.append(self._make_alert(
|
||||||
"solar_not_charging", name, short, node_num, region,
|
"solar_not_charging", name, short, node_num, region,
|
||||||
f"\u2600\uFE0F {name} solar not charging in {region_display}.",
|
f"\u2600\uFE0F {name} solar not charging in {region_display}.",
|
||||||
is_critical,
|
"immediate" if is_critical else "priority",
|
||||||
))
|
))
|
||||||
state.fire(now)
|
state.fire(now)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -364,7 +364,7 @@ class AlertEngine:
|
||||||
alerts.append(self._make_alert(
|
alerts.append(self._make_alert(
|
||||||
"infra_single_gateway", name, short, node_num, region,
|
"infra_single_gateway", name, short, node_num, region,
|
||||||
f"\u26A0\uFE0F {name} dropped to single gateway in {region_display}. At risk if gateway fails.",
|
f"\u26A0\uFE0F {name} dropped to single gateway in {region_display}. At risk if gateway fails.",
|
||||||
is_critical,
|
"immediate" if is_critical else "priority",
|
||||||
))
|
))
|
||||||
state.fire(now)
|
state.fire(now)
|
||||||
elif prev_gw is not None and prev_gw <= 1.0 and node.avg_gateways > 1.0:
|
elif prev_gw is not None and prev_gw <= 1.0 and node.avg_gateways > 1.0:
|
||||||
|
|
@ -397,7 +397,7 @@ class AlertEngine:
|
||||||
"message": f"\U0001F4E1 Feeder gateway {feeder} stopped responding.",
|
"message": f"\U0001F4E1 Feeder gateway {feeder} stopped responding.",
|
||||||
"scope_type": "mesh",
|
"scope_type": "mesh",
|
||||||
"scope_value": None,
|
"scope_value": None,
|
||||||
"is_critical": False,
|
"severity": "routine",
|
||||||
})
|
})
|
||||||
state.fire(now)
|
state.fire(now)
|
||||||
|
|
||||||
|
|
@ -438,7 +438,7 @@ class AlertEngine:
|
||||||
"message": f"\U0001F6A8 TOTAL BLACKOUT: All infrastructure in {region_display} is offline!",
|
"message": f"\U0001F6A8 TOTAL BLACKOUT: All infrastructure in {region_display} is offline!",
|
||||||
"scope_type": "region",
|
"scope_type": "region",
|
||||||
"scope_value": region.name,
|
"scope_value": region.name,
|
||||||
"is_critical": True,
|
"severity": "immediate",
|
||||||
})
|
})
|
||||||
state.fire(now)
|
state.fire(now)
|
||||||
|
|
||||||
|
|
@ -469,7 +469,7 @@ class AlertEngine:
|
||||||
"message": f"\U0001F4C9 Mesh health dropped to {current:.0f}/100 (threshold: {threshold}).",
|
"message": f"\U0001F4C9 Mesh health dropped to {current:.0f}/100 (threshold: {threshold}).",
|
||||||
"scope_type": "mesh",
|
"scope_type": "mesh",
|
||||||
"scope_value": None,
|
"scope_value": None,
|
||||||
"is_critical": False,
|
"severity": "routine",
|
||||||
})
|
})
|
||||||
state.fire(now)
|
state.fire(now)
|
||||||
elif current >= threshold:
|
elif current >= threshold:
|
||||||
|
|
@ -498,7 +498,7 @@ class AlertEngine:
|
||||||
"message": f"\U0001F4C9 {region_display} health dropped to {current:.0f}/100 (threshold: {threshold}).",
|
"message": f"\U0001F4C9 {region_display} health dropped to {current:.0f}/100 (threshold: {threshold}).",
|
||||||
"scope_type": "region",
|
"scope_type": "region",
|
||||||
"scope_value": region.name,
|
"scope_value": region.name,
|
||||||
"is_critical": False,
|
"severity": "routine",
|
||||||
})
|
})
|
||||||
state.fire(now)
|
state.fire(now)
|
||||||
elif current >= threshold:
|
elif current >= threshold:
|
||||||
|
|
@ -550,7 +550,7 @@ class AlertEngine:
|
||||||
logger.debug(f"Battery trend query error: {e}")
|
logger.debug(f"Battery trend query error: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _make_alert(self, alert_type, name, short, node_num, region, message, is_critical):
|
def _make_alert(self, alert_type, name, short, node_num, region, message, severity="priority"):
|
||||||
return {
|
return {
|
||||||
"type": alert_type,
|
"type": alert_type,
|
||||||
"node_name": name,
|
"node_name": name,
|
||||||
|
|
@ -560,7 +560,7 @@ class AlertEngine:
|
||||||
"message": message,
|
"message": message,
|
||||||
"scope_type": "region" if region and region != "Unknown" else "mesh",
|
"scope_type": "region" if region and region != "Unknown" else "mesh",
|
||||||
"scope_value": region if region and region != "Unknown" else None,
|
"scope_value": region if region and region != "Unknown" else None,
|
||||||
"is_critical": is_critical,
|
"severity": severity,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _get_region_display(self, region: str) -> str:
|
def _get_region_display(self, region: str) -> str:
|
||||||
|
|
@ -616,14 +616,13 @@ class AlertEngine:
|
||||||
alerts.append({
|
alerts.append({
|
||||||
"type": "weather_warning",
|
"type": "weather_warning",
|
||||||
"message": f"Warning: {evt['event_type']}: {evt.get('headline', '')[:150]}",
|
"message": f"Warning: {evt['event_type']}: {evt.get('headline', '')[:150]}",
|
||||||
"severity": evt["severity"],
|
|
||||||
"node_num": None,
|
"node_num": None,
|
||||||
"node_name": evt["event_type"],
|
"node_name": evt["event_type"],
|
||||||
"node_short": "NWS",
|
"node_short": "NWS",
|
||||||
"region": "",
|
"region": "",
|
||||||
"scope_type": "mesh",
|
"scope_type": "mesh",
|
||||||
"scope_value": None,
|
"scope_value": None,
|
||||||
"is_critical": evt["severity"] in ("extreme", "emergency"),
|
"severity": "immediate" if evt["severity"] == "extreme" else "priority",
|
||||||
})
|
})
|
||||||
|
|
||||||
# SWPC R-scale >= 3 (HF blackout affecting mesh backhaul)
|
# SWPC R-scale >= 3 (HF blackout affecting mesh backhaul)
|
||||||
|
|
@ -637,14 +636,14 @@ class AlertEngine:
|
||||||
alerts.append({
|
alerts.append({
|
||||||
"type": "hf_blackout",
|
"type": "hf_blackout",
|
||||||
"message": f"Warning: R{r_scale} HF Radio Blackout -- mesh backhaul links may degrade",
|
"message": f"Warning: R{r_scale} HF Radio Blackout -- mesh backhaul links may degrade",
|
||||||
"severity": "warning",
|
"severity": "priority",
|
||||||
"node_num": None,
|
"node_num": None,
|
||||||
"node_name": f"R{r_scale} Blackout",
|
"node_name": f"R{r_scale} Blackout",
|
||||||
"node_short": "SWPC",
|
"node_short": "SWPC",
|
||||||
"region": "",
|
"region": "",
|
||||||
"scope_type": "mesh",
|
"scope_type": "mesh",
|
||||||
"scope_value": None,
|
"scope_value": None,
|
||||||
"is_critical": r_scale >= 4,
|
"severity": "immediate" if r_scale >= 4 else "priority",
|
||||||
})
|
})
|
||||||
|
|
||||||
# Tropospheric ducting (informational -- not critical but operators want to know)
|
# Tropospheric ducting (informational -- not critical but operators want to know)
|
||||||
|
|
@ -659,14 +658,14 @@ class AlertEngine:
|
||||||
alerts.append({
|
alerts.append({
|
||||||
"type": "tropospheric_ducting",
|
"type": "tropospheric_ducting",
|
||||||
"message": f"Tropospheric {condition} detected (dM/dz {gradient} M-units/km)",
|
"message": f"Tropospheric {condition} detected (dM/dz {gradient} M-units/km)",
|
||||||
"severity": "info",
|
"severity": "routine",
|
||||||
"node_num": None,
|
"node_num": None,
|
||||||
"node_name": "Ducting",
|
"node_name": "Ducting",
|
||||||
"node_short": "TROPO",
|
"node_short": "TROPO",
|
||||||
"region": "",
|
"region": "",
|
||||||
"scope_type": "mesh",
|
"scope_type": "mesh",
|
||||||
"scope_value": None,
|
"scope_value": None,
|
||||||
"is_critical": False,
|
"severity": "routine",
|
||||||
})
|
})
|
||||||
|
|
||||||
# Wildfire proximity alerts
|
# Wildfire proximity alerts
|
||||||
|
|
@ -690,14 +689,14 @@ class AlertEngine:
|
||||||
alerts.append({
|
alerts.append({
|
||||||
"type": "wildfire_proximity",
|
"type": "wildfire_proximity",
|
||||||
"message": f"Wildfire '{name}' within {int(distance_km)} km of {anchor} -- {int(acres):,} ac, {int(pct)}% contained",
|
"message": f"Wildfire '{name}' within {int(distance_km)} km of {anchor} -- {int(acres):,} ac, {int(pct)}% contained",
|
||||||
"severity": "critical",
|
"severity": "immediate",
|
||||||
"node_num": None,
|
"node_num": None,
|
||||||
"node_name": name,
|
"node_name": name,
|
||||||
"node_short": "FIRE",
|
"node_short": "FIRE",
|
||||||
"region": anchor,
|
"region": anchor,
|
||||||
"scope_type": "mesh",
|
"scope_type": "mesh",
|
||||||
"scope_value": None,
|
"scope_value": None,
|
||||||
"is_critical": True,
|
"severity": "immediate",
|
||||||
})
|
})
|
||||||
|
|
||||||
elif distance_km < 50:
|
elif distance_km < 50:
|
||||||
|
|
@ -709,14 +708,14 @@ class AlertEngine:
|
||||||
alerts.append({
|
alerts.append({
|
||||||
"type": "wildfire_proximity",
|
"type": "wildfire_proximity",
|
||||||
"message": f"Wildfire '{name}' {int(distance_km)} km from {anchor} -- {int(acres):,} ac, {int(pct)}% contained",
|
"message": f"Wildfire '{name}' {int(distance_km)} km from {anchor} -- {int(acres):,} ac, {int(pct)}% contained",
|
||||||
"severity": "warning",
|
"severity": "priority",
|
||||||
"node_num": None,
|
"node_num": None,
|
||||||
"node_name": name,
|
"node_name": name,
|
||||||
"node_short": "FIRE",
|
"node_short": "FIRE",
|
||||||
"region": anchor,
|
"region": anchor,
|
||||||
"scope_type": "mesh",
|
"scope_type": "mesh",
|
||||||
"scope_value": None,
|
"scope_value": None,
|
||||||
"is_critical": False,
|
"severity": "routine",
|
||||||
})
|
})
|
||||||
|
|
||||||
return alerts
|
return alerts
|
||||||
|
|
|
||||||
|
|
@ -440,7 +440,7 @@ class NotificationRuleConfig:
|
||||||
|
|
||||||
# Condition trigger fields
|
# Condition trigger fields
|
||||||
categories: list = field(default_factory=list) # Empty = all categories
|
categories: list = field(default_factory=list) # Empty = all categories
|
||||||
min_severity: str = "warning"
|
min_severity: str = "routine"
|
||||||
|
|
||||||
# Schedule trigger fields
|
# Schedule trigger fields
|
||||||
schedule_frequency: str = "daily" # daily, twice_daily, weekly, custom
|
schedule_frequency: str = "daily" # daily, twice_daily, weekly, custom
|
||||||
|
|
|
||||||
8
meshai/env/avalanche.py
vendored
8
meshai/env/avalanche.py
vendored
|
|
@ -129,13 +129,13 @@ class AvalancheAdapter:
|
||||||
level_key, level_name = self.DANGER_LEVELS.get(danger_level, ("no_rating", "No Rating"))
|
level_key, level_name = self.DANGER_LEVELS.get(danger_level, ("no_rating", "No Rating"))
|
||||||
|
|
||||||
if danger_level >= 4:
|
if danger_level >= 4:
|
||||||
severity = "warning"
|
severity = "priority"
|
||||||
elif danger_level >= 3:
|
elif danger_level >= 3:
|
||||||
severity = "watch"
|
severity = "routine"
|
||||||
elif danger_level >= 2:
|
elif danger_level >= 2:
|
||||||
severity = "advisory"
|
severity = "routine"
|
||||||
else:
|
else:
|
||||||
severity = "info"
|
severity = "routine"
|
||||||
|
|
||||||
# Compute centroid
|
# Compute centroid
|
||||||
geom = feature.get("geometry")
|
geom = feature.get("geometry")
|
||||||
|
|
|
||||||
8
meshai/env/fires.py
vendored
8
meshai/env/fires.py
vendored
|
|
@ -109,13 +109,13 @@ class NICFFiresAdapter:
|
||||||
# Severity based on distance
|
# Severity based on distance
|
||||||
if distance_km is not None:
|
if distance_km is not None:
|
||||||
if distance_km < 25:
|
if distance_km < 25:
|
||||||
severity = "warning"
|
severity = "priority"
|
||||||
elif distance_km < 50:
|
elif distance_km < 50:
|
||||||
severity = "watch"
|
severity = "routine"
|
||||||
else:
|
else:
|
||||||
severity = "advisory"
|
severity = "routine"
|
||||||
else:
|
else:
|
||||||
severity = "advisory"
|
severity = "routine"
|
||||||
|
|
||||||
# Format headline
|
# Format headline
|
||||||
headline = f"{name} -- {int(acres):,} ac, {int(pct_contained)}% contained"
|
headline = f"{name} -- {int(acres):,} ac, {int(pct_contained)}% contained"
|
||||||
|
|
|
||||||
730
meshai/env/firms.py
vendored
730
meshai/env/firms.py
vendored
|
|
@ -1,365 +1,365 @@
|
||||||
"""NASA FIRMS satellite fire hotspot adapter."""
|
"""NASA FIRMS satellite fire hotspot adapter."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from urllib.error import HTTPError, URLError
|
from urllib.error import HTTPError, URLError
|
||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..config import FIRMSConfig
|
from ..config import FIRMSConfig
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class FIRMSAdapter:
|
class FIRMSAdapter:
|
||||||
"""NASA FIRMS satellite fire hotspot polling.
|
"""NASA FIRMS satellite fire hotspot polling.
|
||||||
|
|
||||||
Detects fire hotspots from satellite data (MODIS, VIIRS) typically
|
Detects fire hotspots from satellite data (MODIS, VIIRS) typically
|
||||||
hours before NIFC publishes official perimeters. Early warning.
|
hours before NIFC publishes official perimeters. Early warning.
|
||||||
|
|
||||||
API: https://firms.modaps.eosdis.nasa.gov/api/area/csv/{MAP_KEY}/{SOURCE}/{BBOX}/{DAY_RANGE}
|
API: https://firms.modaps.eosdis.nasa.gov/api/area/csv/{MAP_KEY}/{SOURCE}/{BBOX}/{DAY_RANGE}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
BASE_URL = "https://firms.modaps.eosdis.nasa.gov/api/area/csv"
|
BASE_URL = "https://firms.modaps.eosdis.nasa.gov/api/area/csv"
|
||||||
|
|
||||||
def __init__(self, config: "FIRMSConfig", region_anchors: list = None, fires_adapter=None):
|
def __init__(self, config: "FIRMSConfig", region_anchors: list = None, fires_adapter=None):
|
||||||
self._map_key = config.map_key
|
self._map_key = config.map_key
|
||||||
self._source = config.source or "VIIRS_SNPP_NRT"
|
self._source = config.source or "VIIRS_SNPP_NRT"
|
||||||
self._bbox = config.bbox # [west, south, east, north]
|
self._bbox = config.bbox # [west, south, east, north]
|
||||||
self._day_range = config.day_range or 1
|
self._day_range = config.day_range or 1
|
||||||
self._tick_interval = config.tick_seconds or 1800
|
self._tick_interval = config.tick_seconds or 1800
|
||||||
self._confidence_min = config.confidence_min or "nominal"
|
self._confidence_min = config.confidence_min or "nominal"
|
||||||
self._proximity_km = config.proximity_km or 10.0 # km to match known fire
|
self._proximity_km = config.proximity_km or 10.0 # km to match known fire
|
||||||
|
|
||||||
self._last_tick = 0.0
|
self._last_tick = 0.0
|
||||||
self._events = []
|
self._events = []
|
||||||
self._consecutive_errors = 0
|
self._consecutive_errors = 0
|
||||||
self._last_error = None
|
self._last_error = None
|
||||||
self._is_loaded = False
|
self._is_loaded = False
|
||||||
|
|
||||||
# For cross-referencing
|
# For cross-referencing
|
||||||
self._region_anchors = region_anchors or []
|
self._region_anchors = region_anchors or []
|
||||||
self._fires_adapter = fires_adapter # NICFFiresAdapter for cross-ref
|
self._fires_adapter = fires_adapter # NICFFiresAdapter for cross-ref
|
||||||
|
|
||||||
def tick(self) -> bool:
|
def tick(self) -> bool:
|
||||||
"""Execute one polling tick.
|
"""Execute one polling tick.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if data changed
|
True if data changed
|
||||||
"""
|
"""
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|
||||||
if now - self._last_tick < self._tick_interval:
|
if now - self._last_tick < self._tick_interval:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
self._last_tick = now
|
self._last_tick = now
|
||||||
|
|
||||||
if not self._map_key:
|
if not self._map_key:
|
||||||
if not self._last_error:
|
if not self._last_error:
|
||||||
logger.warning("FIRMS: No MAP_KEY configured, skipping")
|
logger.warning("FIRMS: No MAP_KEY configured, skipping")
|
||||||
self._last_error = "No MAP_KEY configured"
|
self._last_error = "No MAP_KEY configured"
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if not self._bbox or len(self._bbox) != 4:
|
if not self._bbox or len(self._bbox) != 4:
|
||||||
if not self._last_error:
|
if not self._last_error:
|
||||||
logger.warning("FIRMS: No valid bbox configured, skipping")
|
logger.warning("FIRMS: No valid bbox configured, skipping")
|
||||||
self._last_error = "No valid bbox configured"
|
self._last_error = "No valid bbox configured"
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return self._fetch()
|
return self._fetch()
|
||||||
|
|
||||||
def _fetch(self) -> bool:
|
def _fetch(self) -> bool:
|
||||||
"""Fetch fire hotspots from NASA FIRMS.
|
"""Fetch fire hotspots from NASA FIRMS.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if data changed
|
True if data changed
|
||||||
"""
|
"""
|
||||||
# Format bbox as west,south,east,north
|
# Format bbox as west,south,east,north
|
||||||
bbox_str = ",".join(str(c) for c in self._bbox)
|
bbox_str = ",".join(str(c) for c in self._bbox)
|
||||||
|
|
||||||
url = f"{self.BASE_URL}/{self._map_key}/{self._source}/{bbox_str}/{self._day_range}"
|
url = f"{self.BASE_URL}/{self._map_key}/{self._source}/{bbox_str}/{self._day_range}"
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
"User-Agent": "MeshAI/1.0",
|
"User-Agent": "MeshAI/1.0",
|
||||||
"Accept": "text/csv",
|
"Accept": "text/csv",
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
req = Request(url, headers=headers)
|
req = Request(url, headers=headers)
|
||||||
with urlopen(req, timeout=30) as resp:
|
with urlopen(req, timeout=30) as resp:
|
||||||
csv_data = resp.read().decode("utf-8")
|
csv_data = resp.read().decode("utf-8")
|
||||||
|
|
||||||
except HTTPError as e:
|
except HTTPError as e:
|
||||||
if e.code == 401:
|
if e.code == 401:
|
||||||
logger.error("FIRMS: Invalid MAP_KEY, disabling adapter")
|
logger.error("FIRMS: Invalid MAP_KEY, disabling adapter")
|
||||||
self._last_error = "Invalid MAP_KEY"
|
self._last_error = "Invalid MAP_KEY"
|
||||||
self._consecutive_errors = 999 # Disable
|
self._consecutive_errors = 999 # Disable
|
||||||
return False
|
return False
|
||||||
logger.warning(f"FIRMS HTTP error: {e.code}")
|
logger.warning(f"FIRMS HTTP error: {e.code}")
|
||||||
self._last_error = f"HTTP {e.code}"
|
self._last_error = f"HTTP {e.code}"
|
||||||
self._consecutive_errors += 1
|
self._consecutive_errors += 1
|
||||||
return False
|
return False
|
||||||
|
|
||||||
except URLError as e:
|
except URLError as e:
|
||||||
logger.warning(f"FIRMS connection error: {e.reason}")
|
logger.warning(f"FIRMS connection error: {e.reason}")
|
||||||
self._last_error = str(e.reason)
|
self._last_error = str(e.reason)
|
||||||
self._consecutive_errors += 1
|
self._consecutive_errors += 1
|
||||||
return False
|
return False
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"FIRMS fetch error: {e}")
|
logger.warning(f"FIRMS fetch error: {e}")
|
||||||
self._last_error = str(e)
|
self._last_error = str(e)
|
||||||
self._consecutive_errors += 1
|
self._consecutive_errors += 1
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Parse CSV response
|
# Parse CSV response
|
||||||
new_events = self._parse_csv(csv_data)
|
new_events = self._parse_csv(csv_data)
|
||||||
|
|
||||||
# Check if data changed
|
# Check if data changed
|
||||||
old_ids = {e["event_id"] for e in self._events}
|
old_ids = {e["event_id"] for e in self._events}
|
||||||
new_ids = {e["event_id"] for e in new_events}
|
new_ids = {e["event_id"] for e in new_events}
|
||||||
changed = old_ids != new_ids
|
changed = old_ids != new_ids
|
||||||
|
|
||||||
self._events = new_events
|
self._events = new_events
|
||||||
self._consecutive_errors = 0
|
self._consecutive_errors = 0
|
||||||
self._last_error = None
|
self._last_error = None
|
||||||
self._is_loaded = True
|
self._is_loaded = True
|
||||||
|
|
||||||
if changed:
|
if changed:
|
||||||
new_ignitions = sum(1 for e in new_events if e.get("properties", {}).get("new_ignition"))
|
new_ignitions = sum(1 for e in new_events if e.get("properties", {}).get("new_ignition"))
|
||||||
logger.info(f"FIRMS hotspots updated: {len(new_events)} total, {new_ignitions} potential new ignitions")
|
logger.info(f"FIRMS hotspots updated: {len(new_events)} total, {new_ignitions} potential new ignitions")
|
||||||
|
|
||||||
return changed
|
return changed
|
||||||
|
|
||||||
def _parse_csv(self, csv_data: str) -> list:
|
def _parse_csv(self, csv_data: str) -> list:
|
||||||
"""Parse FIRMS CSV response into events."""
|
"""Parse FIRMS CSV response into events."""
|
||||||
lines = csv_data.strip().split("\n")
|
lines = csv_data.strip().split("\n")
|
||||||
if len(lines) < 2:
|
if len(lines) < 2:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Parse header
|
# Parse header
|
||||||
header = lines[0].split(",")
|
header = lines[0].split(",")
|
||||||
header_map = {col.strip().lower(): i for i, col in enumerate(header)}
|
header_map = {col.strip().lower(): i for i, col in enumerate(header)}
|
||||||
|
|
||||||
# Required columns
|
# Required columns
|
||||||
lat_idx = header_map.get("latitude")
|
lat_idx = header_map.get("latitude")
|
||||||
lon_idx = header_map.get("longitude")
|
lon_idx = header_map.get("longitude")
|
||||||
conf_idx = header_map.get("confidence")
|
conf_idx = header_map.get("confidence")
|
||||||
frp_idx = header_map.get("frp") # Fire Radiative Power
|
frp_idx = header_map.get("frp") # Fire Radiative Power
|
||||||
acq_date_idx = header_map.get("acq_date")
|
acq_date_idx = header_map.get("acq_date")
|
||||||
acq_time_idx = header_map.get("acq_time")
|
acq_time_idx = header_map.get("acq_time")
|
||||||
bright_idx = header_map.get("bright_ti4") or header_map.get("brightness")
|
bright_idx = header_map.get("bright_ti4") or header_map.get("brightness")
|
||||||
|
|
||||||
if lat_idx is None or lon_idx is None:
|
if lat_idx is None or lon_idx is None:
|
||||||
logger.warning("FIRMS CSV missing required columns")
|
logger.warning("FIRMS CSV missing required columns")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
events = []
|
events = []
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|
||||||
# Confidence mapping
|
# Confidence mapping
|
||||||
conf_values = {"low": 1, "l": 1, "nominal": 2, "n": 2, "high": 3, "h": 3}
|
conf_values = {"low": 1, "l": 1, "nominal": 2, "n": 2, "high": 3, "h": 3}
|
||||||
min_conf = conf_values.get(self._confidence_min.lower(), 2)
|
min_conf = conf_values.get(self._confidence_min.lower(), 2)
|
||||||
|
|
||||||
# Get known fire locations for cross-referencing
|
# Get known fire locations for cross-referencing
|
||||||
known_fires = self._get_known_fires()
|
known_fires = self._get_known_fires()
|
||||||
|
|
||||||
for line in lines[1:]:
|
for line in lines[1:]:
|
||||||
cols = line.split(",")
|
cols = line.split(",")
|
||||||
if len(cols) < max(filter(None, [lat_idx, lon_idx, conf_idx])) + 1:
|
if len(cols) < max(filter(None, [lat_idx, lon_idx, conf_idx])) + 1:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
lat = float(cols[lat_idx])
|
lat = float(cols[lat_idx])
|
||||||
lon = float(cols[lon_idx])
|
lon = float(cols[lon_idx])
|
||||||
except (ValueError, IndexError):
|
except (ValueError, IndexError):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Parse confidence
|
# Parse confidence
|
||||||
conf_raw = cols[conf_idx].strip() if conf_idx is not None and conf_idx < len(cols) else "n"
|
conf_raw = cols[conf_idx].strip() if conf_idx is not None and conf_idx < len(cols) else "n"
|
||||||
conf_value = conf_values.get(conf_raw.lower(), 2)
|
conf_value = conf_values.get(conf_raw.lower(), 2)
|
||||||
|
|
||||||
# Filter by confidence
|
# Filter by confidence
|
||||||
if conf_value < min_conf:
|
if conf_value < min_conf:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Parse FRP (fire radiative power in MW)
|
# Parse FRP (fire radiative power in MW)
|
||||||
frp = None
|
frp = None
|
||||||
if frp_idx is not None and frp_idx < len(cols):
|
if frp_idx is not None and frp_idx < len(cols):
|
||||||
try:
|
try:
|
||||||
frp = float(cols[frp_idx])
|
frp = float(cols[frp_idx])
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Parse brightness temperature
|
# Parse brightness temperature
|
||||||
brightness = None
|
brightness = None
|
||||||
if bright_idx is not None and bright_idx < len(cols):
|
if bright_idx is not None and bright_idx < len(cols):
|
||||||
try:
|
try:
|
||||||
brightness = float(cols[bright_idx])
|
brightness = float(cols[bright_idx])
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Parse acquisition datetime
|
# Parse acquisition datetime
|
||||||
acq_date = cols[acq_date_idx].strip() if acq_date_idx is not None and acq_date_idx < len(cols) else ""
|
acq_date = cols[acq_date_idx].strip() if acq_date_idx is not None and acq_date_idx < len(cols) else ""
|
||||||
acq_time = cols[acq_time_idx].strip() if acq_time_idx is not None and acq_time_idx < len(cols) else ""
|
acq_time = cols[acq_time_idx].strip() if acq_time_idx is not None and acq_time_idx < len(cols) else ""
|
||||||
|
|
||||||
# Create unique ID from position and time
|
# Create unique ID from position and time
|
||||||
event_id = f"firms_{lat:.4f}_{lon:.4f}_{acq_date}_{acq_time}"
|
event_id = f"firms_{lat:.4f}_{lon:.4f}_{acq_date}_{acq_time}"
|
||||||
|
|
||||||
# Check if near known fire
|
# Check if near known fire
|
||||||
near_fire, fire_name, distance_to_fire = self._check_near_known_fire(lat, lon, known_fires)
|
near_fire, fire_name, distance_to_fire = self._check_near_known_fire(lat, lon, known_fires)
|
||||||
|
|
||||||
# Determine severity
|
# Determine severity
|
||||||
if not near_fire:
|
if not near_fire:
|
||||||
# Potential new ignition
|
# Potential new ignition
|
||||||
severity = "watch"
|
severity = "routine"
|
||||||
new_ignition = True
|
new_ignition = True
|
||||||
headline = f"NEW HOTSPOT detected"
|
headline = f"NEW HOTSPOT detected"
|
||||||
else:
|
else:
|
||||||
# Near known fire
|
# Near known fire
|
||||||
severity = "advisory"
|
severity = "routine"
|
||||||
new_ignition = False
|
new_ignition = False
|
||||||
headline = f"Hotspot near {fire_name}"
|
headline = f"Hotspot near {fire_name}"
|
||||||
|
|
||||||
# Bump severity for high FRP
|
# Bump severity for high FRP
|
||||||
if frp is not None and frp > 100:
|
if frp is not None and frp > 100:
|
||||||
if severity == "advisory":
|
if severity == "routine":
|
||||||
severity = "watch"
|
severity = "routine"
|
||||||
elif severity == "watch":
|
elif severity == "routine":
|
||||||
severity = "warning"
|
severity = "priority"
|
||||||
headline += f" ({int(frp)} MW)"
|
headline += f" ({int(frp)} MW)"
|
||||||
|
|
||||||
# Compute proximity to region anchors
|
# Compute proximity to region anchors
|
||||||
distance_km, nearest_anchor = self._nearest_anchor_distance(lat, lon)
|
distance_km, nearest_anchor = self._nearest_anchor_distance(lat, lon)
|
||||||
|
|
||||||
if distance_km is not None and nearest_anchor:
|
if distance_km is not None and nearest_anchor:
|
||||||
headline += f" ({int(distance_km)} km from {nearest_anchor})"
|
headline += f" ({int(distance_km)} km from {nearest_anchor})"
|
||||||
|
|
||||||
event = {
|
event = {
|
||||||
"source": "firms",
|
"source": "firms",
|
||||||
"event_id": event_id,
|
"event_id": event_id,
|
||||||
"event_type": "Fire Hotspot",
|
"event_type": "Fire Hotspot",
|
||||||
"severity": severity,
|
"severity": severity,
|
||||||
"headline": headline,
|
"headline": headline,
|
||||||
"lat": lat,
|
"lat": lat,
|
||||||
"lon": lon,
|
"lon": lon,
|
||||||
"expires": now + 21600, # 6 hour TTL
|
"expires": now + 21600, # 6 hour TTL
|
||||||
"fetched_at": now,
|
"fetched_at": now,
|
||||||
"properties": {
|
"properties": {
|
||||||
"new_ignition": new_ignition,
|
"new_ignition": new_ignition,
|
||||||
"confidence": conf_raw,
|
"confidence": conf_raw,
|
||||||
"frp": frp,
|
"frp": frp,
|
||||||
"brightness": brightness,
|
"brightness": brightness,
|
||||||
"acq_date": acq_date,
|
"acq_date": acq_date,
|
||||||
"acq_time": acq_time,
|
"acq_time": acq_time,
|
||||||
"near_fire": fire_name if near_fire else None,
|
"near_fire": fire_name if near_fire else None,
|
||||||
"distance_to_fire_km": distance_to_fire,
|
"distance_to_fire_km": distance_to_fire,
|
||||||
"distance_km": distance_km,
|
"distance_km": distance_km,
|
||||||
"nearest_anchor": nearest_anchor,
|
"nearest_anchor": nearest_anchor,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
events.append(event)
|
events.append(event)
|
||||||
|
|
||||||
return events
|
return events
|
||||||
|
|
||||||
def _get_known_fires(self) -> list:
|
def _get_known_fires(self) -> list:
|
||||||
"""Get known fire locations from NIFC adapter."""
|
"""Get known fire locations from NIFC adapter."""
|
||||||
if not self._fires_adapter:
|
if not self._fires_adapter:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
fires = self._fires_adapter.get_events()
|
fires = self._fires_adapter.get_events()
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"name": f.get("name", "Unknown"),
|
"name": f.get("name", "Unknown"),
|
||||||
"lat": f.get("lat"),
|
"lat": f.get("lat"),
|
||||||
"lon": f.get("lon"),
|
"lon": f.get("lon"),
|
||||||
}
|
}
|
||||||
for f in fires
|
for f in fires
|
||||||
if f.get("lat") is not None and f.get("lon") is not None
|
if f.get("lat") is not None and f.get("lon") is not None
|
||||||
]
|
]
|
||||||
|
|
||||||
def _check_near_known_fire(self, lat: float, lon: float, known_fires: list) -> tuple:
|
def _check_near_known_fire(self, lat: float, lon: float, known_fires: list) -> tuple:
|
||||||
"""Check if hotspot is near a known fire.
|
"""Check if hotspot is near a known fire.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(is_near, fire_name, distance_km)
|
(is_near, fire_name, distance_km)
|
||||||
"""
|
"""
|
||||||
if not known_fires:
|
if not known_fires:
|
||||||
return (False, None, None)
|
return (False, None, None)
|
||||||
|
|
||||||
from ..geo import haversine_distance
|
from ..geo import haversine_distance
|
||||||
|
|
||||||
for fire in known_fires:
|
for fire in known_fires:
|
||||||
fire_lat = fire.get("lat")
|
fire_lat = fire.get("lat")
|
||||||
fire_lon = fire.get("lon")
|
fire_lon = fire.get("lon")
|
||||||
if fire_lat is None or fire_lon is None:
|
if fire_lat is None or fire_lon is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# haversine_distance returns miles, convert to km
|
# haversine_distance returns miles, convert to km
|
||||||
dist_miles = haversine_distance(lat, lon, fire_lat, fire_lon)
|
dist_miles = haversine_distance(lat, lon, fire_lat, fire_lon)
|
||||||
dist_km = dist_miles * 1.60934
|
dist_km = dist_miles * 1.60934
|
||||||
|
|
||||||
if dist_km <= self._proximity_km:
|
if dist_km <= self._proximity_km:
|
||||||
return (True, fire.get("name"), dist_km)
|
return (True, fire.get("name"), dist_km)
|
||||||
|
|
||||||
return (False, None, None)
|
return (False, None, None)
|
||||||
|
|
||||||
def _nearest_anchor_distance(self, lat: float, lon: float) -> tuple:
|
def _nearest_anchor_distance(self, lat: float, lon: float) -> tuple:
|
||||||
"""Find distance to nearest region anchor.
|
"""Find distance to nearest region anchor.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(distance_km, anchor_name) or (None, None)
|
(distance_km, anchor_name) or (None, None)
|
||||||
"""
|
"""
|
||||||
if not self._region_anchors:
|
if not self._region_anchors:
|
||||||
return (None, None)
|
return (None, None)
|
||||||
|
|
||||||
from ..geo import haversine_distance
|
from ..geo import haversine_distance
|
||||||
|
|
||||||
min_dist = float("inf")
|
min_dist = float("inf")
|
||||||
nearest_name = None
|
nearest_name = None
|
||||||
|
|
||||||
for anchor in self._region_anchors:
|
for anchor in self._region_anchors:
|
||||||
anchor_lat = anchor.get("lat") if isinstance(anchor, dict) else getattr(anchor, "lat", None)
|
anchor_lat = anchor.get("lat") if isinstance(anchor, dict) else getattr(anchor, "lat", None)
|
||||||
anchor_lon = anchor.get("lon") if isinstance(anchor, dict) else getattr(anchor, "lon", None)
|
anchor_lon = anchor.get("lon") if isinstance(anchor, dict) else getattr(anchor, "lon", None)
|
||||||
anchor_name = anchor.get("name") if isinstance(anchor, dict) else getattr(anchor, "name", "Unknown")
|
anchor_name = anchor.get("name") if isinstance(anchor, dict) else getattr(anchor, "name", "Unknown")
|
||||||
|
|
||||||
if anchor_lat is None or anchor_lon is None:
|
if anchor_lat is None or anchor_lon is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# haversine_distance returns miles, convert to km
|
# haversine_distance returns miles, convert to km
|
||||||
dist_miles = haversine_distance(lat, lon, anchor_lat, anchor_lon)
|
dist_miles = haversine_distance(lat, lon, anchor_lat, anchor_lon)
|
||||||
dist_km = dist_miles * 1.60934
|
dist_km = dist_miles * 1.60934
|
||||||
|
|
||||||
if dist_km < min_dist:
|
if dist_km < min_dist:
|
||||||
min_dist = dist_km
|
min_dist = dist_km
|
||||||
nearest_name = anchor_name
|
nearest_name = anchor_name
|
||||||
|
|
||||||
if min_dist < float("inf"):
|
if min_dist < float("inf"):
|
||||||
return (min_dist, nearest_name)
|
return (min_dist, nearest_name)
|
||||||
|
|
||||||
return (None, None)
|
return (None, None)
|
||||||
|
|
||||||
def get_events(self) -> list:
|
def get_events(self) -> list:
|
||||||
"""Get current hotspot events."""
|
"""Get current hotspot events."""
|
||||||
return self._events
|
return self._events
|
||||||
|
|
||||||
def get_new_ignitions(self) -> list:
|
def get_new_ignitions(self) -> list:
|
||||||
"""Get only potential new ignitions (not near known fires)."""
|
"""Get only potential new ignitions (not near known fires)."""
|
||||||
return [e for e in self._events if e.get("properties", {}).get("new_ignition")]
|
return [e for e in self._events if e.get("properties", {}).get("new_ignition")]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def health_status(self) -> dict:
|
def health_status(self) -> dict:
|
||||||
"""Get adapter health status."""
|
"""Get adapter health status."""
|
||||||
new_ignitions = len(self.get_new_ignitions())
|
new_ignitions = len(self.get_new_ignitions())
|
||||||
return {
|
return {
|
||||||
"source": "firms",
|
"source": "firms",
|
||||||
"is_loaded": self._is_loaded,
|
"is_loaded": self._is_loaded,
|
||||||
"last_error": str(self._last_error) if self._last_error else None,
|
"last_error": str(self._last_error) if self._last_error else None,
|
||||||
"consecutive_errors": self._consecutive_errors,
|
"consecutive_errors": self._consecutive_errors,
|
||||||
"event_count": len(self._events),
|
"event_count": len(self._events),
|
||||||
"new_ignitions": new_ignitions,
|
"new_ignitions": new_ignitions,
|
||||||
"last_fetch": self._last_tick,
|
"last_fetch": self._last_tick,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
396
meshai/env/nws.py
vendored
396
meshai/env/nws.py
vendored
|
|
@ -1,193 +1,203 @@
|
||||||
"""NWS Active Alerts adapter."""
|
"""NWS Active Alerts adapter."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from urllib.error import HTTPError, URLError
|
from urllib.error import HTTPError, URLError
|
||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..config import NWSConfig
|
from ..config import NWSConfig
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class NWSAlertsAdapter:
|
class NWSAlertsAdapter:
|
||||||
"""NWS Active Alerts -- polls api.weather.gov"""
|
"""NWS Active Alerts -- polls api.weather.gov"""
|
||||||
|
|
||||||
def __init__(self, config: "NWSConfig"):
|
def __init__(self, config: "NWSConfig"):
|
||||||
self._areas = config.areas or ["ID"]
|
self._areas = config.areas or ["ID"]
|
||||||
self._user_agent = config.user_agent or "(meshai, ops@example.com)"
|
self._user_agent = config.user_agent or "(meshai, ops@example.com)"
|
||||||
self._severity_min = config.severity_min or "moderate"
|
self._severity_min = config.severity_min or "moderate"
|
||||||
self._tick_interval = config.tick_seconds or 60
|
self._tick_interval = config.tick_seconds or 60
|
||||||
self._last_tick = 0.0
|
self._last_tick = 0.0
|
||||||
self._events = []
|
self._events = []
|
||||||
self._consecutive_errors = 0
|
self._consecutive_errors = 0
|
||||||
self._last_error = None
|
self._last_error = None
|
||||||
self._backoff_until = 0.0
|
self._backoff_until = 0.0
|
||||||
self._is_loaded = False
|
self._is_loaded = False
|
||||||
|
|
||||||
def tick(self) -> bool:
|
|
||||||
"""Execute one polling tick.
|
def _map_nws_severity(self, nws_severity: str) -> str:
|
||||||
|
"""Map NWS severity to 3-level system."""
|
||||||
Returns:
|
if nws_severity == "extreme":
|
||||||
True if data changed
|
return "immediate"
|
||||||
"""
|
elif nws_severity in ("severe", "warning"):
|
||||||
now = time.time()
|
return "priority"
|
||||||
|
else: # moderate, minor, unknown
|
||||||
# Rate limit backoff
|
return "routine"
|
||||||
if now < self._backoff_until:
|
|
||||||
return False
|
def tick(self) -> bool:
|
||||||
|
"""Execute one polling tick.
|
||||||
# Check tick interval
|
|
||||||
if now - self._last_tick < self._tick_interval:
|
Returns:
|
||||||
return False
|
True if data changed
|
||||||
|
"""
|
||||||
self._last_tick = now
|
now = time.time()
|
||||||
return self._fetch()
|
|
||||||
|
# Rate limit backoff
|
||||||
def _fetch(self) -> bool:
|
if now < self._backoff_until:
|
||||||
"""Fetch alerts from NWS API.
|
return False
|
||||||
|
|
||||||
Returns:
|
# Check tick interval
|
||||||
True if data changed
|
if now - self._last_tick < self._tick_interval:
|
||||||
"""
|
return False
|
||||||
areas = ",".join(self._areas)
|
|
||||||
url = f"https://api.weather.gov/alerts/active?area={areas}"
|
self._last_tick = now
|
||||||
|
return self._fetch()
|
||||||
headers = {
|
|
||||||
"User-Agent": self._user_agent,
|
def _fetch(self) -> bool:
|
||||||
"Accept": "application/geo+json",
|
"""Fetch alerts from NWS API.
|
||||||
}
|
|
||||||
|
Returns:
|
||||||
try:
|
True if data changed
|
||||||
req = Request(url, headers=headers)
|
"""
|
||||||
with urlopen(req, timeout=15) as resp:
|
areas = ",".join(self._areas)
|
||||||
data = json.loads(resp.read().decode("utf-8"))
|
url = f"https://api.weather.gov/alerts/active?area={areas}"
|
||||||
|
|
||||||
except HTTPError as e:
|
headers = {
|
||||||
if e.code == 429:
|
"User-Agent": self._user_agent,
|
||||||
self._backoff_until = time.time() + 5
|
"Accept": "application/geo+json",
|
||||||
logger.warning("NWS rate limited, backing off 5s")
|
}
|
||||||
else:
|
|
||||||
logger.warning(f"NWS HTTP error: {e.code}")
|
try:
|
||||||
self._last_error = f"HTTP {e.code}"
|
req = Request(url, headers=headers)
|
||||||
self._consecutive_errors += 1
|
with urlopen(req, timeout=15) as resp:
|
||||||
return False
|
data = json.loads(resp.read().decode("utf-8"))
|
||||||
|
|
||||||
except URLError as e:
|
except HTTPError as e:
|
||||||
logger.warning(f"NWS connection error: {e.reason}")
|
if e.code == 429:
|
||||||
self._last_error = str(e.reason)
|
self._backoff_until = time.time() + 5
|
||||||
self._consecutive_errors += 1
|
logger.warning("NWS rate limited, backing off 5s")
|
||||||
return False
|
else:
|
||||||
|
logger.warning(f"NWS HTTP error: {e.code}")
|
||||||
except Exception as e:
|
self._last_error = f"HTTP {e.code}"
|
||||||
logger.warning(f"NWS fetch error: {e}")
|
self._consecutive_errors += 1
|
||||||
self._last_error = str(e)
|
return False
|
||||||
self._consecutive_errors += 1
|
|
||||||
return False
|
except URLError as e:
|
||||||
|
logger.warning(f"NWS connection error: {e.reason}")
|
||||||
# Parse response
|
self._last_error = str(e.reason)
|
||||||
features = data.get("features", [])
|
self._consecutive_errors += 1
|
||||||
new_events = []
|
return False
|
||||||
|
|
||||||
# Severity levels for filtering
|
except Exception as e:
|
||||||
severity_levels = ["unknown", "minor", "moderate", "severe", "extreme"]
|
logger.warning(f"NWS fetch error: {e}")
|
||||||
try:
|
self._last_error = str(e)
|
||||||
min_idx = severity_levels.index(self._severity_min.lower())
|
self._consecutive_errors += 1
|
||||||
except ValueError:
|
return False
|
||||||
min_idx = 2 # default to moderate
|
|
||||||
|
# Parse response
|
||||||
for feature in features:
|
features = data.get("features", [])
|
||||||
props = feature.get("properties", {})
|
new_events = []
|
||||||
|
|
||||||
# Severity filtering
|
# Severity levels for filtering
|
||||||
severity = (props.get("severity") or "Unknown").lower()
|
severity_levels = ["unknown", "minor", "moderate", "severe", "extreme"]
|
||||||
try:
|
try:
|
||||||
sev_idx = severity_levels.index(severity)
|
min_idx = severity_levels.index(self._severity_min.lower())
|
||||||
except ValueError:
|
except ValueError:
|
||||||
sev_idx = 0
|
min_idx = 2 # default to moderate
|
||||||
|
|
||||||
if sev_idx < min_idx:
|
for feature in features:
|
||||||
continue
|
props = feature.get("properties", {})
|
||||||
|
|
||||||
# Parse timestamps
|
# Severity filtering
|
||||||
onset = self._parse_iso(props.get("onset"))
|
severity = (props.get("severity") or "Unknown").lower()
|
||||||
expires = self._parse_iso(props.get("expires"))
|
try:
|
||||||
|
sev_idx = severity_levels.index(severity)
|
||||||
event = {
|
except ValueError:
|
||||||
"source": "nws",
|
sev_idx = 0
|
||||||
"event_id": props.get("id", ""),
|
|
||||||
"event_type": props.get("event", "Unknown"),
|
if sev_idx < min_idx:
|
||||||
"severity": severity,
|
continue
|
||||||
"headline": props.get("headline", ""),
|
|
||||||
"description": (props.get("description") or "")[:500],
|
# Parse timestamps
|
||||||
"onset": onset,
|
onset = self._parse_iso(props.get("onset"))
|
||||||
"expires": expires,
|
expires = self._parse_iso(props.get("expires"))
|
||||||
"areas": props.get("geocode", {}).get("UGC", []),
|
|
||||||
"area_desc": props.get("areaDesc", ""),
|
event = {
|
||||||
"fetched_at": time.time(),
|
"source": "nws",
|
||||||
}
|
"event_id": props.get("id", ""),
|
||||||
|
"event_type": props.get("event", "Unknown"),
|
||||||
# Try to get centroid from geometry
|
"severity": severity,
|
||||||
geom = feature.get("geometry")
|
"headline": props.get("headline", ""),
|
||||||
if geom and geom.get("coordinates"):
|
"description": (props.get("description") or "")[:500],
|
||||||
try:
|
"onset": onset,
|
||||||
coords = geom["coordinates"]
|
"expires": expires,
|
||||||
if geom.get("type") == "Polygon" and coords:
|
"areas": props.get("geocode", {}).get("UGC", []),
|
||||||
# Compute centroid of first ring
|
"area_desc": props.get("areaDesc", ""),
|
||||||
ring = coords[0]
|
"fetched_at": time.time(),
|
||||||
lat_sum = sum(c[1] for c in ring)
|
}
|
||||||
lon_sum = sum(c[0] for c in ring)
|
|
||||||
event["lat"] = lat_sum / len(ring)
|
# Try to get centroid from geometry
|
||||||
event["lon"] = lon_sum / len(ring)
|
geom = feature.get("geometry")
|
||||||
except Exception:
|
if geom and geom.get("coordinates"):
|
||||||
pass
|
try:
|
||||||
|
coords = geom["coordinates"]
|
||||||
new_events.append(event)
|
if geom.get("type") == "Polygon" and coords:
|
||||||
|
# Compute centroid of first ring
|
||||||
# Check if data changed
|
ring = coords[0]
|
||||||
old_ids = {e["event_id"] for e in self._events}
|
lat_sum = sum(c[1] for c in ring)
|
||||||
new_ids = {e["event_id"] for e in new_events}
|
lon_sum = sum(c[0] for c in ring)
|
||||||
changed = old_ids != new_ids
|
event["lat"] = lat_sum / len(ring)
|
||||||
|
event["lon"] = lon_sum / len(ring)
|
||||||
self._events = new_events
|
except Exception:
|
||||||
self._consecutive_errors = 0
|
pass
|
||||||
self._last_error = None
|
|
||||||
self._is_loaded = True
|
new_events.append(event)
|
||||||
|
|
||||||
if changed:
|
# Check if data changed
|
||||||
logger.info(f"NWS alerts updated: {len(new_events)} active")
|
old_ids = {e["event_id"] for e in self._events}
|
||||||
|
new_ids = {e["event_id"] for e in new_events}
|
||||||
return changed
|
changed = old_ids != new_ids
|
||||||
|
|
||||||
def _parse_iso(self, iso_str: str) -> float:
|
self._events = new_events
|
||||||
"""Parse ISO timestamp to epoch float."""
|
self._consecutive_errors = 0
|
||||||
if not iso_str:
|
self._last_error = None
|
||||||
return 0.0
|
self._is_loaded = True
|
||||||
try:
|
|
||||||
# Handle various ISO formats
|
if changed:
|
||||||
if iso_str.endswith("Z"):
|
logger.info(f"NWS alerts updated: {len(new_events)} active")
|
||||||
iso_str = iso_str[:-1] + "+00:00"
|
|
||||||
dt = datetime.fromisoformat(iso_str)
|
return changed
|
||||||
return dt.timestamp()
|
|
||||||
except Exception:
|
def _parse_iso(self, iso_str: str) -> float:
|
||||||
return 0.0
|
"""Parse ISO timestamp to epoch float."""
|
||||||
|
if not iso_str:
|
||||||
def get_events(self) -> list:
|
return 0.0
|
||||||
"""Get current events."""
|
try:
|
||||||
return self._events
|
# Handle various ISO formats
|
||||||
|
if iso_str.endswith("Z"):
|
||||||
@property
|
iso_str = iso_str[:-1] + "+00:00"
|
||||||
def health_status(self) -> dict:
|
dt = datetime.fromisoformat(iso_str)
|
||||||
"""Get adapter health status."""
|
return dt.timestamp()
|
||||||
return {
|
except Exception:
|
||||||
"source": "nws",
|
return 0.0
|
||||||
"is_loaded": self._is_loaded,
|
|
||||||
"last_error": str(self._last_error) if self._last_error else None,
|
def get_events(self) -> list:
|
||||||
"consecutive_errors": self._consecutive_errors,
|
"""Get current events."""
|
||||||
"event_count": len(self._events),
|
return self._events
|
||||||
"last_fetch": self._last_tick,
|
|
||||||
}
|
@property
|
||||||
|
def health_status(self) -> dict:
|
||||||
|
"""Get adapter health status."""
|
||||||
|
return {
|
||||||
|
"source": "nws",
|
||||||
|
"is_loaded": self._is_loaded,
|
||||||
|
"last_error": str(self._last_error) if self._last_error else None,
|
||||||
|
"consecutive_errors": self._consecutive_errors,
|
||||||
|
"event_count": len(self._events),
|
||||||
|
"last_fetch": self._last_tick,
|
||||||
|
}
|
||||||
|
|
|
||||||
732
meshai/env/roads511.py
vendored
732
meshai/env/roads511.py
vendored
|
|
@ -1,366 +1,366 @@
|
||||||
"""511 Road Conditions adapter.
|
"""511 Road Conditions adapter.
|
||||||
|
|
||||||
Polls a configurable 511 API for road events. The base URL is fully
|
Polls a configurable 511 API for road events. The base URL is fully
|
||||||
configurable as each state has a different 511 system.
|
configurable as each state has a different 511 system.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from urllib.error import HTTPError, URLError
|
from urllib.error import HTTPError, URLError
|
||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request, urlopen
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..config import Roads511Config
|
from ..config import Roads511Config
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class Roads511Adapter:
|
class Roads511Adapter:
|
||||||
"""511 road conditions polling adapter."""
|
"""511 road conditions polling adapter."""
|
||||||
|
|
||||||
def __init__(self, config: "Roads511Config"):
|
def __init__(self, config: "Roads511Config"):
|
||||||
self._api_key = self._resolve_env(config.api_key or "")
|
self._api_key = self._resolve_env(config.api_key or "")
|
||||||
self._base_url = (config.base_url or "").rstrip("/")
|
self._base_url = (config.base_url or "").rstrip("/")
|
||||||
self._endpoints = config.endpoints or ["/get/event"]
|
self._endpoints = config.endpoints or ["/get/event"]
|
||||||
self._bbox = config.bbox or [] # [west, south, east, north]
|
self._bbox = config.bbox or [] # [west, south, east, north]
|
||||||
self._tick_interval = config.tick_seconds or 300
|
self._tick_interval = config.tick_seconds or 300
|
||||||
self._last_tick = 0.0
|
self._last_tick = 0.0
|
||||||
self._events = []
|
self._events = []
|
||||||
self._consecutive_errors = 0
|
self._consecutive_errors = 0
|
||||||
self._last_error = None
|
self._last_error = None
|
||||||
self._is_loaded = False
|
self._is_loaded = False
|
||||||
self._auth_failed = False # Stop retrying on auth failures
|
self._auth_failed = False # Stop retrying on auth failures
|
||||||
|
|
||||||
if not self._base_url:
|
if not self._base_url:
|
||||||
logger.info("511: No base URL configured, adapter disabled")
|
logger.info("511: No base URL configured, adapter disabled")
|
||||||
|
|
||||||
def _resolve_env(self, value: str) -> str:
|
def _resolve_env(self, value: str) -> str:
|
||||||
"""Resolve ${ENV_VAR} references in value."""
|
"""Resolve ${ENV_VAR} references in value."""
|
||||||
if value and value.startswith("${") and value.endswith("}"):
|
if value and value.startswith("${") and value.endswith("}"):
|
||||||
env_var = value[2:-1]
|
env_var = value[2:-1]
|
||||||
return os.environ.get(env_var, "")
|
return os.environ.get(env_var, "")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def tick(self) -> bool:
|
def tick(self) -> bool:
|
||||||
"""Execute one polling tick.
|
"""Execute one polling tick.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if data changed
|
True if data changed
|
||||||
"""
|
"""
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|
||||||
# No base URL configured
|
# No base URL configured
|
||||||
if not self._base_url:
|
if not self._base_url:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Auth failed - don't keep retrying
|
# Auth failed - don't keep retrying
|
||||||
if self._auth_failed:
|
if self._auth_failed:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Check tick interval
|
# Check tick interval
|
||||||
if now - self._last_tick < self._tick_interval:
|
if now - self._last_tick < self._tick_interval:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
self._last_tick = now
|
self._last_tick = now
|
||||||
return self._fetch_all()
|
return self._fetch_all()
|
||||||
|
|
||||||
def _fetch_all(self) -> bool:
|
def _fetch_all(self) -> bool:
|
||||||
"""Fetch events from all configured endpoints.
|
"""Fetch events from all configured endpoints.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if data changed
|
True if data changed
|
||||||
"""
|
"""
|
||||||
new_events = []
|
new_events = []
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|
||||||
for endpoint in self._endpoints:
|
for endpoint in self._endpoints:
|
||||||
events = self._fetch_endpoint(endpoint, now)
|
events = self._fetch_endpoint(endpoint, now)
|
||||||
if events:
|
if events:
|
||||||
new_events.extend(events)
|
new_events.extend(events)
|
||||||
|
|
||||||
# Apply bbox filter if configured
|
# Apply bbox filter if configured
|
||||||
if self._bbox and len(self._bbox) == 4:
|
if self._bbox and len(self._bbox) == 4:
|
||||||
west, south, east, north = self._bbox
|
west, south, east, north = self._bbox
|
||||||
new_events = [
|
new_events = [
|
||||||
e for e in new_events
|
e for e in new_events
|
||||||
if e.get("lat") is not None and e.get("lon") is not None
|
if e.get("lat") is not None and e.get("lon") is not None
|
||||||
and west <= e["lon"] <= east and south <= e["lat"] <= north
|
and west <= e["lon"] <= east and south <= e["lat"] <= north
|
||||||
]
|
]
|
||||||
|
|
||||||
# Check if data changed
|
# Check if data changed
|
||||||
old_ids = {e["event_id"] for e in self._events}
|
old_ids = {e["event_id"] for e in self._events}
|
||||||
new_ids = {e["event_id"] for e in new_events}
|
new_ids = {e["event_id"] for e in new_events}
|
||||||
changed = old_ids != new_ids
|
changed = old_ids != new_ids
|
||||||
|
|
||||||
self._events = new_events
|
self._events = new_events
|
||||||
self._is_loaded = True
|
self._is_loaded = True
|
||||||
|
|
||||||
if changed:
|
if changed:
|
||||||
logger.info(f"511 road events updated: {len(new_events)} active")
|
logger.info(f"511 road events updated: {len(new_events)} active")
|
||||||
|
|
||||||
return changed
|
return changed
|
||||||
|
|
||||||
def _fetch_endpoint(self, endpoint: str, now: float) -> list:
|
def _fetch_endpoint(self, endpoint: str, now: float) -> list:
|
||||||
"""Fetch events from a single endpoint.
|
"""Fetch events from a single endpoint.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
endpoint: API endpoint path
|
endpoint: API endpoint path
|
||||||
now: Current timestamp
|
now: Current timestamp
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of event dicts
|
List of event dicts
|
||||||
"""
|
"""
|
||||||
url = urljoin(self._base_url + "/", endpoint.lstrip("/"))
|
url = urljoin(self._base_url + "/", endpoint.lstrip("/"))
|
||||||
|
|
||||||
# Add API key if configured
|
# Add API key if configured
|
||||||
if self._api_key:
|
if self._api_key:
|
||||||
sep = "&" if "?" in url else "?"
|
sep = "&" if "?" in url else "?"
|
||||||
url = f"{url}{sep}key={self._api_key}"
|
url = f"{url}{sep}key={self._api_key}"
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
"User-Agent": "MeshAI/1.0",
|
"User-Agent": "MeshAI/1.0",
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
req = Request(url, headers=headers)
|
req = Request(url, headers=headers)
|
||||||
with urlopen(req, timeout=30) as resp:
|
with urlopen(req, timeout=30) as resp:
|
||||||
data = json.loads(resp.read().decode("utf-8"))
|
data = json.loads(resp.read().decode("utf-8"))
|
||||||
|
|
||||||
except HTTPError as e:
|
except HTTPError as e:
|
||||||
if e.code == 401 or e.code == 403:
|
if e.code == 401 or e.code == 403:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"511 auth error: {e.code} - check API key configuration for {self._base_url}"
|
f"511 auth error: {e.code} - check API key configuration for {self._base_url}"
|
||||||
)
|
)
|
||||||
self._last_error = f"Auth error {e.code} - check API key"
|
self._last_error = f"Auth error {e.code} - check API key"
|
||||||
self._auth_failed = True
|
self._auth_failed = True
|
||||||
return []
|
return []
|
||||||
else:
|
else:
|
||||||
logger.warning(f"511 HTTP error for {endpoint}: {e.code}")
|
logger.warning(f"511 HTTP error for {endpoint}: {e.code}")
|
||||||
self._last_error = f"HTTP {e.code}"
|
self._last_error = f"HTTP {e.code}"
|
||||||
self._consecutive_errors += 1
|
self._consecutive_errors += 1
|
||||||
return []
|
return []
|
||||||
|
|
||||||
except URLError as e:
|
except URLError as e:
|
||||||
logger.warning(f"511 connection error for {endpoint}: {e.reason}")
|
logger.warning(f"511 connection error for {endpoint}: {e.reason}")
|
||||||
self._last_error = str(e.reason)
|
self._last_error = str(e.reason)
|
||||||
self._consecutive_errors += 1
|
self._consecutive_errors += 1
|
||||||
return []
|
return []
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"511 fetch error for {endpoint}: {e}")
|
logger.warning(f"511 fetch error for {endpoint}: {e}")
|
||||||
self._last_error = str(e)
|
self._last_error = str(e)
|
||||||
self._consecutive_errors += 1
|
self._consecutive_errors += 1
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Parse response - handle various 511 API formats
|
# Parse response - handle various 511 API formats
|
||||||
return self._parse_response(data, now)
|
return self._parse_response(data, now)
|
||||||
|
|
||||||
def _parse_response(self, data, now: float) -> list:
|
def _parse_response(self, data, now: float) -> list:
|
||||||
"""Parse 511 API response.
|
"""Parse 511 API response.
|
||||||
|
|
||||||
Different states use different formats. Try common patterns.
|
Different states use different formats. Try common patterns.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
data: JSON response data
|
data: JSON response data
|
||||||
now: Current timestamp
|
now: Current timestamp
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of event dicts
|
List of event dicts
|
||||||
"""
|
"""
|
||||||
events = []
|
events = []
|
||||||
|
|
||||||
# Handle array response
|
# Handle array response
|
||||||
if isinstance(data, list):
|
if isinstance(data, list):
|
||||||
items = data
|
items = data
|
||||||
# Handle wrapped response
|
# Handle wrapped response
|
||||||
elif isinstance(data, dict):
|
elif isinstance(data, dict):
|
||||||
# Try common wrapper keys
|
# Try common wrapper keys
|
||||||
items = (
|
items = (
|
||||||
data.get("events") or
|
data.get("events") or
|
||||||
data.get("items") or
|
data.get("items") or
|
||||||
data.get("data") or
|
data.get("data") or
|
||||||
data.get("results") or
|
data.get("results") or
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
if not isinstance(items, list):
|
if not isinstance(items, list):
|
||||||
items = [data] if self._looks_like_event(data) else []
|
items = [data] if self._looks_like_event(data) else []
|
||||||
else:
|
else:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
for item in items:
|
for item in items:
|
||||||
event = self._parse_event(item, now)
|
event = self._parse_event(item, now)
|
||||||
if event:
|
if event:
|
||||||
events.append(event)
|
events.append(event)
|
||||||
|
|
||||||
self._consecutive_errors = 0
|
self._consecutive_errors = 0
|
||||||
self._last_error = None
|
self._last_error = None
|
||||||
return events
|
return events
|
||||||
|
|
||||||
def _looks_like_event(self, item: dict) -> bool:
|
def _looks_like_event(self, item: dict) -> bool:
|
||||||
"""Check if dict looks like a 511 event."""
|
"""Check if dict looks like a 511 event."""
|
||||||
return bool(
|
return bool(
|
||||||
item.get("id") or item.get("EventId") or item.get("event_id")
|
item.get("id") or item.get("EventId") or item.get("event_id")
|
||||||
)
|
)
|
||||||
|
|
||||||
def _parse_event(self, item: dict, now: float) -> dict:
|
def _parse_event(self, item: dict, now: float) -> dict:
|
||||||
"""Parse a single 511 event.
|
"""Parse a single 511 event.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
item: Event dict from API
|
item: Event dict from API
|
||||||
now: Current timestamp
|
now: Current timestamp
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Normalized event dict or None
|
Normalized event dict or None
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Try various ID field names
|
# Try various ID field names
|
||||||
event_id = (
|
event_id = (
|
||||||
item.get("id") or
|
item.get("id") or
|
||||||
item.get("EventId") or
|
item.get("EventId") or
|
||||||
item.get("event_id") or
|
item.get("event_id") or
|
||||||
item.get("ID") or
|
item.get("ID") or
|
||||||
str(hash(str(item)))[:12]
|
str(hash(str(item)))[:12]
|
||||||
)
|
)
|
||||||
|
|
||||||
# Try various type field names
|
# Try various type field names
|
||||||
event_type = (
|
event_type = (
|
||||||
item.get("EventType") or
|
item.get("EventType") or
|
||||||
item.get("event_type") or
|
item.get("event_type") or
|
||||||
item.get("type") or
|
item.get("type") or
|
||||||
item.get("Type") or
|
item.get("Type") or
|
||||||
item.get("category") or
|
item.get("category") or
|
||||||
"Road Event"
|
"Road Event"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Try various road name fields
|
# Try various road name fields
|
||||||
roadway = (
|
roadway = (
|
||||||
item.get("RoadwayName") or
|
item.get("RoadwayName") or
|
||||||
item.get("roadway_name") or
|
item.get("roadway_name") or
|
||||||
item.get("roadway") or
|
item.get("roadway") or
|
||||||
item.get("Roadway") or
|
item.get("Roadway") or
|
||||||
item.get("road") or
|
item.get("road") or
|
||||||
item.get("route") or
|
item.get("route") or
|
||||||
""
|
""
|
||||||
)
|
)
|
||||||
|
|
||||||
# Try various description fields
|
# Try various description fields
|
||||||
description = (
|
description = (
|
||||||
item.get("Description") or
|
item.get("Description") or
|
||||||
item.get("description") or
|
item.get("description") or
|
||||||
item.get("message") or
|
item.get("message") or
|
||||||
item.get("Message") or
|
item.get("Message") or
|
||||||
item.get("details") or
|
item.get("details") or
|
||||||
""
|
""
|
||||||
)
|
)
|
||||||
|
|
||||||
# Try various location fields
|
# Try various location fields
|
||||||
lat = (
|
lat = (
|
||||||
item.get("Latitude") or
|
item.get("Latitude") or
|
||||||
item.get("latitude") or
|
item.get("latitude") or
|
||||||
item.get("lat") or
|
item.get("lat") or
|
||||||
item.get("StartLatitude") or
|
item.get("StartLatitude") or
|
||||||
None
|
None
|
||||||
)
|
)
|
||||||
lon = (
|
lon = (
|
||||||
item.get("Longitude") or
|
item.get("Longitude") or
|
||||||
item.get("longitude") or
|
item.get("longitude") or
|
||||||
item.get("lon") or
|
item.get("lon") or
|
||||||
item.get("lng") or
|
item.get("lng") or
|
||||||
item.get("StartLongitude") or
|
item.get("StartLongitude") or
|
||||||
None
|
None
|
||||||
)
|
)
|
||||||
|
|
||||||
# Try to get coordinates from nested location object
|
# Try to get coordinates from nested location object
|
||||||
if lat is None and "location" in item:
|
if lat is None and "location" in item:
|
||||||
loc = item["location"]
|
loc = item["location"]
|
||||||
if isinstance(loc, dict):
|
if isinstance(loc, dict):
|
||||||
lat = loc.get("latitude") or loc.get("lat")
|
lat = loc.get("latitude") or loc.get("lat")
|
||||||
lon = loc.get("longitude") or loc.get("lon") or loc.get("lng")
|
lon = loc.get("longitude") or loc.get("lon") or loc.get("lng")
|
||||||
|
|
||||||
# Check closure status
|
# Check closure status
|
||||||
is_closure = (
|
is_closure = (
|
||||||
item.get("IsFullClosure") or
|
item.get("IsFullClosure") or
|
||||||
item.get("is_full_closure") or
|
item.get("is_full_closure") or
|
||||||
item.get("fullClosure") or
|
item.get("fullClosure") or
|
||||||
item.get("closed") or
|
item.get("closed") or
|
||||||
"closure" in str(event_type).lower() or
|
"closure" in str(event_type).lower() or
|
||||||
"closed" in str(description).lower()
|
"closed" in str(description).lower()
|
||||||
)
|
)
|
||||||
|
|
||||||
# Determine severity
|
# Determine severity
|
||||||
if is_closure:
|
if is_closure:
|
||||||
severity = "warning"
|
severity = "priority"
|
||||||
elif "construction" in str(event_type).lower():
|
elif "construction" in str(event_type).lower():
|
||||||
severity = "advisory"
|
severity = "routine"
|
||||||
elif "incident" in str(event_type).lower():
|
elif "incident" in str(event_type).lower():
|
||||||
severity = "advisory"
|
severity = "routine"
|
||||||
else:
|
else:
|
||||||
severity = "info"
|
severity = "routine"
|
||||||
|
|
||||||
# Format headline
|
# Format headline
|
||||||
if roadway and description:
|
if roadway and description:
|
||||||
headline = f"{roadway}: {description[:100]}"
|
headline = f"{roadway}: {description[:100]}"
|
||||||
elif roadway:
|
elif roadway:
|
||||||
headline = f"{roadway}: {event_type}"
|
headline = f"{roadway}: {event_type}"
|
||||||
elif description:
|
elif description:
|
||||||
headline = description[:120]
|
headline = description[:120]
|
||||||
else:
|
else:
|
||||||
headline = f"{event_type}"
|
headline = f"{event_type}"
|
||||||
|
|
||||||
# Try to get timestamp for expiry
|
# Try to get timestamp for expiry
|
||||||
last_updated = (
|
last_updated = (
|
||||||
item.get("LastUpdated") or
|
item.get("LastUpdated") or
|
||||||
item.get("last_updated") or
|
item.get("last_updated") or
|
||||||
item.get("updated") or
|
item.get("updated") or
|
||||||
item.get("timestamp") or
|
item.get("timestamp") or
|
||||||
None
|
None
|
||||||
)
|
)
|
||||||
|
|
||||||
# Default 6 hour TTL, refreshed every tick
|
# Default 6 hour TTL, refreshed every tick
|
||||||
expires = now + 21600
|
expires = now + 21600
|
||||||
|
|
||||||
event = {
|
event = {
|
||||||
"source": "511",
|
"source": "511",
|
||||||
"event_id": f"511_{event_id}",
|
"event_id": f"511_{event_id}",
|
||||||
"event_type": event_type,
|
"event_type": event_type,
|
||||||
"headline": headline,
|
"headline": headline,
|
||||||
"description": description[:500] if description else "",
|
"description": description[:500] if description else "",
|
||||||
"severity": severity,
|
"severity": severity,
|
||||||
"lat": float(lat) if lat is not None else None,
|
"lat": float(lat) if lat is not None else None,
|
||||||
"lon": float(lon) if lon is not None else None,
|
"lon": float(lon) if lon is not None else None,
|
||||||
"expires": expires,
|
"expires": expires,
|
||||||
"fetched_at": now,
|
"fetched_at": now,
|
||||||
"properties": {
|
"properties": {
|
||||||
"roadway": roadway,
|
"roadway": roadway,
|
||||||
"is_closure": bool(is_closure),
|
"is_closure": bool(is_closure),
|
||||||
"last_updated": last_updated,
|
"last_updated": last_updated,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return event
|
return event
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"511 event parse error: {e} - item: {item}")
|
logger.debug(f"511 event parse error: {e} - item: {item}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_events(self) -> list:
|
def get_events(self) -> list:
|
||||||
"""Get current road events."""
|
"""Get current road events."""
|
||||||
return self._events
|
return self._events
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def health_status(self) -> dict:
|
def health_status(self) -> dict:
|
||||||
"""Get adapter health status."""
|
"""Get adapter health status."""
|
||||||
return {
|
return {
|
||||||
"source": "511",
|
"source": "511",
|
||||||
"is_loaded": self._is_loaded,
|
"is_loaded": self._is_loaded,
|
||||||
"last_error": str(self._last_error) if self._last_error else None,
|
"last_error": str(self._last_error) if self._last_error else None,
|
||||||
"consecutive_errors": self._consecutive_errors,
|
"consecutive_errors": self._consecutive_errors,
|
||||||
"event_count": len(self._events),
|
"event_count": len(self._events),
|
||||||
"last_fetch": self._last_tick,
|
"last_fetch": self._last_tick,
|
||||||
"auth_failed": self._auth_failed,
|
"auth_failed": self._auth_failed,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
544
meshai/env/swpc.py
vendored
544
meshai/env/swpc.py
vendored
|
|
@ -1,272 +1,272 @@
|
||||||
"""NOAA Space Weather Prediction Center adapter."""
|
"""NOAA Space Weather Prediction Center adapter."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from urllib.error import HTTPError, URLError
|
from urllib.error import HTTPError, URLError
|
||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..config import SWPCConfig
|
from ..config import SWPCConfig
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class SWPCAdapter:
|
class SWPCAdapter:
|
||||||
"""NOAA Space Weather -- multi-endpoint with staggered ticks."""
|
"""NOAA Space Weather -- multi-endpoint with staggered ticks."""
|
||||||
|
|
||||||
# Endpoint definitions: (url, interval_seconds)
|
# Endpoint definitions: (url, interval_seconds)
|
||||||
ENDPOINTS = {
|
ENDPOINTS = {
|
||||||
"scales": ("https://services.swpc.noaa.gov/products/noaa-scales.json", 300),
|
"scales": ("https://services.swpc.noaa.gov/products/noaa-scales.json", 300),
|
||||||
"kp": ("https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json", 600),
|
"kp": ("https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json", 600),
|
||||||
"alerts": ("https://services.swpc.noaa.gov/products/alerts.json", 120),
|
"alerts": ("https://services.swpc.noaa.gov/products/alerts.json", 120),
|
||||||
"f107": ("https://services.swpc.noaa.gov/json/f107_cm_flux.json", 86400),
|
"f107": ("https://services.swpc.noaa.gov/json/f107_cm_flux.json", 86400),
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, config: "SWPCConfig"):
|
def __init__(self, config: "SWPCConfig"):
|
||||||
self._last_tick = {} # endpoint -> last_tick timestamp
|
self._last_tick = {} # endpoint -> last_tick timestamp
|
||||||
self._status = {}
|
self._status = {}
|
||||||
self._events = []
|
self._events = []
|
||||||
self._consecutive_errors = 0
|
self._consecutive_errors = 0
|
||||||
self._last_error = None
|
self._last_error = None
|
||||||
self._is_loaded = False
|
self._is_loaded = False
|
||||||
|
|
||||||
# Initialize tick times to 0
|
# Initialize tick times to 0
|
||||||
for endpoint in self.ENDPOINTS:
|
for endpoint in self.ENDPOINTS:
|
||||||
self._last_tick[endpoint] = 0.0
|
self._last_tick[endpoint] = 0.0
|
||||||
|
|
||||||
def tick(self) -> bool:
|
def tick(self) -> bool:
|
||||||
"""Execute one polling tick.
|
"""Execute one polling tick.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if data changed
|
True if data changed
|
||||||
"""
|
"""
|
||||||
changed = False
|
changed = False
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|
||||||
for endpoint, (url, interval) in self.ENDPOINTS.items():
|
for endpoint, (url, interval) in self.ENDPOINTS.items():
|
||||||
if now - self._last_tick[endpoint] >= interval:
|
if now - self._last_tick[endpoint] >= interval:
|
||||||
self._last_tick[endpoint] = now
|
self._last_tick[endpoint] = now
|
||||||
if self._fetch_endpoint(endpoint, url):
|
if self._fetch_endpoint(endpoint, url):
|
||||||
changed = True
|
changed = True
|
||||||
|
|
||||||
if changed:
|
if changed:
|
||||||
self._update_events()
|
self._update_events()
|
||||||
|
|
||||||
return changed
|
return changed
|
||||||
|
|
||||||
def _fetch_endpoint(self, endpoint: str, url: str) -> bool:
|
def _fetch_endpoint(self, endpoint: str, url: str) -> bool:
|
||||||
"""Fetch a single endpoint.
|
"""Fetch a single endpoint.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True on success
|
True on success
|
||||||
"""
|
"""
|
||||||
headers = {
|
headers = {
|
||||||
"User-Agent": "MeshAI/1.0",
|
"User-Agent": "MeshAI/1.0",
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
req = Request(url, headers=headers)
|
req = Request(url, headers=headers)
|
||||||
with urlopen(req, timeout=15) as resp:
|
with urlopen(req, timeout=15) as resp:
|
||||||
data = json.loads(resp.read().decode("utf-8"))
|
data = json.loads(resp.read().decode("utf-8"))
|
||||||
|
|
||||||
except HTTPError as e:
|
except HTTPError as e:
|
||||||
logger.warning(f"SWPC {endpoint} HTTP error: {e.code}")
|
logger.warning(f"SWPC {endpoint} HTTP error: {e.code}")
|
||||||
self._last_error = f"{endpoint}: HTTP {e.code}"
|
self._last_error = f"{endpoint}: HTTP {e.code}"
|
||||||
self._consecutive_errors += 1
|
self._consecutive_errors += 1
|
||||||
return False
|
return False
|
||||||
|
|
||||||
except URLError as e:
|
except URLError as e:
|
||||||
logger.warning(f"SWPC {endpoint} connection error: {e.reason}")
|
logger.warning(f"SWPC {endpoint} connection error: {e.reason}")
|
||||||
self._last_error = f"{endpoint}: {e.reason}"
|
self._last_error = f"{endpoint}: {e.reason}"
|
||||||
self._consecutive_errors += 1
|
self._consecutive_errors += 1
|
||||||
return False
|
return False
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"SWPC {endpoint} error: {e}")
|
logger.warning(f"SWPC {endpoint} error: {e}")
|
||||||
self._last_error = f"{endpoint}: {e}"
|
self._last_error = f"{endpoint}: {e}"
|
||||||
self._consecutive_errors += 1
|
self._consecutive_errors += 1
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Parse based on endpoint
|
# Parse based on endpoint
|
||||||
try:
|
try:
|
||||||
if endpoint == "scales":
|
if endpoint == "scales":
|
||||||
self._parse_scales(data)
|
self._parse_scales(data)
|
||||||
elif endpoint == "kp":
|
elif endpoint == "kp":
|
||||||
self._parse_kp(data)
|
self._parse_kp(data)
|
||||||
elif endpoint == "alerts":
|
elif endpoint == "alerts":
|
||||||
self._parse_alerts(data)
|
self._parse_alerts(data)
|
||||||
elif endpoint == "f107":
|
elif endpoint == "f107":
|
||||||
self._parse_f107(data)
|
self._parse_f107(data)
|
||||||
|
|
||||||
self._consecutive_errors = 0
|
self._consecutive_errors = 0
|
||||||
self._last_error = None
|
self._last_error = None
|
||||||
self._is_loaded = True
|
self._is_loaded = True
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"SWPC {endpoint} parse error: {e}")
|
logger.warning(f"SWPC {endpoint} parse error: {e}")
|
||||||
self._last_error = f"{endpoint}: parse error"
|
self._last_error = f"{endpoint}: parse error"
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _parse_scales(self, data):
|
def _parse_scales(self, data):
|
||||||
"""Parse noaa-scales.json.
|
"""Parse noaa-scales.json.
|
||||||
|
|
||||||
Data format: {""-1": {...}, "0": {...}, "1": {...}, ...}
|
Data format: {""-1": {...}, "0": {...}, "1": {...}, ...}
|
||||||
"0" is current.
|
"0" is current.
|
||||||
"""
|
"""
|
||||||
current = data.get("0", {})
|
current = data.get("0", {})
|
||||||
|
|
||||||
r_data = current.get("R", {})
|
r_data = current.get("R", {})
|
||||||
s_data = current.get("S", {})
|
s_data = current.get("S", {})
|
||||||
g_data = current.get("G", {})
|
g_data = current.get("G", {})
|
||||||
|
|
||||||
# Handle empty string or None Scale values
|
# Handle empty string or None Scale values
|
||||||
def parse_scale(val):
|
def parse_scale(val):
|
||||||
if val is None or val == "":
|
if val is None or val == "":
|
||||||
return 0
|
return 0
|
||||||
try:
|
try:
|
||||||
return int(val)
|
return int(val)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
self._status["r_scale"] = parse_scale(r_data.get("Scale"))
|
self._status["r_scale"] = parse_scale(r_data.get("Scale"))
|
||||||
self._status["s_scale"] = parse_scale(s_data.get("Scale"))
|
self._status["s_scale"] = parse_scale(s_data.get("Scale"))
|
||||||
self._status["g_scale"] = parse_scale(g_data.get("Scale"))
|
self._status["g_scale"] = parse_scale(g_data.get("Scale"))
|
||||||
|
|
||||||
def _parse_kp(self, data):
|
def _parse_kp(self, data):
|
||||||
"""Parse noaa-planetary-k-index.json.
|
"""Parse noaa-planetary-k-index.json.
|
||||||
|
|
||||||
Data format: array of objects with time_tag, Kp, a_running, station_count
|
Data format: array of objects with time_tag, Kp, a_running, station_count
|
||||||
Last entry is most recent. Store full history for charting.
|
Last entry is most recent. Store full history for charting.
|
||||||
"""
|
"""
|
||||||
if not data:
|
if not data:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Store full history (last 24-48 hours of readings)
|
# Store full history (last 24-48 hours of readings)
|
||||||
kp_history = []
|
kp_history = []
|
||||||
for entry in data:
|
for entry in data:
|
||||||
if isinstance(entry, dict):
|
if isinstance(entry, dict):
|
||||||
try:
|
try:
|
||||||
kp_history.append({
|
kp_history.append({
|
||||||
"time": entry.get("time_tag", ""),
|
"time": entry.get("time_tag", ""),
|
||||||
"value": float(entry.get("Kp", 0)),
|
"value": float(entry.get("Kp", 0)),
|
||||||
})
|
})
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
continue
|
continue
|
||||||
elif isinstance(entry, list) and len(entry) > 1:
|
elif isinstance(entry, list) and len(entry) > 1:
|
||||||
# Legacy array format fallback
|
# Legacy array format fallback
|
||||||
try:
|
try:
|
||||||
kp_history.append({
|
kp_history.append({
|
||||||
"time": entry[0] if len(entry) > 0 else "",
|
"time": entry[0] if len(entry) > 0 else "",
|
||||||
"value": float(entry[1]),
|
"value": float(entry[1]),
|
||||||
})
|
})
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
self._status["kp_history"] = kp_history
|
self._status["kp_history"] = kp_history
|
||||||
|
|
||||||
# Get last entry (most recent) for current value
|
# Get last entry (most recent) for current value
|
||||||
last_entry = data[-1]
|
last_entry = data[-1]
|
||||||
if isinstance(last_entry, dict):
|
if isinstance(last_entry, dict):
|
||||||
try:
|
try:
|
||||||
self._status["kp_current"] = float(last_entry.get("Kp", 0))
|
self._status["kp_current"] = float(last_entry.get("Kp", 0))
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
self._status["kp_timestamp"] = last_entry.get("time_tag", "")
|
self._status["kp_timestamp"] = last_entry.get("time_tag", "")
|
||||||
elif isinstance(last_entry, list) and len(last_entry) > 1:
|
elif isinstance(last_entry, list) and len(last_entry) > 1:
|
||||||
# Legacy array format fallback
|
# Legacy array format fallback
|
||||||
try:
|
try:
|
||||||
self._status["kp_current"] = float(last_entry[1])
|
self._status["kp_current"] = float(last_entry[1])
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
if len(last_entry) > 0:
|
if len(last_entry) > 0:
|
||||||
self._status["kp_timestamp"] = last_entry[0]
|
self._status["kp_timestamp"] = last_entry[0]
|
||||||
|
|
||||||
def _parse_alerts(self, data):
|
def _parse_alerts(self, data):
|
||||||
"""Parse alerts.json.
|
"""Parse alerts.json.
|
||||||
|
|
||||||
Data format: array of objects with product_id, issue_datetime, message
|
Data format: array of objects with product_id, issue_datetime, message
|
||||||
"""
|
"""
|
||||||
warnings = []
|
warnings = []
|
||||||
if isinstance(data, list):
|
if isinstance(data, list):
|
||||||
for alert in data[:5]: # Keep most recent 5
|
for alert in data[:5]: # Keep most recent 5
|
||||||
message = alert.get("message", "")
|
message = alert.get("message", "")
|
||||||
# Extract first line as headline
|
# Extract first line as headline
|
||||||
headline = message.split("\n")[0].strip()
|
headline = message.split("\n")[0].strip()
|
||||||
if headline:
|
if headline:
|
||||||
warnings.append(headline)
|
warnings.append(headline)
|
||||||
|
|
||||||
self._status["active_warnings"] = warnings
|
self._status["active_warnings"] = warnings
|
||||||
|
|
||||||
def _parse_f107(self, data):
|
def _parse_f107(self, data):
|
||||||
"""Parse f107_cm_flux.json.
|
"""Parse f107_cm_flux.json.
|
||||||
|
|
||||||
Data format: array of objects with time_tag, flux
|
Data format: array of objects with time_tag, flux
|
||||||
Store history for potential charting.
|
Store history for potential charting.
|
||||||
"""
|
"""
|
||||||
if not data:
|
if not data:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Store SFI history (last 30 days of readings)
|
# Store SFI history (last 30 days of readings)
|
||||||
sfi_history = []
|
sfi_history = []
|
||||||
if isinstance(data, list):
|
if isinstance(data, list):
|
||||||
for entry in data[-30:]: # Last 30 entries
|
for entry in data[-30:]: # Last 30 entries
|
||||||
if isinstance(entry, dict):
|
if isinstance(entry, dict):
|
||||||
try:
|
try:
|
||||||
sfi_history.append({
|
sfi_history.append({
|
||||||
"time": entry.get("time_tag", ""),
|
"time": entry.get("time_tag", ""),
|
||||||
"value": float(entry.get("flux", 0)),
|
"value": float(entry.get("flux", 0)),
|
||||||
})
|
})
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
self._status["sfi_history"] = sfi_history
|
self._status["sfi_history"] = sfi_history
|
||||||
|
|
||||||
# Get most recent entry (last in list)
|
# Get most recent entry (last in list)
|
||||||
if isinstance(data, list) and data:
|
if isinstance(data, list) and data:
|
||||||
last = data[-1]
|
last = data[-1]
|
||||||
if isinstance(last, dict):
|
if isinstance(last, dict):
|
||||||
try:
|
try:
|
||||||
self._status["sfi"] = float(last.get("flux", 0))
|
self._status["sfi"] = float(last.get("flux", 0))
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _update_events(self):
|
def _update_events(self):
|
||||||
"""Generate events for significant space weather conditions."""
|
"""Generate events for significant space weather conditions."""
|
||||||
# Generate events for R-scale >= 3 (radio blackout)
|
# Generate events for R-scale >= 3 (radio blackout)
|
||||||
self._events = []
|
self._events = []
|
||||||
r_scale = self._status.get("r_scale", 0)
|
r_scale = self._status.get("r_scale", 0)
|
||||||
if r_scale >= 3:
|
if r_scale >= 3:
|
||||||
self._events.append({
|
self._events.append({
|
||||||
"source": "swpc",
|
"source": "swpc",
|
||||||
"event_id": f"swpc_r{r_scale}_{int(time.time())}",
|
"event_id": f"swpc_r{r_scale}_{int(time.time())}",
|
||||||
"event_type": f"R{r_scale} Radio Blackout",
|
"event_type": f"R{r_scale} Radio Blackout",
|
||||||
"severity": "warning" if r_scale >= 3 else "advisory",
|
"severity": "priority" if r_scale >= 3 else "routine",
|
||||||
"headline": f"R{r_scale} Radio Blackout in progress",
|
"headline": f"R{r_scale} Radio Blackout in progress",
|
||||||
"expires": time.time() + 3600, # 1hr TTL
|
"expires": time.time() + 3600, # 1hr TTL
|
||||||
"areas": [],
|
"areas": [],
|
||||||
"fetched_at": time.time(),
|
"fetched_at": time.time(),
|
||||||
})
|
})
|
||||||
|
|
||||||
def get_status(self) -> dict:
|
def get_status(self) -> dict:
|
||||||
"""Get current SWPC status."""
|
"""Get current SWPC status."""
|
||||||
return self._status
|
return self._status
|
||||||
|
|
||||||
def get_events(self) -> list:
|
def get_events(self) -> list:
|
||||||
"""Get current alert events."""
|
"""Get current alert events."""
|
||||||
return self._events
|
return self._events
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def health_status(self) -> dict:
|
def health_status(self) -> dict:
|
||||||
"""Get adapter health status."""
|
"""Get adapter health status."""
|
||||||
return {
|
return {
|
||||||
"source": "swpc",
|
"source": "swpc",
|
||||||
"is_loaded": self._is_loaded,
|
"is_loaded": self._is_loaded,
|
||||||
"last_error": str(self._last_error) if self._last_error else None,
|
"last_error": str(self._last_error) if self._last_error else None,
|
||||||
"consecutive_errors": self._consecutive_errors,
|
"consecutive_errors": self._consecutive_errors,
|
||||||
"event_count": len(self._events),
|
"event_count": len(self._events),
|
||||||
"last_fetch": max(self._last_tick.values()) if self._last_tick else 0,
|
"last_fetch": max(self._last_tick.values()) if self._last_tick else 0,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
508
meshai/env/traffic.py
vendored
508
meshai/env/traffic.py
vendored
|
|
@ -1,254 +1,254 @@
|
||||||
"""TomTom Traffic Flow adapter."""
|
"""TomTom Traffic Flow adapter."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from urllib.error import HTTPError, URLError
|
from urllib.error import HTTPError, URLError
|
||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request, urlopen
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..config import TomTomConfig
|
from ..config import TomTomConfig
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class TomTomTrafficAdapter:
|
class TomTomTrafficAdapter:
|
||||||
"""TomTom Traffic Flow Segment Data polling."""
|
"""TomTom Traffic Flow Segment Data polling."""
|
||||||
|
|
||||||
BASE_URL = "https://api.tomtom.com/traffic/services/4/flowSegmentData/relative0/10/json"
|
BASE_URL = "https://api.tomtom.com/traffic/services/4/flowSegmentData/relative0/10/json"
|
||||||
|
|
||||||
def __init__(self, config: "TomTomConfig"):
|
def __init__(self, config: "TomTomConfig"):
|
||||||
self._api_key = self._resolve_env(config.api_key or "")
|
self._api_key = self._resolve_env(config.api_key or "")
|
||||||
self._corridors = config.corridors or []
|
self._corridors = config.corridors or []
|
||||||
self._tick_interval = config.tick_seconds or 300
|
self._tick_interval = config.tick_seconds or 300
|
||||||
self._last_tick = 0.0
|
self._last_tick = 0.0
|
||||||
self._events = []
|
self._events = []
|
||||||
self._consecutive_errors = 0
|
self._consecutive_errors = 0
|
||||||
self._last_error = None
|
self._last_error = None
|
||||||
self._is_loaded = False
|
self._is_loaded = False
|
||||||
self._daily_requests = 0
|
self._daily_requests = 0
|
||||||
self._daily_reset = 0.0
|
self._daily_reset = 0.0
|
||||||
|
|
||||||
if not self._api_key:
|
if not self._api_key:
|
||||||
logger.warning("TomTom API key not configured, adapter disabled")
|
logger.warning("TomTom API key not configured, adapter disabled")
|
||||||
|
|
||||||
if not self._corridors:
|
if not self._corridors:
|
||||||
logger.info("TomTom: No corridors configured, adapter idle")
|
logger.info("TomTom: No corridors configured, adapter idle")
|
||||||
|
|
||||||
def _resolve_env(self, value: str) -> str:
|
def _resolve_env(self, value: str) -> str:
|
||||||
"""Resolve ${ENV_VAR} references in value."""
|
"""Resolve ${ENV_VAR} references in value."""
|
||||||
if value and value.startswith("${") and value.endswith("}"):
|
if value and value.startswith("${") and value.endswith("}"):
|
||||||
env_var = value[2:-1]
|
env_var = value[2:-1]
|
||||||
return os.environ.get(env_var, "")
|
return os.environ.get(env_var, "")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def tick(self) -> bool:
|
def tick(self) -> bool:
|
||||||
"""Execute one polling tick.
|
"""Execute one polling tick.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if data changed
|
True if data changed
|
||||||
"""
|
"""
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|
||||||
# Reset daily counter at midnight
|
# Reset daily counter at midnight
|
||||||
if now - self._daily_reset > 86400:
|
if now - self._daily_reset > 86400:
|
||||||
self._daily_requests = 0
|
self._daily_requests = 0
|
||||||
self._daily_reset = now
|
self._daily_reset = now
|
||||||
|
|
||||||
# No API key or corridors
|
# No API key or corridors
|
||||||
if not self._api_key or not self._corridors:
|
if not self._api_key or not self._corridors:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Check tick interval
|
# Check tick interval
|
||||||
if now - self._last_tick < self._tick_interval:
|
if now - self._last_tick < self._tick_interval:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
self._last_tick = now
|
self._last_tick = now
|
||||||
return self._fetch_all()
|
return self._fetch_all()
|
||||||
|
|
||||||
def _fetch_all(self) -> bool:
|
def _fetch_all(self) -> bool:
|
||||||
"""Fetch traffic flow for all configured corridors.
|
"""Fetch traffic flow for all configured corridors.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if data changed
|
True if data changed
|
||||||
"""
|
"""
|
||||||
new_events = []
|
new_events = []
|
||||||
now = time.time()
|
now = time.time()
|
||||||
any_error = False
|
any_error = False
|
||||||
|
|
||||||
for corridor in self._corridors:
|
for corridor in self._corridors:
|
||||||
# Support both dict and object formats
|
# Support both dict and object formats
|
||||||
if isinstance(corridor, dict):
|
if isinstance(corridor, dict):
|
||||||
name = corridor.get("name", "Unknown")
|
name = corridor.get("name", "Unknown")
|
||||||
lat = corridor.get("lat")
|
lat = corridor.get("lat")
|
||||||
lon = corridor.get("lon")
|
lon = corridor.get("lon")
|
||||||
else:
|
else:
|
||||||
name = getattr(corridor, "name", "Unknown")
|
name = getattr(corridor, "name", "Unknown")
|
||||||
lat = getattr(corridor, "lat", None)
|
lat = getattr(corridor, "lat", None)
|
||||||
lon = getattr(corridor, "lon", None)
|
lon = getattr(corridor, "lon", None)
|
||||||
|
|
||||||
if lat is None or lon is None:
|
if lat is None or lon is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
event = self._fetch_point(name, lat, lon, now)
|
event = self._fetch_point(name, lat, lon, now)
|
||||||
if event:
|
if event:
|
||||||
new_events.append(event)
|
new_events.append(event)
|
||||||
else:
|
else:
|
||||||
any_error = True
|
any_error = True
|
||||||
|
|
||||||
if any_error and not new_events:
|
if any_error and not new_events:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Check if data changed
|
# Check if data changed
|
||||||
old_ids = {e["event_id"] for e in self._events}
|
old_ids = {e["event_id"] for e in self._events}
|
||||||
new_ids = {e["event_id"] for e in new_events}
|
new_ids = {e["event_id"] for e in new_events}
|
||||||
changed = old_ids != new_ids
|
changed = old_ids != new_ids
|
||||||
|
|
||||||
self._events = new_events
|
self._events = new_events
|
||||||
if not any_error:
|
if not any_error:
|
||||||
self._consecutive_errors = 0
|
self._consecutive_errors = 0
|
||||||
self._last_error = None
|
self._last_error = None
|
||||||
self._is_loaded = True
|
self._is_loaded = True
|
||||||
|
|
||||||
if changed:
|
if changed:
|
||||||
logger.info(f"TomTom traffic updated: {len(new_events)} corridors")
|
logger.info(f"TomTom traffic updated: {len(new_events)} corridors")
|
||||||
|
|
||||||
return changed
|
return changed
|
||||||
|
|
||||||
def _fetch_point(self, name: str, lat: float, lon: float, now: float) -> dict:
|
def _fetch_point(self, name: str, lat: float, lon: float, now: float) -> dict:
|
||||||
"""Fetch traffic flow for a single point.
|
"""Fetch traffic flow for a single point.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name: Corridor name
|
name: Corridor name
|
||||||
lat: Latitude
|
lat: Latitude
|
||||||
lon: Longitude
|
lon: Longitude
|
||||||
now: Current timestamp
|
now: Current timestamp
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Event dict or None on error
|
Event dict or None on error
|
||||||
"""
|
"""
|
||||||
params = {
|
params = {
|
||||||
"point": f"{lat},{lon}",
|
"point": f"{lat},{lon}",
|
||||||
"key": self._api_key,
|
"key": self._api_key,
|
||||||
"unit": "MPH",
|
"unit": "MPH",
|
||||||
}
|
}
|
||||||
|
|
||||||
url = f"{self.BASE_URL}?{urlencode(params)}"
|
url = f"{self.BASE_URL}?{urlencode(params)}"
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
"User-Agent": "MeshAI/1.0",
|
"User-Agent": "MeshAI/1.0",
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
req = Request(url, headers=headers)
|
req = Request(url, headers=headers)
|
||||||
with urlopen(req, timeout=15) as resp:
|
with urlopen(req, timeout=15) as resp:
|
||||||
data = json.loads(resp.read().decode("utf-8"))
|
data = json.loads(resp.read().decode("utf-8"))
|
||||||
|
|
||||||
self._daily_requests += 1
|
self._daily_requests += 1
|
||||||
|
|
||||||
except HTTPError as e:
|
except HTTPError as e:
|
||||||
if e.code == 401 or e.code == 403:
|
if e.code == 401 or e.code == 403:
|
||||||
logger.error(f"TomTom auth error: {e.code} - check API key")
|
logger.error(f"TomTom auth error: {e.code} - check API key")
|
||||||
self._last_error = f"Auth error {e.code}"
|
self._last_error = f"Auth error {e.code}"
|
||||||
else:
|
else:
|
||||||
logger.warning(f"TomTom HTTP error for {name}: {e.code}")
|
logger.warning(f"TomTom HTTP error for {name}: {e.code}")
|
||||||
self._last_error = f"HTTP {e.code}"
|
self._last_error = f"HTTP {e.code}"
|
||||||
self._consecutive_errors += 1
|
self._consecutive_errors += 1
|
||||||
return None
|
return None
|
||||||
|
|
||||||
except URLError as e:
|
except URLError as e:
|
||||||
logger.warning(f"TomTom connection error for {name}: {e.reason}")
|
logger.warning(f"TomTom connection error for {name}: {e.reason}")
|
||||||
self._last_error = str(e.reason)
|
self._last_error = str(e.reason)
|
||||||
self._consecutive_errors += 1
|
self._consecutive_errors += 1
|
||||||
return None
|
return None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"TomTom fetch error for {name}: {e}")
|
logger.warning(f"TomTom fetch error for {name}: {e}")
|
||||||
self._last_error = str(e)
|
self._last_error = str(e)
|
||||||
self._consecutive_errors += 1
|
self._consecutive_errors += 1
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Parse response
|
# Parse response
|
||||||
try:
|
try:
|
||||||
flow = data.get("flowSegmentData", {})
|
flow = data.get("flowSegmentData", {})
|
||||||
current_speed = flow.get("currentSpeed", 0)
|
current_speed = flow.get("currentSpeed", 0)
|
||||||
free_flow_speed = flow.get("freeFlowSpeed", 0)
|
free_flow_speed = flow.get("freeFlowSpeed", 0)
|
||||||
current_time = flow.get("currentTravelTime", 0)
|
current_time = flow.get("currentTravelTime", 0)
|
||||||
free_flow_time = flow.get("freeFlowTravelTime", 0)
|
free_flow_time = flow.get("freeFlowTravelTime", 0)
|
||||||
confidence = flow.get("confidence", 0)
|
confidence = flow.get("confidence", 0)
|
||||||
road_closure = flow.get("roadClosure", False)
|
road_closure = flow.get("roadClosure", False)
|
||||||
|
|
||||||
# Calculate speed ratio for severity
|
# Calculate speed ratio for severity
|
||||||
if free_flow_speed > 0:
|
if free_flow_speed > 0:
|
||||||
ratio = current_speed / free_flow_speed
|
ratio = current_speed / free_flow_speed
|
||||||
else:
|
else:
|
||||||
ratio = 1.0
|
ratio = 1.0
|
||||||
|
|
||||||
# Determine severity
|
# Determine severity
|
||||||
if road_closure:
|
if road_closure:
|
||||||
severity = "warning"
|
severity = "priority"
|
||||||
elif ratio >= 0.8:
|
elif ratio >= 0.8:
|
||||||
severity = "info"
|
severity = "routine"
|
||||||
elif ratio >= 0.5:
|
elif ratio >= 0.5:
|
||||||
severity = "advisory"
|
severity = "routine"
|
||||||
else:
|
else:
|
||||||
severity = "warning"
|
severity = "priority"
|
||||||
|
|
||||||
# Format headline
|
# Format headline
|
||||||
if road_closure:
|
if road_closure:
|
||||||
headline = f"{name}: CLOSED"
|
headline = f"{name}: CLOSED"
|
||||||
else:
|
else:
|
||||||
pct = int(ratio * 100)
|
pct = int(ratio * 100)
|
||||||
headline = f"{name}: {int(current_speed)}mph ({pct}% of free flow)"
|
headline = f"{name}: {int(current_speed)}mph ({pct}% of free flow)"
|
||||||
|
|
||||||
event = {
|
event = {
|
||||||
"source": "traffic",
|
"source": "traffic",
|
||||||
"event_id": f"traffic_{name.replace(' ', '_').lower()}",
|
"event_id": f"traffic_{name.replace(' ', '_').lower()}",
|
||||||
"event_type": "Traffic Flow",
|
"event_type": "Traffic Flow",
|
||||||
"headline": headline,
|
"headline": headline,
|
||||||
"severity": severity,
|
"severity": severity,
|
||||||
"lat": lat,
|
"lat": lat,
|
||||||
"lon": lon,
|
"lon": lon,
|
||||||
"expires": now + 600, # 10 min TTL
|
"expires": now + 600, # 10 min TTL
|
||||||
"fetched_at": now,
|
"fetched_at": now,
|
||||||
"properties": {
|
"properties": {
|
||||||
"corridor": name,
|
"corridor": name,
|
||||||
"currentSpeed": current_speed,
|
"currentSpeed": current_speed,
|
||||||
"freeFlowSpeed": free_flow_speed,
|
"freeFlowSpeed": free_flow_speed,
|
||||||
"speedRatio": ratio,
|
"speedRatio": ratio,
|
||||||
"currentTravelTime": current_time,
|
"currentTravelTime": current_time,
|
||||||
"freeFlowTravelTime": free_flow_time,
|
"freeFlowTravelTime": free_flow_time,
|
||||||
"confidence": confidence,
|
"confidence": confidence,
|
||||||
"roadClosure": road_closure,
|
"roadClosure": road_closure,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return event
|
return event
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"TomTom parse error for {name}: {e}")
|
logger.warning(f"TomTom parse error for {name}: {e}")
|
||||||
self._last_error = f"Parse error: {e}"
|
self._last_error = f"Parse error: {e}"
|
||||||
self._consecutive_errors += 1
|
self._consecutive_errors += 1
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_events(self) -> list:
|
def get_events(self) -> list:
|
||||||
"""Get current traffic events."""
|
"""Get current traffic events."""
|
||||||
return self._events
|
return self._events
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def health_status(self) -> dict:
|
def health_status(self) -> dict:
|
||||||
"""Get adapter health status."""
|
"""Get adapter health status."""
|
||||||
return {
|
return {
|
||||||
"source": "traffic",
|
"source": "traffic",
|
||||||
"is_loaded": self._is_loaded,
|
"is_loaded": self._is_loaded,
|
||||||
"last_error": str(self._last_error) if self._last_error else None,
|
"last_error": str(self._last_error) if self._last_error else None,
|
||||||
"consecutive_errors": self._consecutive_errors,
|
"consecutive_errors": self._consecutive_errors,
|
||||||
"event_count": len(self._events),
|
"event_count": len(self._events),
|
||||||
"last_fetch": self._last_tick,
|
"last_fetch": self._last_tick,
|
||||||
"corridor_count": len(self._corridors),
|
"corridor_count": len(self._corridors),
|
||||||
"daily_requests": self._daily_requests,
|
"daily_requests": self._daily_requests,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
906
meshai/env/usgs.py
vendored
906
meshai/env/usgs.py
vendored
|
|
@ -1,453 +1,453 @@
|
||||||
"""USGS Water Services stream gauge adapter with NWS flood stage auto-lookup.
|
"""USGS Water Services stream gauge adapter with NWS flood stage auto-lookup.
|
||||||
|
|
||||||
# TODO: Migrate to api.waterdata.usgs.gov OGC API before Q1 2027
|
# TODO: Migrate to api.waterdata.usgs.gov OGC API before Q1 2027
|
||||||
# Legacy waterservices.usgs.gov will be decommissioned.
|
# Legacy waterservices.usgs.gov will be decommissioned.
|
||||||
# See: https://www.usgs.gov/tools/usgs-water-data-apis
|
# See: https://www.usgs.gov/tools/usgs-water-data-apis
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from typing import TYPE_CHECKING, Optional
|
from typing import TYPE_CHECKING, Optional
|
||||||
from urllib.error import HTTPError, URLError
|
from urllib.error import HTTPError, URLError
|
||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request, urlopen
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..config import USGSConfig
|
from ..config import USGSConfig
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Minimum tick interval per USGS guidelines (do not fetch same data more than hourly)
|
# Minimum tick interval per USGS guidelines (do not fetch same data more than hourly)
|
||||||
MIN_TICK_SECONDS = 900 # 15 minutes
|
MIN_TICK_SECONDS = 900 # 15 minutes
|
||||||
|
|
||||||
# Cache for NWS flood stages (rarely change)
|
# Cache for NWS flood stages (rarely change)
|
||||||
_nwps_cache: dict[str, dict] = {}
|
_nwps_cache: dict[str, dict] = {}
|
||||||
_nwps_cache_time: dict[str, float] = {}
|
_nwps_cache_time: dict[str, float] = {}
|
||||||
NWPS_CACHE_TTL = 86400 * 7 # 7 days
|
NWPS_CACHE_TTL = 86400 * 7 # 7 days
|
||||||
|
|
||||||
|
|
||||||
class USGSStreamsAdapter:
|
class USGSStreamsAdapter:
|
||||||
"""USGS instantaneous values for stream gauge readings with NWS flood stages."""
|
"""USGS instantaneous values for stream gauge readings with NWS flood stages."""
|
||||||
|
|
||||||
BASE_URL = "https://waterservices.usgs.gov/nwis/iv/"
|
BASE_URL = "https://waterservices.usgs.gov/nwis/iv/"
|
||||||
NWPS_BASE_URL = "https://api.water.noaa.gov/nwps/v1/gauges"
|
NWPS_BASE_URL = "https://api.water.noaa.gov/nwps/v1/gauges"
|
||||||
|
|
||||||
def __init__(self, config: "USGSConfig"):
|
def __init__(self, config: "USGSConfig"):
|
||||||
self._sites = config.sites or []
|
self._sites = config.sites or []
|
||||||
self._tick_interval = max(config.tick_seconds or 900, MIN_TICK_SECONDS)
|
self._tick_interval = max(config.tick_seconds or 900, MIN_TICK_SECONDS)
|
||||||
self._flood_thresholds = getattr(config, "flood_thresholds", {}) or {}
|
self._flood_thresholds = getattr(config, "flood_thresholds", {}) or {}
|
||||||
self._last_tick = 0.0
|
self._last_tick = 0.0
|
||||||
self._events = []
|
self._events = []
|
||||||
self._consecutive_errors = 0
|
self._consecutive_errors = 0
|
||||||
self._last_error = None
|
self._last_error = None
|
||||||
self._is_loaded = False
|
self._is_loaded = False
|
||||||
|
|
||||||
# Site metadata cache (name, flood stages from NWPS)
|
# Site metadata cache (name, flood stages from NWPS)
|
||||||
self._site_metadata: dict[str, dict] = {}
|
self._site_metadata: dict[str, dict] = {}
|
||||||
|
|
||||||
if self._tick_interval < MIN_TICK_SECONDS:
|
if self._tick_interval < MIN_TICK_SECONDS:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"USGS tick_seconds {config.tick_seconds} below minimum, using {MIN_TICK_SECONDS}"
|
f"USGS tick_seconds {config.tick_seconds} below minimum, using {MIN_TICK_SECONDS}"
|
||||||
)
|
)
|
||||||
|
|
||||||
def tick(self) -> bool:
|
def tick(self) -> bool:
|
||||||
"""Execute one polling tick.
|
"""Execute one polling tick.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if data changed
|
True if data changed
|
||||||
"""
|
"""
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|
||||||
# No sites configured
|
# No sites configured
|
||||||
if not self._sites:
|
if not self._sites:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Check tick interval
|
# Check tick interval
|
||||||
if now - self._last_tick < self._tick_interval:
|
if now - self._last_tick < self._tick_interval:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
self._last_tick = now
|
self._last_tick = now
|
||||||
return self._fetch()
|
return self._fetch()
|
||||||
|
|
||||||
def _get_site_ids(self) -> list[str]:
|
def _get_site_ids(self) -> list[str]:
|
||||||
"""Extract site IDs from config (handles both string and dict formats)."""
|
"""Extract site IDs from config (handles both string and dict formats)."""
|
||||||
site_ids = []
|
site_ids = []
|
||||||
for site in self._sites:
|
for site in self._sites:
|
||||||
if isinstance(site, str):
|
if isinstance(site, str):
|
||||||
site_ids.append(site)
|
site_ids.append(site)
|
||||||
elif isinstance(site, dict):
|
elif isinstance(site, dict):
|
||||||
site_ids.append(site.get("id", ""))
|
site_ids.append(site.get("id", ""))
|
||||||
elif hasattr(site, "id"):
|
elif hasattr(site, "id"):
|
||||||
site_ids.append(site.id)
|
site_ids.append(site.id)
|
||||||
return [s for s in site_ids if s]
|
return [s for s in site_ids if s]
|
||||||
|
|
||||||
def _lookup_nwps_stages(self, usgs_site_id: str) -> Optional[dict]:
|
def _lookup_nwps_stages(self, usgs_site_id: str) -> Optional[dict]:
|
||||||
"""Lookup flood stages from NWS National Water Prediction Service.
|
"""Lookup flood stages from NWS National Water Prediction Service.
|
||||||
|
|
||||||
The NWPS API uses NWS gauge IDs which may differ from USGS site IDs.
|
The NWPS API uses NWS gauge IDs which may differ from USGS site IDs.
|
||||||
We try a mapping lookup first, then fall back to direct lookup.
|
We try a mapping lookup first, then fall back to direct lookup.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict with action_stage, flood_stage, moderate_flood_stage, major_flood_stage
|
dict with action_stage, flood_stage, moderate_flood_stage, major_flood_stage
|
||||||
or None if not available
|
or None if not available
|
||||||
"""
|
"""
|
||||||
global _nwps_cache, _nwps_cache_time
|
global _nwps_cache, _nwps_cache_time
|
||||||
|
|
||||||
# Check cache
|
# Check cache
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if usgs_site_id in _nwps_cache:
|
if usgs_site_id in _nwps_cache:
|
||||||
if now - _nwps_cache_time.get(usgs_site_id, 0) < NWPS_CACHE_TTL:
|
if now - _nwps_cache_time.get(usgs_site_id, 0) < NWPS_CACHE_TTL:
|
||||||
return _nwps_cache[usgs_site_id]
|
return _nwps_cache[usgs_site_id]
|
||||||
|
|
||||||
# Try to find NWS gauge ID from USGS site ID
|
# Try to find NWS gauge ID from USGS site ID
|
||||||
# First, query USGS site info to get the NWS ID crosswalk
|
# First, query USGS site info to get the NWS ID crosswalk
|
||||||
nws_gauge_id = self._usgs_to_nws_crosswalk(usgs_site_id)
|
nws_gauge_id = self._usgs_to_nws_crosswalk(usgs_site_id)
|
||||||
if not nws_gauge_id:
|
if not nws_gauge_id:
|
||||||
# Fall back to using USGS ID directly (sometimes they match)
|
# Fall back to using USGS ID directly (sometimes they match)
|
||||||
nws_gauge_id = usgs_site_id
|
nws_gauge_id = usgs_site_id
|
||||||
|
|
||||||
# Query NWPS for flood stages
|
# Query NWPS for flood stages
|
||||||
url = f"{self.NWPS_BASE_URL}/{nws_gauge_id}"
|
url = f"{self.NWPS_BASE_URL}/{nws_gauge_id}"
|
||||||
headers = {
|
headers = {
|
||||||
"User-Agent": "MeshAI/1.0 (stream gauge monitoring)",
|
"User-Agent": "MeshAI/1.0 (stream gauge monitoring)",
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
req = Request(url, headers=headers)
|
req = Request(url, headers=headers)
|
||||||
with urlopen(req, timeout=15) as resp:
|
with urlopen(req, timeout=15) as resp:
|
||||||
data = json.loads(resp.read().decode("utf-8"))
|
data = json.loads(resp.read().decode("utf-8"))
|
||||||
|
|
||||||
# Extract flood stages
|
# Extract flood stages
|
||||||
stages = {}
|
stages = {}
|
||||||
flood_info = data.get("flood", {})
|
flood_info = data.get("flood", {})
|
||||||
|
|
||||||
if "action" in flood_info:
|
if "action" in flood_info:
|
||||||
stages["action_stage"] = flood_info["action"].get("stage")
|
stages["action_stage"] = flood_info["action"].get("stage")
|
||||||
if "minor" in flood_info:
|
if "minor" in flood_info:
|
||||||
stages["flood_stage"] = flood_info["minor"].get("stage")
|
stages["flood_stage"] = flood_info["minor"].get("stage")
|
||||||
if "moderate" in flood_info:
|
if "moderate" in flood_info:
|
||||||
stages["moderate_flood_stage"] = flood_info["moderate"].get("stage")
|
stages["moderate_flood_stage"] = flood_info["moderate"].get("stage")
|
||||||
if "major" in flood_info:
|
if "major" in flood_info:
|
||||||
stages["major_flood_stage"] = flood_info["major"].get("stage")
|
stages["major_flood_stage"] = flood_info["major"].get("stage")
|
||||||
|
|
||||||
# Also grab the official name if available
|
# Also grab the official name if available
|
||||||
stages["nws_name"] = data.get("name", "")
|
stages["nws_name"] = data.get("name", "")
|
||||||
stages["nws_gauge_id"] = nws_gauge_id
|
stages["nws_gauge_id"] = nws_gauge_id
|
||||||
|
|
||||||
# Cache result
|
# Cache result
|
||||||
_nwps_cache[usgs_site_id] = stages
|
_nwps_cache[usgs_site_id] = stages
|
||||||
_nwps_cache_time[usgs_site_id] = now
|
_nwps_cache_time[usgs_site_id] = now
|
||||||
|
|
||||||
logger.info(f"NWPS flood stages for {usgs_site_id}: {stages}")
|
logger.info(f"NWPS flood stages for {usgs_site_id}: {stages}")
|
||||||
return stages
|
return stages
|
||||||
|
|
||||||
except HTTPError as e:
|
except HTTPError as e:
|
||||||
if e.code == 404:
|
if e.code == 404:
|
||||||
# No NWPS data for this gauge - cache the miss
|
# No NWPS data for this gauge - cache the miss
|
||||||
_nwps_cache[usgs_site_id] = {}
|
_nwps_cache[usgs_site_id] = {}
|
||||||
_nwps_cache_time[usgs_site_id] = now
|
_nwps_cache_time[usgs_site_id] = now
|
||||||
logger.debug(f"No NWPS data for gauge {usgs_site_id}")
|
logger.debug(f"No NWPS data for gauge {usgs_site_id}")
|
||||||
else:
|
else:
|
||||||
logger.warning(f"NWPS lookup failed for {usgs_site_id}: HTTP {e.code}")
|
logger.warning(f"NWPS lookup failed for {usgs_site_id}: HTTP {e.code}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"NWPS lookup error for {usgs_site_id}: {e}")
|
logger.warning(f"NWPS lookup error for {usgs_site_id}: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _usgs_to_nws_crosswalk(self, usgs_site_id: str) -> Optional[str]:
|
def _usgs_to_nws_crosswalk(self, usgs_site_id: str) -> Optional[str]:
|
||||||
"""Try to find NWS gauge ID from USGS site ID.
|
"""Try to find NWS gauge ID from USGS site ID.
|
||||||
|
|
||||||
The USGS provides a crosswalk in their site metadata, but it's not
|
The USGS provides a crosswalk in their site metadata, but it's not
|
||||||
always populated. This is a best-effort lookup.
|
always populated. This is a best-effort lookup.
|
||||||
"""
|
"""
|
||||||
# Try USGS site service for metadata including NWS ID
|
# Try USGS site service for metadata including NWS ID
|
||||||
url = f"https://waterservices.usgs.gov/nwis/site/?format=rdb&sites={usgs_site_id}&siteOutput=expanded"
|
url = f"https://waterservices.usgs.gov/nwis/site/?format=rdb&sites={usgs_site_id}&siteOutput=expanded"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
req = Request(url, headers={"User-Agent": "MeshAI/1.0"})
|
req = Request(url, headers={"User-Agent": "MeshAI/1.0"})
|
||||||
with urlopen(req, timeout=10) as resp:
|
with urlopen(req, timeout=10) as resp:
|
||||||
content = resp.read().decode("utf-8")
|
content = resp.read().decode("utf-8")
|
||||||
|
|
||||||
# Parse RDB format - look for NWS ID in the data
|
# Parse RDB format - look for NWS ID in the data
|
||||||
# This is a simplified parser; full implementation would be more robust
|
# This is a simplified parser; full implementation would be more robust
|
||||||
for line in content.split("\n"):
|
for line in content.split("\n"):
|
||||||
if line.startswith(usgs_site_id):
|
if line.startswith(usgs_site_id):
|
||||||
# NWS station ID is typically in column ~30ish
|
# NWS station ID is typically in column ~30ish
|
||||||
# This varies by USGS response format
|
# This varies by USGS response format
|
||||||
pass
|
pass
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def lookup_site(self, site_id: str) -> dict:
|
def lookup_site(self, site_id: str) -> dict:
|
||||||
"""Lookup site metadata for config UI auto-populate.
|
"""Lookup site metadata for config UI auto-populate.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
{
|
{
|
||||||
"site_id": "13090500",
|
"site_id": "13090500",
|
||||||
"name": "Snake River nr Twin Falls ID",
|
"name": "Snake River nr Twin Falls ID",
|
||||||
"lat": 42.xxx,
|
"lat": 42.xxx,
|
||||||
"lon": -114.xxx,
|
"lon": -114.xxx,
|
||||||
"flood_stages": {
|
"flood_stages": {
|
||||||
"action_stage": 9.0,
|
"action_stage": 9.0,
|
||||||
"flood_stage": 10.5,
|
"flood_stage": 10.5,
|
||||||
"moderate_flood_stage": 12.0,
|
"moderate_flood_stage": 12.0,
|
||||||
"major_flood_stage": 14.0,
|
"major_flood_stage": 14.0,
|
||||||
} or None
|
} or None
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
result = {"site_id": site_id, "name": None, "lat": None, "lon": None, "flood_stages": None}
|
result = {"site_id": site_id, "name": None, "lat": None, "lon": None, "flood_stages": None}
|
||||||
|
|
||||||
# Get USGS site info
|
# Get USGS site info
|
||||||
params = {
|
params = {
|
||||||
"format": "json",
|
"format": "json",
|
||||||
"sites": site_id,
|
"sites": site_id,
|
||||||
"siteOutput": "expanded",
|
"siteOutput": "expanded",
|
||||||
}
|
}
|
||||||
url = f"https://waterservices.usgs.gov/nwis/site/?{urlencode(params)}"
|
url = f"https://waterservices.usgs.gov/nwis/site/?{urlencode(params)}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
req = Request(url, headers={"User-Agent": "MeshAI/1.0", "Accept": "application/json"})
|
req = Request(url, headers={"User-Agent": "MeshAI/1.0", "Accept": "application/json"})
|
||||||
with urlopen(req, timeout=15) as resp:
|
with urlopen(req, timeout=15) as resp:
|
||||||
data = json.loads(resp.read().decode("utf-8"))
|
data = json.loads(resp.read().decode("utf-8"))
|
||||||
|
|
||||||
sites = data.get("value", {}).get("timeSeries", [])
|
sites = data.get("value", {}).get("timeSeries", [])
|
||||||
if not sites:
|
if not sites:
|
||||||
# Try alternate format
|
# Try alternate format
|
||||||
sites_list = data.get("value", {}).get("sites", [])
|
sites_list = data.get("value", {}).get("sites", [])
|
||||||
if sites_list:
|
if sites_list:
|
||||||
site_info = sites_list[0]
|
site_info = sites_list[0]
|
||||||
result["name"] = site_info.get("siteName", "")
|
result["name"] = site_info.get("siteName", "")
|
||||||
result["lat"] = site_info.get("geoLocation", {}).get("geogLocation", {}).get("latitude")
|
result["lat"] = site_info.get("geoLocation", {}).get("geogLocation", {}).get("latitude")
|
||||||
result["lon"] = site_info.get("geoLocation", {}).get("geogLocation", {}).get("longitude")
|
result["lon"] = site_info.get("geoLocation", {}).get("geogLocation", {}).get("longitude")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"USGS site lookup failed for {site_id}: {e}")
|
logger.warning(f"USGS site lookup failed for {site_id}: {e}")
|
||||||
|
|
||||||
# Get NWS flood stages
|
# Get NWS flood stages
|
||||||
stages = self._lookup_nwps_stages(site_id)
|
stages = self._lookup_nwps_stages(site_id)
|
||||||
if stages:
|
if stages:
|
||||||
result["flood_stages"] = {
|
result["flood_stages"] = {
|
||||||
"action_stage": stages.get("action_stage"),
|
"action_stage": stages.get("action_stage"),
|
||||||
"flood_stage": stages.get("flood_stage"),
|
"flood_stage": stages.get("flood_stage"),
|
||||||
"moderate_flood_stage": stages.get("moderate_flood_stage"),
|
"moderate_flood_stage": stages.get("moderate_flood_stage"),
|
||||||
"major_flood_stage": stages.get("major_flood_stage"),
|
"major_flood_stage": stages.get("major_flood_stage"),
|
||||||
}
|
}
|
||||||
if stages.get("nws_name") and not result["name"]:
|
if stages.get("nws_name") and not result["name"]:
|
||||||
result["name"] = stages["nws_name"]
|
result["name"] = stages["nws_name"]
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _fetch(self) -> bool:
|
def _fetch(self) -> bool:
|
||||||
"""Fetch instantaneous values from USGS Water Services.
|
"""Fetch instantaneous values from USGS Water Services.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if data changed
|
True if data changed
|
||||||
"""
|
"""
|
||||||
site_ids = self._get_site_ids()
|
site_ids = self._get_site_ids()
|
||||||
if not site_ids:
|
if not site_ids:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
params = {
|
params = {
|
||||||
"format": "json",
|
"format": "json",
|
||||||
"sites": ",".join(site_ids),
|
"sites": ",".join(site_ids),
|
||||||
"parameterCd": "00060,00065", # Streamflow (cfs) and Gage height (ft)
|
"parameterCd": "00060,00065", # Streamflow (cfs) and Gage height (ft)
|
||||||
"siteStatus": "active",
|
"siteStatus": "active",
|
||||||
}
|
}
|
||||||
|
|
||||||
url = f"{self.BASE_URL}?{urlencode(params)}"
|
url = f"{self.BASE_URL}?{urlencode(params)}"
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
"User-Agent": "MeshAI/1.0 (stream gauge monitoring)",
|
"User-Agent": "MeshAI/1.0 (stream gauge monitoring)",
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
req = Request(url, headers=headers)
|
req = Request(url, headers=headers)
|
||||||
with urlopen(req, timeout=30) as resp:
|
with urlopen(req, timeout=30) as resp:
|
||||||
data = json.loads(resp.read().decode("utf-8"))
|
data = json.loads(resp.read().decode("utf-8"))
|
||||||
|
|
||||||
except HTTPError as e:
|
except HTTPError as e:
|
||||||
logger.warning(f"USGS HTTP error: {e.code}")
|
logger.warning(f"USGS HTTP error: {e.code}")
|
||||||
self._last_error = f"HTTP {e.code}"
|
self._last_error = f"HTTP {e.code}"
|
||||||
self._consecutive_errors += 1
|
self._consecutive_errors += 1
|
||||||
return False
|
return False
|
||||||
|
|
||||||
except URLError as e:
|
except URLError as e:
|
||||||
logger.warning(f"USGS connection error: {e.reason}")
|
logger.warning(f"USGS connection error: {e.reason}")
|
||||||
self._last_error = str(e.reason)
|
self._last_error = str(e.reason)
|
||||||
self._consecutive_errors += 1
|
self._consecutive_errors += 1
|
||||||
return False
|
return False
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"USGS fetch error: {e}")
|
logger.warning(f"USGS fetch error: {e}")
|
||||||
self._last_error = str(e)
|
self._last_error = str(e)
|
||||||
self._consecutive_errors += 1
|
self._consecutive_errors += 1
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Parse response
|
# Parse response
|
||||||
new_events = []
|
new_events = []
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
time_series = data.get("value", {}).get("timeSeries", [])
|
time_series = data.get("value", {}).get("timeSeries", [])
|
||||||
|
|
||||||
for ts in time_series:
|
for ts in time_series:
|
||||||
source_info = ts.get("sourceInfo", {})
|
source_info = ts.get("sourceInfo", {})
|
||||||
variable = ts.get("variable", {})
|
variable = ts.get("variable", {})
|
||||||
values_list = ts.get("values", [])
|
values_list = ts.get("values", [])
|
||||||
|
|
||||||
# Extract site info
|
# Extract site info
|
||||||
site_name = source_info.get("siteName", "Unknown Site")
|
site_name = source_info.get("siteName", "Unknown Site")
|
||||||
site_codes = source_info.get("siteCode", [])
|
site_codes = source_info.get("siteCode", [])
|
||||||
site_id = site_codes[0].get("value", "") if site_codes else ""
|
site_id = site_codes[0].get("value", "") if site_codes else ""
|
||||||
|
|
||||||
# Cache site name
|
# Cache site name
|
||||||
if site_id and site_id not in self._site_metadata:
|
if site_id and site_id not in self._site_metadata:
|
||||||
self._site_metadata[site_id] = {"name": site_name}
|
self._site_metadata[site_id] = {"name": site_name}
|
||||||
|
|
||||||
# Extract location
|
# Extract location
|
||||||
geo_loc = source_info.get("geoLocation", {}).get("geogLocation", {})
|
geo_loc = source_info.get("geoLocation", {}).get("geogLocation", {})
|
||||||
lat = geo_loc.get("latitude")
|
lat = geo_loc.get("latitude")
|
||||||
lon = geo_loc.get("longitude")
|
lon = geo_loc.get("longitude")
|
||||||
|
|
||||||
# Extract variable info
|
# Extract variable info
|
||||||
var_name = variable.get("variableName", "Unknown")
|
var_name = variable.get("variableName", "Unknown")
|
||||||
unit_info = variable.get("unit", {})
|
unit_info = variable.get("unit", {})
|
||||||
unit_code = unit_info.get("unitCode", "")
|
unit_code = unit_info.get("unitCode", "")
|
||||||
|
|
||||||
# Determine parameter type
|
# Determine parameter type
|
||||||
if "Streamflow" in var_name or "00060" in str(variable.get("variableCode", [])):
|
if "Streamflow" in var_name or "00060" in str(variable.get("variableCode", [])):
|
||||||
param_type = "flow"
|
param_type = "flow"
|
||||||
param_name = "Streamflow"
|
param_name = "Streamflow"
|
||||||
elif "Gage height" in var_name or "00065" in str(variable.get("variableCode", [])):
|
elif "Gage height" in var_name or "00065" in str(variable.get("variableCode", [])):
|
||||||
param_type = "height"
|
param_type = "height"
|
||||||
param_name = "Gage height"
|
param_name = "Gage height"
|
||||||
else:
|
else:
|
||||||
param_type = "other"
|
param_type = "other"
|
||||||
param_name = var_name
|
param_name = var_name
|
||||||
|
|
||||||
# Get current value (most recent)
|
# Get current value (most recent)
|
||||||
if not values_list or not values_list[0].get("value"):
|
if not values_list or not values_list[0].get("value"):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
value_entries = values_list[0].get("value", [])
|
value_entries = values_list[0].get("value", [])
|
||||||
if not value_entries:
|
if not value_entries:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
latest = value_entries[-1]
|
latest = value_entries[-1]
|
||||||
value_str = latest.get("value", "")
|
value_str = latest.get("value", "")
|
||||||
timestamp_str = latest.get("dateTime", "")
|
timestamp_str = latest.get("dateTime", "")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
value = float(value_str)
|
value = float(value_str)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Get flood stages for this site
|
# Get flood stages for this site
|
||||||
nwps_stages = self._lookup_nwps_stages(site_id)
|
nwps_stages = self._lookup_nwps_stages(site_id)
|
||||||
|
|
||||||
# Determine severity based on flood stages (for gage height)
|
# Determine severity based on flood stages (for gage height)
|
||||||
severity = "info"
|
severity = "routine"
|
||||||
flood_status = None
|
flood_status = None
|
||||||
|
|
||||||
if param_type == "height" and nwps_stages:
|
if param_type == "height" and nwps_stages:
|
||||||
major = nwps_stages.get("major_flood_stage")
|
major = nwps_stages.get("major_flood_stage")
|
||||||
moderate = nwps_stages.get("moderate_flood_stage")
|
moderate = nwps_stages.get("moderate_flood_stage")
|
||||||
minor = nwps_stages.get("flood_stage")
|
minor = nwps_stages.get("flood_stage")
|
||||||
action = nwps_stages.get("action_stage")
|
action = nwps_stages.get("action_stage")
|
||||||
|
|
||||||
if major and value >= major:
|
if major and value >= major:
|
||||||
severity = "critical"
|
severity = "immediate"
|
||||||
flood_status = "Major Flood"
|
flood_status = "Major Flood"
|
||||||
elif moderate and value >= moderate:
|
elif moderate and value >= moderate:
|
||||||
severity = "warning"
|
severity = "priority"
|
||||||
flood_status = "Moderate Flood"
|
flood_status = "Moderate Flood"
|
||||||
elif minor and value >= minor:
|
elif minor and value >= minor:
|
||||||
severity = "warning"
|
severity = "priority"
|
||||||
flood_status = "Minor Flood"
|
flood_status = "Minor Flood"
|
||||||
elif action and value >= action:
|
elif action and value >= action:
|
||||||
severity = "advisory"
|
severity = "routine"
|
||||||
flood_status = "Action Stage"
|
flood_status = "Action Stage"
|
||||||
|
|
||||||
# Fall back to legacy manual thresholds
|
# Fall back to legacy manual thresholds
|
||||||
if severity == "info":
|
if severity == "info":
|
||||||
threshold = self._flood_thresholds.get(site_id, {}).get(param_type)
|
threshold = self._flood_thresholds.get(site_id, {}).get(param_type)
|
||||||
if threshold and value > threshold:
|
if threshold and value > threshold:
|
||||||
severity = "warning"
|
severity = "priority"
|
||||||
|
|
||||||
# Format headline
|
# Format headline
|
||||||
if param_type == "flow":
|
if param_type == "flow":
|
||||||
headline = f"{site_name}: {value:,.0f} {unit_code}"
|
headline = f"{site_name}: {value:,.0f} {unit_code}"
|
||||||
else:
|
else:
|
||||||
headline = f"{site_name}: {value:.1f} {unit_code}"
|
headline = f"{site_name}: {value:.1f} {unit_code}"
|
||||||
|
|
||||||
if flood_status:
|
if flood_status:
|
||||||
headline += f" — {flood_status}"
|
headline += f" — {flood_status}"
|
||||||
|
|
||||||
event = {
|
event = {
|
||||||
"source": "usgs",
|
"source": "usgs",
|
||||||
"event_id": f"{site_id}_{param_type}",
|
"event_id": f"{site_id}_{param_type}",
|
||||||
"event_type": "Stream Gauge",
|
"event_type": "Stream Gauge",
|
||||||
"headline": headline,
|
"headline": headline,
|
||||||
"severity": severity,
|
"severity": severity,
|
||||||
"lat": lat,
|
"lat": lat,
|
||||||
"lon": lon,
|
"lon": lon,
|
||||||
"expires": now + 1800, # 30 min TTL
|
"expires": now + 1800, # 30 min TTL
|
||||||
"fetched_at": now,
|
"fetched_at": now,
|
||||||
"properties": {
|
"properties": {
|
||||||
"site_id": site_id,
|
"site_id": site_id,
|
||||||
"site_name": site_name,
|
"site_name": site_name,
|
||||||
"parameter": param_name,
|
"parameter": param_name,
|
||||||
"value": value,
|
"value": value,
|
||||||
"unit": unit_code,
|
"unit": unit_code,
|
||||||
"timestamp": timestamp_str,
|
"timestamp": timestamp_str,
|
||||||
"flood_status": flood_status,
|
"flood_status": flood_status,
|
||||||
"flood_stages": nwps_stages if nwps_stages else None,
|
"flood_stages": nwps_stages if nwps_stages else None,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
new_events.append(event)
|
new_events.append(event)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"USGS parse error: {e}")
|
logger.warning(f"USGS parse error: {e}")
|
||||||
self._last_error = f"Parse error: {e}"
|
self._last_error = f"Parse error: {e}"
|
||||||
self._consecutive_errors += 1
|
self._consecutive_errors += 1
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Check if data changed
|
# Check if data changed
|
||||||
old_ids = {e["event_id"] for e in self._events}
|
old_ids = {e["event_id"] for e in self._events}
|
||||||
new_ids = {e["event_id"] for e in new_events}
|
new_ids = {e["event_id"] for e in new_events}
|
||||||
changed = old_ids != new_ids or len(self._events) != len(new_events)
|
changed = old_ids != new_ids or len(self._events) != len(new_events)
|
||||||
|
|
||||||
self._events = new_events
|
self._events = new_events
|
||||||
self._consecutive_errors = 0
|
self._consecutive_errors = 0
|
||||||
self._last_error = None
|
self._last_error = None
|
||||||
self._is_loaded = True
|
self._is_loaded = True
|
||||||
|
|
||||||
if changed:
|
if changed:
|
||||||
logger.info(f"USGS streams updated: {len(new_events)} readings from {len(site_ids)} sites")
|
logger.info(f"USGS streams updated: {len(new_events)} readings from {len(site_ids)} sites")
|
||||||
|
|
||||||
return changed
|
return changed
|
||||||
|
|
||||||
def get_events(self) -> list:
|
def get_events(self) -> list:
|
||||||
"""Get current stream gauge events."""
|
"""Get current stream gauge events."""
|
||||||
return self._events
|
return self._events
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def health_status(self) -> dict:
|
def health_status(self) -> dict:
|
||||||
"""Get adapter health status."""
|
"""Get adapter health status."""
|
||||||
return {
|
return {
|
||||||
"source": "usgs",
|
"source": "usgs",
|
||||||
"is_loaded": self._is_loaded,
|
"is_loaded": self._is_loaded,
|
||||||
"last_error": str(self._last_error) if self._last_error else None,
|
"last_error": str(self._last_error) if self._last_error else None,
|
||||||
"consecutive_errors": self._consecutive_errors,
|
"consecutive_errors": self._consecutive_errors,
|
||||||
"event_count": len(self._events),
|
"event_count": len(self._events),
|
||||||
"last_fetch": self._last_tick,
|
"last_fetch": self._last_tick,
|
||||||
"site_count": len(self._get_site_ids()),
|
"site_count": len(self._get_site_ids()),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,11 @@
|
||||||
|
|
||||||
Defines all alertable conditions with human-readable names, descriptions,
|
Defines all alertable conditions with human-readable names, descriptions,
|
||||||
and example messages showing what users will receive.
|
and example messages showing what users will receive.
|
||||||
|
|
||||||
|
Severity levels (military/intelligence precedence):
|
||||||
|
routine - Informational, no time pressure
|
||||||
|
priority - Needs attention soon
|
||||||
|
immediate - Act now, drop everything
|
||||||
"""
|
"""
|
||||||
|
|
||||||
ALERT_CATEGORIES = {
|
ALERT_CATEGORIES = {
|
||||||
|
|
@ -9,25 +14,25 @@ ALERT_CATEGORIES = {
|
||||||
"infra_offline": {
|
"infra_offline": {
|
||||||
"name": "Infrastructure Node Offline",
|
"name": "Infrastructure Node Offline",
|
||||||
"description": "An infrastructure node (router/repeater) stopped responding",
|
"description": "An infrastructure node (router/repeater) stopped responding",
|
||||||
"default_severity": "warning",
|
"default_severity": "priority",
|
||||||
"example_message": "⚠ Infrastructure Offline: MHR — Mountain Harrison Rptr has not been heard for 2 hours",
|
"example_message": "⚠ Infrastructure Offline: MHR — Mountain Harrison Rptr has not been heard for 2 hours",
|
||||||
},
|
},
|
||||||
"critical_node_down": {
|
"critical_node_down": {
|
||||||
"name": "Critical Node Down",
|
"name": "Critical Node Down",
|
||||||
"description": "A node you marked as critical went offline",
|
"description": "A node you marked as critical went offline",
|
||||||
"default_severity": "warning",
|
"default_severity": "immediate",
|
||||||
"example_message": "🚨 Critical Node Down: HPR — Hayden Peak Rptr offline for 1 hour",
|
"example_message": "🚨 Critical Node Down: HPR — Hayden Peak Rptr offline for 1 hour",
|
||||||
},
|
},
|
||||||
"infra_recovery": {
|
"infra_recovery": {
|
||||||
"name": "Infrastructure Recovery",
|
"name": "Infrastructure Recovery",
|
||||||
"description": "An offline infrastructure node came back online",
|
"description": "An offline infrastructure node came back online",
|
||||||
"default_severity": "info",
|
"default_severity": "routine",
|
||||||
"example_message": "✅ Recovery: MHR — Mountain Harrison Rptr back online after 2h outage",
|
"example_message": "✅ Recovery: MHR — Mountain Harrison Rptr back online after 2h outage",
|
||||||
},
|
},
|
||||||
"new_router": {
|
"new_router": {
|
||||||
"name": "New Router",
|
"name": "New Router",
|
||||||
"description": "A new router appeared on the mesh",
|
"description": "A new router appeared on the mesh",
|
||||||
"default_severity": "info",
|
"default_severity": "routine",
|
||||||
"example_message": "📡 New Router: Snake River Relay appeared in Wood River Valley",
|
"example_message": "📡 New Router: Snake River Relay appeared in Wood River Valley",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -35,37 +40,37 @@ ALERT_CATEGORIES = {
|
||||||
"battery_warning": {
|
"battery_warning": {
|
||||||
"name": "Battery Warning",
|
"name": "Battery Warning",
|
||||||
"description": "Infrastructure node battery below 30% (3.60V)",
|
"description": "Infrastructure node battery below 30% (3.60V)",
|
||||||
"default_severity": "advisory",
|
"default_severity": "routine",
|
||||||
"example_message": "🔋 Battery Warning: BLD-MTN at 28% (3.58V), solar not charging",
|
"example_message": "🔋 Battery Warning: BLD-MTN at 28% (3.58V), solar not charging",
|
||||||
},
|
},
|
||||||
"battery_critical": {
|
"battery_critical": {
|
||||||
"name": "Battery Critical",
|
"name": "Battery Critical",
|
||||||
"description": "Infrastructure node battery below 15% (3.50V)",
|
"description": "Infrastructure node battery below 15% (3.50V)",
|
||||||
"default_severity": "warning",
|
"default_severity": "priority",
|
||||||
"example_message": "🔋 Battery Critical: BLD-MTN at 12% (3.48V) — shutdown in hours",
|
"example_message": "🔋 Battery Critical: BLD-MTN at 12% (3.48V) — shutdown in hours",
|
||||||
},
|
},
|
||||||
"battery_emergency": {
|
"battery_emergency": {
|
||||||
"name": "Battery Emergency",
|
"name": "Battery Emergency",
|
||||||
"description": "Infrastructure node battery below 5% (3.40V) — shutdown imminent",
|
"description": "Infrastructure node battery below 5% (3.40V) — shutdown imminent",
|
||||||
"default_severity": "critical",
|
"default_severity": "immediate",
|
||||||
"example_message": "🚨 Battery Emergency: BLD-MTN at 4% (3.38V) — shutdown imminent",
|
"example_message": "🚨 Battery Emergency: BLD-MTN at 4% (3.38V) — shutdown imminent",
|
||||||
},
|
},
|
||||||
"battery_trend": {
|
"battery_trend": {
|
||||||
"name": "Battery Declining",
|
"name": "Battery Declining",
|
||||||
"description": "Battery showing declining trend over 7 days — possible solar or charging issue",
|
"description": "Battery showing declining trend over 7 days — possible solar or charging issue",
|
||||||
"default_severity": "advisory",
|
"default_severity": "routine",
|
||||||
"example_message": "🔋 Battery Trend: HPR declining 85% → 62% over 7 days (-3.3%/day)",
|
"example_message": "🔋 Battery Trend: HPR declining 85% → 62% over 7 days (-3.3%/day)",
|
||||||
},
|
},
|
||||||
"power_source_change": {
|
"power_source_change": {
|
||||||
"name": "Power Source Change",
|
"name": "Power Source Change",
|
||||||
"description": "Node switched from USB to battery — possible power outage at site",
|
"description": "Node switched from USB to battery — possible power outage at site",
|
||||||
"default_severity": "warning",
|
"default_severity": "priority",
|
||||||
"example_message": "⚡ Power Source: MHR switched from USB to battery — possible outage",
|
"example_message": "⚡ Power Source: MHR switched from USB to battery — possible outage",
|
||||||
},
|
},
|
||||||
"solar_not_charging": {
|
"solar_not_charging": {
|
||||||
"name": "Solar Not Charging",
|
"name": "Solar Not Charging",
|
||||||
"description": "Solar panel not charging during daylight hours — panel issue or obstruction",
|
"description": "Solar panel not charging during daylight hours — panel issue or obstruction",
|
||||||
"default_severity": "warning",
|
"default_severity": "priority",
|
||||||
"example_message": "☀️ Solar Issue: BLD-MTN not charging during daylight (12:00 MDT)",
|
"example_message": "☀️ Solar Issue: BLD-MTN not charging during daylight (12:00 MDT)",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -73,19 +78,19 @@ ALERT_CATEGORIES = {
|
||||||
"high_utilization": {
|
"high_utilization": {
|
||||||
"name": "Channel Airtime High",
|
"name": "Channel Airtime High",
|
||||||
"description": "LoRa channel airtime exceeding threshold — mesh congestion",
|
"description": "LoRa channel airtime exceeding threshold — mesh congestion",
|
||||||
"default_severity": "advisory",
|
"default_severity": "routine",
|
||||||
"example_message": "📊 Channel Airtime: 47% utilization (threshold: 40%). Reliability may degrade.",
|
"example_message": "📊 Channel Airtime: 47% utilization (threshold: 40%). Reliability may degrade.",
|
||||||
},
|
},
|
||||||
"sustained_high_util": {
|
"sustained_high_util": {
|
||||||
"name": "Sustained High Utilization",
|
"name": "Sustained High Utilization",
|
||||||
"description": "Channel airtime elevated for extended period — ongoing congestion",
|
"description": "Channel airtime elevated for extended period — ongoing congestion",
|
||||||
"default_severity": "warning",
|
"default_severity": "priority",
|
||||||
"example_message": "📊 Sustained Congestion: 45% channel utilization for 2+ hours. Consider reducing telemetry.",
|
"example_message": "📊 Sustained Congestion: 45% channel utilization for 2+ hours. Consider reducing telemetry.",
|
||||||
},
|
},
|
||||||
"packet_flood": {
|
"packet_flood": {
|
||||||
"name": "Packet Flood",
|
"name": "Packet Flood",
|
||||||
"description": "A single node sending excessive radio packets (NOT water flooding) — possible firmware bug or stuck transmitter",
|
"description": "A single node sending excessive radio packets (NOT water flooding) — possible firmware bug or stuck transmitter",
|
||||||
"default_severity": "warning",
|
"default_severity": "priority",
|
||||||
"example_message": "📻 Packet Flood: Node 'BKBS' transmitting 42 packets/min (threshold: 10/min). Firmware bug?",
|
"example_message": "📻 Packet Flood: Node 'BKBS' transmitting 42 packets/min (threshold: 10/min). Firmware bug?",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -93,19 +98,19 @@ ALERT_CATEGORIES = {
|
||||||
"infra_single_gateway": {
|
"infra_single_gateway": {
|
||||||
"name": "Single Gateway",
|
"name": "Single Gateway",
|
||||||
"description": "Infrastructure node dropped to single gateway coverage — reduced redundancy",
|
"description": "Infrastructure node dropped to single gateway coverage — reduced redundancy",
|
||||||
"default_severity": "advisory",
|
"default_severity": "priority",
|
||||||
"example_message": "📶 Reduced Coverage: HPR dropped to single gateway. Previously had 3 paths.",
|
"example_message": "📶 Reduced Coverage: HPR dropped to single gateway. Previously had 3 paths.",
|
||||||
},
|
},
|
||||||
"feeder_offline": {
|
"feeder_offline": {
|
||||||
"name": "Feeder Offline",
|
"name": "Feeder Offline",
|
||||||
"description": "A feeder gateway stopped responding — coverage gap possible",
|
"description": "A feeder gateway stopped responding — coverage gap possible",
|
||||||
"default_severity": "warning",
|
"default_severity": "priority",
|
||||||
"example_message": "📡 Feeder Offline: AIDA-N2 gateway not responding. 5 nodes may lose uplink.",
|
"example_message": "📡 Feeder Offline: AIDA-N2 gateway not responding. 5 nodes may lose uplink.",
|
||||||
},
|
},
|
||||||
"region_total_blackout": {
|
"region_total_blackout": {
|
||||||
"name": "Region Blackout",
|
"name": "Region Blackout",
|
||||||
"description": "All infrastructure in a region is offline — complete coverage loss",
|
"description": "All infrastructure in a region is offline — complete coverage loss",
|
||||||
"default_severity": "critical",
|
"default_severity": "immediate",
|
||||||
"example_message": "🚨 REGION BLACKOUT: All infrastructure in Magic Valley offline!",
|
"example_message": "🚨 REGION BLACKOUT: All infrastructure in Magic Valley offline!",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -113,13 +118,13 @@ ALERT_CATEGORIES = {
|
||||||
"mesh_score_low": {
|
"mesh_score_low": {
|
||||||
"name": "Mesh Health Low",
|
"name": "Mesh Health Low",
|
||||||
"description": "Overall mesh health score dropped below threshold — multiple issues likely",
|
"description": "Overall mesh health score dropped below threshold — multiple issues likely",
|
||||||
"default_severity": "warning",
|
"default_severity": "priority",
|
||||||
"example_message": "📉 Mesh Health: Score 62/100 (threshold: 65). Infrastructure: 71, Connectivity: 58.",
|
"example_message": "📉 Mesh Health: Score 62/100 (threshold: 65). Infrastructure: 71, Connectivity: 58.",
|
||||||
},
|
},
|
||||||
"region_score_low": {
|
"region_score_low": {
|
||||||
"name": "Region Health Low",
|
"name": "Region Health Low",
|
||||||
"description": "A region's health score below threshold — localized issues",
|
"description": "A region's health score below threshold — localized issues",
|
||||||
"default_severity": "warning",
|
"default_severity": "priority",
|
||||||
"example_message": "📉 Region Health: Magic Valley at 55/100 (threshold: 60). 2 nodes offline.",
|
"example_message": "📉 Region Health: Magic Valley at 55/100 (threshold: 60). 2 nodes offline.",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -127,7 +132,7 @@ ALERT_CATEGORIES = {
|
||||||
"weather_warning": {
|
"weather_warning": {
|
||||||
"name": "Severe Weather",
|
"name": "Severe Weather",
|
||||||
"description": "NWS warning or advisory affecting your mesh area",
|
"description": "NWS warning or advisory affecting your mesh area",
|
||||||
"default_severity": "warning",
|
"default_severity": "priority",
|
||||||
"example_message": "⚠ Red Flag Warning — Twin Falls, Cassia counties. Gusty winds, low humidity. Until May 13 04:00Z",
|
"example_message": "⚠ Red Flag Warning — Twin Falls, Cassia counties. Gusty winds, low humidity. Until May 13 04:00Z",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -135,13 +140,13 @@ ALERT_CATEGORIES = {
|
||||||
"hf_blackout": {
|
"hf_blackout": {
|
||||||
"name": "HF Radio Blackout",
|
"name": "HF Radio Blackout",
|
||||||
"description": "R3+ solar flare degrading HF propagation on sunlit side",
|
"description": "R3+ solar flare degrading HF propagation on sunlit side",
|
||||||
"default_severity": "warning",
|
"default_severity": "priority",
|
||||||
"example_message": "⚠ R3 Strong Radio Blackout — X1.2 flare. Wide-area HF blackout ~1 hour on sunlit side.",
|
"example_message": "⚠ R3 Strong Radio Blackout — X1.2 flare. Wide-area HF blackout ~1 hour on sunlit side.",
|
||||||
},
|
},
|
||||||
"geomagnetic_storm": {
|
"geomagnetic_storm": {
|
||||||
"name": "Geomagnetic Storm",
|
"name": "Geomagnetic Storm",
|
||||||
"description": "G2+ geomagnetic storm — HF degraded at higher latitudes, aurora possible",
|
"description": "G2+ geomagnetic storm — HF degraded at higher latitudes, aurora possible",
|
||||||
"default_severity": "advisory",
|
"default_severity": "priority",
|
||||||
"example_message": "🌐 G2 Moderate Geomagnetic Storm — Kp=6. HF fades at high latitudes, aurora to ~55°.",
|
"example_message": "🌐 G2 Moderate Geomagnetic Storm — Kp=6. HF fades at high latitudes, aurora to ~55°.",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -149,7 +154,7 @@ ALERT_CATEGORIES = {
|
||||||
"tropospheric_ducting": {
|
"tropospheric_ducting": {
|
||||||
"name": "Tropospheric Ducting",
|
"name": "Tropospheric Ducting",
|
||||||
"description": "Atmospheric conditions trapping VHF/UHF signals — extended range",
|
"description": "Atmospheric conditions trapping VHF/UHF signals — extended range",
|
||||||
"default_severity": "info",
|
"default_severity": "routine",
|
||||||
"example_message": "📡 Tropospheric Ducting: Surface duct detected, dM/dz -45 M-units/km, ~120m thick. VHF/UHF extended range.",
|
"example_message": "📡 Tropospheric Ducting: Surface duct detected, dM/dz -45 M-units/km, ~120m thick. VHF/UHF extended range.",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -157,19 +162,19 @@ ALERT_CATEGORIES = {
|
||||||
"fire_proximity": {
|
"fire_proximity": {
|
||||||
"name": "Fire Near Mesh",
|
"name": "Fire Near Mesh",
|
||||||
"description": "Active wildfire within alert radius of mesh infrastructure",
|
"description": "Active wildfire within alert radius of mesh infrastructure",
|
||||||
"default_severity": "warning",
|
"default_severity": "priority",
|
||||||
"example_message": "🔥 Fire Near Mesh: Rock Creek Fire — 1,240 ac, 15% contained, 12 km SSW of MHR. Monitor closely.",
|
"example_message": "🔥 Fire Near Mesh: Rock Creek Fire — 1,240 ac, 15% contained, 12 km SSW of MHR. Monitor closely.",
|
||||||
},
|
},
|
||||||
"wildfire_proximity": {
|
"wildfire_proximity": {
|
||||||
"name": "Fire Near Mesh",
|
"name": "Fire Near Mesh",
|
||||||
"description": "Active wildfire within alert radius of mesh infrastructure",
|
"description": "Active wildfire within alert radius of mesh infrastructure",
|
||||||
"default_severity": "warning",
|
"default_severity": "priority",
|
||||||
"example_message": "🔥 Fire Near Mesh: Rock Creek Fire — 1,240 ac, 15% contained, 12 km SSW of MHR.",
|
"example_message": "🔥 Fire Near Mesh: Rock Creek Fire — 1,240 ac, 15% contained, 12 km SSW of MHR.",
|
||||||
},
|
},
|
||||||
"new_ignition": {
|
"new_ignition": {
|
||||||
"name": "New Fire Ignition",
|
"name": "New Fire Ignition",
|
||||||
"description": "Satellite hotspot detected NOT near any known fire — potential new wildfire",
|
"description": "Satellite hotspot detected NOT near any known fire — potential new wildfire",
|
||||||
"default_severity": "watch",
|
"default_severity": "priority",
|
||||||
"example_message": "🛰 New Ignition: Satellite fire at 42.32°N, 114.30°W — high confidence, 47 MW FRP. Not near any known fire.",
|
"example_message": "🛰 New Ignition: Satellite fire at 42.32°N, 114.30°W — high confidence, 47 MW FRP. Not near any known fire.",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -177,13 +182,13 @@ ALERT_CATEGORIES = {
|
||||||
"stream_flood_warning": {
|
"stream_flood_warning": {
|
||||||
"name": "Stream Flood Warning",
|
"name": "Stream Flood Warning",
|
||||||
"description": "River gauge exceeds NWS flood stage threshold",
|
"description": "River gauge exceeds NWS flood stage threshold",
|
||||||
"default_severity": "warning",
|
"default_severity": "priority",
|
||||||
"example_message": "🌊 Stream Flood Warning: Snake River nr Twin Falls at 12.8 ft — Minor Flood Stage is 10.5 ft.",
|
"example_message": "🌊 Stream Flood Warning: Snake River nr Twin Falls at 12.8 ft — Minor Flood Stage is 10.5 ft.",
|
||||||
},
|
},
|
||||||
"stream_high_water": {
|
"stream_high_water": {
|
||||||
"name": "Stream High Water",
|
"name": "Stream High Water",
|
||||||
"description": "River gauge approaching flood stage — monitoring recommended",
|
"description": "River gauge approaching flood stage — monitoring recommended",
|
||||||
"default_severity": "advisory",
|
"default_severity": "routine",
|
||||||
"example_message": "🌊 High Water: Snake River at 9.8 ft — Action Stage is 9.0 ft. Monitor conditions.",
|
"example_message": "🌊 High Water: Snake River at 9.8 ft — Action Stage is 9.0 ft. Monitor conditions.",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -191,13 +196,13 @@ ALERT_CATEGORIES = {
|
||||||
"road_closure": {
|
"road_closure": {
|
||||||
"name": "Road Closure",
|
"name": "Road Closure",
|
||||||
"description": "Full road closure on a monitored corridor",
|
"description": "Full road closure on a monitored corridor",
|
||||||
"default_severity": "warning",
|
"default_severity": "priority",
|
||||||
"example_message": "🚧 Road Closure: I-84 EB at MP 173 — full closure, construction. Detour via US-30.",
|
"example_message": "🚧 Road Closure: I-84 EB at MP 173 — full closure, construction. Detour via US-30.",
|
||||||
},
|
},
|
||||||
"traffic_congestion": {
|
"traffic_congestion": {
|
||||||
"name": "Traffic Congestion",
|
"name": "Traffic Congestion",
|
||||||
"description": "Traffic speed dropped below congestion threshold on a monitored corridor",
|
"description": "Traffic speed dropped below congestion threshold on a monitored corridor",
|
||||||
"default_severity": "advisory",
|
"default_severity": "routine",
|
||||||
"example_message": "🚗 Traffic Congestion: I-84 Twin Falls — 35 mph (free-flow 70 mph), 50% speed ratio",
|
"example_message": "🚗 Traffic Congestion: I-84 Twin Falls — 35 mph (free-flow 70 mph), 50% speed ratio",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -205,13 +210,13 @@ ALERT_CATEGORIES = {
|
||||||
"avalanche_warning": {
|
"avalanche_warning": {
|
||||||
"name": "Avalanche Danger High",
|
"name": "Avalanche Danger High",
|
||||||
"description": "Avalanche danger level 4 (High) or 5 (Extreme) in your area",
|
"description": "Avalanche danger level 4 (High) or 5 (Extreme) in your area",
|
||||||
"default_severity": "warning",
|
"default_severity": "priority",
|
||||||
"example_message": "⛷ Avalanche Danger HIGH: Sawtooth Zone — avoid avalanche terrain. Natural avalanches likely.",
|
"example_message": "⛷ Avalanche Danger HIGH: Sawtooth Zone — avoid avalanche terrain. Natural avalanches likely.",
|
||||||
},
|
},
|
||||||
"avalanche_considerable": {
|
"avalanche_considerable": {
|
||||||
"name": "Avalanche Danger Considerable",
|
"name": "Avalanche Danger Considerable",
|
||||||
"description": "Avalanche danger level 3 (Considerable) — most fatalities occur at this level",
|
"description": "Avalanche danger level 3 (Considerable) — most fatalities occur at this level",
|
||||||
"default_severity": "watch",
|
"default_severity": "routine",
|
||||||
"example_message": "⛷ Avalanche Danger CONSIDERABLE: Sawtooth Zone — dangerous conditions on steep slopes.",
|
"example_message": "⛷ Avalanche Danger CONSIDERABLE: Sawtooth Zone — dangerous conditions on steep slopes.",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -224,7 +229,7 @@ def get_category(category_id: str) -> dict:
|
||||||
return {
|
return {
|
||||||
"name": category_id.replace("_", " ").title(),
|
"name": category_id.replace("_", " ").title(),
|
||||||
"description": f"Alert type: {category_id}",
|
"description": f"Alert type: {category_id}",
|
||||||
"default_severity": "info",
|
"default_severity": "routine",
|
||||||
"example_message": f"Alert: {category_id}",
|
"example_message": f"Alert: {category_id}",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -168,7 +168,7 @@ class MeshDMChannel(NotificationChannel):
|
||||||
|
|
||||||
for node_id in self._node_ids:
|
for node_id in self._node_ids:
|
||||||
try:
|
try:
|
||||||
dest = int(node_id, 16) if node_id.startswith("!") else (int(node_id) if node_id.isdigit() else node_id)
|
dest = int(node_id[1:], 16) if node_id.startswith("!") else (int(node_id) if node_id.isdigit() else int(node_id, 16))
|
||||||
self._connector.send_message(text=message, destination=dest, channel=0)
|
self._connector.send_message(text=message, destination=dest, channel=0)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to DM %s: %s", node_id, e)
|
logger.error("Failed to DM %s: %s", node_id, e)
|
||||||
|
|
@ -199,7 +199,7 @@ class MeshDMChannel(NotificationChannel):
|
||||||
|
|
||||||
for node_id in self._node_ids:
|
for node_id in self._node_ids:
|
||||||
try:
|
try:
|
||||||
dest = int(node_id, 16) if node_id.startswith("!") else (int(node_id) if node_id.isdigit() else node_id)
|
dest = int(node_id[1:], 16) if node_id.startswith("!") else (int(node_id) if node_id.isdigit() else int(node_id, 16))
|
||||||
self._connector.send_message(
|
self._connector.send_message(
|
||||||
text="MeshAI DM test",
|
text="MeshAI DM test",
|
||||||
destination=dest,
|
destination=dest,
|
||||||
|
|
@ -249,7 +249,7 @@ class MeshDMChannel(NotificationChannel):
|
||||||
|
|
||||||
for node_id in self._node_ids:
|
for node_id in self._node_ids:
|
||||||
try:
|
try:
|
||||||
dest = int(node_id, 16) if node_id.startswith("!") else (int(node_id) if node_id.isdigit() else node_id)
|
dest = int(node_id[1:], 16) if node_id.startswith("!") else (int(node_id) if node_id.isdigit() else int(node_id, 16))
|
||||||
self._connector.send_message(text=message, destination=dest, channel=0)
|
self._connector.send_message(text=message, destination=dest, channel=0)
|
||||||
success_count += 1
|
success_count += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -529,10 +529,9 @@ class WebhookChannel(NotificationChannel):
|
||||||
if "discord.com" in self._url or "slack.com" in self._url:
|
if "discord.com" in self._url or "slack.com" in self._url:
|
||||||
severity = alert.get("severity", "info")
|
severity = alert.get("severity", "info")
|
||||||
color = {
|
color = {
|
||||||
"emergency": 0xFF0000,
|
"immediate": 0xFF0000,
|
||||||
"critical": 0xFF4444,
|
"priority": 0xFFAA00,
|
||||||
"warning": 0xFFAA00,
|
"routine": 0x0099FF,
|
||||||
"info": 0x0099FF,
|
|
||||||
}.get(severity, 0x888888)
|
}.get(severity, 0x888888)
|
||||||
payload = {
|
payload = {
|
||||||
"embeds": [{
|
"embeds": [{
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ if TYPE_CHECKING:
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Severity levels in order
|
# Severity levels in order
|
||||||
SEVERITY_ORDER = ["info", "advisory", "watch", "warning", "critical", "emergency"]
|
SEVERITY_ORDER = ["routine", "priority", "immediate"]
|
||||||
|
|
||||||
# State file for rule statistics
|
# State file for rule statistics
|
||||||
RULE_STATS_FILE = "/opt/meshai/data/rule_stats.json"
|
RULE_STATS_FILE = "/opt/meshai/data/rule_stats.json"
|
||||||
|
|
@ -164,7 +164,7 @@ class NotificationRouter:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if self._quiet_enabled and self._in_quiet_hours():
|
if self._quiet_enabled and self._in_quiet_hours():
|
||||||
if severity not in ("emergency", "critical"):
|
if severity == "routine":
|
||||||
if not rule.get("override_quiet", False):
|
if not rule.get("override_quiet", False):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue