"""Generate Mode S extended squitter frames, so the ADS-B decoder can be tested. Built from the standard rather than from the decoder: the parity is computed here with the same polynomial but the frames are assembled independently, and a decoder that reads back what was put in has read the format rather than agreed with itself. """ import math import numpy as np from bandsaunter.adsb import CALLSIGN_CHARS, PREAMBLE_US, crc24 def with_parity(payload: bytes) -> bytes: """A frame with its 24 parity bits appended, as a transmitter sends it.""" return payload + crc24(payload).to_bytes(3, "big") def identification(icao: int, callsign: str, category: int = 0) -> bytes: """A DF17 type-4 frame: the aircraft saying what it is called.""" me = bytearray(7) me[0] = (4 << 3) | (category & 0x07) text = callsign.upper().ljust(8)[:8] bits = "" for ch in text: index = CALLSIGN_CHARS.find(ch) bits += format(index if index >= 0 else 32, "06b") packed = int(bits, 2).to_bytes(6, "big") me[1:7] = packed return with_parity(bytes([17 << 3 | 5]) + icao.to_bytes(3, "big") + bytes(me)) def _cpr(lat: float, lon: float, odd: bool) -> tuple[int, int]: """Compact position reporting, the encoding side.""" def nl(latitude): if abs(latitude) >= 87.0: return 1 if latitude == 0: return 59 inner = 1 - (1 - math.cos(math.pi / 30)) / \ math.cos(math.radians(abs(latitude))) ** 2 return int(math.floor(2 * math.pi / math.acos(max(-1.0, min(1.0, inner))))) i = 1 if odd else 0 d_lat = 360.0 / (60 - i) j = math.floor(lat / d_lat) + math.floor( 0.5 + (lat % d_lat) / d_lat) 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))) del j return y & 0x1FFFF, x & 0x1FFFF def airborne_position(icao: int, lat: float, lon: float, altitude_ft: int, odd: bool) -> bytes: """A DF17 type-11 frame: where the aircraft is and how high.""" encoded = int(round((altitude_ft + 1000) / 25.0)) field = format(encoded, "011b") alt = field[:7] + "1" + field[7:] # the Q bit, 25-foot steps y, x = _cpr(lat, lon, odd) me_bits = (format(11, "05b") + "000" + alt + "0" + ("1" if odd else "0") + format(y, "017b") + format(x, "017b")) me = int(me_bits, 2).to_bytes(7, "big") return with_parity(bytes([17 << 3 | 5]) + icao.to_bytes(3, "big") + me) def velocity(icao: int, east_kt: int, north_kt: int, vertical_fpm: int = 0) -> bytes: """A DF17 type-19 frame: ground speed and climb rate.""" ew_sign = "1" if east_kt < 0 else "0" ns_sign = "1" if north_kt < 0 else "0" ew = min(1023, abs(east_kt) + 1) ns = min(1023, abs(north_kt) + 1) rate = min(511, abs(vertical_fpm) // 64 + 1) if vertical_fpm else 0 me_bits = (format(19, "05b") + "001" + "00000" + ew_sign + format(ew, "010b") + ns_sign + format(ns, "010b") + "0" + ("1" if vertical_fpm < 0 else "0") + format(rate, "09b") + "0" * 10) me = int(me_bits[:56].ljust(56, "0"), 2).to_bytes(7, "big") return with_parity(bytes([17 << 3 | 5]) + icao.to_bytes(3, "big") + me) def modulate(frames, sample_rate: float = 2_000_000.0, gap_us: float = 60.0, amplitude: float = 1.0, noise: float = 0.0, seed: int = 0) -> np.ndarray: """Turn frames into the magnitude a receiver would see at 1090 MHz. Pulse-position: a preamble of four pulses, then one microsecond per bit with the energy in the first half for a one and the second half for a zero. """ rng = np.random.default_rng(seed) per_us = sample_rate / 1e6 out = [np.zeros(int(round(gap_us * per_us)))] for frame in frames: bits = "".join(format(b, "08b") for b in frame) span = np.zeros(int(round((8 + len(bits)) * per_us))) for at in PREAMBLE_US: lo = int(round(at * per_us)) span[lo:lo + int(round(0.5 * per_us))] = 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 + int(round(0.5 * per_us))] = amplitude out.append(span) out.append(np.zeros(int(round(gap_us * per_us)))) signal = np.concatenate(out) if noise: signal = signal + noise * np.abs( rng.standard_normal(signal.size) + 1j * rng.standard_normal(signal.size)) / math.sqrt(2) return signal.astype(np.complex64)