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:
parent
4239635f74
commit
96fc21ac7d
18 changed files with 3298 additions and 281 deletions
81
README.md
81
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
723
bandsaunter/aircraft.py
Normal 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
352
bandsaunter/basemap.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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},"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
280
tests/test_aircraft_display.py
Normal file
280
tests/test_aircraft_display.py
Normal 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
346
tests/test_aircraft_menu.py
Normal 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
459
tests/test_basemap.py
Normal 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")
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue