mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-21 23:24:44 +02:00
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:
parent
bb36ebb8c3
commit
3d74eb92b0
13 changed files with 786 additions and 81 deletions
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue