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:
zvx-echo6 2026-05-12 15:22:07 -06:00
commit 2255ca5803
15 changed files with 1013 additions and 93 deletions

View file

@ -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')
}

View file

@ -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">

View file

@ -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

View 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)

View file

@ -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():

View 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)

View file

@ -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"),
}

View file

@ -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
View 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
View 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
View file

@ -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:

View file

@ -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

View file

@ -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)