diff --git a/README.md b/README.md index 89a6351..20c89cf 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/bandsaunter/__init__.py b/bandsaunter/__init__.py index d8c159f..f044186 100755 --- a/bandsaunter/__init__.py +++ b/bandsaunter/__init__.py @@ -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}" diff --git a/bandsaunter/adsb.py b/bandsaunter/adsb.py index e368035..6b046bb 100644 --- a/bandsaunter/adsb.py +++ b/bandsaunter/adsb.py @@ -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)] diff --git a/bandsaunter/aircraft.py b/bandsaunter/aircraft.py index 2b9d09f..291e555 100644 --- a/bandsaunter/aircraft.py +++ b/bandsaunter/aircraft.py @@ -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: diff --git a/bandsaunter/cli.py b/bandsaunter/cli.py index 36bd95b..a6398e3 100755 --- a/bandsaunter/cli.py +++ b/bandsaunter/cli.py @@ -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, diff --git a/bandsaunter/flightlog.py b/bandsaunter/flightlog.py index 3df95ed..9330e20 100644 --- a/bandsaunter/flightlog.py +++ b/bandsaunter/flightlog.py @@ -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.""" diff --git a/bandsaunter/flightmap.py b/bandsaunter/flightmap.py index 7a34e17..b7ec03b 100644 --- a/bandsaunter/flightmap.py +++ b/bandsaunter/flightmap.py @@ -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]) diff --git a/packaging/bandsaunter.1 b/packaging/bandsaunter.1 index 4d71558..963a565 100644 --- a/packaging/bandsaunter.1 +++ b/packaging/bandsaunter.1 @@ -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 diff --git a/packaging/make-man.py b/packaging/make-man.py index 777a7ff..0d13994 100755 --- a/packaging/make-man.py +++ b/packaging/make-man.py @@ -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 diff --git a/packaging/saunterbrowse.1 b/packaging/saunterbrowse.1 index ae43c2c..4b73cd0 100644 --- a/packaging/saunterbrowse.1 +++ b/packaging/saunterbrowse.1 @@ -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 diff --git a/tests/test_aircraft_menu.py b/tests/test_aircraft_menu.py index a332a7f..a1f5ffe 100644 --- a/tests/test_aircraft_menu.py +++ b/tests/test_aircraft_menu.py @@ -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() == "" diff --git a/tests/test_flightmap.py b/tests/test_flightmap.py index 8886e8a..b8c5871 100644 --- a/tests/test_flightmap.py +++ b/tests/test_flightmap.py @@ -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 diff --git a/tests/test_signals_named.py b/tests/test_signals_named.py index 9a66217..50e36e1 100644 --- a/tests/test_signals_named.py +++ b/tests/test_signals_named.py @@ -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)