fire: remove fire digest feature and drop out-of-coverage fires at ingest (#107)

Two fire-scope cleanups:

1. Remove the fire digest feature entirely -- scheduler
   (notifications/scheduled/fire_digest.py), pipeline wiring, the
   fires.digest_* adapter_config key registrations, and the Fire Digest
   dashboard UI (ScheduledBroadcasts / Environment / Reference /
   AdapterConfig / ActivityLog). The unrelated generic per-rule
   notification digest is kept. Orphaned fires.digest_* config rows and the
   fire_digest_broadcasts table are left as inert data (v16 migration
   untouched).

2. Add a coverage-scope gate at fire ingest: _ingest_fires now skips any
   fire whose coordinates fall outside all configured coverage areas (same
   areas_from_config + classify_geom_areas membership the dispatch-level
   CoverageFilter uses), so out-of-coverage fires are never stored, tracked,
   alerted, reminded, or re-ingested. Fails open when coverage is disabled,
   has no areas, or excludes the fires adapter.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
malice 2026-07-09 13:46:12 -06:00 committed by GitHub
commit b0b0697bac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 189 additions and 960 deletions

View file

@ -112,7 +112,6 @@ const CATEGORIES = [
{ value: 'all', label: 'All types' },
{ value: 'nws_alerts', label: 'Weather' },
{ value: 'fires', label: 'Fires' },
{ value: 'fire_digest_broadcasts', label: 'Fire digest' },
{ value: 'satpass_events', label: 'Satellite' },
{ value: 'band_conditions_broadcasts', label: 'Band' },
{ value: 'traffic_events', label: 'Traffic' },

View file

@ -22,7 +22,6 @@ import {
// Advanced (raw) view to avoid double-editing.
export const CURATED_KEYS: Record<string, string[]> = {
wfigs: ['allowed_incident_types', 'freshness_seconds', 'cooldown_seconds', 'broadcast_on_acres', 'broadcast_on_contained'],
fires: ['digest_enabled', 'digest_schedule', 'digest_timezone'],
tomtom_incidents: ['min_magnitude', 'drop_non_present', 'drop_zero_magnitude'],
itd_511: ['min_severity', 'enabled_categories', 'enabled_sub_types'],
wzdx: ['broadcast', 'min_severity', 'sub_types'],

View file

@ -74,13 +74,6 @@ interface WfigsConfig {
broadcast_on_contained: boolean
}
// Fires adapter config shape (digest settings)
interface FiresConfig {
digest_enabled: boolean
digest_schedule: string[]
digest_timezone: string
}
// ITD 511 adapter config shape
interface Roads511Config {
min_severity: string
@ -345,12 +338,6 @@ export default function Environment() {
broadcast_on_contained: true,
})
const [wfigsOriginal, setWfigsOriginal] = useState<string>("")
const [firesConfig, setFiresConfig] = useState<FiresConfig>({
digest_enabled: true,
digest_schedule: ["06:00", "18:00"],
digest_timezone: "America/Boise",
})
const [firesOriginal, setFiresOriginal] = useState<string>("")
const [tomtomConfig, setTomtomConfig] = useState<TomtomConfig>({
min_magnitude: 4,
drop_non_present: true,
@ -457,21 +444,6 @@ export default function Environment() {
}
} catch { /* adapter-config optional */ }
// Load adapter-config for fires/digest (array → object fix: line ~367)
try {
const firesRes = await fetch("/api/adapter-config/fires")
if (firesRes.ok) {
const firesData = toMap(await firesRes.json())
const cfg: FiresConfig = {
digest_enabled: (firesData.digest_enabled?.value as boolean) ?? true,
digest_schedule: (firesData.digest_schedule?.value as string[]) ?? ["06:00", "18:00"],
digest_timezone: (firesData.digest_timezone?.value as string) ?? "America/Boise",
}
setFiresConfig(cfg)
setFiresOriginal(JSON.stringify(cfg))
}
} catch { /* adapter-config optional */ }
// Load adapter-config for tomtom_incidents (array → object fix: line ~382)
try {
const ttRes = await fetch("/api/adapter-config/tomtom_incidents")
@ -658,7 +630,6 @@ export default function Environment() {
const hasEnvChanges = env !== null && JSON.stringify(env) !== original
const hasWfigsChanges = JSON.stringify(wfigsConfig) !== wfigsOriginal
const hasFiresChanges = JSON.stringify(firesConfig) !== firesOriginal
const hasTomtomChanges = JSON.stringify(tomtomConfig) !== tomtomOriginal
const hasRoads511Changes = JSON.stringify(roads511Config) !== roads511Original
const hasWzdxChanges = JSON.stringify(wzdxConfig) !== wzdxOriginal
@ -666,7 +637,7 @@ export default function Environment() {
const hasAvalancheChanges = JSON.stringify(avalancheConfig) !== avalancheOriginal
const hasSwpcChanges = JSON.stringify(swpcConfig) !== swpcOriginal
const hasSatpassChanges = JSON.stringify(satpassConfig) !== satpassOriginal
const hasChanges = hasEnvChanges || hasWfigsChanges || hasFiresChanges || hasTomtomChanges || hasRoads511Changes || hasWzdxChanges || hasNwsChanges || hasAvalancheChanges || hasSwpcChanges || hasSatpassChanges
const hasChanges = hasEnvChanges || hasWfigsChanges || hasTomtomChanges || hasRoads511Changes || hasWzdxChanges || hasNwsChanges || hasAvalancheChanges || hasSwpcChanges || hasSatpassChanges
const saveAdapterConfig = async (adapterName: string, key: string, value: unknown) => {
@ -731,21 +702,6 @@ const save = async () => {
setWfigsOriginal(JSON.stringify(wfigsConfig))
}
// Save fires adapter config changes (digest)
if (hasFiresChanges) {
const orig = JSON.parse(firesOriginal) as FiresConfig
if (firesConfig.digest_enabled !== orig.digest_enabled) {
await saveAdapterConfig("fires", "digest_enabled", firesConfig.digest_enabled)
}
if (JSON.stringify(firesConfig.digest_schedule) !== JSON.stringify(orig.digest_schedule)) {
await saveAdapterConfig("fires", "digest_schedule", firesConfig.digest_schedule)
}
if (firesConfig.digest_timezone !== orig.digest_timezone) {
await saveAdapterConfig("fires", "digest_timezone", firesConfig.digest_timezone)
}
setFiresOriginal(JSON.stringify(firesConfig))
}
// Save tomtom adapter config changes
if (hasTomtomChanges) {
const orig = JSON.parse(tomtomOriginal) as TomtomConfig
@ -863,7 +819,6 @@ const save = async () => {
const discard = () => {
if (env) setEnv(JSON.parse(original))
setWfigsConfig(JSON.parse(wfigsOriginal || JSON.stringify(wfigsConfig)))
setFiresConfig(JSON.parse(firesOriginal || JSON.stringify(firesConfig)))
setTomtomConfig(JSON.parse(tomtomOriginal || JSON.stringify(tomtomConfig)))
setRoads511Config(JSON.parse(roads511Original || JSON.stringify(roads511Config)))
setWzdxConfig(JSON.parse(wzdxOriginal || JSON.stringify(wzdxConfig)))
@ -1136,32 +1091,6 @@ const save = async () => {
<NumberInput label="Update Cooldown (hours)" value={Math.round(wfigsConfig.cooldown_seconds / 3600)} onChange={(v) => setWfigsConfig({ ...wfigsConfig, cooldown_seconds: v * 3600 })} min={0} helper="Minimum hours between updates for the same fire" />
<NumberInput label="Freshness Window (hours)" value={Math.round(wfigsConfig.freshness_seconds / 3600)} onChange={(v) => setWfigsConfig({ ...wfigsConfig, freshness_seconds: v * 3600 })} min={0} helper="0 = always broadcast regardless of event age" />
</div>
<div className="border-t border-border pt-4 mt-2">
<div className="text-[10px] font-sans font-medium uppercase tracking-widest text-[#666] mb-3">Fire Digest</div>
<label className="flex items-center justify-between">
<span className="text-sm font-sans text-[#e0e0e0]">Enable daily digest</span>
<input type="checkbox" checked={firesConfig.digest_enabled}
onChange={(e) => setFiresConfig({ ...firesConfig, digest_enabled: e.target.checked })}
className="w-4 h-4 accent-[#f59e0b]" />
</label>
{firesConfig.digest_enabled && (
<div className="mt-3 space-y-3">
<ListInput label="Schedule (HH:MM)" value={firesConfig.digest_schedule}
onChange={(v) => setFiresConfig({ ...firesConfig, digest_schedule: v })}
helper="Digest times in HH:MM format, e.g. 06:00 and 18:00" />
<SelectInput label="Timezone" value={firesConfig.digest_timezone}
onChange={(v) => setFiresConfig({ ...firesConfig, digest_timezone: v })}
options={[
{ value: 'America/Boise', label: 'Mountain — America/Boise' },
{ value: 'America/Los_Angeles', label: 'Pacific — America/Los_Angeles' },
{ value: 'America/Denver', label: 'Mountain — America/Denver' },
{ value: 'America/Chicago', label: 'Central — America/Chicago' },
{ value: 'America/New_York', label: 'Eastern — America/New_York' },
{ value: 'UTC', label: 'UTC' },
]} />
</div>
)}
</div>
</div>
)
case 'avalanche': return (

View file

@ -367,7 +367,7 @@ export default function Reference() {
</ul>
</TopicSection>
{/* Fire Tracker (v0.7 fusion: FIRMS + WFIGS + LLM digest) */}
{/* Fire Tracker (v0.7 fusion: FIRMS + WFIGS) */}
<TopicSection id="fire-tracker" title="Fire Tracker (Fusion)">
<p>
FIRMS hotspots are fast but noisy; WFIGS incidents are accurate but slow.
@ -402,16 +402,6 @@ export default function Reference() {
]}
/>
<SectionHeader>Daily LLM digest</SectionHeader>
<p>
Twice a day (default 06:00 and 18:00 Mountain Time) the bot runs an LLM
summary across every active fire and the last 24 h of growth + spotting
events, then broadcasts one terse line to the mesh. Shape:{' '}
<span className="text-amber-300">"Fires today: Cache Peak 1,847 ac +200 NE; Twin Peaks 320 ac stable; possible new fire 15 mi from Cache Peak."</span>{' '}
Configure the schedule and timezone under <Mono>fires.digest_*</Mono>{' '}
keys on the Adapter Config page.
</p>
<SectionHeader>How attribution works</SectionHeader>
<p>
When a FIRMS hotspot lands, the bot walks every active fire (those not
@ -465,10 +455,6 @@ export default function Reference() {
[<Mono>halt_minimum_seconds</Mono>, '43,200 (12 h)', 'Minimum elapsed seconds since the most recent attributed pixel before wildfire_halted can fire.'],
[<Mono>spotting_distance_threshold_mi</Mono>, '1.5 mi', 'Distance from prior-pass perimeter that fires wildfire_spotting.'],
[<Mono>spotting_cooldown_seconds</Mono>, '3,600 (1 h)', 'Minimum seconds between consecutive spotting broadcasts per fire.'],
[<Mono>digest_enabled</Mono>, 'true', 'Master toggle for the twice-daily digest.'],
[<Mono>digest_schedule</Mono>, '["06:00","18:00"]', 'Local-time slots for the digest.'],
[<Mono>digest_timezone</Mono>, 'America/Boise', 'IANA tz for digest_schedule.'],
[<Mono>digest_max_chars</Mono>, '200', 'Hard cap on the digest wire (the LLM is told to fit; the chunker enforces).'],
]}
/>
</TopicSection>

View file

@ -4,17 +4,10 @@ import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig } from '
import { useDirty } from '@/context/DirtyContext'
import { notifyRestartRequired } from '@/components/RestartBanner'
import {
Toggle, NumberInput, TimeInput, InfoButton,
Toggle, NumberInput, TimeInput,
type NotificationsConfig,
} from '@/pages/Notifications'
// Fires adapter config shape (digest settings)
interface FiresConfig {
digest_enabled: boolean
digest_schedule: string[]
digest_timezone: string
}
interface Props {
family?: 'meshtastic' | 'meshcore'
}
@ -26,14 +19,6 @@ export default function ScheduledBroadcasts({ family = 'meshtastic' }: Props) {
const [notifConfig, setNotifConfig] = useState<NotificationsConfig | null>(null)
const [originalNotifConfig, setOriginalNotifConfig] = useState<NotificationsConfig | null>(null)
// Fires adapter config state
const [firesConfig, setFiresConfig] = useState<FiresConfig>({
digest_enabled: true,
digest_schedule: ['06:00', '18:00'],
digest_timezone: 'America/Boise',
})
const [originalFiresConfig, setOriginalFiresConfig] = useState<string>('')
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
@ -49,24 +34,6 @@ export default function ScheduledBroadcasts({ family = 'meshtastic' }: Props) {
setNotifConfig(notif)
setOriginalNotifConfig(JSON.parse(JSON.stringify(notif)))
// Load fires adapter config
try {
const firesRes = await fetch('/api/adapter-config/fires')
if (firesRes.ok) {
const firesData = await firesRes.json()
const fires: FiresConfig = {
digest_enabled: firesData.digest_enabled?.value ?? true,
digest_schedule: firesData.digest_schedule?.value ?? ['06:00', '18:00'],
digest_timezone: firesData.digest_timezone?.value ?? 'America/Boise',
}
setFiresConfig(fires)
setOriginalFiresConfig(JSON.stringify(fires))
}
} catch {
// adapter-config optional — proceed with defaults
setOriginalFiresConfig(JSON.stringify(firesConfig))
}
setHasChanges(false)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load config')
@ -83,28 +50,15 @@ export default function ScheduledBroadcasts({ family = 'meshtastic' }: Props) {
useEffect(() => {
if (notifConfig && originalNotifConfig) {
const notifChanged = JSON.stringify(notifConfig) !== JSON.stringify(originalNotifConfig)
const firesChanged = JSON.stringify(firesConfig) !== originalFiresConfig
setHasChanges(notifChanged || firesChanged)
setHasChanges(notifChanged)
}
}, [notifConfig, originalNotifConfig, firesConfig, originalFiresConfig])
}, [notifConfig, originalNotifConfig])
useEffect(() => {
setDirty(hasChanges)
return () => setDirty(false)
}, [hasChanges, setDirty])
const saveAdapterKey = async (adapter: string, key: string, value: unknown) => {
const res = await fetch(`/api/adapter-config/${adapter}/${key}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value }),
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.detail || `Failed to save ${adapter}.${key}`)
}
}
const saveConfig = async () => {
if (!notifConfig) return
setSaving(true)
@ -116,19 +70,6 @@ export default function ScheduledBroadcasts({ family = 'meshtastic' }: Props) {
setOriginalNotifConfig(JSON.parse(JSON.stringify(notifConfig)))
if (result.restart_required) notifyRestartRequired([])
// Save fires adapter config — only changed keys
const origFires = originalFiresConfig ? (JSON.parse(originalFiresConfig) as FiresConfig) : null
if (!origFires || firesConfig.digest_enabled !== origFires.digest_enabled) {
await saveAdapterKey('fires', 'digest_enabled', firesConfig.digest_enabled)
}
if (!origFires || JSON.stringify(firesConfig.digest_schedule) !== JSON.stringify(origFires.digest_schedule)) {
await saveAdapterKey('fires', 'digest_schedule', firesConfig.digest_schedule)
}
if (!origFires || firesConfig.digest_timezone !== origFires.digest_timezone) {
await saveAdapterKey('fires', 'digest_timezone', firesConfig.digest_timezone)
}
setOriginalFiresConfig(JSON.stringify(firesConfig))
setHasChanges(false)
setDirty(false)
setSuccess('Scheduled broadcasts saved successfully')
@ -142,7 +83,6 @@ export default function ScheduledBroadcasts({ family = 'meshtastic' }: Props) {
const discardChanges = () => {
if (originalNotifConfig) setNotifConfig(JSON.parse(JSON.stringify(originalNotifConfig)))
if (originalFiresConfig) setFiresConfig(JSON.parse(originalFiresConfig))
setHasChanges(false)
}
@ -235,7 +175,7 @@ export default function ScheduledBroadcasts({ family = 'meshtastic' }: Props) {
label="Enable scheduled band-conditions broadcasts"
checked={notifConfig.band_conditions_enabled ?? true}
onChange={(v) => setNotifConfig({ ...notifConfig, band_conditions_enabled: v })}
helper="3x/day HF propagation summary (Day/Night ratings per band group). The daily fire digest (twice-daily LLM summary of active fires + the last 24h of growth/spotting) is configured separately under Adapter Config -> fires.digest_*. See Reference -> Fire Tracker (Fusion) and Reference -> Broadcast Types for the New/Update/Active prefix system."
helper="3x/day HF propagation summary (Day/Night ratings per band group). See Reference -> Fire Tracker (Fusion) and Reference -> Broadcast Types for the New/Update/Active prefix system."
info="Source priority: (1) recent SWPC readings persisted locally; (2) HamQSL.com fallback; (3) silent skip if both fail. Persistence rows are written either way for an audit trail."
/>
{(notifConfig.band_conditions_enabled ?? true) && (
@ -274,57 +214,6 @@ export default function ScheduledBroadcasts({ family = 'meshtastic' }: Props) {
)}
<p className="text-xs text-slate-600">All times are Mountain Time (America/Boise). DST handled automatically.</p>
</div>
{/* Fire Digest */}
<div className="bg-bg-card border border-border p-6 space-y-4">
<div className="flex items-center gap-2">
<label className="text-xs text-slate-500 uppercase tracking-wide">
Fire Digest
<InfoButton info="Twice-daily LLM summary of active fires + last 24h of growth/spotting events. Configured per the fires adapter." />
</label>
</div>
<Toggle
label="Enable fire digest broadcasts"
checked={firesConfig.digest_enabled}
onChange={(v) => setFiresConfig({ ...firesConfig, digest_enabled: v })}
helper="Send a twice-daily digest of active fire conditions to the mesh"
/>
{firesConfig.digest_enabled && (
<div className="grid grid-cols-2 gap-3">
<TimeInput
label="Digest Slot 1"
value={(firesConfig.digest_schedule ?? ['06:00', '18:00'])[0] || '06:00'}
onChange={(v) => {
const s = [...(firesConfig.digest_schedule ?? ['06:00', '18:00'])]
s[0] = v
setFiresConfig({ ...firesConfig, digest_schedule: s })
}}
helper="Morning digest (default 06:00 MT)"
/>
<TimeInput
label="Digest Slot 2"
value={(firesConfig.digest_schedule ?? ['06:00', '18:00'])[1] || '18:00'}
onChange={(v) => {
const s = [...(firesConfig.digest_schedule ?? ['06:00', '18:00'])]
s[1] = v
setFiresConfig({ ...firesConfig, digest_schedule: s })
}}
helper="Evening digest (default 18:00 MT)"
/>
</div>
)}
<div className="space-y-1">
<label className="text-xs text-slate-500 uppercase tracking-wide">Timezone</label>
<input
type="text"
value={firesConfig.digest_timezone}
onChange={(e) => setFiresConfig({ ...firesConfig, digest_timezone: e.target.value })}
placeholder="America/Boise"
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 focus:outline-none focus:border-accent"
/>
<p className="text-xs text-slate-600">IANA timezone name (e.g. America/Boise). DST handled automatically.</p>
</div>
</div>
</div>
)
}