Four things about the animated pictures, and the window kept in step with them. The aerodromes were amber, and so is an aeroplane at twelve thousand feet. Sixteen units of CIELAB apart is not two colours, it is one: an aircraft low over a field was drawn in the field's own colour and neither could be picked out from the other. They are magenta now, fifty-six units from the nearest altitude colour, which is what the ramp leaves free once red, amber, green, cyan and violet have gone on height -- and what an aeronautical chart marks an aerodrome in anyway. The test states that as the distance rather than as the colour, so that changing the ramp cannot quietly walk an aircraft back into the airports. The height beside an aircraft was the flight level, which is shorter and is what an aviator reads, but "376" is only a height to somebody who already knows it is one. It is feet with the unit on it now, rounded to the twenty-five feet Mode S reports altitude in: a real reading is a multiple of that and comes through untouched, while a moment interpolated between two reports stops claiming to know the height to the foot. The flag of the country of registration now comes off the address block where no register answered. Taking it from the register's answer alone left the flag off exactly the aircraft that had nothing else beside them either. Mexico was missing from the address table while we were in there, which a receiver in the American southwest notices; two registers independently give XA- registrations for that block. And what sort of aircraft it is, which is two facts and not one. What it is comes off the air: every identification message carries three bits under its type code saying whether it is light, large, heavy, a rotorcraft, a glider, a drone or a van on the apron, and that is the only word about what an aircraft *is* that needs no register. They were being decoded and thrown away. They are inside the identification frame the log already writes down in full, so every log this program has ever written has them, including the ones written before anything here knew to look. Whether it is military comes off no air at all -- a tanker calls itself heavy exactly as an airliner does -- and is read from the address block instead. On one evening here AE07D3 broadcast "heavy" and sat in the United States military block; the register, asked separately, came back with a C-17A Globemaster III, tail 90-0534, United States Air Force. Labels in an animation now behave as the boxes in the window do. One keeps its place for as long as that place still works and is moved only when something takes it, which on a real log is about half as many moves as deciding afresh every frame; the moves that are left are eased over half a second of playback; and what the next label is laid out against is where a moving one is going rather than where it has reached. A still has no frame before it and places its labels exactly as it always did. The fading was only half done. A label's name faded, because it is drawn in the aircraft's own colour, but its rows and its flag were fixed colours and stayed at full brightness -- so the brightest thing on that part of the picture was the one aeroplane nothing had been heard from. An indexed picture cannot blend, so the row grey and all twelve flag colours now have dimmed copies at each of the four fade steps, and the whole box goes together. A crowded frame, which drops back to the short label, drops the class and the flag with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
829 lines
33 KiB
Python
829 lines
33 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", "category_name",
|
|
"EMITTER_CATEGORIES"]
|
|
|
|
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)
|
|
|
|
# A position takes an even frame and an odd one, and the pair is only good
|
|
# for as long as the aircraft has not meaningfully moved between them. Ten
|
|
# seconds is what the standard allows; past that the two halves describe
|
|
# different places and the answer is not a position at all.
|
|
CPR_PAIR_SECONDS = 10.0
|
|
|
|
# How long a previous position stays worth checking a new one against.
|
|
CPR_TRUST_SECONDS = 300.0
|
|
|
|
# Faster than anything with a transponder on it, so that a real aircraft is
|
|
# never called an error -- Concorde cruised at 1150 kt.
|
|
MAX_GROUND_SPEED_KT = 2000.0
|
|
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)",
|
|
}
|
|
|
|
|
|
# The three bits under an identification message's type code say what sort
|
|
# of thing is transmitting. Which list they index depends on the type code
|
|
# -- the same three bits mean "heavy" under type 4 and "glider" under type 3
|
|
# -- which is why this is a table of tables and not a table.
|
|
#
|
|
# This is the only word about what an aircraft *is* that comes off the air.
|
|
# Everything else -- the model, the operator, the registration -- is a
|
|
# lookup in somebody's database against the address. There is no category
|
|
# for "military": that has to be read off the address block instead.
|
|
EMITTER_CATEGORIES = {
|
|
4: {1: "light", 2: "small", 3: "large", 4: "high vortex",
|
|
5: "heavy", 6: "high performance", 7: "rotorcraft"},
|
|
3: {1: "glider", 2: "airship", 3: "parachutist", 4: "ultralight",
|
|
6: "drone", 7: "spacecraft"},
|
|
2: {1: "emergency vehicle", 2: "service vehicle", 3: "obstacle",
|
|
4: "obstacle", 5: "obstacle"},
|
|
1: {}, # reserved, and nothing transmits it
|
|
}
|
|
|
|
|
|
def category_name(type_code: int, category: int) -> str:
|
|
"""What an identification message says the transmitter is, or "".
|
|
|
|
Zero means the aircraft declined to say, which is common and is not an
|
|
error: it is answered with nothing rather than with a guess.
|
|
"""
|
|
return EMITTER_CATEGORIES.get(type_code, {}).get(category, "")
|
|
|
|
|
|
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
|
|
category: int = 0 # the emitter category, with the type code
|
|
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)
|
|
# The three bits under the type code say what sort of thing this is
|
|
# -- heavy, rotorcraft, glider, ground vehicle. It comes off the
|
|
# air with the callsign and costs nothing to keep.
|
|
frame.category = int(me[5:8], 2)
|
|
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
|
|
category: str = "" # what it said it was: heavy, rotorcraft...
|
|
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)
|
|
_placed_at: float = field(default=0.0, 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.category and not seen.category:
|
|
seen.category = category_name(frame.type_code, frame.category)
|
|
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
|
|
self._place(seen)
|
|
return seen
|
|
|
|
def _place(self, seen: Aircraft) -> None:
|
|
"""Work out where an aircraft is, from the last even and odd frames.
|
|
|
|
The pair has to be recent, and the answer has to be reachable. Both
|
|
checks are the difference between a map and a scatter of nonsense:
|
|
an unpaired frame kept from ten minutes ago decodes against a fresh
|
|
one to a position on the wrong side of the world, because compact
|
|
position reporting sends a fraction of a zone and the two fractions
|
|
are then read as though the aircraft had not moved between them.
|
|
Measured against one night's recording, that produced positions up
|
|
to seven thousand miles out, on two aircraft in three.
|
|
"""
|
|
even, odd = seen._even, seen._odd
|
|
if even is None or odd is None:
|
|
return
|
|
if abs(even.received_at - odd.received_at) > CPR_PAIR_SECONDS:
|
|
return
|
|
# 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 = (even.received_at, even.at_sample) > \
|
|
(odd.received_at, odd.at_sample)
|
|
found = global_position(even, odd, even_first)
|
|
if found is None or not _on_earth(*found):
|
|
return
|
|
when = max(even.received_at, odd.received_at)
|
|
if not seen.located or self._reachable(seen, found, when):
|
|
seen.latitude, seen.longitude = found
|
|
seen._placed_at = when
|
|
|
|
@staticmethod
|
|
def _reachable(seen: Aircraft, found: tuple[float, float],
|
|
when: float) -> bool:
|
|
"""Whether an aircraft could have got there from where it was.
|
|
|
|
A position that would need eight hundred knots in the second since
|
|
the last one is not a position, whatever the checksum said about the
|
|
frames it came from. The bar is set well above anything that flies
|
|
so that a genuinely fast aircraft, or a gap in reception, is never
|
|
mistaken for an error.
|
|
"""
|
|
gap = when - seen._placed_at
|
|
if gap <= 0 or gap > CPR_TRUST_SECONDS:
|
|
return True # too long ago to argue with
|
|
miles = _distance_nm(seen.latitude, seen.longitude, *found)
|
|
return miles <= MAX_GROUND_SPEED_KT * gap / 3600.0
|
|
|
|
|
|
def _on_earth(lat: float, lon: float) -> bool:
|
|
"""Whether a decoded position is a place at all."""
|
|
return -90.0 <= lat <= 90.0 and -180.0 <= lon <= 180.0
|
|
|
|
|
|
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 * 3440.065 * math.asin(min(1.0, math.sqrt(a)))
|
|
|
|
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))
|