mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-21 23:24:44 +02:00
feat(env): NIFC fire perimeters + avalanche advisories
- WFIGS ArcGIS fire perimeter polling with proximity alerts - Avalanche.org advisory polling (seasonal, SNFAC) - !fire and !avy commands - Distance-based severity for fires near mesh infrastructure - Dashboard environment page integration - Alert engine fires on fires within 50km of mesh area Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
1158e30c0b
commit
2255ca5803
15 changed files with 1013 additions and 93 deletions
|
|
@ -229,6 +229,60 @@ export async function fetchDucting(): Promise<DuctingStatus> {
|
|||
return fetchJson<DuctingStatus>('/api/env/ducting')
|
||||
}
|
||||
|
||||
export interface FireEvent {
|
||||
source: string
|
||||
event_id: string
|
||||
event_type: string
|
||||
severity: string
|
||||
headline: string
|
||||
name: string
|
||||
acres: number
|
||||
pct_contained: number
|
||||
lat: number | null
|
||||
lon: number | null
|
||||
distance_km: number | null
|
||||
nearest_anchor: string | null
|
||||
state: string
|
||||
expires: number
|
||||
fetched_at: number
|
||||
polygon?: number[][][]
|
||||
}
|
||||
|
||||
export interface AvalancheEvent {
|
||||
source: string
|
||||
event_id: string
|
||||
event_type: string
|
||||
severity: string
|
||||
headline: string
|
||||
zone_name: string
|
||||
center: string
|
||||
center_id: string
|
||||
center_link: string
|
||||
forecast_link: string
|
||||
danger: string
|
||||
danger_level: number
|
||||
danger_name: string
|
||||
travel_advice: string
|
||||
state: string
|
||||
lat: number | null
|
||||
lon: number | null
|
||||
expires: number
|
||||
fetched_at: number
|
||||
}
|
||||
|
||||
export interface AvalancheResponse {
|
||||
off_season: boolean
|
||||
advisories: AvalancheEvent[]
|
||||
}
|
||||
|
||||
export async function fetchFires(): Promise<FireEvent[]> {
|
||||
return fetchJson<FireEvent[]>('/api/env/fires')
|
||||
}
|
||||
|
||||
export async function fetchAvalanche(): Promise<AvalancheResponse> {
|
||||
return fetchJson<AvalancheResponse>('/api/env/avalanche')
|
||||
}
|
||||
|
||||
export async function fetchRegions(): Promise<RegionInfo[]> {
|
||||
return fetchJson<RegionInfo[]>('/api/regions')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,16 +8,22 @@ import {
|
|||
CheckCircle,
|
||||
Activity,
|
||||
Wind,
|
||||
Flame,
|
||||
Mountain,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
fetchEnvStatus,
|
||||
fetchEnvActive,
|
||||
fetchSWPC,
|
||||
fetchDucting,
|
||||
fetchFires,
|
||||
fetchAvalanche,
|
||||
type EnvStatus,
|
||||
type EnvEvent,
|
||||
type SWPCStatus,
|
||||
type DuctingStatus,
|
||||
type FireEvent,
|
||||
type AvalancheResponse,
|
||||
} from '@/lib/api'
|
||||
|
||||
function FeedStatusCard({ feed }: { feed: { source: string; is_loaded: boolean; last_error: string | null; consecutive_errors: number; event_count: number; last_fetch: number } }) {
|
||||
|
|
@ -340,6 +346,8 @@ export default function Environment() {
|
|||
const [events, setEvents] = useState<EnvEvent[]>([])
|
||||
const [swpc, setSWPC] = useState<SWPCStatus | null>(null)
|
||||
const [ducting, setDucting] = useState<DuctingStatus | null>(null)
|
||||
const [fires, setFires] = useState<FireEvent[]>([])
|
||||
const [avalanche, setAvalanche] = useState<AvalancheResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
|
|
@ -349,12 +357,16 @@ export default function Environment() {
|
|||
fetchEnvActive().catch(() => []),
|
||||
fetchSWPC().catch(() => null),
|
||||
fetchDucting().catch(() => null),
|
||||
fetchFires().catch(() => []),
|
||||
fetchAvalanche().catch(() => null),
|
||||
])
|
||||
.then(([status, active, swpcData, ductingData]) => {
|
||||
.then(([status, active, swpcData, ductingData, firesData, avyData]) => {
|
||||
setEnvStatus(status)
|
||||
setEvents(active)
|
||||
setSWPC(swpcData)
|
||||
setDucting(ductingData)
|
||||
setFires(firesData)
|
||||
setAvalanche(avyData)
|
||||
setLoading(false)
|
||||
})
|
||||
.catch((err) => {
|
||||
|
|
@ -428,6 +440,129 @@ export default function Environment() {
|
|||
<DuctingPanel ducting={ducting} />
|
||||
</div>
|
||||
|
||||
{/* Fires and Avalanche */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Wildfires */}
|
||||
<div className="bg-bg-card border border-border rounded-lg p-6">
|
||||
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
|
||||
<Flame size={14} />
|
||||
Active Wildfires ({fires.length})
|
||||
</h2>
|
||||
{fires.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{fires.map((fire) => (
|
||||
<div
|
||||
key={fire.event_id}
|
||||
className={`p-3 rounded-lg ${
|
||||
fire.severity === 'warning'
|
||||
? 'bg-red-500/10 border-l-2 border-red-500'
|
||||
: fire.severity === 'watch'
|
||||
? 'bg-amber-500/10 border-l-2 border-amber-500'
|
||||
: 'bg-slate-500/10 border-l-2 border-slate-500'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm font-medium text-slate-200">
|
||||
{fire.name}
|
||||
</span>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${
|
||||
fire.severity === 'warning'
|
||||
? 'bg-red-500/20 text-red-400'
|
||||
: fire.severity === 'watch'
|
||||
? 'bg-amber-500/20 text-amber-400'
|
||||
: 'bg-slate-500/20 text-slate-400'
|
||||
}`}>
|
||||
{fire.severity}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-400 space-y-1">
|
||||
<div>{fire.acres.toLocaleString()} acres, {fire.pct_contained}% contained</div>
|
||||
{fire.distance_km && fire.nearest_anchor && (
|
||||
<div>{Math.round(fire.distance_km)} km from {fire.nearest_anchor}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-slate-500 py-4">
|
||||
<CheckCircle size={16} className="text-green-500" />
|
||||
<span>No active wildfires in the area</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Avalanche */}
|
||||
<div className="bg-bg-card border border-border rounded-lg p-6">
|
||||
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
|
||||
<Mountain size={14} />
|
||||
Avalanche Advisories
|
||||
</h2>
|
||||
{avalanche?.off_season ? (
|
||||
<div className="text-slate-500 py-4">
|
||||
<p>Off season - check back in December</p>
|
||||
</div>
|
||||
) : avalanche && avalanche.advisories.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{avalanche.advisories.map((avy) => (
|
||||
<div
|
||||
key={avy.event_id}
|
||||
className={`p-3 rounded-lg ${
|
||||
avy.danger_level >= 4
|
||||
? 'bg-red-500/10 border-l-2 border-red-500'
|
||||
: avy.danger_level >= 3
|
||||
? 'bg-amber-500/10 border-l-2 border-amber-500'
|
||||
: avy.danger_level >= 2
|
||||
? 'bg-yellow-500/10 border-l-2 border-yellow-500'
|
||||
: 'bg-green-500/10 border-l-2 border-green-500'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm font-medium text-slate-200">
|
||||
{avy.zone_name}
|
||||
</span>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${
|
||||
avy.danger_level >= 4
|
||||
? 'bg-red-500/20 text-red-400'
|
||||
: avy.danger_level >= 3
|
||||
? 'bg-amber-500/20 text-amber-400'
|
||||
: avy.danger_level >= 2
|
||||
? 'bg-yellow-500/20 text-yellow-400'
|
||||
: 'bg-green-500/20 text-green-400'
|
||||
}`}>
|
||||
{avy.danger_name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-400">
|
||||
{avy.center}
|
||||
</div>
|
||||
{avy.travel_advice && (
|
||||
<div className="text-xs text-slate-500 mt-2 line-clamp-2">
|
||||
{avy.travel_advice}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{avalanche.advisories[0]?.center_link && (
|
||||
<a
|
||||
href={avalanche.advisories[0].center_link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-400 hover:underline"
|
||||
>
|
||||
View full forecast
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-slate-500 py-4">
|
||||
<CheckCircle size={16} className="text-green-500" />
|
||||
<span>No avalanche advisories</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Events */}
|
||||
<div className="bg-bg-card border border-border rounded-lg p-6">
|
||||
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -663,4 +663,54 @@ class AlertEngine:
|
|||
"is_critical": False,
|
||||
})
|
||||
|
||||
# Wildfire proximity alerts
|
||||
fires = env_store.get_active(source="nifc")
|
||||
for fire in fires:
|
||||
distance_km = fire.get("distance_km")
|
||||
if distance_km is None:
|
||||
continue
|
||||
|
||||
name = fire.get("name", "Unknown")
|
||||
acres = fire.get("acres", 0)
|
||||
pct = fire.get("pct_contained", 0)
|
||||
anchor = fire.get("nearest_anchor", "mesh area")
|
||||
|
||||
if distance_km < 25:
|
||||
# Critical - fire within 25km
|
||||
key = f"env_fire_critical_{name}"
|
||||
state = self._get_state(key)
|
||||
if state.should_fire(now):
|
||||
state.fire(now)
|
||||
alerts.append({
|
||||
"type": "wildfire_proximity",
|
||||
"message": f"Wildfire '{name}' within {int(distance_km)} km of {anchor} -- {int(acres):,} ac, {int(pct)}% contained",
|
||||
"severity": "critical",
|
||||
"node_num": None,
|
||||
"node_name": name,
|
||||
"node_short": "FIRE",
|
||||
"region": anchor,
|
||||
"scope_type": "mesh",
|
||||
"scope_value": None,
|
||||
"is_critical": True,
|
||||
})
|
||||
|
||||
elif distance_km < 50:
|
||||
# Warning - fire within 50km
|
||||
key = f"env_fire_warning_{name}"
|
||||
state = self._get_state(key)
|
||||
if state.should_fire(now):
|
||||
state.fire(now)
|
||||
alerts.append({
|
||||
"type": "wildfire_proximity",
|
||||
"message": f"Wildfire '{name}' {int(distance_km)} km from {anchor} -- {int(acres):,} ac, {int(pct)}% contained",
|
||||
"severity": "warning",
|
||||
"node_num": None,
|
||||
"node_name": name,
|
||||
"node_short": "FIRE",
|
||||
"region": anchor,
|
||||
"scope_type": "mesh",
|
||||
"scope_value": None,
|
||||
"is_critical": False,
|
||||
})
|
||||
|
||||
return alerts
|
||||
|
|
|
|||
55
meshai/commands/avy_cmd.py
Normal file
55
meshai/commands/avy_cmd.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
"""Avalanche command handler."""
|
||||
|
||||
from .base import CommandContext, CommandHandler
|
||||
|
||||
|
||||
class AvalancheCommand(CommandHandler):
|
||||
"""Avalanche advisory information."""
|
||||
|
||||
name = "avy"
|
||||
description = "Avalanche advisories"
|
||||
usage = "!avy"
|
||||
aliases = ["avalanche"]
|
||||
|
||||
def __init__(self, env_store):
|
||||
self._env_store = env_store
|
||||
|
||||
async def execute(self, args: str, context: CommandContext) -> str:
|
||||
"""Execute the avalanche command."""
|
||||
if not self._env_store:
|
||||
return "Environmental feeds not enabled."
|
||||
|
||||
# Check if any avalanche adapter is off season
|
||||
adapters = getattr(self._env_store, "_adapters", {})
|
||||
avy_adapter = adapters.get("avalanche")
|
||||
if avy_adapter and avy_adapter.is_off_season():
|
||||
return "Avalanche season ended -- check back in December."
|
||||
|
||||
advisories = self._env_store.get_active(source="avalanche")
|
||||
|
||||
if not advisories:
|
||||
return "No avalanche advisories available."
|
||||
|
||||
lines = [f"Avalanche Advisories ({len(advisories)}):"]
|
||||
|
||||
for a in advisories[:5]:
|
||||
zone = a.get("zone_name", "Unknown")
|
||||
danger_name = a.get("danger_name", "Unknown")
|
||||
center = a.get("center", "")
|
||||
link = a.get("forecast_link", "")
|
||||
|
||||
line = f"* {zone}: {danger_name}"
|
||||
if center:
|
||||
line += f" ({center})"
|
||||
lines.append(line)
|
||||
|
||||
# Add travel advice if present
|
||||
advice = a.get("travel_advice", "")
|
||||
if advice:
|
||||
lines.append(f" {advice[:100]}")
|
||||
|
||||
# Add link to first advisory
|
||||
if advisories and advisories[0].get("center_link"):
|
||||
lines.append(f"\nMore: {advisories[0]['center_link']}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
|
@ -266,6 +266,21 @@ def create_dispatcher(
|
|||
wx_cmd.name = "wx-alerts"
|
||||
dispatcher.register(wx_cmd)
|
||||
|
||||
# Register fire command
|
||||
from .fire_cmd import FireCommand
|
||||
fire_cmd = FireCommand(env_store)
|
||||
dispatcher.register(fire_cmd)
|
||||
|
||||
# Register avalanche command
|
||||
from .avy_cmd import AvalancheCommand
|
||||
avy_cmd = AvalancheCommand(env_store)
|
||||
dispatcher.register(avy_cmd)
|
||||
|
||||
# Register !avalanche as alias for !avy
|
||||
avalanche_cmd = AvalancheCommand(env_store)
|
||||
avalanche_cmd.name = "avalanche"
|
||||
dispatcher.register(avalanche_cmd)
|
||||
|
||||
# Register custom commands
|
||||
if custom_commands:
|
||||
for name, response in custom_commands.items():
|
||||
|
|
|
|||
40
meshai/commands/fire_cmd.py
Normal file
40
meshai/commands/fire_cmd.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
"""Fire command handler."""
|
||||
|
||||
from .base import CommandContext, CommandHandler
|
||||
|
||||
|
||||
class FireCommand(CommandHandler):
|
||||
"""Active wildfire information."""
|
||||
|
||||
name = "fire"
|
||||
description = "Active wildfires in the area"
|
||||
usage = "!fire"
|
||||
|
||||
def __init__(self, env_store):
|
||||
self._env_store = env_store
|
||||
|
||||
async def execute(self, args: str, context: CommandContext) -> str:
|
||||
"""Execute the fire command."""
|
||||
if not self._env_store:
|
||||
return "Environmental feeds not enabled."
|
||||
|
||||
fires = self._env_store.get_active(source="nifc")
|
||||
|
||||
if not fires:
|
||||
return "No active wildfires in the area."
|
||||
|
||||
lines = [f"Active Wildfires ({len(fires)}):"]
|
||||
|
||||
for f in fires[:5]:
|
||||
name = f.get("name", "Unknown")
|
||||
acres = f.get("acres", 0)
|
||||
pct = f.get("pct_contained", 0)
|
||||
dist = f.get("distance_km")
|
||||
anchor = f.get("nearest_anchor")
|
||||
|
||||
line = f"* {name} -- {int(acres):,} ac, {int(pct)}% contained"
|
||||
if dist is not None and anchor:
|
||||
line += f" ({int(dist)} km from {anchor})"
|
||||
lines.append(line)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
|
@ -75,3 +75,34 @@ async def get_ducting_data(request: Request):
|
|||
"enabled": True,
|
||||
**status,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/env/fires")
|
||||
async def get_fires_data(request: Request):
|
||||
"""Get active wildfire perimeters."""
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
|
||||
if not env_store:
|
||||
return []
|
||||
|
||||
return env_store.get_active(source="nifc")
|
||||
|
||||
|
||||
@router.get("/env/avalanche")
|
||||
async def get_avalanche_data(request: Request):
|
||||
"""Get avalanche advisories."""
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
|
||||
if not env_store:
|
||||
return {"off_season": True, "advisories": []}
|
||||
|
||||
adapters = getattr(env_store, "_adapters", {})
|
||||
avy_adapter = adapters.get("avalanche")
|
||||
|
||||
if avy_adapter and avy_adapter.is_off_season():
|
||||
return {"off_season": True, "advisories": []}
|
||||
|
||||
return {
|
||||
"off_season": False,
|
||||
"advisories": env_store.get_active(source="avalanche"),
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -8,8 +8,8 @@
|
|||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script type="module" crossorigin src="/assets/index-Hvb4qZ75.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B5wp_1Dg.css">
|
||||
<script type="module" crossorigin src="/assets/index-BaC2Rd9C.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-0HCYKWnt.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
249
meshai/env/avalanche.py
vendored
Normal file
249
meshai/env/avalanche.py
vendored
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
"""Avalanche.org advisory adapter."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..config import AvalancheConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AvalancheAdapter:
|
||||
"""Avalanche.org map layer polling."""
|
||||
|
||||
BASE_URL = "https://api.avalanche.org/v2/public/products/map-layer"
|
||||
|
||||
# Danger level mapping
|
||||
DANGER_LEVELS = {
|
||||
-1: ("no_rating", "No Rating"),
|
||||
0: ("no_rating", "No Rating"),
|
||||
1: ("low", "Low"),
|
||||
2: ("moderate", "Moderate"),
|
||||
3: ("considerable", "Considerable"),
|
||||
4: ("high", "High"),
|
||||
5: ("extreme", "Extreme"),
|
||||
}
|
||||
|
||||
def __init__(self, config: "AvalancheConfig"):
|
||||
self._center_ids = config.center_ids or ["SNFAC"]
|
||||
self._tick_interval = config.tick_seconds or 1800
|
||||
self._season_months = config.season_months or [12, 1, 2, 3, 4]
|
||||
self._last_tick = 0.0
|
||||
self._events = []
|
||||
self._consecutive_errors = 0
|
||||
self._last_error = None
|
||||
self._is_loaded = False
|
||||
self._off_season = False
|
||||
|
||||
def tick(self) -> bool:
|
||||
"""Execute one polling tick.
|
||||
|
||||
Returns:
|
||||
True if data changed
|
||||
"""
|
||||
now = time.time()
|
||||
|
||||
# Check if in season
|
||||
current_month = datetime.now().month
|
||||
if current_month not in self._season_months:
|
||||
self._off_season = True
|
||||
self._events = []
|
||||
return False
|
||||
|
||||
self._off_season = False
|
||||
|
||||
if now - self._last_tick < self._tick_interval:
|
||||
return False
|
||||
|
||||
self._last_tick = now
|
||||
return self._fetch()
|
||||
|
||||
def _fetch(self) -> bool:
|
||||
"""Fetch avalanche advisories from all center IDs.
|
||||
|
||||
Returns:
|
||||
True if data changed
|
||||
"""
|
||||
new_events = []
|
||||
now = time.time()
|
||||
any_error = False
|
||||
|
||||
for center_id in self._center_ids:
|
||||
url = f"{self.BASE_URL}/{center_id}"
|
||||
|
||||
headers = {
|
||||
"User-Agent": "MeshAI/1.0",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
try:
|
||||
req = Request(url, headers=headers)
|
||||
with urlopen(req, timeout=30) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
except HTTPError as e:
|
||||
logger.warning(f"Avalanche {center_id} HTTP error: {e.code}")
|
||||
self._last_error = f"{center_id}: HTTP {e.code}"
|
||||
any_error = True
|
||||
continue
|
||||
|
||||
except URLError as e:
|
||||
logger.warning(f"Avalanche {center_id} connection error: {e.reason}")
|
||||
self._last_error = f"{center_id}: {e.reason}"
|
||||
any_error = True
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Avalanche {center_id} fetch error: {e}")
|
||||
self._last_error = f"{center_id}: {e}"
|
||||
any_error = True
|
||||
continue
|
||||
|
||||
# Parse response - it's a FeatureCollection
|
||||
features = data.get("features", [])
|
||||
|
||||
for feature in features:
|
||||
props = feature.get("properties", {})
|
||||
|
||||
zone_name = props.get("name", "Unknown Zone")
|
||||
center_name = props.get("center", center_id)
|
||||
center_link = props.get("center_link", "")
|
||||
forecast_link = props.get("link", "")
|
||||
danger = props.get("danger", "no rating")
|
||||
danger_level = props.get("danger_level", -1)
|
||||
off_season = props.get("off_season", False)
|
||||
state = props.get("state", "")
|
||||
travel_advice = props.get("travel_advice", "")
|
||||
|
||||
# Skip off-season zones
|
||||
if off_season:
|
||||
continue
|
||||
|
||||
# Map danger level to severity
|
||||
level_key, level_name = self.DANGER_LEVELS.get(danger_level, ("no_rating", "No Rating"))
|
||||
|
||||
if danger_level >= 4:
|
||||
severity = "warning"
|
||||
elif danger_level >= 3:
|
||||
severity = "watch"
|
||||
elif danger_level >= 2:
|
||||
severity = "advisory"
|
||||
else:
|
||||
severity = "info"
|
||||
|
||||
# Compute centroid
|
||||
geom = feature.get("geometry")
|
||||
lat, lon = self._compute_centroid(geom)
|
||||
|
||||
# Format headline
|
||||
headline = f"{zone_name}: {level_name} avalanche danger"
|
||||
if travel_advice:
|
||||
headline += f" -- {travel_advice[:100]}"
|
||||
|
||||
# Expires at end of day (mountain time approximation)
|
||||
end_of_day = datetime.now().replace(hour=23, minute=59, second=59)
|
||||
expires = end_of_day.timestamp()
|
||||
|
||||
event = {
|
||||
"source": "avalanche",
|
||||
"event_id": f"avy_{center_id}_{zone_name.replace(' ', '_').lower()}",
|
||||
"event_type": "Avalanche Advisory",
|
||||
"severity": severity,
|
||||
"headline": headline,
|
||||
"zone_name": zone_name,
|
||||
"center": center_name,
|
||||
"center_id": center_id,
|
||||
"center_link": center_link,
|
||||
"forecast_link": forecast_link,
|
||||
"danger": danger,
|
||||
"danger_level": danger_level,
|
||||
"danger_name": level_name,
|
||||
"travel_advice": travel_advice,
|
||||
"state": state,
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"expires": expires,
|
||||
"fetched_at": now,
|
||||
}
|
||||
|
||||
new_events.append(event)
|
||||
|
||||
# Check if data changed
|
||||
old_ids = {e["event_id"] for e in self._events}
|
||||
new_ids = {e["event_id"] for e in new_events}
|
||||
changed = old_ids != new_ids
|
||||
|
||||
self._events = new_events
|
||||
|
||||
if not any_error:
|
||||
self._consecutive_errors = 0
|
||||
self._last_error = None
|
||||
else:
|
||||
self._consecutive_errors += 1
|
||||
|
||||
self._is_loaded = True
|
||||
|
||||
if changed:
|
||||
logger.info(f"Avalanche advisories updated: {len(new_events)} active zones")
|
||||
|
||||
return changed
|
||||
|
||||
def _compute_centroid(self, geom) -> tuple:
|
||||
"""Compute centroid from GeoJSON geometry."""
|
||||
if not geom:
|
||||
return (None, None)
|
||||
|
||||
try:
|
||||
coords = geom.get("coordinates", [])
|
||||
geom_type = geom.get("type")
|
||||
|
||||
if geom_type == "Polygon" and coords:
|
||||
ring = coords[0]
|
||||
if ring:
|
||||
lat_sum = sum(c[1] for c in ring)
|
||||
lon_sum = sum(c[0] for c in ring)
|
||||
return (lat_sum / len(ring), lon_sum / len(ring))
|
||||
|
||||
elif geom_type == "MultiPolygon" and coords:
|
||||
all_lats = []
|
||||
all_lons = []
|
||||
for polygon in coords:
|
||||
if polygon:
|
||||
ring = polygon[0]
|
||||
if ring:
|
||||
all_lats.append(sum(c[1] for c in ring) / len(ring))
|
||||
all_lons.append(sum(c[0] for c in ring) / len(ring))
|
||||
if all_lats and all_lons:
|
||||
return (sum(all_lats) / len(all_lats), sum(all_lons) / len(all_lons))
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return (None, None)
|
||||
|
||||
def is_off_season(self) -> bool:
|
||||
"""Check if currently off season."""
|
||||
return self._off_season
|
||||
|
||||
def get_events(self) -> list:
|
||||
"""Get current avalanche events."""
|
||||
return self._events
|
||||
|
||||
@property
|
||||
def health_status(self) -> dict:
|
||||
"""Get adapter health status."""
|
||||
return {
|
||||
"source": "avalanche",
|
||||
"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,
|
||||
"off_season": self._off_season,
|
||||
}
|
||||
248
meshai/env/fires.py
vendored
Normal file
248
meshai/env/fires.py
vendored
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
"""NIFC/WFIGS Wildfire perimeter adapter."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.parse import urlencode
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..config import NICFFiresConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NICFFiresAdapter:
|
||||
"""WFIGS ArcGIS fire perimeter polling."""
|
||||
|
||||
BASE_URL = "https://services3.arcgis.com/T4QMspbfLg3qTGWY/arcgis/rest/services/WFIGS_Interagency_Perimeters_Current/FeatureServer/0/query"
|
||||
|
||||
def __init__(self, config: "NICFFiresConfig", region_anchors: list = None):
|
||||
self._state = config.state or "US-ID"
|
||||
self._tick_interval = config.tick_seconds or 600
|
||||
self._last_tick = 0.0
|
||||
self._events = []
|
||||
self._consecutive_errors = 0
|
||||
self._last_error = None
|
||||
self._is_loaded = False
|
||||
# Region anchors for proximity calculation
|
||||
self._region_anchors = region_anchors or []
|
||||
|
||||
def tick(self) -> bool:
|
||||
"""Execute one polling tick.
|
||||
|
||||
Returns:
|
||||
True if data changed
|
||||
"""
|
||||
now = time.time()
|
||||
|
||||
if now - self._last_tick < self._tick_interval:
|
||||
return False
|
||||
|
||||
self._last_tick = now
|
||||
return self._fetch()
|
||||
|
||||
def _fetch(self) -> bool:
|
||||
"""Fetch fire perimeters from WFIGS.
|
||||
|
||||
Returns:
|
||||
True if data changed
|
||||
"""
|
||||
params = {
|
||||
"where": f"attr_POOState='{self._state}' AND attr_IncidentTypeCategory='WF'",
|
||||
"outFields": "attr_IncidentName,attr_IncidentSize,attr_PercentContained,attr_FireDiscoveryDateTime,attr_POOState,poly_GISAcres",
|
||||
"returnGeometry": "true",
|
||||
"f": "geojson",
|
||||
}
|
||||
|
||||
url = f"{self.BASE_URL}?{urlencode(params)}"
|
||||
|
||||
headers = {
|
||||
"User-Agent": "MeshAI/1.0",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
try:
|
||||
req = Request(url, headers=headers)
|
||||
with urlopen(req, timeout=30) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
except HTTPError as e:
|
||||
logger.warning(f"NIFC HTTP error: {e.code}")
|
||||
self._last_error = f"HTTP {e.code}"
|
||||
self._consecutive_errors += 1
|
||||
return False
|
||||
|
||||
except URLError as e:
|
||||
logger.warning(f"NIFC connection error: {e.reason}")
|
||||
self._last_error = str(e.reason)
|
||||
self._consecutive_errors += 1
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"NIFC fetch error: {e}")
|
||||
self._last_error = str(e)
|
||||
self._consecutive_errors += 1
|
||||
return False
|
||||
|
||||
# Parse response
|
||||
features = data.get("features", [])
|
||||
new_events = []
|
||||
now = time.time()
|
||||
|
||||
for feature in features:
|
||||
props = feature.get("properties", {})
|
||||
geom = feature.get("geometry")
|
||||
|
||||
name = props.get("attr_IncidentName", "Unknown Fire")
|
||||
acres = props.get("attr_IncidentSize") or props.get("poly_GISAcres") or 0
|
||||
pct_contained = props.get("attr_PercentContained") or 0
|
||||
|
||||
# Compute centroid from polygon
|
||||
lat, lon = self._compute_centroid(geom)
|
||||
|
||||
# Compute proximity to nearest anchor
|
||||
distance_km, nearest_anchor = self._nearest_anchor_distance(lat, lon)
|
||||
|
||||
# Severity based on distance
|
||||
if distance_km is not None:
|
||||
if distance_km < 25:
|
||||
severity = "warning"
|
||||
elif distance_km < 50:
|
||||
severity = "watch"
|
||||
else:
|
||||
severity = "advisory"
|
||||
else:
|
||||
severity = "advisory"
|
||||
|
||||
# Format headline
|
||||
headline = f"{name} -- {int(acres):,} ac, {int(pct_contained)}% contained"
|
||||
if distance_km is not None and nearest_anchor:
|
||||
headline += f" ({int(distance_km)} km from {nearest_anchor})"
|
||||
|
||||
event = {
|
||||
"source": "nifc",
|
||||
"event_id": f"nifc_{name.replace(' ', '_').lower()}_{self._state}",
|
||||
"event_type": "Wildfire",
|
||||
"severity": severity,
|
||||
"headline": headline,
|
||||
"name": name,
|
||||
"acres": acres,
|
||||
"pct_contained": pct_contained,
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"distance_km": distance_km,
|
||||
"nearest_anchor": nearest_anchor,
|
||||
"state": self._state,
|
||||
"expires": now + 21600, # 6 hour TTL
|
||||
"fetched_at": now,
|
||||
}
|
||||
|
||||
# Store polygon for map overlay
|
||||
if geom and geom.get("type") == "Polygon":
|
||||
event["polygon"] = geom.get("coordinates", [])
|
||||
|
||||
new_events.append(event)
|
||||
|
||||
# Check if data changed
|
||||
old_ids = {e["event_id"] for e in self._events}
|
||||
new_ids = {e["event_id"] for e in new_events}
|
||||
changed = old_ids != new_ids
|
||||
|
||||
self._events = new_events
|
||||
self._consecutive_errors = 0
|
||||
self._last_error = None
|
||||
self._is_loaded = True
|
||||
|
||||
if changed:
|
||||
logger.info(f"NIFC fires updated: {len(new_events)} active in {self._state}")
|
||||
|
||||
return changed
|
||||
|
||||
def _compute_centroid(self, geom) -> tuple:
|
||||
"""Compute centroid from GeoJSON geometry."""
|
||||
if not geom:
|
||||
return (None, None)
|
||||
|
||||
try:
|
||||
coords = geom.get("coordinates", [])
|
||||
geom_type = geom.get("type")
|
||||
|
||||
if geom_type == "Polygon" and coords:
|
||||
# Use first ring
|
||||
ring = coords[0]
|
||||
if ring:
|
||||
lat_sum = sum(c[1] for c in ring)
|
||||
lon_sum = sum(c[0] for c in ring)
|
||||
return (lat_sum / len(ring), lon_sum / len(ring))
|
||||
|
||||
elif geom_type == "MultiPolygon" and coords:
|
||||
# Average all polygon centroids
|
||||
all_lats = []
|
||||
all_lons = []
|
||||
for polygon in coords:
|
||||
if polygon:
|
||||
ring = polygon[0]
|
||||
if ring:
|
||||
all_lats.append(sum(c[1] for c in ring) / len(ring))
|
||||
all_lons.append(sum(c[0] for c in ring) / len(ring))
|
||||
if all_lats and all_lons:
|
||||
return (sum(all_lats) / len(all_lats), sum(all_lons) / len(all_lons))
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return (None, None)
|
||||
|
||||
def _nearest_anchor_distance(self, lat, lon) -> tuple:
|
||||
"""Find distance to nearest region anchor.
|
||||
|
||||
Returns:
|
||||
(distance_km, anchor_name) or (None, None)
|
||||
"""
|
||||
if lat is None or lon is None or not self._region_anchors:
|
||||
return (None, None)
|
||||
|
||||
from ..geo import haversine_distance
|
||||
|
||||
min_dist = float("inf")
|
||||
nearest_name = None
|
||||
|
||||
for anchor in self._region_anchors:
|
||||
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_name = anchor.get("name") if isinstance(anchor, dict) else getattr(anchor, "name", "Unknown")
|
||||
|
||||
if anchor_lat is None or anchor_lon is None:
|
||||
continue
|
||||
|
||||
# haversine_distance returns miles, convert to km
|
||||
dist_miles = haversine_distance(lat, lon, anchor_lat, anchor_lon)
|
||||
dist_km = dist_miles * 1.60934
|
||||
|
||||
if dist_km < min_dist:
|
||||
min_dist = dist_km
|
||||
nearest_name = anchor_name
|
||||
|
||||
if min_dist < float("inf"):
|
||||
return (min_dist, nearest_name)
|
||||
|
||||
return (None, None)
|
||||
|
||||
def get_events(self) -> list:
|
||||
"""Get current fire events."""
|
||||
return self._events
|
||||
|
||||
@property
|
||||
def health_status(self) -> dict:
|
||||
"""Get adapter health status."""
|
||||
return {
|
||||
"source": "nifc",
|
||||
"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,
|
||||
}
|
||||
32
meshai/env/store.py
vendored
32
meshai/env/store.py
vendored
|
|
@ -13,12 +13,13 @@ logger = logging.getLogger(__name__)
|
|||
class EnvironmentalStore:
|
||||
"""Cache and tick-driver for all environmental feed adapters."""
|
||||
|
||||
def __init__(self, config: "EnvironmentalConfig"):
|
||||
def __init__(self, config: "EnvironmentalConfig", region_anchors: list = None):
|
||||
self._adapters = {} # name -> adapter instance
|
||||
self._events = {} # (source, event_id) -> event dict
|
||||
self._swpc_status = {} # Kp/SFI/scales snapshot
|
||||
self._ducting_status = {} # tropo ducting assessment
|
||||
self._mesh_zones = config.nws_zones or []
|
||||
self._region_anchors = region_anchors or []
|
||||
|
||||
# Create adapter instances based on config
|
||||
if config.nws.enabled:
|
||||
|
|
@ -33,6 +34,14 @@ class EnvironmentalStore:
|
|||
from .ducting import DuctingAdapter
|
||||
self._adapters["ducting"] = DuctingAdapter(config.ducting)
|
||||
|
||||
if config.fires.enabled:
|
||||
from .fires import NICFFiresAdapter
|
||||
self._adapters["nifc"] = NICFFiresAdapter(config.fires, self._region_anchors)
|
||||
|
||||
if config.avalanche.enabled:
|
||||
from .avalanche import AvalancheAdapter
|
||||
self._adapters["avalanche"] = AvalancheAdapter(config.avalanche)
|
||||
|
||||
logger.info(f"EnvironmentalStore initialized with {len(self._adapters)} adapters")
|
||||
|
||||
def refresh(self) -> bool:
|
||||
|
|
@ -161,6 +170,27 @@ class EnvironmentalStore:
|
|||
lines.append(f"Tropospheric: {condition.replace('_', ' ').title()}")
|
||||
lines.append(f" dM/dz: {gradient} M-units/km, duct ~{thickness}m thick")
|
||||
|
||||
# Active fires
|
||||
fires = self.get_active(source="nifc")
|
||||
if fires:
|
||||
lines.append(f"Wildfires: {len(fires)} active")
|
||||
for f in fires[:2]:
|
||||
name = f.get("name", "Unknown")
|
||||
acres = f.get("acres", 0)
|
||||
pct = f.get("pct_contained", 0)
|
||||
dist = f.get("distance_km")
|
||||
lines.append(f" - {name}: {int(acres):,} ac, {int(pct)}% contained" +
|
||||
(f" ({int(dist)} km)" if dist else ""))
|
||||
|
||||
# Avalanche advisories
|
||||
avy = self.get_active(source="avalanche")
|
||||
if avy:
|
||||
lines.append(f"Avalanche: {len(avy)} zone(s) with advisories")
|
||||
for a in avy[:2]:
|
||||
zone = a.get("zone_name", "Unknown")
|
||||
danger = a.get("danger_name", "Unknown")
|
||||
lines.append(f" - {zone}: {danger}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def get_source_health(self) -> list:
|
||||
|
|
|
|||
|
|
@ -337,7 +337,9 @@ class MeshAI:
|
|||
env_cfg = self.config.environmental
|
||||
if env_cfg.enabled:
|
||||
from .env.store import EnvironmentalStore
|
||||
self.env_store = EnvironmentalStore(config=env_cfg)
|
||||
# Pass region anchors for fire proximity calculation
|
||||
region_anchors = self.config.mesh_intelligence.regions if self.config.mesh_intelligence.enabled else []
|
||||
self.env_store = EnvironmentalStore(config=env_cfg, region_anchors=region_anchors)
|
||||
logger.info(f"Environmental feeds enabled ({len(self.env_store._adapters)} adapters)")
|
||||
else:
|
||||
self.env_store = None
|
||||
|
|
|
|||
|
|
@ -89,11 +89,12 @@ _MESH_PHRASES = [
|
|||
|
||||
# Keywords that indicate environmental/weather/propagation questions
|
||||
_ENV_KEYWORDS = {
|
||||
"weather", "alert", "warning", "fire", "smoke", "road", "closure",
|
||||
"snow", "avalanche", "avy", "solar", "hf", "propagation", "kp",
|
||||
"aurora", "blackout", "flood", "stream", "river", "ducting",
|
||||
"tropo", "duct", "uhf", "vhf", "906", "band", "conditions",
|
||||
"forecast", "sfi", "ionosphere", "geomagnetic", "storm",
|
||||
"weather", "alert", "warning", "fire", "wildfire", "smoke", "burn",
|
||||
"road", "closure", "snow", "avalanche", "avy", "backcountry",
|
||||
"solar", "hf", "propagation", "kp", "aurora", "blackout",
|
||||
"flood", "stream", "river", "ducting", "tropo", "duct",
|
||||
"uhf", "vhf", "band", "conditions", "forecast", "sfi",
|
||||
"ionosphere", "geomagnetic", "storm",
|
||||
}
|
||||
|
||||
# City name to region mapping (hardcoded fallback)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue