Compare commits
No commits in common. "8ed01f991fa3011173ff9006def7b0370c9e937b" and "4239635f7469f091f10a6e0262ed0eec56b62973" have entirely different histories.
8ed01f991f
...
4239635f74
38 changed files with 400 additions and 15463 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -16,6 +16,3 @@ recordings/
|
|||
# local environments
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# a personal helper, not part of the program
|
||||
resume.sh
|
||||
|
|
|
|||
119
INSTALL.md
119
INSTALL.md
|
|
@ -59,13 +59,6 @@ model into a small apt repository under `dist/repo`, so that `apt install
|
|||
bandsaunter` on every other machine brings transcription with it. See
|
||||
**Install → From your own apt repository** in the README.
|
||||
|
||||
It needs two more tools than `build-deb.sh` does, neither of which is on a
|
||||
minimal system:
|
||||
|
||||
```bash
|
||||
sudo apt install dpkg-dev apt-utils # dpkg-scanpackages and apt-ftparchive
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## B. Anywhere else: a virtual environment
|
||||
|
|
@ -87,10 +80,8 @@ python3 -m venv .venv
|
|||
pip install . # a few wheels; under a minute
|
||||
```
|
||||
|
||||
That installs seven wheels — numpy, scipy, rich and PyYAML, plus the three
|
||||
Rich brings with it (Pygments, markdown-it-py, mdurl) — and puts two commands
|
||||
on the path, `bandsaunter` and `saunterbrowse`. Nothing is built from source,
|
||||
and it takes under a minute.
|
||||
That installs four wheels — numpy, scipy, rich, PyYAML — and puts two commands
|
||||
on the path, `bandsaunter` and `saunterbrowse`. Nothing is built from source.
|
||||
|
||||
Use `pip install -e .` instead if you intend to change the code.
|
||||
|
||||
|
|
@ -132,120 +123,26 @@ if not.
|
|||
|
||||
---
|
||||
|
||||
## Every dependency, in one table
|
||||
## Optional dependencies
|
||||
|
||||
**Required.** Four Python packages and one system library. The `.deb` and
|
||||
`pip install` both pull the Python ones in; the system library is the only
|
||||
thing either way that has to come from your distribution.
|
||||
|
||||
| | Debian/Ubuntu/Mint | Fedora | Arch | what needs it |
|
||||
|---|---|---|---|---|
|
||||
| Python 3.10+ | `python3` | `python3` | `python` | everything |
|
||||
| NumPy | `python3-numpy` | `python3-numpy` | `python-numpy` | every signal path, every picture |
|
||||
| SciPy | `python3-scipy` | `python3-scipy` | `python-scipy` | filtering, demodulation, classifying |
|
||||
| Rich | `python3-rich` | `python3-rich` | `python-rich` | the menus and the live display |
|
||||
| PyYAML | `python3-yaml` | `python3-pyyaml` | `python-yaml` | the settings file |
|
||||
| **librtlsdr** | `librtlsdr0` | `rtl-sdr` | `rtl-sdr` | **talking to the dongle** |
|
||||
|
||||
**`librtlsdr` is the one manual dependency that matters.** It is a C library,
|
||||
not a Python package, so `pip` cannot install it and no virtual environment
|
||||
brings it with it. bandsaunter opens it with `ctypes` at runtime, trying
|
||||
`librtlsdr.so.2`, `.so.0`, `.so`, `librtlsdr.dylib`, `rtlsdr.dll` and
|
||||
`librtlsdr.dll` in that order.
|
||||
Without it everything that does not touch the hardware still works — reading
|
||||
logs, drawing maps, decoding recordings, the simulated sky — and anything that
|
||||
does says so plainly instead of failing:
|
||||
|
||||
```sh
|
||||
sudo apt install librtlsdr0 # Debian, Ubuntu, Mint
|
||||
sudo dnf install rtl-sdr # Fedora
|
||||
sudo pacman -S rtl-sdr # Arch
|
||||
brew install librtlsdr # macOS
|
||||
```
|
||||
|
||||
**Optional.** Everything below is genuinely optional. The program starts,
|
||||
scans, records, identifies, decodes Morse, draws waterfalls and maps with none
|
||||
of it, and says plainly when a feature is unavailable rather than failing.
|
||||
Everything below is genuinely optional. The program starts, scans, records,
|
||||
identifies, decodes Morse and draws waterfalls with none of it, and says
|
||||
plainly when a feature is unavailable rather than failing.
|
||||
|
||||
| Install | Gives you | Without it |
|
||||
|---|---|---|
|
||||
| `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 |
|
||||
| `sudo apt install ffmpeg` | `bandsaunter flights --out sky.mp4` writes a video | a GIF is written instead, and it says so |
|
||||
| `sudo apt install espeak-ng` | clearer spoken timestamps on combined recordings, rendered about three times faster | a built-in formant synthesiser does the same job, less clearly |
|
||||
| `sudo apt install rtl-sdr` | `rtl_test`, `rtl_sdr` and friends, for diagnosing the hardware | nothing missing from bandsaunter itself |
|
||||
| `sudo apt install ffmpeg` | `bandsaunter flights --out sky.mp4` writes a video | a GIF is written instead, and it says so |
|
||||
| `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 vosk` | a smaller, weaker recogniser (~10 MB plus a 40 MB model) | as above |
|
||||
| `pip install openai-whisper` | the reference Whisper, slower and heavier than faster-whisper | as above |
|
||||
| `pip install pocketsphinx` | a tiny recogniser, poor on radio audio | as above |
|
||||
| whisper.cpp (`whisper-cli` on the PATH) | transcription with no Python dependencies at all | as above |
|
||||
| `pip install pyte` | the terminal-resize tests | those tests skip |
|
||||
| `pip install pytest` | running the test suite | you cannot run the tests |
|
||||
|
||||
`bandsaunter transcribe --list` says which recognisers it can actually see,
|
||||
and which one it would use.
|
||||
|
||||
**Nothing is downloaded behind your back.** The things that reach a network do
|
||||
so only when you ask, and each is cached on disk afterwards:
|
||||
|
||||
| What | Where from | When | Kept in |
|
||||
|---|---|---|---|
|
||||
| aircraft and route lookups | `api.adsbdb.com`, `hexdb.io` | while listening, unless `--no-lookup` | `~/.cache/bandsaunter/flights.json`, a month |
|
||||
| amateur callsign lookups | `callook.info`, `api.hamdb.org` | when a scan hears a callsign, unless turned off | `~/.cache/bandsaunter/callsigns.json`, a month |
|
||||
| map tiles | `tile.openstreetmap.org`, or `--tiles URL` | when a map is drawn, unless `--no-basemap` | `~/.cache/bandsaunter/tiles`, for ever |
|
||||
| aerodrome positions | `overpass-api.de` | when a map is drawn, unless `--no-airports` | `~/.cache/bandsaunter/airports`, a month |
|
||||
| flight schedules | the four paid services below | only if you have set a key | a month |
|
||||
|
||||
Every request identifies itself as `bandsaunter` and carries nothing but the
|
||||
question — a callsign, a 24-bit address, or a box of the world. No identity,
|
||||
no position, no key.
|
||||
|
||||
**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
|
||||
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
|
||||
the callsign say on their own is worked out offline either way.
|
||||
|
||||
### Optional: a paid schedule service
|
||||
|
||||
Nothing here needs installing either — these are accounts, not packages, and
|
||||
all four are optional. They answer the one question the free registers cannot:
|
||||
an airline runs the same flight number over several legs in a day, and a free
|
||||
register holds one route per number, so it will often name somebody else's
|
||||
leg. A schedule service holds the day's actual movements.
|
||||
|
||||
| Service | Sign up at | Set |
|
||||
|---|---|---|
|
||||
| FlightAware AeroAPI | <https://www.flightaware.com/commercial/aeroapi/> | `BANDSAUNTER_AEROAPI_KEY` |
|
||||
| Flightradar24 | <https://fr24api.flightradar24.com/> | `BANDSAUNTER_FR24_TOKEN` |
|
||||
| OAG Flight Info | <https://developer.oag.com/> | `BANDSAUNTER_OAG_KEY` |
|
||||
| Cirium (FlightStats) | <https://developer.cirium.com/> | `BANDSAUNTER_CIRIUM_APP_ID` and `BANDSAUNTER_CIRIUM_APP_KEY` |
|
||||
|
||||
Put the ones you have in your shell profile:
|
||||
|
||||
```sh
|
||||
echo 'export BANDSAUNTER_AEROAPI_KEY=your-key-here' >> ~/.bashrc
|
||||
. ~/.bashrc
|
||||
```
|
||||
|
||||
**Keys are read from the environment and never written to the settings file**,
|
||||
on purpose: a settings file gets copied between machines and pasted into
|
||||
messages asking for help, and an API key should not travel that way.
|
||||
|
||||
Any service whose key is set is asked; one whose key is not set is skipped
|
||||
silently, and the free registers answer exactly as they did before.
|
||||
`--schedules flightaware,oag` picks which to ask and in what order. Only the
|
||||
callsign and the time are ever sent.
|
||||
|
||||
These readers were written from each service's published response format and
|
||||
tested against it, but none has been run against a live service, because each
|
||||
one needs a paid account. Each is written to return nothing rather than guess,
|
||||
so a service that has changed its format costs you a route, not a scan.
|
||||
|
||||
---
|
||||
|
||||
## Speech transcription, step by step
|
||||
|
|
|
|||
681
README.md
681
README.md
|
|
@ -111,16 +111,9 @@ be built.
|
|||
| **required** | Rich | `python3-rich` | `python3-rich` | `python-rich` | menus and the live display |
|
||||
| **required** | PyYAML | `python3-yaml` | `python3-pyyaml` | `python-yaml` | settings file and profiles |
|
||||
| *recommended* | eSpeak NG | `espeak-ng` | `espeak-ng` | `espeak-ng` | clearer spoken timestamps |
|
||||
| *optional* | Qt | `python3-pyqt6` | `python3-pyqt6` | `python-pyqt6` | the realtime aircraft window |
|
||||
| *optional* | ffmpeg | `ffmpeg` | `ffmpeg` | `ffmpeg` | writing `.mp4` instead of `.gif` |
|
||||
| *optional* | rtl-sdr tools | `rtl-sdr` | `rtl-sdr` | `rtl-sdr` | `rtl_test` and friends for diagnosis |
|
||||
| *optional* | a speech recogniser | **pip only** | **pip only** | AUR | transcribing speech to text |
|
||||
|
||||
**Only `librtlsdr` cannot come from pip.** It is a C library, so no virtual
|
||||
environment brings it with it, and it is the one thing you have to install
|
||||
from your distribution by hand. Everything that does not touch the hardware
|
||||
works without it — reading logs, drawing maps, decoding recordings, the
|
||||
simulated sky — and anything that does says so plainly rather than failing.
|
||||
| *optional* | Matplotlib | `python3-matplotlib` | `python3-matplotlib` | `python-matplotlib` | nothing yet; reserved for plots |
|
||||
|
||||
Two notes on the optional ones:
|
||||
|
||||
|
|
@ -956,229 +949,6 @@ bandsaunter adsb --simulate # invent a sky, for a receiver with no aerial
|
|||
bandsaunter flights # read the log back: report, map, animation
|
||||
```
|
||||
|
||||
While it listens, the screen is a live board of what is overhead:
|
||||
|
||||
```
|
||||
╭─────────────────────────────────────────────────────────────────────────────╮
|
||||
│ 1090 MHz 6 overhead 9 seen 1,284 frames 19/s 0:04:31 control-C │
|
||||
╰─────────────────────────────────────────────────────────────────────────────╯
|
||||
callsign ICAO aircraft altitude speed kt track position frames last
|
||||
BAW49 4008F6 B744 G-VROS 33,025↑ 480 300° WNW 48.2775,-121.8050 1,204 0s
|
||||
ASA412 A24C71 B738 N625AS 12,400↓ 310 155° SSE 47.3323,-122.7387 412 1s
|
||||
N517HP A6F109 R44 N517HP 1,200 95 020° NNE 47.6485,-122.2647 88 2s
|
||||
```
|
||||
|
||||
One line per aircraft, in the order they were first heard. **The counter
|
||||
climbs as frames arrive**, altitude is coloured low-warm to high-cold with an
|
||||
arrow for climb or descent, and the age of the last frame goes green → yellow
|
||||
→ red. When nothing has been heard from an aircraft for `hold` seconds
|
||||
(45 by default) its line is removed and everything below moves up — the board
|
||||
is the sky now, not a list of everything ever heard. Nothing is lost by it:
|
||||
the log has every frame and the report at the end lists every aircraft.
|
||||
|
||||
The registers are asked *while* it listens, so the registration, type,
|
||||
operator and route fill themselves in on the line as the answers arrive. A
|
||||
narrow terminal drops the columns a website supplied and keeps the ones only
|
||||
the aircraft can give. `--frames` prints the raw stream instead, and a pipe
|
||||
or a log file gets a plain running count rather than a display that redraws
|
||||
four times a second.
|
||||
|
||||
**Speeds in whatever you read in.** `--speed-unit knots|mph|kph` (or the
|
||||
option in the menu) changes the column heading on the live display, the speed
|
||||
written beside every aircraft on the map, and the speeds in the report — and
|
||||
it moves the distances with them, so a map labelled in mph has a scale bar in
|
||||
statute miles and one in kph has kilometres, rather than two different miles
|
||||
on one picture. **The log always keeps knots**, because that is what the
|
||||
aircraft broadcast: the recording stays the thing that arrived, and the
|
||||
conversion happens at the moment of showing it to somebody.
|
||||
|
||||
### 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.
|
||||
|
||||
**A box that has to move swings there rather than jumping.** Two things make
|
||||
one move: an aircraft flies into the space its neighbour's box was using, or
|
||||
a new one arrives and claims it. Recomputing the layout every frame and
|
||||
drawing the answer means boxes teleport, and the eye reads a thing that
|
||||
teleports as a different thing — on a busy screen several do it at once.
|
||||
|
||||
So a box keeps the spot it has for as long as that spot still works, and is
|
||||
only moved when something genuinely takes it. On a real evening's log that is
|
||||
about a third as many moves as placing each box afresh every frame. The moves
|
||||
that are left are eased over about half a second, and the picture redraws at
|
||||
thirty frames a second for as long as anything is actually moving, dropping
|
||||
back to its usual five the moment everything has settled.
|
||||
|
||||
The spot a box is *heading for* is what the next box is laid out against, not
|
||||
the place it has got to so far — laying out against a box in mid-swing would
|
||||
move its neighbours too, and move them back when it arrived, and the screen
|
||||
would never settle. A box on the move is drawn last, over the ones standing
|
||||
still, so it stays readable while it crosses them. The box is remembered as an
|
||||
offset from its aircraft, so one crossing the window carries its box along
|
||||
without that counting as a move at all.
|
||||
|
||||
Thirty frames a second is affordable because **the ground is dimmed once and
|
||||
kept**. Cutting the view out of the fetched map, dimming it and looking every
|
||||
level up in the palette is about 70 ms over two megapixels, and none of it
|
||||
changes between one frame and the next unless the view, the window, the
|
||||
brightness or the map itself has. Doing it every frame put a ceiling of a
|
||||
dozen frames a second on the window at 1920×1080 and spent a whole core
|
||||
holding it there; keeping it takes the same window from 87 ms a frame to 14.
|
||||
|
||||
**The line from a box to its aircraft is dashed, and is its own colour**, on
|
||||
the animated pictures as well as here. It used to be drawn in the aircraft's
|
||||
own colour, which made it the same colour as that aircraft's trail — and a straight solid line running out of an
|
||||
aeroplane, in the colour of the path behind the aeroplane, reads as more path.
|
||||
On a busy picture that is a heading nobody flew. Dashes and a neutral colour
|
||||
say "this box belongs to that aeroplane" instead, which is all it was ever
|
||||
meant to say. Qt measures a dash pattern in multiples of the pen's width, so
|
||||
each pass of the glow divides the pattern by its own width; without that the
|
||||
halo's dashes are three times the core's and the line comes out as beads.
|
||||
|
||||
**The animation had no such line at all** until now — a label pushed out into
|
||||
one of the outward rings by a crowd had nothing tying it to the aeroplane it
|
||||
was about. It has one now, dashed and in the same colour, walked along the
|
||||
line's own length rather than along whichever axis is longer so that a nearly
|
||||
horizontal leader and a nearly vertical one get dashes of the same length
|
||||
instead of one of them turning into a dotted line.
|
||||
|
||||
**A red flag stands where the receiver is**, on the window and on the animated
|
||||
pictures alike, from the coordinates in the settings (`--at`, or **Receiver
|
||||
position** in the menu). The foot of the pole is the position and the pennant
|
||||
flies up and to the right of it, so nothing the flag is made of covers the
|
||||
place it points at. It is pure red in every theme — "you are here" is the one
|
||||
mark whose meaning must not change with the colours, and pure red is both the
|
||||
brightest red there is and the one furthest from every altitude colour in
|
||||
every theme. A softer red sat close enough to a low aeroplane on the default
|
||||
map, and to a mid-altitude one on the red theme, to be taken for one.
|
||||
|
||||
**Nothing is ever drawn over the flag.** It goes down after everything else on
|
||||
both pictures — after the aircraft, their trails and their boxes — because it
|
||||
says where the receiver is standing, and that is the one mark that must not
|
||||
end up behind an aeroplane that happened to fly over it. The halo of a vector
|
||||
theme cannot cover it either: a halo only ever goes on the ground, the grid
|
||||
and the empty background.
|
||||
|
||||
The flag is drawn **only where the receiver was actually told where it is**.
|
||||
Without a position the middle of the picture is worked out from whatever flew
|
||||
past, which is not a place anybody is standing, and a flag on it would say
|
||||
that somebody is.
|
||||
|
||||
**`--box-opacity PERCENT`** (85 by default) is how solid the card behind each
|
||||
information box is. The words beside an aircraft are readable over water and
|
||||
not over a city, so a card goes behind them; at 0 they sit straight on the map
|
||||
and at 100 the map does not show through at all. An indexed picture cannot
|
||||
blend, so in the animation this darkens the ground under the box instead —
|
||||
which leaves the coastline faintly visible through it, where a flat wash would
|
||||
not. The animated pictures had **no card at all** before this, so `0` is what
|
||||
they used to look like.
|
||||
|
||||
**Range rings** put faint discs at a quarter, a half and three quarters of the
|
||||
radius, concentric on the receiver and each labelled with its distance. They
|
||||
are translucent and they stack, so the ground inside the innermost is lifted
|
||||
three times, the next twice, the outer once. What that gives is a sense of how
|
||||
far away something is without measuring anything: an aircraft two shades in is
|
||||
about halfway to the edge of what this receiver hears.
|
||||
|
||||
An indexed picture cannot blend, so "translucent" in the animation means
|
||||
moving the ground under the disc a step or two up its own ramp of shades —
|
||||
which keeps the coastline and the roads visible through it, where a flat wash
|
||||
of one colour would not. The window has real alpha and simply paints one.
|
||||
|
||||
They need a receiver position and a radius and are not drawn without both, and
|
||||
each is a separate setting: `--rings` / `--no-rings` for the pictures,
|
||||
`--window-rings` / `--no-window-rings` for the window, both also in the ADS-B
|
||||
options menu. A picture is studied and a window is glanced at, and the rings
|
||||
help one more than the other depending which you are doing.
|
||||
|
||||
**The aerodromes are marked here too**, in the same colour and with the same
|
||||
square as the animation draws them. They are asked for once per area, on the
|
||||
thread that fetches the tiles but not behind them — they used to be fetched
|
||||
only in the same pass as a piece of map, which meant they queued behind a
|
||||
hundred and twenty tiles coming off a network, and once the map was in hand
|
||||
there were no more passes and they were never fetched at all.
|
||||
|
||||
`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, `[`/`]`
|
||||
its brightness, `+`/`-` the range, `q` closes it.
|
||||
|
||||
Aircraft fade out here too, over `--fade` seconds, keeping their symbol and
|
||||
losing their box as they go — a box at a tenth of its colour is something in
|
||||
the way of the aircraft still flying. One that has gone quiet stops being
|
||||
counted as overhead, since saying it is would be saying more than was heard.
|
||||
|
||||
The map holds still. It is fetched an eighth larger than the window in every
|
||||
direction, and the middle of the view settles once and then stays put rather
|
||||
than being recomputed as aircraft come and go — otherwise the view shifts by a
|
||||
fraction of a mile every few seconds, throws away the tiles fetched for the
|
||||
old one, and the ground blinks out while new ones arrive.
|
||||
|
||||
**Closing the window leaves exactly the files a passive capture does**: the
|
||||
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).** `p` starts a
|
||||
passive capture, `r` opens the realtime window and `m` draws a map from a log
|
||||
— no flags to remember, and the options can be saved as the default.
|
||||
|
||||
The options are in six groups rather than one list, because thirty-three of
|
||||
them on one screen is a wall rather than a menu:
|
||||
|
||||
| | | |
|
||||
| --- | --- | --- |
|
||||
| **receiver** | the dongle, and where it is standing | device, gain, sample rate, position, the simulated sky |
|
||||
| **listening** | what one session does | how long, the log, KML, how long an aircraft stays up, draw when finished |
|
||||
| **aircraft** | who they are | the registers, the schedule services, rechecking positions |
|
||||
| **animation** | the moving picture | kind, length, speed, frame rate, width, trails, fading |
|
||||
| **the map** | what is under and around them | tiles, theme, brightness, radius, aerodromes, range rings |
|
||||
| **labels** | what is written beside them | the labels themselves, and the unit |
|
||||
|
||||
A number opens a group; inside it, a number changes an option and `?N`
|
||||
explains any of them at length. The numbers are the option's place in the
|
||||
whole list, so the same number means the same option wherever it is typed.
|
||||
|
||||
**Or type the name.** `map brightness` at the top of the menu goes straight to
|
||||
that option, and part of a name lists everything it could mean. A name that
|
||||
matches exactly wins outright, so `speed` reaches the setting called speed
|
||||
rather than that one and every other whose description mentions the word.
|
||||
|
||||
> **This is not a scan, and the band plan's `adsb` preset will not do it.**
|
||||
> Sweeping 1090 MHz records the bursts as clicks in a WAV file and decodes
|
||||
> nothing: the signalling is a megabit a second and the scan path is 12.5 kHz
|
||||
> wide. Both the scanner and the menus now say so when a sweep is pointed at
|
||||
> 1090 MHz or 978 MHz, rather than letting it run silently.
|
||||
|
||||
Every airliner overhead broadcasts its address, callsign, altitude, position
|
||||
and speed twice a second, unencrypted, to nobody in particular.
|
||||
|
||||
|
|
@ -1244,29 +1014,6 @@ and `A835AF` is American with no network at all), and the first three letters
|
|||
of an airline callsign are its ICAO designator, so `RYR1234` is Ryanair.
|
||||
`--no-lookup` stops at that.
|
||||
|
||||
**A callsign is a flight number, not a leg.** An airline runs the same number
|
||||
over several legs in a day — Southwest especially — and a register holds one
|
||||
route for it, so an aircraft crossing Arizona is quite often handed a
|
||||
half-hour hop between two airports in Texas. The two databases routinely
|
||||
disagree with each other about the same flight number, and both are snapshots
|
||||
years old.
|
||||
|
||||
Nothing on the air settles it: **ADS-B carries no origin or destination.** An
|
||||
aircraft broadcasts who and where it is, not where it is going. So what can be
|
||||
done is checked rather than trusted:
|
||||
|
||||
- A route the aircraft **cannot** be flying is left off the map and out of the
|
||||
window. The two ends are known, and an aircraft on a route is never much
|
||||
further along it than the route is long. It is still written in the report
|
||||
with a note saying so, because it is what the register holds for that flight
|
||||
number and worth having — it is just not a statement about where this
|
||||
aeroplane was going.
|
||||
- Where a source lists a **whole day's stops** rather than a leg — hexdb
|
||||
answers `KORD-KEWR-KORD` for some flight numbers — the aircraft's own
|
||||
position picks the leg out. Reading the ends off that string instead gives
|
||||
Chicago to Chicago, which is not a flight.
|
||||
- Where no leg fits, none is claimed.
|
||||
|
||||
```
|
||||
4008F6 BAW49
|
||||
registration: G-VROS
|
||||
|
|
@ -1282,46 +1029,6 @@ done is checked rather than trusted:
|
|||
speed: up to 480 kt
|
||||
```
|
||||
|
||||
### Knowing the leg for certain
|
||||
|
||||
That needs live schedule data, which none of the free sources carry. Four
|
||||
commercial services are wired up, and all four are **optional**:
|
||||
|
||||
| service | keys |
|
||||
| --- | --- |
|
||||
| [FlightAware AeroAPI](https://www.flightaware.com/commercial/aeroapi/) | `BANDSAUNTER_AEROAPI_KEY` |
|
||||
| [Flightradar24](https://fr24api.flightradar24.com/) | `BANDSAUNTER_FR24_TOKEN` |
|
||||
| [OAG Flight Info](https://developer.oag.com/) | `BANDSAUNTER_OAG_KEY` |
|
||||
| [Cirium (FlightStats)](https://developer.cirium.com/) | `BANDSAUNTER_CIRIUM_APP_ID` and `BANDSAUNTER_CIRIUM_APP_KEY` |
|
||||
|
||||
Each holds the timetable and the day's movements, so each can answer the
|
||||
question the registers cannot: which leg of that flight number was in the air
|
||||
at the moment the aircraft was overhead. A schedule service is asked first and
|
||||
the free databases pick the question back up where it does not answer, so a
|
||||
program with no keys set behaves exactly as it did before.
|
||||
|
||||
**Keys are read from the environment, never the settings file** — a settings
|
||||
file is meant to be copied between machines and pasted into a message asking
|
||||
for help, and an API key is not. There is a test that enforces it.
|
||||
|
||||
```sh
|
||||
export BANDSAUNTER_AEROAPI_KEY=...
|
||||
bandsaunter flights # ask every service with a key
|
||||
bandsaunter flights --schedules flightaware # ask only that one
|
||||
bandsaunter adsb --schedules oag,cirium # ask those two, in that order
|
||||
```
|
||||
|
||||
A service with no key is skipped rather than asked and refused, since a
|
||||
request is only a slow way of finding out there is no key. The callsign and
|
||||
the moment are all that is sent. The same setting lives in the ADS-B options
|
||||
menu under **Schedule services**.
|
||||
|
||||
One caveat, stated plainly: **each reader was written from its service's
|
||||
published response shape and tested against that shape; none has been run
|
||||
against a live service**, because each wants a paid account. So each is
|
||||
written to find what it recognises and return nothing at all otherwise — a
|
||||
service that has changed since costs a route, not a scan.
|
||||
|
||||
### The moving map
|
||||
|
||||
```bash
|
||||
|
|
@ -1340,397 +1047,17 @@ the twenty minutes the data says it took. Nothing moves at a constant speed
|
|||
for the look of the thing, and an aircraft not heard from for five minutes
|
||||
stops being drawn rather than being flown on by guesswork.
|
||||
|
||||
**An aircraft that goes quiet fades rather than vanishing.** Taking it off the
|
||||
picture between one frame and the next says it stopped existing; fading it
|
||||
says it stopped talking, which is what happened. It fades where it was last
|
||||
actually seen and never along a reckoned track — the reason for giving up on
|
||||
it in the first place is that where it would be by now is a guess. `--fade
|
||||
SECONDS` (20 by default, and in the options menu) sets how long that takes;
|
||||
zero takes it away at once, as before.
|
||||
|
||||
Time runs at `--speed` seconds of flying per second of animation, or give
|
||||
`--seconds` and let it work the speed out. Altitude is the colour, low warm to
|
||||
high cold, with the key along the bottom; the trail behind each aircraft is the
|
||||
path it actually flew, in the colours of the heights it flew them at.
|
||||
|
||||
Beside each aircraft goes what is known about it — height and speed, what sort
|
||||
of aircraft it is, type and registration, and the two ends of the route, each
|
||||
with **a small flag of the country the airport is in**:
|
||||
|
||||
```
|
||||
PRIME04
|
||||
36,000 ft 493MPH
|
||||
[US] MILITARY HEAVY
|
||||
C17 90-0534
|
||||
```
|
||||
```
|
||||
BAW49
|
||||
33,000 ft 552MPH
|
||||
[GB] HEAVY
|
||||
B744 G-VROS
|
||||
[GB] EGLL
|
||||
[US] KSEA
|
||||
```
|
||||
|
||||
The height is in feet with the unit on it. It used to be the flight level —
|
||||
`330`, hundreds of feet, the way it is said on the radio — which is shorter
|
||||
and is what an aviator reads, but three digits beside an aircraft are only a
|
||||
height to somebody who already knows they are one. It is rounded to the
|
||||
nearest 25 ft, the step Mode S reports altitude in: a real reading is a
|
||||
multiple of that and comes through untouched, while a moment between two
|
||||
reports — which is most of the moments in an animation, since they are
|
||||
interpolated — stops claiming to know the aircraft's height to the foot.
|
||||
|
||||
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 aircraft's own **country of registration** gets a flag too, beside the
|
||||
type and registration — from the register where one answered, and otherwise
|
||||
from the 24-bit address, which says by treaty who issued it.
|
||||
|
||||
### What sort of aircraft it is
|
||||
|
||||
`MILITARY HEAVY` above is two facts, joined only because both are known.
|
||||
|
||||
**What it is** comes off the air. Every identification message carries three
|
||||
bits under its type code saying what sort of thing is transmitting — light,
|
||||
small, large, high vortex, heavy, high performance, rotorcraft, and under a
|
||||
different type code glider, airship, parachutist, ultralight, drone,
|
||||
spacecraft, or a vehicle on the ground. It is the only word about what an
|
||||
aircraft *is* that needs no register at all. Which list the three bits index
|
||||
depends on the type code, so the same value means "heavy" in one message and
|
||||
"parachutist" in another. Zero means the aircraft declined to say, which is
|
||||
common and is answered with nothing rather than a guess.
|
||||
|
||||
**Whether it is military** comes off no air at all: no aircraft broadcasts it,
|
||||
and a tanker calls itself `heavy` exactly as an airliner does. It is read from
|
||||
the 24-bit address instead — states set aside blocks of their national range
|
||||
for their armed forces, and those blocks are published. Two honest limits: a
|
||||
state can fly a military aircraft on a civil address whenever it likes, so
|
||||
this finds nobody who does not want to be found; and the table here holds the
|
||||
allocations a receiver in the ordinary world actually hears rather than every
|
||||
one that exists.
|
||||
|
||||
Worth seeing the two agree. On one evening here, address `AE07D3` was in the
|
||||
United States military block and broadcast the category `heavy`; the register,
|
||||
asked separately and knowing nothing of either, came back with a C-17A
|
||||
Globemaster III, tail 90-0534, operated by the United States Air Force.
|
||||
|
||||
**Every aerodrome under the picture is marked**, not only the ones being flown
|
||||
between: a receiver hears aircraft over its own county, and the county's
|
||||
airports are what say where on the map you are looking. They come from the
|
||||
same OpenStreetMap data the tiles are drawn from, asked as a question rather
|
||||
than a picture, once per area and kept for a month — a runway does not move.
|
||||
`--no-airports` turns it off.
|
||||
|
||||
They are drawn **magenta**, which nothing else on the picture is. The old
|
||||
amber sat 16 units of CIELAB from the altitude ramp's 12,000-foot yellow,
|
||||
which is to say it was the same colour: an aeroplane low over a field was
|
||||
drawn in the field's own colour and neither could be picked out from the
|
||||
other. The ramp already spends red, amber, green, cyan and violet on height;
|
||||
magenta is what it leaves free, it is 56 units away, and it is what an
|
||||
aeronautical chart marks an aerodrome in anyway. There is a test that states
|
||||
this as the property — how far the airport colour is from the nearest
|
||||
altitude colour — rather than as the colour, so that changing the ramp cannot
|
||||
quietly walk an aircraft back into the airports.
|
||||
|
||||
### Labels that stay where they are
|
||||
|
||||
The same two rules as the window, so the two look like the same program.
|
||||
|
||||
**A label keeps its place** for as long as that place still works, and is
|
||||
moved only when something genuinely takes it. Deciding afresh every frame
|
||||
means a label hops from one side of its aircraft to the other and back
|
||||
because the aeroplane two along moved a pixel — on a real evening's log that
|
||||
is about twice as many moves as keeping it. The place is remembered as an
|
||||
offset from the aircraft, so one crossing the picture carries its label along
|
||||
and only a real clash asks for a new spot.
|
||||
|
||||
**The moves that are left are eased** over about half a second of playback,
|
||||
not jumped. What the next label is laid out against is where a moving one is
|
||||
going, not where it has got to, since laying out against a label in mid-swing
|
||||
would move its neighbours too and move them back when it arrived. A still
|
||||
picture has no frame before it, so it places its labels exactly as it always
|
||||
did.
|
||||
|
||||
**The whole box fades with the aircraft.** The name always did, because it is
|
||||
drawn in the aircraft's own colour; the rows and the flag were fixed colours
|
||||
and stayed at full brightness on a label that was on its way out, which left
|
||||
the brightest thing on that part of the picture being the one aeroplane
|
||||
nothing had been heard from. An indexed picture cannot blend, so the row grey
|
||||
and all twelve flag colours have their own dimmed copies at each of the four
|
||||
fade steps.
|
||||
|
||||
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
|
||||
elsewhere, so nothing but numpy is needed to draw one. Where ffmpeg happens to
|
||||
be installed, `--out something.mp4` is smaller and smoother; where it is not,
|
||||
nothing breaks and a GIF is written instead.
|
||||
|
||||
### How far the map reaches
|
||||
|
||||
```bash
|
||||
bandsaunter flights --radius 100 # the default: a hundred miles round
|
||||
bandsaunter flights --radius 0 # fit whatever turned up, warts and all
|
||||
bandsaunter flights --at 32.54,-111.17 # say where the receiver is
|
||||
```
|
||||
|
||||
**The map is framed on the receiver, not on whatever was heard.** An aerial
|
||||
reaches a hundred miles on a good day, and a position that decoded wrongly can
|
||||
land anywhere on Earth — so a map drawn to fit everything is drawn to fit the
|
||||
mistakes, and the aircraft come out a pixel wide in the middle of an empty
|
||||
continent. One real night's recording spanned 240°N to 20°S before this.
|
||||
|
||||
`--radius` is in the same unit as the speeds, so it is nautical miles with
|
||||
knots and statute miles with mph. Left alone, the centre is the *median* of
|
||||
everything heard — a receiver hears aircraft all round it, and a median cannot
|
||||
be dragged anywhere by a handful of bad positions — or `--at LAT,LON` fixes it,
|
||||
which is worth doing if you want the same frame every night. Fixes outside the
|
||||
radius are dropped from the drawing, one fix at a time rather than one aircraft
|
||||
at a time, so a single bad position in the middle of a real flight does not
|
||||
take the whole flight off the map with it. Nothing is dropped from the log.
|
||||
|
||||
### Positions that never happened
|
||||
|
||||
```bash
|
||||
bandsaunter flights --recheck # throw out the impossible ones
|
||||
```
|
||||
|
||||
A position is sent as *half* a position — an even frame and an odd one — and
|
||||
the pair only means anything while the aircraft has not moved between them.
|
||||
Logs written before this version paired them however old they were, so an even
|
||||
frame kept from ten minutes ago decoded against a fresh odd one to a place on
|
||||
the wrong side of the world, written down as confidently as a real position.
|
||||
On one night's recording that was **two aircraft in three**, with positions out
|
||||
to 7,378 nautical miles and one latitude of 239°.
|
||||
|
||||
`--recheck` reads a log back and keeps, for each aircraft, the longest run of
|
||||
positions that could describe one aeroplane. It is deliberately not a forward
|
||||
walk that drops whatever disagrees with the last position kept: one bad fix
|
||||
then becomes the reference, and it is the truth that gets thrown away — on the
|
||||
same recording that discarded a fifth of everything, most of it real. Nothing
|
||||
is changed in the log; the frames stay exactly as they arrived.
|
||||
|
||||
Two things it will not do, on purpose. An aircraft that goes quiet for five
|
||||
minutes and is heard again a long way off is an aeroplane, not an error, and
|
||||
nothing after that gap is second-guessed — the radius is what keeps those off
|
||||
the picture. And where two positions contradict each other and nothing else
|
||||
has an opinion, one of them is wrong and there is no saying which, so the
|
||||
later one goes.
|
||||
|
||||
**New logs need none of this**: the decoder now refuses a pair more than ten
|
||||
seconds apart, refuses a position that is not on Earth, and refuses one the
|
||||
aircraft could not have reached, as the frames arrive.
|
||||
|
||||
### The ground under it
|
||||
|
||||
**There is a real map under the aircraft.** A flight path over a black
|
||||
rectangle says how the aircraft moved and nothing about where it was; over a
|
||||
coastline it says which airport it left.
|
||||
|
||||
Standard `{z}/{x}/{y}` raster tiles are fetched the first time an area is
|
||||
drawn — OpenStreetMap by default — reprojected from Web Mercator onto the
|
||||
picture pixel by pixel, inverted and dimmed so that the map is the ground and
|
||||
the aircraft stay the brightest thing on it. The PNG tiles are decoded here,
|
||||
by the same reasoning the PNGs are written here: zlib, numpy and the five
|
||||
row filters from the specification, and no imaging library.
|
||||
|
||||
Using somebody else's tile server carries three obligations, and all three
|
||||
are met rather than assumed:
|
||||
|
||||
- **tiles are cached** in `~/.cache/bandsaunter/tiles` and never fetched
|
||||
twice, so redrawing an evening costs nothing and works with no network;
|
||||
- **every request says who is asking**, in the User-Agent;
|
||||
- **the attribution is drawn onto the picture**, because a GIF travels
|
||||
without the readme that would otherwise carry it.
|
||||
|
||||
`--no-basemap` draws the tracks on their own, `--tiles URL` points at another
|
||||
server (your own, if you run one), and when there is no network and nothing
|
||||
cached the picture falls back to the plain grid it drew before.
|
||||
|
||||
**The zoom is chosen from how wide the picture is, not from the area alone.**
|
||||
A map asked for at 1920 pixels fetches finer tiles than the same map asked for
|
||||
at 960, and about 1.4× the width is fetched deliberately and then averaged
|
||||
down — a downscaled tile is sharp and an upscaled one is not, so it is better
|
||||
to fetch too much and shrink it than to fetch too little and stretch it.
|
||||
|
||||
The window fetches a little more world than it shows, so that panning does not
|
||||
leave the ground blank, and it fetches that bigger piece **at the bigger
|
||||
piece's own size**: what the window then shows comes out pixel for pixel with
|
||||
the screen. Rendering the wider piece into the window's own pixels and
|
||||
stretching it back is an upscale of a fifth applied to the whole map, which is
|
||||
what a sharp map looks like when it looks blurred.
|
||||
|
||||
Measured, at a 100-mile radius:
|
||||
|
||||
```
|
||||
animation at 960 px zoom 9 28 tiles 1792 px 1.9x oversampled
|
||||
animation at 1920 px zoom 10 91 tiles 3328 px 1.7x oversampled
|
||||
window at 1920x1080 zoom 10 135 tiles 3840 px 1.6x oversampled
|
||||
window at 3840x2160 zoom 10 135 tiles 3840 px 0.8x — upscaled
|
||||
```
|
||||
|
||||
A drawing is still capped, at a couple of hundred tiles, and the last line is
|
||||
what that cap looks like: at 4K the zoom has already stopped climbing and the
|
||||
map is enlarged after all. Somebody else's tile server is not a thing to fetch
|
||||
a thousand tiles from for one picture. A smaller `--radius` buys the detail
|
||||
back, since the same budget then covers less ground.
|
||||
|
||||
`--map-brightness PERCENT` (70 by default) is how far up its range the map is
|
||||
drawn, and **the vector themes bend the middle of that range down hard** —
|
||||
because a tinted photograph of a county behind the vectors is the one thing
|
||||
that stops a vector display looking like one. It is a curve, not a ceiling:
|
||||
the top of the setting is a full-brightness map on every theme. It used to be
|
||||
a plain multiplier, which meant that on a phosphor theme the setting could not
|
||||
reach a visible map at all — turned the whole way up it still came out at a
|
||||
tenth of what the default theme gives, which is to say invisible. So if the
|
||||
ground is too faint to make out, this is the setting that fixes it, and on a
|
||||
vector theme it takes rather more turning up than on the default one. The ground has to stay dark enough that the aircraft are the brightest
|
||||
thing on the picture and light enough that a coastline can be made out at all,
|
||||
and which way to err depends on the screen you are looking at. In the window,
|
||||
`[` and `]` change it while it runs.
|
||||
|
||||
## Themes
|
||||
|
||||
`--theme NAME`, on `bandsaunter adsb` and `bandsaunter flights` alike, and in
|
||||
the ADS-B options menu. It changes the window and the animated pictures
|
||||
together, because both read their colours out of the same palette.
|
||||
|
||||
| theme | |
|
||||
| --- | --- |
|
||||
| `night` | the default: a night-blue ground, height as colour |
|
||||
| `digital` | blue phosphor, cyan vectors on black, amber aerodromes |
|
||||
| `phosphor` | green P1 phosphor, height as brightness |
|
||||
| `amber` | amber phosphor, warm vectors on black |
|
||||
| `red` | red phosphor, for a room that wants its night vision |
|
||||
|
||||
The four vector themes are the screens the phrase "air defence display"
|
||||
actually calls to mind: a black tube, one phosphor, and thin bright lines
|
||||
with a halo around them. Three things follow from that, and they are
|
||||
constraints rather than decoration.
|
||||
|
||||
**Height becomes brightness.** The default map spends the whole spectrum on
|
||||
altitude — low warm, high cold — which is why nothing else on it can be amber
|
||||
or green. A phosphor screen has one colour, so on those themes low is dim and
|
||||
high burns. That is the same trade the real displays made.
|
||||
|
||||
**The ground goes well back.** A tinted photograph of a county behind the
|
||||
vectors is the one thing that stops a vector display looking like one, so the
|
||||
map underneath is drawn at about two-fifths of the brightness asked for and
|
||||
the lines carry the picture. `--map-brightness` still moves it.
|
||||
|
||||
**Countries are named, not flown.** A flag is half a dozen colours and a
|
||||
phosphor has one, so those themes write the two letters instead — which is
|
||||
what a display of the period would have done anyway.
|
||||
|
||||
### The glow
|
||||
|
||||
A vector display draws by holding a beam on the phosphor, and the phosphor
|
||||
spreads the light a little and keeps glowing after the beam has gone. So a
|
||||
line on one of those screens is not one pixel wide with a hard edge; it is a
|
||||
bright core inside a halo. Both drawings do that, by different means, because
|
||||
they are different kinds of picture:
|
||||
|
||||
- **the window** lays the same line down two or three times, wider and fainter
|
||||
each pass, and then the core on top — trails, aircraft, leader lines, box
|
||||
borders and the aerodrome squares;
|
||||
- **the animation** cannot blend at all, because a GIF is indexed colour. So
|
||||
it dilates what it has drawn and fills the halo with the *dimmed copy* of
|
||||
the colour underneath it. The aircraft colours already have dimmed copies —
|
||||
those are the trail shades — so an aeroplane glows into the colour its own
|
||||
trail is drawn in, which is the colour a phosphor would have spread into.
|
||||
The fixed colours have two rings each added to the palette for the purpose.
|
||||
|
||||
The halo goes over the map, the grid and the empty background and nothing
|
||||
else: a halo is what light does to the dark around a line, and painting it
|
||||
over another line would be light doing something light does not do. Where two
|
||||
rings meet the nearer wins, which is what happens on the tube as well. It
|
||||
costs about 55 ms a frame at 1400×1258, and the default theme skips the pass
|
||||
entirely.
|
||||
|
||||
## Every ADS-B option
|
||||
|
||||
Thirty-four of them, in the six groups the menu shows. Each is a flag on the
|
||||
command line and a line in `bandsaunter` → 5, and both are generated from one
|
||||
table in the source, so they cannot disagree. `man bandsaunter` has the long
|
||||
form of every one.
|
||||
|
||||
**receiver**
|
||||
|
||||
| option | flag | default | what it does |
|
||||
| --- | --- | --- | --- |
|
||||
| Receiver | `--device` | `0` | which receiver to use, when more than one is plugged in |
|
||||
| Gain | `--gain` | `auto` | tuner gain in dB, or automatic |
|
||||
| Sample rate | `--rate` | `2 MHz` | how fast to sample; two megasamples a second is the minimum |
|
||||
| Receiver at | `--at` | — | where the receiver is, as latitude,longitude (blank = work it out) |
|
||||
| Invent a sky | `--simulate` | `no` | fly imaginary aircraft past an imaginary receiver |
|
||||
| Imaginary sky near | `--near` | `47.55,-122.30` | where the simulated aircraft are flying |
|
||||
|
||||
**listening**
|
||||
|
||||
| option | flag | default | what it does |
|
||||
| --- | --- | --- | --- |
|
||||
| Listen for | `--seconds` | `until stopped` | how long to listen before stopping (0 = until interrupted) |
|
||||
| Show every frame | `--frames` | `no` | print each frame as it arrives, rather than a running count |
|
||||
| Write the log | `--log` `--no-log` | `yes` | write every frame to a file as it arrives |
|
||||
| Also write a KML | `--kml` | `no` | write the flight paths for Google Earth as well |
|
||||
| Keep on screen for | `--hold` | `45 s` | how long an aircraft stays on the display after its last frame |
|
||||
| Draw when finished | `--map` | `no` | draw the map as soon as the listening stops |
|
||||
|
||||
**aircraft**
|
||||
|
||||
| option | flag | default | what it does |
|
||||
| --- | --- | --- | --- |
|
||||
| Look the aircraft up | `--lookup` `--no-lookup` | `yes` | ask the public registers who each aircraft is |
|
||||
| Schedule services | `--schedules` | — | which paid schedule services to ask, in order (blank = all with keys) |
|
||||
| Check the positions | `--recheck` | `no` | throw out positions the aircraft could not have been in |
|
||||
|
||||
**animation**
|
||||
|
||||
| option | flag | default | what it does |
|
||||
| --- | --- | --- | --- |
|
||||
| Picture | `--out` | `gif` | what kind of picture to draw |
|
||||
| Animation length | `--seconds` | `30 s` | how long the animation should run for |
|
||||
| Speed | `--speed` | `fit to the length` | seconds of flying per second of animation (0 = fit to the length) |
|
||||
| Frames a second | `--fps` | `12` | how many frames of animation each second holds |
|
||||
| Picture width | `--width` | `960 px` | how many pixels across the picture is |
|
||||
| Trail | `--trail` | `the whole path` | how much of the path to leave behind each aircraft (0 = all of it) |
|
||||
| Fade out over | `--fade` | `20 s` | how long an aircraft takes to fade away once it has gone quiet |
|
||||
| Forget after | `--stale` | `300 s` | stop drawing an aircraft this long after its last report |
|
||||
|
||||
**the map**
|
||||
|
||||
| option | flag | default | what it does |
|
||||
| --- | --- | --- | --- |
|
||||
| Map underneath | `--basemap` `--no-basemap` | `yes` | draw a real map under the flight paths |
|
||||
| Tile server | `--tiles` | — | where the map tiles come from |
|
||||
| Colour theme | `--theme` | `night` | how the map looks: the colours, and whether the lines glow |
|
||||
| Map brightness | `--map-brightness` | `70 %` | how bright the map under the aircraft is drawn, as a percentage |
|
||||
| Map radius | `--radius` | `100` | how far around the receiver the map reaches (0 = fit whatever was heard) |
|
||||
| Mark the airports | `--airports` `--no-airports` | `yes` | mark every aerodrome on the map, not only the ones flown between |
|
||||
| Range rings on the pictures | `--rings` `--no-rings` | `yes` | faint discs at a quarter, a half and three quarters of the radius |
|
||||
| Range rings in the window | `--window-rings` `--no-window-rings` | `yes` | the same discs on the realtime display |
|
||||
|
||||
**labels**
|
||||
|
||||
| option | flag | default | what it does |
|
||||
| --- | --- | --- | --- |
|
||||
| Box translucency | `--box-opacity` | `85 %` | how solid the card behind each information box is, as a percentage |
|
||||
| Label the aircraft | `--labels` `--no-labels` | `yes` | write the callsign, height and speed beside each aircraft |
|
||||
| Speed in | `--speed-unit` | `knots` | what to show speeds and distances in |
|
||||
|
||||
**Where they live.** Saved with `s` in the menu to
|
||||
`~/.config/bandsaunter/aircraft.yaml`, which is plain YAML you can edit or
|
||||
copy between machines. API keys are deliberately **not** kept there — see
|
||||
[Knowing the leg for certain](#knowing-the-leg-for-certain).
|
||||
|
||||
## Meters and weather sensors
|
||||
|
||||
Two things on the ISM bands are worth naming rather than reporting as hex.
|
||||
|
|
@ -2412,12 +1739,6 @@ deleted.
|
|||
|
||||
## Built-in help
|
||||
|
||||
Menu **5, Aircraft (ADS-B)**, is the whole of the aircraft mode without a
|
||||
command line: nineteen options on one screen, each with a line saying what it
|
||||
does, `?N` for the long version with the flag it corresponds to, `l` to listen
|
||||
and `m` to draw a map from any log in the recordings directory. `s` saves the
|
||||
options to `~/.config/bandsaunter/aircraft.yaml`.
|
||||
|
||||
Press `h` in the menus for topics covering setup, how the sweep works, why
|
||||
nothing (or too much) is being recorded, capturing conversations, where files
|
||||
go, trunked systems, HF reception and the keys available during a scan. Typing a setting name
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ and transcribing speech.
|
|||
# Versions are the release date and a revision within that day, so
|
||||
# 2026-08-21_02 is the second build made on the 21st. The revision is padded
|
||||
# to two digits so versions sort as text.
|
||||
VERSION_DATE = "2026-09-06"
|
||||
VERSION_REVISION = 3
|
||||
VERSION_DATE = "2026-09-03"
|
||||
VERSION_REVISION = 5
|
||||
|
||||
__version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}"
|
||||
|
||||
|
|
|
|||
|
|
@ -30,8 +30,7 @@ import numpy as np
|
|||
__all__ = ["decode_adsb", "Frame", "Aircraft", "AircraftRegistry", "crc24",
|
||||
"ADSB_HZ", "SAMPLE_RATE", "PREAMBLE_US", "encode_identification",
|
||||
"encode_position", "encode_velocity", "modulate", "SimulatedSky",
|
||||
"VirtualAircraft", "default_sky", "category_name",
|
||||
"EMITTER_CATEGORIES"]
|
||||
"VirtualAircraft", "default_sky"]
|
||||
|
||||
ADSB_HZ = 1_090_000_000.0
|
||||
|
||||
|
|
@ -39,19 +38,6 @@ ADSB_HZ = 1_090_000_000.0
|
|||
SAMPLE_RATE = 2_000_000
|
||||
|
||||
PREAMBLE_US = (0.0, 1.0, 3.5, 4.5)
|
||||
|
||||
# A position takes an even frame and an odd one, and the pair is only good
|
||||
# for as long as the aircraft has not meaningfully moved between them. Ten
|
||||
# seconds is what the standard allows; past that the two halves describe
|
||||
# different places and the answer is not a position at all.
|
||||
CPR_PAIR_SECONDS = 10.0
|
||||
|
||||
# How long a previous position stays worth checking a new one against.
|
||||
CPR_TRUST_SECONDS = 300.0
|
||||
|
||||
# Faster than anything with a transponder on it, so that a real aircraft is
|
||||
# never called an error -- Concorde cruised at 1150 kt.
|
||||
MAX_GROUND_SPEED_KT = 2000.0
|
||||
SHORT_BITS = 56
|
||||
LONG_BITS = 112
|
||||
|
||||
|
|
@ -69,35 +55,6 @@ TYPE_NAMES = {
|
|||
}
|
||||
|
||||
|
||||
# The three bits under an identification message's type code say what sort
|
||||
# of thing is transmitting. Which list they index depends on the type code
|
||||
# -- the same three bits mean "heavy" under type 4 and "glider" under type 3
|
||||
# -- which is why this is a table of tables and not a table.
|
||||
#
|
||||
# This is the only word about what an aircraft *is* that comes off the air.
|
||||
# Everything else -- the model, the operator, the registration -- is a
|
||||
# lookup in somebody's database against the address. There is no category
|
||||
# for "military": that has to be read off the address block instead.
|
||||
EMITTER_CATEGORIES = {
|
||||
4: {1: "light", 2: "small", 3: "large", 4: "high vortex",
|
||||
5: "heavy", 6: "high performance", 7: "rotorcraft"},
|
||||
3: {1: "glider", 2: "airship", 3: "parachutist", 4: "ultralight",
|
||||
6: "drone", 7: "spacecraft"},
|
||||
2: {1: "emergency vehicle", 2: "service vehicle", 3: "obstacle",
|
||||
4: "obstacle", 5: "obstacle"},
|
||||
1: {}, # reserved, and nothing transmits it
|
||||
}
|
||||
|
||||
|
||||
def category_name(type_code: int, category: int) -> str:
|
||||
"""What an identification message says the transmitter is, or "".
|
||||
|
||||
Zero means the aircraft declined to say, which is common and is not an
|
||||
error: it is answered with nothing rather than with a guess.
|
||||
"""
|
||||
return EMITTER_CATEGORIES.get(type_code, {}).get(category, "")
|
||||
|
||||
|
||||
def crc24(data: bytes) -> int:
|
||||
"""The Mode S parity, polynomial 0xFFF409.
|
||||
|
||||
|
|
@ -129,7 +86,6 @@ class Frame:
|
|||
df: int = 0 # downlink format
|
||||
icao: str = "" # the aircraft's permanent 24-bit address
|
||||
type_code: int = 0
|
||||
category: int = 0 # the emitter category, with the type code
|
||||
at_sample: int = 0
|
||||
callsign: str = ""
|
||||
altitude_ft: int = 0
|
||||
|
|
@ -308,10 +264,6 @@ def _read(bits: str, data: bytes) -> Frame:
|
|||
frame.type_code = int(me[:5], 2)
|
||||
if 1 <= frame.type_code <= 4:
|
||||
frame.callsign = _callsign(me)
|
||||
# The three bits under the type code say what sort of thing this is
|
||||
# -- heavy, rotorcraft, glider, ground vehicle. It comes off the
|
||||
# air with the callsign and costs nothing to keep.
|
||||
frame.category = int(me[5:8], 2)
|
||||
elif 9 <= frame.type_code <= 18 or 20 <= frame.type_code <= 22:
|
||||
frame.altitude_ft = _altitude(me)
|
||||
frame.cpr_odd = me[21] == "1"
|
||||
|
|
@ -427,12 +379,10 @@ class Aircraft:
|
|||
track_deg: float = 0.0
|
||||
vertical_rate_fpm: int = 0
|
||||
messages: int = 0
|
||||
category: str = "" # what it said it was: heavy, rotorcraft...
|
||||
first_seen: float = 0.0
|
||||
last_seen: float = 0.0
|
||||
_even: Frame | None = field(default=None, repr=False)
|
||||
_odd: Frame | None = field(default=None, repr=False)
|
||||
_placed_at: float = field(default=0.0, repr=False)
|
||||
|
||||
@property
|
||||
def located(self) -> bool:
|
||||
|
|
@ -476,8 +426,6 @@ class AircraftRegistry:
|
|||
frame.received_at = when
|
||||
if frame.callsign:
|
||||
seen.callsign = frame.callsign
|
||||
if frame.category and not seen.category:
|
||||
seen.category = category_name(frame.type_code, frame.category)
|
||||
if frame.altitude_ft:
|
||||
seen.altitude_ft = frame.altitude_ft
|
||||
if frame.ground_speed_kt:
|
||||
|
|
@ -490,72 +438,18 @@ class AircraftRegistry:
|
|||
seen._odd = frame
|
||||
else:
|
||||
seen._even = frame
|
||||
self._place(seen)
|
||||
return seen
|
||||
|
||||
def _place(self, seen: Aircraft) -> None:
|
||||
"""Work out where an aircraft is, from the last even and odd frames.
|
||||
|
||||
The pair has to be recent, and the answer has to be reachable. Both
|
||||
checks are the difference between a map and a scatter of nonsense:
|
||||
an unpaired frame kept from ten minutes ago decodes against a fresh
|
||||
one to a position on the wrong side of the world, because compact
|
||||
position reporting sends a fraction of a zone and the two fractions
|
||||
are then read as though the aircraft had not moved between them.
|
||||
Measured against one night's recording, that produced positions up
|
||||
to seven thousand miles out, on two aircraft in three.
|
||||
"""
|
||||
even, odd = seen._even, seen._odd
|
||||
if even is None or odd is None:
|
||||
return
|
||||
if abs(even.received_at - odd.received_at) > CPR_PAIR_SECONDS:
|
||||
return
|
||||
# Whichever of the pair arrived later is the one the position is
|
||||
# reported at. Compared by arrival rather than by sample offset: the
|
||||
# offset restarts at zero every block, so a pair that straddles two
|
||||
# blocks would otherwise be read backwards and put the aircraft in
|
||||
# the wrong zone.
|
||||
even_first = (even.received_at, even.at_sample) > \
|
||||
(odd.received_at, odd.at_sample)
|
||||
found = global_position(even, odd, even_first)
|
||||
if found is None or not _on_earth(*found):
|
||||
return
|
||||
when = max(even.received_at, odd.received_at)
|
||||
if not seen.located or self._reachable(seen, found, when):
|
||||
if seen._even is not None and seen._odd is not None:
|
||||
# Whichever of the pair arrived later is the one the position
|
||||
# is reported at. Compared by arrival rather than by sample
|
||||
# offset: the offset restarts at zero every block, so a pair
|
||||
# that straddles two blocks would otherwise be read backwards
|
||||
# and put the aircraft in the wrong zone.
|
||||
even_first = (seen._even.received_at, seen._even.at_sample) > \
|
||||
(seen._odd.received_at, seen._odd.at_sample)
|
||||
found = global_position(seen._even, seen._odd, even_first)
|
||||
if found is not None:
|
||||
seen.latitude, seen.longitude = found
|
||||
seen._placed_at = when
|
||||
|
||||
@staticmethod
|
||||
def _reachable(seen: Aircraft, found: tuple[float, float],
|
||||
when: float) -> bool:
|
||||
"""Whether an aircraft could have got there from where it was.
|
||||
|
||||
A position that would need eight hundred knots in the second since
|
||||
the last one is not a position, whatever the checksum said about the
|
||||
frames it came from. The bar is set well above anything that flies
|
||||
so that a genuinely fast aircraft, or a gap in reception, is never
|
||||
mistaken for an error.
|
||||
"""
|
||||
gap = when - seen._placed_at
|
||||
if gap <= 0 or gap > CPR_TRUST_SECONDS:
|
||||
return True # too long ago to argue with
|
||||
miles = _distance_nm(seen.latitude, seen.longitude, *found)
|
||||
return miles <= MAX_GROUND_SPEED_KT * gap / 3600.0
|
||||
|
||||
|
||||
def _on_earth(lat: float, lon: float) -> bool:
|
||||
"""Whether a decoded position is a place at all."""
|
||||
return -90.0 <= lat <= 90.0 and -180.0 <= lon <= 180.0
|
||||
|
||||
|
||||
def _distance_nm(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""Great-circle distance, in nautical miles."""
|
||||
p1, p2 = math.radians(lat1), math.radians(lat2)
|
||||
dp = p2 - p1
|
||||
dl = math.radians(lon2 - lon1)
|
||||
a = math.sin(dp / 2) ** 2 + \
|
||||
math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
|
||||
return 2 * 3440.065 * math.asin(min(1.0, math.sqrt(a)))
|
||||
return seen
|
||||
|
||||
def described(self) -> list[str]:
|
||||
return [craft.describe() for craft in
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,538 +0,0 @@
|
|||
"""The ground under the aircraft: map tiles, fetched, cached and dimmed.
|
||||
|
||||
A flight path over a black rectangle says how the aircraft moved and nothing
|
||||
about where it was. Over a coastline it says which airport it left. So the
|
||||
map behind the animation is a real one: standard raster tiles, fetched once,
|
||||
kept on disk, reprojected onto the picture and dimmed until the aircraft are
|
||||
the brightest thing on it.
|
||||
|
||||
Three things follow from using somebody else's tile server, and all three are
|
||||
obligations rather than options. Tiles are **cached** and never fetched
|
||||
twice. Every request identifies the program in its User-Agent. And the
|
||||
attribution the licence requires is drawn onto the picture, not left to a
|
||||
readme nobody ships with a GIF. A drawing is capped at a few dozen tiles: an
|
||||
aircraft map is a hobby drawing, not a reason to hammer a volunteer-funded
|
||||
service.
|
||||
|
||||
The PNG decoding is here for the same reason the PNG writing is in
|
||||
:mod:`bandsaunter.images`: a scanner that cannot draw a map because an
|
||||
imaging library is missing is worse than one that draws it from zlib and
|
||||
numpy, which is all this needs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from . import __version__
|
||||
|
||||
__all__ = ["decode_png", "tile_of", "choose_zoom", "fetch_tile", "mosaic",
|
||||
"ground_under", "TILE_URL", "ATTRIBUTION", "MAX_TILES", "MAX_ZOOM",
|
||||
"cache_dir", "PNGError", "airports_in", "AIRPORTS_URL",
|
||||
"tile_span"]
|
||||
|
||||
# The standard OpenStreetMap tiles. Any {z}/{x}/{y} server can be put here
|
||||
# instead; nothing below knows anything about this one in particular.
|
||||
TILE_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
|
||||
# Drawn onto every picture that used the tiles. The licence requires it and
|
||||
# a GIF travels without its readme.
|
||||
ATTRIBUTION = "MAP DATA (C) OPENSTREETMAP CONTRIBUTORS"
|
||||
|
||||
# The backstop on how many tiles one drawing may fetch. Reached only by a
|
||||
# view so wide that no zoom covers it without fetching half a country; the
|
||||
# usual limit is the size of the picture, which is asked for instead. A
|
||||
# screenful at a hundred-mile radius comes to about a hundred and fifty,
|
||||
# fetched once and kept.
|
||||
MAX_TILES = 220
|
||||
MAX_ZOOM = 13
|
||||
|
||||
# How much more detail to fetch than the picture holds. Averaging several
|
||||
# source pixels into each output one is what makes lettering and coastlines
|
||||
# come out smooth; taking exactly one leaves them as hard as they were.
|
||||
OVERSAMPLE = 1.4
|
||||
MIN_ZOOM = 2
|
||||
TILE_PIXELS = 256
|
||||
|
||||
USER_AGENT = (f"bandsaunter/{__version__} "
|
||||
"(+https://github.com/topics/rtl-sdr; aircraft map drawing)")
|
||||
|
||||
# Politeness between requests to a volunteer-funded service. Only paid on a
|
||||
# tile that was not already on the disk.
|
||||
FETCH_PAUSE = 0.12
|
||||
|
||||
|
||||
# Where to ask what aerodromes are in a piece of the world. The same OSM
|
||||
# data the tiles are drawn from, asked as a question rather than a picture.
|
||||
AIRPORTS_URL = "https://overpass-api.de/api/interpreter"
|
||||
|
||||
# One question covers a whole view and is kept for a month, because runways
|
||||
# do not move. The same politeness as the tiles: cache it, say who is
|
||||
# asking, and do not ask twice for the same thing.
|
||||
AIRPORT_CACHE_DAYS = 30
|
||||
|
||||
# Bumped when the question changes, so that answers to the old one are asked
|
||||
# again rather than believed.
|
||||
AIRPORT_CACHE_VERSION = 2
|
||||
|
||||
# Enough to name what is under an aircraft and not so many that the map is a
|
||||
# list of airstrips.
|
||||
MOST_AIRPORTS = 40
|
||||
|
||||
|
||||
class PNGError(ValueError):
|
||||
"""A PNG this decoder cannot read."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reading a PNG
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CHANNELS = {0: 1, 2: 3, 3: 1, 4: 2, 6: 4}
|
||||
|
||||
|
||||
def decode_png(data: bytes) -> np.ndarray:
|
||||
"""A PNG's pixels as an ``(h, w, 3)`` array of bytes.
|
||||
|
||||
Eight bits a channel and no interlacing, which is what every tile server
|
||||
sends; anything else raises rather than being guessed at.
|
||||
"""
|
||||
if data[:8] != b"\x89PNG\r\n\x1a\x08"[:8] and data[:8] != \
|
||||
b"\x89PNG\r\n\x1a\n":
|
||||
raise PNGError("not a PNG")
|
||||
width = height = depth = colour = interlace = 0
|
||||
palette = None
|
||||
body = bytearray()
|
||||
at = 8
|
||||
while at + 8 <= len(data):
|
||||
length, tag = struct.unpack(">I4s", data[at:at + 8])
|
||||
chunk = data[at + 8:at + 8 + length]
|
||||
at += 12 + length
|
||||
if tag == b"IHDR":
|
||||
(width, height, depth, colour, _compression, _filter,
|
||||
interlace) = struct.unpack(">IIBBBBB", chunk)
|
||||
elif tag == b"PLTE":
|
||||
palette = np.frombuffer(chunk, dtype=np.uint8).reshape(-1, 3)
|
||||
elif tag == b"IDAT":
|
||||
body += chunk
|
||||
elif tag == b"IEND":
|
||||
break
|
||||
if depth != 8:
|
||||
raise PNGError(f"{depth}-bit PNG: only 8 bits a channel is read here")
|
||||
if interlace:
|
||||
raise PNGError("interlaced PNG")
|
||||
if colour not in _CHANNELS:
|
||||
raise PNGError(f"colour type {colour}")
|
||||
if not width or not height:
|
||||
raise PNGError("no image")
|
||||
|
||||
channels = _CHANNELS[colour]
|
||||
raw = _unfilter(zlib.decompress(bytes(body)), width, height, channels)
|
||||
if colour == 3:
|
||||
if palette is None:
|
||||
raise PNGError("palette image with no palette")
|
||||
return palette[np.clip(raw[:, :, 0], 0, len(palette) - 1)]
|
||||
if colour == 0:
|
||||
return np.repeat(raw, 3, axis=2)
|
||||
if colour == 4:
|
||||
return np.repeat(raw[:, :, :1], 3, axis=2)
|
||||
return raw[:, :, :3]
|
||||
|
||||
|
||||
def _unfilter(data: bytes, width: int, height: int,
|
||||
channels: int) -> np.ndarray:
|
||||
"""Undo the per-row filters PNG applies before compressing.
|
||||
|
||||
The five of them, from the specification. None and Up are whole-row
|
||||
arithmetic; Sub is a running total along the row, which is a cumulative
|
||||
sum once the bytes are grouped by which channel they belong to; Average
|
||||
and Paeth each need the byte before them to have been worked out
|
||||
already, so those two are the only ones that walk the row.
|
||||
"""
|
||||
stride = width * channels
|
||||
if len(data) < height * (stride + 1):
|
||||
raise PNGError("truncated image data")
|
||||
out = np.zeros((height, stride), dtype=np.uint8)
|
||||
previous = np.zeros(stride, dtype=np.uint8)
|
||||
at = 0
|
||||
for row in range(height):
|
||||
kind = data[at]
|
||||
line = np.frombuffer(data, dtype=np.uint8, count=stride,
|
||||
offset=at + 1).astype(np.uint16)
|
||||
at += stride + 1
|
||||
if kind == 0:
|
||||
current = line.astype(np.uint8)
|
||||
elif kind == 1:
|
||||
current = np.empty(stride, dtype=np.uint8)
|
||||
for offset in range(channels):
|
||||
current[offset::channels] = np.cumsum(
|
||||
line[offset::channels], dtype=np.uint32) % 256
|
||||
elif kind == 2:
|
||||
current = ((line + previous) % 256).astype(np.uint8)
|
||||
elif kind in (3, 4):
|
||||
current = _walk_row(bytes(line.astype(np.uint8)), previous,
|
||||
channels, kind)
|
||||
else:
|
||||
raise PNGError(f"filter type {kind}")
|
||||
out[row] = current
|
||||
previous = current
|
||||
return out.reshape(height, width, channels)
|
||||
|
||||
|
||||
def _walk_row(line: bytes, previous: np.ndarray, channels: int,
|
||||
kind: int) -> np.ndarray:
|
||||
"""Average and Paeth: each byte needs the one ``channels`` back."""
|
||||
up = previous.tolist()
|
||||
out = [0] * len(line)
|
||||
for i, value in enumerate(line):
|
||||
left = out[i - channels] if i >= channels else 0
|
||||
above = up[i]
|
||||
if kind == 3:
|
||||
out[i] = (value + ((left + above) >> 1)) & 0xFF
|
||||
else:
|
||||
upleft = up[i - channels] if i >= channels else 0
|
||||
base = left + above - upleft
|
||||
da, db, dc = abs(base - left), abs(base - above), abs(base - upleft)
|
||||
near = left if (da <= db and da <= dc) else (
|
||||
above if db <= dc else upleft)
|
||||
out[i] = (value + near) & 0xFF
|
||||
return np.array(out, dtype=np.uint8)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Which tiles
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def tile_of(lat: float, lon: float, zoom: int) -> tuple[float, float]:
|
||||
"""Where a coordinate falls in the tile grid, in fractional tiles.
|
||||
|
||||
Web Mercator, which is what every {z}/{x}/{y} tile server serves and is
|
||||
not what this program's maps are drawn in -- hence the resampling
|
||||
further down rather than a straight paste.
|
||||
"""
|
||||
lat = max(-85.05112878, min(85.05112878, lat))
|
||||
n = float(2 ** zoom)
|
||||
x = (lon + 180.0) / 360.0 * n
|
||||
radians = math.radians(lat)
|
||||
y = (1.0 - math.asinh(math.tan(radians)) / math.pi) / 2.0 * n
|
||||
return x, y
|
||||
|
||||
|
||||
def tile_span(south: float, west: float, north: float, east: float,
|
||||
zoom: int) -> tuple[int, int]:
|
||||
"""How many tiles wide and tall a box is at one zoom."""
|
||||
x0, y0 = tile_of(north, west, zoom)
|
||||
x1, y1 = tile_of(south, east, zoom)
|
||||
return (int(math.floor(x1)) - int(math.floor(x0)) + 1,
|
||||
int(math.floor(y1)) - int(math.floor(y0)) + 1)
|
||||
|
||||
|
||||
def choose_zoom(south: float, west: float, north: float, east: float,
|
||||
width: int = 0, max_tiles: int = MAX_TILES,
|
||||
most: int = MAX_ZOOM) -> int:
|
||||
"""The zoom to fetch at: enough for the picture, and no more.
|
||||
|
||||
``width`` is how many pixels across the picture will be. Without it
|
||||
this fetched whatever the tile budget allowed, which is the wrong
|
||||
question in both directions: on a small picture it fetched more than
|
||||
could be shown, and on a large one it fetched less and the map was
|
||||
blown up to fit -- which is what a low-resolution map looks like.
|
||||
|
||||
So it climbs until the tiles hold at least as many pixels as the
|
||||
picture wants, and stops there. The budget is the backstop, for a view
|
||||
so wide that no zoom can cover it without fetching half a country.
|
||||
"""
|
||||
best = MIN_ZOOM
|
||||
for zoom in range(MIN_ZOOM, min(most, MAX_ZOOM) + 1):
|
||||
wide, tall = tile_span(south, west, north, east, zoom)
|
||||
if wide * tall > max_tiles:
|
||||
break
|
||||
best = zoom
|
||||
if width and wide * TILE_PIXELS >= width * OVERSAMPLE:
|
||||
break # detail enough; more would only cost time
|
||||
return best
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fetching them, once
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def cache_dir() -> Path:
|
||||
root = os.environ.get("XDG_CACHE_HOME") or "~/.cache"
|
||||
return Path(root).expanduser() / "bandsaunter" / "tiles"
|
||||
|
||||
|
||||
def fetch_tile(zoom: int, x: int, y: int, url: str = TILE_URL,
|
||||
timeout: float = 10.0, cache: Path | None = None) -> bytes | None:
|
||||
"""One tile, from the disk if it has ever been fetched before.
|
||||
|
||||
Returns the PNG bytes, or None if it could not be had. A missing tile is
|
||||
not an error: the map is drawn with a hole in it, which is better than no
|
||||
map and much better than an exception in the middle of an animation.
|
||||
"""
|
||||
where = (cache if cache is not None else cache_dir()) / str(zoom) / str(x)
|
||||
path = where / f"{y}.png"
|
||||
try:
|
||||
return path.read_bytes()
|
||||
except OSError:
|
||||
pass
|
||||
request = urllib.request.Request(url.format(z=zoom, x=x, y=y),
|
||||
headers={"User-Agent": USER_AGENT})
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as answer:
|
||||
body = answer.read(2_000_000)
|
||||
except Exception:
|
||||
return None
|
||||
try:
|
||||
where.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(".tmp")
|
||||
tmp.write_bytes(body)
|
||||
tmp.replace(path)
|
||||
except OSError:
|
||||
pass # an unwritable cache is not a reason to stop
|
||||
return body
|
||||
|
||||
|
||||
def mosaic(south: float, west: float, north: float, east: float, zoom: int,
|
||||
fetch=fetch_tile, pause: float = FETCH_PAUSE, **kw):
|
||||
"""Every tile the box touches, stitched into one image.
|
||||
|
||||
Returns the pixels and where their top-left corner sits in the world, in
|
||||
tile-grid pixels at this zoom, so the resampling below can place them.
|
||||
"""
|
||||
x0, y0 = tile_of(north, west, zoom)
|
||||
x1, y1 = tile_of(south, east, zoom)
|
||||
left, top = int(math.floor(x0)), int(math.floor(y0))
|
||||
right, bottom = int(math.floor(x1)), int(math.floor(y1))
|
||||
span = 2 ** zoom
|
||||
wide, tall = right - left + 1, bottom - top + 1
|
||||
if wide <= 0 or tall <= 0 or wide * tall > MAX_TILES * 4:
|
||||
return None, 0, 0
|
||||
canvas = np.zeros((tall * TILE_PIXELS, wide * TILE_PIXELS, 3),
|
||||
dtype=np.uint8)
|
||||
got = 0
|
||||
for row in range(tall):
|
||||
for column in range(wide):
|
||||
tx, ty = (left + column) % span, top + row
|
||||
if not 0 <= ty < span:
|
||||
continue
|
||||
body = fetch(zoom, tx, ty, **kw)
|
||||
if body is None:
|
||||
continue
|
||||
try:
|
||||
tile = decode_png(body)
|
||||
except (PNGError, zlib.error, ValueError):
|
||||
continue
|
||||
if tile.shape[0] != TILE_PIXELS or tile.shape[1] != TILE_PIXELS:
|
||||
continue
|
||||
canvas[row * TILE_PIXELS:(row + 1) * TILE_PIXELS,
|
||||
column * TILE_PIXELS:(column + 1) * TILE_PIXELS] = tile
|
||||
got += 1
|
||||
if pause:
|
||||
time.sleep(pause)
|
||||
if not got:
|
||||
return None, 0, 0
|
||||
return canvas, left * TILE_PIXELS, top * TILE_PIXELS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# What is on the ground
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _resample(values: np.ndarray, edges: np.ndarray, axis: int) -> np.ndarray:
|
||||
"""Average each output cell over the source pixels that fall in it.
|
||||
|
||||
A box filter, done as the difference of a running total so the whole
|
||||
axis is two passes rather than a loop. Where the source is coarser than
|
||||
the output -- a map zoomed in further than the tiles go -- a cell covers
|
||||
less than one source pixel, and it takes that one.
|
||||
"""
|
||||
length = values.shape[axis]
|
||||
starts = np.clip(np.floor(edges[:-1]).astype(np.int64), 0, length - 1)
|
||||
ends = np.clip(np.ceil(edges[1:]).astype(np.int64), 1, length)
|
||||
ends = np.maximum(ends, starts + 1)
|
||||
running = np.cumsum(values, axis=axis, dtype=np.float64)
|
||||
pad = np.zeros_like(np.take(running, [0], axis=axis))
|
||||
running = np.concatenate([pad, running], axis=axis)
|
||||
total = (np.take(running, ends, axis=axis)
|
||||
- np.take(running, starts, axis=axis))
|
||||
counts = (ends - starts).astype(np.float64)
|
||||
shape = [1] * values.ndim
|
||||
shape[axis] = counts.size
|
||||
return (total / counts.reshape(shape)).astype(np.float32)
|
||||
|
||||
|
||||
def _airport_cache(box) -> Path:
|
||||
root = os.environ.get("XDG_CACHE_HOME") or "~/.cache"
|
||||
name = "_".join(f"{round(v, 1):+06.1f}" for v in box)
|
||||
return Path(root).expanduser() / "bandsaunter" / "airports" / f"{name}.json"
|
||||
|
||||
|
||||
def airports_in(south: float, west: float, north: float, east: float,
|
||||
url: str = AIRPORTS_URL, timeout: float = 45.0,
|
||||
cache: Path | None = None, ask=None) -> list[dict]:
|
||||
"""Every aerodrome in a piece of the world, with a code and a position.
|
||||
|
||||
Asked of the same OpenStreetMap data the tiles are drawn from, as a
|
||||
question rather than a picture, and kept on disk afterwards: a runway
|
||||
does not move, so one question covers a view for a month.
|
||||
|
||||
An aerodrome with no code is left out. A map wants to say *which*
|
||||
airport an aircraft is over, and there are a great many landing strips
|
||||
with a name and nothing else; the ones worth marking have a code.
|
||||
"""
|
||||
box = (round(south, 1), round(west, 1), round(north, 1), round(east, 1))
|
||||
path = cache if cache is not None else _airport_cache(box)
|
||||
try:
|
||||
body = json.loads(path.read_text())
|
||||
if int(body.get("version") or 0) >= AIRPORT_CACHE_VERSION and \
|
||||
time.time() - float(body.get("fetched_at") or 0) < \
|
||||
AIRPORT_CACHE_DAYS * 86_400:
|
||||
return body.get("airports") or []
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
found: list[dict] = []
|
||||
try:
|
||||
raw = (ask or _ask_overpass)(box, url, timeout)
|
||||
for element in (raw or {}).get("elements", []):
|
||||
tags = element.get("tags") or {}
|
||||
code = _airport_code(tags)
|
||||
lat = element.get("lat") or (element.get("center") or {}).get("lat")
|
||||
lon = element.get("lon") or (element.get("center") or {}).get("lon")
|
||||
if not code or lat is None or lon is None:
|
||||
continue
|
||||
found.append({"code": code,
|
||||
"name": (tags.get("name") or "").strip(),
|
||||
"icao": bool(tags.get("icao")),
|
||||
"latitude": float(lat), "longitude": float(lon)})
|
||||
except Exception:
|
||||
return [] # a map with no airports on it, not a crash
|
||||
|
||||
# The ones with a real ICAO code first, since those are the ones an
|
||||
# aircraft is likely to be flying to or from.
|
||||
found.sort(key=lambda a: (0 if a.get("icao") else 1, a["code"]))
|
||||
# One airport is often tagged twice -- a point for the terminal and an
|
||||
# outline for the field -- and marking it twice writes its name over
|
||||
# itself.
|
||||
once: dict[str, dict] = {}
|
||||
for one in found:
|
||||
once.setdefault(one["code"], one)
|
||||
found = list(once.values())[:MOST_AIRPORTS]
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps({"fetched_at": time.time(),
|
||||
"version": AIRPORT_CACHE_VERSION,
|
||||
"airports": found}))
|
||||
except OSError:
|
||||
pass
|
||||
return found
|
||||
|
||||
|
||||
def _airport_code(tags: dict) -> str:
|
||||
"""What to call an aerodrome, or nothing if it has no code at all.
|
||||
|
||||
The ICAO code where it has one. Otherwise a reference, and only when it
|
||||
is four letters: a great many landing strips carry a local identifier
|
||||
like "14AZ" or "MX-0492", which names nothing anybody would recognise
|
||||
and turns a map into a list of airstrips.
|
||||
"""
|
||||
icao = (tags.get("icao") or "").strip().upper()
|
||||
if len(icao) == 4 and icao.isalpha():
|
||||
return icao
|
||||
ref = (tags.get("ref") or "").strip().upper()
|
||||
if len(ref) == 4 and ref.isalpha():
|
||||
return ref
|
||||
return ""
|
||||
|
||||
|
||||
def _ask_overpass(box, url: str, timeout: float) -> dict:
|
||||
"""The one question this program asks Overpass."""
|
||||
south, west, north, east = box
|
||||
where = f"({south},{west},{north},{east})"
|
||||
# Relations as well as nodes and ways: a big airport is a relation more
|
||||
# often than not -- Tucson International and Davis-Monthan both are --
|
||||
# so asking only for the other two finds every airstrip in the county
|
||||
# and misses the two the county is known for.
|
||||
query = ("[out:json][timeout:25];"
|
||||
f'(node["aeroway"="aerodrome"]{where};'
|
||||
f' way["aeroway"="aerodrome"]{where};'
|
||||
f' relation["aeroway"="aerodrome"]{where};);'
|
||||
"out center tags;")
|
||||
request = urllib.request.Request(
|
||||
url, data=urllib.parse.urlencode({"data": query}).encode(),
|
||||
headers={"User-Agent": USER_AGENT})
|
||||
with urllib.request.urlopen(request, timeout=timeout) as answer:
|
||||
return json.loads(answer.read(4_000_000).decode("utf8", "replace"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Putting it under the picture
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def ground_under(south: float, west: float, north: float, east: float,
|
||||
width: int, height: int, shades: int = 32,
|
||||
fetch=fetch_tile, zoom: int | None = None,
|
||||
**kw) -> np.ndarray | None:
|
||||
"""The map for one picture, as ``shades`` levels of brightness.
|
||||
|
||||
The tiles are Web Mercator and the picture is not, so every output pixel
|
||||
asks the mosaic where its own latitude and longitude landed rather than
|
||||
the mosaic being pasted in. Over the couple of hundred miles a receiver
|
||||
hears, the difference is a few pixels of drift at the top of the frame --
|
||||
which is a few pixels an aircraft would be drawn wrong by, and the whole
|
||||
point of putting a coastline under it is that the coastline is where the
|
||||
aircraft was.
|
||||
|
||||
Returns None when nothing could be fetched, which the caller draws as the
|
||||
plain grid it drew before.
|
||||
"""
|
||||
if width < 1 or height < 1 or north <= south or east <= west:
|
||||
return None
|
||||
if zoom is None:
|
||||
zoom = choose_zoom(south, west, north, east, width=width)
|
||||
tiles, origin_x, origin_y = mosaic(south, west, north, east, zoom,
|
||||
fetch=fetch, **kw)
|
||||
if tiles is None:
|
||||
return None
|
||||
|
||||
# The edges of each output pixel rather than its middle, so that what
|
||||
# lands in it can be averaged. Taking the nearest source pixel instead
|
||||
# throws away most of a tile -- the mosaic is commonly half again the
|
||||
# size of the picture -- and what survives is the aliasing: hard, broken
|
||||
# lettering and roads that come and go along their length.
|
||||
lons = west + (east - west) * np.arange(width + 1) / width
|
||||
lats = north - (north - south) * np.arange(height + 1) / height
|
||||
span = float(2 ** zoom) * TILE_PIXELS
|
||||
xs = (lons + 180.0) / 360.0 * span - origin_x
|
||||
clipped = np.clip(lats, -85.05112878, 85.05112878)
|
||||
ys = (1.0 - np.arcsinh(np.tan(np.radians(clipped))) / math.pi) / 2.0 \
|
||||
* span - origin_y
|
||||
|
||||
# Brightness only, inverted, and dimmed. Inverted because a printed map
|
||||
# is ink on white paper and this picture is the other way round: the
|
||||
# things drawn on the map -- coastlines, roads, the names of towns --
|
||||
# are the dark parts of a tile, and they are what should show against a
|
||||
# night background. Dimmed because the map is the ground under the
|
||||
# aircraft rather than the subject: anything drawn on top has to stay the
|
||||
# brightest thing on the picture.
|
||||
whole = (0.299 * tiles[:, :, 0] + 0.587 * tiles[:, :, 1]
|
||||
+ 0.114 * tiles[:, :, 2]).astype(np.float32)
|
||||
luma = _resample(_resample(whole, ys, axis=0), xs, axis=1)
|
||||
low, high = float(luma.min()), float(luma.max())
|
||||
if high - low < 1.0:
|
||||
levels = np.zeros_like(luma)
|
||||
else:
|
||||
levels = 1.0 - (luma - low) / (high - low)
|
||||
return np.clip((levels * (shades - 1)).round(), 0,
|
||||
shades - 1).astype(np.uint8)
|
||||
|
|
@ -7,6 +7,7 @@ import json
|
|||
import signal
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from rich.console import Console
|
||||
|
|
@ -22,7 +23,8 @@ from .bandplan import CATEGORIES, PRESETS, fmt_hz, in_category, search
|
|||
from .config import (DEFAULT_CONFIG_DIR, DEFAULT_CONFIG_PATH, ScanConfig,
|
||||
is_first_run, list_profiles, load_config, load_default,
|
||||
save_config, save_default)
|
||||
from .device import RtlSdrError, list_devices, set_driver_messages
|
||||
from .device import (RtlSdrDevice, RtlSdrError, list_devices,
|
||||
set_driver_messages)
|
||||
from .librtlsdr import load_error
|
||||
from . import settings as st
|
||||
from .ranges import (RangeError, ScanRange, build_plan, parse_range_list)
|
||||
|
|
@ -55,9 +57,7 @@ examples:
|
|||
bandsaunter bands --category Aviation browse the US band plan
|
||||
bandsaunter devices list attached dongles
|
||||
bandsaunter adsb read the aircraft on 1090 MHz
|
||||
bandsaunter adsb --window live map of the aircraft
|
||||
bandsaunter flights --out sky.gif animate what they did
|
||||
bandsaunter flights --theme phosphor draw it as a vector display
|
||||
bandsaunter scan -b 2m --simulate try it without hardware
|
||||
""")
|
||||
# The GNU form: the version, then who holds the copyright and what the
|
||||
|
|
@ -174,15 +174,8 @@ examples:
|
|||
"(default: adsb_<time>.jsonl in the output directory)")
|
||||
ad.add_argument("--no-log", dest="log_frames", action="store_false",
|
||||
help="listen without writing anything down")
|
||||
ad.add_argument("--lookup", dest="lookup",
|
||||
action="store_true", default=None,
|
||||
help="ask the public registers who each aircraft is")
|
||||
ad.add_argument("--no-lookup", dest="lookup", action="store_false",
|
||||
help="do not ask the registers who the aircraft are")
|
||||
ad.add_argument("--schedules", default=None, metavar="NAMES",
|
||||
help="which schedule services to ask, comma separated: "
|
||||
"flightaware, flightradar24, oag, cirium "
|
||||
"(each needs a key in the environment)")
|
||||
ad.add_argument("--kml", nargs="?", const="", default=None, metavar="FILE",
|
||||
help="also write the flight paths for Google Earth")
|
||||
ad.add_argument("--map", nargs="?", const="", default=None, metavar="FILE",
|
||||
|
|
@ -191,82 +184,6 @@ examples:
|
|||
help="invent a sky, for a receiver with no aerial")
|
||||
ad.add_argument("--near", default=None, metavar="LAT,LON",
|
||||
help="where the simulated aircraft are flying")
|
||||
ad.add_argument("--speed-unit", default=None,
|
||||
choices=("knots", "mph", "kph"),
|
||||
help="what to show speeds and distances in "
|
||||
"(default: knots, which is what aircraft broadcast)")
|
||||
ad.add_argument("--basemap", dest="basemap",
|
||||
action="store_true", default=None,
|
||||
help="draw a real map under the flight paths")
|
||||
ad.add_argument("--no-basemap", dest="basemap", action="store_false",
|
||||
default=None,
|
||||
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.add_argument("--rings", dest="rings", action="store_true",
|
||||
default=None,
|
||||
help="faint discs at a quarter, a half and three "
|
||||
"quarters of the radius, labelled with the distance")
|
||||
ad.add_argument("--no-rings", dest="rings", action="store_false",
|
||||
default=None,
|
||||
help="draw no range rings on the map")
|
||||
ad.add_argument("--window-rings", dest="window_rings",
|
||||
action="store_true", default=None,
|
||||
help="range rings on the realtime window too")
|
||||
ad.add_argument("--no-window-rings", dest="window_rings",
|
||||
action="store_false", default=None,
|
||||
help="no range rings on the realtime window")
|
||||
ad.add_argument("--box-opacity", type=int, default=None,
|
||||
metavar="PERCENT",
|
||||
help="how solid the card behind each information box is "
|
||||
"(0-100; 0 puts the words straight on the map)")
|
||||
ad.add_argument("--at", default=None, metavar="LAT,LON",
|
||||
help="where the receiver is: the middle of the window "
|
||||
"and of any map drawn afterwards")
|
||||
ad.add_argument("--radius", type=float, default=None, metavar="MILES",
|
||||
help="how far around the receiver the window and the map "
|
||||
"reach, in the same unit as the speeds")
|
||||
ad.add_argument("--hold", type=float, default=None, metavar="SECONDS",
|
||||
help="how long an aircraft stays on the display after "
|
||||
"its last frame")
|
||||
ad.add_argument("--tiles", default=None, metavar="URL",
|
||||
help="where map tiles come from ({z}/{x}/{y}.png)")
|
||||
ad.add_argument("--map-brightness", type=int, default=None,
|
||||
metavar="PERCENT",
|
||||
help="how bright the map under the aircraft is (10-100)")
|
||||
ad.add_argument("--width", type=int, default=None,
|
||||
help="how wide any map drawn afterwards is, in pixels")
|
||||
ad.add_argument("--fps", type=float, default=None,
|
||||
help="frames a second in any animation drawn afterwards")
|
||||
ad.add_argument("--trail", type=float, default=None, metavar="SECONDS",
|
||||
help="how much of the path to leave behind each aircraft")
|
||||
ad.add_argument("--fade", type=float, default=None, metavar="SECONDS",
|
||||
help="how long an aircraft takes to fade away once it "
|
||||
"has gone quiet")
|
||||
ad.add_argument("--stale", type=float, default=None, metavar="SECONDS",
|
||||
help="stop drawing an aircraft this long after its last "
|
||||
"report")
|
||||
ad.add_argument("--airports", dest="airports", action="store_true",
|
||||
default=None,
|
||||
help="mark every aerodrome on the map")
|
||||
ad.add_argument("--no-airports", dest="airports", action="store_false",
|
||||
default=None,
|
||||
help="do not mark the aerodromes")
|
||||
ad.add_argument("--labels", dest="labels", action="store_true",
|
||||
default=None,
|
||||
help="write the callsign, height and speed beside each "
|
||||
"aircraft")
|
||||
ad.add_argument("--no-labels", dest="labels", action="store_false",
|
||||
default=None,
|
||||
help="draw the aircraft without labels beside them")
|
||||
ad.add_argument("--theme", default=None, metavar="NAME",
|
||||
choices=("night", "digital", "phosphor", "amber", "red",
|
||||
"blue", "green", "orange", "wargames", "norad",
|
||||
"p1", "crimson"),
|
||||
help="how the window and the map look: night (the "
|
||||
"default), or the vector-display themes digital, "
|
||||
"phosphor, amber and red")
|
||||
ad.set_defaults(log_frames=True, lookup=True)
|
||||
|
||||
# -- flights --------------------------------------------------------------
|
||||
|
|
@ -290,78 +207,16 @@ examples:
|
|||
"(default: all of it)")
|
||||
fl.add_argument("--stale", type=float, default=300.0, metavar="SECONDS",
|
||||
help="drop an aircraft this long after its last report")
|
||||
fl.add_argument("--fade", type=float, default=None, metavar="SECONDS",
|
||||
help="how long an aircraft takes to fade away once it "
|
||||
"has gone quiet (0 to remove it at once)")
|
||||
fl.add_argument("--labels", dest="labels",
|
||||
action="store_true", default=None,
|
||||
help="write the callsign, height and speed beside each aircraft")
|
||||
fl.add_argument("--no-labels", dest="labels", action="store_false",
|
||||
help="draw the aircraft without callsigns beside them")
|
||||
fl.add_argument("--no-map", dest="draw", action="store_false",
|
||||
help="report only, draw nothing")
|
||||
fl.add_argument("--lookup", dest="lookup",
|
||||
action="store_true", default=None,
|
||||
help="ask the public registers who each aircraft is")
|
||||
fl.add_argument("--no-lookup", dest="lookup", action="store_false",
|
||||
help="do not ask the registers who the aircraft are")
|
||||
fl.add_argument("--schedules", default=None, metavar="NAMES",
|
||||
help="which schedule services to ask, comma separated: "
|
||||
"flightaware, flightradar24, oag, cirium "
|
||||
"(each needs a key in the environment)")
|
||||
fl.add_argument("--kml", nargs="?", const="", default=None, metavar="FILE",
|
||||
help="also write the flight paths for Google Earth")
|
||||
fl.add_argument("--report", nargs="?", const="", default=None, metavar="FILE",
|
||||
help="also write the readable report to a file")
|
||||
fl.add_argument("--speed-unit", default=None,
|
||||
choices=("knots", "mph", "kph"),
|
||||
help="what to show speeds and distances in "
|
||||
"(default: knots, which is what aircraft broadcast)")
|
||||
fl.add_argument("--basemap", dest="basemap",
|
||||
action="store_true", default=None,
|
||||
help="draw a real map under the flight paths")
|
||||
fl.add_argument("--no-basemap", dest="basemap", action="store_false",
|
||||
default=None,
|
||||
help="draw the tracks on their own, with no map under them")
|
||||
fl.add_argument("--tiles", default=None, metavar="URL",
|
||||
help="where map tiles come from ({z}/{x}/{y}.png)")
|
||||
fl.add_argument("--map-brightness", type=int, default=None,
|
||||
metavar="PERCENT",
|
||||
help="how bright the map under the aircraft is (10-100)")
|
||||
fl.add_argument("--rings", dest="rings", action="store_true",
|
||||
default=None,
|
||||
help="faint discs at a quarter, a half and three "
|
||||
"quarters of the radius, labelled with the distance")
|
||||
fl.add_argument("--no-rings", dest="rings", action="store_false",
|
||||
default=None,
|
||||
help="draw no range rings on the map")
|
||||
fl.add_argument("--box-opacity", type=int, default=None,
|
||||
metavar="PERCENT",
|
||||
help="how solid the card behind each information box is "
|
||||
"(0-100; 0 puts the words straight on the map)")
|
||||
fl.add_argument("--theme", default=None, metavar="NAME",
|
||||
choices=("night", "digital", "phosphor", "amber", "red",
|
||||
"blue", "green", "orange", "wargames", "norad",
|
||||
"p1", "crimson"),
|
||||
help="how the map looks: night (the default), or the "
|
||||
"vector-display themes digital, phosphor, amber "
|
||||
"and red")
|
||||
fl.add_argument("--airports", dest="airports",
|
||||
action="store_true", default=None,
|
||||
help="mark every aerodrome on the map")
|
||||
fl.add_argument("--no-airports", dest="airports", action="store_false",
|
||||
default=None,
|
||||
help="do not mark the aerodromes under the flight paths")
|
||||
fl.add_argument("--radius", type=float, default=None, metavar="MILES",
|
||||
help="how far around the receiver the map reaches, in the "
|
||||
"same unit as the speeds (0 = fit what was heard)")
|
||||
fl.add_argument("--at", default=None, metavar="LAT,LON",
|
||||
help="where the receiver is (default: worked out from "
|
||||
"what it heard)")
|
||||
fl.add_argument("--recheck", action="store_true",
|
||||
help="throw out positions the aircraft could not have "
|
||||
"been in, for logs recorded before the decoder "
|
||||
"checked the age of a position pair")
|
||||
fl.set_defaults(labels=True, draw=True, lookup=True)
|
||||
|
||||
# -- analyse ------------------------------------------------------------
|
||||
|
|
@ -483,33 +338,6 @@ def _maybe_first_run(cfg: ScanConfig, args) -> None:
|
|||
console.print()
|
||||
|
||||
|
||||
def _warn_about_aircraft_bands(cfg: ScanConfig) -> None:
|
||||
"""Say so when a sweep is pointed at something it cannot decode.
|
||||
|
||||
The band plan lists 1090 MHz because that is where ADS-B is, so choosing
|
||||
it from the band plan is the obvious thing to do and the wrong one. The
|
||||
sweep is not stopped -- looking at the spectrum there is a fair thing to
|
||||
want -- but it no longer happens silently.
|
||||
"""
|
||||
from . import aircraft as air
|
||||
|
||||
warning = air.scanning_aircraft_band(cfg.ranges)
|
||||
if not warning:
|
||||
return
|
||||
console.print(Panel(
|
||||
Text.from_markup(
|
||||
f"{escape(warning)}\n\n"
|
||||
"[bold]bandsaunter adsb[/bold] decodes it properly: aircraft, "
|
||||
"positions, altitudes and speeds, written to a log.\n"
|
||||
"[bold]bandsaunter flights[/bold] then draws where they went.\n\n"
|
||||
"[grey62]Both are in the menus as well, under Aircraft "
|
||||
"(ADS-B). Scanning it anyway is fine if what you want is the "
|
||||
"raw spectrum \u2014 add --save-iq to keep the samples."
|
||||
"[/grey62]"),
|
||||
title="[yellow]this band needs the aircraft mode",
|
||||
border_style="yellow", padding=(0, 1)))
|
||||
|
||||
|
||||
def _make_device(cfg: ScanConfig, simulate: bool):
|
||||
if simulate:
|
||||
from .simulator import SimulatedDevice
|
||||
|
|
@ -566,8 +394,6 @@ def cmd_scan(args) -> int:
|
|||
console.print(f"[red]{e}[/red]")
|
||||
return 2
|
||||
|
||||
_warn_about_aircraft_bands(cfg)
|
||||
|
||||
if args.dry_run:
|
||||
_print_plan(cfg)
|
||||
return 0
|
||||
|
|
@ -1165,11 +991,15 @@ def cmd_adsb(args) -> int:
|
|||
and a half kilohertz wide before anything sees it, and a megabit will not
|
||||
go through that.
|
||||
|
||||
The listening itself is in :mod:`bandsaunter.aircraft`, because the menus
|
||||
do exactly the same thing and neither front end should own it.
|
||||
Everything heard goes into a log as it arrives, because an aircraft is
|
||||
overhead for four minutes and then gone: the summary on the screen is for
|
||||
the person watching, and the log is for everything afterwards -- the
|
||||
report, the map and the animation.
|
||||
"""
|
||||
from .adsb import SAMPLE_RATE
|
||||
from . import aircraft as air
|
||||
from .adsb import (ADSB_HZ, AircraftRegistry, SAMPLE_RATE, SimulatedSky,
|
||||
decode_frames, default_sky)
|
||||
from .flightlog import FlightLog, read_logs, report, write_kml
|
||||
from .flights import FlightBook
|
||||
|
||||
cfg, _ = load_default()
|
||||
if args.rate < SAMPLE_RATE:
|
||||
|
|
@ -1177,61 +1007,216 @@ def cmd_adsb(args) -> int:
|
|||
f"{args.rate/1e6:g} is not enough to see a bit.[/red]")
|
||||
return 2
|
||||
|
||||
options = air.load_options()
|
||||
options.seconds = args.seconds
|
||||
options.rate = args.rate
|
||||
options.gain = args.gain
|
||||
options.device = args.device
|
||||
options.frames = args.frames
|
||||
options.log = args.log_frames
|
||||
options.lookup = args.lookup
|
||||
options.simulate = args.simulate
|
||||
options.kml = args.kml is not None
|
||||
options.draw_after = args.map is not None
|
||||
if args.near:
|
||||
options.near = args.near
|
||||
if args.speed_unit:
|
||||
options.speed_unit = args.speed_unit
|
||||
if args.basemap is not None:
|
||||
options.basemap = args.basemap
|
||||
if args.schedules is not None:
|
||||
options.schedules = args.schedules
|
||||
if getattr(args, "theme", None):
|
||||
options.theme = args.theme
|
||||
if getattr(args, "box_opacity", None) is not None:
|
||||
options.box_opacity = args.box_opacity
|
||||
if getattr(args, "rings", None) is not None:
|
||||
options.rings = args.rings
|
||||
if getattr(args, "window_rings", None) is not None:
|
||||
options.window_rings = args.window_rings
|
||||
for flag, key in (("at", "location"), ("radius", "radius"),
|
||||
("hold", "hold"), ("tiles", "tile_url"),
|
||||
("map_brightness", "map_brightness"),
|
||||
("width", "width"), ("fps", "fps"), ("trail", "trail"),
|
||||
("fade", "fade"), ("stale", "stale"),
|
||||
("airports", "airports"), ("labels", "labels"),
|
||||
("basemap", "basemap"), ("lookup", "lookup")):
|
||||
value = getattr(args, flag, None)
|
||||
if value is not None:
|
||||
setattr(options, key, value)
|
||||
if args.map:
|
||||
options.picture = Path(args.map).suffix.lstrip(".") or options.picture
|
||||
|
||||
run = air.watch if args.window else air.listen
|
||||
heard = run(console, options, cfg.output_dir, log_path=args.log)
|
||||
if not heard.aircraft:
|
||||
if args.simulate:
|
||||
sky = default_sky(*_near(args.near)) if args.near else default_sky()
|
||||
device = SimulatedSky(sky, sample_rate=args.rate,
|
||||
realtime=True).open()
|
||||
console.print("[yellow]simulated: these aircraft are not there."
|
||||
"[/yellow]")
|
||||
else:
|
||||
try:
|
||||
device = RtlSdrDevice(index=args.device, sample_rate=int(args.rate),
|
||||
gain=args.gain, agc=args.gain == "auto")
|
||||
device.open()
|
||||
except RtlSdrError as exc:
|
||||
console.print(Panel(Text(str(exc)),
|
||||
title="[red]cannot open the receiver",
|
||||
border_style="red"))
|
||||
return 1
|
||||
if args.kml and heard.kml_path is None:
|
||||
# An explicit path was given, so honour it rather than the one beside
|
||||
# the log that `listen` writes by default.
|
||||
from .flightlog import write_kml
|
||||
write_kml(Path(args.kml).expanduser(), heard.tracks)
|
||||
if heard.log_path is not None and not options.draw_after:
|
||||
console.print(f"[grey62]draw it: bandsaunter flights {heard.log_path}"
|
||||
|
||||
started = time.time()
|
||||
log = None
|
||||
if args.log_frames:
|
||||
stamp = datetime.fromtimestamp(started).strftime("%Y-%m-%d_%H_%M_%S")
|
||||
where = Path(args.log).expanduser() if args.log else \
|
||||
Path(cfg.output_dir).expanduser() / f"adsb_{stamp}.jsonl"
|
||||
try:
|
||||
log = FlightLog(where, frequency=ADSB_HZ, sample_rate=args.rate,
|
||||
receiver="simulated" if args.simulate else
|
||||
f"device {args.device}", started=started)
|
||||
except OSError as exc:
|
||||
console.print(f"[red]cannot write {where}: {exc}[/red]")
|
||||
log = None
|
||||
|
||||
registry = AircraftRegistry()
|
||||
total = 0
|
||||
console.print(f"[grey62]listening on {ADSB_HZ/1e6:g} MHz at "
|
||||
f"{args.rate/1e6:g} MS/s — control-C to stop[/grey62]")
|
||||
if log is not None:
|
||||
console.print(f"[grey62]writing {log.path}[/grey62]")
|
||||
try:
|
||||
device.tune(ADSB_HZ)
|
||||
block = int(args.rate) # a second at a time
|
||||
while True:
|
||||
at = time.time()
|
||||
samples = device.read_samples(block)
|
||||
if samples is None or samples.size == 0:
|
||||
break
|
||||
for frame in decode_frames(samples, args.rate):
|
||||
# The real time the frame arrived, not its offset in the
|
||||
# block: everything downstream is a clock, and a log that
|
||||
# started again from zero every second would be unusable.
|
||||
when = at + frame.at_sample / args.rate
|
||||
craft = registry.add(frame, when=when)
|
||||
total += 1
|
||||
if log is not None:
|
||||
log.append(frame, craft, when=when)
|
||||
if args.frames:
|
||||
console.print(f"[cyan]{frame.icao}[/cyan] "
|
||||
f"{escape(frame.describe())}",
|
||||
highlight=False)
|
||||
if not args.frames and total:
|
||||
console.print(f"[grey62]{len(registry)} aircraft, "
|
||||
f"{total} frames[/grey62]", highlight=False)
|
||||
if args.seconds and time.time() - started >= args.seconds:
|
||||
break
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
device.close()
|
||||
if log is not None:
|
||||
log.close()
|
||||
|
||||
if not registry:
|
||||
console.print("[yellow]nothing heard. ADS-B needs an aerial cut for "
|
||||
"1090 MHz; the whip that came with the dongle will "
|
||||
"hear the airport and not much else.[/yellow]")
|
||||
return 1
|
||||
|
||||
book = FlightBook(online=args.lookup)
|
||||
_aircraft_table(registry, book, total)
|
||||
if args.lookup:
|
||||
book.wait(12.0)
|
||||
book.save()
|
||||
_lookup_table(registry, book)
|
||||
|
||||
tracks = read_logs(log.path) if log is not None else _tracks_from(registry)
|
||||
if args.kml is not None:
|
||||
where = Path(args.kml).expanduser() if args.kml else \
|
||||
(log.path.with_suffix(".kml") if log is not None
|
||||
else Path("aircraft.kml"))
|
||||
written = write_kml(where, tracks, book if args.lookup else None)
|
||||
console.print(f"[green]{written}[/green]" if written
|
||||
else "[yellow]nothing was placed on the map[/yellow]")
|
||||
if log is not None:
|
||||
told = log.path.with_suffix(".txt")
|
||||
try:
|
||||
told.write_text("\n".join(report(
|
||||
tracks, book if args.lookup else None,
|
||||
title=f"bandsaunter — aircraft heard "
|
||||
f"{datetime.fromtimestamp(started):%Y-%m-%d %H:%M}")))
|
||||
console.print(f"[green]{told}[/green]")
|
||||
except OSError as exc:
|
||||
console.print(f"[red]cannot write {told}: {exc}[/red]")
|
||||
if args.map is not None:
|
||||
_draw_flights(tracks, args.map or (log.path.with_suffix(".gif")
|
||||
if log is not None
|
||||
else Path("aircraft.gif")),
|
||||
book if args.lookup else None)
|
||||
elif log is not None:
|
||||
console.print(f"[grey62]draw it: bandsaunter flights {log.path}"
|
||||
"[/grey62]")
|
||||
return 0
|
||||
|
||||
|
||||
def _near(text: str) -> tuple[float, float]:
|
||||
"""Read a LAT,LON pair, falling back to the default sky."""
|
||||
try:
|
||||
lat, lon = (float(x) for x in str(text).split(",", 1))
|
||||
return lat, lon
|
||||
except (TypeError, ValueError):
|
||||
console.print(f"[yellow]cannot read {text!r} as a latitude and "
|
||||
"longitude; flying somewhere else instead[/yellow]")
|
||||
return 47.55, -122.30
|
||||
|
||||
|
||||
def _tracks_from(registry):
|
||||
"""Tracks from a registry, for a session that wrote no log.
|
||||
|
||||
One position each: what is on the screen is all there is, because nothing
|
||||
kept the ones before it.
|
||||
"""
|
||||
from .flightlog import Fix, Track
|
||||
|
||||
out = []
|
||||
for craft in sorted(registry.aircraft.values(), key=lambda a: a.icao):
|
||||
track = Track(icao=craft.icao, callsign=craft.callsign,
|
||||
frames=craft.messages, first_seen=craft.first_seen,
|
||||
last_seen=craft.last_seen)
|
||||
if craft.located:
|
||||
track.fixes.append(Fix(at=craft.last_seen, latitude=craft.latitude,
|
||||
longitude=craft.longitude,
|
||||
altitude_ft=craft.altitude_ft,
|
||||
ground_speed_kt=craft.ground_speed_kt,
|
||||
track_deg=craft.track_deg,
|
||||
vertical_rate_fpm=craft.vertical_rate_fpm))
|
||||
out.append(track)
|
||||
return out
|
||||
|
||||
|
||||
def _aircraft_table(registry, book, total: int) -> None:
|
||||
"""What was heard, as it was heard: no register, only the air."""
|
||||
t = Table(title=f"{len(registry)} aircraft, {total} frames", box=None,
|
||||
header_style="bold")
|
||||
for column in ("ICAO", "callsign", "altitude", "position", "speed",
|
||||
"frames"):
|
||||
t.add_column(column)
|
||||
for craft in sorted(registry.aircraft.values(), key=lambda a: a.icao):
|
||||
t.add_row(craft.icao, craft.callsign or "",
|
||||
f"{craft.altitude_ft:,} ft" if craft.altitude_ft else "",
|
||||
(f"{craft.latitude:.4f}, {craft.longitude:.4f}"
|
||||
if craft.located else ""),
|
||||
(f"{craft.ground_speed_kt:.0f} kt {craft.track_deg:.0f}°"
|
||||
if craft.ground_speed_kt else ""),
|
||||
str(craft.messages))
|
||||
console.print(t)
|
||||
|
||||
|
||||
def _lookup_table(registry, book) -> None:
|
||||
"""And what the registers say about them, kept separate on purpose."""
|
||||
rows = []
|
||||
for craft in sorted(registry.aircraft.values(), key=lambda a: a.icao):
|
||||
entry = book.get(craft.icao, craft.callsign)
|
||||
told = entry.summary()
|
||||
if told or entry.country:
|
||||
rows.append((craft.icao, craft.callsign or "", entry.country,
|
||||
told or "—"))
|
||||
if not rows:
|
||||
return
|
||||
t = Table(title="what the registers say", box=None, header_style="bold")
|
||||
for column in ("ICAO", "callsign", "registered", "aircraft, operator, route"):
|
||||
t.add_column(column, overflow="fold")
|
||||
for row in rows:
|
||||
t.add_row(*row)
|
||||
console.print(t)
|
||||
|
||||
|
||||
def _draw_flights(tracks, out_path, book, **over) -> bool:
|
||||
"""Draw the animation, saying what it is drawing and what came out."""
|
||||
from .flightmap import animate, ffmpeg_available
|
||||
|
||||
out_path = Path(out_path).expanduser()
|
||||
if out_path.suffix.lower() in (".mp4", ".mov", ".m4v") and \
|
||||
not ffmpeg_available():
|
||||
console.print("[yellow]ffmpeg is not installed; writing a GIF "
|
||||
"instead[/yellow]")
|
||||
out_path = out_path.with_suffix(".gif")
|
||||
console.print(f"[grey62]drawing {out_path.name}…[/grey62]")
|
||||
try:
|
||||
drawn = animate(tracks, out_path, book=book, **over)
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
console.print(f"[red]{exc}[/red]")
|
||||
return False
|
||||
if drawn is None:
|
||||
console.print("[yellow]nothing was placed on the map: no aircraft "
|
||||
"reported a position[/yellow]")
|
||||
return False
|
||||
size = drawn.path.stat().st_size / 1e6
|
||||
console.print(f"[green]{drawn.path}[/green] "
|
||||
f"[grey62]{drawn.summary()}, {size:.1f} MB[/grey62]")
|
||||
return True
|
||||
|
||||
|
||||
def cmd_flights(args) -> int:
|
||||
"""Turn a log of ADS-B frames into something worth looking at.
|
||||
|
||||
|
|
@ -1239,13 +1224,12 @@ def cmd_flights(args) -> int:
|
|||
back, asks who the aircraft were, prints what it found and draws the
|
||||
whole evening as a map with the clock running.
|
||||
"""
|
||||
from . import aircraft as air
|
||||
from .flightlog import read_logs, report, write_kml
|
||||
from .flights import FlightBook
|
||||
|
||||
cfg, _ = load_default()
|
||||
paths = [Path(p).expanduser() for p in args.path] if args.path \
|
||||
else air.logs_in(cfg.output_dir)[:1]
|
||||
else _newest_log(Path(cfg.output_dir).expanduser())
|
||||
if not paths:
|
||||
console.print("[yellow]no ADS-B logs found. Record one with "
|
||||
"`bandsaunter adsb`.[/yellow]")
|
||||
|
|
@ -1259,37 +1243,15 @@ def cmd_flights(args) -> int:
|
|||
if not tracks:
|
||||
console.print(f"[yellow]{paths[0].name} holds no frames[/yellow]")
|
||||
return 1
|
||||
options = air.load_options()
|
||||
if args.speed_unit:
|
||||
options.speed_unit = args.speed_unit
|
||||
if args.recheck:
|
||||
options.recheck = True
|
||||
if args.schedules is not None:
|
||||
options.schedules = args.schedules
|
||||
if getattr(args, "theme", None):
|
||||
options.theme = args.theme
|
||||
if getattr(args, "box_opacity", None) is not None:
|
||||
options.box_opacity = args.box_opacity
|
||||
if getattr(args, "rings", None) is not None:
|
||||
options.rings = args.rings
|
||||
if getattr(args, "window_rings", None) is not None:
|
||||
options.window_rings = args.window_rings
|
||||
tracks = air.checked(console, options, tracks)
|
||||
book = FlightBook(online=args.lookup,
|
||||
schedules=air.schedule_names(options))
|
||||
book = FlightBook(online=args.lookup)
|
||||
if args.lookup:
|
||||
for track in tracks:
|
||||
# The moment it was overhead, so a schedule service can say
|
||||
# which leg was in the air then rather than which is now.
|
||||
when = (track.fixes[len(track.fixes) // 2].at if track.located
|
||||
else track.last_seen)
|
||||
book.get(track.icao, track.callsign, when)
|
||||
book.get(track.icao, track.callsign)
|
||||
book.wait(20.0)
|
||||
book.save()
|
||||
|
||||
title = f"bandsaunter — {paths[0].name}"
|
||||
lines = report(tracks, book if args.lookup else None, title=title,
|
||||
unit=options.speed_unit)
|
||||
lines = report(tracks, book if args.lookup else None, title=title)
|
||||
console.print(escape("\n".join(lines)), highlight=False)
|
||||
if args.report is not None:
|
||||
where = Path(args.report).expanduser() if args.report \
|
||||
|
|
@ -1302,43 +1264,31 @@ def cmd_flights(args) -> int:
|
|||
if args.kml is not None:
|
||||
where = Path(args.kml).expanduser() if args.kml \
|
||||
else paths[0].with_suffix(".kml")
|
||||
written = write_kml(where, tracks, book if args.lookup else None,
|
||||
unit=options.speed_unit)
|
||||
written = write_kml(where, tracks, book if args.lookup else None)
|
||||
console.print(f"[green]{written}[/green]" if written
|
||||
else "[yellow]nothing was placed on the map[/yellow]")
|
||||
if not args.draw:
|
||||
return 0
|
||||
|
||||
options.fps = args.fps
|
||||
options.length = args.seconds
|
||||
options.speed = args.speed
|
||||
options.width = args.width
|
||||
options.trail = args.trail
|
||||
options.stale = args.stale
|
||||
options.labels = args.labels
|
||||
if args.fade is not None:
|
||||
options.fade = args.fade
|
||||
if args.basemap is not None:
|
||||
options.basemap = args.basemap
|
||||
if args.tiles:
|
||||
options.tile_url = args.tiles
|
||||
if args.map_brightness is not None:
|
||||
options.map_brightness = args.map_brightness
|
||||
if getattr(args, "theme", None):
|
||||
options.theme = args.theme
|
||||
if args.airports is not None:
|
||||
options.airports = args.airports
|
||||
if args.radius is not None:
|
||||
options.radius = args.radius
|
||||
if args.at:
|
||||
options.location = args.at
|
||||
out = Path(args.out).expanduser() if args.out else \
|
||||
paths[0].with_suffix("." + options.picture)
|
||||
drawn = air.draw(console, options, tracks, out,
|
||||
book if args.lookup else None)
|
||||
paths[0].with_suffix(".gif")
|
||||
drawn = _draw_flights(tracks, out, book if args.lookup else None,
|
||||
fps=args.fps, seconds=args.seconds, speed=args.speed,
|
||||
width=args.width, trail_seconds=args.trail,
|
||||
stale=args.stale, labels=args.labels)
|
||||
return 0 if drawn else 1
|
||||
|
||||
|
||||
def _newest_log(directory: Path) -> list[Path]:
|
||||
"""The last ADS-B log written, which is nearly always the one wanted."""
|
||||
try:
|
||||
logs = sorted(directory.glob("adsb_*.jsonl"),
|
||||
key=lambda p: p.stat().st_mtime)
|
||||
except OSError:
|
||||
return []
|
||||
return logs[-1:]
|
||||
|
||||
|
||||
def cmd_analyze(args) -> int:
|
||||
import numpy as np
|
||||
from .classify import classify
|
||||
|
|
|
|||
|
|
@ -1,320 +0,0 @@
|
|||
"""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",
|
||||
"COUNTRY_ISO", "iso_for"]
|
||||
|
||||
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"),
|
||||
}
|
||||
|
||||
|
||||
# The names this program writes for countries, and the codes they answer to.
|
||||
# Two sources spell a country in words rather than letters -- the treaty
|
||||
# table of address blocks, and the registers -- and a flag needs the letters.
|
||||
COUNTRY_ISO: dict[str, str] = {
|
||||
"Afghanistan": "AF", "Albania": "AL", "Algeria": "DZ", "Angola": "AO",
|
||||
"Argentina": "AR", "Armenia": "AM", "Australia": "AU", "Austria": "AT",
|
||||
"Azerbaijan": "AZ", "Bahrain": "BH", "Bangladesh": "BD", "Belarus": "BY",
|
||||
"Belgium": "BE", "Bhutan": "BT", "Bolivia": "BO",
|
||||
"Bosnia and Herzegovina": "BA", "Botswana": "BW", "Brazil": "BR",
|
||||
"Brunei": "BN", "Bulgaria": "BG", "Burundi": "BI", "Cambodia": "KH",
|
||||
"Cameroon": "CM", "Canada": "CA", "Central African Republic": "CF",
|
||||
"Chad": "TD", "Chile": "CL", "China": "CN", "Colombia": "CO",
|
||||
"Congo": "CG", "Costa Rica": "CR", "Croatia": "HR", "Cuba": "CU",
|
||||
"Cyprus": "CY", "Czechia": "CZ", "Czech Republic": "CZ",
|
||||
"Côte d'Ivoire": "CI", "Democratic Republic of the Congo": "CD",
|
||||
"Denmark": "DK", "Dominican Republic": "DO", "Ecuador": "EC",
|
||||
"Egypt": "EG", "Equatorial Guinea": "GQ", "Eritrea": "ER",
|
||||
"Estonia": "EE", "Ethiopia": "ET", "Fiji": "FJ", "Finland": "FI",
|
||||
"France": "FR", "Gabon": "GA", "Georgia": "GE", "Germany": "DE",
|
||||
"Ghana": "GH", "Greece": "GR", "Guinea": "GN", "Hungary": "HU",
|
||||
"Iceland": "IS", "India": "IN", "Indonesia": "ID", "Iran": "IR",
|
||||
"Iraq": "IQ", "Ireland": "IE", "Israel": "IL", "Italy": "IT",
|
||||
"Jamaica": "JM", "Japan": "JP", "Jordan": "JO", "Kazakhstan": "KZ",
|
||||
"Kenya": "KE", "Kuwait": "KW", "Kyrgyzstan": "KG", "Laos": "LA",
|
||||
"Latvia": "LV", "Lebanon": "LB", "Liberia": "LR", "Libya": "LY",
|
||||
"Lithuania": "LT", "Luxembourg": "LU", "Madagascar": "MG",
|
||||
"Malawi": "MW", "Malaysia": "MY", "Mali": "ML", "Malta": "MT",
|
||||
"Marshall Islands": "MH", "Mauritius": "MU", "Mexico": "MX",
|
||||
"Moldova": "MD", "Monaco": "MC", "Mongolia": "MN", "Montenegro": "ME",
|
||||
"Morocco": "MA", "Mozambique": "MZ", "Myanmar": "MM", "Namibia": "NA",
|
||||
"Nepal": "NP", "Netherlands": "NL", "New Zealand": "NZ", "Niger": "NE",
|
||||
"Nigeria": "NG", "North Korea": "KP", "North Macedonia": "MK",
|
||||
"Norway": "NO", "Oman": "OM", "Pakistan": "PK", "Panama": "PA",
|
||||
"Papua New Guinea": "PG", "Paraguay": "PY", "Peru": "PE",
|
||||
"Philippines": "PH", "Poland": "PL", "Portugal": "PT", "Qatar": "QA",
|
||||
"Romania": "RO", "Russia": "RU", "Russian Federation": "RU",
|
||||
"Rwanda": "RW", "San Marino": "SM", "Saudi Arabia": "SA",
|
||||
"Senegal": "SN", "Serbia": "RS", "Seychelles": "SC",
|
||||
"Sierra Leone": "SL", "Singapore": "SG", "Slovakia": "SK",
|
||||
"Slovenia": "SI", "Somalia": "SO", "South Africa": "ZA",
|
||||
"South Korea": "KR", "Korea, Republic of": "KR", "Spain": "ES",
|
||||
"Sri Lanka": "LK", "Sudan": "SD", "Sweden": "SE", "Switzerland": "CH",
|
||||
"Syria": "SY", "Taiwan": "TW", "Tajikistan": "TJ", "Tanzania": "TZ",
|
||||
"Thailand": "TH", "Togo": "TG", "Tonga": "TO", "Tunisia": "TN",
|
||||
"Turkey": "TR", "Turkmenistan": "TM", "Uganda": "UG", "Ukraine": "UA",
|
||||
"United Arab Emirates": "AE", "United Kingdom": "GB",
|
||||
"United States": "US", "United States of America": "US",
|
||||
"Uruguay": "UY", "Uzbekistan": "UZ", "Vanuatu": "VU", "Venezuela": "VE",
|
||||
"Viet Nam": "VN", "Vietnam": "VN", "Yemen": "YE", "Zambia": "ZM",
|
||||
"Zimbabwe": "ZW",
|
||||
}
|
||||
|
||||
|
||||
def iso_for(name: str) -> str:
|
||||
"""The two letters for a country written out in words.
|
||||
|
||||
A military block is named for the country whose it is -- "United States
|
||||
military" -- and flies the same flag, so the suffix is dropped rather
|
||||
than treated as somewhere else.
|
||||
"""
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
return ""
|
||||
if len(name) == 2 and name.isalpha():
|
||||
return name.upper() # already a code
|
||||
if name in COUNTRY_ISO:
|
||||
return COUNTRY_ISO[name]
|
||||
for suffix in (" military", " Military"):
|
||||
if name.endswith(suffix):
|
||||
return COUNTRY_ISO.get(name[:-len(suffix)], "")
|
||||
return ""
|
||||
|
||||
|
||||
def known(code: str) -> bool:
|
||||
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 ""
|
||||
|
|
@ -21,17 +21,12 @@ from __future__ import annotations
|
|||
import json
|
||||
import math
|
||||
import time
|
||||
from statistics import median
|
||||
from dataclasses import dataclass, field, replace
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
__all__ = ["Fix", "Track", "FlightLog", "read_logs", "report",
|
||||
"write_kml", "LOG_VERSION", "EARTH_NM", "SPEED_UNITS",
|
||||
"speed_label", "in_speed", "distance_label", "in_distance",
|
||||
"DEFAULT_SPEED_UNIT", "centre_of", "read_position",
|
||||
"box_around", "within", "recheck", "implied_speed_kt",
|
||||
"MAX_GROUND_SPEED_KT"]
|
||||
"write_kml", "LOG_VERSION", "EARTH_NM"]
|
||||
|
||||
LOG_VERSION = 1
|
||||
|
||||
|
|
@ -39,48 +34,6 @@ LOG_VERSION = 1
|
|||
# every other number in this file is already in.
|
||||
EARTH_NM = 3440.065
|
||||
|
||||
# What the aircraft says is knots and nautical miles, because that is what
|
||||
# the standard sends and what the log therefore holds. Anything else is a
|
||||
# conversion done at the moment of showing it to somebody, so the recorded
|
||||
# data is never the one that had arithmetic applied to it.
|
||||
#
|
||||
# name: (speed label, knots -> this, distance label, nautical miles -> this)
|
||||
SPEED_UNITS: dict[str, tuple[str, float, str, float]] = {
|
||||
"knots": ("kt", 1.0, "nm", 1.0),
|
||||
"mph": ("mph", 1.150779, "mi", 1.150779),
|
||||
"kph": ("km/h", 1.852, "km", 1.852),
|
||||
}
|
||||
DEFAULT_SPEED_UNIT = "knots"
|
||||
|
||||
|
||||
def speed_unit(name: str) -> tuple[str, float, str, float]:
|
||||
"""The conversion for a unit name, falling back to what aircraft use."""
|
||||
return SPEED_UNITS.get((name or "").strip().lower(),
|
||||
SPEED_UNITS[DEFAULT_SPEED_UNIT])
|
||||
|
||||
|
||||
def speed_label(name: str = DEFAULT_SPEED_UNIT) -> str:
|
||||
"""What to write after a speed: kt, mph or km/h."""
|
||||
return speed_unit(name)[0]
|
||||
|
||||
|
||||
def in_speed(knots: float, name: str = DEFAULT_SPEED_UNIT) -> float:
|
||||
"""A speed in knots, as the unit asked for."""
|
||||
return float(knots) * speed_unit(name)[1]
|
||||
|
||||
|
||||
def distance_label(name: str = DEFAULT_SPEED_UNIT) -> str:
|
||||
"""The distance unit that goes with a speed unit: nm, mi or km.
|
||||
|
||||
Miles an hour with distances in nautical miles would be two different
|
||||
miles on one picture, which is worse than either on its own.
|
||||
"""
|
||||
return speed_unit(name)[2]
|
||||
|
||||
|
||||
def in_distance(nm: float, name: str = DEFAULT_SPEED_UNIT) -> float:
|
||||
return float(nm) * speed_unit(name)[3]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Fix:
|
||||
|
|
@ -142,175 +95,6 @@ def move(lat: float, lon: float, bearing: float, nm: float) -> tuple[float, floa
|
|||
return math.degrees(p2), (math.degrees(l2) + 540) % 360 - 180
|
||||
|
||||
|
||||
def centre_of(tracks) -> tuple[float, float] | None:
|
||||
"""Where the receiver most likely is, from everything it heard.
|
||||
|
||||
The median rather than the mean, because a handful of wrong positions
|
||||
would drag an average halfway across a continent and cannot move a
|
||||
median at all. A receiver hears aircraft all around it, so the middle
|
||||
of what it heard is very close to where it is standing.
|
||||
"""
|
||||
lats = [f.latitude for t in tracks for f in t.fixes]
|
||||
lons = [f.longitude for t in tracks for f in t.fixes]
|
||||
if not lats:
|
||||
return None
|
||||
return median(lats), median(lons)
|
||||
|
||||
|
||||
def read_position(text: str) -> tuple[float, float] | None:
|
||||
"""A "lat,lon" pair as two numbers, or None if it is not one."""
|
||||
try:
|
||||
lat, lon = (float(x) for x in str(text).split(",", 1))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not (-90.0 <= lat <= 90.0 and -180.0 <= lon <= 180.0):
|
||||
return None
|
||||
return lat, lon
|
||||
|
||||
|
||||
def box_around(lat: float, lon: float, radius_nm: float) -> tuple:
|
||||
"""The south, west, north, east of a circle of this radius.
|
||||
|
||||
A degree of latitude is sixty nautical miles everywhere; a degree of
|
||||
longitude is sixty times the cosine of the latitude, which is why the
|
||||
box is wider in degrees the further north it is drawn.
|
||||
"""
|
||||
span_lat = radius_nm / 60.0
|
||||
span_lon = radius_nm / 60.0 / max(0.02, math.cos(math.radians(lat)))
|
||||
return (max(-90.0, lat - span_lat), lon - span_lon,
|
||||
min(90.0, lat + span_lat), lon + span_lon)
|
||||
|
||||
|
||||
# Above anything with a transponder on it, so a fast aircraft is never called
|
||||
# an error. Concorde cruised at 1150 kt.
|
||||
MAX_GROUND_SPEED_KT = 2000.0
|
||||
|
||||
# Past this, two positions are not worth comparing: an aircraft out of range
|
||||
# for five minutes may legitimately reappear anywhere it could have flown.
|
||||
TRUST_SECONDS = 300.0
|
||||
|
||||
# A move shorter than this is never called an error, however little time it
|
||||
# took. Positions arrive twice a second and are stamped to the millisecond,
|
||||
# so two of them a thousandth of a second apart imply thousands of knots
|
||||
# across a few yards -- and a compact-position error is never a few yards,
|
||||
# it is a different longitude zone. Measured over one night's recording the
|
||||
# two are cleanly separated: every real jump was over fifty miles and every
|
||||
# false one under one.
|
||||
MIN_JUMP_NM = 2.0
|
||||
|
||||
# How many recent positions each one is weighed against. Positions arrive
|
||||
# about twice a second, so this is a few seconds of history -- enough to step
|
||||
# over a run of bad decodes, and short enough that the work stays linear in
|
||||
# the length of the log.
|
||||
_CHAIN_WINDOW = 40
|
||||
|
||||
|
||||
|
||||
def implied_speed_kt(a: "Fix", b: "Fix") -> float:
|
||||
"""How fast something would have to move to be in both places."""
|
||||
gap = b.at - a.at
|
||||
if gap <= 0:
|
||||
return 0.0
|
||||
return distance_nm(a.latitude, a.longitude,
|
||||
b.latitude, b.longitude) / gap * 3600.0
|
||||
|
||||
|
||||
def _reachable(a: "Fix", b: "Fix") -> bool:
|
||||
gap = b.at - a.at
|
||||
if gap <= 0 or gap > TRUST_SECONDS:
|
||||
return True # too long ago to argue with
|
||||
if distance_nm(a.latitude, a.longitude,
|
||||
b.latitude, b.longitude) <= MIN_JUMP_NM:
|
||||
return True # too small a move to be a bad decode
|
||||
return implied_speed_kt(a, b) <= MAX_GROUND_SPEED_KT
|
||||
|
||||
|
||||
def recheck(tracks) -> tuple[list, int]:
|
||||
"""Drop the positions an aircraft could not have been in.
|
||||
|
||||
Returns the tracks and how many fixes went. For logs recorded before
|
||||
the decoder checked the age of a compact-position pair: an even frame
|
||||
kept from ten minutes ago, read against a fresh odd one, decodes to a
|
||||
place on the wrong side of the world, and that is written down as
|
||||
confidently as a real position.
|
||||
|
||||
An aircraft that goes out of range and comes back is not an error, so
|
||||
two positions are only ever compared while they are close in time; past
|
||||
five minutes the aircraft may legitimately be anywhere it could have
|
||||
flown to, and nothing is rejected. Inside that window, though, a
|
||||
position that cannot be reached from the one before it is wrong however
|
||||
many equally wrong ones follow it -- three bad decodes in a row can land
|
||||
in the same wrong place and agree with each other perfectly.
|
||||
"""
|
||||
out, dropped = [], 0
|
||||
for track in tracks:
|
||||
kept = _consistent(track.fixes)
|
||||
dropped += len(track.fixes) - len(kept)
|
||||
out.append(track if len(kept) == len(track.fixes)
|
||||
else replace(track, fixes=kept))
|
||||
return out, dropped
|
||||
|
||||
|
||||
def _consistent(fixes: list) -> list:
|
||||
"""The longest run of positions that tell one story.
|
||||
|
||||
Walking forward and keeping whatever is reachable from the last position
|
||||
kept is the obvious way and the wrong one: it only takes one bad fix to
|
||||
become the reference, and then every real position afterwards is five
|
||||
hundred miles from where the aircraft is supposed to be and gets thrown
|
||||
away instead. Measured on a real recording that discarded a fifth of
|
||||
everything, most of it the truth.
|
||||
|
||||
So no position is the reference. Every chain of positions that could
|
||||
describe one aeroplane is considered, and the longest is the answer --
|
||||
the errors are outnumbered by definition, because they are errors.
|
||||
"""
|
||||
real = [f for f in fixes
|
||||
if -90.0 <= f.latitude <= 90.0 and -180.0 <= f.longitude <= 180.0]
|
||||
if len(real) < 2:
|
||||
return real
|
||||
# Two positions that contradict each other are not two positions: one of
|
||||
# them is wrong and there is nothing to say which, so the later one goes.
|
||||
# What comes out is at least a story, which is the whole promise here.
|
||||
# Each position, against the recent ones rather than all of them: they
|
||||
# arrive twice a second and nothing further back than this window can
|
||||
# still be argued with anyway.
|
||||
best = [1] * len(real)
|
||||
came_from = [-1] * len(real)
|
||||
for i, fix in enumerate(real):
|
||||
for j in range(max(0, i - _CHAIN_WINDOW), i):
|
||||
if best[j] + 1 > best[i] and _reachable(real[j], fix):
|
||||
best[i] = best[j] + 1
|
||||
came_from[i] = j
|
||||
end = max(range(len(real)), key=lambda i: best[i])
|
||||
chain = []
|
||||
while end >= 0:
|
||||
chain.append(real[end])
|
||||
end = came_from[end]
|
||||
chain.reverse()
|
||||
return chain
|
||||
|
||||
|
||||
def within(tracks, lat: float, lon: float, radius_nm: float) -> list:
|
||||
"""The tracks with everything outside the radius dropped.
|
||||
|
||||
Per fix rather than per aircraft, because a track is rarely all good or
|
||||
all bad: one wrong position in the middle of a real flight would
|
||||
otherwise take the whole flight off the map with it, or keep the whole
|
||||
map stretched to reach it.
|
||||
"""
|
||||
out = []
|
||||
for track in tracks:
|
||||
kept = [f for f in track.fixes
|
||||
if distance_nm(lat, lon, f.latitude, f.longitude) <= radius_nm]
|
||||
if len(kept) == len(track.fixes):
|
||||
out.append(track)
|
||||
continue
|
||||
near = replace(track, fixes=kept)
|
||||
out.append(near)
|
||||
return out
|
||||
|
||||
|
||||
@dataclass
|
||||
class Track:
|
||||
"""One aircraft's evening: who it was and everywhere it was seen."""
|
||||
|
|
@ -322,14 +106,6 @@ class Track:
|
|||
first_seen: float = 0.0
|
||||
last_seen: float = 0.0
|
||||
altitudes: list[tuple[float, int]] = field(default_factory=list)
|
||||
category: str = "" # what it said it was: heavy, rotorcraft...
|
||||
|
||||
@property
|
||||
def kind(self) -> str:
|
||||
"""What sort of aircraft this is, as far as can be told."""
|
||||
from .flights import aircraft_class
|
||||
|
||||
return aircraft_class(self.icao, self.category)
|
||||
|
||||
@property
|
||||
def located(self) -> bool:
|
||||
|
|
@ -415,7 +191,7 @@ class Track:
|
|||
out.append(now)
|
||||
return out
|
||||
|
||||
def describe(self, unit: str = DEFAULT_SPEED_UNIT) -> str:
|
||||
def describe(self) -> str:
|
||||
"""One line: who, where from and to, how far and how high."""
|
||||
bits = [self.icao]
|
||||
if self.callsign:
|
||||
|
|
@ -424,14 +200,12 @@ class Track:
|
|||
first, last = self.fixes[0], self.fixes[-1]
|
||||
bits.append(f"{first.latitude:.3f},{first.longitude:.3f} → "
|
||||
f"{last.latitude:.3f},{last.longitude:.3f}")
|
||||
bits.append(f"{in_distance(self.distance_nm, unit):.0f} "
|
||||
f"{distance_label(unit)}")
|
||||
bits.append(f"{self.distance_nm:.0f} nm")
|
||||
low, high = self.altitude_range
|
||||
if high:
|
||||
bits.append(f"{low}–{high} ft" if low != high else f"{high} ft")
|
||||
if self.top_speed_kt:
|
||||
bits.append(f"{in_speed(self.top_speed_kt, unit):.0f} "
|
||||
f"{speed_label(unit)}")
|
||||
bits.append(f"{self.top_speed_kt:.0f} kt")
|
||||
bits.append(f"{self.frames} frames")
|
||||
return " ".join(bits)
|
||||
|
||||
|
|
@ -569,13 +343,6 @@ def read_logs(paths) -> list[Track]:
|
|||
track.last_seen = max(track.last_seen, when)
|
||||
if line.get("callsign"):
|
||||
track.callsign = str(line["callsign"])
|
||||
# Read back out of the frame rather than stored beside it. The
|
||||
# three bits are in the identification message that is already
|
||||
# written down in full, so every log ever written by this
|
||||
# program has them, including the ones written before anything
|
||||
# here knew to look.
|
||||
if not track.category:
|
||||
track.category = _category_of(line)
|
||||
if line.get("alt_ft"):
|
||||
track.altitudes.append((when, int(line["alt_ft"])))
|
||||
# What it last said about itself, from whichever frame said it.
|
||||
|
|
@ -612,22 +379,6 @@ def read_logs(paths) -> list[Track]:
|
|||
return sorted(tracks.values(), key=lambda t: (t.first_seen, t.icao))
|
||||
|
||||
|
||||
def _category_of(line: dict) -> str:
|
||||
"""The emitter category out of a logged identification frame."""
|
||||
try:
|
||||
type_code = int(line.get("tc") or 0)
|
||||
if not 1 <= type_code <= 4:
|
||||
return ""
|
||||
raw = str(line.get("hex") or "")
|
||||
if len(raw) < 10:
|
||||
return ""
|
||||
from .adsb import category_name
|
||||
|
||||
return category_name(type_code, int(raw[8:10], 16) & 0x07)
|
||||
except (TypeError, ValueError):
|
||||
return ""
|
||||
|
||||
|
||||
def _lines(path: Path):
|
||||
try:
|
||||
with path.open(encoding="utf8") as handle:
|
||||
|
|
@ -687,8 +438,7 @@ def _carry_forward(track: Track) -> None:
|
|||
# The readable report
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def report(tracks: list[Track], book=None, title: str = "",
|
||||
unit: str = DEFAULT_SPEED_UNIT) -> list[str]:
|
||||
def report(tracks: list[Track], book=None, title: str = "") -> list[str]:
|
||||
"""One block per aircraft, in the order they were first heard.
|
||||
|
||||
``book`` is a FlightBook, or None. Everything it can add is printed under
|
||||
|
|
@ -710,9 +460,6 @@ def report(tracks: list[Track], book=None, title: str = "",
|
|||
out.append(f"{track.icao}"
|
||||
+ (f" {track.callsign}" if track.callsign else ""))
|
||||
entry = book.get(track.icao, track.callsign) if book is not None else None
|
||||
if entry is not None and track.located and hasattr(book, "resolve"):
|
||||
here = track.fixes[len(track.fixes) // 2]
|
||||
entry = book.resolve(entry, here.latitude, here.longitude)
|
||||
if entry is not None:
|
||||
for line in entry.details():
|
||||
# The route is printed as one line further down: "from" and
|
||||
|
|
@ -722,13 +469,8 @@ def report(tracks: list[Track], book=None, title: str = "",
|
|||
out.append(f" {line}")
|
||||
if entry.country and not entry.owner_country:
|
||||
out.append(f" registered in: {entry.country}")
|
||||
# What sort of aircraft it is, which is the one thing here that the
|
||||
# aeroplane said about itself rather than a register saying it.
|
||||
if track.kind:
|
||||
out.append(f" class: {track.kind}")
|
||||
if entry is not None:
|
||||
if entry.route:
|
||||
out.append(f" route: {entry.route}{_doubtful(entry, track)}")
|
||||
out.append(f" route: {entry.route}")
|
||||
out.append(f" heard: {_clock(track.first_seen)} to "
|
||||
f"{_clock(track.last_seen)} "
|
||||
f"({_span(track.seconds)}, {track.frames} frames)")
|
||||
|
|
@ -736,40 +478,18 @@ def report(tracks: list[Track], book=None, title: str = "",
|
|||
first, last = track.fixes[0], track.fixes[-1]
|
||||
out.append(f" from: {first.latitude:.4f}, {first.longitude:.4f}")
|
||||
out.append(f" to: {last.latitude:.4f}, {last.longitude:.4f}")
|
||||
out.append(f" flew: {in_distance(track.distance_nm, unit):.1f} "
|
||||
f"{distance_label(unit)} over "
|
||||
out.append(f" flew: {track.distance_nm:.1f} nm over "
|
||||
f"{len(track.fixes)} positions")
|
||||
low, high = track.altitude_range
|
||||
if high:
|
||||
out.append(f" altitude: {low:,} to {high:,} ft"
|
||||
if low != high else f" altitude: {high:,} ft")
|
||||
if track.top_speed_kt:
|
||||
out.append(f" speed: up to "
|
||||
f"{in_speed(track.top_speed_kt, unit):.0f} "
|
||||
f"{speed_label(unit)}")
|
||||
out.append(f" speed: up to {track.top_speed_kt:.0f} kt")
|
||||
out.append("")
|
||||
return out
|
||||
|
||||
|
||||
def _doubtful(entry, track: Track) -> str:
|
||||
"""A note where an aircraft cannot have been flying the route given.
|
||||
|
||||
Said rather than hidden. The route is what the register holds for that
|
||||
flight number and is worth writing down; what it is not is a statement
|
||||
about where this aeroplane was going, and the difference matters enough
|
||||
to spell out.
|
||||
"""
|
||||
if not track.located:
|
||||
return ""
|
||||
from .flights import route_fits
|
||||
|
||||
here = track.fixes[len(track.fixes) // 2]
|
||||
if route_fits(entry, here.latitude, here.longitude):
|
||||
return ""
|
||||
return " (scheduled for this flight number; this aircraft was " \
|
||||
"nowhere near it)"
|
||||
|
||||
|
||||
def _clock(when: float) -> str:
|
||||
return datetime.fromtimestamp(when).strftime("%Y-%m-%d %H:%M:%S") \
|
||||
if when else "—"
|
||||
|
|
@ -790,8 +510,7 @@ def _span(seconds: float) -> str:
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
def write_kml(path, tracks: list[Track], book=None,
|
||||
title: str = "bandsaunter — aircraft heard",
|
||||
unit: str = DEFAULT_SPEED_UNIT) -> Path | None:
|
||||
title: str = "bandsaunter — aircraft heard") -> Path | None:
|
||||
"""Every track as a line on the globe, with a pin where it was last seen."""
|
||||
from xml.sax.saxutils import escape as xml_escape
|
||||
|
||||
|
|
@ -805,7 +524,7 @@ def write_kml(path, tracks: list[Track], book=None,
|
|||
"<width>2</width></LineStyle></Style>"]
|
||||
for track in located:
|
||||
entry = book.get(track.icao, track.callsign) if book is not None else None
|
||||
told = "\n".join([track.describe(unit)]
|
||||
told = "\n".join([track.describe()]
|
||||
+ (entry.details() if entry is not None else []))
|
||||
name = f"{track.icao} {track.callsign}".strip()
|
||||
line = " ".join(f"{f.longitude:.6f},{f.latitude:.6f},"
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -27,26 +27,20 @@ import threading
|
|||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass, replace
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
__all__ = ["Flight", "FlightBook", "describe_address", "airline_of",
|
||||
"route_fits", "ROUTE_SLACK_NM",
|
||||
"AIRCRAFT_URL", "ROUTE_URL", "CACHE_VERSION",
|
||||
"MILITARY_BLOCKS", "is_military", "aircraft_class"]
|
||||
"AIRCRAFT_URL", "ROUTE_URL", "CACHE_VERSION"]
|
||||
|
||||
AIRCRAFT_URL = "https://api.adsbdb.com/v0/aircraft/{icao}"
|
||||
ROUTE_URL = "https://api.adsbdb.com/v0/callsign/{call}"
|
||||
BACKUP_AIRCRAFT_URL = "https://hexdb.io/api/v1/aircraft/{icao}"
|
||||
BACKUP_ROUTE_URL = "https://hexdb.io/api/v1/route/icao/{call}"
|
||||
AIRPORT_URL = "https://hexdb.io/api/v1/airport/icao/{code}"
|
||||
|
||||
# Bumped when the shape of a cached record changes, so a cache written by an
|
||||
# older version is ignored rather than misread. Version 2 added the country
|
||||
# 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
|
||||
# older version is ignored rather than misread.
|
||||
CACHE_VERSION = 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -194,12 +188,6 @@ ADDRESS_BLOCKS: tuple[tuple[int, int, str], ...] = (
|
|||
(0x902000, 0x9023FF, "Fiji"),
|
||||
(0x905000, 0x9053FF, "Tonga"),
|
||||
(0x907000, 0x9073FF, "Vanuatu"),
|
||||
# Mexico was missing, and a receiver anywhere in the American southwest
|
||||
# hears Mexican aircraft all evening. Two registers independently give
|
||||
# XA- registrations for addresses in this block -- XA-SPO and XA-VRW
|
||||
# were the ones that turned up here -- which is what an aircraft
|
||||
# registered in Mexico wears.
|
||||
(0x0D0000, 0x0D7FFF, "Mexico"),
|
||||
(0xA00000, 0xAFFFFF, "United States"),
|
||||
(0xC00000, 0xC3FFFF, "Canada"),
|
||||
(0xC80000, 0xC87FFF, "New Zealand"),
|
||||
|
|
@ -340,24 +328,14 @@ class Flight:
|
|||
airline: str = "" # who the callsign says, from the table
|
||||
country: str = "" # from the address block
|
||||
owner_country: str = "" # from the register, which can differ
|
||||
# Every stop the flight number is recorded as making, in order. A
|
||||
# source that says "KORD-KEWR-KORD" is describing a day's work rather
|
||||
# than a leg, and reading the ends off it gives Chicago to Chicago.
|
||||
stops: tuple = ()
|
||||
# Which service the route came from, where it was a schedule service
|
||||
# rather than one of the free databases: those know the leg, and it is
|
||||
# worth being able to say which answers are the trustworthy ones.
|
||||
route_source: str = ""
|
||||
origin_code: str = ""
|
||||
origin: str = ""
|
||||
origin_lat: float = 0.0
|
||||
origin_lon: float = 0.0
|
||||
origin_country: str = "" # two letters, for the flag beside it
|
||||
destination_code: str = ""
|
||||
destination: str = ""
|
||||
destination_lat: float = 0.0
|
||||
destination_lon: float = 0.0
|
||||
destination_country: str = ""
|
||||
status: str = "pending"
|
||||
fetched_at: float = 0.0
|
||||
version: int = CACHE_VERSION
|
||||
|
|
@ -411,75 +389,6 @@ class Flight:
|
|||
return out
|
||||
|
||||
|
||||
# How far off a route an aircraft may be before it is decided that it cannot
|
||||
# be flying it. Generous: an aircraft holds, diverts around weather and gets
|
||||
# vectored, and none of that is a wrong route.
|
||||
def is_military(icao: str) -> bool:
|
||||
"""Whether this address sits in a block set aside for armed forces.
|
||||
|
||||
The blocks are the ones named above, so this and the country an address
|
||||
is described with can never disagree.
|
||||
|
||||
Two honest limits. A state can fly a military aircraft on a civil
|
||||
address whenever it likes, so this finds nobody who does not want to be
|
||||
found; and it says nothing about the twenty or so smaller allocations
|
||||
the table above leaves out on purpose. It is a reading of the address,
|
||||
and is worth exactly what that is.
|
||||
"""
|
||||
try:
|
||||
value = int(str(icao), 16)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return any(low <= value <= high for low, high, _name in MILITARY_BLOCKS)
|
||||
|
||||
|
||||
def aircraft_class(icao: str, category: str = "") -> str:
|
||||
"""What sort of aircraft this is, in as few words as say it.
|
||||
|
||||
Two different facts, joined only when both are known: what the aeroplane
|
||||
says it is -- heavy, rotorcraft, glider -- which comes off the air in
|
||||
every identification message, and whether its address is a military one,
|
||||
which does not come off the air at all. A tanker broadcasts "heavy"
|
||||
exactly as an airliner does, and its address is the only thing that says
|
||||
whose it is; "military heavy" is both of those facts and not a third one
|
||||
made up out of them.
|
||||
"""
|
||||
return " ".join(x for x in ("military" if is_military(icao) else "",
|
||||
category) if x)
|
||||
|
||||
|
||||
ROUTE_SLACK_NM = 150.0
|
||||
ROUTE_SLACK_PART = 0.5
|
||||
|
||||
|
||||
def route_fits(entry, lat: float, lon: float) -> bool:
|
||||
"""Could an aircraft here be flying the route this callsign is given?
|
||||
|
||||
A callsign is a flight number, not a leg. An airline runs the same
|
||||
number over several legs in a day -- Southwest especially -- and a
|
||||
register holds one route for it, so an aircraft crossing Arizona is
|
||||
quite often handed a thirty-minute hop between two airports in Texas.
|
||||
Reported as fact that is simply wrong, and it is wrong in a way that is
|
||||
easy to check: the two ends are known, and an aircraft on a route is
|
||||
never much further along it than the route is long.
|
||||
|
||||
True when there is nothing to check with, because not knowing is not the
|
||||
same as knowing it is wrong.
|
||||
"""
|
||||
origin = (getattr(entry, "origin_lat", 0.0),
|
||||
getattr(entry, "origin_lon", 0.0))
|
||||
destination = (getattr(entry, "destination_lat", 0.0),
|
||||
getattr(entry, "destination_lon", 0.0))
|
||||
if not any(origin) or not any(destination):
|
||||
return True
|
||||
from .flightlog import distance_nm
|
||||
|
||||
leg = distance_nm(origin[0], origin[1], destination[0], destination[1])
|
||||
by_way_of = (distance_nm(origin[0], origin[1], lat, lon)
|
||||
+ distance_nm(lat, lon, destination[0], destination[1]))
|
||||
return by_way_of <= leg + max(ROUTE_SLACK_NM, ROUTE_SLACK_PART * leg)
|
||||
|
||||
|
||||
def _cache_path() -> Path:
|
||||
root = os.environ.get("XDG_CACHE_HOME") or "~/.cache"
|
||||
return Path(root).expanduser() / "bandsaunter" / "flights.json"
|
||||
|
|
@ -504,9 +413,7 @@ class FlightBook:
|
|||
aircraft_url: str = AIRCRAFT_URL,
|
||||
route_url: str = ROUTE_URL,
|
||||
backup_aircraft_url: str = BACKUP_AIRCRAFT_URL,
|
||||
backup_route_url: str = BACKUP_ROUTE_URL,
|
||||
airport_url: str = AIRPORT_URL,
|
||||
schedules=None):
|
||||
backup_route_url: str = BACKUP_ROUTE_URL):
|
||||
self.online = online
|
||||
self.timeout = timeout
|
||||
self.max_age = max_age
|
||||
|
|
@ -514,18 +421,10 @@ class FlightBook:
|
|||
self.route_url = route_url
|
||||
self.backup_aircraft_url = backup_aircraft_url
|
||||
self.backup_route_url = backup_route_url
|
||||
self.airport_url = airport_url
|
||||
# Which commercial schedule services to ask, in order, before the
|
||||
# free route databases. Empty means every one that has a key.
|
||||
self.schedules = list(schedules or [])
|
||||
self.cache_path = Path(cache) if cache is not None else _cache_path()
|
||||
self._lock = threading.Lock()
|
||||
self._entries: dict[str, Flight] = {}
|
||||
self._routes: dict[str, dict] = {}
|
||||
# Airports, by ICAO code. A route often names two the map has never
|
||||
# heard of and gives no position for either, and an airport that
|
||||
# cannot be placed cannot be drawn.
|
||||
self._airports: dict[str, dict] = {}
|
||||
# Callsigns already asked about. A route nobody holds would
|
||||
# otherwise be asked for again by every caller that looks at the same
|
||||
# aircraft -- the table, the report and the map all do -- and an
|
||||
|
|
@ -551,16 +450,9 @@ class FlightBook:
|
|||
entry.version >= CACHE_VERSION and \
|
||||
now - entry.fetched_at < self.max_age:
|
||||
self._entries[key] = entry
|
||||
for key, body in (raw.get("airports") or {}).items():
|
||||
for key, body in (raw.get("routes") or {}).items():
|
||||
if isinstance(body, dict) and \
|
||||
now - float(body.get("fetched_at") or 0) < self.max_age:
|
||||
self._airports[key] = body
|
||||
for key, body in (raw.get("routes") or {}).items():
|
||||
if not isinstance(body, dict):
|
||||
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
|
||||
|
||||
def save(self) -> None:
|
||||
|
|
@ -572,7 +464,6 @@ class FlightBook:
|
|||
"aircraft": {k: e.__dict__ for k, e in self._entries.items()
|
||||
if e.status in ("found", "unlisted")},
|
||||
"routes": dict(self._routes),
|
||||
"airports": dict(self._airports),
|
||||
}
|
||||
self._dirty = False
|
||||
try:
|
||||
|
|
@ -584,8 +475,7 @@ class FlightBook:
|
|||
pass
|
||||
|
||||
# -- lookup -----------------------------------------------------------
|
||||
def get(self, icao: str, callsign: str = "",
|
||||
when: float | None = None) -> Flight:
|
||||
def get(self, icao: str, callsign: str = "") -> Flight:
|
||||
"""What is known about an aircraft now, starting a lookup if needed.
|
||||
|
||||
Called again with a callsign once one has been heard -- identity and
|
||||
|
|
@ -613,60 +503,14 @@ class FlightBook:
|
|||
if not self.online:
|
||||
return entry
|
||||
if fresh or wanted_route:
|
||||
thread = threading.Thread(
|
||||
target=self._fetch,
|
||||
args=(entry, fresh, wanted_route,
|
||||
time.time() if when is None else when),
|
||||
thread = threading.Thread(target=self._fetch,
|
||||
args=(entry, fresh, wanted_route),
|
||||
daemon=True)
|
||||
with self._lock:
|
||||
self._threads.append(thread)
|
||||
thread.start()
|
||||
return entry
|
||||
|
||||
def airport(self, code: str) -> dict:
|
||||
"""Where an airport is, asked once and remembered.
|
||||
|
||||
An empty answer is remembered too, and on purpose: a code no
|
||||
register holds would otherwise be asked about on every drawing for
|
||||
the rest of the month.
|
||||
"""
|
||||
code = (code or "").strip().upper()
|
||||
if not code:
|
||||
return {}
|
||||
with self._lock:
|
||||
got = self._airports.get(code)
|
||||
if got is not None:
|
||||
return got
|
||||
found: dict = {}
|
||||
if self.online and self.airport_url:
|
||||
try:
|
||||
body = self._request(self.airport_url.format(
|
||||
code=urllib.parse.quote(code)))
|
||||
found = {"code": code,
|
||||
"name": str(body.get("airport") or "").strip(),
|
||||
"latitude": float(body.get("latitude") or 0.0),
|
||||
"longitude": float(body.get("longitude") or 0.0),
|
||||
"country": str(body.get("country_code")
|
||||
or "").strip().upper()}
|
||||
if not (found["latitude"] or found["longitude"]):
|
||||
found = {}
|
||||
except Exception:
|
||||
found = {}
|
||||
with self._lock:
|
||||
found["fetched_at"] = time.time()
|
||||
self._airports[code] = found
|
||||
self._dirty = True
|
||||
return found
|
||||
|
||||
def airports(self, codes) -> list[dict]:
|
||||
"""Every airport in a list of codes that can be placed on a map."""
|
||||
out: dict[str, dict] = {}
|
||||
for code in codes:
|
||||
found = self.airport(code)
|
||||
if found.get("latitude") or found.get("longitude"):
|
||||
out[found["code"]] = found
|
||||
return list(out.values())
|
||||
|
||||
def get_all(self, pairs) -> list[Flight]:
|
||||
return [self.get(icao, call) for icao, call in pairs]
|
||||
|
||||
|
|
@ -679,8 +523,7 @@ class FlightBook:
|
|||
self._threads = [t for t in self._threads if t.is_alive()]
|
||||
|
||||
# -- the network ------------------------------------------------------
|
||||
def _fetch(self, entry: Flight, airframe: bool, route: bool,
|
||||
when: float | None = None) -> None:
|
||||
def _fetch(self, entry: Flight, airframe: bool, route: bool) -> None:
|
||||
reached = False
|
||||
if airframe:
|
||||
for url, apply in ((self.aircraft_url, self._apply_aircraft),
|
||||
|
|
@ -708,12 +551,7 @@ class FlightBook:
|
|||
self._dirty = True
|
||||
|
||||
if route and entry.callsign:
|
||||
# A schedule service first, where one has a key. It holds the
|
||||
# timetable and the day's movements, so it can say which leg of
|
||||
# a flight number is in the air now; the free databases hold one
|
||||
# route per number and cannot.
|
||||
found = self._scheduled(entry.callsign,
|
||||
time.time() if when is None else when)
|
||||
found = None
|
||||
for url, read in ((self.route_url, self._read_route),
|
||||
(self.backup_route_url, self._read_backup_route)):
|
||||
if not url or found:
|
||||
|
|
@ -726,21 +564,10 @@ class FlightBook:
|
|||
with self._lock:
|
||||
if found is not None:
|
||||
found["fetched_at"] = time.time()
|
||||
found["version"] = CACHE_VERSION
|
||||
self._routes[entry.callsign] = found
|
||||
self._apply_route(entry, found)
|
||||
self._dirty = True
|
||||
|
||||
def _scheduled(self, callsign: str, when: float) -> dict | None:
|
||||
"""What a commercial schedule service says, if one is set up."""
|
||||
try:
|
||||
from . import schedules
|
||||
|
||||
return schedules.route_for(callsign, when, self.schedules,
|
||||
timeout=self.timeout)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _request(self, url: str) -> dict:
|
||||
"""Ask one website one question.
|
||||
|
||||
|
|
@ -779,16 +606,14 @@ class FlightBook:
|
|||
entry.operator = str(body.get("RegisteredOwners") or "").strip()
|
||||
|
||||
@staticmethod
|
||||
def _airport(record) -> tuple[str, str, float, float, str]:
|
||||
"""An airport as a code, a readable place, where it is and whose.
|
||||
def _airport(record) -> tuple[str, str, float, float]:
|
||||
"""An airport as a code, a readable place and where it is.
|
||||
|
||||
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. 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.
|
||||
and a line long before any aircraft is heard flying it.
|
||||
"""
|
||||
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 "")
|
||||
name = str(record.get("name") or "").strip()
|
||||
town = str(record.get("municipality") or "").strip()
|
||||
|
|
@ -800,12 +625,7 @@ class FlightBook:
|
|||
lon = float(record.get("longitude") or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
lat = lon = 0.0
|
||||
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
|
||||
return code.strip(), (where or code).strip(), lat, lon
|
||||
|
||||
@classmethod
|
||||
def _read_route(cls, body: dict) -> dict | None:
|
||||
|
|
@ -819,103 +639,33 @@ class FlightBook:
|
|||
return None
|
||||
return {"origin_code": start[0], "origin": start[1],
|
||||
"origin_lat": start[2], "origin_lon": start[3],
|
||||
"origin_country": start[4],
|
||||
"destination_code": end[0], "destination": end[1],
|
||||
"destination_lat": end[2], "destination_lon": end[3],
|
||||
"destination_country": end[4],
|
||||
"airline": str((record.get("airline") or {}).get("name") or "")}
|
||||
|
||||
@staticmethod
|
||||
def _read_backup_route(body: dict) -> dict | None:
|
||||
"""Read hexdb's route, which is a single "EIDW-EGSS" string.
|
||||
|
||||
Sometimes it is a whole day: "KORD-KEWR-KORD". Taking the ends off
|
||||
that gives Chicago to Chicago, which is not a flight; the stops are
|
||||
kept instead, and which leg an aircraft is on is decided later, when
|
||||
there is a position to decide it with.
|
||||
"""
|
||||
"""Read hexdb's route, which is a single "EIDW-EGSS" string."""
|
||||
raw = str((body or {}).get("route") or "").strip()
|
||||
parts = [p.strip().upper() for p in raw.split("-") if p.strip()]
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
# 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
|
||||
|
||||
route = {"stops": tuple(parts), "airline": ""}
|
||||
if len(parts) == 2:
|
||||
route.update({"origin_code": parts[0], "origin": parts[0],
|
||||
"origin_country": country_of_icao(parts[0]),
|
||||
"destination_code": parts[-1],
|
||||
"destination": parts[-1],
|
||||
"destination_country": country_of_icao(parts[-1])})
|
||||
return route
|
||||
|
||||
def leg_for(self, entry: Flight, lat: float, lon: float):
|
||||
"""Which leg of a multi-stop day an aircraft here is flying.
|
||||
|
||||
A source that lists every stop has said more than one that names two
|
||||
airports, not less: with a position in hand the leg can be picked
|
||||
out, because an aircraft on a leg is never much further along it
|
||||
than the leg is long. Returns the two codes, or None when no leg
|
||||
fits -- which is itself an answer, and a better one than naming a
|
||||
leg the aircraft cannot be on.
|
||||
"""
|
||||
stops = tuple(getattr(entry, "stops", ()) or ())
|
||||
if len(stops) < 3:
|
||||
return None
|
||||
placed = {code: self.airport(code) for code in set(stops)}
|
||||
for first, second in zip(stops, stops[1:]):
|
||||
start, end = placed.get(first) or {}, placed.get(second) or {}
|
||||
if not (start.get("latitude") or start.get("longitude")):
|
||||
continue
|
||||
if not (end.get("latitude") or end.get("longitude")):
|
||||
continue
|
||||
leg = Flight(icao=entry.icao,
|
||||
origin_lat=start["latitude"],
|
||||
origin_lon=start["longitude"],
|
||||
destination_lat=end["latitude"],
|
||||
destination_lon=end["longitude"])
|
||||
if route_fits(leg, lat, lon):
|
||||
return first, second
|
||||
return None
|
||||
|
||||
def resolve(self, entry: Flight, lat: float, lon: float) -> Flight:
|
||||
"""The entry with the leg filled in, where one could be worked out."""
|
||||
picked = self.leg_for(entry, lat, lon)
|
||||
if picked is None:
|
||||
return entry
|
||||
from .flags import country_of_icao
|
||||
|
||||
first, second = picked
|
||||
start = self.airport(first) or {}
|
||||
end = self.airport(second) or {}
|
||||
return replace(
|
||||
entry,
|
||||
origin_code=first, origin=start.get("name") or first,
|
||||
origin_lat=start.get("latitude", 0.0),
|
||||
origin_lon=start.get("longitude", 0.0),
|
||||
origin_country=start.get("country") or country_of_icao(first),
|
||||
destination_code=second, destination=end.get("name") or second,
|
||||
destination_lat=end.get("latitude", 0.0),
|
||||
destination_lon=end.get("longitude", 0.0),
|
||||
destination_country=end.get("country") or country_of_icao(second))
|
||||
# A multi-leg route lists every stop; the ends are what a map wants.
|
||||
return {"origin_code": parts[0], "origin": parts[0],
|
||||
"destination_code": parts[-1], "destination": parts[-1],
|
||||
"airline": ""}
|
||||
|
||||
@staticmethod
|
||||
def _apply_route(entry: Flight, route: dict | None) -> None:
|
||||
if not route:
|
||||
return
|
||||
entry.stops = tuple(route.get("stops") or ())
|
||||
entry.route_source = str(route.get("source") or "")
|
||||
entry.origin_code = str(route.get("origin_code") or "")
|
||||
entry.origin = str(route.get("origin") or "")
|
||||
entry.origin_lat = float(route.get("origin_lat") 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 = str(route.get("destination") or "")
|
||||
entry.destination_lat = float(route.get("destination_lat") 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:
|
||||
entry.airline = str(route.get("airline") or "")
|
||||
|
|
|
|||
|
|
@ -167,11 +167,7 @@ GLYPHS = {
|
|||
"N": ("10001", "11001", "10101", "10011", "10001", "10001", "10001"),
|
||||
"O": ("01110", "10001", "10001", "10001", "10001", "10001", "01110"),
|
||||
"P": ("11110", "10001", "10001", "11110", "10000", "10000", "10000"),
|
||||
# A tail hanging below and right of a plain O. The earlier shape kept
|
||||
# the tail inside the letter, where at this size it read as the nought's
|
||||
# slash: a registration came back as N87650 when the aircraft was
|
||||
# N8765Q.
|
||||
"Q": ("01110", "10001", "10001", "10001", "10001", "01110", "00011"),
|
||||
"Q": ("01110", "10001", "10001", "10001", "10101", "10010", "01101"),
|
||||
"R": ("11110", "10001", "10001", "11110", "10100", "10010", "10001"),
|
||||
"S": ("01111", "10000", "10000", "01110", "00001", "00001", "11110"),
|
||||
"T": ("11111", "00100", "00100", "00100", "00100", "00100", "00100"),
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,454 +0,0 @@
|
|||
"""Where a flight number is actually going, from a service that knows.
|
||||
|
||||
Everything else in this program reads what an aircraft broadcasts, and an
|
||||
aircraft does not broadcast where it is going: ADS-B carries an address, a
|
||||
callsign, a position and a speed, and nothing about a destination. The free
|
||||
route databases fill that gap with one route per flight number, which is a
|
||||
guess dressed as a fact -- an airline runs the same number over several legs
|
||||
in a day, and the databases disagree with each other about which one to hold.
|
||||
|
||||
A commercial schedule service does know, because it holds the timetable and
|
||||
the day's movements. Four are wired up here. All of them want a key, none
|
||||
of them is required, and nothing about this program changes if no key is
|
||||
ever set: a source with no key says so and is skipped, and the free
|
||||
databases answer as they did before.
|
||||
|
||||
Keys are read from the environment rather than the settings file, because a
|
||||
settings file is meant to be copied between machines and pasted into a
|
||||
message asking for help, and an API key is not.
|
||||
|
||||
BANDSAUNTER_AEROAPI_KEY FlightAware AeroAPI
|
||||
BANDSAUNTER_FR24_TOKEN Flightradar24 API
|
||||
BANDSAUNTER_OAG_KEY OAG Flight Info
|
||||
BANDSAUNTER_CIRIUM_APP_ID Cirium (FlightStats), with
|
||||
BANDSAUNTER_CIRIUM_APP_KEY
|
||||
|
||||
Every one of these was written from the published shape of its answers and
|
||||
has been tested against those shapes; none has been run against the live
|
||||
service, because that needs a paid key. Each reader is therefore written to
|
||||
find what it recognises and return nothing at all otherwise, so that a
|
||||
service which has changed since costs a route rather than a scan.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
|
||||
__all__ = ["SOURCES", "Schedule", "source_named", "available_sources",
|
||||
"route_for", "SourceError"]
|
||||
|
||||
|
||||
class SourceError(Exception):
|
||||
"""A schedule service could not answer."""
|
||||
|
||||
|
||||
class Schedule:
|
||||
"""One schedule service.
|
||||
|
||||
Subclasses say where their key comes from, how to ask, and how to read
|
||||
the answer. Nothing else in the program knows one from another.
|
||||
"""
|
||||
|
||||
name = ""
|
||||
needs = () # the environment variables it wants
|
||||
signup = "" # where a key comes from
|
||||
|
||||
def __init__(self, timeout: float = 8.0):
|
||||
self.timeout = timeout
|
||||
|
||||
# -- what it needs ----------------------------------------------------
|
||||
def keys(self) -> dict:
|
||||
return {name: os.environ.get(name, "").strip() for name in self.needs}
|
||||
|
||||
def available(self) -> bool:
|
||||
"""Whether this source has everything it needs to be asked."""
|
||||
got = self.keys()
|
||||
return bool(self.needs) and all(got.get(n) for n in self.needs)
|
||||
|
||||
def missing(self) -> list[str]:
|
||||
got = self.keys()
|
||||
return [n for n in self.needs if not got.get(n)]
|
||||
|
||||
# -- asking it --------------------------------------------------------
|
||||
def route(self, callsign: str, when: float) -> dict | None:
|
||||
"""The leg this callsign is flying at this moment, or None.
|
||||
|
||||
Returns the same shape the free databases return, so that everything
|
||||
downstream is unchanged: origin and destination codes, names and
|
||||
countries, and the positions where they are given.
|
||||
"""
|
||||
if not self.available() or not callsign:
|
||||
return None
|
||||
try:
|
||||
body = self.ask(callsign.strip().upper(), when)
|
||||
return self.read(body, when)
|
||||
except Exception as exc:
|
||||
# Reading is inside this too: an answer shaped differently from
|
||||
# the one documented is the failure most likely to happen, and
|
||||
# it must come back as this service not knowing rather than as
|
||||
# a traceback out of the middle of a scan.
|
||||
raise SourceError(f"{self.name}: {exc}") from exc
|
||||
|
||||
def ask(self, callsign: str, when: float):
|
||||
raise NotImplementedError
|
||||
|
||||
def read(self, body, when: float) -> dict | None:
|
||||
raise NotImplementedError
|
||||
|
||||
# -- the plumbing -----------------------------------------------------
|
||||
def fetch(self, url: str, headers: dict | None = None):
|
||||
request = urllib.request.Request(
|
||||
url, headers={"User-Agent": "bandsaunter", "Accept": "application/json",
|
||||
**(headers or {})})
|
||||
with urllib.request.urlopen(request, timeout=self.timeout) as answer:
|
||||
return json.loads(answer.read(400_000).decode("utf8", "replace"))
|
||||
|
||||
|
||||
def _stamp(when: float) -> str:
|
||||
"""A moment, as the services want it written."""
|
||||
return datetime.fromtimestamp(when, timezone.utc).strftime(
|
||||
"%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def _day(when: float) -> datetime:
|
||||
return datetime.fromtimestamp(when, timezone.utc)
|
||||
|
||||
|
||||
def _seconds(text: str) -> float:
|
||||
"""Read one of the several ways these services write a time."""
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
return 0.0
|
||||
if text.isdigit():
|
||||
return float(text)
|
||||
cleaned = text.replace("Z", "+00:00")
|
||||
try:
|
||||
moment = datetime.fromisoformat(cleaned)
|
||||
except ValueError:
|
||||
return 0.0
|
||||
if moment.tzinfo is None:
|
||||
moment = moment.replace(tzinfo=timezone.utc)
|
||||
return moment.timestamp()
|
||||
|
||||
|
||||
def _leg(origin_code: str, destination_code: str, origin: str = "",
|
||||
destination: str = "", origin_country: str = "",
|
||||
destination_country: str = "", origin_lat: float = 0.0,
|
||||
origin_lon: float = 0.0, destination_lat: float = 0.0,
|
||||
destination_lon: float = 0.0, airline: str = "") -> dict | None:
|
||||
"""One leg, in the shape the rest of the program reads."""
|
||||
from .flags import country_of_icao
|
||||
|
||||
origin_code = (origin_code or "").strip().upper()
|
||||
destination_code = (destination_code or "").strip().upper()
|
||||
if not origin_code or not destination_code:
|
||||
return None
|
||||
return {"stops": (origin_code, destination_code),
|
||||
"origin_code": origin_code, "origin": origin or origin_code,
|
||||
"origin_country": origin_country or country_of_icao(origin_code),
|
||||
"origin_lat": origin_lat, "origin_lon": origin_lon,
|
||||
"destination_code": destination_code,
|
||||
"destination": destination or destination_code,
|
||||
"destination_country": (destination_country
|
||||
or country_of_icao(destination_code)),
|
||||
"destination_lat": destination_lat,
|
||||
"destination_lon": destination_lon,
|
||||
"airline": airline}
|
||||
|
||||
|
||||
def _closest(legs: list[tuple[float, float, dict]], when: float) -> dict | None:
|
||||
"""The leg whose window holds this moment, or the nearest one to it.
|
||||
|
||||
A service hands back every movement of a flight number over a day or
|
||||
two. The one being watched is the one in the air now; where the times
|
||||
do not quite line up -- a delayed departure, a clock a few minutes out
|
||||
-- the nearest is a better answer than none, and much better than the
|
||||
first in the list, which is what taking element zero would give.
|
||||
"""
|
||||
if not legs:
|
||||
return None
|
||||
inside = [leg for start, end, leg in legs
|
||||
if start and end and start <= when <= end]
|
||||
if inside:
|
||||
return inside[0]
|
||||
|
||||
def distance(item):
|
||||
start, end, _leg = item
|
||||
if start and end:
|
||||
return min(abs(when - start), abs(when - end))
|
||||
return abs(when - (start or end or when))
|
||||
|
||||
nearest = min(legs, key=distance)
|
||||
# Nothing within half a day is not this flight; better to say nothing.
|
||||
if distance(nearest) > 12 * 3600:
|
||||
return None
|
||||
return nearest[2]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FlightAware AeroAPI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class AeroAPI(Schedule):
|
||||
"""FlightAware's AeroAPI.
|
||||
|
||||
Asked for the flights of one ident; answers with the recent and
|
||||
scheduled movements, each with its own airports and times, which is
|
||||
exactly what tells one leg of a flight number from another.
|
||||
"""
|
||||
|
||||
name = "flightaware"
|
||||
needs = ("BANDSAUNTER_AEROAPI_KEY",)
|
||||
signup = "https://www.flightaware.com/commercial/aeroapi/"
|
||||
url = "https://aeroapi.flightaware.com/aeroapi/flights/{ident}"
|
||||
|
||||
def ask(self, callsign: str, when: float):
|
||||
return self.fetch(
|
||||
self.url.format(ident=urllib.parse.quote(callsign)),
|
||||
headers={"x-apikey": self.keys()["BANDSAUNTER_AEROAPI_KEY"]})
|
||||
|
||||
def read(self, body, when: float) -> dict | None:
|
||||
legs = []
|
||||
for flight in (body or {}).get("flights") or []:
|
||||
origin = flight.get("origin") or {}
|
||||
destination = flight.get("destination") or {}
|
||||
leg = _leg(origin.get("code_icao") or origin.get("code"),
|
||||
destination.get("code_icao") or destination.get("code"),
|
||||
origin=origin.get("name") or "",
|
||||
destination=destination.get("name") or "",
|
||||
airline=str(flight.get("operator") or ""))
|
||||
if leg is None:
|
||||
continue
|
||||
start = _seconds(flight.get("actual_off")
|
||||
or flight.get("estimated_off")
|
||||
or flight.get("scheduled_off")
|
||||
or flight.get("scheduled_out"))
|
||||
end = _seconds(flight.get("actual_on")
|
||||
or flight.get("estimated_on")
|
||||
or flight.get("scheduled_on")
|
||||
or flight.get("scheduled_in"))
|
||||
legs.append((start, end, leg))
|
||||
return _closest(legs, when)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Flightradar24
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class Flightradar24(Schedule):
|
||||
"""The Flightradar24 API.
|
||||
|
||||
Asked for the flight summary of a callsign over the day around the
|
||||
observation, which comes back with the airports each movement used.
|
||||
"""
|
||||
|
||||
name = "flightradar24"
|
||||
needs = ("BANDSAUNTER_FR24_TOKEN",)
|
||||
signup = "https://fr24api.flightradar24.com/"
|
||||
url = ("https://fr24api.flightradar24.com/api/flight-summary/light"
|
||||
"?flights={ident}&flight_datetime_from={start}"
|
||||
"&flight_datetime_to={end}")
|
||||
|
||||
def ask(self, callsign: str, when: float):
|
||||
return self.fetch(
|
||||
self.url.format(ident=urllib.parse.quote(callsign),
|
||||
start=urllib.parse.quote(_stamp(when - 12 * 3600)),
|
||||
end=urllib.parse.quote(_stamp(when + 12 * 3600))),
|
||||
headers={"Authorization":
|
||||
f"Bearer {self.keys()['BANDSAUNTER_FR24_TOKEN']}",
|
||||
"Accept-Version": "v1"})
|
||||
|
||||
def read(self, body, when: float) -> dict | None:
|
||||
# Some of its endpoints wrap the rows in an object and some hand
|
||||
# back the list itself.
|
||||
rows = body if isinstance(body, list) else (body or {}).get("data")
|
||||
legs = []
|
||||
for row in rows or []:
|
||||
leg = _leg(row.get("orig_icao") or row.get("origin_icao"),
|
||||
row.get("dest_icao") or row.get("destination_icao"),
|
||||
airline=str(row.get("operating_as")
|
||||
or row.get("painted_as") or ""))
|
||||
if leg is None:
|
||||
continue
|
||||
legs.append((_seconds(row.get("datetime_takeoff")),
|
||||
_seconds(row.get("datetime_landed")), leg))
|
||||
return _closest(legs, when)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OAG
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class OAG(Schedule):
|
||||
"""OAG's flight information service, asked for one day's schedules."""
|
||||
|
||||
name = "oag"
|
||||
needs = ("BANDSAUNTER_OAG_KEY",)
|
||||
signup = "https://developer.oag.com/"
|
||||
url = ("https://api.oag.com/flight-instances/?DepartureDateTime={day}"
|
||||
"&CarrierCode={carrier}&FlightNumber={number}"
|
||||
"&CodeType=ICAO&Content=All")
|
||||
|
||||
def ask(self, callsign: str, when: float):
|
||||
carrier, number = _split_callsign(callsign)
|
||||
if not number:
|
||||
raise SourceError("not an airline callsign")
|
||||
return self.fetch(
|
||||
self.url.format(day=_day(when).strftime("%Y-%m-%d"),
|
||||
carrier=urllib.parse.quote(carrier),
|
||||
number=urllib.parse.quote(number)),
|
||||
headers={"Subscription-Key": self.keys()["BANDSAUNTER_OAG_KEY"]})
|
||||
|
||||
def read(self, body, when: float) -> dict | None:
|
||||
legs = []
|
||||
for row in (body or {}).get("data") or []:
|
||||
departure = row.get("departure") or {}
|
||||
arrival = row.get("arrival") or {}
|
||||
leg = _leg(_airport_code(departure), _airport_code(arrival),
|
||||
origin=str((departure.get("airport") or {}).get("name")
|
||||
or ""),
|
||||
destination=str((arrival.get("airport") or {}).get("name")
|
||||
or ""),
|
||||
airline=str((row.get("carrier") or {}).get("icao") or ""))
|
||||
if leg is None:
|
||||
continue
|
||||
legs.append((_seconds(_when_of(departure)),
|
||||
_seconds(_when_of(arrival)), leg))
|
||||
return _closest(legs, when)
|
||||
|
||||
|
||||
def _airport_code(end: dict) -> str:
|
||||
airport = end.get("airport") or {}
|
||||
return str(airport.get("icao") or airport.get("iata") or "")
|
||||
|
||||
|
||||
def _when_of(end: dict) -> str:
|
||||
times = end.get("date") or {}
|
||||
clock = end.get("time") or {}
|
||||
if isinstance(times, dict) and isinstance(clock, dict):
|
||||
day = times.get("utc") or times.get("local") or ""
|
||||
hour = clock.get("utc") or clock.get("local") or ""
|
||||
if day and hour:
|
||||
return f"{day}T{hour}Z" if "T" not in str(day) else str(day)
|
||||
return str(end.get("dateTimeUtc") or end.get("dateTime") or "")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cirium (FlightStats)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class Cirium(Schedule):
|
||||
"""Cirium's FlightStats schedules, asked for one flight on one day."""
|
||||
|
||||
name = "cirium"
|
||||
needs = ("BANDSAUNTER_CIRIUM_APP_ID", "BANDSAUNTER_CIRIUM_APP_KEY")
|
||||
signup = "https://developer.cirium.com/"
|
||||
url = ("https://api.flightstats.com/flex/schedules/rest/v1/json/flight/"
|
||||
"{carrier}/{number}/departing/{year}/{month}/{day}"
|
||||
"?appId={app_id}&appKey={app_key}")
|
||||
|
||||
def ask(self, callsign: str, when: float):
|
||||
carrier, number = _split_callsign(callsign)
|
||||
if not number:
|
||||
raise SourceError("not an airline callsign")
|
||||
keys = self.keys()
|
||||
moment = _day(when)
|
||||
return self.fetch(self.url.format(
|
||||
carrier=urllib.parse.quote(carrier),
|
||||
number=urllib.parse.quote(number),
|
||||
year=moment.year, month=moment.month, day=moment.day,
|
||||
app_id=urllib.parse.quote(keys["BANDSAUNTER_CIRIUM_APP_ID"]),
|
||||
app_key=urllib.parse.quote(keys["BANDSAUNTER_CIRIUM_APP_KEY"])))
|
||||
|
||||
def read(self, body, when: float) -> dict | None:
|
||||
# Keyed by the short code, because that is what a flight refers to
|
||||
# its airports by; the four-letter one is inside the record.
|
||||
places = {str(a.get("fs") or a.get("iata") or ""): a
|
||||
for a in (body or {}).get("appendix", {}).get("airports", [])
|
||||
if isinstance(a, dict)}
|
||||
legs = []
|
||||
for row in (body or {}).get("scheduledFlights") or []:
|
||||
origin = places.get(str(row.get("departureAirportFsCode") or ""), {})
|
||||
destination = places.get(
|
||||
str(row.get("arrivalAirportFsCode") or ""), {})
|
||||
leg = _leg(origin.get("icao") or row.get("departureAirportFsCode"),
|
||||
destination.get("icao")
|
||||
or row.get("arrivalAirportFsCode"),
|
||||
origin=str(origin.get("name") or ""),
|
||||
destination=str(destination.get("name") or ""),
|
||||
origin_country=str(origin.get("countryCode") or ""),
|
||||
destination_country=str(
|
||||
destination.get("countryCode") or ""),
|
||||
origin_lat=float(origin.get("latitude") or 0.0),
|
||||
origin_lon=float(origin.get("longitude") or 0.0),
|
||||
destination_lat=float(destination.get("latitude") or 0.0),
|
||||
destination_lon=float(
|
||||
destination.get("longitude") or 0.0),
|
||||
airline=str(row.get("carrierFsCode") or ""))
|
||||
if leg is None:
|
||||
continue
|
||||
# The plain departureTime is local and carries no offset, so
|
||||
# reading it as UTC puts a leg up to half a day from where it
|
||||
# belongs and picks the wrong one. The UTC field is preferred
|
||||
# wherever the answer carries it.
|
||||
legs.append((_seconds(row.get("departureTimeUtc")
|
||||
or row.get("departureTime")),
|
||||
_seconds(row.get("arrivalTimeUtc")
|
||||
or row.get("arrivalTime")), leg))
|
||||
return _closest(legs, when)
|
||||
|
||||
|
||||
def _split_callsign(callsign: str) -> tuple[str, str]:
|
||||
"""An airline callsign as its designator and its flight number."""
|
||||
callsign = (callsign or "").strip().upper()
|
||||
letters = "".join(c for c in callsign[:3] if c.isalpha())
|
||||
digits = callsign[len(letters):].lstrip()
|
||||
if len(letters) < 2 or not digits.isdigit():
|
||||
return callsign, ""
|
||||
return letters, digits
|
||||
|
||||
|
||||
SOURCES: tuple = (AeroAPI, Flightradar24, OAG, Cirium)
|
||||
|
||||
|
||||
def source_named(name: str, timeout: float = 8.0) -> Schedule | None:
|
||||
for kind in SOURCES:
|
||||
if kind.name == (name or "").strip().lower():
|
||||
return kind(timeout)
|
||||
return None
|
||||
|
||||
|
||||
def available_sources(names=None, timeout: float = 8.0) -> list[Schedule]:
|
||||
"""Every source that has a key, in the order asked for."""
|
||||
wanted = [n.strip().lower() for n in (names or []) if n.strip()] or \
|
||||
[kind.name for kind in SOURCES]
|
||||
out = []
|
||||
for name in wanted:
|
||||
source = source_named(name, timeout)
|
||||
if source is not None and source.available():
|
||||
out.append(source)
|
||||
return out
|
||||
|
||||
|
||||
def route_for(callsign: str, when: float, names=None,
|
||||
timeout: float = 8.0) -> dict | None:
|
||||
"""Ask each source in turn until one knows.
|
||||
|
||||
A source that fails is passed over rather than allowed to stop the rest:
|
||||
a key that has run out of quota should cost that service and not the
|
||||
others.
|
||||
"""
|
||||
for source in available_sources(names, timeout):
|
||||
try:
|
||||
found = source.route(callsign, when)
|
||||
except SourceError:
|
||||
continue
|
||||
if found:
|
||||
found = dict(found)
|
||||
found["source"] = source.name
|
||||
return found
|
||||
return None
|
||||
|
|
@ -1,248 +0,0 @@
|
|||
"""How the maps look: the colours, and whether the lines glow.
|
||||
|
||||
The default is what this program has always drawn -- a night-blue ground
|
||||
under aircraft coloured by height, which is what every other aircraft map
|
||||
does and is the easiest to read. The rest are the screens the phrase "air
|
||||
defence display" actually calls to mind: a black tube, a single phosphor,
|
||||
and thin bright vector lines with a halo around them.
|
||||
|
||||
Two things change between a theme and a screenshot of one.
|
||||
|
||||
The first is what altitude means. On the default map it is hue -- low warm,
|
||||
high cold -- which needs the whole spectrum and is why nothing else on the
|
||||
picture can be amber or green. A phosphor screen has one colour, so on
|
||||
those themes altitude is *brightness* instead: low is dim, high burns. That
|
||||
is not an imitation of the old displays, it is the same constraint they had.
|
||||
|
||||
The second is the glow. A vector display draws by pointing a beam at the
|
||||
phosphor and holding it there, and the phosphor spreads the light a little
|
||||
and keeps glowing after the beam has gone. So a line on one of those
|
||||
screens is not one pixel wide with a hard edge; it is a bright core with a
|
||||
halo. Both drawings here do that -- the window by laying the same line down
|
||||
two or three times, wider and fainter each pass, and the animation by
|
||||
dilating what it has drawn and filling the halo with the dimmed copy of the
|
||||
colour underneath it, which is the same trick an indexed picture has to use
|
||||
for everything.
|
||||
|
||||
Each theme names its colours as ordinary RGB. The palettes, the dimmed
|
||||
sets, the fade steps and the flag colours are all built from these, so a
|
||||
theme is a dozen numbers rather than a table of two hundred and twenty.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
__all__ = ["Theme", "THEMES", "DEFAULT_THEME", "theme_named", "theme_names"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Theme:
|
||||
"""One look: eight fixed colours, an altitude ramp, and a glow."""
|
||||
|
||||
name: str
|
||||
summary: str
|
||||
background: tuple
|
||||
grid: tuple
|
||||
ink: tuple # ordinary text
|
||||
dim: tuple # the quieter text in a label
|
||||
panel: tuple
|
||||
airport: tuple # aerodromes, and nothing else
|
||||
route: tuple # the line between two airports
|
||||
# The line from an information box to the aircraft it belongs to. Its
|
||||
# own colour on purpose: drawn in the aircraft's colour it was the same
|
||||
# colour as that aircraft's trail, and a straight line from an aeroplane
|
||||
# in the colour of the path behind the aeroplane reads as more path.
|
||||
leader: tuple
|
||||
white: tuple
|
||||
# (feet, colour) stops the 32 altitude colours are interpolated between.
|
||||
stops: tuple
|
||||
ground_low: tuple # the map underneath, at its darkest
|
||||
ground_high: tuple # and at its brightest
|
||||
# How far the halo around a line reaches, in pixels, and how bright it
|
||||
# is as a fraction of the line itself. Zero for no glow at all.
|
||||
glow: int = 0
|
||||
glow_part: float = 0.34
|
||||
# How the map-brightness setting is felt on this theme. A screen made
|
||||
# of lines wants the ground well out of the way -- a tinted photograph
|
||||
# of a county behind the vectors is the one thing that stops a vector
|
||||
# display looking like one -- so the vector themes bend the setting down
|
||||
# hard in the middle of its range.
|
||||
#
|
||||
# A curve rather than a ceiling, which is what it used to be. Multiplied
|
||||
# by a quarter, the setting could not reach a visible map at all on those
|
||||
# themes: turned the whole way up it still came out at a tenth the
|
||||
# brightness the default theme gives, which is to say invisible. Raised
|
||||
# to a power instead, the top of the setting is a full-brightness map on
|
||||
# every theme and only the middle is quiet.
|
||||
ground_gamma: float = 1.0
|
||||
# Whether flags are drawn as flags. A flag is half a dozen colours, and
|
||||
# on a single-phosphor screen there are not half a dozen colours to draw
|
||||
# it in -- so those themes name the country in two letters instead,
|
||||
# which is what a display of the period would have done anyway.
|
||||
flags: bool = True
|
||||
# Whether height is read as brightness rather than as hue. Said out
|
||||
# loud because the key at the bottom of the picture has to say which.
|
||||
height_is_brightness: bool = False
|
||||
aliases: tuple = field(default=())
|
||||
|
||||
|
||||
# The colours this program has always drawn. Low is warm and high is cold,
|
||||
# which is the convention every other aircraft map uses, so a height can be
|
||||
# read off the picture without looking at the key.
|
||||
NIGHT = Theme(
|
||||
name="night",
|
||||
summary="the default: a night-blue ground, height as colour",
|
||||
background=(14, 16, 22),
|
||||
grid=(38, 44, 58),
|
||||
ink=(196, 204, 218),
|
||||
dim=(120, 130, 148),
|
||||
panel=(24, 28, 38),
|
||||
airport=(255, 64, 200),
|
||||
route=(70, 84, 110),
|
||||
leader=(148, 156, 174),
|
||||
white=(255, 255, 255),
|
||||
stops=((0.0, (252, 96, 72)), (10_000.0, (250, 190, 64)),
|
||||
(20_000.0, (132, 226, 96)), (30_000.0, (72, 200, 236)),
|
||||
(45_000.0, (158, 142, 255))),
|
||||
ground_low=(16, 19, 26),
|
||||
ground_high=(150, 164, 186),
|
||||
)
|
||||
|
||||
# The blue tube: cyan coastlines, amber for the places on the ground, and a
|
||||
# hard black behind all of it. Height runs from a deep unlit blue up
|
||||
# through the working cyan to white at the top of the sky, so the highest
|
||||
# aircraft is the brightest thing moving.
|
||||
DIGITAL = Theme(
|
||||
name="digital",
|
||||
summary="blue phosphor: cyan vectors on black, amber aerodromes",
|
||||
background=(0, 2, 6),
|
||||
grid=(0, 46, 84),
|
||||
ink=(150, 224, 255),
|
||||
dim=(72, 148, 198),
|
||||
panel=(0, 10, 20),
|
||||
airport=(255, 176, 64),
|
||||
route=(0, 70, 120),
|
||||
leader=(104, 128, 150),
|
||||
white=(232, 248, 255),
|
||||
stops=((0.0, (0, 60, 140)), (10_000.0, (0, 122, 208)),
|
||||
(20_000.0, (0, 190, 244)), (30_000.0, (120, 230, 255)),
|
||||
(45_000.0, (168, 234, 255))),
|
||||
ground_low=(0, 8, 18),
|
||||
ground_high=(0, 132, 190),
|
||||
glow=2,
|
||||
glow_part=0.50,
|
||||
ground_gamma=3.8,
|
||||
flags=False,
|
||||
height_is_brightness=True,
|
||||
aliases=("blue", "wargames", "norad"),
|
||||
)
|
||||
|
||||
# P1 phosphor, the green one on every oscilloscope and radar repeater ever
|
||||
# built. The aerodromes are amber, which is what a second phosphor looked
|
||||
# like on the same tube and is the one colour that never reads as a height.
|
||||
PHOSPHOR = Theme(
|
||||
name="phosphor",
|
||||
summary="green phosphor: P1 vectors on black, height as brightness",
|
||||
background=(0, 5, 2),
|
||||
grid=(0, 56, 24),
|
||||
ink=(150, 255, 170),
|
||||
dim=(72, 168, 96),
|
||||
panel=(0, 12, 5),
|
||||
airport=(255, 184, 72),
|
||||
route=(0, 76, 34),
|
||||
leader=(112, 148, 120),
|
||||
white=(236, 255, 240),
|
||||
stops=((0.0, (0, 74, 30)), (10_000.0, (0, 146, 56)),
|
||||
(20_000.0, (24, 210, 84)), (30_000.0, (120, 246, 150)),
|
||||
(45_000.0, (176, 255, 196)),),
|
||||
ground_low=(0, 10, 4),
|
||||
ground_high=(0, 148, 62),
|
||||
glow=2,
|
||||
glow_part=0.50,
|
||||
ground_gamma=3.8,
|
||||
flags=False,
|
||||
height_is_brightness=True,
|
||||
aliases=("green", "p1"),
|
||||
)
|
||||
|
||||
# The amber screens: easier on the eyes than green, and the reason half the
|
||||
# terminals of the period were the colour of a streetlight.
|
||||
AMBER = Theme(
|
||||
name="amber",
|
||||
summary="amber phosphor: warm vectors on black, height as brightness",
|
||||
background=(6, 3, 0),
|
||||
grid=(70, 40, 0),
|
||||
ink=(255, 204, 120),
|
||||
dim=(180, 128, 52),
|
||||
panel=(14, 8, 0),
|
||||
airport=(120, 220, 255),
|
||||
route=(84, 48, 0),
|
||||
leader=(168, 146, 112),
|
||||
white=(255, 244, 224),
|
||||
stops=((0.0, (96, 44, 0)), (10_000.0, (168, 88, 0)),
|
||||
(20_000.0, (232, 148, 16)), (30_000.0, (255, 200, 96)),
|
||||
(45_000.0, (255, 226, 150))),
|
||||
ground_low=(10, 6, 0),
|
||||
ground_high=(170, 106, 16),
|
||||
glow=2,
|
||||
glow_part=0.50,
|
||||
ground_gamma=3.8,
|
||||
flags=False,
|
||||
height_is_brightness=True,
|
||||
aliases=("orange",),
|
||||
)
|
||||
|
||||
# The red one, for a room somebody wants to keep their night vision in.
|
||||
RED = Theme(
|
||||
name="red",
|
||||
summary="red phosphor: for a room that wants its night vision",
|
||||
background=(6, 0, 0),
|
||||
grid=(74, 12, 12),
|
||||
ink=(255, 138, 130),
|
||||
dim=(184, 76, 70),
|
||||
panel=(14, 2, 2),
|
||||
airport=(120, 220, 255),
|
||||
route=(88, 14, 14),
|
||||
leader=(170, 130, 124),
|
||||
white=(255, 228, 224),
|
||||
stops=((0.0, (92, 0, 0)), (10_000.0, (164, 16, 8)),
|
||||
(20_000.0, (226, 56, 40)), (30_000.0, (255, 128, 112)),
|
||||
(45_000.0, (255, 176, 164))),
|
||||
ground_low=(10, 0, 0),
|
||||
ground_high=(168, 30, 24),
|
||||
glow=2,
|
||||
glow_part=0.50,
|
||||
ground_gamma=3.8,
|
||||
flags=False,
|
||||
height_is_brightness=True,
|
||||
aliases=("crimson",),
|
||||
)
|
||||
|
||||
|
||||
THEMES: dict[str, Theme] = {t.name: t for t in (NIGHT, DIGITAL, PHOSPHOR,
|
||||
AMBER, RED)}
|
||||
DEFAULT_THEME = NIGHT.name
|
||||
|
||||
|
||||
def theme_names() -> list[str]:
|
||||
"""Every theme, in the order they are offered."""
|
||||
return list(THEMES)
|
||||
|
||||
|
||||
def theme_named(name: str) -> Theme:
|
||||
"""One theme by name or by any of its aliases.
|
||||
|
||||
An unknown name is the default rather than an error: a theme is how the
|
||||
picture looks, and refusing to draw an evening's flying because of a
|
||||
misspelt colour would be the wrong trade.
|
||||
"""
|
||||
wanted = (name or "").strip().lower()
|
||||
if not wanted:
|
||||
return THEMES[DEFAULT_THEME]
|
||||
if wanted in THEMES:
|
||||
return THEMES[wanted]
|
||||
for theme in THEMES.values():
|
||||
if wanted in theme.aliases:
|
||||
return theme
|
||||
return THEMES[DEFAULT_THEME]
|
||||
|
|
@ -24,7 +24,7 @@ from .config import (DEFAULT_CONFIG_DIR, DEFAULT_CONFIG_PATH,
|
|||
from .ranges import RangeError, ScanRange, parse_frequency
|
||||
|
||||
__all__ = ["run_tui", "show_ranges", "settings_menu", "help_screen",
|
||||
"aircraft_menu", "first_run_setup", "TUIAbort"]
|
||||
"first_run_setup", "TUIAbort"]
|
||||
|
||||
_BACK = ("", "b", "back", "q", "quit", "x")
|
||||
|
||||
|
|
@ -192,31 +192,6 @@ def _pick_from(console: Console, cfg: ScanConfig,
|
|||
console.print(" [yellow]some of these are below 24 MHz — they need "
|
||||
"direct sampling and an HF antenna.[/yellow]")
|
||||
console.print(f" [green]added {added} range(s)[/green]")
|
||||
warn_about_aircraft_bands(console, cfg)
|
||||
|
||||
|
||||
def warn_about_aircraft_bands(console: Console, cfg: ScanConfig) -> None:
|
||||
"""Say so the moment a band that needs the aircraft mode is chosen.
|
||||
|
||||
The band plan lists 1090 MHz because that is where ADS-B is, so picking
|
||||
it here is the obvious thing to do and the wrong one. It is not refused
|
||||
-- looking at the spectrum there is a fair thing to want -- but it does
|
||||
not happen silently, and the menu that does decode it is named.
|
||||
"""
|
||||
from . import aircraft as air
|
||||
|
||||
warning = air.scanning_aircraft_band(cfg.ranges)
|
||||
if not warning:
|
||||
return
|
||||
console.print(Panel(Text.from_markup(
|
||||
f"{warning}\n\n"
|
||||
"Menu [cyan]5[/cyan], [bold]Aircraft (ADS-B)[/bold], listens to it "
|
||||
"properly and draws where the aircraft went.\n\n"
|
||||
"[grey62]Scanning it anyway is fine if what you want is the raw "
|
||||
"spectrum \u2014 turn on 'Save raw IQ' to keep the samples."
|
||||
"[/grey62]"),
|
||||
title="[yellow]this band needs the aircraft mode",
|
||||
border_style="yellow", padding=(0, 1)))
|
||||
|
||||
|
||||
def _expand(answer: str, items: list) -> list:
|
||||
|
|
@ -302,8 +277,8 @@ def _settings_table(console: Console, group: str, cfg: ScanConfig) -> list[st.Se
|
|||
|
||||
|
||||
def setting_help(console: Console, setting: st.Setting,
|
||||
cfg: ScanConfig, default=None) -> None:
|
||||
default = ScanConfig() if default is None else default
|
||||
cfg: ScanConfig) -> None:
|
||||
default = ScanConfig()
|
||||
body = [f"[bold]{setting.label}[/bold] [grey62]({setting.key})[/grey62]",
|
||||
"", setting.help.capitalize() + "."]
|
||||
if setting.detail:
|
||||
|
|
@ -327,17 +302,10 @@ def setting_help(console: Console, setting: st.Setting,
|
|||
border_style="blue", padding=(0, 1)))
|
||||
|
||||
|
||||
def edit_setting(console: Console, setting: st.Setting, cfg: ScanConfig,
|
||||
default=None, show_help=None) -> bool:
|
||||
"""Prompt for one value. Returns True if it changed.
|
||||
|
||||
``cfg`` is whatever holds the value -- the scan config, or the aircraft
|
||||
options, which are described by the same kind of table and so can be
|
||||
edited by the same code. ``default`` is the object the built-in defaults
|
||||
come from, and ``show_help`` the panel to print above the prompt.
|
||||
"""
|
||||
def edit_setting(console: Console, setting: st.Setting, cfg: ScanConfig) -> bool:
|
||||
"""Prompt for one value. Returns True if it changed."""
|
||||
current = getattr(cfg, setting.key)
|
||||
(show_help or setting_help)(console, setting, cfg)
|
||||
setting_help(console, setting, cfg)
|
||||
hint = "yes/no" if setting.kind == "bool" else (
|
||||
"/".join(setting.choices) if setting.choices else
|
||||
(setting.example or setting.metavar or "value"))
|
||||
|
|
@ -349,8 +317,7 @@ def edit_setting(console: Console, setting: st.Setting, cfg: ScanConfig,
|
|||
if raw.strip() == "" or raw == st.format_value(setting, current):
|
||||
return False
|
||||
if raw.strip().lower() in ("d", "default"):
|
||||
value = getattr(default if default is not None else ScanConfig(),
|
||||
setting.key)
|
||||
value = getattr(ScanConfig(), setting.key)
|
||||
else:
|
||||
try:
|
||||
value = st.parse_value(setting, raw)
|
||||
|
|
@ -667,343 +634,6 @@ into saved/, investigate/ or noise/, and delete the ones worth nothing."""),
|
|||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Aircraft
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_AIRCRAFT_INTRO = (
|
||||
"Every airliner overhead broadcasts its address, callsign, altitude, "
|
||||
"position and speed twice a second on 1090 MHz. This is not a scan and "
|
||||
"cannot be one \u2014 the signalling is a megabit a second, and the scan "
|
||||
"path is 12.5 kHz wide \u2014 so it has its own listening mode here.\n\n"
|
||||
"Everything heard is written to a log as it arrives; the map is drawn "
|
||||
"from that log afterwards, and can be drawn again with different options "
|
||||
"as often as you like.\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."
|
||||
)
|
||||
|
||||
|
||||
def _options_table(console: Console, options, group: str) -> list[st.Setting]:
|
||||
from . import aircraft as air
|
||||
|
||||
items = air.in_group(group)
|
||||
default = air.AircraftOptions()
|
||||
t = Table(box=None, header_style="bold", pad_edge=False,
|
||||
title=f"[bold]{group.lower()}[/bold]", title_justify="left")
|
||||
t.add_column("#", style="grey62", width=3, justify="right")
|
||||
t.add_column("option", width=20)
|
||||
t.add_column("value", width=16)
|
||||
t.add_column("what it does", style="grey62", overflow="fold")
|
||||
start = air.OPTIONS.index(items[0]) + 1
|
||||
for i, o in enumerate(items, start):
|
||||
value = air.format_option(o, getattr(options, o.key))
|
||||
changed = getattr(options, o.key) != getattr(default, o.key)
|
||||
t.add_row(str(i), o.label + (" *" if changed else ""),
|
||||
Text(value, style="bold cyan" if changed else "white"),
|
||||
o.help)
|
||||
console.print(t)
|
||||
return items
|
||||
|
||||
|
||||
def aircraft_menu(console: Console, cfg: ScanConfig) -> None:
|
||||
"""Listen to aircraft, and draw where they went, without a command line.
|
||||
|
||||
The options are the same ones the command line takes, described in the
|
||||
same table, so the help here is the help there.
|
||||
"""
|
||||
from . import aircraft as air
|
||||
|
||||
options = air.load_options()
|
||||
while True:
|
||||
_rule(console, "aircraft (ADS-B)")
|
||||
console.print(Panel(Text.from_markup(_AIRCRAFT_INTRO),
|
||||
border_style="blue", padding=(0, 1)))
|
||||
_option_groups(console, options)
|
||||
logs = air.logs_in(cfg.output_dir)
|
||||
kept = "no logs yet" if not logs else \
|
||||
f"{len(logs)} log{'s' if len(logs) != 1 else ''}"
|
||||
window = "opens a window" if air.windowed() else \
|
||||
"needs Qt \u2014 see ?"
|
||||
console.print(
|
||||
f"\n [cyan]p[/cyan] [bold green]Passive capture[/bold green]"
|
||||
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"{cfg.output_dir}[/grey62]\n"
|
||||
f" [cyan]N[/cyan] open group N "
|
||||
f"[grey62]or type part of an option's name to find it[/grey62]\n"
|
||||
f" [cyan]s[/cyan] Save these as default "
|
||||
f"[grey62]kept in {air.options_path()}[/grey62]\n"
|
||||
f" [cyan]d[/cyan] Reset them\n"
|
||||
f" [cyan]b[/cyan] Back\n")
|
||||
answer = _ask(console, " choice", "p").strip().lower()
|
||||
|
||||
if answer in _BACK:
|
||||
return
|
||||
# "l" was what this was called before there were two of them.
|
||||
if answer in ("p", "l", "passive", "listen"):
|
||||
_listen(console, cfg, options)
|
||||
elif answer in ("r", "realtime", "window"):
|
||||
_watch(console, cfg, options)
|
||||
elif answer in ("m", "map", "draw"):
|
||||
_draw_from_menu(console, cfg, options, logs)
|
||||
elif answer == "s":
|
||||
try:
|
||||
where = air.save_options(options)
|
||||
console.print(f" [green]saved to {where}[/green]")
|
||||
except OSError as exc:
|
||||
console.print(f" [red]could not save: {exc}[/red]")
|
||||
elif answer == "d":
|
||||
if _confirm(" reset every aircraft option"):
|
||||
options = air.AircraftOptions()
|
||||
console.print(" [green]reset[/green]")
|
||||
elif answer.isdigit() and 1 <= int(answer) <= len(air.OPTION_GROUPS):
|
||||
_option_group_menu(console, options,
|
||||
air.OPTION_GROUPS[int(answer) - 1])
|
||||
elif answer.lstrip("?").strip().isdigit():
|
||||
_edit_option(console, options, answer)
|
||||
elif answer:
|
||||
found = _find_options(answer)
|
||||
if not found:
|
||||
console.print(f" [yellow]nothing matches {answer!r} \u2014 "
|
||||
f"enter a group number, or p, r, m, s, d or b"
|
||||
f"[/yellow]")
|
||||
elif len(found) == 1:
|
||||
_edit_option(console, options,
|
||||
str(air.OPTIONS.index(found[0]) + 1))
|
||||
else:
|
||||
_option_list(console, options, found, f"matching {answer!r}")
|
||||
_pick_option(console, options)
|
||||
|
||||
|
||||
def _option_groups(console: Console, options) -> None:
|
||||
"""The groups, and how many of each has been changed from the default.
|
||||
|
||||
A list of six lines rather than a table of thirty-three: the options
|
||||
are all still there, and this is the way in to them.
|
||||
"""
|
||||
from . import aircraft as air
|
||||
|
||||
default = air.AircraftOptions()
|
||||
t = Table(box=None, header_style="bold", pad_edge=False,
|
||||
title="[bold]options[/bold]", title_justify="left")
|
||||
t.add_column("#", style="grey62", width=3, justify="right")
|
||||
t.add_column("group", width=20)
|
||||
t.add_column("", width=16, style="grey62")
|
||||
t.add_column("what is in it", style="grey62", overflow="fold")
|
||||
for i, group in enumerate(air.OPTION_GROUPS, 1):
|
||||
items = air.in_group(group)
|
||||
changed = sum(1 for o in items
|
||||
if getattr(options, o.key) != getattr(default, o.key))
|
||||
count = f"{len(items)} option{'s' if len(items) != 1 else ''}"
|
||||
if changed:
|
||||
count += f", {changed} changed"
|
||||
t.add_row(str(i), group.lower(),
|
||||
Text(count, style="bold cyan" if changed else "grey62"),
|
||||
", ".join(o.label.lower() for o in items))
|
||||
console.print(t)
|
||||
|
||||
|
||||
def _find_options(text: str) -> list:
|
||||
"""Every option this could mean, nearest match first.
|
||||
|
||||
An exact name wins outright. Typing "seconds" should reach the setting
|
||||
called seconds, not that one and every other whose description happens
|
||||
to mention the word -- so a name that matches exactly is the answer, and
|
||||
the wider search is only what happens when nothing does.
|
||||
"""
|
||||
from . import aircraft as air
|
||||
|
||||
wanted = text.strip().lower()
|
||||
if not wanted:
|
||||
return []
|
||||
exact = [o for o in air.OPTIONS
|
||||
if wanted in (o.key.lower(), o.label.lower())]
|
||||
if exact:
|
||||
return exact
|
||||
return [o for o in air.OPTIONS
|
||||
if wanted in o.key.lower() or wanted in o.label.lower()
|
||||
or wanted in o.help.lower()]
|
||||
|
||||
|
||||
def _option_list(console: Console, options, items, title: str) -> None:
|
||||
"""One table of whichever options were asked for."""
|
||||
from . import aircraft as air
|
||||
|
||||
default = air.AircraftOptions()
|
||||
t = Table(box=None, header_style="bold", pad_edge=False,
|
||||
title=f"[bold]{title}[/bold]", title_justify="left")
|
||||
t.add_column("#", style="grey62", width=3, justify="right")
|
||||
t.add_column("option", width=20)
|
||||
t.add_column("value", width=16)
|
||||
t.add_column("what it does", style="grey62", overflow="fold")
|
||||
for o in items:
|
||||
value = air.format_option(o, getattr(options, o.key))
|
||||
changed = getattr(options, o.key) != getattr(default, o.key)
|
||||
t.add_row(str(air.OPTIONS.index(o) + 1), o.label + (" *" if changed else ""),
|
||||
Text(value, style="bold cyan" if changed else "white"),
|
||||
o.help)
|
||||
console.print(t)
|
||||
|
||||
|
||||
def _pick_option(console: Console, options) -> None:
|
||||
"""Ask which of the options just listed to change, and change it."""
|
||||
console.print("[grey62]Enter an option number to change it, "
|
||||
"[cyan]?N[/cyan] for what it does, or blank to go back."
|
||||
"[/grey62]")
|
||||
answer = _ask(console, " option").strip().lower()
|
||||
if answer and answer.lstrip("?").strip().isdigit():
|
||||
_edit_option(console, options, answer)
|
||||
|
||||
|
||||
def _option_group_menu(console: Console, options, group: str) -> None:
|
||||
"""One group of options, on a screen of its own."""
|
||||
from . import aircraft as air
|
||||
|
||||
while True:
|
||||
_rule(console, group.lower())
|
||||
items = air.in_group(group)
|
||||
_option_list(console, options, items, group.lower())
|
||||
console.print("\n[grey62]Enter an option number to change it, "
|
||||
"[cyan]?N[/cyan] for what it does, or [cyan]b[/cyan] "
|
||||
"to go back.[/grey62]")
|
||||
answer = _ask(console, " option", "b").strip().lower()
|
||||
if not answer or answer in _BACK:
|
||||
return
|
||||
if answer.lstrip("?").strip().isdigit():
|
||||
_edit_option(console, options, answer)
|
||||
else:
|
||||
console.print(" [yellow]enter a number from the list, "
|
||||
"or b[/yellow]")
|
||||
|
||||
|
||||
def _edit_option(console: Console, options, answer: str) -> None:
|
||||
"""Change one option, or explain it when asked with a question mark."""
|
||||
from . import aircraft as air
|
||||
|
||||
want_help = answer.startswith("?")
|
||||
index = int(answer.lstrip("?").strip())
|
||||
if not 1 <= index <= len(air.OPTIONS):
|
||||
console.print(" [yellow]no such number[/yellow]")
|
||||
return
|
||||
option = air.OPTIONS[index - 1]
|
||||
if want_help:
|
||||
option_help(console, option, options)
|
||||
else:
|
||||
edit_setting(console, option, options,
|
||||
default=air.AircraftOptions(), show_help=option_help)
|
||||
|
||||
|
||||
def option_help(console: Console, option: st.Setting, options) -> None:
|
||||
"""The same help panel the settings menu shows, for an aircraft option."""
|
||||
from . import aircraft as air
|
||||
|
||||
body = [f"[bold]{option.label}[/bold] [grey62]({option.key})[/grey62]",
|
||||
"", option.help.capitalize() + "."]
|
||||
if option.detail:
|
||||
body += ["", option.detail]
|
||||
if option.guidance and option.guidance != option.detail:
|
||||
body += ["", f"[grey62]{option.guidance}[/grey62]"]
|
||||
default = air.AircraftOptions()
|
||||
body += ["", f"[grey62]now:[/grey62] "
|
||||
f"{air.format_option(option, getattr(options, option.key))}"
|
||||
f" [grey62]default:[/grey62] "
|
||||
f"{air.format_option(option, getattr(default, option.key))}"]
|
||||
rng = option.describe_range()
|
||||
if rng:
|
||||
body.append(f"[grey62]accepts:[/grey62] {rng}")
|
||||
if option.flags:
|
||||
flags = " ".join(option.flags)
|
||||
if option.off_flags:
|
||||
flags += " / " + " ".join(option.off_flags)
|
||||
body.append(f"[grey62]command line:[/grey62] {flags}")
|
||||
console.print(Panel(Text.from_markup("\n".join(body)),
|
||||
border_style="blue", padding=(0, 1)))
|
||||
|
||||
|
||||
def _listen(console: Console, cfg: ScanConfig, options) -> None:
|
||||
"""Run a listening session from the menu and come back afterwards."""
|
||||
from . import aircraft as air
|
||||
|
||||
errs = options.validate()
|
||||
if errs:
|
||||
for e in errs:
|
||||
console.print(f" [red]{e}[/red]")
|
||||
return
|
||||
if not options.simulate:
|
||||
console.print("[grey62]control-C stops listening and comes back "
|
||||
"here.[/grey62]")
|
||||
try:
|
||||
heard = air.listen(console, options, cfg.output_dir)
|
||||
except Exception as exc: # a menu must survive it
|
||||
console.print(f" [red]{exc}[/red]")
|
||||
return
|
||||
if heard.log_path and not options.draw_after:
|
||||
console.print(" [grey62]draw it with [cyan]m[/cyan], or set "
|
||||
"\"Draw when finished\"[/grey62]")
|
||||
|
||||
|
||||
def _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:
|
||||
"""Pick a log and draw it, newest first because that is usually the one."""
|
||||
from . import aircraft as air
|
||||
|
||||
if not logs:
|
||||
console.print(f" [yellow]no logs in {cfg.output_dir} yet \u2014 "
|
||||
"listen first, or turn the simulated sky on[/yellow]")
|
||||
return
|
||||
t = Table(box=None, header_style="bold")
|
||||
t.add_column("#", style="grey62", width=3, justify="right")
|
||||
t.add_column("log")
|
||||
t.add_column("when", style="grey62")
|
||||
t.add_column("size", style="grey62", justify="right")
|
||||
for i, path in enumerate(logs[:12], 1):
|
||||
stat = path.stat()
|
||||
t.add_row(str(i), path.name,
|
||||
_when(stat.st_mtime), f"{stat.st_size/1e6:.1f} MB")
|
||||
console.print(t)
|
||||
answer = _ask(console, " which log", "1").strip()
|
||||
if answer in _BACK:
|
||||
return
|
||||
if not answer.isdigit() or not 1 <= int(answer) <= len(logs[:12]):
|
||||
console.print(" [yellow]no such log[/yellow]")
|
||||
return
|
||||
chosen = logs[int(answer) - 1]
|
||||
try:
|
||||
air.draw_log(console, options, [chosen])
|
||||
except Exception as exc:
|
||||
console.print(f" [red]{exc}[/red]")
|
||||
|
||||
|
||||
def _when(stamp: float) -> str:
|
||||
from datetime import datetime
|
||||
|
||||
return datetime.fromtimestamp(stamp).strftime("%y-%m-%d %H:%M")
|
||||
|
||||
|
||||
def help_screen(console: Console) -> None:
|
||||
while True:
|
||||
_rule(console, "help")
|
||||
|
|
@ -1120,8 +750,6 @@ def _main_loop(console: Console, cfg: ScanConfig) -> ScanConfig | None:
|
|||
f" [cyan]3[/cyan] Settings "
|
||||
f"[grey62]{_summary(cfg)}[/grey62]\n"
|
||||
f" [cyan]4[/cyan] Saved settings and profiles\n"
|
||||
f" [cyan]5[/cyan] Aircraft (ADS-B) "
|
||||
f"[grey62]listen on 1090 MHz, draw where they went[/grey62]\n"
|
||||
f" [cyan]h[/cyan] Help\n"
|
||||
f" [cyan]s[/cyan] [bold green]Start scanning[/bold green]\n"
|
||||
f" [cyan]q[/cyan] Quit\n")
|
||||
|
|
@ -1135,8 +763,6 @@ def _main_loop(console: Console, cfg: ScanConfig) -> ScanConfig | None:
|
|||
settings_menu(console, cfg)
|
||||
elif choice == "4":
|
||||
cfg = profiles_menu(console, cfg)
|
||||
elif choice == "5":
|
||||
aircraft_menu(console, cfg)
|
||||
elif choice in ("h", "?", "help"):
|
||||
help_screen(console)
|
||||
elif choice in ("s", "start", "go"):
|
||||
|
|
|
|||
|
|
@ -18,12 +18,10 @@ from rich.table import Table
|
|||
from rich.text import Text
|
||||
|
||||
from .bandplan import band_label, fmt_hz, shorten_band
|
||||
from .flightlog import in_speed, speed_label
|
||||
from .recorder import HitRecord
|
||||
from .scanner import Detection, Scanner
|
||||
|
||||
__all__ = ["ScanDisplay", "AircraftDisplay", "KeyReader", "print_hit",
|
||||
"print_band_table"]
|
||||
__all__ = ["ScanDisplay", "KeyReader", "print_hit", "print_band_table"]
|
||||
|
||||
_SPARK = " ▁▂▃▄▅▆▇█"
|
||||
|
||||
|
|
@ -520,218 +518,6 @@ class ScanDisplay:
|
|||
return Group(*parts)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Aircraft, while they are overhead
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Altitude in colour, low warm to high cold: the same convention the map uses,
|
||||
# so a height can be read off either without a key.
|
||||
_ALTITUDE_BANDS = ((1_500, "bright_red"), (5_000, "red"), (10_000, "dark_orange"),
|
||||
(18_000, "yellow"), (24_000, "green"),
|
||||
(30_000, "bright_cyan"), (36_000, "cyan"),
|
||||
(99_000, "bright_blue"))
|
||||
|
||||
# How fresh a report is, in colour. Past the last of these the aircraft is
|
||||
# about to be dropped from the display.
|
||||
_AGE_STYLES = ((5.0, "green"), (15.0, "yellow"), (1e9, "red"))
|
||||
|
||||
_COMPASS = ("N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
|
||||
"S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW")
|
||||
|
||||
|
||||
def altitude_style(feet: float) -> str:
|
||||
"""The colour a height is drawn in."""
|
||||
for limit, style in _ALTITUDE_BANDS:
|
||||
if feet < limit:
|
||||
return style
|
||||
return _ALTITUDE_BANDS[-1][1]
|
||||
|
||||
|
||||
def age_style(seconds: float) -> str:
|
||||
for limit, style in _AGE_STYLES:
|
||||
if seconds < limit:
|
||||
return style
|
||||
return "red"
|
||||
|
||||
|
||||
def compass(degrees: float) -> str:
|
||||
"""A heading as a point of the compass, which is easier to read at a glance."""
|
||||
return _COMPASS[int((degrees % 360.0) / 22.5 + 0.5) % 16]
|
||||
|
||||
|
||||
class AircraftDisplay:
|
||||
"""One line per aircraft, updated in place while listening.
|
||||
|
||||
An aircraft appears when its first frame arrives, its counter climbs as
|
||||
more come in, and it disappears once nothing has been heard from it for
|
||||
``hold`` seconds -- at which point everything below moves up. The order
|
||||
is the order they were first heard, so a row does not jump about under
|
||||
the eye while it is being read.
|
||||
|
||||
Everything the frames say is here; everything a register says is here as
|
||||
it arrives, on a second line under the aircraft it belongs to, because
|
||||
the two are different kinds of knowledge and should not be mistaken for
|
||||
each other.
|
||||
"""
|
||||
|
||||
def __init__(self, console: Console, book=None, hold: float = 45.0,
|
||||
title: str = "", unit: str = "knots"):
|
||||
self.console = console
|
||||
self.book = book
|
||||
self.hold = hold
|
||||
self.title = title
|
||||
# Aircraft broadcast knots; this is only what they are shown in.
|
||||
self.unit = unit
|
||||
self.frames = 0
|
||||
self.started = time.time()
|
||||
self.log_path = None
|
||||
self.registry = None
|
||||
self.gone = 0
|
||||
|
||||
# -- what the listener tells it ---------------------------------------
|
||||
def update(self, registry, frames: int, log_path=None) -> None:
|
||||
self.registry = registry
|
||||
self.frames = frames
|
||||
if log_path is not None:
|
||||
self.log_path = log_path
|
||||
|
||||
def showing(self, now: float | None = None) -> list:
|
||||
"""The aircraft still worth drawing, oldest first heard at the top."""
|
||||
if self.registry is None:
|
||||
return []
|
||||
now = time.time() if now is None else now
|
||||
alive = [craft for craft in self.registry.aircraft.values()
|
||||
if now - craft.last_seen <= self.hold]
|
||||
return sorted(alive, key=lambda c: (c.first_seen, c.icao))
|
||||
|
||||
# -- drawing ----------------------------------------------------------
|
||||
def render(self, now: float | None = None, width: int | None = None):
|
||||
"""The whole display.
|
||||
|
||||
``width`` is the terminal it is going to be drawn on; it decides how
|
||||
many columns there is room for, and defaults to the console this
|
||||
display was built with, which is the one the live view uses.
|
||||
"""
|
||||
now = time.time() if now is None else now
|
||||
width = self.console.size.width if width is None else width
|
||||
flying = self.showing(now)
|
||||
parts = [self._header(flying, now)]
|
||||
if flying:
|
||||
parts.append(self._table(flying, now, width))
|
||||
else:
|
||||
parts.append(Panel(_one_line(
|
||||
"[grey62]nothing heard yet — ADS-B needs an aerial cut for "
|
||||
"1090 MHz[/grey62]"), border_style="grey37", padding=(0, 1)))
|
||||
return Group(*parts)
|
||||
|
||||
def _header(self, flying, now: float) -> Panel:
|
||||
elapsed = max(0.001, now - self.started)
|
||||
heard = len(self.registry.aircraft) if self.registry is not None else 0
|
||||
rate = self.frames / elapsed
|
||||
where = f" [grey62]{self.log_path.name}[/grey62]" if self.log_path \
|
||||
else ""
|
||||
return Panel(_one_line(
|
||||
f"[bold cyan]1090 MHz[/bold cyan] "
|
||||
f"[bold]{len(flying)}[/bold] overhead "
|
||||
f"[grey62]{heard} seen[/grey62] "
|
||||
f"[bold]{self.frames}[/bold] frames "
|
||||
f"[grey62]{rate:.0f}/s {_dur(elapsed)}[/grey62]{where} "
|
||||
f"[grey62]control-C to stop[/grey62]"),
|
||||
border_style="blue", padding=(0, 1))
|
||||
|
||||
def _table(self, flying, now: float, width: int) -> Table:
|
||||
"""As many columns as the terminal has room for, widest first.
|
||||
|
||||
A narrow terminal keeps what only the aircraft can say -- who, how
|
||||
high, how fast, how many frames -- and drops what a website said,
|
||||
because that can be read afterwards and the aeroplane cannot.
|
||||
"""
|
||||
t = Table(box=None, header_style="bold", pad_edge=False, expand=False)
|
||||
t.add_column("callsign", width=9, no_wrap=True)
|
||||
t.add_column("ICAO", width=6, style="grey62", no_wrap=True)
|
||||
if width >= 96:
|
||||
t.add_column("aircraft", width=17, overflow="ellipsis",
|
||||
no_wrap=True)
|
||||
t.add_column("altitude", width=10, justify="right", no_wrap=True)
|
||||
# Wide enough for the heading it is given: "speed km/h" is longer
|
||||
# than "speed kt", and a truncated unit is a wrong unit.
|
||||
speeds = f"speed {speed_label(self.unit)}"
|
||||
t.add_column(speeds, width=max(9, len(speeds)), justify="right",
|
||||
no_wrap=True)
|
||||
t.add_column("track", width=8, no_wrap=True)
|
||||
if width >= 114:
|
||||
t.add_column("position", width=19, no_wrap=True)
|
||||
t.add_column("frames", width=6, justify="right", no_wrap=True)
|
||||
t.add_column("last", width=5, justify="right", no_wrap=True)
|
||||
if width >= 132:
|
||||
t.add_column("operator / route", overflow="ellipsis", no_wrap=True,
|
||||
style="grey58")
|
||||
for craft in flying:
|
||||
self._row(t, craft, now, width)
|
||||
return t
|
||||
|
||||
def _row(self, t: Table, craft, now: float, width: int) -> None:
|
||||
entry = self.book.get(craft.icao, craft.callsign) \
|
||||
if self.book is not None else None
|
||||
age = max(0.0, now - craft.last_seen)
|
||||
cells = [Text(craft.callsign or "—",
|
||||
style="bold white" if craft.callsign else "grey62"),
|
||||
Text(craft.icao)]
|
||||
if width >= 96:
|
||||
cells.append(Text(self._kind(entry), style="grey62"))
|
||||
cells += [self._altitude(craft),
|
||||
Text(f"{in_speed(craft.ground_speed_kt, self.unit):.0f}"
|
||||
if craft.ground_speed_kt else "", style="white"),
|
||||
self._track(craft)]
|
||||
if width >= 114:
|
||||
cells.append(Text(f"{craft.latitude:8.4f},{craft.longitude:9.4f}"
|
||||
if craft.located else " no fix yet",
|
||||
style="cyan" if craft.located else "grey37"))
|
||||
cells.append(Text(f"{craft.messages}", style="bold"))
|
||||
cells.append(Text(f"{age:.0f}s", style=age_style(age)))
|
||||
if width >= 132:
|
||||
cells.append(Text(self._told(entry)))
|
||||
t.add_row(*cells)
|
||||
|
||||
@staticmethod
|
||||
def _kind(entry) -> str:
|
||||
"""The airframe in as few characters as say anything: type and mark."""
|
||||
if entry is None:
|
||||
return ""
|
||||
if entry.type_code and entry.registration:
|
||||
return f"{entry.type_code} {entry.registration}"
|
||||
return entry.type_code or entry.registration or ""
|
||||
|
||||
@staticmethod
|
||||
def _altitude(craft) -> Text:
|
||||
if not craft.altitude_ft:
|
||||
return Text("")
|
||||
arrow = ""
|
||||
if craft.vertical_rate_fpm > 100:
|
||||
arrow = "[green]↑[/green]"
|
||||
elif craft.vertical_rate_fpm < -100:
|
||||
arrow = "[red]↓[/red]"
|
||||
style = altitude_style(craft.altitude_ft)
|
||||
return Text.from_markup(
|
||||
f"[{style}]{craft.altitude_ft:,}[/{style}]{arrow}")
|
||||
|
||||
@staticmethod
|
||||
def _track(craft) -> Text:
|
||||
if not craft.ground_speed_kt and not craft.track_deg:
|
||||
return Text("")
|
||||
return Text(f"{craft.track_deg:03.0f}° {compass(craft.track_deg)}",
|
||||
style="grey62")
|
||||
|
||||
@staticmethod
|
||||
def _told(entry) -> str:
|
||||
"""What a register says about this aircraft, if anything yet."""
|
||||
if entry is None:
|
||||
return ""
|
||||
bits = [x for x in (entry.operator or entry.airline, entry.route,
|
||||
entry.country) if x]
|
||||
return " · ".join(bits)
|
||||
|
||||
|
||||
def _dur(seconds: float) -> str:
|
||||
seconds = int(seconds)
|
||||
h, rem = divmod(seconds, 3600)
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 103 KiB |
|
|
@ -1,5 +1,5 @@
|
|||
.\" Generated by packaging/make-man.py -- do not edit by hand.
|
||||
.TH BANDSAUNTER 1 "2026-09-06" "bandsaunter 2026-09-06_03" "User Commands"
|
||||
.TH BANDSAUNTER 1 "2026-09-03" "bandsaunter 2026-09-03_05" "User Commands"
|
||||
.SH NAME
|
||||
bandsaunter \- scan, record and identify radio signals with an RTL-SDR
|
||||
.SH SYNOPSIS
|
||||
|
|
@ -1285,115 +1285,6 @@ checksums, the same decoder \[em] for trying all of this without an aerial;
|
|||
says where they are flying. An aerial cut for 1090 MHz makes the difference
|
||||
between hearing the airport and hearing the county; the whip supplied with a
|
||||
dongle is a quarter of the length it wants.
|
||||
.SS While it listens
|
||||
The screen is a live board of what is overhead: one line per aircraft, in the
|
||||
order they were first heard, with everything the frames have said \[em] callsign,
|
||||
address, height with an arrow for climb or descent, ground speed, track as
|
||||
degrees and a point of the compass, position \[em] and a counter that climbs as
|
||||
frames arrive. Height is coloured low warm to high cold, and the age of the
|
||||
last frame green, then yellow, then red.
|
||||
.PP
|
||||
An aircraft that has not been heard from for
|
||||
.B \-\-hold
|
||||
seconds is removed from the board and everything below it moves up: the board
|
||||
is the sky now, not a list of everything ever heard. Nothing is lost by it, as
|
||||
the log holds every frame and the report at the end lists every aircraft.
|
||||
.PP
|
||||
The registers are asked while the listening runs, so the registration, type,
|
||||
operator and route appear on the line as the answers arrive. A narrow terminal
|
||||
drops the columns a website supplied and keeps the ones only the aircraft can
|
||||
give.
|
||||
.B \-\-frames
|
||||
prints the raw stream instead, and output that is not a terminal gets a plain
|
||||
running count rather than a display that redraws four times a second.
|
||||
.PP
|
||||
.BI \-\-speed\-unit " knots|mph|kph"
|
||||
changes what speeds are shown in: the heading on the live display, the speed
|
||||
written beside every aircraft on the map, and the speeds in the report. The
|
||||
distances move with it \[em] nautical miles with knots, statute miles with miles
|
||||
an hour, kilometres with km/h \[em] so that one picture never carries two
|
||||
different miles. The log always holds knots, because that is what the aircraft
|
||||
broadcast: the recording stays the thing that arrived and the conversion
|
||||
happens at the moment of showing it to somebody.
|
||||
.SS 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
|
||||
A box keeps its place for as long as that place still works and is moved only
|
||||
when an aircraft or another box genuinely takes it, which is about a third as
|
||||
many moves as laying every box out afresh each frame. The moves that are left
|
||||
are eased over about half a second rather than jumped, since a box that
|
||||
teleports reads as a different box, and the window redraws at thirty frames a
|
||||
second for as long as anything is moving and drops back to five when it
|
||||
stops. That is affordable because the ground is dimmed once and kept: cutting
|
||||
the view out of the fetched map and looking every level up in the palette is
|
||||
most of a tenth of a second over two megapixels, and nothing about it changes
|
||||
between frames unless the view, the window, the brightness or the map itself
|
||||
has. What the next box is laid out against is the place a moving box is
|
||||
going to, not the place it has reached, as otherwise its neighbours would
|
||||
move as well and move back when it arrived; and a box in motion is drawn over
|
||||
the ones standing still, so that it stays readable while it crosses them.
|
||||
.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 ]
|
||||
its brightness,
|
||||
.B +
|
||||
and
|
||||
.B \-
|
||||
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
|
||||
Running
|
||||
.B bandsaunter
|
||||
with no arguments and choosing
|
||||
.B 5
|
||||
.RB ( "Aircraft (ADS-B)" )
|
||||
does all of this without a command line. Every option is listed on one screen
|
||||
with a line saying what it does;
|
||||
.BI ? N
|
||||
explains one at length, including the flag it corresponds to,
|
||||
.B p
|
||||
starts a passive capture,
|
||||
.B r
|
||||
opens the window,
|
||||
.B m
|
||||
draws a map from any log in the recordings directory, and
|
||||
.B s
|
||||
saves the options to
|
||||
.IR ~/.config/bandsaunter/aircraft.yaml .
|
||||
.SS Not a scan
|
||||
The band plan lists 1090 MHz because that is where ADS-B is, but sweeping it
|
||||
records the bursts as clicks in a WAV file and decodes nothing: the signalling
|
||||
is a megabit a second and the scan path is twelve and a half kilohertz wide.
|
||||
Both the scanner and the menus say so when a sweep is pointed at 1090 MHz or
|
||||
at the 978 MHz UAT band, rather than letting it run silently. Scanning it
|
||||
anyway is a fair thing to want if what you are after is the raw spectrum;
|
||||
.B \-\-save\-iq
|
||||
keeps the samples.
|
||||
.SS Who the aircraft is
|
||||
The frames say an address, not a registration. Two registers are asked \[em]
|
||||
adsbdb for the airframe and the route, then hexdb \[em] and the answers are
|
||||
|
|
@ -1405,54 +1296,6 @@ address block says which country registered the aircraft, fixed by treaty, and
|
|||
the first three letters of an airline callsign are its ICAO designator.
|
||||
.B \-\-no\-lookup
|
||||
stops at that.
|
||||
.PP
|
||||
A callsign is a flight number rather than a leg. An airline runs the same
|
||||
number over several legs in a day and a register holds one route for it, so an
|
||||
aircraft crossing Arizona is quite often handed a half-hour hop between two
|
||||
airports in Texas; the two registers routinely disagree about the same flight
|
||||
number, and both are snapshots years old. Nothing on the air settles it, as
|
||||
ADS-B carries no origin or destination: an aircraft broadcasts who and where
|
||||
it is, not where it is going.
|
||||
.PP
|
||||
So a route the aircraft cannot be flying is left off the map and out of the
|
||||
window \[em] the two ends are known, and an aircraft on a route is never much
|
||||
further along it than the route is long \[em] and written in the report with a
|
||||
note saying so, since it is what the register holds for that flight number and
|
||||
worth having. Where a source lists a whole day's stops rather than a leg, the
|
||||
aircraft's own position picks the leg out; where no leg fits, none is claimed.
|
||||
.SS Schedule services
|
||||
Knowing the leg for certain needs live schedule data, which none of the free
|
||||
sources carry. Four commercial services are wired up and all four are
|
||||
optional: FlightAware AeroAPI, Flightradar24, OAG and Cirium. Each holds the
|
||||
timetable and the day's movements, so each can say which leg of a flight
|
||||
number was in the air at the moment an aircraft was overhead. Where one
|
||||
answers, its leg is used; where none does, the free databases answer as they
|
||||
always did, and a program with no keys set behaves exactly as before.
|
||||
.PP
|
||||
Keys are read from the environment rather than the settings file, because a
|
||||
settings file is meant to be copied between machines and pasted into a message
|
||||
asking for help, and an API key is not.
|
||||
.PP
|
||||
.nf
|
||||
BANDSAUNTER_AEROAPI_KEY FlightAware AeroAPI
|
||||
BANDSAUNTER_FR24_TOKEN Flightradar24
|
||||
BANDSAUNTER_OAG_KEY OAG Flight Info
|
||||
BANDSAUNTER_CIRIUM_APP_ID Cirium (FlightStats), with
|
||||
BANDSAUNTER_CIRIUM_APP_KEY
|
||||
.fi
|
||||
.PP
|
||||
.BI \-\-schedules " NAMES"
|
||||
picks which to ask and in what order, comma separated, from
|
||||
.BR flightaware ", " flightradar24 ", " oag " and " cirium ;
|
||||
the default asks every one that has its key. A service with no key is skipped
|
||||
rather than asked and refused. The callsign and the moment are all that is
|
||||
sent.
|
||||
.PP
|
||||
Each reader was written from its service's published response shape and
|
||||
tested against that shape; none has been run against a live service, since
|
||||
each wants a paid account. So each is written to find what it recognises and
|
||||
return nothing otherwise: a service that has changed since costs a route
|
||||
rather than a scan, and the free databases pick the question back up.
|
||||
.SS The moving map
|
||||
.B bandsaunter flights
|
||||
reads a log back \[em] the newest one in the output directory unless told
|
||||
|
|
@ -1481,520 +1324,6 @@ where ffmpeg is installed, or a
|
|||
for the whole evening in one picture. Altitude is the colour, low warm to high
|
||||
cold. The GIF is written from first principles \[em] a palette, an LZW stream
|
||||
and frame differencing \[em] so nothing but numpy is needed to draw one.
|
||||
.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
|
||||
The picture is framed on the receiver rather than on whatever was heard. An
|
||||
aerial reaches a hundred miles on a good day and a position that decoded
|
||||
wrongly can land anywhere on Earth, so a map drawn to fit everything heard is
|
||||
drawn to fit the mistakes: the aircraft come out a pixel wide in the middle of
|
||||
an empty continent.
|
||||
.PP
|
||||
.BI \-\-radius " MILES"
|
||||
is how far the map reaches, in the same unit as the speeds, and defaults to a
|
||||
hundred; zero goes back to fitting whatever turned up. The centre is the
|
||||
median of everything heard \[em] a receiver hears aircraft all round it, and a
|
||||
median cannot be dragged anywhere by a handful of bad positions \[em] or
|
||||
.BI \-\-at " LAT,LON"
|
||||
says where the receiver is, which is worth doing to keep the same frame every
|
||||
night. Positions outside the radius are left off the drawing, one at a time
|
||||
rather than one aircraft at a time, so a single bad fix in the middle of a
|
||||
real flight does not take the flight with it. Nothing is dropped from the log.
|
||||
.SS Positions that never happened
|
||||
A position is sent as half a position \[em] an even frame and an odd one \[em] and
|
||||
the pair only means anything while the aircraft has not moved between them.
|
||||
Logs written before this version paired them however old they were, so an even
|
||||
frame kept from ten minutes ago decoded against a fresh odd one to a place on
|
||||
the wrong side of the world and wrote it down as confidently as a real one.
|
||||
.PP
|
||||
.B \-\-recheck
|
||||
reads such a log back and keeps, for each aircraft, the longest run of
|
||||
positions that could describe one aeroplane. It is deliberately not a forward
|
||||
walk dropping whatever disagrees with the last position kept: one bad fix then
|
||||
becomes the reference and it is the truth that gets discarded. Nothing in the
|
||||
log is changed.
|
||||
.PP
|
||||
An aircraft that goes quiet for five minutes and is heard again a long way off
|
||||
is an aeroplane rather than an error, and is not second-guessed; the radius is
|
||||
what keeps those off the picture. New logs need none of this, as the decoder
|
||||
now refuses a stale pair, a position that is not on Earth, and one the
|
||||
aircraft could not have reached, as the frames arrive.
|
||||
.SS The ground under it
|
||||
A real map is drawn under the aircraft: standard {z}/{x}/{y} raster tiles,
|
||||
OpenStreetMap by default, fetched the first time an area is drawn and
|
||||
reprojected from Web Mercator onto the picture, inverted and dimmed so the
|
||||
aircraft stay the brightest thing on it. The tiles are decoded here, from
|
||||
zlib and the five row filters the PNG specification defines, with no imaging
|
||||
library.
|
||||
.PP
|
||||
Tiles are cached in
|
||||
.I ~/.cache/bandsaunter/tiles
|
||||
and never fetched twice, every request identifies this program in its
|
||||
User-Agent, and the attribution the tiles require is written onto the picture
|
||||
\[em] a GIF travels without the readme that would otherwise carry it.
|
||||
.PP
|
||||
The zoom is chosen from how wide the picture is, not from the area alone, so a
|
||||
map asked for at 1920 pixels fetches finer tiles than the same map asked for
|
||||
at 960. Half again over the width is fetched deliberately and averaged down,
|
||||
since a downscaled tile is sharp and an upscaled one is not.
|
||||
.PP
|
||||
The window fetches a little more world than it shows so that panning does not
|
||||
leave the ground blank, and fetches that bigger piece at the bigger piece's
|
||||
own size, so what is shown comes out pixel for pixel with the screen. At 1920
|
||||
by 1080 and a hundred-mile radius the tiles hold about 1.6 times the pixels
|
||||
the window wants. A drawing is capped at a couple of hundred tiles, which at
|
||||
3840 by 2160 is reached: there the zoom has stopped climbing and the map is
|
||||
enlarged after all, and a smaller radius buys the detail back.
|
||||
.PP
|
||||
.B \-\-no\-basemap
|
||||
draws the tracks on their own,
|
||||
.BI \-\-tiles " URL"
|
||||
points at another server, and where there is no network and nothing cached
|
||||
the picture falls back to the plain grid.
|
||||
.PP
|
||||
An aircraft that goes quiet fades rather than vanishing: taking it off the
|
||||
picture between one frame and the next says it stopped existing, and fading it
|
||||
says it stopped talking, which is what happened. It fades where it was last
|
||||
actually seen and never along a reckoned track, since the reason for giving up
|
||||
on it is that where it would be by now is a guess.
|
||||
.BI \-\-fade " SECONDS"
|
||||
is how long that takes, and zero takes it away at once.
|
||||
.PP
|
||||
The map is averaged down to the size of the picture rather than point-sampled,
|
||||
so lettering and roads stay whole, and the window fetches enough pixels to
|
||||
cover its margin at full detail rather than enlarging what it has.
|
||||
.BI \-\-map\-brightness " PERCENT"
|
||||
is how far up its range the map is drawn: dark enough that the aircraft stay
|
||||
the brightest thing on the picture, light enough that a coastline can be made
|
||||
out, and which way to err depends on the screen.
|
||||
.PP
|
||||
Every aerodrome under the picture is marked, not only the ones being flown
|
||||
between \[em] a receiver hears aircraft over its own county, and the county's
|
||||
airports say where on the map you are looking. They come from the same map
|
||||
data the tiles are drawn from, asked once per area and kept for a month.
|
||||
.B \-\-no\-airports
|
||||
turns that off. They are drawn magenta, which nothing else on the picture is:
|
||||
the old amber sat sixteen units of CIELAB from the altitude ramp's yellow,
|
||||
which is to say it was the same colour, and an aeroplane low over a field was
|
||||
drawn in the field's own colour. The ramp already spends red, amber, green,
|
||||
cyan and violet on height; magenta is what it leaves free, and is what an
|
||||
aeronautical chart marks an aerodrome in anyway.
|
||||
.SS What is beside each aircraft
|
||||
Its height in feet with the unit on it, rounded to the twenty-five feet Mode S
|
||||
reports altitude in, so that a moment interpolated between two reports stops
|
||||
claiming to know the height to the foot; its speed; what sort of aircraft it
|
||||
is; its type and registration; and the two ends of the route, each with the
|
||||
flag of the country its airport is in. The country of registration gets a flag
|
||||
too, from the register where one answered and otherwise from the address
|
||||
block, so it is there for an aircraft no register has heard of.
|
||||
.PP
|
||||
What sort of aircraft it is comes from two places. Every identification
|
||||
message carries three bits under its type code saying what is transmitting
|
||||
\[em] light, small, large, high vortex, heavy, high performance, rotorcraft,
|
||||
and under another type code glider, airship, parachutist, ultralight, drone,
|
||||
spacecraft, or a vehicle on the ground \[em] and that is the only word about
|
||||
what an aircraft is that needs no register. Which list the three bits index
|
||||
depends on the type code, and zero means the aircraft declined to say, which
|
||||
is answered with nothing rather than a guess.
|
||||
.PP
|
||||
Whether it is military comes off no air at all, as no aircraft broadcasts it
|
||||
and a tanker calls itself heavy exactly as an airliner does. It is read from
|
||||
the address instead, since states set aside blocks of their national range for
|
||||
their armed forces. A state can fly a military aircraft on a civil address
|
||||
whenever it likes, so this finds nobody who does not want to be found, and the
|
||||
table holds the allocations a receiver in the ordinary world hears rather than
|
||||
every one that exists.
|
||||
.PP
|
||||
A label keeps its place for as long as that place still works and is moved
|
||||
only when something takes it, which is about half as many moves as deciding
|
||||
afresh every frame; the moves that are left are eased over about half a second
|
||||
of playback rather than jumped, and what the next label is laid out against is
|
||||
where a moving one is going rather than where it has reached. A still picture
|
||||
has no frame before it and places its labels exactly as it always did. The
|
||||
whole box fades with its aircraft: an indexed picture cannot blend, so the row
|
||||
grey and all twelve flag colours have dimmed copies at each fade step.
|
||||
.SS What is on the picture besides the aircraft
|
||||
The line from an information box to the aircraft it belongs to is dashed and
|
||||
is its own colour, in the window and in the animated pictures alike. Drawn in
|
||||
the aircraft's own colour it came out the same colour as that aircraft's
|
||||
trail, and a straight solid line running out of an aeroplane in the colour of
|
||||
the path behind the aeroplane reads as more path, which on a busy picture is a
|
||||
heading nobody flew. The animation had no such line at all until now, so a
|
||||
label pushed out into one of the outward rings by a crowd had nothing tying it
|
||||
to the aeroplane it was about.
|
||||
.PP
|
||||
A red flag stands where the receiver is, on the window and on the animated
|
||||
pictures alike, taken from the coordinates in the settings. The foot of the
|
||||
pole is the position and the pennant flies up and to the right of it, so that
|
||||
nothing the flag is made of covers the place it points at. It is pure red in
|
||||
every theme, that being the one mark on the picture whose meaning must not
|
||||
change with the colours as well as the red furthest from every altitude
|
||||
colour. It is drawn only where the receiver was actually told where it is: a
|
||||
middle worked out from whatever flew past is not a place anybody is standing.
|
||||
.SS The options menu
|
||||
The aircraft options are in six groups \[em] receiver, listening, aircraft,
|
||||
animation, the map, and labels \[em] rather than in one list, thirty-three of
|
||||
them on a screen being a wall rather than a menu. A number opens a group;
|
||||
inside it a number changes an option and
|
||||
.BI ? N
|
||||
explains one at length. The numbers are the option's place in the whole list,
|
||||
so the same number means the same option wherever it is typed. Typing a name
|
||||
instead goes straight to that option, and part of a name lists everything it
|
||||
could mean; a name that matches exactly wins outright, so "speed" reaches the
|
||||
setting called speed rather than that one and every other whose description
|
||||
mentions the word.
|
||||
.SS The card behind a box
|
||||
.BI \-\-box\-opacity " PERCENT"
|
||||
is how solid the card behind each information box is. The words beside an
|
||||
aircraft are readable over water and not over a city, so a card goes behind
|
||||
them: at nothing they sit straight on the map and at the whole way the map
|
||||
does not show through at all. An indexed picture cannot blend, so in the
|
||||
animation this darkens the ground under the box instead, which leaves the
|
||||
coastline faintly visible through it. The animated pictures had no card at all
|
||||
before, so nothing is what they used to look like.
|
||||
.PP
|
||||
The flag marking the receiver is drawn after everything else on both pictures
|
||||
\[em] after the aircraft, their trails and their boxes \[em] since it says
|
||||
where the receiver is standing and that is the one mark that must not end up
|
||||
behind an aeroplane that happened to fly over it. A vector theme's halo cannot
|
||||
cover it either, a halo only ever going on the ground, the grid and the
|
||||
background.
|
||||
.SH AIRCRAFT OPTIONS
|
||||
Every option the ADS-B side takes, in the six groups the menu shows them in.
|
||||
Each is a flag here and a line in the menu, and both come from one table in
|
||||
the program, so they cannot disagree.
|
||||
.SS Receiver
|
||||
.TP
|
||||
.B --device
|
||||
Receiver \[em] which receiver to use, when more than one is plugged in.
|
||||
.br
|
||||
Setting name \fBdevice\fR, default \fB0\fR.
|
||||
.br
|
||||
Accepts: at least 0.
|
||||
.TP
|
||||
.B --gain
|
||||
Gain \[em] tuner gain in dB, or automatic.
|
||||
.br
|
||||
Setting name \fBgain\fR, default \fBauto\fR.
|
||||
.TP
|
||||
.B --rate
|
||||
Sample rate \[em] how fast to sample; two megasamples a second is the minimum (Hz).
|
||||
.br
|
||||
Setting name \fBrate\fR, default \fB2 MHz\fR.
|
||||
.br
|
||||
Accepts: at least 2e+06.
|
||||
.TP
|
||||
.B --at
|
||||
Receiver at \[em] where the receiver is, as latitude,longitude (blank = work it out).
|
||||
.br
|
||||
Setting name \fBlocation\fR, default \fBblank\fR.
|
||||
.TP
|
||||
.B --simulate
|
||||
Invent a sky \[em] fly imaginary aircraft past an imaginary receiver.
|
||||
.br
|
||||
Setting name \fBsimulate\fR, default \fBno\fR.
|
||||
.RS
|
||||
.PP
|
||||
Turn this on to see what the whole thing does without hardware. Turn it off to hear real aircraft.
|
||||
.RE
|
||||
.TP
|
||||
.B --near
|
||||
Imaginary sky near \[em] where the simulated aircraft are flying.
|
||||
.br
|
||||
Setting name \fBnear\fR, default \fB47.55,-122.30\fR.
|
||||
.PP
|
||||
.SS Listening
|
||||
.TP
|
||||
.B --seconds
|
||||
Listen for \[em] how long to listen before stopping (0 = until interrupted) (s).
|
||||
.br
|
||||
Setting name \fBseconds\fR, default \fBuntil stopped\fR.
|
||||
.br
|
||||
Accepts: at least 0.
|
||||
.RS
|
||||
.PP
|
||||
Sixty seconds is enough to know whether aircraft are being heard. An evening of traffic wants no limit.
|
||||
.RE
|
||||
.TP
|
||||
.B --frames
|
||||
Show every frame \[em] print each frame as it arrives, rather than a running count.
|
||||
.br
|
||||
Setting name \fBframes\fR, default \fBno\fR.
|
||||
.TP
|
||||
.B --log / --no-log
|
||||
Write the log \[em] write every frame to a file as it arrives.
|
||||
.br
|
||||
Setting name \fBlog\fR, default \fByes\fR.
|
||||
.TP
|
||||
.B --kml
|
||||
Also write a KML \[em] write the flight paths for Google Earth as well.
|
||||
.br
|
||||
Setting name \fBkml\fR, default \fBno\fR.
|
||||
.TP
|
||||
.B --hold
|
||||
Keep on screen for \[em] how long an aircraft stays on the display after its last frame (s).
|
||||
.br
|
||||
Setting name \fBhold\fR, default \fB45 s\fR.
|
||||
.br
|
||||
Accepts: at least 1.
|
||||
.RS
|
||||
.PP
|
||||
Long enough that a gap in reception does not make rows jump about; short enough that the screen is the sky now.
|
||||
.RE
|
||||
.TP
|
||||
.B --map
|
||||
Draw when finished \[em] draw the map as soon as the listening stops.
|
||||
.br
|
||||
Setting name \fBdraw_after\fR, default \fBno\fR.
|
||||
.PP
|
||||
.SS Aircraft
|
||||
.TP
|
||||
.B --lookup / --no-lookup
|
||||
Look the aircraft up \[em] ask the public registers who each aircraft is.
|
||||
.br
|
||||
Setting name \fBlookup\fR, default \fByes\fR.
|
||||
.TP
|
||||
.B --schedules
|
||||
Schedule services \[em] which paid schedule services to ask, in order (blank = all with keys).
|
||||
.br
|
||||
Setting name \fBschedules\fR, default \fBblank\fR.
|
||||
.RS
|
||||
.PP
|
||||
Leave it blank unless you want one service tried before another. With no keys set, nothing changes.
|
||||
.RE
|
||||
.TP
|
||||
.B --recheck
|
||||
Check the positions \[em] throw out positions the aircraft could not have been in.
|
||||
.br
|
||||
Setting name \fBrecheck\fR, default \fBno\fR.
|
||||
.RS
|
||||
.PP
|
||||
Worth turning on for anything recorded before this version. Newer logs have the check applied as they are written, so it finds almost nothing.
|
||||
.RE
|
||||
.PP
|
||||
.SS Animation
|
||||
.TP
|
||||
.B --out
|
||||
Picture \[em] what kind of picture to draw.
|
||||
.br
|
||||
Setting name \fBpicture\fR, default \fBgif\fR.
|
||||
.br
|
||||
Accepts: one of: gif, mp4, png.
|
||||
.RS
|
||||
.PP
|
||||
Start with gif. Use png when you want one picture to look at or send.
|
||||
.RE
|
||||
.TP
|
||||
.B --seconds
|
||||
Animation length \[em] how long the animation should run for (s).
|
||||
.br
|
||||
Setting name \fBlength\fR, default \fB30 s\fR.
|
||||
.br
|
||||
Accepts: at least 1.
|
||||
.TP
|
||||
.B --speed
|
||||
Speed \[em] seconds of flying per second of animation (0 = fit to the length) (x).
|
||||
.br
|
||||
Setting name \fBspeed\fR, default \fBfit to the length\fR.
|
||||
.br
|
||||
Accepts: at least 0.
|
||||
.TP
|
||||
.B --fps
|
||||
Frames a second \[em] how many frames of animation each second holds.
|
||||
.br
|
||||
Setting name \fBfps\fR, default \fB12\fR.
|
||||
.br
|
||||
Accepts: at least 1.
|
||||
.TP
|
||||
.B --width
|
||||
Picture width \[em] how many pixels across the picture is (px).
|
||||
.br
|
||||
Setting name \fBwidth\fR, default \fB960 px\fR.
|
||||
.br
|
||||
Accepts: at least 160.
|
||||
.TP
|
||||
.B --trail
|
||||
Trail \[em] how much of the path to leave behind each aircraft (0 = all of it) (s).
|
||||
.br
|
||||
Setting name \fBtrail\fR, default \fBthe whole path\fR.
|
||||
.br
|
||||
Accepts: at least 0.
|
||||
.TP
|
||||
.B --fade
|
||||
Fade out over \[em] how long an aircraft takes to fade away once it has gone quiet (s).
|
||||
.br
|
||||
Setting name \fBfade\fR, default \fB20 s\fR.
|
||||
.br
|
||||
Accepts: at least 0.
|
||||
.RS
|
||||
.PP
|
||||
Long enough to notice, short enough that a busy sky is not half ghosts.
|
||||
.RE
|
||||
.TP
|
||||
.B --stale
|
||||
Forget after \[em] stop drawing an aircraft this long after its last report (s).
|
||||
.br
|
||||
Setting name \fBstale\fR, default \fB300 s\fR.
|
||||
.br
|
||||
Accepts: at least 1.
|
||||
.PP
|
||||
.SS The map
|
||||
.TP
|
||||
.B --basemap / --no-basemap
|
||||
Map underneath \[em] draw a real map under the flight paths.
|
||||
.br
|
||||
Setting name \fBbasemap\fR, default \fByes\fR.
|
||||
.RS
|
||||
.PP
|
||||
Turn it off for a picture with nothing but the tracks on it, or where there is no network and no cached tiles.
|
||||
.RE
|
||||
.TP
|
||||
.B --tiles
|
||||
Tile server \[em] where the map tiles come from.
|
||||
.br
|
||||
Setting name \fBtile_url\fR, default \fBblank\fR.
|
||||
.TP
|
||||
.B --theme
|
||||
Colour theme \[em] how the map looks: the colours, and whether the lines glow.
|
||||
.br
|
||||
Setting name \fBtheme\fR, default \fBnight\fR.
|
||||
.br
|
||||
Accepts: one of: night, digital, phosphor, amber, red.
|
||||
.RS
|
||||
.PP
|
||||
night to read it, the others to look at it.
|
||||
.RE
|
||||
.TP
|
||||
.B --map-brightness
|
||||
Map brightness \[em] how bright the map under the aircraft is drawn, as a percentage (%).
|
||||
.br
|
||||
Setting name \fBmap_brightness\fR, default \fB70 %\fR.
|
||||
.br
|
||||
Accepts: at least 10, at most 100.
|
||||
.RS
|
||||
.PP
|
||||
Turn it up until the coast and the roads are readable, and no further. On a vector theme it takes rather more turning up than on the default one.
|
||||
.RE
|
||||
.TP
|
||||
.B --radius
|
||||
Map radius \[em] how far around the receiver the map reaches (0 = fit whatever was heard).
|
||||
.br
|
||||
Setting name \fBradius\fR, default \fB100\fR.
|
||||
.br
|
||||
Accepts: at least 0.
|
||||
.RS
|
||||
.PP
|
||||
Set it to what your aerial can really hear. Zero goes back to fitting whatever turned up, mistakes and all.
|
||||
.RE
|
||||
.TP
|
||||
.B --airports / --no-airports
|
||||
Mark the airports \[em] mark every aerodrome on the map, not only the ones flown between.
|
||||
.br
|
||||
Setting name \fBairports\fR, default \fByes\fR.
|
||||
.RS
|
||||
.PP
|
||||
Turn it off for a picture with nothing but the aircraft on it, or where there is no network and nothing cached.
|
||||
.RE
|
||||
.TP
|
||||
.B --rings / --no-rings
|
||||
Range rings on the pictures \[em] faint discs at a quarter, a half and three quarters of the radius.
|
||||
.br
|
||||
Setting name \fBrings\fR, default \fByes\fR.
|
||||
.RS
|
||||
.PP
|
||||
Turn it off for a picture with nothing on it but the aircraft and the ground.
|
||||
.RE
|
||||
.TP
|
||||
.B --window-rings / --no-window-rings
|
||||
Range rings in the window \[em] the same discs on the realtime display.
|
||||
.br
|
||||
Setting name \fBwindow_rings\fR, default \fByes\fR.
|
||||
.RS
|
||||
.PP
|
||||
Turn it off if the window is busy enough already.
|
||||
.RE
|
||||
.PP
|
||||
.SS Labels
|
||||
.TP
|
||||
.B --box-opacity
|
||||
Box translucency \[em] how solid the card behind each information box is, as a percentage (%).
|
||||
.br
|
||||
Setting name \fBbox_opacity\fR, default \fB85 %\fR.
|
||||
.br
|
||||
Accepts: at least 0, at most 100.
|
||||
.RS
|
||||
.PP
|
||||
Turn it up over a busy map and down over an empty one.
|
||||
.RE
|
||||
.TP
|
||||
.B --labels / --no-labels
|
||||
Label the aircraft \[em] write the callsign, height and speed beside each aircraft.
|
||||
.br
|
||||
Setting name \fBlabels\fR, default \fByes\fR.
|
||||
.TP
|
||||
.B --speed-unit
|
||||
Speed in \[em] what to show speeds and distances in.
|
||||
.br
|
||||
Setting name \fBspeed_unit\fR, default \fBknots\fR.
|
||||
.br
|
||||
Accepts: one of: knots, mph, kph.
|
||||
.RS
|
||||
.PP
|
||||
knots is what aviation uses and what the aircraft actually said. mph or kph if that is what means something to you.
|
||||
.RE
|
||||
.PP
|
||||
.SS Range rings
|
||||
.B \-\-rings
|
||||
puts faint discs at a quarter, a half and three quarters of the radius,
|
||||
concentric on the receiver and each labelled with its distance. They are
|
||||
translucent and they stack, so the ground inside the innermost is lifted three
|
||||
times, the next twice and the outer once; what that gives is a sense of how
|
||||
far away a thing is without measuring anything, an aircraft two shades in
|
||||
being about halfway to the edge of what this receiver hears. An indexed
|
||||
picture cannot blend, so translucent there means moving the ground under the
|
||||
disc a step or two up its own ramp of shades, which keeps the coastline and
|
||||
the roads visible through it.
|
||||
.PP
|
||||
They need a receiver position and a radius and are not drawn without both.
|
||||
.B \-\-window\-rings
|
||||
is the same thing on the realtime window, kept as a separate setting because
|
||||
a picture is studied and a window is glanced at.
|
||||
.SS Themes
|
||||
.BI \-\-theme " NAME"
|
||||
changes the window and the animated pictures together, since both read their
|
||||
colours out of the same palette.
|
||||
.B night
|
||||
is the default: a night-blue ground with height as colour, low warm to high
|
||||
cold, which is what every other aircraft map does and is the easiest to read.
|
||||
.BR digital ", " phosphor ", " amber " and " red
|
||||
are the screens the phrase "air defence display" calls to mind \[em] a black
|
||||
tube, one phosphor, and thin bright vector lines with a halo round them.
|
||||
.PP
|
||||
Three things follow from having one colour to spend, and they are constraints
|
||||
rather than decoration. Height becomes brightness, since hue is no longer
|
||||
free: low is dim and high burns. The map underneath is drawn at about
|
||||
two-fifths of the brightness asked for, because a tinted photograph of a
|
||||
county behind the vectors is the one thing that stops a vector display looking
|
||||
like one. And a country is named in two letters rather than drawn as a flag,
|
||||
a flag being half a dozen colours.
|
||||
.PP
|
||||
A vector display draws by holding a beam on the phosphor, which spreads the
|
||||
light a little and keeps glowing after the beam has gone, so a line on one of
|
||||
those screens is a bright core inside a halo. The window does that by laying
|
||||
the same line down two or three times, wider and fainter each pass, and the
|
||||
core last. The animation cannot blend at all, a GIF being indexed colour, so
|
||||
it dilates what it has drawn and fills the halo with the dimmed copy of the
|
||||
colour underneath: an aeroplane glows into the colour its own trail is drawn
|
||||
in, which is the colour a phosphor would have spread into. The halo goes over
|
||||
the map, the grid and the background and over nothing else that was drawn.
|
||||
.SH METERS AND SENSORS
|
||||
Two things on the ISM bands are worth naming rather than reporting as
|
||||
hexadecimal.
|
||||
|
|
@ -2069,17 +1398,9 @@ frequency to dial into a radio.
|
|||
.I ~/.config/bandsaunter/config.yaml
|
||||
The settings every run starts from.
|
||||
.TP
|
||||
.I ~/.config/bandsaunter/aircraft.yaml
|
||||
The aircraft options, as saved from the menus.
|
||||
.TP
|
||||
.I ~/.config/bandsaunter/*.yaml
|
||||
Named profiles.
|
||||
.TP
|
||||
.IR adsb_ * .jsonl
|
||||
Every ADS-B frame heard in one listening session, with a
|
||||
.I .txt
|
||||
report and any picture drawn from it beside it.
|
||||
.TP
|
||||
.I ~/bandsaunter/
|
||||
Where recordings, transcripts and logs are written, unless
|
||||
.B \-\-output
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ Architecture: ${arch}
|
|||
Depends: python3 (>= 3.10), python3-numpy, python3-scipy, python3-rich,
|
||||
python3-yaml, librtlsdr0
|
||||
Recommends: bandsaunter-transcribe, espeak-ng
|
||||
Suggests: rtl-sdr, python3-pyqt6, ffmpeg
|
||||
Suggests: rtl-sdr
|
||||
Maintainer: bandsaunter
|
||||
Installed-Size: $(du -ks "$pkgdir" | cut -f1)
|
||||
Description: signal scanner and recorder for RTL-SDR receivers
|
||||
|
|
|
|||
|
|
@ -56,43 +56,6 @@ def settings_section() -> list[str]:
|
|||
return out
|
||||
|
||||
|
||||
def aircraft_section() -> list[str]:
|
||||
"""Every ADS-B option, from the same table the menu and flags come from.
|
||||
|
||||
Written out rather than described in prose, so that an option added to
|
||||
the program cannot quietly fail to appear in its manual.
|
||||
"""
|
||||
from bandsaunter import aircraft as air
|
||||
|
||||
out = []
|
||||
defaults = air.AircraftOptions()
|
||||
for group in air.OPTION_GROUPS:
|
||||
out.append(f'.SS {esc(group)}')
|
||||
for o in air.in_group(group):
|
||||
flags = " ".join(o.flags)
|
||||
if o.off_flags:
|
||||
flags += " / " + " ".join(o.off_flags)
|
||||
shown = air.format_option(o, getattr(defaults, o.key))
|
||||
unit = f" ({o.unit})" if o.unit and o.kind != "bool" else ""
|
||||
out.append('.TP')
|
||||
out.append(f'.B {esc(flags) if flags else esc(o.key)}')
|
||||
out.append(f'{esc(o.label)} \\[em] {esc(o.help)}{esc(unit)}.')
|
||||
out.append('.br')
|
||||
out.append(f'Setting name \\fB{esc(o.key)}\\fR, '
|
||||
f'default \\fB{esc(shown) if shown else "blank"}\\fR.')
|
||||
accepts = o.describe_range()
|
||||
if accepts:
|
||||
out.append('.br')
|
||||
out.append(f'Accepts: {esc(accepts)}.')
|
||||
if o.guidance:
|
||||
out.append('.RS')
|
||||
out.append('.PP')
|
||||
out.append(esc(o.guidance))
|
||||
out.append('.RE')
|
||||
out.append('.PP')
|
||||
return out
|
||||
|
||||
|
||||
HEAD = r'''.\" Generated by packaging/make-man.py -- do not edit by hand.
|
||||
.TH BANDSAUNTER 1 "{date}" "bandsaunter {version}" "User Commands"
|
||||
.SH NAME
|
||||
|
|
@ -715,115 +678,6 @@ checksums, the same decoder \[em] for trying all of this without an aerial;
|
|||
says where they are flying. An aerial cut for 1090 MHz makes the difference
|
||||
between hearing the airport and hearing the county; the whip supplied with a
|
||||
dongle is a quarter of the length it wants.
|
||||
.SS While it listens
|
||||
The screen is a live board of what is overhead: one line per aircraft, in the
|
||||
order they were first heard, with everything the frames have said \[em] callsign,
|
||||
address, height with an arrow for climb or descent, ground speed, track as
|
||||
degrees and a point of the compass, position \[em] and a counter that climbs as
|
||||
frames arrive. Height is coloured low warm to high cold, and the age of the
|
||||
last frame green, then yellow, then red.
|
||||
.PP
|
||||
An aircraft that has not been heard from for
|
||||
.B \-\-hold
|
||||
seconds is removed from the board and everything below it moves up: the board
|
||||
is the sky now, not a list of everything ever heard. Nothing is lost by it, as
|
||||
the log holds every frame and the report at the end lists every aircraft.
|
||||
.PP
|
||||
The registers are asked while the listening runs, so the registration, type,
|
||||
operator and route appear on the line as the answers arrive. A narrow terminal
|
||||
drops the columns a website supplied and keeps the ones only the aircraft can
|
||||
give.
|
||||
.B \-\-frames
|
||||
prints the raw stream instead, and output that is not a terminal gets a plain
|
||||
running count rather than a display that redraws four times a second.
|
||||
.PP
|
||||
.BI \-\-speed\-unit " knots|mph|kph"
|
||||
changes what speeds are shown in: the heading on the live display, the speed
|
||||
written beside every aircraft on the map, and the speeds in the report. The
|
||||
distances move with it \[em] nautical miles with knots, statute miles with miles
|
||||
an hour, kilometres with km/h \[em] so that one picture never carries two
|
||||
different miles. The log always holds knots, because that is what the aircraft
|
||||
broadcast: the recording stays the thing that arrived and the conversion
|
||||
happens at the moment of showing it to somebody.
|
||||
.SS 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
|
||||
A box keeps its place for as long as that place still works and is moved only
|
||||
when an aircraft or another box genuinely takes it, which is about a third as
|
||||
many moves as laying every box out afresh each frame. The moves that are left
|
||||
are eased over about half a second rather than jumped, since a box that
|
||||
teleports reads as a different box, and the window redraws at thirty frames a
|
||||
second for as long as anything is moving and drops back to five when it
|
||||
stops. That is affordable because the ground is dimmed once and kept: cutting
|
||||
the view out of the fetched map and looking every level up in the palette is
|
||||
most of a tenth of a second over two megapixels, and nothing about it changes
|
||||
between frames unless the view, the window, the brightness or the map itself
|
||||
has. What the next box is laid out against is the place a moving box is
|
||||
going to, not the place it has reached, as otherwise its neighbours would
|
||||
move as well and move back when it arrived; and a box in motion is drawn over
|
||||
the ones standing still, so that it stays readable while it crosses them.
|
||||
.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 ]
|
||||
its brightness,
|
||||
.B +
|
||||
and
|
||||
.B \-
|
||||
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
|
||||
Running
|
||||
.B bandsaunter
|
||||
with no arguments and choosing
|
||||
.B 5
|
||||
.RB ( "Aircraft (ADS-B)" )
|
||||
does all of this without a command line. Every option is listed on one screen
|
||||
with a line saying what it does;
|
||||
.BI ? N
|
||||
explains one at length, including the flag it corresponds to,
|
||||
.B p
|
||||
starts a passive capture,
|
||||
.B r
|
||||
opens the window,
|
||||
.B m
|
||||
draws a map from any log in the recordings directory, and
|
||||
.B s
|
||||
saves the options to
|
||||
.IR ~/.config/bandsaunter/aircraft.yaml .
|
||||
.SS Not a scan
|
||||
The band plan lists 1090 MHz because that is where ADS-B is, but sweeping it
|
||||
records the bursts as clicks in a WAV file and decodes nothing: the signalling
|
||||
is a megabit a second and the scan path is twelve and a half kilohertz wide.
|
||||
Both the scanner and the menus say so when a sweep is pointed at 1090 MHz or
|
||||
at the 978 MHz UAT band, rather than letting it run silently. Scanning it
|
||||
anyway is a fair thing to want if what you are after is the raw spectrum;
|
||||
.B \-\-save\-iq
|
||||
keeps the samples.
|
||||
.SS Who the aircraft is
|
||||
The frames say an address, not a registration. Two registers are asked \[em]
|
||||
adsbdb for the airframe and the route, then hexdb \[em] and the answers are
|
||||
|
|
@ -835,54 +689,6 @@ address block says which country registered the aircraft, fixed by treaty, and
|
|||
the first three letters of an airline callsign are its ICAO designator.
|
||||
.B \-\-no\-lookup
|
||||
stops at that.
|
||||
.PP
|
||||
A callsign is a flight number rather than a leg. An airline runs the same
|
||||
number over several legs in a day and a register holds one route for it, so an
|
||||
aircraft crossing Arizona is quite often handed a half-hour hop between two
|
||||
airports in Texas; the two registers routinely disagree about the same flight
|
||||
number, and both are snapshots years old. Nothing on the air settles it, as
|
||||
ADS-B carries no origin or destination: an aircraft broadcasts who and where
|
||||
it is, not where it is going.
|
||||
.PP
|
||||
So a route the aircraft cannot be flying is left off the map and out of the
|
||||
window \[em] the two ends are known, and an aircraft on a route is never much
|
||||
further along it than the route is long \[em] and written in the report with a
|
||||
note saying so, since it is what the register holds for that flight number and
|
||||
worth having. Where a source lists a whole day's stops rather than a leg, the
|
||||
aircraft's own position picks the leg out; where no leg fits, none is claimed.
|
||||
.SS Schedule services
|
||||
Knowing the leg for certain needs live schedule data, which none of the free
|
||||
sources carry. Four commercial services are wired up and all four are
|
||||
optional: FlightAware AeroAPI, Flightradar24, OAG and Cirium. Each holds the
|
||||
timetable and the day's movements, so each can say which leg of a flight
|
||||
number was in the air at the moment an aircraft was overhead. Where one
|
||||
answers, its leg is used; where none does, the free databases answer as they
|
||||
always did, and a program with no keys set behaves exactly as before.
|
||||
.PP
|
||||
Keys are read from the environment rather than the settings file, because a
|
||||
settings file is meant to be copied between machines and pasted into a message
|
||||
asking for help, and an API key is not.
|
||||
.PP
|
||||
.nf
|
||||
BANDSAUNTER_AEROAPI_KEY FlightAware AeroAPI
|
||||
BANDSAUNTER_FR24_TOKEN Flightradar24
|
||||
BANDSAUNTER_OAG_KEY OAG Flight Info
|
||||
BANDSAUNTER_CIRIUM_APP_ID Cirium (FlightStats), with
|
||||
BANDSAUNTER_CIRIUM_APP_KEY
|
||||
.fi
|
||||
.PP
|
||||
.BI \-\-schedules " NAMES"
|
||||
picks which to ask and in what order, comma separated, from
|
||||
.BR flightaware ", " flightradar24 ", " oag " and " cirium ;
|
||||
the default asks every one that has its key. A service with no key is skipped
|
||||
rather than asked and refused. The callsign and the moment are all that is
|
||||
sent.
|
||||
.PP
|
||||
Each reader was written from its service's published response shape and
|
||||
tested against that shape; none has been run against a live service, since
|
||||
each wants a paid account. So each is written to find what it recognises and
|
||||
return nothing otherwise: a service that has changed since costs a route
|
||||
rather than a scan, and the free databases pick the question back up.
|
||||
.SS The moving map
|
||||
.B bandsaunter flights
|
||||
reads a log back \[em] the newest one in the output directory unless told
|
||||
|
|
@ -911,241 +717,6 @@ where ffmpeg is installed, or a
|
|||
for the whole evening in one picture. Altitude is the colour, low warm to high
|
||||
cold. The GIF is written from first principles \[em] a palette, an LZW stream
|
||||
and frame differencing \[em] so nothing but numpy is needed to draw one.
|
||||
.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
|
||||
The picture is framed on the receiver rather than on whatever was heard. An
|
||||
aerial reaches a hundred miles on a good day and a position that decoded
|
||||
wrongly can land anywhere on Earth, so a map drawn to fit everything heard is
|
||||
drawn to fit the mistakes: the aircraft come out a pixel wide in the middle of
|
||||
an empty continent.
|
||||
.PP
|
||||
.BI \-\-radius " MILES"
|
||||
is how far the map reaches, in the same unit as the speeds, and defaults to a
|
||||
hundred; zero goes back to fitting whatever turned up. The centre is the
|
||||
median of everything heard \[em] a receiver hears aircraft all round it, and a
|
||||
median cannot be dragged anywhere by a handful of bad positions \[em] or
|
||||
.BI \-\-at " LAT,LON"
|
||||
says where the receiver is, which is worth doing to keep the same frame every
|
||||
night. Positions outside the radius are left off the drawing, one at a time
|
||||
rather than one aircraft at a time, so a single bad fix in the middle of a
|
||||
real flight does not take the flight with it. Nothing is dropped from the log.
|
||||
.SS Positions that never happened
|
||||
A position is sent as half a position \[em] an even frame and an odd one \[em] and
|
||||
the pair only means anything while the aircraft has not moved between them.
|
||||
Logs written before this version paired them however old they were, so an even
|
||||
frame kept from ten minutes ago decoded against a fresh odd one to a place on
|
||||
the wrong side of the world and wrote it down as confidently as a real one.
|
||||
.PP
|
||||
.B \-\-recheck
|
||||
reads such a log back and keeps, for each aircraft, the longest run of
|
||||
positions that could describe one aeroplane. It is deliberately not a forward
|
||||
walk dropping whatever disagrees with the last position kept: one bad fix then
|
||||
becomes the reference and it is the truth that gets discarded. Nothing in the
|
||||
log is changed.
|
||||
.PP
|
||||
An aircraft that goes quiet for five minutes and is heard again a long way off
|
||||
is an aeroplane rather than an error, and is not second-guessed; the radius is
|
||||
what keeps those off the picture. New logs need none of this, as the decoder
|
||||
now refuses a stale pair, a position that is not on Earth, and one the
|
||||
aircraft could not have reached, as the frames arrive.
|
||||
.SS The ground under it
|
||||
A real map is drawn under the aircraft: standard {z}/{x}/{y} raster tiles,
|
||||
OpenStreetMap by default, fetched the first time an area is drawn and
|
||||
reprojected from Web Mercator onto the picture, inverted and dimmed so the
|
||||
aircraft stay the brightest thing on it. The tiles are decoded here, from
|
||||
zlib and the five row filters the PNG specification defines, with no imaging
|
||||
library.
|
||||
.PP
|
||||
Tiles are cached in
|
||||
.I ~/.cache/bandsaunter/tiles
|
||||
and never fetched twice, every request identifies this program in its
|
||||
User-Agent, and the attribution the tiles require is written onto the picture
|
||||
\[em] a GIF travels without the readme that would otherwise carry it.
|
||||
.PP
|
||||
The zoom is chosen from how wide the picture is, not from the area alone, so a
|
||||
map asked for at 1920 pixels fetches finer tiles than the same map asked for
|
||||
at 960. Half again over the width is fetched deliberately and averaged down,
|
||||
since a downscaled tile is sharp and an upscaled one is not.
|
||||
.PP
|
||||
The window fetches a little more world than it shows so that panning does not
|
||||
leave the ground blank, and fetches that bigger piece at the bigger piece's
|
||||
own size, so what is shown comes out pixel for pixel with the screen. At 1920
|
||||
by 1080 and a hundred-mile radius the tiles hold about 1.6 times the pixels
|
||||
the window wants. A drawing is capped at a couple of hundred tiles, which at
|
||||
3840 by 2160 is reached: there the zoom has stopped climbing and the map is
|
||||
enlarged after all, and a smaller radius buys the detail back.
|
||||
.PP
|
||||
.B \-\-no\-basemap
|
||||
draws the tracks on their own,
|
||||
.BI \-\-tiles " URL"
|
||||
points at another server, and where there is no network and nothing cached
|
||||
the picture falls back to the plain grid.
|
||||
.PP
|
||||
An aircraft that goes quiet fades rather than vanishing: taking it off the
|
||||
picture between one frame and the next says it stopped existing, and fading it
|
||||
says it stopped talking, which is what happened. It fades where it was last
|
||||
actually seen and never along a reckoned track, since the reason for giving up
|
||||
on it is that where it would be by now is a guess.
|
||||
.BI \-\-fade " SECONDS"
|
||||
is how long that takes, and zero takes it away at once.
|
||||
.PP
|
||||
The map is averaged down to the size of the picture rather than point-sampled,
|
||||
so lettering and roads stay whole, and the window fetches enough pixels to
|
||||
cover its margin at full detail rather than enlarging what it has.
|
||||
.BI \-\-map\-brightness " PERCENT"
|
||||
is how far up its range the map is drawn: dark enough that the aircraft stay
|
||||
the brightest thing on the picture, light enough that a coastline can be made
|
||||
out, and which way to err depends on the screen.
|
||||
.PP
|
||||
Every aerodrome under the picture is marked, not only the ones being flown
|
||||
between \[em] a receiver hears aircraft over its own county, and the county's
|
||||
airports say where on the map you are looking. They come from the same map
|
||||
data the tiles are drawn from, asked once per area and kept for a month.
|
||||
.B \-\-no\-airports
|
||||
turns that off. They are drawn magenta, which nothing else on the picture is:
|
||||
the old amber sat sixteen units of CIELAB from the altitude ramp's yellow,
|
||||
which is to say it was the same colour, and an aeroplane low over a field was
|
||||
drawn in the field's own colour. The ramp already spends red, amber, green,
|
||||
cyan and violet on height; magenta is what it leaves free, and is what an
|
||||
aeronautical chart marks an aerodrome in anyway.
|
||||
.SS What is beside each aircraft
|
||||
Its height in feet with the unit on it, rounded to the twenty-five feet Mode S
|
||||
reports altitude in, so that a moment interpolated between two reports stops
|
||||
claiming to know the height to the foot; its speed; what sort of aircraft it
|
||||
is; its type and registration; and the two ends of the route, each with the
|
||||
flag of the country its airport is in. The country of registration gets a flag
|
||||
too, from the register where one answered and otherwise from the address
|
||||
block, so it is there for an aircraft no register has heard of.
|
||||
.PP
|
||||
What sort of aircraft it is comes from two places. Every identification
|
||||
message carries three bits under its type code saying what is transmitting
|
||||
\[em] light, small, large, high vortex, heavy, high performance, rotorcraft,
|
||||
and under another type code glider, airship, parachutist, ultralight, drone,
|
||||
spacecraft, or a vehicle on the ground \[em] and that is the only word about
|
||||
what an aircraft is that needs no register. Which list the three bits index
|
||||
depends on the type code, and zero means the aircraft declined to say, which
|
||||
is answered with nothing rather than a guess.
|
||||
.PP
|
||||
Whether it is military comes off no air at all, as no aircraft broadcasts it
|
||||
and a tanker calls itself heavy exactly as an airliner does. It is read from
|
||||
the address instead, since states set aside blocks of their national range for
|
||||
their armed forces. A state can fly a military aircraft on a civil address
|
||||
whenever it likes, so this finds nobody who does not want to be found, and the
|
||||
table holds the allocations a receiver in the ordinary world hears rather than
|
||||
every one that exists.
|
||||
.PP
|
||||
A label keeps its place for as long as that place still works and is moved
|
||||
only when something takes it, which is about half as many moves as deciding
|
||||
afresh every frame; the moves that are left are eased over about half a second
|
||||
of playback rather than jumped, and what the next label is laid out against is
|
||||
where a moving one is going rather than where it has reached. A still picture
|
||||
has no frame before it and places its labels exactly as it always did. The
|
||||
whole box fades with its aircraft: an indexed picture cannot blend, so the row
|
||||
grey and all twelve flag colours have dimmed copies at each fade step.
|
||||
.SS What is on the picture besides the aircraft
|
||||
The line from an information box to the aircraft it belongs to is dashed and
|
||||
is its own colour, in the window and in the animated pictures alike. Drawn in
|
||||
the aircraft's own colour it came out the same colour as that aircraft's
|
||||
trail, and a straight solid line running out of an aeroplane in the colour of
|
||||
the path behind the aeroplane reads as more path, which on a busy picture is a
|
||||
heading nobody flew. The animation had no such line at all until now, so a
|
||||
label pushed out into one of the outward rings by a crowd had nothing tying it
|
||||
to the aeroplane it was about.
|
||||
.PP
|
||||
A red flag stands where the receiver is, on the window and on the animated
|
||||
pictures alike, taken from the coordinates in the settings. The foot of the
|
||||
pole is the position and the pennant flies up and to the right of it, so that
|
||||
nothing the flag is made of covers the place it points at. It is pure red in
|
||||
every theme, that being the one mark on the picture whose meaning must not
|
||||
change with the colours as well as the red furthest from every altitude
|
||||
colour. It is drawn only where the receiver was actually told where it is: a
|
||||
middle worked out from whatever flew past is not a place anybody is standing.
|
||||
.SS The options menu
|
||||
The aircraft options are in six groups \[em] receiver, listening, aircraft,
|
||||
animation, the map, and labels \[em] rather than in one list, thirty-three of
|
||||
them on a screen being a wall rather than a menu. A number opens a group;
|
||||
inside it a number changes an option and
|
||||
.BI ? N
|
||||
explains one at length. The numbers are the option's place in the whole list,
|
||||
so the same number means the same option wherever it is typed. Typing a name
|
||||
instead goes straight to that option, and part of a name lists everything it
|
||||
could mean; a name that matches exactly wins outright, so "speed" reaches the
|
||||
setting called speed rather than that one and every other whose description
|
||||
mentions the word.
|
||||
.SS The card behind a box
|
||||
.BI \-\-box\-opacity " PERCENT"
|
||||
is how solid the card behind each information box is. The words beside an
|
||||
aircraft are readable over water and not over a city, so a card goes behind
|
||||
them: at nothing they sit straight on the map and at the whole way the map
|
||||
does not show through at all. An indexed picture cannot blend, so in the
|
||||
animation this darkens the ground under the box instead, which leaves the
|
||||
coastline faintly visible through it. The animated pictures had no card at all
|
||||
before, so nothing is what they used to look like.
|
||||
.PP
|
||||
The flag marking the receiver is drawn after everything else on both pictures
|
||||
\[em] after the aircraft, their trails and their boxes \[em] since it says
|
||||
where the receiver is standing and that is the one mark that must not end up
|
||||
behind an aeroplane that happened to fly over it. A vector theme's halo cannot
|
||||
cover it either, a halo only ever going on the ground, the grid and the
|
||||
background.
|
||||
.SH AIRCRAFT OPTIONS
|
||||
Every option the ADS-B side takes, in the six groups the menu shows them in.
|
||||
Each is a flag here and a line in the menu, and both come from one table in
|
||||
the program, so they cannot disagree.
|
||||
.AIRCRAFT_OPTIONS_HERE
|
||||
.SS Range rings
|
||||
.B \-\-rings
|
||||
puts faint discs at a quarter, a half and three quarters of the radius,
|
||||
concentric on the receiver and each labelled with its distance. They are
|
||||
translucent and they stack, so the ground inside the innermost is lifted three
|
||||
times, the next twice and the outer once; what that gives is a sense of how
|
||||
far away a thing is without measuring anything, an aircraft two shades in
|
||||
being about halfway to the edge of what this receiver hears. An indexed
|
||||
picture cannot blend, so translucent there means moving the ground under the
|
||||
disc a step or two up its own ramp of shades, which keeps the coastline and
|
||||
the roads visible through it.
|
||||
.PP
|
||||
They need a receiver position and a radius and are not drawn without both.
|
||||
.B \-\-window\-rings
|
||||
is the same thing on the realtime window, kept as a separate setting because
|
||||
a picture is studied and a window is glanced at.
|
||||
.SS Themes
|
||||
.BI \-\-theme " NAME"
|
||||
changes the window and the animated pictures together, since both read their
|
||||
colours out of the same palette.
|
||||
.B night
|
||||
is the default: a night-blue ground with height as colour, low warm to high
|
||||
cold, which is what every other aircraft map does and is the easiest to read.
|
||||
.BR digital ", " phosphor ", " amber " and " red
|
||||
are the screens the phrase "air defence display" calls to mind \[em] a black
|
||||
tube, one phosphor, and thin bright vector lines with a halo round them.
|
||||
.PP
|
||||
Three things follow from having one colour to spend, and they are constraints
|
||||
rather than decoration. Height becomes brightness, since hue is no longer
|
||||
free: low is dim and high burns. The map underneath is drawn at about
|
||||
two-fifths of the brightness asked for, because a tinted photograph of a
|
||||
county behind the vectors is the one thing that stops a vector display looking
|
||||
like one. And a country is named in two letters rather than drawn as a flag,
|
||||
a flag being half a dozen colours.
|
||||
.PP
|
||||
A vector display draws by holding a beam on the phosphor, which spreads the
|
||||
light a little and keeps glowing after the beam has gone, so a line on one of
|
||||
those screens is a bright core inside a halo. The window does that by laying
|
||||
the same line down two or three times, wider and fainter each pass, and the
|
||||
core last. The animation cannot blend at all, a GIF being indexed colour, so
|
||||
it dilates what it has drawn and fills the halo with the dimmed copy of the
|
||||
colour underneath: an aeroplane glows into the colour its own trail is drawn
|
||||
in, which is the colour a phosphor would have spread into. The halo goes over
|
||||
the map, the grid and the background and over nothing else that was drawn.
|
||||
.SH METERS AND SENSORS
|
||||
Two things on the ISM bands are worth naming rather than reporting as
|
||||
hexadecimal.
|
||||
|
|
@ -1220,17 +791,9 @@ frequency to dial into a radio.
|
|||
.I ~/.config/bandsaunter/config.yaml
|
||||
The settings every run starts from.
|
||||
.TP
|
||||
.I ~/.config/bandsaunter/aircraft.yaml
|
||||
The aircraft options, as saved from the menus.
|
||||
.TP
|
||||
.I ~/.config/bandsaunter/*.yaml
|
||||
Named profiles.
|
||||
.TP
|
||||
.IR adsb_ * .jsonl
|
||||
Every ADS-B frame heard in one listening session, with a
|
||||
.I .txt
|
||||
report and any picture drawn from it beside it.
|
||||
.TP
|
||||
.I ~/bandsaunter/
|
||||
Where recordings, transcripts and logs are written, unless
|
||||
.B \-\-output
|
||||
|
|
@ -1340,8 +903,6 @@ def main() -> int:
|
|||
out += settings_section()
|
||||
out.append(TAIL)
|
||||
text = "\n".join(out)
|
||||
text = text.replace(".AIRCRAFT_OPTIONS_HERE",
|
||||
"\n".join(aircraft_section()))
|
||||
text = text.replace("\n\n", "\n") # troff dislikes blank lines
|
||||
target = Path(sys.argv[1] if len(sys.argv) > 1
|
||||
else Path(__file__).parent / "bandsaunter.1")
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
.\" Generated by packaging/make-browse-man.py -- do not edit by hand.
|
||||
.TH SAUNTERBROWSE 1 "2026-09-04" "bandsaunter 2026-09-04_08" "User Commands"
|
||||
.TH SAUNTERBROWSE 1 "2026-09-03" "bandsaunter 2026-09-03_05" "User Commands"
|
||||
.SH NAME
|
||||
saunterbrowse \- read and listen to what a bandsaunter scan collected
|
||||
.SH SYNOPSIS
|
||||
|
|
|
|||
|
|
@ -22,9 +22,6 @@ 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"]
|
||||
# pyte is a terminal emulator, used by the resize tests to read back what
|
||||
# a real terminal would show. Those tests skip without it.
|
||||
|
|
|
|||
|
|
@ -45,43 +45,6 @@ def no_licence_lookups(monkeypatch):
|
|||
monkeypatch.setattr(bandsaunter.callsign.CallsignBook, "_request", refuse)
|
||||
|
||||
|
||||
class NoNetwork(BaseException):
|
||||
"""Raised when a test reaches for the network.
|
||||
|
||||
Deliberately not an ``Exception``: the code that fetches map tiles and
|
||||
looks aircraft up treats any ordinary failure as "no map today" and
|
||||
carries on, which would turn this guard into a silent pass.
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def no_aircraft_lookups(monkeypatch):
|
||||
"""Nor may a test ask a register who an aircraft is."""
|
||||
import bandsaunter.flights
|
||||
|
||||
def refuse(self, url):
|
||||
raise NoNetwork(f"a test tried to fetch {url} for real")
|
||||
|
||||
monkeypatch.setattr(bandsaunter.flights.FlightBook, "_request", refuse)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def no_map_tiles(monkeypatch):
|
||||
"""Nor fetch map tiles from somebody's tile server.
|
||||
|
||||
A test that wants a map builds its own tiles and passes them in; one
|
||||
that forgets fails here rather than drawing an evening's worth of
|
||||
requests at a volunteer-funded service.
|
||||
"""
|
||||
import bandsaunter.basemap
|
||||
|
||||
def refuse(request, timeout=None):
|
||||
where = getattr(request, "full_url", request)
|
||||
raise NoNetwork(f"a test tried to fetch {where} for real")
|
||||
|
||||
monkeypatch.setattr(bandsaunter.basemap.urllib.request, "urlopen", refuse)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_settings(tmp_path_factory, monkeypatch):
|
||||
where = tmp_path_factory.mktemp("config")
|
||||
|
|
|
|||
|
|
@ -1,322 +0,0 @@
|
|||
"""The live display: one line per aircraft, updated in place.
|
||||
|
||||
What matters here is not that it is pretty but that it says the right things
|
||||
and stops saying them at the right time -- an aircraft that has gone must
|
||||
leave the screen, and everything below it must move up.
|
||||
"""
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from rich.console import Console
|
||||
|
||||
from bandsaunter import aircraft as air
|
||||
from bandsaunter.adsb import Aircraft, AircraftRegistry
|
||||
from bandsaunter.ui import AircraftDisplay, age_style, altitude_style, compass
|
||||
|
||||
|
||||
NOW = 1_000_000.0
|
||||
|
||||
|
||||
def craft(icao="4CA1FA", callsign="RYR1234", seen=NOW, first=None, **over):
|
||||
one = Aircraft(icao=icao, callsign=callsign,
|
||||
first_seen=first if first is not None else seen,
|
||||
last_seen=seen)
|
||||
one.altitude_ft = over.pop("altitude_ft", 35_000)
|
||||
one.ground_speed_kt = over.pop("ground_speed_kt", 420.0)
|
||||
one.track_deg = over.pop("track_deg", 90.0)
|
||||
one.latitude = over.pop("latitude", 51.5)
|
||||
one.longitude = over.pop("longitude", -0.12)
|
||||
one.messages = over.pop("messages", 7)
|
||||
for key, value in over.items():
|
||||
setattr(one, key, value)
|
||||
return one
|
||||
|
||||
|
||||
def registry(*aircraft) -> AircraftRegistry:
|
||||
reg = AircraftRegistry()
|
||||
for one in aircraft:
|
||||
reg.aircraft[one.icao] = one
|
||||
return reg
|
||||
|
||||
|
||||
def shown(display, now=NOW, width=140) -> str:
|
||||
console = Console(width=width, record=True, force_terminal=True)
|
||||
console.print(display.render(now=now, width=width))
|
||||
return console.export_text()
|
||||
|
||||
|
||||
def display_for(*aircraft, frames=None, hold=45.0, book=None, width=140):
|
||||
reg = registry(*aircraft)
|
||||
console = Console(width=width, force_terminal=True)
|
||||
d = AircraftDisplay(console, book=book, hold=hold)
|
||||
d.started = NOW - 10
|
||||
d.update(reg, frames if frames is not None else
|
||||
sum(a.messages for a in aircraft))
|
||||
return d
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# What is on the line
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_an_aircraft_appears_with_everything_it_has_said():
|
||||
d = display_for(craft())
|
||||
text = shown(d)
|
||||
assert "RYR1234" in text # who
|
||||
assert "4CA1FA" in text # its address
|
||||
assert "35,000" in text # how high
|
||||
assert "420" in text # how fast
|
||||
assert "090°" in text and "E" in text # which way
|
||||
assert "51.5000" in text and "-0.1200" in text
|
||||
assert "7" in text # how many frames
|
||||
|
||||
|
||||
def test_the_counter_is_the_frame_count_and_it_climbs():
|
||||
one = craft(messages=3)
|
||||
d = display_for(one)
|
||||
assert " 3 " in shown(d).replace(" ", " ")
|
||||
one.messages = 41
|
||||
d.update(d.registry, 41)
|
||||
assert "41" in shown(d)
|
||||
|
||||
|
||||
def test_a_climb_and_a_descent_are_marked():
|
||||
up = shown(display_for(craft(vertical_rate_fpm=1600)))
|
||||
down = shown(display_for(craft(icao="A0B1C2", vertical_rate_fpm=-1600)))
|
||||
assert "↑" in up and "↓" not in up
|
||||
assert "↓" in down and "↑" not in down
|
||||
|
||||
|
||||
def test_an_aircraft_that_has_not_said_its_name_still_gets_a_line():
|
||||
text = shown(display_for(craft(callsign="")))
|
||||
assert "4CA1FA" in text
|
||||
assert "—" in text # the callsign it never gave
|
||||
|
||||
|
||||
def test_an_aircraft_with_no_position_yet_says_so_rather_than_nothing():
|
||||
one = craft(latitude=0.0, longitude=0.0)
|
||||
assert "no fix yet" in shown(display_for(one))
|
||||
|
||||
|
||||
def test_what_a_register_says_is_shown_beside_it():
|
||||
class _Book:
|
||||
def get(self, icao, callsign=""):
|
||||
from bandsaunter.flights import Flight
|
||||
return Flight(icao=icao, callsign=callsign, type_code="B738",
|
||||
registration="EI-DYP", operator="Ryanair",
|
||||
origin="Stansted", destination="East Midlands")
|
||||
|
||||
text = shown(display_for(craft(), book=_Book()))
|
||||
assert "B738" in text and "EI-DYP" in text
|
||||
assert "Ryanair" in text
|
||||
assert "Stansted" in text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Coming and going
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_a_second_aircraft_is_added_under_the_first():
|
||||
first = craft(icao="4CA1FA", callsign="RYR1234", first=NOW - 100)
|
||||
second = craft(icao="A0B1C2", callsign="UAL99", first=NOW - 50)
|
||||
text = shown(display_for(first, second))
|
||||
assert text.index("RYR1234") < text.index("UAL99")
|
||||
|
||||
|
||||
def test_an_aircraft_nothing_has_been_heard_from_is_dropped():
|
||||
"""It has gone out of range; its line would otherwise sit there all
|
||||
evening saying the same thing."""
|
||||
here = craft(icao="4CA1FA", callsign="RYR1234", seen=NOW - 2)
|
||||
gone = craft(icao="A0B1C2", callsign="UAL99", seen=NOW - 120)
|
||||
text = shown(display_for(here, gone, hold=45.0))
|
||||
assert "RYR1234" in text
|
||||
assert "UAL99" not in text
|
||||
|
||||
|
||||
def test_the_ones_below_move_up_when_one_goes():
|
||||
top = craft(icao="111111", callsign="FIRST", first=NOW - 300, seen=NOW - 300)
|
||||
middle = craft(icao="222222", callsign="SECOND", first=NOW - 200, seen=NOW)
|
||||
bottom = craft(icao="333333", callsign="THIRD", first=NOW - 100, seen=NOW)
|
||||
d = display_for(top, middle, bottom, hold=45.0)
|
||||
lines = [x for x in shown(d).splitlines() if "SECOND" in x or "THIRD" in x
|
||||
or "FIRST" in x]
|
||||
assert len(lines) == 2 # FIRST has gone
|
||||
assert "SECOND" in lines[0] and "THIRD" in lines[1]
|
||||
|
||||
|
||||
def test_how_long_they_stay_can_be_changed():
|
||||
quiet = craft(seen=NOW - 30)
|
||||
assert "RYR1234" in shown(display_for(quiet, hold=45.0))
|
||||
assert "RYR1234" not in shown(display_for(quiet, hold=10.0))
|
||||
|
||||
|
||||
def test_the_age_of_the_last_frame_is_shown_and_coloured():
|
||||
text = shown(display_for(craft(seen=NOW - 12)))
|
||||
assert "12s" in text
|
||||
assert age_style(1.0) != age_style(12.0) != age_style(40.0)
|
||||
|
||||
|
||||
def test_nothing_heard_yet_says_so_rather_than_drawing_an_empty_table():
|
||||
d = AircraftDisplay(Console(width=120, force_terminal=True))
|
||||
d.update(AircraftRegistry(), 0)
|
||||
assert "nothing heard yet" in shown(d)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The heading
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_the_heading_counts_what_is_overhead_and_what_has_been_seen():
|
||||
here = craft(icao="4CA1FA", callsign="RYR1234", seen=NOW)
|
||||
gone = craft(icao="A0B1C2", callsign="UAL99", seen=NOW - 500)
|
||||
text = shown(display_for(here, gone, frames=99, hold=45.0))
|
||||
assert "1 overhead" in text
|
||||
assert "2 seen" in text # nothing is forgotten
|
||||
assert "99 frames" in text
|
||||
|
||||
|
||||
def test_the_heading_names_the_log_being_written(tmp_path):
|
||||
d = display_for(craft())
|
||||
d.update(d.registry, 5, tmp_path / "adsb_2026-09-03_20_00_00.jsonl")
|
||||
assert "adsb_2026-09-03_20_00_00.jsonl" in shown(d)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Colour, and a narrow terminal
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_height_is_a_colour_low_warm_to_high_cold():
|
||||
low, high = altitude_style(800), altitude_style(38_000)
|
||||
assert low != high
|
||||
assert "red" in low and "blue" in high
|
||||
|
||||
|
||||
def test_the_colours_reach_the_screen():
|
||||
console = Console(width=140, record=True, force_terminal=True,
|
||||
color_system="truecolor")
|
||||
console.print(display_for(craft(altitude_ft=1_200)).render(now=NOW))
|
||||
assert "\x1b[" in console.export_text(styles=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("degrees,point", [(0, "N"), (90, "E"), (180, "S"),
|
||||
(270, "W"), (45, "NE"),
|
||||
(359, "N"), (247, "WSW")])
|
||||
def test_a_heading_is_also_given_as_a_point_of_the_compass(degrees, point):
|
||||
assert compass(degrees) == point
|
||||
|
||||
|
||||
def test_a_narrow_terminal_keeps_what_only_the_aircraft_can_say():
|
||||
"""A website can be read later; the aeroplane cannot."""
|
||||
text = shown(display_for(craft()), width=80)
|
||||
assert "RYR1234" in text and "35,000" in text and "420" in text
|
||||
for line in text.splitlines():
|
||||
assert len(line) <= 80
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When there is no terminal to draw on
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_a_pipe_or_a_log_file_gets_no_live_display():
|
||||
"""Four redraws a second is unreadable as a stream and useless in a file."""
|
||||
console = Console(width=100, file=open("/dev/null", "w"),
|
||||
force_terminal=False)
|
||||
display, live = air._open_display(console, air.AircraftOptions(), None,
|
||||
time.time())
|
||||
assert display is None and live is None
|
||||
|
||||
|
||||
def test_asking_for_every_frame_turns_the_table_off():
|
||||
console = Console(width=100, force_terminal=True)
|
||||
options = air.AircraftOptions(frames=True)
|
||||
display, live = air._open_display(console, options, None, time.time())
|
||||
assert display is None and live is None
|
||||
|
||||
|
||||
def test_a_terminal_gets_one():
|
||||
console = Console(width=100, force_terminal=True,
|
||||
file=open("/dev/null", "w"))
|
||||
display, live = air._open_display(console, air.AircraftOptions(), None,
|
||||
time.time())
|
||||
try:
|
||||
assert display is not None and live is not None
|
||||
assert display.hold == air.AircraftOptions().hold
|
||||
finally:
|
||||
if live is not None:
|
||||
live.stop()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Speeds in whatever the user reads
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("unit,heading,shown_speed", [
|
||||
("knots", "speed kt", "420"),
|
||||
("mph", "speed mph", "483"),
|
||||
("kph", "speed km/h", "778"),
|
||||
])
|
||||
def test_the_heading_and_the_number_change_together(unit, heading, shown_speed):
|
||||
"""A number with the wrong unit over it is worse than no number."""
|
||||
d = display_for(craft(ground_speed_kt=420.0))
|
||||
d.unit = unit
|
||||
text = shown(d)
|
||||
assert heading in text
|
||||
assert shown_speed in text
|
||||
|
||||
|
||||
def test_knots_are_what_it_shows_unless_told_otherwise():
|
||||
assert AircraftDisplay(Console(width=100)).unit == "knots"
|
||||
assert air.AircraftOptions().speed_unit == "knots"
|
||||
|
||||
|
||||
def test_the_unit_reaches_the_display_from_the_options():
|
||||
console = Console(width=100, force_terminal=True, file=open("/dev/null", "w"))
|
||||
options = air.AircraftOptions(speed_unit="kph")
|
||||
display, live = air._open_display(console, options, None, time.time())
|
||||
try:
|
||||
assert display.unit == "kph"
|
||||
finally:
|
||||
if live is not None:
|
||||
live.stop()
|
||||
|
||||
|
||||
def test_the_emitter_category_is_kept_as_the_frames_arrive():
|
||||
"""It comes off the air in the identification message, alongside the
|
||||
callsign, and is the only word about what an aircraft *is* that does
|
||||
not need a register."""
|
||||
import binascii
|
||||
|
||||
from bandsaunter.adsb import _read, category_name
|
||||
|
||||
# A real identification frame: type code 4, category 3.
|
||||
raw = binascii.unhexlify("8DA80A97234CB5F5CF4C604EB016")
|
||||
frame = _read("".join(f"{b:08b}" for b in raw), raw)
|
||||
assert (frame.type_code, frame.category) == (4, 3)
|
||||
assert category_name(4, 3) == "large"
|
||||
|
||||
reg = AircraftRegistry()
|
||||
craft = reg.add(frame, when=1.0)
|
||||
assert craft.category == "large"
|
||||
|
||||
|
||||
def test_an_aircraft_that_declines_to_say_is_not_given_a_category():
|
||||
from bandsaunter.adsb import Frame, category_name
|
||||
|
||||
assert category_name(4, 0) == "" # zero means "not saying"
|
||||
assert category_name(9, 3) == "" # not an identification message
|
||||
reg = AircraftRegistry()
|
||||
craft = reg.add(Frame(icao="ABCDEF", type_code=4, category=0), when=1.0)
|
||||
assert craft.category == ""
|
||||
|
||||
|
||||
def test_the_same_three_bits_mean_different_things_under_different_codes():
|
||||
"""Which list they index depends on the type code, which is why it is a
|
||||
table of tables: category 1 is a light aeroplane under type 4 and a
|
||||
glider under type 3."""
|
||||
from bandsaunter.adsb import category_name
|
||||
|
||||
assert category_name(4, 1) == "light"
|
||||
assert category_name(3, 1) == "glider"
|
||||
assert category_name(2, 1) == "emergency vehicle"
|
||||
assert category_name(4, 7) == "rotorcraft"
|
||||
assert category_name(3, 7) == "spacecraft"
|
||||
|
|
@ -1,594 +0,0 @@
|
|||
"""The aircraft menu: listening and drawing without a command line.
|
||||
|
||||
Everything here runs against the invented sky, so nothing needs a receiver,
|
||||
an aerial or a network.
|
||||
"""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from rich.console import Console
|
||||
|
||||
from bandsaunter import aircraft as air, tui
|
||||
from bandsaunter.config import ScanConfig
|
||||
from bandsaunter.ranges import parse_range_list
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def console():
|
||||
return Console(width=100, file=open("/dev/null", "w"),
|
||||
force_terminal=False)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def settings_dir(tmp_path, monkeypatch):
|
||||
"""Options are saved beside the settings, which must not be the real ones."""
|
||||
monkeypatch.setenv("BANDSAUNTER_CONFIG_DIR", str(tmp_path / "cfg"))
|
||||
monkeypatch.setattr("bandsaunter.config.DEFAULT_CONFIG_DIR",
|
||||
tmp_path / "cfg")
|
||||
return tmp_path / "cfg"
|
||||
|
||||
|
||||
class _Done(Exception):
|
||||
"""Raised when the script runs out, to break out of a menu loop."""
|
||||
|
||||
|
||||
def drive(monkeypatch, answers):
|
||||
script = list(answers)
|
||||
|
||||
def fake_ask(console, prompt, default=""):
|
||||
if not script:
|
||||
raise _Done()
|
||||
return script.pop(0)
|
||||
|
||||
monkeypatch.setattr(tui, "_ask", fake_ask)
|
||||
monkeypatch.setattr(tui.Confirm, "ask", lambda *a, **k: True)
|
||||
return script
|
||||
|
||||
|
||||
def number(key: str) -> str:
|
||||
"""The menu number of one option, looked up rather than counted.
|
||||
|
||||
The numbering moves whenever an option is added, and a test that has
|
||||
memorised it silently edits the wrong one.
|
||||
"""
|
||||
return str(air.OPTIONS.index(air.by_key(key)) + 1)
|
||||
|
||||
|
||||
def reach(key: str) -> str:
|
||||
"""What to type at the top of the aircraft menu to edit one option.
|
||||
|
||||
Its name: the options live in groups now, so a bare number there opens
|
||||
a group. A name that matches one exactly goes straight to it, which is
|
||||
how somebody who knows what they are looking for gets at it without
|
||||
hunting through the groups first.
|
||||
"""
|
||||
return key
|
||||
|
||||
|
||||
def run(monkeypatch, console, answers, cfg):
|
||||
drive(monkeypatch, answers)
|
||||
try:
|
||||
tui.aircraft_menu(console, cfg)
|
||||
except _Done:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The band that is not a scan
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_the_band_plan_still_lists_where_ads_b_is():
|
||||
"""It is a real band and belongs in the plan; the warning is the fix,
|
||||
not removing it."""
|
||||
from bandsaunter.bandplan import PRESETS
|
||||
|
||||
assert any(p.key == "adsb" for p in PRESETS)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("spec", ["1089M-1091M", "1080M-1100M", "1.09G-1.1G"])
|
||||
def test_a_sweep_over_1090_says_it_cannot_decode_it(spec):
|
||||
warning = air.scanning_aircraft_band(parse_range_list(spec))
|
||||
assert "ADS-B" in warning and "cannot decode" in warning
|
||||
|
||||
|
||||
def test_the_uat_band_is_named_too():
|
||||
assert "UAT" in air.scanning_aircraft_band(parse_range_list("977M-979M"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("spec", ["144M-148M", "462M-468M", "88M-108M"])
|
||||
def test_an_ordinary_sweep_is_not_warned_about(spec):
|
||||
assert air.scanning_aircraft_band(parse_range_list(spec)) == ""
|
||||
|
||||
|
||||
def test_nothing_configured_warns_about_nothing():
|
||||
assert air.scanning_aircraft_band([]) == ""
|
||||
assert air.scanning_aircraft_band(None) == ""
|
||||
|
||||
|
||||
def test_the_menu_says_so_the_moment_the_band_is_chosen(console, capsys):
|
||||
"""The band plan is where this mistake is made, so that is where it is
|
||||
caught."""
|
||||
loud = Console(width=100)
|
||||
cfg = ScanConfig(ranges=parse_range_list("1089M-1091M"))
|
||||
tui.warn_about_aircraft_bands(loud, cfg)
|
||||
printed = capsys.readouterr().out
|
||||
assert "aircraft mode" in printed
|
||||
assert "Aircraft (ADS-B)" in printed
|
||||
|
||||
|
||||
def test_the_scan_command_says_so_before_it_starts(capsys):
|
||||
from bandsaunter import cli
|
||||
|
||||
cfg = ScanConfig(ranges=parse_range_list("1089M-1091M"))
|
||||
cli._warn_about_aircraft_bands(cfg)
|
||||
printed = capsys.readouterr().out
|
||||
assert "bandsaunter adsb" in printed
|
||||
assert "flights" in printed
|
||||
|
||||
|
||||
def test_a_scan_of_an_ordinary_band_says_nothing(capsys):
|
||||
from bandsaunter import cli
|
||||
|
||||
cli._warn_about_aircraft_bands(ScanConfig(ranges=parse_range_list("2m")))
|
||||
assert capsys.readouterr().out.strip() == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The options
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_every_option_has_help_and_a_home_on_the_screen():
|
||||
for option in air.OPTIONS:
|
||||
assert option.help, option.key
|
||||
assert option.detail, option.key
|
||||
assert option.group in air.OPTION_GROUPS, option.key
|
||||
assert hasattr(air.AircraftOptions(), option.key), option.key
|
||||
|
||||
|
||||
def test_the_options_are_the_ones_the_listening_and_drawing_take():
|
||||
"""Anything settable must be something the code actually reads."""
|
||||
named = {o.key for o in air.OPTIONS}
|
||||
assert named == set(air.AircraftOptions().__dict__)
|
||||
|
||||
|
||||
def test_a_zero_that_means_something_says_what_it_means():
|
||||
options = air.AircraftOptions()
|
||||
assert air.format_option(air.by_key("seconds"), 0.0) == "until stopped"
|
||||
assert air.format_option(air.by_key("trail"), 0.0) == "the whole path"
|
||||
assert air.format_option(air.by_key("seconds"), 60.0) == "60 s"
|
||||
assert "gif" in air.describe(options)
|
||||
|
||||
|
||||
def test_an_option_is_changed_from_the_menu(monkeypatch, console, settings_dir,
|
||||
tmp_path):
|
||||
cfg = ScanConfig(output_dir=str(tmp_path))
|
||||
run(monkeypatch, console, [number("width"), "640", "b"], cfg)
|
||||
assert air.load_options().width == 960 # not saved yet
|
||||
run(monkeypatch, console, [number("width"), "640", "s", "b"], cfg)
|
||||
assert air.load_options().width == 640 # saved on request
|
||||
|
||||
|
||||
def test_a_bad_value_is_refused_and_the_old_one_kept(monkeypatch, console,
|
||||
settings_dir, tmp_path):
|
||||
cfg = ScanConfig(output_dir=str(tmp_path))
|
||||
run(monkeypatch, console, [number("width"), "banana", "", "s", "b"], cfg)
|
||||
assert air.load_options().width == 960
|
||||
|
||||
|
||||
def test_a_value_the_options_refuse_is_rolled_back(monkeypatch, console,
|
||||
settings_dir, tmp_path):
|
||||
"""Two megasamples a second is the floor; below it a bit cannot be seen."""
|
||||
cfg = ScanConfig(output_dir=str(tmp_path))
|
||||
run(monkeypatch, console, [number("rate"), "500000", "", "s", "b"], cfg)
|
||||
assert air.load_options().rate >= 2_000_000
|
||||
|
||||
|
||||
def test_help_for_one_option_is_shown_without_changing_it(monkeypatch,
|
||||
settings_dir,
|
||||
tmp_path, capsys):
|
||||
loud = Console(width=100)
|
||||
cfg = ScanConfig(output_dir=str(tmp_path))
|
||||
run(monkeypatch, loud, ["?" + number("simulate"), "b"], cfg)
|
||||
printed = capsys.readouterr().out
|
||||
assert "Invent a sky" in printed
|
||||
assert "command line:" in printed and "--simulate" in printed
|
||||
assert air.load_options().simulate is False
|
||||
|
||||
|
||||
def test_the_options_survive_being_saved_and_read_back(settings_dir):
|
||||
options = air.AircraftOptions(seconds=90.0, picture="png", simulate=True,
|
||||
width=640, labels=False)
|
||||
air.save_options(options)
|
||||
again = air.load_options()
|
||||
assert (again.seconds, again.picture, again.simulate) == (90.0, "png", True)
|
||||
assert again.width == 640 and again.labels is False
|
||||
|
||||
|
||||
def test_a_broken_options_file_falls_back_to_the_defaults(settings_dir):
|
||||
air.options_path().parent.mkdir(parents=True, exist_ok=True)
|
||||
air.options_path().write_text("{{{ not yaml at all")
|
||||
assert air.load_options().picture == "png" or True # must not raise
|
||||
assert air.load_options().width == 960
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Listening and drawing, from the menu alone
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _listen_and_draw(monkeypatch, console, tmp_path, picture="png"):
|
||||
cfg = ScanConfig(output_dir=str(tmp_path))
|
||||
run(monkeypatch, console,
|
||||
[reach("simulate"), "yes", # invent a sky
|
||||
reach("seconds"), "3", # listen for three seconds
|
||||
reach("picture"), picture, # what to draw
|
||||
reach("lookup"), "no", # no lookups: no network in a test
|
||||
reach("basemap"), "no", # nor a tile server
|
||||
reach("airports"), "no", # nor the map data
|
||||
"l", # listen now
|
||||
"m", "1", # draw the newest log
|
||||
"b"], cfg)
|
||||
return cfg
|
||||
|
||||
|
||||
def test_listening_from_the_menu_writes_a_log_and_a_report(monkeypatch,
|
||||
console,
|
||||
settings_dir,
|
||||
tmp_path):
|
||||
_listen_and_draw(monkeypatch, console, tmp_path)
|
||||
logs = list(tmp_path.glob("adsb_*.jsonl"))
|
||||
assert len(logs) == 1
|
||||
lines = [json.loads(x) for x in logs[0].read_text().splitlines() if x]
|
||||
assert lines[0]["log"] == "bandsaunter-adsb"
|
||||
assert any(line.get("icao") for line in lines[1:])
|
||||
told = logs[0].with_suffix(".txt")
|
||||
assert told.is_file() and "aircraft" in told.read_text()
|
||||
|
||||
|
||||
def test_drawing_from_the_menu_writes_the_picture(monkeypatch, console,
|
||||
settings_dir, tmp_path):
|
||||
from bandsaunter.images import PNG_SIGNATURE
|
||||
|
||||
_listen_and_draw(monkeypatch, console, tmp_path)
|
||||
pictures = list(tmp_path.glob("adsb_*.png"))
|
||||
assert len(pictures) == 1
|
||||
assert pictures[0].read_bytes()[:8] == PNG_SIGNATURE
|
||||
|
||||
|
||||
def test_the_animation_can_be_chosen_instead(monkeypatch, console,
|
||||
settings_dir, tmp_path):
|
||||
_listen_and_draw(monkeypatch, console, tmp_path, picture="gif")
|
||||
made = list(tmp_path.glob("adsb_*.gif"))
|
||||
assert len(made) == 1
|
||||
assert made[0].read_bytes()[:6] == b"GIF89a"
|
||||
|
||||
|
||||
def test_drawing_when_there_is_nothing_to_draw_says_so(monkeypatch,
|
||||
settings_dir,
|
||||
tmp_path, capsys):
|
||||
loud = Console(width=100)
|
||||
cfg = ScanConfig(output_dir=str(tmp_path))
|
||||
run(monkeypatch, loud, ["m", "b"], cfg)
|
||||
assert "no logs" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_listening_can_draw_as_soon_as_it_stops(monkeypatch, console,
|
||||
settings_dir, tmp_path):
|
||||
"""One key, from nothing to a picture."""
|
||||
cfg = ScanConfig(output_dir=str(tmp_path))
|
||||
run(monkeypatch, console,
|
||||
[reach("simulate"), "yes", reach("seconds"), "3",
|
||||
reach("lookup"), "no", reach("basemap"), "no",
|
||||
reach("airports"), "no", reach("draw_after"), "yes",
|
||||
reach("picture"), "png", "l", "b"], cfg)
|
||||
assert list(tmp_path.glob("adsb_*.png"))
|
||||
|
||||
|
||||
def test_a_receiver_that_cannot_be_opened_returns_to_the_menu(monkeypatch,
|
||||
settings_dir,
|
||||
tmp_path,
|
||||
capsys):
|
||||
"""No dongle, or one in use by something else: say so and come back."""
|
||||
from bandsaunter.device import RtlSdrError
|
||||
|
||||
def refuse(*a, **k):
|
||||
raise RtlSdrError("no device found")
|
||||
|
||||
monkeypatch.setattr("bandsaunter.device.RtlSdrDevice", refuse)
|
||||
loud = Console(width=100)
|
||||
cfg = ScanConfig(output_dir=str(tmp_path))
|
||||
run(monkeypatch, loud, ["l", "b"], cfg) # simulate is off
|
||||
assert "cannot open the receiver" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_the_logs_are_listed_newest_first(tmp_path):
|
||||
import os
|
||||
import time
|
||||
|
||||
for i, name in enumerate(("adsb_a.jsonl", "adsb_b.jsonl", "adsb_c.jsonl")):
|
||||
path = tmp_path / name
|
||||
path.write_text("{}\n")
|
||||
os.utime(path, (time.time() + i, time.time() + i))
|
||||
assert [p.name for p in air.logs_in(tmp_path)] == \
|
||||
["adsb_c.jsonl", "adsb_b.jsonl", "adsb_a.jsonl"]
|
||||
|
||||
|
||||
def test_the_main_menu_offers_it(monkeypatch, console):
|
||||
"""It has to be reachable, or none of the above matters."""
|
||||
from bandsaunter import tui as menus
|
||||
|
||||
seen = {}
|
||||
monkeypatch.setattr(menus, "aircraft_menu",
|
||||
lambda console, cfg: seen.setdefault("opened", True))
|
||||
drive(monkeypatch, ["5", "q"])
|
||||
menus._main_loop(console, ScanConfig())
|
||||
assert seen.get("opened")
|
||||
|
||||
|
||||
def test_the_speed_unit_is_an_option_with_the_three_choices():
|
||||
option = air.by_key("speed_unit")
|
||||
assert option is not None
|
||||
assert set(option.choices) == {"knots", "mph", "kph"}
|
||||
assert "distance" in option.detail # it moves the miles too
|
||||
|
||||
|
||||
def test_the_speed_unit_is_changed_from_the_menu(monkeypatch, console,
|
||||
settings_dir, tmp_path):
|
||||
cfg = ScanConfig(output_dir=str(tmp_path))
|
||||
run(monkeypatch, console, [number("speed_unit"), "mph", "s", "b"], cfg)
|
||||
assert air.load_options().speed_unit == "mph"
|
||||
|
||||
|
||||
def test_a_unit_that_is_not_one_of_the_three_is_refused(monkeypatch, console,
|
||||
settings_dir, tmp_path):
|
||||
cfg = ScanConfig(output_dir=str(tmp_path))
|
||||
run(monkeypatch, console,
|
||||
[number("speed_unit"), "furlongs", "", "s", "b"], cfg)
|
||||
assert air.load_options().speed_unit == "knots"
|
||||
|
||||
|
||||
def test_the_map_underneath_is_an_option_that_can_be_turned_off(monkeypatch,
|
||||
console,
|
||||
settings_dir,
|
||||
tmp_path):
|
||||
"""It fetches from a tile server the first time an area is drawn, so it
|
||||
has to be possible to say no."""
|
||||
assert air.AircraftOptions().basemap is True
|
||||
cfg = ScanConfig(output_dir=str(tmp_path))
|
||||
run(monkeypatch, console, [number("basemap"), "no", "s", "b"], cfg)
|
||||
assert air.load_options().basemap is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# How far the map reaches
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_the_radius_defaults_to_a_hundred():
|
||||
"""An aerial hears about that far; a map drawn to fit everything heard
|
||||
is drawn to fit the mistakes."""
|
||||
assert air.AircraftOptions().radius == 100.0
|
||||
|
||||
|
||||
def test_the_radius_is_changed_from_the_menu(monkeypatch, console,
|
||||
settings_dir, tmp_path):
|
||||
cfg = ScanConfig(output_dir=str(tmp_path))
|
||||
run(monkeypatch, console, [number("radius"), "40", "s", "b"], cfg)
|
||||
assert air.load_options().radius == 40.0
|
||||
|
||||
|
||||
def test_the_radius_follows_the_unit_the_speeds_are_in():
|
||||
"""A picture measuring its speeds in one unit and its own extent in
|
||||
another would be a puzzle rather than a map."""
|
||||
assert air.radius_in_nm(air.AircraftOptions(radius=100)) == 100.0
|
||||
assert air.radius_in_nm(
|
||||
air.AircraftOptions(radius=100, speed_unit="mph")) == pytest.approx(
|
||||
86.9, abs=0.1)
|
||||
assert air.radius_in_nm(
|
||||
air.AircraftOptions(radius=100, speed_unit="kph")) == pytest.approx(
|
||||
54.0, abs=0.1)
|
||||
|
||||
|
||||
def test_no_radius_at_all_goes_back_to_fitting_what_was_heard():
|
||||
assert air.radius_in_nm(air.AircraftOptions(radius=0)) == 0.0
|
||||
|
||||
|
||||
def test_the_receiver_position_can_be_given_or_worked_out():
|
||||
from bandsaunter.flightlog import read_position
|
||||
|
||||
assert read_position("32.54,-111.17") == pytest.approx((32.54, -111.17))
|
||||
assert read_position("") is None
|
||||
assert read_position("somewhere near Tucson") is None
|
||||
assert read_position("240.0,-111.0") is None # not a place
|
||||
assert air.AircraftOptions().location == "" # worked out by default
|
||||
|
||||
|
||||
def test_rechecking_is_an_option_and_is_off_unless_asked(monkeypatch, console,
|
||||
settings_dir,
|
||||
tmp_path):
|
||||
"""Logs written by this version have the check applied as they are
|
||||
written, so it is a repair for older ones rather than a default."""
|
||||
assert air.AircraftOptions().recheck is False
|
||||
cfg = ScanConfig(output_dir=str(tmp_path))
|
||||
run(monkeypatch, console, [number("recheck"), "yes", "s", "b"], cfg)
|
||||
assert air.load_options().recheck is True
|
||||
|
||||
|
||||
def test_rechecking_says_what_it_threw_out(capsys):
|
||||
from bandsaunter.flightlog import Fix, Track
|
||||
|
||||
loud = Console(width=100)
|
||||
track = Track(icao="4CA1FA", fixes=[
|
||||
Fix(at=0.0, latitude=51.5, longitude=-0.12),
|
||||
Fix(at=0.5, latitude=-9.5, longitude=-112.5),
|
||||
Fix(at=1.0, latitude=51.5, longitude=-0.12)])
|
||||
air.checked(loud, air.AircraftOptions(recheck=True), [track])
|
||||
printed = capsys.readouterr().out
|
||||
assert "dropped" in printed and "could have been in" in printed
|
||||
|
||||
|
||||
def test_a_clean_log_is_told_so_rather_than_left_silent(capsys):
|
||||
loud = Console(width=100)
|
||||
air.checked(loud, air.AircraftOptions(recheck=True), [])
|
||||
assert "checks out" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_leaving_it_off_changes_nothing(capsys):
|
||||
from bandsaunter.flightlog import Fix, Track
|
||||
|
||||
loud = Console(width=100)
|
||||
track = Track(icao="4CA1FA", fixes=[
|
||||
Fix(at=0.0, latitude=51.5, longitude=-0.12),
|
||||
Fix(at=0.5, latitude=-9.5, longitude=-112.5)])
|
||||
same = air.checked(loud, air.AircraftOptions(recheck=False), [track])
|
||||
assert same[0] is track
|
||||
assert capsys.readouterr().out.strip() == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The options, in groups
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_no_group_is_long_enough_to_need_scrolling():
|
||||
"""Thirty-three options on one screen is a wall. The point of the
|
||||
groups is that each of them fits in front of you at once."""
|
||||
for group in air.OPTION_GROUPS:
|
||||
items = air.in_group(group)
|
||||
assert 1 <= len(items) <= 10, (group, len(items))
|
||||
|
||||
|
||||
def test_every_option_is_in_exactly_one_group():
|
||||
seen = [o for group in air.OPTION_GROUPS for o in air.in_group(group)]
|
||||
assert len(seen) == len(air.OPTIONS)
|
||||
assert {o.key for o in seen} == {o.key for o in air.OPTIONS}
|
||||
|
||||
|
||||
def test_each_group_sits_together_in_the_numbering():
|
||||
"""The number beside an option is its place in the whole list, so that
|
||||
the same number means the same option wherever it is typed. That only
|
||||
reads sensibly if a group's options are next to each other."""
|
||||
for group in air.OPTION_GROUPS:
|
||||
places = [air.OPTIONS.index(o) for o in air.in_group(group)]
|
||||
assert places == list(range(places[0], places[0] + len(places))), group
|
||||
|
||||
|
||||
def test_a_name_typed_in_full_goes_straight_to_that_option():
|
||||
"""Typing "seconds" should reach the setting called seconds, not that
|
||||
one and every other whose description mentions the word."""
|
||||
for key in ("seconds", "picture", "simulate", "speed", "width", "rings"):
|
||||
found = tui._find_options(key)
|
||||
assert [o.key for o in found] == [key], (key, [o.key for o in found])
|
||||
|
||||
|
||||
def test_a_part_of_a_name_finds_everything_it_could_mean():
|
||||
found = [o.key for o in tui._find_options("ring")]
|
||||
assert "rings" in found and "window_rings" in found
|
||||
|
||||
|
||||
def test_a_name_nobody_has_finds_nothing():
|
||||
assert tui._find_options("zzz") == []
|
||||
assert tui._find_options("") == []
|
||||
|
||||
|
||||
def test_opening_a_group_shows_its_options_and_nothing_else(monkeypatch,
|
||||
console, capsys,
|
||||
settings_dir):
|
||||
from rich.console import Console
|
||||
|
||||
loud = Console(width=100, force_terminal=False, no_color=True)
|
||||
where = air.OPTION_GROUPS.index("The map") + 1
|
||||
run(monkeypatch, loud, [str(where), "b", "b"], ScanConfig())
|
||||
printed = capsys.readouterr().out
|
||||
for option in air.in_group("The map"):
|
||||
assert option.label.split()[0] in printed, option.key
|
||||
# An option from another group is not on that screen.
|
||||
after = printed.split("the map", 2)[-1]
|
||||
assert "Tuner gain" not in after and "Listen for" not in after
|
||||
|
||||
|
||||
def test_typing_an_option_name_at_the_top_opens_that_option(monkeypatch,
|
||||
console,
|
||||
settings_dir):
|
||||
"""The way in for somebody who knows what they are looking for and does
|
||||
not want to hunt through the groups for it."""
|
||||
held = air.AircraftOptions()
|
||||
monkeypatch.setattr(air, "load_options", lambda *a, **kw: held)
|
||||
run(monkeypatch, console, ["map brightness", "45", "b"], ScanConfig())
|
||||
assert held.map_brightness == 45
|
||||
|
||||
|
||||
def test_a_group_number_at_the_top_does_not_edit_the_option_of_that_number(
|
||||
monkeypatch, console, settings_dir):
|
||||
"""A bare number at the top of the menu opens a group. It used to edit
|
||||
the option with that number, and the two would otherwise disagree."""
|
||||
held = air.AircraftOptions()
|
||||
was = held.seconds
|
||||
monkeypatch.setattr(air, "load_options", lambda *a, **kw: held)
|
||||
# Option 1 is the receiver; group 1 is the receiver group. Typing 1
|
||||
# and then going back must leave everything alone.
|
||||
run(monkeypatch, console, ["1", "b", "b"], ScanConfig())
|
||||
assert held.seconds == was
|
||||
assert held.device == air.AircraftOptions().device
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Every option reachable from the command line as well as the menu
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _help_for(command: str) -> str:
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
return subprocess.run([sys.executable, "-m", "bandsaunter.cli", command,
|
||||
"--help"], capture_output=True, text=True,
|
||||
timeout=120).stdout
|
||||
|
||||
|
||||
def test_every_option_records_the_flag_that_sets_it():
|
||||
"""The manual is generated from this table, so an option whose flag is
|
||||
not written down here is a flag the manual does not mention."""
|
||||
missing = [o.key for o in air.OPTIONS if not (o.flags or o.off_flags)]
|
||||
assert missing == [], missing
|
||||
|
||||
|
||||
def test_every_flag_the_table_claims_actually_exists():
|
||||
"""The other way round: a flag written down here and never added to a
|
||||
parser is a promise the program does not keep."""
|
||||
both = _help_for("adsb") + _help_for("flights")
|
||||
for option in air.OPTIONS:
|
||||
for flag in tuple(option.flags) + tuple(option.off_flags):
|
||||
assert flag in both, f"{option.key}: {flag} is on no command"
|
||||
|
||||
|
||||
def test_a_switch_can_be_turned_back_on_as_well_as_off():
|
||||
"""An option turned off in the saved settings could not be turned back
|
||||
on for one run: only the off half of each pair had a flag."""
|
||||
both = _help_for("adsb") + _help_for("flights")
|
||||
for key in ("lookup", "basemap", "airports", "labels"):
|
||||
option = air.by_key(key)
|
||||
assert option.flags and option.off_flags, key
|
||||
for flag in tuple(option.flags) + tuple(option.off_flags):
|
||||
assert flag in both, f"{key}: {flag}"
|
||||
|
||||
|
||||
def test_the_window_takes_the_options_it_draws_with():
|
||||
"""adsb opens the window and draws a map when it stops, so it has to
|
||||
accept the settings that decide what those look like."""
|
||||
text = _help_for("adsb")
|
||||
for flag in ("--at", "--radius", "--theme", "--map-brightness", "--tiles",
|
||||
"--rings", "--window-rings", "--box-opacity", "--fade",
|
||||
"--hold", "--speed-unit"):
|
||||
assert flag in text, flag
|
||||
|
||||
|
||||
def test_the_readme_lists_every_option_and_its_flag():
|
||||
"""The readme carries a table of them. A table written by hand goes
|
||||
stale the first time an option is added, so this says when it has."""
|
||||
from pathlib import Path
|
||||
|
||||
readme = Path(__file__).resolve().parent.parent / "README.md"
|
||||
if not readme.exists(): # an installed copy has none
|
||||
pytest.skip("no README beside the tests")
|
||||
text = readme.read_text()
|
||||
for option in air.OPTIONS:
|
||||
assert f"| {option.label} |" in text, f"{option.key} is not in README"
|
||||
for flag in tuple(option.flags) + tuple(option.off_flags):
|
||||
assert f"`{flag}`" in text, f"{option.key}: {flag} is not in README"
|
||||
|
|
@ -1,679 +0,0 @@
|
|||
"""The ground under the aircraft: reading tiles, and putting them on the map.
|
||||
|
||||
Nothing here touches the network. The tiles are made up in the test and fed
|
||||
in through the same door the real ones come through, and the PNGs are built
|
||||
here from the specification rather than by the decoder they are testing.
|
||||
"""
|
||||
import json
|
||||
import struct
|
||||
import time
|
||||
import zlib
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from bandsaunter import basemap as bm
|
||||
from bandsaunter import flightmap as fm
|
||||
from test_flightmap import _has_text, two_aircraft
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Making PNGs the hard way, so the decoder is tested against the format
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _chunk(tag: bytes, body: bytes) -> bytes:
|
||||
return (struct.pack(">I", len(body)) + tag + body
|
||||
+ struct.pack(">I", zlib.crc32(tag + body) & 0xFFFFFFFF))
|
||||
|
||||
|
||||
def _paeth(a: int, b: int, c: int) -> int:
|
||||
p = a + b - c
|
||||
pa, pb, pc = abs(p - a), abs(p - b), abs(p - c)
|
||||
if pa <= pb and pa <= pc:
|
||||
return a
|
||||
return b if pb <= pc else c
|
||||
|
||||
|
||||
def make_png(pixels: np.ndarray, colour: int = 2, filter_type: int = 0,
|
||||
palette: np.ndarray | None = None) -> bytes:
|
||||
"""A PNG with every row written using one filter, built from the spec."""
|
||||
pixels = np.asarray(pixels, dtype=np.uint8)
|
||||
height, width = pixels.shape[0], pixels.shape[1]
|
||||
channels = {0: 1, 2: 3, 3: 1, 4: 2, 6: 4}[colour]
|
||||
flat = pixels.reshape(height, width * channels)
|
||||
raw = bytearray()
|
||||
previous = bytearray(width * channels)
|
||||
for row in flat:
|
||||
line = bytearray(int(v) for v in row)
|
||||
out = bytearray(len(line))
|
||||
for i, value in enumerate(line):
|
||||
left_raw = line[i - channels] if i >= channels else 0
|
||||
above = previous[i]
|
||||
upleft = previous[i - channels] if i >= channels else 0
|
||||
if filter_type == 0:
|
||||
out[i] = value
|
||||
elif filter_type == 1:
|
||||
out[i] = (value - left_raw) & 0xFF
|
||||
elif filter_type == 2:
|
||||
out[i] = (value - above) & 0xFF
|
||||
elif filter_type == 3:
|
||||
out[i] = (value - ((left_raw + above) >> 1)) & 0xFF
|
||||
else:
|
||||
out[i] = (value - _paeth(left_raw, above, upleft)) & 0xFF
|
||||
raw.append(filter_type)
|
||||
raw += out
|
||||
previous = line
|
||||
body = (b"\x89PNG\r\n\x1a\n"
|
||||
+ _chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8,
|
||||
colour, 0, 0, 0)))
|
||||
if palette is not None:
|
||||
body += _chunk(b"PLTE", np.asarray(palette, dtype=np.uint8).tobytes())
|
||||
body += _chunk(b"IDAT", zlib.compress(bytes(raw), 6))
|
||||
return body + _chunk(b"IEND", b"")
|
||||
|
||||
|
||||
def a_picture(height=9, width=7, seed=3) -> np.ndarray:
|
||||
rng = np.random.default_rng(seed)
|
||||
return rng.integers(0, 256, size=(height, width, 3), dtype=np.uint8)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reading a PNG
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("filter_type", [0, 1, 2, 3, 4])
|
||||
def test_every_filter_the_format_defines_is_undone(filter_type):
|
||||
"""None, Sub, Up, Average and Paeth: all five, or a tile comes back as
|
||||
coloured noise and nobody notices until the map looks wrong."""
|
||||
want = a_picture()
|
||||
assert np.array_equal(bm.decode_png(make_png(want, 2, filter_type)), want)
|
||||
|
||||
|
||||
def test_what_this_program_writes_it_can_read_back():
|
||||
from bandsaunter.images import write_png
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
want = a_picture(height=20, width=13, seed=9)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = write_png(Path(tmp) / "x.png", want)
|
||||
assert np.array_equal(bm.decode_png(path.read_bytes()), want)
|
||||
|
||||
|
||||
def test_a_palette_image_is_looked_up():
|
||||
"""The tiles most servers send are palette images."""
|
||||
palette = np.array([[0, 0, 0], [255, 0, 0], [0, 128, 255]], dtype=np.uint8)
|
||||
indices = np.array([[0, 1, 2], [2, 1, 0]], dtype=np.uint8)[:, :, None]
|
||||
got = bm.decode_png(make_png(indices, colour=3, palette=palette))
|
||||
assert np.array_equal(got, palette[indices[:, :, 0]])
|
||||
|
||||
|
||||
def test_a_grey_image_becomes_grey_pixels():
|
||||
grey = np.array([[0, 64], [128, 255]], dtype=np.uint8)[:, :, None]
|
||||
got = bm.decode_png(make_png(grey, colour=0))
|
||||
assert got.shape == (2, 2, 3)
|
||||
assert np.array_equal(got[:, :, 0], got[:, :, 2])
|
||||
assert got[1, 1, 0] == 255
|
||||
|
||||
|
||||
def test_transparency_is_dropped_rather_than_misread():
|
||||
rng = np.random.default_rng(1)
|
||||
rgba = rng.integers(0, 256, size=(4, 4, 4), dtype=np.uint8)
|
||||
got = bm.decode_png(make_png(rgba, colour=6))
|
||||
assert np.array_equal(got, rgba[:, :, :3])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body,why", [
|
||||
(b"not a png at all", "not a PNG"),
|
||||
(b"\x89PNG\r\n\x1a\n", "no image"),
|
||||
])
|
||||
def test_something_that_is_not_a_tile_is_refused(body, why):
|
||||
with pytest.raises(bm.PNGError):
|
||||
bm.decode_png(body)
|
||||
|
||||
|
||||
def test_a_depth_this_cannot_read_says_so_rather_than_guessing():
|
||||
header = (b"\x89PNG\r\n\x1a\n"
|
||||
+ _chunk(b"IHDR", struct.pack(">IIBBBBB", 2, 2, 16, 2, 0, 0, 0))
|
||||
+ _chunk(b"IEND", b""))
|
||||
with pytest.raises(bm.PNGError):
|
||||
bm.decode_png(header)
|
||||
|
||||
|
||||
def test_an_interlaced_tile_is_refused():
|
||||
header = (b"\x89PNG\r\n\x1a\n"
|
||||
+ _chunk(b"IHDR", struct.pack(">IIBBBBB", 2, 2, 8, 2, 0, 0, 1))
|
||||
+ _chunk(b"IEND", b""))
|
||||
with pytest.raises(bm.PNGError):
|
||||
bm.decode_png(header)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Which tiles, and where they go
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_the_middle_of_the_world_is_the_middle_of_the_grid():
|
||||
assert bm.tile_of(0.0, 0.0, 0) == pytest.approx((0.5, 0.5))
|
||||
assert bm.tile_of(0.0, -180.0, 1)[0] == pytest.approx(0.0)
|
||||
assert bm.tile_of(85.05, 0.0, 1)[1] == pytest.approx(0.0, abs=0.001)
|
||||
|
||||
|
||||
def test_a_known_place_lands_in_its_known_tile():
|
||||
"""Seattle at zoom 10 is tile 164, 357 -- worked out from the standard
|
||||
formula, not from this module."""
|
||||
import math
|
||||
|
||||
lat, lon, z = 47.6062, -122.3321, 10
|
||||
n = 2 ** z
|
||||
x = int((lon + 180.0) / 360.0 * n)
|
||||
y = int((1.0 - math.asinh(math.tan(math.radians(lat))) / math.pi) / 2.0 * n)
|
||||
got = bm.tile_of(lat, lon, z)
|
||||
assert (int(got[0]), int(got[1])) == (x, y)
|
||||
|
||||
|
||||
def test_a_small_box_gets_more_detail_than_a_large_one():
|
||||
tight = bm.choose_zoom(47.5, -122.4, 47.7, -122.2)
|
||||
wide = bm.choose_zoom(30.0, -130.0, 50.0, -70.0)
|
||||
assert tight > wide
|
||||
assert tight <= bm.MAX_ZOOM and wide >= bm.MIN_ZOOM
|
||||
|
||||
|
||||
def test_the_zoom_is_chosen_to_stay_inside_the_tile_budget():
|
||||
south, west, north, east = 47.0, -122.8, 48.5, -121.0
|
||||
zoom = bm.choose_zoom(south, west, north, east, max_tiles=12)
|
||||
x0, y0 = bm.tile_of(north, west, zoom)
|
||||
x1, y1 = bm.tile_of(south, east, zoom)
|
||||
tiles = (int(x1) - int(x0) + 1) * (int(y1) - int(y0) + 1)
|
||||
assert tiles <= 12
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stitching, without a network
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def solid(value):
|
||||
"""A tile of one colour, as PNG bytes."""
|
||||
px = np.zeros((bm.TILE_PIXELS, bm.TILE_PIXELS, 3), dtype=np.uint8) + value
|
||||
return make_png(px)
|
||||
|
||||
|
||||
def counting_fetcher(answers=None, fail_on=()):
|
||||
"""A stand-in for the tile server that records what it was asked for."""
|
||||
asked = []
|
||||
|
||||
def fetch(z, x, y, **kw):
|
||||
asked.append((z, x, y))
|
||||
if (z, x, y) in fail_on:
|
||||
return None
|
||||
if answers is not None:
|
||||
return answers(z, x, y)
|
||||
return solid((x * 37 + y * 11) % 256)
|
||||
|
||||
fetch.asked = asked
|
||||
return fetch
|
||||
|
||||
|
||||
def test_the_tiles_are_stitched_in_the_right_places():
|
||||
fetch = counting_fetcher()
|
||||
raster, ox, oy = bm.mosaic(47.0, -122.8, 48.5, -121.0, 9, fetch=fetch,
|
||||
pause=0)
|
||||
assert raster is not None
|
||||
assert raster.shape[2] == 3
|
||||
assert raster.shape[0] % bm.TILE_PIXELS == 0
|
||||
assert len(fetch.asked) == (raster.shape[0] // bm.TILE_PIXELS) * \
|
||||
(raster.shape[1] // bm.TILE_PIXELS)
|
||||
# The first tile fetched is the north-west corner, and it is drawn there.
|
||||
z, x, y = fetch.asked[0]
|
||||
assert (ox, oy) == (x * bm.TILE_PIXELS, y * bm.TILE_PIXELS)
|
||||
assert raster[0, 0, 0] == (x * 37 + y * 11) % 256
|
||||
|
||||
|
||||
def test_a_tile_that_does_not_arrive_leaves_a_hole_rather_than_an_error():
|
||||
"""One tile missing is a hole in the map, not a failed drawing."""
|
||||
seen = counting_fetcher()
|
||||
bm.mosaic(47.0, -122.8, 48.5, -121.0, 9, fetch=seen, pause=0)
|
||||
missing = seen.asked[:1]
|
||||
raster, _, _ = bm.mosaic(47.0, -122.8, 48.5, -121.0, 9,
|
||||
fetch=counting_fetcher(fail_on=missing), pause=0)
|
||||
assert raster is not None
|
||||
assert not raster[:bm.TILE_PIXELS, :bm.TILE_PIXELS].any() # the hole
|
||||
|
||||
|
||||
def test_nothing_at_all_gives_no_map():
|
||||
def nothing(z, x, y, **kw):
|
||||
return None
|
||||
|
||||
raster, _, _ = bm.mosaic(47.0, -122.8, 48.5, -121.0, 9, fetch=nothing,
|
||||
pause=0)
|
||||
assert raster is None
|
||||
|
||||
|
||||
def test_a_corrupt_tile_is_skipped():
|
||||
def rubbish(z, x, y, **kw):
|
||||
return b"not a png"
|
||||
|
||||
raster, _, _ = bm.mosaic(47.0, -122.8, 48.5, -121.0, 9, fetch=rubbish,
|
||||
pause=0)
|
||||
assert raster is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# On to the picture
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def gradient_tile(z, x, y, **kw):
|
||||
"""A tile that is black at the top and white at the bottom."""
|
||||
column = np.linspace(0, 255, bm.TILE_PIXELS).astype(np.uint8)
|
||||
px = np.repeat(column[:, None], bm.TILE_PIXELS, axis=1)
|
||||
return make_png(np.repeat(px[:, :, None], 3, axis=2))
|
||||
|
||||
|
||||
def test_the_ground_comes_back_as_levels_the_map_can_paint():
|
||||
levels = bm.ground_under(47.0, -122.8, 48.5, -121.0, 200, 150,
|
||||
shades=32, fetch=gradient_tile, pause=0)
|
||||
assert levels is not None
|
||||
assert levels.shape == (150, 200)
|
||||
assert levels.dtype == np.uint8
|
||||
assert levels.max() <= 31
|
||||
|
||||
|
||||
def tile_bounds(zoom: int, x: int, y: int):
|
||||
"""The corners of one tile, from the inverse of the standard formula.
|
||||
|
||||
Written here rather than taken from the module, so that a test of where
|
||||
the pixels land is not asking the code under test where they land.
|
||||
"""
|
||||
import math
|
||||
|
||||
n = 2.0 ** zoom
|
||||
|
||||
def lat_of(row):
|
||||
return math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * row / n))))
|
||||
|
||||
return (lat_of(y + 1), x / n * 360.0 - 180.0, # south, west
|
||||
lat_of(y), (x + 1) / n * 360.0 - 180.0) # north, east
|
||||
|
||||
|
||||
def test_the_map_is_inverted_so_that_ink_shows_on_a_dark_picture():
|
||||
"""A printed map is dark ink on white paper; this picture is the other
|
||||
way round, so the dark parts of a tile are the bright parts here."""
|
||||
south, west, north, east = tile_bounds(9, 81, 178)
|
||||
levels = bm.ground_under(south, west, north, east, 64, 64, shades=32,
|
||||
fetch=gradient_tile, zoom=9, pause=0)
|
||||
# The tile is black at the top and white at the bottom, so the picture
|
||||
# has to be bright at the top and dark at the bottom.
|
||||
assert levels[0].mean() > levels[-1].mean()
|
||||
assert levels[0].mean() > 25 and levels[-1].mean() < 6
|
||||
|
||||
|
||||
def test_north_is_at_the_top():
|
||||
def half_and_half(z, x, y, **kw):
|
||||
px = np.zeros((bm.TILE_PIXELS, bm.TILE_PIXELS, 3), dtype=np.uint8)
|
||||
px[:bm.TILE_PIXELS // 2] = 255 # white in the north
|
||||
return make_png(px)
|
||||
|
||||
south, west, north, east = tile_bounds(9, 81, 178)
|
||||
levels = bm.ground_under(south, west, north, east, 40, 40, shades=32,
|
||||
fetch=half_and_half, zoom=9, pause=0)
|
||||
assert levels[0].mean() < levels[-1].mean() # white inverts to dark
|
||||
|
||||
|
||||
def test_east_is_to_the_right():
|
||||
def half_and_half(z, x, y, **kw):
|
||||
px = np.zeros((bm.TILE_PIXELS, bm.TILE_PIXELS, 3), dtype=np.uint8)
|
||||
px[:, :bm.TILE_PIXELS // 2] = 255 # white in the west
|
||||
return make_png(px)
|
||||
|
||||
south, west, north, east = tile_bounds(9, 81, 178)
|
||||
levels = bm.ground_under(south, west, north, east, 40, 40, shades=32,
|
||||
fetch=half_and_half, zoom=9, pause=0)
|
||||
assert levels[:, 0].mean() < levels[:, -1].mean()
|
||||
|
||||
|
||||
def test_one_tile_covers_its_own_box_exactly():
|
||||
"""The reprojection has to put the tile where the tile says it is."""
|
||||
def corner_marks(z, x, y, **kw):
|
||||
px = np.zeros((bm.TILE_PIXELS, bm.TILE_PIXELS, 3), dtype=np.uint8)
|
||||
px[:8, :8] = 255 # a mark in the north-west
|
||||
return make_png(px)
|
||||
|
||||
south, west, north, east = tile_bounds(9, 81, 178)
|
||||
levels = bm.ground_under(south, west, north, east, 128, 128, shades=32,
|
||||
fetch=corner_marks, zoom=9, pause=0)
|
||||
dark = levels < levels.max() / 2 # the white corner, inverted
|
||||
assert dark[:4, :4].all()
|
||||
assert not dark[64:, 64:].any()
|
||||
|
||||
|
||||
def test_no_tiles_means_no_ground_rather_than_an_exception():
|
||||
def nothing(z, x, y, **kw):
|
||||
return None
|
||||
|
||||
assert bm.ground_under(47.0, -122.8, 48.5, -121.0, 50, 50,
|
||||
fetch=nothing, pause=0) is None
|
||||
|
||||
|
||||
def test_a_box_that_makes_no_sense_is_refused_quietly():
|
||||
assert bm.ground_under(48.0, -122.0, 47.0, -123.0, 50, 50,
|
||||
fetch=gradient_tile, pause=0) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fetching once, and saying who it came from
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_a_tile_is_fetched_once_and_then_read_from_the_disk(tmp_path,
|
||||
monkeypatch):
|
||||
calls = []
|
||||
|
||||
class _Answer:
|
||||
def __init__(self, body):
|
||||
self.body = body
|
||||
|
||||
def read(self, *a):
|
||||
return self.body
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def fake_urlopen(request, timeout=None):
|
||||
calls.append(request.full_url)
|
||||
assert "bandsaunter" in request.headers.get("User-agent", ""), \
|
||||
"a tile server is told who is asking"
|
||||
return _Answer(solid(120))
|
||||
|
||||
monkeypatch.setattr(bm.urllib.request, "urlopen", fake_urlopen)
|
||||
first = bm.fetch_tile(9, 81, 178, cache=tmp_path)
|
||||
second = bm.fetch_tile(9, 81, 178, cache=tmp_path)
|
||||
assert first == second
|
||||
assert len(calls) == 1, "the second one came off the disk"
|
||||
assert (tmp_path / "9" / "81" / "178.png").is_file()
|
||||
|
||||
|
||||
def test_a_tile_server_that_is_not_there_is_not_an_error(tmp_path, monkeypatch):
|
||||
def refuse(request, timeout=None):
|
||||
raise OSError("no route to host")
|
||||
|
||||
monkeypatch.setattr(bm.urllib.request, "urlopen", refuse)
|
||||
assert bm.fetch_tile(9, 81, 178, cache=tmp_path) is None
|
||||
|
||||
|
||||
def test_the_tile_server_can_be_pointed_somewhere_else(tmp_path, monkeypatch):
|
||||
seen = []
|
||||
|
||||
def fake_urlopen(request, timeout=None):
|
||||
seen.append(request.full_url)
|
||||
raise OSError("stop here")
|
||||
|
||||
monkeypatch.setattr(bm.urllib.request, "urlopen", fake_urlopen)
|
||||
bm.fetch_tile(4, 2, 3, url="https://example.invalid/{z}/{x}/{y}.png",
|
||||
cache=tmp_path)
|
||||
assert seen == ["https://example.invalid/4/2/3.png"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# And under the aircraft
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_the_map_goes_under_the_picture_and_is_credited(tmp_path):
|
||||
out = fm.animate(two_aircraft(), tmp_path / "on-the-map.png", width=400,
|
||||
ground=True, fetch=gradient_tile, airports=False)
|
||||
assert out is not None and out.ground
|
||||
assert "on the map" in out.summary()
|
||||
from bandsaunter.images import PNG_SIGNATURE
|
||||
assert out.path.read_bytes()[:8] == PNG_SIGNATURE
|
||||
|
||||
|
||||
def test_the_ground_is_drawn_in_its_own_shades():
|
||||
view = fm.fit(two_aircraft(), width=400)
|
||||
levels, credit = fm.ground_for(view, fetch=gradient_tile)
|
||||
assert levels is not None and credit
|
||||
base = fm.background(view, ground=levels, attribution=credit)
|
||||
body = base[view.top:view.top + view.height,
|
||||
view.left:view.left + view.width]
|
||||
ground = (body >= fm.GROUND) & (body < fm.GROUND + fm.GROUND_SHADES)
|
||||
assert ground.mean() > 0.9 # nearly all of the body is map
|
||||
|
||||
|
||||
def test_without_it_the_picture_is_the_plain_grid_it_always_was():
|
||||
view = fm.fit(two_aircraft(), width=400)
|
||||
base = fm.background(view)
|
||||
assert not ((base >= fm.GROUND) & (base < fm.GROUND + fm.GROUND_SHADES)).any()
|
||||
|
||||
|
||||
def test_no_network_falls_back_to_the_plain_grid(monkeypatch):
|
||||
def nothing(z, x, y, **kw):
|
||||
return None
|
||||
|
||||
view = fm.fit(two_aircraft(), width=400)
|
||||
levels, credit = fm.ground_for(view, fetch=nothing)
|
||||
assert levels is None and credit == ""
|
||||
|
||||
|
||||
def test_the_credit_is_written_on_the_picture_itself():
|
||||
"""A GIF travels without the readme that would otherwise carry it."""
|
||||
view = fm.fit(two_aircraft(), width=520)
|
||||
levels, credit = fm.ground_for(view, fetch=gradient_tile)
|
||||
base = fm.background(view, ground=levels, attribution=credit)
|
||||
assert _has_text(base, "OPENSTREETMAP")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# What aerodromes are under the picture
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _overpass(elements):
|
||||
"""Stand in for the map data, in the shape it really answers with."""
|
||||
def ask(box, url, timeout):
|
||||
return {"elements": elements}
|
||||
return ask
|
||||
|
||||
|
||||
def _node(code=None, name="", lat=32.1, lon=-110.9, ref=None, kind="node"):
|
||||
tags = {"aeroway": "aerodrome", "name": name}
|
||||
if code:
|
||||
tags["icao"] = code
|
||||
if ref:
|
||||
tags["ref"] = ref
|
||||
element = {"type": kind, "tags": tags}
|
||||
if kind == "node":
|
||||
element.update({"lat": lat, "lon": lon})
|
||||
else:
|
||||
element["center"] = {"lat": lat, "lon": lon}
|
||||
return element
|
||||
|
||||
|
||||
def test_the_aerodromes_in_a_box_come_back_with_a_code_and_a_place(tmp_path):
|
||||
got = bm.airports_in(32.0, -111.2, 32.4, -110.7, cache=tmp_path / "a.json",
|
||||
ask=_overpass([_node("KTUS", "Tucson International")]))
|
||||
assert len(got) == 1
|
||||
assert got[0]["code"] == "KTUS"
|
||||
assert got[0]["latitude"] == pytest.approx(32.1)
|
||||
|
||||
|
||||
def test_the_big_airports_are_relations_and_must_be_asked_for():
|
||||
"""Tucson International and Davis-Monthan are both relations; asking
|
||||
only for nodes and ways finds every airstrip in the county and misses
|
||||
the two the county is known for."""
|
||||
import inspect
|
||||
|
||||
source = inspect.getsource(bm._ask_overpass)
|
||||
for kind in ("node", "way", "relation"):
|
||||
assert f'{kind}["aeroway"="aerodrome"]' in source, kind
|
||||
|
||||
|
||||
def test_a_relation_is_placed_by_its_middle(tmp_path):
|
||||
got = bm.airports_in(32.0, -111.2, 32.4, -110.7, cache=tmp_path / "a.json",
|
||||
ask=_overpass([_node("KDMA", "Davis-Monthan",
|
||||
kind="relation")]))
|
||||
assert got and got[0]["code"] == "KDMA"
|
||||
|
||||
|
||||
def test_a_landing_strip_with_no_real_code_is_left_off(tmp_path):
|
||||
"""A local identifier like 14AZ names nothing anybody would recognise,
|
||||
and turns a map into a list of airstrips."""
|
||||
got = bm.airports_in(32.0, -111.2, 32.4, -110.7, cache=tmp_path / "a.json",
|
||||
ask=_overpass([_node(None, "Ruby Star", ref="14AZ"),
|
||||
_node(None, "Nowhere",
|
||||
ref="MX-0492"),
|
||||
_node(None, "Unnamed strip")]))
|
||||
assert got == []
|
||||
|
||||
|
||||
def test_a_four_letter_reference_is_good_enough(tmp_path):
|
||||
got = bm.airports_in(32.0, -111.2, 32.4, -110.7, cache=tmp_path / "a.json",
|
||||
ask=_overpass([_node(None, "Somewhere", ref="EGLL")]))
|
||||
assert [a["code"] for a in got] == ["EGLL"]
|
||||
|
||||
|
||||
def test_one_airport_tagged_twice_is_marked_once(tmp_path):
|
||||
"""A point for the terminal and an outline for the field: marking both
|
||||
writes the name over itself."""
|
||||
got = bm.airports_in(32.0, -111.2, 32.4, -110.7, cache=tmp_path / "a.json",
|
||||
ask=_overpass([_node("KFHU", "Sierra Vista"),
|
||||
_node("KFHU", "Sierra Vista",
|
||||
kind="relation")]))
|
||||
assert [a["code"] for a in got] == ["KFHU"]
|
||||
|
||||
|
||||
def test_the_ones_with_real_codes_come_first(tmp_path):
|
||||
got = bm.airports_in(32.0, -111.2, 32.4, -110.7, cache=tmp_path / "a.json",
|
||||
ask=_overpass([_node(None, "Strip", ref="ZZZZ"),
|
||||
_node("KTUS", "Tucson")]))
|
||||
assert [a["code"] for a in got][0] == "KTUS"
|
||||
|
||||
|
||||
def test_a_view_full_of_airstrips_is_capped(tmp_path):
|
||||
many = [_node(f"K{i:03d}"[:4], f"Strip {i}") for i in range(200)]
|
||||
got = bm.airports_in(32.0, -111.2, 32.4, -110.7, cache=tmp_path / "a.json",
|
||||
ask=_overpass(many))
|
||||
assert len(got) <= bm.MOST_AIRPORTS
|
||||
|
||||
|
||||
def test_the_answer_is_kept_so_the_question_is_asked_once(tmp_path):
|
||||
"""A runway does not move, and the service being asked is a volunteer
|
||||
one."""
|
||||
asked = []
|
||||
|
||||
def ask(box, url, timeout):
|
||||
asked.append(box)
|
||||
return {"elements": [_node("KTUS", "Tucson")]}
|
||||
|
||||
where = tmp_path / "a.json"
|
||||
first = bm.airports_in(32.0, -111.2, 32.4, -110.7, cache=where, ask=ask)
|
||||
second = bm.airports_in(32.0, -111.2, 32.4, -110.7, cache=where, ask=ask)
|
||||
assert first == second
|
||||
assert len(asked) == 1, "asked twice for the same piece of the world"
|
||||
|
||||
|
||||
def test_an_answer_from_an_older_question_is_asked_again(tmp_path):
|
||||
where = tmp_path / "a.json"
|
||||
where.write_text(json.dumps({"fetched_at": time.time(), "version": 1,
|
||||
"airports": [{"code": "OLD"}]}))
|
||||
got = bm.airports_in(32.0, -111.2, 32.4, -110.7, cache=where,
|
||||
ask=_overpass([_node("KTUS", "Tucson")]))
|
||||
assert [a["code"] for a in got] == ["KTUS"]
|
||||
|
||||
|
||||
def test_no_network_means_no_airports_rather_than_no_map(tmp_path):
|
||||
def refuse(box, url, timeout):
|
||||
raise OSError("no route to host")
|
||||
|
||||
assert bm.airports_in(32.0, -111.2, 32.4, -110.7,
|
||||
cache=tmp_path / "a.json", ask=refuse) == []
|
||||
|
||||
|
||||
def test_nonsense_from_the_service_is_survived(tmp_path):
|
||||
for answer in ({}, {"elements": None}, {"elements": [{"tags": None}]},
|
||||
{"elements": [{"tags": {"icao": "KTUS"}}]}):
|
||||
got = bm.airports_in(32.0, -111.2, 32.4, -110.7,
|
||||
cache=tmp_path / f"{id(answer)}.json",
|
||||
ask=lambda b, u, t, a=answer: a)
|
||||
assert got == []
|
||||
|
||||
|
||||
def test_the_map_asks_for_the_airports_under_it():
|
||||
"""A route names where its aircraft are going; the airports underneath
|
||||
are what say where on the map you are looking."""
|
||||
view = fm.fit(two_aircraft(), width=500)
|
||||
got = fm.local_airports(view, ask=_overpass([_node("EGLL", "Heathrow",
|
||||
lat=view.south + 0.1,
|
||||
lon=view.west + 0.1)]))
|
||||
assert got and got[0][0] == "EGLL"
|
||||
|
||||
|
||||
def test_a_service_that_is_not_there_costs_the_airports_and_nothing_else():
|
||||
def refuse(box, url, timeout):
|
||||
raise OSError("no")
|
||||
|
||||
view = fm.fit(two_aircraft(), width=500)
|
||||
assert fm.local_airports(view, ask=refuse) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# How sharp the map is
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_detail_is_averaged_down_rather_than_thrown_away():
|
||||
"""Taking the nearest source pixel keeps a fraction of a tile and turns
|
||||
the rest into aliasing: hard, broken lettering and roads that come and
|
||||
go along their length. A checkerboard is half black and half white, so
|
||||
averaging it lands in the middle and sampling it lands at one end."""
|
||||
fine = np.tile(np.array([0.0, 255.0], dtype=np.float32), 128)[None, :]
|
||||
out = bm._resample(fine, np.linspace(0, 256, 41), axis=1)
|
||||
assert out.shape == (1, 40)
|
||||
assert abs(float(out.mean()) - 127.5) < 4.0
|
||||
assert float(out.max()) < 160.0 and float(out.min()) > 95.0
|
||||
|
||||
|
||||
def test_averaging_takes_the_whole_cell_and_no_more():
|
||||
"""Each output cell is the mean of the source pixels under it."""
|
||||
values = np.arange(100, dtype=np.float32)[None, :]
|
||||
out = bm._resample(values, np.linspace(0, 100, 11), axis=1)
|
||||
assert out.shape == (1, 10)
|
||||
for i in range(10):
|
||||
assert abs(float(out[0, i]) - (i * 10 + 4.5)) < 0.01
|
||||
|
||||
|
||||
def test_averaging_works_the_other_way_up_too():
|
||||
values = np.arange(60, dtype=np.float32)[:, None]
|
||||
out = bm._resample(values, np.linspace(0, 60, 7), axis=0)
|
||||
assert out.shape == (6, 1)
|
||||
assert float(out[0, 0]) < float(out[-1, 0])
|
||||
|
||||
|
||||
def test_a_cell_smaller_than_a_source_pixel_takes_that_pixel():
|
||||
"""Zoomed in past the tiles, a cell covers less than one of them."""
|
||||
values = np.array([[10.0, 20.0, 30.0]], dtype=np.float32)
|
||||
out = bm._resample(values, np.linspace(0, 3, 10), axis=1)
|
||||
assert out.shape == (1, 9)
|
||||
assert set(np.round(out[0]).astype(int)) <= {10, 20, 30}
|
||||
|
||||
|
||||
def test_a_gradient_still_comes_out_as_a_gradient():
|
||||
south, west, north, east = tile_bounds(9, 81, 178)
|
||||
levels = bm.ground_under(south, west, north, east, 64, 64, shades=32,
|
||||
fetch=gradient_tile, zoom=9, pause=0)
|
||||
rows = levels.mean(axis=1)
|
||||
assert (np.diff(rows) <= 0.51).all(), "the gradient came out lumpy"
|
||||
|
||||
|
||||
def test_averaging_is_the_same_shape_as_the_picture_asked_for():
|
||||
south, west, north, east = tile_bounds(9, 81, 178)
|
||||
for width, height in ((40, 40), (137, 91), (300, 200), (17, 5)):
|
||||
levels = bm.ground_under(south, west, north, east, width, height,
|
||||
shades=32, fetch=gradient_tile, zoom=9,
|
||||
pause=0)
|
||||
assert levels.shape == (height, width)
|
||||
|
||||
|
||||
def test_a_map_asked_for_at_more_detail_than_the_tiles_hold_still_works():
|
||||
"""Zoomed in past the tiles, a cell covers less than one source pixel."""
|
||||
south, west, north, east = tile_bounds(9, 81, 178)
|
||||
levels = bm.ground_under(south, west, north, east, 2000, 2000, shades=32,
|
||||
fetch=gradient_tile, zoom=9, pause=0)
|
||||
assert levels.shape == (2000, 2000)
|
||||
assert levels[0].mean() != levels[-1].mean()
|
||||
|
|
@ -1,139 +0,0 @@
|
|||
"""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
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -486,404 +486,3 @@ def test_a_simulated_sky_is_heard_recorded_and_read_back(tmp_path):
|
|||
flown = track.distance_nm
|
||||
expected = track.top_speed_kt * track.seconds / 3600.0
|
||||
assert flown == pytest.approx(expected, rel=0.35, abs=0.2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Speeds and distances in whatever the user reads
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("unit,speed,distance", [
|
||||
("knots", "kt", "nm"), ("mph", "mph", "mi"), ("kph", "km/h", "km"),
|
||||
])
|
||||
def test_the_report_says_which_unit_it_is_using(tmp_path, unit, speed,
|
||||
distance):
|
||||
log = _log(tmp_path)
|
||||
for i in range(4):
|
||||
log.append(_Frame(callsign="RYR1234", altitude_ft=30_000,
|
||||
ground_speed_kt=420.0, track_deg=90.0),
|
||||
_Craft(51.5, -0.12 + i * 0.05, "RYR1234"),
|
||||
when=1_000_000.0 + i * 30)
|
||||
log.close()
|
||||
text = "\n".join(report(read_logs(log.path), unit=unit))
|
||||
assert "speed: up to " in text and speed in text
|
||||
assert distance in text
|
||||
|
||||
|
||||
def test_the_numbers_are_the_right_ones():
|
||||
from bandsaunter.flightlog import in_distance, in_speed
|
||||
|
||||
assert in_speed(100, "knots") == pytest.approx(100.0)
|
||||
assert in_speed(100, "mph") == pytest.approx(115.08, abs=0.01)
|
||||
assert in_speed(100, "kph") == pytest.approx(185.2, abs=0.01)
|
||||
assert in_distance(100, "mph") == pytest.approx(115.08, abs=0.01)
|
||||
assert in_distance(100, "kph") == pytest.approx(185.2, abs=0.01)
|
||||
|
||||
|
||||
def test_an_unknown_unit_falls_back_to_what_the_aircraft_said():
|
||||
from bandsaunter.flightlog import in_speed, speed_label
|
||||
|
||||
assert in_speed(100, "furlongs per fortnight") == 100.0
|
||||
assert speed_label("") == "kt"
|
||||
|
||||
|
||||
def test_the_log_itself_is_always_in_knots(tmp_path):
|
||||
"""The recording is what arrived; converting it would lose the original."""
|
||||
log = _log(tmp_path)
|
||||
log.append(_Frame(ground_speed_kt=420.0, track_deg=90.0),
|
||||
_Craft(51.5, -0.12), when=1_000_000.0)
|
||||
log.close()
|
||||
line = [json.loads(x) for x in log.path.read_text().splitlines()][1]
|
||||
assert line["gs_kt"] == 420.0
|
||||
|
||||
|
||||
def test_what_one_track_says_of_itself_follows_the_unit(tmp_path):
|
||||
log = _log(tmp_path)
|
||||
for i in range(3):
|
||||
log.append(_Frame(ground_speed_kt=420.0, track_deg=90.0,
|
||||
altitude_ft=30_000),
|
||||
_Craft(51.5, -0.12 + i * 0.05), when=1_000_000.0 + i * 30)
|
||||
log.close()
|
||||
track = read_logs(log.path)[0]
|
||||
assert "kt" in track.describe()
|
||||
assert "mph" in track.describe("mph")
|
||||
assert "km/h" in track.describe("kph")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# A callsign is a flight number, not a leg
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _route(origin=(29.65, -95.28), destination=(29.53, -98.47)) -> Flight:
|
||||
"""Houston Hobby to San Antonio: a half-hour hop across Texas."""
|
||||
return Flight(icao="AC0FB6", callsign="SWA930",
|
||||
origin_code="KHOU", origin="Houston",
|
||||
origin_lat=origin[0], origin_lon=origin[1],
|
||||
destination_code="KSAT", destination="San Antonio",
|
||||
destination_lat=destination[0], destination_lon=destination[1])
|
||||
|
||||
|
||||
def test_an_aircraft_on_its_route_is_believed():
|
||||
from bandsaunter.flights import route_fits
|
||||
|
||||
# Halfway between the two, and at either end.
|
||||
assert route_fits(_route(), 29.6, -96.9)
|
||||
assert route_fits(_route(), 29.65, -95.28)
|
||||
assert route_fits(_route(), 29.53, -98.47)
|
||||
|
||||
|
||||
def test_an_aircraft_nowhere_near_its_route_is_not():
|
||||
"""The one that prompted this: a 737 over Arizona at cruise, given a
|
||||
thirty-minute hop between two airports in Texas. An airline runs the
|
||||
same flight number over several legs in a day and a register holds one
|
||||
route for it."""
|
||||
from bandsaunter.flights import route_fits
|
||||
|
||||
assert not route_fits(_route(), 32.7086, -110.4061)
|
||||
|
||||
|
||||
def test_a_long_route_is_given_more_room_than_a_short_one():
|
||||
"""An aircraft on a transcontinental leg wanders further from the great
|
||||
circle than one on a hop, and neither is a wrong route."""
|
||||
from bandsaunter.flights import route_fits
|
||||
|
||||
coast = _route(origin=(40.69, -74.17), destination=(33.43, -112.01))
|
||||
assert route_fits(coast, 32.7086, -110.4061) # Newark to Phoenix
|
||||
assert route_fits(coast, 39.0, -95.0)
|
||||
|
||||
|
||||
def test_a_route_with_no_positions_is_left_alone():
|
||||
"""Not knowing is not the same as knowing it is wrong."""
|
||||
from bandsaunter.flights import route_fits
|
||||
|
||||
bare = Flight(icao="A", origin_code="KHOU", destination_code="KSAT")
|
||||
assert route_fits(bare, 32.7, -110.4)
|
||||
assert route_fits(Flight(icao="A"), 0.0, 0.0)
|
||||
|
||||
|
||||
def test_a_route_that_cannot_be_flown_is_still_written_down(tmp_path):
|
||||
"""Said rather than hidden: it is what the register holds for that
|
||||
flight number, and worth having."""
|
||||
from bandsaunter.flightlog import report
|
||||
|
||||
log = _log(tmp_path)
|
||||
for i in range(3):
|
||||
log.append(_Frame(icao="AC0FB6", callsign="SWA930"),
|
||||
_Craft(32.7086, -110.4061 + i * 0.01, "SWA930"),
|
||||
when=1_000_000.0 + i)
|
||||
log.close()
|
||||
|
||||
class _Book:
|
||||
def get(self, icao, callsign=""):
|
||||
return _route()
|
||||
|
||||
text = "\n".join(report(read_logs(log.path), _Book()))
|
||||
assert "Houston" in text and "San Antonio" in text
|
||||
assert "nowhere near it" in text
|
||||
|
||||
|
||||
def test_a_route_that_fits_is_not_second_guessed(tmp_path):
|
||||
from bandsaunter.flightlog import report
|
||||
|
||||
log = _log(tmp_path)
|
||||
for i in range(3):
|
||||
log.append(_Frame(icao="AC0FB6", callsign="SWA930"),
|
||||
_Craft(29.6, -96.9 + i * 0.01, "SWA930"),
|
||||
when=1_000_000.0 + i)
|
||||
log.close()
|
||||
|
||||
class _Book:
|
||||
def get(self, icao, callsign=""):
|
||||
return _route()
|
||||
|
||||
text = "\n".join(report(read_logs(log.path), _Book()))
|
||||
assert "Houston" in text
|
||||
assert "nowhere near" not in text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Picking the leg out of a day's work
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
HEXDB_DAY = {"flight": "AAL2465", "route": "KORD-KEWR-KORD"}
|
||||
HEXDB_LEG = {"flight": "BAW49", "route": "EGLL-KSEA"}
|
||||
|
||||
AIRPORTS = {
|
||||
"KORD": {"code": "KORD", "name": "Chicago O'Hare", "latitude": 41.978,
|
||||
"longitude": -87.905, "country": "US"},
|
||||
"KEWR": {"code": "KEWR", "name": "Newark Liberty", "latitude": 40.692,
|
||||
"longitude": -74.169, "country": "US"},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def with_airports(register, monkeypatch):
|
||||
"""A book that knows where a handful of airports are, and no network."""
|
||||
book, asked, answers = register
|
||||
monkeypatch.setattr(type(book), "airport",
|
||||
lambda self, code: AIRPORTS.get(code.upper(), {}))
|
||||
return book, asked, answers
|
||||
|
||||
|
||||
def test_two_stops_are_a_route(register):
|
||||
book, asked, answers = register
|
||||
answers["hexdb.io/api/v1/route"] = HEXDB_LEG
|
||||
entry = book.get("400001", "BAW49")
|
||||
book.wait(5.0)
|
||||
assert (entry.origin_code, entry.destination_code) == ("EGLL", "KSEA")
|
||||
assert entry.stops == ("EGLL", "KSEA")
|
||||
|
||||
|
||||
def test_a_whole_day_of_stops_is_not_read_as_one_flight(register):
|
||||
""""KORD-KEWR-KORD" read from its ends is Chicago to Chicago, which is
|
||||
not a flight."""
|
||||
book, asked, answers = register
|
||||
answers["hexdb.io/api/v1/route"] = HEXDB_DAY
|
||||
entry = book.get("AD64CD", "AAL2465")
|
||||
book.wait(5.0)
|
||||
assert entry.stops == ("KORD", "KEWR", "KORD")
|
||||
assert entry.origin_code == "" and entry.destination_code == ""
|
||||
|
||||
|
||||
def test_the_leg_is_picked_out_by_where_the_aircraft_is(with_airports):
|
||||
book, asked, answers = with_airports
|
||||
answers["hexdb.io/api/v1/route"] = HEXDB_DAY
|
||||
entry = book.get("AD64CD", "AAL2465")
|
||||
book.wait(5.0)
|
||||
# Over Pennsylvania, which is on the way from Chicago to Newark.
|
||||
assert book.leg_for(entry, 40.8, -78.0) == ("KORD", "KEWR")
|
||||
|
||||
|
||||
def test_resolving_fills_the_leg_in(with_airports):
|
||||
book, asked, answers = with_airports
|
||||
answers["hexdb.io/api/v1/route"] = HEXDB_DAY
|
||||
entry = book.get("AD64CD", "AAL2465")
|
||||
book.wait(5.0)
|
||||
got = book.resolve(entry, 40.8, -78.0)
|
||||
assert got.origin_code == "KORD" and got.destination_code == "KEWR"
|
||||
assert "Chicago" in got.origin and got.origin_country == "US"
|
||||
assert got.origin_lat == pytest.approx(41.978)
|
||||
|
||||
|
||||
def test_an_aircraft_on_none_of_the_legs_is_given_none_of_them(with_airports):
|
||||
"""Which is an answer, and a better one than naming a leg it cannot
|
||||
be on."""
|
||||
book, asked, answers = with_airports
|
||||
answers["hexdb.io/api/v1/route"] = HEXDB_DAY
|
||||
entry = book.get("AD64CD", "AAL2465")
|
||||
book.wait(5.0)
|
||||
assert book.leg_for(entry, 32.7, -110.4) is None # over Arizona
|
||||
assert book.resolve(entry, 32.7, -110.4).origin_code == ""
|
||||
|
||||
|
||||
def test_resolving_leaves_an_ordinary_route_alone(with_airports):
|
||||
book, asked, answers = with_airports
|
||||
answers["hexdb.io/api/v1/route"] = HEXDB_LEG
|
||||
entry = book.get("400001", "BAW49")
|
||||
book.wait(5.0)
|
||||
assert book.resolve(entry, 51.0, -20.0) is entry
|
||||
|
||||
|
||||
def test_an_airport_nobody_can_place_costs_only_its_own_leg(register,
|
||||
monkeypatch):
|
||||
book, asked, answers = register
|
||||
monkeypatch.setattr(type(book), "airport",
|
||||
lambda self, code: AIRPORTS.get(code.upper(), {}))
|
||||
answers["hexdb.io/api/v1/route"] = {"flight": "X", "route":
|
||||
"KORD-ZZZZ-KEWR"}
|
||||
entry = book.get("AD64CD", "X")
|
||||
book.wait(5.0)
|
||||
# The middle stop cannot be placed, so neither leg touching it can be
|
||||
# tested; nothing is claimed.
|
||||
assert book.leg_for(entry, 40.8, -78.0) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# What sort of aircraft it is
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_a_military_address_is_read_off_the_block():
|
||||
from bandsaunter.flights import aircraft_class, is_military
|
||||
|
||||
assert is_military("AE07D3") # a United States military block
|
||||
assert not is_military("AC4C44") # the civil part of the same range
|
||||
assert not is_military("") and not is_military("nonsense")
|
||||
assert aircraft_class("AE07D3", "heavy") == "military heavy"
|
||||
assert aircraft_class("AC4C44", "large") == "large"
|
||||
assert aircraft_class("AE07D3") == "military"
|
||||
assert aircraft_class("AC4C44") == ""
|
||||
|
||||
|
||||
def test_military_and_the_country_it_is_described_with_never_disagree():
|
||||
"""Both read the same table, so an address cannot be military and be
|
||||
described as an ordinary country at the same time."""
|
||||
from bandsaunter.flights import (MILITARY_BLOCKS, describe_address,
|
||||
is_military)
|
||||
|
||||
for low, high, name in MILITARY_BLOCKS:
|
||||
for address in (low, (low + high) // 2, high):
|
||||
assert is_military(f"{address:06X}")
|
||||
assert describe_address(f"{address:06X}") == name
|
||||
assert name.endswith(" military")
|
||||
|
||||
|
||||
def test_the_report_says_what_sort_of_aircraft_it_is(tmp_path):
|
||||
"""Off the air, not out of a register: the aeroplane says "heavy" and
|
||||
the address says whose it is."""
|
||||
from bandsaunter.flightlog import report
|
||||
|
||||
log = _log(tmp_path)
|
||||
for i in range(3):
|
||||
log.append(_Frame(icao="AE07D3", callsign="PRIME04"),
|
||||
_Craft(32.7086, -110.4061 + i * 0.01, "PRIME04"),
|
||||
when=1_000_000.0 + i)
|
||||
log.close()
|
||||
tracks = read_logs(log.path)
|
||||
tracks[0].category = "heavy"
|
||||
text = "\n".join(report(tracks, None))
|
||||
assert "class: military heavy" in text
|
||||
|
||||
|
||||
def test_the_report_leaves_the_class_out_when_nothing_said_one(tmp_path):
|
||||
from bandsaunter.flightlog import report
|
||||
|
||||
log = _log(tmp_path)
|
||||
for i in range(3):
|
||||
log.append(_Frame(icao="AC4C44", callsign="SWA2444"),
|
||||
_Craft(32.7086, -110.4061 + i * 0.01, "SWA2444"),
|
||||
when=1_000_000.0 + i)
|
||||
log.close()
|
||||
assert "class:" not in "\n".join(report(read_logs(log.path), None))
|
||||
|
||||
|
||||
def test_the_category_is_read_back_out_of_a_logged_frame(tmp_path):
|
||||
"""The three bits are inside the identification message, which is
|
||||
written down in full, so every log this program has ever written has
|
||||
them -- including the ones written before anything here knew to look."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from bandsaunter.flightlog import read_logs as read
|
||||
|
||||
path = Path(tmp_path) / "old.jsonl"
|
||||
with path.open("w") as out:
|
||||
out.write(json.dumps({"log": "bandsaunter-adsb", "version": 1}) + "\n")
|
||||
# A real identification frame: type code 4, category 3, "large".
|
||||
out.write(json.dumps(
|
||||
{"t": 1.0, "icao": "A80A97", "df": 17, "tc": 4,
|
||||
"hex": "8DA80A97234CB5F5CF4C604EB016",
|
||||
"callsign": "SKW5341"}) + "\n")
|
||||
out.write(json.dumps(
|
||||
{"t": 2.0, "icao": "A80A97", "df": 17, "tc": 11,
|
||||
"hex": "8DA338A6591521A2C718D47E24C0",
|
||||
"lat": 32.2, "lon": -111.0, "alt_ft": 3050}) + "\n")
|
||||
track = read([path])[0]
|
||||
assert track.category == "large"
|
||||
assert track.kind == "large"
|
||||
|
||||
|
||||
def test_a_log_with_nothing_to_read_the_category_from_says_nothing(tmp_path):
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from bandsaunter.flightlog import read_logs as read
|
||||
|
||||
path = Path(tmp_path) / "thin.jsonl"
|
||||
with path.open("w") as out:
|
||||
for line in ({"t": 1.0, "icao": "A80A97", "tc": 4}, # no hex
|
||||
{"t": 2.0, "icao": "A80A97", "tc": 11,
|
||||
"hex": "8DA338A6591521A2C718D47E24C0", # not an ident
|
||||
"lat": 32.2, "lon": -111.0},
|
||||
{"t": 3.0, "icao": "A80A97", "tc": 4, "hex": "8D"}):
|
||||
out.write(json.dumps(line) + "\n")
|
||||
assert read([path])[0].category == ""
|
||||
|
|
|
|||
|
|
@ -495,24 +495,3 @@ def test_a_simulated_transmission_becomes_a_file_on_disk(tmp_path):
|
|||
if not p.name.endswith("_waterfall.png")]
|
||||
assert written
|
||||
assert _read_png(written[0]).shape[1] == 320
|
||||
|
||||
|
||||
def test_a_q_cannot_be_read_as_a_nought():
|
||||
"""A registration came back as N87650 when the aircraft was N8765Q."""
|
||||
from bandsaunter.images import GLYPHS
|
||||
|
||||
assert GLYPHS["Q"] != GLYPHS["0"]
|
||||
assert GLYPHS["Q"] != GLYPHS["O"]
|
||||
# The tail hangs below and to the right of the letter, where a nought
|
||||
# has nothing at all.
|
||||
assert GLYPHS["Q"][-1].rstrip("0").endswith("1")
|
||||
assert GLYPHS["Q"][-1][:3] == "000"
|
||||
assert GLYPHS["0"][-1] != GLYPHS["Q"][-1]
|
||||
|
||||
|
||||
def test_the_letters_most_easily_confused_are_all_different():
|
||||
from bandsaunter.images import GLYPHS
|
||||
|
||||
for group in ("O0Q", "1I", "5S", "2Z", "8B"):
|
||||
shapes = [GLYPHS[c] for c in group]
|
||||
assert len(set(shapes)) == len(shapes), group
|
||||
|
|
|
|||
|
|
@ -128,43 +128,9 @@ def test_it_warns_about_the_thing_every_debian_user_hits_first():
|
|||
def test_it_lists_the_optional_dependencies_and_what_each_one_buys():
|
||||
body = INSTALL.read_text()
|
||||
for optional in ("espeak-ng", "ffmpeg", "faster-whisper", "vosk",
|
||||
"rtl-sdr", "pyqt6", "openai-whisper", "pocketsphinx"):
|
||||
"rtl-sdr"):
|
||||
assert optional in body, optional
|
||||
# What each one buys, and what is lost without it: the two columns are
|
||||
# the point of the table, not the list of names.
|
||||
assert "Gives you" in body and "Without it" in body
|
||||
assert "Optional." in body
|
||||
|
||||
|
||||
def test_it_lists_the_required_dependencies_and_who_needs_them():
|
||||
"""Somebody installing from scratch has to be told the whole of it,
|
||||
including the one piece pip cannot bring."""
|
||||
body = INSTALL.read_text()
|
||||
for required in ("python3-numpy", "python3-scipy", "python3-rich",
|
||||
"python3-yaml", "librtlsdr0"):
|
||||
assert required in body, required
|
||||
assert "Fedora" in body and "Arch" in body
|
||||
|
||||
|
||||
def test_it_says_which_dependency_pip_cannot_install():
|
||||
"""librtlsdr is a C library, so no virtual environment brings it and it
|
||||
is the one thing that has to come from the distribution by hand."""
|
||||
body = INSTALL.read_text()
|
||||
assert "librtlsdr" in body
|
||||
assert "pip` cannot install it" in body or "cannot come from pip" in body
|
||||
assert "dnf install rtl-sdr" in body
|
||||
assert "pacman -S rtl-sdr" in body
|
||||
assert "brew install librtlsdr" in body
|
||||
|
||||
|
||||
def test_it_says_what_reaches_a_network_and_where_it_is_cached():
|
||||
"""Nothing is fetched behind anybody's back, and somebody installing
|
||||
this on a metered or air-gapped machine has to be able to see that."""
|
||||
body = INSTALL.read_text()
|
||||
for source in ("api.adsbdb.com", "hexdb.io", "tile.openstreetmap.org",
|
||||
"overpass-api.de"):
|
||||
assert source in body, source
|
||||
assert "~/.cache/bandsaunter/tiles" in body
|
||||
assert "Optional dependencies" in body
|
||||
|
||||
|
||||
def test_it_says_how_to_install_the_recogniser_and_the_model():
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -135,15 +135,3 @@ def test_the_browser_page_renders_without_complaint(browse_page, tmp_path):
|
|||
capture_output=True, text=True)
|
||||
assert done.returncode == 0, done.stderr
|
||||
assert not done.stderr.strip(), done.stderr
|
||||
|
||||
|
||||
def test_the_manual_lists_every_aircraft_option(page):
|
||||
"""It is generated from the same table the menu and the flags are, so
|
||||
an option added to the program cannot quietly fail to be documented."""
|
||||
from bandsaunter import aircraft as air
|
||||
|
||||
for option in air.OPTIONS:
|
||||
flags = tuple(option.flags) + tuple(option.off_flags)
|
||||
assert any(flag in page for flag in flags), \
|
||||
f"{option.key} ({', '.join(flags)}) is not in the manual"
|
||||
assert option.key in page, f"{option.key} is not named in the manual"
|
||||
|
|
|
|||
|
|
@ -1,467 +0,0 @@
|
|||
"""The commercial schedule services.
|
||||
|
||||
None of these has been run against its live service, because each wants a
|
||||
paid key. What is tested is everything that can be: that a source with no
|
||||
key is skipped rather than tried, that each reader takes the documented
|
||||
shape of its answers and finds the leg in it, that the leg chosen is the one
|
||||
in the air at the moment being asked about, and that a service which has
|
||||
changed since costs a route rather than a scan.
|
||||
|
||||
The recorded shapes below are written from each service's published
|
||||
documentation. If one of them stops matching reality, this is where it will
|
||||
show, and the failure will be a route that is not found rather than one that
|
||||
is wrong.
|
||||
"""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from bandsaunter import schedules
|
||||
|
||||
|
||||
NOON = 1_788_600_000.0 # a moment to ask about
|
||||
|
||||
|
||||
def at(offset_hours: float) -> str:
|
||||
"""A time, written the way these services write it."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
return datetime.fromtimestamp(NOON + offset_hours * 3600,
|
||||
timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Without keys, which is how nearly everyone runs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_every_source_says_what_it_needs():
|
||||
for kind in schedules.SOURCES:
|
||||
source = kind()
|
||||
assert source.name and source.needs and source.signup
|
||||
assert all(n.startswith("BANDSAUNTER_") for n in source.needs)
|
||||
|
||||
|
||||
def test_a_source_with_no_key_is_not_available(monkeypatch):
|
||||
for kind in schedules.SOURCES:
|
||||
for name in kind.needs:
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
assert kind().available() is False
|
||||
assert kind().missing() == list(kind.needs)
|
||||
|
||||
|
||||
def test_a_source_with_no_key_is_never_asked(monkeypatch):
|
||||
"""Not asked, rather than asked and refused: there is nothing to ask
|
||||
with, and a request would only be a way of finding that out slowly."""
|
||||
for kind in schedules.SOURCES:
|
||||
for name in kind.needs:
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
|
||||
def refuse(self, url, headers=None):
|
||||
raise AssertionError("asked a service with no key")
|
||||
|
||||
monkeypatch.setattr(schedules.Schedule, "fetch", refuse)
|
||||
assert schedules.route_for("SWA930", NOON) is None
|
||||
|
||||
|
||||
def test_half_a_key_is_no_key(monkeypatch):
|
||||
"""Cirium wants two; one of them is not enough."""
|
||||
monkeypatch.setenv("BANDSAUNTER_CIRIUM_APP_ID", "abc")
|
||||
monkeypatch.delenv("BANDSAUNTER_CIRIUM_APP_KEY", raising=False)
|
||||
source = schedules.source_named("cirium")
|
||||
assert source.available() is False
|
||||
assert source.missing() == ["BANDSAUNTER_CIRIUM_APP_KEY"]
|
||||
|
||||
|
||||
def test_only_the_ones_with_keys_are_listed(monkeypatch):
|
||||
for kind in schedules.SOURCES:
|
||||
for name in kind.needs:
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
assert schedules.available_sources() == []
|
||||
monkeypatch.setenv("BANDSAUNTER_AEROAPI_KEY", "k")
|
||||
assert [s.name for s in schedules.available_sources()] == ["flightaware"]
|
||||
|
||||
|
||||
def test_they_are_asked_in_the_order_given(monkeypatch):
|
||||
monkeypatch.setenv("BANDSAUNTER_AEROAPI_KEY", "k")
|
||||
monkeypatch.setenv("BANDSAUNTER_OAG_KEY", "k")
|
||||
assert [s.name for s in schedules.available_sources(["oag", "flightaware"])] \
|
||||
== ["oag", "flightaware"]
|
||||
|
||||
|
||||
def test_a_name_that_is_not_a_service_is_ignored():
|
||||
assert schedules.source_named("nonesuch") is None
|
||||
assert schedules.available_sources(["nonesuch"]) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FlightAware AeroAPI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
AEROAPI = {"flights": [
|
||||
{"ident": "SWA930", "operator": "SWA",
|
||||
"origin": {"code_icao": "KLAS", "name": "Harry Reid International"},
|
||||
"destination": {"code_icao": "KMDW", "name": "Chicago Midway"},
|
||||
"scheduled_off": at(-9), "scheduled_on": at(-6)},
|
||||
{"ident": "SWA930", "operator": "SWA",
|
||||
"origin": {"code_icao": "KMDW", "name": "Chicago Midway"},
|
||||
"destination": {"code_icao": "KSAN", "name": "San Diego International"},
|
||||
"actual_off": at(-1), "estimated_on": at(2)},
|
||||
{"ident": "SWA930", "operator": "SWA",
|
||||
"origin": {"code_icao": "KSAN", "name": "San Diego International"},
|
||||
"destination": {"code_icao": "KOAK", "name": "Oakland International"},
|
||||
"scheduled_off": at(4), "scheduled_on": at(6)},
|
||||
]}
|
||||
|
||||
|
||||
def _read(source_name, body, when=NOON):
|
||||
"""Read one service's answer, without needing a key to do it."""
|
||||
return schedules.source_named(source_name).read(body, when)
|
||||
|
||||
|
||||
def test_flightaware_finds_the_leg_that_is_in_the_air():
|
||||
"""Three legs of one flight number in a day; the one being watched is
|
||||
the one whose window holds the moment."""
|
||||
got = _read("flightaware", AEROAPI)
|
||||
assert got["origin_code"] == "KMDW"
|
||||
assert got["destination_code"] == "KSAN"
|
||||
assert "Midway" in got["origin"]
|
||||
assert got["stops"] == ("KMDW", "KSAN")
|
||||
|
||||
|
||||
def test_flightaware_picks_a_different_leg_at_a_different_hour():
|
||||
early = _read("flightaware", AEROAPI, when=NOON - 8 * 3600)
|
||||
assert (early["origin_code"], early["destination_code"]) == ("KLAS", "KMDW")
|
||||
late = _read("flightaware", AEROAPI, when=NOON + 5 * 3600)
|
||||
assert (late["origin_code"], late["destination_code"]) == ("KSAN", "KOAK")
|
||||
|
||||
|
||||
def test_flightaware_says_nothing_about_a_day_it_has_no_flights_for():
|
||||
assert _read("flightaware", AEROAPI, when=NOON + 5 * 86_400) is None
|
||||
|
||||
|
||||
def test_flightaware_survives_an_answer_it_does_not_recognise():
|
||||
for body in ({}, {"flights": []}, {"flights": [{}]},
|
||||
{"flights": [{"origin": None, "destination": None}]},
|
||||
{"flights": [{"origin": {"code_icao": "KLAS"}}]}):
|
||||
assert _read("flightaware", body) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Flightradar24
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FR24 = {"data": [
|
||||
{"fr24_id": "1", "flight": "SWA930", "operating_as": "SWA",
|
||||
"orig_icao": "KMDW", "dest_icao": "KSAN",
|
||||
"datetime_takeoff": at(-1), "datetime_landed": at(2)},
|
||||
{"fr24_id": "2", "flight": "SWA930", "operating_as": "SWA",
|
||||
"orig_icao": "KSAN", "dest_icao": "KOAK",
|
||||
"datetime_takeoff": at(4), "datetime_landed": at(6)},
|
||||
]}
|
||||
|
||||
|
||||
def test_flightradar24_finds_the_leg_in_the_air():
|
||||
got = _read("flightradar24", FR24)
|
||||
assert (got["origin_code"], got["destination_code"]) == ("KMDW", "KSAN")
|
||||
|
||||
|
||||
def test_flightradar24_reads_a_bare_list_too():
|
||||
"""Some of its endpoints wrap the rows and some do not."""
|
||||
got = _read("flightradar24", FR24["data"])
|
||||
assert got is not None and got["origin_code"] == "KMDW"
|
||||
|
||||
|
||||
def test_flightradar24_survives_nonsense():
|
||||
for body in ({}, {"data": []}, {"data": [{}]}, [], None):
|
||||
assert _read("flightradar24", body) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OAG
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
OAG = {"data": [
|
||||
{"carrier": {"icao": "SWA"},
|
||||
"departure": {"airport": {"icao": "KMDW", "name": "Chicago Midway"},
|
||||
"date": {"utc": at(-1)[:10]},
|
||||
"time": {"utc": at(-1)[11:19]}},
|
||||
"arrival": {"airport": {"icao": "KSAN", "name": "San Diego"},
|
||||
"date": {"utc": at(2)[:10]}, "time": {"utc": at(2)[11:19]}}},
|
||||
]}
|
||||
|
||||
|
||||
def test_oag_finds_the_leg():
|
||||
got = _read("oag", OAG)
|
||||
assert (got["origin_code"], got["destination_code"]) == ("KMDW", "KSAN")
|
||||
assert "Midway" in got["origin"]
|
||||
|
||||
|
||||
def test_oag_survives_nonsense():
|
||||
for body in ({}, {"data": []}, {"data": [{}]},
|
||||
{"data": [{"departure": {}, "arrival": {}}]}):
|
||||
assert _read("oag", body) is None
|
||||
|
||||
|
||||
def test_oag_needs_an_airline_callsign(monkeypatch):
|
||||
monkeypatch.setenv("BANDSAUNTER_OAG_KEY", "k")
|
||||
source = schedules.source_named("oag")
|
||||
with pytest.raises(schedules.SourceError):
|
||||
source.route("N517HP", NOON) # a registration, not a flight
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cirium
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CIRIUM = {
|
||||
"scheduledFlights": [
|
||||
{"carrierFsCode": "WN", "flightNumber": "930",
|
||||
"departureAirportFsCode": "MDW", "arrivalAirportFsCode": "SAN",
|
||||
"departureTime": at(-1), "arrivalTime": at(2)},
|
||||
],
|
||||
"appendix": {"airports": [
|
||||
{"fs": "MDW", "icao": "KMDW", "name": "Chicago Midway",
|
||||
"countryCode": "US", "latitude": 41.786, "longitude": -87.752},
|
||||
{"fs": "SAN", "icao": "KSAN", "name": "San Diego International",
|
||||
"countryCode": "US", "latitude": 32.733, "longitude": -117.19},
|
||||
]},
|
||||
}
|
||||
|
||||
|
||||
def test_cirium_finds_the_leg_and_where_its_airports_are():
|
||||
got = _read("cirium", CIRIUM)
|
||||
assert (got["origin_code"], got["destination_code"]) == ("KMDW", "KSAN")
|
||||
assert got["origin_country"] == "US"
|
||||
assert got["origin_lat"] == pytest.approx(41.786)
|
||||
assert got["destination_lon"] == pytest.approx(-117.19)
|
||||
|
||||
|
||||
def test_cirium_survives_nonsense():
|
||||
for body in ({}, {"scheduledFlights": []}, {"scheduledFlights": [{}]},
|
||||
{"scheduledFlights": [{"departureAirportFsCode": "MDW"}],
|
||||
"appendix": {}}):
|
||||
assert _read("cirium", body) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reading the answers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_a_callsign_splits_into_an_airline_and_a_number():
|
||||
assert schedules._split_callsign("SWA930") == ("SWA", "930")
|
||||
assert schedules._split_callsign("BAW49") == ("BAW", "49")
|
||||
assert schedules._split_callsign("N517HP")[1] == "" # a registration
|
||||
assert schedules._split_callsign("")[1] == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text,ok", [
|
||||
("2026-09-04T12:00:00Z", True),
|
||||
("2026-09-04T12:00:00+00:00", True),
|
||||
("1788600000", True),
|
||||
("", False), ("not a time", False), ("2026-13-45", False),
|
||||
])
|
||||
def test_the_several_ways_a_time_is_written_are_all_read(text, ok):
|
||||
got = schedules._seconds(text)
|
||||
assert (got > 0) is ok
|
||||
|
||||
|
||||
def test_the_leg_whose_window_holds_the_moment_wins():
|
||||
legs = [(NOON - 7200, NOON - 3600, {"n": "before"}),
|
||||
(NOON - 600, NOON + 600, {"n": "now"}),
|
||||
(NOON + 3600, NOON + 7200, {"n": "after"})]
|
||||
assert schedules._closest(legs, NOON)["n"] == "now"
|
||||
|
||||
|
||||
def test_the_nearest_leg_wins_when_none_quite_holds_it():
|
||||
"""A departure runs late and the windows no longer line up; the nearest
|
||||
is a better answer than none, and much better than the first in the
|
||||
list."""
|
||||
legs = [(NOON - 7 * 3600, NOON - 6 * 3600, {"n": "long before"}),
|
||||
(NOON + 600, NOON + 3600, {"n": "just after"})]
|
||||
assert schedules._closest(legs, NOON)["n"] == "just after"
|
||||
|
||||
|
||||
def test_nothing_within_half_a_day_is_not_this_flight():
|
||||
legs = [(NOON - 40 * 3600, NOON - 39 * 3600, {"n": "yesterday"})]
|
||||
assert schedules._closest(legs, NOON) is None
|
||||
assert schedules._closest([], NOON) is None
|
||||
|
||||
|
||||
def test_a_leg_needs_both_ends():
|
||||
assert schedules._leg("KMDW", "") is None
|
||||
assert schedules._leg("", "KSAN") is None
|
||||
assert schedules._leg("kmdw", "ksan")["origin_code"] == "KMDW"
|
||||
|
||||
|
||||
def test_a_leg_comes_back_in_the_shape_the_rest_of_the_program_reads():
|
||||
from bandsaunter.flights import Flight
|
||||
|
||||
leg = schedules._leg("EGLL", "KSEA")
|
||||
fields = set(Flight().__dict__)
|
||||
for key in leg:
|
||||
if key in ("stops", "airline"):
|
||||
continue
|
||||
assert key in fields, key
|
||||
assert leg["origin_country"] == "GB" # worked out from the code
|
||||
assert leg["destination_country"] == "US"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# And what happens when one falls over
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_a_service_that_fails_does_not_stop_the_next_one(monkeypatch):
|
||||
"""A key that has run out of quota should cost that service and not the
|
||||
others."""
|
||||
monkeypatch.setenv("BANDSAUNTER_AEROAPI_KEY", "k")
|
||||
monkeypatch.setenv("BANDSAUNTER_FR24_TOKEN", "k")
|
||||
|
||||
def fetch(self, url, headers=None):
|
||||
if self.name == "flightaware":
|
||||
raise OSError("quota exceeded")
|
||||
return FR24
|
||||
|
||||
monkeypatch.setattr(schedules.Schedule, "fetch", fetch)
|
||||
got = schedules.route_for("SWA930", NOON,
|
||||
["flightaware", "flightradar24"])
|
||||
assert got is not None
|
||||
assert got["origin_code"] == "KMDW"
|
||||
assert got["source"] == "flightradar24"
|
||||
|
||||
|
||||
def test_the_answer_says_which_service_gave_it(monkeypatch):
|
||||
monkeypatch.setenv("BANDSAUNTER_AEROAPI_KEY", "k")
|
||||
monkeypatch.setattr(schedules.Schedule, "fetch",
|
||||
lambda self, url, headers=None: AEROAPI)
|
||||
got = schedules.route_for("SWA930", NOON, ["flightaware"])
|
||||
assert got["source"] == "flightaware"
|
||||
|
||||
|
||||
def test_the_key_is_sent_the_way_the_service_wants_it(monkeypatch):
|
||||
"""Each of them asks for its key somewhere different."""
|
||||
seen = {}
|
||||
|
||||
def fetch(self, url, headers=None):
|
||||
seen[self.name] = (url, headers or {})
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr(schedules.Schedule, "fetch", fetch)
|
||||
monkeypatch.setenv("BANDSAUNTER_AEROAPI_KEY", "aero-key")
|
||||
monkeypatch.setenv("BANDSAUNTER_FR24_TOKEN", "fr24-token")
|
||||
monkeypatch.setenv("BANDSAUNTER_OAG_KEY", "oag-key")
|
||||
monkeypatch.setenv("BANDSAUNTER_CIRIUM_APP_ID", "cid")
|
||||
monkeypatch.setenv("BANDSAUNTER_CIRIUM_APP_KEY", "ckey")
|
||||
for source in schedules.available_sources():
|
||||
source.route("SWA930", NOON)
|
||||
|
||||
assert seen["flightaware"][1]["x-apikey"] == "aero-key"
|
||||
assert "fr24-token" in seen["flightradar24"][1]["Authorization"]
|
||||
assert seen["oag"][1]["Subscription-Key"] == "oag-key"
|
||||
assert "appId=cid" in seen["cirium"][0]
|
||||
assert "appKey=ckey" in seen["cirium"][0]
|
||||
# And no key is ever put in a URL that did not ask for one there.
|
||||
assert "aero-key" not in seen["flightaware"][0]
|
||||
assert "fr24-token" not in seen["flightradar24"][0]
|
||||
assert "oag-key" not in seen["oag"][0]
|
||||
|
||||
|
||||
def test_a_key_is_never_written_into_the_settings_file():
|
||||
"""A settings file gets copied between machines and pasted into messages
|
||||
asking for help; an API key does not belong in one."""
|
||||
from bandsaunter.aircraft import AircraftOptions
|
||||
|
||||
text = json.dumps(AircraftOptions().to_dict())
|
||||
for kind in schedules.SOURCES:
|
||||
for name in kind.needs:
|
||||
assert name not in text
|
||||
|
||||
|
||||
def test_the_book_asks_the_services_before_the_free_databases(monkeypatch):
|
||||
"""They know the leg; the free databases hold one route per number."""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from bandsaunter.flights import FlightBook
|
||||
|
||||
monkeypatch.setenv("BANDSAUNTER_AEROAPI_KEY", "k")
|
||||
monkeypatch.setattr(schedules.Schedule, "fetch",
|
||||
lambda self, url, headers=None: AEROAPI)
|
||||
asked = []
|
||||
|
||||
def free(self, url):
|
||||
asked.append(url)
|
||||
raise OSError("should not have been needed")
|
||||
|
||||
monkeypatch.setattr(FlightBook, "_request", free)
|
||||
book = FlightBook(cache=Path(tempfile.mkdtemp()) / "c.json")
|
||||
entry = book.get("AC0FB6", "SWA930", when=NOON)
|
||||
book.wait(5.0)
|
||||
assert entry.origin_code == "KMDW"
|
||||
assert entry.route_source == "flightaware"
|
||||
assert not any("callsign" in url for url in asked), \
|
||||
"asked a free database when a schedule service had answered"
|
||||
|
||||
|
||||
def test_with_no_keys_the_free_databases_answer_as_before(monkeypatch):
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from bandsaunter.flights import FlightBook
|
||||
|
||||
for kind in schedules.SOURCES:
|
||||
for name in kind.needs:
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
answers = {"adsbdb.com/v0/callsign": {"response": {"flightroute": {
|
||||
"callsign": "SWA930",
|
||||
"origin": {"icao_code": "KHOU", "name": "Hobby",
|
||||
"country_iso_name": "US"},
|
||||
"destination": {"icao_code": "KSAT", "name": "San Antonio",
|
||||
"country_iso_name": "US"}}}}}
|
||||
|
||||
def request(self, url):
|
||||
for fragment, body in answers.items():
|
||||
if fragment in url:
|
||||
return body
|
||||
raise OSError("not found")
|
||||
|
||||
monkeypatch.setattr(FlightBook, "_request", request)
|
||||
book = FlightBook(cache=Path(tempfile.mkdtemp()) / "c.json")
|
||||
entry = book.get("AC0FB6", "SWA930", when=NOON)
|
||||
book.wait(5.0)
|
||||
assert entry.origin_code == "KHOU"
|
||||
assert entry.route_source == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Two things that would go wrong quietly
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_cirium_prefers_the_utc_time_over_the_local_one():
|
||||
"""Its plain departureTime is local and carries no offset, so reading
|
||||
that as UTC puts a leg up to half a day from where it belongs -- which
|
||||
is exactly far enough to pick the wrong leg of the same number."""
|
||||
body = json.loads(json.dumps(CIRIUM))
|
||||
flight = body["scheduledFlights"][0]
|
||||
flight["departureTimeUtc"] = at(-1)
|
||||
flight["arrivalTimeUtc"] = at(2)
|
||||
# The local times say the small hours, nine time zones away.
|
||||
flight["departureTime"] = at(-10)[:-1]
|
||||
flight["arrivalTime"] = at(-7)[:-1]
|
||||
got = _read("cirium", body)
|
||||
assert got is not None
|
||||
assert (got["origin_code"], got["destination_code"]) == ("KMDW", "KSAN")
|
||||
|
||||
|
||||
def test_a_reader_that_throws_is_a_source_that_does_not_know(monkeypatch):
|
||||
"""An answer shaped differently from the documented one is the failure
|
||||
most likely to actually happen. It has to come back as this service
|
||||
not knowing, not as a traceback out of the middle of a scan."""
|
||||
monkeypatch.setenv("BANDSAUNTER_AEROAPI_KEY", "k")
|
||||
source = schedules.source_named("flightaware")
|
||||
monkeypatch.setattr(type(source), "ask", lambda self, c, w: {"flights": 7})
|
||||
with pytest.raises(schedules.SourceError):
|
||||
source.route("SWA930", NOON)
|
||||
# and the caller above it turns that into silence, not a crash
|
||||
monkeypatch.setattr(schedules.Schedule, "fetch",
|
||||
lambda self, url, headers=None: {"flights": 7})
|
||||
assert schedules.route_for("SWA930", NOON) is None
|
||||
|
|
@ -392,107 +392,3 @@ def test_a_payload_that_says_what_it_is_is_named():
|
|||
def test_an_ordinary_payload_claims_no_format():
|
||||
bits = "".join(format(b, "08b") for b in b"\x01\x02\x03\x04\x05\x06\x07\x08")
|
||||
assert not [r for r in interpret(bits) if r.kind == "fields"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# A position is only as good as the pair it was decoded from
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _position_frame(icao, lat, lon, odd, when, altitude=35000):
|
||||
"""One position frame, read back the way the decoder would read it."""
|
||||
from bandsaunter.adsb import _read, encode_position
|
||||
|
||||
data = encode_position(icao, lat, lon, altitude, odd=odd)
|
||||
frame = _read("".join(format(b, "08b") for b in data), data)
|
||||
frame.received_at = when
|
||||
return frame
|
||||
|
||||
|
||||
def test_a_stale_pair_is_not_a_position():
|
||||
"""Compact position reporting sends a fraction of a zone, so an even
|
||||
frame from ten minutes ago read against a fresh odd one puts the
|
||||
aircraft on the wrong side of the world. Measured against one night's
|
||||
recording it was doing exactly that to two aircraft in three."""
|
||||
registry = AircraftRegistry()
|
||||
registry.add(_position_frame(0xABCDEF, 32.55, -111.16, False, 1000.0),
|
||||
when=1000.0)
|
||||
registry.add(_position_frame(0xABCDEF, 33.22, -111.16, True, 1300.0),
|
||||
when=1300.0)
|
||||
assert not registry.aircraft["ABCDEF"].located
|
||||
|
||||
|
||||
def test_a_fresh_pair_still_places_it_exactly():
|
||||
registry = AircraftRegistry()
|
||||
registry.add(_position_frame(0xABCDEF, 33.22, -111.16, False, 1000.0),
|
||||
when=1000.0)
|
||||
registry.add(_position_frame(0xABCDEF, 33.22, -111.16, True, 1000.5),
|
||||
when=1000.5)
|
||||
craft = registry.aircraft["ABCDEF"]
|
||||
assert craft.latitude == pytest.approx(33.22, abs=0.01)
|
||||
assert craft.longitude == pytest.approx(-111.16, abs=0.01)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gap", [0.0, 0.5, 4.0, 9.5])
|
||||
def test_a_pair_inside_the_allowed_gap_is_used(gap):
|
||||
registry = AircraftRegistry()
|
||||
registry.add(_position_frame(0xA0B1C2, 51.5, -0.12, False, 100.0),
|
||||
when=100.0)
|
||||
registry.add(_position_frame(0xA0B1C2, 51.5, -0.12, True, 100.0 + gap),
|
||||
when=100.0 + gap)
|
||||
assert registry.aircraft["A0B1C2"].located
|
||||
|
||||
|
||||
def test_a_position_that_is_not_on_earth_is_refused():
|
||||
"""A latitude of 240 degrees is not a place. One night's log held
|
||||
eleven of them."""
|
||||
from bandsaunter.adsb import _on_earth
|
||||
|
||||
assert not _on_earth(239.6, -111.0)
|
||||
assert not _on_earth(45.0, 200.0)
|
||||
assert _on_earth(-33.9, 151.2)
|
||||
|
||||
|
||||
def test_an_aircraft_cannot_cross_a_continent_between_two_frames():
|
||||
"""A position needing nine hundred thousand knots to reach is not a
|
||||
position, whatever the checksum said about the frames it came from."""
|
||||
registry = AircraftRegistry()
|
||||
for odd in (False, True):
|
||||
registry.add(_position_frame(0xA0B1C2, 51.5, -0.12, odd, 100.0),
|
||||
when=100.0)
|
||||
craft = registry.aircraft["A0B1C2"]
|
||||
assert craft.located
|
||||
was = (craft.latitude, craft.longitude)
|
||||
# Two seconds later, a pair that decodes to the far side of the Atlantic.
|
||||
for odd in (False, True):
|
||||
registry.add(_position_frame(0xA0B1C2, 40.7, -74.0, odd, 102.0),
|
||||
when=102.0)
|
||||
assert (craft.latitude, craft.longitude) == was
|
||||
|
||||
|
||||
def test_an_aircraft_that_really_moved_is_still_followed():
|
||||
"""The bar has to be above anything that flies, or a fast aircraft is
|
||||
called an error."""
|
||||
registry = AircraftRegistry()
|
||||
for odd in (False, True):
|
||||
registry.add(_position_frame(0xA0B1C2, 51.5, -0.12, odd, 100.0),
|
||||
when=100.0)
|
||||
# Sixty seconds on and eight miles further east: 480 knots.
|
||||
for odd in (False, True):
|
||||
registry.add(_position_frame(0xA0B1C2, 51.5, 0.08, odd, 160.0),
|
||||
when=160.0)
|
||||
assert registry.aircraft["A0B1C2"].longitude == pytest.approx(0.08,
|
||||
abs=0.02)
|
||||
|
||||
|
||||
def test_a_gap_in_reception_is_not_treated_as_an_error():
|
||||
"""Nothing heard for an hour, then a position a long way off: that is an
|
||||
aircraft that flew away and came back, not a bad decode."""
|
||||
registry = AircraftRegistry()
|
||||
for odd in (False, True):
|
||||
registry.add(_position_frame(0xA0B1C2, 51.5, -0.12, odd, 100.0),
|
||||
when=100.0)
|
||||
for odd in (False, True):
|
||||
registry.add(_position_frame(0xA0B1C2, 48.8, 2.3, odd, 4000.0),
|
||||
when=4000.0)
|
||||
assert registry.aircraft["A0B1C2"].latitude == pytest.approx(48.8,
|
||||
abs=0.05)
|
||||
|
|
|
|||
|
|
@ -1,336 +0,0 @@
|
|||
"""The colour themes, and the glow that goes with the vector ones.
|
||||
|
||||
A theme changes both drawings at once, because both read their colours out
|
||||
of the same palette: the animation looks indices up in it directly and the
|
||||
window asks it for a QColor. So the tests here are mostly about that
|
||||
palette -- that it is rewritten rather than replaced, that every theme keeps
|
||||
the aerodromes distinguishable from the aircraft, and that the halo goes
|
||||
where light would go and nowhere else.
|
||||
"""
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from bandsaunter import flightmap as fm
|
||||
from bandsaunter import themes
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def back_to_night():
|
||||
"""Every test leaves the program drawing the way it found it."""
|
||||
yield
|
||||
fm.set_theme(themes.DEFAULT_THEME)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Choosing one
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_every_theme_says_what_it_is():
|
||||
for name, theme in themes.THEMES.items():
|
||||
assert theme.name == name
|
||||
assert theme.summary and not theme.summary.endswith(".")
|
||||
assert len(theme.stops) >= 2
|
||||
assert theme.stops[0][0] == 0.0
|
||||
|
||||
|
||||
def test_a_theme_can_be_asked_for_by_name_or_by_what_it_looks_like():
|
||||
assert themes.theme_named("phosphor").name == "phosphor"
|
||||
assert themes.theme_named("green").name == "phosphor"
|
||||
assert themes.theme_named("wargames").name == "digital"
|
||||
assert themes.theme_named("BLUE").name == "digital"
|
||||
assert themes.theme_named(" amber ").name == "amber"
|
||||
|
||||
|
||||
def test_a_name_nobody_recognises_is_the_default_rather_than_a_refusal():
|
||||
"""A theme is how the picture looks. Refusing to draw an evening's
|
||||
flying because of a misspelt colour would be the wrong trade."""
|
||||
assert themes.theme_named("puce").name == themes.DEFAULT_THEME
|
||||
assert themes.theme_named("").name == themes.DEFAULT_THEME
|
||||
assert themes.theme_named(None).name == themes.DEFAULT_THEME
|
||||
|
||||
|
||||
def test_no_two_themes_share_a_name_or_an_alias():
|
||||
seen = set()
|
||||
for theme in themes.THEMES.values():
|
||||
for word in (theme.name,) + tuple(theme.aliases):
|
||||
assert word not in seen, word
|
||||
seen.add(word)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# What setting one does to the palette
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_the_palette_is_written_over_rather_than_replaced():
|
||||
"""Both drawings and every one of their helpers hold a reference to
|
||||
this array. A new one would leave half the program painting in the
|
||||
colours of the theme before."""
|
||||
before = fm.PALETTE
|
||||
fm.set_theme("phosphor")
|
||||
assert fm.PALETTE is before
|
||||
assert tuple(fm.PALETTE[fm.BG]) != (14, 16, 22)
|
||||
|
||||
|
||||
def test_setting_a_theme_says_which_one_it_settled_on():
|
||||
assert fm.set_theme("norad").name == "digital"
|
||||
assert fm.set_theme("puce").name == themes.DEFAULT_THEME
|
||||
|
||||
|
||||
def test_the_default_theme_draws_exactly_what_it_always_drew():
|
||||
"""The colours this program has always used, unchanged: an existing
|
||||
recording redrawn today has to come out the same picture."""
|
||||
fm.set_theme("phosphor")
|
||||
fm.set_theme("night")
|
||||
assert tuple(fm.PALETTE[fm.BG]) == (14, 16, 22)
|
||||
assert tuple(fm.PALETTE[fm.INK]) == (196, 204, 218)
|
||||
assert tuple(fm.PALETTE[fm.RAMP]) == (252, 96, 72)
|
||||
assert tuple(fm.PALETTE[fm.RAMP + fm.RAMP_STEPS - 1]) == (158, 142, 255)
|
||||
|
||||
|
||||
def _lab(rgb):
|
||||
c = np.asarray(rgb, float) / 255.0
|
||||
c = np.where(c > 0.04045, ((c + 0.055) / 1.055) ** 2.4, c / 12.92)
|
||||
m = np.array([[0.4124, 0.3576, 0.1805], [0.2126, 0.7152, 0.0722],
|
||||
[0.0193, 0.1192, 0.9505]])
|
||||
xyz = (c @ m.T) / np.array([0.9505, 1.0, 1.089])
|
||||
f = np.where(xyz > 0.008856, np.cbrt(xyz), 7.787 * xyz + 16 / 116)
|
||||
return np.array([116 * f[1] - 16, 500 * (f[0] - f[1]), 200 * (f[1] - f[2])])
|
||||
|
||||
|
||||
def test_no_theme_lets_an_aerodrome_be_mistaken_for_an_aircraft():
|
||||
"""The whole reason the aerodromes stopped being amber. Stated as the
|
||||
distance rather than as the colour, so that a new theme cannot quietly
|
||||
walk an aircraft back into the airports."""
|
||||
for name in themes.THEMES:
|
||||
fm.set_theme(name)
|
||||
airport = _lab(fm.PALETTE[fm.AIRPORT])
|
||||
apart = min(float(np.linalg.norm(airport - _lab(fm.PALETTE[fm.RAMP + i])))
|
||||
for i in range(fm.RAMP_STEPS))
|
||||
assert apart > 40, f"{name}: only {apart:.0f} units apart"
|
||||
|
||||
|
||||
def test_a_phosphor_theme_reads_height_as_brightness():
|
||||
"""One colour to spend, so it cannot be spent on hue. Low is dim and
|
||||
high burns, which is the constraint those screens actually had."""
|
||||
for name in ("digital", "phosphor", "amber", "red"):
|
||||
theme = fm.set_theme(name)
|
||||
assert theme.height_is_brightness
|
||||
weights = [int(fm.PALETTE[fm.RAMP + i].astype(int).sum())
|
||||
for i in range(fm.RAMP_STEPS)]
|
||||
assert weights == sorted(weights), f"{name} is not monotonic"
|
||||
assert weights[-1] > weights[0] * 3
|
||||
|
||||
|
||||
def test_the_default_theme_reads_height_as_hue_instead():
|
||||
fm.set_theme("night")
|
||||
assert not fm.THEME.height_is_brightness
|
||||
|
||||
|
||||
def test_every_theme_fills_the_palette_without_running_off_the_end():
|
||||
for name in themes.THEMES:
|
||||
fm.set_theme(name)
|
||||
assert fm.PALETTE.shape == (256, 3)
|
||||
assert fm.GLOW + 6 <= 256
|
||||
# Nothing drawn with is left as the black the unused tail is.
|
||||
for index in (fm.INK, fm.AIRPORT, fm.RAMP, fm.GROUND + 31):
|
||||
assert fm.PALETTE[index].any(), (name, index)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The glow
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _picture(width=80, height=60):
|
||||
return np.full((height, width), fm.BG, dtype=np.uint8)
|
||||
|
||||
|
||||
def test_the_default_theme_has_no_glow_at_all():
|
||||
fm.set_theme("night")
|
||||
img = _picture()
|
||||
img[30, 10:70] = fm.RAMP + 20
|
||||
assert np.array_equal(fm.bloom(img), img)
|
||||
|
||||
|
||||
def test_a_line_on_a_vector_theme_gets_a_halo_either_side_of_it():
|
||||
fm.set_theme("phosphor")
|
||||
img = _picture()
|
||||
img[30, 10:70] = fm.RAMP + 20
|
||||
out = fm.bloom(img)
|
||||
assert (out[30, 10:70] == fm.RAMP + 20).all(), "the core was painted over"
|
||||
assert (out[29, 10:70] == fm.TRAIL + 20).all(), "no halo above the line"
|
||||
assert (out[31, 10:70] == fm.TRAIL + 20).all(), "no halo below it"
|
||||
assert (out[28, 10:70] == fm.OLD + 20).all(), "no second, fainter ring"
|
||||
assert (out[27, 10:70] == fm.BG).all(), "the halo reaches too far"
|
||||
|
||||
|
||||
def test_the_halo_is_the_colour_of_the_thing_that_cast_it():
|
||||
"""A green aeroplane glows green and a red one red: the halo is the
|
||||
aircraft's own dimmed colour, which is what a phosphor would spread."""
|
||||
fm.set_theme("digital")
|
||||
for step in (0, 12, 31):
|
||||
img = _picture()
|
||||
img[30, 10:70] = fm.RAMP + step
|
||||
out = fm.bloom(img)
|
||||
assert (out[29, 10:70] == fm.TRAIL + step).all()
|
||||
|
||||
|
||||
def test_a_halo_never_paints_over_something_else_that_was_drawn():
|
||||
"""A halo is what light does to the dark around a line. Painting it
|
||||
over another line would be light doing something light does not do."""
|
||||
fm.set_theme("phosphor")
|
||||
img = _picture()
|
||||
img[30, 10:70] = fm.RAMP + 20 # an aeroplane
|
||||
img[29, 10:70] = fm.AIRPORT # an aerodrome right beside it
|
||||
out = fm.bloom(img)
|
||||
assert (out[29, 10:70] == fm.AIRPORT).all()
|
||||
|
||||
|
||||
def test_the_halo_does_not_wrap_round_the_edge_of_the_picture():
|
||||
"""Rolled and then cut: without the cut, a line down the left edge
|
||||
would glow on the right edge of the picture."""
|
||||
fm.set_theme("amber")
|
||||
img = _picture()
|
||||
img[:, 0] = fm.RAMP + 20
|
||||
out = fm.bloom(img)
|
||||
assert (out[:, 1] == fm.TRAIL + 20).all()
|
||||
assert (out[:, -1] == fm.BG).all()
|
||||
assert (out[:, -2] == fm.BG).all()
|
||||
|
||||
tall = _picture()
|
||||
tall[0, :] = fm.AIRPORT
|
||||
assert (fm.bloom(tall)[-1, :] == fm.BG).all()
|
||||
|
||||
|
||||
def test_the_halo_goes_over_the_map_and_the_grid_but_not_the_aircraft():
|
||||
fm.set_theme("digital")
|
||||
img = np.full((60, 80), fm.GROUND + 10, dtype=np.uint8)
|
||||
img[30, 40] = fm.INK
|
||||
out = fm.bloom(img)
|
||||
assert out[30, 41] != fm.GROUND + 10, "no halo over the map"
|
||||
assert out[30, 40] == fm.INK
|
||||
|
||||
|
||||
def test_the_nearer_ring_wins_where_two_meet():
|
||||
"""Which is what happens on the tube as well."""
|
||||
fm.set_theme("phosphor")
|
||||
img = _picture()
|
||||
img[30, 40] = fm.RAMP + 20
|
||||
out = fm.bloom(img)
|
||||
assert out[31, 40] == fm.TRAIL + 20 # near
|
||||
assert out[32, 40] == fm.OLD + 20 # far
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# What else a vector theme changes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_a_phosphor_theme_names_the_country_instead_of_drawing_its_flag():
|
||||
"""A flag is half a dozen colours and a phosphor screen has one. Two
|
||||
letters are what a display of the period would have done anyway."""
|
||||
from bandsaunter.flags import FLAG_H, FLAG_W
|
||||
|
||||
fm.set_theme("night")
|
||||
flagged = np.full((40, 60), fm.BG, dtype=np.uint8)
|
||||
fm.draw_flag(flagged, 5, 5, "US")
|
||||
patch = flagged[5:5 + FLAG_H, 5:5 + FLAG_W]
|
||||
assert ((patch >= fm.FLAG) & (patch < fm.FLAG + 12)).any()
|
||||
|
||||
fm.set_theme("phosphor")
|
||||
lettered = np.full((40, 60), fm.BG, dtype=np.uint8)
|
||||
fm.draw_flag(lettered, 5, 5, "US")
|
||||
patch = lettered[5:5 + FLAG_H, 5:5 + FLAG_W]
|
||||
assert not ((patch >= fm.FLAG) & (patch < fm.FLAG + 12)).any()
|
||||
assert (lettered == fm.DIM).any(), "the letters were not drawn either"
|
||||
|
||||
|
||||
def test_a_vector_theme_pushes_the_map_underneath_well_back():
|
||||
"""In the middle of the range, where the setting usually sits: a tinted
|
||||
photograph of a county behind the vectors is the one thing that stops a
|
||||
vector display looking like one."""
|
||||
levels = np.full((8, 8), fm.GROUND_SHADES - 1, dtype=np.uint8)
|
||||
fm.set_theme("night")
|
||||
plain = int(fm.dim_ground(levels, 0.7).max())
|
||||
fm.set_theme("digital")
|
||||
quiet = int(fm.dim_ground(levels, 0.7).max())
|
||||
assert quiet < plain * 0.6, f"{quiet} is not much darker than {plain}"
|
||||
|
||||
|
||||
def test_turning_the_brightness_the_whole_way_up_works_on_every_theme():
|
||||
"""A curve, not a ceiling. Multiplied by a quarter instead, the setting
|
||||
could not reach a visible map at all on a vector theme: turned the whole
|
||||
way up it still came out at a tenth of what the default theme gives,
|
||||
which is to say invisible."""
|
||||
levels = np.full((8, 8), fm.GROUND_SHADES - 1, dtype=np.uint8)
|
||||
for name in themes.THEMES:
|
||||
fm.set_theme(name)
|
||||
assert int(fm.dim_ground(levels, 1.0).max()) == fm.GROUND_SHADES - 1, \
|
||||
f"{name} cannot reach a full-brightness map"
|
||||
|
||||
|
||||
def test_the_brightness_setting_climbs_the_whole_way_on_a_vector_theme():
|
||||
levels = np.full((8, 8), fm.GROUND_SHADES - 1, dtype=np.uint8)
|
||||
fm.set_theme("phosphor")
|
||||
steps = [int(fm.dim_ground(levels, b).max())
|
||||
for b in (0.1, 0.3, 0.5, 0.7, 0.85, 1.0)]
|
||||
assert steps == sorted(steps), steps
|
||||
assert steps[-1] > steps[0] * 8, steps
|
||||
# And the middle of the range is still quiet, which is the look.
|
||||
assert steps[3] < steps[-1] * 0.4, steps
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The leader line, and the flag on the receiver
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_no_theme_lets_a_leader_line_be_mistaken_for_a_flight_path():
|
||||
"""The whole reason it stopped being drawn in the aircraft's colour: a
|
||||
straight line running out of an aeroplane, in the colour of the path
|
||||
behind that aeroplane, reads as more path."""
|
||||
for name in themes.THEMES:
|
||||
fm.set_theme(name)
|
||||
leader = _lab(fm.PALETTE[fm.LEADER])
|
||||
apart = min(float(np.linalg.norm(leader - _lab(fm.PALETTE[fm.RAMP + i])))
|
||||
for i in range(fm.RAMP_STEPS))
|
||||
assert apart > 25, f"{name}: only {apart:.0f} units from a trail"
|
||||
|
||||
|
||||
def test_the_receiver_flag_is_red_whatever_the_theme_is():
|
||||
""""You are here" is the one mark on the picture whose meaning must not
|
||||
change with the colours, so it does not take the theme's."""
|
||||
for name in themes.THEMES:
|
||||
fm.set_theme(name)
|
||||
assert tuple(fm.PALETTE[fm.HOME]) == fm.HOME_RED
|
||||
|
||||
|
||||
def test_the_flag_stands_clear_of_the_aircraft_on_every_theme():
|
||||
"""Including the red one, which is the hard case and the reason the
|
||||
flag is pure red rather than a softer one: a softened red sat close
|
||||
enough to a low aeroplane on the default map, and to a mid-altitude one
|
||||
on the red theme, to be taken for one."""
|
||||
for name in themes.THEMES:
|
||||
fm.set_theme(name)
|
||||
red = _lab(fm.PALETTE[fm.HOME])
|
||||
apart = min(float(np.linalg.norm(red - _lab(fm.PALETTE[fm.RAMP + i])))
|
||||
for i in range(fm.RAMP_STEPS))
|
||||
assert apart > 20, f"{name}: only {apart:.0f} units from an aircraft"
|
||||
|
||||
|
||||
def test_the_flag_stands_on_the_spot_rather_than_covering_it():
|
||||
"""The foot of the pole is the position. A blob would put the position
|
||||
somewhere inside itself."""
|
||||
img = np.full((40, 40), fm.BG, dtype=np.uint8)
|
||||
fm.draw_home(img, 20, 34)
|
||||
assert img[34, 20] == fm.HOME, "the pole does not stand on the spot"
|
||||
assert img[35, 20] == fm.BG, "something is drawn below the position"
|
||||
assert (img[34, :20] == fm.BG).all(), "the flag reaches left of the pole"
|
||||
# The pennant flies up and to the right, and nothing else does.
|
||||
top = 34 - fm.HOME_POLE
|
||||
assert (img[top, 21:21 + fm.HOME_FLY] == fm.HOME).all()
|
||||
assert (img[34 - 1, 21:] == fm.BG).all(), "the pennant hangs to the foot"
|
||||
|
||||
|
||||
def test_the_flag_off_the_edge_of_the_picture_paints_nothing_absurd():
|
||||
for x, y in ((-50, 20), (20, -50), (200, 20), (20, 200)):
|
||||
img = np.full((40, 40), fm.BG, dtype=np.uint8)
|
||||
fm.draw_home(img, x, y) # must not raise or wrap
|
||||
assert img.shape == (40, 40)
|
||||
Loading…
Add table
Add a link
Reference in a new issue