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

@ -942,7 +942,8 @@ turns it off.
```bash
bandsaunter adsb # listen on 1090 MHz until interrupted
bandsaunter adsb --frames # print every frame as it arrives
bandsaunter adsb --kml planes.kml # and write what was heard as a map
bandsaunter adsb --simulate # invent a sky, for a receiver with no aerial
bandsaunter flights # read the log back: report, map, animation
```
Every airliner overhead broadcasts its address, callsign, altitude, position
@ -973,7 +974,86 @@ than resolved against two different grids.
An aerial cut for 1090 MHz is the difference between hearing the airport and
hearing the county; the whip supplied with a dongle is a quarter of the length
it wants.
it wants. `--simulate` flies six imaginary aircraft past an imaginary receiver
— real frames, real checksums, the same decoder — so the whole of the rest of
this section can be tried before any of that is wired up.
### What is written down
An aircraft is overhead for four minutes and then gone, so everything heard
goes into a log as it arrives: `adsb_<time>.jsonl` in the output directory, one
JSON object per frame, flushed as it is written because a listening session
ends with control-C.
```json
{"t":1788496791.486,"icao":"4008F6","df":17,"tc":19,
"hex":"8D4008F69905A11E202C00D3450D","gs_kt":480.3,"track":300.0,"vs_fpm":640}
```
**The raw frame goes down next to what was read out of it**, because the frame
is the evidence and everything else on the line is an opinion about it: a
better decoder can be run over the same evening later. Beside it goes a
readable report, one block per aircraft. A frame costs about 160 bytes on
disk, so a busy sky is a few tens of megabytes an hour; `--no-log` listens
without writing anything down.
### Who the aircraft is
The frames say `4008F6`, not "a Boeing 747 registered in the United Kingdom
flying Heathrow to Seattle". That comes from a register, and two are asked —
[adsbdb](https://api.adsbdb.com) for the airframe and the route, then
[hexdb](https://hexdb.io) — with the answers cached for a month. Nothing is
sent to either but the address or the callsign that was heard on the air.
What can be answered without asking anybody is: the **address block** says
which country registered the aircraft (fixed by treaty, so `4008F6` is British
and `A835AF` is American with no network at all), and the first three letters
of an airline callsign are its ICAO designator, so `RYR1234` is Ryanair.
`--no-lookup` stops at that.
```
4008F6 BAW49
registration: G-VROS
aircraft: Boeing Company 747-443
operator: CELESTIAL AVIATION TRADING 14 LTD
registered in: United Kingdom
route: London Heathrow Airport → Seattle Tacoma International Airport
heard: 2026-09-03 21:44:47 to 2026-09-03 21:48:46 (3 min 59 s, 132 frames)
from: 48.2742, -121.7963
to: 48.5334, -122.4725
flew: 31.2 nm over 81 positions
altitude: 33,000 to 35,475 ft
speed: up to 480 kt
```
### The moving map
```bash
bandsaunter flights # the newest log: report and a GIF
bandsaunter flights evening.jsonl --out sky.mp4 --speed 60
bandsaunter flights --out sky.png # the whole evening in one picture
bandsaunter flights --kml --no-map # for Google Earth instead
```
A log is a list of times and places; drawn on a map with the clock running it
is an evening's air traffic. **Every frame is a moment**: each aircraft is
drawn where it actually was then — interpolated between the position reports
either side of it, and dead-reckoned from its last known speed and heading
where none arrived — so an aircraft crossing the picture in ten seconds took
the twenty minutes the data says it took. Nothing moves at a constant speed
for the look of the thing, and an aircraft not heard from for five minutes
stops being drawn rather than being flown on by guesswork.
Time runs at `--speed` seconds of flying per second of animation, or give
`--seconds` and let it work the speed out. Altitude is the colour, low warm to
high cold, with the key along the bottom; the trail behind each aircraft is the
path it actually flew, in the colours of the heights it flew them at.
The GIF is written here from first principles — a palette, an LZW stream, frame
differencing with a transparent index — in the same spirit as the PNGs
elsewhere, so nothing but numpy is needed to draw one. Where ffmpeg happens to
be installed, `--out something.mp4` is smaller and smoother; where it is not,
nothing breaks and a GIF is written instead.
## Meters and weather sensors

View file

@ -9,7 +9,7 @@ and transcribing speech.
# 2026-08-21_02 is the second build made on the 21st. The revision is padded
# to two digits so versions sort as text.
VERSION_DATE = "2026-09-03"
VERSION_REVISION = 2
VERSION_REVISION = 3
__version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}"

View file

@ -21,12 +21,16 @@ adsb`` parks the receiver on 1090 MHz at full rate instead.
from __future__ import annotations
import math
import random
import time
from dataclasses import dataclass, field
import numpy as np
__all__ = ["decode_adsb", "Frame", "Aircraft", "AircraftRegistry", "crc24",
"ADSB_HZ", "SAMPLE_RATE", "PREAMBLE_US"]
"ADSB_HZ", "SAMPLE_RATE", "PREAMBLE_US", "encode_identification",
"encode_position", "encode_velocity", "modulate", "SimulatedSky",
"VirtualAircraft", "default_sky"]
ADSB_HZ = 1_090_000_000.0
@ -90,6 +94,7 @@ class Frame:
cpr_odd: bool = False
cpr_lat: int = 0
cpr_lon: int = 0
received_at: float = 0.0 # when it arrived, in whatever clock the caller keeps
ground_speed_kt: float = 0.0
track_deg: float = 0.0
vertical_rate_fpm: int = 0
@ -152,21 +157,29 @@ def _preamble_score(mag: np.ndarray, per_us: float) -> np.ndarray:
return at(pulses) - at(quiet)
def _bits_at(mag: np.ndarray, start: int, per_us: float, count: int) -> str:
"""Read ``count`` pulse-position bits: loud first half is a one."""
out = []
def _bits_at(running: np.ndarray, start: int, per_us: float,
count: int) -> str:
"""Read ``count`` pulse-position bits: loud first half is a one.
``running`` is a running total of the magnitudes, so the energy in any
stretch of samples is one subtraction rather than a sum. It matters:
every candidate preamble in a second of receiver output is tried at two
lengths, which is a quarter of a million half-microsecond windows a
second, and adding them up one at a time is the difference between
keeping up with the sky and hearing one frame in seven.
"""
half = 0.5 * per_us
base = start + 8.0 * per_us
for i in range(count):
first = base + i * per_us
a0, a1 = int(round(first)), int(round(first + half))
b0, b1 = a1, int(round(first + per_us))
if b1 > mag.size:
break
early = float(mag[a0:a1].sum())
late = float(mag[b0:b1].sum())
out.append("1" if early > late else "0")
return "".join(out)
firsts = start + 8.0 * per_us + np.arange(count) * per_us
a0 = np.rint(firsts).astype(np.int64)
a1 = np.rint(firsts + half).astype(np.int64)
b1 = np.rint(firsts + per_us).astype(np.int64)
size = running.size - 1
if b1[-1] > size:
keep = int(np.searchsorted(b1, size, side="right"))
a0, a1, b1 = a0[:keep], a1[:keep], b1[:keep]
early = running[a1] - running[a0]
late = running[b1] - running[a1]
return "".join(np.where(early > late, "1", "0"))
# The formats whose parity is the checksum itself. Everything else has the
@ -213,13 +226,18 @@ def decode_frames(iq: np.ndarray, sample_rate: float) -> list[Frame]:
# weak frames through and high enough not to try every sample.
floor = float(np.median(mag)) * 2.0
candidates = np.flatnonzero(score > max(floor, float(np.std(score))))
# Once, for the whole block: every window a candidate asks about is then
# the difference between two of these.
running = np.concatenate(([0.0], np.cumsum(mag, dtype=np.float64)))
frames: list[Frame] = []
seen: set[int] = set()
# The candidates come out in order, so the only accepted frame a new one
# can overlap is the last of them.
taken = -per_us * 2
for start in candidates:
if any(abs(start - s) < per_us for s in seen):
if start - taken < per_us:
continue
for count in (LONG_BITS, SHORT_BITS):
bits = _bits_at(mag, int(start), per_us, count)
bits = _bits_at(running, int(start), per_us, count)
if len(bits) < count:
continue
data = _bytes_of(bits)
@ -228,7 +246,7 @@ def decode_frames(iq: np.ndarray, sample_rate: float) -> list[Frame]:
frame = _read(bits, data)
frame.at_sample = int(start)
frames.append(frame)
seen.add(int(start))
taken = int(start)
break
return frames
@ -405,6 +423,7 @@ class AircraftRegistry:
self.aircraft[frame.icao] = seen
seen.messages += 1
seen.last_seen = when
frame.received_at = when
if frame.callsign:
seen.callsign = frame.callsign
if frame.altitude_ft:
@ -421,8 +440,12 @@ class AircraftRegistry:
seen._even = frame
if seen._even is not None and seen._odd is not None:
# Whichever of the pair arrived later is the one the position
# is reported at.
even_first = seen._even.at_sample > seen._odd.at_sample
# is reported at. Compared by arrival rather than by sample
# offset: the offset restarts at zero every block, so a pair
# that straddles two blocks would otherwise be read backwards
# and put the aircraft in the wrong zone.
even_first = (seen._even.received_at, seen._even.at_sample) > \
(seen._odd.received_at, seen._odd.at_sample)
found = global_position(seen._even, seen._odd, even_first)
if found is not None:
seen.latitude, seen.longitude = found
@ -442,3 +465,259 @@ def decode_adsb(iq: np.ndarray, sample_rate: float,
for frame in frames:
registry.add(frame, when=frame.at_sample / sample_rate)
return frames, registry
# ---------------------------------------------------------------------------
# The other direction: making frames, for a receiver that has no aerial
# ---------------------------------------------------------------------------
#
# ADS-B is the one thing in this program that cannot be tried out indoors. A
# scanner can be pointed at a simulated transmitter, but 1090 MHz needs an
# aerial cut for it and an aeroplane in the sky, and a person deciding whether
# any of this is worth wiring up has neither. So the frames can be built as
# well as read, and a sky full of imaginary aircraft can be flown past an
# imaginary receiver: the same encoding, the same checksum, the same decoder.
def _with_parity(payload: bytes) -> bytes:
"""A frame with its 24 parity bits on the end, as a transmitter sends it."""
return payload + crc24(payload).to_bytes(3, "big")
def _squitter(icao: int, me: bytes) -> bytes:
"""DF17, capability 5, one aircraft address and 56 bits of message."""
return _with_parity(bytes([17 << 3 | 5]) + (icao & 0xFFFFFF).to_bytes(3, "big")
+ bytes(me))
def encode_identification(icao: int, callsign: str, category: int = 0) -> bytes:
"""The frame an aircraft sends to say what it is called."""
text = callsign.upper().ljust(8)[:8]
bits = ""
for char in text:
index = CALLSIGN_CHARS.find(char)
bits += format(index if index >= 0 else 32, "06b")
me = bytes([(4 << 3) | (category & 0x07)]) + int(bits, 2).to_bytes(6, "big")
return _squitter(icao, me)
def _cpr_encode(lat: float, lon: float, odd: bool) -> tuple[int, int]:
"""Compact position reporting, the transmitting side of :func:`global_position`."""
i = 1 if odd else 0
d_lat = 360.0 / (60 - i)
y = int(round(131072 * ((lat % d_lat) / d_lat)))
zones = _nl(lat) - i
d_lon = 360.0 / zones if zones > 0 else 360.0
x = int(round(131072 * ((lon % d_lon) / d_lon)))
return y & 0x1FFFF, x & 0x1FFFF
def encode_position(icao: int, lat: float, lon: float, altitude_ft: int,
odd: bool) -> bytes:
"""An airborne position frame: where the aircraft is and how high.
Half a position, strictly: it takes an even frame and an odd one to say
where anything is, which is the whole point of the encoding.
"""
steps = max(0, int(round((altitude_ft + 1000) / 25.0)))
field_bits = format(min(steps, 0x7FF), "011b")
altitude = field_bits[:7] + "1" + field_bits[7:] # the Q bit: 25 ft
y, x = _cpr_encode(lat, lon, odd)
me_bits = (format(11, "05b") + "000" + altitude + "0"
+ ("1" if odd else "0")
+ format(y, "017b") + format(x, "017b"))
return _squitter(icao, int(me_bits, 2).to_bytes(7, "big"))
def encode_velocity(icao: int, east_kt: float, north_kt: float,
vertical_fpm: int = 0) -> bytes:
"""A velocity frame: ground speed as two components, and climb rate."""
east, north = int(round(east_kt)), int(round(north_kt))
rate = min(511, abs(int(vertical_fpm)) // 64 + 1) if vertical_fpm else 0
me_bits = (format(19, "05b") + "001" + "00000"
+ ("1" if east < 0 else "0") + format(min(1023, abs(east) + 1), "010b")
+ ("1" if north < 0 else "0") + format(min(1023, abs(north) + 1), "010b")
+ "0" + ("1" if vertical_fpm < 0 else "0")
+ format(rate, "09b") + "0" * 10)
return _squitter(icao, int(me_bits[:56], 2).to_bytes(7, "big"))
def _burst(frame: bytes, per_us: float, amplitude: float = 1.0) -> np.ndarray:
"""One frame as the magnitude a receiver sees: preamble, then the bits."""
bits = "".join(format(byte, "08b") for byte in frame)
span = np.zeros(int(round((8 + len(bits) + 1) * per_us)), dtype=np.float32)
half = int(round(0.5 * per_us))
for at in PREAMBLE_US:
lo = int(round(at * per_us))
span[lo:lo + half] = amplitude
for i, bit in enumerate(bits):
base = (8 + i) * per_us
lo = int(round(base if bit == "1" else base + 0.5 * per_us))
span[lo:lo + half] = amplitude
return span
def modulate(frames, sample_rate: float = SAMPLE_RATE, gap_us: float = 60.0,
amplitude: float = 1.0, noise: float = 0.0,
seed: int = 0) -> np.ndarray:
"""Turn frames into what a receiver on 1090 MHz would have heard."""
per_us = sample_rate / 1e6
gap = np.zeros(int(round(gap_us * per_us)), dtype=np.float32)
parts = [gap]
for frame in frames:
parts.append(_burst(frame, per_us, amplitude))
parts.append(gap)
signal = np.concatenate(parts)
if noise:
rng = np.random.default_rng(seed)
signal = signal + noise * np.abs(rng.standard_normal(signal.size))
return signal.astype(np.complex64)
@dataclass
class VirtualAircraft:
"""An aeroplane that does not exist, flying in a straight line.
Enough of an aircraft to be worth drawing: it has an address, a callsign,
a place to be, a speed to get there at and a rate of climb. It reports
itself exactly as a real one does, so nothing downstream can tell the
difference -- which is the point, because everything downstream is being
tested.
"""
icao: int = 0
callsign: str = ""
latitude: float = 0.0
longitude: float = 0.0
altitude_ft: int = 30_000
speed_kt: float = 420.0
heading_deg: float = 90.0
climb_fpm: int = 0
strength: float = 1.0
def advance(self, seconds: float) -> None:
"""Fly on for a while, which is all this aircraft knows how to do."""
nm = self.speed_kt * seconds / 3600.0
theta = math.radians(self.heading_deg)
self.latitude += nm / 60.0 * math.cos(theta)
# A minute of longitude is a minute of latitude times the cosine, and
# at eighty degrees north that difference is most of the answer.
self.longitude += nm / 60.0 * math.sin(theta) / max(
0.05, math.cos(math.radians(self.latitude)))
self.altitude_ft = max(0, int(self.altitude_ft
+ self.climb_fpm * seconds / 60.0))
def frames(self, second: int) -> list[bytes]:
"""What it broadcasts in one second: position, velocity, sometimes a name."""
theta = math.radians(self.heading_deg)
out = [encode_position(self.icao, self.latitude, self.longitude,
self.altitude_ft, odd=False),
encode_position(self.icao, self.latitude, self.longitude,
self.altitude_ft, odd=True),
encode_velocity(self.icao, self.speed_kt * math.sin(theta),
self.speed_kt * math.cos(theta), self.climb_fpm)]
if second % 5 == 0 and self.callsign:
out.insert(0, encode_identification(self.icao, self.callsign))
return out
def default_sky(latitude: float = 47.55, longitude: float = -122.30,
seed: int = 7) -> list[VirtualAircraft]:
"""A handful of aircraft around a receiver, going about their business.
Airliners at height on their way past, one climbing out, one descending
towards the airport and a helicopter going nowhere in particular: enough
different heights and speeds that a map of them is worth looking at.
"""
rng = random.Random(seed)
def near(miles: float) -> tuple[float, float]:
bearing = rng.uniform(0, 360)
return (latitude + miles / 60.0 * math.cos(math.radians(bearing)),
longitude + miles / 60.0 * math.sin(math.radians(bearing))
/ max(0.05, math.cos(math.radians(latitude))))
plan = (("UAL1902", 0xA1B2C3, 36_000, 470.0, 78.0, 0, 40.0),
("ASA412", 0xA24C71, 12_500, 310.0, 155.0, -1800, 22.0),
("SWA2311", 0xA9E0F4, 4_200, 240.0, 342.0, 2200, 14.0),
("DAL88", 0xAB1D55, 39_000, 505.0, 265.0, 0, 55.0),
("N517HP", 0xA6F109, 1_200, 95.0, 20.0, 0, 6.0),
("BAW49", 0x4008F6, 33_000, 480.0, 300.0, 640, 48.0))
sky = []
for callsign, icao, altitude, speed, heading, climb, distance in plan:
lat, lon = near(distance)
sky.append(VirtualAircraft(icao=icao, callsign=callsign, latitude=lat,
longitude=lon, altitude_ft=altitude,
speed_kt=speed, heading_deg=heading,
climb_fpm=climb,
strength=rng.uniform(0.6, 1.0)))
return sky
class SimulatedSky:
"""A receiver-shaped source of aeroplanes that are not there.
It answers ``read_samples`` like the real device does and hands back the
same magnitudes a dongle would, so ``bandsaunter adsb --simulate`` runs
every line of the decoder, the log, the lookups and the map without an
aerial, an aircraft or a licence.
"""
def __init__(self, aircraft=None, sample_rate: float = SAMPLE_RATE,
noise: float = 0.02, seed: int = 0, realtime: bool = False):
self.aircraft = list(aircraft) if aircraft is not None else default_sky()
self.sample_rate = float(sample_rate)
self.noise = noise
self.rng = np.random.default_rng(seed)
self.second = 0
self.frequency = ADSB_HZ
# A block is a second of samples but takes longer than a second to
# decode, so an aircraft advanced by the length of the block falls
# behind the clock the frames are stamped with -- and a map drawn
# from the log would show a 480-knot airliner crawling. Listening
# for real, the sky moves by the time that actually passed; in a
# test, by the block, so the same seed gives the same sky twice.
self.realtime = realtime
self._last = None
# -- the shape of a device -------------------------------------------
def open(self):
return self
def close(self) -> None:
return None
def tune(self, hz: float, settle: bool = True) -> int:
self.frequency = hz
return int(hz)
def read_samples(self, count: int, flush: bool = False) -> np.ndarray:
"""One block of sky: everyone reports, everyone moves on.
The bursts are scattered through the block rather than lined up at
the front, because two aircraft transmitting at the same moment is a
thing that happens and a decoder that has never seen it is untested.
"""
seconds = count / self.sample_rate
if self.realtime:
now = time.monotonic()
if self._last is not None:
seconds = max(0.0, now - self._last)
self._last = now
block = np.zeros(count, dtype=np.float32)
per_us = self.sample_rate / 1e6
for craft in self.aircraft:
for frame in craft.frames(self.second):
burst = _burst(frame, per_us, craft.strength)
at = int(self.rng.integers(0, max(1, count - burst.size)))
block[at:at + burst.size] = np.maximum(
block[at:at + burst.size], burst[:count - at])
craft.advance(seconds)
self.second += 1
if self.noise:
block = block + self.noise * np.abs(
self.rng.standard_normal(count)).astype(np.float32)
return block.astype(np.complex64)
def read_seconds(self, seconds: float, flush: bool = False) -> np.ndarray:
return self.read_samples(int(self.sample_rate * seconds))

View file

@ -7,6 +7,7 @@ import json
import signal
import sys
import time
from datetime import datetime
from pathlib import Path
from rich.console import Console
@ -55,6 +56,8 @@ examples:
bandsaunter config hang_seconds=5 set one setting and save it
bandsaunter bands --category Aviation browse the US band plan
bandsaunter devices list attached dongles
bandsaunter adsb read the aircraft on 1090 MHz
bandsaunter flights --out sky.gif animate what they did
bandsaunter scan -b 2m --simulate try it without hardware
""")
p.add_argument("--version", action="version", version=f"bandsaunter {__version__}")
@ -152,9 +155,8 @@ examples:
pr = sub.add_parser("profiles", help="list saved profiles")
pr.add_argument("--show", metavar="NAME", help="print one profile")
# -- analyse ------------------------------------------------------------
ad = sub.add_parser("adsb",
help="listen to aircraft on 1090 MHz")
# -- adsb ---------------------------------------------------------------
ad = sub.add_parser("adsb", help="listen to aircraft on 1090 MHz")
ad.add_argument("--seconds", type=float, default=0.0,
help="stop after this long (default: until interrupted)")
ad.add_argument("--rate", type=float, default=2_000_000.0,
@ -163,9 +165,57 @@ examples:
ad.add_argument("--device", type=int, default=0, help="which receiver")
ad.add_argument("--frames", action="store_true",
help="print every frame as it arrives, not a summary")
ad.add_argument("--kml", nargs="?", const="aircraft.kml", default=None,
metavar="FILE",
help="write what was heard as a map and exit")
ad.add_argument("--log", default=None, metavar="FILE",
help="where to write the frame log "
"(default: adsb_<time>.jsonl in the output directory)")
ad.add_argument("--no-log", dest="log_frames", action="store_false",
help="listen without writing anything down")
ad.add_argument("--no-lookup", dest="lookup", action="store_false",
help="do not ask the registers who the aircraft are")
ad.add_argument("--kml", nargs="?", const="", default=None, metavar="FILE",
help="also write the flight paths for Google Earth")
ad.add_argument("--map", nargs="?", const="", default=None, metavar="FILE",
help="draw the animated map when the listening stops")
ad.add_argument("--simulate", action="store_true",
help="invent a sky, for a receiver with no aerial")
ad.add_argument("--near", default=None, metavar="LAT,LON",
help="where the simulated aircraft are flying")
ad.set_defaults(log_frames=True, lookup=True)
# -- flights --------------------------------------------------------------
fl = sub.add_parser("flights",
help="read an ADS-B log: report, map, animation")
fl.add_argument("path", nargs="*",
help="frame logs (default: the newest in the output directory)")
fl.add_argument("--out", default=None, metavar="FILE",
help="the animation to write: .gif, .mp4 or .png "
"(default: beside the log, as a GIF)")
fl.add_argument("--fps", type=float, default=12.0,
help="frames a second in the animation")
fl.add_argument("--seconds", type=float, default=30.0,
help="how long the animation should run for")
fl.add_argument("--speed", type=float, default=0.0, metavar="X",
help="seconds of flying per second of animation "
"(overrides --seconds)")
fl.add_argument("--width", type=int, default=960, help="picture width")
fl.add_argument("--trail", type=float, default=0.0, metavar="SECONDS",
help="how much of the path to leave behind "
"(default: all of it)")
fl.add_argument("--stale", type=float, default=300.0, metavar="SECONDS",
help="drop an aircraft this long after its last report")
fl.add_argument("--no-labels", dest="labels", action="store_false",
help="draw the aircraft without callsigns beside them")
fl.add_argument("--no-map", dest="draw", action="store_false",
help="report only, draw nothing")
fl.add_argument("--no-lookup", dest="lookup", action="store_false",
help="do not ask the registers who the aircraft are")
fl.add_argument("--kml", nargs="?", const="", default=None, metavar="FILE",
help="also write the flight paths for Google Earth")
fl.add_argument("--report", nargs="?", const="", default=None, metavar="FILE",
help="also write the readable report to a file")
fl.set_defaults(labels=True, draw=True, lookup=True)
# -- analyse ------------------------------------------------------------
a = sub.add_parser("analyze", aliases=["analyse"],
help="identify a signal in a recorded file")
@ -929,50 +979,89 @@ def cmd_profiles(args) -> int:
def cmd_adsb(args) -> int:
"""Park the receiver on 1090 MHz and read the aircraft overhead.
"""Park the receiver on 1090 MHz and write down the aircraft overhead.
A command of its own because ADS-B does not fit through the scanner. It
is a megabit a second, which needs two megasamples a second of raw
receiver output; the scan path decimates everything to a channel twelve
and a half kilohertz wide before anything sees it, and a megabit will not
go through that.
"""
from .adsb import ADSB_HZ, AircraftRegistry, SAMPLE_RATE, decode_adsb
Everything heard goes into a log as it arrives, because an aircraft is
overhead for four minutes and then gone: the summary on the screen is for
the person watching, and the log is for everything afterwards -- the
report, the map and the animation.
"""
from .adsb import (ADSB_HZ, AircraftRegistry, SAMPLE_RATE, SimulatedSky,
decode_frames, default_sky)
from .flightlog import FlightLog, read_logs, report, write_kml
from .flights import FlightBook
cfg, _ = load_default()
if args.rate < SAMPLE_RATE:
console.print(f"[red]ADS-B needs at least {SAMPLE_RATE/1e6:g} MS/s; "
f"{args.rate/1e6:g} is not enough to see a bit."
"[/red]")
f"{args.rate/1e6:g} is not enough to see a bit.[/red]")
return 2
try:
device = RtlSdrDevice(index=args.device, sample_rate=int(args.rate),
gain=args.gain, agc=args.gain == "auto")
device.open()
except RtlSdrError as exc:
console.print(Panel(Text(str(exc)), title="[red]cannot open the receiver",
border_style="red"))
return 1
if args.simulate:
sky = default_sky(*_near(args.near)) if args.near else default_sky()
device = SimulatedSky(sky, sample_rate=args.rate,
realtime=True).open()
console.print("[yellow]simulated: these aircraft are not there."
"[/yellow]")
else:
try:
device = RtlSdrDevice(index=args.device, sample_rate=int(args.rate),
gain=args.gain, agc=args.gain == "auto")
device.open()
except RtlSdrError as exc:
console.print(Panel(Text(str(exc)),
title="[red]cannot open the receiver",
border_style="red"))
return 1
started = time.time()
log = None
if args.log_frames:
stamp = datetime.fromtimestamp(started).strftime("%Y-%m-%d_%H_%M_%S")
where = Path(args.log).expanduser() if args.log else \
Path(cfg.output_dir).expanduser() / f"adsb_{stamp}.jsonl"
try:
log = FlightLog(where, frequency=ADSB_HZ, sample_rate=args.rate,
receiver="simulated" if args.simulate else
f"device {args.device}", started=started)
except OSError as exc:
console.print(f"[red]cannot write {where}: {exc}[/red]")
log = None
registry = AircraftRegistry()
total = 0
started = time.time()
console.print(f"[grey62]listening on {ADSB_HZ/1e6:g} MHz at "
f"{args.rate/1e6:g} MS/s — control-C to stop[/grey62]")
if log is not None:
console.print(f"[grey62]writing {log.path}[/grey62]")
try:
device.tune(ADSB_HZ)
block = int(args.rate) # a second at a time
while True:
at = time.time()
samples = device.read_samples(block)
if samples is None or samples.size == 0:
break
frames, registry = decode_adsb(samples, args.rate, registry)
total += len(frames)
if args.frames:
for frame in frames:
for frame in decode_frames(samples, args.rate):
# The real time the frame arrived, not its offset in the
# block: everything downstream is a clock, and a log that
# started again from zero every second would be unusable.
when = at + frame.at_sample / args.rate
craft = registry.add(frame, when=when)
total += 1
if log is not None:
log.append(frame, craft, when=when)
if args.frames:
console.print(f"[cyan]{frame.icao}[/cyan] "
f"{escape(frame.describe())}",
highlight=False)
elif frames:
if not args.frames and total:
console.print(f"[grey62]{len(registry)} aircraft, "
f"{total} frames[/grey62]", highlight=False)
if args.seconds and time.time() - started >= args.seconds:
@ -981,12 +1070,88 @@ def cmd_adsb(args) -> int:
pass
finally:
device.close()
if log is not None:
log.close()
if not registry:
console.print("[yellow]nothing heard. ADS-B needs an aerial cut for "
"1090 MHz; the whip that came with the dongle will "
"hear the airport and not much else.[/yellow]")
return 1
book = FlightBook(online=args.lookup)
_aircraft_table(registry, book, total)
if args.lookup:
book.wait(12.0)
book.save()
_lookup_table(registry, book)
tracks = read_logs(log.path) if log is not None else _tracks_from(registry)
if args.kml is not None:
where = Path(args.kml).expanduser() if args.kml else \
(log.path.with_suffix(".kml") if log is not None
else Path("aircraft.kml"))
written = write_kml(where, tracks, book if args.lookup else None)
console.print(f"[green]{written}[/green]" if written
else "[yellow]nothing was placed on the map[/yellow]")
if log is not None:
told = log.path.with_suffix(".txt")
try:
told.write_text("\n".join(report(
tracks, book if args.lookup else None,
title=f"bandsaunter — aircraft heard "
f"{datetime.fromtimestamp(started):%Y-%m-%d %H:%M}")))
console.print(f"[green]{told}[/green]")
except OSError as exc:
console.print(f"[red]cannot write {told}: {exc}[/red]")
if args.map is not None:
_draw_flights(tracks, args.map or (log.path.with_suffix(".gif")
if log is not None
else Path("aircraft.gif")),
book if args.lookup else None)
elif log is not None:
console.print(f"[grey62]draw it: bandsaunter flights {log.path}"
"[/grey62]")
return 0
def _near(text: str) -> tuple[float, float]:
"""Read a LAT,LON pair, falling back to the default sky."""
try:
lat, lon = (float(x) for x in str(text).split(",", 1))
return lat, lon
except (TypeError, ValueError):
console.print(f"[yellow]cannot read {text!r} as a latitude and "
"longitude; flying somewhere else instead[/yellow]")
return 47.55, -122.30
def _tracks_from(registry):
"""Tracks from a registry, for a session that wrote no log.
One position each: what is on the screen is all there is, because nothing
kept the ones before it.
"""
from .flightlog import Fix, Track
out = []
for craft in sorted(registry.aircraft.values(), key=lambda a: a.icao):
track = Track(icao=craft.icao, callsign=craft.callsign,
frames=craft.messages, first_seen=craft.first_seen,
last_seen=craft.last_seen)
if craft.located:
track.fixes.append(Fix(at=craft.last_seen, latitude=craft.latitude,
longitude=craft.longitude,
altitude_ft=craft.altitude_ft,
ground_speed_kt=craft.ground_speed_kt,
track_deg=craft.track_deg,
vertical_rate_fpm=craft.vertical_rate_fpm))
out.append(track)
return out
def _aircraft_table(registry, book, total: int) -> None:
"""What was heard, as it was heard: no register, only the air."""
t = Table(title=f"{len(registry)} aircraft, {total} frames", box=None,
header_style="bold")
for column in ("ICAO", "callsign", "altitude", "position", "speed",
@ -994,44 +1159,130 @@ def cmd_adsb(args) -> int:
t.add_column(column)
for craft in sorted(registry.aircraft.values(), key=lambda a: a.icao):
t.add_row(craft.icao, craft.callsign or "",
f"{craft.altitude_ft} ft" if craft.altitude_ft else "",
f"{craft.altitude_ft:,} ft" if craft.altitude_ft else "",
(f"{craft.latitude:.4f}, {craft.longitude:.4f}"
if craft.located else ""),
(f"{craft.ground_speed_kt:.0f} kt {craft.track_deg:.0f}°"
if craft.ground_speed_kt else ""),
str(craft.messages))
console.print(t)
if args.kml is not None:
written = _write_aircraft_kml(Path(args.kml).expanduser(), registry)
console.print(f"[green]{written}[/green]" if written
else "[red]could not write the map[/red]")
return 0
def _write_aircraft_kml(path: Path, registry) -> Path | None:
"""Every located aircraft as a placemark, for Google Earth."""
from xml.sax.saxutils import escape as xml_escape
located = [a for a in registry.aircraft.values() if a.located]
if not located:
return None
out = ['<?xml version="1.0" encoding="UTF-8"?>',
'<kml xmlns="http://www.opengis.net/kml/2.2">', " <Document>",
" <name>bandsaunter — aircraft heard</name>"]
for craft in sorted(located, key=lambda a: a.icao):
name = f"{craft.icao} {craft.callsign}".strip()
out += [" <Placemark>",
f" <name>{xml_escape(name)}</name>",
f" <description>{xml_escape(craft.describe())}</description>",
" <Point><coordinates>"
f"{craft.longitude:.6f},{craft.latitude:.6f},"
f"{craft.altitude_ft * 0.3048:.0f}</coordinates></Point>",
" </Placemark>"]
out += [" </Document>", "</kml>", ""]
def _lookup_table(registry, book) -> None:
"""And what the registers say about them, kept separate on purpose."""
rows = []
for craft in sorted(registry.aircraft.values(), key=lambda a: a.icao):
entry = book.get(craft.icao, craft.callsign)
told = entry.summary()
if told or entry.country:
rows.append((craft.icao, craft.callsign or "", entry.country,
told or ""))
if not rows:
return
t = Table(title="what the registers say", box=None, header_style="bold")
for column in ("ICAO", "callsign", "registered", "aircraft, operator, route"):
t.add_column(column, overflow="fold")
for row in rows:
t.add_row(*row)
console.print(t)
def _draw_flights(tracks, out_path, book, **over) -> bool:
"""Draw the animation, saying what it is drawing and what came out."""
from .flightmap import animate, ffmpeg_available
out_path = Path(out_path).expanduser()
if out_path.suffix.lower() in (".mp4", ".mov", ".m4v") and \
not ffmpeg_available():
console.print("[yellow]ffmpeg is not installed; writing a GIF "
"instead[/yellow]")
out_path = out_path.with_suffix(".gif")
console.print(f"[grey62]drawing {out_path.name}…[/grey62]")
try:
path.write_text("\n".join(out))
drawn = animate(tracks, out_path, book=book, **over)
except (OSError, RuntimeError, ValueError) as exc:
console.print(f"[red]{exc}[/red]")
return False
if drawn is None:
console.print("[yellow]nothing was placed on the map: no aircraft "
"reported a position[/yellow]")
return False
size = drawn.path.stat().st_size / 1e6
console.print(f"[green]{drawn.path}[/green] "
f"[grey62]{drawn.summary()}, {size:.1f} MB[/grey62]")
return True
def cmd_flights(args) -> int:
"""Turn a log of ADS-B frames into something worth looking at.
The log is a list of times and places; this is the tool that reads it
back, asks who the aircraft were, prints what it found and draws the
whole evening as a map with the clock running.
"""
from .flightlog import read_logs, report, write_kml
from .flights import FlightBook
cfg, _ = load_default()
paths = [Path(p).expanduser() for p in args.path] if args.path \
else _newest_log(Path(cfg.output_dir).expanduser())
if not paths:
console.print("[yellow]no ADS-B logs found. Record one with "
"`bandsaunter adsb`.[/yellow]")
return 1
for path in paths:
if not path.exists():
console.print(f"[red]no such file: {path}[/red]")
return 1
tracks = read_logs(paths)
if not tracks:
console.print(f"[yellow]{paths[0].name} holds no frames[/yellow]")
return 1
book = FlightBook(online=args.lookup)
if args.lookup:
for track in tracks:
book.get(track.icao, track.callsign)
book.wait(20.0)
book.save()
title = f"bandsaunter — {paths[0].name}"
lines = report(tracks, book if args.lookup else None, title=title)
console.print(escape("\n".join(lines)), highlight=False)
if args.report is not None:
where = Path(args.report).expanduser() if args.report \
else paths[0].with_suffix(".txt")
try:
where.write_text("\n".join(lines))
console.print(f"[green]{where}[/green]")
except OSError as exc:
console.print(f"[red]cannot write {where}: {exc}[/red]")
if args.kml is not None:
where = Path(args.kml).expanduser() if args.kml \
else paths[0].with_suffix(".kml")
written = write_kml(where, tracks, book if args.lookup else None)
console.print(f"[green]{written}[/green]" if written
else "[yellow]nothing was placed on the map[/yellow]")
if not args.draw:
return 0
out = Path(args.out).expanduser() if args.out else \
paths[0].with_suffix(".gif")
drawn = _draw_flights(tracks, out, book if args.lookup else None,
fps=args.fps, seconds=args.seconds, speed=args.speed,
width=args.width, trail_seconds=args.trail,
stale=args.stale, labels=args.labels)
return 0 if drawn else 1
def _newest_log(directory: Path) -> list[Path]:
"""The last ADS-B log written, which is nearly always the one wanted."""
try:
logs = sorted(directory.glob("adsb_*.jsonl"),
key=lambda p: p.stat().st_mtime)
except OSError:
return None
return path
return []
return logs[-1:]
def cmd_analyze(args) -> int:
@ -1195,6 +1446,7 @@ def main(argv=None) -> int:
"config": cmd_config, "transcribe": cmd_transcribe,
"profiles": cmd_profiles, "analyze": cmd_analyze, "analyse": cmd_analyze,
"adsb": cmd_adsb, "waterfall": cmd_waterfall,
"flights": cmd_flights,
}
try:
return handlers[args.command](args)

551
bandsaunter/flightlog.py Normal file
View file

@ -0,0 +1,551 @@
"""Writing down what the aircraft said, and reading it back afterwards.
A receiver hears an aircraft for a few minutes as it crosses the sky and then
never again. What it heard is worth keeping: the position reports are a flight
path, and a flight path an hour old is the only way to see where anything went.
Two files come out of a listening session. The log is JSON Lines -- one object
per frame, in the order they arrived, with the raw hex of every frame kept
alongside what was read out of it, so nothing decoded here is the last word and
a better decoder can be run over the same evening later. The report is for
reading: one block per aircraft, saying what it is, who flies it, where it went
and how far.
The log is the input to the map. Everything the animation needs -- who, where,
when, how fast -- is in it, and nothing else is needed to draw the whole
evening again.
"""
from __future__ import annotations
import json
import math
import time
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
__all__ = ["Fix", "Track", "FlightLog", "read_logs", "report",
"write_kml", "LOG_VERSION", "EARTH_NM"]
LOG_VERSION = 1
# One nautical mile is a minute of latitude, which is what makes it the unit
# every other number in this file is already in.
EARTH_NM = 3440.065
@dataclass
class Fix:
"""One position report: where an aircraft was, and when."""
at: float = 0.0
latitude: float = 0.0
longitude: float = 0.0
altitude_ft: int = 0
ground_speed_kt: float = 0.0
track_deg: float = 0.0
vertical_rate_fpm: int = 0
def as_json(self) -> dict:
return {"t": round(self.at, 3),
"lat": round(self.latitude, 6),
"lon": round(self.longitude, 6),
"alt_ft": self.altitude_ft,
"gs_kt": round(self.ground_speed_kt, 1),
"track": round(self.track_deg, 1),
"vs_fpm": self.vertical_rate_fpm}
def distance_nm(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Great-circle distance in nautical miles."""
p1, p2 = math.radians(lat1), math.radians(lat2)
dp = p2 - p1
dl = math.radians(lon2 - lon1)
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * EARTH_NM * math.asin(min(1.0, math.sqrt(a)))
def bearing_deg(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Initial bearing from one point to another, in degrees true."""
p1, p2 = math.radians(lat1), math.radians(lat2)
dl = math.radians(lon2 - lon1)
y = math.sin(dl) * math.cos(p2)
x = math.cos(p1) * math.sin(p2) - math.sin(p1) * math.cos(p2) * math.cos(dl)
return math.degrees(math.atan2(y, x)) % 360.0
def move(lat: float, lon: float, bearing: float, nm: float) -> tuple[float, float]:
"""Where you get to going ``nm`` miles on a bearing: dead reckoning.
Used to fill the gaps. An aircraft heard once a minute is out of sight
for fifty-nine seconds of it, and it did not stop while nobody was
listening -- it carried on at the speed and heading it last reported.
"""
if nm == 0:
return lat, lon
p1 = math.radians(lat)
l1 = math.radians(lon)
d = nm / EARTH_NM
theta = math.radians(bearing)
p2 = math.asin(math.sin(p1) * math.cos(d)
+ math.cos(p1) * math.sin(d) * math.cos(theta))
l2 = l1 + math.atan2(math.sin(theta) * math.sin(d) * math.cos(p1),
math.cos(d) - math.sin(p1) * math.sin(p2))
return math.degrees(p2), (math.degrees(l2) + 540) % 360 - 180
@dataclass
class Track:
"""One aircraft's evening: who it was and everywhere it was seen."""
icao: str = ""
callsign: str = ""
fixes: list[Fix] = field(default_factory=list)
frames: int = 0
first_seen: float = 0.0
last_seen: float = 0.0
altitudes: list[tuple[float, int]] = field(default_factory=list)
@property
def located(self) -> bool:
return bool(self.fixes)
@property
def name(self) -> str:
return self.callsign or self.icao
@property
def seconds(self) -> float:
return max(0.0, self.last_seen - self.first_seen)
@property
def distance_nm(self) -> float:
"""How far it was watched flying, along the path it actually took."""
total = 0.0
for a, b in zip(self.fixes, self.fixes[1:]):
total += distance_nm(a.latitude, a.longitude, b.latitude, b.longitude)
return total
@property
def altitude_range(self) -> tuple[int, int]:
heights = [f.altitude_ft for f in self.fixes if f.altitude_ft] or \
[alt for _, alt in self.altitudes if alt]
return (min(heights), max(heights)) if heights else (0, 0)
@property
def top_speed_kt(self) -> float:
speeds = [f.ground_speed_kt for f in self.fixes if f.ground_speed_kt]
return max(speeds) if speeds else 0.0
def bounds(self) -> tuple[float, float, float, float]:
"""South, west, north, east: the box this track needs on a map."""
lats = [f.latitude for f in self.fixes]
lons = [f.longitude for f in self.fixes]
return (min(lats), min(lons), max(lats), max(lons))
def at(self, when: float, stale: float = 300.0) -> Fix | None:
"""Where the aircraft was at a given moment, or None if unknown.
Between two reports the position is interpolated along the time
between them, which is what makes a stream of fixes into movement.
After the last report it is dead-reckoned from the speed and heading
it was last flying, but only for a while: an aircraft that has not
been heard for five minutes has flown forty miles and is a guess, not
a position, and a map that keeps drawing it is inventing an aeroplane.
"""
if not self.fixes or when < self.fixes[0].at - 1e-9:
return None
last = self.fixes[-1]
if when > last.at:
gap = when - last.at
if gap > stale:
return None
if last.ground_speed_kt <= 0:
return last
lat, lon = move(last.latitude, last.longitude, last.track_deg,
last.ground_speed_kt * gap / 3600.0)
return Fix(at=when, latitude=lat, longitude=lon,
altitude_ft=last.altitude_ft + int(
last.vertical_rate_fpm * gap / 60.0),
ground_speed_kt=last.ground_speed_kt,
track_deg=last.track_deg,
vertical_rate_fpm=last.vertical_rate_fpm)
before = self.fixes[0]
for after in self.fixes[1:]:
if after.at >= when:
span = after.at - before.at
if span <= 0:
return after
part = (when - before.at) / span
return _between(before, after, part)
before = after
return last
def trail(self, until: float, seconds: float = 0.0) -> list[Fix]:
"""The path flown up to a moment, for drawing behind the aircraft."""
start = until - seconds if seconds else float("-inf")
out = [f for f in self.fixes if start <= f.at <= until]
now = self.at(until)
if now is not None and (not out or out[-1].at < now.at):
out.append(now)
return out
def describe(self) -> str:
"""One line: who, where from and to, how far and how high."""
bits = [self.icao]
if self.callsign:
bits.append(self.callsign)
if self.located:
first, last = self.fixes[0], self.fixes[-1]
bits.append(f"{first.latitude:.3f},{first.longitude:.3f}"
f"{last.latitude:.3f},{last.longitude:.3f}")
bits.append(f"{self.distance_nm:.0f} nm")
low, high = self.altitude_range
if high:
bits.append(f"{low}{high} ft" if low != high else f"{high} ft")
if self.top_speed_kt:
bits.append(f"{self.top_speed_kt:.0f} kt")
bits.append(f"{self.frames} frames")
return " ".join(bits)
def _between(before: Fix, after: Fix, part: float) -> Fix:
"""A position part of the way from one fix to the next.
Straight-line in latitude and longitude rather than along a great circle:
over the fifty miles between two reports from the same aircraft the
difference is a few metres, and pretending otherwise would be arithmetic
for its own sake.
"""
def mix(a: float, b: float) -> float:
return a + (b - a) * part
lon_a, lon_b = before.longitude, after.longitude
if abs(lon_b - lon_a) > 180.0: # across the date line
lon_b += 360.0 if lon_b < lon_a else -360.0
lon = (mix(lon_a, lon_b) + 540.0) % 360.0 - 180.0
heading = before.track_deg or bearing_deg(before.latitude, before.longitude,
after.latitude, after.longitude)
return Fix(at=mix(before.at, after.at),
latitude=mix(before.latitude, after.latitude), longitude=lon,
altitude_ft=int(round(mix(before.altitude_ft or after.altitude_ft,
after.altitude_ft or before.altitude_ft))),
ground_speed_kt=mix(before.ground_speed_kt, after.ground_speed_kt),
track_deg=heading,
vertical_rate_fpm=before.vertical_rate_fpm)
# ---------------------------------------------------------------------------
# Writing, as the frames arrive
# ---------------------------------------------------------------------------
class FlightLog:
"""A JSON Lines record of everything heard, written frame by frame.
Flushed after every frame on purpose. A listening session ends when the
aeroplanes stop or the operator gets bored and presses control-C, and a
log that only reached the disk on a clean shutdown would be empty exactly
when it was most wanted.
"""
def __init__(self, path, receiver: str = "", frequency: float = 0.0,
sample_rate: float = 0.0, started: float = 0.0):
self.path = Path(path)
self.frames = 0
self.started = started or time.time()
self.path.parent.mkdir(parents=True, exist_ok=True)
self._file = self.path.open("a", encoding="utf8")
self._write({"log": "bandsaunter-adsb", "version": LOG_VERSION,
"started": round(self.started, 3),
"started_local": datetime.fromtimestamp(
self.started).strftime("%Y-%m-%d %H:%M:%S"),
"frequency": frequency, "sample_rate": sample_rate,
"receiver": receiver})
def _write(self, body: dict) -> None:
self._file.write(json.dumps(body, separators=(",", ":"),
ensure_ascii=False) + "\n")
self._file.flush()
def append(self, frame, craft=None, when: float = 0.0) -> None:
"""Record one frame: what arrived, and what was made of it.
The raw hex goes down whatever was understood, because the frame is
the evidence and everything else in the line is an opinion about it.
"""
body: dict = {"t": round(when or time.time(), 3),
"icao": frame.icao, "df": frame.df,
"tc": frame.type_code,
"hex": frame.data.hex().upper()}
if frame.callsign:
body["callsign"] = frame.callsign
if frame.altitude_ft:
body["alt_ft"] = frame.altitude_ft
if frame.ground_speed_kt:
body["gs_kt"] = round(frame.ground_speed_kt, 1)
body["track"] = round(frame.track_deg, 1)
if frame.vertical_rate_fpm:
body["vs_fpm"] = frame.vertical_rate_fpm
# The position comes from the aircraft rather than the frame: it takes
# an even frame and an odd one, and this is the moment the pair became
# a place.
if craft is not None and craft.located:
body["lat"] = round(craft.latitude, 6)
body["lon"] = round(craft.longitude, 6)
if craft.callsign and "callsign" not in body:
body["callsign"] = craft.callsign
self.frames += 1
self._write(body)
def close(self) -> None:
try:
self._file.close()
except OSError:
pass
def __enter__(self) -> "FlightLog":
return self
def __exit__(self, *exc) -> None:
self.close()
# ---------------------------------------------------------------------------
# Reading, afterwards
# ---------------------------------------------------------------------------
def read_logs(paths) -> list[Track]:
"""Every aircraft in one or more logs, as tracks in time order.
A repeated position is dropped. An aircraft sitting on a stand reports
the same latitude and longitude twice a second for an hour, and a track
with seven thousand identical points in it is seven thousand times the
work to draw and no more informative than the one point.
"""
tracks: dict[str, Track] = {}
# What the aircraft last said about itself on a line that carried no
# position. Speed, heading and height arrive in their own frames, and
# they are facts about the aeroplane that the next position fix inherits.
said: dict[str, dict] = {}
for path in ([paths] if isinstance(paths, (str, Path)) else paths):
for line in _lines(Path(path)):
icao = str(line.get("icao") or "")
if not icao:
continue # the header, or a line about the run
track = tracks.get(icao)
if track is None:
track = Track(icao=icao, first_seen=float(line.get("t") or 0.0))
tracks[icao] = track
when = float(line.get("t") or 0.0)
track.frames += 1
track.first_seen = min(track.first_seen or when, when)
track.last_seen = max(track.last_seen, when)
if line.get("callsign"):
track.callsign = str(line["callsign"])
if line.get("alt_ft"):
track.altitudes.append((when, int(line["alt_ft"])))
# What it last said about itself, from whichever frame said it.
# Once an aircraft has been placed every line carries its
# position, so this cannot be gathered only from the lines that
# have none: it has to be updated from every line, or a velocity
# frame would be given the altitude the aircraft had when it was
# first heard and the track would flick between the two.
latest = said.setdefault(icao, {})
for key in ("alt_ft", "gs_kt", "track", "vs_fpm"):
if line.get(key):
latest[key] = line[key]
if "lat" not in line or "lon" not in line:
continue
fix = Fix(at=when, latitude=float(line["lat"]),
longitude=float(line["lon"]),
altitude_ft=int(line.get("alt_ft")
or latest.get("alt_ft") or 0),
ground_speed_kt=float(line.get("gs_kt")
or latest.get("gs_kt") or 0.0),
track_deg=float(line.get("track")
or latest.get("track") or 0.0),
vertical_rate_fpm=int(line.get("vs_fpm")
or latest.get("vs_fpm") or 0))
last = track.fixes[-1] if track.fixes else None
if last is not None and last.latitude == fix.latitude and \
last.longitude == fix.longitude:
_fill(last, fix)
continue
track.fixes.append(fix)
for track in tracks.values():
track.fixes.sort(key=lambda f: f.at)
_carry_forward(track)
return sorted(tracks.values(), key=lambda t: (t.first_seen, t.icao))
def _lines(path: Path):
try:
with path.open(encoding="utf8") as handle:
for line in handle:
line = line.strip()
if not line:
continue
try:
body = json.loads(line)
except ValueError:
continue # a half-written last line after a crash
if isinstance(body, dict):
yield body
except OSError:
return
def _fill(into: Fix, extra: Fix) -> None:
"""Fold a repeated position into the one already kept."""
into.altitude_ft = extra.altitude_ft or into.altitude_ft
into.ground_speed_kt = extra.ground_speed_kt or into.ground_speed_kt
into.track_deg = extra.track_deg or into.track_deg
into.vertical_rate_fpm = extra.vertical_rate_fpm or into.vertical_rate_fpm
def _carry_forward(track: Track) -> None:
"""Give every fix a speed, a heading and a height.
Position, velocity and altitude arrive in different frames, so a position
fix on its own often has no speed attached. What the aircraft last said
still applies -- it is the same aeroplane a second later -- and where it
never said anything at all the heading between one fix and the next is
what it was doing by definition.
"""
speed = heading = 0.0
altitude = climb = 0
for i, fix in enumerate(track.fixes):
if fix.ground_speed_kt:
speed, heading = fix.ground_speed_kt, fix.track_deg
else:
fix.ground_speed_kt, fix.track_deg = speed, heading
if fix.altitude_ft:
altitude = fix.altitude_ft
else:
fix.altitude_ft = altitude
if fix.vertical_rate_fpm:
climb = fix.vertical_rate_fpm
else:
fix.vertical_rate_fpm = climb
if not fix.track_deg and i + 1 < len(track.fixes):
nxt = track.fixes[i + 1]
fix.track_deg = bearing_deg(fix.latitude, fix.longitude,
nxt.latitude, nxt.longitude)
# ---------------------------------------------------------------------------
# The readable report
# ---------------------------------------------------------------------------
def report(tracks: list[Track], book=None, title: str = "") -> list[str]:
"""One block per aircraft, in the order they were first heard.
``book`` is a FlightBook, or None. Everything it can add is printed under
what was heard rather than mixed into it, so it is always clear which
lines came off the air and which came from a website.
"""
out: list[str] = []
if title:
out += [title, "=" * len(title), ""]
heard = [t for t in tracks if t.frames]
if not heard:
return out + ["nothing heard."]
span = (min(t.first_seen for t in heard), max(t.last_seen for t in heard))
out += [f"{len(heard)} aircraft, {sum(t.frames for t in heard)} frames, "
f"{_clock(span[0])} to {_clock(span[1])}",
f"{sum(1 for t in heard if t.located)} of them placed on the map",
""]
for track in heard:
out.append(f"{track.icao}"
+ (f" {track.callsign}" if track.callsign else ""))
entry = book.get(track.icao, track.callsign) if book is not None else None
if entry is not None:
for line in entry.details():
# The route is printed as one line further down: "from" and
# "to" mean the airports here and the ends of the track a few
# lines below, and one page cannot hold both meanings.
if not line.startswith(("address:", "callsign:", "from:", "to:")):
out.append(f" {line}")
if entry.country and not entry.owner_country:
out.append(f" registered in: {entry.country}")
if entry.route:
out.append(f" route: {entry.route}")
out.append(f" heard: {_clock(track.first_seen)} to "
f"{_clock(track.last_seen)} "
f"({_span(track.seconds)}, {track.frames} frames)")
if track.located:
first, last = track.fixes[0], track.fixes[-1]
out.append(f" from: {first.latitude:.4f}, {first.longitude:.4f}")
out.append(f" to: {last.latitude:.4f}, {last.longitude:.4f}")
out.append(f" flew: {track.distance_nm:.1f} nm over "
f"{len(track.fixes)} positions")
low, high = track.altitude_range
if high:
out.append(f" altitude: {low:,} to {high:,} ft"
if low != high else f" altitude: {high:,} ft")
if track.top_speed_kt:
out.append(f" speed: up to {track.top_speed_kt:.0f} kt")
out.append("")
return out
def _clock(when: float) -> str:
return datetime.fromtimestamp(when).strftime("%Y-%m-%d %H:%M:%S") \
if when else ""
def _span(seconds: float) -> str:
if seconds < 90:
return f"{seconds:.0f} s"
minutes, secs = divmod(int(seconds), 60)
if minutes < 90:
return f"{minutes} min {secs:02d} s"
hours, minutes = divmod(minutes, 60)
return f"{hours} h {minutes:02d} min"
# ---------------------------------------------------------------------------
# The same thing for Google Earth
# ---------------------------------------------------------------------------
def write_kml(path, tracks: list[Track], book=None,
title: str = "bandsaunter — aircraft heard") -> Path | None:
"""Every track as a line on the globe, with a pin where it was last seen."""
from xml.sax.saxutils import escape as xml_escape
located = [t for t in tracks if t.located]
if not located:
return None
out = ['<?xml version="1.0" encoding="UTF-8"?>',
'<kml xmlns="http://www.opengis.net/kml/2.2">', " <Document>",
f" <name>{xml_escape(title)}</name>",
' <Style id="path"><LineStyle><color>ff40c0ff</color>'
"<width>2</width></LineStyle></Style>"]
for track in located:
entry = book.get(track.icao, track.callsign) if book is not None else None
told = "\n".join([track.describe()]
+ (entry.details() if entry is not None else []))
name = f"{track.icao} {track.callsign}".strip()
line = " ".join(f"{f.longitude:.6f},{f.latitude:.6f},"
f"{f.altitude_ft * 0.3048:.0f}" for f in track.fixes)
out += [" <Placemark>",
f" <name>{xml_escape(name)}</name>",
f" <description>{xml_escape(told)}</description>",
" <styleUrl>#path</styleUrl>",
" <LineString><altitudeMode>absolute</altitudeMode>",
" <extrude>0</extrude><tessellate>1</tessellate>",
f" <coordinates>{line}</coordinates>",
" </LineString>", " </Placemark>",
" <Placemark>",
f" <name>{xml_escape(name)}</name>",
" <Point><coordinates>"
f"{track.fixes[-1].longitude:.6f},{track.fixes[-1].latitude:.6f},"
f"{track.fixes[-1].altitude_ft * 0.3048:.0f}</coordinates></Point>",
" </Placemark>"]
out += [" </Document>", "</kml>", ""]
try:
Path(path).write_text("\n".join(out))
except OSError:
return None
return Path(path)

789
bandsaunter/flightmap.py Normal file
View file

@ -0,0 +1,789 @@
"""An evening of aircraft, drawn as a map that moves.
A log of ADS-B frames is a list of times and places. Read down the page it
says nothing; drawn on a map with the clock running it is an evening's air
traffic -- the arrivals stacking up over the airport, the transatlantic
crossings at thirty-eight thousand feet going the other way, the helicopter
that circled for twenty minutes.
The animation is in real time made faster. Every frame is a moment, each
aircraft is drawn where it actually was at that moment -- interpolated between
the position reports that arrived either side of it, and dead-reckoned from
its last known speed and heading when nothing arrived at all -- so an aircraft
crossing the picture in ten seconds of animation took the twenty minutes the
data says it took. Nothing here is drawn at a constant speed for the look of
the thing.
The picture is written as an animated GIF, encoded here from first principles
in the same spirit as the PNGs elsewhere in this program: a palette, an LZW
stream and no imaging library. Where ffmpeg happens to be installed an MP4
can be written instead, which is smaller and smoother, but nothing depends on
it being there.
"""
from __future__ import annotations
import math
import shutil
import struct
import subprocess
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
import numpy as np
from .flightlog import Track, distance_nm
from .images import GLYPH_H, draw_text, text_width, write_png
__all__ = ["Animation", "Projection", "animate", "render_frame", "write_gif",
"write_mp4", "fit", "PALETTE", "ffmpeg_available"]
# ---------------------------------------------------------------------------
# Colours
# ---------------------------------------------------------------------------
# Everything is drawn in indexed colour, because a GIF is indexed colour and
# converting a picture into a palette afterwards is a guess about what the
# picture meant. Drawing straight into the palette means the file holds
# exactly the colours that were asked for.
BG, GRID, INK, DIM, PANEL, AIRPORT, ROUTE, WHITE = range(8)
RAMP = 8 # 32 altitude colours from here
TRAIL = RAMP + 32 # the same 32, dimmed, for the path just flown
OLD = TRAIL + 32 # and dimmer still, for the path flown earlier
RAMP_STEPS = 32
TRANSPARENT = 255 # never drawn with: it means "as the frame before"
CEILING_FT = 45_000.0
# The most frames any one animation is worth: about four minutes at twelve a
# second, by which point a viewer has stopped watching and a GIF has stopped
# opening.
MAX_FRAMES = 3000
_FIXED = ((14, 16, 22), # background: night, not black
(38, 44, 58), # grid
(196, 204, 218), # ink
(120, 130, 148), # dim ink
(24, 28, 38), # panel
(230, 170, 90), # airports
(70, 84, 110), # route lines
(255, 255, 255)) # white
# Low is warm, high is cold: the convention every other aircraft map uses, so
# an altitude can be read off the picture without looking at the key.
ALTITUDE_STOPS = ((0.0, (252, 96, 72)), (10_000.0, (250, 190, 64)),
(20_000.0, (132, 226, 96)), (30_000.0, (72, 200, 236)),
(45_000.0, (158, 142, 255)))
def _ramp(steps: int = RAMP_STEPS) -> list[tuple[int, int, int]]:
"""The altitude ramp, interpolated between the stops above."""
out = []
for i in range(steps):
feet = CEILING_FT * i / (steps - 1)
low = ALTITUDE_STOPS[0]
high = ALTITUDE_STOPS[-1]
for a, b in zip(ALTITUDE_STOPS, ALTITUDE_STOPS[1:]):
if a[0] <= feet <= b[0]:
low, high = a, b
break
span = high[0] - low[0]
part = 0.0 if span <= 0 else (feet - low[0]) / span
out.append(tuple(int(round(x + (y - x) * part))
for x, y in zip(low[1], high[1])))
return out
def _dimmed(colours, factor: float) -> list[tuple[int, int, int]]:
return [tuple(int(round(c * factor)) for c in rgb) for rgb in colours]
def _palette() -> np.ndarray:
ramp = _ramp()
table = list(_FIXED) + ramp + _dimmed(ramp, 0.55) + _dimmed(ramp, 0.30)
table += [(0, 0, 0)] * (256 - len(table))
return np.array(table[:256], dtype=np.uint8)
PALETTE = _palette()
def altitude_step(feet: float) -> int:
"""Which of the 32 altitude colours a height falls in."""
if feet <= 0:
return 0
return int(min(RAMP_STEPS - 1,
max(0, round(feet / CEILING_FT * (RAMP_STEPS - 1)))))
# ---------------------------------------------------------------------------
# Drawing
# ---------------------------------------------------------------------------
def _line(img: np.ndarray, x0: int, y0: int, x1: int, y1: int,
colour: int) -> None:
"""A straight line, clipped to the canvas. Bresenham, no smoothing."""
height, width = img.shape
dx, dy = abs(x1 - x0), -abs(y1 - y0)
sx = 1 if x0 < x1 else -1
sy = 1 if y0 < y1 else -1
err = dx + dy
# A line between two points a long way off the canvas would otherwise be
# walked pixel by pixel for its whole imaginary length.
if max(abs(x1 - x0), abs(y1 - y0)) > 8 * (width + height):
return
while True:
if 0 <= x0 < width and 0 <= y0 < height:
img[y0, x0] = colour
if x0 == x1 and y0 == y1:
return
step = 2 * err
if step >= dy:
err += dy
x0 += sx
if step <= dx:
err += dx
y0 += sy
def _disc(img: np.ndarray, x: int, y: int, radius: int, colour: int) -> None:
height, width = img.shape
r = max(0, int(radius))
y0, y1 = max(0, y - r), min(height, y + r + 1)
x0, x1 = max(0, x - r), min(width, x + r + 1)
if y0 >= y1 or x0 >= x1:
return
ys = np.arange(y0, y1)[:, None] - y
xs = np.arange(x0, x1)[None, :] - x
img[y0:y1, x0:x1][ys * ys + xs * xs <= r * r] = colour
def _triangle(img: np.ndarray, points, colour: int) -> None:
"""A filled triangle: the aircraft, pointing where it is going."""
height, width = img.shape
(ax, ay), (bx, by), (cx, cy) = points
x0, x1 = max(0, int(min(ax, bx, cx))), min(width, int(max(ax, bx, cx)) + 1)
y0, y1 = max(0, int(min(ay, by, cy))), min(height, int(max(ay, by, cy)) + 1)
if x0 >= x1 or y0 >= y1:
return
ys, xs = np.mgrid[y0:y1, x0:x1]
area = (bx - ax) * (cy - ay) - (cx - ax) * (by - ay)
if abs(area) < 1e-9:
return
w0 = ((bx - ax) * (ys - ay) - (xs - ax) * (by - ay)) / area
w1 = ((cx - bx) * (ys - by) - (xs - bx) * (cy - by)) / area
w2 = ((ax - cx) * (ys - cy) - (xs - cx) * (ay - cy)) / area
img[y0:y1, x0:x1][(w0 >= 0) & (w1 >= 0) & (w2 >= 0)] = colour
def _box(img: np.ndarray, x0: int, y0: int, x1: int, y1: int,
colour: int, fill: bool = False) -> None:
height, width = img.shape
x0, x1 = max(0, x0), min(width - 1, x1)
y0, y1 = max(0, y0), min(height - 1, y1)
if x0 > x1 or y0 > y1:
return
if fill:
img[y0:y1 + 1, x0:x1 + 1] = colour
return
img[y0, x0:x1 + 1] = colour
img[y1, x0:x1 + 1] = colour
img[y0:y1 + 1, x0] = colour
img[y0:y1 + 1, x1] = colour
# ---------------------------------------------------------------------------
# Where things go on the picture
# ---------------------------------------------------------------------------
TITLE_H = 26
LEGEND_H = 30
MARGIN = 10
@dataclass
class Projection:
"""A box of the world, and where it lands on the canvas.
Equirectangular, with longitude squeezed by the cosine of the middle
latitude so that a mile across looks like a mile up the picture. Over the
couple of hundred miles a receiver can hear, that is a map; pretending to
a projection with a name would not make it more true.
"""
south: float
west: float
north: float
east: float
left: int
top: int
width: int
height: int
@property
def mid_lat(self) -> float:
return (self.south + self.north) / 2.0
@property
def lon_squeeze(self) -> float:
return max(0.1, math.cos(math.radians(self.mid_lat)))
def xy(self, lat: float, lon: float) -> tuple[int, int]:
span_lon = max(1e-9, self.east - self.west)
span_lat = max(1e-9, self.north - self.south)
x = self.left + (lon - self.west) / span_lon * (self.width - 1)
y = self.top + (self.north - lat) / span_lat * (self.height - 1)
return int(round(x)), int(round(y))
def inside(self, lat: float, lon: float) -> bool:
return self.south <= lat <= self.north and self.west <= lon <= self.east
@property
def width_nm(self) -> float:
return distance_nm(self.mid_lat, self.west, self.mid_lat, self.east)
def bounds_of(tracks: list[Track], margin: float = 0.06):
"""The box every track fits in, with a little air around it."""
lats, lons = [], []
for track in tracks:
for fix in track.fixes:
lats.append(fix.latitude)
lons.append(fix.longitude)
if not lats:
return None
south, north = min(lats), max(lats)
west, east = min(lons), max(lons)
# A single aircraft heard once needs a box anyway, or the map is a point.
pad_lat = max((north - south) * margin, 0.02)
pad_lon = max((east - west) * margin, 0.02)
return (south - pad_lat, west - pad_lon, north + pad_lat, east + pad_lon)
def fit(tracks: list[Track], width: int = 960,
max_height: int = 1200) -> Projection | None:
"""Choose the canvas the tracks want: as wide as asked, as tall as needed."""
box = bounds_of(tracks)
if box is None:
return None
south, west, north, east = box
body_w = max(120, width - 2 * MARGIN)
mid = (south + north) / 2.0
across = max(1e-6, (east - west) * math.cos(math.radians(mid)))
down = max(1e-6, north - south)
body_h = int(round(body_w * down / across))
body_h = max(160, min(max_height, body_h))
return Projection(south=south, west=west, north=north, east=east,
left=MARGIN, top=TITLE_H, width=body_w, height=body_h)
def canvas_size(view: Projection) -> tuple[int, int]:
"""The whole picture, body plus the strips above and below it."""
width = view.width + 2 * MARGIN
height = view.top + view.height + LEGEND_H
return width + width % 2, height + height % 2 # even, for the video
# ---------------------------------------------------------------------------
# The parts that never move
# ---------------------------------------------------------------------------
_LADDER = (0.01, 0.02, 0.05, 0.1, 0.2, 0.25, 0.5, 1.0, 2.0, 5.0, 10.0, 20.0)
def _grid_step(span: float, wanted: int = 5) -> float:
for step in _LADDER:
if span / step <= wanted:
return step
return _LADDER[-1]
def _degrees(value: float, axis: str) -> str:
"""A latitude or longitude as a label, without a degree sign to draw."""
hemisphere = ("N" if value >= 0 else "S") if axis == "lat" \
else ("E" if value >= 0 else "W")
return f"{abs(value):.2f}{hemisphere}"
def background(view: Projection, title: str = "",
airports=()) -> np.ndarray:
"""The map without anything flying on it: grid, scale, key and title."""
width, height = canvas_size(view)
img = np.full((height, width), BG, dtype=np.uint8)
_box(img, view.left - 1, view.top - 1, view.left + view.width,
view.top + view.height, GRID)
step_lat = _grid_step(view.north - view.south)
step_lon = _grid_step(view.east - view.west)
lat = math.ceil(view.south / step_lat) * step_lat
while lat <= view.north:
_, y = view.xy(lat, view.west)
img[y, view.left:view.left + view.width:3] = GRID
draw_text(img, view.left + 3, y - GLYPH_H - 1, _degrees(lat, "lat"), DIM)
lat += step_lat
lon = math.ceil(view.west / step_lon) * step_lon
while lon <= view.east:
x, _ = view.xy(view.south, lon)
img[view.top:view.top + view.height:3, x] = GRID
draw_text(img, x + 3, view.top + view.height - GLYPH_H - 3,
_degrees(lon, "lon"), DIM)
lon += step_lon
for name, lat, lon in airports:
if not view.inside(lat, lon):
continue
x, y = view.xy(lat, lon)
_box(img, x - 3, y - 3, x + 3, y + 3, AIRPORT)
draw_text(img, x + 6, y - 3, name, AIRPORT)
if title:
draw_text(img, MARGIN, (TITLE_H - GLYPH_H) // 2, title, INK)
_scale_bar(img, view)
_key(img, view)
return img
def _scale_bar(img: np.ndarray, view: Projection) -> None:
"""A bar of a round number of nautical miles, for judging distance."""
per_nm = view.width / max(1e-9, view.width_nm)
for miles in (200, 100, 50, 20, 10, 5, 2, 1):
pixels = int(round(miles * per_nm))
if pixels <= view.width * 0.32:
break
else:
return
y = view.top + view.height + 12
x = view.left
_line(img, x, y, x + pixels, y, DIM)
_line(img, x, y - 3, x, y + 3, DIM)
_line(img, x + pixels, y - 3, x + pixels, y + 3, DIM)
draw_text(img, x + pixels + 6, y - 3, f"{miles} NM", DIM)
def _key(img: np.ndarray, view: Projection) -> None:
"""The altitude ramp, with the numbers that go with the colours."""
height, width = img.shape
bar_w = min(220, max(80, view.width // 4))
x0 = width - MARGIN - bar_w
y = view.top + view.height + 8
for i in range(bar_w):
img[y:y + 7, x0 + i] = RAMP + min(RAMP_STEPS - 1,
i * RAMP_STEPS // bar_w)
draw_text(img, x0 - text_width("ALTITUDE") - 6, y, "ALTITUDE", DIM)
for part, label in ((0.0, "0"), (0.5, "22K"), (1.0, "45K FT")):
x = x0 + int(part * (bar_w - 1))
# Left-aligned at the cold end, centred in the middle, right-aligned
# at the hot end, so no label hangs off either end of the bar.
draw_text(img, x - int(text_width(label) * part), y + 9, label, DIM)
# ---------------------------------------------------------------------------
# One frame
# ---------------------------------------------------------------------------
def render_frame(base: np.ndarray, view: Projection, tracks: list[Track],
when: float, *, trail_seconds: float = 0.0,
stale: float = 300.0, labels: bool = True,
clock: str = "") -> np.ndarray:
"""The map at one moment: where everything was, and where it had been."""
img = base.copy()
flying = 0
taken: list[tuple[int, int, int, int]] = []
for track in tracks:
now = track.at(when, stale=stale)
if now is None:
continue
flying += 1
trail = track.trail(when, trail_seconds)
recent = when - (trail_seconds or 120.0) / 2.0
for a, b in zip(trail, trail[1:]):
shade = (TRAIL if b.at >= recent else OLD) + \
altitude_step(b.altitude_ft)
x0, y0 = view.xy(a.latitude, a.longitude)
x1, y1 = view.xy(b.latitude, b.longitude)
_line(img, x0, y0, x1, y1, shade)
colour = RAMP + altitude_step(now.altitude_ft)
x, y = view.xy(now.latitude, now.longitude)
_marker(img, x, y, now.track_deg, colour)
if labels:
_label(img, x, y, track, now, colour, taken)
if clock:
_clock_strip(img, view, clock, flying)
return img
def _marker(img: np.ndarray, x: int, y: int, heading: float,
colour: int) -> None:
"""A little arrowhead, pointing the way the aircraft is going."""
angle = math.radians(heading % 360.0)
sin, cos = math.sin(angle), math.cos(angle)
def point(ahead: float, side: float) -> tuple[float, float]:
# Screen coordinates: north is up, which is minus y.
return (x + side * cos + ahead * sin, y + side * sin - ahead * cos)
_triangle(img, (point(5.0, 0.0), point(-3.5, 3.0), point(-3.5, -3.0)),
colour)
img[max(0, y - 1):y + 2, max(0, x - 1):x + 2] = colour
def _label(img: np.ndarray, x: int, y: int, track: Track, now,
colour: int, taken: list | None = None) -> None:
"""Who it is and how high, beside the aircraft.
The height is the flight level -- hundreds of feet, the way it is said
on the radio -- because five digits beside every aircraft is a picture
made of numbers.
Two aircraft that pass close together would otherwise have their labels
written over each other, which is exactly the moment somebody is looking
at that part of the picture. So the four places a label can go are tried
in turn and the first clear one is used; when they are all taken the
label goes to the right anyway, because a label somewhere beats none.
"""
height, width = img.shape
name = track.name
below = f"{now.altitude_ft // 100:03d}" if now.altitude_ft else ""
if now.ground_speed_kt:
below = f"{below} {now.ground_speed_kt:.0f}KT".strip()
span = max(text_width(name), text_width(below))
tall = GLYPH_H * 2 + 3
places = ((x + 8, y - GLYPH_H - 1), # right, the usual place
(x - 8 - span, y - GLYPH_H - 1), # left
(x - span // 2, y + 9), # under it
(x - span // 2, y - tall - 6)) # over it
left, top = places[0]
for candidate_x, candidate_y in places:
if candidate_x < 2 or candidate_x + span > width - 2:
continue
if candidate_y < 1 or candidate_y + tall > height - 1:
continue
box = (candidate_x, candidate_y, candidate_x + span, candidate_y + tall)
if taken is None or not any(_overlaps(box, other) for other in taken):
left, top = candidate_x, candidate_y
break
left = max(2, min(left, width - 2 - span))
if taken is not None:
taken.append((left, top, left + span, top + tall))
draw_text(img, left, top, name, colour)
if below:
draw_text(img, left, top + GLYPH_H + 3, below, DIM)
def _overlaps(a, b) -> bool:
return not (a[2] < b[0] or b[2] < a[0] or a[3] < b[1] or b[3] < a[1])
def _clock_strip(img: np.ndarray, view: Projection, clock: str,
flying: int) -> None:
"""The time and the count, top right, where they do not cover the map."""
text = f"{clock} {flying} FLYING"
width = img.shape[1]
x = width - MARGIN - text_width(text)
_box(img, x - 4, 1, width - MARGIN + 2, TITLE_H - 4, PANEL, fill=True)
draw_text(img, x, (TITLE_H - GLYPH_H) // 2 - 1, text, INK)
# ---------------------------------------------------------------------------
# GIF
# ---------------------------------------------------------------------------
class _Bits:
"""Least-significant-bit-first bit packing, which is what GIF wants."""
def __init__(self):
self.out = bytearray()
self._value = 0
self._held = 0
def write(self, code: int, width: int) -> None:
self._value |= code << self._held
self._held += width
while self._held >= 8:
self.out.append(self._value & 0xFF)
self._value >>= 8
self._held -= 8
def flush(self) -> bytes:
if self._held:
self.out.append(self._value & 0xFF)
self._value, self._held = 0, 0
return bytes(self.out)
def _lzw(data: bytes, code_bits: int) -> bytes:
"""GIF's variable-width LZW.
Straight from the specification: codes start one bit wider than the
palette, the table grows a code at a time, the width goes up when the
next code would not fit and the whole table is thrown away and started
again when it fills.
"""
clear, end = 1 << code_bits, (1 << code_bits) + 1
roots = {bytes([i]): i for i in range(clear)}
table = dict(roots)
width = code_bits + 1
nxt = end + 1
bits = _Bits()
bits.write(clear, width)
run = b""
for byte in data:
longer = run + bytes([byte])
if longer in table:
run = longer
continue
bits.write(table[run], width)
if nxt < 4096:
table[longer] = nxt
nxt += 1
if nxt > (1 << width) and width < 12:
width += 1
else:
bits.write(clear, width)
table = dict(roots)
nxt = end + 1
width = code_bits + 1
run = bytes([byte])
if run:
bits.write(table[run], width)
bits.write(end, width)
return bits.flush()
def _blocks(data: bytes) -> bytes:
"""GIF carries its data in sub-blocks of at most 255 bytes."""
out = bytearray()
for at in range(0, len(data), 255):
chunk = data[at:at + 255]
out.append(len(chunk))
out += chunk
out.append(0)
return bytes(out)
def _frame_chunk(indices: np.ndarray, left: int, top: int, delay_cs: int,
transparent: int | None) -> bytes:
height, width = indices.shape
out = bytearray()
flags = 0x04 | (0x01 if transparent is not None else 0) # disposal: keep
out += b"\x21\xf9\x04" + bytes([flags]) + struct.pack("<H", delay_cs) \
+ bytes([transparent or 0, 0])
out += b"\x2c" + struct.pack("<HHHH", left, top, width, height) + b"\x00"
out.append(8)
out += _blocks(_lzw(indices.astype(np.uint8).tobytes(), 8))
return bytes(out)
def write_gif(path, frames, palette: np.ndarray = PALETTE,
delay_cs: int = 8, loop: bool = True) -> Path:
"""Write an animated GIF from an iterator of index arrays.
Only what changed is written after the first frame: an aircraft moves a
few pixels between frames and the map underneath it does not move at all,
so the difference is a small box and the file is a fraction of the size.
Unchanged pixels inside that box are transparent, which in GIF means
"leave whatever was there".
"""
path = Path(path)
previous: np.ndarray | None = None
with path.open("wb") as out:
for index, frame in enumerate(frames):
frame = np.asarray(frame, dtype=np.uint8)
if previous is None:
height, width = frame.shape
out.write(b"GIF89a" + struct.pack("<HH", width, height)
+ bytes([0xF7, 0, 0]))
out.write(palette.astype(np.uint8).tobytes())
if loop:
out.write(b"\x21\xff\x0bNETSCAPE2.0\x03\x01\x00\x00\x00")
out.write(_frame_chunk(frame, 0, 0, delay_cs, None))
previous = frame
continue
changed = frame != previous
if not changed.any():
# Nothing moved. Repeat the shortest possible sub-image
# rather than the whole picture, so a still moment costs a
# dozen bytes and the clock still runs.
out.write(_frame_chunk(frame[:1, :1], 0, 0, delay_cs, None))
previous = frame
continue
rows = np.flatnonzero(changed.any(axis=1))
cols = np.flatnonzero(changed.any(axis=0))
top, bottom = int(rows[0]), int(rows[-1]) + 1
left, right = int(cols[0]), int(cols[-1]) + 1
patch = frame[top:bottom, left:right].copy()
patch[~changed[top:bottom, left:right]] = TRANSPARENT
out.write(_frame_chunk(patch, left, top, delay_cs, TRANSPARENT))
previous = frame
out.write(b"\x3b")
return path
def ffmpeg_available() -> bool:
return shutil.which("ffmpeg") is not None
def write_mp4(path, frames, palette: np.ndarray = PALETTE,
fps: float = 12.0, size: tuple[int, int] | None = None) -> Path:
"""Write an MP4 by feeding raw frames to ffmpeg, where there is one."""
path = Path(path)
first = None
iterator = iter(frames)
if size is None:
first = np.asarray(next(iterator), dtype=np.uint8)
size = (first.shape[1], first.shape[0])
command = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-f", "rawvideo", "-pix_fmt", "rgb24",
"-s", f"{size[0]}x{size[1]}", "-r", f"{fps:g}", "-i", "-",
"-c:v", "libx264", "-preset", "medium", "-crf", "20",
"-pix_fmt", "yuv420p", "-movflags", "+faststart", str(path)]
process = subprocess.Popen(command, stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE)
try:
if first is not None:
process.stdin.write(palette[first].tobytes())
for frame in iterator:
process.stdin.write(
palette[np.asarray(frame, dtype=np.uint8)].tobytes())
process.stdin.close()
except BrokenPipeError:
pass
code = process.wait()
if code != 0:
raise RuntimeError(f"ffmpeg could not write {path.name}")
return path
# ---------------------------------------------------------------------------
# Putting it together
# ---------------------------------------------------------------------------
@dataclass
class Animation:
"""What was drawn, for saying so afterwards."""
path: Path
kind: str = "gif"
frames: int = 0
fps: float = 0.0
speed: float = 1.0
width: int = 0
height: int = 0
aircraft: int = 0
covers: float = 0.0 # seconds of real time in the picture
def summary(self) -> str:
real = _span(self.covers)
played = _span(self.frames / self.fps if self.fps else 0.0)
return (f"{self.aircraft} aircraft, {real} of flying in {played} "
f"({self.speed:.0f}x), {self.frames} frames at "
f"{self.width}x{self.height}")
def _span(seconds: float) -> str:
if seconds < 90:
return f"{seconds:.0f} s"
minutes, secs = divmod(int(seconds), 60)
if minutes < 90:
return f"{minutes} min {secs:02d} s"
hours, minutes = divmod(minutes, 60)
return f"{hours} h {minutes:02d} min"
def _airports_from(book, tracks: list[Track]):
"""Every airport a route lookup gave a position for."""
if book is None:
return []
seen: dict[str, tuple[str, float, float]] = {}
for track in tracks:
entry = book.get(track.icao, track.callsign)
for code, lat, lon in ((entry.origin_code, entry.origin_lat,
entry.origin_lon),
(entry.destination_code, entry.destination_lat,
entry.destination_lon)):
if code and (lat or lon):
seen[code] = (code, lat, lon)
return list(seen.values())
def animate(tracks: list[Track], out_path, *, fps: float = 12.0,
seconds: float = 30.0, speed: float = 0.0, width: int = 960,
trail_seconds: float = 0.0, stale: float = 300.0,
title: str = "", book=None, labels: bool = True,
kind: str = "") -> Animation | None:
"""Draw the whole log as a moving map.
``speed`` is how many seconds of real flying go by in one second of
animation; given ``seconds`` instead, it is worked out so the whole log
plays in about that long. The clock in the corner is the real time of
day, so a fast animation is still readable as an evening.
"""
located = [t for t in tracks if t.located]
if not located:
return None
view = fit(located, width=width)
if view is None:
return None
start = min(t.fixes[0].at for t in located)
finish = max(t.fixes[-1].at for t in located)
covers = max(1.0, finish - start)
if speed <= 0:
speed = covers / max(1.0, seconds)
frame_count = max(2, int(round(covers / speed * fps)) + 1)
if frame_count > MAX_FRAMES:
# An all-night log at one second per second is a hundred thousand
# frames and a file nobody can open. The animation runs faster
# instead of running out of disk, and says so afterwards.
frame_count = MAX_FRAMES
speed = covers / max(1e-9, (frame_count - 1) / fps)
day = datetime.fromtimestamp(start).strftime("%Y-%m-%d")
heading = title or f"{len(located)} AIRCRAFT {day}"
base = background(view, title=heading, airports=_airports_from(book, located))
canvas_w, canvas_h = canvas_size(view)
base = _pad_to(base, canvas_w, canvas_h)
def frames():
for i in range(frame_count):
when = start + i * speed / fps
clock = datetime.fromtimestamp(when).strftime("%H:%M:%S")
yield _pad_to(render_frame(base, view, located, when,
trail_seconds=trail_seconds,
stale=stale, labels=labels,
clock=clock), canvas_w, canvas_h)
path = Path(out_path)
kind = (kind or path.suffix.lstrip(".") or "gif").lower()
if kind == "png":
# Not an animation at all: the whole log at once, every path drawn.
still = render_frame(base, view, located, finish, stale=covers + 1,
labels=labels,
clock=datetime.fromtimestamp(finish)
.strftime("%H:%M:%S"))
write_png(path, PALETTE[still])
return Animation(path=path, kind="png", frames=1, fps=0.0, speed=speed,
width=canvas_w, height=canvas_h,
aircraft=len(located), covers=covers)
if kind in ("mp4", "mov", "m4v"):
write_mp4(path, frames(), PALETTE, fps=fps, size=(canvas_w, canvas_h))
else:
delay = max(2, int(round(100.0 / fps)))
fps = 100.0 / delay # what the file will actually play at
speed = covers / max(1e-9, (frame_count - 1) / fps)
write_gif(path, frames(), PALETTE, delay_cs=delay)
return Animation(path=path, kind=kind, frames=frame_count, fps=fps,
speed=speed, width=canvas_w, height=canvas_h,
aircraft=len(located), covers=covers)
def _pad_to(img: np.ndarray, width: int, height: int) -> np.ndarray:
"""Make a frame exactly the size the file was told it would be."""
if img.shape == (height, width):
return img
out = np.full((height, width), BG, dtype=np.uint8)
rows = min(height, img.shape[0])
cols = min(width, img.shape[1])
out[:rows, :cols] = img[:rows, :cols]
return out

671
bandsaunter/flights.py Normal file
View file

@ -0,0 +1,671 @@
"""Who the aircraft is, beyond what it broadcasts.
An ADS-B frame says a 24-bit address, a callsign, a position and a speed. It
does not say that 4CA1FA is a Boeing 737 in Ryanair colours registered in
Ireland, or that RYR1234 is this morning's Dublin to Stansted. That comes
from a register, and this is the module that asks one.
Two are asked, in order, for the same reason the callsign book asks two: they
hold different things and neither is always up. adsbdb knows the airframe and
the route; hexdb knows the airframe and, separately, the pair of airports a
callsign flew between. Both are free, neither wants a key, and both are sent
nothing but the address or the callsign that was heard on the air.
What can be answered without asking anybody is answered here instead. The
address itself says which country registered the aircraft -- the allocations
are fixed by treaty and do not change -- and the first three letters of an
airline callsign are its ICAO designator, so RYR1234 is Ryanair whether or not
anything answers the telephone. A receiver in a field with no signal still
gets the country and the airline.
"""
from __future__ import annotations
import json
import os
import threading
import time
import urllib.parse
import urllib.request
from dataclasses import dataclass
from pathlib import Path
__all__ = ["Flight", "FlightBook", "describe_address", "airline_of",
"AIRCRAFT_URL", "ROUTE_URL", "CACHE_VERSION"]
AIRCRAFT_URL = "https://api.adsbdb.com/v0/aircraft/{icao}"
ROUTE_URL = "https://api.adsbdb.com/v0/callsign/{call}"
BACKUP_AIRCRAFT_URL = "https://hexdb.io/api/v1/aircraft/{icao}"
BACKUP_ROUTE_URL = "https://hexdb.io/api/v1/route/icao/{call}"
# Bumped when the shape of a cached record changes, so a cache written by an
# older version is ignored rather than misread.
CACHE_VERSION = 1
# ---------------------------------------------------------------------------
# What the address alone says
# ---------------------------------------------------------------------------
# The ICAO 24-bit address blocks, as allocated in Annex 10. This is the
# major allocations rather than every last one: a block that is not listed
# gives no country rather than a wrong one, which is the only honest answer
# a table can give about a range it does not have.
ADDRESS_BLOCKS: tuple[tuple[int, int, str], ...] = (
(0x004000, 0x0043FF, "Zimbabwe"),
(0x006000, 0x006FFF, "Mozambique"),
(0x008000, 0x00FFFF, "South Africa"),
(0x010000, 0x017FFF, "Egypt"),
(0x018000, 0x01FFFF, "Libya"),
(0x020000, 0x027FFF, "Morocco"),
(0x028000, 0x02FFFF, "Tunisia"),
(0x030000, 0x0303FF, "Botswana"),
(0x032000, 0x032FFF, "Burundi"),
(0x034000, 0x034FFF, "Cameroon"),
(0x036000, 0x036FFF, "Congo"),
(0x038000, 0x038FFF, "Côte d'Ivoire"),
(0x03E000, 0x03EFFF, "Gabon"),
(0x040000, 0x040FFF, "Ethiopia"),
(0x042000, 0x042FFF, "Equatorial Guinea"),
(0x044000, 0x044FFF, "Ghana"),
(0x046000, 0x046FFF, "Guinea"),
(0x04C000, 0x04CFFF, "Kenya"),
(0x050000, 0x050FFF, "Liberia"),
(0x054000, 0x054FFF, "Madagascar"),
(0x058000, 0x058FFF, "Malawi"),
(0x05C000, 0x05CFFF, "Mali"),
(0x060000, 0x0603FF, "Mauritius"),
(0x062000, 0x062FFF, "Niger"),
(0x064000, 0x064FFF, "Nigeria"),
(0x068000, 0x068FFF, "Uganda"),
(0x06A000, 0x06A3FF, "Qatar"),
(0x06C000, 0x06CFFF, "Central African Republic"),
(0x06E000, 0x06EFFF, "Rwanda"),
(0x070000, 0x070FFF, "Senegal"),
(0x074000, 0x0743FF, "Seychelles"),
(0x076000, 0x0763FF, "Sierra Leone"),
(0x078000, 0x078FFF, "Somalia"),
(0x07C000, 0x07CFFF, "Sudan"),
(0x080000, 0x080FFF, "Tanzania"),
(0x084000, 0x084FFF, "Chad"),
(0x088000, 0x088FFF, "Togo"),
(0x08A000, 0x08AFFF, "Zambia"),
(0x08C000, 0x08CFFF, "Democratic Republic of the Congo"),
(0x090000, 0x090FFF, "Angola"),
(0x09A000, 0x09AFFF, "Eritrea"),
(0x0A0000, 0x0A7FFF, "Algeria"),
(0x0B0000, 0x0B0FFF, "Namibia"),
(0x100000, 0x1FFFFF, "Russia"),
(0x201000, 0x2013FF, "Namibia"),
(0x300000, 0x33FFFF, "Italy"),
(0x340000, 0x37FFFF, "Spain"),
(0x380000, 0x3BFFFF, "France"),
(0x3C0000, 0x3FFFFF, "Germany"),
(0x400000, 0x43FFFF, "United Kingdom"),
(0x440000, 0x447FFF, "Austria"),
(0x448000, 0x44FFFF, "Belgium"),
(0x450000, 0x457FFF, "Bulgaria"),
(0x458000, 0x45FFFF, "Denmark"),
(0x460000, 0x467FFF, "Finland"),
(0x468000, 0x46FFFF, "Greece"),
(0x470000, 0x477FFF, "Hungary"),
(0x478000, 0x47FFFF, "Norway"),
(0x480000, 0x487FFF, "Netherlands"),
(0x488000, 0x48FFFF, "Poland"),
(0x490000, 0x497FFF, "Portugal"),
(0x498000, 0x49FFFF, "Czechia"),
(0x4A0000, 0x4A7FFF, "Romania"),
(0x4A8000, 0x4AFFFF, "Sweden"),
(0x4B0000, 0x4B7FFF, "Switzerland"),
(0x4B8000, 0x4BFFFF, "Turkey"),
(0x4C0000, 0x4C7FFF, "Serbia"),
(0x4C8000, 0x4C83FF, "Cyprus"),
(0x4CA000, 0x4CAFFF, "Ireland"),
(0x4CC000, 0x4CCFFF, "Iceland"),
(0x4D0000, 0x4D03FF, "Luxembourg"),
(0x4D2000, 0x4D23FF, "Malta"),
(0x4D4000, 0x4D43FF, "Monaco"),
(0x500000, 0x5003FF, "San Marino"),
(0x501000, 0x5013FF, "Albania"),
(0x501C00, 0x501FFF, "Croatia"),
(0x502C00, 0x502FFF, "Latvia"),
(0x503C00, 0x503FFF, "Lithuania"),
(0x504C00, 0x504FFF, "Moldova"),
(0x505C00, 0x505FFF, "Slovakia"),
(0x506C00, 0x506FFF, "Slovenia"),
(0x507C00, 0x507FFF, "Uzbekistan"),
(0x508000, 0x50FFFF, "Ukraine"),
(0x510000, 0x5103FF, "Belarus"),
(0x511000, 0x5113FF, "Estonia"),
(0x512000, 0x5123FF, "North Macedonia"),
(0x513000, 0x5133FF, "Bosnia and Herzegovina"),
(0x514000, 0x5143FF, "Georgia"),
(0x515000, 0x5153FF, "Tajikistan"),
(0x516000, 0x5163FF, "Montenegro"),
(0x600000, 0x6003FF, "Armenia"),
(0x600800, 0x600BFF, "Azerbaijan"),
(0x601000, 0x6013FF, "Kyrgyzstan"),
(0x601800, 0x601BFF, "Turkmenistan"),
(0x680000, 0x6803FF, "Bhutan"),
(0x682000, 0x6823FF, "Mongolia"),
(0x683000, 0x6833FF, "Kazakhstan"),
(0x700000, 0x700FFF, "Afghanistan"),
(0x702000, 0x702FFF, "Bangladesh"),
(0x704000, 0x704FFF, "Myanmar"),
(0x706000, 0x706FFF, "Kuwait"),
(0x708000, 0x708FFF, "Laos"),
(0x70A000, 0x70AFFF, "Nepal"),
(0x70C000, 0x70C3FF, "Oman"),
(0x70E000, 0x70EFFF, "Cambodia"),
(0x710000, 0x717FFF, "Saudi Arabia"),
(0x718000, 0x71FFFF, "South Korea"),
(0x720000, 0x727FFF, "North Korea"),
(0x728000, 0x72FFFF, "Iraq"),
(0x730000, 0x737FFF, "Iran"),
(0x738000, 0x73FFFF, "Israel"),
(0x740000, 0x747FFF, "Jordan"),
(0x748000, 0x74FFFF, "Lebanon"),
(0x750000, 0x757FFF, "Malaysia"),
(0x758000, 0x75FFFF, "Philippines"),
(0x760000, 0x767FFF, "Pakistan"),
(0x768000, 0x76FFFF, "Singapore"),
(0x770000, 0x777FFF, "Sri Lanka"),
(0x778000, 0x77FFFF, "Syria"),
(0x780000, 0x7BFFFF, "China"),
(0x7C0000, 0x7FFFFF, "Australia"),
(0x800000, 0x83FFFF, "India"),
(0x840000, 0x87FFFF, "Japan"),
(0x880000, 0x887FFF, "Thailand"),
(0x888000, 0x88FFFF, "Viet Nam"),
(0x890000, 0x890FFF, "Yemen"),
(0x894000, 0x894FFF, "Bahrain"),
(0x895000, 0x8953FF, "Brunei"),
(0x896000, 0x896FFF, "United Arab Emirates"),
(0x898000, 0x898FFF, "Papua New Guinea"),
(0x899000, 0x8993FF, "Taiwan"),
(0x8A0000, 0x8A7FFF, "Indonesia"),
(0x900000, 0x9003FF, "Marshall Islands"),
(0x902000, 0x9023FF, "Fiji"),
(0x905000, 0x9053FF, "Tonga"),
(0x907000, 0x9073FF, "Vanuatu"),
(0xA00000, 0xAFFFFF, "United States"),
(0xC00000, 0xC3FFFF, "Canada"),
(0xC80000, 0xC87FFF, "New Zealand"),
(0xE00000, 0xE3FFFF, "Argentina"),
(0xE40000, 0xE7FFFF, "Brazil"),
(0xE80000, 0xE80FFF, "Chile"),
(0xE84000, 0xE84FFF, "Ecuador"),
(0xE88000, 0xE88FFF, "Paraguay"),
(0xE8C000, 0xE8CFFF, "Peru"),
(0xE90000, 0xE90FFF, "Uruguay"),
(0xE94000, 0xE94FFF, "Bolivia"),
)
# Military and state blocks sit inside the national ranges above, so they are
# checked first. These are the ones a receiver in the ordinary world hears.
MILITARY_BLOCKS: tuple[tuple[int, int, str], ...] = (
(0xADF7C8, 0xAFFFFF, "United States military"),
(0x43C000, 0x43CFFF, "United Kingdom military"),
(0x3AA000, 0x3AFFFF, "France military"),
(0x3B7000, 0x3BFFFF, "France military"),
(0x3EA000, 0x3EBFFF, "Germany military"),
(0x3F4000, 0x3FBFFF, "Germany military"),
(0x33FF00, 0x33FFFF, "Italy military"),
(0x350000, 0x37FFFF, "Spain military"),
(0x71C000, 0x71FFFF, "South Korea military"),
(0x7CF800, 0x7CFFFF, "Australia military"),
(0xC20000, 0xC3FFFF, "Canada military"),
)
def describe_address(icao: str) -> str:
"""The country that issued a 24-bit address, from the treaty allocation.
No network, no register, no doubt: the blocks are fixed and an aircraft
keeps its address for as long as it keeps its registration. An address
outside every block listed here comes back empty rather than guessed.
"""
try:
value = int(icao, 16)
except (TypeError, ValueError):
return ""
for low, high, name in MILITARY_BLOCKS:
if low <= value <= high:
return name
for low, high, name in ADDRESS_BLOCKS:
if low <= value <= high:
return name
return ""
# The ICAO airline designators a receiver in the ordinary world hears most.
# Three letters at the front of a callsign, and the rest is the flight number.
AIRLINES: dict[str, str] = {
"AAL": "American Airlines", "ACA": "Air Canada", "AFR": "Air France",
"AIC": "Air India", "AAR": "Asiana Airlines", "ANA": "All Nippon Airways",
"ANZ": "Air New Zealand", "ASA": "Alaska Airlines", "AUA": "Austrian",
"AWI": "Air Wisconsin", "AZA": "ITA Airways", "BAW": "British Airways",
"BEL": "Brussels Airlines", "BOX": "AeroLogic", "CAL": "China Airlines",
"CCA": "Air China", "CES": "China Eastern", "CFG": "Condor",
"CKS": "Kalitta Air", "CPA": "Cathay Pacific", "CSN": "China Southern",
"CTN": "Croatia Airlines", "DAL": "Delta Air Lines", "DLH": "Lufthansa",
"EDV": "Endeavor Air", "EIN": "Aer Lingus", "ELY": "El Al",
"ETD": "Etihad Airways", "ETH": "Ethiopian Airlines", "EVA": "EVA Air",
"EZY": "easyJet", "EJU": "easyJet Europe", "FDX": "FedEx Express",
"FFT": "Frontier Airlines", "FIN": "Finnair", "GEC": "Lufthansa Cargo",
"GIA": "Garuda Indonesia", "GLO": "Gol", "HAL": "Hawaiian Airlines",
"IBE": "Iberia", "IBS": "Iberia Express", "ICE": "Icelandair",
"JAL": "Japan Airlines", "JBU": "JetBlue Airways", "JST": "Jetstar",
"KAL": "Korean Air", "KLM": "KLM", "LAN": "LATAM Chile",
"LOT": "LOT Polish Airlines", "LNI": "Lion Air", "MAS": "Malaysia Airlines",
"MSR": "EgyptAir", "NAX": "Norwegian", "NKS": "Spirit Airlines",
"NOZ": "Norwegian Air Sweden", "PAL": "Philippine Airlines",
"QFA": "Qantas", "QTR": "Qatar Airways", "RJA": "Royal Jordanian",
"ROU": "Air Canada Rouge", "RPA": "Republic Airways", "RYR": "Ryanair",
"RYS": "Ryanair Sun", "SAS": "Scandinavian Airlines", "SAA": "South African",
"SEJ": "SpiceJet", "SIA": "Singapore Airlines", "SKW": "SkyWest",
"SVA": "Saudia", "SWA": "Southwest Airlines", "SWR": "Swiss",
"TAM": "LATAM Brasil", "TAP": "TAP Air Portugal", "THA": "Thai Airways",
"THY": "Turkish Airlines", "TOM": "TUI Airways", "TRA": "Transavia",
"TVF": "Transavia France", "UAE": "Emirates", "UAL": "United Airlines",
"UPS": "UPS Airlines", "VIR": "Virgin Atlantic", "VLG": "Vueling",
"VOI": "Volaris", "WJA": "WestJet", "WUK": "Wizz Air UK",
"WZZ": "Wizz Air", "AFL": "Aeroflot", "AZU": "Azul",
"BER": "Eurowings", "EWG": "Eurowings", "DAH": "Air Algérie",
"AMX": "Aeroméxico", "ARG": "Aerolíneas Argentinas", "AVA": "Avianca",
"CPZ": "Compass Airlines", "JIA": "PSA Airlines", "ENY": "Envoy Air",
"GJS": "GoJet Airlines", "QXE": "Horizon Air", "ASH": "Mesa Airlines",
"NCA": "Nippon Cargo Airlines", "ABW": "AirBridgeCargo",
"CLX": "Cargolux", "ABX": "ABX Air", "GTI": "Atlas Air",
"NPT": "Atlantic Airways", "SQC": "Singapore Airlines Cargo",
}
# Callsigns that are not an airline at all, and are worth naming as such.
SPECIAL_PREFIXES: dict[str, str] = {
"RCH": "United States Air Mobility Command (Reach)",
"RRR": "Royal Air Force (Ascot)",
"CFC": "Canadian Forces",
"LIFEGUARD": "air ambulance",
"NATO": "NATO",
}
def airline_of(callsign: str) -> str:
"""The airline a flight callsign belongs to, from its ICAO designator.
A flight callsign is three letters and a flight number: RYR1234. A
registration used as a callsign -- N737AB, G-ABCD -- is not, and comes
back empty rather than being read as an airline whose designator happens
to look like the start of a registration.
"""
call = (callsign or "").strip().upper()
if len(call) < 4 or not call[:3].isalpha() or not call[3].isdigit():
return SPECIAL_PREFIXES.get(call[:3], "") if len(call) >= 3 else ""
return AIRLINES.get(call[:3], SPECIAL_PREFIXES.get(call[:3], ""))
# ---------------------------------------------------------------------------
# One aircraft, one flight
# ---------------------------------------------------------------------------
@dataclass
class Flight:
"""Everything known about one aircraft and the flight it is operating.
``status`` is how the knowing went: ``found`` from a register, ``local``
for what the address and callsign say on their own, ``unlisted`` for an
aircraft no register holds, ``offline`` when nothing could be reached and
``pending`` while a lookup is still out.
"""
icao: str = ""
callsign: str = ""
registration: str = ""
type_code: str = "" # ICAO type designator, e.g. B738
model: str = "" # "737-8AS"
manufacturer: str = ""
operator: str = "" # who flies it, from the register
airline: str = "" # who the callsign says, from the table
country: str = "" # from the address block
owner_country: str = "" # from the register, which can differ
origin_code: str = ""
origin: str = ""
origin_lat: float = 0.0
origin_lon: float = 0.0
destination_code: str = ""
destination: str = ""
destination_lat: float = 0.0
destination_lon: float = 0.0
status: str = "pending"
fetched_at: float = 0.0
version: int = CACHE_VERSION
@property
def known(self) -> bool:
"""Whether a register answered with an airframe."""
return bool(self.registration or self.type_code or self.model)
@property
def aircraft(self) -> str:
"""The airframe in one line: manufacturer, model and registration."""
kind = " ".join(x for x in (self.manufacturer, self.model) if x)
kind = kind or self.type_code
if self.registration and kind:
return f"{kind} ({self.registration})"
return kind or self.registration
@property
def route(self) -> str:
"""Where it came from and where it is going, when that is known."""
if not (self.origin or self.destination):
return ""
start = self.origin or self.origin_code or "?"
end = self.destination or self.destination_code or "?"
return f"{start}{end}"
def summary(self) -> str:
"""One line for a table: what it is, who flies it, where it is going."""
bits = [x for x in (self.aircraft, self.operator or self.airline,
self.route) if x]
return " · ".join(bits)
def details(self) -> list[str]:
"""Every field worth printing, labelled, for the readable report."""
out: list[str] = []
pairs = (("address", f"{self.icao}"
+ (f" ({self.country})" if self.country else "")),
("callsign", self.callsign),
("registration", self.registration),
("aircraft", " ".join(x for x in (self.manufacturer,
self.model) if x)),
("type", self.type_code),
("operator", self.operator or self.airline),
("registered in", self.owner_country),
("from", self.origin or self.origin_code),
("to", self.destination or self.destination_code))
for label, value in pairs:
if value:
out.append(f"{label}: {value}")
return out
def _cache_path() -> Path:
root = os.environ.get("XDG_CACHE_HOME") or "~/.cache"
return Path(root).expanduser() / "bandsaunter" / "flights.json"
class FlightBook:
"""Resolves aircraft, in the background, once each.
The same shape as the callsign book and for the same reason: a display
that redraws several times a second must never wait on a website, so a
lookup returns what is known immediately and fills itself in later.
Answers are cached on disk, so an aircraft that flies the same route every
morning is looked up once a month rather than once a day.
A route is cached against the callsign rather than the aircraft, because
the airframe is a fact about the aeroplane and the route is a fact about
the flight number: the same aircraft flies four different routes in a day.
"""
def __init__(self, online: bool = True, cache: Path | None = None,
timeout: float = 6.0, max_age: float = 30 * 86_400,
aircraft_url: str = AIRCRAFT_URL,
route_url: str = ROUTE_URL,
backup_aircraft_url: str = BACKUP_AIRCRAFT_URL,
backup_route_url: str = BACKUP_ROUTE_URL):
self.online = online
self.timeout = timeout
self.max_age = max_age
self.aircraft_url = aircraft_url
self.route_url = route_url
self.backup_aircraft_url = backup_aircraft_url
self.backup_route_url = backup_route_url
self.cache_path = Path(cache) if cache is not None else _cache_path()
self._lock = threading.Lock()
self._entries: dict[str, Flight] = {}
self._routes: dict[str, dict] = {}
# Callsigns already asked about. A route nobody holds would
# otherwise be asked for again by every caller that looks at the same
# aircraft -- the table, the report and the map all do -- and an
# evening of unlisted flight numbers would end in a thousand threads.
self._asked: set[str] = set()
self._threads: list[threading.Thread] = []
self._dirty = False
self._load()
# -- cache ------------------------------------------------------------
def _load(self) -> None:
try:
raw = json.loads(self.cache_path.read_text())
except (OSError, ValueError):
return
now = time.time()
for key, body in (raw.get("aircraft") or {}).items():
try:
entry = Flight(**body)
except TypeError:
continue # written by a version with other fields
if entry.status in ("found", "unlisted") and \
entry.version >= CACHE_VERSION and \
now - entry.fetched_at < self.max_age:
self._entries[key] = entry
for key, body in (raw.get("routes") or {}).items():
if isinstance(body, dict) and \
now - float(body.get("fetched_at") or 0) < self.max_age:
self._routes[key] = body
def save(self) -> None:
"""Write the cache. Failing to is never worth an error."""
with self._lock:
if not self._dirty:
return
body = {
"aircraft": {k: e.__dict__ for k, e in self._entries.items()
if e.status in ("found", "unlisted")},
"routes": dict(self._routes),
}
self._dirty = False
try:
self.cache_path.parent.mkdir(parents=True, exist_ok=True)
tmp = self.cache_path.with_suffix(".tmp")
tmp.write_text(json.dumps(body, indent=1, sort_keys=True))
tmp.replace(self.cache_path)
except OSError:
pass
# -- lookup -----------------------------------------------------------
def get(self, icao: str, callsign: str = "") -> Flight:
"""What is known about an aircraft now, starting a lookup if needed.
Called again with a callsign once one has been heard -- identity and
position arrive in different frames, and the callsign often arrives
second -- so a flight already resolved without one is topped up with
its route rather than looked up from the beginning.
"""
icao = (icao or "").upper()
callsign = (callsign or "").strip().upper()
with self._lock:
entry = self._entries.get(icao)
fresh = entry is None
if entry is None:
entry = Flight(icao=icao, country=describe_address(icao),
status="pending" if self.online else "local")
self._entries[icao] = entry
if callsign and callsign != entry.callsign:
entry.callsign = callsign
entry.airline = airline_of(callsign)
self._apply_route(entry, self._routes.get(callsign))
wanted_route = callsign not in self._asked
self._asked.add(callsign)
else:
wanted_route = False
if not self.online:
return entry
if fresh or wanted_route:
thread = threading.Thread(target=self._fetch,
args=(entry, fresh, wanted_route),
daemon=True)
with self._lock:
self._threads.append(thread)
thread.start()
return entry
def get_all(self, pairs) -> list[Flight]:
return [self.get(icao, call) for icao, call in pairs]
def wait(self, timeout: float = 15.0) -> None:
"""Block until the outstanding lookups finish. For scripts, not the UI."""
deadline = time.time() + timeout
for thread in list(self._threads):
thread.join(max(0.0, deadline - time.time()))
with self._lock:
self._threads = [t for t in self._threads if t.is_alive()]
# -- the network ------------------------------------------------------
def _fetch(self, entry: Flight, airframe: bool, route: bool) -> None:
reached = False
if airframe:
for url, apply in ((self.aircraft_url, self._apply_aircraft),
(self.backup_aircraft_url,
self._apply_backup_aircraft)):
if not url or entry.known:
continue
try:
apply(entry, self._request(url.format(
icao=urllib.parse.quote(entry.icao))))
reached = True
except Exception:
continue
with self._lock:
if entry.known:
entry.status = "found"
elif reached:
# Reached a register and it had never heard of it. A
# private aircraft that has changed hands, or a brand new
# airframe: worth remembering so it is not asked daily.
entry.status = "unlisted"
else:
entry.status = "offline"
entry.fetched_at = time.time()
self._dirty = True
if route and entry.callsign:
found = None
for url, read in ((self.route_url, self._read_route),
(self.backup_route_url, self._read_backup_route)):
if not url or found:
continue
try:
found = read(self._request(url.format(
call=urllib.parse.quote(entry.callsign))))
except Exception:
continue
with self._lock:
if found is not None:
found["fetched_at"] = time.time()
self._routes[entry.callsign] = found
self._apply_route(entry, found)
self._dirty = True
def _request(self, url: str) -> dict:
"""Ask one website one question.
Nothing but the address or callsign heard on the air goes out: no
identity, no position, no key. A register that wants to know who is
asking is told the name of the program and nothing else.
"""
req = urllib.request.Request(url, headers={"User-Agent": "bandsaunter"})
with urllib.request.urlopen(req, timeout=self.timeout) as response:
return json.loads(response.read(64_000).decode("utf8", "replace"))
# -- reading the answers ----------------------------------------------
@staticmethod
def _apply_aircraft(entry: Flight, body: dict) -> None:
"""Read adsbdb's aircraft record."""
record = ((body or {}).get("response") or {}).get("aircraft")
if not isinstance(record, dict):
return
entry.registration = str(record.get("registration") or "").strip()
entry.type_code = str(record.get("icao_type") or "").strip()
entry.model = str(record.get("type") or "").strip()
entry.manufacturer = str(record.get("manufacturer") or "").strip()
entry.operator = str(record.get("registered_owner") or "").strip()
entry.owner_country = str(
record.get("registered_owner_country_name") or "").strip()
@staticmethod
def _apply_backup_aircraft(entry: Flight, body: dict) -> None:
"""Read hexdb's aircraft record, which is flat and named differently."""
if not isinstance(body, dict) or not body.get("Registration"):
return
entry.registration = str(body.get("Registration") or "").strip()
entry.type_code = str(body.get("ICAOTypeCode") or "").strip()
entry.model = str(body.get("Type") or "").strip()
entry.manufacturer = str(body.get("Manufacturer") or "").strip()
entry.operator = str(body.get("RegisteredOwners") or "").strip()
@staticmethod
def _airport(record) -> tuple[str, str, float, float]:
"""An airport as a code, a readable place and where it is.
The position is kept because a map can draw it: a route is two dots
and a line long before any aircraft is heard flying it.
"""
if not isinstance(record, dict):
return "", "", 0.0, 0.0
code = str(record.get("icao_code") or record.get("iata_code") or "")
name = str(record.get("name") or "").strip()
town = str(record.get("municipality") or "").strip()
where = name
if town and town.lower() not in name.lower():
where = f"{name}, {town}" if name else town
try:
lat = float(record.get("latitude") or 0.0)
lon = float(record.get("longitude") or 0.0)
except (TypeError, ValueError):
lat = lon = 0.0
return code.strip(), (where or code).strip(), lat, lon
@classmethod
def _read_route(cls, body: dict) -> dict | None:
"""Read adsbdb's flight route."""
record = ((body or {}).get("response") or {}).get("flightroute")
if not isinstance(record, dict):
return None
start = cls._airport(record.get("origin"))
end = cls._airport(record.get("destination"))
if not (start[0] or end[0]):
return None
return {"origin_code": start[0], "origin": start[1],
"origin_lat": start[2], "origin_lon": start[3],
"destination_code": end[0], "destination": end[1],
"destination_lat": end[2], "destination_lon": end[3],
"airline": str((record.get("airline") or {}).get("name") or "")}
@staticmethod
def _read_backup_route(body: dict) -> dict | None:
"""Read hexdb's route, which is a single "EIDW-EGSS" string."""
raw = str((body or {}).get("route") or "").strip()
parts = [p.strip().upper() for p in raw.split("-") if p.strip()]
if len(parts) < 2:
return None
# A multi-leg route lists every stop; the ends are what a map wants.
return {"origin_code": parts[0], "origin": parts[0],
"destination_code": parts[-1], "destination": parts[-1],
"airline": ""}
@staticmethod
def _apply_route(entry: Flight, route: dict | None) -> None:
if not route:
return
entry.origin_code = str(route.get("origin_code") or "")
entry.origin = str(route.get("origin") or "")
entry.origin_lat = float(route.get("origin_lat") or 0.0)
entry.origin_lon = float(route.get("origin_lon") or 0.0)
entry.destination_code = str(route.get("destination_code") or "")
entry.destination = str(route.get("destination") or "")
entry.destination_lat = float(route.get("destination_lat") or 0.0)
entry.destination_lon = float(route.get("destination_lon") or 0.0)
if not entry.airline:
entry.airline = str(route.get("airline") or "")

View file

@ -29,7 +29,10 @@ import numpy as np
from scipy import signal as sps
__all__ = ["ImageDecode", "write_png", "instantaneous_frequency",
"tone_amplitude", "resample_to", "PNG_SIGNATURE"]
"tone_amplitude", "resample_to", "PNG_SIGNATURE",
"GLYPHS", "GLYPH_W", "GLYPH_H", "CHAR_ADVANCE", "text_width",
"INK",
"draw_text"]
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
@ -126,6 +129,99 @@ def write_png(path, pixels: np.ndarray) -> Path:
return path
# ---------------------------------------------------------------------------
# A font, because a picture with no numbers on it is a decoration
# ---------------------------------------------------------------------------
# The colour a label is drawn in unless the caller says otherwise.
INK = (190, 195, 205)
# Five by seven, uppercase, digits and the punctuation an axis label needs.
# Hand-cut for the same reason the PNG is hand-written: a label has to work on
# a machine with no fonts installed, and seven rows of five bits is a smaller
# thing to carry than a dependency.
GLYPHS = {
"0": ("01110", "10001", "10011", "10101", "11001", "10001", "01110"),
"1": ("00100", "01100", "00100", "00100", "00100", "00100", "01110"),
"2": ("01110", "10001", "00001", "00010", "00100", "01000", "11111"),
"3": ("11111", "00010", "00100", "00010", "00001", "10001", "01110"),
"4": ("00010", "00110", "01010", "10010", "11111", "00010", "00010"),
"5": ("11111", "10000", "11110", "00001", "00001", "10001", "01110"),
"6": ("00110", "01000", "10000", "11110", "10001", "10001", "01110"),
"7": ("11111", "00001", "00010", "00100", "01000", "01000", "01000"),
"8": ("01110", "10001", "10001", "01110", "10001", "10001", "01110"),
"9": ("01110", "10001", "10001", "01111", "00001", "00010", "01100"),
"A": ("01110", "10001", "10001", "11111", "10001", "10001", "10001"),
"B": ("11110", "10001", "10001", "11110", "10001", "10001", "11110"),
"C": ("01110", "10001", "10000", "10000", "10000", "10001", "01110"),
"D": ("11100", "10010", "10001", "10001", "10001", "10010", "11100"),
"E": ("11111", "10000", "10000", "11110", "10000", "10000", "11111"),
"F": ("11111", "10000", "10000", "11110", "10000", "10000", "10000"),
"G": ("01110", "10001", "10000", "10111", "10001", "10001", "01111"),
"H": ("10001", "10001", "10001", "11111", "10001", "10001", "10001"),
"I": ("01110", "00100", "00100", "00100", "00100", "00100", "01110"),
"J": ("00111", "00010", "00010", "00010", "00010", "10010", "01100"),
"K": ("10001", "10010", "10100", "11000", "10100", "10010", "10001"),
"L": ("10000", "10000", "10000", "10000", "10000", "10000", "11111"),
"M": ("10001", "11011", "10101", "10101", "10001", "10001", "10001"),
"N": ("10001", "11001", "10101", "10011", "10001", "10001", "10001"),
"O": ("01110", "10001", "10001", "10001", "10001", "10001", "01110"),
"P": ("11110", "10001", "10001", "11110", "10000", "10000", "10000"),
"Q": ("01110", "10001", "10001", "10001", "10101", "10010", "01101"),
"R": ("11110", "10001", "10001", "11110", "10100", "10010", "10001"),
"S": ("01111", "10000", "10000", "01110", "00001", "00001", "11110"),
"T": ("11111", "00100", "00100", "00100", "00100", "00100", "00100"),
"U": ("10001", "10001", "10001", "10001", "10001", "10001", "01110"),
"V": ("10001", "10001", "10001", "10001", "10001", "01010", "00100"),
"W": ("10001", "10001", "10001", "10101", "10101", "11011", "10001"),
"X": ("10001", "10001", "01010", "00100", "01010", "10001", "10001"),
"Y": ("10001", "10001", "01010", "00100", "00100", "00100", "00100"),
"Z": ("11111", "00001", "00010", "00100", "01000", "10000", "11111"),
".": ("00000", "00000", "00000", "00000", "00000", "01100", "01100"),
",": ("00000", "00000", "00000", "00000", "01100", "01100", "11000"),
"-": ("00000", "00000", "00000", "11111", "00000", "00000", "00000"),
"+": ("00000", "00100", "00100", "11111", "00100", "00100", "00000"),
":": ("00000", "01100", "01100", "00000", "01100", "01100", "00000"),
"/": ("00001", "00010", "00010", "00100", "01000", "01000", "10000"),
"(": ("00010", "00100", "01000", "01000", "01000", "00100", "00010"),
")": ("01000", "00100", "00010", "00010", "00010", "00100", "01000"),
"%": ("11001", "11010", "00010", "00100", "01000", "01011", "10011"),
" ": ("00000", "00000", "00000", "00000", "00000", "00000", "00000"),
}
GLYPH_W, GLYPH_H = 5, 7
CHAR_ADVANCE = GLYPH_W + 1
def text_width(text: str) -> int:
"""How wide a label will be, so it can be centred or right-aligned."""
return max(0, len(text) * CHAR_ADVANCE - 1)
def draw_text(canvas: np.ndarray, x: int, y: int, text: str,
colour=INK) -> None:
"""Stamp a label into an RGB array. Clipped, never wrapped.
Anything with no glyph is drawn as a space rather than refused: a label
is a convenience, and a picture that failed to be written because of one
unexpected character would be a poor trade.
"""
height, width = canvas.shape[0], canvas.shape[1]
for index, char in enumerate(text.upper()):
rows = GLYPHS.get(char)
left = x + index * CHAR_ADVANCE
if rows is None or left >= width:
continue
for row, bits in enumerate(rows):
yy = y + row
if not 0 <= yy < height:
continue
for col, bit in enumerate(bits):
xx = left + col
if bit == "1" and 0 <= xx < width:
canvas[yy, xx] = colour
# ---------------------------------------------------------------------------
# The measurements every one of these decoders needs
# ---------------------------------------------------------------------------

View file

@ -28,11 +28,16 @@ from pathlib import Path
import numpy as np
from .images import write_png
from .images import (CHAR_ADVANCE, GLYPH_H, GLYPH_W, GLYPHS, INK,
draw_text, text_width, write_png)
__all__ = ["Waterfall", "render_waterfall", "write_waterfall",
"draw_for_recording", "waterfall_path", "is_readable",
"caption_for", "read_iq", "COLOURS"]
"caption_for", "read_iq", "COLOURS",
# Borrowed from images, and re-exported: a waterfall's labels and
# a map's labels are the same five-by-seven letters.
"GLYPHS", "GLYPH_W", "GLYPH_H", "CHAR_ADVANCE", "INK",
"draw_text", "text_width"]
# ---------------------------------------------------------------------------
@ -54,7 +59,6 @@ COLOURS = (
)
BACKGROUND = (16, 16, 20)
INK = (190, 195, 205)
GRID = (70, 74, 84)
WIDTH = 512 # spectrum bins across
@ -87,88 +91,6 @@ MAX_RANGE_DB = 80.0
# MHZ" in capitals is how every receiver front panel has ever written it.
# ---------------------------------------------------------------------------
_GLYPHS = {
"0": ("01110", "10001", "10011", "10101", "11001", "10001", "01110"),
"1": ("00100", "01100", "00100", "00100", "00100", "00100", "01110"),
"2": ("01110", "10001", "00001", "00010", "00100", "01000", "11111"),
"3": ("11111", "00010", "00100", "00010", "00001", "10001", "01110"),
"4": ("00010", "00110", "01010", "10010", "11111", "00010", "00010"),
"5": ("11111", "10000", "11110", "00001", "00001", "10001", "01110"),
"6": ("00110", "01000", "10000", "11110", "10001", "10001", "01110"),
"7": ("11111", "00001", "00010", "00100", "01000", "01000", "01000"),
"8": ("01110", "10001", "10001", "01110", "10001", "10001", "01110"),
"9": ("01110", "10001", "10001", "01111", "00001", "00010", "01100"),
"A": ("01110", "10001", "10001", "11111", "10001", "10001", "10001"),
"B": ("11110", "10001", "10001", "11110", "10001", "10001", "11110"),
"C": ("01110", "10001", "10000", "10000", "10000", "10001", "01110"),
"D": ("11100", "10010", "10001", "10001", "10001", "10010", "11100"),
"E": ("11111", "10000", "10000", "11110", "10000", "10000", "11111"),
"F": ("11111", "10000", "10000", "11110", "10000", "10000", "10000"),
"G": ("01110", "10001", "10000", "10111", "10001", "10001", "01111"),
"H": ("10001", "10001", "10001", "11111", "10001", "10001", "10001"),
"I": ("01110", "00100", "00100", "00100", "00100", "00100", "01110"),
"J": ("00111", "00010", "00010", "00010", "00010", "10010", "01100"),
"K": ("10001", "10010", "10100", "11000", "10100", "10010", "10001"),
"L": ("10000", "10000", "10000", "10000", "10000", "10000", "11111"),
"M": ("10001", "11011", "10101", "10101", "10001", "10001", "10001"),
"N": ("10001", "11001", "10101", "10011", "10001", "10001", "10001"),
"O": ("01110", "10001", "10001", "10001", "10001", "10001", "01110"),
"P": ("11110", "10001", "10001", "11110", "10000", "10000", "10000"),
"Q": ("01110", "10001", "10001", "10001", "10101", "10010", "01101"),
"R": ("11110", "10001", "10001", "11110", "10100", "10010", "10001"),
"S": ("01111", "10000", "10000", "01110", "00001", "00001", "11110"),
"T": ("11111", "00100", "00100", "00100", "00100", "00100", "00100"),
"U": ("10001", "10001", "10001", "10001", "10001", "10001", "01110"),
"V": ("10001", "10001", "10001", "10001", "10001", "01010", "00100"),
"W": ("10001", "10001", "10001", "10101", "10101", "11011", "10001"),
"X": ("10001", "10001", "01010", "00100", "01010", "10001", "10001"),
"Y": ("10001", "10001", "01010", "00100", "00100", "00100", "00100"),
"Z": ("11111", "00001", "00010", "00100", "01000", "10000", "11111"),
".": ("00000", "00000", "00000", "00000", "00000", "01100", "01100"),
",": ("00000", "00000", "00000", "00000", "01100", "01100", "11000"),
"-": ("00000", "00000", "00000", "11111", "00000", "00000", "00000"),
"+": ("00000", "00100", "00100", "11111", "00100", "00100", "00000"),
":": ("00000", "01100", "01100", "00000", "01100", "01100", "00000"),
"/": ("00001", "00010", "00010", "00100", "01000", "01000", "10000"),
"(": ("00010", "00100", "01000", "01000", "01000", "00100", "00010"),
")": ("01000", "00100", "00010", "00010", "00010", "00100", "01000"),
"%": ("11001", "11010", "00010", "00100", "01000", "01011", "10011"),
" ": ("00000", "00000", "00000", "00000", "00000", "00000", "00000"),
}
GLYPH_W, GLYPH_H = 5, 7
CHAR_ADVANCE = GLYPH_W + 1
def text_width(text: str) -> int:
"""How wide a label will be, so it can be centred or right-aligned."""
return max(0, len(text) * CHAR_ADVANCE - 1)
def draw_text(canvas: np.ndarray, x: int, y: int, text: str,
colour=INK) -> None:
"""Stamp a label into an RGB array. Clipped, never wrapped.
Anything with no glyph is drawn as a space rather than refused: a label
is a convenience, and a picture that failed to be written because of one
unexpected character would be a poor trade.
"""
height, width = canvas.shape[0], canvas.shape[1]
for index, char in enumerate(text.upper()):
rows = _GLYPHS.get(char)
left = x + index * CHAR_ADVANCE
if rows is None or left >= width:
continue
for row, bits in enumerate(rows):
yy = y + row
if not 0 <= yy < height:
continue
for col, bit in enumerate(bits):
xx = left + col
if bit == "1" and 0 <= xx < width:
canvas[yy, xx] = colour
# ---------------------------------------------------------------------------
# What was drawn
# ---------------------------------------------------------------------------

View file

@ -1,5 +1,5 @@
.\" Generated by packaging/make-man.py -- do not edit by hand.
.TH BANDSAUNTER 1 "2026-09-03" "bandsaunter 2026-09-03_02" "User Commands"
.TH BANDSAUNTER 1 "2026-09-03" "bandsaunter 2026-09-03_03" "User Commands"
.SH NAME
bandsaunter \- scan, record and identify radio signals with an RTL-SDR
.SH SYNOPSIS
@ -75,7 +75,13 @@ List attached receivers.
List saved profiles.
.TP
.B adsb
Listen to aircraft on 1090 MHz. See
Listen to aircraft on 1090 MHz and write down everything they say. See
.B AIRCRAFT
below.
.TP
.B flights
Read an ADS-B log back: the report, the map for Google Earth and the
animation. See
.B AIRCRAFT
below.
.TP
@ -1255,12 +1261,69 @@ two frames \[em] the encoding sends a fraction of a zone, and one frame alone is
ambiguous by hundreds of miles \[em] so an aircraft is placed once an even and an
odd frame have both arrived, about a second apart.
.PP
.B \-\-kml
writes what was heard as a map.
An aircraft is overhead for four minutes and then gone, so everything heard is
written down as it arrives: a JSON Lines log, one object per frame, in
.I adsb_<time>.jsonl
in the output directory, with the raw hexadecimal of every frame kept beside
what was read out of it \[em] the frame is the evidence and the rest of the line
is an opinion about it. The log is flushed as it is written, because a
listening session ends with control-C. Beside it goes a readable report, one
block per aircraft.
.PP
.B \-\-frames
prints each frame as it arrives instead of a running count. An aerial cut for
1090 MHz makes the difference between hearing the airport and hearing the
county; the whip supplied with a dongle is a quarter of the length it wants.
prints each frame as it arrives instead of a running count,
.B \-\-no\-log
listens without writing anything down,
.B \-\-kml
writes the flight paths for Google Earth and
.B \-\-map
draws the animation when the listening stops.
.B \-\-simulate
flies six imaginary aircraft past an imaginary receiver \[em] real frames, real
checksums, the same decoder \[em] for trying all of this without an aerial;
.BI \-\-near " LAT,LON"
says where they are flying. An aerial cut for 1090 MHz makes the difference
between hearing the airport and hearing the county; the whip supplied with a
dongle is a quarter of the length it wants.
.SS Who the aircraft is
The frames say an address, not a registration. Two registers are asked \[em]
adsbdb for the airframe and the route, then hexdb \[em] and the answers are
cached for a month. Nothing is sent to either but the address or the callsign
that was heard on the air.
.PP
What can be answered without asking anybody is answered without asking. The
address block says which country registered the aircraft, fixed by treaty, and
the first three letters of an airline callsign are its ICAO designator.
.B \-\-no\-lookup
stops at that.
.SS The moving map
.B bandsaunter flights
reads a log back \[em] the newest one in the output directory unless told
otherwise \[em] prints the report and draws the whole evening as a map with the
clock running.
.PP
Every frame of the animation is a moment: each aircraft is drawn where it
actually was then, interpolated between the position reports either side of it
and dead-reckoned from its last known speed and heading where none arrived, so
an aircraft crossing the picture in ten seconds took the twenty minutes the
data says it took. An aircraft not heard from for
.B \-\-stale
seconds stops being drawn rather than being flown on by guesswork.
.PP
.BI \-\-speed " X"
is seconds of flying per second of animation;
.BI \-\-seconds " N"
works that out from how long the animation should run instead.
.B \-\-out
takes a
.IR .gif ,
an
.I .mp4
where ffmpeg is installed, or a
.I .png
for the whole evening in one picture. Altitude is the colour, low warm to high
cold. The GIF is written from first principles \[em] a palette, an LZW stream
and frame differencing \[em] so nothing but numpy is needed to draw one.
.SH METERS AND SENSORS
Two things on the ISM bands are worth naming rather than reporting as
hexadecimal.

View file

@ -133,7 +133,13 @@ List attached receivers.
List saved profiles.
.TP
.B adsb
Listen to aircraft on 1090 MHz. See
Listen to aircraft on 1090 MHz and write down everything they say. See
.B AIRCRAFT
below.
.TP
.B flights
Read an ADS-B log back: the report, the map for Google Earth and the
animation. See
.B AIRCRAFT
below.
.TP
@ -648,12 +654,69 @@ two frames \[em] the encoding sends a fraction of a zone, and one frame alone is
ambiguous by hundreds of miles \[em] so an aircraft is placed once an even and an
odd frame have both arrived, about a second apart.
.PP
.B \-\-kml
writes what was heard as a map.
An aircraft is overhead for four minutes and then gone, so everything heard is
written down as it arrives: a JSON Lines log, one object per frame, in
.I adsb_<time>.jsonl
in the output directory, with the raw hexadecimal of every frame kept beside
what was read out of it \[em] the frame is the evidence and the rest of the line
is an opinion about it. The log is flushed as it is written, because a
listening session ends with control-C. Beside it goes a readable report, one
block per aircraft.
.PP
.B \-\-frames
prints each frame as it arrives instead of a running count. An aerial cut for
1090 MHz makes the difference between hearing the airport and hearing the
county; the whip supplied with a dongle is a quarter of the length it wants.
prints each frame as it arrives instead of a running count,
.B \-\-no\-log
listens without writing anything down,
.B \-\-kml
writes the flight paths for Google Earth and
.B \-\-map
draws the animation when the listening stops.
.B \-\-simulate
flies six imaginary aircraft past an imaginary receiver \[em] real frames, real
checksums, the same decoder \[em] for trying all of this without an aerial;
.BI \-\-near " LAT,LON"
says where they are flying. An aerial cut for 1090 MHz makes the difference
between hearing the airport and hearing the county; the whip supplied with a
dongle is a quarter of the length it wants.
.SS Who the aircraft is
The frames say an address, not a registration. Two registers are asked \[em]
adsbdb for the airframe and the route, then hexdb \[em] and the answers are
cached for a month. Nothing is sent to either but the address or the callsign
that was heard on the air.
.PP
What can be answered without asking anybody is answered without asking. The
address block says which country registered the aircraft, fixed by treaty, and
the first three letters of an airline callsign are its ICAO designator.
.B \-\-no\-lookup
stops at that.
.SS The moving map
.B bandsaunter flights
reads a log back \[em] the newest one in the output directory unless told
otherwise \[em] prints the report and draws the whole evening as a map with the
clock running.
.PP
Every frame of the animation is a moment: each aircraft is drawn where it
actually was then, interpolated between the position reports either side of it
and dead-reckoned from its last known speed and heading where none arrived, so
an aircraft crossing the picture in ten seconds took the twenty minutes the
data says it took. An aircraft not heard from for
.B \-\-stale
seconds stops being drawn rather than being flown on by guesswork.
.PP
.BI \-\-speed " X"
is seconds of flying per second of animation;
.BI \-\-seconds " N"
works that out from how long the animation should run instead.
.B \-\-out
takes a
.IR .gif ,
an
.I .mp4
where ffmpeg is installed, or a
.I .png
for the whole evening in one picture. Altitude is the colour, low warm to high
cold. The GIF is written from first principles \[em] a palette, an LZW stream
and frame differencing \[em] so nothing but numpy is needed to draw one.
.SH METERS AND SENSORS
Two things on the ISM bands are worth naming rather than reporting as
hexadecimal.

View file

@ -1,5 +1,5 @@
.\" Generated by packaging/make-browse-man.py -- do not edit by hand.
.TH SAUNTERBROWSE 1 "2026-09-03" "bandsaunter 2026-09-03_02" "User Commands"
.TH SAUNTERBROWSE 1 "2026-09-03" "bandsaunter 2026-09-03_03" "User Commands"
.SH NAME
saunterbrowse \- read and listen to what a bandsaunter scan collected
.SH SYNOPSIS

400
tests/test_flightmap.py Normal file
View file

@ -0,0 +1,400 @@
"""The moving map: what is drawn, and whether the file is really a GIF.
A picture is checked here the way a picture has to be -- by reading the pixels
back -- and the file by taking it apart with a reader written from the GIF
specification rather than from the encoder in the program. An encoder checked
against itself is not checked.
"""
import struct
import subprocess
from pathlib import Path
import numpy as np
import pytest
from bandsaunter import flightmap as fm
from bandsaunter.flightlog import Fix, Track
# ---------------------------------------------------------------------------
# A GIF reader, built from the specification
# ---------------------------------------------------------------------------
def lzw_decode(data: bytes, code_bits: int) -> bytes:
"""GIF's LZW, the reading side, written from the standard."""
clear, end = 1 << code_bits, (1 << code_bits) + 1
table = {i: bytes([i]) for i in range(clear)}
width = code_bits + 1
counter = end + 1 # counts codes read, not entries added
out = bytearray()
previous = None
value = held = at = 0
while True:
while held < width and at < len(data):
value |= data[at] << held
held += 8
at += 1
if held < width:
break
code = value & ((1 << width) - 1)
value >>= width
held -= width
if code == clear:
table = {i: bytes([i]) for i in range(clear)}
width, counter, previous = code_bits + 1, end + 1, None
continue
if code == end:
break
# The width goes up as codes are read rather than as the table is
# filled: the decoder builds its table one code behind the encoder,
# and counting entries instead would widen the codes one too late.
read_at = counter
if counter < 4096:
counter += 1
if counter > (1 << width) and width < 12:
width += 1
if code in table:
entry = table[code]
elif previous is not None:
entry = previous + previous[:1]
else:
raise ValueError(f"code {code} before anything defined it")
out += entry
if previous is not None and read_at - 1 < 4096:
table[read_at - 1] = previous + entry[:1]
previous = entry
return bytes(out)
def read_gif(path: Path) -> dict:
"""Take a GIF apart: the screen, the palette and every frame in it."""
raw = Path(path).read_bytes()
assert raw[:6] == b"GIF89a", "not a GIF89a"
width, height, packed, _bg, _aspect = struct.unpack("<HHBBB", raw[6:13])
at = 13
table_size = 2 ** ((packed & 0x07) + 1)
assert packed & 0x80, "no global colour table"
palette = np.frombuffer(raw[at:at + table_size * 3],
dtype=np.uint8).reshape(-1, 3)
at += table_size * 3
frames, loops, control = [], False, {}
while at < len(raw):
block = raw[at]
if block == 0x3B: # trailer
break
if block == 0x21: # extension
label = raw[at + 1]
at += 2
body = b""
while raw[at]:
size = raw[at]
body += raw[at + 1:at + 1 + size]
at += size + 1
at += 1
if label == 0xF9:
control = {"disposal": (body[0] >> 2) & 0x07,
"transparent": body[3] if body[0] & 1 else None,
"delay": struct.unpack("<H", body[1:3])[0]}
elif label == 0xFF and body.startswith(b"NETSCAPE2.0"):
loops = True
continue
if block == 0x2C: # image descriptor
left, top, w, h, flags = struct.unpack("<HHHHB", raw[at + 1:at + 10])
at += 10
assert not flags & 0x80, "a local colour table was not expected"
code_bits = raw[at]
at += 1
data = b""
while raw[at]:
size = raw[at]
data += raw[at + 1:at + 1 + size]
at += size + 1
at += 1
pixels = np.frombuffer(lzw_decode(data, code_bits),
dtype=np.uint8)
assert pixels.size == w * h, f"{pixels.size} pixels for {w}x{h}"
frames.append({"left": left, "top": top, "width": w, "height": h,
"pixels": pixels.reshape(h, w), **control})
continue
raise ValueError(f"unknown block {block:#x} at {at}")
return {"width": width, "height": height, "palette": palette,
"frames": frames, "loops": loops}
def played(gif: dict) -> list[np.ndarray]:
"""Every frame as it appears on the screen, patches laid over each other."""
canvas = np.zeros((gif["height"], gif["width"]), dtype=np.uint8)
out = []
for frame in gif["frames"]:
patch = frame["pixels"]
top, left = frame["top"], frame["left"]
area = canvas[top:top + frame["height"], left:left + frame["width"]]
if frame.get("transparent") is None:
area[:, :] = patch
else:
keep = patch != frame["transparent"]
area[keep] = patch[keep]
out.append(canvas.copy())
return out
# ---------------------------------------------------------------------------
# Tracks to draw
# ---------------------------------------------------------------------------
def straight(icao="4CA1FA", callsign="RYR1234", lat=51.0, lon=-1.0,
heading=90.0, speed=480.0, altitude=35_000, seconds=600.0,
every=20.0, start=1_000_000.0) -> Track:
"""One aircraft flying a straight line at a steady speed."""
from bandsaunter.flightlog import move
fixes = []
when = 0.0
while when <= seconds:
step = speed * when / 3600.0
here = move(lat, lon, heading, step)
fixes.append(Fix(at=start + when, latitude=here[0], longitude=here[1],
altitude_ft=altitude, ground_speed_kt=speed,
track_deg=heading))
when += every
return Track(icao=icao, callsign=callsign, fixes=fixes,
frames=len(fixes) * 3, first_seen=start,
last_seen=start + seconds)
def two_aircraft() -> list[Track]:
return [straight(),
straight(icao="A835AF", callsign="UAL1902", lat=51.4, lon=-0.6,
heading=250.0, speed=300.0, altitude=9_000)]
# ---------------------------------------------------------------------------
# Colours and geometry
# ---------------------------------------------------------------------------
def test_the_palette_is_a_full_table_with_room_for_transparency():
assert fm.PALETTE.shape == (256, 3)
assert fm.TRANSPARENT == 255
def test_altitude_becomes_a_colour_that_climbs_with_it():
steps = [fm.altitude_step(ft) for ft in (0, 5_000, 20_000, 35_000, 45_000)]
assert steps == sorted(steps)
assert steps[0] == 0 and steps[-1] == fm.RAMP_STEPS - 1
assert fm.altitude_step(90_000) == fm.RAMP_STEPS - 1 # clamped
def test_the_map_holds_every_position_that_was_reported():
tracks = two_aircraft()
view = fm.fit(tracks, width=800)
for track in tracks:
for fix in track.fixes:
assert view.inside(fix.latitude, fix.longitude)
x, y = view.xy(fix.latitude, fix.longitude)
assert view.left <= x < view.left + view.width
assert view.top <= y < view.top + view.height
def test_a_mile_across_is_a_mile_up_the_picture():
"""Longitude is squeezed by the cosine, or everything at fifty degrees
north comes out stretched half as wide again."""
view = fm.fit(two_aircraft(), width=800)
per_nm_x = view.width / view.width_nm
tall_nm = (view.north - view.south) * 60.0
per_nm_y = view.height / tall_nm
assert per_nm_x == pytest.approx(per_nm_y, rel=0.02)
def test_one_aircraft_heard_once_still_gets_a_map():
track = Track(icao="4CA1FA", fixes=[Fix(at=1.0, latitude=51.0,
longitude=-1.0)])
view = fm.fit([track], width=400)
assert view is not None and view.north > view.south
def test_nothing_to_draw_draws_nothing(tmp_path):
assert fm.fit([Track(icao="4CA1FA")]) is None
assert fm.animate([Track(icao="4CA1FA")], tmp_path / "x.gif") is None
# ---------------------------------------------------------------------------
# What ends up on the picture
# ---------------------------------------------------------------------------
def test_the_background_has_a_grid_a_scale_and_a_title():
view = fm.fit(two_aircraft(), width=800)
base = fm.background(view, title="6 AIRCRAFT 2026-09-03")
assert (base == fm.GRID).sum() > 200 # the graticule and the border
assert (base == fm.INK).sum() > 40 # the title
assert (base == fm.DIM).sum() > 40 # scale bar and axis labels
assert (base == fm.RAMP + fm.RAMP_STEPS - 1).any() # the key
def test_an_aircraft_is_drawn_where_it_was_at_that_moment():
tracks = [straight()]
view = fm.fit(tracks, width=800)
base = fm.background(view)
when = tracks[0].first_seen + 300.0
frame = fm.render_frame(base, view, tracks, when)
fix = tracks[0].at(when)
x, y = view.xy(fix.latitude, fix.longitude)
patch = frame[y - 6:y + 7, x - 6:x + 7]
assert (patch >= fm.RAMP).any() and (patch < fm.TRAIL).any()
def test_the_aircraft_moves_between_frames_and_leaves_a_trail():
tracks = [straight()]
view = fm.fit(tracks, width=800)
base = fm.background(view)
early = fm.render_frame(base, view, tracks, tracks[0].first_seen + 60)
late = fm.render_frame(base, view, tracks, tracks[0].first_seen + 540)
assert not np.array_equal(early, late)
trail_early = (early >= fm.TRAIL) & (early < fm.TRANSPARENT)
trail_late = (late >= fm.TRAIL) & (late < fm.TRANSPARENT)
assert trail_late.sum() > trail_early.sum()
def test_an_aircraft_is_not_drawn_before_it_was_ever_heard():
tracks = [straight()]
view = fm.fit(tracks, width=800)
base = fm.background(view)
frame = fm.render_frame(base, view, tracks, tracks[0].first_seen - 10)
assert np.array_equal(frame, base)
def test_an_aircraft_long_gone_is_not_drawn_at_a_guessed_position():
tracks = [straight()]
view = fm.fit(tracks, width=800)
base = fm.background(view)
frame = fm.render_frame(base, view, tracks,
tracks[0].last_seen + 900, stale=300)
assert np.array_equal(frame, base)
def test_the_clock_and_the_count_are_drawn_over_the_map():
tracks = two_aircraft()
view = fm.fit(tracks, width=800)
base = fm.background(view)
frame = fm.render_frame(base, view, tracks, tracks[0].first_seen + 60,
clock="19:45:02")
assert (frame == fm.PANEL).any()
assert (frame == fm.INK).sum() > (base == fm.INK).sum()
# ---------------------------------------------------------------------------
# The file
# ---------------------------------------------------------------------------
def test_the_lzw_stream_reads_back_as_what_went_in():
for body in (b"\x00" * 300, bytes(range(256)) * 3,
bytes([7, 7, 7, 8, 9, 7, 7, 8]) * 40):
assert lzw_decode(fm._lzw(body, 8), 8) == body
def test_a_long_stream_survives_the_table_filling_up():
rng = np.random.default_rng(4)
body = rng.integers(0, 60, size=200_000, dtype=np.uint8).tobytes()
assert lzw_decode(fm._lzw(body, 8), 8) == body
def test_the_animation_is_a_gif_that_loops(tmp_path):
out = fm.animate(two_aircraft(), tmp_path / "flights.gif", fps=10,
seconds=4, width=480)
assert out is not None and out.path.exists()
gif = read_gif(out.path)
assert gif["loops"], "a map that plays once and stops"
assert len(gif["frames"]) == out.frames
assert (gif["width"], gif["height"]) == (out.width, out.height)
assert gif["frames"][0]["delay"] == 10 # hundredths, so 10 a second
def test_only_what_changed_is_written_after_the_first_frame(tmp_path):
out = fm.animate(two_aircraft(), tmp_path / "flights.gif", fps=8,
seconds=4, width=480)
gif = read_gif(out.path)
first = gif["frames"][0]
assert (first["width"], first["height"]) == (out.width, out.height)
later = gif["frames"][1:]
assert later, "one frame is not an animation"
assert all(f["width"] * f["height"] < out.width * out.height for f in later)
assert all(f["transparent"] is not None for f in later)
def test_the_frames_played_back_show_the_aircraft_moving(tmp_path):
tracks = [straight()]
out = fm.animate(tracks, tmp_path / "one.gif", fps=8, seconds=4, width=480)
screens = played(read_gif(out.path))
assert len(screens) == out.frames
def where(frame):
lit = np.argwhere((frame >= fm.RAMP) & (frame < fm.TRAIL))
return lit.mean(axis=0)
start, end = where(screens[1]), where(screens[-1])
assert abs(end[1] - start[1]) > 20 # it went east across the picture
for frame in screens:
assert frame.shape == (out.height, out.width)
def test_the_animation_says_how_much_flying_it_covers(tmp_path):
tracks = two_aircraft()
out = fm.animate(tracks, tmp_path / "flights.gif", fps=10, seconds=5,
width=400)
assert out.covers == pytest.approx(600.0, abs=1.0)
played_for = out.frames / out.fps
assert played_for == pytest.approx(5.0, rel=0.3)
assert out.speed == pytest.approx(out.covers / played_for, rel=0.05)
assert "aircraft" in out.summary()
def test_asking_for_a_speed_gives_that_speed(tmp_path):
out = fm.animate(two_aircraft(), tmp_path / "flights.gif", fps=10,
speed=60.0, width=400)
assert out.speed == pytest.approx(60.0, rel=0.1)
assert out.frames == pytest.approx(600 / 60 * 10, abs=2)
def test_a_still_picture_is_a_png_of_the_whole_evening(tmp_path):
from bandsaunter.images import PNG_SIGNATURE
out = fm.animate(two_aircraft(), tmp_path / "flights.png", width=400)
assert out.kind == "png"
assert out.path.read_bytes()[:8] == PNG_SIGNATURE
def test_pillow_agrees_that_it_is_an_animation(tmp_path):
"""Not a dependency; when it happens to be installed it is a second
opinion from a decoder nobody here wrote."""
Image = pytest.importorskip("PIL.Image")
out = fm.animate(two_aircraft(), tmp_path / "flights.gif", fps=10,
seconds=3, width=400)
with Image.open(out.path) as picture:
assert picture.n_frames == out.frames
assert picture.size == (out.width, out.height)
picture.seek(0)
first = np.array(picture.convert("RGB"))
picture.seek(picture.n_frames - 1)
last = np.array(picture.convert("RGB"))
assert not np.array_equal(first, last)
@pytest.mark.skipif(not fm.ffmpeg_available(), reason="ffmpeg is not installed")
def test_a_video_can_be_written_where_ffmpeg_exists(tmp_path):
out = fm.animate(two_aircraft(), tmp_path / "flights.mp4", fps=10,
seconds=3, width=400)
assert out.kind == "mp4" and out.path.stat().st_size > 1000
probe = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=width,height",
"-of", "csv=p=0", str(out.path)],
capture_output=True, text=True)
if probe.returncode == 0:
assert probe.stdout.strip() == f"{out.width},{out.height}"
def test_the_picture_is_an_even_number_of_pixels_across(tmp_path):
"""Video encoders refuse an odd width, and it costs nothing to be even."""
for width in (401, 402, 555):
view = fm.fit(two_aircraft(), width=width)
w, h = fm.canvas_size(view)
assert w % 2 == 0 and h % 2 == 0

488
tests/test_flights.py Normal file
View file

@ -0,0 +1,488 @@
"""Aircraft: who they are, what was written down, and what it reads back as.
Nothing here goes near a network. The registers are stubbed with the shapes
they really answer with -- checked against the live services when this was
written -- so a test failing means the reader changed, not that a website is
down.
"""
import json
import time
import pytest
from bandsaunter.flights import (Flight, FlightBook, airline_of,
describe_address)
from bandsaunter.flightlog import (Fix, FlightLog, Track, bearing_deg,
distance_nm, move, read_logs, report,
write_kml)
# ---------------------------------------------------------------------------
# What the address alone says
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("icao,country", [
("A835AF", "United States"), # N628TS
("406B3B", "United Kingdom"), # G-DGEF
("3C6444", "Germany"), # D-AIBD
("4CA1FA", "Ireland"), # EI-DDH
("7C4A1B", "Australia"),
("C01234", "Canada"),
("484200", "Netherlands"),
])
def test_the_address_says_which_country_registered_it(icao, country):
"""Fixed by treaty, so no website is needed and none is asked."""
assert describe_address(icao) == country
def test_an_address_in_no_block_is_not_guessed_at():
assert describe_address("F00000") == ""
assert describe_address("") == ""
assert describe_address("nonsense") == ""
def test_a_military_block_is_named_before_the_country_it_sits_inside():
assert describe_address("ADFFFF") == "United States military"
@pytest.mark.parametrize("callsign,airline", [
("RYR1234", "Ryanair"),
("BAW49", "British Airways"),
("UAL1902", "United Airlines"),
("RCH445", "United States Air Mobility Command (Reach)"),
])
def test_the_callsign_says_which_airline_is_flying(callsign, airline):
assert airline_of(callsign) == airline
@pytest.mark.parametrize("callsign", ["N517HP", "G-ABCD", "", "XX"])
def test_a_registration_flown_as_a_callsign_is_not_an_airline(callsign):
"""A private aircraft uses its registration, and reading three letters of
that as an airline designator would invent one."""
assert airline_of(callsign) == ""
# ---------------------------------------------------------------------------
# The registers
# ---------------------------------------------------------------------------
ADSBDB_AIRCRAFT = {"response": {"aircraft": {
"type": "737 8AS", "icao_type": "B738", "manufacturer": "Boeing",
"mode_s": "4CA1FA", "registration": "EI-DYP",
"registered_owner_country_iso_name": "IE",
"registered_owner_country_name": "Ireland",
"registered_owner": "Ryanair"}}}
ADSBDB_ROUTE = {"response": {"flightroute": {
"callsign": "RYR1234",
"airline": {"name": "Ryanair", "icao": "RYR"},
"origin": {"icao_code": "EGSS", "iata_code": "STN",
"name": "London Stansted Airport", "municipality": "London",
"latitude": 51.885, "longitude": 0.235},
"destination": {"icao_code": "EGNX", "iata_code": "EMA",
"name": "East Midlands Airport", "municipality": "Nottingham",
"latitude": 52.8311, "longitude": -1.32806}}}}
HEXDB_AIRCRAFT = {"ModeS": "3C6444", "Registration": "D-AIBD",
"Manufacturer": "Airbus", "ICAOTypeCode": "A319",
"Type": "A319 112", "RegisteredOwners": "Lufthansa",
"OperatorFlagCode": "DLH"}
@pytest.fixture
def register(monkeypatch, tmp_path):
"""A book that answers from a table instead of the internet."""
asked: list[str] = []
answers: dict[str, object] = {}
def request(self, url):
asked.append(url)
for fragment, body in answers.items():
if fragment in url:
return body
raise OSError("not found")
monkeypatch.setattr(FlightBook, "_request", request)
book = FlightBook(cache=tmp_path / "flights.json")
return book, asked, answers
def test_a_register_answers_with_the_airframe_and_the_route(register):
book, asked, answers = register
answers["adsbdb.com/v0/aircraft"] = ADSBDB_AIRCRAFT
answers["adsbdb.com/v0/callsign"] = ADSBDB_ROUTE
entry = book.get("4CA1FA", "RYR1234")
book.wait(5.0)
assert entry.status == "found"
assert entry.registration == "EI-DYP"
assert entry.type_code == "B738"
assert entry.operator == "Ryanair"
assert entry.origin_code == "EGSS" and entry.destination_code == "EGNX"
assert "Stansted" in entry.route and "East Midlands" in entry.route
assert entry.origin_lat == pytest.approx(51.885)
def test_the_second_register_is_asked_when_the_first_has_nothing(register):
book, asked, answers = register
answers["hexdb.io/api/v1/aircraft"] = HEXDB_AIRCRAFT
entry = book.get("3C6444")
book.wait(5.0)
assert entry.status == "found"
assert entry.registration == "D-AIBD"
assert entry.manufacturer == "Airbus"
assert any("adsbdb" in url for url in asked), "the first was skipped"
def test_a_route_written_as_one_string_is_read_as_two_airports(register):
book, asked, answers = register
answers["hexdb.io/api/v1/route"] = {"flight": "BAW123",
"route": "EGLL-OTHH"}
entry = book.get("400001", "BAW123")
book.wait(5.0)
assert (entry.origin_code, entry.destination_code) == ("EGLL", "OTHH")
def test_an_aircraft_no_register_holds_is_remembered_as_unlisted(register):
book, asked, answers = register
answers["adsbdb.com/v0/aircraft"] = {"response": {"aircraft": None}}
entry = book.get("ABCDEF")
book.wait(5.0)
assert entry.status == "unlisted"
assert entry.country == "United States" # the address still says this
def test_nothing_reachable_leaves_what_the_address_said(register):
book, asked, answers = register # no answers at all
entry = book.get("4CA1FA", "RYR1234")
book.wait(5.0)
assert entry.status == "offline"
assert entry.country == "Ireland"
assert entry.airline == "Ryanair" # from the callsign, not a website
def test_offline_asks_nobody(tmp_path, monkeypatch):
def refuse(self, url):
raise AssertionError(f"asked {url} while offline")
monkeypatch.setattr(FlightBook, "_request", refuse)
book = FlightBook(online=False, cache=tmp_path / "c.json")
entry = book.get("A835AF", "UAL1902")
book.wait(1.0)
assert entry.status == "local"
assert entry.country == "United States"
assert entry.airline == "United Airlines"
def test_only_the_address_and_the_callsign_are_ever_sent(register):
"""A register is told what was heard on the air and nothing else."""
book, asked, answers = register
answers["adsbdb.com"] = ADSBDB_AIRCRAFT
book.get("4CA1FA", "RYR1234")
book.wait(5.0)
assert asked
for url in asked:
tail = url.split("://", 1)[1]
for piece in tail.split("/")[1:]:
assert piece in ("v0", "aircraft", "callsign", "api", "v1",
"route", "icao", "4CA1FA", "RYR1234"), url
def test_an_answer_is_kept_and_the_register_is_not_asked_twice(register,
tmp_path):
book, asked, answers = register
answers["adsbdb.com/v0/aircraft"] = ADSBDB_AIRCRAFT
book.get("4CA1FA")
book.wait(5.0)
book.save()
again = FlightBook(cache=tmp_path / "flights.json")
entry = again.get("4CA1FA")
assert entry.registration == "EI-DYP"
assert entry.status == "found"
def test_a_cache_from_another_version_is_ignored(tmp_path):
body = {"aircraft": {"4CA1FA": {"icao": "4CA1FA", "registration": "EI-DYP",
"status": "found", "version": 0,
"fetched_at": time.time()}}}
(tmp_path / "c.json").write_text(json.dumps(body))
book = FlightBook(online=False, cache=tmp_path / "c.json")
assert book.get("4CA1FA").registration == ""
def test_what_is_printed_says_the_aircraft_the_operator_and_the_route():
flight = Flight(icao="4CA1FA", callsign="RYR1234", registration="EI-DYP",
manufacturer="Boeing", model="737-8AS", operator="Ryanair",
origin="Stansted", destination="East Midlands")
assert flight.aircraft == "Boeing 737-8AS (EI-DYP)"
assert flight.route == "Stansted → East Midlands"
assert "Ryanair" in flight.summary()
assert "registration: EI-DYP" in flight.details()
# ---------------------------------------------------------------------------
# The log
# ---------------------------------------------------------------------------
class _Frame:
"""Just enough of a decoded frame for the log to write one down."""
def __init__(self, icao="4CA1FA", df=17, tc=11, data=b"\x8d\x4c\xa1\xfa",
callsign="", altitude_ft=0, ground_speed_kt=0.0,
track_deg=0.0, vertical_rate_fpm=0):
self.icao, self.df, self.type_code, self.data = icao, df, tc, data
self.callsign = callsign
self.altitude_ft = altitude_ft
self.ground_speed_kt = ground_speed_kt
self.track_deg = track_deg
self.vertical_rate_fpm = vertical_rate_fpm
class _Craft:
def __init__(self, lat=0.0, lon=0.0, callsign=""):
self.latitude, self.longitude, self.callsign = lat, lon, callsign
@property
def located(self):
return bool(self.latitude or self.longitude)
def _log(tmp_path, name="adsb_test.jsonl"):
return FlightLog(tmp_path / name, receiver="test", frequency=1090e6,
sample_rate=2e6, started=1_000_000.0)
def test_the_log_keeps_the_raw_frame_next_to_what_was_read_out_of_it(tmp_path):
log = _log(tmp_path)
log.append(_Frame(data=bytes.fromhex("8D4CA1FA9905A11E202C00D3450D"),
altitude_ft=35000),
_Craft(51.5, -0.12), when=1_000_001.0)
log.close()
lines = [json.loads(x) for x in
log.path.read_text().splitlines() if x.strip()]
assert lines[0]["log"] == "bandsaunter-adsb"
assert lines[1]["hex"] == "8D4CA1FA9905A11E202C00D3450D"
assert lines[1]["lat"] == 51.5 and lines[1]["alt_ft"] == 35000
def test_the_log_is_on_the_disk_before_the_session_ends(tmp_path):
"""A listening session ends with control-C, so nothing may wait for a
clean shutdown."""
log = _log(tmp_path)
log.append(_Frame(), _Craft(51.5, -0.12), when=1_000_001.0)
assert "4CA1FA" in log.path.read_text() # not closed, already written
log.close()
def test_what_was_written_reads_back_as_a_track(tmp_path):
log = _log(tmp_path)
for i in range(5):
log.append(_Frame(altitude_ft=30000 + i * 100, ground_speed_kt=420.0,
track_deg=90.0, callsign="RYR1234"),
_Craft(51.5 + i * 0.01, -0.12 + i * 0.02, "RYR1234"),
when=1_000_000.0 + i * 10)
log.close()
tracks = read_logs(log.path)
assert len(tracks) == 1
track = tracks[0]
assert track.icao == "4CA1FA" and track.callsign == "RYR1234"
assert len(track.fixes) == 5
assert track.frames == 5
assert track.distance_nm > 0
assert track.altitude_range == (30000, 30400)
def test_an_aircraft_that_never_moved_is_one_point_not_seven_thousand(tmp_path):
"""A transponder on a stand reports the same place twice a second."""
log = _log(tmp_path)
for i in range(50):
log.append(_Frame(altitude_ft=0), _Craft(51.5, -0.12),
when=1_000_000.0 + i)
log.close()
track = read_logs(log.path)[0]
assert len(track.fixes) == 1
assert track.frames == 50
def test_two_logs_read_as_one_evening(tmp_path):
first = _log(tmp_path, "a.jsonl")
first.append(_Frame(), _Craft(51.5, -0.12), when=1_000_000.0)
first.close()
second = _log(tmp_path, "b.jsonl")
second.append(_Frame(icao="3C6444"), _Craft(50.0, 8.0), when=1_000_100.0)
second.close()
tracks = read_logs([first.path, second.path])
assert [t.icao for t in tracks] == ["4CA1FA", "3C6444"]
def test_a_half_written_last_line_does_not_lose_the_rest(tmp_path):
"""Control-C during a write, or a full disk: the evening still reads."""
log = _log(tmp_path)
log.append(_Frame(), _Craft(51.5, -0.12), when=1_000_000.0)
log.close()
with log.path.open("a") as handle:
handle.write('{"t": 1000001.0, "icao": "3C64')
assert len(read_logs(log.path)) == 1
def test_a_position_fix_is_given_the_speed_the_aircraft_last_reported(tmp_path):
"""Position and velocity arrive in different frames; the aeroplane is the
same aeroplane a second later."""
log = _log(tmp_path)
log.append(_Frame(ground_speed_kt=420.0, track_deg=90.0), None,
when=1_000_000.0)
log.append(_Frame(), _Craft(51.5, -0.12), when=1_000_001.0)
log.append(_Frame(), _Craft(51.6, -0.10), when=1_000_011.0)
log.close()
track = read_logs(log.path)[0]
assert track.fixes[0].ground_speed_kt == 0.0 or track.fixes[0].track_deg
assert track.fixes[-1].ground_speed_kt == 420.0
# ---------------------------------------------------------------------------
# Time and speed: where an aircraft was between two reports
# ---------------------------------------------------------------------------
def _track(**over) -> Track:
fixes = [Fix(at=0.0, latitude=51.0, longitude=0.0, altitude_ft=10_000,
ground_speed_kt=600.0, track_deg=90.0),
Fix(at=60.0, latitude=51.0, longitude=0.2648, altitude_ft=12_000,
ground_speed_kt=600.0, track_deg=90.0)]
track = Track(icao="4CA1FA", callsign="RYR1234", fixes=fixes,
first_seen=0.0, last_seen=60.0)
for key, value in over.items():
setattr(track, key, value)
return track
def test_halfway_between_two_reports_is_halfway_along():
fix = _track().at(30.0)
assert fix.longitude == pytest.approx(0.1324, abs=1e-3)
assert fix.altitude_ft == pytest.approx(11_000, abs=20)
def test_before_the_first_report_the_aircraft_is_not_drawn():
assert _track().at(-1.0) is None
def test_after_the_last_report_it_carries_on_at_the_speed_it_said():
"""Ten knots-minutes on: dead reckoning, not a jump."""
fix = _track().at(120.0, stale=300.0)
assert fix is not None
flown = distance_nm(51.0, 0.2648, fix.latitude, fix.longitude)
assert flown == pytest.approx(10.0, rel=0.05) # 600 kt for a minute
def test_an_aircraft_not_heard_for_a_long_time_stops_being_drawn():
"""Five minutes on it has flown fifty miles and is a guess."""
assert _track().at(60.0 + 400.0, stale=300.0) is None
def test_the_trail_is_everywhere_it_had_been_by_then():
track = _track()
assert len(track.trail(30.0)) == 2 # one fix and where it is
assert track.trail(30.0)[-1].longitude == pytest.approx(0.1324, abs=1e-3)
def test_a_bearing_and_a_distance_agree_with_the_move_they_describe():
lat, lon = move(51.0, 0.0, 90.0, 60.0)
assert distance_nm(51.0, 0.0, lat, lon) == pytest.approx(60.0, rel=1e-3)
assert bearing_deg(51.0, 0.0, lat, lon) == pytest.approx(90.0, abs=0.5)
def test_a_track_across_the_date_line_does_not_go_the_long_way_round():
track = Track(fixes=[Fix(at=0.0, latitude=0.0, longitude=179.9),
Fix(at=10.0, latitude=0.0, longitude=-179.9)])
fix = track.at(5.0)
assert abs(fix.longitude) > 179.0
# ---------------------------------------------------------------------------
# The readable report, and the map for Google Earth
# ---------------------------------------------------------------------------
def test_the_report_says_who_where_and_how_far(tmp_path):
log = _log(tmp_path)
for i in range(4):
log.append(_Frame(callsign="RYR1234", altitude_ft=30_000,
ground_speed_kt=420.0, track_deg=90.0),
_Craft(51.5, -0.12 + i * 0.05, "RYR1234"),
when=1_000_000.0 + i * 30)
log.close()
lines = report(read_logs(log.path), title="test")
text = "\n".join(lines)
assert "1 aircraft" in text
assert "4CA1FA" in text and "RYR1234" in text
assert "flew:" in text and "altitude: 30,000 ft" in text
def test_the_report_keeps_the_register_apart_from_the_air(register, tmp_path):
book, asked, answers = register
answers["adsbdb.com/v0/aircraft"] = ADSBDB_AIRCRAFT
book.get("4CA1FA")
book.wait(5.0)
log = _log(tmp_path)
log.append(_Frame(), _Craft(51.5, -0.12), when=1_000_000.0)
log.close()
text = "\n".join(report(read_logs(log.path), book))
assert "registration: EI-DYP" in text
assert "heard:" in text
def test_nothing_heard_is_said_rather_than_drawn_as_an_empty_table():
assert report([]) == ["nothing heard."]
def test_google_earth_gets_the_whole_path_not_just_the_last_place(tmp_path):
log = _log(tmp_path)
for i in range(3):
log.append(_Frame(altitude_ft=10_000), _Craft(51.5 + i * 0.1, -0.12),
when=1_000_000.0 + i * 20)
log.close()
out = write_kml(tmp_path / "flights.kml", read_logs(log.path))
body = out.read_text()
assert "<LineString>" in body
assert body.count(",") > 3
assert "3048" in body or "3048" in body.replace(" ", "") # feet to metres
def test_a_log_with_no_positions_makes_no_map(tmp_path):
log = _log(tmp_path)
log.append(_Frame(), None, when=1_000_000.0)
log.close()
assert write_kml(tmp_path / "flights.kml", read_logs(log.path)) is None
# ---------------------------------------------------------------------------
# End to end, through the real decoder
# ---------------------------------------------------------------------------
def test_a_simulated_sky_is_heard_recorded_and_read_back(tmp_path):
"""The whole path: frames encoded, modulated, decoded, logged, re-read."""
from bandsaunter.adsb import AircraftRegistry, SAMPLE_RATE, decode_frames
from bandsaunter.adsb import SimulatedSky, default_sky
sky = SimulatedSky(default_sky(seed=3), noise=0.02, seed=1)
registry = AircraftRegistry()
log = _log(tmp_path)
when = 1_000_000.0
for _ in range(8):
block = sky.read_samples(int(SAMPLE_RATE))
for frame in decode_frames(block, SAMPLE_RATE):
craft = registry.add(frame, when=when + frame.at_sample / SAMPLE_RATE)
log.append(frame, craft, when=when + frame.at_sample / SAMPLE_RATE)
when += 1.0
log.close()
tracks = read_logs(log.path)
assert len(tracks) == len(default_sky())
assert all(t.callsign for t in tracks)
assert all(t.located for t in tracks)
for track in tracks:
low, high = track.altitude_range
assert 0 < high < 50_000
assert track.top_speed_kt > 50
# It flew at the speed it said it was flying, give or take the
# quarter of a knot the encoding rounds to.
flown = track.distance_nm
expected = track.top_speed_kt * track.seconds / 3600.0
assert flown == pytest.approx(expected, rel=0.35, abs=0.2)

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)):