Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion addon/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
26 changes: 20 additions & 6 deletions src/ha_airspace/alerts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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


Expand Down
1 change: 1 addition & 0 deletions src/ha_airspace/mqtt/publisher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 10 additions & 2 deletions tests/test_alerts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion tests/test_mqtt_publisher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading