Skip to content

Live ADS-B: ground rows retire aircraft, altitude jump guard, fix-age expiry - #15

Merged
jehanazad merged 2 commits into
mainfrom
fix/live-adsb-feed-sanity
Sep 13, 2026
Merged

jehanazad merged 2 commits into
mainfrom
fix/live-adsb-feed-sanity

Conversation

@jehanazad

Copy link
Copy Markdown
Contributor

Why

adsb.retina.fm (/v2/point, adsb.lol-shaped) is not trustworthy row-for-row, and the fleet applied it verbatim. Verified on test + staging 2026-09-10/11 against solve mnece44394c3 (= mn-adsb-a85f17 = N6389R, a PA-28):

  • Corrupt altitudes. Another aircraft's cruise altitude turns up in a low GA row's alt_baro — observed +23k…+33k ft over truth, flipping between polls, persisting up to ~50 s. The altitude the server's known lane pinned its solve to was +2 / +8 / +10 km wrong for two minutes. Every row is type: "other" with no alt_geom, so there is no feed field to filter on; only scale separates the cases — a real aircraft moves under 150 m in a 5 s poll even at 6,000 ft/min, the corruption is 7–10 km.
  • Ground rows thrown away. The feed encodes on-ground as a numeric alt_baro: 0, not adsb.lol's "ground" sentinel, so the parser dropped landed aircraft as unparseable instead of reading the one positive "it landed" signal the feed gives us. A landed aircraft kept flying east for 146 s (rows dead-reckoned by seen_pos up to 55 s, then a 60 s coast).
  • Expiry keyed on the poll, not the fix. The feed keeps echoing a stale row after an aircraft stops transmitting, so a ghost lived ~115 s instead of the intended 60.

What

live_adsb.parse_point_response

  • A row whose alt_baro is "ground" or any non-positive number is now emitted with on_ground: True and alt_baro: 0.0; everything else gets on_ground: False. Rows with no position or no usable altitude are still dropped. Docstring says why.

world.SimulationWorld.ingest_live_aircraft

  • on_ground → retire. A known hex is removed from live_aircraft and aircraft immediately (the way _expire_live_aircraft does) and counted under the new "landed" stat; an unknown hex is skipped, never created.
  • Stale fixes skipped. A row whose raw fix age (now_wall - captured_at) exceeds live_stale_s is not applied and does not create — a fix that old is no evidence the aircraft is where extrapolating it would put it. The existing clamp still covers ages inside the window.
  • Fix-age expiry. live_seen_s = self._time - age (the fix's capture time on the world clock) in both branches. _expire_live_aircraft is unchanged; the effect is that an aircraft leaves live_stale_s after its last fix rather than after the feed last echoed a stale row (~115 s → ≤ 60 s). Field comment and _expire_live_aircraft docstring updated.
  • Altitude jump guard (update branch only — a new aircraft has nothing to compare against). ac.alt_km has been coasted to the world clock by _dead_reckon and the row's alt_km extrapolated to now_wall by vel_up * age, so they are comparable. A disagreement over _LIVE_ALT_JUMP_KM = 1.0 keeps the coasted altitude and counts "alt_rejected", while lat/lon/velocities/heading/vel_up are still applied (baro_rate was correct in every corrupt row observed). The first rejection time is kept in a new SimulatedAircraft.live_alt_reject_s; once the disagreement has stood for _LIVE_ALT_ADOPT_S = 60.0 it is adopted and the field cleared — a reading that disagrees for a full minute is a re-level, not a glitch, and a persistently wrong feed altitude is bounded to a minute of wrongness rather than forever. Any accepted reading clears the field.
  • _dead_reckon is untouched. The returned stats dict gains "landed" and "alt_rejected" (the two existing exact-dict assertions in the tests were updated).

Tests

tests/test_live_adsb.py:

  • parser: "ground", numeric 0 and a negative altitude → on_ground True / alt 0.0; positive → False; missing/None altitude still dropped.
  • world: a ground row retires an existing live aircraft (gone from both containers, landed == 1) and never creates an unknown one.
  • world: a fix older than live_stale_s creates nothing, updates nothing, and does not bump live_seen_s.
  • world: expiry keyed on fix age — a 50 s old fix in a 60 s window survives 9 s of world time and is gone by 12 s; a fresh fix keeps the aircraft alive.
  • world: altitude jump — 1.2 km, 1.2, 11.2 (rejected: altitude holds at 1.2, alt_rejected == 1, lat still updated), 1.2 (accepted, reject field cleared); a sustained 11.2 is adopted after 60 s of world time; a normal 0.1 km-per-poll descent is never rejected.

Ran (borrowed venv, this package on PYTHONPATH):

PYTHONPATH=/root/wt-sim-live-adsb /root/wt-node-display/backend/.venv/bin/python -m pytest -q
209 passed in 7.84s

Pre-commit on the changed files (ruff, ruff format, dead-code, ruff-config): all passed.

🤖 Generated with Claude Code

jehanazad and others added 2 commits September 11, 2026 20:35
… expiry

adsb.retina.fm (/v2/point) is not trustworthy row-for-row, and the fleet was
applying it verbatim.  Verified on test/staging 2026-09-10/11 against solve
mnece44394c3 (= N6389R, a PA-28):

* Another aircraft's cruise altitude turns up in a low GA row's alt_baro
  (+23k…+33k ft over truth, flipping between polls, persisting up to ~50 s).
  The altitude the server's known lane pinned its solve to was +2/+8/+10 km
  wrong for two minutes.  Every row is type "other" with no alt_geom, so
  there is no feed field to filter on — only scale separates the cases: a
  real aircraft moves under 150 m in a 5 s poll even at 6,000 ft/min.
* Ground is encoded as a numeric 0, not adsb.lol's "ground" sentinel, so the
  parser dropped landed aircraft as unparseable rows instead of reading them
  as the one positive "it landed" signal the feed gives us.  A landed
  aircraft kept flying east for 146 s (dead-reckoned by seen_pos, then a
  60 s coast).
* Expiry keyed on the last poll that mentioned an aircraft, not on its last
  fix, so the feed echoing a stale row kept a ghost alive ~115 s.

Parser: ground rows (the "ground" sentinel or any non-positive altitude) are
now emitted with on_ground=True and alt_baro 0.0; rows with no position or no
usable altitude are still dropped.

World ingest: an on_ground row retires a known aircraft at once (new "landed"
stat) and never creates an unknown one; a row whose raw fix age already
exceeds live_stale_s is skipped entirely; live_seen_s is the fix time on the
world clock, so an aircraft leaves live_stale_s after its LAST FIX (~115 s →
≤ 60 s); and an updated aircraft whose feed altitude disagrees with its
coasted one by more than 1 km keeps the coasted altitude (new "alt_rejected"
stat) while still taking the row's position, velocities and baro_rate — until
the disagreement has stood for 60 s, which is a re-level rather than a glitch
and is adopted.  A persistently wrong feed altitude is therefore bounded to a
minute of wrongness.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The altitude jump guard adopted ANY reading once the run of rejections had
stood for 60 s, without checking that the rejected readings agreed with each
other.  Replaying N6389R (2026-09-10) the feed disagreed with the coasted
altitude for 97 s straight, but across four different corrupt values (+10.0 km
for 22 s, +2.1 km for 7 s, +9.9 km for 48 s, +8.0 km for 13 s, then truth).
The old rule adopts +9.9 km at the 60 s mark and then rejects the returning
truth as a fresh >1 km jump for another minute: 84 s wrong, versus 97 s
unguarded — the guard barely pays for itself.

Track the last rejected altitude (live_alt_reject_km) and restart the run
whenever a reading disagrees with it by more than _LIVE_ALT_JUMP_KM, so only a
run that agrees with itself can ever be adopted.  That keeps the path the adopt
rule exists for — a wrong HELD altitude from a corrupt spawn or a corrupt
adoption, where the feed's consistent truth is the side being rejected — and
drops the one that made things worse.  A genuinely re-levelling aircraft never
jumps a kilometre between polls, so it tracks the coasted altitude and never
opens a run at all.

Tests: the N6389R replay shape (four corrupt values, 100 s, all rejected,
nothing adopted, truth accepted the moment it returns) and corrupt-spawn
recovery (consistent truth adopted at the 60 s mark, not before); the existing
sustained-run adoption test still passes unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jehanazad

Copy link
Copy Markdown
Contributor Author

Review follow-up (556f9c7): the altitude jump guard now adopts only from a self-consistent run of rejections.

Defect: the adopt rule fired on elapsed time alone — any reading was adopted once self._time - live_alt_reject_s >= _LIVE_ALT_ADOPT_S, whether or not the rejected readings in that run agreed with each other. Replaying N6389R (2026-09-10) the feed disagreed with the coasted altitude for 97 s continuously, but across four different corrupt values: +10.0 km for 22 s, +2.1 km for 7 s, +9.9 km for 48 s, +8.0 km for 13 s, then truth. The old rule adopts +9.9 km at the 60 s mark, and the returning truth is then a >1 km jump against the adopted value and is rejected for another minute — 84 s wrong versus 97 s unguarded.

Fix: new SimulatedAircraft.live_alt_reject_km holds the last rejected reading. A rejected reading that disagrees with it by more than _LIVE_ALT_JUMP_KM starts a NEW run instead of extending the old one, so only a run that agrees with itself can reach the adopt branch. That preserves the case the adopt path exists for — a wrong HELD altitude (corrupt spawn, or a corrupt adoption), where the feed's consistent truth is the side being rejected — and removes the case that made things worse. A genuinely re-levelling aircraft never jumps a kilometre between 5 s polls, so its readings track the coasted altitude and never open a run at all.

Tests (extend TestAltitudeJumpGuard):

  • test_a_flip_flopping_feed_is_never_adopted — the N6389R shape (11.2 / 3.3 / 11.1 / 9.2 over 100 s): all 20 readings rejected, altitude never leaves the coasted truth, nothing adopted, and the returning truth accepted on the first poll.
  • test_a_corrupt_spawn_altitude_is_recovered_after_a_minute — created at 11.2 from a corrupt row, consistent 1.2 adopted at the 60 s mark and not before.
  • The existing sustained-11.2-run adoption test is unchanged and still passes.

PYTHONPATH=/root/wt-sim-live-adsb .../python -m pytest -q211 passed; pre-commit (ruff, ruff format, vulture, ruff-config) clean on both changed files.

@jehanazad
jehanazad merged commit c62f6e4 into main Sep 13, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant