diff --git a/CHANGELOG.md b/CHANGELOG.md index b7fd915..87c7070 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,20 @@ The **published MQTT payload contract** is versioned separately via Additive, optional payload fields are backwards-compatible and do **not** bump the major version; a removed/renamed/retyped field does. +## [0.2.33] — 2026-06-19 +### Fixed +- Predictive AGL alert gate (`max_alt_agl_ft` on an inbound rule) now caps its + altitude look-ahead at 120 s. It extrapolates `current AGL + vertical_rate * + horizon` linearly; with the previous unbounded ETA, a routine descent + projected a still-high, 20+ nm-out aircraft below the threshold and tripped the + alert minutes early (descending bizjets at 3000+ ft firing a 1000 ft rule). The + cap keeps the "fire before it dips into drone airspace" intent but only when the + track will actually be low soon. + +### Added +- Alert `info` attributes now include `vertical_rate_fpm`, so the per-rule + `binary_sensor` shows the descent/climb rate that drove a predictive-AGL firing. + ## [0.2.32] — 2026-06-18 ### Added - Drone payloads carry `self_id` — the Remote ID Self-ID message (free-text @@ -161,4 +175,4 @@ Initial development. Established the full pipeline and contracts: optional Prometheus `/metrics`. - Pydantic v2 config (strict, fail-fast), structlog logging. -[Unreleased]: https://github.com/ifnull/ha-airspace/compare/v0.2.32...HEAD +[Unreleased]: https://github.com/ifnull/ha-airspace/compare/v0.2.33...HEAD diff --git a/addon/config.yaml b/addon/config.yaml index 269dc89..f19c560 100644 --- a/addon/config.yaml +++ b/addon/config.yaml @@ -3,7 +3,7 @@ # Display name is "Airspace" (friendly, matches the MQTT device); the slug, # package, repo, and image names stay ha-airspace as the technical identity. name: Airspace -version: "0.2.32" +version: "0.2.33" slug: ha_airspace description: >- Multi-source ADS-B + drone Remote ID enrichment to MQTT for Home Assistant. diff --git a/pyproject.toml b/pyproject.toml index 3bac532..01a380f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ha-airspace" -version = "0.2.32" +version = "0.2.33" description = "Multi-source ADS-B enrichment service: aircraft.json → MQTT → Home Assistant." readme = "README.md" requires-python = ">=3.12" diff --git a/src/ha_airspace/alerts.py b/src/ha_airspace/alerts.py index f1f4932..a14a784 100644 --- a/src/ha_airspace/alerts.py +++ b/src/ha_airspace/alerts.py @@ -52,6 +52,16 @@ def _default_clock() -> datetime: return datetime.now(UTC) +# How far ahead the predictive AGL gate extrapolates the track's altitude. The +# projection (current AGL + vertical_rate * horizon) is a straight-line +# extrapolation, which is only realistic over a short window — a descent rate +# held constant for several minutes drives the projected altitude wildly low and +# trips the gate when the aircraft is still high and tens of nm out. Capping the +# look-ahead keeps the "fire before it dips into drone airspace" intent while +# only firing when the track will actually be low *soon*. +_AGL_PREDICTION_HORIZON_S = 120.0 + + def _passes_alt_agl( state: AircraftState, max_alt_agl_ft: float, @@ -65,11 +75,14 @@ def _passes_alt_agl( Missing altitude can't satisfy it. 1 m = 3.28084 ft. For a ``predictive`` (inbound) rule, also extrapolate the altitude the track - will have at closest approach — current AGL plus vertical rate over the ETA — - and test the *lower* of now-vs-projected. So a descending aircraft dipping - into drone airspace trips the alert before it is actually low; a climber still - trips while genuinely low and clears once it has climbed past the threshold. - Falls back to the current snapshot when vertical rate or ETA is unknown.""" + will have at closest approach — current AGL plus vertical rate over the ETA, + capped at ``_AGL_PREDICTION_HORIZON_S`` — and test the *lower* of + now-vs-projected. So a descending aircraft dipping into drone airspace trips + the alert before it is actually low; a climber still trips while genuinely low + and clears once it has climbed past the threshold. The horizon cap stops a + sustained descent rate from projecting a still-high, far-out track below the + threshold minutes early. Falls back to the current snapshot when vertical rate + or ETA is unknown.""" alt_msl = state.canonical.alt_baro_ft if alt_msl is None: return False @@ -79,7 +92,8 @@ def _passes_alt_agl( vr = state.canonical.vertical_rate_fpm eta = state.predicted_eta_to_home_s if vr is not None and eta is not None: - agl = min(agl, agl + vr * (eta / 60.0)) # vr is ft/min, eta seconds + horizon = min(eta, _AGL_PREDICTION_HORIZON_S) # cap the look-ahead + agl = min(agl, agl + vr * (horizon / 60.0)) # vr ft/min, horizon seconds return agl <= max_alt_agl_ft diff --git a/src/ha_airspace/mqtt/publisher.py b/src/ha_airspace/mqtt/publisher.py index 45caa36..8dc1bc9 100644 --- a/src/ha_airspace/mqtt/publisher.py +++ b/src/ha_airspace/mqtt/publisher.py @@ -425,6 +425,7 @@ def _alert_info(state: AircraftState, photo: PhotoPayload | None) -> dict[str, A "country_flag": flag_for(state.hex), "squawk": canonical.squawk, "alt_baro_ft": canonical.alt_baro_ft, + "vertical_rate_fpm": canonical.vertical_rate_fpm, "distance_to": dict(state.distance_to), "bearing_to": dict(state.bearing_to), "predicted_closest_approach_nm": state.predicted_closest_approach_nm, diff --git a/tests/test_alerts.py b/tests/test_alerts.py index 33fd597..8689e97 100644 --- a/tests/test_alerts.py +++ b/tests/test_alerts.py @@ -115,10 +115,18 @@ class TestPredictiveAltitude: _RULE = MatchBlock(max_closest_approach_nm=10, max_alt_agl_ft=1640, watchpoint="home") def test_descending_above_threshold_matches(self) -> None: - # 4000 ft now, descending 1000 ft/min, 3 min to approach -> ~1000 ft there. - s = _state(alt_baro_ft=4000, vertical_rate_fpm=-1000, cpa_nm=2.0, eta_s=180.0) + # 3000 ft now, descending 1000 ft/min, 2 min to approach (within the + # look-ahead cap) -> ~1000 ft there, below the 1640 ft threshold. + s = _state(alt_baro_ft=3000, vertical_rate_fpm=-1000, cpa_nm=2.0, eta_s=120.0) assert rule_matches(s, self._RULE, elevation_m_for=_no_elevation) + def test_far_out_descender_capped_does_not_match(self) -> None: + # The over-eager case: 4000 ft now, descending 1000 ft/min, but 6 min + # out. Uncapped the projection would be -2000 ft (matches); capped at + # 120 s it only projects to 2000 ft, above the 1640 ft threshold. + s = _state(alt_baro_ft=4000, vertical_rate_fpm=-1000, cpa_nm=2.0, eta_s=360.0) + assert not rule_matches(s, self._RULE, elevation_m_for=_no_elevation) + def test_high_and_level_does_not_match(self) -> None: s = _state(alt_baro_ft=4000, vertical_rate_fpm=0, cpa_nm=2.0, eta_s=180.0) assert not rule_matches(s, self._RULE, elevation_m_for=_no_elevation) diff --git a/tests/test_mqtt_publisher.py b/tests/test_mqtt_publisher.py index a4b8351..70031c9 100644 --- a/tests/test_mqtt_publisher.py +++ b/tests/test_mqtt_publisher.py @@ -704,7 +704,8 @@ async def test_info_carries_altitude_eta_and_squawk(self, fake_client: FakeMqttC data = json.loads(_info_publishes(fake_client)[0]["payload"]) assert data["alt_baro_ft"] == 35000 # Predictive + geometry keys are always present (None until computed). - for key in ("predicted_eta_to_home_s", "bearing_to", "squawk"): + # vertical_rate_fpm explains the predictive-AGL projection (why it fired). + for key in ("predicted_eta_to_home_s", "bearing_to", "squawk", "vertical_rate_fpm"): assert key in data async def test_inactive_clears_info(self, fake_client: FakeMqttClient) -> None: diff --git a/uv.lock b/uv.lock index 501e725..5ddda94 100644 --- a/uv.lock +++ b/uv.lock @@ -290,7 +290,7 @@ wheels = [ [[package]] name = "ha-airspace" -version = "0.2.32" +version = "0.2.33" source = { editable = "." } dependencies = [ { name = "aiomqtt" },