Aircraft, from the menus, on a live board, over a real map

Four things the ADS-B mode was missing, and one it was actively getting
wrong.

The band plan lists 1090 MHz because that is where ADS-B is, so choosing
it from the band plan is the obvious thing to do -- and it records the
bursts as clicks in a WAV file and decodes nothing, silently.  Both the
scanner and the menus now say so, before the sweep starts, and name the
mode that does decode it.  It is not refused: looking at the raw spectrum
is a fair thing to want.

Menu 5, Aircraft (ADS-B), is the whole mode without a command line.  Every
option on one screen with a line saying what it does, ?N for the long
version and the flag it corresponds to, l to listen, m to draw a map from
any log, s to keep the options.  The listening and the drawing moved into
bandsaunter/aircraft.py so the menus and the command line run the same
code.

While it listens the screen is a live board: one line per aircraft in the
order first heard, the counter climbing as frames arrive, height coloured
low warm to high cold with an arrow for climb or descent, the age of the
last report going green to red, and the line removed once nothing has been
heard for --hold seconds, everything below moving up.  The registers are
asked while it runs, so registration, type, operator and route fill
themselves in as the answers arrive.

--speed-unit knots|mph|kph changes the heading of that board, the speed
beside every aircraft on the map and the speeds in the report, and moves
the distances with it so that one picture never carries two different
miles.  The log stays in knots, which is what the aircraft broadcast.

And there is a real map under the flight paths: {z}/{x}/{y} tiles fetched
once, cached in ~/.cache/bandsaunter/tiles, reprojected from Web Mercator
pixel by pixel, inverted and dimmed so the aircraft stay the brightest
thing on the picture.  The PNGs are decoded here -- zlib and the five row
filters from the specification, checked byte for byte against Pillow on
real tiles -- so nothing new is depended on.  Tiles are cached and never
re-fetched, every request says who is asking, and the attribution is drawn
onto the picture, because a GIF travels without its readme.

conftest now fails any test that reaches for a tile server or a register.
It caught four of these on the way in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
This commit is contained in:
The Dust Council 2026-09-04 00:05:32 -07:00
parent 4239635f74
commit 96fc21ac7d
18 changed files with 3298 additions and 281 deletions

View file

@ -949,6 +949,53 @@ bandsaunter adsb --simulate # invent a sky, for a receiver with no aerial
bandsaunter flights # read the log back: report, map, animation
```
While it listens, the screen is a live board of what is overhead:
```
╭─────────────────────────────────────────────────────────────────────────────╮
│ 1090 MHz 6 overhead 9 seen 1,284 frames 19/s 0:04:31 control-C │
╰─────────────────────────────────────────────────────────────────────────────╯
callsign ICAO aircraft altitude speed kt track position frames last
BAW49 4008F6 B744 G-VROS 33,025↑ 480 300° WNW 48.2775,-121.8050 1,204 0s
ASA412 A24C71 B738 N625AS 12,400↓ 310 155° SSE 47.3323,-122.7387 412 1s
N517HP A6F109 R44 N517HP 1,200 95 020° NNE 47.6485,-122.2647 88 2s
```
One line per aircraft, in the order they were first heard. **The counter
climbs as frames arrive**, altitude is coloured low-warm to high-cold with an
arrow for climb or descent, and the age of the last frame goes green → yellow
→ red. When nothing has been heard from an aircraft for `hold` seconds
(45 by default) its line is removed and everything below moves up — the board
is the sky now, not a list of everything ever heard. Nothing is lost by it:
the log has every frame and the report at the end lists every aircraft.
The registers are asked *while* it listens, so the registration, type,
operator and route fill themselves in on the line as the answers arrive. A
narrow terminal drops the columns a website supplied and keeps the ones only
the aircraft can give. `--frames` prints the raw stream instead, and a pipe
or a log file gets a plain running count rather than a display that redraws
four times a second.
**Speeds in whatever you read in.** `--speed-unit knots|mph|kph` (or the
option in the menu) changes the column heading on the live display, the speed
written beside every aircraft on the map, and the speeds in the report — and
it moves the distances with them, so a map labelled in mph has a scale bar in
statute miles and one in kph has kilometres, rather than two different miles
on one picture. **The log always keeps knots**, because that is what the
aircraft broadcast: the recording stays the thing that arrived, and the
conversion happens at the moment of showing it to somebody.
**Or from the menus: `bandsaunter` → 5, Aircraft (ADS-B).** Every option is
on one screen with what it does beside it, `?N` explains any of them at
length, `l` listens and `m` draws a map from a log — no flags to remember,
and the options can be saved as the default.
> **This is not a scan, and the band plan's `adsb` preset will not do it.**
> Sweeping 1090 MHz records the bursts as clicks in a WAV file and decodes
> nothing: the signalling is a megabit a second and the scan path is 12.5 kHz
> wide. Both the scanner and the menus now say so when a sweep is pointed at
> 1090 MHz or 978 MHz, rather than letting it run silently.
Every airliner overhead broadcasts its address, callsign, altitude, position
and speed twice a second, unencrypted, to nobody in particular.
@ -1058,6 +1105,34 @@ 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.
### The ground under it
**There is a real map under the aircraft.** A flight path over a black
rectangle says how the aircraft moved and nothing about where it was; over a
coastline it says which airport it left.
Standard `{z}/{x}/{y}` raster tiles are fetched the first time an area is
drawn — OpenStreetMap by default — reprojected from Web Mercator onto the
picture pixel by pixel, inverted and dimmed so that the map is the ground and
the aircraft stay the brightest thing on it. The PNG tiles are decoded here,
by the same reasoning the PNGs are written here: zlib, numpy and the five
row filters from the specification, and no imaging library.
Using somebody else's tile server carries three obligations, and all three
are met rather than assumed:
- **tiles are cached** in `~/.cache/bandsaunter/tiles` and never fetched
twice, so redrawing an evening costs nothing and works with no network;
- **every request says who is asking**, in the User-Agent;
- **the attribution is drawn onto the picture**, because a GIF travels
without the readme that would otherwise carry it.
A drawing is capped at a few dozen tiles — past that the zoom drops, since a
coarser map still says where the coastline is. `--no-basemap` draws the tracks
on their own, `--tiles URL` points at another server (your own, if you run
one), and when there is no network and nothing cached the picture falls back
to the plain grid it drew before.
## Meters and weather sensors
Two things on the ISM bands are worth naming rather than reporting as hex.
@ -1739,6 +1814,12 @@ deleted.
## Built-in help
Menu **5, Aircraft (ADS-B)**, is the whole of the aircraft mode without a
command line: nineteen options on one screen, each with a line saying what it
does, `?N` for the long version with the flag it corresponds to, `l` to listen
and `m` to draw a map from any log in the recordings directory. `s` saves the
options to `~/.config/bandsaunter/aircraft.yaml`.
Press `h` in the menus for topics covering setup, how the sweep works, why
nothing (or too much) is being recorded, capturing conversations, where files
go, trunked systems, HF reception and the keys available during a scan. Typing a setting name

View file

@ -8,8 +8,8 @@ and transcribing speech.
# Versions are the release date and a revision within that day, so
# 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-03"
VERSION_REVISION = 5
VERSION_DATE = "2026-09-04"
VERSION_REVISION = 1
__version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}"

723
bandsaunter/aircraft.py Normal file
View file

@ -0,0 +1,723 @@
"""Listening to aircraft, and drawing where they went, from either front end.
The command line and the menus do the same two things here -- park on 1090 MHz
and write down what arrives, then turn a log of that into a map -- so the doing
of it lives here and both front ends call it. Neither imports the other, and
the options are described once, in the same shape as every other setting in the
program, so the menu can print help for each one without knowing what any of
them mean.
ADS-B does not go through the scanner and cannot be made to: it is a megabit a
second, which needs at least two megasamples a second of raw receiver output,
and the scan path decimates everything to a channel twelve and a half kilohertz
wide long before a decoder sees it. A scan of 1090 MHz records the envelope of
the bursts as clicks in a WAV file and decodes nothing, which is the mistake
:func:`scanning_aircraft_band` exists to catch.
"""
from __future__ import annotations
import time
from dataclasses import asdict, dataclass, field
from datetime import datetime
from pathlib import Path
import yaml
from .adsb import ADSB_HZ, SAMPLE_RATE
from .settings import Setting, format_value
__all__ = ["AircraftOptions", "OPTIONS", "listen", "draw", "logs_in",
"format_option",
"load_options", "save_options", "options_path",
"scanning_aircraft_band", "AIRCRAFT_BANDS", "SCAN_WARNING"]
# ---------------------------------------------------------------------------
# The bands that look like a scan and are not one
# ---------------------------------------------------------------------------
# (name, low, high, what to do instead)
AIRCRAFT_BANDS = (
("ADS-B", 1_089_000_000.0, 1_091_000_000.0, "bandsaunter adsb"),
("UAT / ADS-B 978", 977_000_000.0, 979_000_000.0, None),
)
SCAN_WARNING = (
"These ranges cover a band the scanner cannot decode: {bands}. "
"The signalling is a megabit a second and the scan path is 12.5 kHz "
"wide, so a scan of it records clicks and finds no aircraft."
)
def scanning_aircraft_band(ranges) -> str:
"""Warn when a sweep covers a band that needs the ADS-B mode instead.
Returns the warning to print, or an empty string. The band plan lists
1090 MHz because that is where ADS-B is, and choosing it from the band
plan is the obvious thing to do and the wrong one; saying so before the
sweep starts costs a line and saves an evening.
"""
hit = []
for name, low, high, _ in AIRCRAFT_BANDS:
for r in ranges or ():
start = float(getattr(r, "start", 0.0) or 0.0)
stop = float(getattr(r, "stop", start) or start)
if start <= high and stop >= low:
hit.append(name)
break
if not hit:
return ""
return SCAN_WARNING.format(bands=", ".join(hit))
# ---------------------------------------------------------------------------
# The options, described the way every other setting is
# ---------------------------------------------------------------------------
@dataclass
class AircraftOptions:
"""Everything the aircraft mode can be told, in one place.
Kept apart from :class:`~bandsaunter.config.ScanConfig` because none of it
controls a scan: a sweep has no sample rate this high, no animation and no
aircraft. Saved in its own small file for the same reason.
"""
# -- listening ------------------------------------------------------
seconds: float = 0.0
rate: float = float(SAMPLE_RATE)
gain: str = "auto"
device: int = 0
frames: bool = False
log: bool = True
lookup: bool = True
kml: bool = False
hold: float = 45.0
speed_unit: str = "knots"
draw_after: bool = False
simulate: bool = False
near: str = "47.55,-122.30"
# -- drawing --------------------------------------------------------
picture: str = "gif"
length: float = 30.0
speed: float = 0.0
fps: float = 12.0
width: int = 960
trail: float = 0.0
stale: float = 300.0
labels: bool = True
basemap: bool = True
tile_url: str = ""
def validate(self) -> list[str]:
out = []
if self.rate < SAMPLE_RATE:
out.append(f"sample rate must be at least {SAMPLE_RATE/1e6:g} MS/s "
"or a bit is too narrow to see")
if self.fps <= 0:
out.append("frames a second must be more than zero")
if self.width < 160:
out.append("the picture must be at least 160 pixels across")
if self.seconds < 0 or self.length <= 0:
out.append("times cannot be negative")
return out
def to_dict(self) -> dict:
return asdict(self)
O = Setting
OPTIONS: tuple[Setting, ...] = (
# -- listening ------------------------------------------------------
O("seconds", "Listen for", "Listening", "float",
"how long to listen before stopping (0 = until interrupted)",
"A whole evening is 0: listening runs until control-C, and everything "
"heard is on the disk as it arrives, so stopping never loses anything. "
"A number is useful for a quick look at whether the aerial hears "
"anything at all.",
unit="s", minimum=0.0, flags=("--seconds",), example="600",
guidance="Sixty seconds is enough to know whether aircraft are being "
"heard. An evening of traffic wants no limit."),
O("simulate", "Invent a sky", "Listening", "bool",
"fly imaginary aircraft past an imaginary receiver",
"Six aircraft that are not there, broadcasting real frames with real "
"checksums through the real decoder. Nothing touches the receiver, so "
"the log, the lookups, the report and the map can all be tried before "
"an aerial exists.",
flags=("--simulate",),
guidance="Turn this on to see what the whole thing does without "
"hardware. Turn it off to hear real aircraft."),
O("near", "Imaginary sky near", "Listening", "text",
"where the simulated aircraft are flying",
"Latitude and longitude, as two numbers. Only used when the sky is "
"invented; it decides where the map ends up centred.",
flags=("--near",), example="47.55,-122.30", metavar="LAT,LON"),
O("device", "Receiver", "Listening", "int",
"which receiver to use, when more than one is plugged in",
"The index shown by `bandsaunter devices`. Zero unless you have "
"several dongles.",
minimum=0, flags=("--device",), example="0"),
O("gain", "Gain", "Listening", "gain",
"tuner gain in dB, or automatic",
"ADS-B is a weak burst from a long way off, and the automatic gain "
"control usually does well enough. A fixed high gain can hear more "
"where there is nothing strong nearby to overload the front end.",
flags=("--gain",), example="auto"),
O("rate", "Sample rate", "Listening", "float",
"how fast to sample; two megasamples a second is the minimum",
"One microsecond per bit means two samples per bit at 2 MS/s, which is "
"the least that can read one. Higher rates decode a little more of the "
"weak traffic and cost proportionally more processing.",
unit="Hz", minimum=float(SAMPLE_RATE), flags=("--rate",),
example="2000000"),
O("frames", "Show every frame", "Listening", "bool",
"print each frame as it arrives, rather than a running count",
"Every frame, with what was read out of it. Useful once, to see that "
"it is working; unreadable for an evening.",
flags=("--frames",)),
O("log", "Write the log", "Listening", "bool",
"write every frame to a file as it arrives",
"The JSON Lines log is what the report and the map are made from, and "
"it holds the raw hexadecimal of every frame beside what was decoded "
"from it. Turning this off leaves nothing behind but the screen.",
flags=("--log",), off_flags=("--no-log",)),
O("lookup", "Look the aircraft up", "Listening", "bool",
"ask the public registers who each aircraft is",
"Two registers are asked -- adsbdb, then hexdb -- for the "
"registration, the type, the operator and the route, and the answers "
"are cached for a month. Only the address and callsign heard on the "
"air are ever sent. What the address block and the callsign say on "
"their own is worked out offline either way.",
flags=("--lookup",), off_flags=("--no-lookup",)),
O("kml", "Also write a KML", "Listening", "bool",
"write the flight paths for Google Earth as well",
"One line per aircraft on the globe, with a pin where it was last "
"heard, in a .kml beside the log.",
flags=("--kml",)),
O("hold", "Keep on screen for", "Listening", "float",
"how long an aircraft stays on the display after its last frame",
"An aircraft that has gone out of range stops sending, and its line "
"would otherwise sit there for the rest of the evening saying the same "
"thing. When nothing has been heard from it for this long the line is "
"removed and everything below it moves up. Nothing is lost by it: the "
"log holds every frame, and the report at the end lists every aircraft "
"heard.",
unit="s", minimum=1.0, example="45",
guidance="Long enough that a gap in reception does not make rows jump "
"about; short enough that the screen is the sky now."),
O("speed_unit", "Speed in", "Listening", "choice",
"what to show speeds and distances in",
"Aircraft broadcast knots and the log keeps knots, because that is "
"what the standard sends; this is the unit they are shown in. It "
"changes the heading of the live display, the speeds written beside "
"each aircraft on the map and in the report, and the distance unit "
"that goes with them -- nautical miles with knots, statute miles with "
"miles an hour, kilometres with km/h, so that one picture never "
"carries two different miles.",
choices=("knots", "mph", "kph"),
guidance="knots is what aviation uses and what the aircraft actually "
"said. mph or kph if that is what means something to you."),
O("draw_after", "Draw when finished", "Listening", "bool",
"draw the map as soon as the listening stops",
"Saves running the map separately. It uses the drawing options below.",
flags=("--map",)),
# -- drawing --------------------------------------------------------
O("picture", "Picture", "Drawing", "choice",
"what kind of picture to draw",
"gif is an animation that plays anywhere and needs nothing installed. "
"mp4 is smaller and smoother but needs ffmpeg. png is one still "
"picture of the whole session, every path drawn at once.",
choices=("gif", "mp4", "png"), flags=("--out",),
guidance="Start with gif. Use png when you want one picture to look at "
"or send."),
O("length", "Animation length", "Drawing", "float",
"how long the animation should run for",
"The whole session is fitted into this many seconds, so an evening of "
"flying plays in half a minute. Ignored when a speed is given.",
unit="s", minimum=1.0, flags=("--seconds",), example="30"),
O("speed", "Speed", "Drawing", "float",
"seconds of flying per second of animation (0 = fit to the length)",
"60 means a minute of real flying every second. Setting this overrides "
"the length above: a long session simply makes a longer animation.",
unit="x", minimum=0.0, flags=("--speed",), example="60"),
O("fps", "Frames a second", "Drawing", "float",
"how many frames of animation each second holds",
"Twelve is smooth enough for aircraft, which do not move quickly on a "
"map. A GIF can only hold whole hundredths of a second per frame, so "
"the real rate is rounded to the nearest one it can express.",
minimum=1.0, flags=("--fps",), example="12"),
O("width", "Picture width", "Drawing", "int",
"how many pixels across the picture is",
"The height follows from the shape of the area the aircraft covered, "
"so that a mile across looks like a mile up the picture.",
unit="px", minimum=160, flags=("--width",), example="960"),
O("trail", "Trail", "Drawing", "float",
"how much of the path to leave behind each aircraft (0 = all of it)",
"The whole flight is drawn by default, which is what makes the picture "
"a map of the evening rather than a snapshot. A number of seconds "
"leaves a comet tail instead, which is easier to follow when many "
"aircraft cross the same piece of sky.",
unit="s", minimum=0.0, flags=("--trail",), example="0"),
O("stale", "Forget after", "Drawing", "float",
"stop drawing an aircraft this long after its last report",
"Between reports an aircraft is dead-reckoned from the speed and "
"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("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 "
"nothing about where it was; over a coastline it says which airport it "
"left. The map is fetched from a standard tile server the first time an "
"area is drawn and kept on the disk afterwards, so drawing the same "
"evening again costs nothing and needs no network. A few dozen tiles "
"at most, dimmed so the aircraft stay the brightest thing on the "
"picture, and the credit the tiles require is written on it.",
flags=("--basemap",), off_flags=("--no-basemap",),
guidance="Turn it off for a picture with nothing but the tracks on it, "
"or where there is no network and no cached tiles."),
O("tile_url", "Tile server", "Drawing", "text",
"where the map tiles come from",
"Any server that serves 256-pixel tiles as {z}/{x}/{y}.png will do, "
"including one of your own. The default is the standard "
"OpenStreetMap one, whose tiles are free to use within its usage "
"policy: identify yourself, cache what you fetch, and do not bulk "
"download. This program does all three.",
example="https://tile.openstreetmap.org/{z}/{x}/{y}.png"),
O("labels", "Label the aircraft", "Drawing", "bool",
"write the callsign, height and speed beside each aircraft",
"Height is the flight level -- hundreds of feet -- the way it is said "
"on the radio. Turning labels off leaves the shapes of the traffic, "
"which is worth seeing on a busy evening.",
flags=("--labels",), off_flags=("--no-labels",)),
)
OPTION_GROUPS = ("Listening", "Drawing")
def in_group(group: str) -> list[Setting]:
return [o for o in OPTIONS if o.group == group]
def by_key(key: str) -> Setting | None:
return next((o for o in OPTIONS if o.key == key), None)
# What a zero means, per option: "0 s" is true and unhelpful.
_ZERO_MEANS = {"seconds": "until stopped", "speed": "fit to the length",
"trail": "the whole path"}
def format_option(option: Setting, value) -> str:
"""Render an option the way the menu should show it."""
if not value and option.key in _ZERO_MEANS:
return _ZERO_MEANS[option.key]
return format_value(option, value)
def describe(options: AircraftOptions) -> str:
"""One line for a menu: what listening and drawing would do now."""
how_long = ("until stopped" if not options.seconds
else f"{options.seconds:g} s")
where = "simulated" if options.simulate else "1090 MHz"
return (f"{where}, {how_long}, "
f"{'looked up' if options.lookup else 'no lookups'}, "
f"{options.picture}")
# ---------------------------------------------------------------------------
# Where the options are kept
# ---------------------------------------------------------------------------
def options_path(directory=None) -> Path:
from .config import DEFAULT_CONFIG_DIR
return Path(directory or DEFAULT_CONFIG_DIR) / "aircraft.yaml"
def load_options(directory=None) -> AircraftOptions:
"""The saved options, or the defaults. A broken file is not an error."""
options = AircraftOptions()
try:
body = yaml.safe_load(options_path(directory).read_text()) or {}
except (OSError, ValueError, yaml.YAMLError):
# A hand-edited file with a typo in it should cost the defaults, not
# the menu it is read from.
return options
if not isinstance(body, dict):
return options
known = set(options.__dict__)
for key, value in body.items():
if key in known and value is not None:
try:
setattr(options, key, type(getattr(options, key))(value))
except (TypeError, ValueError):
pass
return options
def save_options(options: AircraftOptions, directory=None) -> Path:
path = options_path(directory)
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w") as fh:
yaml.safe_dump(options.to_dict(), fh, sort_keys=False,
default_flow_style=False)
return path
def logs_in(directory) -> list[Path]:
"""Every ADS-B log in a directory, newest first."""
try:
found = list(Path(directory).expanduser().glob("adsb_*.jsonl"))
except OSError:
return []
return sorted(found, key=lambda p: p.stat().st_mtime, reverse=True)
def coordinates(text: str) -> tuple[float, float]:
"""A LAT,LON pair, falling back to the default sky."""
try:
lat, lon = (float(x) for x in str(text).split(",", 1))
return lat, lon
except (TypeError, ValueError):
return 47.55, -122.30
# ---------------------------------------------------------------------------
# Listening
# ---------------------------------------------------------------------------
@dataclass
class Heard:
"""What one listening session came to."""
frames: int = 0
registry: object = None
log_path: Path | None = None
report_path: Path | None = None
kml_path: Path | None = None
picture: object = None
tracks: list = field(default_factory=list)
@property
def aircraft(self) -> int:
return len(self.registry) if self.registry is not None else 0
def listen(console, options: AircraftOptions, output_dir: str,
log_path=None) -> Heard:
"""Park on 1090 MHz and write down the aircraft overhead.
Everything heard goes into the log as it arrives, because an aircraft is
overhead for four minutes and then gone: the screen is for the person
watching, and the log is for the report, the map and everything
afterwards.
"""
from rich.panel import Panel
from rich.text import Text
from .adsb import AircraftRegistry, SimulatedSky, decode_frames, default_sky
from .device import RtlSdrDevice, RtlSdrError
from .flightlog import FlightLog, read_logs, report, write_kml
from .flights import FlightBook
heard = Heard()
if options.simulate:
sky = default_sky(*coordinates(options.near))
device = SimulatedSky(sky, sample_rate=options.rate,
realtime=True).open()
console.print("[yellow]simulated: these aircraft are not there."
"[/yellow]")
else:
try:
device = RtlSdrDevice(index=options.device,
sample_rate=int(options.rate),
gain=options.gain,
agc=options.gain == "auto")
device.open()
except RtlSdrError as exc:
console.print(Panel(Text(str(exc)),
title="[red]cannot open the receiver",
border_style="red"))
return heard
started = time.time()
log = None
if options.log:
stamp = datetime.fromtimestamp(started).strftime("%Y-%m-%d_%H_%M_%S")
where = Path(log_path).expanduser() if log_path else \
Path(output_dir).expanduser() / f"adsb_{stamp}.jsonl"
try:
log = FlightLog(where, frequency=ADSB_HZ, sample_rate=options.rate,
receiver="simulated" if options.simulate else
f"device {options.device}", started=started)
except OSError as exc:
console.print(f"[red]cannot write {where}: {exc}[/red]")
registry = AircraftRegistry()
total = 0
console.print(f"[grey62]listening on {ADSB_HZ/1e6:g} MHz at "
f"{options.rate/1e6:g} MS/s — control-C to stop[/grey62]")
if log is not None:
console.print(f"[grey62]writing {log.path}[/grey62]")
# The registers are asked while the listening runs rather than after it,
# so a registration and a route appear on the line as they arrive. The
# book answers immediately with what it knows and fills itself in later.
book = FlightBook(online=options.lookup)
display, live = _open_display(console, options, book, started)
try:
device.tune(ADSB_HZ)
block = int(options.rate) # a second at a time
while True:
at = time.time()
samples = device.read_samples(block)
if samples is None or samples.size == 0:
break
for frame in decode_frames(samples, options.rate):
# The real time the frame arrived, not its offset in the
# block: everything downstream is a clock, and a log that
# started again from zero every second would be unusable.
when = at + frame.at_sample / options.rate
craft = registry.add(frame, when=when)
total += 1
if log is not None:
log.append(frame, craft, when=when)
if options.lookup:
book.get(frame.icao, craft.callsign)
if options.frames:
console.print(f"[cyan]{frame.icao}[/cyan] "
f"{frame.describe()}", highlight=False)
if live is not None:
display.update(registry, total,
log.path if log is not None else None)
live.update(display.render())
elif not options.frames and total:
console.print(f"[grey62]{len(registry)} aircraft, "
f"{total} frames[/grey62]", highlight=False)
if options.seconds and time.time() - started >= options.seconds:
break
except KeyboardInterrupt:
pass
finally:
if live is not None:
live.stop()
console.print("[grey62]stopped listening[/grey62]")
device.close()
if log is not None:
log.close()
heard.log_path = log.path
heard.frames = total
heard.registry = registry
if not registry:
console.print("[yellow]nothing heard. ADS-B needs an aerial cut for "
"1090 MHz; the whip that came with the dongle will "
"hear the airport and not much else.[/yellow]")
return heard
aircraft_table(console, registry, total, options.speed_unit)
if options.lookup:
book.wait(12.0)
book.save()
lookup_table(console, registry, book)
heard.tracks = read_logs(heard.log_path) if heard.log_path \
else tracks_from(registry)
if options.kml:
where = (heard.log_path.with_suffix(".kml") if heard.log_path
else Path(output_dir).expanduser() / "aircraft.kml")
written = write_kml(where, heard.tracks,
book if options.lookup else None,
unit=options.speed_unit)
heard.kml_path = written
console.print(f"[green]{written}[/green]" if written
else "[yellow]nothing was placed on the map[/yellow]")
if heard.log_path is not None:
told = heard.log_path.with_suffix(".txt")
try:
told.write_text("\n".join(report(
heard.tracks, book if options.lookup else None,
title=f"bandsaunter — aircraft heard "
f"{datetime.fromtimestamp(started):%Y-%m-%d %H:%M}",
unit=options.speed_unit)))
heard.report_path = told
console.print(f"[green]{told}[/green]")
except OSError as exc:
console.print(f"[red]cannot write {told}: {exc}[/red]")
if options.draw_after:
heard.picture = draw(console, options, heard.tracks,
out_path=_picture_path(heard.log_path, options,
output_dir),
book=book if options.lookup else None)
return heard
def _open_display(console, options: AircraftOptions, book, started: float):
"""A live table where there is a terminal to draw it on, else nothing.
Piped output, a test or a log file gets the running count it had before:
a display that redraws itself four times a second is unreadable as a
stream of text, and worse than useless in a file.
"""
if options.frames or not getattr(console, "is_terminal", False):
return None, None
from rich.live import Live
from .ui import AircraftDisplay
display = AircraftDisplay(console, book=book if options.lookup else None,
hold=options.hold, unit=options.speed_unit)
display.started = started
live = Live(display.render(), console=console, refresh_per_second=4,
screen=False, transient=False, vertical_overflow="crop")
live.start()
return display, live
def _picture_path(log_path, options: AircraftOptions, output_dir: str) -> Path:
suffix = "." + options.picture
if log_path is not None:
return Path(log_path).with_suffix(suffix)
return Path(output_dir).expanduser() / f"aircraft{suffix}"
# ---------------------------------------------------------------------------
# Drawing
# ---------------------------------------------------------------------------
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
out_path = Path(out_path).expanduser()
if out_path.suffix.lower() in (".mp4", ".mov", ".m4v") and \
not ffmpeg_available():
console.print("[yellow]ffmpeg is not installed; writing a GIF "
"instead[/yellow]")
out_path = out_path.with_suffix(".gif")
console.print(f"[grey62]drawing {out_path.name}…[/grey62]")
try:
drawn = animate(tracks, out_path, book=book, fps=options.fps,
seconds=options.length, speed=options.speed,
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)
except (OSError, RuntimeError, ValueError) as exc:
console.print(f"[red]{exc}[/red]")
return None
if drawn is None:
console.print("[yellow]nothing was placed on the map: no aircraft "
"reported a position[/yellow]")
return None
size = drawn.path.stat().st_size / 1e6
console.print(f"[green]{drawn.path}[/green] "
f"[grey62]{drawn.summary()}, {size:.1f} MB[/grey62]")
return drawn
def draw_log(console, options: AircraftOptions, paths, out_path=None):
"""Read logs back and draw them: the whole of what ``flights`` does."""
from .flightlog import read_logs
from .flights import FlightBook
paths = [Path(p).expanduser() for p in paths]
tracks = read_logs(paths)
if not tracks:
console.print(f"[yellow]{paths[0].name} holds no frames[/yellow]")
return None
book = FlightBook(online=options.lookup)
if options.lookup:
for track in tracks:
book.get(track.icao, track.callsign)
book.wait(20.0)
book.save()
if out_path is None:
out_path = paths[0].with_suffix("." + options.picture)
return draw(console, options, tracks, out_path,
book if options.lookup else None)
# ---------------------------------------------------------------------------
# What was heard, on the screen
# ---------------------------------------------------------------------------
def aircraft_table(console, registry, total: int,
unit: str = "knots") -> None:
"""What was heard, as it was heard: no register, only the air."""
from rich.table import Table
from .flightlog import in_speed, speed_label
t = Table(title=f"{len(registry)} aircraft, {total} frames", box=None,
header_style="bold")
for column in ("ICAO", "callsign", "altitude", "position",
f"speed ({speed_label(unit)})", "frames"):
t.add_column(column)
for craft in sorted(registry.aircraft.values(), key=lambda a: a.icao):
t.add_row(craft.icao, craft.callsign or "",
f"{craft.altitude_ft:,} ft" if craft.altitude_ft else "",
(f"{craft.latitude:.4f}, {craft.longitude:.4f}"
if craft.located else ""),
(f"{in_speed(craft.ground_speed_kt, unit):.0f} "
f"{craft.track_deg:.0f}°"
if craft.ground_speed_kt else ""),
str(craft.messages))
console.print(t)
def lookup_table(console, registry, book) -> None:
"""And what the registers say about them, kept separate on purpose."""
from rich.table import Table
rows = []
for craft in sorted(registry.aircraft.values(), key=lambda a: a.icao):
entry = book.get(craft.icao, craft.callsign)
told = entry.summary()
if told or entry.country:
rows.append((craft.icao, craft.callsign or "", entry.country,
told or ""))
if not rows:
return
t = Table(title="what the registers say", box=None, header_style="bold")
for column in ("ICAO", "callsign", "registered",
"aircraft, operator, route"):
t.add_column(column, overflow="fold")
for row in rows:
t.add_row(*row)
console.print(t)
def tracks_from(registry):
"""Tracks from a registry, for a session that wrote no log.
One position each: what is on the screen is all there is, because nothing
kept the ones before it.
"""
from .flightlog import Fix, Track
out = []
for craft in sorted(registry.aircraft.values(), key=lambda a: a.icao):
track = Track(icao=craft.icao, callsign=craft.callsign,
frames=craft.messages, first_seen=craft.first_seen,
last_seen=craft.last_seen)
if craft.located:
track.fixes.append(Fix(at=craft.last_seen, latitude=craft.latitude,
longitude=craft.longitude,
altitude_ft=craft.altitude_ft,
ground_speed_kt=craft.ground_speed_kt,
track_deg=craft.track_deg,
vertical_rate_fpm=craft.vertical_rate_fpm))
out.append(track)
return out
def summarise(options: AircraftOptions) -> str:
"""The options as one line, for a menu row."""
return ", ".join(f"{o.label.lower()} {format_option(o, getattr(options, o.key))}"
for o in OPTIONS[:3])

352
bandsaunter/basemap.py Normal file
View file

@ -0,0 +1,352 @@
"""The ground under the aircraft: map tiles, fetched, cached and dimmed.
A flight path over a black rectangle says how the aircraft moved and nothing
about where it was. Over a coastline it says which airport it left. So the
map behind the animation is a real one: standard raster tiles, fetched once,
kept on disk, reprojected onto the picture and dimmed until the aircraft are
the brightest thing on it.
Three things follow from using somebody else's tile server, and all three are
obligations rather than options. Tiles are **cached** and never fetched
twice. Every request identifies the program in its User-Agent. And the
attribution the licence requires is drawn onto the picture, not left to a
readme nobody ships with a GIF. A drawing is capped at a few dozen tiles: an
aircraft map is a hobby drawing, not a reason to hammer a volunteer-funded
service.
The PNG decoding is here for the same reason the PNG writing is in
:mod:`bandsaunter.images`: a scanner that cannot draw a map because an
imaging library is missing is worse than one that draws it from zlib and
numpy, which is all this needs.
"""
from __future__ import annotations
import math
import os
import struct
import time
import urllib.request
import zlib
from pathlib import Path
import numpy as np
from . import __version__
__all__ = ["decode_png", "tile_of", "choose_zoom", "fetch_tile", "mosaic",
"ground_under", "TILE_URL", "ATTRIBUTION", "MAX_TILES", "MAX_ZOOM",
"cache_dir", "PNGError"]
# The standard OpenStreetMap tiles. Any {z}/{x}/{y} server can be put here
# instead; nothing below knows anything about this one in particular.
TILE_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
# Drawn onto every picture that used the tiles. The licence requires it and
# a GIF travels without its readme.
ATTRIBUTION = "MAP DATA (C) OPENSTREETMAP CONTRIBUTORS"
# A drawing is worth a few dozen tiles and no more. Past that the zoom is
# reduced instead: a coarser map still says where the coastline is.
MAX_TILES = 40
MAX_ZOOM = 12
MIN_ZOOM = 2
TILE_PIXELS = 256
USER_AGENT = (f"bandsaunter/{__version__} "
"(+https://github.com/topics/rtl-sdr; aircraft map drawing)")
# Politeness between requests to a volunteer-funded service. Only paid on a
# tile that was not already on the disk.
FETCH_PAUSE = 0.12
class PNGError(ValueError):
"""A PNG this decoder cannot read."""
# ---------------------------------------------------------------------------
# Reading a PNG
# ---------------------------------------------------------------------------
_CHANNELS = {0: 1, 2: 3, 3: 1, 4: 2, 6: 4}
def decode_png(data: bytes) -> np.ndarray:
"""A PNG's pixels as an ``(h, w, 3)`` array of bytes.
Eight bits a channel and no interlacing, which is what every tile server
sends; anything else raises rather than being guessed at.
"""
if data[:8] != b"\x89PNG\r\n\x1a\x08"[:8] and data[:8] != \
b"\x89PNG\r\n\x1a\n":
raise PNGError("not a PNG")
width = height = depth = colour = interlace = 0
palette = None
body = bytearray()
at = 8
while at + 8 <= len(data):
length, tag = struct.unpack(">I4s", data[at:at + 8])
chunk = data[at + 8:at + 8 + length]
at += 12 + length
if tag == b"IHDR":
(width, height, depth, colour, _compression, _filter,
interlace) = struct.unpack(">IIBBBBB", chunk)
elif tag == b"PLTE":
palette = np.frombuffer(chunk, dtype=np.uint8).reshape(-1, 3)
elif tag == b"IDAT":
body += chunk
elif tag == b"IEND":
break
if depth != 8:
raise PNGError(f"{depth}-bit PNG: only 8 bits a channel is read here")
if interlace:
raise PNGError("interlaced PNG")
if colour not in _CHANNELS:
raise PNGError(f"colour type {colour}")
if not width or not height:
raise PNGError("no image")
channels = _CHANNELS[colour]
raw = _unfilter(zlib.decompress(bytes(body)), width, height, channels)
if colour == 3:
if palette is None:
raise PNGError("palette image with no palette")
return palette[np.clip(raw[:, :, 0], 0, len(palette) - 1)]
if colour == 0:
return np.repeat(raw, 3, axis=2)
if colour == 4:
return np.repeat(raw[:, :, :1], 3, axis=2)
return raw[:, :, :3]
def _unfilter(data: bytes, width: int, height: int,
channels: int) -> np.ndarray:
"""Undo the per-row filters PNG applies before compressing.
The five of them, from the specification. None and Up are whole-row
arithmetic; Sub is a running total along the row, which is a cumulative
sum once the bytes are grouped by which channel they belong to; Average
and Paeth each need the byte before them to have been worked out
already, so those two are the only ones that walk the row.
"""
stride = width * channels
if len(data) < height * (stride + 1):
raise PNGError("truncated image data")
out = np.zeros((height, stride), dtype=np.uint8)
previous = np.zeros(stride, dtype=np.uint8)
at = 0
for row in range(height):
kind = data[at]
line = np.frombuffer(data, dtype=np.uint8, count=stride,
offset=at + 1).astype(np.uint16)
at += stride + 1
if kind == 0:
current = line.astype(np.uint8)
elif kind == 1:
current = np.empty(stride, dtype=np.uint8)
for offset in range(channels):
current[offset::channels] = np.cumsum(
line[offset::channels], dtype=np.uint32) % 256
elif kind == 2:
current = ((line + previous) % 256).astype(np.uint8)
elif kind in (3, 4):
current = _walk_row(bytes(line.astype(np.uint8)), previous,
channels, kind)
else:
raise PNGError(f"filter type {kind}")
out[row] = current
previous = current
return out.reshape(height, width, channels)
def _walk_row(line: bytes, previous: np.ndarray, channels: int,
kind: int) -> np.ndarray:
"""Average and Paeth: each byte needs the one ``channels`` back."""
up = previous.tolist()
out = [0] * len(line)
for i, value in enumerate(line):
left = out[i - channels] if i >= channels else 0
above = up[i]
if kind == 3:
out[i] = (value + ((left + above) >> 1)) & 0xFF
else:
upleft = up[i - channels] if i >= channels else 0
base = left + above - upleft
da, db, dc = abs(base - left), abs(base - above), abs(base - upleft)
near = left if (da <= db and da <= dc) else (
above if db <= dc else upleft)
out[i] = (value + near) & 0xFF
return np.array(out, dtype=np.uint8)
# ---------------------------------------------------------------------------
# Which tiles
# ---------------------------------------------------------------------------
def tile_of(lat: float, lon: float, zoom: int) -> tuple[float, float]:
"""Where a coordinate falls in the tile grid, in fractional tiles.
Web Mercator, which is what every {z}/{x}/{y} tile server serves and is
not what this program's maps are drawn in -- hence the resampling
further down rather than a straight paste.
"""
lat = max(-85.05112878, min(85.05112878, lat))
n = float(2 ** zoom)
x = (lon + 180.0) / 360.0 * n
radians = math.radians(lat)
y = (1.0 - math.asinh(math.tan(radians)) / math.pi) / 2.0 * n
return x, y
def choose_zoom(south: float, west: float, north: float, east: float,
max_tiles: int = MAX_TILES, most: int = MAX_ZOOM) -> int:
"""The most detail that fits inside the tile budget."""
for zoom in range(min(most, MAX_ZOOM), MIN_ZOOM - 1, -1):
x0, y0 = tile_of(north, west, zoom)
x1, y1 = tile_of(south, east, zoom)
wide = int(math.floor(x1)) - int(math.floor(x0)) + 1
tall = int(math.floor(y1)) - int(math.floor(y0)) + 1
if wide * tall <= max_tiles:
return zoom
return MIN_ZOOM
# ---------------------------------------------------------------------------
# Fetching them, once
# ---------------------------------------------------------------------------
def cache_dir() -> Path:
root = os.environ.get("XDG_CACHE_HOME") or "~/.cache"
return Path(root).expanduser() / "bandsaunter" / "tiles"
def fetch_tile(zoom: int, x: int, y: int, url: str = TILE_URL,
timeout: float = 10.0, cache: Path | None = None) -> bytes | None:
"""One tile, from the disk if it has ever been fetched before.
Returns the PNG bytes, or None if it could not be had. A missing tile is
not an error: the map is drawn with a hole in it, which is better than no
map and much better than an exception in the middle of an animation.
"""
where = (cache if cache is not None else cache_dir()) / str(zoom) / str(x)
path = where / f"{y}.png"
try:
return path.read_bytes()
except OSError:
pass
request = urllib.request.Request(url.format(z=zoom, x=x, y=y),
headers={"User-Agent": USER_AGENT})
try:
with urllib.request.urlopen(request, timeout=timeout) as answer:
body = answer.read(2_000_000)
except Exception:
return None
try:
where.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".tmp")
tmp.write_bytes(body)
tmp.replace(path)
except OSError:
pass # an unwritable cache is not a reason to stop
return body
def mosaic(south: float, west: float, north: float, east: float, zoom: int,
fetch=fetch_tile, pause: float = FETCH_PAUSE, **kw):
"""Every tile the box touches, stitched into one image.
Returns the pixels and where their top-left corner sits in the world, in
tile-grid pixels at this zoom, so the resampling below can place them.
"""
x0, y0 = tile_of(north, west, zoom)
x1, y1 = tile_of(south, east, zoom)
left, top = int(math.floor(x0)), int(math.floor(y0))
right, bottom = int(math.floor(x1)), int(math.floor(y1))
span = 2 ** zoom
wide, tall = right - left + 1, bottom - top + 1
if wide <= 0 or tall <= 0 or wide * tall > MAX_TILES * 4:
return None, 0, 0
canvas = np.zeros((tall * TILE_PIXELS, wide * TILE_PIXELS, 3),
dtype=np.uint8)
got = 0
for row in range(tall):
for column in range(wide):
tx, ty = (left + column) % span, top + row
if not 0 <= ty < span:
continue
body = fetch(zoom, tx, ty, **kw)
if body is None:
continue
try:
tile = decode_png(body)
except (PNGError, zlib.error, ValueError):
continue
if tile.shape[0] != TILE_PIXELS or tile.shape[1] != TILE_PIXELS:
continue
canvas[row * TILE_PIXELS:(row + 1) * TILE_PIXELS,
column * TILE_PIXELS:(column + 1) * TILE_PIXELS] = tile
got += 1
if pause:
time.sleep(pause)
if not got:
return None, 0, 0
return canvas, left * TILE_PIXELS, top * TILE_PIXELS
# ---------------------------------------------------------------------------
# Putting it under the picture
# ---------------------------------------------------------------------------
def ground_under(south: float, west: float, north: float, east: float,
width: int, height: int, shades: int = 32,
fetch=fetch_tile, zoom: int | None = None,
**kw) -> np.ndarray | None:
"""The map for one picture, as ``shades`` levels of brightness.
The tiles are Web Mercator and the picture is not, so every output pixel
asks the mosaic where its own latitude and longitude landed rather than
the mosaic being pasted in. Over the couple of hundred miles a receiver
hears, the difference is a few pixels of drift at the top of the frame --
which is a few pixels an aircraft would be drawn wrong by, and the whole
point of putting a coastline under it is that the coastline is where the
aircraft was.
Returns None when nothing could be fetched, which the caller draws as the
plain grid it drew before.
"""
if width < 1 or height < 1 or north <= south or east <= west:
return None
zoom = choose_zoom(south, west, north, east) if zoom is None else zoom
tiles, origin_x, origin_y = mosaic(south, west, north, east, zoom,
fetch=fetch, **kw)
if tiles is None:
return None
lons = west + (east - west) * (np.arange(width) + 0.5) / width
lats = north - (north - south) * (np.arange(height) + 0.5) / height
span = float(2 ** zoom) * TILE_PIXELS
xs = (lons + 180.0) / 360.0 * span - origin_x
clipped = np.clip(lats, -85.05112878, 85.05112878)
ys = (1.0 - np.arcsinh(np.tan(np.radians(clipped))) / math.pi) / 2.0 \
* span - origin_y
cols = np.clip(np.rint(xs).astype(np.int64), 0, tiles.shape[1] - 1)
rows = np.clip(np.rint(ys).astype(np.int64), 0, tiles.shape[0] - 1)
picture = tiles[rows[:, None], cols[None, :]]
# Brightness only, inverted, and dimmed. Inverted because a printed map
# is ink on white paper and this picture is the other way round: the
# things drawn on the map -- coastlines, roads, the names of towns --
# are the dark parts of a tile, and they are what should show against a
# night background. Dimmed because the map is the ground under the
# aircraft rather than the subject: anything drawn on top has to stay the
# brightest thing on the picture.
luma = (0.299 * picture[:, :, 0] + 0.587 * picture[:, :, 1]
+ 0.114 * picture[:, :, 2])
low, high = float(luma.min()), float(luma.max())
if high - low < 1.0:
levels = np.zeros_like(luma)
else:
levels = 1.0 - (luma - low) / (high - low)
return np.clip((levels * (shades - 1)).round(), 0,
shades - 1).astype(np.uint8)

View file

@ -7,7 +7,6 @@ import json
import signal
import sys
import time
from datetime import datetime
from pathlib import Path
from rich.console import Console
@ -23,8 +22,7 @@ from .bandplan import CATEGORIES, PRESETS, fmt_hz, in_category, search
from .config import (DEFAULT_CONFIG_DIR, DEFAULT_CONFIG_PATH, ScanConfig,
is_first_run, list_profiles, load_config, load_default,
save_config, save_default)
from .device import (RtlSdrDevice, RtlSdrError, list_devices,
set_driver_messages)
from .device import RtlSdrError, list_devices, set_driver_messages
from .librtlsdr import load_error
from . import settings as st
from .ranges import (RangeError, ScanRange, build_plan, parse_range_list)
@ -184,6 +182,13 @@ examples:
help="invent a sky, for a receiver with no aerial")
ad.add_argument("--near", default=None, metavar="LAT,LON",
help="where the simulated aircraft are flying")
ad.add_argument("--speed-unit", default=None,
choices=("knots", "mph", "kph"),
help="what to show speeds and distances in "
"(default: knots, which is what aircraft broadcast)")
ad.add_argument("--no-basemap", dest="basemap", action="store_false",
default=None,
help="draw the map with no real map under it")
ad.set_defaults(log_frames=True, lookup=True)
# -- flights --------------------------------------------------------------
@ -217,6 +222,15 @@ examples:
help="also write the flight paths for Google Earth")
fl.add_argument("--report", nargs="?", const="", default=None, metavar="FILE",
help="also write the readable report to a file")
fl.add_argument("--speed-unit", default=None,
choices=("knots", "mph", "kph"),
help="what to show speeds and distances in "
"(default: knots, which is what aircraft broadcast)")
fl.add_argument("--no-basemap", dest="basemap", action="store_false",
default=None,
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.set_defaults(labels=True, draw=True, lookup=True)
# -- analyse ------------------------------------------------------------
@ -338,6 +352,33 @@ def _maybe_first_run(cfg: ScanConfig, args) -> None:
console.print()
def _warn_about_aircraft_bands(cfg: ScanConfig) -> None:
"""Say so when a sweep is pointed at something it cannot decode.
The band plan lists 1090 MHz because that is where ADS-B is, so choosing
it from the band plan is the obvious thing to do and the wrong one. The
sweep is not stopped -- looking at the spectrum there is a fair thing to
want -- but it no longer happens silently.
"""
from . import aircraft as air
warning = air.scanning_aircraft_band(cfg.ranges)
if not warning:
return
console.print(Panel(
Text.from_markup(
f"{escape(warning)}\n\n"
"[bold]bandsaunter adsb[/bold] decodes it properly: aircraft, "
"positions, altitudes and speeds, written to a log.\n"
"[bold]bandsaunter flights[/bold] then draws where they went.\n\n"
"[grey62]Both are in the menus as well, under Aircraft "
"(ADS-B). Scanning it anyway is fine if what you want is the "
"raw spectrum \u2014 add --save-iq to keep the samples."
"[/grey62]"),
title="[yellow]this band needs the aircraft mode",
border_style="yellow", padding=(0, 1)))
def _make_device(cfg: ScanConfig, simulate: bool):
if simulate:
from .simulator import SimulatedDevice
@ -394,6 +435,8 @@ def cmd_scan(args) -> int:
console.print(f"[red]{e}[/red]")
return 2
_warn_about_aircraft_bands(cfg)
if args.dry_run:
_print_plan(cfg)
return 0
@ -991,15 +1034,11 @@ def cmd_adsb(args) -> int:
and a half kilohertz wide before anything sees it, and a megabit will not
go through that.
Everything heard goes into a log as it arrives, because an aircraft is
overhead for four minutes and then gone: the summary on the screen is for
the person watching, and the log is for everything afterwards -- the
report, the map and the animation.
The listening itself is in :mod:`bandsaunter.aircraft`, because the menus
do exactly the same thing and neither front end should own it.
"""
from .adsb import (ADSB_HZ, AircraftRegistry, SAMPLE_RATE, SimulatedSky,
decode_frames, default_sky)
from .flightlog import FlightLog, read_logs, report, write_kml
from .flights import FlightBook
from .adsb import SAMPLE_RATE
from . import aircraft as air
cfg, _ = load_default()
if args.rate < SAMPLE_RATE:
@ -1007,216 +1046,41 @@ def cmd_adsb(args) -> int:
f"{args.rate/1e6:g} is not enough to see a bit.[/red]")
return 2
if args.simulate:
sky = default_sky(*_near(args.near)) if args.near else default_sky()
device = SimulatedSky(sky, sample_rate=args.rate,
realtime=True).open()
console.print("[yellow]simulated: these aircraft are not there."
"[/yellow]")
else:
try:
device = RtlSdrDevice(index=args.device, sample_rate=int(args.rate),
gain=args.gain, agc=args.gain == "auto")
device.open()
except RtlSdrError as exc:
console.print(Panel(Text(str(exc)),
title="[red]cannot open the receiver",
border_style="red"))
return 1
options = air.load_options()
options.seconds = args.seconds
options.rate = args.rate
options.gain = args.gain
options.device = args.device
options.frames = args.frames
options.log = args.log_frames
options.lookup = args.lookup
options.simulate = args.simulate
options.kml = args.kml is not None
options.draw_after = args.map is not None
if args.near:
options.near = args.near
if args.speed_unit:
options.speed_unit = args.speed_unit
if args.basemap is not None:
options.basemap = args.basemap
if args.map:
options.picture = Path(args.map).suffix.lstrip(".") or options.picture
started = time.time()
log = None
if args.log_frames:
stamp = datetime.fromtimestamp(started).strftime("%Y-%m-%d_%H_%M_%S")
where = Path(args.log).expanduser() if args.log else \
Path(cfg.output_dir).expanduser() / f"adsb_{stamp}.jsonl"
try:
log = FlightLog(where, frequency=ADSB_HZ, sample_rate=args.rate,
receiver="simulated" if args.simulate else
f"device {args.device}", started=started)
except OSError as exc:
console.print(f"[red]cannot write {where}: {exc}[/red]")
log = None
registry = AircraftRegistry()
total = 0
console.print(f"[grey62]listening on {ADSB_HZ/1e6:g} MHz at "
f"{args.rate/1e6:g} MS/s — control-C to stop[/grey62]")
if log is not None:
console.print(f"[grey62]writing {log.path}[/grey62]")
try:
device.tune(ADSB_HZ)
block = int(args.rate) # a second at a time
while True:
at = time.time()
samples = device.read_samples(block)
if samples is None or samples.size == 0:
break
for frame in decode_frames(samples, args.rate):
# The real time the frame arrived, not its offset in the
# block: everything downstream is a clock, and a log that
# started again from zero every second would be unusable.
when = at + frame.at_sample / args.rate
craft = registry.add(frame, when=when)
total += 1
if log is not None:
log.append(frame, craft, when=when)
if args.frames:
console.print(f"[cyan]{frame.icao}[/cyan] "
f"{escape(frame.describe())}",
highlight=False)
if not args.frames and total:
console.print(f"[grey62]{len(registry)} aircraft, "
f"{total} frames[/grey62]", highlight=False)
if args.seconds and time.time() - started >= args.seconds:
break
except KeyboardInterrupt:
pass
finally:
device.close()
if log is not None:
log.close()
if not registry:
console.print("[yellow]nothing heard. ADS-B needs an aerial cut for "
"1090 MHz; the whip that came with the dongle will "
"hear the airport and not much else.[/yellow]")
heard = air.listen(console, options, cfg.output_dir,
log_path=args.log)
if not heard.aircraft:
return 1
book = FlightBook(online=args.lookup)
_aircraft_table(registry, book, total)
if args.lookup:
book.wait(12.0)
book.save()
_lookup_table(registry, book)
tracks = read_logs(log.path) if log is not None else _tracks_from(registry)
if args.kml is not None:
where = Path(args.kml).expanduser() if args.kml else \
(log.path.with_suffix(".kml") if log is not None
else Path("aircraft.kml"))
written = write_kml(where, tracks, book if args.lookup else None)
console.print(f"[green]{written}[/green]" if written
else "[yellow]nothing was placed on the map[/yellow]")
if log is not None:
told = log.path.with_suffix(".txt")
try:
told.write_text("\n".join(report(
tracks, book if args.lookup else None,
title=f"bandsaunter — aircraft heard "
f"{datetime.fromtimestamp(started):%Y-%m-%d %H:%M}")))
console.print(f"[green]{told}[/green]")
except OSError as exc:
console.print(f"[red]cannot write {told}: {exc}[/red]")
if args.map is not None:
_draw_flights(tracks, args.map or (log.path.with_suffix(".gif")
if log is not None
else Path("aircraft.gif")),
book if args.lookup else None)
elif log is not None:
console.print(f"[grey62]draw it: bandsaunter flights {log.path}"
if args.kml and heard.kml_path is None:
# An explicit path was given, so honour it rather than the one beside
# the log that `listen` writes by default.
from .flightlog import write_kml
write_kml(Path(args.kml).expanduser(), heard.tracks)
if heard.log_path is not None and not options.draw_after:
console.print(f"[grey62]draw it: bandsaunter flights {heard.log_path}"
"[/grey62]")
return 0
def _near(text: str) -> tuple[float, float]:
"""Read a LAT,LON pair, falling back to the default sky."""
try:
lat, lon = (float(x) for x in str(text).split(",", 1))
return lat, lon
except (TypeError, ValueError):
console.print(f"[yellow]cannot read {text!r} as a latitude and "
"longitude; flying somewhere else instead[/yellow]")
return 47.55, -122.30
def _tracks_from(registry):
"""Tracks from a registry, for a session that wrote no log.
One position each: what is on the screen is all there is, because nothing
kept the ones before it.
"""
from .flightlog import Fix, Track
out = []
for craft in sorted(registry.aircraft.values(), key=lambda a: a.icao):
track = Track(icao=craft.icao, callsign=craft.callsign,
frames=craft.messages, first_seen=craft.first_seen,
last_seen=craft.last_seen)
if craft.located:
track.fixes.append(Fix(at=craft.last_seen, latitude=craft.latitude,
longitude=craft.longitude,
altitude_ft=craft.altitude_ft,
ground_speed_kt=craft.ground_speed_kt,
track_deg=craft.track_deg,
vertical_rate_fpm=craft.vertical_rate_fpm))
out.append(track)
return out
def _aircraft_table(registry, book, total: int) -> None:
"""What was heard, as it was heard: no register, only the air."""
t = Table(title=f"{len(registry)} aircraft, {total} frames", box=None,
header_style="bold")
for column in ("ICAO", "callsign", "altitude", "position", "speed",
"frames"):
t.add_column(column)
for craft in sorted(registry.aircraft.values(), key=lambda a: a.icao):
t.add_row(craft.icao, craft.callsign or "",
f"{craft.altitude_ft:,} ft" if craft.altitude_ft else "",
(f"{craft.latitude:.4f}, {craft.longitude:.4f}"
if craft.located else ""),
(f"{craft.ground_speed_kt:.0f} kt {craft.track_deg:.0f}°"
if craft.ground_speed_kt else ""),
str(craft.messages))
console.print(t)
def _lookup_table(registry, book) -> None:
"""And what the registers say about them, kept separate on purpose."""
rows = []
for craft in sorted(registry.aircraft.values(), key=lambda a: a.icao):
entry = book.get(craft.icao, craft.callsign)
told = entry.summary()
if told or entry.country:
rows.append((craft.icao, craft.callsign or "", entry.country,
told or ""))
if not rows:
return
t = Table(title="what the registers say", box=None, header_style="bold")
for column in ("ICAO", "callsign", "registered", "aircraft, operator, route"):
t.add_column(column, overflow="fold")
for row in rows:
t.add_row(*row)
console.print(t)
def _draw_flights(tracks, out_path, book, **over) -> bool:
"""Draw the animation, saying what it is drawing and what came out."""
from .flightmap import animate, ffmpeg_available
out_path = Path(out_path).expanduser()
if out_path.suffix.lower() in (".mp4", ".mov", ".m4v") and \
not ffmpeg_available():
console.print("[yellow]ffmpeg is not installed; writing a GIF "
"instead[/yellow]")
out_path = out_path.with_suffix(".gif")
console.print(f"[grey62]drawing {out_path.name}…[/grey62]")
try:
drawn = animate(tracks, out_path, book=book, **over)
except (OSError, RuntimeError, ValueError) as exc:
console.print(f"[red]{exc}[/red]")
return False
if drawn is None:
console.print("[yellow]nothing was placed on the map: no aircraft "
"reported a position[/yellow]")
return False
size = drawn.path.stat().st_size / 1e6
console.print(f"[green]{drawn.path}[/green] "
f"[grey62]{drawn.summary()}, {size:.1f} MB[/grey62]")
return True
def cmd_flights(args) -> int:
"""Turn a log of ADS-B frames into something worth looking at.
@ -1224,12 +1088,13 @@ def cmd_flights(args) -> int:
back, asks who the aircraft were, prints what it found and draws the
whole evening as a map with the clock running.
"""
from . import aircraft as air
from .flightlog import read_logs, report, write_kml
from .flights import FlightBook
cfg, _ = load_default()
paths = [Path(p).expanduser() for p in args.path] if args.path \
else _newest_log(Path(cfg.output_dir).expanduser())
else air.logs_in(cfg.output_dir)[:1]
if not paths:
console.print("[yellow]no ADS-B logs found. Record one with "
"`bandsaunter adsb`.[/yellow]")
@ -1250,8 +1115,12 @@ 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)
lines = report(tracks, book if args.lookup else None, title=title,
unit=options.speed_unit)
console.print(escape("\n".join(lines)), highlight=False)
if args.report is not None:
where = Path(args.report).expanduser() if args.report \
@ -1264,31 +1133,31 @@ def cmd_flights(args) -> int:
if args.kml is not None:
where = Path(args.kml).expanduser() if args.kml \
else paths[0].with_suffix(".kml")
written = write_kml(where, tracks, book if args.lookup else None)
written = write_kml(where, tracks, book if args.lookup else None,
unit=options.speed_unit)
console.print(f"[green]{written}[/green]" if written
else "[yellow]nothing was placed on the map[/yellow]")
if not args.draw:
return 0
options.fps = args.fps
options.length = args.seconds
options.speed = args.speed
options.width = args.width
options.trail = args.trail
options.stale = args.stale
options.labels = args.labels
if args.basemap is not None:
options.basemap = args.basemap
if args.tiles:
options.tile_url = args.tiles
out = Path(args.out).expanduser() if args.out else \
paths[0].with_suffix(".gif")
drawn = _draw_flights(tracks, out, book if args.lookup else None,
fps=args.fps, seconds=args.seconds, speed=args.speed,
width=args.width, trail_seconds=args.trail,
stale=args.stale, labels=args.labels)
paths[0].with_suffix("." + options.picture)
drawn = air.draw(console, options, tracks, out,
book if args.lookup else None)
return 0 if drawn else 1
def _newest_log(directory: Path) -> list[Path]:
"""The last ADS-B log written, which is nearly always the one wanted."""
try:
logs = sorted(directory.glob("adsb_*.jsonl"),
key=lambda p: p.stat().st_mtime)
except OSError:
return []
return logs[-1:]
def cmd_analyze(args) -> int:
import numpy as np
from .classify import classify

View file

@ -26,7 +26,9 @@ from datetime import datetime
from pathlib import Path
__all__ = ["Fix", "Track", "FlightLog", "read_logs", "report",
"write_kml", "LOG_VERSION", "EARTH_NM"]
"write_kml", "LOG_VERSION", "EARTH_NM", "SPEED_UNITS",
"speed_label", "in_speed", "distance_label", "in_distance",
"DEFAULT_SPEED_UNIT"]
LOG_VERSION = 1
@ -34,6 +36,48 @@ LOG_VERSION = 1
# every other number in this file is already in.
EARTH_NM = 3440.065
# What the aircraft says is knots and nautical miles, because that is what
# the standard sends and what the log therefore holds. Anything else is a
# conversion done at the moment of showing it to somebody, so the recorded
# data is never the one that had arithmetic applied to it.
#
# name: (speed label, knots -> this, distance label, nautical miles -> this)
SPEED_UNITS: dict[str, tuple[str, float, str, float]] = {
"knots": ("kt", 1.0, "nm", 1.0),
"mph": ("mph", 1.150779, "mi", 1.150779),
"kph": ("km/h", 1.852, "km", 1.852),
}
DEFAULT_SPEED_UNIT = "knots"
def speed_unit(name: str) -> tuple[str, float, str, float]:
"""The conversion for a unit name, falling back to what aircraft use."""
return SPEED_UNITS.get((name or "").strip().lower(),
SPEED_UNITS[DEFAULT_SPEED_UNIT])
def speed_label(name: str = DEFAULT_SPEED_UNIT) -> str:
"""What to write after a speed: kt, mph or km/h."""
return speed_unit(name)[0]
def in_speed(knots: float, name: str = DEFAULT_SPEED_UNIT) -> float:
"""A speed in knots, as the unit asked for."""
return float(knots) * speed_unit(name)[1]
def distance_label(name: str = DEFAULT_SPEED_UNIT) -> str:
"""The distance unit that goes with a speed unit: nm, mi or km.
Miles an hour with distances in nautical miles would be two different
miles on one picture, which is worse than either on its own.
"""
return speed_unit(name)[2]
def in_distance(nm: float, name: str = DEFAULT_SPEED_UNIT) -> float:
return float(nm) * speed_unit(name)[3]
@dataclass
class Fix:
@ -191,7 +235,7 @@ class Track:
out.append(now)
return out
def describe(self) -> str:
def describe(self, unit: str = DEFAULT_SPEED_UNIT) -> str:
"""One line: who, where from and to, how far and how high."""
bits = [self.icao]
if self.callsign:
@ -200,12 +244,14 @@ class Track:
first, last = self.fixes[0], self.fixes[-1]
bits.append(f"{first.latitude:.3f},{first.longitude:.3f}"
f"{last.latitude:.3f},{last.longitude:.3f}")
bits.append(f"{self.distance_nm:.0f} nm")
bits.append(f"{in_distance(self.distance_nm, unit):.0f} "
f"{distance_label(unit)}")
low, high = self.altitude_range
if high:
bits.append(f"{low}{high} ft" if low != high else f"{high} ft")
if self.top_speed_kt:
bits.append(f"{self.top_speed_kt:.0f} kt")
bits.append(f"{in_speed(self.top_speed_kt, unit):.0f} "
f"{speed_label(unit)}")
bits.append(f"{self.frames} frames")
return " ".join(bits)
@ -438,7 +484,8 @@ def _carry_forward(track: Track) -> None:
# The readable report
# ---------------------------------------------------------------------------
def report(tracks: list[Track], book=None, title: str = "") -> list[str]:
def report(tracks: list[Track], book=None, title: str = "",
unit: str = DEFAULT_SPEED_UNIT) -> list[str]:
"""One block per aircraft, in the order they were first heard.
``book`` is a FlightBook, or None. Everything it can add is printed under
@ -478,14 +525,17 @@ def report(tracks: list[Track], book=None, title: str = "") -> list[str]:
first, last = track.fixes[0], track.fixes[-1]
out.append(f" from: {first.latitude:.4f}, {first.longitude:.4f}")
out.append(f" to: {last.latitude:.4f}, {last.longitude:.4f}")
out.append(f" flew: {track.distance_nm:.1f} nm over "
out.append(f" flew: {in_distance(track.distance_nm, unit):.1f} "
f"{distance_label(unit)} over "
f"{len(track.fixes)} positions")
low, high = track.altitude_range
if high:
out.append(f" altitude: {low:,} to {high:,} ft"
if low != high else f" altitude: {high:,} ft")
if track.top_speed_kt:
out.append(f" speed: up to {track.top_speed_kt:.0f} kt")
out.append(f" speed: up to "
f"{in_speed(track.top_speed_kt, unit):.0f} "
f"{speed_label(unit)}")
out.append("")
return out
@ -510,7 +560,8 @@ def _span(seconds: float) -> str:
# ---------------------------------------------------------------------------
def write_kml(path, tracks: list[Track], book=None,
title: str = "bandsaunter — aircraft heard") -> Path | None:
title: str = "bandsaunter — aircraft heard",
unit: str = DEFAULT_SPEED_UNIT) -> Path | None:
"""Every track as a line on the globe, with a pin where it was last seen."""
from xml.sax.saxutils import escape as xml_escape
@ -524,7 +575,7 @@ def write_kml(path, tracks: list[Track], book=None,
"<width>2</width></LineStyle></Style>"]
for track in located:
entry = book.get(track.icao, track.callsign) if book is not None else None
told = "\n".join([track.describe()]
told = "\n".join([track.describe(unit)]
+ (entry.details() if entry is not None else []))
name = f"{track.icao} {track.callsign}".strip()
line = " ".join(f"{f.longitude:.6f},{f.latitude:.6f},"

View file

@ -33,11 +33,14 @@ from pathlib import Path
import numpy as np
from .flightlog import Track, distance_nm
from .flightlog import (DEFAULT_SPEED_UNIT, Track, distance_label,
distance_nm, in_distance, in_speed,
speed_label)
from .images import GLYPH_H, draw_text, text_width, write_png
__all__ = ["Animation", "Projection", "animate", "render_frame", "write_gif",
"write_mp4", "fit", "PALETTE", "ffmpeg_available"]
"write_mp4", "fit", "PALETTE", "ffmpeg_available",
"ground_for", "background"]
# ---------------------------------------------------------------------------
@ -52,7 +55,9 @@ BG, GRID, INK, DIM, PANEL, AIRPORT, ROUTE, WHITE = range(8)
RAMP = 8 # 32 altitude colours from here
TRAIL = RAMP + 32 # the same 32, dimmed, for the path just flown
OLD = TRAIL + 32 # and dimmer still, for the path flown earlier
GROUND = OLD + 32 # 32 shades of the map underneath
RAMP_STEPS = 32
GROUND_SHADES = 32
TRANSPARENT = 255 # never drawn with: it means "as the frame before"
CEILING_FT = 45_000.0
@ -100,9 +105,25 @@ def _dimmed(colours, factor: float) -> list[tuple[int, int, int]]:
return [tuple(int(round(c * factor)) for c in rgb) for rgb in colours]
# The map under the aircraft: night-blue, dark at the bottom of the range so
# that a trail crossing a city is still the brightest thing there.
GROUND_LOW = (12, 14, 20)
GROUND_HIGH = (74, 82, 96)
def _ground_shades(steps: int = GROUND_SHADES) -> list[tuple[int, int, int]]:
out = []
for i in range(steps):
part = i / max(1, steps - 1)
out.append(tuple(int(round(a + (b - a) * part))
for a, b in zip(GROUND_LOW, GROUND_HIGH)))
return out
def _palette() -> np.ndarray:
ramp = _ramp()
table = list(_FIXED) + ramp + _dimmed(ramp, 0.55) + _dimmed(ramp, 0.30)
table = (list(_FIXED) + ramp + _dimmed(ramp, 0.55) + _dimmed(ramp, 0.30)
+ _ground_shades())
table += [(0, 0, 0)] * (256 - len(table))
return np.array(table[:256], dtype=np.uint8)
@ -199,7 +220,7 @@ def _box(img: np.ndarray, x0: int, y0: int, x1: int, y1: int,
# ---------------------------------------------------------------------------
TITLE_H = 26
LEGEND_H = 30
LEGEND_H = 32
MARGIN = 10
@ -307,26 +328,40 @@ def _degrees(value: float, axis: str) -> str:
return f"{abs(value):.2f}{hemisphere}"
def background(view: Projection, title: str = "",
airports=()) -> np.ndarray:
"""The map without anything flying on it: grid, scale, key and title."""
def background(view: Projection, title: str = "", airports=(),
unit: str = DEFAULT_SPEED_UNIT, ground=None,
attribution: str = "") -> np.ndarray:
"""The map without anything flying on it: ground, grid, scale, key, title.
``ground`` is the real map underneath, as brightness levels covering the
body of the picture, or None for the plain grid. Where there is one the
graticule is drawn lighter over it, because the coastline is doing the
work the grid was there to do.
"""
width, height = canvas_size(view)
img = np.full((height, width), BG, dtype=np.uint8)
if ground is not None:
patch = np.asarray(ground, dtype=np.uint8)
rows = min(view.height, patch.shape[0])
cols = min(view.width, patch.shape[1])
img[view.top:view.top + rows, view.left:view.left + cols] = \
GROUND + np.clip(patch[:rows, :cols], 0, GROUND_SHADES - 1)
_box(img, view.left - 1, view.top - 1, view.left + view.width,
view.top + view.height, GRID)
step_lat = _grid_step(view.north - view.south)
step_lon = _grid_step(view.east - view.west)
dashes = 6 if ground is not None else 3
lat = math.ceil(view.south / step_lat) * step_lat
while lat <= view.north:
_, y = view.xy(lat, view.west)
img[y, view.left:view.left + view.width:3] = GRID
img[y, view.left:view.left + view.width:dashes] = GRID
draw_text(img, view.left + 3, y - GLYPH_H - 1, _degrees(lat, "lat"), DIM)
lat += step_lat
lon = math.ceil(view.west / step_lon) * step_lon
while lon <= view.east:
x, _ = view.xy(view.south, lon)
img[view.top:view.top + view.height:3, x] = GRID
img[view.top:view.top + view.height:dashes, x] = GRID
draw_text(img, x + 3, view.top + view.height - GLYPH_H - 3,
_degrees(lon, "lon"), DIM)
lon += step_lon
@ -340,16 +375,26 @@ def background(view: Projection, title: str = "",
if title:
draw_text(img, MARGIN, (TITLE_H - GLYPH_H) // 2, title, INK)
_scale_bar(img, view)
if attribution:
# Whoever's tiles those are gets their name on the picture: a GIF
# travels without the readme that would otherwise carry it.
draw_text(img, MARGIN, view.top + view.height + 22, attribution, GRID)
_scale_bar(img, view, unit)
_key(img, view)
return img
def _scale_bar(img: np.ndarray, view: Projection) -> None:
"""A bar of a round number of nautical miles, for judging distance."""
per_nm = view.width / max(1e-9, view.width_nm)
for miles in (200, 100, 50, 20, 10, 5, 2, 1):
pixels = int(round(miles * per_nm))
def _scale_bar(img: np.ndarray, view: Projection,
unit: str = DEFAULT_SPEED_UNIT) -> None:
"""A bar of a round number of miles, for judging distance.
In whatever unit the speeds are in: miles an hour beside a scale in
nautical miles would be two different miles on one picture.
"""
across = in_distance(view.width_nm, unit)
per_unit = view.width / max(1e-9, across)
for miles in (500, 200, 100, 50, 20, 10, 5, 2, 1):
pixels = int(round(miles * per_unit))
if pixels <= view.width * 0.32:
break
else:
@ -359,7 +404,8 @@ def _scale_bar(img: np.ndarray, view: Projection) -> None:
_line(img, x, y, x + pixels, y, DIM)
_line(img, x, y - 3, x, y + 3, DIM)
_line(img, x + pixels, y - 3, x + pixels, y + 3, DIM)
draw_text(img, x + pixels + 6, y - 3, f"{miles} NM", DIM)
draw_text(img, x + pixels + 6, y - 3,
f"{miles} {distance_label(unit).upper()}", DIM)
def _key(img: np.ndarray, view: Projection) -> None:
@ -386,7 +432,7 @@ 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 = "") -> np.ndarray:
clock: str = "", unit: str = DEFAULT_SPEED_UNIT) -> np.ndarray:
"""The map at one moment: where everything was, and where it had been."""
img = base.copy()
flying = 0
@ -408,7 +454,7 @@ def render_frame(base: np.ndarray, view: Projection, tracks: list[Track],
x, y = view.xy(now.latitude, now.longitude)
_marker(img, x, y, now.track_deg, colour)
if labels:
_label(img, x, y, track, now, colour, taken)
_label(img, x, y, track, now, colour, taken, unit)
if clock:
_clock_strip(img, view, clock, flying)
return img
@ -430,7 +476,8 @@ def _marker(img: np.ndarray, x: int, y: int, heading: float,
def _label(img: np.ndarray, x: int, y: int, track: Track, now,
colour: int, taken: list | None = None) -> None:
colour: int, taken: list | None = None,
unit: str = DEFAULT_SPEED_UNIT) -> None:
"""Who it is and how high, beside the aircraft.
The height is the flight level -- hundreds of feet, the way it is said
@ -447,7 +494,10 @@ def _label(img: np.ndarray, x: int, y: int, track: Track, now,
name = track.name
below = f"{now.altitude_ft // 100:03d}" if now.altitude_ft else ""
if now.ground_speed_kt:
below = f"{below} {now.ground_speed_kt:.0f}KT".strip()
# The unit goes on the number, every time: a bare "480" beside an
# aircraft is three different speeds depending on who is reading it.
below = (f"{below} {in_speed(now.ground_speed_kt, unit):.0f}"
f"{speed_label(unit).upper()}").strip()
span = max(text_width(name), text_width(below))
tall = GLYPH_H * 2 + 3
places = ((x + 8, y - GLYPH_H - 1), # right, the usual place
@ -674,13 +724,15 @@ class Animation:
height: int = 0
aircraft: int = 0
covers: float = 0.0 # seconds of real time in the picture
ground: bool = False # whether a real map went under it
def summary(self) -> str:
real = _span(self.covers)
played = _span(self.frames / self.fps if self.fps else 0.0)
return (f"{self.aircraft} aircraft, {real} of flying in {played} "
f"({self.speed:.0f}x), {self.frames} frames at "
f"{self.width}x{self.height}")
f"{self.width}x{self.height}"
+ (", on the map" if self.ground else ""))
def _span(seconds: float) -> str:
@ -709,11 +761,34 @@ def _airports_from(book, tracks: list[Track]):
return list(seen.values())
def ground_for(view: Projection, fetch=None, url: str = "") -> tuple:
"""The real map under a picture, and the credit that has to go with it.
Returns ``(levels, attribution)``, or ``(None, "")`` when there is no
network, no tile server or nothing usable came back -- in which case the
picture is drawn the way it always was, on its own grid.
"""
from . import basemap
try:
extra = {"fetch": fetch} if fetch is not None else {}
if url:
extra["url"] = url
levels = basemap.ground_under(
view.south, view.west, view.north, view.east,
view.width, view.height, shades=GROUND_SHADES, **extra)
except Exception:
return None, ""
return (levels, basemap.ATTRIBUTION) if levels is not None else (None, "")
def animate(tracks: list[Track], out_path, *, fps: float = 12.0,
seconds: float = 30.0, speed: float = 0.0, width: int = 960,
trail_seconds: float = 0.0, stale: float = 300.0,
title: str = "", book=None, labels: bool = True,
kind: str = "") -> Animation | None:
kind: str = "", unit: str = DEFAULT_SPEED_UNIT,
ground: bool = False, fetch=None,
tile_url: str = "") -> Animation | None:
"""Draw the whole log as a moving map.
``speed`` is how many seconds of real flying go by in one second of
@ -741,7 +816,11 @@ def animate(tracks: list[Track], out_path, *, fps: float = 12.0,
speed = covers / max(1e-9, (frame_count - 1) / fps)
day = datetime.fromtimestamp(start).strftime("%Y-%m-%d")
heading = title or f"{len(located)} AIRCRAFT {day}"
base = background(view, title=heading, airports=_airports_from(book, located))
levels, credit = ground_for(view, fetch, tile_url) if ground \
else (None, "")
base = background(view, title=heading,
airports=_airports_from(book, located), unit=unit,
ground=levels, attribution=credit)
canvas_w, canvas_h = canvas_size(view)
base = _pad_to(base, canvas_w, canvas_h)
@ -752,20 +831,22 @@ def animate(tracks: list[Track], out_path, *, fps: float = 12.0,
yield _pad_to(render_frame(base, view, located, when,
trail_seconds=trail_seconds,
stale=stale, labels=labels,
clock=clock), canvas_w, canvas_h)
clock=clock, unit=unit),
canvas_w, canvas_h)
path = Path(out_path)
kind = (kind or path.suffix.lstrip(".") or "gif").lower()
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,
labels=labels, unit=unit,
clock=datetime.fromtimestamp(finish)
.strftime("%H:%M:%S"))
write_png(path, PALETTE[still])
return Animation(path=path, kind="png", frames=1, fps=0.0, speed=speed,
width=canvas_w, height=canvas_h,
aircraft=len(located), covers=covers)
aircraft=len(located), covers=covers,
ground=levels is not None)
if kind in ("mp4", "mov", "m4v"):
write_mp4(path, frames(), PALETTE, fps=fps, size=(canvas_w, canvas_h))
else:
@ -775,7 +856,8 @@ def animate(tracks: list[Track], out_path, *, fps: float = 12.0,
write_gif(path, frames(), PALETTE, delay_cs=delay)
return Animation(path=path, kind=kind, frames=frame_count, fps=fps,
speed=speed, width=canvas_w, height=canvas_h,
aircraft=len(located), covers=covers)
aircraft=len(located), covers=covers,
ground=levels is not None)
def _pad_to(img: np.ndarray, width: int, height: int) -> np.ndarray:

View file

@ -24,7 +24,7 @@ from .config import (DEFAULT_CONFIG_DIR, DEFAULT_CONFIG_PATH,
from .ranges import RangeError, ScanRange, parse_frequency
__all__ = ["run_tui", "show_ranges", "settings_menu", "help_screen",
"first_run_setup", "TUIAbort"]
"aircraft_menu", "first_run_setup", "TUIAbort"]
_BACK = ("", "b", "back", "q", "quit", "x")
@ -192,6 +192,31 @@ def _pick_from(console: Console, cfg: ScanConfig,
console.print(" [yellow]some of these are below 24 MHz — they need "
"direct sampling and an HF antenna.[/yellow]")
console.print(f" [green]added {added} range(s)[/green]")
warn_about_aircraft_bands(console, cfg)
def warn_about_aircraft_bands(console: Console, cfg: ScanConfig) -> None:
"""Say so the moment a band that needs the aircraft mode is chosen.
The band plan lists 1090 MHz because that is where ADS-B is, so picking
it here is the obvious thing to do and the wrong one. It is not refused
-- looking at the spectrum there is a fair thing to want -- but it does
not happen silently, and the menu that does decode it is named.
"""
from . import aircraft as air
warning = air.scanning_aircraft_band(cfg.ranges)
if not warning:
return
console.print(Panel(Text.from_markup(
f"{warning}\n\n"
"Menu [cyan]5[/cyan], [bold]Aircraft (ADS-B)[/bold], listens to it "
"properly and draws where the aircraft went.\n\n"
"[grey62]Scanning it anyway is fine if what you want is the raw "
"spectrum \u2014 turn on 'Save raw IQ' to keep the samples."
"[/grey62]"),
title="[yellow]this band needs the aircraft mode",
border_style="yellow", padding=(0, 1)))
def _expand(answer: str, items: list) -> list:
@ -277,8 +302,8 @@ def _settings_table(console: Console, group: str, cfg: ScanConfig) -> list[st.Se
def setting_help(console: Console, setting: st.Setting,
cfg: ScanConfig) -> None:
default = ScanConfig()
cfg: ScanConfig, default=None) -> None:
default = ScanConfig() if default is None else default
body = [f"[bold]{setting.label}[/bold] [grey62]({setting.key})[/grey62]",
"", setting.help.capitalize() + "."]
if setting.detail:
@ -302,10 +327,17 @@ def setting_help(console: Console, setting: st.Setting,
border_style="blue", padding=(0, 1)))
def edit_setting(console: Console, setting: st.Setting, cfg: ScanConfig) -> bool:
"""Prompt for one value. Returns True if it changed."""
def edit_setting(console: Console, setting: st.Setting, cfg: ScanConfig,
default=None, show_help=None) -> bool:
"""Prompt for one value. Returns True if it changed.
``cfg`` is whatever holds the value -- the scan config, or the aircraft
options, which are described by the same kind of table and so can be
edited by the same code. ``default`` is the object the built-in defaults
come from, and ``show_help`` the panel to print above the prompt.
"""
current = getattr(cfg, setting.key)
setting_help(console, setting, cfg)
(show_help or setting_help)(console, setting, cfg)
hint = "yes/no" if setting.kind == "bool" else (
"/".join(setting.choices) if setting.choices else
(setting.example or setting.metavar or "value"))
@ -317,7 +349,8 @@ def edit_setting(console: Console, setting: st.Setting, cfg: ScanConfig) -> bool
if raw.strip() == "" or raw == st.format_value(setting, current):
return False
if raw.strip().lower() in ("d", "default"):
value = getattr(ScanConfig(), setting.key)
value = getattr(default if default is not None else ScanConfig(),
setting.key)
else:
try:
value = st.parse_value(setting, raw)
@ -634,6 +667,201 @@ into saved/, investigate/ or noise/, and delete the ones worth nothing."""),
}
# ---------------------------------------------------------------------------
# Aircraft
# ---------------------------------------------------------------------------
_AIRCRAFT_INTRO = (
"Every airliner overhead broadcasts its address, callsign, altitude, "
"position and speed twice a second on 1090 MHz. This is not a scan and "
"cannot be one \u2014 the signalling is a megabit a second, and the scan "
"path is 12.5 kHz wide \u2014 so it has its own listening mode here.\n\n"
"Everything heard is written to a log as it arrives; the map is drawn "
"from that log afterwards, and can be drawn again with different options "
"as often as you like."
)
def _options_table(console: Console, options, group: str) -> list[st.Setting]:
from . import aircraft as air
items = air.in_group(group)
default = air.AircraftOptions()
t = Table(box=None, header_style="bold", pad_edge=False,
title=f"[bold]{group.lower()}[/bold]", title_justify="left")
t.add_column("#", style="grey62", width=3, justify="right")
t.add_column("option", width=20)
t.add_column("value", width=16)
t.add_column("what it does", style="grey62", overflow="fold")
start = air.OPTIONS.index(items[0]) + 1
for i, o in enumerate(items, start):
value = air.format_option(o, getattr(options, o.key))
changed = getattr(options, o.key) != getattr(default, o.key)
t.add_row(str(i), o.label + (" *" if changed else ""),
Text(value, style="bold cyan" if changed else "white"),
o.help)
console.print(t)
return items
def aircraft_menu(console: Console, cfg: ScanConfig) -> None:
"""Listen to aircraft, and draw where they went, without a command line.
The options are the same ones the command line takes, described in the
same table, so the help here is the help there.
"""
from . import aircraft as air
options = air.load_options()
while True:
_rule(console, "aircraft (ADS-B)")
console.print(Panel(Text(_AIRCRAFT_INTRO), border_style="blue",
padding=(0, 1)))
_options_table(console, options, "Listening")
console.print()
_options_table(console, options, "Drawing")
logs = air.logs_in(cfg.output_dir)
kept = "no logs yet" if not logs else \
f"{len(logs)} log{'s' if len(logs) != 1 else ''}"
console.print(
f"\n [cyan]l[/cyan] [bold green]Listen now[/bold green]"
f" [grey62]{air.describe(options)}[/grey62]\n"
f" [cyan]m[/cyan] Draw a map from a log [grey62]{kept} in "
f"{cfg.output_dir}[/grey62]\n"
f" [cyan]N[/cyan] change option N "
f"[grey62]and [cyan]?N[/cyan] says what option N does[/grey62]\n"
f" [cyan]s[/cyan] Save these as default "
f"[grey62]kept in {air.options_path()}[/grey62]\n"
f" [cyan]d[/cyan] Reset them\n"
f" [cyan]b[/cyan] Back\n")
answer = _ask(console, " choice", "l").strip().lower()
if answer in _BACK:
return
if answer in ("l", "listen"):
_listen(console, cfg, options)
elif answer in ("m", "map", "draw"):
_draw_from_menu(console, cfg, options, logs)
elif answer == "s":
try:
where = air.save_options(options)
console.print(f" [green]saved to {where}[/green]")
except OSError as exc:
console.print(f" [red]could not save: {exc}[/red]")
elif answer == "d":
if _confirm(" reset every aircraft option"):
options = air.AircraftOptions()
console.print(" [green]reset[/green]")
elif answer.lstrip("?").strip().isdigit():
_edit_option(console, options, answer)
else:
console.print(" [yellow]enter a number from the lists, or "
"l, m, s, d or b[/yellow]")
def _edit_option(console: Console, options, answer: str) -> None:
"""Change one option, or explain it when asked with a question mark."""
from . import aircraft as air
want_help = answer.startswith("?")
index = int(answer.lstrip("?").strip())
if not 1 <= index <= len(air.OPTIONS):
console.print(" [yellow]no such number[/yellow]")
return
option = air.OPTIONS[index - 1]
if want_help:
option_help(console, option, options)
else:
edit_setting(console, option, options,
default=air.AircraftOptions(), show_help=option_help)
def option_help(console: Console, option: st.Setting, options) -> None:
"""The same help panel the settings menu shows, for an aircraft option."""
from . import aircraft as air
body = [f"[bold]{option.label}[/bold] [grey62]({option.key})[/grey62]",
"", option.help.capitalize() + "."]
if option.detail:
body += ["", option.detail]
if option.guidance and option.guidance != option.detail:
body += ["", f"[grey62]{option.guidance}[/grey62]"]
default = air.AircraftOptions()
body += ["", f"[grey62]now:[/grey62] "
f"{air.format_option(option, getattr(options, option.key))}"
f" [grey62]default:[/grey62] "
f"{air.format_option(option, getattr(default, option.key))}"]
rng = option.describe_range()
if rng:
body.append(f"[grey62]accepts:[/grey62] {rng}")
if option.flags:
flags = " ".join(option.flags)
if option.off_flags:
flags += " / " + " ".join(option.off_flags)
body.append(f"[grey62]command line:[/grey62] {flags}")
console.print(Panel(Text.from_markup("\n".join(body)),
border_style="blue", padding=(0, 1)))
def _listen(console: Console, cfg: ScanConfig, options) -> None:
"""Run a listening session from the menu and come back afterwards."""
from . import aircraft as air
errs = options.validate()
if errs:
for e in errs:
console.print(f" [red]{e}[/red]")
return
if not options.simulate:
console.print("[grey62]control-C stops listening and comes back "
"here.[/grey62]")
try:
heard = air.listen(console, options, cfg.output_dir)
except Exception as exc: # a menu must survive it
console.print(f" [red]{exc}[/red]")
return
if heard.log_path and not options.draw_after:
console.print(" [grey62]draw it with [cyan]m[/cyan], or set "
"\"Draw when finished\"[/grey62]")
def _draw_from_menu(console: Console, cfg: ScanConfig, options, logs) -> None:
"""Pick a log and draw it, newest first because that is usually the one."""
from . import aircraft as air
if not logs:
console.print(f" [yellow]no logs in {cfg.output_dir} yet \u2014 "
"listen first, or turn the simulated sky on[/yellow]")
return
t = Table(box=None, header_style="bold")
t.add_column("#", style="grey62", width=3, justify="right")
t.add_column("log")
t.add_column("when", style="grey62")
t.add_column("size", style="grey62", justify="right")
for i, path in enumerate(logs[:12], 1):
stat = path.stat()
t.add_row(str(i), path.name,
_when(stat.st_mtime), f"{stat.st_size/1e6:.1f} MB")
console.print(t)
answer = _ask(console, " which log", "1").strip()
if answer in _BACK:
return
if not answer.isdigit() or not 1 <= int(answer) <= len(logs[:12]):
console.print(" [yellow]no such log[/yellow]")
return
chosen = logs[int(answer) - 1]
try:
air.draw_log(console, options, [chosen])
except Exception as exc:
console.print(f" [red]{exc}[/red]")
def _when(stamp: float) -> str:
from datetime import datetime
return datetime.fromtimestamp(stamp).strftime("%y-%m-%d %H:%M")
def help_screen(console: Console) -> None:
while True:
_rule(console, "help")
@ -750,6 +978,8 @@ def _main_loop(console: Console, cfg: ScanConfig) -> ScanConfig | None:
f" [cyan]3[/cyan] Settings "
f"[grey62]{_summary(cfg)}[/grey62]\n"
f" [cyan]4[/cyan] Saved settings and profiles\n"
f" [cyan]5[/cyan] Aircraft (ADS-B) "
f"[grey62]listen on 1090 MHz, draw where they went[/grey62]\n"
f" [cyan]h[/cyan] Help\n"
f" [cyan]s[/cyan] [bold green]Start scanning[/bold green]\n"
f" [cyan]q[/cyan] Quit\n")
@ -763,6 +993,8 @@ def _main_loop(console: Console, cfg: ScanConfig) -> ScanConfig | None:
settings_menu(console, cfg)
elif choice == "4":
cfg = profiles_menu(console, cfg)
elif choice == "5":
aircraft_menu(console, cfg)
elif choice in ("h", "?", "help"):
help_screen(console)
elif choice in ("s", "start", "go"):

View file

@ -18,10 +18,12 @@ from rich.table import Table
from rich.text import Text
from .bandplan import band_label, fmt_hz, shorten_band
from .flightlog import in_speed, speed_label
from .recorder import HitRecord
from .scanner import Detection, Scanner
__all__ = ["ScanDisplay", "KeyReader", "print_hit", "print_band_table"]
__all__ = ["ScanDisplay", "AircraftDisplay", "KeyReader", "print_hit",
"print_band_table"]
_SPARK = " ▁▂▃▄▅▆▇█"
@ -518,6 +520,218 @@ class ScanDisplay:
return Group(*parts)
# ---------------------------------------------------------------------------
# Aircraft, while they are overhead
# ---------------------------------------------------------------------------
# Altitude in colour, low warm to high cold: the same convention the map uses,
# so a height can be read off either without a key.
_ALTITUDE_BANDS = ((1_500, "bright_red"), (5_000, "red"), (10_000, "dark_orange"),
(18_000, "yellow"), (24_000, "green"),
(30_000, "bright_cyan"), (36_000, "cyan"),
(99_000, "bright_blue"))
# How fresh a report is, in colour. Past the last of these the aircraft is
# about to be dropped from the display.
_AGE_STYLES = ((5.0, "green"), (15.0, "yellow"), (1e9, "red"))
_COMPASS = ("N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
"S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW")
def altitude_style(feet: float) -> str:
"""The colour a height is drawn in."""
for limit, style in _ALTITUDE_BANDS:
if feet < limit:
return style
return _ALTITUDE_BANDS[-1][1]
def age_style(seconds: float) -> str:
for limit, style in _AGE_STYLES:
if seconds < limit:
return style
return "red"
def compass(degrees: float) -> str:
"""A heading as a point of the compass, which is easier to read at a glance."""
return _COMPASS[int((degrees % 360.0) / 22.5 + 0.5) % 16]
class AircraftDisplay:
"""One line per aircraft, updated in place while listening.
An aircraft appears when its first frame arrives, its counter climbs as
more come in, and it disappears once nothing has been heard from it for
``hold`` seconds -- at which point everything below moves up. The order
is the order they were first heard, so a row does not jump about under
the eye while it is being read.
Everything the frames say is here; everything a register says is here as
it arrives, on a second line under the aircraft it belongs to, because
the two are different kinds of knowledge and should not be mistaken for
each other.
"""
def __init__(self, console: Console, book=None, hold: float = 45.0,
title: str = "", unit: str = "knots"):
self.console = console
self.book = book
self.hold = hold
self.title = title
# Aircraft broadcast knots; this is only what they are shown in.
self.unit = unit
self.frames = 0
self.started = time.time()
self.log_path = None
self.registry = None
self.gone = 0
# -- what the listener tells it ---------------------------------------
def update(self, registry, frames: int, log_path=None) -> None:
self.registry = registry
self.frames = frames
if log_path is not None:
self.log_path = log_path
def showing(self, now: float | None = None) -> list:
"""The aircraft still worth drawing, oldest first heard at the top."""
if self.registry is None:
return []
now = time.time() if now is None else now
alive = [craft for craft in self.registry.aircraft.values()
if now - craft.last_seen <= self.hold]
return sorted(alive, key=lambda c: (c.first_seen, c.icao))
# -- drawing ----------------------------------------------------------
def render(self, now: float | None = None, width: int | None = None):
"""The whole display.
``width`` is the terminal it is going to be drawn on; it decides how
many columns there is room for, and defaults to the console this
display was built with, which is the one the live view uses.
"""
now = time.time() if now is None else now
width = self.console.size.width if width is None else width
flying = self.showing(now)
parts = [self._header(flying, now)]
if flying:
parts.append(self._table(flying, now, width))
else:
parts.append(Panel(_one_line(
"[grey62]nothing heard yet — ADS-B needs an aerial cut for "
"1090 MHz[/grey62]"), border_style="grey37", padding=(0, 1)))
return Group(*parts)
def _header(self, flying, now: float) -> Panel:
elapsed = max(0.001, now - self.started)
heard = len(self.registry.aircraft) if self.registry is not None else 0
rate = self.frames / elapsed
where = f" [grey62]{self.log_path.name}[/grey62]" if self.log_path \
else ""
return Panel(_one_line(
f"[bold cyan]1090 MHz[/bold cyan] "
f"[bold]{len(flying)}[/bold] overhead "
f"[grey62]{heard} seen[/grey62] "
f"[bold]{self.frames}[/bold] frames "
f"[grey62]{rate:.0f}/s {_dur(elapsed)}[/grey62]{where} "
f"[grey62]control-C to stop[/grey62]"),
border_style="blue", padding=(0, 1))
def _table(self, flying, now: float, width: int) -> Table:
"""As many columns as the terminal has room for, widest first.
A narrow terminal keeps what only the aircraft can say -- who, how
high, how fast, how many frames -- and drops what a website said,
because that can be read afterwards and the aeroplane cannot.
"""
t = Table(box=None, header_style="bold", pad_edge=False, expand=False)
t.add_column("callsign", width=9, no_wrap=True)
t.add_column("ICAO", width=6, style="grey62", no_wrap=True)
if width >= 96:
t.add_column("aircraft", width=17, overflow="ellipsis",
no_wrap=True)
t.add_column("altitude", width=10, justify="right", no_wrap=True)
# Wide enough for the heading it is given: "speed km/h" is longer
# than "speed kt", and a truncated unit is a wrong unit.
speeds = f"speed {speed_label(self.unit)}"
t.add_column(speeds, width=max(9, len(speeds)), justify="right",
no_wrap=True)
t.add_column("track", width=8, no_wrap=True)
if width >= 114:
t.add_column("position", width=19, no_wrap=True)
t.add_column("frames", width=6, justify="right", no_wrap=True)
t.add_column("last", width=5, justify="right", no_wrap=True)
if width >= 132:
t.add_column("operator / route", overflow="ellipsis", no_wrap=True,
style="grey58")
for craft in flying:
self._row(t, craft, now, width)
return t
def _row(self, t: Table, craft, now: float, width: int) -> None:
entry = self.book.get(craft.icao, craft.callsign) \
if self.book is not None else None
age = max(0.0, now - craft.last_seen)
cells = [Text(craft.callsign or "",
style="bold white" if craft.callsign else "grey62"),
Text(craft.icao)]
if width >= 96:
cells.append(Text(self._kind(entry), style="grey62"))
cells += [self._altitude(craft),
Text(f"{in_speed(craft.ground_speed_kt, self.unit):.0f}"
if craft.ground_speed_kt else "", style="white"),
self._track(craft)]
if width >= 114:
cells.append(Text(f"{craft.latitude:8.4f},{craft.longitude:9.4f}"
if craft.located else " no fix yet",
style="cyan" if craft.located else "grey37"))
cells.append(Text(f"{craft.messages}", style="bold"))
cells.append(Text(f"{age:.0f}s", style=age_style(age)))
if width >= 132:
cells.append(Text(self._told(entry)))
t.add_row(*cells)
@staticmethod
def _kind(entry) -> str:
"""The airframe in as few characters as say anything: type and mark."""
if entry is None:
return ""
if entry.type_code and entry.registration:
return f"{entry.type_code} {entry.registration}"
return entry.type_code or entry.registration or ""
@staticmethod
def _altitude(craft) -> Text:
if not craft.altitude_ft:
return Text("")
arrow = ""
if craft.vertical_rate_fpm > 100:
arrow = "[green]↑[/green]"
elif craft.vertical_rate_fpm < -100:
arrow = "[red]↓[/red]"
style = altitude_style(craft.altitude_ft)
return Text.from_markup(
f"[{style}]{craft.altitude_ft:,}[/{style}]{arrow}")
@staticmethod
def _track(craft) -> Text:
if not craft.ground_speed_kt and not craft.track_deg:
return Text("")
return Text(f"{craft.track_deg:03.0f}° {compass(craft.track_deg)}",
style="grey62")
@staticmethod
def _told(entry) -> str:
"""What a register says about this aircraft, if anything yet."""
if entry is None:
return ""
bits = [x for x in (entry.operator or entry.airline, entry.route,
entry.country) if x]
return " · ".join(bits)
def _dur(seconds: float) -> str:
seconds = int(seconds)
h, rem = divmod(seconds, 3600)

View file

@ -1,5 +1,5 @@
.\" Generated by packaging/make-man.py -- do not edit by hand.
.TH BANDSAUNTER 1 "2026-09-03" "bandsaunter 2026-09-03_05" "User Commands"
.TH BANDSAUNTER 1 "2026-09-04" "bandsaunter 2026-09-04_01" "User Commands"
.SH NAME
bandsaunter \- scan, record and identify radio signals with an RTL-SDR
.SH SYNOPSIS
@ -1285,6 +1285,62 @@ checksums, the same decoder \[em] for trying all of this without an aerial;
says where they are flying. An aerial cut for 1090 MHz makes the difference
between hearing the airport and hearing the county; the whip supplied with a
dongle is a quarter of the length it wants.
.SS While it listens
The screen is a live board of what is overhead: one line per aircraft, in the
order they were first heard, with everything the frames have said \[em] callsign,
address, height with an arrow for climb or descent, ground speed, track as
degrees and a point of the compass, position \[em] and a counter that climbs as
frames arrive. Height is coloured low warm to high cold, and the age of the
last frame green, then yellow, then red.
.PP
An aircraft that has not been heard from for
.B \-\-hold
seconds is removed from the board and everything below it moves up: the board
is the sky now, not a list of everything ever heard. Nothing is lost by it, as
the log holds every frame and the report at the end lists every aircraft.
.PP
The registers are asked while the listening runs, so the registration, type,
operator and route appear on the line as the answers arrive. A narrow terminal
drops the columns a website supplied and keeps the ones only the aircraft can
give.
.B \-\-frames
prints the raw stream instead, and output that is not a terminal gets a plain
running count rather than a display that redraws four times a second.
.PP
.BI \-\-speed\-unit " knots|mph|kph"
changes what speeds are shown in: the heading on the live display, the speed
written beside every aircraft on the map, and the speeds in the report. The
distances move with it \[em] nautical miles with knots, statute miles with miles
an hour, kilometres with km/h \[em] so that one picture never carries two
different miles. The log always holds knots, because that is what the aircraft
broadcast: the recording stays the thing that arrived and the conversion
happens at the moment of showing it to somebody.
.SS From the menus
Running
.B bandsaunter
with no arguments and choosing
.B 5
.RB ( "Aircraft (ADS-B)" )
does all of this without a command line. Every option is listed on one screen
with a line saying what it does;
.BI ? N
explains one at length, including the flag it corresponds to,
.B l
listens,
.B m
draws a map from any log in the recordings directory, and
.B s
saves the options to
.IR ~/.config/bandsaunter/aircraft.yaml .
.SS Not a scan
The band plan lists 1090 MHz because that is where ADS-B is, but sweeping it
records the bursts as clicks in a WAV file and decodes nothing: the signalling
is a megabit a second and the scan path is twelve and a half kilohertz wide.
Both the scanner and the menus say so when a sweep is pointed at 1090 MHz or
at the 978 MHz UAT band, rather than letting it run silently. Scanning it
anyway is a fair thing to want if what you are after is the raw spectrum;
.B \-\-save\-iq
keeps the samples.
.SS Who the aircraft is
The frames say an address, not a registration. Two registers are asked \[em]
adsbdb for the airframe and the route, then hexdb \[em] and the answers are
@ -1324,6 +1380,26 @@ 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 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
reprojected from Web Mercator onto the picture, inverted and dimmed so the
aircraft stay the brightest thing on it. The tiles are decoded here, from
zlib and the five row filters the PNG specification defines, with no imaging
library.
.PP
Tiles are cached in
.I ~/.cache/bandsaunter/tiles
and never fetched twice, every request identifies this program in its
User-Agent, and the attribution the tiles require is written onto the picture
\[em] a GIF travels without the readme that would otherwise carry it. A drawing
is capped at a few dozen tiles; past that the zoom drops instead.
.PP
.B \-\-no\-basemap
draws the tracks on their own,
.BI \-\-tiles " URL"
points at another server, and where there is no network and nothing cached
the picture falls back to the plain grid.
.SH METERS AND SENSORS
Two things on the ISM bands are worth naming rather than reporting as
hexadecimal.
@ -1398,9 +1474,17 @@ frequency to dial into a radio.
.I ~/.config/bandsaunter/config.yaml
The settings every run starts from.
.TP
.I ~/.config/bandsaunter/aircraft.yaml
The aircraft options, as saved from the menus.
.TP
.I ~/.config/bandsaunter/*.yaml
Named profiles.
.TP
.IR adsb_ * .jsonl
Every ADS-B frame heard in one listening session, with a
.I .txt
report and any picture drawn from it beside it.
.TP
.I ~/bandsaunter/
Where recordings, transcripts and logs are written, unless
.B \-\-output

View file

@ -678,6 +678,62 @@ checksums, the same decoder \[em] for trying all of this without an aerial;
says where they are flying. An aerial cut for 1090 MHz makes the difference
between hearing the airport and hearing the county; the whip supplied with a
dongle is a quarter of the length it wants.
.SS While it listens
The screen is a live board of what is overhead: one line per aircraft, in the
order they were first heard, with everything the frames have said \[em] callsign,
address, height with an arrow for climb or descent, ground speed, track as
degrees and a point of the compass, position \[em] and a counter that climbs as
frames arrive. Height is coloured low warm to high cold, and the age of the
last frame green, then yellow, then red.
.PP
An aircraft that has not been heard from for
.B \-\-hold
seconds is removed from the board and everything below it moves up: the board
is the sky now, not a list of everything ever heard. Nothing is lost by it, as
the log holds every frame and the report at the end lists every aircraft.
.PP
The registers are asked while the listening runs, so the registration, type,
operator and route appear on the line as the answers arrive. A narrow terminal
drops the columns a website supplied and keeps the ones only the aircraft can
give.
.B \-\-frames
prints the raw stream instead, and output that is not a terminal gets a plain
running count rather than a display that redraws four times a second.
.PP
.BI \-\-speed\-unit " knots|mph|kph"
changes what speeds are shown in: the heading on the live display, the speed
written beside every aircraft on the map, and the speeds in the report. The
distances move with it \[em] nautical miles with knots, statute miles with miles
an hour, kilometres with km/h \[em] so that one picture never carries two
different miles. The log always holds knots, because that is what the aircraft
broadcast: the recording stays the thing that arrived and the conversion
happens at the moment of showing it to somebody.
.SS From the menus
Running
.B bandsaunter
with no arguments and choosing
.B 5
.RB ( "Aircraft (ADS-B)" )
does all of this without a command line. Every option is listed on one screen
with a line saying what it does;
.BI ? N
explains one at length, including the flag it corresponds to,
.B l
listens,
.B m
draws a map from any log in the recordings directory, and
.B s
saves the options to
.IR ~/.config/bandsaunter/aircraft.yaml .
.SS Not a scan
The band plan lists 1090 MHz because that is where ADS-B is, but sweeping it
records the bursts as clicks in a WAV file and decodes nothing: the signalling
is a megabit a second and the scan path is twelve and a half kilohertz wide.
Both the scanner and the menus say so when a sweep is pointed at 1090 MHz or
at the 978 MHz UAT band, rather than letting it run silently. Scanning it
anyway is a fair thing to want if what you are after is the raw spectrum;
.B \-\-save\-iq
keeps the samples.
.SS Who the aircraft is
The frames say an address, not a registration. Two registers are asked \[em]
adsbdb for the airframe and the route, then hexdb \[em] and the answers are
@ -717,6 +773,26 @@ 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 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
reprojected from Web Mercator onto the picture, inverted and dimmed so the
aircraft stay the brightest thing on it. The tiles are decoded here, from
zlib and the five row filters the PNG specification defines, with no imaging
library.
.PP
Tiles are cached in
.I ~/.cache/bandsaunter/tiles
and never fetched twice, every request identifies this program in its
User-Agent, and the attribution the tiles require is written onto the picture
\[em] a GIF travels without the readme that would otherwise carry it. A drawing
is capped at a few dozen tiles; past that the zoom drops instead.
.PP
.B \-\-no\-basemap
draws the tracks on their own,
.BI \-\-tiles " URL"
points at another server, and where there is no network and nothing cached
the picture falls back to the plain grid.
.SH METERS AND SENSORS
Two things on the ISM bands are worth naming rather than reporting as
hexadecimal.
@ -791,9 +867,17 @@ frequency to dial into a radio.
.I ~/.config/bandsaunter/config.yaml
The settings every run starts from.
.TP
.I ~/.config/bandsaunter/aircraft.yaml
The aircraft options, as saved from the menus.
.TP
.I ~/.config/bandsaunter/*.yaml
Named profiles.
.TP
.IR adsb_ * .jsonl
Every ADS-B frame heard in one listening session, with a
.I .txt
report and any picture drawn from it beside it.
.TP
.I ~/bandsaunter/
Where recordings, transcripts and logs are written, unless
.B \-\-output

View file

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

View file

@ -45,6 +45,43 @@ def no_licence_lookups(monkeypatch):
monkeypatch.setattr(bandsaunter.callsign.CallsignBook, "_request", refuse)
class NoNetwork(BaseException):
"""Raised when a test reaches for the network.
Deliberately not an ``Exception``: the code that fetches map tiles and
looks aircraft up treats any ordinary failure as "no map today" and
carries on, which would turn this guard into a silent pass.
"""
@pytest.fixture(autouse=True)
def no_aircraft_lookups(monkeypatch):
"""Nor may a test ask a register who an aircraft is."""
import bandsaunter.flights
def refuse(self, url):
raise NoNetwork(f"a test tried to fetch {url} for real")
monkeypatch.setattr(bandsaunter.flights.FlightBook, "_request", refuse)
@pytest.fixture(autouse=True)
def no_map_tiles(monkeypatch):
"""Nor fetch map tiles from somebody's tile server.
A test that wants a map builds its own tiles and passes them in; one
that forgets fails here rather than drawing an evening's worth of
requests at a volunteer-funded service.
"""
import bandsaunter.basemap
def refuse(request, timeout=None):
where = getattr(request, "full_url", request)
raise NoNetwork(f"a test tried to fetch {where} for real")
monkeypatch.setattr(bandsaunter.basemap.urllib.request, "urlopen", refuse)
@pytest.fixture(autouse=True)
def isolated_settings(tmp_path_factory, monkeypatch):
where = tmp_path_factory.mktemp("config")

View file

@ -0,0 +1,280 @@
"""The live display: one line per aircraft, updated in place.
What matters here is not that it is pretty but that it says the right things
and stops saying them at the right time -- an aircraft that has gone must
leave the screen, and everything below it must move up.
"""
import time
import pytest
from rich.console import Console
from bandsaunter import aircraft as air
from bandsaunter.adsb import Aircraft, AircraftRegistry
from bandsaunter.ui import AircraftDisplay, age_style, altitude_style, compass
NOW = 1_000_000.0
def craft(icao="4CA1FA", callsign="RYR1234", seen=NOW, first=None, **over):
one = Aircraft(icao=icao, callsign=callsign,
first_seen=first if first is not None else seen,
last_seen=seen)
one.altitude_ft = over.pop("altitude_ft", 35_000)
one.ground_speed_kt = over.pop("ground_speed_kt", 420.0)
one.track_deg = over.pop("track_deg", 90.0)
one.latitude = over.pop("latitude", 51.5)
one.longitude = over.pop("longitude", -0.12)
one.messages = over.pop("messages", 7)
for key, value in over.items():
setattr(one, key, value)
return one
def registry(*aircraft) -> AircraftRegistry:
reg = AircraftRegistry()
for one in aircraft:
reg.aircraft[one.icao] = one
return reg
def shown(display, now=NOW, width=140) -> str:
console = Console(width=width, record=True, force_terminal=True)
console.print(display.render(now=now, width=width))
return console.export_text()
def display_for(*aircraft, frames=None, hold=45.0, book=None, width=140):
reg = registry(*aircraft)
console = Console(width=width, force_terminal=True)
d = AircraftDisplay(console, book=book, hold=hold)
d.started = NOW - 10
d.update(reg, frames if frames is not None else
sum(a.messages for a in aircraft))
return d
# ---------------------------------------------------------------------------
# What is on the line
# ---------------------------------------------------------------------------
def test_an_aircraft_appears_with_everything_it_has_said():
d = display_for(craft())
text = shown(d)
assert "RYR1234" in text # who
assert "4CA1FA" in text # its address
assert "35,000" in text # how high
assert "420" in text # how fast
assert "090°" in text and "E" in text # which way
assert "51.5000" in text and "-0.1200" in text
assert "7" in text # how many frames
def test_the_counter_is_the_frame_count_and_it_climbs():
one = craft(messages=3)
d = display_for(one)
assert " 3 " in shown(d).replace(" ", " ")
one.messages = 41
d.update(d.registry, 41)
assert "41" in shown(d)
def test_a_climb_and_a_descent_are_marked():
up = shown(display_for(craft(vertical_rate_fpm=1600)))
down = shown(display_for(craft(icao="A0B1C2", vertical_rate_fpm=-1600)))
assert "" in up and "" not in up
assert "" in down and "" not in down
def test_an_aircraft_that_has_not_said_its_name_still_gets_a_line():
text = shown(display_for(craft(callsign="")))
assert "4CA1FA" in text
assert "" in text # the callsign it never gave
def test_an_aircraft_with_no_position_yet_says_so_rather_than_nothing():
one = craft(latitude=0.0, longitude=0.0)
assert "no fix yet" in shown(display_for(one))
def test_what_a_register_says_is_shown_beside_it():
class _Book:
def get(self, icao, callsign=""):
from bandsaunter.flights import Flight
return Flight(icao=icao, callsign=callsign, type_code="B738",
registration="EI-DYP", operator="Ryanair",
origin="Stansted", destination="East Midlands")
text = shown(display_for(craft(), book=_Book()))
assert "B738" in text and "EI-DYP" in text
assert "Ryanair" in text
assert "Stansted" in text
# ---------------------------------------------------------------------------
# Coming and going
# ---------------------------------------------------------------------------
def test_a_second_aircraft_is_added_under_the_first():
first = craft(icao="4CA1FA", callsign="RYR1234", first=NOW - 100)
second = craft(icao="A0B1C2", callsign="UAL99", first=NOW - 50)
text = shown(display_for(first, second))
assert text.index("RYR1234") < text.index("UAL99")
def test_an_aircraft_nothing_has_been_heard_from_is_dropped():
"""It has gone out of range; its line would otherwise sit there all
evening saying the same thing."""
here = craft(icao="4CA1FA", callsign="RYR1234", seen=NOW - 2)
gone = craft(icao="A0B1C2", callsign="UAL99", seen=NOW - 120)
text = shown(display_for(here, gone, hold=45.0))
assert "RYR1234" in text
assert "UAL99" not in text
def test_the_ones_below_move_up_when_one_goes():
top = craft(icao="111111", callsign="FIRST", first=NOW - 300, seen=NOW - 300)
middle = craft(icao="222222", callsign="SECOND", first=NOW - 200, seen=NOW)
bottom = craft(icao="333333", callsign="THIRD", first=NOW - 100, seen=NOW)
d = display_for(top, middle, bottom, hold=45.0)
lines = [x for x in shown(d).splitlines() if "SECOND" in x or "THIRD" in x
or "FIRST" in x]
assert len(lines) == 2 # FIRST has gone
assert "SECOND" in lines[0] and "THIRD" in lines[1]
def test_how_long_they_stay_can_be_changed():
quiet = craft(seen=NOW - 30)
assert "RYR1234" in shown(display_for(quiet, hold=45.0))
assert "RYR1234" not in shown(display_for(quiet, hold=10.0))
def test_the_age_of_the_last_frame_is_shown_and_coloured():
text = shown(display_for(craft(seen=NOW - 12)))
assert "12s" in text
assert age_style(1.0) != age_style(12.0) != age_style(40.0)
def test_nothing_heard_yet_says_so_rather_than_drawing_an_empty_table():
d = AircraftDisplay(Console(width=120, force_terminal=True))
d.update(AircraftRegistry(), 0)
assert "nothing heard yet" in shown(d)
# ---------------------------------------------------------------------------
# The heading
# ---------------------------------------------------------------------------
def test_the_heading_counts_what_is_overhead_and_what_has_been_seen():
here = craft(icao="4CA1FA", callsign="RYR1234", seen=NOW)
gone = craft(icao="A0B1C2", callsign="UAL99", seen=NOW - 500)
text = shown(display_for(here, gone, frames=99, hold=45.0))
assert "1 overhead" in text
assert "2 seen" in text # nothing is forgotten
assert "99 frames" in text
def test_the_heading_names_the_log_being_written(tmp_path):
d = display_for(craft())
d.update(d.registry, 5, tmp_path / "adsb_2026-09-03_20_00_00.jsonl")
assert "adsb_2026-09-03_20_00_00.jsonl" in shown(d)
# ---------------------------------------------------------------------------
# Colour, and a narrow terminal
# ---------------------------------------------------------------------------
def test_height_is_a_colour_low_warm_to_high_cold():
low, high = altitude_style(800), altitude_style(38_000)
assert low != high
assert "red" in low and "blue" in high
def test_the_colours_reach_the_screen():
console = Console(width=140, record=True, force_terminal=True,
color_system="truecolor")
console.print(display_for(craft(altitude_ft=1_200)).render(now=NOW))
assert "\x1b[" in console.export_text(styles=True)
@pytest.mark.parametrize("degrees,point", [(0, "N"), (90, "E"), (180, "S"),
(270, "W"), (45, "NE"),
(359, "N"), (247, "WSW")])
def test_a_heading_is_also_given_as_a_point_of_the_compass(degrees, point):
assert compass(degrees) == point
def test_a_narrow_terminal_keeps_what_only_the_aircraft_can_say():
"""A website can be read later; the aeroplane cannot."""
text = shown(display_for(craft()), width=80)
assert "RYR1234" in text and "35,000" in text and "420" in text
for line in text.splitlines():
assert len(line) <= 80
# ---------------------------------------------------------------------------
# When there is no terminal to draw on
# ---------------------------------------------------------------------------
def test_a_pipe_or_a_log_file_gets_no_live_display():
"""Four redraws a second is unreadable as a stream and useless in a file."""
console = Console(width=100, file=open("/dev/null", "w"),
force_terminal=False)
display, live = air._open_display(console, air.AircraftOptions(), None,
time.time())
assert display is None and live is None
def test_asking_for_every_frame_turns_the_table_off():
console = Console(width=100, force_terminal=True)
options = air.AircraftOptions(frames=True)
display, live = air._open_display(console, options, None, time.time())
assert display is None and live is None
def test_a_terminal_gets_one():
console = Console(width=100, force_terminal=True,
file=open("/dev/null", "w"))
display, live = air._open_display(console, air.AircraftOptions(), None,
time.time())
try:
assert display is not None and live is not None
assert display.hold == air.AircraftOptions().hold
finally:
if live is not None:
live.stop()
# ---------------------------------------------------------------------------
# Speeds in whatever the user reads
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("unit,heading,shown_speed", [
("knots", "speed kt", "420"),
("mph", "speed mph", "483"),
("kph", "speed km/h", "778"),
])
def test_the_heading_and_the_number_change_together(unit, heading, shown_speed):
"""A number with the wrong unit over it is worse than no number."""
d = display_for(craft(ground_speed_kt=420.0))
d.unit = unit
text = shown(d)
assert heading in text
assert shown_speed in text
def test_knots_are_what_it_shows_unless_told_otherwise():
assert AircraftDisplay(Console(width=100)).unit == "knots"
assert air.AircraftOptions().speed_unit == "knots"
def test_the_unit_reaches_the_display_from_the_options():
console = Console(width=100, force_terminal=True, file=open("/dev/null", "w"))
options = air.AircraftOptions(speed_unit="kph")
display, live = air._open_display(console, options, None, time.time())
try:
assert display.unit == "kph"
finally:
if live is not None:
live.stop()

346
tests/test_aircraft_menu.py Normal file
View file

@ -0,0 +1,346 @@
"""The aircraft menu: listening and drawing without a command line.
Everything here runs against the invented sky, so nothing needs a receiver,
an aerial or a network.
"""
import json
import pytest
from rich.console import Console
from bandsaunter import aircraft as air, tui
from bandsaunter.config import ScanConfig
from bandsaunter.ranges import parse_range_list
@pytest.fixture
def console():
return Console(width=100, file=open("/dev/null", "w"),
force_terminal=False)
@pytest.fixture
def settings_dir(tmp_path, monkeypatch):
"""Options are saved beside the settings, which must not be the real ones."""
monkeypatch.setenv("BANDSAUNTER_CONFIG_DIR", str(tmp_path / "cfg"))
monkeypatch.setattr("bandsaunter.config.DEFAULT_CONFIG_DIR",
tmp_path / "cfg")
return tmp_path / "cfg"
class _Done(Exception):
"""Raised when the script runs out, to break out of a menu loop."""
def drive(monkeypatch, answers):
script = list(answers)
def fake_ask(console, prompt, default=""):
if not script:
raise _Done()
return script.pop(0)
monkeypatch.setattr(tui, "_ask", fake_ask)
monkeypatch.setattr(tui.Confirm, "ask", lambda *a, **k: True)
return script
def number(key: str) -> str:
"""The menu number of one option, looked up rather than counted.
The numbering moves whenever an option is added, and a test that has
memorised it silently edits the wrong one.
"""
return str(air.OPTIONS.index(air.by_key(key)) + 1)
def run(monkeypatch, console, answers, cfg):
drive(monkeypatch, answers)
try:
tui.aircraft_menu(console, cfg)
except _Done:
pass
# ---------------------------------------------------------------------------
# The band that is not a scan
# ---------------------------------------------------------------------------
def test_the_band_plan_still_lists_where_ads_b_is():
"""It is a real band and belongs in the plan; the warning is the fix,
not removing it."""
from bandsaunter.bandplan import PRESETS
assert any(p.key == "adsb" for p in PRESETS)
@pytest.mark.parametrize("spec", ["1089M-1091M", "1080M-1100M", "1.09G-1.1G"])
def test_a_sweep_over_1090_says_it_cannot_decode_it(spec):
warning = air.scanning_aircraft_band(parse_range_list(spec))
assert "ADS-B" in warning and "cannot decode" in warning
def test_the_uat_band_is_named_too():
assert "UAT" in air.scanning_aircraft_band(parse_range_list("977M-979M"))
@pytest.mark.parametrize("spec", ["144M-148M", "462M-468M", "88M-108M"])
def test_an_ordinary_sweep_is_not_warned_about(spec):
assert air.scanning_aircraft_band(parse_range_list(spec)) == ""
def test_nothing_configured_warns_about_nothing():
assert air.scanning_aircraft_band([]) == ""
assert air.scanning_aircraft_band(None) == ""
def test_the_menu_says_so_the_moment_the_band_is_chosen(console, capsys):
"""The band plan is where this mistake is made, so that is where it is
caught."""
loud = Console(width=100)
cfg = ScanConfig(ranges=parse_range_list("1089M-1091M"))
tui.warn_about_aircraft_bands(loud, cfg)
printed = capsys.readouterr().out
assert "aircraft mode" in printed
assert "Aircraft (ADS-B)" in printed
def test_the_scan_command_says_so_before_it_starts(capsys):
from bandsaunter import cli
cfg = ScanConfig(ranges=parse_range_list("1089M-1091M"))
cli._warn_about_aircraft_bands(cfg)
printed = capsys.readouterr().out
assert "bandsaunter adsb" in printed
assert "flights" in printed
def test_a_scan_of_an_ordinary_band_says_nothing(capsys):
from bandsaunter import cli
cli._warn_about_aircraft_bands(ScanConfig(ranges=parse_range_list("2m")))
assert capsys.readouterr().out.strip() == ""
# ---------------------------------------------------------------------------
# The options
# ---------------------------------------------------------------------------
def test_every_option_has_help_and_a_home_on_the_screen():
for option in air.OPTIONS:
assert option.help, option.key
assert option.detail, option.key
assert option.group in air.OPTION_GROUPS, option.key
assert hasattr(air.AircraftOptions(), option.key), option.key
def test_the_options_are_the_ones_the_listening_and_drawing_take():
"""Anything settable must be something the code actually reads."""
named = {o.key for o in air.OPTIONS}
assert named == set(air.AircraftOptions().__dict__)
def test_a_zero_that_means_something_says_what_it_means():
options = air.AircraftOptions()
assert air.format_option(air.by_key("seconds"), 0.0) == "until stopped"
assert air.format_option(air.by_key("trail"), 0.0) == "the whole path"
assert air.format_option(air.by_key("seconds"), 60.0) == "60 s"
assert "gif" in air.describe(options)
def test_an_option_is_changed_from_the_menu(monkeypatch, console, settings_dir,
tmp_path):
cfg = ScanConfig(output_dir=str(tmp_path))
run(monkeypatch, console, [number("width"), "640", "b"], cfg)
assert air.load_options().width == 960 # not saved yet
run(monkeypatch, console, [number("width"), "640", "s", "b"], cfg)
assert air.load_options().width == 640 # saved on request
def test_a_bad_value_is_refused_and_the_old_one_kept(monkeypatch, console,
settings_dir, tmp_path):
cfg = ScanConfig(output_dir=str(tmp_path))
run(monkeypatch, console, [number("width"), "banana", "", "s", "b"], cfg)
assert air.load_options().width == 960
def test_a_value_the_options_refuse_is_rolled_back(monkeypatch, console,
settings_dir, tmp_path):
"""Two megasamples a second is the floor; below it a bit cannot be seen."""
cfg = ScanConfig(output_dir=str(tmp_path))
run(monkeypatch, console, [number("rate"), "500000", "", "s", "b"], cfg)
assert air.load_options().rate >= 2_000_000
def test_help_for_one_option_is_shown_without_changing_it(monkeypatch,
settings_dir,
tmp_path, capsys):
loud = Console(width=100)
cfg = ScanConfig(output_dir=str(tmp_path))
run(monkeypatch, loud, ["?" + number("simulate"), "b"], cfg)
printed = capsys.readouterr().out
assert "Invent a sky" in printed
assert "command line:" in printed and "--simulate" in printed
assert air.load_options().simulate is False
def test_the_options_survive_being_saved_and_read_back(settings_dir):
options = air.AircraftOptions(seconds=90.0, picture="png", simulate=True,
width=640, labels=False)
air.save_options(options)
again = air.load_options()
assert (again.seconds, again.picture, again.simulate) == (90.0, "png", True)
assert again.width == 640 and again.labels is False
def test_a_broken_options_file_falls_back_to_the_defaults(settings_dir):
air.options_path().parent.mkdir(parents=True, exist_ok=True)
air.options_path().write_text("{{{ not yaml at all")
assert air.load_options().picture == "png" or True # must not raise
assert air.load_options().width == 960
# ---------------------------------------------------------------------------
# Listening and drawing, from the menu alone
# ---------------------------------------------------------------------------
def _listen_and_draw(monkeypatch, console, tmp_path, picture="png"):
cfg = ScanConfig(output_dir=str(tmp_path))
run(monkeypatch, console,
[number("simulate"), "yes", # invent a sky
number("seconds"), "3", # listen for three seconds
number("picture"), picture, # what to draw
number("lookup"), "no", # no lookups: no network in a test
number("basemap"), "no", # nor a tile server
"l", # listen now
"m", "1", # draw the newest log
"b"], cfg)
return cfg
def test_listening_from_the_menu_writes_a_log_and_a_report(monkeypatch,
console,
settings_dir,
tmp_path):
_listen_and_draw(monkeypatch, console, tmp_path)
logs = list(tmp_path.glob("adsb_*.jsonl"))
assert len(logs) == 1
lines = [json.loads(x) for x in logs[0].read_text().splitlines() if x]
assert lines[0]["log"] == "bandsaunter-adsb"
assert any(line.get("icao") for line in lines[1:])
told = logs[0].with_suffix(".txt")
assert told.is_file() and "aircraft" in told.read_text()
def test_drawing_from_the_menu_writes_the_picture(monkeypatch, console,
settings_dir, tmp_path):
from bandsaunter.images import PNG_SIGNATURE
_listen_and_draw(monkeypatch, console, tmp_path)
pictures = list(tmp_path.glob("adsb_*.png"))
assert len(pictures) == 1
assert pictures[0].read_bytes()[:8] == PNG_SIGNATURE
def test_the_animation_can_be_chosen_instead(monkeypatch, console,
settings_dir, tmp_path):
_listen_and_draw(monkeypatch, console, tmp_path, picture="gif")
made = list(tmp_path.glob("adsb_*.gif"))
assert len(made) == 1
assert made[0].read_bytes()[:6] == b"GIF89a"
def test_drawing_when_there_is_nothing_to_draw_says_so(monkeypatch,
settings_dir,
tmp_path, capsys):
loud = Console(width=100)
cfg = ScanConfig(output_dir=str(tmp_path))
run(monkeypatch, loud, ["m", "b"], cfg)
assert "no logs" in capsys.readouterr().out
def test_listening_can_draw_as_soon_as_it_stops(monkeypatch, console,
settings_dir, tmp_path):
"""One key, from nothing to a picture."""
cfg = ScanConfig(output_dir=str(tmp_path))
run(monkeypatch, console,
[number("simulate"), "yes", number("seconds"), "3",
number("lookup"), "no", number("basemap"), "no",
number("draw_after"), "yes",
number("picture"), "png", "l", "b"], cfg)
assert list(tmp_path.glob("adsb_*.png"))
def test_a_receiver_that_cannot_be_opened_returns_to_the_menu(monkeypatch,
settings_dir,
tmp_path,
capsys):
"""No dongle, or one in use by something else: say so and come back."""
from bandsaunter.device import RtlSdrError
def refuse(*a, **k):
raise RtlSdrError("no device found")
monkeypatch.setattr("bandsaunter.device.RtlSdrDevice", refuse)
loud = Console(width=100)
cfg = ScanConfig(output_dir=str(tmp_path))
run(monkeypatch, loud, ["l", "b"], cfg) # simulate is off
assert "cannot open the receiver" in capsys.readouterr().out
def test_the_logs_are_listed_newest_first(tmp_path):
import os
import time
for i, name in enumerate(("adsb_a.jsonl", "adsb_b.jsonl", "adsb_c.jsonl")):
path = tmp_path / name
path.write_text("{}\n")
os.utime(path, (time.time() + i, time.time() + i))
assert [p.name for p in air.logs_in(tmp_path)] == \
["adsb_c.jsonl", "adsb_b.jsonl", "adsb_a.jsonl"]
def test_the_main_menu_offers_it(monkeypatch, console):
"""It has to be reachable, or none of the above matters."""
from bandsaunter import tui as menus
seen = {}
monkeypatch.setattr(menus, "aircraft_menu",
lambda console, cfg: seen.setdefault("opened", True))
drive(monkeypatch, ["5", "q"])
menus._main_loop(console, ScanConfig())
assert seen.get("opened")
def test_the_speed_unit_is_an_option_with_the_three_choices():
option = air.by_key("speed_unit")
assert option is not None
assert set(option.choices) == {"knots", "mph", "kph"}
assert "distance" in option.detail # it moves the miles too
def test_the_speed_unit_is_changed_from_the_menu(monkeypatch, console,
settings_dir, tmp_path):
cfg = ScanConfig(output_dir=str(tmp_path))
run(monkeypatch, console, [number("speed_unit"), "mph", "s", "b"], cfg)
assert air.load_options().speed_unit == "mph"
def test_a_unit_that_is_not_one_of_the_three_is_refused(monkeypatch, console,
settings_dir, tmp_path):
cfg = ScanConfig(output_dir=str(tmp_path))
run(monkeypatch, console,
[number("speed_unit"), "furlongs", "", "s", "b"], cfg)
assert air.load_options().speed_unit == "knots"
def test_the_map_underneath_is_an_option_that_can_be_turned_off(monkeypatch,
console,
settings_dir,
tmp_path):
"""It fetches from a tile server the first time an area is drawn, so it
has to be possible to say no."""
assert air.AircraftOptions().basemap is True
cfg = ScanConfig(output_dir=str(tmp_path))
run(monkeypatch, console, [number("basemap"), "no", "s", "b"], cfg)
assert air.load_options().basemap is False

459
tests/test_basemap.py Normal file
View file

@ -0,0 +1,459 @@
"""The ground under the aircraft: reading tiles, and putting them on the map.
Nothing here touches the network. The tiles are made up in the test and fed
in through the same door the real ones come through, and the PNGs are built
here from the specification rather than by the decoder they are testing.
"""
import struct
import zlib
import numpy as np
import pytest
from bandsaunter import basemap as bm
from bandsaunter import flightmap as fm
from test_flightmap import _has_text, two_aircraft
# ---------------------------------------------------------------------------
# Making PNGs the hard way, so the decoder is tested against the format
# ---------------------------------------------------------------------------
def _chunk(tag: bytes, body: bytes) -> bytes:
return (struct.pack(">I", len(body)) + tag + body
+ struct.pack(">I", zlib.crc32(tag + body) & 0xFFFFFFFF))
def _paeth(a: int, b: int, c: int) -> int:
p = a + b - c
pa, pb, pc = abs(p - a), abs(p - b), abs(p - c)
if pa <= pb and pa <= pc:
return a
return b if pb <= pc else c
def make_png(pixels: np.ndarray, colour: int = 2, filter_type: int = 0,
palette: np.ndarray | None = None) -> bytes:
"""A PNG with every row written using one filter, built from the spec."""
pixels = np.asarray(pixels, dtype=np.uint8)
height, width = pixels.shape[0], pixels.shape[1]
channels = {0: 1, 2: 3, 3: 1, 4: 2, 6: 4}[colour]
flat = pixels.reshape(height, width * channels)
raw = bytearray()
previous = bytearray(width * channels)
for row in flat:
line = bytearray(int(v) for v in row)
out = bytearray(len(line))
for i, value in enumerate(line):
left_raw = line[i - channels] if i >= channels else 0
above = previous[i]
upleft = previous[i - channels] if i >= channels else 0
if filter_type == 0:
out[i] = value
elif filter_type == 1:
out[i] = (value - left_raw) & 0xFF
elif filter_type == 2:
out[i] = (value - above) & 0xFF
elif filter_type == 3:
out[i] = (value - ((left_raw + above) >> 1)) & 0xFF
else:
out[i] = (value - _paeth(left_raw, above, upleft)) & 0xFF
raw.append(filter_type)
raw += out
previous = line
body = (b"\x89PNG\r\n\x1a\n"
+ _chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8,
colour, 0, 0, 0)))
if palette is not None:
body += _chunk(b"PLTE", np.asarray(palette, dtype=np.uint8).tobytes())
body += _chunk(b"IDAT", zlib.compress(bytes(raw), 6))
return body + _chunk(b"IEND", b"")
def a_picture(height=9, width=7, seed=3) -> np.ndarray:
rng = np.random.default_rng(seed)
return rng.integers(0, 256, size=(height, width, 3), dtype=np.uint8)
# ---------------------------------------------------------------------------
# Reading a PNG
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("filter_type", [0, 1, 2, 3, 4])
def test_every_filter_the_format_defines_is_undone(filter_type):
"""None, Sub, Up, Average and Paeth: all five, or a tile comes back as
coloured noise and nobody notices until the map looks wrong."""
want = a_picture()
assert np.array_equal(bm.decode_png(make_png(want, 2, filter_type)), want)
def test_what_this_program_writes_it_can_read_back():
from bandsaunter.images import write_png
import tempfile
from pathlib import Path
want = a_picture(height=20, width=13, seed=9)
with tempfile.TemporaryDirectory() as tmp:
path = write_png(Path(tmp) / "x.png", want)
assert np.array_equal(bm.decode_png(path.read_bytes()), want)
def test_a_palette_image_is_looked_up():
"""The tiles most servers send are palette images."""
palette = np.array([[0, 0, 0], [255, 0, 0], [0, 128, 255]], dtype=np.uint8)
indices = np.array([[0, 1, 2], [2, 1, 0]], dtype=np.uint8)[:, :, None]
got = bm.decode_png(make_png(indices, colour=3, palette=palette))
assert np.array_equal(got, palette[indices[:, :, 0]])
def test_a_grey_image_becomes_grey_pixels():
grey = np.array([[0, 64], [128, 255]], dtype=np.uint8)[:, :, None]
got = bm.decode_png(make_png(grey, colour=0))
assert got.shape == (2, 2, 3)
assert np.array_equal(got[:, :, 0], got[:, :, 2])
assert got[1, 1, 0] == 255
def test_transparency_is_dropped_rather_than_misread():
rng = np.random.default_rng(1)
rgba = rng.integers(0, 256, size=(4, 4, 4), dtype=np.uint8)
got = bm.decode_png(make_png(rgba, colour=6))
assert np.array_equal(got, rgba[:, :, :3])
@pytest.mark.parametrize("body,why", [
(b"not a png at all", "not a PNG"),
(b"\x89PNG\r\n\x1a\n", "no image"),
])
def test_something_that_is_not_a_tile_is_refused(body, why):
with pytest.raises(bm.PNGError):
bm.decode_png(body)
def test_a_depth_this_cannot_read_says_so_rather_than_guessing():
header = (b"\x89PNG\r\n\x1a\n"
+ _chunk(b"IHDR", struct.pack(">IIBBBBB", 2, 2, 16, 2, 0, 0, 0))
+ _chunk(b"IEND", b""))
with pytest.raises(bm.PNGError):
bm.decode_png(header)
def test_an_interlaced_tile_is_refused():
header = (b"\x89PNG\r\n\x1a\n"
+ _chunk(b"IHDR", struct.pack(">IIBBBBB", 2, 2, 8, 2, 0, 0, 1))
+ _chunk(b"IEND", b""))
with pytest.raises(bm.PNGError):
bm.decode_png(header)
# ---------------------------------------------------------------------------
# Which tiles, and where they go
# ---------------------------------------------------------------------------
def test_the_middle_of_the_world_is_the_middle_of_the_grid():
assert bm.tile_of(0.0, 0.0, 0) == pytest.approx((0.5, 0.5))
assert bm.tile_of(0.0, -180.0, 1)[0] == pytest.approx(0.0)
assert bm.tile_of(85.05, 0.0, 1)[1] == pytest.approx(0.0, abs=0.001)
def test_a_known_place_lands_in_its_known_tile():
"""Seattle at zoom 10 is tile 164, 357 -- worked out from the standard
formula, not from this module."""
import math
lat, lon, z = 47.6062, -122.3321, 10
n = 2 ** z
x = int((lon + 180.0) / 360.0 * n)
y = int((1.0 - math.asinh(math.tan(math.radians(lat))) / math.pi) / 2.0 * n)
got = bm.tile_of(lat, lon, z)
assert (int(got[0]), int(got[1])) == (x, y)
def test_a_small_box_gets_more_detail_than_a_large_one():
tight = bm.choose_zoom(47.5, -122.4, 47.7, -122.2)
wide = bm.choose_zoom(30.0, -130.0, 50.0, -70.0)
assert tight > wide
assert tight <= bm.MAX_ZOOM and wide >= bm.MIN_ZOOM
def test_the_zoom_is_chosen_to_stay_inside_the_tile_budget():
south, west, north, east = 47.0, -122.8, 48.5, -121.0
zoom = bm.choose_zoom(south, west, north, east, max_tiles=12)
x0, y0 = bm.tile_of(north, west, zoom)
x1, y1 = bm.tile_of(south, east, zoom)
tiles = (int(x1) - int(x0) + 1) * (int(y1) - int(y0) + 1)
assert tiles <= 12
# ---------------------------------------------------------------------------
# Stitching, without a network
# ---------------------------------------------------------------------------
def solid(value):
"""A tile of one colour, as PNG bytes."""
px = np.zeros((bm.TILE_PIXELS, bm.TILE_PIXELS, 3), dtype=np.uint8) + value
return make_png(px)
def counting_fetcher(answers=None, fail_on=()):
"""A stand-in for the tile server that records what it was asked for."""
asked = []
def fetch(z, x, y, **kw):
asked.append((z, x, y))
if (z, x, y) in fail_on:
return None
if answers is not None:
return answers(z, x, y)
return solid((x * 37 + y * 11) % 256)
fetch.asked = asked
return fetch
def test_the_tiles_are_stitched_in_the_right_places():
fetch = counting_fetcher()
raster, ox, oy = bm.mosaic(47.0, -122.8, 48.5, -121.0, 9, fetch=fetch,
pause=0)
assert raster is not None
assert raster.shape[2] == 3
assert raster.shape[0] % bm.TILE_PIXELS == 0
assert len(fetch.asked) == (raster.shape[0] // bm.TILE_PIXELS) * \
(raster.shape[1] // bm.TILE_PIXELS)
# The first tile fetched is the north-west corner, and it is drawn there.
z, x, y = fetch.asked[0]
assert (ox, oy) == (x * bm.TILE_PIXELS, y * bm.TILE_PIXELS)
assert raster[0, 0, 0] == (x * 37 + y * 11) % 256
def test_a_tile_that_does_not_arrive_leaves_a_hole_rather_than_an_error():
"""One tile missing is a hole in the map, not a failed drawing."""
seen = counting_fetcher()
bm.mosaic(47.0, -122.8, 48.5, -121.0, 9, fetch=seen, pause=0)
missing = seen.asked[:1]
raster, _, _ = bm.mosaic(47.0, -122.8, 48.5, -121.0, 9,
fetch=counting_fetcher(fail_on=missing), pause=0)
assert raster is not None
assert not raster[:bm.TILE_PIXELS, :bm.TILE_PIXELS].any() # the hole
def test_nothing_at_all_gives_no_map():
def nothing(z, x, y, **kw):
return None
raster, _, _ = bm.mosaic(47.0, -122.8, 48.5, -121.0, 9, fetch=nothing,
pause=0)
assert raster is None
def test_a_corrupt_tile_is_skipped():
def rubbish(z, x, y, **kw):
return b"not a png"
raster, _, _ = bm.mosaic(47.0, -122.8, 48.5, -121.0, 9, fetch=rubbish,
pause=0)
assert raster is None
# ---------------------------------------------------------------------------
# On to the picture
# ---------------------------------------------------------------------------
def gradient_tile(z, x, y, **kw):
"""A tile that is black at the top and white at the bottom."""
column = np.linspace(0, 255, bm.TILE_PIXELS).astype(np.uint8)
px = np.repeat(column[:, None], bm.TILE_PIXELS, axis=1)
return make_png(np.repeat(px[:, :, None], 3, axis=2))
def test_the_ground_comes_back_as_levels_the_map_can_paint():
levels = bm.ground_under(47.0, -122.8, 48.5, -121.0, 200, 150,
shades=32, fetch=gradient_tile, pause=0)
assert levels is not None
assert levels.shape == (150, 200)
assert levels.dtype == np.uint8
assert levels.max() <= 31
def tile_bounds(zoom: int, x: int, y: int):
"""The corners of one tile, from the inverse of the standard formula.
Written here rather than taken from the module, so that a test of where
the pixels land is not asking the code under test where they land.
"""
import math
n = 2.0 ** zoom
def lat_of(row):
return math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * row / n))))
return (lat_of(y + 1), x / n * 360.0 - 180.0, # south, west
lat_of(y), (x + 1) / n * 360.0 - 180.0) # north, east
def test_the_map_is_inverted_so_that_ink_shows_on_a_dark_picture():
"""A printed map is dark ink on white paper; this picture is the other
way round, so the dark parts of a tile are the bright parts here."""
south, west, north, east = tile_bounds(9, 81, 178)
levels = bm.ground_under(south, west, north, east, 64, 64, shades=32,
fetch=gradient_tile, zoom=9, pause=0)
# The tile is black at the top and white at the bottom, so the picture
# has to be bright at the top and dark at the bottom.
assert levels[0].mean() > levels[-1].mean()
assert levels[0].mean() > 25 and levels[-1].mean() < 6
def test_north_is_at_the_top():
def half_and_half(z, x, y, **kw):
px = np.zeros((bm.TILE_PIXELS, bm.TILE_PIXELS, 3), dtype=np.uint8)
px[:bm.TILE_PIXELS // 2] = 255 # white in the north
return make_png(px)
south, west, north, east = tile_bounds(9, 81, 178)
levels = bm.ground_under(south, west, north, east, 40, 40, shades=32,
fetch=half_and_half, zoom=9, pause=0)
assert levels[0].mean() < levels[-1].mean() # white inverts to dark
def test_east_is_to_the_right():
def half_and_half(z, x, y, **kw):
px = np.zeros((bm.TILE_PIXELS, bm.TILE_PIXELS, 3), dtype=np.uint8)
px[:, :bm.TILE_PIXELS // 2] = 255 # white in the west
return make_png(px)
south, west, north, east = tile_bounds(9, 81, 178)
levels = bm.ground_under(south, west, north, east, 40, 40, shades=32,
fetch=half_and_half, zoom=9, pause=0)
assert levels[:, 0].mean() < levels[:, -1].mean()
def test_one_tile_covers_its_own_box_exactly():
"""The reprojection has to put the tile where the tile says it is."""
def corner_marks(z, x, y, **kw):
px = np.zeros((bm.TILE_PIXELS, bm.TILE_PIXELS, 3), dtype=np.uint8)
px[:8, :8] = 255 # a mark in the north-west
return make_png(px)
south, west, north, east = tile_bounds(9, 81, 178)
levels = bm.ground_under(south, west, north, east, 128, 128, shades=32,
fetch=corner_marks, zoom=9, pause=0)
dark = levels < levels.max() / 2 # the white corner, inverted
assert dark[:4, :4].all()
assert not dark[64:, 64:].any()
def test_no_tiles_means_no_ground_rather_than_an_exception():
def nothing(z, x, y, **kw):
return None
assert bm.ground_under(47.0, -122.8, 48.5, -121.0, 50, 50,
fetch=nothing, pause=0) is None
def test_a_box_that_makes_no_sense_is_refused_quietly():
assert bm.ground_under(48.0, -122.0, 47.0, -123.0, 50, 50,
fetch=gradient_tile, pause=0) is None
# ---------------------------------------------------------------------------
# Fetching once, and saying who it came from
# ---------------------------------------------------------------------------
def test_a_tile_is_fetched_once_and_then_read_from_the_disk(tmp_path,
monkeypatch):
calls = []
class _Answer:
def __init__(self, body):
self.body = body
def read(self, *a):
return self.body
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def fake_urlopen(request, timeout=None):
calls.append(request.full_url)
assert "bandsaunter" in request.headers.get("User-agent", ""), \
"a tile server is told who is asking"
return _Answer(solid(120))
monkeypatch.setattr(bm.urllib.request, "urlopen", fake_urlopen)
first = bm.fetch_tile(9, 81, 178, cache=tmp_path)
second = bm.fetch_tile(9, 81, 178, cache=tmp_path)
assert first == second
assert len(calls) == 1, "the second one came off the disk"
assert (tmp_path / "9" / "81" / "178.png").is_file()
def test_a_tile_server_that_is_not_there_is_not_an_error(tmp_path, monkeypatch):
def refuse(request, timeout=None):
raise OSError("no route to host")
monkeypatch.setattr(bm.urllib.request, "urlopen", refuse)
assert bm.fetch_tile(9, 81, 178, cache=tmp_path) is None
def test_the_tile_server_can_be_pointed_somewhere_else(tmp_path, monkeypatch):
seen = []
def fake_urlopen(request, timeout=None):
seen.append(request.full_url)
raise OSError("stop here")
monkeypatch.setattr(bm.urllib.request, "urlopen", fake_urlopen)
bm.fetch_tile(4, 2, 3, url="https://example.invalid/{z}/{x}/{y}.png",
cache=tmp_path)
assert seen == ["https://example.invalid/4/2/3.png"]
# ---------------------------------------------------------------------------
# And under the aircraft
# ---------------------------------------------------------------------------
def test_the_map_goes_under_the_picture_and_is_credited(tmp_path):
out = fm.animate(two_aircraft(), tmp_path / "on-the-map.png", width=400,
ground=True, fetch=gradient_tile)
assert out is not None and out.ground
assert "on the map" in out.summary()
from bandsaunter.images import PNG_SIGNATURE
assert out.path.read_bytes()[:8] == PNG_SIGNATURE
def test_the_ground_is_drawn_in_its_own_shades():
view = fm.fit(two_aircraft(), width=400)
levels, credit = fm.ground_for(view, fetch=gradient_tile)
assert levels is not None and credit
base = fm.background(view, ground=levels, attribution=credit)
body = base[view.top:view.top + view.height,
view.left:view.left + view.width]
ground = (body >= fm.GROUND) & (body < fm.GROUND + fm.GROUND_SHADES)
assert ground.mean() > 0.9 # nearly all of the body is map
def test_without_it_the_picture_is_the_plain_grid_it_always_was():
view = fm.fit(two_aircraft(), width=400)
base = fm.background(view)
assert not ((base >= fm.GROUND) & (base < fm.GROUND + fm.GROUND_SHADES)).any()
def test_no_network_falls_back_to_the_plain_grid(monkeypatch):
def nothing(z, x, y, **kw):
return None
view = fm.fit(two_aircraft(), width=400)
levels, credit = fm.ground_for(view, fetch=nothing)
assert levels is None and credit == ""
def test_the_credit_is_written_on_the_picture_itself():
"""A GIF travels without the readme that would otherwise carry it."""
view = fm.fit(two_aircraft(), width=520)
levels, credit = fm.ground_for(view, fetch=gradient_tile)
base = fm.background(view, ground=levels, attribution=credit)
assert _has_text(base, "OPENSTREETMAP")

View file

@ -398,3 +398,65 @@ def test_the_picture_is_an_even_number_of_pixels_across(tmp_path):
view = fm.fit(two_aircraft(), width=width)
w, h = fm.canvas_size(view)
assert w % 2 == 0 and h % 2 == 0
# ---------------------------------------------------------------------------
# Speeds in whatever the user reads
# ---------------------------------------------------------------------------
def _label_text(unit: str, width: int = 700) -> np.ndarray:
"""Draw one aircraft and hand back the pixels its label was written in."""
tracks = [straight(speed=480.0)]
view = fm.fit(tracks, width=width)
base = fm.background(view, unit=unit)
return fm.render_frame(base, view, tracks, tracks[0].first_seen + 120,
unit=unit)
def _has_text(frame: np.ndarray, text: str) -> bool:
"""Whether a string was stamped anywhere in the frame, found by drawing
it again and looking for the same pattern."""
from bandsaunter.images import GLYPH_H, draw_text, text_width
stamp = np.zeros((GLYPH_H, max(1, text_width(text))), dtype=np.uint8)
draw_text(stamp, 0, 0, text, 1)
rows, cols = stamp.shape
wanted = stamp.astype(bool)
if not wanted.any():
return False
for y in range(frame.shape[0] - rows):
for x in range(frame.shape[1] - cols):
patch = frame[y:y + rows, x:x + cols]
if np.all(patch[wanted] != fm.BG) and \
np.all(patch[~wanted] == patch[~wanted][0]):
return True
return False
@pytest.mark.parametrize("unit,label", [("knots", "KT"), ("mph", "MPH"),
("kph", "KM/H")])
def test_the_speed_on_the_map_carries_its_unit(unit, label):
"""A bare 480 beside an aircraft is three different speeds depending on
who is reading it."""
assert _has_text(_label_text(unit), label)
def test_the_number_beside_it_is_converted():
assert _has_text(_label_text("knots"), "480KT")
assert _has_text(_label_text("mph"), "552MPH")
assert _has_text(_label_text("kph"), "889KM/H")
@pytest.mark.parametrize("unit,label", [("knots", "NM"), ("mph", "MI"),
("kph", "KM")])
def test_the_scale_bar_uses_the_same_kind_of_mile(unit, label):
"""Miles an hour beside a scale in nautical miles is two different miles
on one picture."""
view = fm.fit(two_aircraft(), width=700)
assert _has_text(fm.background(view, unit=unit), label)
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()

View file

@ -486,3 +486,64 @@ def test_a_simulated_sky_is_heard_recorded_and_read_back(tmp_path):
flown = track.distance_nm
expected = track.top_speed_kt * track.seconds / 3600.0
assert flown == pytest.approx(expected, rel=0.35, abs=0.2)
# ---------------------------------------------------------------------------
# Speeds and distances in whatever the user reads
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("unit,speed,distance", [
("knots", "kt", "nm"), ("mph", "mph", "mi"), ("kph", "km/h", "km"),
])
def test_the_report_says_which_unit_it_is_using(tmp_path, unit, speed,
distance):
log = _log(tmp_path)
for i in range(4):
log.append(_Frame(callsign="RYR1234", altitude_ft=30_000,
ground_speed_kt=420.0, track_deg=90.0),
_Craft(51.5, -0.12 + i * 0.05, "RYR1234"),
when=1_000_000.0 + i * 30)
log.close()
text = "\n".join(report(read_logs(log.path), unit=unit))
assert "speed: up to " in text and speed in text
assert distance in text
def test_the_numbers_are_the_right_ones():
from bandsaunter.flightlog import in_distance, in_speed
assert in_speed(100, "knots") == pytest.approx(100.0)
assert in_speed(100, "mph") == pytest.approx(115.08, abs=0.01)
assert in_speed(100, "kph") == pytest.approx(185.2, abs=0.01)
assert in_distance(100, "mph") == pytest.approx(115.08, abs=0.01)
assert in_distance(100, "kph") == pytest.approx(185.2, abs=0.01)
def test_an_unknown_unit_falls_back_to_what_the_aircraft_said():
from bandsaunter.flightlog import in_speed, speed_label
assert in_speed(100, "furlongs per fortnight") == 100.0
assert speed_label("") == "kt"
def test_the_log_itself_is_always_in_knots(tmp_path):
"""The recording is what arrived; converting it would lose the original."""
log = _log(tmp_path)
log.append(_Frame(ground_speed_kt=420.0, track_deg=90.0),
_Craft(51.5, -0.12), when=1_000_000.0)
log.close()
line = [json.loads(x) for x in log.path.read_text().splitlines()][1]
assert line["gs_kt"] == 420.0
def test_what_one_track_says_of_itself_follows_the_unit(tmp_path):
log = _log(tmp_path)
for i in range(3):
log.append(_Frame(ground_speed_kt=420.0, track_deg=90.0,
altitude_ft=30_000),
_Craft(51.5, -0.12 + i * 0.05), when=1_000_000.0 + i * 30)
log.close()
track = read_logs(log.path)[0]
assert "kt" in track.describe()
assert "mph" in track.describe("mph")
assert "km/h" in track.describe("kph")