mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-06-10 17:04:45 +02:00
1 commit
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| d3b62ad3c5 |
feat(notifications): Phase 2.13 ducting adapter threshold-crossing emission (severity-tiered, Option C)
Adds a tier-based threshold-crossing emission path to the tropospheric
ducting adapter, which was status-only until now.
EMISSION PATH (before -> after):
before: DuctingAdapter had only get_status(); store._ingest's ducting
branch did `self._ducting_status = adapter.get_status()` and
emitted NOTHING -- no get_events(), no to_event(), event_count
hardcoded 0.
after: the adapter derives a propagation TIER each tick (with
hysteresis) and stages an event on tier change; get_events() +
to_event() added; store._ingest's ducting branch now mirrors the
swpc branch (dedup on (source, event_id) + _emit_event), so a
tier change emits to the pipeline bus.
Option C design (severity-tiered by enhancement strength):
- Driving quantity: min M-gradient (modified refractivity gradient,
M-units/km) the adapter already computes.
- Tiers (ascending strength): normal < super_refraction < duct <
surface_duct.
0 <= g < 79 -> super_refraction -> category rf_anomalous_propagation,
severity routine
g < 0 -> duct (elevated) -> category rf_ducting_enhancement,
severity priority
surface_duct OR g < -100 -> strong/surface duct ->
category rf_ducting_enhancement, severity immediate,
surface flag set in the summary
g >= 79 -> normal -> no event
- Hysteresis / anti-flap: a DEADBAND of 5 M-units (TIER_DEADBAND) on the
two gradient boundaries (79 and 0). A tier change commits only once the
gradient is past the boundary by the deadband, so a wiggle right at a
threshold does not flap-trip across the 3h poll interval / 30-min
Inhibitor TTL mismatch (the Inhibitor TTL is shorter than the poll
interval, so anti-flap must live in the adapter). The most-severe
surface/strong-duct tier is categorical (duct reaches the ground) and is
intentionally NOT held back or onto by the deadband -- it fires and
clears promptly. (Deadband = 5 M-units chosen per the 5-10 guidance.)
- Stable event_id (SWPC idiom): "ducting_{tier_code}_{lat}_{lon}", e.g.
"ducting_duct_42.56_-114.47". A sustained tier coalesces on this
group_key (the store dedups it); an escalation to a stronger tier yields
a new key and re-notifies. group_key = sole inhibit_key; severity tiering
delegated to the Inhibitor.
- Prior-state tracking: self._last_tier persists across ticks (the
deadband needs the last committed tier); _parse_response rebuilds
_status wholesale, so _update_events runs at the end of each parse.
- Ducting is geographic: events carry the assessment location's lat/lon
(config.latitude/longitude). Defensive: missing/normal tier, missing
location, or missing gradient -> None; try/except-guarded.
Rule 17: no new tunable (latitude/longitude/tick_seconds already in
env_feeds.yaml; TIER_DEADBAND is an internal constant). Rule 18 N/A --
Open-Meteo GFS (api.open-meteo.com) is keyless. Rule 16: standalone fetch
path validated in-container.
Tests: tests/test_adapter_ducting.py (19 tests) mirrors the 2.12 SWPC
shape -- tier classification (normal/super_refraction/duct/surface_duct),
severity tiering, scale->category mapping, group_key/inhibit_keys, field
population, defensive cases (normal/missing location/missing gradient/
corrupted -> None), plus regression guards: dedup id stable across
same-tier ticks, tier escalation yields a new id, and TWO deadband guards
(a sub-deadband wiggle at the 0 boundary and at the 79 boundary holds the
prior tier; surface duct is not held by the deadband). Full suite: 233
passed.
Live smoke test (prod container, Phase 2.13 code rebuilt in): clean
startup, 7 env adapters loaded (ducting already counted), healthy, no
traceback. An in-container standalone _fetch of the Open-Meteo GFS
endpoint succeeded (fetch_ok=true, is_loaded=true, last_error=null,
consecutive_errors=0) -- 3/3 repeat probes clean. The current atmosphere
is normal (min M-gradient 122.5 >= 79) so tier=normal and no Event is
emitted -- acceptable, and it exercises the no-emit path and the tier
classifier on live data. NOTE: the running container's first ducting tick
logged a transient "[SSL: UNEXPECTED_EOF_WHILE_READING]" connection error;
the immediate and repeated standalone probes all succeeded, so this was a
transient upstream TLS drop (not DNS/auth/config) and the adapter degrades
gracefully (logs, increments consecutive_errors, returns False, no crash).
The emission path (tier change -> rf_anomalous_propagation /
rf_ducting_enhancement) is unit-validated and uses the same store->bus
path that emitted live for NWS, traffic, and NIFC fires.
Follow-up (not in this change): DuctingAdapter.health_status still returns
event_count hardcoded 0; now that the adapter emits, it could report
len(self._events). Cosmetic (health endpoint only); left out to keep the
diff scoped.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|