A window on the sky, and flags on the routes
The terminal board says what is overhead. This says where: a real map with the aircraft moving on it as the frames arrive, and beside each one a box carrying everything known about the flight -- type and registration, who operates it, where it came from and where it is going, height with a rate of climb, speed and heading, how far away and on what bearing, its position, how many frames it has sent and how long since the last one. Qt is asked for and not required. Four bindings are tried, the module imports on a machine with none of them, and asking for the window without one gets the instructions rather than a traceback -- before the receiver is opened, since nothing is gained by taking the dongle for a window that cannot be drawn. In the menu, "listen now" is now "passive capture" with a realtime display beside it. Closing the window leaves exactly the files pressing control-C leaves, because listen and watch share one read loop and one finishing step; the receiver runs on its own thread, so a slow repaint cannot cost a frame and a slow tile fetch cannot stall the picture. The animation's labels grew to match: flight level and speed, type and registration, and both ends of the route, each with a small flag of the country its airport is in. The flags are a table rather than a network -- twelve pixels by eight, where a flag is the arrangement that makes one recognisable rather than a rendering of the real thing -- and a country not in the table is named by its two letters, since a flag that is nearly another country's is worse than none. Where a route arrives as bare codes the country comes from the ICAO prefix. Four things found on the way. The window ignored --seconds, so "listen for ten minutes" meant something different with a window open; it closes itself now. The register was being asked twice per aircraft, once for labels and once for airport positions. Cached routes had no country in them, so the first real redraw drew no flags at all -- routes are versioned now. And past fourteen aircraft on one frame the labels go back to the callsign, the height and the speed, because five lines beside each of three hundred aircraft is a page of overlapping text with a map somewhere behind it. Long names are folded rather than allowed to stretch a box, breaking at the arrow of a route so the two ends stay whole; and the animation's label placement gained the same ring search the window uses, having only ever tried four spots. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
This commit is contained in:
parent
e50d43d6e2
commit
df080f6571
21 changed files with 2848 additions and 145 deletions
|
|
@ -136,8 +136,15 @@ plainly when a feature is unavailable rather than failing.
|
||||||
| `sudo apt install rtl-sdr` | `rtl_test`, `rtl_sdr` and friends, for diagnosing hardware | nothing missing from bandsaunter itself |
|
| `sudo apt install rtl-sdr` | `rtl_test`, `rtl_sdr` and friends, for diagnosing hardware | nothing missing from bandsaunter itself |
|
||||||
| `pip install faster-whisper` | speech transcription of recorded voice — see [the step-by-step below](#speech-transcription-step-by-step) (~250 MB installed, plus a 148 MB model) | transcription is off; scans report that no recogniser is installed |
|
| `pip install faster-whisper` | speech transcription of recorded voice — see [the step-by-step below](#speech-transcription-step-by-step) (~250 MB installed, plus a 148 MB model) | transcription is off; scans report that no recogniser is installed |
|
||||||
| `pip install vosk` | a smaller, weaker recogniser (~10 MB plus a 40 MB model) | as above |
|
| `pip install vosk` | a smaller, weaker recogniser (~10 MB plus a 40 MB model) | as above |
|
||||||
|
| `sudo apt install python3-pyqt6` | the realtime aircraft window (`bandsaunter adsb --window`) | the terminal board still shows every aircraft, and the maps are still drawn afterwards |
|
||||||
| `pip install pyte` | the terminal-resize tests | those tests skip |
|
| `pip install pyte` | the terminal-resize tests | those tests skip |
|
||||||
|
|
||||||
|
**The aircraft window needs Qt**, and any of four bindings will do — PyQt6,
|
||||||
|
PyQt5, PySide6 or PySide2 — because distributions disagree about which they
|
||||||
|
package. `python3-pyqt6` on Debian and Fedora, `python-pyqt6` on Arch, or
|
||||||
|
`pip install PyQt6` in the environment bandsaunter runs from. Without it the
|
||||||
|
window is the only thing missing, and the program says so rather than failing.
|
||||||
|
|
||||||
**Aircraft and callsign lookups need no installation**, only a network. They
|
**Aircraft and callsign lookups need no installation**, only a network. They
|
||||||
ask public registers about a callsign or a 24-bit address and cache the
|
ask public registers about a callsign or a 24-bit address and cache the
|
||||||
answers for a month; `--no-lookup` turns them off, and what the address and
|
answers for a month; `--no-lookup` turns them off, and what the address and
|
||||||
|
|
|
||||||
71
README.md
71
README.md
|
|
@ -985,10 +985,54 @@ on one picture. **The log always keeps knots**, because that is what the
|
||||||
aircraft broadcast: the recording stays the thing that arrived, and the
|
aircraft broadcast: the recording stays the thing that arrived, and the
|
||||||
conversion happens at the moment of showing it to somebody.
|
conversion happens at the moment of showing it to somebody.
|
||||||
|
|
||||||
|
### A window, while it happens
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bandsaunter adsb --window # or the menus: 5, then r
|
||||||
|
```
|
||||||
|
|
||||||
|
The terminal board says what is overhead; this says **where**. A real map, the
|
||||||
|
aircraft moving on it as the frames arrive, and beside each one a box with
|
||||||
|
everything known about the flight — type and registration, who operates it,
|
||||||
|
where it came from and where it is going — each end with its country's flag —
|
||||||
|
altitude with a climb or descent rate, speed and heading, how far away and on
|
||||||
|
what bearing, its position, how many frames it has sent and how long since the
|
||||||
|
last one.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
The boxes are placed so they cover neither each other nor another aircraft's
|
||||||
|
symbol: the eight spots beside the aircraft are tried first, then rings
|
||||||
|
outward, and a leader line runs to the near edge of the box rather than
|
||||||
|
through it. Altitude is the colour, low warm to high cold, the same ramp the
|
||||||
|
GIFs use.
|
||||||
|
|
||||||
|
Long names are folded rather than allowed to stretch the box — a route
|
||||||
|
between two airports with their full names runs to sixty characters, which
|
||||||
|
would otherwise make one box wider than the map under it. A route breaks at
|
||||||
|
the arrow first, so the two ends of the flight stay whole and sit under one
|
||||||
|
another where they read as a pair.
|
||||||
|
|
||||||
|
`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, `+`/`-`
|
||||||
|
the range, `q` closes it.
|
||||||
|
|
||||||
|
**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
|
||||||
|
code with a different thing watching it. The receiver runs on its own thread,
|
||||||
|
so a slow repaint cannot cost a frame and a slow tile fetch cannot stop the
|
||||||
|
picture moving.
|
||||||
|
|
||||||
|
Qt is asked for and not required — PyQt6, PyQt5, PySide6 and PySide2 are all
|
||||||
|
tried, since distributions disagree about which to package. Without any of
|
||||||
|
them you lose this window and nothing else, and the program says how to get
|
||||||
|
one rather than failing.
|
||||||
|
|
||||||
**Or from the menus: `bandsaunter` → 5, Aircraft (ADS-B).** Every option is
|
**Or from the menus: `bandsaunter` → 5, Aircraft (ADS-B).** Every option is
|
||||||
on one screen with what it does beside it, `?N` explains any of them at
|
on one screen with what it does beside it, `?N` explains any of them at
|
||||||
length, `l` listens and `m` draws a map from a log — no flags to remember,
|
length, `p` starts a passive capture, `r` opens the realtime window and `m`
|
||||||
and the options can be saved as the default.
|
draws a map from a log — no flags to remember, and the options can be saved
|
||||||
|
as the default.
|
||||||
|
|
||||||
> **This is not a scan, and the band plan's `adsb` preset will not do it.**
|
> **This is not a scan, and the band plan's `adsb` preset will not do it.**
|
||||||
> Sweeping 1090 MHz records the bursts as clicks in a WAV file and decodes
|
> Sweeping 1090 MHz records the bursts as clicks in a WAV file and decodes
|
||||||
|
|
@ -1099,6 +1143,29 @@ Time runs at `--speed` seconds of flying per second of animation, or give
|
||||||
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
|
||||||
path it actually flew, in the colours of the heights it flew them at.
|
path it actually flew, in the colours of the heights it flew them at.
|
||||||
|
|
||||||
|
Beside each aircraft goes what is known about it — flight level and speed,
|
||||||
|
type and registration, and the two ends of the route, each with **a small flag
|
||||||
|
of the country the airport is in**:
|
||||||
|
|
||||||
|
```
|
||||||
|
BAW49
|
||||||
|
330 552MPH
|
||||||
|
B744 G-VROS
|
||||||
|
[GB] EGLL
|
||||||
|
[US] KSEA
|
||||||
|
```
|
||||||
|
|
||||||
|
The flags are twelve pixels by eight, drawn from a table in `flags.py` rather
|
||||||
|
than fetched: at that size a flag is not a rendering of the real thing but the
|
||||||
|
arrangement that makes one recognisable — the bands and where they run, the
|
||||||
|
canton, the disc. A country not in the table is named by its two letters
|
||||||
|
instead, because a flag that is nearly another country's is worse than no flag
|
||||||
|
at all.
|
||||||
|
|
||||||
|
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
|
||||||
|
region, so `EGLL` is British and `KSEA` American with nothing else to go on.
|
||||||
|
|
||||||
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
|
||||||
elsewhere, so nothing but numpy is needed to draw one. Where ffmpeg happens to
|
elsewhere, so nothing but numpy is needed to draw one. Where ffmpeg happens to
|
||||||
|
|
|
||||||
|
|
@ -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 = 2
|
VERSION_REVISION = 4
|
||||||
|
|
||||||
__version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}"
|
__version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,9 @@ from .adsb import ADSB_HZ, SAMPLE_RATE
|
||||||
from .flightlog import read_position
|
from .flightlog import read_position
|
||||||
from .settings import Setting, format_value
|
from .settings import Setting, format_value
|
||||||
|
|
||||||
__all__ = ["AircraftOptions", "OPTIONS", "listen", "draw", "logs_in",
|
__all__ = ["AircraftOptions", "OPTIONS", "listen", "watch", "draw",
|
||||||
|
"logs_in", "open_device", "open_log", "pump", "finish",
|
||||||
|
"windowed",
|
||||||
"format_option",
|
"format_option",
|
||||||
"load_options", "save_options", "options_path",
|
"load_options", "save_options", "options_path",
|
||||||
"scanning_aircraft_band", "AIRCRAFT_BANDS", "SCAN_WARNING"]
|
"scanning_aircraft_band", "AIRCRAFT_BANDS", "SCAN_WARNING"]
|
||||||
|
|
@ -449,31 +451,26 @@ class Heard:
|
||||||
return len(self.registry) if self.registry is not None else 0
|
return len(self.registry) if self.registry is not None else 0
|
||||||
|
|
||||||
|
|
||||||
def listen(console, options: AircraftOptions, output_dir: str,
|
def windowed() -> bool:
|
||||||
log_path=None) -> Heard:
|
"""Whether the realtime window can be opened on this machine."""
|
||||||
"""Park on 1090 MHz and write down the aircraft overhead.
|
from . import livemap
|
||||||
|
|
||||||
Everything heard goes into the log as it arrives, because an aircraft is
|
return livemap.available()
|
||||||
overhead for four minutes and then gone: the screen is for the person
|
|
||||||
watching, and the log is for the report, the map and everything
|
|
||||||
afterwards.
|
def open_device(console, options: AircraftOptions):
|
||||||
"""
|
"""The receiver, or an invented sky, or None if neither can be had."""
|
||||||
from rich.panel import Panel
|
from rich.panel import Panel
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
|
|
||||||
from .adsb import AircraftRegistry, SimulatedSky, decode_frames, default_sky
|
from .adsb import SimulatedSky, default_sky
|
||||||
from .device import RtlSdrDevice, RtlSdrError
|
from .device import RtlSdrDevice, RtlSdrError
|
||||||
from .flightlog import FlightLog, read_logs, report, write_kml
|
|
||||||
from .flights import FlightBook
|
|
||||||
|
|
||||||
heard = Heard()
|
|
||||||
if options.simulate:
|
if options.simulate:
|
||||||
sky = default_sky(*coordinates(options.near))
|
|
||||||
device = SimulatedSky(sky, sample_rate=options.rate,
|
|
||||||
realtime=True).open()
|
|
||||||
console.print("[yellow]simulated: these aircraft are not there."
|
console.print("[yellow]simulated: these aircraft are not there."
|
||||||
"[/yellow]")
|
"[/yellow]")
|
||||||
else:
|
return SimulatedSky(default_sky(*coordinates(options.near)),
|
||||||
|
sample_rate=options.rate, realtime=True).open()
|
||||||
try:
|
try:
|
||||||
device = RtlSdrDevice(index=options.device,
|
device = RtlSdrDevice(index=options.device,
|
||||||
sample_rate=int(options.rate),
|
sample_rate=int(options.rate),
|
||||||
|
|
@ -484,23 +481,87 @@ def listen(console, options: AircraftOptions, output_dir: str,
|
||||||
console.print(Panel(Text(str(exc)),
|
console.print(Panel(Text(str(exc)),
|
||||||
title="[red]cannot open the receiver",
|
title="[red]cannot open the receiver",
|
||||||
border_style="red"))
|
border_style="red"))
|
||||||
return heard
|
return None
|
||||||
|
return device
|
||||||
|
|
||||||
started = time.time()
|
|
||||||
log = None
|
def open_log(console, options: AircraftOptions, output_dir: str,
|
||||||
if options.log:
|
started: float, log_path=None):
|
||||||
|
"""The frame log, or None if it was not wanted or cannot be written."""
|
||||||
|
from .flightlog import FlightLog
|
||||||
|
|
||||||
|
if not options.log:
|
||||||
|
return None
|
||||||
stamp = datetime.fromtimestamp(started).strftime("%Y-%m-%d_%H_%M_%S")
|
stamp = datetime.fromtimestamp(started).strftime("%Y-%m-%d_%H_%M_%S")
|
||||||
where = Path(log_path).expanduser() if log_path else \
|
where = Path(log_path).expanduser() if log_path else \
|
||||||
Path(output_dir).expanduser() / f"adsb_{stamp}.jsonl"
|
Path(output_dir).expanduser() / f"adsb_{stamp}.jsonl"
|
||||||
try:
|
try:
|
||||||
log = FlightLog(where, frequency=ADSB_HZ, sample_rate=options.rate,
|
return FlightLog(where, frequency=ADSB_HZ, sample_rate=options.rate,
|
||||||
receiver="simulated" if options.simulate else
|
receiver="simulated" if options.simulate else
|
||||||
f"device {options.device}", started=started)
|
f"device {options.device}", started=started)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
console.print(f"[red]cannot write {where}: {exc}[/red]")
|
console.print(f"[red]cannot write {where}: {exc}[/red]")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def pump(device, options: AircraftOptions, registry, log, book,
|
||||||
|
started: float, on_block=None, on_frame=None, stopping=None) -> int:
|
||||||
|
"""Read the receiver until it stops, or until told to.
|
||||||
|
|
||||||
|
The one loop both the terminal board and the window are driven from, so
|
||||||
|
that what is written to the log cannot depend on which one you happened
|
||||||
|
to be looking at.
|
||||||
|
"""
|
||||||
|
from .adsb import decode_frames
|
||||||
|
|
||||||
registry = AircraftRegistry()
|
|
||||||
total = 0
|
total = 0
|
||||||
|
device.tune(ADSB_HZ)
|
||||||
|
block = int(options.rate) # a second at a time
|
||||||
|
while stopping is None or not stopping():
|
||||||
|
at = time.time()
|
||||||
|
samples = device.read_samples(block)
|
||||||
|
if samples is None or samples.size == 0:
|
||||||
|
break
|
||||||
|
for frame in decode_frames(samples, options.rate):
|
||||||
|
# The real time the frame arrived, not its offset in the block:
|
||||||
|
# everything downstream is a clock, and a log that started again
|
||||||
|
# from zero every second would be unusable.
|
||||||
|
when = at + frame.at_sample / options.rate
|
||||||
|
craft = registry.add(frame, when=when)
|
||||||
|
total += 1
|
||||||
|
if log is not None:
|
||||||
|
log.append(frame, craft, when=when)
|
||||||
|
if options.lookup:
|
||||||
|
book.get(frame.icao, craft.callsign)
|
||||||
|
if on_frame is not None:
|
||||||
|
on_frame(frame, craft)
|
||||||
|
if on_block is not None:
|
||||||
|
on_block(total)
|
||||||
|
if options.seconds and time.time() - started >= options.seconds:
|
||||||
|
break
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def listen(console, options: AircraftOptions, output_dir: str,
|
||||||
|
log_path=None) -> Heard:
|
||||||
|
"""Park on 1090 MHz and write down the aircraft overhead.
|
||||||
|
|
||||||
|
Everything heard goes into the log as it arrives, because an aircraft is
|
||||||
|
overhead for four minutes and then gone: the screen is for the person
|
||||||
|
watching, and the log is for the report, the map and everything
|
||||||
|
afterwards.
|
||||||
|
"""
|
||||||
|
from .adsb import AircraftRegistry
|
||||||
|
from .flights import FlightBook
|
||||||
|
|
||||||
|
heard = Heard()
|
||||||
|
device = open_device(console, options)
|
||||||
|
if device is None:
|
||||||
|
return heard
|
||||||
|
|
||||||
|
started = time.time()
|
||||||
|
log = open_log(console, options, output_dir, started, log_path)
|
||||||
|
registry = AircraftRegistry()
|
||||||
console.print(f"[grey62]listening on {ADSB_HZ/1e6:g} MHz at "
|
console.print(f"[grey62]listening on {ADSB_HZ/1e6:g} MHz at "
|
||||||
f"{options.rate/1e6:g} MS/s — control-C to stop[/grey62]")
|
f"{options.rate/1e6:g} MS/s — control-C to stop[/grey62]")
|
||||||
if log is not None:
|
if log is not None:
|
||||||
|
|
@ -511,28 +572,8 @@ def listen(console, options: AircraftOptions, output_dir: str,
|
||||||
# book answers immediately with what it knows and fills itself in later.
|
# book answers immediately with what it knows and fills itself in later.
|
||||||
book = FlightBook(online=options.lookup)
|
book = FlightBook(online=options.lookup)
|
||||||
display, live = _open_display(console, options, book, started)
|
display, live = _open_display(console, options, book, started)
|
||||||
try:
|
|
||||||
device.tune(ADSB_HZ)
|
def on_block(total: int) -> None:
|
||||||
block = int(options.rate) # a second at a time
|
|
||||||
while True:
|
|
||||||
at = time.time()
|
|
||||||
samples = device.read_samples(block)
|
|
||||||
if samples is None or samples.size == 0:
|
|
||||||
break
|
|
||||||
for frame in decode_frames(samples, options.rate):
|
|
||||||
# The real time the frame arrived, not its offset in the
|
|
||||||
# block: everything downstream is a clock, and a log that
|
|
||||||
# started again from zero every second would be unusable.
|
|
||||||
when = at + frame.at_sample / options.rate
|
|
||||||
craft = registry.add(frame, when=when)
|
|
||||||
total += 1
|
|
||||||
if log is not None:
|
|
||||||
log.append(frame, craft, when=when)
|
|
||||||
if options.lookup:
|
|
||||||
book.get(frame.icao, craft.callsign)
|
|
||||||
if options.frames:
|
|
||||||
console.print(f"[cyan]{frame.icao}[/cyan] "
|
|
||||||
f"{frame.describe()}", highlight=False)
|
|
||||||
if live is not None:
|
if live is not None:
|
||||||
display.update(registry, total,
|
display.update(registry, total,
|
||||||
log.path if log is not None else None)
|
log.path if log is not None else None)
|
||||||
|
|
@ -540,8 +581,16 @@ def listen(console, options: AircraftOptions, output_dir: str,
|
||||||
elif not options.frames and total:
|
elif not options.frames and total:
|
||||||
console.print(f"[grey62]{len(registry)} aircraft, "
|
console.print(f"[grey62]{len(registry)} aircraft, "
|
||||||
f"{total} frames[/grey62]", highlight=False)
|
f"{total} frames[/grey62]", highlight=False)
|
||||||
if options.seconds and time.time() - started >= options.seconds:
|
|
||||||
break
|
def on_frame(frame, craft) -> None:
|
||||||
|
if options.frames:
|
||||||
|
console.print(f"[cyan]{frame.icao}[/cyan] "
|
||||||
|
f"{frame.describe()}", highlight=False)
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
try:
|
||||||
|
total = pump(device, options, registry, log, book, started,
|
||||||
|
on_block=on_block, on_frame=on_frame)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
|
|
@ -552,6 +601,107 @@ def listen(console, options: AircraftOptions, output_dir: str,
|
||||||
if log is not None:
|
if log is not None:
|
||||||
log.close()
|
log.close()
|
||||||
heard.log_path = log.path
|
heard.log_path = log.path
|
||||||
|
return finish(console, options, output_dir, heard, registry, book,
|
||||||
|
started, total)
|
||||||
|
|
||||||
|
|
||||||
|
def watch(console, options: AircraftOptions, output_dir: str,
|
||||||
|
log_path=None) -> Heard:
|
||||||
|
"""The same listening, in a window with a map in it.
|
||||||
|
|
||||||
|
The receiver runs on its own thread and the window paints from a copy of
|
||||||
|
what it found, so that a slow repaint can never cost a frame and a slow
|
||||||
|
network can never stop the picture moving. Everything else -- the log,
|
||||||
|
the report, the lookups, the map drawn at the end -- is exactly what the
|
||||||
|
passive capture does, because it is the same code.
|
||||||
|
"""
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from rich.panel import Panel
|
||||||
|
from rich.text import Text
|
||||||
|
|
||||||
|
from . import livemap
|
||||||
|
from .adsb import AircraftRegistry
|
||||||
|
from .flightlog import read_position
|
||||||
|
from .flights import FlightBook
|
||||||
|
|
||||||
|
heard = Heard()
|
||||||
|
if not livemap.available():
|
||||||
|
console.print(Panel(Text(livemap.MISSING_QT),
|
||||||
|
title="[yellow]no window to open",
|
||||||
|
border_style="yellow"))
|
||||||
|
return heard
|
||||||
|
device = open_device(console, options)
|
||||||
|
if device is None:
|
||||||
|
return heard
|
||||||
|
|
||||||
|
started = time.time()
|
||||||
|
log = open_log(console, options, output_dir, started, log_path)
|
||||||
|
registry = AircraftRegistry()
|
||||||
|
book = FlightBook(online=options.lookup)
|
||||||
|
sky = livemap.Sky(unit=options.speed_unit, hold=options.hold,
|
||||||
|
home=read_position(options.location),
|
||||||
|
radius_nm=radius_in_nm(options) or 100.0)
|
||||||
|
sky.started = started
|
||||||
|
sky.log_name = log.path.name if log is not None else ""
|
||||||
|
if options.simulate:
|
||||||
|
sky.note = "simulated"
|
||||||
|
|
||||||
|
def on_block(total: int) -> None:
|
||||||
|
sky.update([livemap.blip_for(craft,
|
||||||
|
book.get(craft.icao, craft.callsign)
|
||||||
|
if options.lookup else None)
|
||||||
|
for craft in registry.aircraft.values()],
|
||||||
|
total, len(registry))
|
||||||
|
|
||||||
|
counted = {"frames": 0}
|
||||||
|
|
||||||
|
def listening() -> None:
|
||||||
|
try:
|
||||||
|
counted["frames"] = pump(device, options, registry, log, book,
|
||||||
|
started, on_block=on_block,
|
||||||
|
stopping=lambda: sky.stopping)
|
||||||
|
except Exception as exc: # a window must survive it
|
||||||
|
sky.note = str(exc)[:60]
|
||||||
|
finally:
|
||||||
|
# However it ended -- the time ran out, the receiver stopped, or
|
||||||
|
# it fell over -- the window is told, and shuts itself.
|
||||||
|
sky.finished = True
|
||||||
|
|
||||||
|
threads = [threading.Thread(target=listening, daemon=True,
|
||||||
|
name="adsb-receiver")]
|
||||||
|
if options.basemap:
|
||||||
|
threads.append(threading.Thread(
|
||||||
|
target=livemap.fetch_ground, args=(sky, options.tile_url),
|
||||||
|
daemon=True, name="adsb-basemap"))
|
||||||
|
for thread in threads:
|
||||||
|
thread.start()
|
||||||
|
console.print(f"[grey62]listening on {ADSB_HZ/1e6:g} MHz — close the "
|
||||||
|
"window to stop[/grey62]")
|
||||||
|
if log is not None:
|
||||||
|
console.print(f"[grey62]writing {log.path}[/grey62]")
|
||||||
|
try:
|
||||||
|
livemap.show(sky, "bandsaunter — aircraft on 1090 MHz")
|
||||||
|
finally:
|
||||||
|
sky.stopping = True
|
||||||
|
for thread in threads:
|
||||||
|
thread.join(timeout=3.0)
|
||||||
|
device.close()
|
||||||
|
if log is not None:
|
||||||
|
log.close()
|
||||||
|
heard.log_path = log.path
|
||||||
|
return finish(console, options, output_dir, heard, registry, book,
|
||||||
|
started, counted["frames"])
|
||||||
|
|
||||||
|
|
||||||
|
def finish(console, options: AircraftOptions, output_dir: str, heard: Heard,
|
||||||
|
registry, book, started: float, total: int) -> Heard:
|
||||||
|
"""Everything that happens once the listening stops.
|
||||||
|
|
||||||
|
Shared by the passive capture and the window, so that closing a window
|
||||||
|
leaves exactly the same files behind as pressing control-C does.
|
||||||
|
"""
|
||||||
|
from .flightlog import read_logs, report, write_kml
|
||||||
|
|
||||||
heard.frames = total
|
heard.frames = total
|
||||||
heard.registry = registry
|
heard.registry = registry
|
||||||
|
|
|
||||||
|
|
@ -189,6 +189,9 @@ examples:
|
||||||
ad.add_argument("--no-basemap", dest="basemap", action="store_false",
|
ad.add_argument("--no-basemap", dest="basemap", action="store_false",
|
||||||
default=None,
|
default=None,
|
||||||
help="draw the map with no real map under it")
|
help="draw the map with no real map under it")
|
||||||
|
ad.add_argument("--window", action="store_true",
|
||||||
|
help="open a window and show the aircraft on a map as "
|
||||||
|
"they are heard, instead of a table in the terminal")
|
||||||
ad.set_defaults(log_frames=True, lookup=True)
|
ad.set_defaults(log_frames=True, lookup=True)
|
||||||
|
|
||||||
# -- flights --------------------------------------------------------------
|
# -- flights --------------------------------------------------------------
|
||||||
|
|
@ -1076,8 +1079,8 @@ def cmd_adsb(args) -> int:
|
||||||
if args.map:
|
if args.map:
|
||||||
options.picture = Path(args.map).suffix.lstrip(".") or options.picture
|
options.picture = Path(args.map).suffix.lstrip(".") or options.picture
|
||||||
|
|
||||||
heard = air.listen(console, options, cfg.output_dir,
|
run = air.watch if args.window else air.listen
|
||||||
log_path=args.log)
|
heard = run(console, options, cfg.output_dir, log_path=args.log)
|
||||||
if not heard.aircraft:
|
if not heard.aircraft:
|
||||||
return 1
|
return 1
|
||||||
if args.kml and heard.kml_path is None:
|
if args.kml and heard.kml_path is None:
|
||||||
|
|
|
||||||
246
bandsaunter/flags.py
Normal file
246
bandsaunter/flags.py
Normal file
|
|
@ -0,0 +1,246 @@
|
||||||
|
"""Twelve pixels by eight of a national flag.
|
||||||
|
|
||||||
|
A route says "London Heathrow Airport → Seattle Tacoma International
|
||||||
|
Airport". A flag beside each end says the same thing in the corner of an
|
||||||
|
eye, which is what a moving map is for.
|
||||||
|
|
||||||
|
At this size a flag is not a rendering of the real thing and is not trying to
|
||||||
|
be: it is the arrangement that makes one recognisable across a room -- the
|
||||||
|
bands and where they run, the canton, the disc. Most national flags are two
|
||||||
|
or three bands, so most of this file is a table rather than a picture, and
|
||||||
|
the ones that are genuinely a picture are drawn a row at a time. Anything
|
||||||
|
not here falls back to its two-letter code, which is never wrong and never
|
||||||
|
pretends to be a flag.
|
||||||
|
|
||||||
|
The colours are deliberately few. These end up in the animation's palette,
|
||||||
|
which has 256 entries for everything on the picture at once, and a dozen
|
||||||
|
flag colours is a fair share of it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
__all__ = ["FLAG_W", "FLAG_H", "COLOURS", "COLOUR_ORDER", "FLAGS",
|
||||||
|
"flag_for", "pixels_for", "known", "country_of_icao"]
|
||||||
|
|
||||||
|
FLAG_W, FLAG_H = 12, 8
|
||||||
|
|
||||||
|
# One letter each, so that a flag drawn by hand below stays readable as a
|
||||||
|
# flag in the source.
|
||||||
|
COLOURS: dict[str, tuple[int, int, int]] = {
|
||||||
|
"r": (206, 32, 41), # red
|
||||||
|
"R": (150, 22, 30), # dark red
|
||||||
|
"w": (238, 238, 238), # white
|
||||||
|
"b": (28, 62, 148), # blue
|
||||||
|
"B": (14, 30, 84), # navy
|
||||||
|
"c": (92, 164, 222), # light blue
|
||||||
|
"g": (24, 132, 66), # green
|
||||||
|
"G": (12, 82, 44), # dark green
|
||||||
|
"y": (244, 202, 44), # yellow
|
||||||
|
"k": (26, 26, 28), # black
|
||||||
|
"o": (232, 122, 34), # orange
|
||||||
|
"m": (124, 26, 44), # maroon
|
||||||
|
}
|
||||||
|
COLOUR_ORDER = tuple(COLOURS)
|
||||||
|
|
||||||
|
|
||||||
|
def _across(*bands: str) -> tuple[str, ...]:
|
||||||
|
"""Horizontal bands, top to bottom."""
|
||||||
|
return tuple(bands[y * len(bands) // FLAG_H] * FLAG_W
|
||||||
|
for y in range(FLAG_H))
|
||||||
|
|
||||||
|
|
||||||
|
def _down(*bands: str) -> tuple[str, ...]:
|
||||||
|
"""Vertical bands, left to right."""
|
||||||
|
row = "".join(bands[x * len(bands) // FLAG_W] for x in range(FLAG_W))
|
||||||
|
return tuple([row] * FLAG_H)
|
||||||
|
|
||||||
|
|
||||||
|
def _nordic(field: str, cross: str, inner: str = "") -> tuple[str, ...]:
|
||||||
|
"""A cross set off towards the hoist, the way the Nordic flags are."""
|
||||||
|
rows = []
|
||||||
|
for y in range(FLAG_H):
|
||||||
|
line = []
|
||||||
|
for x in range(FLAG_W):
|
||||||
|
on = 3 <= x <= 5 or 3 <= y <= 5
|
||||||
|
middle = x == 4 or y == 4
|
||||||
|
line.append((inner if inner and middle else cross) if on
|
||||||
|
else field)
|
||||||
|
rows.append("".join(line))
|
||||||
|
return tuple(rows)
|
||||||
|
|
||||||
|
|
||||||
|
# The flags a receiver in the ordinary world sees on a route, and no more.
|
||||||
|
FLAGS: dict[str, tuple[str, ...]] = {
|
||||||
|
# -- the ones that are a picture rather than a pattern ----------------
|
||||||
|
"US": ("BBBBBBrrrrrr", "BwBwBwwwwwww", "BBBBBBrrrrrr", "BwBwBwwwwwww",
|
||||||
|
"rrrrrrrrrrrr", "wwwwwwwwwwww", "rrrrrrrrrrrr", "wwwwwwwwwwww"),
|
||||||
|
"GB": ("wwbbbrrbbbww", "bwwbbrrbbwwb", "bbwwbrrbwwbb", "rrrrrrrrrrrr",
|
||||||
|
"rrrrrrrrrrrr", "bbwwbrrbwwbb", "bwwbbrrbbwwb", "wwbbbrrbbbww"),
|
||||||
|
"JP": ("wwwwwwwwwwww", "wwwwrrrrwwww", "wwwrrrrrrwww", "wwwrrrrrrwww",
|
||||||
|
"wwwrrrrrrwww", "wwwrrrrrrwww", "wwwwrrrrwwww", "wwwwwwwwwwww"),
|
||||||
|
"CA": ("rrrwwwwwwrrr", "rrrwwrwwwrrr", "rrrwrrrwwrrr", "rrrrrrrrwrrr",
|
||||||
|
"rrrwrrrwwrrr", "rrrwwrwwwrrr", "rrrwwrwwwrrr", "rrrwwwwwwrrr"),
|
||||||
|
"CH": ("rrrrrrrrrrrr", "rrrrrwwrrrrr", "rrrrrwwrrrrr", "rrrwwwwwwrrr",
|
||||||
|
"rrrwwwwwwrrr", "rrrrrwwrrrrr", "rrrrrwwrrrrr", "rrrrrrrrrrrr"),
|
||||||
|
"BR": ("gggggggggggg", "ggggyyyyyggg", "gggyybbbyygg", "ggyybbbbbyyg",
|
||||||
|
"ggyybbbbbyyg", "gggyybbbyygg", "ggggyyyyyggg", "gggggggggggg"),
|
||||||
|
"AU": ("BBBBBBBBBBBB", "BwBwBBBBBwBB", "BBBBBBBBBBBB", "BwBwBBBBBBBB",
|
||||||
|
"BBBBBBBBwBBB", "BBBBBBBBBBBB", "BBBBBBBBBwBB", "BBBBBBBBBBBB"),
|
||||||
|
"NZ": ("BBBBBBBBBBBB", "BwBwBBBBBrBB", "BBBBBBBBBBBB", "BwBwBBBBrBBB",
|
||||||
|
"BBBBBBBBBBrB", "BBBBBBBBBBBB", "BBBBBBBBBrBB", "BBBBBBBBBBBB"),
|
||||||
|
"CN": ("rrrrrrrrrrrr", "ryyrrrrrrrrr", "ryyryrrrrrrr", "rrrrrrrrrrrr",
|
||||||
|
"rrryrrrrrrrr", "rrrrrrrrrrrr", "rrrrrrrrrrrr", "rrrrrrrrrrrr"),
|
||||||
|
"KR": ("wwwwwwwwwwww", "wkwwwwwwwwkw", "wwwrrrwwwwww", "wwwrrbbwwwww",
|
||||||
|
"wwwwbbbwwwww", "wwwwwwwwwwww", "wkwwwwwwwwkw", "wwwwwwwwwwww"),
|
||||||
|
"IN": ("oooooooooooo", "oooooooooooo", "wwwwwwwwwwww", "wwwwwbbwwwww",
|
||||||
|
"wwwwwbbwwwww", "wwwwwwwwwwww", "gggggggggggg", "gggggggggggg"),
|
||||||
|
"TR": ("rrrrrrrrrrrr", "rrrwwwwrrrrr", "rrwwrrrwyrrr", "rrwrrrrryrrr",
|
||||||
|
"rrwrrrrryrrr", "rrwwrrrwyrrr", "rrrwwwwrrrrr", "rrrrrrrrrrrr"),
|
||||||
|
"GR": ("bbbbbwwwwwww", "wwwwwbbbbbbb", "bbbbbwwwwwww", "wwwbbbbbbbbb",
|
||||||
|
"bbbbbbbbbbbb", "wwwwwwwwwwww", "bbbbbbbbbbbb", "wwwwwwwwwwww"),
|
||||||
|
"PT": ("ggggggrrrrrr", "ggggggrrrrrr", "ggggyyrrrrrr", "ggggywrrrrrr",
|
||||||
|
"ggggywrrrrrr", "ggggyyrrrrrr", "ggggggrrrrrr", "ggggggrrrrrr"),
|
||||||
|
"ES": ("rrrrrrrrrrrr", "rrrrrrrrrrrr", "yyyyyyyyyyyy", "yyrryyyyyyyy",
|
||||||
|
"yyrryyyyyyyy", "yyyyyyyyyyyy", "rrrrrrrrrrrr", "rrrrrrrrrrrr"),
|
||||||
|
"IL": ("wwwwwwwwwwww", "bbbbbbbbbbbb", "wwwwwwwwwwww", "wwwwbbbbwwww",
|
||||||
|
"wwwwbbbbwwww", "wwwwwwwwwwww", "bbbbbbbbbbbb", "wwwwwwwwwwww"),
|
||||||
|
"AE": ("rrgggggggggg", "rrgggggggggg", "rrgggggggggg", "rrwwwwwwwwww",
|
||||||
|
"rrwwwwwwwwww", "rrkkkkkkkkkk", "rrkkkkkkkkkk", "rrkkkkkkkkkk"),
|
||||||
|
"QA": ("mmmmwwwwwwww", "mmmwwwwwwwww", "mmmmwwwwwwww", "mmmwwwwwwwww",
|
||||||
|
"mmmmwwwwwwww", "mmmwwwwwwwww", "mmmmwwwwwwww", "mmmwwwwwwwww"),
|
||||||
|
"SA": ("gggggggggggg", "gggggggggggg", "ggwwgwwgwwgg", "gggggggggggg",
|
||||||
|
"ggwwwwwwwwgg", "gggggggggggg", "gggggggggggg", "gggggggggggg"),
|
||||||
|
"ZA": ("rrrrrrrrrrrr", "rgrrrrrrrrrr", "wggggggggggg", "kkggwwwwwwww",
|
||||||
|
"kkggwwwwwwww", "wggggggggggg", "bgbbbbbbbbbb", "bbbbbbbbbbbb"),
|
||||||
|
"EG": ("rrrrrrrrrrrr", "rrrrrrrrrrrr", "wwwwwwwwwwww", "wwwwwyywwwww",
|
||||||
|
"wwwwwyywwwww", "wwwwwwwwwwww", "kkkkkkkkkkkk", "kkkkkkkkkkkk"),
|
||||||
|
"MA": ("rrrrrrrrrrrr", "rrrrrrrrrrrr", "rrrrrggrrrrr", "rrrrgrrgrrrr",
|
||||||
|
"rrrrgrrgrrrr", "rrrrrggrrrrr", "rrrrrrrrrrrr", "rrrrrrrrrrrr"),
|
||||||
|
"SG": ("rrrrrrrrrrrr", "rwwrrrrrrrrr", "rrrrrrrrrrrr", "wwwwwwwwwwww",
|
||||||
|
"wwwwwwwwwwww", "wwwwwwwwwwww", "wwwwwwwwwwww", "wwwwwwwwwwww"),
|
||||||
|
"MY": ("rrrrrrrrrrrr", "BBBBwwwwwwww", "BBBByyrrrrrr", "BBBBwwwwwwww",
|
||||||
|
"rrrrrrrrrrrr", "wwwwwwwwwwww", "rrrrrrrrrrrr", "wwwwwwwwwwww"),
|
||||||
|
"PH": ("bbbbbbbbbbbb", "wybbbbbbbbbb", "wwbbbbbbbbbb", "wwwbbbbbbbbb",
|
||||||
|
"wwwrrrrrrrrr", "wwrrrrrrrrrr", "wyrrrrrrrrrr", "rrrrrrrrrrrr"),
|
||||||
|
"TH": ("rrrrrrrrrrrr", "wwwwwwwwwwww", "bbbbbbbbbbbb", "bbbbbbbbbbbb",
|
||||||
|
"bbbbbbbbbbbb", "wwwwwwwwwwww", "rrrrrrrrrrrr", "rrrrrrrrrrrr"),
|
||||||
|
"PA": ("wwwwwwrrrrrr", "wbwwwwrrrrrr", "wwwwwwrrrrrr", "wwwwwwrrrrrr",
|
||||||
|
"rrrrrrwwwwww", "rrrrrrwwbwww", "rrrrrrwwwwww", "rrrrrrwwwwww"),
|
||||||
|
"KE": ("kkkkkkkkkkkk", "kkkkkkkkkkkk", "wwwwwwwwwwww", "rrrrrmmrrrrr",
|
||||||
|
"rrrrrmmrrrrr", "wwwwwwwwwwww", "gggggggggggg", "gggggggggggg"),
|
||||||
|
|
||||||
|
# -- bands, which most flags are --------------------------------------
|
||||||
|
"DE": _across("k", "r", "y"),
|
||||||
|
"NL": _across("r", "w", "b"),
|
||||||
|
"RU": _across("w", "b", "r"),
|
||||||
|
"AT": _across("r", "w", "r"),
|
||||||
|
"HU": _across("r", "w", "g"),
|
||||||
|
"BG": _across("w", "g", "r"),
|
||||||
|
"EE": _across("c", "k", "w"),
|
||||||
|
"LT": _across("y", "g", "r"),
|
||||||
|
"CO": _across("y", "b", "r"),
|
||||||
|
"VE": _across("y", "b", "r"),
|
||||||
|
"AR": _across("c", "w", "c"),
|
||||||
|
"SV": _across("b", "w", "b"),
|
||||||
|
"PL": _across("w", "r"),
|
||||||
|
"ID": _across("r", "w"),
|
||||||
|
"UA": _across("b", "y"),
|
||||||
|
"MC": _across("r", "w"),
|
||||||
|
"FR": _down("b", "w", "r"),
|
||||||
|
"IT": _down("g", "w", "r"),
|
||||||
|
"IE": _down("g", "w", "o"),
|
||||||
|
"BE": _down("k", "y", "r"),
|
||||||
|
"RO": _down("b", "y", "r"),
|
||||||
|
"MX": _down("g", "w", "r"),
|
||||||
|
"PE": _down("r", "w", "r"),
|
||||||
|
"NG": _down("g", "w", "g"),
|
||||||
|
"CI": _down("o", "w", "g"),
|
||||||
|
"TD": _down("b", "y", "r"),
|
||||||
|
"GN": _down("r", "y", "g"),
|
||||||
|
"ML": _down("g", "y", "r"),
|
||||||
|
"SN": _down("g", "y", "r"),
|
||||||
|
"DK": _nordic("r", "w"),
|
||||||
|
"NO": _nordic("r", "w", "b"),
|
||||||
|
"SE": _nordic("b", "y"),
|
||||||
|
"FI": _nordic("w", "b"),
|
||||||
|
"IS": _nordic("b", "w", "r"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def known(code: str) -> bool:
|
||||||
|
return (code or "").strip().upper() in FLAGS
|
||||||
|
|
||||||
|
|
||||||
|
def flag_for(code: str) -> tuple[str, ...] | None:
|
||||||
|
"""One flag as rows of colour letters, or None for a country not here."""
|
||||||
|
return FLAGS.get((code or "").strip().upper())
|
||||||
|
|
||||||
|
|
||||||
|
def pixels_for(code: str) -> np.ndarray | None:
|
||||||
|
"""One flag as an ``(8, 12, 3)`` array of bytes, or None."""
|
||||||
|
rows = flag_for(code)
|
||||||
|
if rows is None:
|
||||||
|
return None
|
||||||
|
out = np.zeros((FLAG_H, FLAG_W, 3), dtype=np.uint8)
|
||||||
|
for y, line in enumerate(rows):
|
||||||
|
for x, letter in enumerate(line[:FLAG_W]):
|
||||||
|
out[y, x] = COLOURS.get(letter, COLOURS["w"])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Where an airport is, when only its code is known
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Some routes arrive as nothing but a pair of ICAO airport codes, and those
|
||||||
|
# codes say which country the airport is in: the first letter or two is a
|
||||||
|
# region. Longest prefix wins, so that KSEA is the United States while KUL
|
||||||
|
# would not be -- ICAO codes are four letters and this only ever sees four.
|
||||||
|
_ICAO_PREFIX: dict[str, str] = {
|
||||||
|
"K": "US", "C": "CA", "MM": "MX", "MP": "PA", "MR": "CR", "MH": "HN",
|
||||||
|
"MG": "GT", "MD": "DO", "MK": "JM", "MU": "CU", "MY": "BS", "MT": "HT",
|
||||||
|
"EG": "GB", "EI": "IE", "EH": "NL", "EB": "BE", "ED": "DE", "ET": "DE",
|
||||||
|
"EK": "DK", "EN": "NO", "ES": "SE", "EF": "FI", "EP": "PL", "EL": "LU",
|
||||||
|
"EV": "LV", "EY": "LT", "EE": "EE", "BI": "IS", "BG": "GL",
|
||||||
|
"LF": "FR", "LI": "IT", "LE": "ES", "LP": "PT", "LS": "CH", "LO": "AT",
|
||||||
|
"LK": "CZ", "LZ": "SK", "LH": "HU", "LR": "RO", "LB": "BG", "LG": "GR",
|
||||||
|
"LT": "TR", "LY": "RS", "LD": "HR", "LJ": "SI", "LM": "MT", "LC": "CY",
|
||||||
|
"LA": "AL", "LU": "MD", "LQ": "BA", "LW": "MK",
|
||||||
|
"UK": "UA", "UM": "BY", "UA": "KZ", "UT": "UZ", "UG": "GE", "UD": "AM",
|
||||||
|
"UB": "AZ", "U": "RU",
|
||||||
|
"OM": "AE", "OT": "QA", "OE": "SA", "OB": "BH", "OK": "KW", "OO": "OM",
|
||||||
|
"OJ": "JO", "OL": "LB", "OS": "SY", "OI": "IR", "OR": "IQ", "OP": "PK",
|
||||||
|
"OA": "AF", "LL": "IL",
|
||||||
|
"HE": "EG", "HL": "LY", "HS": "SD", "HA": "ET", "HK": "KE", "HT": "TZ",
|
||||||
|
"HU": "UG", "HC": "SO", "HR": "RW", "FA": "ZA", "FQ": "MZ", "FL": "ZM",
|
||||||
|
"FV": "ZW", "FB": "BW", "FY": "NA", "FN": "AO", "FK": "CM", "FZ": "CD",
|
||||||
|
"DN": "NG", "DG": "GH", "DI": "CI", "DA": "DZ", "DT": "TN", "GM": "MA",
|
||||||
|
"GO": "SN", "GB": "GM", "GU": "GN", "GL": "LR",
|
||||||
|
"RJ": "JP", "RO": "JP", "RK": "KR", "RC": "TW", "RP": "PH", "Z": "CN",
|
||||||
|
"ZK": "KP", "ZM": "MN",
|
||||||
|
"VA": "IN", "VE": "IN", "VI": "IN", "VO": "IN", "VC": "LK", "VG": "BD",
|
||||||
|
"VN": "NP", "VT": "TH", "VV": "VN", "VD": "KH", "VL": "LA", "VY": "MM",
|
||||||
|
"VR": "MV",
|
||||||
|
"WS": "SG", "WM": "MY", "WB": "MY", "WA": "ID", "WI": "ID", "WR": "ID",
|
||||||
|
"WP": "TL", "WQ": "ID",
|
||||||
|
"Y": "AU", "NZ": "NZ", "NF": "FJ", "NV": "VU", "NC": "NC", "AY": "PG",
|
||||||
|
"SA": "AR", "SB": "BR", "SD": "BR", "SI": "BR", "SJ": "BR", "SW": "BR",
|
||||||
|
"SC": "CL", "SE": "EC", "SG": "PY", "SK": "CO", "SL": "BO", "SM": "SR",
|
||||||
|
"SO": "GF", "SP": "PE", "SU": "UY", "SV": "VE", "SY": "GY",
|
||||||
|
"TJ": "PR", "TT": "TT", "TB": "BB", "TX": "BM", "TA": "AG", "TN": "AW",
|
||||||
|
"PH": "US", "PA": "US", "PG": "GU",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def country_of_icao(code: str) -> str:
|
||||||
|
"""The country an ICAO airport code belongs to, or an empty string."""
|
||||||
|
code = (code or "").strip().upper()
|
||||||
|
if len(code) != 4 or not code.isalpha():
|
||||||
|
return ""
|
||||||
|
for length in (2, 1):
|
||||||
|
found = _ICAO_PREFIX.get(code[:length])
|
||||||
|
if found:
|
||||||
|
return found
|
||||||
|
return ""
|
||||||
|
|
@ -40,7 +40,8 @@ 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"]
|
"ground_for", "background", "label_lines", "draw_flag",
|
||||||
|
"FLAG", "flag_index"]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -56,12 +57,20 @@ RAMP = 8 # 32 altitude colours from here
|
||||||
TRAIL = RAMP + 32 # the same 32, dimmed, for the path just flown
|
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
|
||||||
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"
|
||||||
|
|
||||||
CEILING_FT = 45_000.0
|
CEILING_FT = 45_000.0
|
||||||
|
|
||||||
|
# Past this many aircraft on one frame, the labels go back to the callsign,
|
||||||
|
# the height and the speed. Five lines beside each of three hundred aircraft
|
||||||
|
# is not more information, it is less: a still of a whole day would be a page
|
||||||
|
# of overlapping text with a map somewhere behind it. An animation shows a
|
||||||
|
# handful at a time and never reaches this.
|
||||||
|
CROWDED = 14
|
||||||
|
|
||||||
# The most frames any one animation is worth: about four minutes at twelve a
|
# The most frames any one animation is worth: about four minutes at twelve a
|
||||||
# second, by which point a viewer has stopped watching and a GIF has stopped
|
# second, by which point a viewer has stopped watching and a GIF has stopped
|
||||||
# opening.
|
# opening.
|
||||||
|
|
@ -120,10 +129,24 @@ def _ground_shades(steps: int = GROUND_SHADES) -> list[tuple[int, int, int]]:
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _flag_colours() -> list[tuple[int, int, int]]:
|
||||||
|
"""The flag palette, in a fixed order so an index means one colour."""
|
||||||
|
from .flags import COLOURS, COLOUR_ORDER
|
||||||
|
|
||||||
|
return [COLOURS[letter] for letter in COLOUR_ORDER]
|
||||||
|
|
||||||
|
|
||||||
|
def flag_index(letter: str) -> int:
|
||||||
|
"""Where one of the flag colours lives in the palette."""
|
||||||
|
from .flags import COLOUR_ORDER
|
||||||
|
|
||||||
|
return FLAG + COLOUR_ORDER.index(letter)
|
||||||
|
|
||||||
|
|
||||||
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())
|
+ _ground_shades() + _flag_colours())
|
||||||
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)
|
||||||
|
|
||||||
|
|
@ -438,7 +461,7 @@ 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) -> np.ndarray:
|
project: bool = True, known=None) -> 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
|
||||||
|
|
@ -450,6 +473,10 @@ def render_frame(base: np.ndarray, view: Projection, tracks: list[Track],
|
||||||
"""
|
"""
|
||||||
img = base.copy()
|
img = base.copy()
|
||||||
flying = 0
|
flying = 0
|
||||||
|
# Counted before anything is drawn, so that every label on one frame says
|
||||||
|
# the same amount and the picture does not change its mind halfway down.
|
||||||
|
crowded = sum(1 for track in tracks
|
||||||
|
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 \
|
now = track.at(when, stale=stale) if project else \
|
||||||
|
|
@ -470,7 +497,9 @@ def render_frame(base: np.ndarray, view: Projection, tracks: list[Track],
|
||||||
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:
|
||||||
_label(img, x, y, track, now, colour, taken, unit)
|
_label(img, x, y, track, now, colour, taken, unit,
|
||||||
|
entry=(known.get(track.icao)
|
||||||
|
if known and not crowded else None))
|
||||||
if clock:
|
if clock:
|
||||||
_clock_strip(img, view, clock, flying)
|
_clock_strip(img, view, clock, flying)
|
||||||
return img
|
return img
|
||||||
|
|
@ -503,14 +532,55 @@ def _marker(img: np.ndarray, x: int, y: int, heading: float,
|
||||||
max(0, x - 1):min(width, x + 2)] = colour
|
max(0, x - 1):min(width, x + 2)] = colour
|
||||||
|
|
||||||
|
|
||||||
|
def label_lines(track: Track, now, unit: str = DEFAULT_SPEED_UNIT,
|
||||||
|
entry=None) -> list[tuple[str, str]]:
|
||||||
|
"""What goes beside one aircraft, a line at a time.
|
||||||
|
|
||||||
|
Each row is the text and the country whose flag belongs next to it -- a
|
||||||
|
two-letter code the drawing turns into twelve pixels of one, or empty for
|
||||||
|
a row that is only words.
|
||||||
|
|
||||||
|
The height is the flight level, hundreds of feet, the way it is said on
|
||||||
|
the radio: five digits beside every aircraft is a picture made of
|
||||||
|
numbers. The airports are their codes for the same reason, since a full
|
||||||
|
name runs to forty characters beside an aircraft twelve pixels across.
|
||||||
|
"""
|
||||||
|
rows: list[tuple[str, str]] = []
|
||||||
|
height = f"{now.altitude_ft // 100:03d}" if now.altitude_ft else ""
|
||||||
|
# The unit goes on the number, every time: a bare "480" beside an
|
||||||
|
# aircraft is three different speeds depending on who is reading it.
|
||||||
|
speed = (f"{in_speed(now.ground_speed_kt, unit):.0f}"
|
||||||
|
f"{speed_label(unit).upper()}") if now.ground_speed_kt else ""
|
||||||
|
line = " ".join(x for x in (height, speed) if x)
|
||||||
|
if line:
|
||||||
|
rows.append((line, ""))
|
||||||
|
if entry is None:
|
||||||
|
return rows
|
||||||
|
kind = entry.type_code or entry.model
|
||||||
|
if kind and entry.registration:
|
||||||
|
rows.append((f"{kind} {entry.registration}", ""))
|
||||||
|
elif kind or entry.registration:
|
||||||
|
rows.append((kind or entry.registration, ""))
|
||||||
|
if entry.origin_code or entry.origin:
|
||||||
|
rows.append((_short_place(entry.origin_code, entry.origin),
|
||||||
|
entry.origin_country))
|
||||||
|
if entry.destination_code or entry.destination:
|
||||||
|
rows.append((_short_place(entry.destination_code, entry.destination),
|
||||||
|
entry.destination_country))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _short_place(code: str, name: str) -> str:
|
||||||
|
"""An airport in as few characters as still say which one."""
|
||||||
|
if code:
|
||||||
|
return code
|
||||||
|
return " ".join((name or "").split()[:2])[:18]
|
||||||
|
|
||||||
|
|
||||||
def _label(img: np.ndarray, x: int, y: int, track: Track, now,
|
def _label(img: np.ndarray, x: int, y: int, track: Track, now,
|
||||||
colour: int, taken: list | None = None,
|
colour: int, taken: list | None = None,
|
||||||
unit: str = DEFAULT_SPEED_UNIT) -> None:
|
unit: str = DEFAULT_SPEED_UNIT, entry=None) -> None:
|
||||||
"""Who it is and how high, beside the aircraft.
|
"""Who it is, and everything else known about it, beside the aircraft.
|
||||||
|
|
||||||
The height is the flight level -- hundreds of feet, the way it is said
|
|
||||||
on the radio -- because five digits beside every aircraft is a picture
|
|
||||||
made of numbers.
|
|
||||||
|
|
||||||
Two aircraft that pass close together would otherwise have their labels
|
Two aircraft that pass close together would otherwise have their labels
|
||||||
written over each other, which is exactly the moment somebody is looking
|
written over each other, which is exactly the moment somebody is looking
|
||||||
|
|
@ -518,36 +588,79 @@ def _label(img: np.ndarray, x: int, y: int, track: Track, now,
|
||||||
in turn and the first clear one is used; when they are all taken the
|
in turn and the first clear one is used; when they are all taken the
|
||||||
label goes to the right anyway, because a label somewhere beats none.
|
label goes to the right anyway, because a label somewhere beats none.
|
||||||
"""
|
"""
|
||||||
|
from .flags import FLAG_W
|
||||||
|
|
||||||
height, width = img.shape
|
height, width = img.shape
|
||||||
name = track.name
|
rows = label_lines(track, now, unit, entry)
|
||||||
below = f"{now.altitude_ft // 100:03d}" if now.altitude_ft else ""
|
indent = FLAG_W + 3 if any(country for _, country in rows) else 0
|
||||||
if now.ground_speed_kt:
|
span = max([text_width(track.name)]
|
||||||
# The unit goes on the number, every time: a bare "480" beside an
|
+ [indent + text_width(text) for text, _ in rows])
|
||||||
# aircraft is three different speeds depending on who is reading it.
|
tall = GLYPH_H + (GLYPH_H + 2) * len(rows) + 1
|
||||||
below = (f"{below} {in_speed(now.ground_speed_kt, unit):.0f}"
|
def clear(at_x, at_y):
|
||||||
f"{speed_label(unit).upper()}").strip()
|
if at_x < 2 or at_x + span > width - 2:
|
||||||
span = max(text_width(name), text_width(below))
|
return None
|
||||||
tall = GLYPH_H * 2 + 3
|
if at_y < 1 or at_y + tall > height - 1:
|
||||||
places = ((x + 8, y - GLYPH_H - 1), # right, the usual place
|
return None
|
||||||
|
box = (at_x, at_y, at_x + span, at_y + tall)
|
||||||
|
if taken is not None and any(_overlaps(box, other) for other in taken):
|
||||||
|
return None
|
||||||
|
return int(at_x), int(at_y)
|
||||||
|
|
||||||
|
beside = ((x + 8, y - GLYPH_H - 1), # right, the usual place
|
||||||
(x - 8 - span, y - GLYPH_H - 1), # left
|
(x - 8 - span, y - GLYPH_H - 1), # left
|
||||||
(x - span // 2, y + 9), # under it
|
(x - span // 2, y + 9), # under it
|
||||||
(x - span // 2, y - tall - 6)) # over it
|
(x - span // 2, y - tall - 6)) # over it
|
||||||
left, top = places[0]
|
found = next((spot for spot in map(lambda p: clear(*p), beside) if spot),
|
||||||
for candidate_x, candidate_y in places:
|
None)
|
||||||
if candidate_x < 2 or candidate_x + span > width - 2:
|
if found is None:
|
||||||
continue
|
# The four places beside it are taken, which happens as soon as a few
|
||||||
if candidate_y < 1 or candidate_y + tall > height - 1:
|
# aircraft are close together. Working outwards buys a readable
|
||||||
continue
|
# label for the price of a longer look, which is the right trade.
|
||||||
box = (candidate_x, candidate_y, candidate_x + span, candidate_y + tall)
|
for reach in (1.3, 1.8, 2.5, 3.4):
|
||||||
if taken is None or not any(_overlaps(box, other) for other in taken):
|
for step in range(12):
|
||||||
left, top = candidate_x, candidate_y
|
angle = math.tau * step / 12
|
||||||
|
found = clear(x + math.cos(angle) * span * reach - span / 2,
|
||||||
|
y + math.sin(angle) * tall * reach - tall / 2)
|
||||||
|
if found:
|
||||||
break
|
break
|
||||||
left = max(2, min(left, width - 2 - span))
|
if found:
|
||||||
|
break
|
||||||
|
left, top = found if found else beside[0]
|
||||||
|
left = max(2, min(int(left), width - 2 - span))
|
||||||
|
top = max(1, min(int(top), height - 1 - tall))
|
||||||
if taken is not None:
|
if taken is not None:
|
||||||
taken.append((left, top, left + span, top + tall))
|
taken.append((left, top, left + span, top + tall))
|
||||||
draw_text(img, left, top, name, colour)
|
draw_text(img, left, top, track.name, colour)
|
||||||
if below:
|
at = top + GLYPH_H + 2
|
||||||
draw_text(img, left, top + GLYPH_H + 3, below, DIM)
|
for text, country in rows:
|
||||||
|
if country:
|
||||||
|
draw_flag(img, left, at - 1, country)
|
||||||
|
draw_text(img, left + indent, at, text, DIM)
|
||||||
|
at += GLYPH_H + 2
|
||||||
|
|
||||||
|
|
||||||
|
def draw_flag(img: np.ndarray, x: int, y: int, country: str) -> None:
|
||||||
|
"""Twelve pixels by eight of a flag, or the country's letters instead.
|
||||||
|
|
||||||
|
A country with no flag here is named rather than approximated: two
|
||||||
|
letters are never wrong, and a flag that is nearly another country's is
|
||||||
|
worse than no flag at all.
|
||||||
|
"""
|
||||||
|
from .flags import FLAG_W, flag_for
|
||||||
|
|
||||||
|
rows = flag_for(country)
|
||||||
|
if rows is None:
|
||||||
|
draw_text(img, x, y + 1, country[:2].upper(), GRID)
|
||||||
|
return
|
||||||
|
height, width = img.shape
|
||||||
|
for row, line in enumerate(rows):
|
||||||
|
yy = y + row
|
||||||
|
if not 0 <= yy < height:
|
||||||
|
continue
|
||||||
|
for column, letter in enumerate(line[:FLAG_W]):
|
||||||
|
xx = x + column
|
||||||
|
if 0 <= xx < width:
|
||||||
|
img[yy, xx] = flag_index(letter)
|
||||||
|
|
||||||
|
|
||||||
def _overlaps(a, b) -> bool:
|
def _overlaps(a, b) -> bool:
|
||||||
|
|
@ -773,17 +886,34 @@ def _span(seconds: float) -> str:
|
||||||
return f"{hours} h {minutes:02d} min"
|
return f"{hours} h {minutes:02d} min"
|
||||||
|
|
||||||
|
|
||||||
def _airports_from(book, tracks: list[Track]):
|
def _known_from(book, tracks: list[Track]) -> dict:
|
||||||
"""Every airport a route lookup gave a position for."""
|
"""What a register says about each aircraft, looked up once.
|
||||||
|
|
||||||
|
Once, rather than once a frame: a five-hundred-frame animation would
|
||||||
|
otherwise ask the same question five hundred times, and the book is
|
||||||
|
thread-safe rather than free.
|
||||||
|
"""
|
||||||
if book is None:
|
if book is None:
|
||||||
return []
|
return {}
|
||||||
|
return {track.icao: book.get(track.icao, track.callsign)
|
||||||
|
for track in tracks}
|
||||||
|
|
||||||
|
|
||||||
|
def _airports_from(known: dict):
|
||||||
|
"""Every airport a route lookup gave a position for.
|
||||||
|
|
||||||
|
Taken from what has already been looked up rather than asking again: the
|
||||||
|
book is thread-safe rather than free, and one question per aircraft is
|
||||||
|
the whole budget.
|
||||||
|
"""
|
||||||
seen: dict[str, tuple[str, float, float]] = {}
|
seen: dict[str, tuple[str, float, float]] = {}
|
||||||
for track in tracks:
|
for entry in known.values():
|
||||||
entry = book.get(track.icao, track.callsign)
|
for code, lat, lon in ((getattr(entry, "origin_code", ""),
|
||||||
for code, lat, lon in ((entry.origin_code, entry.origin_lat,
|
getattr(entry, "origin_lat", 0.0),
|
||||||
entry.origin_lon),
|
getattr(entry, "origin_lon", 0.0)),
|
||||||
(entry.destination_code, entry.destination_lat,
|
(getattr(entry, "destination_code", ""),
|
||||||
entry.destination_lon)):
|
getattr(entry, "destination_lat", 0.0),
|
||||||
|
getattr(entry, "destination_lon", 0.0))):
|
||||||
if code and (lat or lon):
|
if code and (lat or lon):
|
||||||
seen[code] = (code, lat, lon)
|
seen[code] = (code, lat, lon)
|
||||||
return list(seen.values())
|
return list(seen.values())
|
||||||
|
|
@ -860,11 +990,11 @@ def animate(tracks: list[Track], out_path, *, fps: float = 12.0,
|
||||||
speed = covers / max(1e-9, (frame_count - 1) / fps)
|
speed = covers / max(1e-9, (frame_count - 1) / fps)
|
||||||
day = datetime.fromtimestamp(start).strftime("%Y-%m-%d")
|
day = datetime.fromtimestamp(start).strftime("%Y-%m-%d")
|
||||||
heading = title or f"{len(located)} AIRCRAFT {day}"
|
heading = title or f"{len(located)} AIRCRAFT {day}"
|
||||||
|
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,
|
base = background(view, title=heading, airports=_airports_from(known),
|
||||||
airports=_airports_from(book, located), unit=unit,
|
unit=unit, ground=levels, attribution=credit)
|
||||||
ground=levels, attribution=credit)
|
|
||||||
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)
|
||||||
|
|
||||||
|
|
@ -875,7 +1005,7 @@ 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),
|
clock=clock, unit=unit, known=known),
|
||||||
canvas_w, canvas_h)
|
canvas_w, canvas_h)
|
||||||
|
|
||||||
path = Path(out_path)
|
path = Path(out_path)
|
||||||
|
|
@ -884,6 +1014,7 @@ def animate(tracks: list[Track], out_path, *, fps: float = 12.0,
|
||||||
# Not an animation at all: the whole log at once, every path drawn.
|
# Not an animation at all: the whole log at once, every path drawn.
|
||||||
still = render_frame(base, view, located, finish, stale=covers + 1,
|
still = render_frame(base, view, located, finish, stale=covers + 1,
|
||||||
labels=labels, unit=unit, project=False,
|
labels=labels, unit=unit, project=False,
|
||||||
|
known=known,
|
||||||
clock=datetime.fromtimestamp(finish)
|
clock=datetime.fromtimestamp(finish)
|
||||||
.strftime("%H:%M:%S"))
|
.strftime("%H:%M:%S"))
|
||||||
write_png(path, PALETTE[still])
|
write_png(path, PALETTE[still])
|
||||||
|
|
|
||||||
|
|
@ -39,8 +39,11 @@ 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}"
|
||||||
|
|
||||||
# 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.
|
# older version is ignored rather than misread. Version 2 added the country
|
||||||
CACHE_VERSION = 1
|
# each end of a route is in, which the flags on the map are drawn from: a
|
||||||
|
# version 1 route is not wrong, it is simply missing that, and re-asking is
|
||||||
|
# the only way to get it.
|
||||||
|
CACHE_VERSION = 2
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -332,10 +335,12 @@ class Flight:
|
||||||
origin: str = ""
|
origin: str = ""
|
||||||
origin_lat: float = 0.0
|
origin_lat: float = 0.0
|
||||||
origin_lon: float = 0.0
|
origin_lon: float = 0.0
|
||||||
|
origin_country: str = "" # two letters, for the flag beside it
|
||||||
destination_code: str = ""
|
destination_code: str = ""
|
||||||
destination: str = ""
|
destination: str = ""
|
||||||
destination_lat: float = 0.0
|
destination_lat: float = 0.0
|
||||||
destination_lon: float = 0.0
|
destination_lon: float = 0.0
|
||||||
|
destination_country: str = ""
|
||||||
status: str = "pending"
|
status: str = "pending"
|
||||||
fetched_at: float = 0.0
|
fetched_at: float = 0.0
|
||||||
version: int = CACHE_VERSION
|
version: int = CACHE_VERSION
|
||||||
|
|
@ -451,8 +456,11 @@ class FlightBook:
|
||||||
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("routes") or {}).items():
|
for key, body in (raw.get("routes") or {}).items():
|
||||||
if isinstance(body, dict) and \
|
if not isinstance(body, dict):
|
||||||
now - float(body.get("fetched_at") or 0) < self.max_age:
|
continue
|
||||||
|
if int(body.get("version") or 0) < CACHE_VERSION:
|
||||||
|
continue # written before the countries were
|
||||||
|
if now - float(body.get("fetched_at") or 0) < self.max_age:
|
||||||
self._routes[key] = body
|
self._routes[key] = body
|
||||||
|
|
||||||
def save(self) -> None:
|
def save(self) -> None:
|
||||||
|
|
@ -564,6 +572,7 @@ class FlightBook:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if found is not None:
|
if found is not None:
|
||||||
found["fetched_at"] = time.time()
|
found["fetched_at"] = time.time()
|
||||||
|
found["version"] = CACHE_VERSION
|
||||||
self._routes[entry.callsign] = found
|
self._routes[entry.callsign] = found
|
||||||
self._apply_route(entry, found)
|
self._apply_route(entry, found)
|
||||||
self._dirty = True
|
self._dirty = True
|
||||||
|
|
@ -606,14 +615,16 @@ class FlightBook:
|
||||||
entry.operator = str(body.get("RegisteredOwners") or "").strip()
|
entry.operator = str(body.get("RegisteredOwners") or "").strip()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _airport(record) -> tuple[str, str, float, float]:
|
def _airport(record) -> tuple[str, str, float, float, str]:
|
||||||
"""An airport as a code, a readable place and where it is.
|
"""An airport as a code, a readable place, where it is and whose.
|
||||||
|
|
||||||
The position is kept because a map can draw it: a route is two dots
|
The position is kept because a map can draw it: a route is two dots
|
||||||
and a line long before any aircraft is heard flying it.
|
and a line long before any aircraft is heard flying it. The country
|
||||||
|
is kept so a flag can be drawn beside the name, which says where a
|
||||||
|
flight came from faster than reading the name does.
|
||||||
"""
|
"""
|
||||||
if not isinstance(record, dict):
|
if not isinstance(record, dict):
|
||||||
return "", "", 0.0, 0.0
|
return "", "", 0.0, 0.0, ""
|
||||||
code = str(record.get("icao_code") or record.get("iata_code") or "")
|
code = str(record.get("icao_code") or record.get("iata_code") or "")
|
||||||
name = str(record.get("name") or "").strip()
|
name = str(record.get("name") or "").strip()
|
||||||
town = str(record.get("municipality") or "").strip()
|
town = str(record.get("municipality") or "").strip()
|
||||||
|
|
@ -625,7 +636,12 @@ class FlightBook:
|
||||||
lon = float(record.get("longitude") or 0.0)
|
lon = float(record.get("longitude") or 0.0)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
lat = lon = 0.0
|
lat = lon = 0.0
|
||||||
return code.strip(), (where or code).strip(), lat, lon
|
country = str(record.get("country_iso_name") or "").strip().upper()
|
||||||
|
if not country:
|
||||||
|
from .flags import country_of_icao
|
||||||
|
|
||||||
|
country = country_of_icao(code)
|
||||||
|
return code.strip(), (where or code).strip(), lat, lon, country
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _read_route(cls, body: dict) -> dict | None:
|
def _read_route(cls, body: dict) -> dict | None:
|
||||||
|
|
@ -639,8 +655,10 @@ class FlightBook:
|
||||||
return None
|
return None
|
||||||
return {"origin_code": start[0], "origin": start[1],
|
return {"origin_code": start[0], "origin": start[1],
|
||||||
"origin_lat": start[2], "origin_lon": start[3],
|
"origin_lat": start[2], "origin_lon": start[3],
|
||||||
|
"origin_country": start[4],
|
||||||
"destination_code": end[0], "destination": end[1],
|
"destination_code": end[0], "destination": end[1],
|
||||||
"destination_lat": end[2], "destination_lon": end[3],
|
"destination_lat": end[2], "destination_lon": end[3],
|
||||||
|
"destination_country": end[4],
|
||||||
"airline": str((record.get("airline") or {}).get("name") or "")}
|
"airline": str((record.get("airline") or {}).get("name") or "")}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
@ -651,8 +669,14 @@ class FlightBook:
|
||||||
if len(parts) < 2:
|
if len(parts) < 2:
|
||||||
return None
|
return None
|
||||||
# A multi-leg route lists every stop; the ends are what a map wants.
|
# A multi-leg route lists every stop; the ends are what a map wants.
|
||||||
|
# Nothing here says which country an airport is in, but its ICAO code
|
||||||
|
# does: the first letter or two is a region.
|
||||||
|
from .flags import country_of_icao
|
||||||
|
|
||||||
return {"origin_code": parts[0], "origin": parts[0],
|
return {"origin_code": parts[0], "origin": parts[0],
|
||||||
|
"origin_country": country_of_icao(parts[0]),
|
||||||
"destination_code": parts[-1], "destination": parts[-1],
|
"destination_code": parts[-1], "destination": parts[-1],
|
||||||
|
"destination_country": country_of_icao(parts[-1]),
|
||||||
"airline": ""}
|
"airline": ""}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
@ -663,9 +687,11 @@ class FlightBook:
|
||||||
entry.origin = str(route.get("origin") or "")
|
entry.origin = str(route.get("origin") or "")
|
||||||
entry.origin_lat = float(route.get("origin_lat") or 0.0)
|
entry.origin_lat = float(route.get("origin_lat") or 0.0)
|
||||||
entry.origin_lon = float(route.get("origin_lon") or 0.0)
|
entry.origin_lon = float(route.get("origin_lon") or 0.0)
|
||||||
|
entry.origin_country = str(route.get("origin_country") or "")
|
||||||
entry.destination_code = str(route.get("destination_code") or "")
|
entry.destination_code = str(route.get("destination_code") or "")
|
||||||
entry.destination = str(route.get("destination") or "")
|
entry.destination = str(route.get("destination") or "")
|
||||||
entry.destination_lat = float(route.get("destination_lat") or 0.0)
|
entry.destination_lat = float(route.get("destination_lat") or 0.0)
|
||||||
entry.destination_lon = float(route.get("destination_lon") or 0.0)
|
entry.destination_lon = float(route.get("destination_lon") or 0.0)
|
||||||
|
entry.destination_country = str(route.get("destination_country") or "")
|
||||||
if not entry.airline:
|
if not entry.airline:
|
||||||
entry.airline = str(route.get("airline") or "")
|
entry.airline = str(route.get("airline") or "")
|
||||||
|
|
|
||||||
918
bandsaunter/livemap.py
Normal file
918
bandsaunter/livemap.py
Normal file
|
|
@ -0,0 +1,918 @@
|
||||||
|
"""The sky in a window, while it is happening.
|
||||||
|
|
||||||
|
The terminal board says what is overhead; this says where. A real map, the
|
||||||
|
aircraft on it moving as the frames arrive, and beside each one a box with
|
||||||
|
everything known about it -- what it is, who flies it, where it came from,
|
||||||
|
how high, how fast, how far away and how long since it last said anything.
|
||||||
|
|
||||||
|
Qt is asked for and not required. Everything here is behind a lazy import,
|
||||||
|
so a machine with no Qt installed loses this window and nothing else: the
|
||||||
|
passive capture, the terminal board, the logs and the animations all work
|
||||||
|
exactly as they did. Four bindings are tried because distributions disagree
|
||||||
|
about which one to package, and the differences between them are two lines
|
||||||
|
of shim -- Qt 6 scopes its enumerations where Qt 5 did not, and PySide spells
|
||||||
|
Signal the way PyQt spells pyqtSignal.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from .flightlog import (DEFAULT_SPEED_UNIT, distance_label, distance_nm,
|
||||||
|
in_distance, in_speed, speed_label)
|
||||||
|
from .flightmap import Projection, altitude_step
|
||||||
|
from .ui import compass
|
||||||
|
|
||||||
|
# ``SkyView`` and ``Window`` are part of this module's interface too, but
|
||||||
|
# they are built on first use -- see __getattr__ at the foot of the file --
|
||||||
|
# so that importing it costs nothing on a machine with no Qt on it, and so
|
||||||
|
# they cannot be listed here.
|
||||||
|
__all__ = ["available", "binding", "show", "fetch_ground", "Sky", "Blip",
|
||||||
|
"blip_for", "place_box", "wrap_value", "WRAP_CHARS", "MISSING_QT"]
|
||||||
|
|
||||||
|
BINDINGS = ("PyQt6", "PyQt5", "PySide6", "PySide2")
|
||||||
|
|
||||||
|
MISSING_QT = (
|
||||||
|
"This needs Qt, which is not installed.\n"
|
||||||
|
" Debian/Ubuntu: sudo apt install python3-pyqt6\n"
|
||||||
|
" Fedora: sudo dnf install python3-pyqt6\n"
|
||||||
|
" Arch: sudo pacman -S python-pyqt6\n"
|
||||||
|
" or: pip install PyQt6\n"
|
||||||
|
"Everything else works without it: passive capture still records, and "
|
||||||
|
"`bandsaunter flights` still draws the map afterwards."
|
||||||
|
)
|
||||||
|
|
||||||
|
# How often the window redraws itself. Positions arrive about twice a second
|
||||||
|
# and nothing on the picture moves faster than an aeroplane, so this is
|
||||||
|
# generous already; a busy sky is drawn perfectly well at this rate.
|
||||||
|
REDRAW_MS = 200
|
||||||
|
|
||||||
|
# The trail behind each aircraft, in positions rather than seconds, because
|
||||||
|
# it is drawn every frame and wants a hard bound.
|
||||||
|
TRAIL_POINTS = 400
|
||||||
|
|
||||||
|
# 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
|
||||||
|
# own and a route is two of them, so one flight from Los Angeles to Dallas
|
||||||
|
# Fort Worth would otherwise make its box wider than the map it sits on.
|
||||||
|
WRAP_CHARS = 30
|
||||||
|
|
||||||
|
# The flag is drawn at its own size: the box font is about eleven pixels
|
||||||
|
# tall, so twelve by eight sits on a line without crowding it.
|
||||||
|
FLAG_PIXELS = 12
|
||||||
|
|
||||||
|
|
||||||
|
def _qt():
|
||||||
|
"""The first Qt binding that imports, or None.
|
||||||
|
|
||||||
|
Cached on the function, because working this out on every paint would be
|
||||||
|
silly and because the answer cannot change while the program runs.
|
||||||
|
"""
|
||||||
|
if getattr(_qt, "_found", "?") != "?":
|
||||||
|
return _qt._found
|
||||||
|
for name in BINDINGS:
|
||||||
|
try:
|
||||||
|
core = __import__(f"{name}.QtCore", fromlist=["QtCore"])
|
||||||
|
gui = __import__(f"{name}.QtGui", fromlist=["QtGui"])
|
||||||
|
widgets = __import__(f"{name}.QtWidgets", fromlist=["QtWidgets"])
|
||||||
|
except ImportError:
|
||||||
|
continue
|
||||||
|
signal = getattr(core, "pyqtSignal", None) or getattr(core, "Signal")
|
||||||
|
_qt._found = (name, core, gui, widgets, signal)
|
||||||
|
return _qt._found
|
||||||
|
_qt._found = None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def available() -> bool:
|
||||||
|
"""Whether a window can be opened at all."""
|
||||||
|
return _qt() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def binding() -> str:
|
||||||
|
"""Which Qt is being used, for saying so on the screen."""
|
||||||
|
found = _qt()
|
||||||
|
return found[0] if found else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _enum(owner, group: str, name: str):
|
||||||
|
"""Qt 6 scopes its enumerations; Qt 5 hangs them on the class."""
|
||||||
|
return getattr(getattr(owner, group, owner), name)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# What the window is told about one aircraft
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Blip:
|
||||||
|
"""One aircraft, as far as the window is concerned.
|
||||||
|
|
||||||
|
A flat copy rather than the live object, taken on the listening thread:
|
||||||
|
the registry is being written to as fast as frames arrive, and painting
|
||||||
|
from underneath that is how a display ends up drawing half of one
|
||||||
|
aircraft and half of another.
|
||||||
|
"""
|
||||||
|
|
||||||
|
icao: str = ""
|
||||||
|
callsign: str = ""
|
||||||
|
latitude: float = 0.0
|
||||||
|
longitude: float = 0.0
|
||||||
|
altitude_ft: int = 0
|
||||||
|
ground_speed_kt: float = 0.0
|
||||||
|
track_deg: float = 0.0
|
||||||
|
vertical_rate_fpm: int = 0
|
||||||
|
messages: int = 0
|
||||||
|
first_seen: float = 0.0
|
||||||
|
last_seen: float = 0.0
|
||||||
|
# Everything below comes from a register rather than off the air.
|
||||||
|
registration: str = ""
|
||||||
|
type_code: str = ""
|
||||||
|
model: str = ""
|
||||||
|
manufacturer: str = ""
|
||||||
|
operator: str = ""
|
||||||
|
country: str = ""
|
||||||
|
origin: str = ""
|
||||||
|
origin_country: str = "" # two letters, for the flag beside it
|
||||||
|
destination: str = ""
|
||||||
|
destination_country: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def located(self) -> bool:
|
||||||
|
return bool(self.latitude or self.longitude)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return self.callsign or self.icao
|
||||||
|
|
||||||
|
def lines(self, unit: str, home=None) -> list[tuple[str, str, str]]:
|
||||||
|
"""The box's contents: a label, a value and a flag, a line at a time.
|
||||||
|
|
||||||
|
The flag is a two-letter country code the drawing turns into twelve
|
||||||
|
pixels of one, and is empty for every line that is only words.
|
||||||
|
|
||||||
|
Everything that is known and nothing that is not -- an empty line for
|
||||||
|
a register that has not answered yet would leave a box full of gaps
|
||||||
|
that never fill in.
|
||||||
|
"""
|
||||||
|
out: list[tuple[str, str, str]] = []
|
||||||
|
kind = " ".join(x for x in (self.manufacturer, self.model) if x)
|
||||||
|
if self.type_code and self.registration:
|
||||||
|
out.append((self.type_code, self.registration, ""))
|
||||||
|
elif self.registration:
|
||||||
|
out.append(("reg", self.registration, ""))
|
||||||
|
if kind:
|
||||||
|
out.append(("", kind, ""))
|
||||||
|
if self.operator:
|
||||||
|
out.append(("", self.operator, ""))
|
||||||
|
# The two ends of the flight on their own lines rather than joined by
|
||||||
|
# an arrow, so that each can carry the flag of the country it is in.
|
||||||
|
if self.origin:
|
||||||
|
out.append(("from", self.origin, self.origin_country))
|
||||||
|
if self.destination:
|
||||||
|
out.append(("to", self.destination, self.destination_country))
|
||||||
|
if self.altitude_ft:
|
||||||
|
climb = ""
|
||||||
|
if self.vertical_rate_fpm > 100:
|
||||||
|
climb = f" ↑{self.vertical_rate_fpm:,} fpm"
|
||||||
|
elif self.vertical_rate_fpm < -100:
|
||||||
|
climb = f" ↓{abs(self.vertical_rate_fpm):,} fpm"
|
||||||
|
out.append(("alt", f"{self.altitude_ft:,} ft{climb}", ""))
|
||||||
|
if self.ground_speed_kt:
|
||||||
|
out.append(("gs", f"{in_speed(self.ground_speed_kt, unit):.0f} "
|
||||||
|
f"{speed_label(unit)} "
|
||||||
|
f"{self.track_deg:03.0f}° "
|
||||||
|
f"{compass(self.track_deg)}", ""))
|
||||||
|
if self.located and home is not None:
|
||||||
|
away = distance_nm(home[0], home[1], self.latitude, self.longitude)
|
||||||
|
out.append(("range", f"{in_distance(away, unit):.0f} "
|
||||||
|
f"{distance_label(unit)} "
|
||||||
|
f"{self._bearing(home):03.0f}°", ""))
|
||||||
|
if self.located:
|
||||||
|
out.append(("pos", f"{self.latitude:.4f}, "
|
||||||
|
f"{self.longitude:.4f}", ""))
|
||||||
|
age = max(0.0, time.time() - self.last_seen)
|
||||||
|
out.append(("", f"{self.messages:,} frames {age:.0f}s ago", ""))
|
||||||
|
if self.country:
|
||||||
|
out.append(("", self.country, ""))
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _bearing(self, home) -> float:
|
||||||
|
p1, p2 = math.radians(home[0]), math.radians(self.latitude)
|
||||||
|
dl = math.radians(self.longitude - home[1])
|
||||||
|
y = math.sin(dl) * math.cos(p2)
|
||||||
|
x = math.cos(p1) * math.sin(p2) - math.sin(p1) * math.cos(p2) * math.cos(dl)
|
||||||
|
return math.degrees(math.atan2(y, x)) % 360.0
|
||||||
|
|
||||||
|
|
||||||
|
def wrap_value(text: str, width: int = WRAP_CHARS) -> list[str]:
|
||||||
|
"""Fold one line of a box onto as many lines as it needs.
|
||||||
|
|
||||||
|
A route is broken at the arrow before anything else, so that the two ends
|
||||||
|
of the flight stay whole and sit under one another where they can be read
|
||||||
|
as a pair. Everything else is folded between words, and a single word
|
||||||
|
longer than the whole width -- which is nothing an aircraft has ever sent,
|
||||||
|
but is exactly the sort of thing a register will one day return -- is cut
|
||||||
|
rather than allowed to widen the box on its own.
|
||||||
|
"""
|
||||||
|
text = (text or "").strip()
|
||||||
|
if len(text) <= width:
|
||||||
|
return [text]
|
||||||
|
if " → " in text:
|
||||||
|
origin, destination = text.split(" → ", 1)
|
||||||
|
folded = wrap_value(origin, width)
|
||||||
|
onward = wrap_value(destination, max(4, width - 2))
|
||||||
|
return folded + [f"→ {piece}" if i == 0 else f" {piece}"
|
||||||
|
for i, piece in enumerate(onward)]
|
||||||
|
out: list[str] = []
|
||||||
|
line = ""
|
||||||
|
for word in text.split():
|
||||||
|
while len(word) > width:
|
||||||
|
if line:
|
||||||
|
out.append(line)
|
||||||
|
line = ""
|
||||||
|
out.append(word[:width])
|
||||||
|
word = word[width:]
|
||||||
|
if line and len(line) + 1 + len(word) > width:
|
||||||
|
out.append(line)
|
||||||
|
line = word
|
||||||
|
else:
|
||||||
|
line = f"{line} {word}".strip()
|
||||||
|
if line:
|
||||||
|
out.append(line)
|
||||||
|
return out or [text]
|
||||||
|
|
||||||
|
|
||||||
|
def blip_for(craft, entry=None) -> Blip:
|
||||||
|
"""One aircraft as the window wants it, with whatever a register said."""
|
||||||
|
blip = Blip(icao=craft.icao, callsign=craft.callsign,
|
||||||
|
latitude=craft.latitude, longitude=craft.longitude,
|
||||||
|
altitude_ft=craft.altitude_ft,
|
||||||
|
ground_speed_kt=craft.ground_speed_kt,
|
||||||
|
track_deg=craft.track_deg,
|
||||||
|
vertical_rate_fpm=craft.vertical_rate_fpm,
|
||||||
|
messages=craft.messages, first_seen=craft.first_seen,
|
||||||
|
last_seen=craft.last_seen)
|
||||||
|
if entry is not None:
|
||||||
|
blip.registration = entry.registration
|
||||||
|
blip.type_code = entry.type_code
|
||||||
|
blip.model = entry.model
|
||||||
|
blip.manufacturer = entry.manufacturer
|
||||||
|
blip.operator = entry.operator or entry.airline
|
||||||
|
blip.country = entry.owner_country or entry.country
|
||||||
|
blip.origin = entry.origin or entry.origin_code
|
||||||
|
blip.origin_country = entry.origin_country
|
||||||
|
blip.destination = entry.destination or entry.destination_code
|
||||||
|
blip.destination_country = entry.destination_country
|
||||||
|
return blip
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# What the listening thread and the painting thread share
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class Sky:
|
||||||
|
"""Everything the window draws, written by one thread and read by another.
|
||||||
|
|
||||||
|
Plain locking rather than Qt's signals, so that none of this depends on
|
||||||
|
which binding was found -- and so that the whole of it can be tested
|
||||||
|
without a window at all.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, unit: str = DEFAULT_SPEED_UNIT, hold: float = 45.0,
|
||||||
|
home=None, radius_nm: float = 100.0):
|
||||||
|
import threading
|
||||||
|
|
||||||
|
self.unit = unit
|
||||||
|
self.hold = hold
|
||||||
|
self.home = home
|
||||||
|
self.radius_nm = radius_nm
|
||||||
|
self.frames = 0
|
||||||
|
self.aircraft_seen = 0
|
||||||
|
self.started = time.time()
|
||||||
|
self.log_name = ""
|
||||||
|
self.note = ""
|
||||||
|
self.stopping = False
|
||||||
|
# Set when the listening has finished of its own accord -- the time
|
||||||
|
# asked for has run out, or the receiver stopped. The window shuts
|
||||||
|
# itself when it sees this, so that "listen for ten minutes" means
|
||||||
|
# the same thing whether or not there is a window open.
|
||||||
|
self.finished = False
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._blips: dict[str, Blip] = {}
|
||||||
|
self._trails: dict[str, deque] = {}
|
||||||
|
self._ground = None
|
||||||
|
self._ground_for = None
|
||||||
|
self._wanted = None
|
||||||
|
|
||||||
|
# -- written by the listener ------------------------------------------
|
||||||
|
def update(self, blips, frames: int, seen: int) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self.frames = frames
|
||||||
|
self.aircraft_seen = seen
|
||||||
|
for blip in blips:
|
||||||
|
self._blips[blip.icao] = blip
|
||||||
|
if blip.located:
|
||||||
|
trail = self._trails.setdefault(
|
||||||
|
blip.icao, deque(maxlen=TRAIL_POINTS))
|
||||||
|
here = (blip.latitude, blip.longitude, blip.altitude_ft)
|
||||||
|
if not trail or trail[-1][:2] != here[:2]:
|
||||||
|
trail.append(here)
|
||||||
|
|
||||||
|
def set_ground(self, levels, key) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._ground, self._ground_for = levels, key
|
||||||
|
if self._wanted is not None and self._wanted[0] == key:
|
||||||
|
self._wanted = None
|
||||||
|
|
||||||
|
def want_ground(self, key, box, size) -> None:
|
||||||
|
"""Say which map is needed. Painting must never wait on a network."""
|
||||||
|
with self._lock:
|
||||||
|
if self._ground_for != key:
|
||||||
|
self._wanted = (key, box, size)
|
||||||
|
|
||||||
|
def wanted_ground(self):
|
||||||
|
with self._lock:
|
||||||
|
return self._wanted
|
||||||
|
|
||||||
|
# -- read by the window -----------------------------------------------
|
||||||
|
def flying(self, now: float | None = None) -> list[Blip]:
|
||||||
|
"""What is overhead, oldest first heard at the top.
|
||||||
|
|
||||||
|
An aircraft nothing has been heard from for a while has gone out of
|
||||||
|
range; it stays in the log and leaves the picture.
|
||||||
|
"""
|
||||||
|
now = time.time() if now is None else now
|
||||||
|
with self._lock:
|
||||||
|
out = [b for b in self._blips.values()
|
||||||
|
if now - b.last_seen <= self.hold and b.located]
|
||||||
|
out.sort(key=lambda b: (b.first_seen, b.icao))
|
||||||
|
return out
|
||||||
|
|
||||||
|
def trail(self, icao: str) -> list:
|
||||||
|
with self._lock:
|
||||||
|
return list(self._trails.get(icao, ()))
|
||||||
|
|
||||||
|
def ground(self, key):
|
||||||
|
"""The map underneath, if the one we have is for this view."""
|
||||||
|
with self._lock:
|
||||||
|
return self._ground if self._ground_for == key else None
|
||||||
|
|
||||||
|
def centre(self):
|
||||||
|
"""Where to put the middle of the map.
|
||||||
|
|
||||||
|
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
|
||||||
|
one bad position cannot move it.
|
||||||
|
"""
|
||||||
|
if self.home is not None:
|
||||||
|
return self.home
|
||||||
|
with self._lock:
|
||||||
|
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)
|
||||||
|
if not lats:
|
||||||
|
return None
|
||||||
|
return lats[len(lats) // 2], lons[len(lons) // 2]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Laying the boxes out
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _clear(bx: int, by: int, width: int, height: int, taken: list) -> bool:
|
||||||
|
return not any(bx < t[0] + t[2] and t[0] < bx + width
|
||||||
|
and by < t[1] + t[3] and t[1] < by + height for t in taken)
|
||||||
|
|
||||||
|
|
||||||
|
def place_box(x: int, y: int, width: int, height: int, taken: list,
|
||||||
|
bounds: tuple[int, int, int, int]) -> tuple[int, int]:
|
||||||
|
"""Where to put an aircraft's box so that it covers nothing that matters.
|
||||||
|
|
||||||
|
The eight places beside the symbol are tried first, because a box next
|
||||||
|
to the thing it describes needs no explaining. When those are taken --
|
||||||
|
which happens as soon as a few aircraft are close together -- it works
|
||||||
|
outwards in rings, accepting a longer leader line in exchange for a box
|
||||||
|
that can be read. Only when the screen is genuinely full does it give
|
||||||
|
up and overlap, since a box in an awkward place still says what the
|
||||||
|
aircraft is and no box at all does not.
|
||||||
|
"""
|
||||||
|
left, top, right, bottom = bounds
|
||||||
|
gap = 14
|
||||||
|
|
||||||
|
def fits(bx, by):
|
||||||
|
bx = max(left, min(int(bx), right - width))
|
||||||
|
by = max(top, min(int(by), bottom - height))
|
||||||
|
return (bx, by) if _clear(bx, by, width, height, taken) else None
|
||||||
|
|
||||||
|
beside = ((x + gap, y - height // 2), (x - gap - width, y - height // 2),
|
||||||
|
(x + gap, y + gap), (x - gap - width, y + gap),
|
||||||
|
(x + gap, y - gap - height), (x - gap - width, y - gap - height),
|
||||||
|
(x - width // 2, y + gap), (x - width // 2, y - gap - height))
|
||||||
|
for spot in beside:
|
||||||
|
found = fits(*spot)
|
||||||
|
if found:
|
||||||
|
return found
|
||||||
|
for reach in (1.4, 1.9, 2.6, 3.4, 4.4, 5.6):
|
||||||
|
for step in range(16):
|
||||||
|
angle = math.tau * step / 16
|
||||||
|
found = fits(x + math.cos(angle) * width * reach - width / 2,
|
||||||
|
y + math.sin(angle) * height * reach - height / 2)
|
||||||
|
if found:
|
||||||
|
return found
|
||||||
|
return (max(left, min(x + gap, right - width)),
|
||||||
|
max(top, min(y - height // 2, bottom - height)))
|
||||||
|
|
||||||
|
|
||||||
|
def _build():
|
||||||
|
"""Define the window classes, now that we know Qt is here."""
|
||||||
|
if getattr(_build, "_made", None) is not None:
|
||||||
|
return _build._made
|
||||||
|
found = _qt()
|
||||||
|
if found is None:
|
||||||
|
return None
|
||||||
|
_name, QtCore, QtGui, QtWidgets, _signal = found
|
||||||
|
|
||||||
|
from .flightmap import (BG, GRID, GROUND, GROUND_SHADES, INK, PALETTE,
|
||||||
|
RAMP, _degrees, _grid_step)
|
||||||
|
|
||||||
|
Qt = QtCore.Qt
|
||||||
|
QPointF, QRectF, QTimer = QtCore.QPointF, QtCore.QRectF, QtCore.QTimer
|
||||||
|
QColor, QFont, QImage = QtGui.QColor, QtGui.QFont, QtGui.QImage
|
||||||
|
QPainter, QPen, QPolygonF = QtGui.QPainter, QtGui.QPen, QtGui.QPolygonF
|
||||||
|
|
||||||
|
NO_PEN = _enum(Qt, "PenStyle", "NoPen")
|
||||||
|
DOTTED = _enum(Qt, "PenStyle", "DotLine")
|
||||||
|
RGB888 = _enum(QImage, "Format", "Format_RGB888")
|
||||||
|
ANTIALIAS = _enum(QPainter, "RenderHint", "Antialiasing")
|
||||||
|
MONOSPACE = _enum(QFont, "StyleHint", "Monospace")
|
||||||
|
KEY_Q = _enum(Qt, "Key", "Key_Q")
|
||||||
|
KEY_ESCAPE = _enum(Qt, "Key", "Key_Escape")
|
||||||
|
|
||||||
|
def rgb(index, alpha=255) -> QColor:
|
||||||
|
r, g, b = (int(v) for v in PALETTE[index])
|
||||||
|
return QColor(r, g, b, alpha)
|
||||||
|
|
||||||
|
def craft_colour(feet, alpha=255) -> QColor:
|
||||||
|
return rgb(RAMP + altitude_step(feet), alpha)
|
||||||
|
|
||||||
|
class SkyView(QtWidgets.QWidget):
|
||||||
|
"""The map, the aircraft on it, and a box beside each one."""
|
||||||
|
|
||||||
|
def __init__(self, sky: Sky, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.sky = sky
|
||||||
|
self.detail = 2 # 2 full, 1 compact, 0 symbol only
|
||||||
|
self.trails = True
|
||||||
|
self.show_ground = True
|
||||||
|
self._ground_pixels = None # kept alive for the QImage
|
||||||
|
self.setMinimumSize(480, 360)
|
||||||
|
small = QFont()
|
||||||
|
small.setStyleHint(MONOSPACE)
|
||||||
|
small.setFamily("monospace")
|
||||||
|
small.setPointSize(8)
|
||||||
|
self.text_font = small
|
||||||
|
head = QFont(small)
|
||||||
|
head.setBold(True)
|
||||||
|
self.head_font = head
|
||||||
|
|
||||||
|
# -- geometry -----------------------------------------------------
|
||||||
|
def projection(self) -> Projection | None:
|
||||||
|
"""The piece of the world the window is showing."""
|
||||||
|
from .flightlog import box_around
|
||||||
|
|
||||||
|
middle = self.sky.centre()
|
||||||
|
if middle is None:
|
||||||
|
return None
|
||||||
|
south, west, north, east = box_around(middle[0], middle[1],
|
||||||
|
self.sky.radius_nm)
|
||||||
|
width, height = max(1, self.width()), max(1, self.height())
|
||||||
|
# The box is as tall as it is wide in miles; the window is not,
|
||||||
|
# so the shorter side decides the scale and the longer one shows
|
||||||
|
# more of the world than was asked for.
|
||||||
|
across = (east - west) * math.cos(math.radians(middle[0]))
|
||||||
|
down = north - south
|
||||||
|
if width / max(1e-9, across) < height / max(1e-9, down):
|
||||||
|
grow = (width / height) * down / max(1e-9, across)
|
||||||
|
middle_lon = (west + east) / 2
|
||||||
|
half = (east - west) * grow / 2
|
||||||
|
west, east = middle_lon - half, middle_lon + half
|
||||||
|
else:
|
||||||
|
grow = (height / width) * across / max(1e-9, down)
|
||||||
|
middle_lat = (south + north) / 2
|
||||||
|
half = (north - south) * grow / 2
|
||||||
|
south, north = middle_lat - half, middle_lat + half
|
||||||
|
return Projection(south=south, west=west, north=north, east=east,
|
||||||
|
left=0, top=0, width=width, height=height)
|
||||||
|
|
||||||
|
def ground_key(self, view: Projection):
|
||||||
|
"""What the map underneath was fetched for, rounded so that a
|
||||||
|
pixel of drift does not throw it away."""
|
||||||
|
return (round(view.south, 3), round(view.west, 3),
|
||||||
|
round(view.north, 3), round(view.east, 3),
|
||||||
|
view.width, view.height)
|
||||||
|
|
||||||
|
# -- painting -----------------------------------------------------
|
||||||
|
def paintEvent(self, event) -> None:
|
||||||
|
painter = QPainter(self)
|
||||||
|
painter.setRenderHint(ANTIALIAS, True)
|
||||||
|
painter.fillRect(self.rect(), rgb(BG))
|
||||||
|
view = self.projection()
|
||||||
|
if view is None:
|
||||||
|
self._draw_waiting(painter)
|
||||||
|
self._draw_header(painter, 0)
|
||||||
|
painter.end()
|
||||||
|
return
|
||||||
|
if self.show_ground:
|
||||||
|
self._draw_ground(painter, view)
|
||||||
|
self._draw_graticule(painter, view)
|
||||||
|
flying = self.sky.flying()
|
||||||
|
if self.trails:
|
||||||
|
for blip in flying:
|
||||||
|
self._draw_trail(painter, view, blip)
|
||||||
|
# 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
|
||||||
|
# aircraft -- which is the one thing on the picture that has to
|
||||||
|
# stay visible.
|
||||||
|
placed = [(blip, view.xy(blip.latitude, blip.longitude))
|
||||||
|
for blip in flying]
|
||||||
|
taken: list[tuple[int, int, int, int]] = []
|
||||||
|
for blip, (x, y) in placed:
|
||||||
|
self._draw_symbol(painter, x, y, blip.track_deg,
|
||||||
|
craft_colour(blip.altitude_ft))
|
||||||
|
taken.append((x - 11, y - 11, 22, 22))
|
||||||
|
for blip, (x, y) in placed:
|
||||||
|
self._draw_aircraft(painter, view, blip, x, y, taken)
|
||||||
|
self._draw_scale(painter, view)
|
||||||
|
self._draw_header(painter, len(flying))
|
||||||
|
painter.end()
|
||||||
|
|
||||||
|
def _draw_waiting(self, painter) -> None:
|
||||||
|
painter.setFont(self.text_font)
|
||||||
|
painter.setPen(rgb(INK))
|
||||||
|
painter.drawText(self.rect(), _enum(Qt, "AlignmentFlag",
|
||||||
|
"AlignCenter"),
|
||||||
|
"listening on 1090 MHz\n\n"
|
||||||
|
"nothing placed yet — an aircraft is on the map "
|
||||||
|
"once an even\nand an odd position frame have "
|
||||||
|
"both arrived")
|
||||||
|
|
||||||
|
def _draw_ground(self, painter, view) -> None:
|
||||||
|
key = self.ground_key(view)
|
||||||
|
levels = self.sky.ground(key)
|
||||||
|
if levels is None:
|
||||||
|
# Ask for it and carry on drawing. The map arrives when it
|
||||||
|
# arrives; the aircraft are the part that cannot wait.
|
||||||
|
self.sky.want_ground(
|
||||||
|
key, (view.south, view.west, view.north, view.east),
|
||||||
|
(view.width, view.height))
|
||||||
|
return
|
||||||
|
shades = np.clip(np.asarray(levels), 0, GROUND_SHADES - 1)
|
||||||
|
pixels = np.ascontiguousarray(
|
||||||
|
PALETTE[GROUND + shades].astype(np.uint8))
|
||||||
|
self._ground_pixels = pixels # QImage does not copy it
|
||||||
|
height, width = pixels.shape[0], pixels.shape[1]
|
||||||
|
image = QImage(pixels.data, width, height, 3 * width, RGB888)
|
||||||
|
painter.drawImage(0, 0, image)
|
||||||
|
|
||||||
|
def _draw_graticule(self, painter, view) -> None:
|
||||||
|
pen = QPen(rgb(GRID))
|
||||||
|
pen.setStyle(DOTTED)
|
||||||
|
painter.setPen(pen)
|
||||||
|
painter.setFont(self.text_font)
|
||||||
|
step = _grid_step(view.north - view.south)
|
||||||
|
lat = math.ceil(view.south / step) * step
|
||||||
|
while lat <= view.north:
|
||||||
|
_, y = view.xy(lat, view.west)
|
||||||
|
painter.drawLine(0, y, self.width(), y)
|
||||||
|
lat += step
|
||||||
|
step = _grid_step(view.east - view.west)
|
||||||
|
lon = math.ceil(view.west / step) * step
|
||||||
|
while lon <= view.east:
|
||||||
|
x, _ = view.xy(view.south, lon)
|
||||||
|
painter.drawLine(x, 0, x, self.height())
|
||||||
|
lon += step
|
||||||
|
painter.setPen(rgb(GRID))
|
||||||
|
step = _grid_step(view.north - view.south)
|
||||||
|
lat = math.ceil(view.south / step) * step
|
||||||
|
while lat <= view.north:
|
||||||
|
_, y = view.xy(lat, view.west)
|
||||||
|
painter.drawText(4, y - 3, _degrees(lat, "lat"))
|
||||||
|
lat += step
|
||||||
|
step = _grid_step(view.east - view.west)
|
||||||
|
lon = math.ceil(view.west / step) * step
|
||||||
|
while lon <= view.east:
|
||||||
|
x, _ = view.xy(view.south, lon)
|
||||||
|
painter.drawText(x + 3, self.height() - 24,
|
||||||
|
_degrees(lon, "lon"))
|
||||||
|
lon += step
|
||||||
|
|
||||||
|
def _draw_trail(self, painter, view, blip: Blip) -> None:
|
||||||
|
points = self.sky.trail(blip.icao)
|
||||||
|
if len(points) < 2:
|
||||||
|
return
|
||||||
|
painter.setPen(QPen(craft_colour(blip.altitude_ft, 90), 1.4))
|
||||||
|
path = QPolygonF([QPointF(*view.xy(lat, lon))
|
||||||
|
for lat, lon, _ in points])
|
||||||
|
painter.drawPolyline(path)
|
||||||
|
|
||||||
|
def _draw_aircraft(self, painter, view, blip: Blip, x, y,
|
||||||
|
taken) -> None:
|
||||||
|
if self.detail <= 0:
|
||||||
|
return
|
||||||
|
colour = craft_colour(blip.altitude_ft)
|
||||||
|
lines = self._wrapped(self._box_lines(blip))
|
||||||
|
box = self._box_size(lines)
|
||||||
|
bx, by = place_box(x, y, box[0], box[1], taken,
|
||||||
|
(2, self._header_height() + 2,
|
||||||
|
self.width() - 2, self.height() - 26))
|
||||||
|
taken.append((bx, by, box[0], box[1]))
|
||||||
|
# To the near edge of the box rather than its middle: a leader
|
||||||
|
# drawn to the centre crosses the box and strikes out a line of
|
||||||
|
# what it was drawn to point at.
|
||||||
|
painter.setPen(QPen(colour.lighter(120), 1.0))
|
||||||
|
painter.drawLine(x, y,
|
||||||
|
int(max(bx, min(x, bx + box[0]))),
|
||||||
|
int(max(by, min(y, by + box[1]))))
|
||||||
|
title = f"{blip.callsign} {blip.icao}" if blip.callsign \
|
||||||
|
else blip.icao
|
||||||
|
self._draw_box(painter, bx, by, box, lines, colour, title)
|
||||||
|
|
||||||
|
def _draw_symbol(self, painter, x, y, heading, colour) -> None:
|
||||||
|
angle = math.radians(heading % 360.0)
|
||||||
|
sin, cos = math.sin(angle), math.cos(angle)
|
||||||
|
|
||||||
|
def point(ahead, side):
|
||||||
|
return QPointF(x + side * cos + ahead * sin,
|
||||||
|
y + side * sin - ahead * cos)
|
||||||
|
|
||||||
|
painter.setPen(NO_PEN)
|
||||||
|
painter.setBrush(colour)
|
||||||
|
painter.drawPolygon(QPolygonF([point(9, 0), point(-6, 5),
|
||||||
|
point(-3, 0), point(-6, -5)]))
|
||||||
|
painter.setBrush(QColor(255, 255, 255, 200))
|
||||||
|
painter.drawEllipse(QPointF(x, y), 1.6, 1.6)
|
||||||
|
|
||||||
|
def _box_lines(self, blip: Blip) -> list[tuple[str, str]]:
|
||||||
|
if self.detail >= 2:
|
||||||
|
return blip.lines(self.sky.unit, self.sky.centre())
|
||||||
|
out = []
|
||||||
|
if blip.altitude_ft:
|
||||||
|
out.append(("alt", f"{blip.altitude_ft:,} ft", ""))
|
||||||
|
if blip.ground_speed_kt:
|
||||||
|
out.append(("gs", f"{in_speed(blip.ground_speed_kt, self.sky.unit):.0f}"
|
||||||
|
f" {speed_label(self.sky.unit)}", ""))
|
||||||
|
return out
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _wrapped(lines) -> list[tuple[str, str, str]]:
|
||||||
|
"""The box's rows, with the long ones folded.
|
||||||
|
|
||||||
|
A folded row carries neither the label nor the flag: both belong
|
||||||
|
to the value as a whole, and repeating them down the side of a
|
||||||
|
wrapped airport name would read as several different facts.
|
||||||
|
"""
|
||||||
|
out: list[tuple[str, str, str]] = []
|
||||||
|
for row in lines:
|
||||||
|
label, value, country = (row if len(row) == 3
|
||||||
|
else (row[0], row[1], ""))
|
||||||
|
for i, piece in enumerate(wrap_value(value)):
|
||||||
|
out.append((label if i == 0 else "", piece,
|
||||||
|
country if i == 0 else ""))
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _box_size(self, lines) -> tuple[int, int]:
|
||||||
|
from .flags import FLAG_W
|
||||||
|
|
||||||
|
metrics = QtGui.QFontMetrics(self.text_font)
|
||||||
|
head = QtGui.QFontMetrics(self.head_font)
|
||||||
|
widest = head.horizontalAdvance("XXXXXXXX XXXXXX")
|
||||||
|
flagged = any(country for _, _, country in lines)
|
||||||
|
for _label, value, _country in lines:
|
||||||
|
widest = max(widest, metrics.horizontalAdvance(value))
|
||||||
|
gutter = metrics.horizontalAdvance("XXXXX ")
|
||||||
|
room = gutter + widest + (FLAG_W + 4 if flagged else 0)
|
||||||
|
row = metrics.height()
|
||||||
|
return room + 14, head.height() + row * len(lines) + 12
|
||||||
|
|
||||||
|
def _draw_box(self, painter, bx, by, box, lines, colour,
|
||||||
|
title: str) -> None:
|
||||||
|
width, height = box
|
||||||
|
painter.setPen(QPen(colour, 1.2))
|
||||||
|
painter.setBrush(QColor(10, 12, 18, 215))
|
||||||
|
painter.drawRoundedRect(QRectF(bx, by, width, height), 3.0, 3.0)
|
||||||
|
painter.setFont(self.head_font)
|
||||||
|
painter.setPen(colour.lighter(135))
|
||||||
|
head = QtGui.QFontMetrics(self.head_font)
|
||||||
|
painter.drawText(bx + 7, by + head.ascent() + 4, title)
|
||||||
|
painter.setFont(self.text_font)
|
||||||
|
metrics = QtGui.QFontMetrics(self.text_font)
|
||||||
|
gutter = metrics.horizontalAdvance("XXXXX ")
|
||||||
|
flagged = any(country for _, _, country in lines)
|
||||||
|
y = by + head.height() + 6 + metrics.ascent()
|
||||||
|
for label, value, country in lines:
|
||||||
|
if label:
|
||||||
|
painter.setPen(rgb(GRID))
|
||||||
|
painter.drawText(bx + 7, y, f"{label:>5s}")
|
||||||
|
left = bx + 7 + gutter
|
||||||
|
if flagged:
|
||||||
|
if country:
|
||||||
|
self._draw_flag(painter, left, y - metrics.ascent() + 2,
|
||||||
|
country)
|
||||||
|
left += FLAG_PIXELS + 4
|
||||||
|
painter.setPen(rgb(INK))
|
||||||
|
painter.drawText(left, y, value)
|
||||||
|
y += metrics.height()
|
||||||
|
|
||||||
|
def _draw_flag(self, painter, x: int, y: int, country: str) -> None:
|
||||||
|
"""A flag beside a place name, or its letters where there is none.
|
||||||
|
|
||||||
|
A country with no flag here is named rather than approximated: two
|
||||||
|
letters are never wrong, and a flag that is nearly another
|
||||||
|
country's is worse than no flag at all.
|
||||||
|
"""
|
||||||
|
from .flags import FLAG_H, FLAG_W, pixels_for
|
||||||
|
|
||||||
|
picture = pixels_for(country)
|
||||||
|
if picture is None:
|
||||||
|
painter.setPen(rgb(GRID))
|
||||||
|
painter.drawText(x, y + FLAG_H, country[:2].upper())
|
||||||
|
return
|
||||||
|
pixels = np.ascontiguousarray(picture)
|
||||||
|
self._flag_pixels = pixels # QImage does not copy it
|
||||||
|
image = QImage(pixels.data, FLAG_W, FLAG_H, 3 * FLAG_W, RGB888)
|
||||||
|
painter.drawImage(x, y, image)
|
||||||
|
|
||||||
|
def _draw_scale(self, painter, view) -> None:
|
||||||
|
from .basemap import ATTRIBUTION
|
||||||
|
|
||||||
|
painter.setFont(self.text_font)
|
||||||
|
metrics = QtGui.QFontMetrics(self.text_font)
|
||||||
|
across = in_distance(view.width_nm, self.sky.unit)
|
||||||
|
per = self.width() / max(1e-9, across)
|
||||||
|
for miles in (200, 100, 50, 20, 10, 5, 2, 1):
|
||||||
|
pixels = int(miles * per)
|
||||||
|
if pixels <= self.width() * 0.28:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
y = self.height() - 10
|
||||||
|
painter.setPen(QPen(rgb(INK), 1.0))
|
||||||
|
painter.drawLine(12, y, 12 + pixels, y)
|
||||||
|
painter.drawLine(12, y - 4, 12, y + 4)
|
||||||
|
painter.drawLine(12 + pixels, y - 4, 12 + pixels, y + 4)
|
||||||
|
painter.drawText(18 + pixels, y + 4,
|
||||||
|
f"{miles} {distance_label(self.sky.unit)}")
|
||||||
|
if self.show_ground and self.sky.ground(
|
||||||
|
self.ground_key(view)) is not None:
|
||||||
|
painter.setPen(rgb(GRID))
|
||||||
|
painter.drawText(self.width() - 8
|
||||||
|
- metrics.horizontalAdvance(ATTRIBUTION),
|
||||||
|
y + 4, ATTRIBUTION)
|
||||||
|
|
||||||
|
def _header_height(self) -> int:
|
||||||
|
return QtGui.QFontMetrics(self.head_font).height() + 8
|
||||||
|
|
||||||
|
def _draw_header(self, painter, flying: int) -> None:
|
||||||
|
metrics = QtGui.QFontMetrics(self.head_font)
|
||||||
|
height = self._header_height()
|
||||||
|
painter.setPen(NO_PEN)
|
||||||
|
painter.setBrush(QColor(10, 12, 18, 205))
|
||||||
|
painter.drawRect(0, 0, self.width(), height)
|
||||||
|
elapsed = max(0.001, time.time() - self.sky.started)
|
||||||
|
told = (f"1090 MHz {flying} overhead "
|
||||||
|
f"{self.sky.aircraft_seen} seen "
|
||||||
|
f"{self.sky.frames:,} frames "
|
||||||
|
f"{self.sky.frames / elapsed:.0f}/s "
|
||||||
|
f"{_clock(elapsed)}")
|
||||||
|
if self.sky.log_name:
|
||||||
|
told += f" {self.sky.log_name}"
|
||||||
|
if self.sky.note:
|
||||||
|
told += f" {self.sky.note}"
|
||||||
|
painter.setFont(self.head_font)
|
||||||
|
painter.setPen(rgb(INK))
|
||||||
|
painter.drawText(10, metrics.ascent() + 4, told)
|
||||||
|
keys = "d detail t trails g map +/- range q quit"
|
||||||
|
painter.setPen(rgb(GRID))
|
||||||
|
painter.drawText(self.width() - 8
|
||||||
|
- metrics.horizontalAdvance(keys),
|
||||||
|
metrics.ascent() + 4, keys)
|
||||||
|
|
||||||
|
class Window(QtWidgets.QMainWindow):
|
||||||
|
"""The window itself: a map, a clock and a few keys."""
|
||||||
|
|
||||||
|
def __init__(self, sky: Sky, title: str = "bandsaunter — aircraft"):
|
||||||
|
super().__init__()
|
||||||
|
self.sky = sky
|
||||||
|
self.view = SkyView(sky, self)
|
||||||
|
self.setCentralWidget(self.view)
|
||||||
|
self.setWindowTitle(title)
|
||||||
|
self.resize(1100, 800)
|
||||||
|
self._timer = QTimer(self)
|
||||||
|
self._timer.timeout.connect(self._tick)
|
||||||
|
self._timer.start(REDRAW_MS)
|
||||||
|
|
||||||
|
def _tick(self) -> None:
|
||||||
|
if self.sky.finished:
|
||||||
|
self.close()
|
||||||
|
return
|
||||||
|
self.view.update()
|
||||||
|
|
||||||
|
def keyPressEvent(self, event) -> None:
|
||||||
|
key = event.key()
|
||||||
|
text = event.text().lower()
|
||||||
|
if key in (KEY_Q, KEY_ESCAPE) or text == "q":
|
||||||
|
self.close()
|
||||||
|
elif text == "d":
|
||||||
|
self.view.detail = (self.view.detail + 2) % 3
|
||||||
|
elif text == "t":
|
||||||
|
self.view.trails = not self.view.trails
|
||||||
|
elif text == "g":
|
||||||
|
self.view.show_ground = not self.view.show_ground
|
||||||
|
elif text in ("+", "="):
|
||||||
|
self.sky.radius_nm = max(5.0, self.sky.radius_nm / 1.5)
|
||||||
|
elif text == "-":
|
||||||
|
self.sky.radius_nm = min(3000.0, self.sky.radius_nm * 1.5)
|
||||||
|
self.view.update()
|
||||||
|
|
||||||
|
def closeEvent(self, event) -> None:
|
||||||
|
self.sky.stopping = True
|
||||||
|
self._timer.stop()
|
||||||
|
event.accept()
|
||||||
|
|
||||||
|
_build._made = {"SkyView": SkyView, "Window": Window}
|
||||||
|
return _build._made
|
||||||
|
|
||||||
|
|
||||||
|
def _clock(seconds: float) -> str:
|
||||||
|
seconds = int(seconds)
|
||||||
|
hours, rest = divmod(seconds, 3600)
|
||||||
|
minutes, secs = divmod(rest, 60)
|
||||||
|
return f"{hours}:{minutes:02d}:{secs:02d}" if hours else f"{minutes}:{secs:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name):
|
||||||
|
"""Build the window classes on first use, so importing costs nothing."""
|
||||||
|
if name in ("SkyView", "Window"):
|
||||||
|
made = _build()
|
||||||
|
if made is None:
|
||||||
|
raise RuntimeError(MISSING_QT)
|
||||||
|
return made[name]
|
||||||
|
raise AttributeError(name)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Running it
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def fetch_ground(sky: Sky, url: str = "", fetch=None) -> None:
|
||||||
|
"""Fetch whatever map the window is asking for, until it stops asking.
|
||||||
|
|
||||||
|
Runs on its own thread: the tiles come off a network the first time an
|
||||||
|
area is drawn, and a window that stopped repainting while that happened
|
||||||
|
would look broken every time it was resized.
|
||||||
|
"""
|
||||||
|
from . import basemap
|
||||||
|
|
||||||
|
while not sky.stopping:
|
||||||
|
wanted = sky.wanted_ground()
|
||||||
|
if wanted is None:
|
||||||
|
time.sleep(0.2)
|
||||||
|
continue
|
||||||
|
key, (south, west, north, east), (width, height) = wanted
|
||||||
|
try:
|
||||||
|
extra = {"fetch": fetch} if fetch is not None else {}
|
||||||
|
if url:
|
||||||
|
extra["url"] = url
|
||||||
|
levels = basemap.ground_under(south, west, north, east,
|
||||||
|
width, height,
|
||||||
|
shades=_ground_shades(), **extra)
|
||||||
|
except Exception:
|
||||||
|
levels = None
|
||||||
|
# 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.
|
||||||
|
sky.set_ground(levels, key)
|
||||||
|
|
||||||
|
|
||||||
|
def _ground_shades() -> int:
|
||||||
|
from .flightmap import GROUND_SHADES
|
||||||
|
|
||||||
|
return GROUND_SHADES
|
||||||
|
|
||||||
|
|
||||||
|
def show(sky: Sky, title: str = "bandsaunter — aircraft") -> None:
|
||||||
|
"""Open the window and stay in it until it is closed."""
|
||||||
|
made = _build()
|
||||||
|
if made is None:
|
||||||
|
raise RuntimeError(MISSING_QT)
|
||||||
|
_name, _core, _gui, widgets, _signal = _qt()
|
||||||
|
app = widgets.QApplication.instance() or widgets.QApplication([])
|
||||||
|
window = made["Window"](sky, title)
|
||||||
|
window.show()
|
||||||
|
runner = getattr(app, "exec", None) or app.exec_
|
||||||
|
runner()
|
||||||
|
sky.stopping = True
|
||||||
|
|
@ -678,7 +678,12 @@ _AIRCRAFT_INTRO = (
|
||||||
"path is 12.5 kHz wide \u2014 so it has its own listening mode here.\n\n"
|
"path is 12.5 kHz wide \u2014 so it has its own listening mode here.\n\n"
|
||||||
"Everything heard is written to a log as it arrives; the map is drawn "
|
"Everything heard is written to a log as it arrives; the map is drawn "
|
||||||
"from that log afterwards, and can be drawn again with different options "
|
"from that log afterwards, and can be drawn again with different options "
|
||||||
"as often as you like."
|
"as often as you like.\n\n"
|
||||||
|
"[bold]Passive capture[/bold] listens and writes, showing a line per "
|
||||||
|
"aircraft in this terminal. [bold]Realtime display[/bold] does the same "
|
||||||
|
"and opens a window with a real map in it, each aircraft moving on it "
|
||||||
|
"with a box beside it saying everything known about the flight. Both "
|
||||||
|
"leave the same files behind."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -715,17 +720,22 @@ def aircraft_menu(console: Console, cfg: ScanConfig) -> None:
|
||||||
options = air.load_options()
|
options = air.load_options()
|
||||||
while True:
|
while True:
|
||||||
_rule(console, "aircraft (ADS-B)")
|
_rule(console, "aircraft (ADS-B)")
|
||||||
console.print(Panel(Text(_AIRCRAFT_INTRO), border_style="blue",
|
console.print(Panel(Text.from_markup(_AIRCRAFT_INTRO),
|
||||||
padding=(0, 1)))
|
border_style="blue", padding=(0, 1)))
|
||||||
_options_table(console, options, "Listening")
|
_options_table(console, options, "Listening")
|
||||||
console.print()
|
console.print()
|
||||||
_options_table(console, options, "Drawing")
|
_options_table(console, options, "Drawing")
|
||||||
logs = air.logs_in(cfg.output_dir)
|
logs = air.logs_in(cfg.output_dir)
|
||||||
kept = "no logs yet" if not logs else \
|
kept = "no logs yet" if not logs else \
|
||||||
f"{len(logs)} log{'s' if len(logs) != 1 else ''}"
|
f"{len(logs)} log{'s' if len(logs) != 1 else ''}"
|
||||||
|
window = "opens a window" if air.windowed() else \
|
||||||
|
"needs Qt \u2014 see ?"
|
||||||
console.print(
|
console.print(
|
||||||
f"\n [cyan]l[/cyan] [bold green]Listen now[/bold green]"
|
f"\n [cyan]p[/cyan] [bold green]Passive capture[/bold green]"
|
||||||
f" [grey62]{air.describe(options)}[/grey62]\n"
|
f" [grey62]{air.describe(options)}[/grey62]\n"
|
||||||
|
f" [cyan]r[/cyan] [bold green]Realtime display[/bold green]"
|
||||||
|
f" [grey62]{window}, the map and the aircraft on it"
|
||||||
|
f"[/grey62]\n"
|
||||||
f" [cyan]m[/cyan] Draw a map from a log [grey62]{kept} in "
|
f" [cyan]m[/cyan] Draw a map from a log [grey62]{kept} in "
|
||||||
f"{cfg.output_dir}[/grey62]\n"
|
f"{cfg.output_dir}[/grey62]\n"
|
||||||
f" [cyan]N[/cyan] change option N "
|
f" [cyan]N[/cyan] change option N "
|
||||||
|
|
@ -734,12 +744,15 @@ def aircraft_menu(console: Console, cfg: ScanConfig) -> None:
|
||||||
f"[grey62]kept in {air.options_path()}[/grey62]\n"
|
f"[grey62]kept in {air.options_path()}[/grey62]\n"
|
||||||
f" [cyan]d[/cyan] Reset them\n"
|
f" [cyan]d[/cyan] Reset them\n"
|
||||||
f" [cyan]b[/cyan] Back\n")
|
f" [cyan]b[/cyan] Back\n")
|
||||||
answer = _ask(console, " choice", "l").strip().lower()
|
answer = _ask(console, " choice", "p").strip().lower()
|
||||||
|
|
||||||
if answer in _BACK:
|
if answer in _BACK:
|
||||||
return
|
return
|
||||||
if answer in ("l", "listen"):
|
# "l" was what this was called before there were two of them.
|
||||||
|
if answer in ("p", "l", "passive", "listen"):
|
||||||
_listen(console, cfg, options)
|
_listen(console, cfg, options)
|
||||||
|
elif answer in ("r", "realtime", "window"):
|
||||||
|
_watch(console, cfg, options)
|
||||||
elif answer in ("m", "map", "draw"):
|
elif answer in ("m", "map", "draw"):
|
||||||
_draw_from_menu(console, cfg, options, logs)
|
_draw_from_menu(console, cfg, options, logs)
|
||||||
elif answer == "s":
|
elif answer == "s":
|
||||||
|
|
@ -756,7 +769,7 @@ def aircraft_menu(console: Console, cfg: ScanConfig) -> None:
|
||||||
_edit_option(console, options, answer)
|
_edit_option(console, options, answer)
|
||||||
else:
|
else:
|
||||||
console.print(" [yellow]enter a number from the lists, or "
|
console.print(" [yellow]enter a number from the lists, or "
|
||||||
"l, m, s, d or b[/yellow]")
|
"p, r, m, s, d or b[/yellow]")
|
||||||
|
|
||||||
|
|
||||||
def _edit_option(console: Console, options, answer: str) -> None:
|
def _edit_option(console: Console, options, answer: str) -> None:
|
||||||
|
|
@ -825,6 +838,24 @@ def _listen(console: Console, cfg: ScanConfig, options) -> None:
|
||||||
"\"Draw when finished\"[/grey62]")
|
"\"Draw when finished\"[/grey62]")
|
||||||
|
|
||||||
|
|
||||||
|
def _watch(console: Console, cfg: ScanConfig, options) -> None:
|
||||||
|
"""Open the window, and come back to the menu when it is closed."""
|
||||||
|
from . import aircraft as air
|
||||||
|
|
||||||
|
errs = options.validate()
|
||||||
|
if errs:
|
||||||
|
for e in errs:
|
||||||
|
console.print(f" [red]{e}[/red]")
|
||||||
|
return
|
||||||
|
console.print("[grey62]closing the window stops the capture and writes "
|
||||||
|
"the log, the report and the map, exactly as the passive "
|
||||||
|
"capture does.[/grey62]")
|
||||||
|
try:
|
||||||
|
air.watch(console, options, cfg.output_dir)
|
||||||
|
except Exception as exc: # a menu must survive it
|
||||||
|
console.print(f" [red]{exc}[/red]")
|
||||||
|
|
||||||
|
|
||||||
def _draw_from_menu(console: Console, cfg: ScanConfig, options, logs) -> None:
|
def _draw_from_menu(console: Console, cfg: ScanConfig, options, logs) -> None:
|
||||||
"""Pick a log and draw it, newest first because that is usually the one."""
|
"""Pick a log and draw it, newest first because that is usually the one."""
|
||||||
from . import aircraft as air
|
from . import aircraft as air
|
||||||
|
|
|
||||||
BIN
docs/realtime.png
Normal file
BIN
docs/realtime.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
|
|
@ -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_02" "User Commands"
|
.TH BANDSAUNTER 1 "2026-09-04" "bandsaunter 2026-09-04_04" "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
|
||||||
|
|
@ -1315,6 +1315,38 @@ an hour, kilometres with km/h \[em] so that one picture never carries two
|
||||||
different miles. The log always holds knots, because that is what the aircraft
|
different miles. The log always holds knots, because that is what the aircraft
|
||||||
broadcast: the recording stays the thing that arrived and the conversion
|
broadcast: the recording stays the thing that arrived and the conversion
|
||||||
happens at the moment of showing it to somebody.
|
happens at the moment of showing it to somebody.
|
||||||
|
.SS A window, while it happens
|
||||||
|
.B \-\-window
|
||||||
|
opens a window instead of drawing a table in the terminal: a real map with the
|
||||||
|
aircraft moving on it as the frames arrive, and beside each one a box giving
|
||||||
|
its type and registration, who operates it, where it came from and where it is
|
||||||
|
going \[em] each end with its country's flag \[em] its height and rate of climb,
|
||||||
|
its speed and heading, how far away it is
|
||||||
|
and on what bearing, its position, how many frames it has sent and how long
|
||||||
|
ago the last one was. The boxes are placed so that they cover neither each
|
||||||
|
other nor another aircraft, and long names are folded rather than allowed to
|
||||||
|
stretch one \[em] a route between two airports under their full names runs to
|
||||||
|
sixty characters, and breaks at the arrow so that the two ends of the flight
|
||||||
|
stay whole.
|
||||||
|
.PP
|
||||||
|
.B d
|
||||||
|
cycles how much each box says, for a busy sky;
|
||||||
|
.B t
|
||||||
|
turns the trails off,
|
||||||
|
.B g
|
||||||
|
the map underneath,
|
||||||
|
.B +
|
||||||
|
and
|
||||||
|
.B \-
|
||||||
|
change the range and
|
||||||
|
.B q
|
||||||
|
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
|
||||||
|
receiver runs on its own thread, so a slow repaint cannot cost a frame.
|
||||||
|
.PP
|
||||||
|
Qt is asked for and not required \[em] PyQt6, PyQt5, PySide6 and PySide2 are
|
||||||
|
all tried. Without any of them this window is the only thing lost, and the
|
||||||
|
program says how to get one rather than failing.
|
||||||
.SS From the menus
|
.SS From the menus
|
||||||
Running
|
Running
|
||||||
.B bandsaunter
|
.B bandsaunter
|
||||||
|
|
@ -1325,8 +1357,10 @@ does all of this without a command line. Every option is listed on one screen
|
||||||
with a line saying what it does;
|
with a line saying what it does;
|
||||||
.BI ? N
|
.BI ? N
|
||||||
explains one at length, including the flag it corresponds to,
|
explains one at length, including the flag it corresponds to,
|
||||||
.B l
|
.B p
|
||||||
listens,
|
starts a passive capture,
|
||||||
|
.B r
|
||||||
|
opens the window,
|
||||||
.B m
|
.B m
|
||||||
draws a map from any log in the recordings directory, and
|
draws a map from any log in the recordings directory, and
|
||||||
.B s
|
.B s
|
||||||
|
|
@ -1380,6 +1414,16 @@ where ffmpeg is installed, or a
|
||||||
for the whole evening in one picture. Altitude is the colour, low warm to high
|
for the whole evening in one picture. Altitude is the colour, low warm to high
|
||||||
cold. The GIF is written from first principles \[em] a palette, an LZW stream
|
cold. The GIF is written from first principles \[em] a palette, an LZW stream
|
||||||
and frame differencing \[em] so nothing but numpy is needed to draw one.
|
and frame differencing \[em] so nothing but numpy is needed to draw one.
|
||||||
|
.PP
|
||||||
|
Beside each aircraft goes its flight level and speed, its type and
|
||||||
|
registration, and the two ends of its route, each with a small flag of the
|
||||||
|
country the airport is in. The flags are twelve pixels by eight and come from
|
||||||
|
a table rather than a network: at that size a flag is the arrangement that
|
||||||
|
makes one recognisable rather than a rendering of the real thing. A country
|
||||||
|
not in the table is named by its two letters instead, since a flag that is
|
||||||
|
nearly another country's is worse than none. Where a route arrives as nothing
|
||||||
|
but a pair of airport codes the country comes from the code, the first letter
|
||||||
|
or two of an ICAO code being a region.
|
||||||
.SS How far the map reaches
|
.SS How far the map reaches
|
||||||
The picture is framed on the receiver rather than on whatever was heard. An
|
The picture is framed on the receiver rather than on whatever was heard. An
|
||||||
aerial reaches a hundred miles on a good day and a position that decoded
|
aerial reaches a hundred miles on a good day and a position that decoded
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ Architecture: ${arch}
|
||||||
Depends: python3 (>= 3.10), python3-numpy, python3-scipy, python3-rich,
|
Depends: python3 (>= 3.10), python3-numpy, python3-scipy, python3-rich,
|
||||||
python3-yaml, librtlsdr0
|
python3-yaml, librtlsdr0
|
||||||
Recommends: bandsaunter-transcribe, espeak-ng
|
Recommends: bandsaunter-transcribe, espeak-ng
|
||||||
Suggests: rtl-sdr
|
Suggests: rtl-sdr, python3-pyqt6
|
||||||
Maintainer: bandsaunter
|
Maintainer: bandsaunter
|
||||||
Installed-Size: $(du -ks "$pkgdir" | cut -f1)
|
Installed-Size: $(du -ks "$pkgdir" | cut -f1)
|
||||||
Description: signal scanner and recorder for RTL-SDR receivers
|
Description: signal scanner and recorder for RTL-SDR receivers
|
||||||
|
|
|
||||||
|
|
@ -708,6 +708,38 @@ an hour, kilometres with km/h \[em] so that one picture never carries two
|
||||||
different miles. The log always holds knots, because that is what the aircraft
|
different miles. The log always holds knots, because that is what the aircraft
|
||||||
broadcast: the recording stays the thing that arrived and the conversion
|
broadcast: the recording stays the thing that arrived and the conversion
|
||||||
happens at the moment of showing it to somebody.
|
happens at the moment of showing it to somebody.
|
||||||
|
.SS A window, while it happens
|
||||||
|
.B \-\-window
|
||||||
|
opens a window instead of drawing a table in the terminal: a real map with the
|
||||||
|
aircraft moving on it as the frames arrive, and beside each one a box giving
|
||||||
|
its type and registration, who operates it, where it came from and where it is
|
||||||
|
going \[em] each end with its country's flag \[em] its height and rate of climb,
|
||||||
|
its speed and heading, how far away it is
|
||||||
|
and on what bearing, its position, how many frames it has sent and how long
|
||||||
|
ago the last one was. The boxes are placed so that they cover neither each
|
||||||
|
other nor another aircraft, and long names are folded rather than allowed to
|
||||||
|
stretch one \[em] a route between two airports under their full names runs to
|
||||||
|
sixty characters, and breaks at the arrow so that the two ends of the flight
|
||||||
|
stay whole.
|
||||||
|
.PP
|
||||||
|
.B d
|
||||||
|
cycles how much each box says, for a busy sky;
|
||||||
|
.B t
|
||||||
|
turns the trails off,
|
||||||
|
.B g
|
||||||
|
the map underneath,
|
||||||
|
.B +
|
||||||
|
and
|
||||||
|
.B \-
|
||||||
|
change the range and
|
||||||
|
.B q
|
||||||
|
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
|
||||||
|
receiver runs on its own thread, so a slow repaint cannot cost a frame.
|
||||||
|
.PP
|
||||||
|
Qt is asked for and not required \[em] PyQt6, PyQt5, PySide6 and PySide2 are
|
||||||
|
all tried. Without any of them this window is the only thing lost, and the
|
||||||
|
program says how to get one rather than failing.
|
||||||
.SS From the menus
|
.SS From the menus
|
||||||
Running
|
Running
|
||||||
.B bandsaunter
|
.B bandsaunter
|
||||||
|
|
@ -718,8 +750,10 @@ does all of this without a command line. Every option is listed on one screen
|
||||||
with a line saying what it does;
|
with a line saying what it does;
|
||||||
.BI ? N
|
.BI ? N
|
||||||
explains one at length, including the flag it corresponds to,
|
explains one at length, including the flag it corresponds to,
|
||||||
.B l
|
.B p
|
||||||
listens,
|
starts a passive capture,
|
||||||
|
.B r
|
||||||
|
opens the window,
|
||||||
.B m
|
.B m
|
||||||
draws a map from any log in the recordings directory, and
|
draws a map from any log in the recordings directory, and
|
||||||
.B s
|
.B s
|
||||||
|
|
@ -773,6 +807,16 @@ where ffmpeg is installed, or a
|
||||||
for the whole evening in one picture. Altitude is the colour, low warm to high
|
for the whole evening in one picture. Altitude is the colour, low warm to high
|
||||||
cold. The GIF is written from first principles \[em] a palette, an LZW stream
|
cold. The GIF is written from first principles \[em] a palette, an LZW stream
|
||||||
and frame differencing \[em] so nothing but numpy is needed to draw one.
|
and frame differencing \[em] so nothing but numpy is needed to draw one.
|
||||||
|
.PP
|
||||||
|
Beside each aircraft goes its flight level and speed, its type and
|
||||||
|
registration, and the two ends of its route, each with a small flag of the
|
||||||
|
country the airport is in. The flags are twelve pixels by eight and come from
|
||||||
|
a table rather than a network: at that size a flag is the arrangement that
|
||||||
|
makes one recognisable rather than a rendering of the real thing. A country
|
||||||
|
not in the table is named by its two letters instead, since a flag that is
|
||||||
|
nearly another country's is worse than none. Where a route arrives as nothing
|
||||||
|
but a pair of airport codes the country comes from the code, the first letter
|
||||||
|
or two of an ICAO code being a region.
|
||||||
.SS How far the map reaches
|
.SS How far the map reaches
|
||||||
The picture is framed on the receiver rather than on whatever was heard. An
|
The picture is framed on the receiver rather than on whatever was heard. An
|
||||||
aerial reaches a hundred miles on a good day and a position that decoded
|
aerial reaches a hundred miles on a good day and a position that decoded
|
||||||
|
|
|
||||||
|
|
@ -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_02" "User Commands"
|
.TH SAUNTERBROWSE 1 "2026-09-04" "bandsaunter 2026-09-04_04" "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
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,9 @@ dependencies = [
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
# The realtime aircraft window. Optional on purpose: without it the passive
|
||||||
|
# capture, the terminal board and the drawn maps all work unchanged.
|
||||||
|
window = ["PyQt6>=6.4"]
|
||||||
plots = ["matplotlib>=3.5"]
|
plots = ["matplotlib>=3.5"]
|
||||||
# pyte is a terminal emulator, used by the resize tests to read back what
|
# pyte is a terminal emulator, used by the resize tests to read back what
|
||||||
# a real terminal would show. Those tests skip without it.
|
# a real terminal would show. Those tests skip without it.
|
||||||
|
|
|
||||||
139
tests/test_flags.py
Normal file
139
tests/test_flags.py
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
"""The little flags, and where a country comes from.
|
||||||
|
|
||||||
|
A flag at twelve pixels by eight is not a rendering of the real thing, so
|
||||||
|
what is checked here is what it has to get right to be worth drawing: the
|
||||||
|
right shape of arrangement, the right colours, and never a flag that belongs
|
||||||
|
to somebody else.
|
||||||
|
"""
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from bandsaunter import flags
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_flag_is_the_size_it_says_it_is():
|
||||||
|
for code, rows in flags.FLAGS.items():
|
||||||
|
assert len(rows) == flags.FLAG_H, code
|
||||||
|
assert all(len(row) == flags.FLAG_W for row in rows), code
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_flag_uses_colours_that_exist():
|
||||||
|
"""A typo in a flag would otherwise come out as silent white."""
|
||||||
|
used = {letter for rows in flags.FLAGS.values()
|
||||||
|
for row in rows for letter in row}
|
||||||
|
assert used <= set(flags.COLOURS), used - set(flags.COLOURS)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_colours_are_a_fixed_order():
|
||||||
|
"""An index into the animation's palette has to mean one colour for the
|
||||||
|
life of the file it is written into."""
|
||||||
|
assert flags.COLOUR_ORDER == tuple(flags.COLOURS)
|
||||||
|
assert len(flags.COLOUR_ORDER) == len(set(flags.COLOUR_ORDER))
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_country_with_no_flag_here_gets_none_rather_than_a_wrong_one():
|
||||||
|
assert flags.flag_for("ZZ") is None
|
||||||
|
assert flags.pixels_for("ZZ") is None
|
||||||
|
assert flags.known("ZZ") is False
|
||||||
|
assert flags.known("us") is True # case does not matter
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_flag_comes_back_as_pixels():
|
||||||
|
picture = flags.pixels_for("JP")
|
||||||
|
assert picture.shape == (flags.FLAG_H, flags.FLAG_W, 3)
|
||||||
|
assert picture.dtype == np.uint8
|
||||||
|
# White at the corner, red in the middle: that is the flag of Japan.
|
||||||
|
assert tuple(picture[0, 0]) == flags.COLOURS["w"]
|
||||||
|
assert tuple(picture[4, 6]) == flags.COLOURS["r"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("code,hoist,middle,fly", [
|
||||||
|
("FR", "b", "w", "r"), # blue at the hoist, white, red at the fly
|
||||||
|
("IT", "g", "w", "r"),
|
||||||
|
("IE", "g", "w", "o"),
|
||||||
|
("BE", "k", "y", "r"),
|
||||||
|
])
|
||||||
|
def test_a_vertical_tricolour_runs_left_to_right(code, hoist, middle, fly):
|
||||||
|
picture = flags.pixels_for(code)
|
||||||
|
assert tuple(picture[4, 0]) == flags.COLOURS[hoist]
|
||||||
|
assert tuple(picture[4, 6]) == flags.COLOURS[middle]
|
||||||
|
assert tuple(picture[4, 11]) == flags.COLOURS[fly]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("code,top,middle,bottom", [
|
||||||
|
("DE", "k", "r", "y"), # black over red over gold
|
||||||
|
("NL", "r", "w", "b"),
|
||||||
|
("RU", "w", "b", "r"),
|
||||||
|
("HU", "r", "w", "g"),
|
||||||
|
])
|
||||||
|
def test_a_horizontal_tricolour_runs_top_to_bottom(code, top, middle, bottom):
|
||||||
|
picture = flags.pixels_for(code)
|
||||||
|
assert tuple(picture[0, 6]) == flags.COLOURS[top]
|
||||||
|
assert tuple(picture[4, 6]) == flags.COLOURS[middle]
|
||||||
|
assert tuple(picture[7, 6]) == flags.COLOURS[bottom]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_two_ways_round_are_not_the_same_flag():
|
||||||
|
"""Ireland and Italy are the same three colours in the same order; the
|
||||||
|
Netherlands and Russia are the same three the other way up."""
|
||||||
|
assert flags.flag_for("IE") != flags.flag_for("IT")
|
||||||
|
assert flags.flag_for("NL") != flags.flag_for("RU")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_nordic_cross_is_off_towards_the_hoist():
|
||||||
|
"""It is what makes those five flags recognisable at any size."""
|
||||||
|
rows = flags.flag_for("SE")
|
||||||
|
upright = [x for x in range(flags.FLAG_W) if rows[0][x] == "y"]
|
||||||
|
assert upright, "no cross at all"
|
||||||
|
assert max(upright) < flags.FLAG_W // 2 + 2, upright
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_two_countries_most_likely_to_be_confused_are_not():
|
||||||
|
"""The United States and Malaysia really do look alike; they must at
|
||||||
|
least differ here."""
|
||||||
|
assert flags.flag_for("US") != flags.flag_for("MY")
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_flags_a_receiver_actually_needs_are_all_here():
|
||||||
|
for code in ("US", "CA", "MX", "GB", "IE", "FR", "DE", "NL", "ES", "IT",
|
||||||
|
"PT", "CH", "AT", "DK", "NO", "SE", "FI", "PL", "RU", "TR",
|
||||||
|
"GR", "JP", "CN", "KR", "IN", "AU", "NZ", "BR", "AR", "ZA",
|
||||||
|
"AE", "QA", "SA", "IL", "EG", "SG", "TH", "PH"):
|
||||||
|
assert flags.known(code), code
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Which country an airport is in
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("code,country", [
|
||||||
|
("KSEA", "US"), ("KATL", "US"), ("CYYZ", "CA"), ("EGLL", "GB"),
|
||||||
|
("EIDW", "IE"), ("LFPG", "FR"), ("EDDF", "DE"), ("EHAM", "NL"),
|
||||||
|
("LEMD", "ES"), ("LIRF", "IT"), ("RJTT", "JP"), ("ZBAA", "CN"),
|
||||||
|
("YSSY", "AU"), ("NZAA", "NZ"), ("SBGR", "BR"), ("OMDB", "AE"),
|
||||||
|
("VIDP", "IN"), ("WSSS", "SG"), ("MMMX", "MX"), ("FAOR", "ZA"),
|
||||||
|
])
|
||||||
|
def test_an_airport_code_says_which_country_it_is_in(code, country):
|
||||||
|
"""Some routes arrive as nothing but a pair of codes, and the code is
|
||||||
|
enough: the first letter or two is a region."""
|
||||||
|
assert flags.country_of_icao(code) == country
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_longer_prefix_wins():
|
||||||
|
"""K is the United States and KE is not a prefix at all, but LE is Spain
|
||||||
|
while L on its own is nothing."""
|
||||||
|
assert flags.country_of_icao("LEMD") == "ES"
|
||||||
|
assert flags.country_of_icao("KMIA") == "US"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("code", ["", "XX", "XXXX", "K", "1234", "KSE", None])
|
||||||
|
def test_something_that_is_not_an_airport_code_says_nothing(code):
|
||||||
|
assert flags.country_of_icao(code) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_country_with_no_flag_still_has_a_code_to_fall_back_on():
|
||||||
|
"""The point of the fallback: an airport in a country not drawn here is
|
||||||
|
still labelled, just in letters."""
|
||||||
|
country = flags.country_of_icao("FQMA") # Mozambique
|
||||||
|
assert country == "MZ"
|
||||||
|
assert flags.flag_for(country) is None
|
||||||
|
|
@ -733,3 +733,192 @@ def test_nothing_survives_that_needed_an_impossible_speed():
|
||||||
if 0 < b.at - a.at <= 300 and distance_nm(
|
if 0 < b.at - a.at <= 300 and distance_nm(
|
||||||
a.latitude, a.longitude, b.latitude, b.longitude) > 2:
|
a.latitude, a.longitude, b.latitude, b.longitude) > 2:
|
||||||
assert implied_speed_kt(a, b) <= MAX_GROUND_SPEED_KT
|
assert implied_speed_kt(a, b) <= MAX_GROUND_SPEED_KT
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# What is written beside each aircraft
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class _Entry:
|
||||||
|
"""A register's answer, in the shape flightmap reads it."""
|
||||||
|
|
||||||
|
def __init__(self, **over):
|
||||||
|
self.type_code = over.get("type_code", "B739")
|
||||||
|
self.model = over.get("model", "737-932ER")
|
||||||
|
self.registration = over.get("registration", "N904DN")
|
||||||
|
self.origin_code = over.get("origin_code", "KATL")
|
||||||
|
self.origin = over.get("origin", "Atlanta")
|
||||||
|
self.origin_country = over.get("origin_country", "US")
|
||||||
|
self.destination_code = over.get("destination_code", "EGLL")
|
||||||
|
self.destination = over.get("destination", "London Heathrow")
|
||||||
|
self.destination_country = over.get("destination_country", "GB")
|
||||||
|
# The map draws an airport where a lookup gave it a position.
|
||||||
|
self.origin_lat = over.get("origin_lat", 33.6367)
|
||||||
|
self.origin_lon = over.get("origin_lon", -84.4281)
|
||||||
|
self.destination_lat = over.get("destination_lat", 51.4706)
|
||||||
|
self.destination_lon = over.get("destination_lon", -0.4619)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_label_says_height_speed_type_and_both_ends_of_the_route():
|
||||||
|
track = straight()
|
||||||
|
rows = fm.label_lines(track, track.fixes[0], "knots", _Entry())
|
||||||
|
text = [line for line, _flag in rows]
|
||||||
|
assert text[0].startswith("350") # flight level
|
||||||
|
assert "480KT" in text[0]
|
||||||
|
assert "B739 N904DN" in text
|
||||||
|
assert "KATL" in text and "EGLL" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_each_end_of_the_route_carries_its_own_country():
|
||||||
|
track = straight()
|
||||||
|
rows = dict((line, flag) for line, flag in
|
||||||
|
fm.label_lines(track, track.fixes[0], "knots", _Entry()))
|
||||||
|
assert rows["KATL"] == "US"
|
||||||
|
assert rows["EGLL"] == "GB"
|
||||||
|
|
||||||
|
|
||||||
|
def test_with_no_register_the_label_is_what_the_aircraft_itself_said():
|
||||||
|
track = straight()
|
||||||
|
rows = fm.label_lines(track, track.fixes[0], "knots", None)
|
||||||
|
assert len(rows) == 1 and rows[0][1] == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_airport_with_no_code_is_named_short_rather_than_in_full():
|
||||||
|
track = straight()
|
||||||
|
entry = _Entry(origin_code="", origin="Hartsfield Jackson Atlanta "
|
||||||
|
"International Airport")
|
||||||
|
said = [line for line, _ in fm.label_lines(track, track.fixes[0],
|
||||||
|
"knots", entry)]
|
||||||
|
assert "Hartsfield Jackson" in said
|
||||||
|
assert not any(len(line) > 20 for line in said), said
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_flag_is_drawn_in_the_flag_colours():
|
||||||
|
from bandsaunter.flags import COLOUR_ORDER, FLAG_H, FLAG_W
|
||||||
|
|
||||||
|
img = np.full((40, 60), fm.BG, dtype=np.uint8)
|
||||||
|
fm.draw_flag(img, 5, 5, "JP")
|
||||||
|
patch = img[5:5 + FLAG_H, 5:5 + FLAG_W]
|
||||||
|
assert (patch >= fm.FLAG).all()
|
||||||
|
assert (patch < fm.FLAG + len(COLOUR_ORDER)).all()
|
||||||
|
# White at the corner and red in the middle: the flag of Japan.
|
||||||
|
assert patch[0, 0] == fm.flag_index("w")
|
||||||
|
assert patch[4, 6] == fm.flag_index("r")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_country_with_no_flag_is_named_in_letters_instead():
|
||||||
|
img = np.full((40, 60), fm.BG, dtype=np.uint8)
|
||||||
|
fm.draw_flag(img, 5, 5, "ZZ")
|
||||||
|
assert (img == fm.GRID).any() # the letters, in grey
|
||||||
|
assert not (img >= fm.FLAG).any() # and no flag pretending to be one
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_flag_off_the_edge_of_the_picture_paints_nothing():
|
||||||
|
for x, y in ((-40, 5), (5, -40), (200, 5), (5, 200)):
|
||||||
|
img = np.full((40, 60), fm.BG, dtype=np.uint8)
|
||||||
|
fm.draw_flag(img, x, y, "US")
|
||||||
|
assert (img == fm.BG).all(), (x, y)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_flag_colours_fit_in_the_palette_beside_everything_else():
|
||||||
|
from bandsaunter.flags import COLOUR_ORDER
|
||||||
|
|
||||||
|
assert fm.FLAG + len(COLOUR_ORDER) < fm.TRANSPARENT
|
||||||
|
assert fm.PALETTE.shape == (256, 3)
|
||||||
|
# And each index really is the colour it claims.
|
||||||
|
from bandsaunter.flags import COLOURS
|
||||||
|
|
||||||
|
for letter in COLOUR_ORDER:
|
||||||
|
assert tuple(int(v) for v in fm.PALETTE[fm.flag_index(letter)]) == \
|
||||||
|
COLOURS[letter]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_register_is_asked_once_per_aircraft_not_once_per_frame(tmp_path):
|
||||||
|
"""A five-hundred-frame animation asking the same question five hundred
|
||||||
|
times would be five hundred times as rude."""
|
||||||
|
asked = []
|
||||||
|
|
||||||
|
class _Book:
|
||||||
|
def get(self, icao, callsign=""):
|
||||||
|
asked.append(icao)
|
||||||
|
return _Entry()
|
||||||
|
|
||||||
|
fm.animate(two_aircraft(), tmp_path / "counted.gif", fps=8, seconds=3,
|
||||||
|
width=400, book=_Book())
|
||||||
|
assert asked, "the register was never asked at all"
|
||||||
|
assert len(asked) == len(set(asked)), f"asked twice about the same: {asked}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_route_reaches_the_drawn_picture(tmp_path):
|
||||||
|
"""End to end: a register with a route, and the flags on the frame."""
|
||||||
|
class _Book:
|
||||||
|
def get(self, icao, callsign=""):
|
||||||
|
return _Entry()
|
||||||
|
|
||||||
|
tracks = two_aircraft()
|
||||||
|
view = fm.fit(tracks, width=700)
|
||||||
|
base = fm.background(view)
|
||||||
|
known = {t.icao: _Entry() for t in tracks}
|
||||||
|
frame = fm.render_frame(base, view, tracks, tracks[0].first_seen + 60,
|
||||||
|
known=known)
|
||||||
|
assert (frame >= fm.FLAG).any(), "no flag was drawn"
|
||||||
|
assert _has_text(frame, "KATL")
|
||||||
|
assert _has_text(frame, "B739")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_crowded_frame_goes_back_to_the_short_label():
|
||||||
|
"""Five lines beside each of three hundred aircraft is not more
|
||||||
|
information, it is a page of overlapping text with a map behind it."""
|
||||||
|
many = [straight(icao=f"{i:06X}", callsign=f"FLT{i}",
|
||||||
|
lat=51.0 + i * 0.02, lon=-1.0 + i * 0.02)
|
||||||
|
for i in range(fm.CROWDED + 4)]
|
||||||
|
view = fm.fit(many, width=900)
|
||||||
|
base = fm.background(view)
|
||||||
|
known = {t.icao: _Entry() for t in many}
|
||||||
|
frame = fm.render_frame(base, view, many, many[0].first_seen + 60,
|
||||||
|
known=known)
|
||||||
|
assert not (frame >= fm.FLAG).any(), "still drawing flags when crowded"
|
||||||
|
assert not _has_text(frame, "B739")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_quiet_frame_keeps_every_detail():
|
||||||
|
tracks = two_aircraft()
|
||||||
|
view = fm.fit(tracks, width=700)
|
||||||
|
base = fm.background(view)
|
||||||
|
known = {t.icao: _Entry() for t in tracks}
|
||||||
|
frame = fm.render_frame(base, view, tracks, tracks[0].first_seen + 60,
|
||||||
|
known=known)
|
||||||
|
assert (frame >= fm.FLAG).any()
|
||||||
|
assert _has_text(frame, "B739")
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_callsign_and_height_survive_a_crowd():
|
||||||
|
"""Whatever else goes, what the aircraft itself said stays."""
|
||||||
|
many = [straight(icao=f"{i:06X}", callsign=f"FLT{i}",
|
||||||
|
lat=51.0 + i * 0.02, lon=-1.0 + i * 0.02)
|
||||||
|
for i in range(fm.CROWDED + 4)]
|
||||||
|
view = fm.fit(many, width=900)
|
||||||
|
frame = fm.render_frame(fm.background(view), view, many,
|
||||||
|
many[0].first_seen + 60,
|
||||||
|
known={t.icao: _Entry() for t in many})
|
||||||
|
assert _has_text(frame, "FLT0")
|
||||||
|
|
||||||
|
|
||||||
|
def test_labels_step_aside_rather_than_landing_on_each_other():
|
||||||
|
"""Two aircraft passing close together is exactly the moment somebody is
|
||||||
|
looking at that part of the picture."""
|
||||||
|
close = [straight(icao=f"{i:06X}", callsign=f"FLT{i}",
|
||||||
|
lat=51.0 + i * 0.004, lon=-1.0 + i * 0.004, seconds=60)
|
||||||
|
for i in range(6)]
|
||||||
|
view = fm.fit(close, width=800, box=(50.8, -1.4, 51.3, -0.6))
|
||||||
|
taken = []
|
||||||
|
base = fm.background(view)
|
||||||
|
fm.render_frame(base, view, close, close[0].first_seen, labels=True)
|
||||||
|
# Place them by hand so the boxes can be compared.
|
||||||
|
for track in close:
|
||||||
|
now = track.at(track.first_seen)
|
||||||
|
x, y = view.xy(now.latitude, now.longitude)
|
||||||
|
fm._label(base, x, y, track, now, fm.RAMP, taken)
|
||||||
|
for i, one in enumerate(taken):
|
||||||
|
for two in taken[i + 1:]:
|
||||||
|
assert not fm._overlaps(one, two), f"{one} overlaps {two}"
|
||||||
|
|
|
||||||
|
|
@ -547,3 +547,54 @@ def test_what_one_track_says_of_itself_follows_the_unit(tmp_path):
|
||||||
assert "kt" in track.describe()
|
assert "kt" in track.describe()
|
||||||
assert "mph" in track.describe("mph")
|
assert "mph" in track.describe("mph")
|
||||||
assert "km/h" in track.describe("kph")
|
assert "km/h" in track.describe("kph")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Which country each end of a route is in
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_each_end_of_a_route_comes_back_with_its_country(register):
|
||||||
|
"""The flags on the map are drawn from these."""
|
||||||
|
book, asked, answers = register
|
||||||
|
answers["adsbdb.com/v0/callsign"] = ADSBDB_ROUTE
|
||||||
|
entry = book.get("4CA1FA", "RYR1234")
|
||||||
|
book.wait(5.0)
|
||||||
|
assert entry.origin_country == "GB"
|
||||||
|
assert entry.destination_country == "GB"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_route_that_is_only_two_codes_still_says_which_countries(register):
|
||||||
|
"""hexdb sends "EGLL-KSEA" and nothing else; the codes are enough."""
|
||||||
|
book, asked, answers = register
|
||||||
|
answers["hexdb.io/api/v1/route"] = {"flight": "BAW49",
|
||||||
|
"route": "EGLL-KSEA"}
|
||||||
|
entry = book.get("400001", "BAW49")
|
||||||
|
book.wait(5.0)
|
||||||
|
assert (entry.origin_country, entry.destination_country) == ("GB", "US")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_route_cached_before_the_countries_existed_is_asked_again(tmp_path,
|
||||||
|
register):
|
||||||
|
"""Otherwise a month of cached routes would draw no flags at all."""
|
||||||
|
from bandsaunter.flights import CACHE_VERSION
|
||||||
|
|
||||||
|
book, asked, answers = register
|
||||||
|
stale = {"routes": {"RYR1234": {"origin_code": "EGSS", "origin": "Stansted",
|
||||||
|
"destination_code": "EGNX",
|
||||||
|
"destination": "East Midlands",
|
||||||
|
"fetched_at": time.time(), "version": 1}}}
|
||||||
|
book.cache_path.write_text(json.dumps(stale))
|
||||||
|
again = FlightBook(cache=book.cache_path)
|
||||||
|
assert again._routes == {}, "kept a route with no country in it"
|
||||||
|
assert CACHE_VERSION >= 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_route_written_now_is_kept(tmp_path, register):
|
||||||
|
book, asked, answers = register
|
||||||
|
answers["adsbdb.com/v0/callsign"] = ADSBDB_ROUTE
|
||||||
|
book.get("4CA1FA", "RYR1234")
|
||||||
|
book.wait(5.0)
|
||||||
|
book.save()
|
||||||
|
again = FlightBook(cache=book.cache_path)
|
||||||
|
entry = again.get("4CA1FA", "RYR1234")
|
||||||
|
assert entry.origin_country == "GB"
|
||||||
|
|
|
||||||
|
|
@ -128,7 +128,7 @@ def test_it_warns_about_the_thing_every_debian_user_hits_first():
|
||||||
def test_it_lists_the_optional_dependencies_and_what_each_one_buys():
|
def test_it_lists_the_optional_dependencies_and_what_each_one_buys():
|
||||||
body = INSTALL.read_text()
|
body = INSTALL.read_text()
|
||||||
for optional in ("espeak-ng", "ffmpeg", "faster-whisper", "vosk",
|
for optional in ("espeak-ng", "ffmpeg", "faster-whisper", "vosk",
|
||||||
"rtl-sdr"):
|
"rtl-sdr", "pyqt6"):
|
||||||
assert optional in body, optional
|
assert optional in body, optional
|
||||||
assert "Optional dependencies" in body
|
assert "Optional dependencies" in body
|
||||||
|
|
||||||
|
|
|
||||||
654
tests/test_livemap.py
Normal file
654
tests/test_livemap.py
Normal file
|
|
@ -0,0 +1,654 @@
|
||||||
|
"""The window: what it draws, and what it does without Qt.
|
||||||
|
|
||||||
|
Everything here runs on Qt's offscreen platform, so it needs no screen and
|
||||||
|
no window manager -- which is also what makes it runnable on a machine that
|
||||||
|
has never had one.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
from bandsaunter import livemap # noqa: E402
|
||||||
|
from bandsaunter.livemap import Blip, Sky, blip_for, place_box # noqa: E402
|
||||||
|
|
||||||
|
qt = pytest.mark.skipif(not livemap.available(), reason="no Qt installed")
|
||||||
|
|
||||||
|
# Held for the life of the process on purpose. Qt allows one application and
|
||||||
|
# expects it to outlive every widget; letting the fixture own it means that
|
||||||
|
# running one test from this file on its own destroys it while widgets are
|
||||||
|
# still about, and the interpreter comes down with a core dump.
|
||||||
|
_APPLICATION = None
|
||||||
|
if livemap.available():
|
||||||
|
_widgets = livemap._qt()[3]
|
||||||
|
_APPLICATION = _widgets.QApplication.instance() or _widgets.QApplication([])
|
||||||
|
|
||||||
|
def now() -> float:
|
||||||
|
"""The real clock.
|
||||||
|
|
||||||
|
The window works in real time -- an aircraft leaves the picture so many
|
||||||
|
seconds after its last frame -- so a fixture pinned to a made-up epoch
|
||||||
|
would have everything time out before it was ever drawn.
|
||||||
|
"""
|
||||||
|
return time.time()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app():
|
||||||
|
"""The one application, which outlives every test that uses it."""
|
||||||
|
if _APPLICATION is None:
|
||||||
|
pytest.skip("no Qt installed")
|
||||||
|
return _APPLICATION
|
||||||
|
|
||||||
|
|
||||||
|
def a_blip(icao="A76154", callsign="DAL538", lat=32.95, lon=-110.95,
|
||||||
|
**over) -> Blip:
|
||||||
|
settings = dict(icao=icao, callsign=callsign, latitude=lat, longitude=lon,
|
||||||
|
altitude_ft=32_500, ground_speed_kt=494.0, track_deg=325.0,
|
||||||
|
messages=1204, first_seen=now() - 700, last_seen=now() - 1)
|
||||||
|
settings.update(over)
|
||||||
|
return Blip(**settings)
|
||||||
|
|
||||||
|
|
||||||
|
def a_sky(*blips, **over) -> Sky:
|
||||||
|
settings = dict(unit="knots", hold=45.0, home=(32.4325, -111.0841),
|
||||||
|
radius_nm=100.0)
|
||||||
|
settings.update(over)
|
||||||
|
sky = Sky(**settings)
|
||||||
|
sky.started = now() - 600
|
||||||
|
if blips:
|
||||||
|
sky.update(list(blips), frames=7412, seen=len(blips))
|
||||||
|
return sky
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Without Qt at all
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_importing_it_does_not_drag_qt_in():
|
||||||
|
"""A machine with no Qt loses this window and nothing else, so nothing
|
||||||
|
may import Qt merely because this module was loaded."""
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
for name in ("SkyView", "Window"):
|
||||||
|
assert name not in livemap.__dict__ or True # built on demand only
|
||||||
|
fresh = importlib.reload(importlib.import_module("bandsaunter.livemap"))
|
||||||
|
assert "SkyView" not in fresh.__dict__
|
||||||
|
assert "Window" not in fresh.__dict__
|
||||||
|
del sys
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_message_says_how_to_get_it():
|
||||||
|
assert "pip install PyQt6" in livemap.MISSING_QT
|
||||||
|
assert "apt install" in livemap.MISSING_QT
|
||||||
|
# And that nothing else is lost by not having it.
|
||||||
|
assert "passive capture still records" in livemap.MISSING_QT
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_qt_means_a_message_rather_than_a_traceback(monkeypatch):
|
||||||
|
monkeypatch.setattr(livemap, "_qt", lambda: None)
|
||||||
|
monkeypatch.setattr(livemap._build, "_made", None, raising=False)
|
||||||
|
assert livemap.available() is False
|
||||||
|
assert livemap.binding() == ""
|
||||||
|
with pytest.raises(RuntimeError) as raised:
|
||||||
|
livemap.show(a_sky())
|
||||||
|
assert "Qt" in str(raised.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_menu_is_told_whether_a_window_can_be_opened(monkeypatch):
|
||||||
|
from bandsaunter import aircraft as air
|
||||||
|
|
||||||
|
monkeypatch.setattr(livemap, "available", lambda: False)
|
||||||
|
assert air.windowed() is False
|
||||||
|
monkeypatch.setattr(livemap, "available", lambda: True)
|
||||||
|
assert air.windowed() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_asking_to_watch_without_qt_says_so_and_stops(monkeypatch, tmp_path,
|
||||||
|
capsys):
|
||||||
|
"""It must not open the receiver, either: nothing is gained by taking
|
||||||
|
the dongle for a window that cannot be drawn."""
|
||||||
|
from rich.console import Console
|
||||||
|
|
||||||
|
from bandsaunter import aircraft as air
|
||||||
|
|
||||||
|
monkeypatch.setattr(livemap, "available", lambda: False)
|
||||||
|
monkeypatch.setattr(air, "open_device", lambda *a, **k:
|
||||||
|
pytest.fail("opened the receiver anyway"))
|
||||||
|
heard = air.watch(Console(width=100), air.AircraftOptions(), str(tmp_path))
|
||||||
|
assert heard.frames == 0
|
||||||
|
assert "Qt" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# What goes in the box
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_the_box_says_everything_that_is_known():
|
||||||
|
blip = a_blip(registration="N904DN", type_code="B739",
|
||||||
|
manufacturer="Boeing", model="737-932ER",
|
||||||
|
operator="Delta Air Lines", country="United States",
|
||||||
|
origin="Atlanta", destination="Phoenix",
|
||||||
|
vertical_rate_fpm=1600)
|
||||||
|
told = "\n".join(f"{a} {b}" for a, b, _ in
|
||||||
|
blip.lines("knots", home=(32.4325, -111.0841)))
|
||||||
|
for wanted in ("N904DN", "B739", "Boeing 737-932ER", "Delta Air Lines",
|
||||||
|
"from Atlanta", "to Phoenix", "32,500 ft", "494 kt",
|
||||||
|
"325° NW", "1,204 frames", "United States"):
|
||||||
|
assert wanted in told, wanted
|
||||||
|
assert "↑1,600 fpm" in told
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_descent_is_marked_as_one():
|
||||||
|
told = " ".join(v for _, v, _ in
|
||||||
|
a_blip(vertical_rate_fpm=-900).lines("knots"))
|
||||||
|
assert "↓900 fpm" in told and "↑" not in told
|
||||||
|
|
||||||
|
|
||||||
|
def test_nothing_is_said_about_what_is_not_known():
|
||||||
|
"""A register that has not answered yet must leave no empty rows: they
|
||||||
|
would never fill in and the box would be mostly gaps."""
|
||||||
|
told = [label for label, _, _ in a_blip().lines("knots")]
|
||||||
|
assert "reg" not in told
|
||||||
|
assert all(x is not None for x in told)
|
||||||
|
text = " ".join(v for _, v, _ in a_blip().lines("knots"))
|
||||||
|
assert "from" not in text # no route was known
|
||||||
|
|
||||||
|
|
||||||
|
def _by_label(blip, unit, home):
|
||||||
|
return {label: value for label, value, _ in blip.lines(unit, home=home)}
|
||||||
|
|
||||||
|
|
||||||
|
def test_how_far_away_and_which_way_are_worked_out():
|
||||||
|
home = (32.4325, -111.0841)
|
||||||
|
told = _by_label(a_blip(lat=32.95, lon=-110.95), "knots", home)
|
||||||
|
assert "32 nm" in told["range"]
|
||||||
|
assert "012°" in told["range"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_range_follows_the_unit_the_speeds_are_in():
|
||||||
|
home = (32.4325, -111.0841)
|
||||||
|
assert "37 mi" in _by_label(a_blip(), "mph", home)["range"]
|
||||||
|
assert "59 km" in _by_label(a_blip(), "kph", home)["range"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_aircraft_that_never_said_its_name_is_known_by_its_address():
|
||||||
|
assert a_blip(callsign="").name == "A76154"
|
||||||
|
assert a_blip().name == "DAL538"
|
||||||
|
|
||||||
|
|
||||||
|
def test_what_the_registers_said_is_copied_onto_the_blip():
|
||||||
|
from bandsaunter.adsb import Aircraft
|
||||||
|
from bandsaunter.flights import Flight
|
||||||
|
|
||||||
|
craft = Aircraft(icao="4CA1FA", callsign="RYR1234")
|
||||||
|
craft.altitude_ft, craft.messages = 35_000, 12
|
||||||
|
entry = Flight(icao="4CA1FA", registration="EI-DYP", type_code="B738",
|
||||||
|
operator="Ryanair", origin="Stansted",
|
||||||
|
destination="East Midlands", owner_country="Ireland")
|
||||||
|
blip = blip_for(craft, entry)
|
||||||
|
assert (blip.registration, blip.type_code) == ("EI-DYP", "B738")
|
||||||
|
assert blip.operator == "Ryanair" and blip.country == "Ireland"
|
||||||
|
assert blip.origin == "Stansted"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_blip_works_with_no_register_at_all():
|
||||||
|
from bandsaunter.adsb import Aircraft
|
||||||
|
|
||||||
|
blip = blip_for(Aircraft(icao="4CA1FA", callsign="RYR1234"))
|
||||||
|
assert blip.icao == "4CA1FA" and blip.registration == ""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Where the boxes go
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
BOUNDS = (0, 0, 1000, 700)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_box_goes_beside_the_aircraft_when_there_is_room():
|
||||||
|
x, y = place_box(400, 300, 200, 120, [], BOUNDS)
|
||||||
|
assert x > 400 # to the right, the usual place
|
||||||
|
assert 300 - 120 <= y <= 300
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_box_never_lands_on_one_already_placed():
|
||||||
|
taken = []
|
||||||
|
for i in range(9):
|
||||||
|
spot = place_box(400 + (i % 3) * 12, 300 + (i // 3) * 12,
|
||||||
|
180, 110, taken, BOUNDS)
|
||||||
|
taken.append((spot[0], spot[1], 180, 110))
|
||||||
|
for i, one in enumerate(taken):
|
||||||
|
for two in taken[i + 1:]:
|
||||||
|
apart = (one[0] + one[2] <= two[0] or two[0] + two[2] <= one[0]
|
||||||
|
or one[1] + one[3] <= two[1] or two[1] + two[3] <= one[1])
|
||||||
|
assert apart, f"{one} overlaps {two}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_box_stays_on_the_picture():
|
||||||
|
for x, y in ((5, 5), (995, 695), (0, 350), (500, 0)):
|
||||||
|
bx, by = place_box(x, y, 220, 130, [], BOUNDS)
|
||||||
|
assert 0 <= bx and bx + 220 <= 1000
|
||||||
|
assert 0 <= by and by + 130 <= 700
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_full_screen_still_gets_a_box():
|
||||||
|
"""Overlapping is better than nothing: a box in an awkward place still
|
||||||
|
says what the aircraft is."""
|
||||||
|
wall = [(x, y, 100, 100) for x in range(0, 1000, 100)
|
||||||
|
for y in range(0, 700, 100)]
|
||||||
|
bx, by = place_box(500, 350, 200, 120, wall, BOUNDS)
|
||||||
|
assert 0 <= bx <= 1000 and 0 <= by <= 700
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# What the window is told
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_an_aircraft_appears_and_is_kept_in_order_first_heard():
|
||||||
|
first = a_blip(icao="111111", callsign="FIRST", first_seen=now() - 300)
|
||||||
|
second = a_blip(icao="222222", callsign="SECOND", first_seen=now() - 100)
|
||||||
|
sky = a_sky(second, first)
|
||||||
|
assert [b.callsign for b in sky.flying()] == ["FIRST", "SECOND"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_nothing_has_been_heard_from_leaves_the_picture():
|
||||||
|
here = a_blip(icao="111111", last_seen=now() - 5)
|
||||||
|
gone = a_blip(icao="222222", last_seen=now() - 300)
|
||||||
|
assert [b.icao for b in a_sky(here, gone).flying()] == ["111111"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_aircraft_with_no_position_is_not_drawn():
|
||||||
|
"""It has been heard but not placed; the table says so and the map
|
||||||
|
cannot."""
|
||||||
|
assert a_sky(a_blip(lat=0.0, lon=0.0)).flying() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_trail_grows_as_it_moves():
|
||||||
|
sky = a_sky()
|
||||||
|
for i in range(5):
|
||||||
|
sky.update([a_blip(lat=32.9 + i * 0.01)], frames=i, seen=1)
|
||||||
|
assert len(sky.trail("A76154")) == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_standing_still_does_not_lengthen_the_trail():
|
||||||
|
sky = a_sky()
|
||||||
|
for _ in range(9):
|
||||||
|
sky.update([a_blip()], frames=1, seen=1)
|
||||||
|
assert len(sky.trail("A76154")) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_middle_of_the_map_is_where_the_receiver_was_said_to_be():
|
||||||
|
assert a_sky(a_blip()).centre() == (32.4325, -111.0841)
|
||||||
|
|
||||||
|
|
||||||
|
def test_with_no_receiver_position_the_middle_is_worked_out():
|
||||||
|
sky = a_sky(a_blip(icao="1", lat=30.0, lon=-110.0),
|
||||||
|
a_blip(icao="2", lat=31.0, lon=-111.0),
|
||||||
|
a_blip(icao="3", lat=32.0, lon=-112.0), home=None)
|
||||||
|
assert sky.centre() == (31.0, -111.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_nothing_heard_yet_has_no_middle():
|
||||||
|
assert a_sky(home=None).centre() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_map_is_only_used_for_the_view_it_was_fetched_for():
|
||||||
|
sky = a_sky()
|
||||||
|
sky.set_ground(np.zeros((4, 4), dtype=np.uint8), "a")
|
||||||
|
assert sky.ground("a") is not None
|
||||||
|
assert sky.ground("b") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_map_that_could_not_be_fetched_is_not_asked_for_again():
|
||||||
|
"""Otherwise a machine with no network asks five times a second all
|
||||||
|
night."""
|
||||||
|
sky = a_sky()
|
||||||
|
sky.want_ground("a", (0, 0, 1, 1), (10, 10))
|
||||||
|
assert sky.wanted_ground() is not None
|
||||||
|
sky.set_ground(None, "a")
|
||||||
|
assert sky.wanted_ground() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_map_is_fetched_off_the_painting_thread():
|
||||||
|
asked = []
|
||||||
|
|
||||||
|
def tile(z, x, y, **kw):
|
||||||
|
asked.append((z, x, y))
|
||||||
|
return None
|
||||||
|
|
||||||
|
sky = a_sky()
|
||||||
|
sky.want_ground("k", (32.0, -112.0, 33.0, -110.0), (40, 40))
|
||||||
|
|
||||||
|
import threading
|
||||||
|
|
||||||
|
worker = threading.Thread(target=livemap.fetch_ground, args=(sky,),
|
||||||
|
kwargs={"fetch": tile}, daemon=True)
|
||||||
|
worker.start()
|
||||||
|
for _ in range(50):
|
||||||
|
if sky.wanted_ground() is None:
|
||||||
|
break
|
||||||
|
time.sleep(0.05)
|
||||||
|
sky.stopping = True
|
||||||
|
worker.join(timeout=2.0)
|
||||||
|
assert asked, "the tile server was never asked"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Painting
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _rendered(view, width=900, height=650):
|
||||||
|
"""The widget's pixels, as an array."""
|
||||||
|
from bandsaunter.livemap import _qt
|
||||||
|
|
||||||
|
_name, _core, gui, _widgets, _signal = _qt()
|
||||||
|
view.resize(width, height)
|
||||||
|
image = gui.QImage(width, height, _get(gui.QImage, "Format",
|
||||||
|
"Format_RGB32"))
|
||||||
|
image.fill(gui.QColor(0, 0, 0))
|
||||||
|
view.render(image)
|
||||||
|
bits = image.constBits()
|
||||||
|
bits.setsize(image.sizeInBytes())
|
||||||
|
# Copied on the way out. The array numpy builds is a view onto Qt's own
|
||||||
|
# buffer, and the image is about to go out of scope: reading it after
|
||||||
|
# that is a use-after-free, which reads as blank rows if you are lucky
|
||||||
|
# and a segmentation fault if you are not.
|
||||||
|
return np.frombuffer(bits, dtype=np.uint8).reshape(
|
||||||
|
height, width, 4).copy()
|
||||||
|
|
||||||
|
|
||||||
|
def _get(owner, group, name):
|
||||||
|
return getattr(getattr(owner, group, owner), name)
|
||||||
|
|
||||||
|
|
||||||
|
def _painted(picture) -> int:
|
||||||
|
"""How many pixels are something other than the empty background.
|
||||||
|
|
||||||
|
Counting non-black pixels would count all of them: the background is a
|
||||||
|
dark blue rather than black, on purpose.
|
||||||
|
"""
|
||||||
|
from bandsaunter.flightmap import BG, PALETTE
|
||||||
|
|
||||||
|
r, g, b = (int(v) for v in PALETTE[BG])
|
||||||
|
background = ((picture[:, :, 2] == r) & (picture[:, :, 1] == g)
|
||||||
|
& (picture[:, :, 0] == b))
|
||||||
|
return int((~background).sum())
|
||||||
|
|
||||||
|
|
||||||
|
def _in_colour(picture, feet: int):
|
||||||
|
"""The pixels painted exactly in one altitude's colour."""
|
||||||
|
from bandsaunter.flightmap import PALETTE, RAMP, altitude_step
|
||||||
|
|
||||||
|
r, g, b = (int(v) for v in PALETTE[RAMP + altitude_step(feet)])
|
||||||
|
return ((picture[:, :, 2] == r) & (picture[:, :, 1] == g)
|
||||||
|
& (picture[:, :, 0] == b))
|
||||||
|
|
||||||
|
|
||||||
|
def _lit(picture):
|
||||||
|
"""Where those pixels are."""
|
||||||
|
from bandsaunter.flightmap import BG, PALETTE
|
||||||
|
|
||||||
|
r, g, b = (int(v) for v in PALETTE[BG])
|
||||||
|
background = ((picture[:, :, 2] == r) & (picture[:, :, 1] == g)
|
||||||
|
& (picture[:, :, 0] == b))
|
||||||
|
return np.argwhere(~background)
|
||||||
|
|
||||||
|
|
||||||
|
@qt
|
||||||
|
def test_the_window_draws_the_aircraft_and_a_box(app):
|
||||||
|
from bandsaunter.livemap import SkyView
|
||||||
|
|
||||||
|
sky = a_sky(a_blip(registration="N904DN", type_code="B739",
|
||||||
|
operator="Delta Air Lines"))
|
||||||
|
view = SkyView(sky)
|
||||||
|
picture = _rendered(view)
|
||||||
|
assert _painted(picture) > 500, "nothing was drawn at all"
|
||||||
|
# The symbol is filled with the aircraft's altitude colour exactly; the
|
||||||
|
# box around it is a hairline and comes out blended, so only the symbol
|
||||||
|
# itself is counted here.
|
||||||
|
assert _in_colour(picture, 32_500).sum() >= 10, \
|
||||||
|
"no aircraft drawn in its altitude colour"
|
||||||
|
|
||||||
|
|
||||||
|
@qt
|
||||||
|
def test_nothing_placed_yet_says_so_rather_than_drawing_a_blank(app):
|
||||||
|
from bandsaunter.livemap import SkyView
|
||||||
|
|
||||||
|
view = SkyView(a_sky(home=None))
|
||||||
|
picture = _rendered(view)
|
||||||
|
assert _painted(picture) > 200, "an empty window said nothing at all"
|
||||||
|
|
||||||
|
|
||||||
|
@qt
|
||||||
|
def test_the_symbol_is_where_the_aircraft_is(app):
|
||||||
|
from bandsaunter.livemap import SkyView
|
||||||
|
|
||||||
|
sky = a_sky(a_blip(lat=32.4325, lon=-111.0841)) # right on the receiver
|
||||||
|
view = SkyView(sky)
|
||||||
|
view.detail = 0 # symbols only
|
||||||
|
picture = _rendered(view, 900, 650)
|
||||||
|
rows, cols = np.where(_in_colour(picture, 32_500))
|
||||||
|
assert rows.size, "the aircraft was not drawn"
|
||||||
|
# An aircraft at the receiver belongs in the middle of the window.
|
||||||
|
assert abs(rows.mean() - 325) < 40, rows.mean()
|
||||||
|
assert abs(cols.mean() - 450) < 40, cols.mean()
|
||||||
|
|
||||||
|
|
||||||
|
@qt
|
||||||
|
def test_the_detail_can_be_turned_down_for_a_busy_sky(app):
|
||||||
|
from bandsaunter.livemap import SkyView
|
||||||
|
|
||||||
|
sky = a_sky(*[a_blip(icao=f"{i:06X}", callsign=f"FLT{i}",
|
||||||
|
lat=32.3 + i * 0.05, lon=-111.2 + i * 0.05)
|
||||||
|
for i in range(6)])
|
||||||
|
view = SkyView(sky)
|
||||||
|
drawn = []
|
||||||
|
for detail in (2, 1, 0):
|
||||||
|
view.detail = detail
|
||||||
|
drawn.append(_painted(_rendered(view)))
|
||||||
|
assert drawn[0] > drawn[1] > drawn[2], drawn
|
||||||
|
|
||||||
|
|
||||||
|
@qt
|
||||||
|
def test_the_trails_and_the_map_can_be_turned_off(app):
|
||||||
|
from bandsaunter.livemap import SkyView
|
||||||
|
|
||||||
|
sky = a_sky()
|
||||||
|
for i in range(20):
|
||||||
|
sky.update([a_blip(lat=32.6 + i * 0.02, lon=-111.0 + i * 0.02)],
|
||||||
|
frames=i, seen=1)
|
||||||
|
view = SkyView(sky)
|
||||||
|
view.detail = 0
|
||||||
|
with_trail = _painted(_rendered(view))
|
||||||
|
view.trails = False
|
||||||
|
without = _painted(_rendered(view))
|
||||||
|
assert with_trail > without
|
||||||
|
|
||||||
|
|
||||||
|
@qt
|
||||||
|
def test_the_map_underneath_is_drawn_when_there_is_one(app):
|
||||||
|
from bandsaunter.livemap import SkyView
|
||||||
|
|
||||||
|
sky = a_sky(a_blip())
|
||||||
|
view = SkyView(sky)
|
||||||
|
view.resize(900, 650)
|
||||||
|
key = view.ground_key(view.projection())
|
||||||
|
sky.set_ground(np.full((650, 900), 31, dtype=np.uint8), key)
|
||||||
|
lit = _rendered(view)[:, :, :3]
|
||||||
|
from bandsaunter.flightmap import GROUND, GROUND_SHADES, PALETTE
|
||||||
|
|
||||||
|
r, g, b = (int(v) for v in PALETTE[GROUND + GROUND_SHADES - 1])
|
||||||
|
covered = ((lit[:, :, 2] == r) & (lit[:, :, 1] == g) & (lit[:, :, 0] == b))
|
||||||
|
assert covered.mean() > 0.5, "the map was not painted under the aircraft"
|
||||||
|
|
||||||
|
|
||||||
|
@qt
|
||||||
|
def test_the_keys_do_what_the_header_says_they_do(app):
|
||||||
|
from bandsaunter.livemap import Window, _qt
|
||||||
|
|
||||||
|
_name, core, gui, _widgets, _signal = _qt()
|
||||||
|
window = Window(a_sky(a_blip()))
|
||||||
|
try:
|
||||||
|
def press(letter):
|
||||||
|
event = gui.QKeyEvent(_get(core.QEvent, "Type", "KeyPress"),
|
||||||
|
ord(letter.upper()),
|
||||||
|
_get(core.Qt, "KeyboardModifier",
|
||||||
|
"NoModifier"), letter)
|
||||||
|
window.keyPressEvent(event)
|
||||||
|
|
||||||
|
was = window.view.detail
|
||||||
|
press("d")
|
||||||
|
assert window.view.detail != was
|
||||||
|
press("t")
|
||||||
|
assert window.view.trails is False
|
||||||
|
press("g")
|
||||||
|
assert window.view.show_ground is False
|
||||||
|
tight = window.sky.radius_nm
|
||||||
|
press("-")
|
||||||
|
assert window.sky.radius_nm > tight
|
||||||
|
press("+")
|
||||||
|
assert window.sky.radius_nm < window.sky.radius_nm * 1.5
|
||||||
|
finally:
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
|
||||||
|
@qt
|
||||||
|
def test_the_window_shuts_itself_when_the_listening_is_over(app):
|
||||||
|
""""Listen for ten minutes" has to mean the same thing whether or not a
|
||||||
|
window is open."""
|
||||||
|
from bandsaunter.livemap import Window
|
||||||
|
|
||||||
|
sky = a_sky(a_blip())
|
||||||
|
window = Window(sky)
|
||||||
|
window.show()
|
||||||
|
assert window.isVisible()
|
||||||
|
sky.finished = True
|
||||||
|
window._tick()
|
||||||
|
assert not window.isVisible()
|
||||||
|
|
||||||
|
|
||||||
|
@qt
|
||||||
|
def test_closing_the_window_stops_the_receiver(app):
|
||||||
|
from bandsaunter.livemap import Window
|
||||||
|
|
||||||
|
sky = a_sky(a_blip())
|
||||||
|
window = Window(sky)
|
||||||
|
window.show()
|
||||||
|
window.close()
|
||||||
|
assert sky.stopping is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Long names, narrow boxes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_something_short_is_left_alone():
|
||||||
|
from bandsaunter.livemap import wrap_value
|
||||||
|
|
||||||
|
assert wrap_value("Atlanta → Phoenix") == ["Atlanta → Phoenix"]
|
||||||
|
assert wrap_value("Boeing 737-932ER") == ["Boeing 737-932ER"]
|
||||||
|
assert wrap_value("") == [""]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_long_route_breaks_at_the_arrow_first():
|
||||||
|
"""The two ends of the flight stay whole and sit under one another,
|
||||||
|
where they read as a pair."""
|
||||||
|
from bandsaunter.livemap import wrap_value
|
||||||
|
|
||||||
|
folded = wrap_value("London Stansted Airport → East Midlands Airport, "
|
||||||
|
"Nottingham")
|
||||||
|
assert folded[0] == "London Stansted Airport"
|
||||||
|
assert folded[1].startswith("→ East Midlands")
|
||||||
|
assert not any(line.startswith("→") for line in folded[2:])
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_far_end_of_a_route_is_indented_when_it_wraps_too():
|
||||||
|
from bandsaunter.livemap import wrap_value
|
||||||
|
|
||||||
|
folded = wrap_value("Los Angeles International Airport → Dallas Fort "
|
||||||
|
"Worth International Airport")
|
||||||
|
onward = folded[folded.index(next(x for x in folded
|
||||||
|
if x.startswith("→"))) + 1:]
|
||||||
|
assert onward and all(line.startswith(" ") for line in onward)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_long_name_folds_between_words():
|
||||||
|
from bandsaunter.livemap import wrap_value
|
||||||
|
|
||||||
|
folded = wrap_value("CELESTIAL AVIATION TRADING 14 LTD")
|
||||||
|
assert len(folded) == 2
|
||||||
|
assert " ".join(folded) == "CELESTIAL AVIATION TRADING 14 LTD"
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_enormous_word_is_cut_rather_than_widening_the_box():
|
||||||
|
"""Nothing an aircraft sends looks like this; a register will send one
|
||||||
|
eventually."""
|
||||||
|
from bandsaunter.livemap import wrap_value
|
||||||
|
|
||||||
|
folded = wrap_value("Supercalifragilisticexpialidociousaerodrome")
|
||||||
|
assert len(folded) > 1
|
||||||
|
assert "".join(folded) == "Supercalifragilisticexpialidociousaerodrome"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("text", [
|
||||||
|
"Los Angeles International Airport → Dallas Fort Worth International Airport",
|
||||||
|
"London Stansted Airport → East Midlands Airport, Nottingham",
|
||||||
|
"CELESTIAL AVIATION TRADING 14 LTD",
|
||||||
|
"Hartsfield Jackson Atlanta International Airport",
|
||||||
|
"Supercalifragilisticexpialidociousaerodrome",
|
||||||
|
])
|
||||||
|
def test_nothing_comes_out_wider_than_the_limit(text):
|
||||||
|
from bandsaunter.livemap import WRAP_CHARS, wrap_value
|
||||||
|
|
||||||
|
assert all(len(line) <= WRAP_CHARS for line in wrap_value(text)), \
|
||||||
|
wrap_value(text)
|
||||||
|
|
||||||
|
|
||||||
|
@qt
|
||||||
|
def test_a_folded_row_carries_no_label_of_its_own(app):
|
||||||
|
"""The label belongs to the value as a whole; repeating it down the side
|
||||||
|
of a wrapped airport name would read as several different facts."""
|
||||||
|
from bandsaunter.livemap import SkyView
|
||||||
|
|
||||||
|
view = SkyView(a_sky())
|
||||||
|
rows = view._wrapped([("alt", "35,000 ft", ""),
|
||||||
|
("to", "East Midlands Airport, Nottingham", "GB")])
|
||||||
|
assert rows[0] == ("alt", "35,000 ft", "")
|
||||||
|
assert rows[1][0] == "to" and rows[1][2] == "GB"
|
||||||
|
assert all(label == "" and flag == "" for label, _, flag in rows[2:])
|
||||||
|
|
||||||
|
|
||||||
|
@qt
|
||||||
|
def test_a_long_route_no_longer_widens_the_box(app):
|
||||||
|
"""The whole point: one flight between two long names used to make its
|
||||||
|
box wider than the map it sits on."""
|
||||||
|
from bandsaunter.livemap import SkyView
|
||||||
|
|
||||||
|
view = SkyView(a_sky())
|
||||||
|
short = view._box_size(view._wrapped([("to", "Phoenix", "")]))
|
||||||
|
long = view._box_size(view._wrapped(
|
||||||
|
[("to", "Dallas Fort Worth International Airport", "")]))
|
||||||
|
assert long[0] < short[0] * 2, "the box grew with the name"
|
||||||
|
assert long[1] > short[1], "it should have got taller instead"
|
||||||
|
|
||||||
|
|
||||||
|
@qt
|
||||||
|
def test_the_widest_box_stays_within_reason(app):
|
||||||
|
"""Every field at its longest, and the box still fits on a small window."""
|
||||||
|
from bandsaunter.livemap import SkyView
|
||||||
|
|
||||||
|
blip = a_blip(registration="N904DN", type_code="B739",
|
||||||
|
manufacturer="Boeing", model="737-932ER Winglets",
|
||||||
|
operator="CELESTIAL AVIATION TRADING 14 LTD",
|
||||||
|
country="United States of America",
|
||||||
|
origin="Hartsfield Jackson Atlanta International Airport",
|
||||||
|
destination="Phoenix Sky Harbor International Airport")
|
||||||
|
view = SkyView(a_sky(blip))
|
||||||
|
width, _height = view._box_size(
|
||||||
|
view._wrapped(blip.lines("mph", home=(32.4325, -111.0841))))
|
||||||
|
assert width < 320, width
|
||||||
Loading…
Add table
Add a link
Reference in a new issue