Write the aircraft down, and draw where they went

ADS-B was a live table and nothing else: an aircraft was overhead for four
minutes and then gone, with nothing kept.  Now everything heard goes into
adsb_<time>.jsonl as it arrives -- one object per frame, the raw hex beside
what was read out of it, flushed per line because a listening session ends
with control-C -- with a readable report beside it.

flights.py asks who the aircraft are: adsbdb for the airframe and the
route, hexdb behind it, cached for a month.  What needs no website is
answered without one, because the ICAO address block says which country
registered the aircraft and the first three letters of an airline callsign
are its designator.  Nothing but the address and the callsign heard on the
air is ever sent.

  bandsaunter flights [LOG...] --out sky.gif

reads a log back and draws the evening as a map with the clock running.
Every frame is a moment: each aircraft is where it actually was then,
interpolated between the position reports either side of it and
dead-reckoned from its last speed and heading between them, and dropped
rather than guessed at once it has not been heard for --stale seconds.
The GIF is written here -- palette, LZW, frame differencing against a
transparent index -- so nothing but numpy is needed; ffmpeg writes an MP4
where it happens to be installed, and .png draws the whole evening at once.

The decoder needed 6.3 s to read a second of sky, so a live capture was
losing six frames in seven.  Reading the bits off a running total instead
of summing each window takes that to 0.6 s, with identical output.

--simulate flies six aircraft that are not there past a receiver that is
not there, through the real encoder, the real checksum and the real
decoder, so all of this can be tried without an aerial.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
This commit is contained in:
The Dust Council 2026-09-03 22:22:49 -07:00
parent a8a8548369
commit eae60cb04d
15 changed files with 3857 additions and 177 deletions

View file

@ -91,6 +91,32 @@ def test_every_frame_survives_a_noisy_receiver(noise):
assert registry.aircraft["4CA1FA"].located
def test_a_frame_at_the_very_end_of_the_block_is_not_half_read():
"""The bit reader runs off the end of the samples, and a long frame that
does not fit must not be read as the short frame that does."""
frame = gen.identification(0x4CA1FA, "RYR1234")
samples = gen.modulate([frame], gap_us=8.0)
cut = samples[:samples.size - int(0.5 * RATE / 1e6)] # half a bit short
got = decode_frames(cut, RATE)
assert got == [] or got[0].icao == "4CA1FA"
def test_a_second_of_sky_is_decoded_in_less_than_a_second():
"""A capture mode that cannot keep up is not capturing. Timed loosely --
this is about the shape of the work, not the speed of the machine."""
import time
from bandsaunter.adsb import SAMPLE_RATE, SimulatedSky, default_sky
sky = SimulatedSky(default_sky(), noise=0.02, seed=1)
block = sky.read_samples(int(SAMPLE_RATE))
started = time.perf_counter()
got = decode_frames(block, SAMPLE_RATE)
took = time.perf_counter() - started
assert len(got) >= 18 # six aircraft, three frames each
assert took < 3.0, f"a second of sky took {took:.1f} s to decode"
def test_many_aircraft_are_kept_apart():
frames = []
for i, icao in enumerate((0x4CA1FA, 0xA0B1C2, 0x3C6444, 0x780102)):