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

View file

@ -318,7 +318,7 @@ REGISTRY: dict[tuple[str, str], dict[str, Any]] = {
},
# =================================================================
# FIRES -- 10 settings (P1 radius + P2 growth/halt + P3 spotting + P4 digest)
# FIRES -- 7 settings (P1 radius + P2 growth/halt + P3 spotting)
# =================================================================
# Per-fire spread radius override lives in fires.spread_radius_mi;
# the value below is the fallback. v0.7-fire-1 shipped 5 mi based on
@ -394,43 +394,6 @@ REGISTRY: dict[tuple[str, str], dict[str, Any]] = {
"type": "int",
"description": "Minimum seconds between consecutive wildfire_spotting broadcasts for the same fire; suppresses rapid-ember spam.",
},
# v0.7-fire-4 -- daily fire digest scheduled broadcaster.
# digest_enabled: master switch. Off by default for prod safety;
# flip via GUI once the digest wording is dialed in.
("fires", "digest_enabled"): {
"default": True,
"type": "bool",
"description": "Whether the fire-digest scheduler broadcasts at the configured slots. Off => no broadcasts even if all other config is valid.",
},
# digest_broadcast_enabled: independent kill-switch on the actual mesh
# emission. Disabled by default so the scheduler keeps running (building /
# recording digests) without putting the twice-daily digest on the mesh.
# Per-fire wfigs alerts are unaffected.
("fires", "digest_broadcast_enabled"): {
"default": False,
"type": "bool",
"description": "Emit the twice-daily fire-digest broadcast. Disabled by default; per-fire wfigs alerts are unaffected.",
},
# digest_schedule: list of HH:MM strings, local-time per digest_timezone.
# Mirrors band_conditions_schedule shape so operators can reason
# about the two side-by-side.
("fires", "digest_schedule"): {
"default": ["06:00", "18:00"],
"type": "json",
"description": "Local-time HH:MM slots for the fire-digest broadcast (list of strings). Honor digest_timezone for wall-clock semantics.",
},
("fires", "digest_timezone"): {
"default": "America/Boise",
"type": "str",
"description": "IANA tz used to interpret digest_schedule.",
},
# digest_max_chars: mesh wire cap. The LLM is told to fit under this.
# Reuses the response.max_length chunking if the LLM ignores the cap.
("fires", "digest_max_chars"): {
"default": 140,
"type": "int",
"description": "Hard cap on the digest wire string length (chars). The LLM prompt asks to fit; the chunker enforces.",
},
# =================================================================
# FIRMS -- 7 settings (storage floors + dedup + 3 v0.7 cluster knobs)
@ -509,7 +472,7 @@ REGISTRY: dict[tuple[str, str], dict[str, Any]] = {
("reminders_wfigs", "enabled"): {
"default": False,
"type": "bool",
"description": "Enable Active: reminder broadcasts for ongoing fires. Disabled by default — use the digest instead.",
"description": "Enable Active: reminder broadcasts for ongoing fires. Disabled by default.",
},
("reminders_wfigs", "cadence_kind"): {
"default": "interval",

View file

@ -40,6 +40,7 @@ class EnvironmentalStore:
event_bus: Optional["EventBus"] = None,
coverage_bbox: list = None,
coverage_excluded: list = None,
coverage_areas: list = None,
generic_sources: list = None,
):
# Config-driven REST/GeoJSON sources (top-level config.generic_sources)
@ -56,6 +57,38 @@ class EnvironmentalStore:
self._coverage_bbox = coverage_bbox or []
self._coverage_excluded = set(coverage_excluded or [])
# ── Fire-ingest coverage-scope gate ──────────────────────────────
# The exact per-area MonitoringArea union the dispatch-level
# CoverageFilter gates on (areas_from_config(config.coverage) — the
# SAME set-union Shapely membership test). Built ONCE here and reused
# per-fire in _ingest_fires so an out-of-area fire is never STORED
# (thus never tracked / alerted / reminded / re-ingested), keeping
# ingest scope == dispatch scope. Fail-OPEN: an empty list (coverage
# disabled or no areas configured) means the gate is a no-op and every
# fire is stored, preserving current behaviour. `coverage_areas` is the
# raw config.coverage.areas dict-list; areas_from_config also honours
# the legacy single-`bbox` fallback via the `.bbox` attribute, so we
# wrap the raw list in a tiny shim exposing both fields.
self._fire_coverage_areas = []
try:
from meshai.coverage_area import areas_from_config
class _CoverageShim:
def __init__(self, areas, bbox):
self.areas = areas or []
self.bbox = bbox or []
# Only build a real gate when this adapter is NOT on the coverage
# opt-out list ("fires"), mirroring _coverage_for's escape hatch:
# an excluded fires adapter falls back to no coverage gating.
if "fires" not in self._coverage_excluded:
self._fire_coverage_areas = areas_from_config(
_CoverageShim(coverage_areas, self._coverage_bbox))
except Exception:
logger.exception(
"fire coverage-scope gate init failed; failing OPEN (no gate)")
self._fire_coverage_areas = []
# ── Received-delta gate (NATIVE-only) ────────────────────────────
# The model the operator demanded: a native adapter broadcasts an item
# ONLY when it was newly RECEIVED from the API this poll — never by
@ -356,6 +389,31 @@ class EnvironmentalStore:
# seeded — the leak-proof invariant behind the wzdx staging fix.
self._seeded.update(touched)
def _fire_out_of_coverage(self, lat, lon) -> bool:
"""True iff (lat, lon) lies OUTSIDE every configured coverage area.
Mirrors the dispatch-level CoverageFilter membership exactly: builds a
GeoJSON Point from the fire's (lon, lat) and classifies it against
``self._fire_coverage_areas`` with the SAME set-union Shapely test
(classify_geom_areas). Only ``out-of-bounds`` returns True (drop);
every other verdict in-bounds, or an unlocatable / unparseable point
(null-geom / invalid-geom) returns False (fail-OPEN, keep), matching
the fire path's fail-open semantics. Callers must already have checked
that ``self._fire_coverage_areas`` is non-empty.
"""
try:
if lat is None or lon is None:
return False # unlocatable fire -> fail open (keep/store)
from meshai.coverage_area import build_geom_json, classify_geom_areas
geom_json = build_geom_json({"centroid": [lon, lat]})
verdict = classify_geom_areas(geom_json, self._fire_coverage_areas)
return verdict == "out-of-bounds"
except Exception:
logger.exception(
"fire coverage-scope test failed for (%s,%s); failing OPEN",
lat, lon)
return False
def _ingest_fires(self, adapter) -> None:
"""Native WFIGS fire ingest — Phase-3 growth-decider path.
@ -412,6 +470,22 @@ class EnvironmentalStore:
contained = evt.get("contained_pct")
declared = evt.get("declared_at_epoch")
# ── Coverage-scope drop ──────────────────────────────────
# BEFORE any store write (cold-start seed AND live
# INSERT/UPDATE), drop a fire whose (lat, lon) falls OUTSIDE
# every configured coverage area. Uses the SAME per-area
# set-union Shapely membership test as the dispatch-level
# CoverageFilter (classify_geom_areas over the areas built in
# __init__), so ingest scope == dispatch scope. Fail-OPEN: with
# no coverage areas the list is empty and this never drops.
if self._fire_coverage_areas and self._fire_out_of_coverage(
evt.get("lat"), evt.get("lon")):
logger.info(
"coverage: skipped out-of-area fire %s (%s,%s)",
evt.get("name") or irwin_id,
evt.get("lat"), evt.get("lon"))
continue
row = conn.execute(
"SELECT last_broadcast_at FROM fires WHERE irwin_id=?",
(irwin_id,)).fetchone()

View file

@ -113,9 +113,9 @@ class MeshAI:
# now that we are inside the running event loop.
if self.event_bus is not None:
from .notifications.pipeline import start_pipeline
# v0.7-fire-tracker-4 llm_backend hook: surface the LLM into
# the pipeline components dict BEFORE start_pipeline spawns the
# scheduled broadcasters. FireDigestScheduler reads this.
# Surface the LLM into the pipeline components dict BEFORE
# start_pipeline spawns the scheduled broadcasters, so any
# component that reads comps["llm_backend"] can find it.
try:
comps = getattr(self.event_bus, "_pipeline_components", {}) or {}
comps["llm_backend"] = self.llm
@ -679,10 +679,16 @@ class MeshAI:
from meshai.coverage import enclosing_bbox
cov = self.config.coverage
coverage_bbox = (enclosing_bbox(cov.areas) or cov.bbox) if cov.enabled else []
# Per-area list for the store's fire-ingest coverage gate (the SAME
# areas the dispatch-level CoverageFilter uses, so ingest scope ==
# dispatch scope). Only when coverage is enabled — a disabled
# coverage leaves this empty so the ingest gate fails OPEN.
coverage_areas = cov.areas if cov.enabled else []
self.env_store = EnvironmentalStore(
config=env_cfg, region_anchors=region_anchors,
coverage_bbox=coverage_bbox, event_bus=self.event_bus,
coverage_excluded=cov.excluded_adapters,
coverage_areas=coverage_areas,
generic_sources=self.config.generic_sources,
)
logger.info(f"Environmental feeds enabled ({len(self.env_store._adapters)} adapters)")

View file

@ -28,7 +28,6 @@ from meshai.notifications.channels import create_channel
from meshai.notifications.pipeline.bus import EventBus, get_bus
from meshai.notifications.pipeline.dispatcher import Dispatcher
try:
from meshai.notifications.scheduled.fire_digest import FireDigestScheduler
from meshai.notifications.scheduled.band_conditions import (
BandConditionsScheduler,
)
@ -249,22 +248,6 @@ async def start_pipeline(bus: EventBus, config) -> DigestScheduler:
_lg.getLogger("meshai.pipeline").exception(
"band_conditions scheduler failed to start")
# v0.7-fire-tracker-4 FireDigestScheduler -- twice-daily fire digest.
if FireDigestScheduler is not None:
try:
comps = getattr(bus, "_pipeline_components", {}) or {}
disp = comps.get("dispatcher")
llm_backend = comps.get("llm_backend")
if disp is not None:
fd_sched = FireDigestScheduler(disp)
await fd_sched.start()
comps["fire_digest_scheduler"] = fd_sched
bus._pipeline_components = comps
except Exception:
import logging as _lg
_lg.getLogger("meshai.pipeline").exception(
"fire_digest scheduler failed to start")
# v0.6-phase3 ReminderScheduler -- runs alongside band_conditions.
if ReminderScheduler is not None:
try:

View file

@ -1,306 +0,0 @@
"""v0.7-fire-tracker-4 fire-digest scheduled broadcaster.
Twice-daily (default 06:00 + 18:00 Mountain) deterministic summary of
active fires, broadcast via dispatcher.dispatch_scheduled_broadcast.
Modeled after band_conditions.py (cf. v0.5.11 scheduled broadcaster).
"""
from __future__ import annotations
import asyncio
import logging
import time
from datetime import datetime, timedelta, timezone
from typing import Any, Callable, Optional
try:
from zoneinfo import ZoneInfo
except ImportError:
ZoneInfo = None # pragma: no cover
from meshai.adapter_config import adapter_config
logger = logging.getLogger("meshai.scheduled.fire_digest")
# ===========================================================================
# Slot epoch -- HH:MM local -> UNIX epoch (UTC)
# ===========================================================================
def _slot_epoch(now_dt: datetime, hh_mm: str, tz_name: str) -> int:
"""Convert HH:MM in `tz_name` on now_dt's local date to UNIX epoch."""
h, m = hh_mm.split(":")
if ZoneInfo is None:
# Fall back: treat as UTC.
local = now_dt.replace(hour=int(h), minute=int(m),
second=0, microsecond=0,
tzinfo=timezone.utc)
else:
tz = ZoneInfo(tz_name)
local = now_dt.astimezone(tz).replace(
hour=int(h), minute=int(m), second=0, microsecond=0,
)
return int(local.astimezone(timezone.utc).timestamp())
# ===========================================================================
# Deterministic renderer
# ===========================================================================
def _get_anchor(lat, lon) -> str:
"""Get location anchor for a fire using nearest_town from central_normalizer."""
if not isinstance(lat, (int, float)) or not isinstance(lon, (int, float)):
return ""
try:
from meshai.central_normalizer import nearest_town
max_mi = float(adapter_config.wfigs.anchor_max_mi)
nt = nearest_town(lat, lon, max_distance_mi=max_mi)
except Exception:
return ""
if nt and nt.get("name"):
town = nt["name"].title()
d = nt.get("distance_mi")
bearing = nt.get("bearing")
if isinstance(d, (int, float)):
if d < 1:
return f"near {town}"
return f"{int(round(d))} mi {bearing or ''} of {town}".strip()
return f"near {town}"
return ""
async def render_digest(*, now: Optional[int] = None) -> tuple[str, str]:
"""Build the digest wire string deterministically.
Returns (wire, source). source is 'deterministic' on success,
'no_fires' if there are no active fires (wire is empty).
"""
from meshai.persistence import get_db
now = now if now is not None else int(time.time())
conn = get_db()
cutoff = now - 7 * 86400
rows = conn.execute(
"SELECT incident_name, current_acres, current_contained_pct, county, state "
"FROM fires WHERE last_event_at >= ? "
"AND tombstoned_at IS NULL "
"AND (current_contained_pct IS NULL OR current_contained_pct < 100) "
"ORDER BY last_event_at DESC",
(cutoff,),
).fetchall()
if not rows:
return "", "no_fires"
total = len(rows)
top = rows[:2]
header = f"\U0001f525 Fire Digest \u2014 {total} active wildfire(s)"
fire_lines: list[str] = []
for row in top:
name = row["incident_name"] or "(unnamed)"
county = row["county"]
state = row["state"]
if county and state:
fire_lines.append(f"{name} in {county} Co, {state}")
elif state:
fire_lines.append(f"{name} in {state}")
else:
fire_lines.append(name)
# Assemble within the universal mesh budget; trim fire lines, never the tail
budget = int(adapter_config.fires.digest_max_chars)
shown: list[str] = []
for line in fire_lines:
# Estimate tail for budget check
est_remaining = total - len(shown) - 1
if est_remaining == 1:
est_tail = "There is 1 additional wildfire. DM me for the full list."
elif est_remaining > 1:
est_tail = f"There are {est_remaining} additional wildfires. DM me for the full list."
else:
est_tail = ""
parts = [header] + shown + [line]
if est_tail:
parts.append(est_tail)
candidate = "\n".join(parts)
if len(candidate.encode("utf-8")) <= budget:
shown.append(line)
else:
break
# Compute tail AFTER budget loop with actual shown count
remaining = total - len(shown)
if remaining == 1:
tail = "There is 1 additional wildfire. DM me for the full list."
elif remaining > 1:
tail = f"There are {remaining} additional wildfires. DM me for the full list."
else:
tail = ""
parts = [header] + shown
if tail:
parts.append(tail)
wire = "\n".join(parts)
return wire, "deterministic"
# ===========================================================================
# Broadcast
# ===========================================================================
def _record_slot_attempt(slot_epoch_s: int, *,
sent_at: int,
summary: Optional[str],
source: str) -> Optional[int]:
"""Insert into fire_digest_broadcasts. Returns rowid on insert, None
if the slot was already broadcast (UNIQUE PK collision)."""
try:
from meshai.persistence import get_db
conn = get_db()
except Exception:
return None
cur = conn.execute(
"INSERT OR IGNORE INTO fire_digest_broadcasts(slot_epoch, "
"sent_at, summary, source) VALUES (?,?,?,?)",
(slot_epoch_s, sent_at, summary, source),
)
return int(cur.lastrowid) if cur.rowcount > 0 else None
# ===========================================================================
# Scheduler
# ===========================================================================
class FireDigestScheduler:
"""Fires fire-digest broadcasts at configured local times."""
def __init__(self, dispatcher, *,
clock: Optional[Callable[[], float]] = None,
sleep: Optional[Callable[[float], Any]] = None):
self._dispatcher = dispatcher
self._clock = clock or time.time
self._sleep = sleep or asyncio.sleep
self._task: Optional[asyncio.Task] = None
self._stop_event: Optional[asyncio.Event] = None
self._logger = logger
def _enabled(self) -> bool:
try:
return bool(adapter_config.fires.digest_enabled)
except Exception:
return False
def _schedule(self) -> list[str]:
try:
sched = adapter_config.fires.digest_schedule
except Exception:
sched = ["06:00", "18:00"]
if not isinstance(sched, list):
sched = ["06:00", "18:00"]
return [s for s in sched if isinstance(s, str) and ":" in s]
def _tz_name(self) -> str:
try:
return str(adapter_config.fires.digest_timezone)
except Exception:
return "America/Boise"
async def start(self) -> None:
if self._task is not None and not self._task.done():
raise RuntimeError("FireDigestScheduler already running")
self._stop_event = asyncio.Event()
self._task = asyncio.create_task(self._run(),
name="fire-digest-scheduler")
self._logger.info(
"Fire digest scheduler started: enabled=%s schedule=%s tz=%s",
self._enabled(), self._schedule(), self._tz_name())
async def stop(self) -> None:
if self._stop_event:
self._stop_event.set()
if self._task:
await self._task
async def _run(self) -> None:
while not (self._stop_event and self._stop_event.is_set()):
if not self._enabled():
await self._sleep(60); continue
now = self._clock()
now_dt = datetime.fromtimestamp(now, tz=timezone.utc)
target_epoch, target_hh_mm = self._next_slot(now_dt)
wait_s = max(1, target_epoch - int(now))
try:
await self._sleep(min(wait_s, 3600))
except asyncio.CancelledError:
break
now2 = int(self._clock())
if now2 >= target_epoch:
await self.fire_slot(target_epoch, target_hh_mm)
def _next_slot(self, now_dt: datetime) -> tuple[int, str]:
schedule = sorted(set(self._schedule()))
if not schedule:
tomorrow = now_dt + timedelta(days=1)
return _slot_epoch(tomorrow, "12:00", self._tz_name()), "12:00"
today_now = int(now_dt.timestamp())
for hh_mm in schedule:
ep = _slot_epoch(now_dt, hh_mm, self._tz_name())
if ep > today_now:
return ep, hh_mm
tomorrow = now_dt + timedelta(days=1)
return _slot_epoch(tomorrow, schedule[0], self._tz_name()), schedule[0]
def _broadcast_enabled(self) -> bool:
try:
return bool(adapter_config.fires.digest_broadcast_enabled)
except Exception:
return False
async def fire_slot(self, slot_epoch_s: int, hh_mm: str) -> bool:
"""Build + broadcast for the given slot. Returns True on broadcast."""
# Kill-switch on the actual mesh emission. Disabled by default: the
# scheduler wiring stays intact (so flipping the flag to True cleanly
# re-enables it) but nothing is dispatched. Per-fire wfigs alerts are a
# separate path and are unaffected.
if not self._broadcast_enabled():
self._logger.info(
"fire-digest: broadcast disabled "
"(fires.digest_broadcast_enabled=False); skipping slot %s", hh_mm)
return False
wire, source = await render_digest(now=int(self._clock()))
if source == "no_fires":
self._logger.info(
"fire-digest: silent skip for %s (no active fires)", hh_mm)
_record_slot_attempt(slot_epoch_s,
sent_at=int(self._clock()),
summary=None,
source="skipped_no_fires")
return False
bcast_id = _record_slot_attempt(slot_epoch_s,
sent_at=int(self._clock()),
summary=wire,
source=source)
if bcast_id is None:
self._logger.info(
"fire-digest: slot %s already broadcast; skipping dup",
hh_mm)
return False
try:
success = await self._dispatcher.dispatch_scheduled_broadcast(
text=wire,
source_event_table="fire_digest_broadcasts",
source_event_pk=str(bcast_id),
)
except Exception:
self._logger.exception(
"fire-digest: dispatcher raised; row stays in table")
success = False
return bool(success)

View file

@ -129,20 +129,20 @@ def test_wfigs_broadcast_on_acres_bool_roundtrip(client):
assert type(val) is bool
def test_fires_digest_enabled_bool_roundtrip(client):
"""Third adapter (fires.digest_enabled) -- additional proof of generic handling."""
def test_reminders_wfigs_enabled_bool_roundtrip(client):
"""Third adapter (reminders_wfigs.enabled) -- additional proof of generic handling."""
# Read default
default_val = adapter_config.fires.digest_enabled
default_val = adapter_config.reminders_wfigs.enabled
# Flip
new_val = not default_val
r = client.put(
"/api/adapter-config/fires/digest_enabled",
"/api/adapter-config/reminders_wfigs/enabled",
json={"value": new_val},
)
assert r.status_code == 200
assert r.json()["value"] is new_val
# Accessor returns the correct bool
assert adapter_config.fires.digest_enabled is new_val
assert type(adapter_config.fires.digest_enabled) is bool
assert adapter_config.reminders_wfigs.enabled is new_val
assert type(adapter_config.reminders_wfigs.enabled) is bool

View file

@ -1,73 +0,0 @@
"""Tests for the fire-digest broadcast kill-switch (fires.digest_broadcast_enabled).
The scheduler wiring stays intact; only the mesh EMISSION is gated. Disabled by
default -> fire_slot() dispatches nothing. Flipping the flag True re-enables it
cleanly. Per-fire wfigs alerts are a separate path and are unaffected (covered in
test_wfigs_handler.py).
"""
from __future__ import annotations
import asyncio
import time
from meshai.persistence import get_db
from meshai.notifications.scheduled.fire_digest import FireDigestScheduler
class _RecordingDispatcher:
def __init__(self):
self.calls = []
async def dispatch_scheduled_broadcast(self, *, text, source_event_table,
source_event_pk):
self.calls.append(
{"text": text, "table": source_event_table, "pk": source_event_pk})
return True
def _seed_active_fire(conn):
now = int(time.time())
conn.execute(
"INSERT OR REPLACE INTO fires(irwin_id, incident_name, incident_type, "
"current_acres, current_contained_pct, lat, lon, county, state, "
"declared_at, last_event_at, tombstoned_at) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
("GATE-01", "Gatekeeper Fire", "WF", 900, None, 43.6, -116.2,
"Ada", "ID", now, now - 600, None),
)
def test_fire_slot_disabled_by_default_dispatches_nothing():
"""With defaults (digest_broadcast_enabled=False) fire_slot emits nothing."""
conn = get_db()
_seed_active_fire(conn) # a broadcast WOULD be produced if the gate were open
now = int(time.time())
dispatcher = _RecordingDispatcher()
sched = FireDigestScheduler(dispatcher, clock=lambda: now)
result = asyncio.run(sched.fire_slot(now, "06:00"))
assert result is False
assert dispatcher.calls == [], "digest broadcast must NOT be dispatched by default"
def test_fire_slot_dispatches_when_flag_enabled():
"""Flipping fires.digest_broadcast_enabled True cleanly re-enables emission."""
from meshai.adapter_config import set_runtime_override
conn = get_db()
_seed_active_fire(conn)
now = int(time.time())
dispatcher = _RecordingDispatcher()
sched = FireDigestScheduler(dispatcher, clock=lambda: now)
set_runtime_override("fires", "digest_broadcast_enabled", True)
try:
result = asyncio.run(sched.fire_slot(now, "06:00"))
finally:
# Reset so the override doesn't leak into other tests in this process.
set_runtime_override("fires", "digest_broadcast_enabled", False)
assert result is True
assert len(dispatcher.calls) == 1
assert dispatcher.calls[0]["table"] == "fire_digest_broadcasts"
assert "Gatekeeper Fire" in dispatcher.calls[0]["text"]

View file

@ -1,230 +0,0 @@
"""Tests for the fire digest deterministic renderer.
Validates recency ordering, contained/tombstoned exclusion, the
"Name in County Co, ST" line format, correct tail count after budget
trimming, singular/plural grammar, and the 140-byte universal mesh budget.
"""
from __future__ import annotations
import asyncio
import time
import pytest
from meshai.persistence import get_db
def _seed_fire(conn, *, irwin_id, name, acres, contained=None, lat=43.6, lon=-116.2,
county="Ada", state="ID", declared_at=None, last_event_at=None,
tombstoned_at=None):
now = int(time.time())
conn.execute(
"INSERT OR REPLACE INTO fires(irwin_id, incident_name, incident_type, "
"current_acres, current_contained_pct, lat, lon, county, state, "
"declared_at, last_event_at, tombstoned_at) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
(irwin_id, name, "WF", acres, contained, lat, lon, county, state,
declared_at or now, last_event_at or now, tombstoned_at),
)
def _seed_scenario(conn):
"""Seed fires: 3 active, 1 contained, 1 tombstoned."""
now = int(time.time())
day = 86400
_seed_fire(conn, irwin_id="F-01", name="Alpha Fire",
acres=500, contained=None,
last_event_at=now - 3600,
county="Ada", state="ID")
_seed_fire(conn, irwin_id="F-02", name="Bravo Fire",
acres=200, contained=25,
last_event_at=now - 7200,
county="Boise", state="ID")
_seed_fire(conn, irwin_id="F-03", name="Charlie Fire",
acres=1000, contained=None,
last_event_at=now - 2 * day,
county="Elmore", state="ID")
_seed_fire(conn, irwin_id="F-04", name="Contained Fire",
acres=800, contained=100,
last_event_at=now - 1800,
county="Gem", state="ID")
_seed_fire(conn, irwin_id="F-05", name="Tombstoned Fire",
acres=3000, contained=50,
last_event_at=now - day,
tombstoned_at=now - 3600,
county="Owyhee", state="ID")
class TestFireDigestRecency:
"""Deterministic fire digest renderer tests."""
def test_top_2_listed_in_recency_order(self):
"""The most recent active fire is listed first.
With the universal 140-byte budget, the header + 1 fire line + tail
fits; the second fire line does not. Alpha (most recent) must appear;
Bravo belongs to the tail count.
"""
conn = get_db()
now = int(time.time())
_seed_scenario(conn)
from meshai.notifications.scheduled.fire_digest import render_digest
wire, source = asyncio.run(render_digest(now=now))
assert source == "deterministic"
# Most recent fire must appear in the wire body.
assert "Alpha Fire" in wire
# Bravo does not fit within 140 bytes alongside the header + tail.
assert "Bravo Fire" not in wire
def test_line_format_name_in_county_co_state(self):
"""Fire lines render as 'Name in County Co, ST'.
Only the most recent fire fits within the 140-byte budget.
"""
conn = get_db()
now = int(time.time())
_seed_scenario(conn)
from meshai.notifications.scheduled.fire_digest import render_digest
wire, _ = asyncio.run(render_digest(now=now))
assert "Alpha Fire in Ada Co, ID" in wire
def test_missing_county_renders_name_in_state(self):
"""Fire with no county renders as 'Name in ST'."""
conn = get_db()
now = int(time.time())
_seed_fire(conn, irwin_id="NC-01", name="No County Blaze",
acres=100, contained=None,
last_event_at=now - 3600,
county=None, state="MT")
_seed_fire(conn, irwin_id="NC-02", name="Second Fire",
acres=50, contained=None,
last_event_at=now - 7200,
county="Ada", state="ID")
from meshai.notifications.scheduled.fire_digest import render_digest
wire, _ = asyncio.run(render_digest(now=now))
assert "No County Blaze in MT" in wire
def test_contained_excluded(self):
"""100%-contained fires are excluded from the digest."""
conn = get_db()
now = int(time.time())
_seed_scenario(conn)
from meshai.notifications.scheduled.fire_digest import render_digest
wire, _ = asyncio.run(render_digest(now=now))
assert "Contained Fire" not in wire
def test_tombstoned_excluded(self):
"""Tombstoned fires are excluded from the digest."""
conn = get_db()
now = int(time.time())
_seed_scenario(conn)
from meshai.notifications.scheduled.fire_digest import render_digest
wire, _ = asyncio.run(render_digest(now=now))
assert "Tombstoned Fire" not in wire
def test_n1_grammar_singular(self):
"""N == 1 renders 'There is 1 additional wildfire.'."""
conn = get_db()
now = int(time.time())
# 3 fires; short names + no county → lines are short enough that
# 2 fit within the 140-byte budget, leaving 1 in the tail.
for irwin, name, offset in [
("SG-01", "A", 3600),
("SG-02", "B", 7200),
("SG-03", "C", 10800),
]:
_seed_fire(conn, irwin_id=irwin, name=name, acres=100,
contained=None, last_event_at=now - offset,
county=None, state="ID")
from meshai.notifications.scheduled.fire_digest import render_digest
wire, _ = asyncio.run(render_digest(now=now))
# 3 active total, 2 shown (budget), 1 remaining
assert "There is 1 additional wildfire. DM me for the full list." in wire
def test_n_plural_grammar(self):
"""N > 1 renders 'There are N additional wildfires.'."""
conn = get_db()
now = int(time.time())
for i in range(4):
_seed_fire(conn, irwin_id=f"PL-{i:02d}", name=f"Fire {i}",
acres=100 + i, contained=None,
last_event_at=now - 3600 * (i + 1),
county="Ada", state="ID")
from meshai.notifications.scheduled.fire_digest import render_digest
wire, _ = asyncio.run(render_digest(now=now))
# 4 active; "Fire N in Ada Co, ID" lines total ~142 bytes for 2 lines +
# header + tail → only 1 line fits in the 140-byte budget → 3 remaining.
assert "There are 3 additional wildfires. DM me for the full list." in wire
def test_n_zero_omits_sentence(self):
"""When N == 0, the tail sentence is omitted entirely."""
conn = get_db()
now = int(time.time())
_seed_fire(conn, irwin_id="X-01", name="Fire One",
acres=100, contained=None,
last_event_at=now - 3600,
county="Ada", state="ID")
_seed_fire(conn, irwin_id="X-02", name="Fire Two",
acres=50, contained=10,
last_event_at=now - 7200,
county="Boise", state="ID")
from meshai.notifications.scheduled.fire_digest import render_digest
wire, source = asyncio.run(render_digest(now=now))
assert source == "deterministic"
assert "additional" not in wire
assert "Fire One" in wire
assert "Fire Two" in wire
def test_tail_count_correct_after_budget_trim(self):
"""When budget trims a line, N reflects actual shown count."""
conn = get_db()
now = int(time.time())
# Long names force the second line to be trimmed
long_name_1 = "A" * 60
long_name_2 = "B" * 60
_seed_fire(conn, irwin_id="LN-01", name=long_name_1,
acres=500, contained=None,
last_event_at=now - 3600,
county="Bonneville", state="ID")
_seed_fire(conn, irwin_id="LN-02", name=long_name_2,
acres=200, contained=None,
last_event_at=now - 7200,
county="Bannock", state="ID")
_seed_fire(conn, irwin_id="LN-03", name="Short Fire",
acres=100, contained=None,
last_event_at=now - 3 * 86400,
county="Ada", state="ID")
from meshai.notifications.scheduled.fire_digest import render_digest
wire, source = asyncio.run(render_digest(now=now))
assert source == "deterministic"
byte_len = len(wire.encode("utf-8"))
assert byte_len <= 140, f"Wire is {byte_len} bytes, exceeds 140"
# If both long lines fit, remaining = 1; if only one fits, remaining = 2
# Either way the tail count must match (total - shown)
lines = wire.split("\n")
shown_fires = [l for l in lines if long_name_1 in l or long_name_2 in l]
remaining = 3 - len(shown_fires)
if remaining == 1:
assert "There is 1 additional wildfire." in wire
elif remaining > 1:
assert f"There are {remaining} additional wildfires." in wire
def test_rendered_within_200_bytes(self):
"""Rendered output must be <= 200 bytes for LoRa budget."""
conn = get_db()
now = int(time.time())
_seed_scenario(conn)
from meshai.notifications.scheduled.fire_digest import render_digest
wire, _ = asyncio.run(render_digest(now=now))
byte_len = len(wire.encode("utf-8"))
assert byte_len <= 140, f"Digest is {byte_len} bytes, exceeds 140-byte budget"
def test_no_fires_returns_empty(self):
"""No active fires -> empty wire, 'no_fires' source."""
from meshai.notifications.scheduled.fire_digest import render_digest
wire, source = asyncio.run(render_digest())
assert wire == ""
assert source == "no_fires"

View file

@ -110,16 +110,27 @@ def _raw_fire(*, name="MORA", irwin=_IRWIN, acres=2410, contained=10,
}
def _make_store():
def _make_store(coverage_areas=None, coverage_excluded=None):
bus = EventBus()
captured: list = []
bus.subscribe(lambda e: captured.append(e))
store = EnvironmentalStore(EnvironmentalConfig(), event_bus=bus)
store = EnvironmentalStore(
EnvironmentalConfig(), event_bus=bus,
coverage_areas=coverage_areas,
coverage_excluded=coverage_excluded,
)
adapter = _FakeFires()
store._adapters["nifc"] = adapter
return store, adapter, captured
# A coverage area covering SW Idaho (the default _raw_fire lat/lon 44.0,-115.0
# sits inside this box; a fire near 0,0 or on the US east coast is outside).
_SW_IDAHO_AREA = {
"name": "sw-id", "west": -117.0, "south": 42.0, "east": -114.0, "north": 45.0,
}
def _seed_row(conn, *, acres, contained, last_bcast_at):
"""Manually insert an already-broadcast fires row (skips cold-start)."""
conn.execute(
@ -309,3 +320,80 @@ def test_to_event_stamps_canonical_data(env):
assert ev.data["contained_pct"] == 30
assert ev.data["declared_at_epoch"] == _NOW
assert ev.data["lat"] == 44.0 and ev.data["state"] == "US-ID"
# ── 7. Coverage-scope ingest gate ────────────────────────────────────────────
# Fires OUTSIDE every configured coverage area are NEVER stored (so they are
# never tracked / alerted / reminded / re-ingested). The gate mirrors the
# dispatch-level CoverageFilter's set-union membership and applies to BOTH the
# cold-start seed and the live INSERT/UPDATE path. Fail-OPEN when coverage is
# disabled or has no areas (unchanged current behaviour).
def _fires_row(conn, irwin):
return conn.execute(
"SELECT irwin_id FROM fires WHERE irwin_id=?", (irwin,)).fetchone()
def test_coverage_gate_inside_area_is_stored(env):
"""A fire INSIDE a coverage box is stored (cold-start seed row present)."""
conn, _clk = env
store, adapter, captured = _make_store(coverage_areas=[_SW_IDAHO_AREA])
# Default coords 44.0,-115.0 are inside _SW_IDAHO_AREA.
adapter.set_batch([_raw_fire(irwin="IRWIN-IN-1", lat=44.0, lon=-115.0)])
store._ingest("nifc", adapter)
assert captured == [], "cold-start seed must broadcast nothing"
assert _fires_row(conn, "IRWIN-IN-1") is not None, \
"in-area fire must be stored"
def test_coverage_gate_outside_area_is_not_stored(env):
"""A fire OUTSIDE all coverage boxes is NOT stored (no row) with coverage
enabled + areas defined neither the cold-start seed nor a live upsert."""
conn, _clk = env
store, adapter, captured = _make_store(coverage_areas=[_SW_IDAHO_AREA])
# 0,0 (Gulf of Guinea) is far outside the SW-Idaho box.
adapter.set_batch([_raw_fire(irwin="IRWIN-OUT-1", lat=0.0, lon=0.0)])
store._ingest("nifc", adapter)
assert captured == [], "out-of-area fire must broadcast nothing"
assert _fires_row(conn, "IRWIN-OUT-1") is None, \
"out-of-area fire must NOT be stored (cold-start seed dropped)"
# A later (already-seeded) poll must ALSO refuse to INSERT the out-of-area
# fire via the live path — it must never latch a row.
store._fires_seeded = True
store._ingest("nifc", adapter)
assert _fires_row(conn, "IRWIN-OUT-1") is None, \
"out-of-area fire must NOT be stored on the live upsert path either"
def test_coverage_gate_fail_open_when_no_areas(env):
"""Coverage DISABLED / no areas -> an out-of-box fire IS stored (fail-open,
unchanged behaviour)."""
conn, _clk = env
store, adapter, captured = _make_store() # no coverage areas
assert store._fire_coverage_areas == [], \
"no coverage areas -> gate is a no-op list"
adapter.set_batch([_raw_fire(irwin="IRWIN-OPEN-1", lat=0.0, lon=0.0)])
store._ingest("nifc", adapter)
assert _fires_row(conn, "IRWIN-OPEN-1") is not None, \
"with no coverage areas, every fire is stored (fail-open)"
def test_coverage_gate_excluded_adapter_fails_open(env):
"""When 'fires' is on the coverage opt-out list, the gate is disabled even
with areas configured mirrors the _coverage_for fetch-scope escape hatch;
an out-of-box fire IS stored."""
conn, _clk = env
store, adapter, captured = _make_store(
coverage_areas=[_SW_IDAHO_AREA], coverage_excluded=["fires"])
assert store._fire_coverage_areas == [], \
"excluded 'fires' adapter -> no gate built"
adapter.set_batch([_raw_fire(irwin="IRWIN-EXCL-1", lat=0.0, lon=0.0)])
store._ingest("nifc", adapter)
assert _fires_row(conn, "IRWIN-EXCL-1") is not None, \
"excluded adapter falls back to no coverage gating (fire stored)"

View file

@ -59,79 +59,6 @@ def test_router_scope_type_defined_before_env_check():
or "scope_type:" in preceding
# ===========================================================================
# adapter_config seed + categories registration
# ===========================================================================
def test_adapter_config_seeds_digest_keys():
from meshai.persistence import get_db
rows = {
(r["adapter"], r["key"]): r["default_json"]
for r in get_db().execute(
"SELECT adapter, key, default_json FROM adapter_config "
"WHERE adapter='fires' AND key LIKE 'digest%'"
)
}
assert rows[("fires", "digest_enabled")] == "true"
assert rows[("fires", "digest_schedule")] == '["06:00", "18:00"]'
assert rows[("fires", "digest_timezone")] == '"America/Boise"'
assert rows[("fires", "digest_max_chars")] == "140"
# ===========================================================================
# Digest renderer
# ===========================================================================
def test_render_digest_returns_no_fires_when_table_empty():
from meshai.notifications.scheduled.fire_digest import render_digest
async def _run():
return await render_digest(now=None)
wire, source = asyncio.run(_run())
assert wire == ""
assert source == "no_fires"
def test_render_digest_terse_fallback_when_no_llm():
_seed_fire(irwin_id="ID-A", name="Cache Peak",
lat=42.0, lon=-114.0, acres=1847, contained=23)
_seed_fire(irwin_id="ID-B", name="Twin Peaks",
lat=43.0, lon=-115.0, acres=320, contained=5)
from meshai.notifications.scheduled.fire_digest import render_digest
async def _run():
return await render_digest(now=None)
wire, source = asyncio.run(_run())
assert source == "deterministic"
assert wire
assert "Cache Peak" in wire
assert len(wire) <= 140
def test_render_digest_uses_llm_when_available():
"""When the LLM backend returns a string, that string IS the wire."""
_seed_fire(irwin_id="ID-A", name="Cache Peak",
lat=42.0, lon=-114.0, acres=1847)
class StubLLM:
async def generate(self, *, messages, system_prompt, max_tokens):
# The renderer must give us a single-line wire derived from
# the LLM output, with markdown stripped + cap applied.
return "Cache Peak 1847 ac stable; no spotting today."
from meshai.notifications.scheduled.fire_digest import render_digest
async def _run():
return await render_digest(now=None)
wire, source = asyncio.run(_run())
assert source == "deterministic"
# render_digest is now fully deterministic (no LLM backend).
assert "Cache Peak" in wire
assert "1,847 ac" in wire
# ===========================================================================
# Natural-language fire DMs route to the LLM (no ?status fallback)
# ===========================================================================

View file

@ -670,16 +670,11 @@ def test_wfigs_discovery_is_date_only():
# ============================================================================
# Fire-digest kill-switch does NOT touch the per-fire wfigs path: a new-fire
# envelope still produces a broadcast even though digest broadcast is disabled.
# A new-fire envelope produces a per-fire wfigs broadcast.
# ============================================================================
def test_per_fire_wfigs_broadcasts_while_digest_disabled(mem_db, no_photon):
from meshai.adapter_config import adapter_config
# Default posture: the twice-daily digest broadcast is OFF.
assert bool(adapter_config.fires.digest_broadcast_enabled) is False
# ... yet a new per-fire wfigs alert still broadcasts.
def test_per_fire_wfigs_broadcasts_new_fire(mem_db, no_photon):
env = _make_active_envelope(geocoder_city="Burley")
data = {}
wire = handle_wfigs(cn.normalize(env), env, env["subject"],