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
723 lines
28 KiB
Python
723 lines
28 KiB
Python
"""ADS-B: aircraft on 1090 MHz saying where they are.
|
|
|
|
Every airliner overhead broadcasts its identity, position, altitude and speed
|
|
twice a second, unencrypted, to nobody in particular. The format is Mode S
|
|
extended squitter and it is the easiest useful thing a receiver can decode,
|
|
because every frame carries a 24-bit checksum that either comes out right or
|
|
does not -- there is no judgement anywhere in this module about whether a
|
|
decode is believable.
|
|
|
|
The signalling is pulse-position modulation at a megabit a second. Each bit
|
|
is one microsecond wide and split in half: energy in the first half is a one,
|
|
energy in the second half is a zero. A frame opens with a preamble of four
|
|
pulses at 0, 1, 3.5 and 4.5 microseconds, which is what is searched for.
|
|
|
|
That rate is why this does not ride on the ordinary scan path. A megabit a
|
|
second needs at least two megasamples a second of raw receiver output, and
|
|
the scanner's channels are twelve and a half kilohertz wide; ``bandsaunter
|
|
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", "encode_identification",
|
|
"encode_position", "encode_velocity", "modulate", "SimulatedSky",
|
|
"VirtualAircraft", "default_sky"]
|
|
|
|
ADSB_HZ = 1_090_000_000.0
|
|
|
|
# The lowest rate this can work at: one megabit a second, sampled twice a bit.
|
|
SAMPLE_RATE = 2_000_000
|
|
|
|
PREAMBLE_US = (0.0, 1.0, 3.5, 4.5)
|
|
SHORT_BITS = 56
|
|
LONG_BITS = 112
|
|
|
|
# The characters a callsign can be built from, six bits each. The hashes are
|
|
# the code points the standard leaves unassigned.
|
|
CALLSIGN_CHARS = ("#ABCDEFGHIJKLMNOPQRSTUVWXYZ#####_###############"
|
|
"0123456789######")
|
|
|
|
TYPE_NAMES = {
|
|
(1, 4): "identification",
|
|
(5, 8): "surface position",
|
|
(9, 18): "airborne position",
|
|
(19, 19): "velocity",
|
|
(20, 22): "airborne position (GNSS height)",
|
|
}
|
|
|
|
|
|
def crc24(data: bytes) -> int:
|
|
"""The Mode S parity, polynomial 0xFFF409.
|
|
|
|
Run over the whole frame including its three parity bytes, a good frame
|
|
gives zero. That is the entire error check in this module and it is
|
|
enough: twenty-four bits of it means a frame passes by chance one time in
|
|
sixteen million.
|
|
"""
|
|
poly = 0xFFF409
|
|
crc = 0
|
|
for byte in data:
|
|
crc ^= byte << 16
|
|
for _ in range(8):
|
|
crc = (((crc << 1) ^ poly) & 0xFFFFFF if crc & 0x800000
|
|
else (crc << 1) & 0xFFFFFF)
|
|
return crc
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# One frame
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@dataclass
|
|
class Frame:
|
|
"""One Mode S frame that passed its checksum."""
|
|
|
|
bits: str = ""
|
|
data: bytes = b""
|
|
df: int = 0 # downlink format
|
|
icao: str = "" # the aircraft's permanent 24-bit address
|
|
type_code: int = 0
|
|
at_sample: int = 0
|
|
callsign: str = ""
|
|
altitude_ft: int = 0
|
|
latitude: float = 0.0
|
|
longitude: float = 0.0
|
|
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
|
|
|
|
@property
|
|
def what(self) -> str:
|
|
for (low, high), name in TYPE_NAMES.items():
|
|
if low <= self.type_code <= high:
|
|
return name
|
|
return f"type {self.type_code}"
|
|
|
|
def describe(self) -> str:
|
|
bits = [self.icao]
|
|
if self.callsign:
|
|
bits.append(self.callsign)
|
|
if self.altitude_ft:
|
|
bits.append(f"{self.altitude_ft} ft")
|
|
if self.latitude or self.longitude:
|
|
bits.append(f"{self.latitude:.4f},{self.longitude:.4f}")
|
|
if self.ground_speed_kt:
|
|
bits.append(f"{self.ground_speed_kt:.0f} kt")
|
|
bits.append(f"{self.track_deg:.0f}°")
|
|
if self.vertical_rate_fpm:
|
|
bits.append(f"{self.vertical_rate_fpm:+d} fpm")
|
|
if len(bits) == 1:
|
|
bits.append(self.what)
|
|
return " ".join(bits)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Finding frames in the samples
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _magnitude(iq: np.ndarray) -> np.ndarray:
|
|
if np.iscomplexobj(iq):
|
|
return np.abs(iq).astype(np.float32)
|
|
return np.abs(np.asarray(iq, dtype=np.float32))
|
|
|
|
|
|
def _preamble_score(mag: np.ndarray, per_us: float) -> np.ndarray:
|
|
"""How much each sample looks like the start of a preamble.
|
|
|
|
The four pulses minus the four gaps that have to be quiet between them.
|
|
A correlation with the pattern alone finds a steady carrier just as
|
|
happily; requiring the gaps is what makes it a preamble.
|
|
"""
|
|
pulses = [int(round(us * per_us)) for us in PREAMBLE_US]
|
|
quiet = [int(round(us * per_us)) for us in (2.0, 2.5, 3.0, 6.0, 6.5, 7.0)]
|
|
width = max(1, int(round(0.5 * per_us)))
|
|
need = int(round(8.0 * per_us))
|
|
if mag.size < need + width:
|
|
return np.zeros(0, dtype=np.float32)
|
|
|
|
def at(offsets):
|
|
total = np.zeros(mag.size - need - width, dtype=np.float32)
|
|
for offset in offsets:
|
|
total += mag[offset:offset + total.size]
|
|
return total / len(offsets)
|
|
|
|
return at(pulses) - at(quiet)
|
|
|
|
|
|
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
|
|
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
|
|
# aircraft's address exclusive-ored into it, so a decoder without a list of
|
|
# the aircraft it expects to hear cannot check them at all.
|
|
CHECKABLE_FORMATS = frozenset({11, 17, 18})
|
|
|
|
|
|
def _plausible(data: bytes) -> bool:
|
|
"""Whether a candidate is a real frame rather than a run of silence.
|
|
|
|
The checksum does the work, with one exception that matters: a frame of
|
|
all zeros passes it, because zero divided by anything leaves nothing.
|
|
Silence between transmissions is exactly that, so it would otherwise
|
|
decode as an endless stream of aircraft 000000.
|
|
"""
|
|
if not any(data):
|
|
return False
|
|
if (data[0] >> 3) not in CHECKABLE_FORMATS:
|
|
return False
|
|
return crc24(data) == 0
|
|
|
|
|
|
def _bytes_of(bits: str) -> bytes:
|
|
whole = len(bits) - len(bits) % 8
|
|
return bytes(int(bits[i:i + 8], 2) for i in range(0, whole, 8))
|
|
|
|
|
|
def decode_frames(iq: np.ndarray, sample_rate: float) -> list[Frame]:
|
|
"""Every Mode S frame in a block of raw receiver output.
|
|
|
|
Only frames whose checksum comes out right are returned, so there is no
|
|
threshold to tune and nothing to disbelieve.
|
|
"""
|
|
if sample_rate < SAMPLE_RATE * 0.99:
|
|
return []
|
|
mag = _magnitude(iq)
|
|
per_us = sample_rate / 1e6
|
|
score = _preamble_score(mag, per_us)
|
|
if score.size == 0:
|
|
return []
|
|
# A preamble stands well above the noise around it. This is only a
|
|
# shortlist -- the checksum decides -- so it is set low enough to let
|
|
# 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] = []
|
|
# 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 start - taken < per_us:
|
|
continue
|
|
for count in (LONG_BITS, SHORT_BITS):
|
|
bits = _bits_at(running, int(start), per_us, count)
|
|
if len(bits) < count:
|
|
continue
|
|
data = _bytes_of(bits)
|
|
if len(data) * 8 != count or not _plausible(data):
|
|
continue
|
|
frame = _read(bits, data)
|
|
frame.at_sample = int(start)
|
|
frames.append(frame)
|
|
taken = int(start)
|
|
break
|
|
return frames
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# What a frame says
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _read(bits: str, data: bytes) -> Frame:
|
|
frame = Frame(bits=bits, data=data, df=data[0] >> 3)
|
|
frame.icao = f"{int.from_bytes(data[1:4], 'big'):06X}"
|
|
if frame.df not in (17, 18) or len(data) < 11:
|
|
return frame
|
|
me = bits[32:88]
|
|
frame.type_code = int(me[:5], 2)
|
|
if 1 <= frame.type_code <= 4:
|
|
frame.callsign = _callsign(me)
|
|
elif 9 <= frame.type_code <= 18 or 20 <= frame.type_code <= 22:
|
|
frame.altitude_ft = _altitude(me)
|
|
frame.cpr_odd = me[21] == "1"
|
|
frame.cpr_lat = int(me[22:39], 2)
|
|
frame.cpr_lon = int(me[39:56], 2)
|
|
elif frame.type_code == 19:
|
|
_velocity(frame, me)
|
|
return frame
|
|
|
|
|
|
def _callsign(me: str) -> str:
|
|
chars = [CALLSIGN_CHARS[int(me[8 + 6 * i:14 + 6 * i], 2)] for i in range(8)]
|
|
return "".join(chars).replace("#", "").strip("_ ").strip()
|
|
|
|
|
|
def _altitude(me: str) -> int:
|
|
"""The 12-bit altitude field, in feet.
|
|
|
|
The Q bit says which of two encodings is in use: 25-foot steps, which is
|
|
everything in normal service, or the older 100-foot Gillham code, which
|
|
is not decoded here and comes back as zero rather than as a wrong number.
|
|
"""
|
|
field = me[8:20]
|
|
if field == "0" * 12:
|
|
return 0
|
|
q_bit = field[7]
|
|
if q_bit != "1":
|
|
return 0
|
|
value = int(field[:7] + field[8:], 2)
|
|
return value * 25 - 1000
|
|
|
|
|
|
def _velocity(frame: Frame, me: str) -> None:
|
|
subtype = int(me[5:8], 2)
|
|
if subtype not in (1, 2):
|
|
return # airspeed rather than ground speed
|
|
sign_ew = -1 if me[13] == "1" else 1
|
|
ew = int(me[14:24], 2) - 1
|
|
sign_ns = -1 if me[24] == "1" else 1
|
|
ns = int(me[25:35], 2) - 1
|
|
if ew < 0 or ns < 0:
|
|
return
|
|
scale = 4.0 if subtype == 2 else 1.0 # supersonic
|
|
vx, vy = sign_ew * ew * scale, sign_ns * ns * scale
|
|
frame.ground_speed_kt = math.hypot(vx, vy)
|
|
frame.track_deg = math.degrees(math.atan2(vx, vy)) % 360.0
|
|
rate = int(me[37:46], 2)
|
|
if rate:
|
|
frame.vertical_rate_fpm = (rate - 1) * 64 * (-1 if me[36] == "1" else 1)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Position, which takes two frames
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _nl(lat: float) -> int:
|
|
"""How many longitude zones there are at this latitude."""
|
|
if abs(lat) >= 87.0:
|
|
return 1
|
|
if lat == 0:
|
|
return 59
|
|
inner = 1 - (1 - math.cos(math.pi / (2 * 15))) / \
|
|
math.cos(math.radians(abs(lat))) ** 2
|
|
inner = max(-1.0, min(1.0, inner))
|
|
return int(math.floor(2 * math.pi / math.acos(inner)))
|
|
|
|
|
|
def global_position(even: Frame, odd: Frame,
|
|
even_first: bool = True) -> tuple[float, float] | None:
|
|
"""Where an aircraft is, from one even and one odd position frame.
|
|
|
|
Compact position reporting sends a latitude and longitude as fractions of
|
|
a zone, with the zones laid out differently in the two frame types. One
|
|
frame alone is ambiguous by hundreds of miles; the pair is not.
|
|
"""
|
|
lat_even, lon_even = even.cpr_lat / 131072.0, even.cpr_lon / 131072.0
|
|
lat_odd, lon_odd = odd.cpr_lat / 131072.0, odd.cpr_lon / 131072.0
|
|
j = math.floor(59 * lat_even - 60 * lat_odd + 0.5)
|
|
rlat_even = (360.0 / 60) * ((j % 60) + lat_even)
|
|
rlat_odd = (360.0 / 59) * ((j % 59) + lat_odd)
|
|
if rlat_even >= 270:
|
|
rlat_even -= 360
|
|
if rlat_odd >= 270:
|
|
rlat_odd -= 360
|
|
if _nl(rlat_even) != _nl(rlat_odd):
|
|
return None # the two straddle a zone boundary
|
|
nl = _nl(rlat_even)
|
|
if even_first:
|
|
lat = rlat_even
|
|
ni = max(nl, 1)
|
|
m = math.floor(lon_even * (nl - 1) - lon_odd * nl + 0.5)
|
|
lon = (360.0 / ni) * ((m % ni) + lon_even)
|
|
else:
|
|
lat = rlat_odd
|
|
ni = max(nl - 1, 1)
|
|
m = math.floor(lon_even * (nl - 1) - lon_odd * nl + 0.5)
|
|
lon = (360.0 / ni) * ((m % ni) + lon_odd)
|
|
if lon >= 180:
|
|
lon -= 360
|
|
return lat, lon
|
|
|
|
|
|
@dataclass
|
|
class Aircraft:
|
|
"""What has been heard from one aircraft."""
|
|
|
|
icao: str
|
|
callsign: str = ""
|
|
altitude_ft: int = 0
|
|
latitude: float = 0.0
|
|
longitude: float = 0.0
|
|
ground_speed_kt: float = 0.0
|
|
track_deg: float = 0.0
|
|
vertical_rate_fpm: int = 0
|
|
messages: int = 0
|
|
first_seen: float = 0.0
|
|
last_seen: float = 0.0
|
|
_even: Frame | None = field(default=None, repr=False)
|
|
_odd: Frame | None = field(default=None, repr=False)
|
|
|
|
@property
|
|
def located(self) -> bool:
|
|
return bool(self.latitude or self.longitude)
|
|
|
|
def describe(self) -> str:
|
|
bits = [self.icao]
|
|
if self.callsign:
|
|
bits.append(self.callsign)
|
|
if self.located:
|
|
bits.append(f"{self.latitude:.4f},{self.longitude:.4f}")
|
|
if self.altitude_ft:
|
|
bits.append(f"{self.altitude_ft} ft")
|
|
if self.ground_speed_kt:
|
|
bits.append(f"{self.ground_speed_kt:.0f} kt {self.track_deg:.0f}°")
|
|
return " ".join(bits)
|
|
|
|
|
|
class AircraftRegistry:
|
|
"""Everything heard, gathered by aircraft rather than by frame.
|
|
|
|
Position needs an even frame and an odd one, which arrive half a second
|
|
apart, so something has to remember the first while the second is on its
|
|
way. This is that, and it is also what turns four hundred frames into
|
|
the dozen aircraft they came from.
|
|
"""
|
|
|
|
def __init__(self):
|
|
self.aircraft: dict[str, Aircraft] = {}
|
|
|
|
def __len__(self) -> int:
|
|
return len(self.aircraft)
|
|
|
|
def add(self, frame: Frame, when: float = 0.0) -> Aircraft:
|
|
seen = self.aircraft.get(frame.icao)
|
|
if seen is None:
|
|
seen = Aircraft(icao=frame.icao, first_seen=when)
|
|
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:
|
|
seen.altitude_ft = frame.altitude_ft
|
|
if frame.ground_speed_kt:
|
|
seen.ground_speed_kt = frame.ground_speed_kt
|
|
seen.track_deg = frame.track_deg
|
|
if frame.vertical_rate_fpm:
|
|
seen.vertical_rate_fpm = frame.vertical_rate_fpm
|
|
if frame.cpr_lat or frame.cpr_lon:
|
|
if frame.cpr_odd:
|
|
seen._odd = frame
|
|
else:
|
|
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. 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
|
|
return seen
|
|
|
|
def described(self) -> list[str]:
|
|
return [craft.describe() for craft in
|
|
sorted(self.aircraft.values(), key=lambda a: a.icao)]
|
|
|
|
|
|
def decode_adsb(iq: np.ndarray, sample_rate: float,
|
|
registry: AircraftRegistry | None = None
|
|
) -> tuple[list[Frame], AircraftRegistry]:
|
|
"""Decode every frame in a block, and fold them into aircraft."""
|
|
registry = registry if registry is not None else AircraftRegistry()
|
|
frames = decode_frames(iq, sample_rate)
|
|
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))
|