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:
parent
a8a8548369
commit
eae60cb04d
15 changed files with 3857 additions and 177 deletions
84
README.md
84
README.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
551
bandsaunter/flightlog.py
Normal 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
789
bandsaunter/flightmap.py
Normal 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
671
bandsaunter/flights.py
Normal 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 "")
|
||||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
400
tests/test_flightmap.py
Normal 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
488
tests/test_flights.py
Normal 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)
|
||||
|
|
@ -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)):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue