feat(env): add NASA FIRMS satellite fire hotspot detection

- Implement FIRMSAdapter polling NASA FIRMS area API for satellite hotspots
- Cross-reference hotspots against NIFC perimeters to identify new ignitions
- Add !hotspots command with --new flag for filtering new ignitions only
- Add FIRMSConfig dataclass with map_key, source, bbox, day_range options
- Add /api/env/hotspots endpoint for dashboard integration
- Add Satellite Hotspots section to Environment.tsx with NEW badges
- Add FIRMS configuration section to Config.tsx with source/confidence options
- Update config.example.yaml with FIRMS configuration template

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
K7ZVX 2026-05-12 23:06:55 +00:00
commit 3d74eb92b0
13 changed files with 786 additions and 81 deletions

View file

@ -330,6 +330,36 @@ export interface RoadEvent {
}
}
export interface HotspotEvent {
source: string
event_id: string
event_type: string
headline: string
severity: string
lat?: number
lon?: number
expires: number
fetched_at: number
properties: {
new_ignition: boolean
confidence: string
frp?: number
brightness?: number
acq_date: string
acq_time: string
near_fire?: string
distance_to_fire_km?: number
distance_km?: number
nearest_anchor?: string
}
}
export interface HotspotsResponse {
enabled: boolean
hotspots: HotspotEvent[]
new_ignitions: number
}
export interface AvalancheResponse {
off_season: boolean
advisories: AvalancheEvent[]
@ -355,6 +385,10 @@ export async function fetchRoads(): Promise<RoadEvent[]> {
return fetchJson<RoadEvent[]>('/api/env/roads')
}
export async function fetchHotspots(): Promise<HotspotsResponse> {
return fetchJson<HotspotsResponse>('/api/env/hotspots')
}
export async function fetchRegions(): Promise<RegionInfo[]> {
return fetchJson<RegionInfo[]>('/api/regions')
}

View file

@ -189,6 +189,7 @@ interface EnvironmentalConfig {
usgs: { enabled: boolean; tick_seconds: number; sites: string[] }
traffic: { enabled: boolean; tick_seconds: number; api_key: string; corridors: { name: string; lat: number; lon: number }[] }
roads511: { enabled: boolean; tick_seconds: number; api_key: string; base_url: string; endpoints: string[]; bbox: number[] }
firms: { enabled: boolean; tick_seconds: number; map_key: string; source: string; bbox: number[]; day_range: number; confidence_min: string; proximity_km: number }
}
interface DashboardConfig {
@ -1070,6 +1071,64 @@ function EnvironmentalSection({ data, onChange }: { data: EnvironmentalConfig; o
</>
)}
</div>
<div className="border border-[#1e2a3a] rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-slate-300">NASA FIRMS Satellite Fire Detection</span>
<Toggle label="" checked={data.firms?.enabled || false} onChange={(v) => onChange({ ...data, firms: { ...data.firms, enabled: v, tick_seconds: data.firms?.tick_seconds || 1800, map_key: data.firms?.map_key || '', source: data.firms?.source || 'VIIRS_SNPP_NRT', bbox: data.firms?.bbox || [], day_range: data.firms?.day_range || 1, confidence_min: data.firms?.confidence_min || 'nominal', proximity_km: data.firms?.proximity_km || 10 } })} />
</div>
{data.firms?.enabled && (
<>
<TextInput label="MAP Key" value={data.firms.map_key} onChange={(v) => onChange({ ...data, firms: { ...data.firms, map_key: v } })} type="password" helper="Get key at firms.modaps.eosdis.nasa.gov/api/area/" />
<NumberInput label="Tick Seconds" value={data.firms.tick_seconds} onChange={(v) => onChange({ ...data, firms: { ...data.firms, tick_seconds: v } })} min={300} />
<SelectInput
label="Satellite Source"
value={data.firms.source}
onChange={(v) => onChange({ ...data, firms: { ...data.firms, source: v } })}
options={[
{ value: 'VIIRS_SNPP_NRT', label: 'VIIRS SNPP (Near Real-Time)' },
{ value: 'VIIRS_NOAA20_NRT', label: 'VIIRS NOAA-20 (Near Real-Time)' },
{ value: 'MODIS_NRT', label: 'MODIS (Near Real-Time)' },
]}
/>
<NumberInput label="Day Range" value={data.firms.day_range} onChange={(v) => onChange({ ...data, firms: { ...data.firms, day_range: v } })} min={1} max={10} helper="1-10 days of data" />
<SelectInput
label="Minimum Confidence"
value={data.firms.confidence_min}
onChange={(v) => onChange({ ...data, firms: { ...data.firms, confidence_min: v } })}
options={[
{ value: 'low', label: 'Low' },
{ value: 'nominal', label: 'Nominal' },
{ value: 'high', label: 'High' },
]}
/>
<NumberInput label="Proximity (km)" value={data.firms.proximity_km} onChange={(v) => onChange({ ...data, firms: { ...data.firms, proximity_km: v } })} step={0.5} helper="Distance to match known fires" />
<div className="grid grid-cols-4 gap-2">
<NumberInput label="West" value={data.firms.bbox?.[0] || 0} onChange={(v) => {
const bbox = [...(data.firms.bbox || [0, 0, 0, 0])]
bbox[0] = v
onChange({ ...data, firms: { ...data.firms, bbox } })
}} step={0.01} />
<NumberInput label="South" value={data.firms.bbox?.[1] || 0} onChange={(v) => {
const bbox = [...(data.firms.bbox || [0, 0, 0, 0])]
bbox[1] = v
onChange({ ...data, firms: { ...data.firms, bbox } })
}} step={0.01} />
<NumberInput label="East" value={data.firms.bbox?.[2] || 0} onChange={(v) => {
const bbox = [...(data.firms.bbox || [0, 0, 0, 0])]
bbox[2] = v
onChange({ ...data, firms: { ...data.firms, bbox } })
}} step={0.01} />
<NumberInput label="North" value={data.firms.bbox?.[3] || 0} onChange={(v) => {
const bbox = [...(data.firms.bbox || [0, 0, 0, 0])]
bbox[3] = v
onChange({ ...data, firms: { ...data.firms, bbox } })
}} step={0.01} />
</div>
<div className="text-xs text-slate-500">Bounding box for monitoring area (required)</div>
</>
)}
</div>
</>
)}
</div>

View file

@ -12,6 +12,7 @@ import {
Mountain,
Droplets,
Car,
Satellite,
} from 'lucide-react'
import {
fetchEnvStatus,
@ -23,6 +24,7 @@ import {
fetchStreams,
fetchTraffic,
fetchRoads,
fetchHotspots,
type EnvStatus,
type EnvEvent,
type SWPCStatus,
@ -32,6 +34,8 @@ import {
type StreamGaugeEvent,
type TrafficEvent,
type RoadEvent,
type HotspotEvent,
} 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 } }) {
@ -359,6 +363,8 @@ export default function Environment() {
const [streams, setStreams] = useState<StreamGaugeEvent[]>([])
const [traffic, setTraffic] = useState<TrafficEvent[]>([])
const [roads, setRoads] = useState<RoadEvent[]>([])
const [hotspots, setHotspots] = useState<HotspotEvent[]>([])
const [newIgnitions, setNewIgnitions] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@ -373,8 +379,9 @@ export default function Environment() {
fetchStreams().catch(() => []),
fetchTraffic().catch(() => []),
fetchRoads().catch(() => []),
fetchHotspots().catch(() => ({ hotspots: [], new_ignitions: 0 })),
])
.then(([status, active, swpcData, ductingData, firesData, avyData, streamsData, trafficData, roadsData]) => {
.then(([status, active, swpcData, ductingData, firesData, avyData, streamsData, trafficData, roadsData, hotspotsData]) => {
setEnvStatus(status)
setEvents(active)
setSWPC(swpcData)
@ -384,6 +391,8 @@ export default function Environment() {
setStreams(streamsData || [])
setTraffic(trafficData || [])
setRoads(roadsData || [])
setHotspots(hotspotsData?.hotspots || [])
setNewIgnitions(hotspotsData?.new_ignitions || 0)
setLoading(false)
})
.catch((err) => {
@ -690,6 +699,60 @@ export default function Environment() {
</div>
)}
{/* Satellite Hotspots */}
{hotspots.length > 0 && (
<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">
<Satellite size={14} />
Satellite Hotspots ({hotspots.length})
{newIgnitions > 0 && (
<span className="ml-2 px-2 py-0.5 text-xs rounded-full bg-red-500/20 text-red-400 animate-pulse">
{newIgnitions} NEW
</span>
)}
</h2>
<div className="space-y-2">
{hotspots.map((h) => (
<div
key={h.event_id}
className={`p-3 rounded-lg ${
h.properties?.new_ignition
? 'bg-red-500/10 border-l-2 border-red-500'
: h.severity === 'watch'
? 'bg-amber-500/10 border-l-2 border-amber-500'
: 'bg-orange-500/10 border-l-2 border-orange-500'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{h.properties?.new_ignition && (
<span className="text-xs px-1.5 py-0.5 rounded bg-red-500/20 text-red-400">
NEW
</span>
)}
<span className="text-sm text-slate-200">
{h.headline}
</span>
</div>
{h.properties?.frp && (
<span className="text-sm font-mono text-orange-400">
{Math.round(h.properties.frp)} MW
</span>
)}
</div>
<div className="text-xs text-slate-500 mt-1 flex items-center gap-3">
<span>Conf: {h.properties?.confidence || 'N/A'}</span>
{h.properties?.acq_time && <span>@{h.properties.acq_time}Z</span>}
{h.properties?.near_fire && (
<span>Near: {h.properties.near_fire}</span>
)}
</div>
</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">