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:
parent
96fc21ac7d
commit
e50d43d6e2
13 changed files with 999 additions and 36 deletions
55
README.md
55
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,
|
be installed, `--out something.mp4` is smaller and smoother; where it is not,
|
||||||
nothing breaks and a GIF is written instead.
|
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
|
### The ground under it
|
||||||
|
|
||||||
**There is a real map under the aircraft.** A flight path over a black
|
**There is a real map under the aircraft.** A flight path over a black
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ and transcribing speech.
|
||||||
# 2026-08-21_02 is the second build made on the 21st. The revision is padded
|
# 2026-08-21_02 is the second build made on the 21st. The revision is padded
|
||||||
# to two digits so versions sort as text.
|
# to two digits so versions sort as text.
|
||||||
VERSION_DATE = "2026-09-04"
|
VERSION_DATE = "2026-09-04"
|
||||||
VERSION_REVISION = 1
|
VERSION_REVISION = 2
|
||||||
|
|
||||||
__version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}"
|
__version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,19 @@ ADSB_HZ = 1_090_000_000.0
|
||||||
SAMPLE_RATE = 2_000_000
|
SAMPLE_RATE = 2_000_000
|
||||||
|
|
||||||
PREAMBLE_US = (0.0, 1.0, 3.5, 4.5)
|
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
|
SHORT_BITS = 56
|
||||||
LONG_BITS = 112
|
LONG_BITS = 112
|
||||||
|
|
||||||
|
|
@ -383,6 +396,7 @@ class Aircraft:
|
||||||
last_seen: float = 0.0
|
last_seen: float = 0.0
|
||||||
_even: Frame | None = field(default=None, repr=False)
|
_even: Frame | None = field(default=None, repr=False)
|
||||||
_odd: 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
|
@property
|
||||||
def located(self) -> bool:
|
def located(self) -> bool:
|
||||||
|
|
@ -438,19 +452,73 @@ class AircraftRegistry:
|
||||||
seen._odd = frame
|
seen._odd = frame
|
||||||
else:
|
else:
|
||||||
seen._even = frame
|
seen._even = frame
|
||||||
if seen._even is not None and seen._odd is not None:
|
self._place(seen)
|
||||||
# Whichever of the pair arrived later is the one the position
|
|
||||||
# is reported at. Compared by arrival rather than by sample
|
|
||||||
# offset: the offset restarts at zero every block, so a pair
|
|
||||||
# that straddles two blocks would otherwise be read backwards
|
|
||||||
# and put the aircraft in the wrong zone.
|
|
||||||
even_first = (seen._even.received_at, seen._even.at_sample) > \
|
|
||||||
(seen._odd.received_at, seen._odd.at_sample)
|
|
||||||
found = global_position(seen._even, seen._odd, even_first)
|
|
||||||
if found is not None:
|
|
||||||
seen.latitude, seen.longitude = found
|
|
||||||
return seen
|
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]:
|
def described(self) -> list[str]:
|
||||||
return [craft.describe() for craft in
|
return [craft.describe() for craft in
|
||||||
sorted(self.aircraft.values(), key=lambda a: a.icao)]
|
sorted(self.aircraft.values(), key=lambda a: a.icao)]
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ from pathlib import Path
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from .adsb import ADSB_HZ, SAMPLE_RATE
|
from .adsb import ADSB_HZ, SAMPLE_RATE
|
||||||
|
from .flightlog import read_position
|
||||||
from .settings import Setting, format_value
|
from .settings import Setting, format_value
|
||||||
|
|
||||||
__all__ = ["AircraftOptions", "OPTIONS", "listen", "draw", "logs_in",
|
__all__ = ["AircraftOptions", "OPTIONS", "listen", "draw", "logs_in",
|
||||||
|
|
@ -108,6 +109,9 @@ class AircraftOptions:
|
||||||
trail: float = 0.0
|
trail: float = 0.0
|
||||||
stale: float = 300.0
|
stale: float = 300.0
|
||||||
labels: bool = True
|
labels: bool = True
|
||||||
|
radius: float = 100.0
|
||||||
|
location: str = ""
|
||||||
|
recheck: bool = False
|
||||||
basemap: bool = True
|
basemap: bool = True
|
||||||
tile_url: str = ""
|
tile_url: str = ""
|
||||||
|
|
||||||
|
|
@ -268,6 +272,43 @@ OPTIONS: tuple[Setting, ...] = (
|
||||||
"heading it last gave. After a few minutes of that it has flown fifty "
|
"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.",
|
"miles on a guess, so it is dropped instead of invented.",
|
||||||
unit="s", minimum=1.0, flags=("--stale",), example="300"),
|
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",
|
O("basemap", "Map underneath", "Drawing", "bool",
|
||||||
"draw a real map under the flight paths",
|
"draw a real map under the flight paths",
|
||||||
"A flight path over a black rectangle says how the aircraft moved and "
|
"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
|
# 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):
|
def draw(console, options: AircraftOptions, tracks, out_path, book=None):
|
||||||
"""Draw the map, saying what it is drawing and what came out of it."""
|
"""Draw the map, saying what it is drawing and what came out of it."""
|
||||||
from .flightmap import animate, ffmpeg_available
|
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,
|
width=options.width, trail_seconds=options.trail,
|
||||||
stale=options.stale, labels=options.labels,
|
stale=options.stale, labels=options.labels,
|
||||||
unit=options.speed_unit, ground=options.basemap,
|
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:
|
except (OSError, RuntimeError, ValueError) as exc:
|
||||||
console.print(f"[red]{exc}[/red]")
|
console.print(f"[red]{exc}[/red]")
|
||||||
return None
|
return None
|
||||||
|
|
@ -631,6 +703,7 @@ def draw_log(console, options: AircraftOptions, paths, out_path=None):
|
||||||
if not tracks:
|
if not tracks:
|
||||||
console.print(f"[yellow]{paths[0].name} holds no frames[/yellow]")
|
console.print(f"[yellow]{paths[0].name} holds no frames[/yellow]")
|
||||||
return None
|
return None
|
||||||
|
tracks = checked(console, options, tracks)
|
||||||
book = FlightBook(online=options.lookup)
|
book = FlightBook(online=options.lookup)
|
||||||
if options.lookup:
|
if options.lookup:
|
||||||
for track in tracks:
|
for track in tracks:
|
||||||
|
|
|
||||||
|
|
@ -231,6 +231,16 @@ examples:
|
||||||
help="draw the tracks on their own, with no map under them")
|
help="draw the tracks on their own, with no map under them")
|
||||||
fl.add_argument("--tiles", default=None, metavar="URL",
|
fl.add_argument("--tiles", default=None, metavar="URL",
|
||||||
help="where map tiles come from ({z}/{x}/{y}.png)")
|
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)
|
fl.set_defaults(labels=True, draw=True, lookup=True)
|
||||||
|
|
||||||
# -- analyse ------------------------------------------------------------
|
# -- analyse ------------------------------------------------------------
|
||||||
|
|
@ -1108,6 +1118,12 @@ def cmd_flights(args) -> int:
|
||||||
if not tracks:
|
if not tracks:
|
||||||
console.print(f"[yellow]{paths[0].name} holds no frames[/yellow]")
|
console.print(f"[yellow]{paths[0].name} holds no frames[/yellow]")
|
||||||
return 1
|
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)
|
book = FlightBook(online=args.lookup)
|
||||||
if args.lookup:
|
if args.lookup:
|
||||||
for track in tracks:
|
for track in tracks:
|
||||||
|
|
@ -1115,9 +1131,6 @@ def cmd_flights(args) -> int:
|
||||||
book.wait(20.0)
|
book.wait(20.0)
|
||||||
book.save()
|
book.save()
|
||||||
|
|
||||||
options = air.load_options()
|
|
||||||
if args.speed_unit:
|
|
||||||
options.speed_unit = args.speed_unit
|
|
||||||
title = f"bandsaunter — {paths[0].name}"
|
title = f"bandsaunter — {paths[0].name}"
|
||||||
lines = report(tracks, book if args.lookup else None, title=title,
|
lines = report(tracks, book if args.lookup else None, title=title,
|
||||||
unit=options.speed_unit)
|
unit=options.speed_unit)
|
||||||
|
|
@ -1151,6 +1164,10 @@ def cmd_flights(args) -> int:
|
||||||
options.basemap = args.basemap
|
options.basemap = args.basemap
|
||||||
if args.tiles:
|
if args.tiles:
|
||||||
options.tile_url = 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 \
|
out = Path(args.out).expanduser() if args.out else \
|
||||||
paths[0].with_suffix("." + options.picture)
|
paths[0].with_suffix("." + options.picture)
|
||||||
drawn = air.draw(console, options, tracks, out,
|
drawn = air.draw(console, options, tracks, out,
|
||||||
|
|
|
||||||
|
|
@ -21,14 +21,17 @@ from __future__ import annotations
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from statistics import median
|
||||||
|
from dataclasses import dataclass, field, replace
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
__all__ = ["Fix", "Track", "FlightLog", "read_logs", "report",
|
__all__ = ["Fix", "Track", "FlightLog", "read_logs", "report",
|
||||||
"write_kml", "LOG_VERSION", "EARTH_NM", "SPEED_UNITS",
|
"write_kml", "LOG_VERSION", "EARTH_NM", "SPEED_UNITS",
|
||||||
"speed_label", "in_speed", "distance_label", "in_distance",
|
"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
|
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
|
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
|
@dataclass
|
||||||
class Track:
|
class Track:
|
||||||
"""One aircraft's evening: who it was and everywhere it was seen."""
|
"""One aircraft's evening: who it was and everywhere it was seen."""
|
||||||
|
|
|
||||||
|
|
@ -33,9 +33,9 @@ from pathlib import Path
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from .flightlog import (DEFAULT_SPEED_UNIT, Track, distance_label,
|
from .flightlog import (DEFAULT_SPEED_UNIT, Track, box_around, centre_of,
|
||||||
distance_nm, in_distance, in_speed,
|
distance_label, distance_nm, in_distance, in_speed,
|
||||||
speed_label)
|
speed_label, within)
|
||||||
from .images import GLYPH_H, draw_text, text_width, write_png
|
from .images import GLYPH_H, draw_text, text_width, write_png
|
||||||
|
|
||||||
__all__ = ["Animation", "Projection", "animate", "render_frame", "write_gif",
|
__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)
|
return (south - pad_lat, west - pad_lon, north + pad_lat, east + pad_lon)
|
||||||
|
|
||||||
|
|
||||||
def fit(tracks: list[Track], width: int = 960,
|
def fit(tracks: list[Track], width: int = 960, max_height: int = 1200,
|
||||||
max_height: int = 1200) -> Projection | None:
|
box=None) -> Projection | None:
|
||||||
"""Choose the canvas the tracks want: as wide as asked, as tall as needed."""
|
"""Choose the canvas: as wide as asked, as tall as the area needs.
|
||||||
box = bounds_of(tracks)
|
|
||||||
|
``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:
|
if box is None:
|
||||||
return None
|
return None
|
||||||
south, west, north, east = box
|
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],
|
def render_frame(base: np.ndarray, view: Projection, tracks: list[Track],
|
||||||
when: float, *, trail_seconds: float = 0.0,
|
when: float, *, trail_seconds: float = 0.0,
|
||||||
stale: float = 300.0, labels: bool = True,
|
stale: float = 300.0, labels: bool = True,
|
||||||
clock: str = "", unit: str = DEFAULT_SPEED_UNIT) -> np.ndarray:
|
clock: str = "", unit: str = DEFAULT_SPEED_UNIT,
|
||||||
"""The map at one moment: where everything was, and where it had been."""
|
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()
|
img = base.copy()
|
||||||
flying = 0
|
flying = 0
|
||||||
taken: list[tuple[int, int, int, int]] = []
|
taken: list[tuple[int, int, int, int]] = []
|
||||||
for track in tracks:
|
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:
|
if now is None:
|
||||||
continue
|
continue
|
||||||
flying += 1
|
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,
|
def _marker(img: np.ndarray, x: int, y: int, heading: float,
|
||||||
colour: int) -> None:
|
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)
|
angle = math.radians(heading % 360.0)
|
||||||
sin, cos = math.sin(angle), math.cos(angle)
|
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)),
|
_triangle(img, (point(5.0, 0.0), point(-3.5, 3.0), point(-3.5, -3.0)),
|
||||||
colour)
|
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,
|
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,
|
trail_seconds: float = 0.0, stale: float = 300.0,
|
||||||
title: str = "", book=None, labels: bool = True,
|
title: str = "", book=None, labels: bool = True,
|
||||||
kind: str = "", unit: str = DEFAULT_SPEED_UNIT,
|
kind: str = "", unit: str = DEFAULT_SPEED_UNIT,
|
||||||
ground: bool = False, fetch=None,
|
ground: bool = False, fetch=None, tile_url: str = "",
|
||||||
tile_url: str = "") -> Animation | None:
|
radius_nm: float = 0.0, centre=None) -> Animation | None:
|
||||||
"""Draw the whole log as a moving map.
|
"""Draw the whole log as a moving map.
|
||||||
|
|
||||||
``speed`` is how many seconds of real flying go by in one second of
|
``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]
|
located = [t for t in tracks if t.located]
|
||||||
if not located:
|
if not located:
|
||||||
return None
|
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:
|
if view is None:
|
||||||
return None
|
return None
|
||||||
start = min(t.fixes[0].at for t in located)
|
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":
|
if kind == "png":
|
||||||
# Not an animation at all: the whole log at once, every path drawn.
|
# Not an animation at all: the whole log at once, every path drawn.
|
||||||
still = render_frame(base, view, located, finish, stale=covers + 1,
|
still = render_frame(base, view, located, finish, stale=covers + 1,
|
||||||
labels=labels, unit=unit,
|
labels=labels, unit=unit, project=False,
|
||||||
clock=datetime.fromtimestamp(finish)
|
clock=datetime.fromtimestamp(finish)
|
||||||
.strftime("%H:%M:%S"))
|
.strftime("%H:%M:%S"))
|
||||||
write_png(path, PALETTE[still])
|
write_png(path, PALETTE[still])
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
.\" Generated by packaging/make-man.py -- do not edit by hand.
|
.\" 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
|
.SH NAME
|
||||||
bandsaunter \- scan, record and identify radio signals with an RTL-SDR
|
bandsaunter \- scan, record and identify radio signals with an RTL-SDR
|
||||||
.SH SYNOPSIS
|
.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
|
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
|
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.
|
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
|
.SS The ground under it
|
||||||
A real map is drawn under the aircraft: standard {z}/{x}/{y} raster tiles,
|
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
|
OpenStreetMap by default, fetched the first time an area is drawn and
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
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.
|
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
|
.SS The ground under it
|
||||||
A real map is drawn under the aircraft: standard {z}/{x}/{y} raster tiles,
|
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
|
OpenStreetMap by default, fetched the first time an area is drawn and
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
.\" Generated by packaging/make-browse-man.py -- do not edit by hand.
|
.\" 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
|
.SH NAME
|
||||||
saunterbrowse \- read and listen to what a bandsaunter scan collected
|
saunterbrowse \- read and listen to what a bandsaunter scan collected
|
||||||
.SH SYNOPSIS
|
.SH SYNOPSIS
|
||||||
|
|
|
||||||
|
|
@ -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))
|
cfg = ScanConfig(output_dir=str(tmp_path))
|
||||||
run(monkeypatch, console, [number("basemap"), "no", "s", "b"], cfg)
|
run(monkeypatch, console, [number("basemap"), "no", "s", "b"], cfg)
|
||||||
assert air.load_options().basemap is False
|
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() == ""
|
||||||
|
|
|
||||||
|
|
@ -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,
|
out = fm.animate(two_aircraft(), tmp_path / "mph.png", width=500,
|
||||||
unit="mph")
|
unit="mph")
|
||||||
assert out is not None and out.path.is_file()
|
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
|
||||||
|
|
|
||||||
|
|
@ -392,3 +392,107 @@ def test_a_payload_that_says_what_it_is_is_named():
|
||||||
def test_an_ordinary_payload_claims_no_format():
|
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")
|
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"]
|
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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue