Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 15 additions & 6 deletions poller/pollers/nifc.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import logging
import math
from datetime import datetime, timezone
Expand Down Expand Up @@ -80,8 +81,6 @@ async def poll(self):
}

# 2. Determine which specific distant fires to fetch (targeted sync)
from bus import get_bus
import json
redis = await get_bus()
keys = await redis.keys("entity:fire:*")
fire_names: set[str] = set()
Expand All @@ -93,10 +92,12 @@ async def poll(self):
ent = json.loads(raw)
name = ent.get("display_name")
if name and name != "Wildfire":
clean_name = name.split(',')[0].replace(" WILDFIRE", "").replace(" FIRE", "").strip()
# Uppercase BEFORE stripping suffixes — EONET titles are title-case
# ("Haystack Butte Wildfire"), and NIFC IncidentName omits the suffix.
clean_name = name.split(',')[0].upper().replace(" WILDFIRE", "").replace(" FIRE", "").strip()
if clean_name:
fire_names.add(clean_name.upper())
except: continue
fire_names.add(clean_name)
except (json.JSONDecodeError, TypeError, AttributeError): continue

# Combine searches if we have distant fires to track
features: list[dict] = []
Expand All @@ -105,13 +106,17 @@ async def poll(self):
r1 = await client.get(_NIFC_BASE, params=spatial_params)
r1.raise_for_status()
data1 = r1.json()
# ArcGIS returns HTTP 200 with an "error" object on bad queries —
# treat that as a fetch failure, not a confirmed-empty result.
if isinstance(data1, dict) and data1.get("error"):
raise RuntimeError(f"ArcGIS error: {data1['error'].get('message', data1['error'])}")
f1 = data1.get("features") or []
features.extend(f1)
logger.debug("[nifc] spatial sync returned %d features", len(f1))

# supplement with distant named fires if any
if fire_names:
names_str = ",".join([f"'{n}'" for n in fire_names])
names_str = ",".join([f"""'{n.replace("'", "''")}'""" for n in fire_names])
name_params = {
"where": f"UPPER(IncidentName) IN ({names_str})",
"outFields": _FIELDS,
Expand All @@ -129,7 +134,11 @@ async def poll(self):
if not features: return

if not features:
# Successful query with zero results is a confirmed negative — write an
# empty collection so consumers (AI summary) can distinguish "no
# perimeters" from "feed never synced".
logger.info("[nifc] zero perimeters returned from ArcGIS (spatial bbox: %s)", bbox)
await set_feed("fire:perimeters", {"type": "FeatureCollection", "features": []})
return

# De-duplicate by a hash of geometry or incident ID if possible,
Expand Down
54 changes: 50 additions & 4 deletions poller/pollers/summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,49 @@ async def _generate(self, r):
else:
context_parts.append("WEATHER STATUS: Data feed currently unavailable.")

# 2. Fire Activity (NIFC/EONET)
# 2a. Fire Incident Tracker (EONET entities — same data the UI fire card shows)
fire_entities: list[dict] = []
for key in await r.keys("entity:fire:*"):
ent_raw = await r.get(key)
if not ent_raw:
continue
try:
ent = json.loads(ent_raw)
except (json.JSONDecodeError, TypeError):
continue
if isinstance(ent, dict):
fire_entities.append(ent)
if fire_entities:
# Local (alert radius) fires first, then by distance.
fire_entities.sort(key=lambda e: (
(e.get("identity") or {}).get("relevance") != "local",
e.get("distance_km") or 0,
))
lines = []
for ent in fire_entities[:10]:
relevance = (ent.get("identity") or {}).get("relevance")
tag = "ALERT (local radius)" if relevance == "local" else "WATCH (regional smoke source)"
name = _sanitise(str(ent.get("display_name") or "Wildfire"), 120)
dist = ent.get("distance_km")
dist_text = f"{round(dist)} km" if isinstance(dist, (int, float)) else "distance unknown"
updated = ent.get("last_seen") or "update time unknown"
lines.append(f"- {tag}: {name} — {dist_text}, last update {updated}")
context_parts.append("WILDFIRE INCIDENT TRACKER:\n" + "\n".join(lines))
else:
context_parts.append(
"WILDFIRE INCIDENT TRACKER: No wildfire incidents inside the local alert or regional watch radius."
)

# 2b. Smoke / Air Quality (AirNow via weather feed)
raw = await r.get("feed:weather:current")
if raw:
wx = json.loads(raw)
aqi = wx.get("aqi") if isinstance(wx, dict) else None
if aqi is not None:
label = wx.get("aqi_label") or "Unknown"
context_parts.append(f"SMOKE / AIR QUALITY: AQI {aqi} ({label}).")

# 2c. Fire Perimeters (NIFC mapped polygons)
raw = await r.get("feed:fire:perimeters")
if raw:
payload = json.loads(raw)
Expand All @@ -114,11 +156,15 @@ async def _generate(self, r):
acres = props.get("acres")
acres_text = f"{acres} acres" if acres is not None else "acreage unknown"
lines.append(f"- {name}: {state} ({acres_text})")
context_parts.append("WILDFIRE ACTIVITY:\n" + "\n".join(lines))
context_parts.append("MAPPED FIRE PERIMETERS (NIFC):\n" + "\n".join(lines))
else:
context_parts.append("FIRE STATUS: No active wildfire perimeters in regional radius.")
context_parts.append(
"MAPPED FIRE PERIMETERS (NIFC): Confirmed zero mapped perimeters in the regional query area."
)
else:
context_parts.append("FIRE STATUS: Data feed currently unavailable.")
context_parts.append(
"MAPPED FIRE PERIMETERS (NIFC): Perimeter feed has not synced; rely on the incident tracker above for fire status."
)

# 3. Traffic Impacts (ODOT/Real-time)
raw = await r.get("feed:traffic:incidents")
Expand Down
Loading