Frame the map on the receiver, and throw out what never happened

A first real capture came back as a map spanning 240 degrees north to 20
south, with the aircraft an indistinguishable smudge in one corner.  Two
separate faults, one of them mine from the start.

A position is sent as half a position -- an even frame and an odd one --
and the pair only means anything while the aircraft has not moved between
them.  The registry kept the last of each forever and paired them
regardless of age, so an even frame from ten minutes ago decoded against
a fresh odd one to a place on the wrong side of the world.  Measured: a
pair 300 seconds apart puts the aircraft 2,566 nm from where it is, and
one night's log had it happening to two aircraft in three, with eleven
positions off the planet altogether.  A pair is now good for ten seconds,
the answer has to be on Earth, and the aircraft has to have been able to
reach it.

--radius, defaulting to a hundred, frames the picture on the receiver
rather than on whatever was heard, so the scale is the same from one
evening to the next.  In the same unit as the speeds.  The centre is the
median of everything heard, which a handful of wrong positions cannot
move, or --at LAT,LON says where the aerial is.

--recheck repairs a log recorded before all this: for each aircraft it
keeps the longest run of positions that could describe one aeroplane.
Not a forward walk dropping whatever disagrees with the last position
kept -- that lets one bad fix become the reference, and on the same log
it discarded a fifth of everything, most of it the truth.

Two calibrations came from the recording rather than from taste.  The
failures separate cleanly -- a hundred artefacts under a mile, twelve
hundred real errors over fifty, and nothing in between -- because
positions are stamped to the millisecond, so two a thousandth of a second
apart imply thousands of knots across a few yards.  Nothing under two
miles is called an error.  Afterwards the worst surviving jump is 513 kt.

One rendering bug the tighter frame exposed: an aircraft just off the top
left painted 743,774 pixels of an 844,200-pixel picture, because the dot
at a marker's centre clamped its near edge and left the far one alone, and
numpy reads a negative slice end as counting back from the far side.  And
a still of a whole evening was dead-reckoning every aircraft forward to
the final moment, which for a ten-hour log flew 291 of 335 clean off the
picture; a still now draws each where it was last actually heard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
This commit is contained in:
The Dust Council 2026-09-04 12:25:39 -07:00
parent 96fc21ac7d
commit e50d43d6e2
13 changed files with 999 additions and 36 deletions

View file

@ -1105,6 +1105,61 @@ 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.
### How far the map reaches
```bash
bandsaunter flights --radius 100 # the default: a hundred miles round
bandsaunter flights --radius 0 # fit whatever turned up, warts and all
bandsaunter flights --at 32.54,-111.17 # say where the receiver is
```
**The map is framed on the receiver, not on whatever was heard.** An aerial
reaches a hundred miles on a good day, and a position that decoded wrongly can
land anywhere on Earth — so a map drawn to fit everything is drawn to fit the
mistakes, and the aircraft come out a pixel wide in the middle of an empty
continent. One real night's recording spanned 240°N to 20°S before this.
`--radius` is in the same unit as the speeds, so it is nautical miles with
knots and statute miles with mph. Left alone, the centre is the *median* of
everything heard — a receiver hears aircraft all round it, and a median cannot
be dragged anywhere by a handful of bad positions — or `--at LAT,LON` fixes it,
which is worth doing if you want the same frame every night. Fixes outside the
radius are dropped from the drawing, one fix at a time rather than one aircraft
at a time, so a single bad position in the middle of a real flight does not
take the whole flight off the map with it. Nothing is dropped from the log.
### Positions that never happened
```bash
bandsaunter flights --recheck # throw out the impossible ones
```
A position is sent as *half* a position — an even frame and an odd one — and
the pair only means anything while the aircraft has not moved between them.
Logs written before this version paired them however old they were, so an even
frame kept from ten minutes ago decoded against a fresh odd one to a place on
the wrong side of the world, written down as confidently as a real position.
On one night's recording that was **two aircraft in three**, with positions out
to 7,378 nautical miles and one latitude of 239°.
`--recheck` reads a log back and keeps, for each aircraft, the longest run of
positions that could describe one aeroplane. It is deliberately not a forward
walk that drops whatever disagrees with the last position kept: one bad fix
then becomes the reference, and it is the truth that gets thrown away — on the
same recording that discarded a fifth of everything, most of it real. Nothing
is changed in the log; the frames stay exactly as they arrived.
Two things it will not do, on purpose. An aircraft that goes quiet for five
minutes and is heard again a long way off is an aeroplane, not an error, and
nothing after that gap is second-guessed — the radius is what keeps those off
the picture. And where two positions contradict each other and nothing else
has an opinion, one of them is wrong and there is no saying which, so the
later one goes.
**New logs need none of this**: the decoder now refuses a pair more than ten
seconds apart, refuses a position that is not on Earth, and refuses one the
aircraft could not have reached, as the frames arrive.
### The ground under it
**There is a real map under the aircraft.** A flight path over a black

View file

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

View file

@ -38,6 +38,19 @@ ADSB_HZ = 1_090_000_000.0
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
@ -383,6 +396,7 @@ class Aircraft:
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:
@ -438,19 +452,73 @@ class AircraftRegistry:
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
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)]

View file

@ -25,6 +25,7 @@ from pathlib import Path
import yaml
from .adsb import ADSB_HZ, SAMPLE_RATE
from .flightlog import read_position
from .settings import Setting, format_value
__all__ = ["AircraftOptions", "OPTIONS", "listen", "draw", "logs_in",
@ -108,6 +109,9 @@ class AircraftOptions:
trail: float = 0.0
stale: float = 300.0
labels: bool = True
radius: float = 100.0
location: str = ""
recheck: bool = False
basemap: bool = True
tile_url: str = ""
@ -268,6 +272,43 @@ OPTIONS: tuple[Setting, ...] = (
"heading it last gave. After a few minutes of that it has flown fifty "
"miles on a guess, so it is dropped instead of invented.",
unit="s", minimum=1.0, flags=("--stale",), example="300"),
O("radius", "Map radius", "Drawing", "float",
"how far around the receiver the map reaches (0 = fit whatever was heard)",
"An aerial hears a hundred miles on a good day, and a position that "
"decoded wrongly can land anywhere on Earth. A map drawn to fit "
"everything heard is therefore drawn to fit the mistakes: the aircraft "
"come out a pixel wide in the middle of an empty continent. This frames "
"the picture on the receiver instead, so the scale stays the same from "
"one evening to the next and anything further out is left off the edge. "
"In the same unit as the speeds -- nautical miles with knots, statute "
"miles with mph, kilometres with kph.",
minimum=0.0, flags=("--radius",), example="100",
guidance="Set it to what your aerial can really hear. Zero goes back "
"to fitting whatever turned up, mistakes and all."),
O("location", "Receiver at", "Drawing", "text",
"where the receiver is, as latitude,longitude (blank = work it out)",
"The centre of the map. Left blank it is taken from the middle of "
"everything heard, which is very close to right: a receiver hears "
"aircraft all around it, and the middle is a median rather than an "
"average, so a handful of wrong positions cannot drag it anywhere. "
"Setting it explicitly is worth doing if you want the same frame every "
"night regardless of which way the traffic went.",
example="32.54,-111.17", metavar="LAT,LON"),
O("recheck", "Check the positions", "Drawing", "bool",
"throw out positions the aircraft could not have been in",
"For logs recorded before the decoder checked how old the two halves "
"of a position were. A compact-position report is half a position: an "
"even frame and an odd one, and the pair only means anything while the "
"aircraft has not moved between them. An even frame kept from ten "
"minutes ago decodes against a fresh odd one to a place on the wrong "
"side of the world, and that gets written down as confidently as a "
"real position. This reads the log back and keeps, for each aircraft, "
"the longest run of positions that could describe one aeroplane. "
"Nothing is changed in the log itself.",
flags=("--recheck",),
guidance="Worth turning on for anything recorded before this version. "
"Newer logs have the check applied as they are written, so it "
"finds almost nothing."),
O("basemap", "Map underneath", "Drawing", "bool",
"draw a real map under the flight paths",
"A flight path over a black rectangle says how the aircraft moved and "
@ -590,6 +631,35 @@ def _picture_path(log_path, options: AircraftOptions, output_dir: str) -> Path:
# Drawing
# ---------------------------------------------------------------------------
def checked(console, options: AircraftOptions, tracks):
"""Throw out impossible positions, if asked, and say how many went."""
if not options.recheck:
return tracks
from .flightlog import recheck as recheck_tracks
before = sum(len(t.fixes) for t in tracks)
tracks, dropped = recheck_tracks(tracks)
if dropped:
console.print(f"[yellow]dropped {dropped:,} of {before:,} positions "
f"({100.0 * dropped / max(1, before):.1f}%) that no "
"aircraft could have been in[/yellow]")
else:
console.print("[grey62]every position checks out[/grey62]")
return tracks
def radius_in_nm(options: AircraftOptions) -> float:
"""The map radius as the drawing wants it, in nautical miles.
The option is in whatever the speeds are in, because a picture that
measured its speeds in one unit and its own extent in another would be
a puzzle rather than a map.
"""
from .flightlog import speed_unit
return max(0.0, float(options.radius)) / speed_unit(options.speed_unit)[3]
def draw(console, options: AircraftOptions, tracks, out_path, book=None):
"""Draw the map, saying what it is drawing and what came out of it."""
from .flightmap import animate, ffmpeg_available
@ -607,7 +677,9 @@ def draw(console, options: AircraftOptions, tracks, out_path, book=None):
width=options.width, trail_seconds=options.trail,
stale=options.stale, labels=options.labels,
unit=options.speed_unit, ground=options.basemap,
tile_url=options.tile_url)
tile_url=options.tile_url,
radius_nm=radius_in_nm(options),
centre=read_position(options.location))
except (OSError, RuntimeError, ValueError) as exc:
console.print(f"[red]{exc}[/red]")
return None
@ -631,6 +703,7 @@ def draw_log(console, options: AircraftOptions, paths, out_path=None):
if not tracks:
console.print(f"[yellow]{paths[0].name} holds no frames[/yellow]")
return None
tracks = checked(console, options, tracks)
book = FlightBook(online=options.lookup)
if options.lookup:
for track in tracks:

View file

@ -231,6 +231,16 @@ examples:
help="draw the tracks on their own, with no map under them")
fl.add_argument("--tiles", default=None, metavar="URL",
help="where map tiles come from ({z}/{x}/{y}.png)")
fl.add_argument("--radius", type=float, default=None, metavar="MILES",
help="how far around the receiver the map reaches, in the "
"same unit as the speeds (0 = fit what was heard)")
fl.add_argument("--at", default=None, metavar="LAT,LON",
help="where the receiver is (default: worked out from "
"what it heard)")
fl.add_argument("--recheck", action="store_true",
help="throw out positions the aircraft could not have "
"been in, for logs recorded before the decoder "
"checked the age of a position pair")
fl.set_defaults(labels=True, draw=True, lookup=True)
# -- analyse ------------------------------------------------------------
@ -1108,6 +1118,12 @@ def cmd_flights(args) -> int:
if not tracks:
console.print(f"[yellow]{paths[0].name} holds no frames[/yellow]")
return 1
options = air.load_options()
if args.speed_unit:
options.speed_unit = args.speed_unit
if args.recheck:
options.recheck = True
tracks = air.checked(console, options, tracks)
book = FlightBook(online=args.lookup)
if args.lookup:
for track in tracks:
@ -1115,9 +1131,6 @@ def cmd_flights(args) -> int:
book.wait(20.0)
book.save()
options = air.load_options()
if args.speed_unit:
options.speed_unit = args.speed_unit
title = f"bandsaunter — {paths[0].name}"
lines = report(tracks, book if args.lookup else None, title=title,
unit=options.speed_unit)
@ -1151,6 +1164,10 @@ def cmd_flights(args) -> int:
options.basemap = args.basemap
if args.tiles:
options.tile_url = args.tiles
if args.radius is not None:
options.radius = args.radius
if args.at:
options.location = args.at
out = Path(args.out).expanduser() if args.out else \
paths[0].with_suffix("." + options.picture)
drawn = air.draw(console, options, tracks, out,

View file

@ -21,14 +21,17 @@ from __future__ import annotations
import json
import math
import time
from dataclasses import dataclass, field
from statistics import median
from dataclasses import dataclass, field, replace
from datetime import datetime
from pathlib import Path
__all__ = ["Fix", "Track", "FlightLog", "read_logs", "report",
"write_kml", "LOG_VERSION", "EARTH_NM", "SPEED_UNITS",
"speed_label", "in_speed", "distance_label", "in_distance",
"DEFAULT_SPEED_UNIT"]
"DEFAULT_SPEED_UNIT", "centre_of", "read_position",
"box_around", "within", "recheck", "implied_speed_kt",
"MAX_GROUND_SPEED_KT"]
LOG_VERSION = 1
@ -139,6 +142,175 @@ def move(lat: float, lon: float, bearing: float, nm: float) -> tuple[float, floa
return math.degrees(p2), (math.degrees(l2) + 540) % 360 - 180
def centre_of(tracks) -> tuple[float, float] | None:
"""Where the receiver most likely is, from everything it heard.
The median rather than the mean, because a handful of wrong positions
would drag an average halfway across a continent and cannot move a
median at all. A receiver hears aircraft all around it, so the middle
of what it heard is very close to where it is standing.
"""
lats = [f.latitude for t in tracks for f in t.fixes]
lons = [f.longitude for t in tracks for f in t.fixes]
if not lats:
return None
return median(lats), median(lons)
def read_position(text: str) -> tuple[float, float] | None:
"""A "lat,lon" pair as two numbers, or None if it is not one."""
try:
lat, lon = (float(x) for x in str(text).split(",", 1))
except (TypeError, ValueError):
return None
if not (-90.0 <= lat <= 90.0 and -180.0 <= lon <= 180.0):
return None
return lat, lon
def box_around(lat: float, lon: float, radius_nm: float) -> tuple:
"""The south, west, north, east of a circle of this radius.
A degree of latitude is sixty nautical miles everywhere; a degree of
longitude is sixty times the cosine of the latitude, which is why the
box is wider in degrees the further north it is drawn.
"""
span_lat = radius_nm / 60.0
span_lon = radius_nm / 60.0 / max(0.02, math.cos(math.radians(lat)))
return (max(-90.0, lat - span_lat), lon - span_lon,
min(90.0, lat + span_lat), lon + span_lon)
# Above anything with a transponder on it, so a fast aircraft is never called
# an error. Concorde cruised at 1150 kt.
MAX_GROUND_SPEED_KT = 2000.0
# Past this, two positions are not worth comparing: an aircraft out of range
# for five minutes may legitimately reappear anywhere it could have flown.
TRUST_SECONDS = 300.0
# A move shorter than this is never called an error, however little time it
# took. Positions arrive twice a second and are stamped to the millisecond,
# so two of them a thousandth of a second apart imply thousands of knots
# across a few yards -- and a compact-position error is never a few yards,
# it is a different longitude zone. Measured over one night's recording the
# two are cleanly separated: every real jump was over fifty miles and every
# false one under one.
MIN_JUMP_NM = 2.0
# How many recent positions each one is weighed against. Positions arrive
# about twice a second, so this is a few seconds of history -- enough to step
# over a run of bad decodes, and short enough that the work stays linear in
# the length of the log.
_CHAIN_WINDOW = 40
def implied_speed_kt(a: "Fix", b: "Fix") -> float:
"""How fast something would have to move to be in both places."""
gap = b.at - a.at
if gap <= 0:
return 0.0
return distance_nm(a.latitude, a.longitude,
b.latitude, b.longitude) / gap * 3600.0
def _reachable(a: "Fix", b: "Fix") -> bool:
gap = b.at - a.at
if gap <= 0 or gap > TRUST_SECONDS:
return True # too long ago to argue with
if distance_nm(a.latitude, a.longitude,
b.latitude, b.longitude) <= MIN_JUMP_NM:
return True # too small a move to be a bad decode
return implied_speed_kt(a, b) <= MAX_GROUND_SPEED_KT
def recheck(tracks) -> tuple[list, int]:
"""Drop the positions an aircraft could not have been in.
Returns the tracks and how many fixes went. For logs recorded before
the decoder checked the age of a compact-position pair: an even frame
kept from ten minutes ago, read against a fresh odd one, decodes to a
place on the wrong side of the world, and that is written down as
confidently as a real position.
An aircraft that goes out of range and comes back is not an error, so
two positions are only ever compared while they are close in time; past
five minutes the aircraft may legitimately be anywhere it could have
flown to, and nothing is rejected. Inside that window, though, a
position that cannot be reached from the one before it is wrong however
many equally wrong ones follow it -- three bad decodes in a row can land
in the same wrong place and agree with each other perfectly.
"""
out, dropped = [], 0
for track in tracks:
kept = _consistent(track.fixes)
dropped += len(track.fixes) - len(kept)
out.append(track if len(kept) == len(track.fixes)
else replace(track, fixes=kept))
return out, dropped
def _consistent(fixes: list) -> list:
"""The longest run of positions that tell one story.
Walking forward and keeping whatever is reachable from the last position
kept is the obvious way and the wrong one: it only takes one bad fix to
become the reference, and then every real position afterwards is five
hundred miles from where the aircraft is supposed to be and gets thrown
away instead. Measured on a real recording that discarded a fifth of
everything, most of it the truth.
So no position is the reference. Every chain of positions that could
describe one aeroplane is considered, and the longest is the answer --
the errors are outnumbered by definition, because they are errors.
"""
real = [f for f in fixes
if -90.0 <= f.latitude <= 90.0 and -180.0 <= f.longitude <= 180.0]
if len(real) < 2:
return real
# Two positions that contradict each other are not two positions: one of
# them is wrong and there is nothing to say which, so the later one goes.
# What comes out is at least a story, which is the whole promise here.
# Each position, against the recent ones rather than all of them: they
# arrive twice a second and nothing further back than this window can
# still be argued with anyway.
best = [1] * len(real)
came_from = [-1] * len(real)
for i, fix in enumerate(real):
for j in range(max(0, i - _CHAIN_WINDOW), i):
if best[j] + 1 > best[i] and _reachable(real[j], fix):
best[i] = best[j] + 1
came_from[i] = j
end = max(range(len(real)), key=lambda i: best[i])
chain = []
while end >= 0:
chain.append(real[end])
end = came_from[end]
chain.reverse()
return chain
def within(tracks, lat: float, lon: float, radius_nm: float) -> list:
"""The tracks with everything outside the radius dropped.
Per fix rather than per aircraft, because a track is rarely all good or
all bad: one wrong position in the middle of a real flight would
otherwise take the whole flight off the map with it, or keep the whole
map stretched to reach it.
"""
out = []
for track in tracks:
kept = [f for f in track.fixes
if distance_nm(lat, lon, f.latitude, f.longitude) <= radius_nm]
if len(kept) == len(track.fixes):
out.append(track)
continue
near = replace(track, fixes=kept)
out.append(near)
return out
@dataclass
class Track:
"""One aircraft's evening: who it was and everywhere it was seen."""

View file

@ -33,9 +33,9 @@ from pathlib import Path
import numpy as np
from .flightlog import (DEFAULT_SPEED_UNIT, Track, distance_label,
distance_nm, in_distance, in_speed,
speed_label)
from .flightlog import (DEFAULT_SPEED_UNIT, Track, box_around, centre_of,
distance_label, distance_nm, in_distance, in_speed,
speed_label, within)
from .images import GLYPH_H, draw_text, text_width, write_png
__all__ = ["Animation", "Projection", "animate", "render_frame", "write_gif",
@ -283,10 +283,15 @@ def bounds_of(tracks: list[Track], margin: float = 0.06):
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)
def fit(tracks: list[Track], width: int = 960, max_height: int = 1200,
box=None) -> Projection | None:
"""Choose the canvas: as wide as asked, as tall as the area needs.
``box`` is a (south, west, north, east) to draw instead of the one the
tracks happen to fill -- a fixed frame around the receiver, so that the
scale of the picture does not change with whatever flew past.
"""
box = bounds_of(tracks) if box is None else box
if box is None:
return None
south, west, north, east = box
@ -432,13 +437,24 @@ def _key(img: np.ndarray, view: Projection) -> None:
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 = "", unit: str = DEFAULT_SPEED_UNIT) -> np.ndarray:
"""The map at one moment: where everything was, and where it had been."""
clock: str = "", unit: str = DEFAULT_SPEED_UNIT,
project: bool = True) -> np.ndarray:
"""The map at one moment: where everything was, and where it had been.
``project`` is what makes an animation an animation: between reports an
aircraft is dead-reckoned from the speed and heading it last gave. A
still picture of a whole evening turns it off, because there the moment
being drawn is hours after most of the aircraft stopped transmitting,
and flying each of them on for those hours would scatter the lot of them
across three states.
"""
img = base.copy()
flying = 0
taken: list[tuple[int, int, int, int]] = []
for track in tracks:
now = track.at(when, stale=stale)
now = track.at(when, stale=stale) if project else \
(track.fixes[-1] if when >= track.fixes[-1].at
else track.at(when, stale=stale))
if now is None:
continue
flying += 1
@ -462,7 +478,18 @@ def render_frame(base: np.ndarray, view: Projection, tracks: list[Track],
def _marker(img: np.ndarray, x: int, y: int, heading: float,
colour: int) -> None:
"""A little arrowhead, pointing the way the aircraft is going."""
"""A little arrowhead, pointing the way the aircraft is going.
Nothing is drawn for an aircraft that is not on the picture. Said
plainly because the obvious way to write the dot at its centre --
clamping the near edge and letting the far one alone -- reads as a
negative slice when the marker is off the top or the left, which numpy
obligingly interprets as counting back from the far side and fills most
of the frame with one colour.
"""
height, width = img.shape
if not (0 <= x < width and 0 <= y < height):
return
angle = math.radians(heading % 360.0)
sin, cos = math.sin(angle), math.cos(angle)
@ -472,7 +499,8 @@ def _marker(img: np.ndarray, x: int, y: int, heading: float,
_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
img[max(0, y - 1):min(height, y + 2),
max(0, x - 1):min(width, x + 2)] = colour
def _label(img: np.ndarray, x: int, y: int, track: Track, now,
@ -787,8 +815,8 @@ def animate(tracks: list[Track], out_path, *, fps: float = 12.0,
trail_seconds: float = 0.0, stale: float = 300.0,
title: str = "", book=None, labels: bool = True,
kind: str = "", unit: str = DEFAULT_SPEED_UNIT,
ground: bool = False, fetch=None,
tile_url: str = "") -> Animation | None:
ground: bool = False, fetch=None, tile_url: str = "",
radius_nm: float = 0.0, centre=None) -> Animation | None:
"""Draw the whole log as a moving map.
``speed`` is how many seconds of real flying go by in one second of
@ -799,7 +827,23 @@ def animate(tracks: list[Track], out_path, *, fps: float = 12.0,
located = [t for t in tracks if t.located]
if not located:
return None
view = fit(located, width=width)
# A receiver hears a hundred miles on a good day and a wrong position can
# come from anywhere, so a map drawn to fit everything heard is drawn to
# fit the errors: the aircraft end up a pixel across in the middle of an
# empty continent. Framing it on the receiver instead keeps the scale
# the same from one evening to the next, and leaves the mistakes off the
# edge where they belong.
box = None
if radius_nm > 0:
middle = centre or centre_of(located)
if middle is not None:
located = [t for t in within(located, middle[0], middle[1],
radius_nm) if t.located]
if not located:
return None
box = box_around(middle[0], middle[1], radius_nm)
view = fit(located, width=width, box=box)
if view is None:
return None
start = min(t.fixes[0].at for t in located)
@ -839,7 +883,7 @@ def animate(tracks: list[Track], out_path, *, fps: float = 12.0,
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, unit=unit,
labels=labels, unit=unit, project=False,
clock=datetime.fromtimestamp(finish)
.strftime("%H:%M:%S"))
write_png(path, PALETTE[still])

View file

@ -1,5 +1,5 @@
.\" Generated by packaging/make-man.py -- do not edit by hand.
.TH BANDSAUNTER 1 "2026-09-04" "bandsaunter 2026-09-04_01" "User Commands"
.TH BANDSAUNTER 1 "2026-09-04" "bandsaunter 2026-09-04_02" "User Commands"
.SH NAME
bandsaunter \- scan, record and identify radio signals with an RTL-SDR
.SH SYNOPSIS
@ -1380,6 +1380,42 @@ where ffmpeg is installed, or a
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.
.SS How far the map reaches
The picture is framed on the receiver rather than on whatever was heard. An
aerial reaches a hundred miles on a good day and a position that decoded
wrongly can land anywhere on Earth, so a map drawn to fit everything heard is
drawn to fit the mistakes: the aircraft come out a pixel wide in the middle of
an empty continent.
.PP
.BI \-\-radius " MILES"
is how far the map reaches, in the same unit as the speeds, and defaults to a
hundred; zero goes back to fitting whatever turned up. The centre is the
median of everything heard \[em] a receiver hears aircraft all round it, and a
median cannot be dragged anywhere by a handful of bad positions \[em] or
.BI \-\-at " LAT,LON"
says where the receiver is, which is worth doing to keep the same frame every
night. Positions outside the radius are left off the drawing, one at a time
rather than one aircraft at a time, so a single bad fix in the middle of a
real flight does not take the flight with it. Nothing is dropped from the log.
.SS Positions that never happened
A position is sent as half a position \[em] an even frame and an odd one \[em] and
the pair only means anything while the aircraft has not moved between them.
Logs written before this version paired them however old they were, so an even
frame kept from ten minutes ago decoded against a fresh odd one to a place on
the wrong side of the world and wrote it down as confidently as a real one.
.PP
.B \-\-recheck
reads such a log back and keeps, for each aircraft, the longest run of
positions that could describe one aeroplane. It is deliberately not a forward
walk dropping whatever disagrees with the last position kept: one bad fix then
becomes the reference and it is the truth that gets discarded. Nothing in the
log is changed.
.PP
An aircraft that goes quiet for five minutes and is heard again a long way off
is an aeroplane rather than an error, and is not second-guessed; the radius is
what keeps those off the picture. New logs need none of this, as the decoder
now refuses a stale pair, a position that is not on Earth, and one the
aircraft could not have reached, as the frames arrive.
.SS The ground under it
A real map is drawn under the aircraft: standard {z}/{x}/{y} raster tiles,
OpenStreetMap by default, fetched the first time an area is drawn and

View file

@ -773,6 +773,42 @@ where ffmpeg is installed, or a
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.
.SS How far the map reaches
The picture is framed on the receiver rather than on whatever was heard. An
aerial reaches a hundred miles on a good day and a position that decoded
wrongly can land anywhere on Earth, so a map drawn to fit everything heard is
drawn to fit the mistakes: the aircraft come out a pixel wide in the middle of
an empty continent.
.PP
.BI \-\-radius " MILES"
is how far the map reaches, in the same unit as the speeds, and defaults to a
hundred; zero goes back to fitting whatever turned up. The centre is the
median of everything heard \[em] a receiver hears aircraft all round it, and a
median cannot be dragged anywhere by a handful of bad positions \[em] or
.BI \-\-at " LAT,LON"
says where the receiver is, which is worth doing to keep the same frame every
night. Positions outside the radius are left off the drawing, one at a time
rather than one aircraft at a time, so a single bad fix in the middle of a
real flight does not take the flight with it. Nothing is dropped from the log.
.SS Positions that never happened
A position is sent as half a position \[em] an even frame and an odd one \[em] and
the pair only means anything while the aircraft has not moved between them.
Logs written before this version paired them however old they were, so an even
frame kept from ten minutes ago decoded against a fresh odd one to a place on
the wrong side of the world and wrote it down as confidently as a real one.
.PP
.B \-\-recheck
reads such a log back and keeps, for each aircraft, the longest run of
positions that could describe one aeroplane. It is deliberately not a forward
walk dropping whatever disagrees with the last position kept: one bad fix then
becomes the reference and it is the truth that gets discarded. Nothing in the
log is changed.
.PP
An aircraft that goes quiet for five minutes and is heard again a long way off
is an aeroplane rather than an error, and is not second-guessed; the radius is
what keeps those off the picture. New logs need none of this, as the decoder
now refuses a stale pair, a position that is not on Earth, and one the
aircraft could not have reached, as the frames arrive.
.SS The ground under it
A real map is drawn under the aircraft: standard {z}/{x}/{y} raster tiles,
OpenStreetMap by default, fetched the first time an area is drawn and

View file

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

View file

@ -344,3 +344,88 @@ def test_the_map_underneath_is_an_option_that_can_be_turned_off(monkeypatch,
cfg = ScanConfig(output_dir=str(tmp_path))
run(monkeypatch, console, [number("basemap"), "no", "s", "b"], cfg)
assert air.load_options().basemap is False
# ---------------------------------------------------------------------------
# How far the map reaches
# ---------------------------------------------------------------------------
def test_the_radius_defaults_to_a_hundred():
"""An aerial hears about that far; a map drawn to fit everything heard
is drawn to fit the mistakes."""
assert air.AircraftOptions().radius == 100.0
def test_the_radius_is_changed_from_the_menu(monkeypatch, console,
settings_dir, tmp_path):
cfg = ScanConfig(output_dir=str(tmp_path))
run(monkeypatch, console, [number("radius"), "40", "s", "b"], cfg)
assert air.load_options().radius == 40.0
def test_the_radius_follows_the_unit_the_speeds_are_in():
"""A picture measuring its speeds in one unit and its own extent in
another would be a puzzle rather than a map."""
assert air.radius_in_nm(air.AircraftOptions(radius=100)) == 100.0
assert air.radius_in_nm(
air.AircraftOptions(radius=100, speed_unit="mph")) == pytest.approx(
86.9, abs=0.1)
assert air.radius_in_nm(
air.AircraftOptions(radius=100, speed_unit="kph")) == pytest.approx(
54.0, abs=0.1)
def test_no_radius_at_all_goes_back_to_fitting_what_was_heard():
assert air.radius_in_nm(air.AircraftOptions(radius=0)) == 0.0
def test_the_receiver_position_can_be_given_or_worked_out():
from bandsaunter.flightlog import read_position
assert read_position("32.54,-111.17") == pytest.approx((32.54, -111.17))
assert read_position("") is None
assert read_position("somewhere near Tucson") is None
assert read_position("240.0,-111.0") is None # not a place
assert air.AircraftOptions().location == "" # worked out by default
def test_rechecking_is_an_option_and_is_off_unless_asked(monkeypatch, console,
settings_dir,
tmp_path):
"""Logs written by this version have the check applied as they are
written, so it is a repair for older ones rather than a default."""
assert air.AircraftOptions().recheck is False
cfg = ScanConfig(output_dir=str(tmp_path))
run(monkeypatch, console, [number("recheck"), "yes", "s", "b"], cfg)
assert air.load_options().recheck is True
def test_rechecking_says_what_it_threw_out(capsys):
from bandsaunter.flightlog import Fix, Track
loud = Console(width=100)
track = Track(icao="4CA1FA", fixes=[
Fix(at=0.0, latitude=51.5, longitude=-0.12),
Fix(at=0.5, latitude=-9.5, longitude=-112.5),
Fix(at=1.0, latitude=51.5, longitude=-0.12)])
air.checked(loud, air.AircraftOptions(recheck=True), [track])
printed = capsys.readouterr().out
assert "dropped" in printed and "could have been in" in printed
def test_a_clean_log_is_told_so_rather_than_left_silent(capsys):
loud = Console(width=100)
air.checked(loud, air.AircraftOptions(recheck=True), [])
assert "checks out" in capsys.readouterr().out
def test_leaving_it_off_changes_nothing(capsys):
from bandsaunter.flightlog import Fix, Track
loud = Console(width=100)
track = Track(icao="4CA1FA", fixes=[
Fix(at=0.0, latitude=51.5, longitude=-0.12),
Fix(at=0.5, latitude=-9.5, longitude=-112.5)])
same = air.checked(loud, air.AircraftOptions(recheck=False), [track])
assert same[0] is track
assert capsys.readouterr().out.strip() == ""

View file

@ -460,3 +460,276 @@ def test_the_animation_takes_the_unit_through_to_the_file(tmp_path):
out = fm.animate(two_aircraft(), tmp_path / "mph.png", width=500,
unit="mph")
assert out is not None and out.path.is_file()
# ---------------------------------------------------------------------------
# Framing the picture on the receiver
# ---------------------------------------------------------------------------
def far_away(icao="BAD001", lat=-9.5, lon=-112.5) -> Track:
"""One aircraft in the wrong hemisphere: a decode that went wrong."""
return Track(icao=icao, callsign="GHOST",
fixes=[Fix(at=1_000_000.0, latitude=lat, longitude=lon,
altitude_ft=35_000, ground_speed_kt=400.0)],
first_seen=1_000_000.0, last_seen=1_000_000.0)
def test_the_middle_of_what_was_heard_is_where_the_receiver_is():
"""A median, so a handful of wrong positions cannot drag it anywhere."""
from bandsaunter.flightlog import centre_of
tracks = two_aircraft() + [far_away(), far_away("BAD002", 60.0, 120.0)]
lat, lon = centre_of(tracks)
assert lat == pytest.approx(51.0, abs=1.0)
assert lon == pytest.approx(-1.0, abs=1.5)
def test_a_radius_leaves_the_far_ones_off_the_map():
tracks = two_aircraft() + [far_away()]
view_all = fm.fit([t for t in tracks if t.located])
assert view_all.north - view_all.south > 50 # dragged across a globe
from bandsaunter.flightlog import centre_of, within
lat, lon = centre_of(two_aircraft())
near = [t for t in within(tracks, lat, lon, 100.0) if t.located]
assert {t.icao for t in near} == {t.icao for t in two_aircraft()}
def test_the_frame_is_the_radius_rather_than_whatever_turned_up():
"""The scale should not change with the traffic."""
from bandsaunter.flightlog import box_around, centre_of, distance_nm
lat, lon = centre_of(two_aircraft())
box = box_around(lat, lon, 100.0)
view = fm.fit(two_aircraft(), width=600, box=box)
assert view.south == pytest.approx(box[0]) and view.north == pytest.approx(box[2])
across = distance_nm(lat, box[1], lat, box[3])
assert across == pytest.approx(200.0, rel=0.02) # a hundred each way
def test_the_radius_is_kept_by_the_animation(tmp_path):
"""Without one the frame stretches to reach a bad position on the other
side of the equator, and the real aircraft come out a pixel wide."""
from bandsaunter.flightlog import box_around, centre_of
tracks = two_aircraft() + [far_away()]
wide = fm.animate(tracks, tmp_path / "wide.png", width=400, radius_nm=0)
tight = fm.animate(tracks, tmp_path / "tight.png", width=400, radius_nm=100)
assert wide.aircraft == 3 and tight.aircraft == 2
everything = fm.fit([t for t in tracks if t.located], width=400)
lat, lon = centre_of(two_aircraft())
framed = fm.fit(two_aircraft(), width=400,
box=box_around(lat, lon, 100.0))
assert everything.north - everything.south > 50 # most of a hemisphere
assert framed.north - framed.south < 4 # a hundred miles
def test_an_explicit_receiver_position_is_used_as_given(tmp_path):
"""Somewhere with no aircraft near it: everything falls outside."""
out = fm.animate(two_aircraft(), tmp_path / "elsewhere.png", width=400,
radius_nm=50, centre=(0.0, 0.0))
assert out is None
def test_a_track_with_one_bad_fix_keeps_the_rest_of_its_flight():
"""Per fix rather than per aircraft: one wrong position in the middle of
a real flight must not take the flight off the map with it."""
from bandsaunter.flightlog import within
track = straight()
good = len(track.fixes)
track.fixes.insert(len(track.fixes) // 2,
Fix(at=track.fixes[0].at + 1, latitude=-9.5,
longitude=-112.5, altitude_ft=35_000))
kept = within([track], 51.0, -1.0, 100.0)[0]
assert len(kept.fixes) == good
# ---------------------------------------------------------------------------
# Drawing off the edge
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("x,y", [(-28, -89), (-4000, 400), (450, -9000),
(2000, 400), (450, 4000)])
def test_an_aircraft_off_the_picture_paints_nothing(x, y):
"""Clamping the near edge of the centre dot and leaving the far one alone
reads as a negative slice, which numpy counts back from the far side --
and fills most of the frame with one colour."""
img = np.zeros((300, 500), dtype=np.uint8)
fm._marker(img, x, y, 90.0, 20)
assert int((img != 0).sum()) == 0
@pytest.mark.parametrize("x,y", [(0, 0), (499, 299), (250, 150)])
def test_an_aircraft_on_the_picture_is_still_drawn(x, y):
img = np.zeros((300, 500), dtype=np.uint8)
fm._marker(img, x, y, 90.0, 20)
assert 0 < int((img != 0).sum()) < 60
def test_a_still_draws_where_they_were_not_where_they_might_have_got_to():
"""A still of a whole evening is drawn hours after most of the aircraft
stopped transmitting; flying them on for those hours would scatter them
across three states."""
tracks = [straight(seconds=600.0)]
view = fm.fit(tracks, width=500)
base = fm.background(view)
hours_later = tracks[0].last_seen + 6 * 3600
projected = fm.render_frame(base, view, tracks, hours_later,
stale=7 * 3600, project=True, labels=False)
still = fm.render_frame(base, view, tracks, hours_later,
stale=7 * 3600, project=False, labels=False)
def markers(frame):
"""Just the aircraft: what changed, in the aircraft colours, inside
the map itself. The altitude key along the bottom is drawn in those
same colours and belongs to the background."""
body = slice(view.top, view.top + view.height), \
slice(view.left, view.left + view.width)
drawn = (frame[body] != base[body]) & (frame[body] >= fm.RAMP) & \
(frame[body] < fm.TRAIL)
return int(drawn.sum())
assert markers(projected) == 0 # flown clean off the picture
assert markers(still) > 0 # drawn where it last was
last = tracks[0].fixes[-1]
x, y = view.xy(last.latitude, last.longitude)
assert (still[y - 6:y + 7, x - 6:x + 7] >= fm.RAMP).any()
def test_a_whole_evening_as_one_picture_keeps_every_aircraft_on_it(tmp_path):
tracks = two_aircraft()
for t in tracks: # heard hours ago
for f in t.fixes:
f.at -= 6 * 3600
t.first_seen -= 6 * 3600
t.last_seen -= 6 * 3600
tracks.append(straight(icao="C0FFEE", callsign="LATE", lat=51.2, lon=-0.9))
out = fm.animate(tracks, tmp_path / "evening.png", width=500)
assert out is not None and out.aircraft == 3
# ---------------------------------------------------------------------------
# Throwing out what could not have happened
# ---------------------------------------------------------------------------
def _with_bad_fix(track: Track, at_index: int, lat=-9.5, lon=-112.5) -> Track:
"""Drop one impossible position into the middle of a real flight."""
when = track.fixes[at_index].at + 0.001
track.fixes.insert(at_index + 1,
Fix(at=when, latitude=lat, longitude=lon,
altitude_ft=35_000, ground_speed_kt=400.0))
return track
def test_a_position_no_aircraft_could_reach_is_thrown_out():
from bandsaunter.flightlog import recheck
track = _with_bad_fix(straight(), 10)
good = len(track.fixes) - 1
clean, dropped = recheck([track])
assert dropped == 1
assert len(clean[0].fixes) == good
assert all(-90 <= f.latitude <= 90 for f in clean[0].fixes)
def test_a_flight_that_is_entirely_real_loses_nothing():
from bandsaunter.flightlog import recheck
clean, dropped = recheck(two_aircraft())
assert dropped == 0
assert [len(t.fixes) for t in clean] == [len(t.fixes) for t in two_aircraft()]
def test_a_bad_position_early_on_does_not_take_the_flight_with_it():
"""Keeping whatever is reachable from the last position kept lets one
bad fix become the reference, and then the truth is what gets thrown
away. On a real recording that discarded a fifth of everything."""
from bandsaunter.flightlog import recheck
track = _with_bad_fix(straight(), 0)
clean, dropped = recheck([track])
assert dropped == 1
assert len(clean[0].fixes) == len(straight().fixes)
def test_a_run_of_bad_positions_that_agree_with_each_other_still_goes():
"""Three bad decodes can land in the same wrong place and agree
perfectly; they are still wrong."""
from bandsaunter.flightlog import recheck
track = straight()
where = 10
for i in range(3):
track.fixes.insert(where + 1 + i,
Fix(at=track.fixes[where].at + 0.001 * (i + 1),
latitude=-9.5 + i * 0.001, longitude=-112.5,
altitude_ft=35_000))
clean, dropped = recheck([track])
assert dropped == 3
assert all(f.latitude > 0 for f in clean[0].fixes)
def test_a_position_that_is_not_on_earth_never_survives():
from bandsaunter.flightlog import recheck
track = _with_bad_fix(straight(), 5, lat=239.6, lon=-111.0)
clean, _ = recheck([track])
assert all(-90 <= f.latitude <= 90 for f in clean[0].fixes)
def test_an_aircraft_that_went_quiet_and_came_back_is_not_an_error():
"""Out of range for ten minutes, then heard again a long way off: that
is an aeroplane, not a bad decode, and there is no way to say otherwise."""
from bandsaunter.flightlog import recheck
track = straight(seconds=200.0)
last = track.fixes[-1]
track.fixes.append(Fix(at=last.at + 900, latitude=last.latitude + 1.5,
longitude=last.longitude + 2.0,
altitude_ft=35_000, ground_speed_kt=480.0))
clean, dropped = recheck([track])
assert dropped == 0
def test_two_positions_that_contradict_each_other_do_not_both_stand():
"""One of them is wrong and nothing says which; what comes out has at
least to be a story."""
from bandsaunter.flightlog import recheck
track = Track(icao="4CA1FA", fixes=[
Fix(at=0.0, latitude=51.5, longitude=-0.12),
Fix(at=0.5, latitude=-9.5, longitude=-112.5)])
clean, dropped = recheck([track])
assert dropped == 1
assert len(clean[0].fixes) == 1
def test_a_jitter_of_a_few_yards_is_never_called_an_error():
"""Positions arrive twice a second and are stamped to the millisecond,
so two of them a thousandth of a second apart imply thousands of knots
across a few yards."""
from bandsaunter.flightlog import recheck
track = Track(icao="4CA1FA", fixes=[
Fix(at=0.0, latitude=51.5000, longitude=-0.1200),
Fix(at=0.001, latitude=51.5001, longitude=-0.1201),
Fix(at=0.002, latitude=51.5002, longitude=-0.1202)])
clean, dropped = recheck([track])
assert dropped == 0
def test_nothing_survives_that_needed_an_impossible_speed():
"""The promise of the whole thing, said as one assertion."""
from bandsaunter.flightlog import (MAX_GROUND_SPEED_KT, distance_nm,
implied_speed_kt, recheck)
tracks = [_with_bad_fix(straight(), i) for i in (3, 12, 20)]
clean, dropped = recheck(tracks)
assert dropped == 3
for track in clean:
for a, b in zip(track.fixes, track.fixes[1:]):
if 0 < b.at - a.at <= 300 and distance_nm(
a.latitude, a.longitude, b.latitude, b.longitude) > 2:
assert implied_speed_kt(a, b) <= MAX_GROUND_SPEED_KT

View file

@ -392,3 +392,107 @@ def test_a_payload_that_says_what_it_is_is_named():
def test_an_ordinary_payload_claims_no_format():
bits = "".join(format(b, "08b") for b in b"\x01\x02\x03\x04\x05\x06\x07\x08")
assert not [r for r in interpret(bits) if r.kind == "fields"]
# ---------------------------------------------------------------------------
# A position is only as good as the pair it was decoded from
# ---------------------------------------------------------------------------
def _position_frame(icao, lat, lon, odd, when, altitude=35000):
"""One position frame, read back the way the decoder would read it."""
from bandsaunter.adsb import _read, encode_position
data = encode_position(icao, lat, lon, altitude, odd=odd)
frame = _read("".join(format(b, "08b") for b in data), data)
frame.received_at = when
return frame
def test_a_stale_pair_is_not_a_position():
"""Compact position reporting sends a fraction of a zone, so an even
frame from ten minutes ago read against a fresh odd one puts the
aircraft on the wrong side of the world. Measured against one night's
recording it was doing exactly that to two aircraft in three."""
registry = AircraftRegistry()
registry.add(_position_frame(0xABCDEF, 32.55, -111.16, False, 1000.0),
when=1000.0)
registry.add(_position_frame(0xABCDEF, 33.22, -111.16, True, 1300.0),
when=1300.0)
assert not registry.aircraft["ABCDEF"].located
def test_a_fresh_pair_still_places_it_exactly():
registry = AircraftRegistry()
registry.add(_position_frame(0xABCDEF, 33.22, -111.16, False, 1000.0),
when=1000.0)
registry.add(_position_frame(0xABCDEF, 33.22, -111.16, True, 1000.5),
when=1000.5)
craft = registry.aircraft["ABCDEF"]
assert craft.latitude == pytest.approx(33.22, abs=0.01)
assert craft.longitude == pytest.approx(-111.16, abs=0.01)
@pytest.mark.parametrize("gap", [0.0, 0.5, 4.0, 9.5])
def test_a_pair_inside_the_allowed_gap_is_used(gap):
registry = AircraftRegistry()
registry.add(_position_frame(0xA0B1C2, 51.5, -0.12, False, 100.0),
when=100.0)
registry.add(_position_frame(0xA0B1C2, 51.5, -0.12, True, 100.0 + gap),
when=100.0 + gap)
assert registry.aircraft["A0B1C2"].located
def test_a_position_that_is_not_on_earth_is_refused():
"""A latitude of 240 degrees is not a place. One night's log held
eleven of them."""
from bandsaunter.adsb import _on_earth
assert not _on_earth(239.6, -111.0)
assert not _on_earth(45.0, 200.0)
assert _on_earth(-33.9, 151.2)
def test_an_aircraft_cannot_cross_a_continent_between_two_frames():
"""A position needing nine hundred thousand knots to reach is not a
position, whatever the checksum said about the frames it came from."""
registry = AircraftRegistry()
for odd in (False, True):
registry.add(_position_frame(0xA0B1C2, 51.5, -0.12, odd, 100.0),
when=100.0)
craft = registry.aircraft["A0B1C2"]
assert craft.located
was = (craft.latitude, craft.longitude)
# Two seconds later, a pair that decodes to the far side of the Atlantic.
for odd in (False, True):
registry.add(_position_frame(0xA0B1C2, 40.7, -74.0, odd, 102.0),
when=102.0)
assert (craft.latitude, craft.longitude) == was
def test_an_aircraft_that_really_moved_is_still_followed():
"""The bar has to be above anything that flies, or a fast aircraft is
called an error."""
registry = AircraftRegistry()
for odd in (False, True):
registry.add(_position_frame(0xA0B1C2, 51.5, -0.12, odd, 100.0),
when=100.0)
# Sixty seconds on and eight miles further east: 480 knots.
for odd in (False, True):
registry.add(_position_frame(0xA0B1C2, 51.5, 0.08, odd, 160.0),
when=160.0)
assert registry.aircraft["A0B1C2"].longitude == pytest.approx(0.08,
abs=0.02)
def test_a_gap_in_reception_is_not_treated_as_an_error():
"""Nothing heard for an hour, then a position a long way off: that is an
aircraft that flew away and came back, not a bad decode."""
registry = AircraftRegistry()
for odd in (False, True):
registry.add(_position_frame(0xA0B1C2, 51.5, -0.12, odd, 100.0),
when=100.0)
for odd in (False, True):
registry.add(_position_frame(0xA0B1C2, 48.8, 2.3, odd, 4000.0),
when=4000.0)
assert registry.aircraft["A0B1C2"].latitude == pytest.approx(48.8,
abs=0.05)