From b0b0697bac81131c6d12d031a24d025366758e38 Mon Sep 17 00:00:00 2001 From: malice Date: Thu, 9 Jul 2026 13:46:12 -0600 Subject: [PATCH] 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 Co-authored-by: Claude Opus 4.8 (1M context) --- .../src/pages/ActivityLog.tsx | 1 - .../src/pages/AdapterConfig.tsx | 1 - .../src/pages/Environment.tsx | 73 +---- .../src/pages/Reference.tsx | 16 +- .../src/pages/ScheduledBroadcasts.tsx | 119 +------ work/meshai/adapter_config/defaults.py | 41 +-- work/meshai/env/store.py | 74 +++++ work/meshai/main.py | 12 +- .../meshai/notifications/pipeline/__init__.py | 17 - .../notifications/scheduled/fire_digest.py | 306 ------------------ work/tests/test_bool_roundtrip.py | 12 +- work/tests/test_fire_digest_broadcast_gate.py | 73 ----- work/tests/test_fire_digest_recency.py | 230 ------------- work/tests/test_fire_native_growth.py | 92 +++++- work/tests/test_fire_tracker_phase4.py | 73 ----- work/tests/test_wfigs_handler.py | 9 +- 16 files changed, 189 insertions(+), 960 deletions(-) delete mode 100644 work/meshai/notifications/scheduled/fire_digest.py delete mode 100644 work/tests/test_fire_digest_broadcast_gate.py delete mode 100644 work/tests/test_fire_digest_recency.py diff --git a/work/dashboard-frontend/src/pages/ActivityLog.tsx b/work/dashboard-frontend/src/pages/ActivityLog.tsx index e3d93f0..00c1523 100644 --- a/work/dashboard-frontend/src/pages/ActivityLog.tsx +++ b/work/dashboard-frontend/src/pages/ActivityLog.tsx @@ -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' }, diff --git a/work/dashboard-frontend/src/pages/AdapterConfig.tsx b/work/dashboard-frontend/src/pages/AdapterConfig.tsx index 0293c49..6b74248 100644 --- a/work/dashboard-frontend/src/pages/AdapterConfig.tsx +++ b/work/dashboard-frontend/src/pages/AdapterConfig.tsx @@ -22,7 +22,6 @@ import { // Advanced (raw) view to avoid double-editing. export const CURATED_KEYS: Record = { 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'], diff --git a/work/dashboard-frontend/src/pages/Environment.tsx b/work/dashboard-frontend/src/pages/Environment.tsx index dc656ae..a9f6a4e 100644 --- a/work/dashboard-frontend/src/pages/Environment.tsx +++ b/work/dashboard-frontend/src/pages/Environment.tsx @@ -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("") - const [firesConfig, setFiresConfig] = useState({ - digest_enabled: true, - digest_schedule: ["06:00", "18:00"], - digest_timezone: "America/Boise", - }) - const [firesOriginal, setFiresOriginal] = useState("") const [tomtomConfig, setTomtomConfig] = useState({ 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 () => { setWfigsConfig({ ...wfigsConfig, cooldown_seconds: v * 3600 })} min={0} helper="Minimum hours between updates for the same fire" /> setWfigsConfig({ ...wfigsConfig, freshness_seconds: v * 3600 })} min={0} helper="0 = always broadcast regardless of event age" /> -
-
Fire Digest
- - {firesConfig.digest_enabled && ( -
- setFiresConfig({ ...firesConfig, digest_schedule: v })} - helper="Digest times in HH:MM format, e.g. 06:00 and 18:00" /> - 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' }, - ]} /> -
- )} -
) case 'avalanche': return ( diff --git a/work/dashboard-frontend/src/pages/Reference.tsx b/work/dashboard-frontend/src/pages/Reference.tsx index 493365f..4df3543 100644 --- a/work/dashboard-frontend/src/pages/Reference.tsx +++ b/work/dashboard-frontend/src/pages/Reference.tsx @@ -367,7 +367,7 @@ export default function Reference() { - {/* Fire Tracker (v0.7 fusion: FIRMS + WFIGS + LLM digest) */} + {/* Fire Tracker (v0.7 fusion: FIRMS + WFIGS) */}

FIRMS hotspots are fast but noisy; WFIGS incidents are accurate but slow. @@ -402,16 +402,6 @@ export default function Reference() { ]} /> - Daily LLM digest -

- 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:{' '} - "Fires today: Cache Peak 1,847 ac +200 NE; Twin Peaks 320 ac stable; possible new fire 15 mi from Cache Peak."{' '} - Configure the schedule and timezone under fires.digest_*{' '} - keys on the Adapter Config page. -

- How attribution works

When a FIRMS hotspot lands, the bot walks every active fire (those not @@ -465,10 +455,6 @@ export default function Reference() { [halt_minimum_seconds, '43,200 (12 h)', 'Minimum elapsed seconds since the most recent attributed pixel before wildfire_halted can fire.'], [spotting_distance_threshold_mi, '1.5 mi', 'Distance from prior-pass perimeter that fires wildfire_spotting.'], [spotting_cooldown_seconds, '3,600 (1 h)', 'Minimum seconds between consecutive spotting broadcasts per fire.'], - [digest_enabled, 'true', 'Master toggle for the twice-daily digest.'], - [digest_schedule, '["06:00","18:00"]', 'Local-time slots for the digest.'], - [digest_timezone, 'America/Boise', 'IANA tz for digest_schedule.'], - [digest_max_chars, '200', 'Hard cap on the digest wire (the LLM is told to fit; the chunker enforces).'], ]} /> diff --git a/work/dashboard-frontend/src/pages/ScheduledBroadcasts.tsx b/work/dashboard-frontend/src/pages/ScheduledBroadcasts.tsx index b435e67..a159dda 100644 --- a/work/dashboard-frontend/src/pages/ScheduledBroadcasts.tsx +++ b/work/dashboard-frontend/src/pages/ScheduledBroadcasts.tsx @@ -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(null) const [originalNotifConfig, setOriginalNotifConfig] = useState(null) - // Fires adapter config state - const [firesConfig, setFiresConfig] = useState({ - digest_enabled: true, - digest_schedule: ['06:00', '18:00'], - digest_timezone: 'America/Boise', - }) - const [originalFiresConfig, setOriginalFiresConfig] = useState('') - const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [error, setError] = useState(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) { )}

All times are Mountain Time (America/Boise). DST handled automatically.

- - {/* Fire Digest */} -
-
- -
- setFiresConfig({ ...firesConfig, digest_enabled: v })} - helper="Send a twice-daily digest of active fire conditions to the mesh" - /> - {firesConfig.digest_enabled && ( -
- { - const s = [...(firesConfig.digest_schedule ?? ['06:00', '18:00'])] - s[0] = v - setFiresConfig({ ...firesConfig, digest_schedule: s }) - }} - helper="Morning digest (default 06:00 MT)" - /> - { - const s = [...(firesConfig.digest_schedule ?? ['06:00', '18:00'])] - s[1] = v - setFiresConfig({ ...firesConfig, digest_schedule: s }) - }} - helper="Evening digest (default 18:00 MT)" - /> -
- )} -
- - 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" - /> -

IANA timezone name (e.g. America/Boise). DST handled automatically.

-
-
) } diff --git a/work/meshai/adapter_config/defaults.py b/work/meshai/adapter_config/defaults.py index 925a170..1ae21a3 100644 --- a/work/meshai/adapter_config/defaults.py +++ b/work/meshai/adapter_config/defaults.py @@ -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", diff --git a/work/meshai/env/store.py b/work/meshai/env/store.py index 7e9ac0f..8162490 100644 --- a/work/meshai/env/store.py +++ b/work/meshai/env/store.py @@ -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() diff --git a/work/meshai/main.py b/work/meshai/main.py index 53442ea..ea9eab1 100644 --- a/work/meshai/main.py +++ b/work/meshai/main.py @@ -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)") diff --git a/work/meshai/notifications/pipeline/__init__.py b/work/meshai/notifications/pipeline/__init__.py index d2db288..074652f 100644 --- a/work/meshai/notifications/pipeline/__init__.py +++ b/work/meshai/notifications/pipeline/__init__.py @@ -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: diff --git a/work/meshai/notifications/scheduled/fire_digest.py b/work/meshai/notifications/scheduled/fire_digest.py deleted file mode 100644 index 437d59c..0000000 --- a/work/meshai/notifications/scheduled/fire_digest.py +++ /dev/null @@ -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) diff --git a/work/tests/test_bool_roundtrip.py b/work/tests/test_bool_roundtrip.py index 6653139..11ddd83 100644 --- a/work/tests/test_bool_roundtrip.py +++ b/work/tests/test_bool_roundtrip.py @@ -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 diff --git a/work/tests/test_fire_digest_broadcast_gate.py b/work/tests/test_fire_digest_broadcast_gate.py deleted file mode 100644 index c9ae119..0000000 --- a/work/tests/test_fire_digest_broadcast_gate.py +++ /dev/null @@ -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"] diff --git a/work/tests/test_fire_digest_recency.py b/work/tests/test_fire_digest_recency.py deleted file mode 100644 index 1c85f00..0000000 --- a/work/tests/test_fire_digest_recency.py +++ /dev/null @@ -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" diff --git a/work/tests/test_fire_native_growth.py b/work/tests/test_fire_native_growth.py index 79ef819..012ee8d 100644 --- a/work/tests/test_fire_native_growth.py +++ b/work/tests/test_fire_native_growth.py @@ -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)" diff --git a/work/tests/test_fire_tracker_phase4.py b/work/tests/test_fire_tracker_phase4.py index 0408b9b..bc51e0f 100644 --- a/work/tests/test_fire_tracker_phase4.py +++ b/work/tests/test_fire_tracker_phase4.py @@ -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) # =========================================================================== diff --git a/work/tests/test_wfigs_handler.py b/work/tests/test_wfigs_handler.py index abfaaad..0cb724f 100644 --- a/work/tests/test_wfigs_handler.py +++ b/work/tests/test_wfigs_handler.py @@ -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"],