A sharper map, and aircraft that fade rather than blink out

Three things asked for, and a fourth found while doing them.

The map looked like a photograph of a map, and did so twice over.  The
window fetched at its own pixel size but for a box a third larger in each
direction -- the margin added to stop the ground blinking -- and then cut
the middle out, so every pixel was enlarged by two thirds.  It now asks
for enough pixels to cover the bigger box at the window's own detail.
Underneath that, both the window and the animation took the nearest source
pixel: the tile mosaic is commonly half again the size of the picture, so
most of every tile was thrown away and what survived was the aliasing.
Both now average the source pixels that fall in each output cell, done as
the difference of a running total rather than a loop.

An aircraft that goes quiet now fades instead of vanishing.  Taking it off
between one frame and the next says it stopped existing; fading says it
stopped talking, which is what happened.  It fades where it was last
actually seen and never along a reckoned track, because the reason for
giving up on it is that where it would be by now is a guess.  --fade sets
how long, and it is in the menu.  The window has alpha and fades smoothly;
an indexed picture cannot blend, so the animation gained a fourth ramp at
a seventh of full and fades in four steps, which at a second apart reads
as a fade.  A trail fades with the aircraft it belongs to, and the box
goes before the symbol does.

The aircraft's country of registration carries a flag now as well as the
two ends of its route, from the register where one answered and from the
address block otherwise.

And the fourth: the window's header counted an aircraft that had gone
quiet as overhead, which was saying more than had been heard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
This commit is contained in:
The Dust Council 2026-09-04 19:04:09 -07:00
parent df080f6571
commit 87b0954f0c
17 changed files with 1609 additions and 101 deletions

View file

@ -1014,8 +1014,19 @@ the arrow first, so the two ends of the flight stay whole and sit under one
another where they read as a pair. another where they read as a pair.
`d` cycles the detail — full box, just height and speed, or symbols alone — `d` cycles the detail — full box, just height and speed, or symbols alone —
for when the sky is busy. `t` toggles trails, `g` the map underneath, `+`/`-` for when the sky is busy. `t` toggles trails, `g` the map underneath, `[`/`]`
the range, `q` closes it. its brightness, `+`/`-` the range, `q` closes it.
Aircraft fade out here too, over `--fade` seconds, keeping their symbol and
losing their box as they go — a box at a tenth of its colour is something in
the way of the aircraft still flying. One that has gone quiet stops being
counted as overhead, since saying it is would be saying more than was heard.
The map holds still. It is fetched a third larger than the window in every
direction, and the middle of the view settles once and then stays put rather
than being recomputed as aircraft come and go — otherwise the view shifts by a
fraction of a mile every few seconds, throws away the tiles fetched for the
old one, and the ground blinks out while new ones arrive.
**Closing the window leaves exactly the files a passive capture does**: the **Closing the window leaves exactly the files a passive capture does**: the
same log, the same report, the same KML and animation, because it is the same same log, the same report, the same KML and animation, because it is the same
@ -1138,6 +1149,14 @@ the twenty minutes the data says it took. Nothing moves at a constant speed
for the look of the thing, and an aircraft not heard from for five minutes for the look of the thing, and an aircraft not heard from for five minutes
stops being drawn rather than being flown on by guesswork. stops being drawn rather than being flown on by guesswork.
**An aircraft that goes quiet fades rather than vanishing.** Taking it off the
picture between one frame and the next says it stopped existing; fading it
says it stopped talking, which is what happened. It fades where it was last
actually seen and never along a reckoned track — the reason for giving up on
it in the first place is that where it would be by now is a guess. `--fade
SECONDS` (20 by default, and in the options menu) sets how long that takes;
zero takes it away at once, as before.
Time runs at `--speed` seconds of flying per second of animation, or give Time runs at `--speed` seconds of flying per second of animation, or give
`--seconds` and let it work the speed out. Altitude is the colour, low warm to `--seconds` and let it work the speed out. Altitude is the colour, low warm to
high cold, with the key along the bottom; the trail behind each aircraft is the high cold, with the key along the bottom; the trail behind each aircraft is the
@ -1165,6 +1184,16 @@ at all.
Where a route arrives as nothing but a pair of airport codes, the country Where a route arrives as nothing but a pair of airport codes, the country
comes from the code itself: the first letter or two of an ICAO code is a comes from the code itself: the first letter or two of an ICAO code is a
region, so `EGLL` is British and `KSEA` American with nothing else to go on. region, so `EGLL` is British and `KSEA` American with nothing else to go on.
The aircraft's own **country of registration** gets a flag too, beside the
type and registration — from the register where one answered, and otherwise
from the 24-bit address, which says by treaty who issued it.
**Every aerodrome under the picture is marked**, not only the ones being flown
between: a receiver hears aircraft over its own county, and the county's
airports are what say where on the map you are looking. They come from the
same OpenStreetMap data the tiles are drawn from, asked as a question rather
than a picture, once per area and kept for a month — a runway does not move.
`--no-airports` turns it off.
The GIF is written here from first principles — a palette, an LZW stream, frame The GIF is written here from first principles — a palette, an LZW stream, frame
differencing with a transparent index — in the same spirit as the PNGs differencing with a transparent index — in the same spirit as the PNGs
@ -1255,6 +1284,17 @@ 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 one), and when there is no network and nothing cached the picture falls back
to the plain grid it drew before. to the plain grid it drew before.
The map is drawn at the resolution it was fetched at, not stretched: tiles are
averaged down to the picture rather than point-sampled, so lettering and roads
stay whole instead of breaking up, and the window fetches enough pixels to
cover its margin at full detail rather than enlarging what it has.
`--map-brightness PERCENT` (70 by default) is how far up its range the map is
drawn. The ground has to stay dark enough that the aircraft are the brightest
thing on the picture and light enough that a coastline can be made out at all,
and which way to err depends on the screen you are looking at. In the window,
`[` and `]` change it while it runs.
## Meters and weather sensors ## Meters and weather sensors
Two things on the ISM bands are worth naming rather than reporting as hex. Two things on the ISM bands are worth naming rather than reporting as hex.

View file

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

View file

@ -110,11 +110,14 @@ class AircraftOptions:
width: int = 960 width: int = 960
trail: float = 0.0 trail: float = 0.0
stale: float = 300.0 stale: float = 300.0
fade: float = 20.0
labels: bool = True labels: bool = True
radius: float = 100.0 radius: float = 100.0
location: str = "" location: str = ""
recheck: bool = False recheck: bool = False
basemap: bool = True basemap: bool = True
airports: bool = True
map_brightness: int = 70
tile_url: str = "" tile_url: str = ""
def validate(self) -> list[str]: def validate(self) -> list[str]:
@ -268,6 +271,19 @@ OPTIONS: tuple[Setting, ...] = (
"leaves a comet tail instead, which is easier to follow when many " "leaves a comet tail instead, which is easier to follow when many "
"aircraft cross the same piece of sky.", "aircraft cross the same piece of sky.",
unit="s", minimum=0.0, flags=("--trail",), example="0"), unit="s", minimum=0.0, flags=("--trail",), example="0"),
O("fade", "Fade out over", "Drawing", "float",
"how long an aircraft takes to fade away once it has gone quiet",
"An aircraft that stops transmitting has not stopped existing, and "
"taking it off the picture between one frame and the next says that it "
"did. Instead it is left where it was last actually seen and fades from "
"there, which reads as an aircraft going quiet rather than as a blink. "
"Nothing is invented by it: the fading happens at the last known "
"position, never at a reckoned one, because the reason for giving up on "
"an aircraft in the first place is that where it would be by now is a "
"guess. Zero takes it away the moment it is given up on.",
unit="s", minimum=0.0, flags=("--fade",), example="20",
guidance="Long enough to notice, short enough that a busy sky is not "
"half ghosts."),
O("stale", "Forget after", "Drawing", "float", O("stale", "Forget after", "Drawing", "float",
"stop drawing an aircraft this long after its last report", "stop drawing an aircraft this long after its last report",
"Between reports an aircraft is dead-reckoned from the speed and " "Between reports an aircraft is dead-reckoned from the speed and "
@ -311,6 +327,28 @@ OPTIONS: tuple[Setting, ...] = (
guidance="Worth turning on for anything recorded before this version. " guidance="Worth turning on for anything recorded before this version. "
"Newer logs have the check applied as they are written, so it " "Newer logs have the check applied as they are written, so it "
"finds almost nothing."), "finds almost nothing."),
O("airports", "Mark the airports", "Drawing", "bool",
"mark every aerodrome on the map, not only the ones flown between",
"A route names the two airports its aircraft is flying between, and "
"those are almost never the ones underneath: a receiver hears aircraft "
"over its own county, and the county's airports are what say where on "
"the map you are looking. They are asked for once per area from the "
"same map data the tiles are drawn from, and kept on disk for a month "
"afterwards, since a runway does not move.",
flags=("--airports",), off_flags=("--no-airports",),
guidance="Turn it off for a picture with nothing but the aircraft on "
"it, or where there is no network and nothing cached."),
O("map_brightness", "Map brightness", "Drawing", "int",
"how bright the map under the aircraft is drawn, as a percentage",
"The map is the ground, not the subject, so it is drawn dark enough "
"that the aircraft and their trails stay the brightest things on the "
"picture. Too dark and a coastline cannot be made out at all; too "
"bright and a city washes out the aircraft crossing it. This is which "
"way to err on the screen you are actually looking at.",
unit="%", minimum=10, maximum=100, flags=("--map-brightness",),
metavar="PERCENT", example="70",
guidance="Turn it up until the coast and the roads are readable, and "
"no further."),
O("basemap", "Map underneath", "Drawing", "bool", O("basemap", "Map underneath", "Drawing", "bool",
"draw a real map under the flight paths", "draw a real map under the flight paths",
"A flight path over a black rectangle says how the aircraft moved and " "A flight path over a black rectangle says how the aircraft moved and "
@ -641,7 +679,9 @@ def watch(console, options: AircraftOptions, output_dir: str,
book = FlightBook(online=options.lookup) book = FlightBook(online=options.lookup)
sky = livemap.Sky(unit=options.speed_unit, hold=options.hold, sky = livemap.Sky(unit=options.speed_unit, hold=options.hold,
home=read_position(options.location), home=read_position(options.location),
radius_nm=radius_in_nm(options) or 100.0) radius_nm=radius_in_nm(options) or 100.0,
brightness=max(10, options.map_brightness) / 100.0,
fade=max(0.0, options.fade))
sky.started = started sky.started = started
sky.log_name = log.path.name if log is not None else "" sky.log_name = log.path.name if log is not None else ""
if options.simulate: if options.simulate:
@ -826,8 +866,11 @@ def draw(console, options: AircraftOptions, tracks, out_path, book=None):
seconds=options.length, speed=options.speed, seconds=options.length, speed=options.speed,
width=options.width, trail_seconds=options.trail, width=options.width, trail_seconds=options.trail,
stale=options.stale, labels=options.labels, stale=options.stale, labels=options.labels,
fade=max(0.0, options.fade),
unit=options.speed_unit, ground=options.basemap, unit=options.speed_unit, ground=options.basemap,
tile_url=options.tile_url, tile_url=options.tile_url,
brightness=max(10, options.map_brightness) / 100.0,
airports=options.airports,
radius_nm=radius_in_nm(options), radius_nm=radius_in_nm(options),
centre=read_position(options.location)) centre=read_position(options.location))
except (OSError, RuntimeError, ValueError) as exc: except (OSError, RuntimeError, ValueError) as exc:

View file

@ -22,10 +22,12 @@ numpy, which is all this needs.
from __future__ import annotations from __future__ import annotations
import json
import math import math
import os import os
import struct import struct
import time import time
import urllib.parse
import urllib.request import urllib.request
import zlib import zlib
from pathlib import Path from pathlib import Path
@ -36,7 +38,7 @@ from . import __version__
__all__ = ["decode_png", "tile_of", "choose_zoom", "fetch_tile", "mosaic", __all__ = ["decode_png", "tile_of", "choose_zoom", "fetch_tile", "mosaic",
"ground_under", "TILE_URL", "ATTRIBUTION", "MAX_TILES", "MAX_ZOOM", "ground_under", "TILE_URL", "ATTRIBUTION", "MAX_TILES", "MAX_ZOOM",
"cache_dir", "PNGError"] "cache_dir", "PNGError", "airports_in", "AIRPORTS_URL"]
# The standard OpenStreetMap tiles. Any {z}/{x}/{y} server can be put here # The standard OpenStreetMap tiles. Any {z}/{x}/{y} server can be put here
# instead; nothing below knows anything about this one in particular. # instead; nothing below knows anything about this one in particular.
@ -48,8 +50,8 @@ ATTRIBUTION = "MAP DATA (C) OPENSTREETMAP CONTRIBUTORS"
# A drawing is worth a few dozen tiles and no more. Past that the zoom is # 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. # reduced instead: a coarser map still says where the coastline is.
MAX_TILES = 40 MAX_TILES = 90
MAX_ZOOM = 12 MAX_ZOOM = 13
MIN_ZOOM = 2 MIN_ZOOM = 2
TILE_PIXELS = 256 TILE_PIXELS = 256
@ -61,6 +63,24 @@ USER_AGENT = (f"bandsaunter/{__version__} "
FETCH_PAUSE = 0.12 FETCH_PAUSE = 0.12
# Where to ask what aerodromes are in a piece of the world. The same OSM
# data the tiles are drawn from, asked as a question rather than a picture.
AIRPORTS_URL = "https://overpass-api.de/api/interpreter"
# One question covers a whole view and is kept for a month, because runways
# do not move. The same politeness as the tiles: cache it, say who is
# asking, and do not ask twice for the same thing.
AIRPORT_CACHE_DAYS = 30
# Bumped when the question changes, so that answers to the old one are asked
# again rather than believed.
AIRPORT_CACHE_VERSION = 2
# Enough to name what is under an aircraft and not so many that the map is a
# list of airstrips.
MOST_AIRPORTS = 40
class PNGError(ValueError): class PNGError(ValueError):
"""A PNG this decoder cannot read.""" """A PNG this decoder cannot read."""
@ -294,6 +314,137 @@ def mosaic(south: float, west: float, north: float, east: float, zoom: int,
return canvas, left * TILE_PIXELS, top * TILE_PIXELS return canvas, left * TILE_PIXELS, top * TILE_PIXELS
# ---------------------------------------------------------------------------
# What is on the ground
# ---------------------------------------------------------------------------
def _resample(values: np.ndarray, edges: np.ndarray, axis: int) -> np.ndarray:
"""Average each output cell over the source pixels that fall in it.
A box filter, done as the difference of a running total so the whole
axis is two passes rather than a loop. Where the source is coarser than
the output -- a map zoomed in further than the tiles go -- a cell covers
less than one source pixel, and it takes that one.
"""
length = values.shape[axis]
starts = np.clip(np.floor(edges[:-1]).astype(np.int64), 0, length - 1)
ends = np.clip(np.ceil(edges[1:]).astype(np.int64), 1, length)
ends = np.maximum(ends, starts + 1)
running = np.cumsum(values, axis=axis, dtype=np.float64)
pad = np.zeros_like(np.take(running, [0], axis=axis))
running = np.concatenate([pad, running], axis=axis)
total = (np.take(running, ends, axis=axis)
- np.take(running, starts, axis=axis))
counts = (ends - starts).astype(np.float64)
shape = [1] * values.ndim
shape[axis] = counts.size
return (total / counts.reshape(shape)).astype(np.float32)
def _airport_cache(box) -> Path:
root = os.environ.get("XDG_CACHE_HOME") or "~/.cache"
name = "_".join(f"{round(v, 1):+06.1f}" for v in box)
return Path(root).expanduser() / "bandsaunter" / "airports" / f"{name}.json"
def airports_in(south: float, west: float, north: float, east: float,
url: str = AIRPORTS_URL, timeout: float = 45.0,
cache: Path | None = None, ask=None) -> list[dict]:
"""Every aerodrome in a piece of the world, with a code and a position.
Asked of the same OpenStreetMap data the tiles are drawn from, as a
question rather than a picture, and kept on disk afterwards: a runway
does not move, so one question covers a view for a month.
An aerodrome with no code is left out. A map wants to say *which*
airport an aircraft is over, and there are a great many landing strips
with a name and nothing else; the ones worth marking have a code.
"""
box = (round(south, 1), round(west, 1), round(north, 1), round(east, 1))
path = cache if cache is not None else _airport_cache(box)
try:
body = json.loads(path.read_text())
if int(body.get("version") or 0) >= AIRPORT_CACHE_VERSION and \
time.time() - float(body.get("fetched_at") or 0) < \
AIRPORT_CACHE_DAYS * 86_400:
return body.get("airports") or []
except (OSError, ValueError):
pass
found: list[dict] = []
try:
raw = (ask or _ask_overpass)(box, url, timeout)
for element in (raw or {}).get("elements", []):
tags = element.get("tags") or {}
code = _airport_code(tags)
lat = element.get("lat") or (element.get("center") or {}).get("lat")
lon = element.get("lon") or (element.get("center") or {}).get("lon")
if not code or lat is None or lon is None:
continue
found.append({"code": code,
"name": (tags.get("name") or "").strip(),
"icao": bool(tags.get("icao")),
"latitude": float(lat), "longitude": float(lon)})
except Exception:
return [] # a map with no airports on it, not a crash
# The ones with a real ICAO code first, since those are the ones an
# aircraft is likely to be flying to or from.
found.sort(key=lambda a: (0 if a.get("icao") else 1, a["code"]))
# One airport is often tagged twice -- a point for the terminal and an
# outline for the field -- and marking it twice writes its name over
# itself.
once: dict[str, dict] = {}
for one in found:
once.setdefault(one["code"], one)
found = list(once.values())[:MOST_AIRPORTS]
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({"fetched_at": time.time(),
"version": AIRPORT_CACHE_VERSION,
"airports": found}))
except OSError:
pass
return found
def _airport_code(tags: dict) -> str:
"""What to call an aerodrome, or nothing if it has no code at all.
The ICAO code where it has one. Otherwise a reference, and only when it
is four letters: a great many landing strips carry a local identifier
like "14AZ" or "MX-0492", which names nothing anybody would recognise
and turns a map into a list of airstrips.
"""
icao = (tags.get("icao") or "").strip().upper()
if len(icao) == 4 and icao.isalpha():
return icao
ref = (tags.get("ref") or "").strip().upper()
if len(ref) == 4 and ref.isalpha():
return ref
return ""
def _ask_overpass(box, url: str, timeout: float) -> dict:
"""The one question this program asks Overpass."""
south, west, north, east = box
where = f"({south},{west},{north},{east})"
# Relations as well as nodes and ways: a big airport is a relation more
# often than not -- Tucson International and Davis-Monthan both are --
# so asking only for the other two finds every airstrip in the county
# and misses the two the county is known for.
query = ("[out:json][timeout:25];"
f'(node["aeroway"="aerodrome"]{where};'
f' way["aeroway"="aerodrome"]{where};'
f' relation["aeroway"="aerodrome"]{where};);'
"out center tags;")
request = urllib.request.Request(
url, data=urllib.parse.urlencode({"data": query}).encode(),
headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(request, timeout=timeout) as answer:
return json.loads(answer.read(4_000_000).decode("utf8", "replace"))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Putting it under the picture # Putting it under the picture
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -323,16 +474,18 @@ def ground_under(south: float, west: float, north: float, east: float,
if tiles is None: if tiles is None:
return None return None
lons = west + (east - west) * (np.arange(width) + 0.5) / width # The edges of each output pixel rather than its middle, so that what
lats = north - (north - south) * (np.arange(height) + 0.5) / height # lands in it can be averaged. Taking the nearest source pixel instead
# throws away most of a tile -- the mosaic is commonly half again the
# size of the picture -- and what survives is the aliasing: hard, broken
# lettering and roads that come and go along their length.
lons = west + (east - west) * np.arange(width + 1) / width
lats = north - (north - south) * np.arange(height + 1) / height
span = float(2 ** zoom) * TILE_PIXELS span = float(2 ** zoom) * TILE_PIXELS
xs = (lons + 180.0) / 360.0 * span - origin_x xs = (lons + 180.0) / 360.0 * span - origin_x
clipped = np.clip(lats, -85.05112878, 85.05112878) clipped = np.clip(lats, -85.05112878, 85.05112878)
ys = (1.0 - np.arcsinh(np.tan(np.radians(clipped))) / math.pi) / 2.0 \ ys = (1.0 - np.arcsinh(np.tan(np.radians(clipped))) / math.pi) / 2.0 \
* span - origin_y * 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 # Brightness only, inverted, and dimmed. Inverted because a printed map
# is ink on white paper and this picture is the other way round: the # is ink on white paper and this picture is the other way round: the
@ -341,8 +494,9 @@ def ground_under(south: float, west: float, north: float, east: float,
# night background. Dimmed because the map is the ground under the # night background. Dimmed because the map is the ground under the
# aircraft rather than the subject: anything drawn on top has to stay the # aircraft rather than the subject: anything drawn on top has to stay the
# brightest thing on the picture. # brightest thing on the picture.
luma = (0.299 * picture[:, :, 0] + 0.587 * picture[:, :, 1] whole = (0.299 * tiles[:, :, 0] + 0.587 * tiles[:, :, 1]
+ 0.114 * picture[:, :, 2]) + 0.114 * tiles[:, :, 2]).astype(np.float32)
luma = _resample(_resample(whole, ys, axis=0), xs, axis=1)
low, high = float(luma.min()), float(luma.max()) low, high = float(luma.min()), float(luma.max())
if high - low < 1.0: if high - low < 1.0:
levels = np.zeros_like(luma) levels = np.zeros_like(luma)

View file

@ -215,6 +215,9 @@ examples:
"(default: all of it)") "(default: all of it)")
fl.add_argument("--stale", type=float, default=300.0, metavar="SECONDS", fl.add_argument("--stale", type=float, default=300.0, metavar="SECONDS",
help="drop an aircraft this long after its last report") help="drop an aircraft this long after its last report")
fl.add_argument("--fade", type=float, default=None, metavar="SECONDS",
help="how long an aircraft takes to fade away once it "
"has gone quiet (0 to remove it at once)")
fl.add_argument("--no-labels", dest="labels", action="store_false", fl.add_argument("--no-labels", dest="labels", action="store_false",
help="draw the aircraft without callsigns beside them") help="draw the aircraft without callsigns beside them")
fl.add_argument("--no-map", dest="draw", action="store_false", fl.add_argument("--no-map", dest="draw", action="store_false",
@ -234,6 +237,12 @@ examples:
help="draw the tracks on their own, with no map under them") help="draw the tracks on their own, with no map under them")
fl.add_argument("--tiles", default=None, metavar="URL", fl.add_argument("--tiles", default=None, metavar="URL",
help="where map tiles come from ({z}/{x}/{y}.png)") help="where map tiles come from ({z}/{x}/{y}.png)")
fl.add_argument("--map-brightness", type=int, default=None,
metavar="PERCENT",
help="how bright the map under the aircraft is (10-100)")
fl.add_argument("--no-airports", dest="airports", action="store_false",
default=None,
help="do not mark the aerodromes under the flight paths")
fl.add_argument("--radius", type=float, default=None, metavar="MILES", fl.add_argument("--radius", type=float, default=None, metavar="MILES",
help="how far around the receiver the map reaches, in the " help="how far around the receiver the map reaches, in the "
"same unit as the speeds (0 = fit what was heard)") "same unit as the speeds (0 = fit what was heard)")
@ -1163,10 +1172,16 @@ def cmd_flights(args) -> int:
options.trail = args.trail options.trail = args.trail
options.stale = args.stale options.stale = args.stale
options.labels = args.labels options.labels = args.labels
if args.fade is not None:
options.fade = args.fade
if args.basemap is not None: if args.basemap is not None:
options.basemap = args.basemap options.basemap = args.basemap
if args.tiles: if args.tiles:
options.tile_url = args.tiles options.tile_url = args.tiles
if args.map_brightness is not None:
options.map_brightness = args.map_brightness
if args.airports is not None:
options.airports = args.airports
if args.radius is not None: if args.radius is not None:
options.radius = args.radius options.radius = args.radius
if args.at: if args.at:

View file

@ -22,7 +22,8 @@ from __future__ import annotations
import numpy as np import numpy as np
__all__ = ["FLAG_W", "FLAG_H", "COLOURS", "COLOUR_ORDER", "FLAGS", __all__ = ["FLAG_W", "FLAG_H", "COLOURS", "COLOUR_ORDER", "FLAGS",
"flag_for", "pixels_for", "known", "country_of_icao"] "flag_for", "pixels_for", "known", "country_of_icao",
"COUNTRY_ISO", "iso_for"]
FLAG_W, FLAG_H = 12, 8 FLAG_W, FLAG_H = 12, 8
@ -169,6 +170,79 @@ FLAGS: dict[str, tuple[str, ...]] = {
} }
# The names this program writes for countries, and the codes they answer to.
# Two sources spell a country in words rather than letters -- the treaty
# table of address blocks, and the registers -- and a flag needs the letters.
COUNTRY_ISO: dict[str, str] = {
"Afghanistan": "AF", "Albania": "AL", "Algeria": "DZ", "Angola": "AO",
"Argentina": "AR", "Armenia": "AM", "Australia": "AU", "Austria": "AT",
"Azerbaijan": "AZ", "Bahrain": "BH", "Bangladesh": "BD", "Belarus": "BY",
"Belgium": "BE", "Bhutan": "BT", "Bolivia": "BO",
"Bosnia and Herzegovina": "BA", "Botswana": "BW", "Brazil": "BR",
"Brunei": "BN", "Bulgaria": "BG", "Burundi": "BI", "Cambodia": "KH",
"Cameroon": "CM", "Canada": "CA", "Central African Republic": "CF",
"Chad": "TD", "Chile": "CL", "China": "CN", "Colombia": "CO",
"Congo": "CG", "Costa Rica": "CR", "Croatia": "HR", "Cuba": "CU",
"Cyprus": "CY", "Czechia": "CZ", "Czech Republic": "CZ",
"Côte d'Ivoire": "CI", "Democratic Republic of the Congo": "CD",
"Denmark": "DK", "Dominican Republic": "DO", "Ecuador": "EC",
"Egypt": "EG", "Equatorial Guinea": "GQ", "Eritrea": "ER",
"Estonia": "EE", "Ethiopia": "ET", "Fiji": "FJ", "Finland": "FI",
"France": "FR", "Gabon": "GA", "Georgia": "GE", "Germany": "DE",
"Ghana": "GH", "Greece": "GR", "Guinea": "GN", "Hungary": "HU",
"Iceland": "IS", "India": "IN", "Indonesia": "ID", "Iran": "IR",
"Iraq": "IQ", "Ireland": "IE", "Israel": "IL", "Italy": "IT",
"Jamaica": "JM", "Japan": "JP", "Jordan": "JO", "Kazakhstan": "KZ",
"Kenya": "KE", "Kuwait": "KW", "Kyrgyzstan": "KG", "Laos": "LA",
"Latvia": "LV", "Lebanon": "LB", "Liberia": "LR", "Libya": "LY",
"Lithuania": "LT", "Luxembourg": "LU", "Madagascar": "MG",
"Malawi": "MW", "Malaysia": "MY", "Mali": "ML", "Malta": "MT",
"Marshall Islands": "MH", "Mauritius": "MU", "Mexico": "MX",
"Moldova": "MD", "Monaco": "MC", "Mongolia": "MN", "Montenegro": "ME",
"Morocco": "MA", "Mozambique": "MZ", "Myanmar": "MM", "Namibia": "NA",
"Nepal": "NP", "Netherlands": "NL", "New Zealand": "NZ", "Niger": "NE",
"Nigeria": "NG", "North Korea": "KP", "North Macedonia": "MK",
"Norway": "NO", "Oman": "OM", "Pakistan": "PK", "Panama": "PA",
"Papua New Guinea": "PG", "Paraguay": "PY", "Peru": "PE",
"Philippines": "PH", "Poland": "PL", "Portugal": "PT", "Qatar": "QA",
"Romania": "RO", "Russia": "RU", "Russian Federation": "RU",
"Rwanda": "RW", "San Marino": "SM", "Saudi Arabia": "SA",
"Senegal": "SN", "Serbia": "RS", "Seychelles": "SC",
"Sierra Leone": "SL", "Singapore": "SG", "Slovakia": "SK",
"Slovenia": "SI", "Somalia": "SO", "South Africa": "ZA",
"South Korea": "KR", "Korea, Republic of": "KR", "Spain": "ES",
"Sri Lanka": "LK", "Sudan": "SD", "Sweden": "SE", "Switzerland": "CH",
"Syria": "SY", "Taiwan": "TW", "Tajikistan": "TJ", "Tanzania": "TZ",
"Thailand": "TH", "Togo": "TG", "Tonga": "TO", "Tunisia": "TN",
"Turkey": "TR", "Turkmenistan": "TM", "Uganda": "UG", "Ukraine": "UA",
"United Arab Emirates": "AE", "United Kingdom": "GB",
"United States": "US", "United States of America": "US",
"Uruguay": "UY", "Uzbekistan": "UZ", "Vanuatu": "VU", "Venezuela": "VE",
"Viet Nam": "VN", "Vietnam": "VN", "Yemen": "YE", "Zambia": "ZM",
"Zimbabwe": "ZW",
}
def iso_for(name: str) -> str:
"""The two letters for a country written out in words.
A military block is named for the country whose it is -- "United States
military" -- and flies the same flag, so the suffix is dropped rather
than treated as somewhere else.
"""
name = (name or "").strip()
if not name:
return ""
if len(name) == 2 and name.isalpha():
return name.upper() # already a code
if name in COUNTRY_ISO:
return COUNTRY_ISO[name]
for suffix in (" military", " Military"):
if name.endswith(suffix):
return COUNTRY_ISO.get(name[:-len(suffix)], "")
return ""
def known(code: str) -> bool: def known(code: str) -> bool:
return (code or "").strip().upper() in FLAGS return (code or "").strip().upper() in FLAGS

View file

@ -41,7 +41,9 @@ from .images import GLYPH_H, draw_text, text_width, write_png
__all__ = ["Animation", "Projection", "animate", "render_frame", "write_gif", __all__ = ["Animation", "Projection", "animate", "render_frame", "write_gif",
"write_mp4", "fit", "PALETTE", "ffmpeg_available", "write_mp4", "fit", "PALETTE", "ffmpeg_available",
"ground_for", "background", "label_lines", "draw_flag", "ground_for", "background", "label_lines", "draw_flag",
"FLAG", "flag_index"] "FLAG", "FAINT", "flag_index", "dim_ground", "local_airports",
"showing", "fade_ramp",
"GROUND_BRIGHTNESS"]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -58,6 +60,7 @@ TRAIL = RAMP + 32 # the same 32, dimmed, for the path just flown
OLD = TRAIL + 32 # and dimmer still, for the path flown earlier OLD = TRAIL + 32 # and dimmer still, for the path flown earlier
GROUND = OLD + 32 # 32 shades of the map underneath GROUND = OLD + 32 # 32 shades of the map underneath
FLAG = GROUND + 32 # the dozen colours the little flags are made of FLAG = GROUND + 32 # the dozen colours the little flags are made of
FAINT = FLAG + 12 # the same 32 again, fainter still, for fading
RAMP_STEPS = 32 RAMP_STEPS = 32
GROUND_SHADES = 32 GROUND_SHADES = 32
TRANSPARENT = 255 # never drawn with: it means "as the frame before" TRANSPARENT = 255 # never drawn with: it means "as the frame before"
@ -114,10 +117,18 @@ def _dimmed(colours, factor: float) -> list[tuple[int, int, int]]:
return [tuple(int(round(c * factor)) for c in rgb) for rgb in colours] 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 # The map under the aircraft: night-blue, running from nearly the background
# that a trail crossing a city is still the brightest thing there. # to about as bright as it can go before the aircraft stop standing out. How
GROUND_LOW = (12, 14, 20) # far up that range a drawing actually goes is the brightness setting below,
GROUND_HIGH = (74, 82, 96) # so there is room to turn it up on a screen that needs it and down on one
# that does not.
GROUND_LOW = (16, 19, 26)
GROUND_HIGH = (150, 164, 186)
# What fraction of that range the map is drawn over unless told otherwise.
# Enough to read a coastline and the name of a town, and not so much that a
# city washes out the aircraft crossing it.
GROUND_BRIGHTNESS = 0.70
def _ground_shades(steps: int = GROUND_SHADES) -> list[tuple[int, int, int]]: def _ground_shades(steps: int = GROUND_SHADES) -> list[tuple[int, int, int]]:
@ -146,7 +157,7 @@ def flag_index(letter: str) -> int:
def _palette() -> np.ndarray: def _palette() -> np.ndarray:
ramp = _ramp() 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() + _flag_colours()) + _ground_shades() + _flag_colours() + _dimmed(ramp, 0.14))
table += [(0, 0, 0)] * (256 - len(table)) table += [(0, 0, 0)] * (256 - len(table))
return np.array(table[:256], dtype=np.uint8) return np.array(table[:256], dtype=np.uint8)
@ -154,6 +165,25 @@ def _palette() -> np.ndarray:
PALETTE = _palette() PALETTE = _palette()
# How faint an aircraft is drawn as it fades, in the four strengths the
# palette holds. An indexed picture cannot blend, so a fade is a handful of
# steps rather than a slope -- which at a second or two apart reads as a
# fade all the same.
FADE_STEPS = ((0.66, RAMP), (0.40, TRAIL), (0.18, OLD), (0.0, FAINT))
# Below this the label goes: a name at a tenth of its colour is not
# information, it is something in the way of the aircraft still flying.
LABEL_WHILE = 0.40
def fade_ramp(strength: float) -> int:
"""Which set of colours an aircraft at this strength is drawn from."""
for above, base in FADE_STEPS:
if strength > above:
return base
return FAINT
def altitude_step(feet: float) -> int: def altitude_step(feet: float) -> int:
"""Which of the 32 altitude colours a height falls in.""" """Which of the 32 altitude colours a height falls in."""
if feet <= 0: if feet <= 0:
@ -356,9 +386,25 @@ def _degrees(value: float, axis: str) -> str:
return f"{abs(value):.2f}{hemisphere}" return f"{abs(value):.2f}{hemisphere}"
def dim_ground(levels, brightness: float = GROUND_BRIGHTNESS):
"""Squeeze the map into the part of its range being used.
The palette holds the whole range; a drawing uses as much of it as the
brightness asks for. Scaling the shades rather than rebuilding the
palette means the animation's colour table stays the same table from one
frame to the next, which is the whole basis of the frame differencing.
"""
part = max(0.05, min(1.0, float(brightness)))
top = max(1, int(round((GROUND_SHADES - 1) * part)))
return np.clip((np.asarray(levels, dtype=np.float64)
* top / (GROUND_SHADES - 1)).round(),
0, GROUND_SHADES - 1).astype(np.uint8)
def background(view: Projection, title: str = "", airports=(), def background(view: Projection, title: str = "", airports=(),
unit: str = DEFAULT_SPEED_UNIT, ground=None, unit: str = DEFAULT_SPEED_UNIT, ground=None,
attribution: str = "") -> np.ndarray: attribution: str = "",
brightness: float = GROUND_BRIGHTNESS) -> np.ndarray:
"""The map without anything flying on it: ground, grid, scale, key, title. """The map without anything flying on it: ground, grid, scale, key, title.
``ground`` is the real map underneath, as brightness levels covering the ``ground`` is the real map underneath, as brightness levels covering the
@ -369,7 +415,7 @@ def background(view: Projection, title: str = "", airports=(),
width, height = canvas_size(view) width, height = canvas_size(view)
img = np.full((height, width), BG, dtype=np.uint8) img = np.full((height, width), BG, dtype=np.uint8)
if ground is not None: if ground is not None:
patch = np.asarray(ground, dtype=np.uint8) patch = dim_ground(ground, brightness)
rows = min(view.height, patch.shape[0]) rows = min(view.height, patch.shape[0])
cols = min(view.width, patch.shape[1]) cols = min(view.width, patch.shape[1])
img[view.top:view.top + rows, view.left:view.left + cols] = \ img[view.top:view.top + rows, view.left:view.left + cols] = \
@ -399,6 +445,7 @@ def background(view: Projection, title: str = "", airports=(),
continue continue
x, y = view.xy(lat, lon) x, y = view.xy(lat, lon)
_box(img, x - 3, y - 3, x + 3, y + 3, AIRPORT) _box(img, x - 3, y - 3, x + 3, y + 3, AIRPORT)
_box(img, x - 1, y - 1, x + 1, y + 1, AIRPORT, fill=True)
draw_text(img, x + 6, y - 3, name, AIRPORT) draw_text(img, x + 6, y - 3, name, AIRPORT)
if title: if title:
@ -461,7 +508,8 @@ def render_frame(base: np.ndarray, view: Projection, tracks: list[Track],
when: float, *, trail_seconds: float = 0.0, when: float, *, trail_seconds: float = 0.0,
stale: float = 300.0, labels: bool = True, stale: float = 300.0, labels: bool = True,
clock: str = "", unit: str = DEFAULT_SPEED_UNIT, clock: str = "", unit: str = DEFAULT_SPEED_UNIT,
project: bool = True, known=None) -> np.ndarray: project: bool = True, known=None,
fade: float = 0.0) -> np.ndarray:
"""The map at one moment: where everything was, and where it had been. """The map at one moment: where everything was, and where it had been.
``project`` is what makes an animation an animation: between reports an ``project`` is what makes an animation an animation: between reports an
@ -479,24 +527,31 @@ def render_frame(base: np.ndarray, view: Projection, tracks: list[Track],
if track.at(when, stale=stale) is not None) > CROWDED if track.at(when, stale=stale) is not None) > CROWDED
taken: list[tuple[int, int, int, int]] = [] taken: list[tuple[int, int, int, int]] = []
for track in tracks: for track in tracks:
now = track.at(when, stale=stale) if project else \ if project:
(track.fixes[-1] if when >= track.fixes[-1].at seen = showing(track, when, stale, fade)
else track.at(when, stale=stale)) else:
if now is None: seen = ((track.fixes[-1], 1.0) if when >= track.fixes[-1].at
else showing(track, when, stale, fade))
if seen is None:
continue continue
now, strength = seen
flying += 1 flying += 1
trail = track.trail(when, trail_seconds) trail = track.trail(when, trail_seconds)
recent = when - (trail_seconds or 120.0) / 2.0 recent = when - (trail_seconds or 120.0) / 2.0
faded = fade_ramp(strength)
for a, b in zip(trail, trail[1:]): for a, b in zip(trail, trail[1:]):
shade = (TRAIL if b.at >= recent else OLD) + \ base_shade = TRAIL if b.at >= recent else OLD
altitude_step(b.altitude_ft) # A fading aircraft takes its trail with it: the two are one
# thing on the picture and half of it lingering would be worse
# than either.
shade = max(base_shade, faded) + altitude_step(b.altitude_ft)
x0, y0 = view.xy(a.latitude, a.longitude) x0, y0 = view.xy(a.latitude, a.longitude)
x1, y1 = view.xy(b.latitude, b.longitude) x1, y1 = view.xy(b.latitude, b.longitude)
_line(img, x0, y0, x1, y1, shade) _line(img, x0, y0, x1, y1, shade)
colour = RAMP + altitude_step(now.altitude_ft) colour = faded + altitude_step(now.altitude_ft)
x, y = view.xy(now.latitude, now.longitude) x, y = view.xy(now.latitude, now.longitude)
_marker(img, x, y, now.track_deg, colour) _marker(img, x, y, now.track_deg, colour)
if labels: if labels and strength > LABEL_WHILE:
_label(img, x, y, track, now, colour, taken, unit, _label(img, x, y, track, now, colour, taken, unit,
entry=(known.get(track.icao) entry=(known.get(track.icao)
if known and not crowded else None)) if known and not crowded else None))
@ -505,6 +560,33 @@ def render_frame(base: np.ndarray, view: Projection, tracks: list[Track],
return img return img
def showing(track: Track, when: float, stale: float = 300.0,
fade: float = 0.0):
"""Where an aircraft is at a moment, and how strongly to draw it.
An aircraft that stops transmitting has not stopped existing, and taking
it off the picture between one frame and the next says it did. So past
the point where its position can still be believed it is left where it
was last actually seen, fading, until it is gone -- which is what the
watching eye reads as "that one has gone quiet", rather than as a
blink.
Fading happens at the last known position and never at a reckoned one:
the whole reason for giving up on an aircraft is that where it would be
by now is a guess.
"""
now = track.at(when, stale=stale)
if now is not None:
return now, 1.0
if fade <= 0 or not track.fixes:
return None
last = track.fixes[-1]
gone = when - last.at - stale
if gone < 0 or gone > fade:
return None
return last, max(0.0, 1.0 - gone / fade)
def _marker(img: np.ndarray, x: int, y: int, heading: float, def _marker(img: np.ndarray, x: int, y: int, heading: float,
colour: int) -> None: colour: int) -> None:
"""A little arrowhead, pointing the way the aircraft is going. """A little arrowhead, pointing the way the aircraft is going.
@ -556,11 +638,15 @@ def label_lines(track: Track, now, unit: str = DEFAULT_SPEED_UNIT,
rows.append((line, "")) rows.append((line, ""))
if entry is None: if entry is None:
return rows return rows
from .flags import iso_for
home = iso_for(getattr(entry, "owner_country", "")
or getattr(entry, "country", ""))
kind = entry.type_code or entry.model kind = entry.type_code or entry.model
if kind and entry.registration: if kind and entry.registration:
rows.append((f"{kind} {entry.registration}", "")) rows.append((f"{kind} {entry.registration}", home))
elif kind or entry.registration: elif kind or entry.registration:
rows.append((kind or entry.registration, "")) rows.append((kind or entry.registration, home))
if entry.origin_code or entry.origin: if entry.origin_code or entry.origin:
rows.append((_short_place(entry.origin_code, entry.origin), rows.append((_short_place(entry.origin_code, entry.origin),
entry.origin_country)) entry.origin_country))
@ -886,6 +972,25 @@ def _span(seconds: float) -> str:
return f"{hours} h {minutes:02d} min" return f"{hours} h {minutes:02d} min"
def local_airports(view: Projection, ask=None) -> list:
"""Every aerodrome inside the picture, from the map data itself.
A route only names the airports its aircraft are flying between, and
those are almost never the ones underneath: a receiver hears aircraft
over its own county, and the county's airports are exactly what says
where on the map you are looking.
"""
from . import basemap
try:
extra = {"ask": ask} if ask is not None else {}
found = basemap.airports_in(view.south, view.west, view.north,
view.east, **extra)
except Exception:
return []
return [(a["code"], a["latitude"], a["longitude"]) for a in found]
def _known_from(book, tracks: list[Track]) -> dict: def _known_from(book, tracks: list[Track]) -> dict:
"""What a register says about each aircraft, looked up once. """What a register says about each aircraft, looked up once.
@ -899,14 +1004,16 @@ def _known_from(book, tracks: list[Track]) -> dict:
for track in tracks} for track in tracks}
def _airports_from(known: dict): def _airports_from(known: dict, book=None):
"""Every airport a route lookup gave a position for. """Every airport named by a route, wherever its position can be had.
Taken from what has already been looked up rather than asking again: the A route names two airports and often gives a position for neither, so
book is thread-safe rather than free, and one question per aircraft is the codes are looked up as well -- once each, and remembered. An
the whole budget. airport that cannot be placed cannot be drawn, and a map of an evening's
flying with no airports on it is missing the two ends of every flight.
""" """
seen: dict[str, tuple[str, float, float]] = {} seen: dict[str, tuple[str, float, float]] = {}
wanted: set[str] = set()
for entry in known.values(): for entry in known.values():
for code, lat, lon in ((getattr(entry, "origin_code", ""), for code, lat, lon in ((getattr(entry, "origin_code", ""),
getattr(entry, "origin_lat", 0.0), getattr(entry, "origin_lat", 0.0),
@ -914,8 +1021,19 @@ def _airports_from(known: dict):
(getattr(entry, "destination_code", ""), (getattr(entry, "destination_code", ""),
getattr(entry, "destination_lat", 0.0), getattr(entry, "destination_lat", 0.0),
getattr(entry, "destination_lon", 0.0))): getattr(entry, "destination_lon", 0.0))):
if code and (lat or lon): if not code:
continue
if lat or lon:
seen[code] = (code, lat, lon) seen[code] = (code, lat, lon)
else:
wanted.add(code)
if book is not None and hasattr(book, "airports"):
try:
for found in book.airports(sorted(wanted - set(seen))):
seen[found["code"]] = (found["code"], found["latitude"],
found["longitude"])
except Exception:
pass # a map with fewer airports on it
return list(seen.values()) return list(seen.values())
@ -946,13 +1064,20 @@ def animate(tracks: list[Track], out_path, *, fps: float = 12.0,
title: str = "", book=None, labels: bool = True, title: str = "", book=None, labels: bool = True,
kind: str = "", unit: str = DEFAULT_SPEED_UNIT, kind: str = "", unit: str = DEFAULT_SPEED_UNIT,
ground: bool = False, fetch=None, tile_url: str = "", ground: bool = False, fetch=None, tile_url: str = "",
radius_nm: float = 0.0, centre=None) -> Animation | None: radius_nm: float = 0.0, centre=None,
brightness: float = GROUND_BRIGHTNESS,
airports: bool = False, ask=None,
fade: float = 0.0) -> Animation | None:
"""Draw the whole log as a moving map. """Draw the whole log as a moving map.
``speed`` is how many seconds of real flying go by in one second of ``speed`` is how many seconds of real flying go by in one second of
animation; given ``seconds`` instead, it is worked out so the whole log animation; given ``seconds`` instead, it is worked out so the whole log
plays in about that long. The clock in the corner is the real time of plays in about that long. The clock in the corner is the real time of
day, so a fast animation is still readable as an evening. day, so a fast animation is still readable as an evening.
``ground`` and ``airports`` both reach for a network and are therefore
both off here, and both on in the settings the program itself uses: a
library call should not go out to the world unless it was asked to.
""" """
located = [t for t in tracks if t.located] located = [t for t in tracks if t.located]
if not located: if not located:
@ -993,8 +1118,16 @@ def animate(tracks: list[Track], out_path, *, fps: float = 12.0,
known = _known_from(book, located) known = _known_from(book, located)
levels, credit = ground_for(view, fetch, tile_url) if ground \ levels, credit = ground_for(view, fetch, tile_url) if ground \
else (None, "") else (None, "")
base = background(view, title=heading, airports=_airports_from(known), marked = _airports_from(known, book)
unit=unit, ground=levels, attribution=credit) if airports:
# The ones underneath as well as the ones flown between, and the
# nearer ones last so they win where two share a name.
seen = {code for code, _, _ in marked}
marked += [one for one in local_airports(view, ask)
if one[0] not in seen]
base = background(view, title=heading, airports=marked,
unit=unit, ground=levels, attribution=credit,
brightness=brightness)
canvas_w, canvas_h = canvas_size(view) canvas_w, canvas_h = canvas_size(view)
base = _pad_to(base, canvas_w, canvas_h) base = _pad_to(base, canvas_w, canvas_h)
@ -1005,7 +1138,8 @@ def animate(tracks: list[Track], out_path, *, fps: float = 12.0,
yield _pad_to(render_frame(base, view, located, when, yield _pad_to(render_frame(base, view, located, when,
trail_seconds=trail_seconds, trail_seconds=trail_seconds,
stale=stale, labels=labels, stale=stale, labels=labels,
clock=clock, unit=unit, known=known), clock=clock, unit=unit, known=known,
fade=fade),
canvas_w, canvas_h) canvas_w, canvas_h)
path = Path(out_path) path = Path(out_path)

View file

@ -37,6 +37,7 @@ AIRCRAFT_URL = "https://api.adsbdb.com/v0/aircraft/{icao}"
ROUTE_URL = "https://api.adsbdb.com/v0/callsign/{call}" ROUTE_URL = "https://api.adsbdb.com/v0/callsign/{call}"
BACKUP_AIRCRAFT_URL = "https://hexdb.io/api/v1/aircraft/{icao}" BACKUP_AIRCRAFT_URL = "https://hexdb.io/api/v1/aircraft/{icao}"
BACKUP_ROUTE_URL = "https://hexdb.io/api/v1/route/icao/{call}" BACKUP_ROUTE_URL = "https://hexdb.io/api/v1/route/icao/{call}"
AIRPORT_URL = "https://hexdb.io/api/v1/airport/icao/{code}"
# Bumped when the shape of a cached record changes, so a cache written by an # Bumped when the shape of a cached record changes, so a cache written by an
# older version is ignored rather than misread. Version 2 added the country # older version is ignored rather than misread. Version 2 added the country
@ -418,7 +419,8 @@ class FlightBook:
aircraft_url: str = AIRCRAFT_URL, aircraft_url: str = AIRCRAFT_URL,
route_url: str = ROUTE_URL, route_url: str = ROUTE_URL,
backup_aircraft_url: str = BACKUP_AIRCRAFT_URL, backup_aircraft_url: str = BACKUP_AIRCRAFT_URL,
backup_route_url: str = BACKUP_ROUTE_URL): backup_route_url: str = BACKUP_ROUTE_URL,
airport_url: str = AIRPORT_URL):
self.online = online self.online = online
self.timeout = timeout self.timeout = timeout
self.max_age = max_age self.max_age = max_age
@ -426,10 +428,15 @@ class FlightBook:
self.route_url = route_url self.route_url = route_url
self.backup_aircraft_url = backup_aircraft_url self.backup_aircraft_url = backup_aircraft_url
self.backup_route_url = backup_route_url self.backup_route_url = backup_route_url
self.airport_url = airport_url
self.cache_path = Path(cache) if cache is not None else _cache_path() self.cache_path = Path(cache) if cache is not None else _cache_path()
self._lock = threading.Lock() self._lock = threading.Lock()
self._entries: dict[str, Flight] = {} self._entries: dict[str, Flight] = {}
self._routes: dict[str, dict] = {} self._routes: dict[str, dict] = {}
# Airports, by ICAO code. A route often names two the map has never
# heard of and gives no position for either, and an airport that
# cannot be placed cannot be drawn.
self._airports: dict[str, dict] = {}
# Callsigns already asked about. A route nobody holds would # Callsigns already asked about. A route nobody holds would
# otherwise be asked for again by every caller that looks at the same # otherwise be asked for again by every caller that looks at the same
# aircraft -- the table, the report and the map all do -- and an # aircraft -- the table, the report and the map all do -- and an
@ -455,6 +462,10 @@ class FlightBook:
entry.version >= CACHE_VERSION and \ entry.version >= CACHE_VERSION and \
now - entry.fetched_at < self.max_age: now - entry.fetched_at < self.max_age:
self._entries[key] = entry self._entries[key] = entry
for key, body in (raw.get("airports") or {}).items():
if isinstance(body, dict) and \
now - float(body.get("fetched_at") or 0) < self.max_age:
self._airports[key] = body
for key, body in (raw.get("routes") or {}).items(): for key, body in (raw.get("routes") or {}).items():
if not isinstance(body, dict): if not isinstance(body, dict):
continue continue
@ -472,6 +483,7 @@ class FlightBook:
"aircraft": {k: e.__dict__ for k, e in self._entries.items() "aircraft": {k: e.__dict__ for k, e in self._entries.items()
if e.status in ("found", "unlisted")}, if e.status in ("found", "unlisted")},
"routes": dict(self._routes), "routes": dict(self._routes),
"airports": dict(self._airports),
} }
self._dirty = False self._dirty = False
try: try:
@ -519,6 +531,50 @@ class FlightBook:
thread.start() thread.start()
return entry return entry
def airport(self, code: str) -> dict:
"""Where an airport is, asked once and remembered.
An empty answer is remembered too, and on purpose: a code no
register holds would otherwise be asked about on every drawing for
the rest of the month.
"""
code = (code or "").strip().upper()
if not code:
return {}
with self._lock:
got = self._airports.get(code)
if got is not None:
return got
found: dict = {}
if self.online and self.airport_url:
try:
body = self._request(self.airport_url.format(
code=urllib.parse.quote(code)))
found = {"code": code,
"name": str(body.get("airport") or "").strip(),
"latitude": float(body.get("latitude") or 0.0),
"longitude": float(body.get("longitude") or 0.0),
"country": str(body.get("country_code")
or "").strip().upper()}
if not (found["latitude"] or found["longitude"]):
found = {}
except Exception:
found = {}
with self._lock:
found["fetched_at"] = time.time()
self._airports[code] = found
self._dirty = True
return found
def airports(self, codes) -> list[dict]:
"""Every airport in a list of codes that can be placed on a map."""
out: dict[str, dict] = {}
for code in codes:
found = self.airport(code)
if found.get("latitude") or found.get("longitude"):
out[found["code"]] = found
return list(out.values())
def get_all(self, pairs) -> list[Flight]: def get_all(self, pairs) -> list[Flight]:
return [self.get(icao, call) for icao, call in pairs] return [self.get(icao, call) for icao, call in pairs]

View file

@ -23,6 +23,7 @@ from dataclasses import dataclass
import numpy as np import numpy as np
from .flags import iso_for
from .flightlog import (DEFAULT_SPEED_UNIT, distance_label, distance_nm, from .flightlog import (DEFAULT_SPEED_UNIT, distance_label, distance_nm,
in_distance, in_speed, speed_label) in_distance, in_speed, speed_label)
from .flightmap import Projection, altitude_step from .flightmap import Projection, altitude_step
@ -56,6 +57,17 @@ REDRAW_MS = 200
# it is drawn every frame and wants a hard bound. # it is drawn every frame and wants a hard bound.
TRAIL_POINTS = 400 TRAIL_POINTS = 400
# How far the worked-out middle of the map has to drift before it is moved.
# Below this it is noise from aircraft arriving and leaving, and moving the
# map for it costs the tiles that were fetched for the old view.
RESETTLE_NM = 12.0
# How much map to fetch beyond what is being shown. A window that fetched
# exactly what it needed would fetch again on every resize and every small
# drift; a third again in each direction covers both, at the cost of about
# twice as many tiles the first time.
GROUND_MARGIN = 0.33
# The widest a line in a box is allowed to get before it is folded. An # The widest a line in a box is allowed to get before it is folded. An
# airport's full name and the town it is in run to forty characters on their # airport's full name and the town it is in run to forty characters on their
# own and a route is two of them, so one flight from Los Angeles to Dallas # own and a route is two of them, so one flight from Los Angeles to Dallas
@ -66,6 +78,14 @@ WRAP_CHARS = 30
# tall, so twelve by eight sits on a line without crowding it. # tall, so twelve by eight sits on a line without crowding it.
FLAG_PIXELS = 12 FLAG_PIXELS = 12
# Below this an aircraft keeps its symbol and loses its box.
LABEL_WHILE = 0.40
def _alpha(strength: float) -> int:
"""How opaque something at this strength is drawn."""
return max(0, min(255, int(round(255 * strength))))
def _qt(): def _qt():
"""The first Qt binding that imports, or None. """The first Qt binding that imports, or None.
@ -137,6 +157,7 @@ class Blip:
manufacturer: str = "" manufacturer: str = ""
operator: str = "" operator: str = ""
country: str = "" country: str = ""
country_code: str = "" # two letters, for the flag beside it
origin: str = "" origin: str = ""
origin_country: str = "" # two letters, for the flag beside it origin_country: str = "" # two letters, for the flag beside it
destination: str = "" destination: str = ""
@ -199,7 +220,7 @@ class Blip:
age = max(0.0, time.time() - self.last_seen) age = max(0.0, time.time() - self.last_seen)
out.append(("", f"{self.messages:,} frames {age:.0f}s ago", "")) out.append(("", f"{self.messages:,} frames {age:.0f}s ago", ""))
if self.country: if self.country:
out.append(("", self.country, "")) out.append(("reg'd", self.country, self.country_code))
return out return out
def _bearing(self, home) -> float: def _bearing(self, home) -> float:
@ -265,6 +286,7 @@ def blip_for(craft, entry=None) -> Blip:
blip.manufacturer = entry.manufacturer blip.manufacturer = entry.manufacturer
blip.operator = entry.operator or entry.airline blip.operator = entry.operator or entry.airline
blip.country = entry.owner_country or entry.country blip.country = entry.owner_country or entry.country
blip.country_code = iso_for(blip.country)
blip.origin = entry.origin or entry.origin_code blip.origin = entry.origin or entry.origin_code
blip.origin_country = entry.origin_country blip.origin_country = entry.origin_country
blip.destination = entry.destination or entry.destination_code blip.destination = entry.destination or entry.destination_code
@ -285,13 +307,16 @@ class Sky:
""" """
def __init__(self, unit: str = DEFAULT_SPEED_UNIT, hold: float = 45.0, def __init__(self, unit: str = DEFAULT_SPEED_UNIT, hold: float = 45.0,
home=None, radius_nm: float = 100.0): home=None, radius_nm: float = 100.0,
brightness: float = 0.70, fade: float = 20.0):
import threading import threading
self.unit = unit self.unit = unit
self.hold = hold self.hold = hold
self.home = home self.home = home
self.radius_nm = radius_nm self.radius_nm = radius_nm
self.brightness = brightness
self.fade = fade
self.frames = 0 self.frames = 0
self.aircraft_seen = 0 self.aircraft_seen = 0
self.started = time.time() self.started = time.time()
@ -308,7 +333,9 @@ class Sky:
self._trails: dict[str, deque] = {} self._trails: dict[str, deque] = {}
self._ground = None self._ground = None
self._ground_for = None self._ground_for = None
self._ground_box = None
self._wanted = None self._wanted = None
self._settled = None
# -- written by the listener ------------------------------------------ # -- written by the listener ------------------------------------------
def update(self, blips, frames: int, seen: int) -> None: def update(self, blips, frames: int, seen: int) -> None:
@ -324,9 +351,15 @@ class Sky:
if not trail or trail[-1][:2] != here[:2]: if not trail or trail[-1][:2] != here[:2]:
trail.append(here) trail.append(here)
def set_ground(self, levels, key) -> None: def set_ground(self, levels, key, box=None) -> None:
"""Keep the map that was fetched, and the piece of world it covers.
A failed fetch is kept too, as nothing: otherwise a machine with no
network asks for the same tiles five times a second all night.
"""
with self._lock: with self._lock:
self._ground, self._ground_for = levels, key self._ground, self._ground_for = levels, key
self._ground_box = box
if self._wanted is not None and self._wanted[0] == key: if self._wanted is not None and self._wanted[0] == key:
self._wanted = None self._wanted = None
@ -340,20 +373,52 @@ class Sky:
with self._lock: with self._lock:
return self._wanted return self._wanted
def ground_covering(self, south, west, north, east):
"""The map already fetched, if it covers this view, and its box.
What is asked for is a slightly different piece of the world every
time the middle shifts or the window is resized, and refetching for
each of those would leave the ground blank while it happened. The
fetched piece is bigger than the piece being shown for exactly this
reason, and anything inside it is taken from what is already here.
"""
with self._lock:
levels, box = self._ground, self._ground_box
if levels is None or box is None:
return None, None
if (south >= box[0] and west >= box[1]
and north <= box[2] and east <= box[3]):
return levels, box
return None, None
# -- read by the window ----------------------------------------------- # -- read by the window -----------------------------------------------
def flying(self, now: float | None = None) -> list[Blip]: def flying(self, now: float | None = None) -> list[Blip]:
"""What is overhead, oldest first heard at the top. """What is overhead, oldest first heard at the top.
An aircraft nothing has been heard from for a while has gone out of An aircraft nothing has been heard from for a while has gone out of
range; it stays in the log and leaves the picture. range; it stays in the log and fades off the picture.
""" """
now = time.time() if now is None else now now = time.time() if now is None else now
limit = self.hold + max(0.0, self.fade)
with self._lock: with self._lock:
out = [b for b in self._blips.values() out = [b for b in self._blips.values()
if now - b.last_seen <= self.hold and b.located] if now - b.last_seen <= limit and b.located]
out.sort(key=lambda b: (b.first_seen, b.icao)) out.sort(key=lambda b: (b.first_seen, b.icao))
return out return out
def strength(self, blip: Blip, now: float | None = None) -> float:
"""How strongly to draw one aircraft: full, then fading, then gone.
Taking an aircraft off the picture between one frame and the next
says it stopped existing. Fading it says it stopped talking, which
is what actually happened.
"""
now = time.time() if now is None else now
age = max(0.0, now - blip.last_seen)
if age <= self.hold or self.fade <= 0:
return 1.0 if age <= self.hold else 0.0
return max(0.0, 1.0 - (age - self.hold) / self.fade)
def trail(self, icao: str) -> list: def trail(self, icao: str) -> list:
with self._lock: with self._lock:
return list(self._trails.get(icao, ())) return list(self._trails.get(icao, ()))
@ -367,17 +432,31 @@ class Sky:
"""Where to put the middle of the map. """Where to put the middle of the map.
Whatever the receiver was told, or the middle of everything heard so Whatever the receiver was told, or the middle of everything heard so
far, which settles within the first few aircraft and is a median, so far -- a median, so one bad position cannot move it.
one bad position cannot move it.
Once settled it is left alone. Recomputing it every time the picture
is painted moves the map by a fraction of a mile whenever an aircraft
appears or leaves, which throws away the tiles fetched for the old
view and leaves the ground blank while new ones are fetched: the map
blinks out several times a minute for no reason anybody watching
could see. It only moves again if it has drifted far enough to
matter, which happens once, early, and then never.
""" """
if self.home is not None: if self.home is not None:
return self.home return self.home
with self._lock: with self._lock:
settled = self._settled
lats = sorted(b.latitude for b in self._blips.values() if b.located) lats = sorted(b.latitude for b in self._blips.values() if b.located)
lons = sorted(b.longitude for b in self._blips.values() if b.located) lons = sorted(b.longitude for b in self._blips.values() if b.located)
if not lats: if not lats:
return None return settled
return lats[len(lats) // 2], lons[len(lons) // 2] middle = lats[len(lats) // 2], lons[len(lons) // 2]
if settled is not None and \
distance_nm(settled[0], settled[1], *middle) < RESETTLE_NM:
return settled
with self._lock:
self._settled = middle
return middle
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -509,12 +588,20 @@ def _build():
return Projection(south=south, west=west, north=north, east=east, return Projection(south=south, west=west, north=north, east=east,
left=0, top=0, width=width, height=height) left=0, top=0, width=width, height=height)
@staticmethod
def ground_box(view: Projection):
"""The piece of world to fetch: what is shown, and some around it."""
down = (view.north - view.south) * GROUND_MARGIN
across = (view.east - view.west) * GROUND_MARGIN
return (max(-85.0, view.south - down), view.west - across,
min(85.0, view.north + down), view.east + across)
def ground_key(self, view: Projection): def ground_key(self, view: Projection):
"""What the map underneath was fetched for, rounded so that a """What the map underneath was fetched for, rounded so that a
pixel of drift does not throw it away.""" pixel of drift does not throw it away."""
return (round(view.south, 3), round(view.west, 3), box = self.ground_box(view)
round(view.north, 3), round(view.east, 3), return (round(box[0], 2), round(box[1], 2), round(box[2], 2),
view.width, view.height) round(box[3], 2), view.width, view.height)
# -- painting ----------------------------------------------------- # -- painting -----------------------------------------------------
def paintEvent(self, event) -> None: def paintEvent(self, event) -> None:
@ -533,22 +620,29 @@ def _build():
flying = self.sky.flying() flying = self.sky.flying()
if self.trails: if self.trails:
for blip in flying: for blip in flying:
self._draw_trail(painter, view, blip) self._draw_trail(painter, view, blip,
self.sky.strength(blip))
# The symbols go down first and their own space is spoken for, # The symbols go down first and their own space is spoken for,
# so that one aircraft's box cannot be placed on top of another # so that one aircraft's box cannot be placed on top of another
# aircraft -- which is the one thing on the picture that has to # aircraft -- which is the one thing on the picture that has to
# stay visible. # stay visible.
placed = [(blip, view.xy(blip.latitude, blip.longitude)) now = time.time()
for blip in flying] placed = [(blip, view.xy(blip.latitude, blip.longitude),
self.sky.strength(blip, now)) for blip in flying]
taken: list[tuple[int, int, int, int]] = [] taken: list[tuple[int, int, int, int]] = []
for blip, (x, y) in placed: for blip, (x, y), strength in placed:
self._draw_symbol(painter, x, y, blip.track_deg, self._draw_symbol(painter, x, y, blip.track_deg,
craft_colour(blip.altitude_ft)) craft_colour(blip.altitude_ft,
_alpha(strength)), strength)
taken.append((x - 11, y - 11, 22, 22)) taken.append((x - 11, y - 11, 22, 22))
for blip, (x, y) in placed: for blip, (x, y), strength in placed:
self._draw_aircraft(painter, view, blip, x, y, taken) self._draw_aircraft(painter, view, blip, x, y, taken, strength)
self._draw_scale(painter, view) self._draw_scale(painter, view)
self._draw_header(painter, len(flying)) # Counted as overhead only while it is still being heard: one
# that has gone quiet is on the picture, fading, and saying it
# is overhead would be saying more than was heard.
self._draw_header(painter,
sum(1 for _b, _xy, s in placed if s >= 1.0))
painter.end() painter.end()
def _draw_waiting(self, painter) -> None: def _draw_waiting(self, painter) -> None:
@ -562,16 +656,26 @@ def _build():
"both arrived") "both arrived")
def _draw_ground(self, painter, view) -> None: def _draw_ground(self, painter, view) -> None:
key = self.ground_key(view) levels, box = self.sky.ground_covering(view.south, view.west,
levels = self.sky.ground(key) view.north, view.east)
if levels is None: if levels is None:
# Ask for it and carry on drawing. The map arrives when it # Ask for it and carry on drawing. The map arrives when it
# arrives; the aircraft are the part that cannot wait. # arrives; the aircraft are the part that cannot wait.
self.sky.want_ground( # Asked for at the size the bigger box needs to hold the
key, (view.south, view.west, view.north, view.east), # same detail as the window, rather than at the window's
(view.width, view.height)) # own size: fetching a third more world into the same
# pixels and then cutting the middle out of it is how a
# sharp map ends up looking like a photograph of a map.
scale = 1.0 + 2.0 * GROUND_MARGIN
self.sky.want_ground(self.ground_key(view),
self.ground_box(view),
(int(view.width * scale),
int(view.height * scale)))
return return
shades = np.clip(np.asarray(levels), 0, GROUND_SHADES - 1) from .flightmap import dim_ground
shades = dim_ground(self._crop(levels, box, view),
self.sky.brightness)
pixels = np.ascontiguousarray( pixels = np.ascontiguousarray(
PALETTE[GROUND + shades].astype(np.uint8)) PALETTE[GROUND + shades].astype(np.uint8))
self._ground_pixels = pixels # QImage does not copy it self._ground_pixels = pixels # QImage does not copy it
@ -579,6 +683,25 @@ def _build():
image = QImage(pixels.data, width, height, 3 * width, RGB888) image = QImage(pixels.data, width, height, 3 * width, RGB888)
painter.drawImage(0, 0, image) painter.drawImage(0, 0, image)
@staticmethod
def _crop(levels, box, view: Projection):
"""The part of the fetched map this view is looking at.
Both are laid out the same way -- latitude down, longitude across,
evenly -- so this is arithmetic rather than another projection.
"""
south, west, north, east = box
rows, columns = levels.shape[0], levels.shape[1]
lats = np.linspace(view.north, view.south, view.height)
lons = np.linspace(view.west, view.east, view.width)
y = np.clip(((north - lats) / max(1e-9, north - south)
* (rows - 1)).round().astype(np.int64), 0, rows - 1)
x = np.clip(((lons - west) / max(1e-9, east - west)
* (columns - 1)).round().astype(np.int64),
0, columns - 1)
return np.clip(levels[y[:, None], x[None, :]], 0,
GROUND_SHADES - 1)
def _draw_graticule(self, painter, view) -> None: def _draw_graticule(self, painter, view) -> None:
pen = QPen(rgb(GRID)) pen = QPen(rgb(GRID))
pen.setStyle(DOTTED) pen.setStyle(DOTTED)
@ -611,20 +734,24 @@ def _build():
_degrees(lon, "lon")) _degrees(lon, "lon"))
lon += step lon += step
def _draw_trail(self, painter, view, blip: Blip) -> None: def _draw_trail(self, painter, view, blip: Blip,
strength: float = 1.0) -> None:
points = self.sky.trail(blip.icao) points = self.sky.trail(blip.icao)
if len(points) < 2: if len(points) < 2:
return return
painter.setPen(QPen(craft_colour(blip.altitude_ft, 90), 1.4)) painter.setPen(QPen(craft_colour(blip.altitude_ft,
int(90 * strength)), 1.4))
path = QPolygonF([QPointF(*view.xy(lat, lon)) path = QPolygonF([QPointF(*view.xy(lat, lon))
for lat, lon, _ in points]) for lat, lon, _ in points])
painter.drawPolyline(path) painter.drawPolyline(path)
def _draw_aircraft(self, painter, view, blip: Blip, x, y, def _draw_aircraft(self, painter, view, blip: Blip, x, y,
taken) -> None: taken, strength: float = 1.0) -> None:
if self.detail <= 0: # A box at a tenth of its colour is not information, it is
# something in the way of the aircraft still flying.
if self.detail <= 0 or strength < LABEL_WHILE:
return return
colour = craft_colour(blip.altitude_ft) colour = craft_colour(blip.altitude_ft, _alpha(strength))
lines = self._wrapped(self._box_lines(blip)) lines = self._wrapped(self._box_lines(blip))
box = self._box_size(lines) box = self._box_size(lines)
bx, by = place_box(x, y, box[0], box[1], taken, bx, by = place_box(x, y, box[0], box[1], taken,
@ -642,7 +769,8 @@ def _build():
else blip.icao else blip.icao
self._draw_box(painter, bx, by, box, lines, colour, title) self._draw_box(painter, bx, by, box, lines, colour, title)
def _draw_symbol(self, painter, x, y, heading, colour) -> None: def _draw_symbol(self, painter, x, y, heading, colour,
strength: float = 1.0) -> None:
angle = math.radians(heading % 360.0) angle = math.radians(heading % 360.0)
sin, cos = math.sin(angle), math.cos(angle) sin, cos = math.sin(angle), math.cos(angle)
@ -654,7 +782,7 @@ def _build():
painter.setBrush(colour) painter.setBrush(colour)
painter.drawPolygon(QPolygonF([point(9, 0), point(-6, 5), painter.drawPolygon(QPolygonF([point(9, 0), point(-6, 5),
point(-3, 0), point(-6, -5)])) point(-3, 0), point(-6, -5)]))
painter.setBrush(QColor(255, 255, 255, 200)) painter.setBrush(QColor(255, 255, 255, int(200 * strength)))
painter.drawEllipse(QPointF(x, y), 1.6, 1.6) painter.drawEllipse(QPointF(x, y), 1.6, 1.6)
def _box_lines(self, blip: Blip) -> list[tuple[str, str]]: def _box_lines(self, blip: Blip) -> list[tuple[str, str]]:
@ -703,9 +831,10 @@ def _build():
title: str) -> None: title: str) -> None:
width, height = box width, height = box
painter.setPen(QPen(colour, 1.2)) painter.setPen(QPen(colour, 1.2))
painter.setBrush(QColor(10, 12, 18, 215)) painter.setBrush(QColor(10, 12, 18, int(215 * (colour.alpha() / 255))))
painter.drawRoundedRect(QRectF(bx, by, width, height), 3.0, 3.0) painter.drawRoundedRect(QRectF(bx, by, width, height), 3.0, 3.0)
painter.setFont(self.head_font) painter.setFont(self.head_font)
faded = colour.alpha()
painter.setPen(colour.lighter(135)) painter.setPen(colour.lighter(135))
head = QtGui.QFontMetrics(self.head_font) head = QtGui.QFontMetrics(self.head_font)
painter.drawText(bx + 7, by + head.ascent() + 4, title) painter.drawText(bx + 7, by + head.ascent() + 4, title)
@ -716,7 +845,7 @@ def _build():
y = by + head.height() + 6 + metrics.ascent() y = by + head.height() + 6 + metrics.ascent()
for label, value, country in lines: for label, value, country in lines:
if label: if label:
painter.setPen(rgb(GRID)) painter.setPen(rgb(GRID, faded))
painter.drawText(bx + 7, y, f"{label:>5s}") painter.drawText(bx + 7, y, f"{label:>5s}")
left = bx + 7 + gutter left = bx + 7 + gutter
if flagged: if flagged:
@ -724,7 +853,7 @@ def _build():
self._draw_flag(painter, left, y - metrics.ascent() + 2, self._draw_flag(painter, left, y - metrics.ascent() + 2,
country) country)
left += FLAG_PIXELS + 4 left += FLAG_PIXELS + 4
painter.setPen(rgb(INK)) painter.setPen(rgb(INK, faded))
painter.drawText(left, y, value) painter.drawText(left, y, value)
y += metrics.height() y += metrics.height()
@ -767,8 +896,8 @@ def _build():
painter.drawLine(12 + pixels, y - 4, 12 + pixels, y + 4) painter.drawLine(12 + pixels, y - 4, 12 + pixels, y + 4)
painter.drawText(18 + pixels, y + 4, painter.drawText(18 + pixels, y + 4,
f"{miles} {distance_label(self.sky.unit)}") f"{miles} {distance_label(self.sky.unit)}")
if self.show_ground and self.sky.ground( if self.show_ground and self.sky.ground_covering(
self.ground_key(view)) is not None: view.south, view.west, view.north, view.east)[0] is not None:
painter.setPen(rgb(GRID)) painter.setPen(rgb(GRID))
painter.drawText(self.width() - 8 painter.drawText(self.width() - 8
- metrics.horizontalAdvance(ATTRIBUTION), - metrics.horizontalAdvance(ATTRIBUTION),
@ -796,7 +925,8 @@ def _build():
painter.setFont(self.head_font) painter.setFont(self.head_font)
painter.setPen(rgb(INK)) painter.setPen(rgb(INK))
painter.drawText(10, metrics.ascent() + 4, told) painter.drawText(10, metrics.ascent() + 4, told)
keys = "d detail t trails g map +/- range q quit" keys = ("d detail t trails g map [ ] bright "
"+/- range q quit")
painter.setPen(rgb(GRID)) painter.setPen(rgb(GRID))
painter.drawText(self.width() - 8 painter.drawText(self.width() - 8
- metrics.horizontalAdvance(keys), - metrics.horizontalAdvance(keys),
@ -837,6 +967,10 @@ def _build():
self.sky.radius_nm = max(5.0, self.sky.radius_nm / 1.5) self.sky.radius_nm = max(5.0, self.sky.radius_nm / 1.5)
elif text == "-": elif text == "-":
self.sky.radius_nm = min(3000.0, self.sky.radius_nm * 1.5) self.sky.radius_nm = min(3000.0, self.sky.radius_nm * 1.5)
elif text == "[":
self.sky.brightness = max(0.1, self.sky.brightness - 0.1)
elif text == "]":
self.sky.brightness = min(1.0, self.sky.brightness + 0.1)
self.view.update() self.view.update()
def closeEvent(self, event) -> None: def closeEvent(self, event) -> None:
@ -883,11 +1017,15 @@ def fetch_ground(sky: Sky, url: str = "", fetch=None) -> None:
if wanted is None: if wanted is None:
time.sleep(0.2) time.sleep(0.2)
continue continue
key, (south, west, north, east), (width, height) = wanted key, box, (width, height) = wanted
south, west, north, east = box
try: try:
extra = {"fetch": fetch} if fetch is not None else {} extra = {"fetch": fetch} if fetch is not None else {}
if url: if url:
extra["url"] = url extra["url"] = url
# Fetched at the size of the window rather than the bigger box,
# so a wider view costs no more pixels; it is stretched back over
# the box when it is drawn.
levels = basemap.ground_under(south, west, north, east, levels = basemap.ground_under(south, west, north, east,
width, height, width, height,
shades=_ground_shades(), **extra) shades=_ground_shades(), **extra)
@ -895,7 +1033,7 @@ def fetch_ground(sky: Sky, url: str = "", fetch=None) -> None:
levels = None levels = None
# Remembered either way: a map that could not be fetched must not be # Remembered either way: a map that could not be fetched must not be
# asked for again every fifth of a second for the rest of the night. # asked for again every fifth of a second for the rest of the night.
sky.set_ground(levels, key) sky.set_ground(levels, key, box if levels is not None else None)
def _ground_shades() -> int: def _ground_shades() -> int:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Before After
Before After

View file

@ -1,5 +1,5 @@
.\" Generated by packaging/make-man.py -- do not edit by hand. .\" Generated by packaging/make-man.py -- do not edit by hand.
.TH BANDSAUNTER 1 "2026-09-04" "bandsaunter 2026-09-04_04" "User Commands" .TH BANDSAUNTER 1 "2026-09-04" "bandsaunter 2026-09-04_06" "User Commands"
.SH NAME .SH NAME
bandsaunter \- scan, record and identify radio signals with an RTL-SDR bandsaunter \- scan, record and identify radio signals with an RTL-SDR
.SH SYNOPSIS .SH SYNOPSIS
@ -1335,10 +1335,14 @@ cycles how much each box says, for a busy sky;
turns the trails off, turns the trails off,
.B g .B g
the map underneath, the map underneath,
.B [
and
.B ]
its brightness,
.B + .B +
and and
.B \- .B \-
change the range and the range, and
.B q .B q
closes it. Closing the window leaves exactly the files a passive capture closes it. Closing the window leaves exactly the files a passive capture
leaves, because it is the same code with a different thing watching it: the leaves, because it is the same code with a different thing watching it: the
@ -1480,6 +1484,29 @@ draws the tracks on their own,
.BI \-\-tiles " URL" .BI \-\-tiles " URL"
points at another server, and where there is no network and nothing cached points at another server, and where there is no network and nothing cached
the picture falls back to the plain grid. the picture falls back to the plain grid.
.PP
An aircraft that goes quiet fades rather than vanishing: taking it off the
picture between one frame and the next says it stopped existing, and fading it
says it stopped talking, which is what happened. It fades where it was last
actually seen and never along a reckoned track, since the reason for giving up
on it is that where it would be by now is a guess.
.BI \-\-fade " SECONDS"
is how long that takes, and zero takes it away at once.
.PP
The map is averaged down to the size of the picture rather than point-sampled,
so lettering and roads stay whole, and the window fetches enough pixels to
cover its margin at full detail rather than enlarging what it has.
.BI \-\-map\-brightness " PERCENT"
is how far up its range the map is drawn: dark enough that the aircraft stay
the brightest thing on the picture, light enough that a coastline can be made
out, and which way to err depends on the screen.
.PP
Every aerodrome under the picture is marked, not only the ones being flown
between \[em] a receiver hears aircraft over its own county, and the county's
airports say where on the map you are looking. They come from the same map
data the tiles are drawn from, asked once per area and kept for a month.
.B \-\-no\-airports
turns that off.
.SH METERS AND SENSORS .SH METERS AND SENSORS
Two things on the ISM bands are worth naming rather than reporting as Two things on the ISM bands are worth naming rather than reporting as
hexadecimal. hexadecimal.

View file

@ -728,10 +728,14 @@ cycles how much each box says, for a busy sky;
turns the trails off, turns the trails off,
.B g .B g
the map underneath, the map underneath,
.B [
and
.B ]
its brightness,
.B + .B +
and and
.B \- .B \-
change the range and the range, and
.B q .B q
closes it. Closing the window leaves exactly the files a passive capture closes it. Closing the window leaves exactly the files a passive capture
leaves, because it is the same code with a different thing watching it: the leaves, because it is the same code with a different thing watching it: the
@ -873,6 +877,29 @@ draws the tracks on their own,
.BI \-\-tiles " URL" .BI \-\-tiles " URL"
points at another server, and where there is no network and nothing cached points at another server, and where there is no network and nothing cached
the picture falls back to the plain grid. the picture falls back to the plain grid.
.PP
An aircraft that goes quiet fades rather than vanishing: taking it off the
picture between one frame and the next says it stopped existing, and fading it
says it stopped talking, which is what happened. It fades where it was last
actually seen and never along a reckoned track, since the reason for giving up
on it is that where it would be by now is a guess.
.BI \-\-fade " SECONDS"
is how long that takes, and zero takes it away at once.
.PP
The map is averaged down to the size of the picture rather than point-sampled,
so lettering and roads stay whole, and the window fetches enough pixels to
cover its margin at full detail rather than enlarging what it has.
.BI \-\-map\-brightness " PERCENT"
is how far up its range the map is drawn: dark enough that the aircraft stay
the brightest thing on the picture, light enough that a coastline can be made
out, and which way to err depends on the screen.
.PP
Every aerodrome under the picture is marked, not only the ones being flown
between \[em] a receiver hears aircraft over its own county, and the county's
airports say where on the map you are looking. They come from the same map
data the tiles are drawn from, asked once per area and kept for a month.
.B \-\-no\-airports
turns that off.
.SH METERS AND SENSORS .SH METERS AND SENSORS
Two things on the ISM bands are worth naming rather than reporting as Two things on the ISM bands are worth naming rather than reporting as
hexadecimal. hexadecimal.

View file

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

View file

@ -212,6 +212,7 @@ def _listen_and_draw(monkeypatch, console, tmp_path, picture="png"):
number("picture"), picture, # what to draw number("picture"), picture, # what to draw
number("lookup"), "no", # no lookups: no network in a test number("lookup"), "no", # no lookups: no network in a test
number("basemap"), "no", # nor a tile server number("basemap"), "no", # nor a tile server
number("airports"), "no", # nor the map data
"l", # listen now "l", # listen now
"m", "1", # draw the newest log "m", "1", # draw the newest log
"b"], cfg) "b"], cfg)
@ -266,7 +267,7 @@ def test_listening_can_draw_as_soon_as_it_stops(monkeypatch, console,
run(monkeypatch, console, run(monkeypatch, console,
[number("simulate"), "yes", number("seconds"), "3", [number("simulate"), "yes", number("seconds"), "3",
number("lookup"), "no", number("basemap"), "no", number("lookup"), "no", number("basemap"), "no",
number("draw_after"), "yes", number("airports"), "no", number("draw_after"), "yes",
number("picture"), "png", "l", "b"], cfg) number("picture"), "png", "l", "b"], cfg)
assert list(tmp_path.glob("adsb_*.png")) assert list(tmp_path.glob("adsb_*.png"))

View file

@ -4,7 +4,9 @@ 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 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. here from the specification rather than by the decoder they are testing.
""" """
import json
import struct import struct
import time
import zlib import zlib
import numpy as np import numpy as np
@ -418,7 +420,7 @@ def test_the_tile_server_can_be_pointed_somewhere_else(tmp_path, monkeypatch):
def test_the_map_goes_under_the_picture_and_is_credited(tmp_path): 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, out = fm.animate(two_aircraft(), tmp_path / "on-the-map.png", width=400,
ground=True, fetch=gradient_tile) ground=True, fetch=gradient_tile, airports=False)
assert out is not None and out.ground assert out is not None and out.ground
assert "on the map" in out.summary() assert "on the map" in out.summary()
from bandsaunter.images import PNG_SIGNATURE from bandsaunter.images import PNG_SIGNATURE
@ -457,3 +459,221 @@ def test_the_credit_is_written_on_the_picture_itself():
levels, credit = fm.ground_for(view, fetch=gradient_tile) levels, credit = fm.ground_for(view, fetch=gradient_tile)
base = fm.background(view, ground=levels, attribution=credit) base = fm.background(view, ground=levels, attribution=credit)
assert _has_text(base, "OPENSTREETMAP") assert _has_text(base, "OPENSTREETMAP")
# ---------------------------------------------------------------------------
# What aerodromes are under the picture
# ---------------------------------------------------------------------------
def _overpass(elements):
"""Stand in for the map data, in the shape it really answers with."""
def ask(box, url, timeout):
return {"elements": elements}
return ask
def _node(code=None, name="", lat=32.1, lon=-110.9, ref=None, kind="node"):
tags = {"aeroway": "aerodrome", "name": name}
if code:
tags["icao"] = code
if ref:
tags["ref"] = ref
element = {"type": kind, "tags": tags}
if kind == "node":
element.update({"lat": lat, "lon": lon})
else:
element["center"] = {"lat": lat, "lon": lon}
return element
def test_the_aerodromes_in_a_box_come_back_with_a_code_and_a_place(tmp_path):
got = bm.airports_in(32.0, -111.2, 32.4, -110.7, cache=tmp_path / "a.json",
ask=_overpass([_node("KTUS", "Tucson International")]))
assert len(got) == 1
assert got[0]["code"] == "KTUS"
assert got[0]["latitude"] == pytest.approx(32.1)
def test_the_big_airports_are_relations_and_must_be_asked_for():
"""Tucson International and Davis-Monthan are both relations; asking
only for nodes and ways finds every airstrip in the county and misses
the two the county is known for."""
import inspect
source = inspect.getsource(bm._ask_overpass)
for kind in ("node", "way", "relation"):
assert f'{kind}["aeroway"="aerodrome"]' in source, kind
def test_a_relation_is_placed_by_its_middle(tmp_path):
got = bm.airports_in(32.0, -111.2, 32.4, -110.7, cache=tmp_path / "a.json",
ask=_overpass([_node("KDMA", "Davis-Monthan",
kind="relation")]))
assert got and got[0]["code"] == "KDMA"
def test_a_landing_strip_with_no_real_code_is_left_off(tmp_path):
"""A local identifier like 14AZ names nothing anybody would recognise,
and turns a map into a list of airstrips."""
got = bm.airports_in(32.0, -111.2, 32.4, -110.7, cache=tmp_path / "a.json",
ask=_overpass([_node(None, "Ruby Star", ref="14AZ"),
_node(None, "Nowhere",
ref="MX-0492"),
_node(None, "Unnamed strip")]))
assert got == []
def test_a_four_letter_reference_is_good_enough(tmp_path):
got = bm.airports_in(32.0, -111.2, 32.4, -110.7, cache=tmp_path / "a.json",
ask=_overpass([_node(None, "Somewhere", ref="EGLL")]))
assert [a["code"] for a in got] == ["EGLL"]
def test_one_airport_tagged_twice_is_marked_once(tmp_path):
"""A point for the terminal and an outline for the field: marking both
writes the name over itself."""
got = bm.airports_in(32.0, -111.2, 32.4, -110.7, cache=tmp_path / "a.json",
ask=_overpass([_node("KFHU", "Sierra Vista"),
_node("KFHU", "Sierra Vista",
kind="relation")]))
assert [a["code"] for a in got] == ["KFHU"]
def test_the_ones_with_real_codes_come_first(tmp_path):
got = bm.airports_in(32.0, -111.2, 32.4, -110.7, cache=tmp_path / "a.json",
ask=_overpass([_node(None, "Strip", ref="ZZZZ"),
_node("KTUS", "Tucson")]))
assert [a["code"] for a in got][0] == "KTUS"
def test_a_view_full_of_airstrips_is_capped(tmp_path):
many = [_node(f"K{i:03d}"[:4], f"Strip {i}") for i in range(200)]
got = bm.airports_in(32.0, -111.2, 32.4, -110.7, cache=tmp_path / "a.json",
ask=_overpass(many))
assert len(got) <= bm.MOST_AIRPORTS
def test_the_answer_is_kept_so_the_question_is_asked_once(tmp_path):
"""A runway does not move, and the service being asked is a volunteer
one."""
asked = []
def ask(box, url, timeout):
asked.append(box)
return {"elements": [_node("KTUS", "Tucson")]}
where = tmp_path / "a.json"
first = bm.airports_in(32.0, -111.2, 32.4, -110.7, cache=where, ask=ask)
second = bm.airports_in(32.0, -111.2, 32.4, -110.7, cache=where, ask=ask)
assert first == second
assert len(asked) == 1, "asked twice for the same piece of the world"
def test_an_answer_from_an_older_question_is_asked_again(tmp_path):
where = tmp_path / "a.json"
where.write_text(json.dumps({"fetched_at": time.time(), "version": 1,
"airports": [{"code": "OLD"}]}))
got = bm.airports_in(32.0, -111.2, 32.4, -110.7, cache=where,
ask=_overpass([_node("KTUS", "Tucson")]))
assert [a["code"] for a in got] == ["KTUS"]
def test_no_network_means_no_airports_rather_than_no_map(tmp_path):
def refuse(box, url, timeout):
raise OSError("no route to host")
assert bm.airports_in(32.0, -111.2, 32.4, -110.7,
cache=tmp_path / "a.json", ask=refuse) == []
def test_nonsense_from_the_service_is_survived(tmp_path):
for answer in ({}, {"elements": None}, {"elements": [{"tags": None}]},
{"elements": [{"tags": {"icao": "KTUS"}}]}):
got = bm.airports_in(32.0, -111.2, 32.4, -110.7,
cache=tmp_path / f"{id(answer)}.json",
ask=lambda b, u, t, a=answer: a)
assert got == []
def test_the_map_asks_for_the_airports_under_it():
"""A route names where its aircraft are going; the airports underneath
are what say where on the map you are looking."""
view = fm.fit(two_aircraft(), width=500)
got = fm.local_airports(view, ask=_overpass([_node("EGLL", "Heathrow",
lat=view.south + 0.1,
lon=view.west + 0.1)]))
assert got and got[0][0] == "EGLL"
def test_a_service_that_is_not_there_costs_the_airports_and_nothing_else():
def refuse(box, url, timeout):
raise OSError("no")
view = fm.fit(two_aircraft(), width=500)
assert fm.local_airports(view, ask=refuse) == []
# ---------------------------------------------------------------------------
# How sharp the map is
# ---------------------------------------------------------------------------
def test_detail_is_averaged_down_rather_than_thrown_away():
"""Taking the nearest source pixel keeps a fraction of a tile and turns
the rest into aliasing: hard, broken lettering and roads that come and
go along their length. A checkerboard is half black and half white, so
averaging it lands in the middle and sampling it lands at one end."""
fine = np.tile(np.array([0.0, 255.0], dtype=np.float32), 128)[None, :]
out = bm._resample(fine, np.linspace(0, 256, 41), axis=1)
assert out.shape == (1, 40)
assert abs(float(out.mean()) - 127.5) < 4.0
assert float(out.max()) < 160.0 and float(out.min()) > 95.0
def test_averaging_takes_the_whole_cell_and_no_more():
"""Each output cell is the mean of the source pixels under it."""
values = np.arange(100, dtype=np.float32)[None, :]
out = bm._resample(values, np.linspace(0, 100, 11), axis=1)
assert out.shape == (1, 10)
for i in range(10):
assert abs(float(out[0, i]) - (i * 10 + 4.5)) < 0.01
def test_averaging_works_the_other_way_up_too():
values = np.arange(60, dtype=np.float32)[:, None]
out = bm._resample(values, np.linspace(0, 60, 7), axis=0)
assert out.shape == (6, 1)
assert float(out[0, 0]) < float(out[-1, 0])
def test_a_cell_smaller_than_a_source_pixel_takes_that_pixel():
"""Zoomed in past the tiles, a cell covers less than one of them."""
values = np.array([[10.0, 20.0, 30.0]], dtype=np.float32)
out = bm._resample(values, np.linspace(0, 3, 10), axis=1)
assert out.shape == (1, 9)
assert set(np.round(out[0]).astype(int)) <= {10, 20, 30}
def test_a_gradient_still_comes_out_as_a_gradient():
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)
rows = levels.mean(axis=1)
assert (np.diff(rows) <= 0.51).all(), "the gradient came out lumpy"
def test_averaging_is_the_same_shape_as_the_picture_asked_for():
south, west, north, east = tile_bounds(9, 81, 178)
for width, height in ((40, 40), (137, 91), (300, 200), (17, 5)):
levels = bm.ground_under(south, west, north, east, width, height,
shades=32, fetch=gradient_tile, zoom=9,
pause=0)
assert levels.shape == (height, width)
def test_a_map_asked_for_at_more_detail_than_the_tiles_hold_still_works():
"""Zoomed in past the tiles, a cell covers less than one source pixel."""
south, west, north, east = tile_bounds(9, 81, 178)
levels = bm.ground_under(south, west, north, east, 2000, 2000, shades=32,
fetch=gradient_tile, zoom=9, pause=0)
assert levels.shape == (2000, 2000)
assert levels[0].mean() != levels[-1].mean()

View file

@ -922,3 +922,267 @@ def test_labels_step_aside_rather_than_landing_on_each_other():
for i, one in enumerate(taken): for i, one in enumerate(taken):
for two in taken[i + 1:]: for two in taken[i + 1:]:
assert not fm._overlaps(one, two), f"{one} overlaps {two}" assert not fm._overlaps(one, two), f"{one} overlaps {two}"
# ---------------------------------------------------------------------------
# The country an aircraft is registered in
# ---------------------------------------------------------------------------
def test_the_registration_carries_the_country_it_is_registered_in():
track = straight()
entry = _Entry()
entry.owner_country = "United Kingdom"
rows = dict((text, flag) for text, flag in
fm.label_lines(track, track.fixes[0], "knots", entry))
assert rows["B739 N904DN"] == "GB"
def test_the_address_block_stands_in_when_a_register_says_nothing():
"""The 24-bit address says which country issued it, by treaty, with no
website involved at all."""
track = straight()
entry = _Entry()
entry.owner_country = ""
entry.country = "United States"
rows = dict(fm.label_lines(track, track.fixes[0], "knots", entry))
assert rows["B739 N904DN"] == "US"
def test_a_country_nobody_named_gets_no_flag():
track = straight()
entry = _Entry()
entry.owner_country = ""
entry.country = ""
rows = dict(fm.label_lines(track, track.fixes[0], "knots", entry))
assert rows["B739 N904DN"] == ""
# ---------------------------------------------------------------------------
# Airports on the map
# ---------------------------------------------------------------------------
def test_an_airport_a_route_gave_a_position_for_is_marked():
known = {"A": _Entry()}
found = fm._airports_from(known)
assert {code for code, _, _ in found} == {"KATL", "EGLL"}
def test_an_airport_with_only_a_code_is_looked_up_so_it_can_be_marked():
"""A route names two airports and often gives a position for neither; an
airport that cannot be placed cannot be drawn."""
asked = []
class _Book:
def airports(self, codes):
asked.extend(codes)
return [{"code": code, "latitude": 32.1, "longitude": -110.9}
for code in codes]
entry = _Entry(origin_lat=0.0, origin_lon=0.0,
destination_lat=0.0, destination_lon=0.0)
found = fm._airports_from({"A": entry}, _Book())
assert sorted(asked) == ["EGLL", "KATL"]
assert len(found) == 2
def test_an_airport_that_cannot_be_looked_up_is_simply_not_drawn():
class _Book:
def airports(self, codes):
raise OSError("no network")
entry = _Entry(origin_lat=0.0, origin_lon=0.0,
destination_lat=0.0, destination_lon=0.0)
assert fm._airports_from({"A": entry}, _Book()) == []
def test_an_airport_already_placed_is_not_looked_up_again():
asked = []
class _Book:
def airports(self, codes):
asked.extend(codes)
return []
fm._airports_from({"A": _Entry()}, _Book())
assert asked == [], "asked about airports it already had"
def test_an_airport_is_drawn_where_it_is_and_named():
view = fm.fit(two_aircraft(), width=600)
middle = ((view.south + view.north) / 2, (view.west + view.east) / 2)
base = fm.background(view, airports=[("EGLL", middle[0], middle[1])])
x, y = view.xy(*middle)
marker = (base[y - 4:y + 5, x - 4:x + 5] == fm.AIRPORT)
assert marker.any(), "no marker where the airport is"
# And its name beside it, in the same colour, to the right of the marker.
beside = (base[y - 6:y + 8, x + 5:x + 60] == fm.AIRPORT)
assert beside.sum() > 10, "the airport was marked but not named"
def test_an_airport_off_the_edge_is_not_drawn():
view = fm.fit(two_aircraft(), width=600)
plain = fm.background(view)
away = fm.background(view, airports=[("KSEA", 47.4, -122.3)])
assert np.array_equal(plain, away)
# ---------------------------------------------------------------------------
# How dark the ground is
# ---------------------------------------------------------------------------
def _brightest_drawn(brightness):
"""The lightest the map actually gets at one brightness setting."""
top = int(fm.dim_ground(np.array([[fm.GROUND_SHADES - 1]]),
brightness)[0, 0])
return fm.PALETTE[fm.GROUND + top].astype(float).mean()
def test_the_map_is_light_enough_to_read():
"""It was too dark to make out a coastline at all."""
assert _brightest_drawn(fm.GROUND_BRIGHTNESS) > 110
def test_the_aircraft_stay_brighter_than_the_ground_they_are_over():
"""Whatever else, the picture has to stay about the aeroplanes."""
ground = _brightest_drawn(fm.GROUND_BRIGHTNESS)
for feet in (0, 10_000, 25_000, 40_000):
marker = fm.PALETTE[fm.RAMP + fm.altitude_step(feet)]
assert marker.astype(float).mean() > ground, feet
def test_there_is_room_to_turn_it_up_and_down():
assert _brightest_drawn(1.0) > _brightest_drawn(fm.GROUND_BRIGHTNESS)
assert _brightest_drawn(0.3) < _brightest_drawn(fm.GROUND_BRIGHTNESS)
@pytest.mark.parametrize("brightness", [0.0, -1.0, 5.0, 1.0, 0.1])
def test_a_brightness_outside_the_range_is_brought_back_into_it(brightness):
shades = fm.dim_ground(np.arange(fm.GROUND_SHADES, dtype=np.uint8),
brightness)
assert shades.min() >= 0 and shades.max() <= fm.GROUND_SHADES - 1
def test_turning_it_down_really_does_darken_the_picture():
levels = np.full((20, 20), fm.GROUND_SHADES - 1, dtype=np.uint8)
assert fm.dim_ground(levels, 0.3).max() < fm.dim_ground(levels, 0.9).max()
def test_the_darkest_ground_is_no_darker_than_the_background():
darkest = fm.PALETTE[fm.GROUND].astype(float).mean()
background = fm.PALETTE[fm.BG].astype(float).mean()
assert darkest >= background
# ---------------------------------------------------------------------------
# Fading out rather than blinking out
# ---------------------------------------------------------------------------
def test_an_aircraft_still_being_heard_is_at_full_strength():
track = straight(seconds=120)
seen = fm.showing(track, track.fixes[-1].at, stale=300.0, fade=20.0)
assert seen is not None and seen[1] == 1.0
def test_one_that_has_gone_quiet_fades_instead_of_disappearing():
track = straight(seconds=120)
last = track.fixes[-1].at
strengths = []
for gone in (301, 305, 310, 315, 319):
seen = fm.showing(track, last + gone, stale=300.0, fade=20.0)
assert seen is not None, gone
strengths.append(seen[1])
assert strengths == sorted(strengths, reverse=True)
assert strengths[0] > 0.9 and strengths[-1] < 0.1
def test_it_is_gone_once_the_fade_is_over():
track = straight(seconds=120)
last = track.fixes[-1].at
assert fm.showing(track, last + 321, stale=300.0, fade=20.0) is None
def test_no_fade_takes_it_away_the_moment_it_is_given_up_on():
track = straight(seconds=120)
last = track.fixes[-1].at
assert fm.showing(track, last + 301, stale=300.0, fade=0.0) is None
def test_it_fades_where_it_was_last_seen_and_not_where_it_might_be():
"""The whole reason for giving up on an aircraft is that where it would
be by now is a guess; fading it along that guess would be inventing an
aeroplane slowly instead of quickly."""
track = straight(seconds=120)
last = track.fixes[-1]
seen = fm.showing(track, last.at + 310, stale=300.0, fade=20.0)
assert (seen[0].latitude, seen[0].longitude) == (last.latitude,
last.longitude)
def test_before_it_was_ever_heard_it_is_still_not_drawn():
track = straight(seconds=120)
assert fm.showing(track, track.fixes[0].at - 60, fade=20.0) is None
def test_the_fading_aircraft_is_drawn_in_fainter_colours():
track = straight(seconds=120)
view = fm.fit([track], width=500)
base = fm.background(view)
last = track.fixes[-1].at
def drawn(when):
"""The aircraft's own pixels: what changed, not what was already
there. The altitude key along the bottom is painted in the same
colours and belongs to the background."""
frame = fm.render_frame(base, view, [track], when, stale=300.0,
fade=20.0, labels=False)
return frame[frame != base]
fresh = drawn(last)
faint = drawn(last + 318)
assert fresh.size and faint.size
# A live aircraft has its marker in the full colours; its trail behind it
# is dimmer on purpose, so only the brightest pixels are the test.
assert (fresh < fm.TRAIL).any(), "a live aircraft was drawn faintly"
# A nearly gone one has nothing in them at all.
assert not (faint < fm.FAINT).any(), "a fading aircraft was drawn brightly"
def test_a_faded_aircraft_keeps_its_symbol_and_loses_its_label():
track = straight(seconds=120)
view = fm.fit([track], width=500)
base = fm.background(view)
last = track.fixes[-1].at
known = {track.icao: _Entry()}
faint = fm.render_frame(base, view, [track], last + 318, stale=300.0,
fade=20.0, known=known)
assert (faint != base).any(), "nothing was drawn at all"
assert not _has_text(faint, "B739"), "the label survived the fade"
def test_the_trail_fades_with_the_aircraft_it_belongs_to():
"""The two are one thing on the picture, and half of it lingering would
be worse than either."""
track = straight(seconds=120)
view = fm.fit([track], width=500)
base = fm.background(view)
last = track.fixes[-1].at
faint = fm.render_frame(base, view, [track], last + 318, stale=300.0,
fade=20.0, labels=False)
drawn = faint[faint != base]
assert drawn.size
assert drawn.min() >= fm.FAINT, "the trail stayed behind when it faded"
def test_the_faint_colours_are_the_same_hues_only_dimmer():
for step in (0, 15, 31):
bright = fm.PALETTE[fm.RAMP + step].astype(float)
faint = fm.PALETTE[fm.FAINT + step].astype(float)
assert faint.sum() < bright.sum()
# The same colour, turned down: the ratios between the channels hold.
assert np.allclose(faint / max(1e-9, faint.sum()),
bright / bright.sum(), atol=0.03)
def test_the_palette_still_has_room_after_the_fade_colours():
assert fm.FAINT + fm.RAMP_STEPS < fm.TRANSPARENT
assert fm.PALETTE.shape == (256, 3)

View file

@ -55,7 +55,7 @@ def a_blip(icao="A76154", callsign="DAL538", lat=32.95, lon=-110.95,
def a_sky(*blips, **over) -> Sky: def a_sky(*blips, **over) -> Sky:
settings = dict(unit="knots", hold=45.0, home=(32.4325, -111.0841), settings = dict(unit="knots", hold=45.0, home=(32.4325, -111.0841),
radius_nm=100.0) radius_nm=100.0, brightness=0.70)
settings.update(over) settings.update(over)
sky = Sky(**settings) sky = Sky(**settings)
sky.started = now() - 600 sky.started = now() - 600
@ -297,11 +297,60 @@ def test_nothing_heard_yet_has_no_middle():
assert a_sky(home=None).centre() is None assert a_sky(home=None).centre() is None
def test_a_map_is_only_used_for_the_view_it_was_fetched_for(): def test_a_map_is_used_for_any_view_it_covers():
"""The whole point of fetching more than is shown: a view that has
drifted a little is still inside what was fetched."""
sky = a_sky() sky = a_sky()
sky.set_ground(np.zeros((4, 4), dtype=np.uint8), "a") sky.set_ground(np.zeros((40, 40), dtype=np.uint8), "k",
assert sky.ground("a") is not None (31.0, -113.0, 34.0, -109.0))
assert sky.ground("b") is None levels, box = sky.ground_covering(32.0, -112.0, 33.0, -110.0)
assert levels is not None and box == (31.0, -113.0, 34.0, -109.0)
def test_a_map_is_not_used_for_a_view_that_has_left_it():
sky = a_sky()
sky.set_ground(np.zeros((40, 40), dtype=np.uint8), "k",
(31.0, -113.0, 34.0, -109.0))
assert sky.ground_covering(40.0, -113.0, 42.0, -109.0)[0] is None
assert sky.ground_covering(31.0, -120.0, 34.0, -109.0)[0] is None
@qt
def test_the_map_is_fetched_at_the_size_the_bigger_box_needs(app):
"""Fetching a third more world into the same pixels and then cutting the
middle out of it is how a sharp map ends up looking like a photograph of
a map."""
from bandsaunter.livemap import GROUND_MARGIN, SkyView
sky = a_sky(a_blip())
view = SkyView(sky)
view.resize(900, 650)
view._draw_ground(_NoPainter(), view.projection())
wanted = sky.wanted_ground()
assert wanted is not None
_key, _box, (width, height) = wanted
assert width >= 900 * (1 + 2 * GROUND_MARGIN) - 2
assert height >= 650 * (1 + 2 * GROUND_MARGIN) - 2
class _NoPainter:
"""Stands in for a painter for the one call that only asks for a map."""
def drawImage(self, *a):
raise AssertionError("nothing should be drawn without a map")
def test_more_is_fetched_than_is_shown(app):
"""A window that fetched exactly what it needed would fetch again on
every resize and every small drift of the middle."""
from bandsaunter.livemap import SkyView
view = SkyView(a_sky(a_blip()))
view.resize(900, 650)
projection = view.projection()
box = view.ground_box(projection)
assert box[0] < projection.south and box[1] < projection.west
assert box[2] > projection.north and box[3] > projection.east
def test_a_map_that_could_not_be_fetched_is_not_asked_for_again(): def test_a_map_that_could_not_be_fetched_is_not_asked_for_again():
@ -314,6 +363,84 @@ def test_a_map_that_could_not_be_fetched_is_not_asked_for_again():
assert sky.wanted_ground() is None assert sky.wanted_ground() is None
# ---------------------------------------------------------------------------
# The map staying still
# ---------------------------------------------------------------------------
def test_the_middle_settles_and_then_stays_put():
"""Recomputing it on every paint moves the map a fraction of a mile
whenever an aircraft appears or leaves, which throws away the tiles
fetched for the old view and blinks the ground out several times a
minute."""
sky = a_sky(a_blip(icao="1", lat=32.4, lon=-111.0),
a_blip(icao="2", lat=32.5, lon=-111.1),
a_blip(icao="3", lat=32.6, lon=-111.2), home=None)
settled = sky.centre()
assert settled is not None
# One more aircraft, a little to one side: the middle must not follow it.
sky.update([a_blip(icao="4", lat=32.9, lon=-110.7)], 1, 4)
assert sky.centre() == settled
def test_the_middle_does_move_if_it_is_really_somewhere_else():
"""Settling must not mean stuck: the first few aircraft can be anywhere."""
sky = a_sky(a_blip(icao="1", lat=32.4, lon=-111.0), home=None)
first = sky.centre()
for i in range(6):
sky.update([a_blip(icao=f"far{i}", lat=40.0 + i * 0.1, lon=-90.0)],
1, i)
assert sky.centre() != first
assert abs(sky.centre()[0] - 40.0) < 2
def test_a_receiver_that_was_told_where_it_is_never_drifts_at_all():
sky = a_sky(a_blip(icao="1", lat=32.4, lon=-111.0))
for i in range(5):
sky.update([a_blip(icao=f"x{i}", lat=35.0 + i, lon=-100.0)], 1, i)
assert sky.centre() == (32.4325, -111.0841)
@qt
def test_a_small_drift_does_not_throw_the_map_away(app):
"""The failure this is here for: the ground blinking out because the
view moved by less than a mile."""
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip(icao="1", lat=32.40, lon=-111.00),
a_blip(icao="2", lat=32.50, lon=-111.10), home=None)
view = SkyView(sky)
view.resize(900, 650)
first = view.projection()
sky.set_ground(np.full((650, 900), 20, dtype=np.uint8),
view.ground_key(first), view.ground_box(first))
# Another aircraft arrives a few miles away.
sky.update([a_blip(icao="3", lat=32.44, lon=-111.06)], 1, 3)
moved = view.projection()
assert sky.ground_covering(moved.south, moved.west,
moved.north, moved.east)[0] is not None
@qt
def test_the_crop_lines_the_map_up_with_the_view(app):
"""A map fetched for a bigger box has to be cut to the right piece, or
the coast is drawn in the wrong place and the aircraft with it."""
from bandsaunter.livemap import SkyView
view = SkyView(a_sky(a_blip()))
view.resize(100, 80)
projection = view.projection()
box = (projection.south - 1.0, projection.west - 1.0,
projection.north + 1.0, projection.east + 1.0)
# A map that is dark in the north and bright in the south.
levels = np.repeat(np.linspace(0, 31, 200).astype(np.uint8)[:, None],
200, axis=1)
cropped = view._crop(levels, box, projection)
assert cropped.shape == (projection.height, projection.width)
assert cropped[0].mean() < cropped[-1].mean() # north still on top
# And the piece taken is the middle of the fetched one, not all of it.
assert cropped.min() > levels.min() and cropped.max() < levels.max()
def test_the_map_is_fetched_off_the_painting_thread(): def test_the_map_is_fetched_off_the_painting_thread():
asked = [] asked = []
@ -477,12 +604,18 @@ def test_the_map_underneath_is_drawn_when_there_is_one(app):
sky = a_sky(a_blip()) sky = a_sky(a_blip())
view = SkyView(sky) view = SkyView(sky)
view.resize(900, 650) view.resize(900, 650)
key = view.ground_key(view.projection()) projection = view.projection()
sky.set_ground(np.full((650, 900), 31, dtype=np.uint8), key) sky.set_ground(np.full((650, 900), 31, dtype=np.uint8),
view.ground_key(projection),
view.ground_box(projection))
lit = _rendered(view)[:, :, :3] lit = _rendered(view)[:, :, :3]
from bandsaunter.flightmap import GROUND, GROUND_SHADES, PALETTE from bandsaunter.flightmap import (GROUND, GROUND_SHADES, PALETTE,
dim_ground)
r, g, b = (int(v) for v in PALETTE[GROUND + GROUND_SHADES - 1]) # The brightest shade a drawing reaches is what the brightness allows,
# not the top of the palette.
top = int(dim_ground(np.array([[GROUND_SHADES - 1]]), sky.brightness)[0, 0])
r, g, b = (int(v) for v in PALETTE[GROUND + top])
covered = ((lit[:, :, 2] == r) & (lit[:, :, 1] == g) & (lit[:, :, 0] == b)) covered = ((lit[:, :, 2] == r) & (lit[:, :, 1] == g) & (lit[:, :, 0] == b))
assert covered.mean() > 0.5, "the map was not painted under the aircraft" assert covered.mean() > 0.5, "the map was not painted under the aircraft"
@ -652,3 +785,185 @@ def test_the_widest_box_stays_within_reason(app):
width, _height = view._box_size( width, _height = view._box_size(
view._wrapped(blip.lines("mph", home=(32.4325, -111.0841)))) view._wrapped(blip.lines("mph", home=(32.4325, -111.0841))))
assert width < 320, width assert width < 320, width
# ---------------------------------------------------------------------------
# How bright the map is
# ---------------------------------------------------------------------------
def test_the_map_brightness_reaches_the_window():
from bandsaunter import aircraft as air
sky = Sky(brightness=0.9)
assert sky.brightness == 0.9
assert air.AircraftOptions().map_brightness == 70
@qt
def test_turning_the_brightness_down_darkens_the_map(app):
from bandsaunter.livemap import SkyView
def painted(brightness):
sky = a_sky(a_blip(), brightness=brightness)
view = SkyView(sky)
view.resize(400, 300)
projection = view.projection()
sky.set_ground(np.full((300, 400), 31, dtype=np.uint8),
view.ground_key(projection),
view.ground_box(projection))
return _rendered(view, 400, 300)[:, :, :3].astype(float).mean()
assert painted(0.3) < painted(0.7) < painted(1.0)
@qt
def test_the_keys_change_the_brightness(app):
from bandsaunter.livemap import Window, _qt
_name, core, gui, _widgets, _signal = _qt()
window = Window(a_sky(a_blip()))
try:
def press(letter):
window.keyPressEvent(gui.QKeyEvent(
_get(core.QEvent, "Type", "KeyPress"), ord(letter),
_get(core.Qt, "KeyboardModifier", "NoModifier"), letter))
was = window.sky.brightness
press("]")
assert window.sky.brightness > was
press("[")
press("[")
assert window.sky.brightness < was
for _ in range(20): # and never off either end
press("[")
assert 0.0 < window.sky.brightness <= 1.0
finally:
window.close()
def test_the_country_of_registration_carries_a_flag():
from bandsaunter.adsb import Aircraft
from bandsaunter.flights import Flight
craft = Aircraft(icao="4CA1FA", callsign="RYR1234")
blip = blip_for(craft, Flight(icao="4CA1FA", owner_country="Ireland"))
assert blip.country == "Ireland" and blip.country_code == "IE"
rows = {label: (value, flag) for label, value, flag in blip.lines("knots")}
assert rows["reg'd"] == ("Ireland", "IE")
def test_a_country_with_no_code_gets_no_flag_rather_than_a_wrong_one():
from bandsaunter.adsb import Aircraft
from bandsaunter.flights import Flight
blip = blip_for(Aircraft(icao="4CA1FA"),
Flight(icao="4CA1FA", owner_country="Atlantis"))
assert blip.country_code == ""
# ---------------------------------------------------------------------------
# Fading out rather than blinking out
# ---------------------------------------------------------------------------
def test_an_aircraft_still_being_heard_is_drawn_in_full():
sky = a_sky(hold=10.0, fade=20.0)
assert sky.strength(a_blip(last_seen=now() - 1)) == 1.0
assert sky.strength(a_blip(last_seen=now() - 9)) == 1.0
def test_one_that_has_gone_quiet_fades_rather_than_vanishing():
"""Taking it off between one frame and the next says it stopped
existing; fading says it stopped talking, which is what happened."""
sky = a_sky(hold=10.0, fade=20.0)
at = now()
strengths = [sky.strength(a_blip(last_seen=at - age), at)
for age in (5, 15, 20, 25, 29)]
assert strengths == sorted(strengths, reverse=True)
assert strengths[0] == 1.0
assert 0.0 < strengths[-1] < 0.1
def test_it_is_gone_once_it_has_finished_fading():
sky = a_sky(hold=10.0, fade=20.0)
old = a_blip(last_seen=now() - 40)
assert sky.strength(old) == 0.0
sky.update([old], 1, 1)
assert old.icao not in [b.icao for b in sky.flying()]
def test_it_stays_on_the_picture_for_the_whole_fade():
sky = a_sky(hold=10.0, fade=20.0)
quiet = a_blip(last_seen=now() - 25)
sky.update([quiet], 1, 1)
assert [b.icao for b in sky.flying()] == [quiet.icao]
def test_no_fade_at_all_takes_it_away_at_once():
sky = a_sky(hold=10.0, fade=0.0)
assert sky.strength(a_blip(last_seen=now() - 9)) == 1.0
assert sky.strength(a_blip(last_seen=now() - 11)) == 0.0
def test_how_long_the_fade_takes_is_a_setting():
from bandsaunter import aircraft as air
assert air.AircraftOptions().fade == 20.0
quick = a_sky(hold=10.0, fade=4.0)
slow = a_sky(hold=10.0, fade=60.0)
faded = a_blip(last_seen=now() - 20)
assert quick.strength(faded) == 0.0
assert slow.strength(faded) > 0.5
def test_the_setting_reaches_the_window():
assert Sky(fade=7.5).fade == 7.5
@qt
def test_a_fading_aircraft_is_drawn_fainter(app):
from bandsaunter.livemap import SkyView
def brightness(age):
sky = a_sky(hold=5.0, fade=20.0)
sky.update([a_blip(last_seen=now() - age)], 1, 1)
view = SkyView(sky)
view.detail = 0
picture = _rendered(view, 500, 400)
lit = _lit(picture)
if not len(lit):
return 0.0
return float(picture[lit[:, 0], lit[:, 1], :3].mean())
assert brightness(1) > brightness(20)
@qt
def test_a_faded_aircraft_keeps_its_symbol_and_loses_its_box(app):
"""A box at a tenth of its colour is something in the way of the
aircraft still flying."""
from bandsaunter.livemap import SkyView
sky = a_sky(hold=5.0, fade=20.0)
sky.update([a_blip(last_seen=now() - 22)], 1, 1) # strength about 0.15
view = SkyView(sky)
fresh = _painted(_rendered(view, 600, 450))
sky2 = a_sky(hold=5.0, fade=20.0)
sky2.update([a_blip(last_seen=now() - 1)], 1, 1)
view2 = SkyView(sky2)
full = _painted(_rendered(view2, 600, 450))
assert fresh < full, "the box was still drawn on a nearly faded aircraft"
@qt
def test_one_that_has_gone_quiet_is_not_counted_as_overhead(app):
"""It is on the picture, fading; saying it is overhead says more than
was heard."""
from bandsaunter.livemap import SkyView
sky = a_sky(hold=5.0, fade=30.0)
sky.update([a_blip(icao="AAA111", last_seen=now() - 1),
a_blip(icao="BBB222", lat=32.6, last_seen=now() - 20)], 9, 2)
picture = _rendered(SkyView(sky), 900, 650)
assert _painted(picture) > 100 # both are drawn
assert len(sky.flying()) == 2
assert sum(1 for b in sky.flying() if sky.strength(b) >= 1.0) == 1