Adds NICFFiresAdapter.to_event(), wiring the NIFC/WFIGS wildfire perimeter
adapter into the notification EventBus, following the Phase 2.7 traffic /
2.9 USGS / 2.10 avalanche pattern.
to_event() design:
- Category: every active perimeter with a reported size maps to a single
wildfire_incident category (the adapter's WFIGS query already filters to
active WF incidents in the configured state).
- Severity: PASSED THROUGH unchanged. The adapter computes severity by
proximity to region anchors (< 25 km -> priority, else routine), which
is a richer, more actionable signal for a mesh-notification use case
than raw acreage. I deliberately did NOT invent acreage breakpoints --
pass-through matches the 2.9/2.10 pattern and defers tiering to the
pipeline Inhibitor. (Flagged for review: if acreage-based or
containment-based severity is preferred, it belongs in the adapter's
_fetch severity logic, not to_event.)
- Summary: incident name + acreage + % contained + distance to nearest
anchor.
- group_key/inhibit_keys: the adapter's stable "nifc_{name}_{state}"
event_id as both. Re-polls of the same incident coalesce; single
inhibit key lets the Inhibitor suppress lower-severity re-emissions.
- Defensive: missing centroid (lat/lon), missing event_id, or missing/zero
acreage returns None; try/except-guarded.
No store.py change: the Phase 2.9 _emit_event None-guard already handles
to_event() returning None, and store gates emission on
hasattr(adapter, "to_event").
Rule 17: no new tunable. fires enabled / state / tick_seconds already
exist in env_feeds.yaml (GUI-editable). Rule 18 N/A -- the WFIGS
Interagency Perimeters ArcGIS FeatureServer is keyless (no .env entry;
the .ref credentials store has no NIFC/ArcGIS/wildfire key, confirming
none is needed). Rule 16: standalone fetch path validated in-container.
FIRMS side-investigation (flagged in the 2.10 report): firms is disabled
because it needs a NASA FIRMS map key that is not provisioned --
env_feeds.yaml has firms.enabled=false with map_key='' (not even a
${FIRMS_MAP_KEY} reference), and /data/secrets/.env has no FIRMS key.
Intentional/blocked-on-key, not a bug. No action this phase.
Config note: fires was already enabled (state US-ID) and already one of
the 7 live adapters (store key "nifc"), so this phase keeps the count at 7
(no 7->8 change) and required no env_feeds.yaml edit. No seasonal
short-circuit, so no temp config wiggling was needed (unlike 2.10).
Tests: tests/test_adapter_fires.py (12 tests) mirrors test_adapter_usgs /
test_adapter_avalanche -- category (always wildfire_incident, independent
of severity), severity pass-through, group_key/inhibit_keys,
distinct-incident keys, field population, summary content, and the
defensive cases (zero acreage -> None, missing centroid/event_id -> None,
corrupted -> None). Full suite: 200 passed.
Live smoke test (prod container, Phase 2.11 code rebuilt in): clean
startup, 7 env adapters loaded, no traceback. There IS an active Idaho
incident today, so this produced a real end-to-end emission rather than
the empty-result cases of 2.9/2.10: the running store logged "NIFC fires
updated: 1 active in US-ID" and "Emitted nifc event cc4bd340be7fd57e
(wildfire_incident) to pipeline bus". An in-container standalone fetch
confirmed health is_loaded=true, last_error=null, consecutive_errors=0,
event_count=1 -- the WFIGS ArcGIS endpoint was reached with no DNS/auth
errors (Phase 2.6.6 DNS fix). The Summit Creek incident (1,500 ac, 0%
contained, ~72 km from the Twin Falls anchor) mapped to
wildfire_incident / routine as designed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds AvalancheAdapter.to_event(), wiring the avalanche.org map-layer
adapter into the notification EventBus, following the Phase 2.7 traffic /
2.9 USGS pattern.
to_event() design (emit only elevated danger):
- Category from danger_level: High/Extreme (4-5) -> avalanche_warning;
Considerable (3) -> avalanche_watch.
- Low/Moderate (1-2) and No-Rating (-1/0) have no distinct trend trigger
in this adapter and are intentionally NOT emitted (return None) -- the
two categories are warning/watch only, matching the spec.
- Severity: passed through unchanged from the adapter's danger mapping
(danger >= 4 -> priority, else routine; the adapter never emits
"immediate"). Severity tiering is delegated to the pipeline Inhibitor.
- Summary: headline + danger name + travel advice.
- group_key/inhibit_keys: the adapter's stable "avy_{center}_{zone}"
event_id as both. Re-polls of the same zone coalesce; single inhibit
key lets the Inhibitor suppress lower-severity re-emissions.
- Defensive: missing centroid (lat/lon), missing event_id, or missing
danger_level returns None; try/except-guarded.
No store.py change: the Phase 2.9 _emit_event None-guard already handles
to_event() returning None, and store gates emission on
hasattr(adapter, "to_event").
Rule 17: no new tunable. avalanche enabled / center_ids / season_months
already exist in env_feeds.yaml (GUI-editable). Rule 18 N/A -- the
avalanche.org v2 public map-layer API is keyless (no .env entry; the
.ref credentials store has no avalanche provider key, confirming none is
needed). Rule 16: standalone fetch path validated in-container below.
Config note: avalanche was already enabled (center_ids: [SNFAC], the
Sawtooth Avalanche Center -- the correct South Central Idaho / Magic
Valley center). It was already one of the 7 live adapters, so this phase
keeps the count at 7 (no 7->8 change) and required no env_feeds.yaml
edit. There is no per-zone config knob; the adapter fetches all zones for
the configured center.
Tests: tests/test_adapter_avalanche.py (14 tests) mirrors
test_adapter_usgs -- category split (warning vs watch), severity
pass-through, group_key/inhibit_keys, distinct-zone keys, field
population, and the non-emit/defensive cases (low/moderate -> None,
no-rating -> None, missing danger_level/centroid/event_id -> None,
corrupted -> None). Full suite: 188 passed.
Live smoke test (prod container, Phase 2.10 code rebuilt in): clean
startup, 7 env adapters loaded, no traceback. Late May is off-season
(season_months [12,1,2,3,4]) so tick() short-circuits in normal
operation. To exercise the open-API path, a one-shot standalone fetch was
run in-container with an all-months config against center SNFAC: health
is_loaded=true, last_error=null, consecutive_errors=0, last_fetch set,
off_season=false -- the fetch reached api.avalanche.org with no DNS/auth
errors (Phase 2.6.6 DNS fix). event_count=0 because all SNFAC zones are
server-side off_season in late May, so no Event is emitted -- acceptable
per the seasonal caveat. The temporary season_months edit was reverted
and the container restarted on the real config (7 adapters, healthy). The
emission path (elevated -> avalanche_warning / avalanche_watch) is
unit-validated and is the same store->bus path emitting live for NWS and
traffic.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds USGSStreamsAdapter.to_event(), wiring the USGS Water Services stream
gauge adapter into the notification EventBus, following the Phase 2.7
traffic pattern.
to_event() design (emit only actionable/elevated readings):
- Category from flood_status: an exceeded stage (Minor/Moderate/Major
Flood) -> stream_flood_warning; "Action Stage" (approaching) ->
stream_high_water.
- A routine reading has no flood_status and is intentionally NOT emitted
(returns None) -- the two categories are both flood-specific and routine
gauge chatter is not actionable. This matches the spec ("category ...
based on flood_status").
- Severity: passed through unchanged from the adapter's NWPS-stage logic
(action->routine, minor/moderate->priority, major->immediate).
- Summary: reading value/unit + flood status.
- group_key/inhibit_keys: a single stable {site_id}_{param} key (the
adapter's own event_id) as both. Re-polls coalesce; severity tiering is
delegated to the pipeline Inhibitor (no severity encoded in the key).
- Defensive: missing lat/lon or event_id returns None; try/except-guarded.
store fix (meshai/env/store.py): _emit_event now skips a None return from
to_event() instead of passing it to bus.emit(). Required because usgs
returns None for the common (routine) reading; also retroactively protects
the defensive None returns of the FIRMS/traffic/roads511 adapters, which
previously would have logged a spurious "Failed to emit" warning.
Rule 17: no new tunable. usgs sites / tick_seconds / flood_thresholds
already exist in env_feeds.yaml (GUI-editable). Open API, no key, no .env
entry. Rule 16: standalone path validated end-to-end below.
Tests: tests/test_adapter_usgs.py (13 tests) mirrors test_adapter_traffic
-- category split (flood vs action), severity pass-through,
group_key/inhibit_keys, field population, and the non-emit/defensive cases
(routine -> None, missing lat/lon -> None, missing event_id -> None,
missing properties -> None, corrupted -> None). Full suite: 174 passed.
Live smoke test (prod, sites 13090500 Snake R nr Twin Falls, 13092747 Rock
Creek at Twin Falls, 13108150 Salmon Falls Creek nr Hagerman): clean
startup, 7 env adapters loaded, no traceback. "USGS streams updated: 6
readings from 3 sites" with NWPS flood stages resolved for all 3 -- fetch
succeeds over the open API with no DNS/auth errors (Phase 2.6.6 DNS fix).
All gauges currently below action stage, so flood_status is None and
to_event correctly emits nothing; the new None-guard skipped all 6 with no
error log. The emission path (elevated -> stream_flood_warning /
stream_high_water) is unit-validated and is the same store->bus path
emitting live for NWS (weather_warning/statement) and traffic
(traffic_congestion).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds Roads511Adapter.to_event(), wiring the state 511 road-events adapter
into the notification EventBus following the Phase 2.7 traffic pattern.
to_event() design:
- Category: fixed "road_closure".
- Severity: passed through unchanged from the adapter's existing
_parse_event logic (priority on closure, else routine).
- Summary enriched with closure status, roadway, and description.
- group_key: the stored event_id (already the stable "511_{id}" key), so
re-polls of the same incident coalesce.
- inhibit_keys: a single key equal to group_key. Severity tiering is
delegated to the pipeline Inhibitor (ranks routine<priority<immediate
per shared key, suppressing lower-severity re-emissions of the same
incident within the Inhibitor TTL). No severity encoded into the key.
- Defensive: missing lat/lon or missing event_id returns None; whole body
is try/except-guarded (returns None on corruption).
Store wiring: no change. EnvironmentalStore._ingest()'s generic "else"
branch already emits any adapter exposing to_event() (live since 2.6.5).
Rule 17: to_event introduces no new tunable. (The state base_url / bbox /
api_key already exist in Roads511Config and env_feeds.yaml; secrets go in
/data/secrets/.env via ${VAR}, never git.)
Tests: tests/test_adapter_roads511.py (14 tests) mirrors
test_adapter_traffic.py -- category, severity pass-through,
group_key/inhibit_keys, field population, defensive cases. Full suite:
161 passed.
live smoke test SKIPPED: Idaho 511 v2 (511.idaho.gov/api/v2) requires an
API key ("Invalid Key" response) and none is available in .ref/credentials
(cannot self-register). Per the standing key-less-adapter policy, the code
+ unit tests are committed and Gate D is skipped; roads511 is left disabled
in prod (enabling it keyless would only emit HTTP 400 errors). The
to_event() path is fully unit-validated and structurally identical to the
live traffic/FIRMS wiring (same EnvironmentalStore->EventBus path); live
validation will run if/when an Idaho 511 key is provided.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds TomTomTrafficAdapter.to_event(), wiring the traffic adapter into
the notification EventBus following the FIRMS pattern (Phase 2.6).
to_event() design:
- Category: fixed "traffic_congestion" (a road closure raises severity,
not category).
- Severity: passed through unchanged from the adapter's existing
_fetch_point logic (priority on closure / heavy congestion, else
routine). No threshold is re-derived or introduced in to_event.
- Summary enriched with current/free-flow speed, % free flow, closure,
and confidence.
- Defensive: missing lat/lon or missing corridor identity returns None;
the whole body is try/except-guarded (returns None on corruption).
Inhibit-key composition:
- A single stable per-corridor key, "traffic_{corridor}" (lowercased,
spaces->_), is used as BOTH group_key and the sole inhibit_key. This
matches the adapter's own event_id, so re-polls of a corridor coalesce.
- Severity tiering is delegated to the pipeline Inhibitor, which ranks
routine<priority<immediate per shared inhibit_key: a higher-severity
emission for a corridor suppresses lower-severity re-emissions of the
same corridor within the Inhibitor TTL window. No severity is encoded
into the key (mirrors FIRMS's spatial-key approach).
Store wiring: no change. EnvironmentalStore._ingest()'s generic "else"
branch already emits any adapter exposing to_event() (live since 2.6.5).
Rule 17: to_event introduces no new tunable. The api_key is injected via
the secrets channel ($TOMTOM_API_KEY in /data/secrets/.env, referenced
as ${TOMTOM_API_KEY} in env_feeds.yaml) -- the GUI-editable reference
stays in config while the secret never enters git. The only other knob
in play is the pipeline-level Inhibitor TTL (1800s, set in
build_pipeline), which is pipeline infrastructure, not traffic-owned;
left out of scope.
Tests: tests/test_adapter_traffic.py (15 tests) mirrors
test_adapter_firms.py -- category, severity pass-through,
group_key/inhibit_keys, field population, defensive cases. Full suite:
147 passed.
Smoke test (prod, Magic Valley corridors I-84 @ Jerome, US-93 Perrine
Bridge, US-30 Twin Falls): clean startup, 6 env adapters loaded, no
traceback. "TomTom traffic updated: 3 corridors" (no auth/DNS error),
then 3 Events emitted to the pipeline bus with traffic_congestion
category -- the full store->bus->pipeline path observed live. Emission
count stable at 3 (one per corridor, is_new-gated).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Second adapter wired to the new pipeline (after NWS). Reuses the
store-side emission logic added in the NWS commit.
- FIRMSAdapter.to_event() maps stored dict to pipeline Event.
- Category decision: new_ignition vs wildfire_proximity based on
properties.new_ignition (computed by FIRMS during ingest from
proximity to known fires).
- Severity passes through (FIRMS already pre-maps to our 3-level
system during _parse_csv).
- group_key and inhibit_keys use a spatial grid key
(firms:LAT:LON rounded to 0.01 degrees, ~1km) so repeated
satellite detections of the same hotspot are coalesced and
lower-severity re-detections are inhibited.
- Summary text enriched with FRP, confidence, and distance from
the nearest region anchor when present.
- 13 tests covering category decision, severity pass-through,
spatial grouping, and defensive handling of incomplete dicts.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Wires the NWS adapter to the new notification pipeline via EventBus:
- Added fine-grained weather categories: weather_watch, weather_advisory,
weather_statement (all routine severity) alongside existing weather_warning
- NWSAlertsAdapter._derive_category() maps NWS event type suffix to category:
"Warning" -> weather_warning, "Watch" -> weather_watch, etc.
- NWSAlertsAdapter.to_event() converts internal event dict to pipeline Event
with proper group_key (event_id) and inhibit_keys (Warning suppresses Watch)
- EnvironmentalStore accepts optional event_bus parameter
- EnvironmentalStore._ingest() emits new events to bus via _emit_event()
- 22 new tests in test_adapter_nws.py covering category derivation,
severity mapping, and Event field population
All 119 tests pass.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Adds dedicated renderer classes per channel type:
- MeshRenderer produces 1+ chunks <=200 chars with (k/N) counters
when the payload overflows. Reuses the toggle-label vocabulary
from the digest. Mesh channels skip re-chunking when the payload
already carries chunk_index metadata (digest path).
- EmailRenderer produces {subject, body} with structured context
lines. Plain text only; HTML body is a future polish.
- WebhookRenderer produces a JSON-serializable dict with stable
schema_version 1.0. Optional fields omitted (not nulled) for
compactness. Designed for reuse by Phase 2.6.5's MQTT event
publisher.
- All four channel implementations (MeshBroadcast, MeshDM, Email,
Webhook) now call their renderer in deliver() before transport.
- New renderer tests cover each renderer in isolation; new channel
integration tests confirm channels actually call their renderer.
Renderers are pure functions of the payload - no network, no
state, fully testable without mocking I/O. The future MQTT
publisher will instantiate WebhookRenderer directly.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Switch channels.py from dict-based to dataclass-based interfaces
- Add NotificationPayload dataclass and make_payload_from_event helper
- Update channel.deliver() to be async with (payload, rule) signature
- Add connector parameter to Dispatcher, DigestScheduler, and pipeline builders
- Update pipeline tee to use asyncio.create_task for async dispatch
- Add create_channel_from_dict for legacy router.py compatibility
- Update tests for new async interfaces
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
build_pipeline previously constructed its own LLMBackend from
config.llm, which:
- duplicated main.py's already-running backend instance
- failed to inherit env-loaded LLM_API_KEY when called from
short-lived scripts (eyeball checks, tests), forcing fallback
- prevented pipeline components from sharing the live backend
build_pipeline and build_pipeline_components now require an
llm_backend parameter. main.py passes the same instance it
constructed for its primary responder. Tests pass mocks. The
digest accumulator now uses the live, authenticated backend.
Added test_build_pipeline_uses_provided_backend to lock in the
injection contract.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Remove severity-based fork; tee pattern sends all events to both dispatcher and accumulator
- Add ToggleFilter before tee; drops events for disabled toggles
- Rework DigestAccumulator: event log instead of active/resolved tracking
- render_digest now async, calls LLM once per toggle with severity-ordered events
- Fallback to count-based summary when LLM unavailable
- Add TogglesConfig to config.py for master toggle settings
- Update scheduler to await async render_digest
- 75 tests passing
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
One-time renormalization pass under the .gitattributes added in the
previous commit. Every tracked text file now uses LF. No semantic
changes — verified via git diff --cached --ignore-all-space showing
zero real differences. Future diffs will only show real content
changes.
This commit will appear huge in git log --stat but represents zero
behavior change. Use git log --follow --ignore-all-space or
git blame -w when archaeologically tracing through this commit.
Adds DigestScheduler class that fires digest at configured time (default 07:00)
and routes to rules with trigger_type=schedule and schedule_match=digest.
- DigestScheduler: asyncio task with start/stop lifecycle
- Config: DigestConfig dataclass with schedule and include fields
- Config: schedule_match field on NotificationRuleConfig
- Pipeline: start_pipeline/stop_pipeline async lifecycle functions
- Mesh channels get per-chunk delivery, email/webhook get full text
- 26 new tests covering schedule computation, fire behavior, lifecycle
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Adds DigestAccumulator tracking ACTIVE NOW and SINCE LAST DIGEST
state per toggle. Replaces StubDigestQueue in build_pipeline; the
stub class is kept for Phase 2.1 backward-compat tests.
- enqueue(): adds new events, updates in place by id, detects
resolutions (expires past, or title contains cleared/reopened/
ended/resolved/back online/recovered/lifted)
- tick(now): rolls expired actives into since_last
- render_digest(now): produces a Digest with mesh_compact (<=200
chars) and full multi-line forms; clears since_last after
- Toggle ordering and labels match the v0.3 design
- Phase 2.3b will add real scheduling on top of this
Adds inline pipeline stages between the bus and the severity router:
- Inhibitor: suppresses lower-or-equal severity events when a key
in event.inhibit_keys is already active. TTL configurable, default
30 minutes.
- Grouper: coalesces events sharing group_key within a time window
(default 60s). Most recent event wins. tick() and flush_all()
drive emission; no background timers in Phase 2.2.
- build_pipeline now wires: bus -> inhibitor -> grouper -> severity_router
Phase 2.1 dispatcher tests continue to pass unchanged.