diff --git a/.gitignore b/.gitignore
index d85aae5..e5a6c1a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,6 +16,3 @@ recordings/
# local environments
.venv/
venv/
-
-# a personal helper, not part of the program
-resume.sh
diff --git a/INSTALL.md b/INSTALL.md
index baa9e40..2062e0f 100644
--- a/INSTALL.md
+++ b/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 | | `BANDSAUNTER_AEROAPI_KEY` |
-| Flightradar24 | | `BANDSAUNTER_FR24_TOKEN` |
-| OAG Flight Info | | `BANDSAUNTER_OAG_KEY` |
-| Cirium (FlightStats) | | `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
diff --git a/README.md b/README.md
index deb7624..3b6c3e5 100644
--- a/README.md
+++ b/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
diff --git a/bandsaunter/__init__.py b/bandsaunter/__init__.py
index 2f8d679..81a1c99 100755
--- a/bandsaunter/__init__.py
+++ b/bandsaunter/__init__.py
@@ -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}"
diff --git a/bandsaunter/adsb.py b/bandsaunter/adsb.py
index e3bca5c..e368035 100644
--- a/bandsaunter/adsb.py
+++ b/bandsaunter/adsb.py
@@ -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,73 +438,19 @@ class AircraftRegistry:
seen._odd = frame
else:
seen._even = frame
- self._place(seen)
+ 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
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):
- 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)))
-
def described(self) -> list[str]:
return [craft.describe() for craft in
sorted(self.aircraft.values(), key=lambda a: a.icao)]
diff --git a/bandsaunter/aircraft.py b/bandsaunter/aircraft.py
deleted file mode 100644
index e233f83..0000000
--- a/bandsaunter/aircraft.py
+++ /dev/null
@@ -1,1099 +0,0 @@
-"""Listening to aircraft, and drawing where they went, from either front end.
-
-The command line and the menus do the same two things here -- park on 1090 MHz
-and write down what arrives, then turn a log of that into a map -- so the doing
-of it lives here and both front ends call it. Neither imports the other, and
-the options are described once, in the same shape as every other setting in the
-program, so the menu can print help for each one without knowing what any of
-them mean.
-
-ADS-B does not go through the scanner and cannot be made to: it is a megabit a
-second, which needs at least two megasamples a second of raw receiver output,
-and the scan path decimates everything to a channel twelve and a half kilohertz
-wide long before a decoder sees it. A scan of 1090 MHz records the envelope of
-the bursts as clicks in a WAV file and decodes nothing, which is the mistake
-:func:`scanning_aircraft_band` exists to catch.
-"""
-
-from __future__ import annotations
-
-import time
-from dataclasses import asdict, dataclass, field
-from datetime import datetime
-from pathlib import Path
-
-import yaml
-
-from .adsb import ADSB_HZ, SAMPLE_RATE
-from .flightlog import read_position
-from .settings import Setting, format_value
-
-__all__ = ["AircraftOptions", "OPTIONS", "listen", "watch", "draw",
- "logs_in", "open_device", "open_log", "pump", "finish",
- "windowed",
- "format_option",
- "load_options", "save_options", "options_path",
- "scanning_aircraft_band", "AIRCRAFT_BANDS", "SCAN_WARNING"]
-
-
-# ---------------------------------------------------------------------------
-# The bands that look like a scan and are not one
-# ---------------------------------------------------------------------------
-
-# (name, low, high, what to do instead)
-AIRCRAFT_BANDS = (
- ("ADS-B", 1_089_000_000.0, 1_091_000_000.0, "bandsaunter adsb"),
- ("UAT / ADS-B 978", 977_000_000.0, 979_000_000.0, None),
-)
-
-SCAN_WARNING = (
- "These ranges cover a band the scanner cannot decode: {bands}. "
- "The signalling is a megabit a second and the scan path is 12.5 kHz "
- "wide, so a scan of it records clicks and finds no aircraft."
-)
-
-
-def scanning_aircraft_band(ranges) -> str:
- """Warn when a sweep covers a band that needs the ADS-B mode instead.
-
- Returns the warning to print, or an empty string. The band plan lists
- 1090 MHz because that is where ADS-B is, and choosing it from the band
- plan is the obvious thing to do and the wrong one; saying so before the
- sweep starts costs a line and saves an evening.
- """
- hit = []
- for name, low, high, _ in AIRCRAFT_BANDS:
- for r in ranges or ():
- start = float(getattr(r, "start", 0.0) or 0.0)
- stop = float(getattr(r, "stop", start) or start)
- if start <= high and stop >= low:
- hit.append(name)
- break
- if not hit:
- return ""
- return SCAN_WARNING.format(bands=", ".join(hit))
-
-
-# ---------------------------------------------------------------------------
-# The options, described the way every other setting is
-# ---------------------------------------------------------------------------
-
-@dataclass
-class AircraftOptions:
- """Everything the aircraft mode can be told, in one place.
-
- Kept apart from :class:`~bandsaunter.config.ScanConfig` because none of it
- controls a scan: a sweep has no sample rate this high, no animation and no
- aircraft. Saved in its own small file for the same reason.
- """
-
- # -- listening ------------------------------------------------------
- seconds: float = 0.0
- rate: float = float(SAMPLE_RATE)
- gain: str = "auto"
- device: int = 0
- frames: bool = False
- log: bool = True
- lookup: bool = True
- schedules: str = ""
- kml: bool = False
- hold: float = 45.0
- speed_unit: str = "knots"
- draw_after: bool = False
- simulate: bool = False
- near: str = "47.55,-122.30"
-
- # -- drawing --------------------------------------------------------
- picture: str = "gif"
- length: float = 30.0
- speed: float = 0.0
- fps: float = 12.0
- width: int = 960
- trail: float = 0.0
- stale: float = 300.0
- fade: float = 20.0
- labels: bool = True
- radius: float = 100.0
- location: str = ""
- recheck: bool = False
- basemap: bool = True
- airports: bool = True
- rings: bool = True
- window_rings: bool = True
- theme: str = "night"
- box_opacity: int = 85
- map_brightness: int = 70
- tile_url: str = ""
-
- def validate(self) -> list[str]:
- out = []
- if self.rate < SAMPLE_RATE:
- out.append(f"sample rate must be at least {SAMPLE_RATE/1e6:g} MS/s "
- "or a bit is too narrow to see")
- if self.fps <= 0:
- out.append("frames a second must be more than zero")
- if self.width < 160:
- out.append("the picture must be at least 160 pixels across")
- if self.seconds < 0 or self.length <= 0:
- out.append("times cannot be negative")
- return out
-
- def to_dict(self) -> dict:
- return asdict(self)
-
-
-O = Setting
-
-OPTIONS: tuple[Setting, ...] = ( O("device", "Receiver", "Receiver", "int",
- "which receiver to use, when more than one is plugged in",
- "The index shown by `bandsaunter devices`. Zero unless you have "
- "several dongles.",
- minimum=0, flags=("--device",), example="0"),
- O("gain", "Gain", "Receiver", "gain",
- "tuner gain in dB, or automatic",
- "ADS-B is a weak burst from a long way off, and the automatic gain "
- "control usually does well enough. A fixed high gain can hear more "
- "where there is nothing strong nearby to overload the front end.",
- flags=("--gain",), example="auto"),
- O("rate", "Sample rate", "Receiver", "float",
- "how fast to sample; two megasamples a second is the minimum",
- "One microsecond per bit means two samples per bit at 2 MS/s, which is "
- "the least that can read one. Higher rates decode a little more of the "
- "weak traffic and cost proportionally more processing.",
- unit="Hz", minimum=float(SAMPLE_RATE), flags=("--rate",),
- example="2000000"),
- O("location", "Receiver at", "Receiver", "text",
- "where the receiver is, as latitude,longitude (blank = work it out)",
- "The centre of the map. Left blank it is taken from the middle of "
- "everything heard, which is very close to right: a receiver hears "
- "aircraft all around it, and the middle is a median rather than an "
- "average, so a handful of wrong positions cannot drag it anywhere. "
- "Setting it explicitly is worth doing if you want the same frame every "
- "night regardless of which way the traffic went.",
- flags=("--at",), example="32.54,-111.17", metavar="LAT,LON"),
- O("simulate", "Invent a sky", "Receiver", "bool",
- "fly imaginary aircraft past an imaginary receiver",
- "Six aircraft that are not there, broadcasting real frames with real "
- "checksums through the real decoder. Nothing touches the receiver, so "
- "the log, the lookups, the report and the map can all be tried before "
- "an aerial exists.",
- flags=("--simulate",),
- guidance="Turn this on to see what the whole thing does without "
- "hardware. Turn it off to hear real aircraft."),
- O("near", "Imaginary sky near", "Receiver", "text",
- "where the simulated aircraft are flying",
- "Latitude and longitude, as two numbers. Only used when the sky is "
- "invented; it decides where the map ends up centred.",
- flags=("--near",), example="47.55,-122.30", metavar="LAT,LON"),
- # -- listening ------------------------------------------------------
- O("seconds", "Listen for", "Listening", "float",
- "how long to listen before stopping (0 = until interrupted)",
- "A whole evening is 0: listening runs until control-C, and everything "
- "heard is on the disk as it arrives, so stopping never loses anything. "
- "A number is useful for a quick look at whether the aerial hears "
- "anything at all.",
- unit="s", minimum=0.0, flags=("--seconds",), example="600",
- guidance="Sixty seconds is enough to know whether aircraft are being "
- "heard. An evening of traffic wants no limit."),
- O("frames", "Show every frame", "Listening", "bool",
- "print each frame as it arrives, rather than a running count",
- "Every frame, with what was read out of it. Useful once, to see that "
- "it is working; unreadable for an evening.",
- flags=("--frames",)),
- O("log", "Write the log", "Listening", "bool",
- "write every frame to a file as it arrives",
- "The JSON Lines log is what the report and the map are made from, and "
- "it holds the raw hexadecimal of every frame beside what was decoded "
- "from it. Turning this off leaves nothing behind but the screen.",
- flags=("--log",), off_flags=("--no-log",)),
- O("kml", "Also write a KML", "Listening", "bool",
- "write the flight paths for Google Earth as well",
- "One line per aircraft on the globe, with a pin where it was last "
- "heard, in a .kml beside the log.",
- flags=("--kml",)),
- O("hold", "Keep on screen for", "Listening", "float",
- "how long an aircraft stays on the display after its last frame",
- "An aircraft that has gone out of range stops sending, and its line "
- "would otherwise sit there for the rest of the evening saying the same "
- "thing. When nothing has been heard from it for this long the line is "
- "removed and everything below it moves up. Nothing is lost by it: the "
- "log holds every frame, and the report at the end lists every aircraft "
- "heard.",
- unit="s", minimum=1.0, example="45",
- guidance="Long enough that a gap in reception does not make rows jump "
- "about; short enough that the screen is the sky now.",
- flags=("--hold",), metavar="SECONDS"),
- O("draw_after", "Draw when finished", "Listening", "bool",
- "draw the map as soon as the listening stops",
- "Saves running the map separately. It uses the drawing options below.",
- flags=("--map",)),
- O("lookup", "Look the aircraft up", "Aircraft", "bool",
- "ask the public registers who each aircraft is",
- "Two registers are asked -- adsbdb, then hexdb -- for the "
- "registration, the type, the operator and the route, and the answers "
- "are cached for a month. Only the address and callsign heard on the "
- "air are ever sent. What the address block and the callsign say on "
- "their own is worked out offline either way.",
- flags=("--lookup",), off_flags=("--no-lookup",)),
- O("schedules", "Schedule services", "Aircraft", "text",
- "which paid schedule services to ask, in order (blank = all with keys)",
- "A callsign is a flight number and an airline runs the same number over "
- "several legs in a day, so the free route databases -- which hold one "
- "route per number -- often name somebody else's leg. A commercial "
- "schedule service holds the timetable and the day's movements and can "
- "say which leg is in the air now. Four are wired up: flightaware, "
- "flightradar24, oag and cirium. Each wants a key, none is required, "
- "and a service with no key is skipped in silence. Keys are read from "
- "the environment rather than kept here, because a settings file gets "
- "copied between machines and pasted into messages asking for help: "
- "BANDSAUNTER_AEROAPI_KEY, BANDSAUNTER_FR24_TOKEN, BANDSAUNTER_OAG_KEY, "
- "and BANDSAUNTER_CIRIUM_APP_ID with BANDSAUNTER_CIRIUM_APP_KEY.",
- flags=("--schedules",), metavar="NAMES",
- example="flightaware,cirium",
- guidance="Leave it blank unless you want one service tried before "
- "another. With no keys set, nothing changes."),
- O("recheck", "Check the positions", "Aircraft", "bool",
- "throw out positions the aircraft could not have been in",
- "For logs recorded before the decoder checked how old the two halves "
- "of a position were. A compact-position report is half a position: an "
- "even frame and an odd one, and the pair only means anything while the "
- "aircraft has not moved between them. An even frame kept from ten "
- "minutes ago decodes against a fresh odd one to a place on the wrong "
- "side of the world, and that gets written down as confidently as a "
- "real position. This reads the log back and keeps, for each aircraft, "
- "the longest run of positions that could describe one aeroplane. "
- "Nothing is changed in the log itself.",
- flags=("--recheck",),
- guidance="Worth turning on for anything recorded before this version. "
- "Newer logs have the check applied as they are written, so it "
- "finds almost nothing."),
- # -- drawing --------------------------------------------------------
- O("picture", "Picture", "Animation", "choice",
- "what kind of picture to draw",
- "gif is an animation that plays anywhere and needs nothing installed. "
- "mp4 is smaller and smoother but needs ffmpeg. png is one still "
- "picture of the whole session, every path drawn at once.",
- choices=("gif", "mp4", "png"), flags=("--out",),
- guidance="Start with gif. Use png when you want one picture to look at "
- "or send."),
- O("length", "Animation length", "Animation", "float",
- "how long the animation should run for",
- "The whole session is fitted into this many seconds, so an evening of "
- "flying plays in half a minute. Ignored when a speed is given.",
- unit="s", minimum=1.0, flags=("--seconds",), example="30"),
- O("speed", "Speed", "Animation", "float",
- "seconds of flying per second of animation (0 = fit to the length)",
- "60 means a minute of real flying every second. Setting this overrides "
- "the length above: a long session simply makes a longer animation.",
- unit="x", minimum=0.0, flags=("--speed",), example="60"),
- O("fps", "Frames a second", "Animation", "float",
- "how many frames of animation each second holds",
- "Twelve is smooth enough for aircraft, which do not move quickly on a "
- "map. A GIF can only hold whole hundredths of a second per frame, so "
- "the real rate is rounded to the nearest one it can express.",
- minimum=1.0, flags=("--fps",), example="12"),
- O("width", "Picture width", "Animation", "int",
- "how many pixels across the picture is",
- "The height follows from the shape of the area the aircraft covered, "
- "so that a mile across looks like a mile up the picture.",
- unit="px", minimum=160, flags=("--width",), example="960"),
- O("trail", "Trail", "Animation", "float",
- "how much of the path to leave behind each aircraft (0 = all of it)",
- "The whole flight is drawn by default, which is what makes the picture "
- "a map of the evening rather than a snapshot. A number of seconds "
- "leaves a comet tail instead, which is easier to follow when many "
- "aircraft cross the same piece of sky.",
- unit="s", minimum=0.0, flags=("--trail",), example="0"),
- O("fade", "Fade out over", "Animation", "float",
- "how long an aircraft takes to fade away once it has gone quiet",
- "An aircraft that stops transmitting has not stopped existing, and "
- "taking it off the picture between one frame and the next says that it "
- "did. Instead it is left where it was last actually seen and fades from "
- "there, which reads as an aircraft going quiet rather than as a blink. "
- "Nothing is invented by it: the fading happens at the last known "
- "position, never at a reckoned one, because the reason for giving up on "
- "an aircraft in the first place is that where it would be by now is a "
- "guess. Zero takes it away the moment it is given up on.",
- unit="s", minimum=0.0, flags=("--fade",), example="20",
- guidance="Long enough to notice, short enough that a busy sky is not "
- "half ghosts."),
- O("stale", "Forget after", "Animation", "float",
- "stop drawing an aircraft this long after its last report",
- "Between reports an aircraft is dead-reckoned from the speed and "
- "heading it last gave. After a few minutes of that it has flown fifty "
- "miles on a guess, so it is dropped instead of invented.",
- unit="s", minimum=1.0, flags=("--stale",), example="300"),
- O("basemap", "Map underneath", "The map", "bool",
- "draw a real map under the flight paths",
- "A flight path over a black rectangle says how the aircraft moved and "
- "nothing about where it was; over a coastline it says which airport it "
- "left. The map is fetched from a standard tile server the first time an "
- "area is drawn and kept on the disk afterwards, so drawing the same "
- "evening again costs nothing and needs no network. A few dozen tiles "
- "at most, dimmed so the aircraft stay the brightest thing on the "
- "picture, and the credit the tiles require is written on it.",
- flags=("--basemap",), off_flags=("--no-basemap",),
- guidance="Turn it off for a picture with nothing but the tracks on it, "
- "or where there is no network and no cached tiles."),
- O("tile_url", "Tile server", "The map", "text",
- "where the map tiles come from",
- "Any server that serves 256-pixel tiles as {z}/{x}/{y}.png will do, "
- "including one of your own. The default is the standard "
- "OpenStreetMap one, whose tiles are free to use within its usage "
- "policy: identify yourself, cache what you fetch, and do not bulk "
- "download. This program does all three.",
- flags=("--tiles",), metavar="URL",
- example="https://tile.openstreetmap.org/{z}/{x}/{y}.png"),
- O("theme", "Colour theme", "The map", "choice",
- "how the map looks: the colours, and whether the lines glow",
- "The default draws 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. The rest are the screens the phrase 'air "
- "defence display' calls to mind: a black tube, one phosphor, and thin "
- "bright vector lines with a halo round them. Those have one colour to "
- "spend, so height is brightness instead -- low is dim, high burns -- "
- "the ground underneath is pushed well back so the lines carry the "
- "picture, and a country is named in two letters rather than drawn as "
- "a flag, because a flag needs half a dozen colours and a phosphor has "
- "one. It applies to the window and to the animated pictures alike.",
- choices=("night", "digital", "phosphor", "amber", "red"),
- flags=("--theme",), metavar="NAME", example="phosphor",
- guidance="night to read it, the others to look at it."),
- O("map_brightness", "Map brightness", "The map", "int",
- "how bright the map under the aircraft is drawn, as a percentage",
- "The map is the ground, not the subject, so it is drawn dark enough "
- "that the aircraft and their trails stay the brightest things on the "
- "picture. Too dark and a coastline cannot be made out at all; too "
- "bright and a city washes out the aircraft crossing it. This is which "
- "way to err on the screen you are actually looking at. The vector "
- "themes bend the middle of this 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 -- but the top of the range "
- "is a full-brightness map on every theme, so if the ground is barely "
- "visible this is the setting that fixes it.",
- unit="%", minimum=10, maximum=100, flags=("--map-brightness",),
- metavar="PERCENT", example="70",
- guidance="Turn it up until the coast and the roads are readable, and "
- "no further. On a vector theme it takes rather more turning "
- "up than on the default one."),
- O("radius", "Map radius", "The map", "float",
- "how far around the receiver the map reaches (0 = fit whatever was heard)",
- "An aerial hears a hundred miles on a good day, and a position that "
- "decoded wrongly can land anywhere on Earth. A map drawn to fit "
- "everything heard is therefore drawn to fit the mistakes: the aircraft "
- "come out a pixel wide in the middle of an empty continent. This frames "
- "the picture on the receiver instead, so the scale stays the same from "
- "one evening to the next and anything further out is left off the edge. "
- "In the same unit as the speeds -- nautical miles with knots, statute "
- "miles with mph, kilometres with kph.",
- minimum=0.0, flags=("--radius",), example="100",
- guidance="Set it to what your aerial can really hear. Zero goes back "
- "to fitting whatever turned up, mistakes and all."),
- O("airports", "Mark the airports", "The map", "bool",
- "mark every aerodrome on the map, not only the ones flown between",
- "A route names the two airports its aircraft is flying between, and "
- "those are almost never the ones underneath: a receiver hears aircraft "
- "over its own county, and the county's airports are what say where on "
- "the map you are looking. They are asked for once per area from the "
- "same map data the tiles are drawn from, and kept on disk for a month "
- "afterwards, since a runway does not move.",
- flags=("--airports",), off_flags=("--no-airports",),
- guidance="Turn it off for a picture with nothing but the aircraft on "
- "it, or where there is no network and nothing cached."),
- O("rings", "Range rings on the pictures", "The map", "bool",
- "faint discs at a quarter, a half and three quarters of the radius",
- "Concentric on the receiver and translucent, so that they stack: 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 a thing "
- "is without measuring anything -- an aircraft two shades in is about "
- "halfway to the edge of what this receiver hears. Each is labelled "
- "with its distance. They need a receiver position and a radius, and "
- "are not drawn without both.",
- flags=("--rings",), off_flags=("--no-rings",),
- guidance="Turn it off for a picture with nothing on it but the "
- "aircraft and the ground."),
- O("window_rings", "Range rings in the window", "The map", "bool",
- "the same discs on the realtime display",
- "The same rings as the pictures get, on the window instead. They are "
- "separate settings because the two are looked at differently: a "
- "picture is studied and a window is glanced at, and the rings help "
- "one more than the other depending on which you are doing.",
- flags=("--window-rings",), off_flags=("--no-window-rings",),
- guidance="Turn it off if the window is busy enough already."),
- O("box_opacity", "Box translucency", "Labels", "int",
- "how solid the card behind each information box is, as a percentage",
- "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 as they used to; at the whole way the map does not show "
- "through at all and the box is a solid panel. In between it darkens "
- "the ground under the box, so the coastline still shows faintly "
- "through it. It applies to the window and to the animated pictures "
- "alike -- the pictures had no card at all before, and setting this "
- "to nothing is what they used to look like.",
- unit="%", minimum=0, maximum=100, flags=("--box-opacity",),
- metavar="PERCENT", example="85",
- guidance="Turn it up over a busy map and down over an empty one."),
- O("labels", "Label the aircraft", "Labels", "bool",
- "write the callsign, height and speed beside each aircraft",
- "Height is the flight level -- hundreds of feet -- the way it is said "
- "on the radio. Turning labels off leaves the shapes of the traffic, "
- "which is worth seeing on a busy evening.",
- flags=("--labels",), off_flags=("--no-labels",)),
- O("speed_unit", "Speed in", "Labels", "choice",
- "what to show speeds and distances in",
- "Aircraft broadcast knots and the log keeps knots, because that is "
- "what the standard sends; this is the unit they are shown in. It "
- "changes the heading of the live display, the speeds written beside "
- "each aircraft on the map and in the report, and the distance unit "
- "that goes with them -- nautical miles with knots, statute miles with "
- "miles an hour, kilometres with km/h, so that one picture never "
- "carries two different miles.",
- choices=("knots", "mph", "kph"),
- flags=("--speed-unit",), metavar="UNIT",
- guidance="knots is what aviation uses and what the aircraft actually "
- "said. mph or kph if that is what means something to you."),
-)
-
-OPTION_GROUPS = ("Receiver", "Listening", "Aircraft", "Animation", "The map", "Labels")
-
-
-def in_group(group: str) -> list[Setting]:
- return [o for o in OPTIONS if o.group == group]
-
-
-def by_key(key: str) -> Setting | None:
- return next((o for o in OPTIONS if o.key == key), None)
-
-
-# What a zero means, per option: "0 s" is true and unhelpful.
-_ZERO_MEANS = {"seconds": "until stopped", "speed": "fit to the length",
- "trail": "the whole path"}
-
-
-def format_option(option: Setting, value) -> str:
- """Render an option the way the menu should show it."""
- if not value and option.key in _ZERO_MEANS:
- return _ZERO_MEANS[option.key]
- return format_value(option, value)
-
-
-def describe(options: AircraftOptions) -> str:
- """One line for a menu: what listening and drawing would do now."""
- how_long = ("until stopped" if not options.seconds
- else f"{options.seconds:g} s")
- where = "simulated" if options.simulate else "1090 MHz"
- return (f"{where}, {how_long}, "
- f"{'looked up' if options.lookup else 'no lookups'}, "
- f"{options.picture}")
-
-
-# ---------------------------------------------------------------------------
-# Where the options are kept
-# ---------------------------------------------------------------------------
-
-def options_path(directory=None) -> Path:
- from .config import DEFAULT_CONFIG_DIR
-
- return Path(directory or DEFAULT_CONFIG_DIR) / "aircraft.yaml"
-
-
-def load_options(directory=None) -> AircraftOptions:
- """The saved options, or the defaults. A broken file is not an error."""
- options = AircraftOptions()
- try:
- body = yaml.safe_load(options_path(directory).read_text()) or {}
- except (OSError, ValueError, yaml.YAMLError):
- # A hand-edited file with a typo in it should cost the defaults, not
- # the menu it is read from.
- return options
- if not isinstance(body, dict):
- return options
- known = set(options.__dict__)
- for key, value in body.items():
- if key in known and value is not None:
- try:
- setattr(options, key, type(getattr(options, key))(value))
- except (TypeError, ValueError):
- pass
- return options
-
-
-def save_options(options: AircraftOptions, directory=None) -> Path:
- path = options_path(directory)
- path.parent.mkdir(parents=True, exist_ok=True)
- with open(path, "w") as fh:
- yaml.safe_dump(options.to_dict(), fh, sort_keys=False,
- default_flow_style=False)
- return path
-
-
-def logs_in(directory) -> list[Path]:
- """Every ADS-B log in a directory, newest first."""
- try:
- found = list(Path(directory).expanduser().glob("adsb_*.jsonl"))
- except OSError:
- return []
- return sorted(found, key=lambda p: p.stat().st_mtime, reverse=True)
-
-
-def coordinates(text: str) -> tuple[float, float]:
- """A LAT,LON pair, falling back to the default sky."""
- try:
- lat, lon = (float(x) for x in str(text).split(",", 1))
- return lat, lon
- except (TypeError, ValueError):
- return 47.55, -122.30
-
-
-# ---------------------------------------------------------------------------
-# Listening
-# ---------------------------------------------------------------------------
-
-@dataclass
-class Heard:
- """What one listening session came to."""
-
- frames: int = 0
- registry: object = None
- log_path: Path | None = None
- report_path: Path | None = None
- kml_path: Path | None = None
- picture: object = None
- tracks: list = field(default_factory=list)
-
- @property
- def aircraft(self) -> int:
- return len(self.registry) if self.registry is not None else 0
-
-
-def windowed() -> bool:
- """Whether the realtime window can be opened on this machine."""
- from . import livemap
-
- return livemap.available()
-
-
-def open_device(console, options: AircraftOptions):
- """The receiver, or an invented sky, or None if neither can be had."""
- from rich.panel import Panel
- from rich.text import Text
-
- from .adsb import SimulatedSky, default_sky
- from .device import RtlSdrDevice, RtlSdrError
-
- if options.simulate:
- console.print("[yellow]simulated: these aircraft are not there."
- "[/yellow]")
- return SimulatedSky(default_sky(*coordinates(options.near)),
- sample_rate=options.rate, realtime=True).open()
- try:
- device = RtlSdrDevice(index=options.device,
- sample_rate=int(options.rate),
- gain=options.gain,
- agc=options.gain == "auto")
- device.open()
- except RtlSdrError as exc:
- console.print(Panel(Text(str(exc)),
- title="[red]cannot open the receiver",
- border_style="red"))
- return None
- return device
-
-
-def open_log(console, options: AircraftOptions, output_dir: str,
- started: float, log_path=None):
- """The frame log, or None if it was not wanted or cannot be written."""
- from .flightlog import FlightLog
-
- if not options.log:
- return None
- stamp = datetime.fromtimestamp(started).strftime("%Y-%m-%d_%H_%M_%S")
- where = Path(log_path).expanduser() if log_path else \
- Path(output_dir).expanduser() / f"adsb_{stamp}.jsonl"
- try:
- return FlightLog(where, frequency=ADSB_HZ, sample_rate=options.rate,
- receiver="simulated" if options.simulate else
- f"device {options.device}", started=started)
- except OSError as exc:
- console.print(f"[red]cannot write {where}: {exc}[/red]")
- return None
-
-
-def pump(device, options: AircraftOptions, registry, log, book,
- started: float, on_block=None, on_frame=None, stopping=None) -> int:
- """Read the receiver until it stops, or until told to.
-
- The one loop both the terminal board and the window are driven from, so
- that what is written to the log cannot depend on which one you happened
- to be looking at.
- """
- from .adsb import decode_frames
-
- total = 0
- device.tune(ADSB_HZ)
- block = int(options.rate) # a second at a time
- while stopping is None or not stopping():
- at = time.time()
- samples = device.read_samples(block)
- if samples is None or samples.size == 0:
- break
- for frame in decode_frames(samples, options.rate):
- # The real time the frame arrived, not its offset in the block:
- # everything downstream is a clock, and a log that started again
- # from zero every second would be unusable.
- when = at + frame.at_sample / options.rate
- craft = registry.add(frame, when=when)
- total += 1
- if log is not None:
- log.append(frame, craft, when=when)
- if options.lookup:
- book.get(frame.icao, craft.callsign)
- if on_frame is not None:
- on_frame(frame, craft)
- if on_block is not None:
- on_block(total)
- if options.seconds and time.time() - started >= options.seconds:
- break
- return total
-
-
-def listen(console, options: AircraftOptions, output_dir: str,
- log_path=None) -> Heard:
- """Park on 1090 MHz and write down the aircraft overhead.
-
- Everything heard goes into the log as it arrives, because an aircraft is
- overhead for four minutes and then gone: the screen is for the person
- watching, and the log is for the report, the map and everything
- afterwards.
- """
- from .adsb import AircraftRegistry
- from .flights import FlightBook
-
- heard = Heard()
- device = open_device(console, options)
- if device is None:
- return heard
-
- started = time.time()
- log = open_log(console, options, output_dir, started, log_path)
- registry = AircraftRegistry()
- console.print(f"[grey62]listening on {ADSB_HZ/1e6:g} MHz at "
- f"{options.rate/1e6:g} MS/s — control-C to stop[/grey62]")
- if log is not None:
- console.print(f"[grey62]writing {log.path}[/grey62]")
-
- # The registers are asked while the listening runs rather than after it,
- # so a registration and a route appear on the line as they arrive. The
- # book answers immediately with what it knows and fills itself in later.
- book = FlightBook(online=options.lookup,
- schedules=schedule_names(options))
- display, live = _open_display(console, options, book, started)
-
- def on_block(total: int) -> None:
- if live is not None:
- display.update(registry, total,
- log.path if log is not None else None)
- live.update(display.render())
- elif not options.frames and total:
- console.print(f"[grey62]{len(registry)} aircraft, "
- f"{total} frames[/grey62]", highlight=False)
-
- def on_frame(frame, craft) -> None:
- if options.frames:
- console.print(f"[cyan]{frame.icao}[/cyan] "
- f"{frame.describe()}", highlight=False)
-
- total = 0
- try:
- total = pump(device, options, registry, log, book, started,
- on_block=on_block, on_frame=on_frame)
- except KeyboardInterrupt:
- pass
- finally:
- if live is not None:
- live.stop()
- console.print("[grey62]stopped listening[/grey62]")
- device.close()
- if log is not None:
- log.close()
- heard.log_path = log.path
- return finish(console, options, output_dir, heard, registry, book,
- started, total)
-
-
-def watch(console, options: AircraftOptions, output_dir: str,
- log_path=None) -> Heard:
- """The same listening, in a window with a map in it.
-
- The receiver runs on its own thread and the window paints from a copy of
- what it found, so that a slow repaint can never cost a frame and a slow
- network can never stop the picture moving. Everything else -- the log,
- the report, the lookups, the map drawn at the end -- is exactly what the
- passive capture does, because it is the same code.
- """
- import threading
-
- from rich.panel import Panel
- from rich.text import Text
-
- from . import livemap
- from .adsb import AircraftRegistry
- from .flightlog import read_position
- from .flights import FlightBook
-
- heard = Heard()
- if not livemap.available():
- console.print(Panel(Text(livemap.MISSING_QT),
- title="[yellow]no window to open",
- border_style="yellow"))
- return heard
- device = open_device(console, options)
- if device is None:
- return heard
-
- started = time.time()
- log = open_log(console, options, output_dir, started, log_path)
- registry = AircraftRegistry()
- book = FlightBook(online=options.lookup,
- schedules=schedule_names(options))
- # Set before the window is built, because the window reads its colours
- # out of the palette this writes.
- from .flightmap import set_theme
-
- set_theme(options.theme)
- sky = livemap.Sky(unit=options.speed_unit, hold=options.hold,
- home=read_position(options.location),
- radius_nm=radius_in_nm(options) or 100.0,
- brightness=max(10, options.map_brightness) / 100.0,
- fade=max(0.0, options.fade),
- airports=options.airports,
- rings=options.window_rings,
- box_opacity=max(0, options.box_opacity) / 100.0)
- sky.started = started
- sky.log_name = log.path.name if log is not None else ""
- if options.simulate:
- sky.note = "simulated"
-
- def told_about(craft):
- """What a register says about one aircraft, for the window."""
- if not options.lookup:
- return None
- entry = book.get(craft.icao, craft.callsign)
- if craft.located:
- # A source that listed a whole day's stops has said which leg
- # this is, once there is a position to read it with.
- entry = book.resolve(entry, craft.latitude, craft.longitude)
- return entry
-
- def on_block(total: int) -> None:
- sky.update([livemap.blip_for(craft, told_about(craft))
- for craft in registry.aircraft.values()],
- total, len(registry))
-
- counted = {"frames": 0}
-
- def listening() -> None:
- try:
- counted["frames"] = pump(device, options, registry, log, book,
- started, on_block=on_block,
- stopping=lambda: sky.stopping)
- except Exception as exc: # a window must survive it
- sky.note = str(exc)[:60]
- finally:
- # However it ended -- the time ran out, the receiver stopped, or
- # it fell over -- the window is told, and shuts itself.
- sky.finished = True
-
- threads = [threading.Thread(target=listening, daemon=True,
- name="adsb-receiver")]
- # One thread serves both the map and the aerodromes, and either on its
- # own is reason enough to start it: the aerodromes are a separate
- # question to a separate service, and asking for them with the tiles
- # turned off is a perfectly ordinary thing to want.
- if options.basemap or options.airports:
- threads.append(threading.Thread(
- target=livemap.fetch_ground, args=(sky, options.tile_url),
- daemon=True, name="adsb-basemap"))
- for thread in threads:
- thread.start()
- console.print(f"[grey62]listening on {ADSB_HZ/1e6:g} MHz — close the "
- "window to stop[/grey62]")
- if log is not None:
- console.print(f"[grey62]writing {log.path}[/grey62]")
- try:
- livemap.show(sky, "bandsaunter — aircraft on 1090 MHz")
- finally:
- sky.stopping = True
- for thread in threads:
- thread.join(timeout=3.0)
- device.close()
- if log is not None:
- log.close()
- heard.log_path = log.path
- return finish(console, options, output_dir, heard, registry, book,
- started, counted["frames"])
-
-
-def finish(console, options: AircraftOptions, output_dir: str, heard: Heard,
- registry, book, started: float, total: int) -> Heard:
- """Everything that happens once the listening stops.
-
- Shared by the passive capture and the window, so that closing a window
- leaves exactly the same files behind as pressing control-C does.
- """
- from .flightlog import read_logs, report, write_kml
-
- heard.frames = total
- heard.registry = registry
- if not registry:
- console.print("[yellow]nothing heard. ADS-B needs an aerial cut for "
- "1090 MHz; the whip that came with the dongle will "
- "hear the airport and not much else.[/yellow]")
- return heard
-
- aircraft_table(console, registry, total, options.speed_unit)
- if options.lookup:
- book.wait(12.0)
- book.save()
- lookup_table(console, registry, book)
-
- heard.tracks = read_logs(heard.log_path) if heard.log_path \
- else tracks_from(registry)
- if options.kml:
- where = (heard.log_path.with_suffix(".kml") if heard.log_path
- else Path(output_dir).expanduser() / "aircraft.kml")
- written = write_kml(where, heard.tracks,
- book if options.lookup else None,
- unit=options.speed_unit)
- heard.kml_path = written
- console.print(f"[green]{written}[/green]" if written
- else "[yellow]nothing was placed on the map[/yellow]")
- if heard.log_path is not None:
- told = heard.log_path.with_suffix(".txt")
- try:
- told.write_text("\n".join(report(
- heard.tracks, book if options.lookup else None,
- title=f"bandsaunter — aircraft heard "
- f"{datetime.fromtimestamp(started):%Y-%m-%d %H:%M}",
- unit=options.speed_unit)))
- heard.report_path = told
- console.print(f"[green]{told}[/green]")
- except OSError as exc:
- console.print(f"[red]cannot write {told}: {exc}[/red]")
- if options.draw_after:
- heard.picture = draw(console, options, heard.tracks,
- out_path=_picture_path(heard.log_path, options,
- output_dir),
- book=book if options.lookup else None)
- return heard
-
-
-def _open_display(console, options: AircraftOptions, book, started: float):
- """A live table where there is a terminal to draw it on, else nothing.
-
- Piped output, a test or a log file gets the running count it had before:
- a display that redraws itself four times a second is unreadable as a
- stream of text, and worse than useless in a file.
- """
- if options.frames or not getattr(console, "is_terminal", False):
- return None, None
- from rich.live import Live
-
- from .ui import AircraftDisplay
-
- display = AircraftDisplay(console, book=book if options.lookup else None,
- hold=options.hold, unit=options.speed_unit)
- display.started = started
- live = Live(display.render(), console=console, refresh_per_second=4,
- screen=False, transient=False, vertical_overflow="crop")
- live.start()
- return display, live
-
-
-def _picture_path(log_path, options: AircraftOptions, output_dir: str) -> Path:
- suffix = "." + options.picture
- if log_path is not None:
- return Path(log_path).with_suffix(suffix)
- return Path(output_dir).expanduser() / f"aircraft{suffix}"
-
-
-# ---------------------------------------------------------------------------
-# Drawing
-# ---------------------------------------------------------------------------
-
-def checked(console, options: AircraftOptions, tracks):
- """Throw out impossible positions, if asked, and say how many went."""
- if not options.recheck:
- return tracks
- from .flightlog import recheck as recheck_tracks
-
- before = sum(len(t.fixes) for t in tracks)
- tracks, dropped = recheck_tracks(tracks)
- if dropped:
- console.print(f"[yellow]dropped {dropped:,} of {before:,} positions "
- f"({100.0 * dropped / max(1, before):.1f}%) that no "
- "aircraft could have been in[/yellow]")
- else:
- console.print("[grey62]every position checks out[/grey62]")
- return tracks
-
-
-def schedule_names(options: AircraftOptions) -> list[str]:
- """The schedule services to ask, in the order given."""
- return [name.strip() for name in str(options.schedules or "").split(",")
- if name.strip()]
-
-
-def radius_in_nm(options: AircraftOptions) -> float:
- """The map radius as the drawing wants it, in nautical miles.
-
- The option is in whatever the speeds are in, because a picture that
- measured its speeds in one unit and its own extent in another would be
- a puzzle rather than a map.
- """
- from .flightlog import speed_unit
-
- return max(0.0, float(options.radius)) / speed_unit(options.speed_unit)[3]
-
-
-def draw(console, options: AircraftOptions, tracks, out_path, book=None):
- """Draw the map, saying what it is drawing and what came out of it."""
- from .flightmap import animate, ffmpeg_available, set_theme
-
- 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:
- set_theme(options.theme)
- drawn = animate(tracks, out_path, book=book, fps=options.fps,
- seconds=options.length, speed=options.speed,
- width=options.width, trail_seconds=options.trail,
- stale=options.stale, labels=options.labels,
- fade=max(0.0, options.fade),
- unit=options.speed_unit, ground=options.basemap,
- tile_url=options.tile_url,
- brightness=max(10, options.map_brightness) / 100.0,
- airports=options.airports,
- rings=options.rings,
- box_opacity=max(0, options.box_opacity) / 100.0,
- radius_nm=radius_in_nm(options),
- centre=read_position(options.location))
- except (OSError, RuntimeError, ValueError) as exc:
- console.print(f"[red]{exc}[/red]")
- return None
- if drawn is None:
- console.print("[yellow]nothing was placed on the map: no aircraft "
- "reported a position[/yellow]")
- return None
- size = drawn.path.stat().st_size / 1e6
- console.print(f"[green]{drawn.path}[/green] "
- f"[grey62]{drawn.summary()}, {size:.1f} MB[/grey62]")
- return drawn
-
-
-def draw_log(console, options: AircraftOptions, paths, out_path=None):
- """Read logs back and draw them: the whole of what ``flights`` does."""
- from .flightlog import read_logs
- from .flights import FlightBook
-
- paths = [Path(p).expanduser() for p in paths]
- tracks = read_logs(paths)
- if not tracks:
- console.print(f"[yellow]{paths[0].name} holds no frames[/yellow]")
- return None
- tracks = checked(console, options, tracks)
- book = FlightBook(online=options.lookup,
- schedules=schedule_names(options))
- if options.lookup:
- for track in tracks:
- book.get(track.icao, track.callsign)
- book.wait(20.0)
- book.save()
- if out_path is None:
- out_path = paths[0].with_suffix("." + options.picture)
- return draw(console, options, tracks, out_path,
- book if options.lookup else None)
-
-
-# ---------------------------------------------------------------------------
-# What was heard, on the screen
-# ---------------------------------------------------------------------------
-
-def aircraft_table(console, registry, total: int,
- unit: str = "knots") -> None:
- """What was heard, as it was heard: no register, only the air."""
- from rich.table import Table
-
- from .flightlog import in_speed, speed_label
-
- t = Table(title=f"{len(registry)} aircraft, {total} frames", box=None,
- header_style="bold")
- for column in ("ICAO", "callsign", "altitude", "position",
- f"speed ({speed_label(unit)})", "frames"):
- t.add_column(column)
- for craft in sorted(registry.aircraft.values(), key=lambda a: a.icao):
- t.add_row(craft.icao, craft.callsign or "",
- f"{craft.altitude_ft:,} ft" if craft.altitude_ft else "",
- (f"{craft.latitude:.4f}, {craft.longitude:.4f}"
- if craft.located else ""),
- (f"{in_speed(craft.ground_speed_kt, unit):.0f} "
- f"{craft.track_deg:.0f}°"
- if craft.ground_speed_kt else ""),
- str(craft.messages))
- console.print(t)
-
-
-def lookup_table(console, registry, book) -> None:
- """And what the registers say about them, kept separate on purpose."""
- from rich.table import Table
-
- rows = []
- for craft in sorted(registry.aircraft.values(), key=lambda a: a.icao):
- entry = book.get(craft.icao, craft.callsign)
- told = entry.summary()
- if told or entry.country:
- rows.append((craft.icao, craft.callsign or "", entry.country,
- told or "—"))
- if not rows:
- return
- t = Table(title="what the registers say", box=None, header_style="bold")
- for column in ("ICAO", "callsign", "registered",
- "aircraft, operator, route"):
- t.add_column(column, overflow="fold")
- for row in rows:
- t.add_row(*row)
- console.print(t)
-
-
-def tracks_from(registry):
- """Tracks from a registry, for a session that wrote no log.
-
- One position each: what is on the screen is all there is, because nothing
- kept the ones before it.
- """
- from .flightlog import Fix, Track
-
- out = []
- for craft in sorted(registry.aircraft.values(), key=lambda a: a.icao):
- track = Track(icao=craft.icao, callsign=craft.callsign,
- frames=craft.messages, first_seen=craft.first_seen,
- last_seen=craft.last_seen)
- if craft.located:
- track.fixes.append(Fix(at=craft.last_seen, latitude=craft.latitude,
- longitude=craft.longitude,
- altitude_ft=craft.altitude_ft,
- ground_speed_kt=craft.ground_speed_kt,
- track_deg=craft.track_deg,
- vertical_rate_fpm=craft.vertical_rate_fpm))
- out.append(track)
- return out
-
-
-def summarise(options: AircraftOptions) -> str:
- """The options as one line, for a menu row."""
- return ", ".join(f"{o.label.lower()} {format_option(o, getattr(options, o.key))}"
- for o in OPTIONS[:3])
diff --git a/bandsaunter/basemap.py b/bandsaunter/basemap.py
deleted file mode 100644
index ede263e..0000000
--- a/bandsaunter/basemap.py
+++ /dev/null
@@ -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)
diff --git a/bandsaunter/cli.py b/bandsaunter/cli.py
index 9db58f0..9f2b8df 100755
--- a/bandsaunter/cli.py
+++ b/bandsaunter/cli.py
@@ -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_.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
+ 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
- run = air.watch if args.window else air.listen
- heard = run(console, options, cfg.output_dir, log_path=args.log)
- if not heard.aircraft:
+ 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
- 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}"
+
+ 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
diff --git a/bandsaunter/flags.py b/bandsaunter/flags.py
deleted file mode 100644
index 5a7f1a0..0000000
--- a/bandsaunter/flags.py
+++ /dev/null
@@ -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 ""
diff --git a/bandsaunter/flightlog.py b/bandsaunter/flightlog.py
index d6146bf..7405a20 100644
--- a/bandsaunter/flightlog.py
+++ b/bandsaunter/flightlog.py
@@ -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,
"2 "]
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},"
diff --git a/bandsaunter/flightmap.py b/bandsaunter/flightmap.py
index 52fddcb..7f5fe75 100644
--- a/bandsaunter/flightmap.py
+++ b/bandsaunter/flightmap.py
@@ -33,20 +33,11 @@ from pathlib import Path
import numpy as np
-from . import themes as _themes
-from .flightlog import (DEFAULT_SPEED_UNIT, Track, box_around, centre_of,
- distance_label, distance_nm, in_distance, in_speed,
- speed_label, within)
+from .flightlog import Track, distance_nm
from .images import GLYPH_H, draw_text, text_width, write_png
__all__ = ["Animation", "Projection", "animate", "render_frame", "write_gif",
- "write_mp4", "fit", "PALETTE", "ffmpeg_available",
- "ground_for", "background", "label_lines", "draw_flag",
- "set_theme", "theme", "THEME", "bloom", "draw_home",
- "draw_rings", "RING_STEPS", "ring_labels",
- "FLAG", "FAINT", "flag_index", "dim_ground", "local_airports",
- "showing", "fade_ramp",
- "GROUND_BRIGHTNESS"]
+ "write_mp4", "fit", "PALETTE", "ffmpeg_available"]
# ---------------------------------------------------------------------------
@@ -61,83 +52,40 @@ BG, GRID, INK, DIM, PANEL, AIRPORT, ROUTE, WHITE = range(8)
RAMP = 8 # 32 altitude colours from here
TRAIL = RAMP + 32 # the same 32, dimmed, for the path just flown
OLD = TRAIL + 32 # and dimmer still, for the path flown earlier
-GROUND = OLD + 32 # 32 shades of the map underneath
-FLAG = GROUND + 32 # the dozen colours the little flags are made of
-FAINT = FLAG + 12 # the same 32 again, fainter still, for fading
-# A fading aircraft's box has to fade with it. An indexed picture cannot
-# blend, so everything the box is made of needs its own dimmed copies: the
-# grey the rows are written in, and the flag colours, at the same three
-# steps the aircraft itself fades through.
-LABEL_INK = FAINT + 32 # the row grey, at the four strengths
-FLAG_FADED = LABEL_INK + 4 # three dimmer sets of the twelve flag colours
-# A vector display draws by holding a beam on the phosphor, and the phosphor
-# spreads the light: a line on one of those screens is a bright core with a
-# halo round it. The aircraft colours already have dimmed copies to make a
-# halo out of -- that is what the trail shades are -- but the fixed colours
-# do not, so the three that are worth a glow get two rings each here.
-GLOW = FLAG_FADED + 36 # airport, ink and white, at two ring strengths
-LEADER = GLOW + 6 # the line from a box to its aircraft, and its halo
-# Where the receiver is: a red flag, and red in every theme. "You are here"
-# is the one mark on the picture whose meaning must not change with the
-# colours, and it is the only thing on the map that is not an aircraft, an
-# aerodrome or the ground.
-HOME = LEADER + 3
-# Pure red rather than a softened one. The softer reds 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; this is the brightest red there is and the one
-# furthest from every altitude colour in every theme.
-HOME_RED = (255, 0, 0)
RAMP_STEPS = 32
-GROUND_SHADES = 32
TRANSPARENT = 255 # never drawn with: it means "as the frame before"
CEILING_FT = 45_000.0
-# Past this many aircraft on one frame, the labels go back to the callsign,
-# the height and the speed. Five lines beside each of three hundred aircraft
-# is not more information, it is less: a still of a whole day would be a page
-# of overlapping text with a map somewhere behind it. An animation shows a
-# handful at a time and never reaches this.
-CROWDED = 14
-
# The most frames any one animation is worth: about four minutes at twelve a
# second, by which point a viewer has stopped watching and a GIF has stopped
# opening.
MAX_FRAMES = 3000
-# The airport colour is chosen to be as far from every altitude colour as a
-# colour can be. The old amber sat 16 units of CIELAB from the 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. Magenta is 56 units away, further than any
-# other hue the ramp leaves free -- the ramp already spends red, amber,
-# green, cyan and violet on height -- and it is what an aeronautical chart
-# marks an aerodrome in anyway.
-# Which theme is being drawn. Everything below is built from it, so
-# changing it changes both the animation and the window: they read the same
-# palette, and the window looks the colours up in it rather than keeping any
-# of its own.
-THEME = _themes.THEMES[_themes.DEFAULT_THEME]
+_FIXED = ((14, 16, 22), # background: night, not black
+ (38, 44, 58), # grid
+ (196, 204, 218), # ink
+ (120, 130, 148), # dim ink
+ (24, 28, 38), # panel
+ (230, 170, 90), # airports
+ (70, 84, 110), # route lines
+ (255, 255, 255)) # white
-_FIXED = (THEME.background, THEME.grid, THEME.ink, THEME.dim, THEME.panel,
- THEME.airport, THEME.route, THEME.white)
-
-# Low is warm, high is cold on the default map: the convention every other
-# aircraft map uses, so an altitude can be read off the picture without
-# looking at the key. A phosphor theme has one colour to spend and reads
-# height as brightness instead.
-ALTITUDE_STOPS = THEME.stops
+# Low is warm, high is cold: the convention every other aircraft map uses, so
+# an altitude can be read off the picture without looking at the key.
+ALTITUDE_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)))
-def _ramp(steps: int = RAMP_STEPS, stops=None) -> list[tuple[int, int, int]]:
+def _ramp(steps: int = RAMP_STEPS) -> list[tuple[int, int, int]]:
"""The altitude ramp, interpolated between the stops above."""
- stops = ALTITUDE_STOPS if stops is None else stops
out = []
for i in range(steps):
feet = CEILING_FT * i / (steps - 1)
- low = stops[0]
- high = stops[-1]
- for a, b in zip(stops, stops[1:]):
+ low = ALTITUDE_STOPS[0]
+ high = ALTITUDE_STOPS[-1]
+ for a, b in zip(ALTITUDE_STOPS, ALTITUDE_STOPS[1:]):
if a[0] <= feet <= b[0]:
low, high = a, b
break
@@ -152,88 +100,9 @@ def _dimmed(colours, factor: float) -> list[tuple[int, int, int]]:
return [tuple(int(round(c * factor)) for c in rgb) for rgb in colours]
-# The map under the aircraft: night-blue, running from nearly the background
-# to about as bright as it can go before the aircraft stop standing out. How
-# far up that range a drawing actually goes is the brightness setting below,
-# so there is room to turn it up on a screen that needs it and down on one
-# that does not.
-GROUND_LOW = THEME.ground_low
-GROUND_HIGH = THEME.ground_high
-
-# What fraction of that range the map is drawn over unless told otherwise.
-# Enough to read a coastline and the name of a town, and not so much that a
-# city washes out the aircraft crossing it.
-GROUND_BRIGHTNESS = 0.70
-
-
-def _ground_shades(steps: int = GROUND_SHADES, low=None,
- high=None) -> list[tuple[int, int, int]]:
- low = GROUND_LOW if low is None else low
- high = GROUND_HIGH if high is None else high
- out = []
- for i in range(steps):
- part = i / max(1, steps - 1)
- out.append(tuple(int(round(a + (b - a) * part))
- for a, b in zip(low, high)))
- return out
-
-
-def _flag_colours() -> list[tuple[int, int, int]]:
- """The flag palette, in a fixed order so an index means one colour."""
- from .flags import COLOURS, COLOUR_ORDER
-
- return [COLOURS[letter] for letter in COLOUR_ORDER]
-
-
-def flag_index(letter: str, level: int = 0) -> int:
- """Where one of the flag colours lives in the palette.
-
- ``level`` is how far the aircraft has faded, 0 for full strength.
- """
- from .flags import COLOUR_ORDER
-
- place = COLOUR_ORDER.index(letter)
- if level <= 0:
- return FLAG + place
- return FLAG_FADED + (min(level, 3) - 1) * len(COLOUR_ORDER) + place
-
-
-def fade_level(strength: float) -> int:
- """How far a thing at this strength has faded: 0 full, 3 nearly gone."""
- for level, (above, _base) in enumerate(FADE_STEPS):
- if strength > above:
- return level
- return len(FADE_STEPS) - 1
-
-
-# What each fade step multiplies a colour by. The first is the aircraft at
-# full strength and is not dimmed at all.
-FADE_FACTORS = (1.0, 0.55, 0.30, 0.14)
-
-
-def _palette(theme=None) -> np.ndarray:
- theme = THEME if theme is None else theme
- fixed = (theme.background, theme.grid, theme.ink, theme.dim, theme.panel,
- theme.airport, theme.route, theme.white)
- ramp = _ramp(stops=theme.stops)
- flags = _flag_colours()
- table = (list(fixed) + ramp + _dimmed(ramp, 0.55) + _dimmed(ramp, 0.30)
- + _ground_shades(low=theme.ground_low, high=theme.ground_high)
- + flags + _dimmed(ramp, 0.14)
- # The row grey and then the flags, dimmed the same way the
- # aircraft above them is, so that a whole label fades together.
- + [_dimmed([fixed[DIM]], factor)[0] for factor in FADE_FACTORS])
- for factor in FADE_FACTORS[1:]:
- table += _dimmed(flags, factor)
- # The halo colours: the near ring and the far one, for the three fixed
- # colours bright enough to be worth glowing.
- part = theme.glow_part or 0.34
- for colour in (fixed[AIRPORT], fixed[INK], fixed[WHITE]):
- table += [_dimmed([colour], part)[0],
- _dimmed([colour], part * part)[0]]
- for colour in (theme.leader, HOME_RED):
- table += [colour, _dimmed([colour], part)[0],
- _dimmed([colour], part * part)[0]]
+def _palette() -> np.ndarray:
+ ramp = _ramp()
+ table = list(_FIXED) + ramp + _dimmed(ramp, 0.55) + _dimmed(ramp, 0.30)
table += [(0, 0, 0)] * (256 - len(table))
return np.array(table[:256], dtype=np.uint8)
@@ -241,131 +110,6 @@ def _palette(theme=None) -> np.ndarray:
PALETTE = _palette()
-def set_theme(name) -> "_themes.Theme":
- """Draw in this theme from now on, and say which one that turned out to be.
-
- The palette is written over in place rather than replaced, because both
- drawings and every one of their helpers hold a reference to this array
- and a new one would leave half the program painting in the old colours.
-
- Returns the theme, so a caller that passed a name can say what it got.
- """
- global THEME, _FIXED, ALTITUDE_STOPS, GROUND_LOW, GROUND_HIGH
-
- theme = name if isinstance(name, _themes.Theme) else _themes.theme_named(name)
- THEME = theme
- _FIXED = (theme.background, theme.grid, theme.ink, theme.dim, theme.panel,
- theme.airport, theme.route, theme.white)
- ALTITUDE_STOPS = theme.stops
- GROUND_LOW, GROUND_HIGH = theme.ground_low, theme.ground_high
- PALETTE[:] = _palette(theme)
- return theme
-
-
-def theme() -> "_themes.Theme":
- """Which theme is being drawn."""
- return THEME
-
-
-# How faint an aircraft is drawn as it fades, in the four strengths the
-# palette holds. An indexed picture cannot blend, so a fade is a handful of
-# steps rather than a slope -- which at a second or two apart reads as a
-# fade all the same.
-FADE_STEPS = ((0.66, RAMP), (0.40, TRAIL), (0.18, OLD), (0.0, FAINT))
-
-# Below this the label goes: a name at a tenth of its colour is not
-# information, it is something in the way of the aircraft still flying.
-LABEL_WHILE = 0.40
-
-
-# Which colour the halo around each drawn colour is, a ring at a time. The
-# aircraft families already have their dimmed copies -- the trail shades --
-# so a glowing aeroplane spreads into the colour its own trail is drawn in,
-# which is the same colour a phosphor would have spread into.
-_NO_HALO = TRANSPARENT
-
-
-def _halo_tables() -> tuple:
- near = np.full(256, _NO_HALO, dtype=np.uint8)
- far = np.full(256, _NO_HALO, dtype=np.uint8)
- for step in range(RAMP_STEPS):
- for base in (RAMP, TRAIL, OLD, FAINT):
- near[base + step] = TRAIL + step
- far[base + step] = OLD + step
- for i, colour in enumerate((AIRPORT, INK, WHITE)):
- near[colour] = GLOW + i * 2
- far[colour] = GLOW + i * 2 + 1
- for colour in (LEADER, HOME):
- near[colour] = colour + 1
- far[colour] = colour + 2
- # The quieter rows of a label glow too, into the fainter greys they
- # already fade through. Lettering on those screens has a halo the same
- # as everything else does; it is the same beam drawing it.
- near[DIM] = LABEL_INK + 1
- far[DIM] = LABEL_INK + 2
- for level in range(3):
- near[LABEL_INK + level] = LABEL_INK + level + 1
- far[LABEL_INK + level] = LABEL_INK + min(3, level + 2)
- return near, far
-
-
-_HALO_NEAR, _HALO_FAR = _halo_tables()
-
-
-def _spread(values: np.ndarray) -> np.ndarray:
- """One pixel of halo in every direction, first writer wins.
-
- Done as two one-dimensional passes rather than eight shifts of the whole
- picture: the second pass spreads what the first one already spread, so
- the corners come out with it, at half the work.
- """
- def moved(values, axis, step):
- # Rolled and then cut, because a roll wraps: without this a line
- # down the left edge would glow on the right edge of the picture.
- shifted = np.roll(values, step, axis=axis)
- edge = [slice(None), slice(None)]
- edge[axis] = slice(0, 1) if step > 0 else slice(-1, None)
- shifted[tuple(edge)] = _NO_HALO
- return shifted
-
- out = values.copy()
- for step in (1, -1):
- out = np.where(out == _NO_HALO, moved(values, 1, step), out)
- across = out.copy()
- for step in (1, -1):
- out = np.where(out == _NO_HALO, moved(across, 0, step), out)
- return out
-
-
-def bloom(img: np.ndarray, theme=None) -> np.ndarray:
- """Put a halo around everything bright, the way a phosphor does.
-
- Only over the ground, the grid and the empty background: 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.
-
- The near ring goes down after the far one, so where two rings meet the
- brighter wins -- which is what happens on the tube as well.
- """
- theme = THEME if theme is None else theme
- if not theme.glow:
- return img
- empty = ((img == BG) | (img == GRID)
- | ((img >= GROUND) & (img < GROUND + GROUND_SHADES)))
- near = _spread(_HALO_NEAR[img])
- far = near if theme.glow < 2 else _spread(_spread(_HALO_FAR[img]))
- out = np.where(empty & (far != _NO_HALO), far, img)
- return np.where(empty & (near != _NO_HALO), near, out)
-
-
-def fade_ramp(strength: float) -> int:
- """Which set of colours an aircraft at this strength is drawn from."""
- for above, base in FADE_STEPS:
- if strength > above:
- return base
- return FAINT
-
-
def altitude_step(feet: float) -> int:
"""Which of the 32 altitude colours a height falls in."""
if feet <= 0:
@@ -404,69 +148,6 @@ def _line(img: np.ndarray, x0: int, y0: int, x1: int, y1: int,
y0 += sy
-# The flag at the receiver: a pole standing on the spot, and a pennant
-# flying off the top of it. Drawn as a shape rather than as a dot because
-# the spot itself has to stay legible -- the point of the pole is the
-# position, and a blob would put the position somewhere inside itself.
-HOME_POLE = 17 # how tall the pole stands, in pixels
-HOME_FLY = 12 # how far the pennant reaches from the pole
-HOME_DROP = 8 # and how far down its trailing edge comes
-
-
-def draw_home(img: np.ndarray, x: int, y: int, colour: int = None) -> None:
- """A flag on the spot the receiver was told it is standing on.
-
- The foot of the pole is the position: the pennant flies to the right of
- it and above it, so that nothing the flag is made of covers the place it
- is pointing at.
- """
- colour = HOME if colour is None else colour
- height, width = img.shape
- top = y - HOME_POLE
- _line(img, x, y, x, top, colour)
- # A filled triangle, drawn a row at a time: at the top it reaches the
- # whole fly, and by the bottom of the drop it has come back to the pole.
- for row in range(HOME_DROP + 1):
- reach = int(round(HOME_FLY * (1.0 - row / max(1, HOME_DROP))))
- yy = top + row
- if not 0 <= yy < height or reach <= 0:
- continue
- x0, x1 = max(0, x + 1), min(width, x + 1 + reach)
- if x1 > x0:
- img[yy, x0:x1] = colour
-
-
-# The dashes on the line from a label to its aircraft, in pixels on and off.
-# The same pattern the window uses, so the two look like the same program.
-LEADER_DASH = (5, 4)
-
-
-def _dashed(img: np.ndarray, x0: int, y0: int, x1: int, y1: int,
- colour: int, pattern=LEADER_DASH) -> None:
- """A dashed straight line, walked at an even rate along its own length.
-
- Stepped along the line rather than along whichever axis is longer, so
- that a nearly-horizontal leader and a nearly-vertical one come out with
- dashes of the same length instead of one of them turning into a dotted
- line.
- """
- on, off = (int(pattern[0]), int(pattern[1])) if pattern else (1, 0)
- span = math.hypot(x1 - x0, y1 - y0)
- if span < 1.0:
- return
- height, width = img.shape
- cycle = max(1, on + off)
- steps = int(span)
- for step in range(steps + 1):
- if step % cycle >= on:
- continue
- part = step / span
- x = int(round(x0 + (x1 - x0) * part))
- y = int(round(y0 + (y1 - y0) * part))
- if 0 <= x < width and 0 <= y < height:
- img[y, x] = colour
-
-
def _disc(img: np.ndarray, x: int, y: int, radius: int, colour: int) -> None:
height, width = img.shape
r = max(0, int(radius))
@@ -518,7 +199,7 @@ def _box(img: np.ndarray, x0: int, y0: int, x1: int, y1: int,
# ---------------------------------------------------------------------------
TITLE_H = 26
-LEGEND_H = 32
+LEGEND_H = 30
MARGIN = 10
@@ -581,15 +262,10 @@ def bounds_of(tracks: list[Track], margin: float = 0.06):
return (south - pad_lat, west - pad_lon, north + pad_lat, east + pad_lon)
-def fit(tracks: list[Track], width: int = 960, max_height: int = 1200,
- box=None) -> Projection | None:
- """Choose the canvas: as wide as asked, as tall as the area needs.
-
- ``box`` is a (south, west, north, east) to draw instead of the one the
- tracks happen to fill -- a fixed frame around the receiver, so that the
- scale of the picture does not change with whatever flew past.
- """
- box = bounds_of(tracks) if box is None else box
+def fit(tracks: list[Track], width: int = 960,
+ max_height: int = 1200) -> Projection | None:
+ """Choose the canvas the tracks want: as wide as asked, as tall as needed."""
+ box = bounds_of(tracks)
if box is None:
return None
south, west, north, east = box
@@ -631,68 +307,26 @@ def _degrees(value: float, axis: str) -> str:
return f"{abs(value):.2f}{hemisphere}"
-def dim_ground(levels, brightness: float = GROUND_BRIGHTNESS):
- """Squeeze the map into the part of its range being used.
-
- The palette holds the whole range; a drawing uses as much of it as the
- brightness asks for. Scaling the shades rather than rebuilding the
- palette means the animation's colour table stays the same table from one
- frame to the next, which is the whole basis of the frame differencing.
- """
- # The theme shapes how the setting is felt, without capping it. A
- # screen made of lines wants the ground well out of the way, so those
- # themes bend the middle of the range down hard -- but the top of the
- # setting is still a full-brightness map on every one of them, which a
- # plain multiplier could never give.
- part = max(0.05, min(1.0, float(brightness))) ** THEME.ground_gamma
- part = max(0.02, part)
- top = max(1, int(round((GROUND_SHADES - 1) * part)))
- return np.clip((np.asarray(levels, dtype=np.float64)
- * top / (GROUND_SHADES - 1)).round(),
- 0, GROUND_SHADES - 1).astype(np.uint8)
-
-
-def background(view: Projection, title: str = "", airports=(),
- unit: str = DEFAULT_SPEED_UNIT, ground=None,
- attribution: str = "",
- brightness: float = GROUND_BRIGHTNESS,
- home=None, rings: float = 0.0) -> np.ndarray:
- """The map without anything flying on it: ground, grid, scale, key, title.
-
- ``home`` is where the receiver is standing, and here it only says where
- the range rings are centred. The flag that marks it is drawn by the
- frame rather than by the ground, so that nothing can be drawn over it
- afterwards.
-
- ``ground`` is the real map underneath, as brightness levels covering the
- body of the picture, or None for the plain grid. Where there is one the
- graticule is drawn lighter over it, because the coastline is doing the
- work the grid was there to do.
- """
+def background(view: Projection, title: str = "",
+ airports=()) -> np.ndarray:
+ """The map without anything flying on it: grid, scale, key and title."""
width, height = canvas_size(view)
img = np.full((height, width), BG, dtype=np.uint8)
- if ground is not None:
- patch = dim_ground(ground, brightness)
- rows = min(view.height, patch.shape[0])
- cols = min(view.width, patch.shape[1])
- img[view.top:view.top + rows, view.left:view.left + cols] = \
- GROUND + np.clip(patch[:rows, :cols], 0, GROUND_SHADES - 1)
_box(img, view.left - 1, view.top - 1, view.left + view.width,
view.top + view.height, GRID)
step_lat = _grid_step(view.north - view.south)
step_lon = _grid_step(view.east - view.west)
- dashes = 6 if ground is not None else 3
lat = math.ceil(view.south / step_lat) * step_lat
while lat <= view.north:
_, y = view.xy(lat, view.west)
- img[y, view.left:view.left + view.width:dashes] = GRID
+ img[y, view.left:view.left + view.width:3] = GRID
draw_text(img, view.left + 3, y - GLYPH_H - 1, _degrees(lat, "lat"), DIM)
lat += step_lat
lon = math.ceil(view.west / step_lon) * step_lon
while lon <= view.east:
x, _ = view.xy(view.south, lon)
- img[view.top:view.top + view.height:dashes, x] = GRID
+ img[view.top:view.top + view.height:3, x] = GRID
draw_text(img, x + 3, view.top + view.height - GLYPH_H - 3,
_degrees(lon, "lon"), DIM)
lon += step_lon
@@ -702,139 +336,20 @@ def background(view: Projection, title: str = "", airports=(),
continue
x, y = view.xy(lat, lon)
_box(img, x - 3, y - 3, x + 3, y + 3, AIRPORT)
- _box(img, x - 1, y - 1, x + 1, y + 1, AIRPORT, fill=True)
draw_text(img, x + 6, y - 3, name, AIRPORT)
- # After everything else on the ground, and harmless there: the rings
- # only lift pixels that are still map or still empty, so the grid, the
- # aerodromes and their names come through them untouched.
- if rings:
- draw_rings(img, view, home, rings, unit)
-
if title:
draw_text(img, MARGIN, (TITLE_H - GLYPH_H) // 2, title, INK)
- if attribution:
- # Whoever's tiles those are gets their name on the picture: a GIF
- # travels without the readme that would otherwise carry it.
- draw_text(img, MARGIN, view.top + view.height + 22, attribution, GRID)
- _scale_bar(img, view, unit)
+ _scale_bar(img, view)
_key(img, view)
return img
-# Where the range rings go, as fractions of the radius being drawn, and how
-# much each one lifts the ground under it.
-RING_STEPS = (0.25, 0.50, 0.75)
-RING_LIFT = 2 # ground shades, added once per disc
-
-
-def draw_rings(img: np.ndarray, view: Projection, home, radius_nm: float,
- unit: str = DEFAULT_SPEED_UNIT) -> None:
- """Filled discs at a quarter, a half and three quarters of the radius.
-
- Concentric on the receiver and translucent, so they stack: 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 a thing 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" here means moving the
- ground under the disc a step or two up its own ramp of shades. That
- keeps the coastline and the roads visible through it, which a flat wash
- of one colour would not.
- """
- if home is None or radius_nm <= 0:
- return
- height, width = img.shape
- # Only the part of the canvas the map is actually drawn on. The title
- # sits above it and there are margins either side, and stretching the
- # view's latitudes over the whole picture would put the rings in the
- # wrong place and make them the wrong size.
- rows = slice(view.top, min(height, view.top + view.height))
- columns = slice(view.left, min(width, view.left + view.width))
- patch = img[rows, columns]
- if patch.size == 0:
- return
- # The distance from the receiver to every pixel of it, worked out once:
- # the rings do not move, so this is the only time it has to be done.
- lats = view.north - (view.north - view.south) * (
- np.arange(patch.shape[0]) + 0.5) / view.height
- lons = view.west + (view.east - view.west) * (
- np.arange(patch.shape[1]) + 0.5) / view.width
- away = _distance_field(home, lats, lons)
-
- ground = (patch >= GROUND) & (patch < GROUND + GROUND_SHADES)
- empty = patch == BG
- for part in sorted(RING_STEPS, reverse=True):
- inside = away <= radius_nm * part
- shade = np.where(ground, patch.astype(np.int16) - GROUND, 0)
- lifted = np.clip(shade + RING_LIFT, 0, GROUND_SHADES - 1)
- patch[:] = np.where(inside & (ground | empty),
- (GROUND + lifted).astype(np.uint8), patch)
- # Whatever was empty is ground now, so the next disc lifts it again
- # rather than starting it over.
- ground = ground | (inside & empty)
- empty = empty & ~inside
-
- for nm, text in ring_labels(radius_nm, unit):
- text = text.upper() # the bitmap font here has no lower case
- edge = _ring_top(view, home, nm)
- if edge is None:
- continue
- x, y = edge
- x -= text_width(text) // 2
- if 1 <= y < height - GLYPH_H and 0 <= x < width - text_width(text):
- draw_text(img, x, y + 2, text, DIM)
-
-
-def ring_labels(radius_nm: float, unit: str = DEFAULT_SPEED_UNIT) -> list:
- """What each ring is called, and how far out it is in nautical miles.
-
- In whatever unit the speeds are in: miles an hour beside a ring measured
- in nautical miles would be two different miles on one picture. Shared
- by both drawings so that the window and the pictures cannot come to
- different numbers for the same ring.
- """
- out = []
- for part in RING_STEPS:
- nm = radius_nm * part
- out.append((nm, f"{in_distance(nm, unit):.0f} {distance_label(unit)}"))
- return out
-
-
-def _distance_field(home, lats, lons) -> np.ndarray:
- """How far every pixel of a picture is from one place, in nautical miles."""
- lat0, lon0 = math.radians(home[0]), math.radians(home[1])
- phi = np.radians(lats)[:, None]
- lam = np.radians(lons)[None, :]
- dphi = phi - lat0
- dlam = lam - lon0
- a = (np.sin(dphi / 2) ** 2
- + math.cos(lat0) * np.cos(phi) * np.sin(dlam / 2) ** 2)
- return 2 * 3440.065 * np.arcsin(np.sqrt(np.clip(a, 0.0, 1.0)))
-
-
-def _ring_top(view: Projection, home, nm: float):
- """Where the top of a ring of this radius falls on the picture."""
- from .flightlog import move
-
- north = move(home[0], home[1], 0.0, nm)
- if not view.inside(north[0], north[1]):
- return None
- return view.xy(north[0], north[1])
-
-
-def _scale_bar(img: np.ndarray, view: Projection,
- unit: str = DEFAULT_SPEED_UNIT) -> None:
- """A bar of a round number of miles, for judging distance.
-
- In whatever unit the speeds are in: miles an hour beside a scale in
- nautical miles would be two different miles on one picture.
- """
- across = in_distance(view.width_nm, unit)
- per_unit = view.width / max(1e-9, across)
- for miles in (500, 200, 100, 50, 20, 10, 5, 2, 1):
- pixels = int(round(miles * per_unit))
+def _scale_bar(img: np.ndarray, view: Projection) -> None:
+ """A bar of a round number of nautical miles, for judging distance."""
+ per_nm = view.width / max(1e-9, view.width_nm)
+ for miles in (200, 100, 50, 20, 10, 5, 2, 1):
+ pixels = int(round(miles * per_nm))
if pixels <= view.width * 0.32:
break
else:
@@ -844,8 +359,7 @@ def _scale_bar(img: np.ndarray, view: Projection,
_line(img, x, y, x + pixels, y, DIM)
_line(img, x, y - 3, x, y + 3, DIM)
_line(img, x + pixels, y - 3, x + pixels, y + 3, DIM)
- draw_text(img, x + pixels + 6, y - 3,
- f"{miles} {distance_label(unit).upper()}", DIM)
+ draw_text(img, x + pixels + 6, y - 3, f"{miles} NM", DIM)
def _key(img: np.ndarray, view: Projection) -> None:
@@ -869,213 +383,40 @@ def _key(img: np.ndarray, view: Projection) -> None:
# One frame
# ---------------------------------------------------------------------------
-# How long a label takes to swing across when it has to move, in seconds of
-# the animation as it plays. The same half-second the window uses, so the
-# two look like the same program.
-LABEL_GLIDE = 0.45
-
-
-class LabelPlaces:
- """Where each aircraft's label is, and where it is going.
-
- A frame on its own has no reason to put a label anywhere in particular,
- and the placer is happy to answer differently on the next one -- so a
- label hops from one side of its aircraft to the other and back as the
- aeroplane two along moves a pixel. Watching that is worse than reading
- a label in an awkward place.
-
- So the same rule the window uses: a label keeps the spot it has for as
- long as that spot still works, and the moves that are left are eased
- rather than jumped. Kept as an offset from the aircraft, so one
- crossing the picture carries its label with it and only a real clash
- asks for a new place.
- """
-
- def __init__(self, glide: float = LABEL_GLIDE):
- self.glide = glide
- self.want: dict[str, tuple[int, int]] = {}
- self.at: dict[str, tuple[float, float]] = {}
- self._seen: set[str] = set()
-
- def begin(self) -> None:
- self._seen = set()
-
- def end(self) -> None:
- """Forget the labels that were not drawn this time.
-
- An aircraft that has gone must not glide in from where it stood
- half an hour ago when it comes back.
- """
- for gone in set(self.at) - self._seen:
- self.at.pop(gone, None)
- self.want.pop(gone, None)
-
- def kept(self, icao: str, x: int, y: int, fits):
- """The spot this label already holds, if it still works.
-
- ``fits`` is asked whether a place is free and returns it, or None.
- A label is only sent looking for somewhere new when the place it has
- has actually been taken.
- """
- self._seen.add(icao)
- held = self.want.get(icao)
- if held is None:
- return None
- return fits(x + held[0], y + held[1])
-
- def settle(self, icao: str, x: int, y: int, spot) -> tuple[int, int]:
- """Remember where this label belongs, and say where to draw it."""
- self._seen.add(icao)
- self.want[icao] = (spot[0] - x, spot[1] - y)
- return spot
-
- def drawn(self, icao: str, x: int, y: int, spot,
- step: float) -> tuple[int, int]:
- """Where the label actually goes this frame, on its way to `spot`."""
- target = (float(spot[0] - x), float(spot[1] - y))
- here = self.at.get(icao)
- if here is None: # first sight: no swing to make
- self.at[icao] = target
- return spot
- here = _glide(here, target, step, self.glide)
- self.at[icao] = here
- return int(round(x + here[0])), int(round(y + here[1]))
-
-
-def _glide(current, target, elapsed: float, seconds: float):
- """One step of a label on its way to where it now belongs.
-
- Eased by the distance left rather than by a count of frames, so the
- swing takes the same time whatever the frame rate is set to.
- """
- cx, cy = current
- tx, ty = target
- if seconds <= 0.0:
- return (float(tx), float(ty))
- if elapsed <= 0.0:
- return (float(cx), float(cy))
- part = 1.0 - math.exp(-3.0 * elapsed / seconds)
- nx, ny = cx + (tx - cx) * part, cy + (ty - cy) * part
- if abs(tx - nx) < 0.4 and abs(ty - ny) < 0.4:
- return (float(tx), float(ty))
- return (nx, ny)
-
-
def render_frame(base: np.ndarray, view: Projection, tracks: list[Track],
when: float, *, trail_seconds: float = 0.0,
stale: float = 300.0, labels: bool = True,
- clock: str = "", unit: str = DEFAULT_SPEED_UNIT,
- project: bool = True, known=None,
- fade: float = 0.0, places: "LabelPlaces | None" = None,
- step: float = 0.0, home=None,
- box_opacity: float = 0.0) -> np.ndarray:
- """The map at one moment: where everything was, and where it had been.
-
- ``project`` is what makes an animation an animation: between reports an
- aircraft is dead-reckoned from the speed and heading it last gave. A
- still picture of a whole evening turns it off, because there the moment
- being drawn is hours after most of the aircraft stopped transmitting,
- and flying each of them on for those hours would scatter the lot of them
- across three states.
- """
+ clock: str = "") -> np.ndarray:
+ """The map at one moment: where everything was, and where it had been."""
img = base.copy()
flying = 0
- # Counted before anything is drawn, so that every label on one frame says
- # the same amount and the picture does not change its mind halfway down.
- crowded = sum(1 for track in tracks
- if track.at(when, stale=stale) is not None) > CROWDED
taken: list[tuple[int, int, int, int]] = []
- if places is not None:
- places.begin()
for track in tracks:
- if project:
- seen = showing(track, when, stale, fade)
- else:
- seen = ((track.fixes[-1], 1.0) if when >= track.fixes[-1].at
- else showing(track, when, stale, fade))
- if seen is None:
+ now = track.at(when, stale=stale)
+ if now is None:
continue
- now, strength = seen
flying += 1
trail = track.trail(when, trail_seconds)
recent = when - (trail_seconds or 120.0) / 2.0
- faded = fade_ramp(strength)
for a, b in zip(trail, trail[1:]):
- base_shade = TRAIL if b.at >= recent else OLD
- # A fading aircraft takes its trail with it: the two are one
- # thing on the picture and half of it lingering would be worse
- # than either.
- shade = max(base_shade, faded) + altitude_step(b.altitude_ft)
+ shade = (TRAIL if b.at >= recent else OLD) + \
+ altitude_step(b.altitude_ft)
x0, y0 = view.xy(a.latitude, a.longitude)
x1, y1 = view.xy(b.latitude, b.longitude)
_line(img, x0, y0, x1, y1, shade)
- colour = faded + altitude_step(now.altitude_ft)
+ colour = RAMP + altitude_step(now.altitude_ft)
x, y = view.xy(now.latitude, now.longitude)
_marker(img, x, y, now.track_deg, colour)
- if labels and strength > LABEL_WHILE:
- _label(img, x, y, track, now, colour, taken, unit,
- entry=(known.get(track.icao)
- if known and not crowded else None),
- places=places, step=step, strength=strength,
- brief=crowded, opacity=box_opacity)
- if places is not None:
- places.end()
+ if labels:
+ _label(img, x, y, track, now, colour, taken)
if clock:
_clock_strip(img, view, clock, flying)
- # The flag goes down after everything else, and nothing is drawn after
- # it: it says where the receiver is standing, which is the one thing on
- # the picture that must never be hidden behind an aeroplane that
- # happened to fly over it.
- if home is not None and view.inside(home[0], home[1]):
- x, y = view.xy(home[0], home[1])
- draw_home(img, x, y)
- # Last of all, so that everything drawn this frame glows and nothing
- # drawn after it paints over the halo. The halo only ever goes on the
- # ground, the grid and the background, so it cannot cover the flag.
- return bloom(img)
-
-
-def showing(track: Track, when: float, stale: float = 300.0,
- fade: float = 0.0):
- """Where an aircraft is at a moment, and how strongly to draw it.
-
- An aircraft that stops transmitting has not stopped existing, and taking
- it off the picture between one frame and the next says it did. So past
- the point where its position can still be believed it is left where it
- was last actually seen, fading, until it is gone -- which is what the
- watching eye reads as "that one has gone quiet", rather than as a
- blink.
-
- Fading happens at the last known position and never at a reckoned one:
- the whole reason for giving up on an aircraft is that where it would be
- by now is a guess.
- """
- now = track.at(when, stale=stale)
- if now is not None:
- return now, 1.0
- if fade <= 0 or not track.fixes:
- return None
- last = track.fixes[-1]
- gone = when - last.at - stale
- if gone < 0 or gone > fade:
- return None
- return last, max(0.0, 1.0 - gone / fade)
+ return img
def _marker(img: np.ndarray, x: int, y: int, heading: float,
colour: int) -> None:
- """A little arrowhead, pointing the way the aircraft is going.
-
- Nothing is drawn for an aircraft that is not on the picture. Said
- plainly because the obvious way to write the dot at its centre --
- clamping the near edge and letting the far one alone -- reads as a
- negative slice when the marker is off the top or the left, which numpy
- obligingly interprets as counting back from the far side and fills most
- of the frame with one colour.
- """
- height, width = img.shape
- if not (0 <= x < width and 0 <= y < height):
- return
+ """A little arrowhead, pointing the way the aircraft is going."""
angle = math.radians(heading % 360.0)
sin, cos = math.sin(angle), math.cos(angle)
@@ -1085,266 +426,50 @@ def _marker(img: np.ndarray, x: int, y: int, heading: float,
_triangle(img, (point(5.0, 0.0), point(-3.5, 3.0), point(-3.5, -3.0)),
colour)
- img[max(0, y - 1):min(height, y + 2),
- max(0, x - 1):min(width, x + 2)] = colour
-
-
-def label_lines(track: Track, now, unit: str = DEFAULT_SPEED_UNIT,
- entry=None, brief: bool = False) -> list[tuple[str, str]]:
- """What goes beside one aircraft, a line at a time.
-
- Each row is the text and the country whose flag belongs next to it -- a
- two-letter code the drawing turns into twelve pixels of one, or empty for
- a row that is only words.
-
- The height is in feet with its unit on it. It used to be the flight
- level, hundreds of feet the way it is said on the radio, which is shorter
- and is what an aviator reads -- but "376" beside an aircraft is only a
- height to somebody who already knows it is one. The airports stay as
- their codes, since a full name runs to forty characters beside an
- aircraft twelve pixels across.
- """
- rows: list[tuple[str, str]] = []
- # To the nearest twenty-five feet, which is the step Mode S reports
- # altitude in: a real reading is a multiple of it and comes through
- # untouched, while a moment between two reports -- which is interpolated,
- # and is most of the moments in an animation -- stops claiming to know
- # the aircraft's height to the foot.
- height = (f"{round(now.altitude_ft / 25) * 25:,} ft"
- if now.altitude_ft else "")
- # The unit goes on the number, every time: a bare "480" beside an
- # aircraft is three different speeds depending on who is reading it.
- speed = (f"{in_speed(now.ground_speed_kt, unit):.0f}"
- f"{speed_label(unit).upper()}") if now.ground_speed_kt else ""
- line = " ".join(x for x in (height, speed) if x)
- if line:
- rows.append((line, ""))
- if brief:
- # A crowded picture: the height and the speed and nothing else. A
- # flag and a class beside each of three hundred aircraft is not more
- # information, it is a page of small pictures with a map behind it.
- return rows
- from .flags import iso_for
- from .flights import aircraft_class, describe_address
-
- # The country of registration, off the address block, so it is there for
- # an aircraft no register has ever heard of. Taking it from the
- # register's answer instead left the flag off exactly the aircraft that
- # had nothing else beside them either.
- home = iso_for(getattr(entry, "owner_country", "")
- or getattr(entry, "country", "")
- or describe_address(track.icao))
- # What sort of aircraft it is: what it broadcast about itself, and
- # whether its address is a military one.
- kind_of = aircraft_class(track.icao, getattr(track, "category", ""))
- if kind_of:
- rows.append((kind_of, home))
- if entry is None:
- # Nothing but the flag left to say, and it is worth a row of its
- # own: it says where the aeroplane is from, which is more than the
- # rest of an unknown aircraft's box says put together.
- if home and not kind_of:
- rows.append(("", home))
- return rows
- kind = entry.type_code or entry.model
- identity = " ".join(x for x in (kind, entry.registration) if x)
- if identity:
- rows.append((identity, "" if kind_of else home))
- elif home and not kind_of:
- rows.append(("", home))
- # Only when the aircraft could actually be flying it. A callsign is a
- # flight number and an airline runs the same number over several legs in
- # a day, so a register's one route for it is quite often somebody else's
- # leg -- and a route drawn beside an aircraft reads as a statement about
- # that aircraft.
- from .flights import route_fits
-
- if not route_fits(entry, now.latitude, now.longitude):
- return rows
- if entry.origin_code or entry.origin:
- rows.append((_short_place(entry.origin_code, entry.origin),
- entry.origin_country))
- if entry.destination_code or entry.destination:
- rows.append((_short_place(entry.destination_code, entry.destination),
- entry.destination_country))
- return rows
-
-
-def _short_place(code: str, name: str) -> str:
- """An airport in as few characters as still say which one."""
- if code:
- return code
- return " ".join((name or "").split()[:2])[:18]
+ img[max(0, y - 1):y + 2, max(0, x - 1):x + 2] = colour
def _label(img: np.ndarray, x: int, y: int, track: Track, now,
- colour: int, taken: list | None = None,
- unit: str = DEFAULT_SPEED_UNIT, entry=None,
- places: "LabelPlaces | None" = None, step: float = 0.0,
- strength: float = 1.0, brief: bool = False,
- opacity: float = 0.0) -> None:
- """Who it is, and everything else known about it, beside the aircraft.
+ colour: int, taken: list | None = None) -> None:
+ """Who it is and how high, beside the aircraft.
+
+ The height is the flight level -- hundreds of feet, the way it is said
+ on the radio -- because five digits beside every aircraft is a picture
+ made of numbers.
Two aircraft that pass close together would otherwise have their labels
written over each other, which is exactly the moment somebody is looking
at that part of the picture. So the four places a label can go are tried
in turn and the first clear one is used; when they are all taken the
label goes to the right anyway, because a label somewhere beats none.
-
- ``places`` carries what was decided on the frame before, so that a label
- keeps its spot instead of being re-decided from nothing every frame, and
- swings to a new one rather than jumping. Without it -- a still picture,
- where there is no frame before -- the placing is exactly as it was.
"""
- from .flags import FLAG_W
-
height, width = img.shape
- rows = label_lines(track, now, unit, entry, brief=brief)
- indent = FLAG_W + 3 if any(country for _, country in rows) else 0
- span = max([text_width(track.name)]
- + [indent + text_width(text) for text, _ in rows])
- tall = GLYPH_H + (GLYPH_H + 2) * len(rows) + 1
- def clear(at_x, at_y):
- if at_x < 2 or at_x + span > width - 2:
- return None
- if at_y < 1 or at_y + tall > height - 1:
- return None
- box = (at_x, at_y, at_x + span, at_y + tall)
- if taken is not None and any(_overlaps(box, other) for other in taken):
- return None
- return int(at_x), int(at_y)
-
- beside = ((x + 8, y - GLYPH_H - 1), # right, the usual place
+ name = track.name
+ below = f"{now.altitude_ft // 100:03d}" if now.altitude_ft else ""
+ if now.ground_speed_kt:
+ below = f"{below} {now.ground_speed_kt:.0f}KT".strip()
+ span = max(text_width(name), text_width(below))
+ tall = GLYPH_H * 2 + 3
+ places = ((x + 8, y - GLYPH_H - 1), # right, the usual place
(x - 8 - span, y - GLYPH_H - 1), # left
(x - span // 2, y + 9), # under it
(x - span // 2, y - tall - 6)) # over it
- # The place it had, first of all, and only if something has taken it is
- # anywhere else considered.
- found = None if places is None else places.kept(track.icao, x, y, clear)
- if found is None:
- found = next((spot for spot in map(lambda p: clear(*p), beside)
- if spot), None)
- if found is None:
- # The four places beside it are taken, which happens as soon as a few
- # aircraft are close together. Working outwards buys a readable
- # label for the price of a longer look, which is the right trade.
- for reach in (1.3, 1.8, 2.5, 3.4):
- for step in range(12):
- angle = math.tau * step / 12
- found = clear(x + math.cos(angle) * span * reach - span / 2,
- y + math.sin(angle) * tall * reach - tall / 2)
- if found:
- break
- if found:
- break
- left, top = found if found else beside[0]
- left = max(2, min(int(left), width - 2 - span))
- top = max(1, min(int(top), height - 1 - tall))
- # What is spoken for is where the label is settling, not where it has
- # got to: laying the next one out against a label in mid-swing would
- # move that one as well, and move it back when the first arrived.
+ left, top = places[0]
+ for candidate_x, candidate_y in places:
+ if candidate_x < 2 or candidate_x + span > width - 2:
+ continue
+ if candidate_y < 1 or candidate_y + tall > height - 1:
+ continue
+ box = (candidate_x, candidate_y, candidate_x + span, candidate_y + tall)
+ if taken is None or not any(_overlaps(box, other) for other in taken):
+ left, top = candidate_x, candidate_y
+ break
+ left = max(2, min(left, width - 2 - span))
if taken is not None:
taken.append((left, top, left + span, top + tall))
- if places is not None:
- places.settle(track.icao, x, y, (left, top))
- left, top = places.drawn(track.icao, x, y, (left, top), step)
- left = max(2, min(left, width - 2 - span))
- top = max(1, min(top, height - 1 - tall))
- # The box fades with the aircraft: the name already did, because it is
- # drawn in the aircraft's own colour, but 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.
- level = fade_level(strength)
- # A card behind the words, as translucent as the setting asks for. An
- # indexed picture cannot blend, so this darkens the ground under the
- # label towards the panel colour instead: at nothing it is untouched and
- # the words sit straight on the map as they always did, and at the whole
- # way it is the panel and nothing of the map shows through.
- if opacity > 0.0:
- _panel(img, left - 3, top - 2, left + span + 3, top + tall,
- opacity * strength)
- # A line from the label to the aircraft it belongs to, dashed and in its
- # own colour. The window has always had one; here there was nothing at
- # all, and a label pushed out into one of the rings by a crowd had
- # nothing tying it to the aeroplane it was about. Dashed, and not in
- # the aircraft's colour, because a solid line running out of an
- # aeroplane in the colour of the path behind it reads as more path.
- #
- # To the near edge of the label rather than into the middle of it: a
- # leader drawn to the centre crosses the words and strikes out a line of
- # what it was drawn to point at.
- _dashed(img, x, y,
- int(max(left, min(x, left + span))),
- int(max(top, min(y, top + tall))),
- LEADER if level <= 0 else LEADER + min(level, 2))
- draw_text(img, left, top, track.name, colour)
- at = top + GLYPH_H + 2
- for text, country in rows:
- if country:
- draw_flag(img, left, at - 1, country, level)
- draw_text(img, left + indent, at, text, LABEL_INK + level)
- at += GLYPH_H + 2
-
-
-def _panel(img: np.ndarray, x0: int, y0: int, x1: int, y1: int,
- opacity: float) -> None:
- """Darken a rectangle towards the panel colour, by however much.
-
- The ground has thirty-two shades and this walks them down: at a tenth
- the map is very nearly untouched, at nine tenths it is a dark card with
- the coastline just showing through, and at the whole way it is the panel
- colour with nothing behind it at all. The background, which is already
- as dark as the picture goes, is left alone until the very top of the
- range, where the whole rectangle becomes the panel.
- """
- height, width = img.shape
- x0, x1 = max(0, int(x0)), min(width, int(x1))
- y0, y1 = max(0, int(y0)), min(height, int(y1))
- if x1 <= x0 or y1 <= y0:
- return
- part = max(0.0, min(1.0, float(opacity)))
- if part <= 0.0:
- return
- patch = img[y0:y1, x0:x1]
- if part >= 0.98:
- patch[:] = PANEL
- return
- ground = (patch >= GROUND) & (patch < GROUND + GROUND_SHADES)
- shade = (patch.astype(np.int16) - GROUND) * (1.0 - part)
- patch[:] = np.where(ground, (GROUND + shade.round()).astype(np.uint8),
- patch)
-
-
-def draw_flag(img: np.ndarray, x: int, y: int, country: str,
- level: int = 0) -> None:
- """Twelve pixels by eight of a flag, or the country's letters instead.
-
- A country with no flag here is named rather than approximated: two
- letters are never wrong, and a flag that is nearly another country's is
- worse than no flag at all.
- """
- from .flags import FLAG_W, flag_for
-
- rows = flag_for(country) if THEME.flags else None
- if rows is None:
- # Named in the theme's own colour rather than the grid's when the
- # theme has no flags at all: there it is not a fallback, it is what
- # every country gets, and it has to be as readable as the row it
- # sits beside.
- plain = DIM if not THEME.flags else GRID
- draw_text(img, x, y + 1, country[:2].upper(),
- plain if level <= 0 else LABEL_INK + min(level, 3))
- return
- height, width = img.shape
- for row, line in enumerate(rows):
- yy = y + row
- if not 0 <= yy < height:
- continue
- for column, letter in enumerate(line[:FLAG_W]):
- xx = x + column
- if 0 <= xx < width:
- img[yy, xx] = flag_index(letter, level)
+ draw_text(img, left, top, name, colour)
+ if below:
+ draw_text(img, left, top + GLYPH_H + 3, below, DIM)
def _overlaps(a, b) -> bool:
@@ -1549,15 +674,13 @@ class Animation:
height: int = 0
aircraft: int = 0
covers: float = 0.0 # seconds of real time in the picture
- ground: bool = False # whether a real map went under it
def summary(self) -> str:
real = _span(self.covers)
played = _span(self.frames / self.fps if self.fps else 0.0)
return (f"{self.aircraft} aircraft, {real} of flying in {played} "
f"({self.speed:.0f}x), {self.frames} frames at "
- f"{self.width}x{self.height}"
- + (", on the map" if self.ground else ""))
+ f"{self.width}x{self.height}")
def _span(seconds: float) -> str:
@@ -1570,142 +693,38 @@ def _span(seconds: float) -> str:
return f"{hours} h {minutes:02d} min"
-def local_airports(view: Projection, ask=None) -> list:
- """Every aerodrome inside the picture, from the map data itself.
-
- A route only names the airports its aircraft are flying between, and
- those are almost never the ones underneath: a receiver hears aircraft
- over its own county, and the county's airports are exactly what says
- where on the map you are looking.
- """
- from . import basemap
-
- try:
- extra = {"ask": ask} if ask is not None else {}
- found = basemap.airports_in(view.south, view.west, view.north,
- view.east, **extra)
- except Exception:
- return []
- return [(a["code"], a["latitude"], a["longitude"]) for a in found]
-
-
-def _known_from(book, tracks: list[Track]) -> dict:
- """What a register says about each aircraft, looked up once.
-
- Once, rather than once a frame: a five-hundred-frame animation would
- otherwise ask the same question five hundred times, and the book is
- thread-safe rather than free.
- """
+def _airports_from(book, tracks: list[Track]):
+ """Every airport a route lookup gave a position for."""
if book is None:
- return {}
- out = {}
+ return []
+ seen: dict[str, tuple[str, float, float]] = {}
for track in tracks:
entry = book.get(track.icao, track.callsign)
- if track.located and hasattr(book, "resolve"):
- # Where a source listed a whole day's stops rather than a leg,
- # the aircraft's own position says which leg it is on.
- here = track.fixes[len(track.fixes) // 2]
- entry = book.resolve(entry, here.latitude, here.longitude)
- out[track.icao] = entry
- return out
-
-
-def _airports_from(known: dict, book=None):
- """Every airport named by a route, wherever its position can be had.
-
- A route names two airports and often gives a position for neither, so
- the codes are looked up as well -- once each, and remembered. An
- airport that cannot be placed cannot be drawn, and a map of an evening's
- flying with no airports on it is missing the two ends of every flight.
- """
- seen: dict[str, tuple[str, float, float]] = {}
- wanted: set[str] = set()
- for entry in known.values():
- for code, lat, lon in ((getattr(entry, "origin_code", ""),
- getattr(entry, "origin_lat", 0.0),
- getattr(entry, "origin_lon", 0.0)),
- (getattr(entry, "destination_code", ""),
- getattr(entry, "destination_lat", 0.0),
- getattr(entry, "destination_lon", 0.0))):
- if not code:
- continue
- if lat or lon:
+ for code, lat, lon in ((entry.origin_code, entry.origin_lat,
+ entry.origin_lon),
+ (entry.destination_code, entry.destination_lat,
+ entry.destination_lon)):
+ if code and (lat or lon):
seen[code] = (code, lat, lon)
- else:
- wanted.add(code)
- if book is not None and hasattr(book, "airports"):
- try:
- for found in book.airports(sorted(wanted - set(seen))):
- seen[found["code"]] = (found["code"], found["latitude"],
- found["longitude"])
- except Exception:
- pass # a map with fewer airports on it
return list(seen.values())
-def ground_for(view: Projection, fetch=None, url: str = "") -> tuple:
- """The real map under a picture, and the credit that has to go with it.
-
- Returns ``(levels, attribution)``, or ``(None, "")`` when there is no
- network, no tile server or nothing usable came back -- in which case the
- picture is drawn the way it always was, on its own grid.
- """
- from . import basemap
-
- try:
- extra = {"fetch": fetch} if fetch is not None else {}
- if url:
- extra["url"] = url
- levels = basemap.ground_under(
- view.south, view.west, view.north, view.east,
- view.width, view.height, shades=GROUND_SHADES, **extra)
- except Exception:
- return None, ""
- return (levels, basemap.ATTRIBUTION) if levels is not None else (None, "")
-
-
def animate(tracks: list[Track], out_path, *, fps: float = 12.0,
seconds: float = 30.0, speed: float = 0.0, width: int = 960,
trail_seconds: float = 0.0, stale: float = 300.0,
title: str = "", book=None, labels: bool = True,
- kind: str = "", unit: str = DEFAULT_SPEED_UNIT,
- ground: bool = False, fetch=None, tile_url: str = "",
- radius_nm: float = 0.0, centre=None,
- brightness: float = GROUND_BRIGHTNESS,
- airports: bool = False, ask=None, rings: bool = False,
- box_opacity: float = 0.0,
- fade: float = 0.0) -> Animation | None:
+ kind: str = "") -> Animation | None:
"""Draw the whole log as a moving map.
``speed`` is how many seconds of real flying go by in one second of
animation; given ``seconds`` instead, it is worked out so the whole log
plays in about that long. The clock in the corner is the real time of
day, so a fast animation is still readable as an evening.
-
- ``ground`` and ``airports`` both reach for a network and are therefore
- both off here, and both on in the settings the program itself uses: a
- library call should not go out to the world unless it was asked to.
"""
located = [t for t in tracks if t.located]
if not located:
return None
-
- # A receiver hears a hundred miles on a good day and a wrong position can
- # come from anywhere, so a map drawn to fit everything heard is drawn to
- # fit the errors: the aircraft end up a pixel across in the middle of an
- # empty continent. Framing it on the receiver instead keeps the scale
- # the same from one evening to the next, and leaves the mistakes off the
- # edge where they belong.
- box = None
- if radius_nm > 0:
- middle = centre or centre_of(located)
- if middle is not None:
- located = [t for t in within(located, middle[0], middle[1],
- radius_nm) if t.located]
- if not located:
- return None
- box = box_around(middle[0], middle[1], radius_nm)
- view = fit(located, width=width, box=box)
+ view = fit(located, width=width)
if view is None:
return None
start = min(t.fixes[0].at for t in located)
@@ -1722,66 +741,31 @@ def animate(tracks: list[Track], out_path, *, fps: float = 12.0,
speed = covers / max(1e-9, (frame_count - 1) / fps)
day = datetime.fromtimestamp(start).strftime("%Y-%m-%d")
heading = title or f"{len(located)} AIRCRAFT {day}"
- known = _known_from(book, located)
- levels, credit = ground_for(view, fetch, tile_url) if ground \
- else (None, "")
- marked = _airports_from(known, book)
- if airports:
- # The ones underneath as well as the ones flown between, and the
- # nearer ones last so they win where two share a name.
- seen = {code for code, _, _ in marked}
- marked += [one for one in local_airports(view, ask)
- if one[0] not in seen]
- base = background(view, title=heading, airports=marked,
- unit=unit, ground=levels, attribution=credit,
- brightness=brightness,
- # 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, and a flag on it
- # would say that somebody is.
- home=centre,
- # The rings are measured from the radius asked for,
- # so they mean nothing without one.
- rings=radius_nm if rings and centre else 0.0)
+ base = background(view, title=heading, airports=_airports_from(book, located))
canvas_w, canvas_h = canvas_size(view)
base = _pad_to(base, canvas_w, canvas_h)
def frames():
- # One of these for the whole run: it is what remembers where each
- # label was on the frame before, so a label can keep its place and
- # swing to a new one instead of being re-decided from nothing. The
- # step is a frame of the animation as it plays, not of the flying,
- # so the swing takes half a second to watch however fast the evening
- # is being run through.
- places = LabelPlaces()
- step = 1.0 / fps if fps else 0.0
for i in range(frame_count):
when = start + i * speed / fps
clock = datetime.fromtimestamp(when).strftime("%H:%M:%S")
yield _pad_to(render_frame(base, view, located, when,
trail_seconds=trail_seconds,
stale=stale, labels=labels,
- clock=clock, unit=unit, known=known,
- fade=fade, places=places, step=step,
- home=centre,
- box_opacity=box_opacity),
- canvas_w, canvas_h)
+ clock=clock), canvas_w, canvas_h)
path = Path(out_path)
kind = (kind or path.suffix.lstrip(".") or "gif").lower()
if kind == "png":
# Not an animation at all: the whole log at once, every path drawn.
still = render_frame(base, view, located, finish, stale=covers + 1,
- labels=labels, unit=unit, project=False,
- known=known, home=centre,
- box_opacity=box_opacity,
+ labels=labels,
clock=datetime.fromtimestamp(finish)
.strftime("%H:%M:%S"))
write_png(path, PALETTE[still])
return Animation(path=path, kind="png", frames=1, fps=0.0, speed=speed,
width=canvas_w, height=canvas_h,
- aircraft=len(located), covers=covers,
- ground=levels is not None)
+ aircraft=len(located), covers=covers)
if kind in ("mp4", "mov", "m4v"):
write_mp4(path, frames(), PALETTE, fps=fps, size=(canvas_w, canvas_h))
else:
@@ -1791,8 +775,7 @@ def animate(tracks: list[Track], out_path, *, fps: float = 12.0,
write_gif(path, frames(), PALETTE, delay_cs=delay)
return Animation(path=path, kind=kind, frames=frame_count, fps=fps,
speed=speed, width=canvas_w, height=canvas_h,
- aircraft=len(located), covers=covers,
- ground=levels is not None)
+ aircraft=len(located), covers=covers)
def _pad_to(img: np.ndarray, width: int, height: int) -> np.ndarray:
diff --git a/bandsaunter/flights.py b/bandsaunter/flights.py
index 4a9a025..c008813 100644
--- a/bandsaunter/flights.py
+++ b/bandsaunter/flights.py
@@ -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),
- daemon=True)
+ 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 "")
diff --git a/bandsaunter/images.py b/bandsaunter/images.py
index 6da81f7..3917066 100644
--- a/bandsaunter/images.py
+++ b/bandsaunter/images.py
@@ -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"),
diff --git a/bandsaunter/livemap.py b/bandsaunter/livemap.py
deleted file mode 100644
index 3ca10ee..0000000
--- a/bandsaunter/livemap.py
+++ /dev/null
@@ -1,1586 +0,0 @@
-"""The sky in a window, while it is happening.
-
-The terminal board says what is overhead; this says where. A real map, the
-aircraft on it moving as the frames arrive, and beside each one a box with
-everything known about it -- what it is, who flies it, where it came from,
-how high, how fast, how far away and how long since it last said anything.
-
-Qt is asked for and not required. Everything here is behind a lazy import,
-so a machine with no Qt installed loses this window and nothing else: the
-passive capture, the terminal board, the logs and the animations all work
-exactly as they did. Four bindings are tried because distributions disagree
-about which one to package, and the differences between them are two lines
-of shim -- Qt 6 scopes its enumerations where Qt 5 did not, and PySide spells
-Signal the way PyQt spells pyqtSignal.
-"""
-
-from __future__ import annotations
-
-import math
-import time
-from collections import deque
-from dataclasses import dataclass
-
-import numpy as np
-
-from .flags import iso_for
-from .flightlog import (DEFAULT_SPEED_UNIT, distance_label, distance_nm,
- in_distance, in_speed, speed_label)
-from .flightmap import LEADER_DASH as _LEADER_DASH
-from .flightmap import Projection, altitude_step
-from .ui import compass
-
-# ``SkyView`` and ``Window`` are part of this module's interface too, but
-# they are built on first use -- see __getattr__ at the foot of the file --
-# so that importing it costs nothing on a machine with no Qt on it, and so
-# they cannot be listed here.
-__all__ = ["available", "binding", "show", "fetch_ground", "Sky", "Blip",
- "blip_for", "place_box", "glide", "dash_for", "wrap_value",
- "WRAP_CHARS",
- "MISSING_QT"]
-
-BINDINGS = ("PyQt6", "PyQt5", "PySide6", "PySide2")
-
-MISSING_QT = (
- "This needs Qt, which is not installed.\n"
- " Debian/Ubuntu: sudo apt install python3-pyqt6\n"
- " Fedora: sudo dnf install python3-pyqt6\n"
- " Arch: sudo pacman -S python-pyqt6\n"
- " or: pip install PyQt6\n"
- "Everything else works without it: passive capture still records, and "
- "`bandsaunter flights` still draws the map afterwards."
-)
-
-# How often the window redraws itself. Positions arrive about twice a second
-# and nothing on the picture moves faster than an aeroplane, so this is
-# generous already; a busy sky is drawn perfectly well at this rate.
-REDRAW_MS = 200
-
-# The trail behind each aircraft, in positions rather than seconds, because
-# it is drawn every frame and wants a hard bound.
-TRAIL_POINTS = 400
-
-# How far the worked-out middle of the map has to drift before it is moved.
-# Below this it is noise from aircraft arriving and leaving, and moving the
-# map for it costs the tiles that were fetched for the old view.
-RESETTLE_NM = 12.0
-
-# How much map to fetch beyond what is being shown. A window that fetched
-# exactly what it needed would fetch again on every resize and every small
-# drift. Modest, because the fetch is now at the picture's own resolution
-# and every extra degree of margin is a ring of tiles: with the middle of
-# the map settling rather than wandering, a little is enough.
-GROUND_MARGIN = 0.12
-
-# The widest a line in a box is allowed to get before it is folded. An
-# airport's full name and the town it is in run to forty characters on their
-# own and a route is two of them, so one flight from Los Angeles to Dallas
-# Fort Worth would otherwise make its box wider than the map it sits on.
-WRAP_CHARS = 30
-
-# The flag is drawn at its own size: the box font is about eleven pixels
-# tall, so twelve by eight sits on a line without crowding it.
-FLAG_PIXELS = 12
-
-# Below this an aircraft keeps its symbol and loses its box.
-LABEL_WHILE = 0.40
-
-# The dashes on the line from a box to its aircraft, in pixels on and off.
-# Taken from the drawing that also uses it rather than written down twice:
-# the two pictures are meant to look like the same program, and two copies
-# of a number are two chances to change only one of them.
-LEADER_DASH = tuple(float(step) for step in _LEADER_DASH)
-
-
-def dash_for(pattern, width: float) -> list:
- """A dash pattern in pixels, as the pen drawing it wants to be told.
-
- Qt measures a dash pattern in multiples of the pen's own width, so the
- same pattern handed to a wide pen and a narrow one gives dashes of
- different lengths. A glowing line is the same line drawn two or three
- times at different widths, so without this its halo would have dashes
- three times the length of its core and the line would come out as beads
- rather than as a dashed line.
- """
- width = max(0.1, float(width))
- return [max(0.1, float(step) / width) for step in pattern]
-
-# How long a box takes to swing across when it has to move, and how often to
-# repaint while one is on its way. The ordinary redraw is five times a
-# second, which is plenty for aeroplanes -- they move a pixel or two between
-# frames -- but a box crossing the window in five steps is a slideshow, so
-# the picture is redrawn faster for as long as anything is actually moving
-# and drops back to the slow rate the moment everything has settled.
-BOX_GLIDE = 0.45
-BOX_GLIDE_MS = 33
-BOX_SETTLED_PX = 0.4
-
-
-def _alpha(strength: float) -> int:
- """How opaque something at this strength is drawn."""
- return max(0, min(255, int(round(255 * strength))))
-
-
-def _qt():
- """The first Qt binding that imports, or None.
-
- Cached on the function, because working this out on every paint would be
- silly and because the answer cannot change while the program runs.
- """
- if getattr(_qt, "_found", "?") != "?":
- return _qt._found
- for name in BINDINGS:
- try:
- core = __import__(f"{name}.QtCore", fromlist=["QtCore"])
- gui = __import__(f"{name}.QtGui", fromlist=["QtGui"])
- widgets = __import__(f"{name}.QtWidgets", fromlist=["QtWidgets"])
- except ImportError:
- continue
- signal = getattr(core, "pyqtSignal", None) or getattr(core, "Signal")
- _qt._found = (name, core, gui, widgets, signal)
- return _qt._found
- _qt._found = None
- return None
-
-
-def available() -> bool:
- """Whether a window can be opened at all."""
- return _qt() is not None
-
-
-def binding() -> str:
- """Which Qt is being used, for saying so on the screen."""
- found = _qt()
- return found[0] if found else ""
-
-
-def _enum(owner, group: str, name: str):
- """Qt 6 scopes its enumerations; Qt 5 hangs them on the class."""
- return getattr(getattr(owner, group, owner), name)
-
-
-# ---------------------------------------------------------------------------
-# What the window is told about one aircraft
-# ---------------------------------------------------------------------------
-
-@dataclass
-class Blip:
- """One aircraft, as far as the window is concerned.
-
- A flat copy rather than the live object, taken on the listening thread:
- the registry is being written to as fast as frames arrive, and painting
- from underneath that is how a display ends up drawing half of one
- aircraft and half of another.
- """
-
- icao: str = ""
- callsign: str = ""
- latitude: float = 0.0
- longitude: float = 0.0
- altitude_ft: int = 0
- ground_speed_kt: float = 0.0
- track_deg: float = 0.0
- vertical_rate_fpm: int = 0
- messages: int = 0
- category: str = "" # what it said it was: heavy, rotorcraft...
- first_seen: float = 0.0
- last_seen: float = 0.0
- # Everything below comes from a register rather than off the air.
- registration: str = ""
- type_code: str = ""
- model: str = ""
- manufacturer: str = ""
- operator: str = ""
- country: str = ""
- country_code: str = "" # two letters, for the flag beside it
- origin: str = ""
- origin_country: str = "" # two letters, for the flag beside it
- destination: str = ""
- destination_country: str = ""
- # Whether this aircraft could be flying the route its callsign is given.
- # A callsign is a flight number rather than a leg, so often it could not.
- route_fits: bool = True
-
- @property
- def located(self) -> bool:
- return bool(self.latitude or self.longitude)
-
- @property
- def name(self) -> str:
- return self.callsign or self.icao
-
- def lines(self, unit: str, home=None) -> list[tuple[str, str, str]]:
- """The box's contents: a label, a value and a flag, a line at a time.
-
- The flag is a two-letter country code the drawing turns into twelve
- pixels of one, and is empty for every line that is only words.
-
- Everything that is known and nothing that is not -- an empty line for
- a register that has not answered yet would leave a box full of gaps
- that never fill in.
- """
- out: list[tuple[str, str, str]] = []
- # What sort of aircraft it is: the emitter category, which came off
- # the air with the callsign, and whether the address is a military
- # one, which comes off no air at all.
- from .flights import aircraft_class
-
- sort_of = aircraft_class(self.icao, self.category)
- if sort_of:
- out.append(("class", sort_of, ""))
- kind = " ".join(x for x in (self.manufacturer, self.model) if x)
- if self.type_code and self.registration:
- out.append((self.type_code, self.registration, ""))
- elif self.registration:
- out.append(("reg", self.registration, ""))
- if kind:
- out.append(("", kind, ""))
- if self.operator:
- out.append(("", self.operator, ""))
- # The two ends of the flight on their own lines rather than joined by
- # an arrow, so that each can carry the flag of the country it is in.
- if self.route_fits and self.origin:
- out.append(("from", self.origin, self.origin_country))
- if self.route_fits and self.destination:
- out.append(("to", self.destination, self.destination_country))
- if self.altitude_ft:
- climb = ""
- if self.vertical_rate_fpm > 100:
- climb = f" ↑{self.vertical_rate_fpm:,} fpm"
- elif self.vertical_rate_fpm < -100:
- climb = f" ↓{abs(self.vertical_rate_fpm):,} fpm"
- out.append(("alt", f"{self.altitude_ft:,} ft{climb}", ""))
- if self.ground_speed_kt:
- out.append(("gs", f"{in_speed(self.ground_speed_kt, unit):.0f} "
- f"{speed_label(unit)} "
- f"{self.track_deg:03.0f}° "
- f"{compass(self.track_deg)}", ""))
- if self.located and home is not None:
- away = distance_nm(home[0], home[1], self.latitude, self.longitude)
- out.append(("range", f"{in_distance(away, unit):.0f} "
- f"{distance_label(unit)} "
- f"{self._bearing(home):03.0f}°", ""))
- if self.located:
- out.append(("pos", f"{self.latitude:.4f}, "
- f"{self.longitude:.4f}", ""))
- age = max(0.0, time.time() - self.last_seen)
- out.append(("", f"{self.messages:,} frames {age:.0f}s ago", ""))
- if self.country:
- out.append(("reg'd", self.country, self.country_code))
- return out
-
- def _bearing(self, home) -> float:
- p1, p2 = math.radians(home[0]), math.radians(self.latitude)
- dl = math.radians(self.longitude - home[1])
- y = math.sin(dl) * math.cos(p2)
- x = math.cos(p1) * math.sin(p2) - math.sin(p1) * math.cos(p2) * math.cos(dl)
- return math.degrees(math.atan2(y, x)) % 360.0
-
-
-def wrap_value(text: str, width: int = WRAP_CHARS) -> list[str]:
- """Fold one line of a box onto as many lines as it needs.
-
- A route is broken at the arrow before anything else, so that the two ends
- of the flight stay whole and sit under one another where they can be read
- as a pair. Everything else is folded between words, and a single word
- longer than the whole width -- which is nothing an aircraft has ever sent,
- but is exactly the sort of thing a register will one day return -- is cut
- rather than allowed to widen the box on its own.
- """
- text = (text or "").strip()
- if len(text) <= width:
- return [text]
- if " → " in text:
- origin, destination = text.split(" → ", 1)
- folded = wrap_value(origin, width)
- onward = wrap_value(destination, max(4, width - 2))
- return folded + [f"→ {piece}" if i == 0 else f" {piece}"
- for i, piece in enumerate(onward)]
- out: list[str] = []
- line = ""
- for word in text.split():
- while len(word) > width:
- if line:
- out.append(line)
- line = ""
- out.append(word[:width])
- word = word[width:]
- if line and len(line) + 1 + len(word) > width:
- out.append(line)
- line = word
- else:
- line = f"{line} {word}".strip()
- if line:
- out.append(line)
- return out or [text]
-
-
-def blip_for(craft, entry=None) -> Blip:
- """One aircraft as the window wants it, with whatever a register said."""
- blip = Blip(icao=craft.icao, callsign=craft.callsign,
- latitude=craft.latitude, longitude=craft.longitude,
- altitude_ft=craft.altitude_ft,
- ground_speed_kt=craft.ground_speed_kt,
- track_deg=craft.track_deg,
- vertical_rate_fpm=craft.vertical_rate_fpm,
- messages=craft.messages, first_seen=craft.first_seen,
- last_seen=craft.last_seen,
- category=getattr(craft, "category", ""))
- if entry is not None:
- blip.registration = entry.registration
- blip.type_code = entry.type_code
- blip.model = entry.model
- blip.manufacturer = entry.manufacturer
- blip.operator = entry.operator or entry.airline
- blip.country = entry.owner_country or entry.country
- blip.country_code = iso_for(blip.country)
- blip.origin = entry.origin or entry.origin_code
- blip.origin_country = entry.origin_country
- blip.destination = entry.destination or entry.destination_code
- blip.destination_country = entry.destination_country
- if blip.located:
- from .flights import route_fits
-
- blip.route_fits = route_fits(entry, blip.latitude, blip.longitude)
- return blip
-
-
-# ---------------------------------------------------------------------------
-# What the listening thread and the painting thread share
-# ---------------------------------------------------------------------------
-
-class Sky:
- """Everything the window draws, written by one thread and read by another.
-
- Plain locking rather than Qt's signals, so that none of this depends on
- which binding was found -- and so that the whole of it can be tested
- without a window at all.
- """
-
- def __init__(self, unit: str = DEFAULT_SPEED_UNIT, hold: float = 45.0,
- home=None, radius_nm: float = 100.0,
- brightness: float = 0.70, fade: float = 20.0,
- airports: bool = False, rings: bool = False,
- box_opacity: float = 0.85):
- import threading
-
- self.unit = unit
- self.hold = hold
- self.home = home
- self.radius_nm = radius_nm
- self.brightness = brightness
- self.fade = fade
- # Off unless asked for, because saying yes means a question to a
- # network the first time an area is drawn. The program turns it on;
- # anything using this as a library has to say so on purpose.
- self.show_airports = airports
- self.show_rings = rings
- # How solid the card behind each information box is. Nothing at
- # all puts the words straight on the map, which is readable over
- # water and not over a city.
- self.box_opacity = max(0.0, min(1.0, float(box_opacity)))
- self.frames = 0
- self.aircraft_seen = 0
- self.started = time.time()
- self.log_name = ""
- self.note = ""
- self.stopping = False
- # Set when the listening has finished of its own accord -- the time
- # asked for has run out, or the receiver stopped. The window shuts
- # itself when it sees this, so that "listen for ten minutes" means
- # the same thing whether or not there is a window open.
- self.finished = False
- self._lock = threading.Lock()
- self._blips: dict[str, Blip] = {}
- self._trails: dict[str, deque] = {}
- self._ground = None
- self._ground_for = None
- self._ground_box = None
- self._ground_serial = 0
- # The aerodromes under the view, fetched with the map and kept the
- # same way: they come from the same place, cover the same box, and
- # go stale at the same moment.
- self._airports: list | None = None
- self._airports_box = None
- self._airports_want = None
- self._wanted = None
- self._settled = None
-
- # -- written by the listener ------------------------------------------
- def update(self, blips, frames: int, seen: int) -> None:
- with self._lock:
- self.frames = frames
- self.aircraft_seen = seen
- for blip in blips:
- self._blips[blip.icao] = blip
- if blip.located:
- trail = self._trails.setdefault(
- blip.icao, deque(maxlen=TRAIL_POINTS))
- here = (blip.latitude, blip.longitude, blip.altitude_ft)
- if not trail or trail[-1][:2] != here[:2]:
- trail.append(here)
-
- def set_ground(self, levels, key, box=None) -> None:
- """Keep the map that was fetched, and the piece of world it covers.
-
- A failed fetch is kept too, as nothing: otherwise a machine with no
- network asks for the same tiles five times a second all night.
- """
- with self._lock:
- self._ground, self._ground_for = levels, key
- self._ground_box = box
- # Counted rather than compared: the drawing side keeps the
- # dimmed pixels it made last time, and needs to know whether
- # what it made them from is still the same map.
- self._ground_serial += 1
- if self._wanted is not None and self._wanted[0] == key:
- self._wanted = None
-
- def set_airports(self, found, box) -> None:
- """Keep the aerodromes fetched, and the piece of world they cover.
-
- An empty answer is kept as an empty list rather than as nothing at
- all: an area with no aerodromes in it has been asked about and
- answered, and asking again five times a second for the rest of the
- night would be the wrong lesson to draw from it.
- """
- with self._lock:
- self._airports = list(found or [])
- self._airports_box = box
- if self._airports_want == box:
- self._airports_want = None
-
- def want_airports(self, box) -> None:
- """Say which aerodromes are needed. Painting never waits."""
- with self._lock:
- if self._airports_box != box:
- self._airports_want = box
-
- def wanted_airports(self):
- with self._lock:
- return self._airports_want
-
- def airports_covering(self, south, west, north, east):
- """The aerodromes fetched, or None if what is here does not cover.
-
- Held to the same rule as the map: what was asked for covers rather
- more world than is being shown, so a view that has drifted inside it
- is answered from what is already here. A view that has moved
- outside it is answered with None -- not an empty list, which would
- mean "asked, and there are none" -- so that the caller knows to ask.
- """
- with self._lock:
- found, box = self._airports, self._airports_box
- if found is None or box is None:
- return None
- if (south >= box[0] and west >= box[1]
- and north <= box[2] and east <= box[3]):
- return found
- return None
-
- def ground_serial(self) -> int:
- """Which map is in hand. Changes whenever a new one is fetched."""
- with self._lock:
- return self._ground_serial
-
- def want_ground(self, key, box, size) -> None:
- """Say which map is needed. Painting must never wait on a network."""
- with self._lock:
- if self._ground_for != key:
- self._wanted = (key, box, size)
-
- def wanted_ground(self):
- with self._lock:
- return self._wanted
-
- def ground_covering(self, south, west, north, east):
- """The map already fetched, if it covers this view, and its box.
-
- What is asked for is a slightly different piece of the world every
- time the middle shifts or the window is resized, and refetching for
- each of those would leave the ground blank while it happened. The
- fetched piece is bigger than the piece being shown for exactly this
- reason, and anything inside it is taken from what is already here.
- """
- with self._lock:
- levels, box = self._ground, self._ground_box
- if levels is None or box is None:
- return None, None
- if (south >= box[0] and west >= box[1]
- and north <= box[2] and east <= box[3]):
- return levels, box
- return None, None
-
- # -- read by the window -----------------------------------------------
- def flying(self, now: float | None = None) -> list[Blip]:
- """What is overhead, oldest first heard at the top.
-
- An aircraft nothing has been heard from for a while has gone out of
- range; it stays in the log and fades off the picture.
- """
- now = time.time() if now is None else now
- limit = self.hold + max(0.0, self.fade)
- with self._lock:
- out = [b for b in self._blips.values()
- if now - b.last_seen <= limit and b.located]
- out.sort(key=lambda b: (b.first_seen, b.icao))
- return out
-
- def strength(self, blip: Blip, now: float | None = None) -> float:
- """How strongly to draw one aircraft: full, then fading, then gone.
-
- Taking an aircraft off the picture between one frame and the next
- says it stopped existing. Fading it says it stopped talking, which
- is what actually happened.
- """
- now = time.time() if now is None else now
- age = max(0.0, now - blip.last_seen)
- if age <= self.hold or self.fade <= 0:
- return 1.0 if age <= self.hold else 0.0
- return max(0.0, 1.0 - (age - self.hold) / self.fade)
-
- def trail(self, icao: str) -> list:
- with self._lock:
- return list(self._trails.get(icao, ()))
-
- def ground(self, key):
- """The map underneath, if the one we have is for this view."""
- with self._lock:
- return self._ground if self._ground_for == key else None
-
- def centre(self):
- """Where to put the middle of the map.
-
- Whatever the receiver was told, or the middle of everything heard so
- far -- a median, so one bad position cannot move it.
-
- Once settled it is left alone. Recomputing it every time the picture
- is painted moves the map by a fraction of a mile whenever an aircraft
- appears or leaves, which throws away the tiles fetched for the old
- view and leaves the ground blank while new ones are fetched: the map
- blinks out several times a minute for no reason anybody watching
- could see. It only moves again if it has drifted far enough to
- matter, which happens once, early, and then never.
- """
- if self.home is not None:
- return self.home
- with self._lock:
- settled = self._settled
- lats = sorted(b.latitude for b in self._blips.values() if b.located)
- lons = sorted(b.longitude for b in self._blips.values() if b.located)
- if not lats:
- return settled
- middle = lats[len(lats) // 2], lons[len(lons) // 2]
- if settled is not None and \
- distance_nm(settled[0], settled[1], *middle) < RESETTLE_NM:
- return settled
- with self._lock:
- self._settled = middle
- return middle
-
-
-# ---------------------------------------------------------------------------
-# Laying the boxes out
-# ---------------------------------------------------------------------------
-
-def _clear(bx: int, by: int, width: int, height: int, taken: list) -> bool:
- return not any(bx < t[0] + t[2] and t[0] < bx + width
- and by < t[1] + t[3] and t[1] < by + height for t in taken)
-
-
-def glide(current: tuple[float, float], target: tuple[float, float],
- elapsed: float, seconds: float = BOX_GLIDE) -> tuple[float, float]:
- """One step of a box on its way to where it now belongs.
-
- Boxes are placed afresh every frame, so a box whose spot has been taken
- by something else jumps to its new one between one frame and the next.
- The jump is the problem: the eye reads a thing that teleports as a
- different thing, and on a busy screen several of them teleport at once.
- Easing it means the same box is still the same box, and the movement
- itself says that something moved rather than appeared.
-
- Eased by the distance left rather than by a count of frames, so it is
- the same swing whatever the redraw rate is doing, and so a window that
- was buried under another for ten seconds does not spend the next half
- second replaying moves nobody watched: a long enough gap arrives.
- """
- cx, cy = current
- tx, ty = target
- if seconds <= 0.0:
- return (float(tx), float(ty))
- if elapsed <= 0.0:
- return (float(cx), float(cy))
- # Three time constants, so `seconds` is most of the way there rather
- # than a fraction of the way there.
- part = 1.0 - math.exp(-3.0 * elapsed / seconds)
- nx, ny = cx + (tx - cx) * part, cy + (ty - cy) * part
- if abs(tx - nx) < BOX_SETTLED_PX and abs(ty - ny) < BOX_SETTLED_PX:
- return (float(tx), float(ty)) # arrived; stop asking for frames
- return (nx, ny)
-
-
-def place_box(x: int, y: int, width: int, height: int, taken: list,
- bounds: tuple[int, int, int, int]) -> tuple[int, int]:
- """Where to put an aircraft's box so that it covers nothing that matters.
-
- The eight places beside the symbol are tried first, because a box next
- to the thing it describes needs no explaining. When those are taken --
- which happens as soon as a few aircraft are close together -- it works
- outwards in rings, accepting a longer leader line in exchange for a box
- that can be read. Only when the screen is genuinely full does it give
- up and overlap, since a box in an awkward place still says what the
- aircraft is and no box at all does not.
- """
- left, top, right, bottom = bounds
- gap = 14
-
- def fits(bx, by):
- bx = max(left, min(int(bx), right - width))
- by = max(top, min(int(by), bottom - height))
- return (bx, by) if _clear(bx, by, width, height, taken) else None
-
- beside = ((x + gap, y - height // 2), (x - gap - width, y - height // 2),
- (x + gap, y + gap), (x - gap - width, y + gap),
- (x + gap, y - gap - height), (x - gap - width, y - gap - height),
- (x - width // 2, y + gap), (x - width // 2, y - gap - height))
- for spot in beside:
- found = fits(*spot)
- if found:
- return found
- for reach in (1.4, 1.9, 2.6, 3.4, 4.4, 5.6):
- for step in range(16):
- angle = math.tau * step / 16
- found = fits(x + math.cos(angle) * width * reach - width / 2,
- y + math.sin(angle) * height * reach - height / 2)
- if found:
- return found
- return (max(left, min(x + gap, right - width)),
- max(top, min(y - height // 2, bottom - height)))
-
-
-def _build():
- """Define the window classes, now that we know Qt is here."""
- if getattr(_build, "_made", None) is not None:
- return _build._made
- found = _qt()
- if found is None:
- return None
- _name, QtCore, QtGui, QtWidgets, _signal = found
-
- from .flightmap import (AIRPORT, BG, DIM, GRID, GROUND, GROUND_SHADES,
- HOME, INK, LEADER, PALETTE, PANEL, RAMP,
- _degrees, _grid_step)
-
- Qt = QtCore.Qt
- QPointF, QRectF, QTimer = QtCore.QPointF, QtCore.QRectF, QtCore.QTimer
- QColor, QFont, QImage = QtGui.QColor, QtGui.QFont, QtGui.QImage
- QPainter, QPen, QPolygonF = QtGui.QPainter, QtGui.QPen, QtGui.QPolygonF
-
- NO_PEN = _enum(Qt, "PenStyle", "NoPen")
- ROUND_CAP = _enum(Qt, "PenCapStyle", "RoundCap")
- ROUND_JOIN = _enum(Qt, "PenJoinStyle", "RoundJoin")
- _NO_BRUSH = QtGui.QBrush(_enum(Qt, "BrushStyle", "NoBrush"))
- DOTTED = _enum(Qt, "PenStyle", "DotLine")
- RGB888 = _enum(QImage, "Format", "Format_RGB888")
- ANTIALIAS = _enum(QPainter, "RenderHint", "Antialiasing")
- MONOSPACE = _enum(QFont, "StyleHint", "Monospace")
- KEY_Q = _enum(Qt, "Key", "Key_Q")
- KEY_ESCAPE = _enum(Qt, "Key", "Key_Escape")
-
- def rgb(index, alpha=255) -> QColor:
- r, g, b = (int(v) for v in PALETTE[index])
- return QColor(r, g, b, alpha)
-
- def craft_colour(feet, alpha=255) -> QColor:
- return rgb(RAMP + altitude_step(feet), alpha)
-
- def glow_line(painter, colour, width: float, draw, core=True,
- dash=None) -> None:
- """Lay one line down two or three times, wider and fainter each pass.
-
- A vector display holds a beam on the phosphor, and the phosphor
- spreads the light and keeps glowing after the beam has moved on, so
- a line on one of those screens is a bright core inside a halo. This
- is that, done the only way a painter can do it cheaply: the halo
- first, in a wide soft pen, and the core last on top of it.
-
- ``draw`` is handed a pen and asked to draw with it, so the same
- thing works for a polyline, a rectangle or a leader. ``core=False``
- lays down the halo alone, for a shape whose core wants drawing some
- other way -- unsmoothed, or filled rather than stroked.
-
- ``dash`` is a pattern in pixels. Qt measures a dash pattern in pen
- widths, so each pass divides the pattern by its own width: without
- that, the halo's dashes would be three times the length of the
- core's and the line would come out as beads rather than as a dashed
- line.
- """
- from .flightmap import THEME
-
- def pen_for(colour, width):
- pen = QPen(colour, width)
- if dash:
- pen.setDashPattern(dash_for(dash, width))
- return pen
-
- rings = THEME.glow
- if rings:
- part = THEME.glow_part
- for ring in range(rings, 0, -1):
- faint = QColor(colour)
- faint.setAlpha(max(6, int(colour.alpha() * part ** ring)))
- pen = pen_for(faint, width + ring * 2.4)
- pen.setCapStyle(ROUND_CAP)
- pen.setJoinStyle(ROUND_JOIN)
- draw(pen)
- if core:
- draw(pen_for(colour, width))
-
- class SkyView(QtWidgets.QWidget):
- """The map, the aircraft on it, and a box beside each one."""
-
- def __init__(self, sky: Sky, parent=None):
- super().__init__(parent)
- self.sky = sky
- self.detail = 2 # 2 full, 1 compact, 0 symbol only
- self.trails = True
- self.show_ground = True
- self._ground_pixels = None # kept alive for the QImage
- self._ground_ready = None # what those pixels were made from
- # Where each aircraft's box is, and where it is going: kept as
- # an offset from the symbol rather than a place on the screen,
- # so that a box follows its aeroplane across the window without
- # that counting as a move to be animated.
- self._box_at: dict[str, tuple[float, float]] = {}
- self._box_want: dict[str, tuple[int, int]] = {}
- self._box_seen: set[str] = set()
- self._box_moving = False
- self._glide_at = 0.0
- self._glide_step = 0.0
- self._glide_timer = QTimer(self)
- self._glide_timer.setSingleShot(True)
- self._glide_timer.timeout.connect(self.update)
- self.setMinimumSize(480, 360)
- small = QFont()
- small.setStyleHint(MONOSPACE)
- small.setFamily("monospace")
- small.setPointSize(8)
- self.text_font = small
- head = QFont(small)
- head.setBold(True)
- self.head_font = head
-
- # -- geometry -----------------------------------------------------
- def projection(self) -> Projection | None:
- """The piece of the world the window is showing."""
- from .flightlog import box_around
-
- middle = self.sky.centre()
- if middle is None or self.sky.radius_nm <= 0:
- # A radius of nothing is a box of nothing, and every pixel
- # of the window then maps to the same point: the grid asks
- # for a line every fraction of a degree across a span of
- # zero, and the drawing dies rather than being unreadable.
- # There is nothing to show, which is what None means here.
- return None
- south, west, north, east = box_around(middle[0], middle[1],
- self.sky.radius_nm)
- width, height = max(1, self.width()), max(1, self.height())
- # The box is as tall as it is wide in miles; the window is not,
- # so the shorter side decides the scale and the longer one shows
- # more of the world than was asked for.
- across = (east - west) * math.cos(math.radians(middle[0]))
- down = north - south
- if width / max(1e-9, across) < height / max(1e-9, down):
- grow = (width / height) * down / max(1e-9, across)
- middle_lon = (west + east) / 2
- half = (east - west) * grow / 2
- west, east = middle_lon - half, middle_lon + half
- else:
- grow = (height / width) * across / max(1e-9, down)
- middle_lat = (south + north) / 2
- half = (north - south) * grow / 2
- south, north = middle_lat - half, middle_lat + half
- return Projection(south=south, west=west, north=north, east=east,
- left=0, top=0, width=width, height=height)
-
- @staticmethod
- def ground_box(view: Projection):
- """The piece of world to fetch: what is shown, and some around it."""
- down = (view.north - view.south) * GROUND_MARGIN
- across = (view.east - view.west) * GROUND_MARGIN
- return (max(-85.0, view.south - down), view.west - across,
- min(85.0, view.north + down), view.east + across)
-
- def ground_key(self, view: Projection):
- """What the map underneath was fetched for, rounded so that a
- pixel of drift does not throw it away."""
- box = self.ground_box(view)
- return (round(box[0], 2), round(box[1], 2), round(box[2], 2),
- round(box[3], 2), view.width, view.height)
-
- # -- painting -----------------------------------------------------
- def paintEvent(self, event) -> None:
- painter = QPainter(self)
- painter.setRenderHint(ANTIALIAS, True)
- painter.fillRect(self.rect(), rgb(BG))
- view = self.projection()
- if view is None:
- self._draw_waiting(painter)
- self._draw_header(painter, 0)
- painter.end()
- return
- if self.show_ground:
- self._draw_ground(painter, view)
- self._draw_rings(painter, view)
- self._draw_graticule(painter, view)
- self._draw_airports(painter, view)
- flying = self.sky.flying()
- if self.trails:
- for blip in flying:
- self._draw_trail(painter, view, blip,
- self.sky.strength(blip))
- # The symbols go down first and their own space is spoken for,
- # so that one aircraft's box cannot be placed on top of another
- # aircraft -- which is the one thing on the picture that has to
- # stay visible.
- now = time.time()
- # One step for every box on the picture, so that they all swing
- # at the same rate whatever the frame took.
- self._glide_step = (now - self._glide_at) if self._glide_at else 0.0
- self._glide_at = now
- self._box_seen = set()
- self._box_moving = False
- placed = [(blip, view.xy(blip.latitude, blip.longitude),
- self.sky.strength(blip, now)) for blip in flying]
- taken: list[tuple[int, int, int, int]] = []
- for blip, (x, y), strength in placed:
- self._draw_symbol(painter, x, y, blip.track_deg,
- craft_colour(blip.altitude_ft,
- _alpha(strength)), strength)
- taken.append((x - 11, y - 11, 22, 22))
- labels = [self._lay_out(blip, x, y, taken, strength)
- for blip, (x, y), strength in placed]
- # A box on its way somewhere is drawn last, over the ones that
- # are standing still. It crosses its neighbours for a moment,
- # and the one that is moving is the one the eye is following,
- # so it is the one that has to stay readable while it does.
- for laid in sorted((x for x in labels if x is not None),
- key=lambda laid: laid[0]):
- self._paint_label(painter, laid)
- # The flag goes down after everything else and nothing is drawn
- # after it: it says where the receiver is standing, which is the
- # one thing on the picture that must never end up behind an
- # aeroplane that happened to fly over it, or behind the box
- # belonging to one.
- self._draw_home(painter, view)
- # An aircraft with no box this time -- gone, faded past reading,
- # or the detail turned down -- is forgotten, so one that comes
- # back is placed afresh instead of gliding in from wherever it
- # was standing minutes ago.
- for gone in set(self._box_at) - self._box_seen:
- self._box_at.pop(gone, None)
- self._box_want.pop(gone, None)
- self._draw_scale(painter, view)
- # Counted as overhead only while it is still being heard: one
- # that has gone quiet is on the picture, fading, and saying it
- # is overhead would be saying more than was heard.
- self._draw_header(painter,
- sum(1 for _b, _xy, s in placed if s >= 1.0))
- painter.end()
- # Only while something is actually moving: the rest of the time
- # the ordinary five-a-second redraw is what runs.
- if self._box_moving and not self._glide_timer.isActive():
- self._glide_timer.start(BOX_GLIDE_MS)
-
- def _draw_waiting(self, painter) -> None:
- painter.setFont(self.text_font)
- painter.setPen(rgb(INK))
- painter.drawText(self.rect(), _enum(Qt, "AlignmentFlag",
- "AlignCenter"),
- "listening on 1090 MHz\n\n"
- "nothing placed yet — an aircraft is on the map "
- "once an even\nand an odd position frame have "
- "both arrived")
-
- def _draw_ground(self, painter, view) -> None:
- levels, box = self.sky.ground_covering(view.south, view.west,
- view.north, view.east)
- if levels is None:
- # Ask for it and carry on drawing. The map arrives when it
- # arrives; the aircraft are the part that cannot wait.
- # Asked for at the size the bigger box needs to hold the
- # same detail as the window, rather than at the window's
- # own size: fetching a third more world into the same
- # pixels and then cutting the middle out of it is how a
- # sharp map ends up looking like a photograph of a map.
- scale = 1.0 + 2.0 * GROUND_MARGIN
- self.sky.want_ground(self.ground_key(view),
- self.ground_box(view),
- (int(view.width * scale),
- int(view.height * scale)))
- return
- # Cutting the view out of the fetched map, dimming it and
- # looking every level up in the palette is about seventy
- # milliseconds 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 and
- # spent a whole core holding it there.
- key = (self.sky.ground_serial(), self.sky.brightness,
- view.south, view.west, view.north, view.east,
- view.width, view.height)
- if key != getattr(self, "_ground_ready", None):
- from .flightmap import dim_ground
-
- shades = dim_ground(self._crop(levels, box, view),
- self.sky.brightness)
- self._ground_pixels = np.ascontiguousarray(
- PALETTE[GROUND + shades].astype(np.uint8))
- self._ground_ready = key
- pixels = self._ground_pixels # QImage does not copy it
- height, width = pixels.shape[0], pixels.shape[1]
- image = QImage(pixels.data, width, height, 3 * width, RGB888)
- painter.drawImage(0, 0, image)
-
- @staticmethod
- def _crop(levels, box, view: Projection):
- """The part of the fetched map this view is looking at.
-
- Both are laid out the same way -- latitude down, longitude across,
- evenly -- so this is arithmetic rather than another projection.
- """
- south, west, north, east = box
- rows, columns = levels.shape[0], levels.shape[1]
- lats = np.linspace(view.north, view.south, view.height)
- lons = np.linspace(view.west, view.east, view.width)
- y = np.clip(((north - lats) / max(1e-9, north - south)
- * (rows - 1)).round().astype(np.int64), 0, rows - 1)
- x = np.clip(((lons - west) / max(1e-9, east - west)
- * (columns - 1)).round().astype(np.int64),
- 0, columns - 1)
- return np.clip(levels[y[:, None], x[None, :]], 0,
- GROUND_SHADES - 1)
-
- def _draw_graticule(self, painter, view) -> None:
- pen = QPen(rgb(GRID))
- pen.setStyle(DOTTED)
- painter.setPen(pen)
- painter.setFont(self.text_font)
- step = _grid_step(view.north - view.south)
- lat = math.ceil(view.south / step) * step
- while lat <= view.north:
- _, y = view.xy(lat, view.west)
- painter.drawLine(0, y, self.width(), y)
- lat += step
- step = _grid_step(view.east - view.west)
- lon = math.ceil(view.west / step) * step
- while lon <= view.east:
- x, _ = view.xy(view.south, lon)
- painter.drawLine(x, 0, x, self.height())
- lon += step
- painter.setPen(rgb(GRID))
- step = _grid_step(view.north - view.south)
- lat = math.ceil(view.south / step) * step
- while lat <= view.north:
- _, y = view.xy(lat, view.west)
- painter.drawText(4, y - 3, _degrees(lat, "lat"))
- lat += step
- step = _grid_step(view.east - view.west)
- lon = math.ceil(view.west / step) * step
- while lon <= view.east:
- x, _ = view.xy(view.south, lon)
- painter.drawText(x + 3, self.height() - 24,
- _degrees(lon, "lon"))
- lon += step
-
- def _draw_airports(self, painter, view) -> None:
- """Every aerodrome under the view, marked and named.
-
- A route names the two airports its aircraft are flying between,
- and those are almost never the ones underneath: a receiver hears
- aircraft over its own county, and the county's airports are what
- say where on the map you are looking.
-
- Drawn under the aircraft and before them, since an aeroplane is
- what the window is for and an airport is where it is.
- """
- if not self.sky.show_airports:
- return
- found = self.sky.airports_covering(view.south, view.west,
- view.north, view.east)
- if found is None:
- # Ask for them and carry on drawing. They arrive when they
- # arrive; the aircraft are the part that cannot wait.
- self.sky.want_airports(self.ground_box(view))
- return
- if not found:
- return
- from .flightmap import THEME
-
- colour = rgb(AIRPORT)
- glowing = bool(THEME.glow)
- painter.setFont(self.text_font)
- for code, lat, lon in found:
- # Skipped rather than drawn and clipped. What is fetched
- # covers rather more world than is shown, so on a zoomed-in
- # view most of the county's airports are outside it, and
- # they are not worth a draw call each to have Qt throw away.
- if not view.inside(lat, lon):
- continue
- x, y = view.xy(lat, lon)
- # The square goes down unsmoothed. It is six pixels on a
- # side and axis-aligned, so antialiasing it only spreads a
- # one-pixel line across two and leaves the mark looking out
- # of focus next to the crisp aircraft beside it. The name
- # keeps its smoothing, which is what letters want.
- def square(pen):
- painter.setPen(pen)
- painter.setBrush(_NO_BRUSH)
- painter.drawRect(x - 3, y - 3, 6, 6)
-
- # The halo wants smoothing and the square does not. It is
- # six pixels on a side and axis-aligned, so antialiasing the
- # square itself only spreads a one-pixel line across two and
- # leaves the mark looking out of focus beside the crisp
- # aircraft; the glow round it is all curve and needs it.
- if glowing:
- glow_line(painter, colour, 1.0, square, core=False)
- painter.setRenderHint(ANTIALIAS, False)
- square(QPen(colour, 1.0))
- painter.fillRect(x - 1, y - 1, 2, 2, colour)
- painter.setRenderHint(ANTIALIAS, True)
- if code:
- painter.drawText(x + 7, y + 4, code)
-
- def _draw_rings(self, painter, view) -> None:
- """Filled discs at a quarter, a half and three quarters of the
- radius, concentric on the receiver.
-
- Translucent and stacked, so the ground inside the innermost is
- lifted three times and the outermost once. What that gives is a
- sense of how far away a thing is without measuring anything: an
- aircraft two shades in is about halfway to the edge of what this
- receiver hears.
- """
- from .flightmap import RING_STEPS, ring_labels
-
- home = self.sky.home
- radius = self.sky.radius_nm
- if not self.sky.show_rings or home is None or radius <= 0:
- return
- from .flightlog import move
-
- tint = rgb(GROUND + GROUND_SHADES - 1, 22)
- painter.setPen(NO_PEN)
- painter.setBrush(tint)
- middle = QPointF(*view.xy(home[0], home[1]))
- painter.setFont(self.text_font)
- for part in sorted(RING_STEPS, reverse=True):
- nm = radius * part
- north = view.xy(*move(home[0], home[1], 0.0, nm))
- east = view.xy(*move(home[0], home[1], 90.0, nm))
- across = abs(east[0] - middle.x())
- down = abs(north[1] - middle.y())
- if across < 2 or down < 2:
- continue
- painter.setPen(NO_PEN)
- painter.setBrush(tint)
- painter.drawEllipse(middle, across, down)
- for nm, text in ring_labels(radius, self.sky.unit):
- north = view.xy(*move(home[0], home[1], 0.0, nm))
- if not (0 <= north[0] < self.width()
- and 0 <= north[1] < self.height()):
- continue
- wide = QtGui.QFontMetrics(self.text_font).horizontalAdvance(text)
- painter.setPen(rgb(DIM))
- painter.drawText(int(north[0] - wide / 2),
- int(north[1]) + 12, text)
-
- def _draw_home(self, painter, view) -> None:
- """A flag on the spot the receiver was told it is standing on.
-
- Only where it was actually told. The middle of the picture is
- otherwise worked out from whatever flew past, which is not a
- place anybody is standing, and a flag on it would say somebody
- is.
-
- 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.
- """
- from .flightmap import HOME_DROP, HOME_FLY, HOME_POLE
-
- home = self.sky.home
- if home is None or not view.inside(home[0], home[1]):
- return
- x, y = view.xy(home[0], home[1])
- colour = rgb(HOME)
- top = y - HOME_POLE
-
- def pole(pen):
- painter.setPen(pen)
- painter.drawLine(x, y, x, top)
-
- glow_line(painter, colour, 2.0, pole)
- pennant = QPolygonF([QPointF(x + 1, top),
- QPointF(x + 1 + HOME_FLY, top),
- QPointF(x + 1, top + HOME_DROP)])
-
- def outline(pen):
- painter.setPen(pen)
- painter.setBrush(_NO_BRUSH)
- painter.drawPolygon(pennant)
-
- glow_line(painter, colour, 0.1, outline, core=False)
- painter.setPen(NO_PEN)
- painter.setBrush(colour)
- painter.drawPolygon(pennant)
-
- def _draw_trail(self, painter, view, blip: Blip,
- strength: float = 1.0) -> None:
- points = self.sky.trail(blip.icao)
- if len(points) < 2:
- return
- path = QPolygonF([QPointF(*view.xy(lat, lon))
- for lat, lon, _ in points])
-
- def stroke(pen):
- painter.setPen(pen)
- painter.drawPolyline(path)
-
- glow_line(painter, craft_colour(blip.altitude_ft,
- int(90 * strength)), 1.4, stroke)
-
- def _lay_out(self, blip: Blip, x, y, taken, strength: float = 1.0):
- """Where this aircraft's box goes, and what is in it.
-
- Kept apart from the painting so that every box's place is
- decided before any of them is drawn, which is what lets the
- ones in motion go down last.
- """
- # A box at a tenth of its colour is not information, it is
- # something in the way of the aircraft still flying.
- if self.detail <= 0 or strength < LABEL_WHILE:
- return None
- lines = self._wrapped(self._box_lines(blip))
- box = self._box_size(lines)
- bx, by = self._box_spot(blip.icao, x, y, box, taken)
- # The settled place is what is spoken for, not the place the box
- # happens to have reached: laying out against a box in mid-swing
- # would move every box near it as well, and they would move
- # again when it arrived. A glide crosses its neighbours for a
- # moment; a whole screen rearranging itself twice does not
- # settle at all.
- taken.append((bx, by, box[0], box[1]))
- dx, dy = self._box_drawn(blip.icao, x, y, (bx, by))
- return ((dx, dy) != (bx, by), blip, x, y, dx, dy, box, lines,
- strength)
-
- def _paint_label(self, painter, laid) -> None:
- _moving, blip, x, y, dx, dy, box, lines, strength = laid
- colour = craft_colour(blip.altitude_ft, _alpha(strength))
- # To the near edge of the box rather than its middle: a leader
- # drawn to the centre crosses the box and strikes out a line of
- # what it was drawn to point at.
- to_x = int(max(dx, min(x, dx + box[0])))
- to_y = int(max(dy, min(y, dy + box[1])))
-
- def leader(pen):
- painter.setPen(pen)
- painter.drawLine(x, y, to_x, to_y)
-
- # Its own colour, and dashed: neither of which it used to be.
- # 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 -- a heading nobody flew.
- glow_line(painter, rgb(LEADER, _alpha(strength)), 1.0, leader,
- dash=LEADER_DASH)
- title = f"{blip.callsign} {blip.icao}" if blip.callsign \
- else blip.icao
- self._draw_box(painter, dx, dy, box, lines, colour, title)
-
- def _box_spot(self, icao: str, x: int, y: int, box, taken):
- """Where this aircraft's box belongs, once everything settles.
-
- The spot it already had is kept for as long as it still works,
- which is what stops a box hopping about because the aeroplane
- two along moved a pixel. It is remembered as an offset, so an
- aircraft crossing the window carries its box with it and only a
- genuine clash asks for a new place.
- """
- bounds = (2, self._header_height() + 2,
- self.width() - 2, self.height() - 26)
- left, top, right, bottom = bounds
- want = self._box_want.get(icao)
- if want is not None:
- bx = max(left, min(int(x + want[0]), right - box[0]))
- by = max(top, min(int(y + want[1]), bottom - box[1]))
- if _clear(bx, by, box[0], box[1], taken):
- self._box_want[icao] = (bx - x, by - y)
- return bx, by
- bx, by = place_box(x, y, box[0], box[1], taken, bounds)
- self._box_want[icao] = (bx - x, by - y)
- return bx, by
-
- def _box_drawn(self, icao: str, x: int, y: int, want):
- """Where to draw the box this frame, on its way to `want`."""
- self._box_seen.add(icao)
- target = (want[0] - x, want[1] - y)
- here = self._box_at.get(icao)
- if here is None: # first sight: no swing to make
- self._box_at[icao] = (float(target[0]), float(target[1]))
- return want
- here = glide(here, target, self._glide_step)
- self._box_at[icao] = here
- if here != (float(target[0]), float(target[1])):
- self._box_moving = True
- return int(round(x + here[0])), int(round(y + here[1]))
-
- def _draw_symbol(self, painter, x, y, heading, colour,
- strength: float = 1.0) -> None:
- angle = math.radians(heading % 360.0)
- sin, cos = math.sin(angle), math.cos(angle)
-
- def point(ahead, side):
- return QPointF(x + side * cos + ahead * sin,
- y + side * sin - ahead * cos)
-
- shape = QPolygonF([point(9, 0), point(-6, 5),
- point(-3, 0), point(-6, -5)])
- # The halo is stroked round the outline rather than filled, so
- # that it spreads outwards from the symbol instead of merely
- # making it bigger.
- from .flightmap import THEME
-
- if THEME.glow:
- def outline(pen):
- painter.setPen(pen)
- painter.setBrush(_NO_BRUSH)
- painter.drawPolygon(shape)
-
- glow_line(painter, colour, 0.1, outline, core=False)
- painter.setPen(NO_PEN)
- painter.setBrush(colour)
- painter.drawPolygon(shape)
- painter.setBrush(QColor(255, 255, 255, int(200 * strength)))
- painter.drawEllipse(QPointF(x, y), 1.6, 1.6)
-
- def _box_lines(self, blip: Blip) -> list[tuple[str, str]]:
- if self.detail >= 2:
- return blip.lines(self.sky.unit, self.sky.centre())
- out = []
- if blip.altitude_ft:
- out.append(("alt", f"{blip.altitude_ft:,} ft", ""))
- if blip.ground_speed_kt:
- out.append(("gs", f"{in_speed(blip.ground_speed_kt, self.sky.unit):.0f}"
- f" {speed_label(self.sky.unit)}", ""))
- return out
-
- @staticmethod
- def _wrapped(lines) -> list[tuple[str, str, str]]:
- """The box's rows, with the long ones folded.
-
- A folded row carries neither the label nor the flag: both belong
- to the value as a whole, and repeating them down the side of a
- wrapped airport name would read as several different facts.
- """
- out: list[tuple[str, str, str]] = []
- for row in lines:
- label, value, country = (row if len(row) == 3
- else (row[0], row[1], ""))
- for i, piece in enumerate(wrap_value(value)):
- out.append((label if i == 0 else "", piece,
- country if i == 0 else ""))
- return out
-
- def _box_size(self, lines) -> tuple[int, int]:
- from .flags import FLAG_W
-
- metrics = QtGui.QFontMetrics(self.text_font)
- head = QtGui.QFontMetrics(self.head_font)
- widest = head.horizontalAdvance("XXXXXXXX XXXXXX")
- flagged = any(country for _, _, country in lines)
- for _label, value, _country in lines:
- widest = max(widest, metrics.horizontalAdvance(value))
- gutter = metrics.horizontalAdvance("XXXXX ")
- room = gutter + widest + (FLAG_W + 4 if flagged else 0)
- row = metrics.height()
- return room + 14, head.height() + row * len(lines) + 12
-
- def _draw_box(self, painter, bx, by, box, lines, colour,
- title: str) -> None:
- width, height = box
- shape = QRectF(bx, by, width, height)
- # The panel colour, from the theme, so a box on a phosphor
- # screen is the black of the tube rather than a blue-grey card.
- # As solid as the setting asks for, and no more solid than the
- # label it is behind: a card at full strength under a label that
- # is fading out would be the brightest thing left of it.
- back = rgb(PANEL, int(255 * self.sky.box_opacity
- * (colour.alpha() / 255)))
- painter.setBrush(back)
- painter.setPen(QPen(colour, 1.2))
- painter.drawRoundedRect(shape, 3.0, 3.0)
-
- def outline(pen):
- painter.setPen(pen)
- painter.setBrush(_NO_BRUSH)
- painter.drawRoundedRect(shape, 3.0, 3.0)
-
- glow_line(painter, colour, 1.2, outline)
- painter.setFont(self.head_font)
- faded = colour.alpha()
- painter.setPen(colour.lighter(135))
- head = QtGui.QFontMetrics(self.head_font)
- painter.drawText(bx + 7, by + head.ascent() + 4, title)
- painter.setFont(self.text_font)
- metrics = QtGui.QFontMetrics(self.text_font)
- gutter = metrics.horizontalAdvance("XXXXX ")
- flagged = any(country for _, _, country in lines)
- y = by + head.height() + 6 + metrics.ascent()
- for label, value, country in lines:
- if label:
- painter.setPen(rgb(GRID, faded))
- painter.drawText(bx + 7, y, f"{label:>5s}")
- left = bx + 7 + gutter
- if flagged:
- if country:
- self._draw_flag(painter, left, y - metrics.ascent() + 2,
- country)
- left += FLAG_PIXELS + 4
- painter.setPen(rgb(INK, faded))
- painter.drawText(left, y, value)
- y += metrics.height()
-
- def _draw_flag(self, painter, x: int, y: int, country: str) -> None:
- """A flag beside a place name, or its letters where there is none.
-
- A country with no flag here is named rather than approximated: two
- letters are never wrong, and a flag that is nearly another
- country's is worse than no flag at all.
- """
- from .flags import FLAG_H, FLAG_W, pixels_for
-
- picture = pixels_for(country)
- if picture is None:
- painter.setPen(rgb(GRID))
- painter.drawText(x, y + FLAG_H, country[:2].upper())
- return
- pixels = np.ascontiguousarray(picture)
- self._flag_pixels = pixels # QImage does not copy it
- image = QImage(pixels.data, FLAG_W, FLAG_H, 3 * FLAG_W, RGB888)
- painter.drawImage(x, y, image)
-
- def _draw_scale(self, painter, view) -> None:
- from .basemap import ATTRIBUTION
-
- painter.setFont(self.text_font)
- metrics = QtGui.QFontMetrics(self.text_font)
- across = in_distance(view.width_nm, self.sky.unit)
- per = self.width() / max(1e-9, across)
- for miles in (200, 100, 50, 20, 10, 5, 2, 1):
- pixels = int(miles * per)
- if pixels <= self.width() * 0.28:
- break
- else:
- return
- y = self.height() - 10
- painter.setPen(QPen(rgb(INK), 1.0))
- painter.drawLine(12, y, 12 + pixels, y)
- painter.drawLine(12, y - 4, 12, y + 4)
- painter.drawLine(12 + pixels, y - 4, 12 + pixels, y + 4)
- painter.drawText(18 + pixels, y + 4,
- f"{miles} {distance_label(self.sky.unit)}")
- if self.show_ground and self.sky.ground_covering(
- view.south, view.west, view.north, view.east)[0] is not None:
- painter.setPen(rgb(GRID))
- painter.drawText(self.width() - 8
- - metrics.horizontalAdvance(ATTRIBUTION),
- y + 4, ATTRIBUTION)
-
- def _header_height(self) -> int:
- return QtGui.QFontMetrics(self.head_font).height() + 8
-
- def _draw_header(self, painter, flying: int) -> None:
- metrics = QtGui.QFontMetrics(self.head_font)
- height = self._header_height()
- painter.setPen(NO_PEN)
- painter.setBrush(QColor(10, 12, 18, 205))
- painter.drawRect(0, 0, self.width(), height)
- elapsed = max(0.001, time.time() - self.sky.started)
- told = (f"1090 MHz {flying} overhead "
- f"{self.sky.aircraft_seen} seen "
- f"{self.sky.frames:,} frames "
- f"{self.sky.frames / elapsed:.0f}/s "
- f"{_clock(elapsed)}")
- if self.sky.log_name:
- told += f" {self.sky.log_name}"
- if self.sky.note:
- told += f" {self.sky.note}"
- painter.setFont(self.head_font)
- painter.setPen(rgb(INK))
- painter.drawText(10, metrics.ascent() + 4, told)
- keys = ("d detail t trails g map [ ] bright "
- "+/- range q quit")
- painter.setPen(rgb(GRID))
- painter.drawText(self.width() - 8
- - metrics.horizontalAdvance(keys),
- metrics.ascent() + 4, keys)
-
- class Window(QtWidgets.QMainWindow):
- """The window itself: a map, a clock and a few keys."""
-
- def __init__(self, sky: Sky, title: str = "bandsaunter — aircraft"):
- super().__init__()
- self.sky = sky
- self.view = SkyView(sky, self)
- self.setCentralWidget(self.view)
- self.setWindowTitle(title)
- self.resize(1100, 800)
- self._timer = QTimer(self)
- self._timer.timeout.connect(self._tick)
- self._timer.start(REDRAW_MS)
-
- def _tick(self) -> None:
- if self.sky.finished:
- self.close()
- return
- self.view.update()
-
- def keyPressEvent(self, event) -> None:
- key = event.key()
- text = event.text().lower()
- if key in (KEY_Q, KEY_ESCAPE) or text == "q":
- self.close()
- elif text == "d":
- self.view.detail = (self.view.detail + 2) % 3
- elif text == "t":
- self.view.trails = not self.view.trails
- elif text == "g":
- self.view.show_ground = not self.view.show_ground
- elif text in ("+", "="):
- self.sky.radius_nm = max(5.0, self.sky.radius_nm / 1.5)
- elif text == "-":
- self.sky.radius_nm = min(3000.0, self.sky.radius_nm * 1.5)
- elif text == "[":
- self.sky.brightness = max(0.1, self.sky.brightness - 0.1)
- elif text == "]":
- self.sky.brightness = min(1.0, self.sky.brightness + 0.1)
- self.view.update()
-
- def closeEvent(self, event) -> None:
- self.sky.stopping = True
- self._timer.stop()
- event.accept()
-
- _build._made = {"SkyView": SkyView, "Window": Window}
- return _build._made
-
-
-def _clock(seconds: float) -> str:
- seconds = int(seconds)
- hours, rest = divmod(seconds, 3600)
- minutes, secs = divmod(rest, 60)
- return f"{hours}:{minutes:02d}:{secs:02d}" if hours else f"{minutes}:{secs:02d}"
-
-
-def __getattr__(name):
- """Build the window classes on first use, so importing costs nothing."""
- if name in ("SkyView", "Window"):
- made = _build()
- if made is None:
- raise RuntimeError(MISSING_QT)
- return made[name]
- raise AttributeError(name)
-
-
-# ---------------------------------------------------------------------------
-# Running it
-# ---------------------------------------------------------------------------
-
-def fetch_ground(sky: Sky, url: str = "", fetch=None) -> None:
- """Fetch whatever map the window is asking for, until it stops asking.
-
- Runs on its own thread: the tiles come off a network the first time an
- area is drawn, and a window that stopped repainting while that happened
- would look broken every time it was resized.
- """
- from . import basemap
-
- while not sky.stopping:
- # The aerodromes first, and on their own account. They used to be
- # fetched only in the same pass as a piece of map, which meant they
- # waited behind a hundred and twenty tiles coming off a network --
- # and once the map was in hand there were no more passes, so they
- # were never fetched again. They are one small question and they
- # have nothing to do with the tiles.
- _airports(sky)
- wanted = sky.wanted_ground()
- if wanted is None:
- time.sleep(0.2)
- continue
- key, box, (width, height) = wanted
- south, west, north, east = box
- try:
- extra = {"fetch": fetch} if fetch is not None else {}
- if url:
- extra["url"] = url
- # The size asked for is the box's own, not the window's, so the
- # piece of it the window shows comes out pixel for pixel with
- # the screen. Rendering the wider box into the window's pixels
- # and stretching it back was a whole-map upscale of a fifth,
- # which is what a sharp map looks like when it looks blurred.
- levels = basemap.ground_under(south, west, north, east,
- width, height,
- shades=_ground_shades(), **extra)
- except Exception:
- levels = None
- # Remembered either way: a map that could not be fetched must not be
- # asked for again every fifth of a second for the rest of the night.
- sky.set_ground(levels, key, box if levels is not None else None)
-
-
-def _airports(sky: Sky) -> None:
- """Fetch the aerodromes the window is asking for, if it is asking.
-
- On the fetching thread, for the same reason the tiles are: it is a
- question to a network the first time an area is drawn, and the window
- must not stop repainting while it is asked. Cached on disk for a month
- afterwards, since a runway does not move.
- """
- from . import basemap
-
- box = sky.wanted_airports()
- if box is None or not sky.show_airports:
- return
- try:
- found = [(a["code"], a["latitude"], a["longitude"])
- for a in basemap.airports_in(*box)]
- except Exception:
- # Kept as nothing rather than left unanswered: an area that could
- # not be asked about must not be asked about again every fifth of a
- # second for the rest of the night.
- found = []
- sky.set_airports(found, box)
-
-
-def _ground_shades() -> int:
- from .flightmap import GROUND_SHADES
-
- return GROUND_SHADES
-
-
-def show(sky: Sky, title: str = "bandsaunter — aircraft") -> None:
- """Open the window and stay in it until it is closed."""
- made = _build()
- if made is None:
- raise RuntimeError(MISSING_QT)
- _name, _core, _gui, widgets, _signal = _qt()
- app = widgets.QApplication.instance() or widgets.QApplication([])
- window = made["Window"](sky, title)
- window.show()
- runner = getattr(app, "exec", None) or app.exec_
- runner()
- sky.stopping = True
diff --git a/bandsaunter/schedules.py b/bandsaunter/schedules.py
deleted file mode 100644
index c46c804..0000000
--- a/bandsaunter/schedules.py
+++ /dev/null
@@ -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
diff --git a/bandsaunter/themes.py b/bandsaunter/themes.py
deleted file mode 100644
index 6cea4ff..0000000
--- a/bandsaunter/themes.py
+++ /dev/null
@@ -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]
diff --git a/bandsaunter/tui.py b/bandsaunter/tui.py
index 12de9e0..838ad14 100644
--- a/bandsaunter/tui.py
+++ b/bandsaunter/tui.py
@@ -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"):
diff --git a/bandsaunter/ui.py b/bandsaunter/ui.py
index 9fd0ba3..901ffdc 100755
--- a/bandsaunter/ui.py
+++ b/bandsaunter/ui.py
@@ -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)
diff --git a/docs/realtime.png b/docs/realtime.png
deleted file mode 100644
index 6ddb583..0000000
Binary files a/docs/realtime.png and /dev/null differ
diff --git a/packaging/bandsaunter.1 b/packaging/bandsaunter.1
index 779b308..3e76832 100644
--- a/packaging/bandsaunter.1
+++ b/packaging/bandsaunter.1
@@ -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
diff --git a/packaging/build-deb.sh b/packaging/build-deb.sh
index 39c1136..c3af755 100755
--- a/packaging/build-deb.sh
+++ b/packaging/build-deb.sh
@@ -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
diff --git a/packaging/make-man.py b/packaging/make-man.py
index 12d2af5..d6fdad2 100755
--- a/packaging/make-man.py
+++ b/packaging/make-man.py
@@ -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")
diff --git a/packaging/saunterbrowse.1 b/packaging/saunterbrowse.1
index 9226fc3..57fa2ff 100644
--- a/packaging/saunterbrowse.1
+++ b/packaging/saunterbrowse.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
diff --git a/pyproject.toml b/pyproject.toml
index 634293c..50aa90f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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.
diff --git a/tests/conftest.py b/tests/conftest.py
index 076d8dd..ba52f16 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -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")
diff --git a/tests/test_aircraft_display.py b/tests/test_aircraft_display.py
deleted file mode 100644
index 1e556fa..0000000
--- a/tests/test_aircraft_display.py
+++ /dev/null
@@ -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"
diff --git a/tests/test_aircraft_menu.py b/tests/test_aircraft_menu.py
deleted file mode 100644
index 4b67e28..0000000
--- a/tests/test_aircraft_menu.py
+++ /dev/null
@@ -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"
diff --git a/tests/test_basemap.py b/tests/test_basemap.py
deleted file mode 100644
index bbee9bb..0000000
--- a/tests/test_basemap.py
+++ /dev/null
@@ -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()
diff --git a/tests/test_flags.py b/tests/test_flags.py
deleted file mode 100644
index 396f53e..0000000
--- a/tests/test_flags.py
+++ /dev/null
@@ -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
diff --git a/tests/test_flightmap.py b/tests/test_flightmap.py
index 889a5d3..682e119 100644
--- a/tests/test_flightmap.py
+++ b/tests/test_flightmap.py
@@ -398,1552 +398,3 @@ def test_the_picture_is_an_even_number_of_pixels_across(tmp_path):
view = fm.fit(two_aircraft(), width=width)
w, h = fm.canvas_size(view)
assert w % 2 == 0 and h % 2 == 0
-
-
-# ---------------------------------------------------------------------------
-# Speeds in whatever the user reads
-# ---------------------------------------------------------------------------
-
-def _label_text(unit: str, width: int = 700) -> np.ndarray:
- """Draw one aircraft and hand back the pixels its label was written in."""
- tracks = [straight(speed=480.0)]
- view = fm.fit(tracks, width=width)
- base = fm.background(view, unit=unit)
- return fm.render_frame(base, view, tracks, tracks[0].first_seen + 120,
- unit=unit)
-
-
-def _has_text(frame: np.ndarray, text: str) -> bool:
- """Whether a string was stamped anywhere in the frame, found by drawing
- it again and looking for the same pattern."""
- from bandsaunter.images import GLYPH_H, draw_text, text_width
-
- stamp = np.zeros((GLYPH_H, max(1, text_width(text))), dtype=np.uint8)
- draw_text(stamp, 0, 0, text, 1)
- rows, cols = stamp.shape
- wanted = stamp.astype(bool)
- if not wanted.any():
- return False
- for y in range(frame.shape[0] - rows):
- for x in range(frame.shape[1] - cols):
- patch = frame[y:y + rows, x:x + cols]
- if np.all(patch[wanted] != fm.BG) and \
- np.all(patch[~wanted] == patch[~wanted][0]):
- return True
- return False
-
-
-@pytest.mark.parametrize("unit,label", [("knots", "KT"), ("mph", "MPH"),
- ("kph", "KM/H")])
-def test_the_speed_on_the_map_carries_its_unit(unit, label):
- """A bare 480 beside an aircraft is three different speeds depending on
- who is reading it."""
- assert _has_text(_label_text(unit), label)
-
-
-def test_the_number_beside_it_is_converted():
- assert _has_text(_label_text("knots"), "480KT")
- assert _has_text(_label_text("mph"), "552MPH")
- assert _has_text(_label_text("kph"), "889KM/H")
-
-
-@pytest.mark.parametrize("unit,label", [("knots", "NM"), ("mph", "MI"),
- ("kph", "KM")])
-def test_the_scale_bar_uses_the_same_kind_of_mile(unit, label):
- """Miles an hour beside a scale in nautical miles is two different miles
- on one picture."""
- view = fm.fit(two_aircraft(), width=700)
- assert _has_text(fm.background(view, unit=unit), label)
-
-
-def test_the_animation_takes_the_unit_through_to_the_file(tmp_path):
- out = fm.animate(two_aircraft(), tmp_path / "mph.png", width=500,
- unit="mph")
- assert out is not None and out.path.is_file()
-
-
-# ---------------------------------------------------------------------------
-# Framing the picture on the receiver
-# ---------------------------------------------------------------------------
-
-def far_away(icao="BAD001", lat=-9.5, lon=-112.5) -> Track:
- """One aircraft in the wrong hemisphere: a decode that went wrong."""
- return Track(icao=icao, callsign="GHOST",
- fixes=[Fix(at=1_000_000.0, latitude=lat, longitude=lon,
- altitude_ft=35_000, ground_speed_kt=400.0)],
- first_seen=1_000_000.0, last_seen=1_000_000.0)
-
-
-def test_the_middle_of_what_was_heard_is_where_the_receiver_is():
- """A median, so a handful of wrong positions cannot drag it anywhere."""
- from bandsaunter.flightlog import centre_of
-
- tracks = two_aircraft() + [far_away(), far_away("BAD002", 60.0, 120.0)]
- lat, lon = centre_of(tracks)
- assert lat == pytest.approx(51.0, abs=1.0)
- assert lon == pytest.approx(-1.0, abs=1.5)
-
-
-def test_a_radius_leaves_the_far_ones_off_the_map():
- tracks = two_aircraft() + [far_away()]
- view_all = fm.fit([t for t in tracks if t.located])
- assert view_all.north - view_all.south > 50 # dragged across a globe
- from bandsaunter.flightlog import centre_of, within
-
- lat, lon = centre_of(two_aircraft())
- near = [t for t in within(tracks, lat, lon, 100.0) if t.located]
- assert {t.icao for t in near} == {t.icao for t in two_aircraft()}
-
-
-def test_the_frame_is_the_radius_rather_than_whatever_turned_up():
- """The scale should not change with the traffic."""
- from bandsaunter.flightlog import box_around, centre_of, distance_nm
-
- lat, lon = centre_of(two_aircraft())
- box = box_around(lat, lon, 100.0)
- view = fm.fit(two_aircraft(), width=600, box=box)
- assert view.south == pytest.approx(box[0]) and view.north == pytest.approx(box[2])
- across = distance_nm(lat, box[1], lat, box[3])
- assert across == pytest.approx(200.0, rel=0.02) # a hundred each way
-
-
-def test_the_radius_is_kept_by_the_animation(tmp_path):
- """Without one the frame stretches to reach a bad position on the other
- side of the equator, and the real aircraft come out a pixel wide."""
- from bandsaunter.flightlog import box_around, centre_of
-
- tracks = two_aircraft() + [far_away()]
- wide = fm.animate(tracks, tmp_path / "wide.png", width=400, radius_nm=0)
- tight = fm.animate(tracks, tmp_path / "tight.png", width=400, radius_nm=100)
- assert wide.aircraft == 3 and tight.aircraft == 2
-
- everything = fm.fit([t for t in tracks if t.located], width=400)
- lat, lon = centre_of(two_aircraft())
- framed = fm.fit(two_aircraft(), width=400,
- box=box_around(lat, lon, 100.0))
- assert everything.north - everything.south > 50 # most of a hemisphere
- assert framed.north - framed.south < 4 # a hundred miles
-
-
-def test_an_explicit_receiver_position_is_used_as_given(tmp_path):
- """Somewhere with no aircraft near it: everything falls outside."""
- out = fm.animate(two_aircraft(), tmp_path / "elsewhere.png", width=400,
- radius_nm=50, centre=(0.0, 0.0))
- assert out is None
-
-
-def test_a_track_with_one_bad_fix_keeps_the_rest_of_its_flight():
- """Per fix rather than per aircraft: one wrong position in the middle of
- a real flight must not take the flight off the map with it."""
- from bandsaunter.flightlog import within
-
- track = straight()
- good = len(track.fixes)
- track.fixes.insert(len(track.fixes) // 2,
- Fix(at=track.fixes[0].at + 1, latitude=-9.5,
- longitude=-112.5, altitude_ft=35_000))
- kept = within([track], 51.0, -1.0, 100.0)[0]
- assert len(kept.fixes) == good
-
-
-# ---------------------------------------------------------------------------
-# Drawing off the edge
-# ---------------------------------------------------------------------------
-
-@pytest.mark.parametrize("x,y", [(-28, -89), (-4000, 400), (450, -9000),
- (2000, 400), (450, 4000)])
-def test_an_aircraft_off_the_picture_paints_nothing(x, y):
- """Clamping the near edge of the centre dot and leaving the far one alone
- reads as a negative slice, which numpy counts back from the far side --
- and fills most of the frame with one colour."""
- img = np.zeros((300, 500), dtype=np.uint8)
- fm._marker(img, x, y, 90.0, 20)
- assert int((img != 0).sum()) == 0
-
-
-@pytest.mark.parametrize("x,y", [(0, 0), (499, 299), (250, 150)])
-def test_an_aircraft_on_the_picture_is_still_drawn(x, y):
- img = np.zeros((300, 500), dtype=np.uint8)
- fm._marker(img, x, y, 90.0, 20)
- assert 0 < int((img != 0).sum()) < 60
-
-
-def test_a_still_draws_where_they_were_not_where_they_might_have_got_to():
- """A still of a whole evening is drawn hours after most of the aircraft
- stopped transmitting; flying them on for those hours would scatter them
- across three states."""
- tracks = [straight(seconds=600.0)]
- view = fm.fit(tracks, width=500)
- base = fm.background(view)
- hours_later = tracks[0].last_seen + 6 * 3600
- projected = fm.render_frame(base, view, tracks, hours_later,
- stale=7 * 3600, project=True, labels=False)
- still = fm.render_frame(base, view, tracks, hours_later,
- stale=7 * 3600, project=False, labels=False)
-
- def markers(frame):
- """Just the aircraft: what changed, in the aircraft colours, inside
- the map itself. The altitude key along the bottom is drawn in those
- same colours and belongs to the background."""
- body = slice(view.top, view.top + view.height), \
- slice(view.left, view.left + view.width)
- drawn = (frame[body] != base[body]) & (frame[body] >= fm.RAMP) & \
- (frame[body] < fm.TRAIL)
- return int(drawn.sum())
-
- assert markers(projected) == 0 # flown clean off the picture
- assert markers(still) > 0 # drawn where it last was
- last = tracks[0].fixes[-1]
- x, y = view.xy(last.latitude, last.longitude)
- assert (still[y - 6:y + 7, x - 6:x + 7] >= fm.RAMP).any()
-
-
-def test_a_whole_evening_as_one_picture_keeps_every_aircraft_on_it(tmp_path):
- tracks = two_aircraft()
- for t in tracks: # heard hours ago
- for f in t.fixes:
- f.at -= 6 * 3600
- t.first_seen -= 6 * 3600
- t.last_seen -= 6 * 3600
- tracks.append(straight(icao="C0FFEE", callsign="LATE", lat=51.2, lon=-0.9))
- out = fm.animate(tracks, tmp_path / "evening.png", width=500)
- assert out is not None and out.aircraft == 3
-
-
-# ---------------------------------------------------------------------------
-# Throwing out what could not have happened
-# ---------------------------------------------------------------------------
-
-def _with_bad_fix(track: Track, at_index: int, lat=-9.5, lon=-112.5) -> Track:
- """Drop one impossible position into the middle of a real flight."""
- when = track.fixes[at_index].at + 0.001
- track.fixes.insert(at_index + 1,
- Fix(at=when, latitude=lat, longitude=lon,
- altitude_ft=35_000, ground_speed_kt=400.0))
- return track
-
-
-def test_a_position_no_aircraft_could_reach_is_thrown_out():
- from bandsaunter.flightlog import recheck
-
- track = _with_bad_fix(straight(), 10)
- good = len(track.fixes) - 1
- clean, dropped = recheck([track])
- assert dropped == 1
- assert len(clean[0].fixes) == good
- assert all(-90 <= f.latitude <= 90 for f in clean[0].fixes)
-
-
-def test_a_flight_that_is_entirely_real_loses_nothing():
- from bandsaunter.flightlog import recheck
-
- clean, dropped = recheck(two_aircraft())
- assert dropped == 0
- assert [len(t.fixes) for t in clean] == [len(t.fixes) for t in two_aircraft()]
-
-
-def test_a_bad_position_early_on_does_not_take_the_flight_with_it():
- """Keeping whatever is reachable from the last position kept lets one
- bad fix become the reference, and then the truth is what gets thrown
- away. On a real recording that discarded a fifth of everything."""
- from bandsaunter.flightlog import recheck
-
- track = _with_bad_fix(straight(), 0)
- clean, dropped = recheck([track])
- assert dropped == 1
- assert len(clean[0].fixes) == len(straight().fixes)
-
-
-def test_a_run_of_bad_positions_that_agree_with_each_other_still_goes():
- """Three bad decodes can land in the same wrong place and agree
- perfectly; they are still wrong."""
- from bandsaunter.flightlog import recheck
-
- track = straight()
- where = 10
- for i in range(3):
- track.fixes.insert(where + 1 + i,
- Fix(at=track.fixes[where].at + 0.001 * (i + 1),
- latitude=-9.5 + i * 0.001, longitude=-112.5,
- altitude_ft=35_000))
- clean, dropped = recheck([track])
- assert dropped == 3
- assert all(f.latitude > 0 for f in clean[0].fixes)
-
-
-def test_a_position_that_is_not_on_earth_never_survives():
- from bandsaunter.flightlog import recheck
-
- track = _with_bad_fix(straight(), 5, lat=239.6, lon=-111.0)
- clean, _ = recheck([track])
- assert all(-90 <= f.latitude <= 90 for f in clean[0].fixes)
-
-
-def test_an_aircraft_that_went_quiet_and_came_back_is_not_an_error():
- """Out of range for ten minutes, then heard again a long way off: that
- is an aeroplane, not a bad decode, and there is no way to say otherwise."""
- from bandsaunter.flightlog import recheck
-
- track = straight(seconds=200.0)
- last = track.fixes[-1]
- track.fixes.append(Fix(at=last.at + 900, latitude=last.latitude + 1.5,
- longitude=last.longitude + 2.0,
- altitude_ft=35_000, ground_speed_kt=480.0))
- clean, dropped = recheck([track])
- assert dropped == 0
-
-
-def test_two_positions_that_contradict_each_other_do_not_both_stand():
- """One of them is wrong and nothing says which; what comes out has at
- least to be a story."""
- from bandsaunter.flightlog import recheck
-
- 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)])
- clean, dropped = recheck([track])
- assert dropped == 1
- assert len(clean[0].fixes) == 1
-
-
-def test_a_jitter_of_a_few_yards_is_never_called_an_error():
- """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."""
- from bandsaunter.flightlog import recheck
-
- track = Track(icao="4CA1FA", fixes=[
- Fix(at=0.0, latitude=51.5000, longitude=-0.1200),
- Fix(at=0.001, latitude=51.5001, longitude=-0.1201),
- Fix(at=0.002, latitude=51.5002, longitude=-0.1202)])
- clean, dropped = recheck([track])
- assert dropped == 0
-
-
-def test_nothing_survives_that_needed_an_impossible_speed():
- """The promise of the whole thing, said as one assertion."""
- from bandsaunter.flightlog import (MAX_GROUND_SPEED_KT, distance_nm,
- implied_speed_kt, recheck)
-
- tracks = [_with_bad_fix(straight(), i) for i in (3, 12, 20)]
- clean, dropped = recheck(tracks)
- assert dropped == 3
- for track in clean:
- for a, b in zip(track.fixes, track.fixes[1:]):
- if 0 < b.at - a.at <= 300 and distance_nm(
- a.latitude, a.longitude, b.latitude, b.longitude) > 2:
- assert implied_speed_kt(a, b) <= MAX_GROUND_SPEED_KT
-
-
-# ---------------------------------------------------------------------------
-# What is written beside each aircraft
-# ---------------------------------------------------------------------------
-
-class _Entry:
- """A register's answer, in the shape flightmap reads it."""
-
- def __init__(self, **over):
- self.type_code = over.get("type_code", "B739")
- self.model = over.get("model", "737-932ER")
- self.registration = over.get("registration", "N904DN")
- self.origin_code = over.get("origin_code", "KATL")
- self.origin = over.get("origin", "Atlanta")
- self.origin_country = over.get("origin_country", "US")
- self.destination_code = over.get("destination_code", "EGLL")
- self.destination = over.get("destination", "London Heathrow")
- self.destination_country = over.get("destination_country", "GB")
- # The map draws an airport where a lookup gave it a position.
- self.origin_lat = over.get("origin_lat", 33.6367)
- self.origin_lon = over.get("origin_lon", -84.4281)
- self.destination_lat = over.get("destination_lat", 51.4706)
- self.destination_lon = over.get("destination_lon", -0.4619)
-
-
-def test_the_label_says_height_speed_type_and_both_ends_of_the_route():
- track = straight()
- rows = fm.label_lines(track, track.fixes[0], "knots", _Entry())
- text = [line for line, _flag in rows]
- # The height in feet with its unit on it: "350" is only a height to
- # somebody who already knows it is one.
- assert text[0].startswith("35,000 ft")
- assert "480KT" in text[0]
- assert "B739 N904DN" in text
- assert "KATL" in text and "EGLL" in text
-
-
-def test_each_end_of_the_route_carries_its_own_country():
- track = straight()
- rows = dict((line, flag) for line, flag in
- fm.label_lines(track, track.fixes[0], "knots", _Entry()))
- assert rows["KATL"] == "US"
- assert rows["EGLL"] == "GB"
-
-
-def test_with_no_register_the_label_is_what_the_aircraft_itself_said():
- """The height and the speed, which came off the air, and the flag of the
- country that issued the address, which needs no register either."""
- track = straight()
- rows = fm.label_lines(track, track.fixes[0], "knots", None)
- assert rows[0][0].startswith("35,000 ft") and rows[0][1] == ""
- assert [flag for _text, flag in rows if flag] == ["IE"]
- assert not any("N904DN" in text for text, _flag in rows)
-
-
-def test_the_flag_is_there_for_an_aircraft_no_register_has_heard_of():
- """Taking the country from the register's answer left the flag off
- exactly the aircraft that had nothing else beside them either. The
- address block says it without asking anybody."""
- for icao, expected in (("4CA1FA", "IE"), ("A12345", "US"),
- ("0D0468", "MX"), ("406B12", "GB")):
- track = straight(icao=icao)
- rows = fm.label_lines(track, track.fixes[0], "knots", None)
- assert [flag for _text, flag in rows if flag] == [expected], icao
-
-
-def test_an_airport_with_no_code_is_named_short_rather_than_in_full():
- track = straight()
- entry = _Entry(origin_code="", origin="Hartsfield Jackson Atlanta "
- "International Airport")
- said = [line for line, _ in fm.label_lines(track, track.fixes[0],
- "knots", entry)]
- assert "Hartsfield Jackson" in said
- assert not any(len(line) > 20 for line in said), said
-
-
-def test_the_flag_is_drawn_in_the_flag_colours():
- from bandsaunter.flags import COLOUR_ORDER, FLAG_H, FLAG_W
-
- img = np.full((40, 60), fm.BG, dtype=np.uint8)
- fm.draw_flag(img, 5, 5, "JP")
- patch = img[5:5 + FLAG_H, 5:5 + FLAG_W]
- assert (patch >= fm.FLAG).all()
- assert (patch < fm.FLAG + len(COLOUR_ORDER)).all()
- # White at the corner and red in the middle: the flag of Japan.
- assert patch[0, 0] == fm.flag_index("w")
- assert patch[4, 6] == fm.flag_index("r")
-
-
-def test_a_country_with_no_flag_is_named_in_letters_instead():
- img = np.full((40, 60), fm.BG, dtype=np.uint8)
- fm.draw_flag(img, 5, 5, "ZZ")
- assert (img == fm.GRID).any() # the letters, in grey
- assert not (img >= fm.FLAG).any() # and no flag pretending to be one
-
-
-def test_a_flag_off_the_edge_of_the_picture_paints_nothing():
- for x, y in ((-40, 5), (5, -40), (200, 5), (5, 200)):
- img = np.full((40, 60), fm.BG, dtype=np.uint8)
- fm.draw_flag(img, x, y, "US")
- assert (img == fm.BG).all(), (x, y)
-
-
-def test_the_flag_colours_fit_in_the_palette_beside_everything_else():
- from bandsaunter.flags import COLOUR_ORDER
-
- assert fm.FLAG + len(COLOUR_ORDER) < fm.TRANSPARENT
- assert fm.PALETTE.shape == (256, 3)
- # And each index really is the colour it claims.
- from bandsaunter.flags import COLOURS
-
- for letter in COLOUR_ORDER:
- assert tuple(int(v) for v in fm.PALETTE[fm.flag_index(letter)]) == \
- COLOURS[letter]
-
-
-def test_the_register_is_asked_once_per_aircraft_not_once_per_frame(tmp_path):
- """A five-hundred-frame animation asking the same question five hundred
- times would be five hundred times as rude."""
- asked = []
-
- class _Book:
- def get(self, icao, callsign=""):
- asked.append(icao)
- return _Entry()
-
- fm.animate(two_aircraft(), tmp_path / "counted.gif", fps=8, seconds=3,
- width=400, book=_Book())
- assert asked, "the register was never asked at all"
- assert len(asked) == len(set(asked)), f"asked twice about the same: {asked}"
-
-
-def test_the_route_reaches_the_drawn_picture(tmp_path):
- """End to end: a register with a route, and the flags on the frame."""
- class _Book:
- def get(self, icao, callsign=""):
- return _Entry()
-
- tracks = two_aircraft()
- view = fm.fit(tracks, width=700)
- base = fm.background(view)
- known = {t.icao: _Entry() for t in tracks}
- frame = fm.render_frame(base, view, tracks, tracks[0].first_seen + 60,
- known=known)
- assert _has_flag(frame), "no flag was drawn"
- assert _has_text(frame, "KATL")
- assert _has_text(frame, "B739")
-
-
-def _has_flag(frame) -> bool:
- """Whether any flag was drawn: its own colours, at any fade step.
-
- Checked by the exact palette ranges rather than "any index above the
- flags", because everything a fading label is drawn in lives above them
- too.
- """
- import numpy as np
-
- from bandsaunter.flags import COLOUR_ORDER
-
- wide = len(COLOUR_ORDER)
- return bool(((frame >= fm.FLAG) & (frame < fm.FLAG + wide)).any()
- or ((frame >= fm.FLAG_FADED)
- & (frame < fm.FLAG_FADED + 3 * wide)).any())
-
-
-def test_a_crowded_frame_goes_back_to_the_short_label():
- """Five lines beside each of three hundred aircraft is not more
- information, it is a page of overlapping text with a map behind it."""
- many = [straight(icao=f"{i:06X}", callsign=f"FLT{i}",
- lat=51.0 + i * 0.02, lon=-1.0 + i * 0.02)
- for i in range(fm.CROWDED + 4)]
- view = fm.fit(many, width=900)
- base = fm.background(view)
- known = {t.icao: _Entry() for t in many}
- frame = fm.render_frame(base, view, many, many[0].first_seen + 60,
- known=known)
- assert not _has_flag(frame), "still drawing flags when crowded"
- assert not _has_text(frame, "B739")
-
-
-def test_a_quiet_frame_keeps_every_detail():
- tracks = two_aircraft()
- view = fm.fit(tracks, width=700)
- base = fm.background(view)
- known = {t.icao: _Entry() for t in tracks}
- frame = fm.render_frame(base, view, tracks, tracks[0].first_seen + 60,
- known=known)
- assert (frame >= fm.FLAG).any()
- assert _has_text(frame, "B739")
-
-
-def test_the_callsign_and_height_survive_a_crowd():
- """Whatever else goes, what the aircraft itself said stays."""
- many = [straight(icao=f"{i:06X}", callsign=f"FLT{i}",
- lat=51.0 + i * 0.02, lon=-1.0 + i * 0.02)
- for i in range(fm.CROWDED + 4)]
- view = fm.fit(many, width=900)
- frame = fm.render_frame(fm.background(view), view, many,
- many[0].first_seen + 60,
- known={t.icao: _Entry() for t in many})
- assert _has_text(frame, "FLT0")
-
-
-def test_labels_step_aside_rather_than_landing_on_each_other():
- """Two aircraft passing close together is exactly the moment somebody is
- looking at that part of the picture."""
- close = [straight(icao=f"{i:06X}", callsign=f"FLT{i}",
- lat=51.0 + i * 0.004, lon=-1.0 + i * 0.004, seconds=60)
- for i in range(6)]
- view = fm.fit(close, width=800, box=(50.8, -1.4, 51.3, -0.6))
- taken = []
- base = fm.background(view)
- fm.render_frame(base, view, close, close[0].first_seen, labels=True)
- # Place them by hand so the boxes can be compared.
- for track in close:
- now = track.at(track.first_seen)
- x, y = view.xy(now.latitude, now.longitude)
- fm._label(base, x, y, track, now, fm.RAMP, taken)
- for i, one in enumerate(taken):
- for two in taken[i + 1:]:
- assert not fm._overlaps(one, two), f"{one} overlaps {two}"
-
-
-# ---------------------------------------------------------------------------
-# The country an aircraft is registered in
-# ---------------------------------------------------------------------------
-
-def test_the_registration_carries_the_country_it_is_registered_in():
- track = straight()
- entry = _Entry()
- entry.owner_country = "United Kingdom"
- rows = dict((text, flag) for text, flag in
- fm.label_lines(track, track.fixes[0], "knots", entry))
- assert rows["B739 N904DN"] == "GB"
-
-
-def test_the_address_block_stands_in_when_a_register_says_nothing():
- """The 24-bit address says which country issued it, by treaty, with no
- website involved at all."""
- track = straight()
- entry = _Entry()
- entry.owner_country = ""
- entry.country = "United States"
- rows = dict(fm.label_lines(track, track.fixes[0], "knots", entry))
- assert rows["B739 N904DN"] == "US"
-
-
-def test_a_country_nobody_named_gets_no_flag():
- """An address outside every block anybody has published, and a register
- that did not say either: no flag rather than a guessed one."""
- from bandsaunter.flights import describe_address
-
- assert describe_address("0F0000") == "", "pick an unallocated address"
- track = straight(icao="0F0000")
- entry = _Entry()
- entry.owner_country = ""
- entry.country = ""
- rows = fm.label_lines(track, track.fixes[0], "knots", entry)
- assert not any(flag for _text, flag in rows if _text == "B739 N904DN")
- assert dict(rows)["B739 N904DN"] == ""
-
-
-# ---------------------------------------------------------------------------
-# Airports on the map
-# ---------------------------------------------------------------------------
-
-def test_an_airport_a_route_gave_a_position_for_is_marked():
- known = {"A": _Entry()}
- found = fm._airports_from(known)
- assert {code for code, _, _ in found} == {"KATL", "EGLL"}
-
-
-def test_an_airport_with_only_a_code_is_looked_up_so_it_can_be_marked():
- """A route names two airports and often gives a position for neither; an
- airport that cannot be placed cannot be drawn."""
- asked = []
-
- class _Book:
- def airports(self, codes):
- asked.extend(codes)
- return [{"code": code, "latitude": 32.1, "longitude": -110.9}
- for code in codes]
-
- entry = _Entry(origin_lat=0.0, origin_lon=0.0,
- destination_lat=0.0, destination_lon=0.0)
- found = fm._airports_from({"A": entry}, _Book())
- assert sorted(asked) == ["EGLL", "KATL"]
- assert len(found) == 2
-
-
-def test_an_airport_that_cannot_be_looked_up_is_simply_not_drawn():
- class _Book:
- def airports(self, codes):
- raise OSError("no network")
-
- entry = _Entry(origin_lat=0.0, origin_lon=0.0,
- destination_lat=0.0, destination_lon=0.0)
- assert fm._airports_from({"A": entry}, _Book()) == []
-
-
-def test_an_airport_already_placed_is_not_looked_up_again():
- asked = []
-
- class _Book:
- def airports(self, codes):
- asked.extend(codes)
- return []
-
- fm._airports_from({"A": _Entry()}, _Book())
- assert asked == [], "asked about airports it already had"
-
-
-def test_an_airport_is_drawn_where_it_is_and_named():
- view = fm.fit(two_aircraft(), width=600)
- middle = ((view.south + view.north) / 2, (view.west + view.east) / 2)
- base = fm.background(view, airports=[("EGLL", middle[0], middle[1])])
- x, y = view.xy(*middle)
- marker = (base[y - 4:y + 5, x - 4:x + 5] == fm.AIRPORT)
- assert marker.any(), "no marker where the airport is"
- # And its name beside it, in the same colour, to the right of the marker.
- beside = (base[y - 6:y + 8, x + 5:x + 60] == fm.AIRPORT)
- assert beside.sum() > 10, "the airport was marked but not named"
-
-
-def test_an_airport_off_the_edge_is_not_drawn():
- view = fm.fit(two_aircraft(), width=600)
- plain = fm.background(view)
- away = fm.background(view, airports=[("KSEA", 47.4, -122.3)])
- assert np.array_equal(plain, away)
-
-
-# ---------------------------------------------------------------------------
-# How dark the ground is
-# ---------------------------------------------------------------------------
-
-def _brightest_drawn(brightness):
- """The lightest the map actually gets at one brightness setting."""
- top = int(fm.dim_ground(np.array([[fm.GROUND_SHADES - 1]]),
- brightness)[0, 0])
- return fm.PALETTE[fm.GROUND + top].astype(float).mean()
-
-
-def test_the_map_is_light_enough_to_read():
- """It was too dark to make out a coastline at all."""
- assert _brightest_drawn(fm.GROUND_BRIGHTNESS) > 110
-
-
-def test_the_aircraft_stay_brighter_than_the_ground_they_are_over():
- """Whatever else, the picture has to stay about the aeroplanes."""
- ground = _brightest_drawn(fm.GROUND_BRIGHTNESS)
- for feet in (0, 10_000, 25_000, 40_000):
- marker = fm.PALETTE[fm.RAMP + fm.altitude_step(feet)]
- assert marker.astype(float).mean() > ground, feet
-
-
-def test_there_is_room_to_turn_it_up_and_down():
- assert _brightest_drawn(1.0) > _brightest_drawn(fm.GROUND_BRIGHTNESS)
- assert _brightest_drawn(0.3) < _brightest_drawn(fm.GROUND_BRIGHTNESS)
-
-
-@pytest.mark.parametrize("brightness", [0.0, -1.0, 5.0, 1.0, 0.1])
-def test_a_brightness_outside_the_range_is_brought_back_into_it(brightness):
- shades = fm.dim_ground(np.arange(fm.GROUND_SHADES, dtype=np.uint8),
- brightness)
- assert shades.min() >= 0 and shades.max() <= fm.GROUND_SHADES - 1
-
-
-def test_turning_it_down_really_does_darken_the_picture():
- levels = np.full((20, 20), fm.GROUND_SHADES - 1, dtype=np.uint8)
- assert fm.dim_ground(levels, 0.3).max() < fm.dim_ground(levels, 0.9).max()
-
-
-def test_the_darkest_ground_is_no_darker_than_the_background():
- darkest = fm.PALETTE[fm.GROUND].astype(float).mean()
- background = fm.PALETTE[fm.BG].astype(float).mean()
- assert darkest >= background
-
-
-# ---------------------------------------------------------------------------
-# Fading out rather than blinking out
-# ---------------------------------------------------------------------------
-
-def test_an_aircraft_still_being_heard_is_at_full_strength():
- track = straight(seconds=120)
- seen = fm.showing(track, track.fixes[-1].at, stale=300.0, fade=20.0)
- assert seen is not None and seen[1] == 1.0
-
-
-def test_one_that_has_gone_quiet_fades_instead_of_disappearing():
- track = straight(seconds=120)
- last = track.fixes[-1].at
- strengths = []
- for gone in (301, 305, 310, 315, 319):
- seen = fm.showing(track, last + gone, stale=300.0, fade=20.0)
- assert seen is not None, gone
- strengths.append(seen[1])
- assert strengths == sorted(strengths, reverse=True)
- assert strengths[0] > 0.9 and strengths[-1] < 0.1
-
-
-def test_it_is_gone_once_the_fade_is_over():
- track = straight(seconds=120)
- last = track.fixes[-1].at
- assert fm.showing(track, last + 321, stale=300.0, fade=20.0) is None
-
-
-def test_no_fade_takes_it_away_the_moment_it_is_given_up_on():
- track = straight(seconds=120)
- last = track.fixes[-1].at
- assert fm.showing(track, last + 301, stale=300.0, fade=0.0) is None
-
-
-def test_it_fades_where_it_was_last_seen_and_not_where_it_might_be():
- """The whole reason for giving up on an aircraft is that where it would
- be by now is a guess; fading it along that guess would be inventing an
- aeroplane slowly instead of quickly."""
- track = straight(seconds=120)
- last = track.fixes[-1]
- seen = fm.showing(track, last.at + 310, stale=300.0, fade=20.0)
- assert (seen[0].latitude, seen[0].longitude) == (last.latitude,
- last.longitude)
-
-
-def test_before_it_was_ever_heard_it_is_still_not_drawn():
- track = straight(seconds=120)
- assert fm.showing(track, track.fixes[0].at - 60, fade=20.0) is None
-
-
-def test_the_fading_aircraft_is_drawn_in_fainter_colours():
- track = straight(seconds=120)
- view = fm.fit([track], width=500)
- base = fm.background(view)
- last = track.fixes[-1].at
-
- def drawn(when):
- """The aircraft's own pixels: what changed, not what was already
- there. The altitude key along the bottom is painted in the same
- colours and belongs to the background."""
- frame = fm.render_frame(base, view, [track], when, stale=300.0,
- fade=20.0, labels=False)
- return frame[frame != base]
-
- fresh = drawn(last)
- faint = drawn(last + 318)
- assert fresh.size and faint.size
- # A live aircraft has its marker in the full colours; its trail behind it
- # is dimmer on purpose, so only the brightest pixels are the test.
- assert (fresh < fm.TRAIL).any(), "a live aircraft was drawn faintly"
- # A nearly gone one has nothing in them at all.
- assert not (faint < fm.FAINT).any(), "a fading aircraft was drawn brightly"
-
-
-def test_a_faded_aircraft_keeps_its_symbol_and_loses_its_label():
- track = straight(seconds=120)
- view = fm.fit([track], width=500)
- base = fm.background(view)
- last = track.fixes[-1].at
- known = {track.icao: _Entry()}
- faint = fm.render_frame(base, view, [track], last + 318, stale=300.0,
- fade=20.0, known=known)
- assert (faint != base).any(), "nothing was drawn at all"
- assert not _has_text(faint, "B739"), "the label survived the fade"
-
-
-def test_the_trail_fades_with_the_aircraft_it_belongs_to():
- """The two are one thing on the picture, and half of it lingering would
- be worse than either."""
- track = straight(seconds=120)
- view = fm.fit([track], width=500)
- base = fm.background(view)
- last = track.fixes[-1].at
- faint = fm.render_frame(base, view, [track], last + 318, stale=300.0,
- fade=20.0, labels=False)
- drawn = faint[faint != base]
- assert drawn.size
- assert drawn.min() >= fm.FAINT, "the trail stayed behind when it faded"
-
-
-def test_the_faint_colours_are_the_same_hues_only_dimmer():
- for step in (0, 15, 31):
- bright = fm.PALETTE[fm.RAMP + step].astype(float)
- faint = fm.PALETTE[fm.FAINT + step].astype(float)
- assert faint.sum() < bright.sum()
- # The same colour, turned down: the ratios between the channels hold.
- assert np.allclose(faint / max(1e-9, faint.sum()),
- bright / bright.sum(), atol=0.03)
-
-
-def test_the_palette_still_has_room_after_the_fade_colours():
- assert fm.FAINT + fm.RAMP_STEPS < fm.TRANSPARENT
- assert fm.PALETTE.shape == (256, 3)
-
-
-def test_a_route_the_aircraft_cannot_be_flying_is_not_drawn_beside_it():
- """A route beside an aircraft reads as a statement about that aircraft,
- and a callsign is a flight number rather than a leg."""
- track = straight(lat=32.7, lon=-110.4)
- entry = _Entry(origin_code="KHOU", origin_lat=29.65, origin_lon=-95.28,
- destination_code="KSAT", destination_lat=29.53,
- destination_lon=-98.47)
- said = [text for text, _flag in
- fm.label_lines(track, track.fixes[0], "knots", entry)]
- assert "KHOU" not in said and "KSAT" not in said
- assert any("B739" in line for line in said), "the rest of it went too"
-
-
-def test_a_route_it_could_be_flying_is_drawn():
- track = straight(lat=32.7, lon=-110.4)
- entry = _Entry(origin_code="KLAX", origin_lat=33.94, origin_lon=-118.41,
- destination_code="KDFW", destination_lat=32.90,
- destination_lon=-97.04)
- said = [text for text, _flag in
- fm.label_lines(track, track.fixes[0], "knots", entry)]
- assert "KLAX" in said and "KDFW" in said
-
-
-# ---------------------------------------------------------------------------
-# What sort of aircraft it is
-# ---------------------------------------------------------------------------
-
-def test_the_label_says_what_sort_of_aircraft_it_is():
- """It comes off the air: the three bits under an identification
- message's type code, which every aircraft sends with its callsign."""
- track = straight()
- track.category = "heavy"
- said = [text for text, _flag in fm.label_lines(track, track.fixes[0],
- "knots", None)]
- assert "heavy" in said
-
-
-def test_a_military_address_says_so_beside_the_aircraft():
- """No aircraft broadcasts that it is military -- a tanker calls itself
- "heavy" exactly as an airliner does -- so it is read off the address."""
- track = straight(icao="AE07D3") # a United States military block
- track.category = "heavy"
- said = [text for text, _flag in fm.label_lines(track, track.fixes[0],
- "knots", None)]
- assert "military heavy" in said
- plain = straight(icao="A12345") # the civil part of the same range
- plain.category = "heavy"
- assert "heavy" in [t for t, _f in fm.label_lines(plain, plain.fixes[0],
- "knots", None)]
- assert "military heavy" not in [t for t, _f in
- fm.label_lines(plain, plain.fixes[0],
- "knots", None)]
-
-
-def test_an_aircraft_that_says_nothing_about_itself_is_not_given_a_class():
- track = straight()
- assert not any(t in ("heavy", "large", "light") for t, _f in
- fm.label_lines(track, track.fixes[0], "knots", None))
-
-
-def test_the_class_carries_the_flag_so_the_type_line_need_not():
- """One flag per aircraft, on the first row that says what it is."""
- track = straight()
- track.category = "large"
- rows = fm.label_lines(track, track.fixes[0], "knots", _Entry())
- flags = [(text, flag) for text, flag in rows if flag]
- assert ("large", "IE") in flags
- assert dict(rows)["B739 N904DN"] == ""
-
-
-# ---------------------------------------------------------------------------
-# A label that has to move swings there
-# ---------------------------------------------------------------------------
-
-def test_the_label_glide_moves_towards_the_target_and_arrives():
- here = (0.0, 0.0)
- for _ in range(40):
- here = fm._glide(here, (120.0, -80.0), 0.05, fm.LABEL_GLIDE)
- assert 0.0 <= here[0] <= 120.0 and -80.0 <= here[1] <= 0.0
- assert here == (120.0, -80.0)
-
-
-def test_the_label_glide_is_the_same_swing_at_any_frame_rate():
- fast = slow = (0.0, 0.0)
- for _ in range(8):
- fast = fm._glide(fast, (200.0, 0.0), 0.025, fm.LABEL_GLIDE)
- for _ in range(2):
- slow = fm._glide(slow, (200.0, 0.0), 0.1, fm.LABEL_GLIDE)
- assert abs(fast[0] - slow[0]) < 1.0
-
-
-def test_a_label_keeps_the_place_it_has():
- """The reason labels stay still: one is only moved when its own place
- has actually been taken, never because the placer would now prefer a
- different one."""
- places = fm.LabelPlaces()
- places.settle("ABC123", 100, 100, (160, 90))
- kept = places.kept("ABC123", 104, 100, lambda bx, by: (int(bx), int(by)))
- assert kept == (164, 90), "it did not travel with its aircraft"
-
-
-def test_a_label_whose_place_is_taken_is_sent_looking():
- places = fm.LabelPlaces()
- places.settle("ABC123", 100, 100, (160, 90))
- assert places.kept("ABC123", 100, 100, lambda bx, by: None) is None
-
-
-def test_a_label_that_has_to_move_swings_rather_than_jumps():
- places = fm.LabelPlaces()
- places.settle("ABC123", 0, 0, (100, 100))
- assert places.drawn("ABC123", 0, 0, (100, 100), 0.08) == (100, 100)
- part = places.drawn("ABC123", 0, 0, (300, 240), 0.08)
- assert part != (300, 240), "it jumped the whole way in one frame"
- assert 100 < part[0] < 300
- for _ in range(40):
- part = places.drawn("ABC123", 0, 0, (300, 240), 0.08)
- assert part == (300, 240)
-
-
-def test_a_label_is_forgotten_when_its_aircraft_goes():
- """Otherwise one that comes back glides in from wherever it stood half
- an hour ago, across the whole picture."""
- places = fm.LabelPlaces()
- places.begin()
- places.settle("ABC123", 0, 0, (100, 100))
- places.drawn("ABC123", 0, 0, (100, 100), 0.08)
- places.end()
- assert "ABC123" in places.at
- places.begin()
- places.end()
- assert places.at == {} and places.want == {}
-
-
-def test_a_label_swings_across_a_real_frame_rather_than_jumping():
- """The same easing the window does, wired through a drawn frame: the
- label is somewhere between where it was and where it now belongs, and
- what is spoken for is where it is going."""
- track = straight()
- # A canvas with room around the aircraft, so that a displaced label is
- # displaced rather than pushed off the edge and refused.
- view = fm.fit([track], width=800, box=(50.0, -3.0, 52.0, 3.0))
- base = fm.background(view, unit="knots")
- places = fm.LabelPlaces()
- when = track.fixes[0].at
- fm.render_frame(base, view, [track], when, labels=True, unit="knots",
- places=places, step=1.0 / 12.0)
- settled = places.want[track.icao]
- assert places.at[track.icao] == (float(settled[0]), float(settled[1]))
- # Push its place a long way, as a crowd of aircraft arriving would.
- places.want[track.icao] = (settled[0] - 120, settled[1] + 90)
- after = fm.render_frame(base, view, [track], when, labels=True,
- unit="knots", places=places, step=1.0 / 12.0)
- target = places.want[track.icao]
- now_at = places.at[track.icao]
- assert target != settled, "the test did not displace it"
- assert now_at != (float(settled[0]), float(settled[1])), "it did not move"
- assert now_at != (float(target[0]), float(target[1])), \
- "it jumped the whole way in one frame"
- part = (now_at[0] - settled[0]) / (target[0] - settled[0])
- assert 0.05 < part < 0.60, f"one frame carried it {part:.0%} of the way"
- # And it gets there, given the frames.
- for _ in range(30):
- fm.render_frame(base, view, [track], when, labels=True, unit="knots",
- places=places, step=1.0 / 12.0)
- assert places.at[track.icao] == (float(places.want[track.icao][0]),
- float(places.want[track.icao][1]))
- assert after.shape == base.shape
-
-
-def test_a_still_picture_places_its_labels_exactly_as_it_always_did():
- """There is no frame before a still, so there is nothing to keep and
- nothing to swing: the placing has to be untouched."""
- track = straight()
- view = fm.fit([track], width=800)
- base = fm.background(view, unit="knots")
- when = track.fixes[0].at
- plain = fm.render_frame(base, view, [track], when, labels=True,
- unit="knots", project=False)
- again = fm.render_frame(base, view, [track], when, labels=True,
- unit="knots", project=False)
- assert np.array_equal(plain, again)
-
-
-def test_the_height_does_not_claim_to_know_a_foot_it_was_not_told():
- """Most moments in an animation are between two reports and the height
- at them is interpolated. Mode S reports altitude in twenty-five foot
- steps, so a real reading survives untouched and an invented one stops
- pretending to be exact."""
- track = straight(altitude=37_675)
- fix = track.fixes[0]
- assert fm.label_lines(track, fix, "knots", None)[0][0] \
- .startswith("37,675 ft")
- from dataclasses import replace
-
- between = replace(fix, altitude_ft=37_699)
- assert fm.label_lines(track, between, "knots", None)[0][0] \
- .startswith("37,700 ft")
-
-
-# ---------------------------------------------------------------------------
-# The box fades with the aircraft
-# ---------------------------------------------------------------------------
-
-def test_the_fade_level_follows_the_strength():
- assert fm.fade_level(1.0) == 0
- assert fm.fade_level(0.5) == 1
- assert fm.fade_level(0.3) == 2
- assert fm.fade_level(0.05) == 3
-
-
-def test_each_fade_step_of_the_row_grey_is_darker_than_the_last():
- greys = [fm.PALETTE[fm.LABEL_INK + i].astype(int).sum() for i in range(4)]
- assert greys == sorted(greys, reverse=True)
- assert greys[0] > greys[-1] * 3, "the last step is barely dimmer"
-
-
-def test_each_fade_step_of_a_flag_colour_is_darker_than_the_last():
- for letter in ("r", "w", "b"):
- shades = [fm.PALETTE[fm.flag_index(letter, level)].astype(int).sum()
- for level in range(4)]
- assert shades == sorted(shades, reverse=True), letter
-
-
-def test_a_fading_label_is_drawn_fainter_than_a_full_one():
- """The name already faded, because it is drawn in the aircraft's own
- colour. The rows and the flag were fixed colours, so the brightest
- thing left on that part of the picture was the one aeroplane nothing
- had been heard from."""
- track = straight()
- view = fm.fit([track], width=800, box=(50.0, -3.0, 52.0, 3.0))
- base = fm.background(view, unit="knots")
- entry = _Entry()
-
- def rows_of(strength):
- img = base.copy()
- fm._label(img, 300, 200, track, track.fixes[0], fm.RAMP, [], "knots",
- entry=entry, strength=strength)
- drawn = img != base
- return fm.PALETTE[img[drawn]].astype(int).sum()
-
- full = rows_of(1.0)
- half = rows_of(0.5)
- nearly_gone = rows_of(0.05)
- assert half < full, "a fading label was as bright as a full one"
- assert nearly_gone < half
-
-
-def _label_pixels(strength, entry=None):
- """One label drawn at a strength, as the palette indices it used."""
- track = straight()
- track.category = "large"
- view = fm.fit([track], width=800, box=(50.0, -3.0, 52.0, 3.0))
- base = fm.background(view, unit="knots")
- img = base.copy()
- fm._label(img, 300, 200, track, track.fixes[0], fm.RAMP, [], "knots",
- entry=entry if entry is not None else _Entry(),
- strength=strength)
- return set(np.unique(img[img != base]).tolist())
-
-
-def test_the_rows_are_drawn_in_the_grey_for_the_strength_they_are_at():
- """Not merely dimmer on average -- the label has a name in it that
- fades on its own -- but each row written in the grey that belongs to
- how far the aircraft has faded."""
- full = _label_pixels(1.0)
- assert fm.LABEL_INK + 0 in full
- for strength, level in ((0.5, 1), (0.3, 2), (0.05, 3)):
- used = _label_pixels(strength)
- assert fm.LABEL_INK + level in used, strength
- assert not any(fm.LABEL_INK + other in used
- for other in range(4) if other != level), strength
-
-
-def test_the_flag_on_a_label_is_drawn_at_the_labels_own_fade_step():
- from bandsaunter.flags import COLOUR_ORDER
-
- wide = len(COLOUR_ORDER)
- full = _label_pixels(1.0)
- assert any(fm.FLAG <= i < fm.FLAG + wide for i in full), "no flag drawn"
- for strength, level in ((0.5, 1), (0.3, 2), (0.05, 3)):
- used = _label_pixels(strength)
- assert not any(fm.FLAG <= i < fm.FLAG + wide for i in used), \
- f"a full-strength flag on a label faded to {strength}"
- want = fm.FLAG_FADED + (level - 1) * wide
- assert any(want <= i < want + wide for i in used), strength
-
-
-def test_a_flag_on_a_fading_label_fades_with_it():
- from bandsaunter.flags import FLAG_H, FLAG_W
-
- bright = np.full((40, 60), fm.BG, dtype=np.uint8)
- faint = np.full((40, 60), fm.BG, dtype=np.uint8)
- fm.draw_flag(bright, 5, 5, "US", 0)
- fm.draw_flag(faint, 5, 5, "US", 3)
- patch = (slice(5, 5 + FLAG_H), slice(5, 5 + FLAG_W))
- assert fm.PALETTE[faint[patch]].astype(int).sum() < \
- fm.PALETTE[bright[patch]].astype(int).sum()
-
-
-def test_a_named_country_with_no_flag_fades_too():
- faint = np.full((40, 60), fm.BG, dtype=np.uint8)
- bright = np.full((40, 60), fm.BG, dtype=np.uint8)
- fm.draw_flag(bright, 5, 5, "ZZ", 0)
- fm.draw_flag(faint, 5, 5, "ZZ", 3)
- assert fm.PALETTE[faint].astype(int).sum() < \
- fm.PALETTE[bright].astype(int).sum()
-
-
-def test_an_airport_cannot_be_mistaken_for_an_aircraft():
- """The old amber sat sixteen units of CIELAB from the ramp's 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.
-
- Stated as the property rather than as the colour, so that changing the
- altitude ramp cannot quietly walk an aircraft back into the airports.
- """
- 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])])
-
- 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"the airport colour is {apart:.0f} units from the "
- f"nearest altitude colour; under about 25 they read "
- f"as the same colour")
-
-
-# ---------------------------------------------------------------------------
-# The flag on the receiver
-# ---------------------------------------------------------------------------
-
-def test_the_frame_flies_a_flag_where_the_receiver_was_told_it_is():
- track = straight()
- view = fm.fit([track], width=600)
- middle = ((view.south + view.north) / 2, (view.west + view.east) / 2)
- base = fm.background(view, unit="knots")
- assert not (base == fm.HOME).any(), "the ground still flies the flag"
- flagged = fm.render_frame(base, view, [track], track.fixes[0].at,
- unit="knots", home=middle)
- assert (flagged == fm.HOME).any(), "no flag was drawn"
- # The foot of the pole is the position it is pointing at.
- x, y = view.xy(*middle)
- assert flagged[y, x] == fm.HOME
-
-
-def test_no_flag_where_nobody_said_the_receiver_is():
- """The middle is otherwise worked out from whatever flew past, which is
- not a place anybody is standing, and a flag on it would say one is."""
- track = straight()
- view = fm.fit([track], width=600)
- base = fm.background(view, unit="knots")
- assert not (fm.render_frame(base, view, [track], track.fixes[0].at,
- unit="knots") == fm.HOME).any()
-
-
-def test_a_receiver_outside_the_picture_is_not_flagged_at_its_edge():
- track = straight()
- view = fm.fit([track], width=600)
- base = fm.background(view, unit="knots")
- away = (view.north + 20.0, view.east + 20.0)
- assert not (fm.render_frame(base, view, [track], track.fixes[0].at,
- unit="knots", home=away) == fm.HOME).any()
-
-
-def test_nothing_is_drawn_over_the_flag():
- """It says where the receiver is standing, which is the one thing on
- the picture that must never be hidden behind an aeroplane that happened
- to fly over it."""
- track = straight()
- view = fm.fit([track], width=600)
- base = fm.background(view, unit="knots")
- # An aircraft exactly on top of the receiver, with a label and a trail.
- here = (track.fixes[0].latitude, track.fixes[0].longitude)
- img = fm.render_frame(base, view, [track], track.fixes[0].at,
- unit="knots", labels=True, home=here,
- trail_seconds=600.0)
- x, y = view.xy(*here)
- assert img[y, x] == fm.HOME, "the aircraft was drawn over the flag"
- # The whole pole, not just its foot.
- pole = img[y - fm.HOME_POLE:y + 1, x]
- assert (pole == fm.HOME).all(), "part of the pole was painted over"
-
-
-def test_the_animation_flies_the_flag_only_where_it_was_given_a_centre(tmp_path):
- track = straight()
- middle = (track.fixes[0].latitude, track.fixes[0].longitude)
- told = fm.animate([track], tmp_path / "told.png", ground=False,
- airports=False, centre=middle, radius_nm=200.0)
- guessed = fm.animate([track], tmp_path / "guessed.png", ground=False,
- airports=False)
- assert told is not None and guessed is not None
- import numpy as np
- from PIL import Image
-
- def has_red(path):
- picture = np.array(Image.open(path).convert("RGB"))
- return bool(((picture[:, :, 0] == fm.HOME_RED[0])
- & (picture[:, :, 1] == fm.HOME_RED[1])
- & (picture[:, :, 2] == fm.HOME_RED[2])).any())
-
- assert has_red(told.path)
- assert not has_red(guessed.path)
-
-
-# ---------------------------------------------------------------------------
-# The line from a label to its aircraft
-# ---------------------------------------------------------------------------
-
-def test_a_dashed_line_has_gaps_in_it():
- img = np.full((40, 80), fm.BG, dtype=np.uint8)
- fm._dashed(img, 5, 20, 74, 20, fm.LEADER)
- row = img[20, 5:75]
- assert (row == fm.LEADER).any(), "nothing was drawn"
- assert (row == fm.BG).any(), "no gaps: that is a solid line"
- # On and off in the lengths asked for, near enough to the pixel.
- runs, last, count = [], row[0], 0
- for value in row:
- if value == last:
- count += 1
- else:
- runs.append((last, count))
- last, count = value, 1
- runs.append((last, count))
- on = [n for value, n in runs[1:-1] if value == fm.LEADER]
- assert on and all(4 <= n <= 6 for n in on), on
-
-
-def test_a_dash_is_the_same_length_whichever_way_the_line_runs():
- """Stepped along the line rather than along whichever axis is longer,
- so a nearly-horizontal leader and a nearly-vertical one come out the
- same instead of one of them turning into a dotted line."""
- import math
-
- drawn = []
- for x1, y1 in ((79, 21), (21, 39), (79, 39)):
- img = np.full((40, 80), fm.BG, dtype=np.uint8)
- fm._dashed(img, 1, 1, x1, y1, fm.LEADER)
- length = math.hypot(x1 - 1, y1 - 1)
- drawn.append((img == fm.LEADER).sum() / length)
- assert max(drawn) - min(drawn) < 0.25, drawn
-
-
-def test_a_leader_stops_at_the_aircraft_rather_than_running_past_it():
- """Walked along the line's own length, so the last step lands on the
- far end. Walked along whichever axis is longer instead, a diagonal
- overshoots by half as much again and the leader carries on past the
- aeroplane it was drawn to point at."""
- img = np.full((80, 80), fm.BG, dtype=np.uint8)
- fm._dashed(img, 10, 10, 50, 50, fm.LEADER)
- ys, xs = np.where(img == fm.LEADER)
- assert len(xs), "nothing was drawn"
- assert xs.max() <= 50 and ys.max() <= 50, \
- f"ran past the end to ({xs.max()}, {ys.max()})"
- assert xs.min() >= 10 and ys.min() >= 10
- # And it does reach it, near enough to the pixel.
- assert xs.max() >= 46 and ys.max() >= 46
-
-
-def test_a_leader_that_goes_nowhere_draws_nothing():
- img = np.full((40, 80), fm.BG, dtype=np.uint8)
- fm._dashed(img, 20, 20, 20, 20, fm.LEADER)
- assert not (img == fm.LEADER).any()
-
-
-def test_the_animation_ties_a_label_to_its_aircraft():
- """The window has always had a leader; here there was nothing at all,
- and a label pushed out into one of the rings by a crowd had nothing
- tying it to the aeroplane it was about."""
- track = straight()
- view = fm.fit([track], width=800, box=(50.0, -3.0, 52.0, 3.0))
- base = fm.background(view, unit="knots")
- img = base.copy()
- fm._label(img, 300, 200, track, track.fixes[0], fm.RAMP, [], "knots",
- entry=_Entry())
- assert (img == fm.LEADER).any(), "no leader was drawn"
-
-
-def test_the_animation_leader_is_not_the_aircrafts_own_colour():
- """A solid line running out of an aeroplane in the colour of the path
- behind it reads as more path; so does a line of any pattern in that
- colour, and this one is neither."""
- from bandsaunter.flightmap import LEADER, PALETTE, RAMP, RAMP_STEPS
-
- leader = tuple(int(v) for v in PALETTE[LEADER])
- ramp = {tuple(int(v) for v in PALETTE[RAMP + i]) for i in range(RAMP_STEPS)}
- assert leader not in ramp
-
-
-def test_the_leader_fades_with_the_label_it_belongs_to():
- track = straight()
- view = fm.fit([track], width=800, box=(50.0, -3.0, 52.0, 3.0))
- base = fm.background(view, unit="knots")
-
- def leader_shade(strength):
- img = base.copy()
- fm._label(img, 300, 200, track, track.fixes[0], fm.RAMP, [], "knots",
- entry=_Entry(), strength=strength)
- drawn = {int(v) for v in np.unique(img[img != base])}
- return {v for v in drawn if fm.LEADER <= v <= fm.LEADER + 2}
-
- assert leader_shade(1.0) == {fm.LEADER}
- faded = leader_shade(0.45)
- assert faded and fm.LEADER not in faded, faded
-
-
-# ---------------------------------------------------------------------------
-# Range rings
-# ---------------------------------------------------------------------------
-
-def _ring_view(width=400):
- track = straight()
- return fm.fit([track], width=width, box=(50.0, -3.0, 52.0, 3.0))
-
-
-def test_the_rings_lift_the_ground_more_the_nearer_the_middle_they_are():
- """Concentric and translucent, so they stack: three lifts inside the
- innermost, two in the next, one in the outer, none beyond."""
- view = _ring_view()
- home = ((view.south + view.north) / 2, (view.west + view.east) / 2)
- ground = np.zeros((view.height, view.width), dtype=np.uint8)
- img = fm.background(view, unit="knots", ground=ground, home=home,
- rings=60.0)
- lift = fm.RING_LIFT
- counts = [int((img == fm.GROUND + lift * n).sum()) for n in range(4)]
- assert all(counts), f"not every ring was drawn: {counts}"
- # Each ring is an annulus further out than the last, so it covers more
- # of the picture than the one inside it.
- assert counts[3] < counts[2] < counts[1], counts
-
-
-def test_the_rings_stack_on_a_picture_with_no_map_under_it():
- """With no map there is nothing but background to lift, and a pixel
- the outer disc has lifted has to count as ground for the next one --
- otherwise every ring lands on bare background and they all come out the
- same shade."""
- view = _ring_view()
- home = ((view.south + view.north) / 2, (view.west + view.east) / 2)
- img = fm.background(view, unit="knots", home=home, rings=60.0)
- lift = fm.RING_LIFT
- shades = [int((img == fm.GROUND + lift * n).sum()) for n in (1, 2, 3)]
- assert all(shades), f"the rings did not stack: {shades}"
- assert shades[0] > shades[1] > shades[2], shades
-
-
-def test_the_rings_stop_at_the_radius_they_were_given():
- view = _ring_view()
- home = ((view.south + view.north) / 2, (view.west + view.east) / 2)
- ground = np.zeros((view.height, view.width), dtype=np.uint8)
- img = fm.background(view, unit="knots", ground=ground, home=home,
- rings=60.0)
- away = fm._distance_field(
- home,
- view.north - (view.north - view.south) * (np.arange(view.height) + 0.5)
- / view.height,
- view.west + (view.east - view.west) * (np.arange(view.width) + 0.5)
- / view.width)
- # The view sits inside the canvas, below the title and inside the
- # margins, so the distance field lines up with that part of it.
- patch = img[view.top:view.top + view.height,
- view.left:view.left + view.width]
- # Ground only: the flag is drawn over the middle of the innermost ring
- # and is not ground that was lifted.
- ground = (patch >= fm.GROUND) & (patch < fm.GROUND + fm.GROUND_SHADES)
- lifted = ground & (patch > fm.GROUND)
- beyond = away > 60.0 * 0.75 + 1.0
- assert not (lifted & beyond).any(), \
- "the ground beyond the outer ring was lifted"
- assert (lifted & (away < 60.0 * 0.25)).any(), "the innermost ring is missing"
-
-
-def test_no_rings_without_a_radius_or_without_a_position():
- view = _ring_view()
- home = ((view.south + view.north) / 2, (view.west + view.east) / 2)
- ground = np.zeros((view.height, view.width), dtype=np.uint8)
- plain = fm.background(view, unit="knots", ground=ground)
- assert np.array_equal(
- fm.background(view, unit="knots", ground=ground, home=None,
- rings=60.0), plain)
- # A position on its own is not a reason to draw rings: it is the radius
- # that says how far out they go.
- placed = fm.background(view, unit="knots", ground=ground, home=home)
- assert not (placed == fm.GROUND + fm.RING_LIFT).any()
-
-
-def _labels_drawn(**over):
- """Every string the drawing was asked to stamp, in order.
-
- Read by watching the drawing rather than by looking for the letters in
- the picture afterwards: a picture with a ring on it has large areas of
- one flat colour, and searching those for a pattern of pixels finds
- whatever it is asked for.
- """
- view = _ring_view(width=700)
- home = ((view.south + view.north) / 2, (view.west + view.east) / 2)
- said = []
- real = fm.draw_text
-
- def watch(img, x, y, text, colour):
- said.append(text)
- return real(img, x, y, text, colour)
-
- fm.draw_text = watch
- try:
- fm.background(view, home=home, rings=60.0, **over)
- finally:
- fm.draw_text = real
- return said
-
-
-def test_each_ring_is_labelled_with_how_far_out_it_is():
- said = _labels_drawn(unit="knots")
- for nm in (15, 30, 45):
- assert f"{nm} NM" in said, (nm, said)
-
-
-def test_the_ring_labels_are_in_the_unit_the_rest_of_the_picture_uses():
- """Miles an hour beside a ring measured in nautical miles would be two
- different miles on one picture."""
- said = _labels_drawn(unit="mph")
- assert "17 MI" in said, said # 15 nm, written as statute
- assert not any(word.endswith(" NM") for word in said), said
-
-
-def test_the_rings_leave_the_aerodromes_and_the_grid_alone():
- """They only lift pixels that are still map or still empty."""
- view = _ring_view()
- home = ((view.south + view.north) / 2, (view.west + view.east) / 2)
- airports = [("EGLL", home[0], home[1] + 0.2)]
- img = fm.background(view, unit="knots", airports=airports, home=home,
- rings=300.0)
- assert (img == fm.AIRPORT).any(), "the aerodrome was washed out"
- assert (img == fm.GRID).any(), "the grid was washed out"
-
-
-def test_a_distance_field_measures_from_the_place_it_was_given():
- lats = np.array([51.0, 52.0])
- lons = np.array([-1.0, -1.0])
- away = fm._distance_field((51.0, -1.0), lats, lons)
- assert away[0, 0] == pytest.approx(0.0, abs=0.01)
- # A degree of latitude is sixty nautical miles, near enough.
- assert away[1, 0] == pytest.approx(60.0, abs=0.5)
-
-
-# ---------------------------------------------------------------------------
-# The card behind a label
-# ---------------------------------------------------------------------------
-
-def _labelled(opacity, shade=20):
- track = straight()
- view = fm.fit([track], width=800, box=(50.0, -3.0, 52.0, 3.0))
- ground = np.full((view.height, view.width), shade, dtype=np.uint8)
- base = fm.background(view, unit="knots", ground=ground, brightness=1.0)
- img = base.copy()
- fm._label(img, 300, 200, track, track.fixes[0], fm.RAMP, [], "knots",
- entry=_Entry(), opacity=opacity)
- return base, img
-
-
-def test_no_card_at_all_puts_the_words_straight_on_the_map():
- """Which is what the pictures used to look like, and still can."""
- base, img = _labelled(0.0)
- # Only pixels that are still map in both: the words themselves turn map
- # into ink, and that is the label being drawn rather than a card.
- ground = (base >= fm.GROUND) & (base < fm.GROUND + fm.GROUND_SHADES)
- still = ground & (img >= fm.GROUND) & (img < fm.GROUND + fm.GROUND_SHADES)
- assert np.array_equal(img[still], base[still]), "the map was darkened"
-
-
-def test_a_card_darkens_the_map_under_the_words():
- base, img = _labelled(0.6)
- darker = (img < base) & (base >= fm.GROUND) & \
- (base < fm.GROUND + fm.GROUND_SHADES)
- assert darker.sum() > 200, "nothing was darkened"
- # And the coastline still shows through: not everything went to one shade.
- under = img[darker]
- assert under.max() > under.min() or under.min() > fm.GROUND
-
-
-def test_the_card_gets_darker_the_more_of_it_is_asked_for():
- def under(opacity):
- base, img = _labelled(opacity)
- return int(img[195:215, 295:400].astype(int).sum())
-
- steps = [under(part) for part in (0.0, 0.3, 0.6, 0.9)]
- assert steps == sorted(steps, reverse=True), steps
-
-
-def test_a_card_the_whole_way_up_is_a_solid_panel():
- base, img = _labelled(1.0)
- assert (img == fm.PANEL).any(), "the panel colour was never used"
- ground = (base >= fm.GROUND) & (base < fm.GROUND + fm.GROUND_SHADES)
- covered = ground & (img == fm.PANEL)
- assert covered.sum() > 200, "the map still shows through a solid panel"
-
-
-def test_the_card_fades_with_the_label_it_is_behind():
- """A card at full strength under a label on its way out would be the
- brightest thing left of it."""
- track = straight()
- view = fm.fit([track], width=800, box=(50.0, -3.0, 52.0, 3.0))
- ground = np.full((view.height, view.width), 20, dtype=np.uint8)
- base = fm.background(view, unit="knots", ground=ground, brightness=1.0)
-
- def darkness(strength):
- img = base.copy()
- fm._label(img, 300, 200, track, track.fixes[0], fm.RAMP, [], "knots",
- entry=_Entry(), opacity=0.9, strength=strength)
- # How much darker, not how many pixels: a fainter card covers the
- # same rectangle and merely takes less out of it.
- return int((base.astype(int) - img.astype(int)).clip(0).sum())
-
- assert darkness(1.0) > darkness(0.5) > 0
diff --git a/tests/test_flights.py b/tests/test_flights.py
index 0337d34..874fc5f 100644
--- a/tests/test_flights.py
+++ b/tests/test_flights.py
@@ -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 == ""
diff --git a/tests/test_images.py b/tests/test_images.py
index ae9ae2b..aef4ac5 100644
--- a/tests/test_images.py
+++ b/tests/test_images.py
@@ -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
diff --git a/tests/test_licence.py b/tests/test_licence.py
index 63f0931..4f21bad 100644
--- a/tests/test_licence.py
+++ b/tests/test_licence.py
@@ -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():
diff --git a/tests/test_livemap.py b/tests/test_livemap.py
deleted file mode 100644
index 19a256c..0000000
--- a/tests/test_livemap.py
+++ /dev/null
@@ -1,1921 +0,0 @@
-"""The window: what it draws, and what it does without Qt.
-
-Everything here runs on Qt's offscreen platform, so it needs no screen and
-no window manager -- which is also what makes it runnable on a machine that
-has never had one.
-"""
-import os
-import time
-
-import numpy as np
-import pytest
-
-os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
-
-from bandsaunter import livemap # noqa: E402
-from bandsaunter.livemap import Blip, Sky, blip_for, place_box # noqa: E402
-
-qt = pytest.mark.skipif(not livemap.available(), reason="no Qt installed")
-
-# Held for the life of the process on purpose. Qt allows one application and
-# expects it to outlive every widget; letting the fixture own it means that
-# running one test from this file on its own destroys it while widgets are
-# still about, and the interpreter comes down with a core dump.
-_APPLICATION = None
-if livemap.available():
- _widgets = livemap._qt()[3]
- _APPLICATION = _widgets.QApplication.instance() or _widgets.QApplication([])
-
-def now() -> float:
- """The real clock.
-
- The window works in real time -- an aircraft leaves the picture so many
- seconds after its last frame -- so a fixture pinned to a made-up epoch
- would have everything time out before it was ever drawn.
- """
- return time.time()
-
-
-@pytest.fixture
-def app():
- """The one application, which outlives every test that uses it."""
- if _APPLICATION is None:
- pytest.skip("no Qt installed")
- return _APPLICATION
-
-
-def a_blip(icao="A76154", callsign="DAL538", lat=32.95, lon=-110.95,
- **over) -> Blip:
- settings = dict(icao=icao, callsign=callsign, latitude=lat, longitude=lon,
- altitude_ft=32_500, ground_speed_kt=494.0, track_deg=325.0,
- messages=1204, first_seen=now() - 700, last_seen=now() - 1)
- settings.update(over)
- return Blip(**settings)
-
-
-def a_sky(*blips, **over) -> Sky:
- settings = dict(unit="knots", hold=45.0, home=(32.4325, -111.0841),
- radius_nm=100.0, brightness=0.70)
- settings.update(over)
- sky = Sky(**settings)
- sky.started = now() - 600
- if blips:
- sky.update(list(blips), frames=7412, seen=len(blips))
- return sky
-
-
-# ---------------------------------------------------------------------------
-# Without Qt at all
-# ---------------------------------------------------------------------------
-
-def test_importing_it_does_not_drag_qt_in():
- """A machine with no Qt loses this window and nothing else, so nothing
- may import Qt merely because this module was loaded."""
- import importlib
- import sys
-
- for name in ("SkyView", "Window"):
- assert name not in livemap.__dict__ or True # built on demand only
- fresh = importlib.reload(importlib.import_module("bandsaunter.livemap"))
- assert "SkyView" not in fresh.__dict__
- assert "Window" not in fresh.__dict__
- del sys
-
-
-def test_the_message_says_how_to_get_it():
- assert "pip install PyQt6" in livemap.MISSING_QT
- assert "apt install" in livemap.MISSING_QT
- # And that nothing else is lost by not having it.
- assert "passive capture still records" in livemap.MISSING_QT
-
-
-def test_no_qt_means_a_message_rather_than_a_traceback(monkeypatch):
- monkeypatch.setattr(livemap, "_qt", lambda: None)
- monkeypatch.setattr(livemap._build, "_made", None, raising=False)
- assert livemap.available() is False
- assert livemap.binding() == ""
- with pytest.raises(RuntimeError) as raised:
- livemap.show(a_sky())
- assert "Qt" in str(raised.value)
-
-
-def test_the_menu_is_told_whether_a_window_can_be_opened(monkeypatch):
- from bandsaunter import aircraft as air
-
- monkeypatch.setattr(livemap, "available", lambda: False)
- assert air.windowed() is False
- monkeypatch.setattr(livemap, "available", lambda: True)
- assert air.windowed() is True
-
-
-def test_asking_to_watch_without_qt_says_so_and_stops(monkeypatch, tmp_path,
- capsys):
- """It must not open the receiver, either: nothing is gained by taking
- the dongle for a window that cannot be drawn."""
- from rich.console import Console
-
- from bandsaunter import aircraft as air
-
- monkeypatch.setattr(livemap, "available", lambda: False)
- monkeypatch.setattr(air, "open_device", lambda *a, **k:
- pytest.fail("opened the receiver anyway"))
- heard = air.watch(Console(width=100), air.AircraftOptions(), str(tmp_path))
- assert heard.frames == 0
- assert "Qt" in capsys.readouterr().out
-
-
-# ---------------------------------------------------------------------------
-# What goes in the box
-# ---------------------------------------------------------------------------
-
-def test_the_box_says_everything_that_is_known():
- blip = a_blip(registration="N904DN", type_code="B739",
- manufacturer="Boeing", model="737-932ER",
- operator="Delta Air Lines", country="United States",
- origin="Atlanta", destination="Phoenix",
- vertical_rate_fpm=1600)
- told = "\n".join(f"{a} {b}" for a, b, _ in
- blip.lines("knots", home=(32.4325, -111.0841)))
- for wanted in ("N904DN", "B739", "Boeing 737-932ER", "Delta Air Lines",
- "from Atlanta", "to Phoenix", "32,500 ft", "494 kt",
- "325° NW", "1,204 frames", "United States"):
- assert wanted in told, wanted
- assert "↑1,600 fpm" in told
-
-
-def test_a_descent_is_marked_as_one():
- told = " ".join(v for _, v, _ in
- a_blip(vertical_rate_fpm=-900).lines("knots"))
- assert "↓900 fpm" in told and "↑" not in told
-
-
-def test_nothing_is_said_about_what_is_not_known():
- """A register that has not answered yet must leave no empty rows: they
- would never fill in and the box would be mostly gaps."""
- told = [label for label, _, _ in a_blip().lines("knots")]
- assert "reg" not in told
- assert all(x is not None for x in told)
- text = " ".join(v for _, v, _ in a_blip().lines("knots"))
- assert "from" not in text # no route was known
-
-
-def _by_label(blip, unit, home):
- return {label: value for label, value, _ in blip.lines(unit, home=home)}
-
-
-def test_how_far_away_and_which_way_are_worked_out():
- home = (32.4325, -111.0841)
- told = _by_label(a_blip(lat=32.95, lon=-110.95), "knots", home)
- assert "32 nm" in told["range"]
- assert "012°" in told["range"]
-
-
-def test_the_range_follows_the_unit_the_speeds_are_in():
- home = (32.4325, -111.0841)
- assert "37 mi" in _by_label(a_blip(), "mph", home)["range"]
- assert "59 km" in _by_label(a_blip(), "kph", home)["range"]
-
-
-def test_an_aircraft_that_never_said_its_name_is_known_by_its_address():
- assert a_blip(callsign="").name == "A76154"
- assert a_blip().name == "DAL538"
-
-
-def test_what_the_registers_said_is_copied_onto_the_blip():
- from bandsaunter.adsb import Aircraft
- from bandsaunter.flights import Flight
-
- craft = Aircraft(icao="4CA1FA", callsign="RYR1234")
- craft.altitude_ft, craft.messages = 35_000, 12
- entry = Flight(icao="4CA1FA", registration="EI-DYP", type_code="B738",
- operator="Ryanair", origin="Stansted",
- destination="East Midlands", owner_country="Ireland")
- blip = blip_for(craft, entry)
- assert (blip.registration, blip.type_code) == ("EI-DYP", "B738")
- assert blip.operator == "Ryanair" and blip.country == "Ireland"
- assert blip.origin == "Stansted"
-
-
-def test_a_blip_works_with_no_register_at_all():
- from bandsaunter.adsb import Aircraft
-
- blip = blip_for(Aircraft(icao="4CA1FA", callsign="RYR1234"))
- assert blip.icao == "4CA1FA" and blip.registration == ""
-
-
-# ---------------------------------------------------------------------------
-# Where the boxes go
-# ---------------------------------------------------------------------------
-
-BOUNDS = (0, 0, 1000, 700)
-
-
-def test_a_box_goes_beside_the_aircraft_when_there_is_room():
- x, y = place_box(400, 300, 200, 120, [], BOUNDS)
- assert x > 400 # to the right, the usual place
- assert 300 - 120 <= y <= 300
-
-
-def test_a_box_never_lands_on_one_already_placed():
- taken = []
- for i in range(9):
- spot = place_box(400 + (i % 3) * 12, 300 + (i // 3) * 12,
- 180, 110, taken, BOUNDS)
- taken.append((spot[0], spot[1], 180, 110))
- for i, one in enumerate(taken):
- for two in taken[i + 1:]:
- apart = (one[0] + one[2] <= two[0] or two[0] + two[2] <= one[0]
- or one[1] + one[3] <= two[1] or two[1] + two[3] <= one[1])
- assert apart, f"{one} overlaps {two}"
-
-
-def test_a_box_stays_on_the_picture():
- for x, y in ((5, 5), (995, 695), (0, 350), (500, 0)):
- bx, by = place_box(x, y, 220, 130, [], BOUNDS)
- assert 0 <= bx and bx + 220 <= 1000
- assert 0 <= by and by + 130 <= 700
-
-
-def test_a_full_screen_still_gets_a_box():
- """Overlapping is better than nothing: a box in an awkward place still
- says what the aircraft is."""
- wall = [(x, y, 100, 100) for x in range(0, 1000, 100)
- for y in range(0, 700, 100)]
- bx, by = place_box(500, 350, 200, 120, wall, BOUNDS)
- assert 0 <= bx <= 1000 and 0 <= by <= 700
-
-
-# ---------------------------------------------------------------------------
-# What the window is told
-# ---------------------------------------------------------------------------
-
-def test_an_aircraft_appears_and_is_kept_in_order_first_heard():
- first = a_blip(icao="111111", callsign="FIRST", first_seen=now() - 300)
- second = a_blip(icao="222222", callsign="SECOND", first_seen=now() - 100)
- sky = a_sky(second, first)
- assert [b.callsign for b in sky.flying()] == ["FIRST", "SECOND"]
-
-
-def test_one_nothing_has_been_heard_from_leaves_the_picture():
- here = a_blip(icao="111111", last_seen=now() - 5)
- gone = a_blip(icao="222222", last_seen=now() - 300)
- assert [b.icao for b in a_sky(here, gone).flying()] == ["111111"]
-
-
-def test_an_aircraft_with_no_position_is_not_drawn():
- """It has been heard but not placed; the table says so and the map
- cannot."""
- assert a_sky(a_blip(lat=0.0, lon=0.0)).flying() == []
-
-
-def test_the_trail_grows_as_it_moves():
- sky = a_sky()
- for i in range(5):
- sky.update([a_blip(lat=32.9 + i * 0.01)], frames=i, seen=1)
- assert len(sky.trail("A76154")) == 5
-
-
-def test_standing_still_does_not_lengthen_the_trail():
- sky = a_sky()
- for _ in range(9):
- sky.update([a_blip()], frames=1, seen=1)
- assert len(sky.trail("A76154")) == 1
-
-
-def test_the_middle_of_the_map_is_where_the_receiver_was_said_to_be():
- assert a_sky(a_blip()).centre() == (32.4325, -111.0841)
-
-
-def test_with_no_receiver_position_the_middle_is_worked_out():
- sky = a_sky(a_blip(icao="1", lat=30.0, lon=-110.0),
- a_blip(icao="2", lat=31.0, lon=-111.0),
- a_blip(icao="3", lat=32.0, lon=-112.0), home=None)
- assert sky.centre() == (31.0, -111.0)
-
-
-def test_nothing_heard_yet_has_no_middle():
- assert a_sky(home=None).centre() is None
-
-
-def test_a_map_is_used_for_any_view_it_covers():
- """The whole point of fetching more than is shown: a view that has
- drifted a little is still inside what was fetched."""
- sky = a_sky()
- sky.set_ground(np.zeros((40, 40), dtype=np.uint8), "k",
- (31.0, -113.0, 34.0, -109.0))
- levels, box = sky.ground_covering(32.0, -112.0, 33.0, -110.0)
- assert levels is not None and box == (31.0, -113.0, 34.0, -109.0)
-
-
-def test_a_map_is_not_used_for_a_view_that_has_left_it():
- sky = a_sky()
- sky.set_ground(np.zeros((40, 40), dtype=np.uint8), "k",
- (31.0, -113.0, 34.0, -109.0))
- assert sky.ground_covering(40.0, -113.0, 42.0, -109.0)[0] is None
- assert sky.ground_covering(31.0, -120.0, 34.0, -109.0)[0] is None
-
-
-@qt
-def test_the_map_is_fetched_at_the_size_the_bigger_box_needs(app):
- """Fetching a third more world into the same pixels and then cutting the
- middle out of it is how a sharp map ends up looking like a photograph of
- a map."""
- from bandsaunter.livemap import GROUND_MARGIN, SkyView
-
- sky = a_sky(a_blip())
- view = SkyView(sky)
- view.resize(900, 650)
- view._draw_ground(_NoPainter(), view.projection())
- wanted = sky.wanted_ground()
- assert wanted is not None
- _key, _box, (width, height) = wanted
- assert width >= 900 * (1 + 2 * GROUND_MARGIN) - 2
- assert height >= 650 * (1 + 2 * GROUND_MARGIN) - 2
-
-
-class _NoPainter:
- """Stands in for a painter for the one call that only asks for a map."""
-
- def drawImage(self, *a):
- raise AssertionError("nothing should be drawn without a map")
-
-
-def test_more_is_fetched_than_is_shown(app):
- """A window that fetched exactly what it needed would fetch again on
- every resize and every small drift of the middle."""
- from bandsaunter.livemap import SkyView
-
- view = SkyView(a_sky(a_blip()))
- view.resize(900, 650)
- projection = view.projection()
- box = view.ground_box(projection)
- assert box[0] < projection.south and box[1] < projection.west
- assert box[2] > projection.north and box[3] > projection.east
-
-
-def test_a_map_that_could_not_be_fetched_is_not_asked_for_again():
- """Otherwise a machine with no network asks five times a second all
- night."""
- sky = a_sky()
- sky.want_ground("a", (0, 0, 1, 1), (10, 10))
- assert sky.wanted_ground() is not None
- sky.set_ground(None, "a")
- assert sky.wanted_ground() is None
-
-
-# ---------------------------------------------------------------------------
-# The map staying still
-# ---------------------------------------------------------------------------
-
-def test_the_middle_settles_and_then_stays_put():
- """Recomputing it on every paint moves the map a fraction of a mile
- whenever an aircraft appears or leaves, which throws away the tiles
- fetched for the old view and blinks the ground out several times a
- minute."""
- sky = a_sky(a_blip(icao="1", lat=32.4, lon=-111.0),
- a_blip(icao="2", lat=32.5, lon=-111.1),
- a_blip(icao="3", lat=32.6, lon=-111.2), home=None)
- settled = sky.centre()
- assert settled is not None
- # One more aircraft, a little to one side: the middle must not follow it.
- sky.update([a_blip(icao="4", lat=32.9, lon=-110.7)], 1, 4)
- assert sky.centre() == settled
-
-
-def test_the_middle_does_move_if_it_is_really_somewhere_else():
- """Settling must not mean stuck: the first few aircraft can be anywhere."""
- sky = a_sky(a_blip(icao="1", lat=32.4, lon=-111.0), home=None)
- first = sky.centre()
- for i in range(6):
- sky.update([a_blip(icao=f"far{i}", lat=40.0 + i * 0.1, lon=-90.0)],
- 1, i)
- assert sky.centre() != first
- assert abs(sky.centre()[0] - 40.0) < 2
-
-
-def test_a_receiver_that_was_told_where_it_is_never_drifts_at_all():
- sky = a_sky(a_blip(icao="1", lat=32.4, lon=-111.0))
- for i in range(5):
- sky.update([a_blip(icao=f"x{i}", lat=35.0 + i, lon=-100.0)], 1, i)
- assert sky.centre() == (32.4325, -111.0841)
-
-
-@qt
-def test_a_small_drift_does_not_throw_the_map_away(app):
- """The failure this is here for: the ground blinking out because the
- view moved by less than a mile."""
- from bandsaunter.livemap import SkyView
-
- sky = a_sky(a_blip(icao="1", lat=32.40, lon=-111.00),
- a_blip(icao="2", lat=32.50, lon=-111.10), home=None)
- view = SkyView(sky)
- view.resize(900, 650)
- first = view.projection()
- sky.set_ground(np.full((650, 900), 20, dtype=np.uint8),
- view.ground_key(first), view.ground_box(first))
- # Another aircraft arrives a few miles away.
- sky.update([a_blip(icao="3", lat=32.44, lon=-111.06)], 1, 3)
- moved = view.projection()
- assert sky.ground_covering(moved.south, moved.west,
- moved.north, moved.east)[0] is not None
-
-
-@qt
-def test_the_crop_lines_the_map_up_with_the_view(app):
- """A map fetched for a bigger box has to be cut to the right piece, or
- the coast is drawn in the wrong place and the aircraft with it."""
- from bandsaunter.livemap import SkyView
-
- view = SkyView(a_sky(a_blip()))
- view.resize(100, 80)
- projection = view.projection()
- box = (projection.south - 1.0, projection.west - 1.0,
- projection.north + 1.0, projection.east + 1.0)
- # A map that is dark in the north and bright in the south.
- levels = np.repeat(np.linspace(0, 31, 200).astype(np.uint8)[:, None],
- 200, axis=1)
- cropped = view._crop(levels, box, projection)
- assert cropped.shape == (projection.height, projection.width)
- assert cropped[0].mean() < cropped[-1].mean() # north still on top
- # And the piece taken is the middle of the fetched one, not all of it.
- assert cropped.min() > levels.min() and cropped.max() < levels.max()
-
-
-def test_the_map_is_fetched_off_the_painting_thread():
- asked = []
-
- def tile(z, x, y, **kw):
- asked.append((z, x, y))
- return None
-
- sky = a_sky()
- sky.want_ground("k", (32.0, -112.0, 33.0, -110.0), (40, 40))
-
- import threading
-
- worker = threading.Thread(target=livemap.fetch_ground, args=(sky,),
- kwargs={"fetch": tile}, daemon=True)
- worker.start()
- for _ in range(50):
- if sky.wanted_ground() is None:
- break
- time.sleep(0.05)
- sky.stopping = True
- worker.join(timeout=2.0)
- assert asked, "the tile server was never asked"
-
-
-# ---------------------------------------------------------------------------
-# Painting
-# ---------------------------------------------------------------------------
-
-def _rendered(view, width=900, height=650):
- """The widget's pixels, as an array."""
- from bandsaunter.livemap import _qt
-
- _name, _core, gui, _widgets, _signal = _qt()
- view.resize(width, height)
- image = gui.QImage(width, height, _get(gui.QImage, "Format",
- "Format_RGB32"))
- image.fill(gui.QColor(0, 0, 0))
- view.render(image)
- bits = image.constBits()
- bits.setsize(image.sizeInBytes())
- # Copied on the way out. The array numpy builds is a view onto Qt's own
- # buffer, and the image is about to go out of scope: reading it after
- # that is a use-after-free, which reads as blank rows if you are lucky
- # and a segmentation fault if you are not.
- return np.frombuffer(bits, dtype=np.uint8).reshape(
- height, width, 4).copy()
-
-
-def _get(owner, group, name):
- return getattr(getattr(owner, group, owner), name)
-
-
-def _painted(picture) -> int:
- """How many pixels are something other than the empty background.
-
- Counting non-black pixels would count all of them: the background is a
- dark blue rather than black, on purpose.
- """
- from bandsaunter.flightmap import BG, PALETTE
-
- r, g, b = (int(v) for v in PALETTE[BG])
- background = ((picture[:, :, 2] == r) & (picture[:, :, 1] == g)
- & (picture[:, :, 0] == b))
- return int((~background).sum())
-
-
-def _in_colour(picture, feet: int):
- """The pixels painted exactly in one altitude's colour."""
- from bandsaunter.flightmap import PALETTE, RAMP, altitude_step
-
- r, g, b = (int(v) for v in PALETTE[RAMP + altitude_step(feet)])
- return ((picture[:, :, 2] == r) & (picture[:, :, 1] == g)
- & (picture[:, :, 0] == b))
-
-
-def _lit(picture):
- """Where those pixels are."""
- from bandsaunter.flightmap import BG, PALETTE
-
- r, g, b = (int(v) for v in PALETTE[BG])
- background = ((picture[:, :, 2] == r) & (picture[:, :, 1] == g)
- & (picture[:, :, 0] == b))
- return np.argwhere(~background)
-
-
-@qt
-def test_the_window_draws_the_aircraft_and_a_box(app):
- from bandsaunter.livemap import SkyView
-
- sky = a_sky(a_blip(registration="N904DN", type_code="B739",
- operator="Delta Air Lines"))
- view = SkyView(sky)
- picture = _rendered(view)
- assert _painted(picture) > 500, "nothing was drawn at all"
- # The symbol is filled with the aircraft's altitude colour exactly; the
- # box around it is a hairline and comes out blended, so only the symbol
- # itself is counted here.
- assert _in_colour(picture, 32_500).sum() >= 10, \
- "no aircraft drawn in its altitude colour"
-
-
-@qt
-def test_nothing_placed_yet_says_so_rather_than_drawing_a_blank(app):
- from bandsaunter.livemap import SkyView
-
- view = SkyView(a_sky(home=None))
- picture = _rendered(view)
- assert _painted(picture) > 200, "an empty window said nothing at all"
-
-
-@qt
-def test_the_symbol_is_where_the_aircraft_is(app):
- from bandsaunter.livemap import SkyView
-
- sky = a_sky(a_blip(lat=32.4325, lon=-111.0841)) # right on the receiver
- view = SkyView(sky)
- view.detail = 0 # symbols only
- picture = _rendered(view, 900, 650)
- rows, cols = np.where(_in_colour(picture, 32_500))
- assert rows.size, "the aircraft was not drawn"
- # An aircraft at the receiver belongs in the middle of the window.
- assert abs(rows.mean() - 325) < 40, rows.mean()
- assert abs(cols.mean() - 450) < 40, cols.mean()
-
-
-@qt
-def test_the_detail_can_be_turned_down_for_a_busy_sky(app):
- from bandsaunter.livemap import SkyView
-
- sky = a_sky(*[a_blip(icao=f"{i:06X}", callsign=f"FLT{i}",
- lat=32.3 + i * 0.05, lon=-111.2 + i * 0.05)
- for i in range(6)])
- view = SkyView(sky)
- drawn = []
- for detail in (2, 1, 0):
- view.detail = detail
- drawn.append(_painted(_rendered(view)))
- assert drawn[0] > drawn[1] > drawn[2], drawn
-
-
-@qt
-def test_the_trails_and_the_map_can_be_turned_off(app):
- from bandsaunter.livemap import SkyView
-
- sky = a_sky()
- for i in range(20):
- sky.update([a_blip(lat=32.6 + i * 0.02, lon=-111.0 + i * 0.02)],
- frames=i, seen=1)
- view = SkyView(sky)
- view.detail = 0
- with_trail = _painted(_rendered(view))
- view.trails = False
- without = _painted(_rendered(view))
- assert with_trail > without
-
-
-@qt
-def test_the_map_underneath_is_drawn_when_there_is_one(app):
- from bandsaunter.livemap import SkyView
-
- sky = a_sky(a_blip())
- view = SkyView(sky)
- view.resize(900, 650)
- projection = view.projection()
- sky.set_ground(np.full((650, 900), 31, dtype=np.uint8),
- view.ground_key(projection),
- view.ground_box(projection))
- lit = _rendered(view)[:, :, :3]
- from bandsaunter.flightmap import (GROUND, GROUND_SHADES, PALETTE,
- dim_ground)
-
- # The brightest shade a drawing reaches is what the brightness allows,
- # not the top of the palette.
- top = int(dim_ground(np.array([[GROUND_SHADES - 1]]), sky.brightness)[0, 0])
- r, g, b = (int(v) for v in PALETTE[GROUND + top])
- covered = ((lit[:, :, 2] == r) & (lit[:, :, 1] == g) & (lit[:, :, 0] == b))
- assert covered.mean() > 0.5, "the map was not painted under the aircraft"
-
-
-@qt
-def test_the_keys_do_what_the_header_says_they_do(app):
- from bandsaunter.livemap import Window, _qt
-
- _name, core, gui, _widgets, _signal = _qt()
- window = Window(a_sky(a_blip()))
- try:
- def press(letter):
- event = gui.QKeyEvent(_get(core.QEvent, "Type", "KeyPress"),
- ord(letter.upper()),
- _get(core.Qt, "KeyboardModifier",
- "NoModifier"), letter)
- window.keyPressEvent(event)
-
- was = window.view.detail
- press("d")
- assert window.view.detail != was
- press("t")
- assert window.view.trails is False
- press("g")
- assert window.view.show_ground is False
- tight = window.sky.radius_nm
- press("-")
- assert window.sky.radius_nm > tight
- press("+")
- assert window.sky.radius_nm < window.sky.radius_nm * 1.5
- finally:
- window.close()
-
-
-@qt
-def test_the_window_shuts_itself_when_the_listening_is_over(app):
- """"Listen for ten minutes" has to mean the same thing whether or not a
- window is open."""
- from bandsaunter.livemap import Window
-
- sky = a_sky(a_blip())
- window = Window(sky)
- window.show()
- assert window.isVisible()
- sky.finished = True
- window._tick()
- assert not window.isVisible()
-
-
-@qt
-def test_closing_the_window_stops_the_receiver(app):
- from bandsaunter.livemap import Window
-
- sky = a_sky(a_blip())
- window = Window(sky)
- window.show()
- window.close()
- assert sky.stopping is True
-
-
-# ---------------------------------------------------------------------------
-# Long names, narrow boxes
-# ---------------------------------------------------------------------------
-
-def test_something_short_is_left_alone():
- from bandsaunter.livemap import wrap_value
-
- assert wrap_value("Atlanta → Phoenix") == ["Atlanta → Phoenix"]
- assert wrap_value("Boeing 737-932ER") == ["Boeing 737-932ER"]
- assert wrap_value("") == [""]
-
-
-def test_a_long_route_breaks_at_the_arrow_first():
- """The two ends of the flight stay whole and sit under one another,
- where they read as a pair."""
- from bandsaunter.livemap import wrap_value
-
- folded = wrap_value("London Stansted Airport → East Midlands Airport, "
- "Nottingham")
- assert folded[0] == "London Stansted Airport"
- assert folded[1].startswith("→ East Midlands")
- assert not any(line.startswith("→") for line in folded[2:])
-
-
-def test_the_far_end_of_a_route_is_indented_when_it_wraps_too():
- from bandsaunter.livemap import wrap_value
-
- folded = wrap_value("Los Angeles International Airport → Dallas Fort "
- "Worth International Airport")
- onward = folded[folded.index(next(x for x in folded
- if x.startswith("→"))) + 1:]
- assert onward and all(line.startswith(" ") for line in onward)
-
-
-def test_a_long_name_folds_between_words():
- from bandsaunter.livemap import wrap_value
-
- folded = wrap_value("CELESTIAL AVIATION TRADING 14 LTD")
- assert len(folded) == 2
- assert " ".join(folded) == "CELESTIAL AVIATION TRADING 14 LTD"
-
-
-def test_one_enormous_word_is_cut_rather_than_widening_the_box():
- """Nothing an aircraft sends looks like this; a register will send one
- eventually."""
- from bandsaunter.livemap import wrap_value
-
- folded = wrap_value("Supercalifragilisticexpialidociousaerodrome")
- assert len(folded) > 1
- assert "".join(folded) == "Supercalifragilisticexpialidociousaerodrome"
-
-
-@pytest.mark.parametrize("text", [
- "Los Angeles International Airport → Dallas Fort Worth International Airport",
- "London Stansted Airport → East Midlands Airport, Nottingham",
- "CELESTIAL AVIATION TRADING 14 LTD",
- "Hartsfield Jackson Atlanta International Airport",
- "Supercalifragilisticexpialidociousaerodrome",
-])
-def test_nothing_comes_out_wider_than_the_limit(text):
- from bandsaunter.livemap import WRAP_CHARS, wrap_value
-
- assert all(len(line) <= WRAP_CHARS for line in wrap_value(text)), \
- wrap_value(text)
-
-
-@qt
-def test_a_folded_row_carries_no_label_of_its_own(app):
- """The label belongs to the value as a whole; repeating it down the side
- of a wrapped airport name would read as several different facts."""
- from bandsaunter.livemap import SkyView
-
- view = SkyView(a_sky())
- rows = view._wrapped([("alt", "35,000 ft", ""),
- ("to", "East Midlands Airport, Nottingham", "GB")])
- assert rows[0] == ("alt", "35,000 ft", "")
- assert rows[1][0] == "to" and rows[1][2] == "GB"
- assert all(label == "" and flag == "" for label, _, flag in rows[2:])
-
-
-@qt
-def test_a_long_route_no_longer_widens_the_box(app):
- """The whole point: one flight between two long names used to make its
- box wider than the map it sits on."""
- from bandsaunter.livemap import SkyView
-
- view = SkyView(a_sky())
- short = view._box_size(view._wrapped([("to", "Phoenix", "")]))
- long = view._box_size(view._wrapped(
- [("to", "Dallas Fort Worth International Airport", "")]))
- assert long[0] < short[0] * 2, "the box grew with the name"
- assert long[1] > short[1], "it should have got taller instead"
-
-
-@qt
-def test_the_widest_box_stays_within_reason(app):
- """Every field at its longest, and the box still fits on a small window."""
- from bandsaunter.livemap import SkyView
-
- blip = a_blip(registration="N904DN", type_code="B739",
- manufacturer="Boeing", model="737-932ER Winglets",
- operator="CELESTIAL AVIATION TRADING 14 LTD",
- country="United States of America",
- origin="Hartsfield Jackson Atlanta International Airport",
- destination="Phoenix Sky Harbor International Airport")
- view = SkyView(a_sky(blip))
- width, _height = view._box_size(
- view._wrapped(blip.lines("mph", home=(32.4325, -111.0841))))
- assert width < 320, width
-
-
-# ---------------------------------------------------------------------------
-# How bright the map is
-# ---------------------------------------------------------------------------
-
-def test_the_map_brightness_reaches_the_window():
- from bandsaunter import aircraft as air
-
- sky = Sky(brightness=0.9)
- assert sky.brightness == 0.9
- assert air.AircraftOptions().map_brightness == 70
-
-
-@qt
-def test_turning_the_brightness_down_darkens_the_map(app):
- from bandsaunter.livemap import SkyView
-
- def painted(brightness):
- sky = a_sky(a_blip(), brightness=brightness)
- view = SkyView(sky)
- view.resize(400, 300)
- projection = view.projection()
- sky.set_ground(np.full((300, 400), 31, dtype=np.uint8),
- view.ground_key(projection),
- view.ground_box(projection))
- return _rendered(view, 400, 300)[:, :, :3].astype(float).mean()
-
- assert painted(0.3) < painted(0.7) < painted(1.0)
-
-
-@qt
-def test_the_keys_change_the_brightness(app):
- from bandsaunter.livemap import Window, _qt
-
- _name, core, gui, _widgets, _signal = _qt()
- window = Window(a_sky(a_blip()))
- try:
- def press(letter):
- window.keyPressEvent(gui.QKeyEvent(
- _get(core.QEvent, "Type", "KeyPress"), ord(letter),
- _get(core.Qt, "KeyboardModifier", "NoModifier"), letter))
-
- was = window.sky.brightness
- press("]")
- assert window.sky.brightness > was
- press("[")
- press("[")
- assert window.sky.brightness < was
- for _ in range(20): # and never off either end
- press("[")
- assert 0.0 < window.sky.brightness <= 1.0
- finally:
- window.close()
-
-
-def test_the_country_of_registration_carries_a_flag():
- from bandsaunter.adsb import Aircraft
- from bandsaunter.flights import Flight
-
- craft = Aircraft(icao="4CA1FA", callsign="RYR1234")
- blip = blip_for(craft, Flight(icao="4CA1FA", owner_country="Ireland"))
- assert blip.country == "Ireland" and blip.country_code == "IE"
- rows = {label: (value, flag) for label, value, flag in blip.lines("knots")}
- assert rows["reg'd"] == ("Ireland", "IE")
-
-
-def test_a_country_with_no_code_gets_no_flag_rather_than_a_wrong_one():
- from bandsaunter.adsb import Aircraft
- from bandsaunter.flights import Flight
-
- blip = blip_for(Aircraft(icao="4CA1FA"),
- Flight(icao="4CA1FA", owner_country="Atlantis"))
- assert blip.country_code == ""
-
-
-# ---------------------------------------------------------------------------
-# Fading out rather than blinking out
-# ---------------------------------------------------------------------------
-
-def test_an_aircraft_still_being_heard_is_drawn_in_full():
- sky = a_sky(hold=10.0, fade=20.0)
- assert sky.strength(a_blip(last_seen=now() - 1)) == 1.0
- assert sky.strength(a_blip(last_seen=now() - 9)) == 1.0
-
-
-def test_one_that_has_gone_quiet_fades_rather_than_vanishing():
- """Taking it off between one frame and the next says it stopped
- existing; fading says it stopped talking, which is what happened."""
- sky = a_sky(hold=10.0, fade=20.0)
- at = now()
- strengths = [sky.strength(a_blip(last_seen=at - age), at)
- for age in (5, 15, 20, 25, 29)]
- assert strengths == sorted(strengths, reverse=True)
- assert strengths[0] == 1.0
- assert 0.0 < strengths[-1] < 0.1
-
-
-def test_it_is_gone_once_it_has_finished_fading():
- sky = a_sky(hold=10.0, fade=20.0)
- old = a_blip(last_seen=now() - 40)
- assert sky.strength(old) == 0.0
- sky.update([old], 1, 1)
- assert old.icao not in [b.icao for b in sky.flying()]
-
-
-def test_it_stays_on_the_picture_for_the_whole_fade():
- sky = a_sky(hold=10.0, fade=20.0)
- quiet = a_blip(last_seen=now() - 25)
- sky.update([quiet], 1, 1)
- assert [b.icao for b in sky.flying()] == [quiet.icao]
-
-
-def test_no_fade_at_all_takes_it_away_at_once():
- sky = a_sky(hold=10.0, fade=0.0)
- assert sky.strength(a_blip(last_seen=now() - 9)) == 1.0
- assert sky.strength(a_blip(last_seen=now() - 11)) == 0.0
-
-
-def test_how_long_the_fade_takes_is_a_setting():
- from bandsaunter import aircraft as air
-
- assert air.AircraftOptions().fade == 20.0
- quick = a_sky(hold=10.0, fade=4.0)
- slow = a_sky(hold=10.0, fade=60.0)
- faded = a_blip(last_seen=now() - 20)
- assert quick.strength(faded) == 0.0
- assert slow.strength(faded) > 0.5
-
-
-def test_the_setting_reaches_the_window():
- assert Sky(fade=7.5).fade == 7.5
-
-
-@qt
-def test_a_fading_aircraft_is_drawn_fainter(app):
- from bandsaunter.livemap import SkyView
-
- def brightness(age):
- sky = a_sky(hold=5.0, fade=20.0)
- sky.update([a_blip(last_seen=now() - age)], 1, 1)
- view = SkyView(sky)
- view.detail = 0
- picture = _rendered(view, 500, 400)
- lit = _lit(picture)
- if not len(lit):
- return 0.0
- return float(picture[lit[:, 0], lit[:, 1], :3].mean())
-
- assert brightness(1) > brightness(20)
-
-
-@qt
-def test_a_faded_aircraft_keeps_its_symbol_and_loses_its_box(app):
- """A box at a tenth of its colour is something in the way of the
- aircraft still flying."""
- from bandsaunter.livemap import SkyView
-
- sky = a_sky(hold=5.0, fade=20.0)
- sky.update([a_blip(last_seen=now() - 22)], 1, 1) # strength about 0.15
- view = SkyView(sky)
- fresh = _painted(_rendered(view, 600, 450))
- sky2 = a_sky(hold=5.0, fade=20.0)
- sky2.update([a_blip(last_seen=now() - 1)], 1, 1)
- view2 = SkyView(sky2)
- full = _painted(_rendered(view2, 600, 450))
- assert fresh < full, "the box was still drawn on a nearly faded aircraft"
-
-
-@qt
-def test_one_that_has_gone_quiet_is_not_counted_as_overhead(app):
- """It is on the picture, fading; saying it is overhead says more than
- was heard."""
- from bandsaunter.livemap import SkyView
-
- sky = a_sky(hold=5.0, fade=30.0)
- sky.update([a_blip(icao="AAA111", last_seen=now() - 1),
- a_blip(icao="BBB222", lat=32.6, last_seen=now() - 20)], 9, 2)
- picture = _rendered(SkyView(sky), 900, 650)
- assert _painted(picture) > 100 # both are drawn
- assert len(sky.flying()) == 2
- assert sum(1 for b in sky.flying() if sky.strength(b) >= 1.0) == 1
-
-
-def test_a_route_the_aircraft_cannot_be_flying_is_left_out_of_the_box():
- from bandsaunter.adsb import Aircraft
- from bandsaunter.flights import Flight
-
- craft = Aircraft(icao="AC0FB6", callsign="SWA930")
- craft.latitude, craft.longitude = 32.7086, -110.4061
- entry = Flight(icao="AC0FB6", origin="Houston", origin_code="KHOU",
- origin_lat=29.65, origin_lon=-95.28,
- destination="San Antonio", destination_code="KSAT",
- destination_lat=29.53, destination_lon=-98.47,
- registration="N8765Q")
- blip = blip_for(craft, entry)
- assert blip.route_fits is False
- labels = [label for label, _v, _f in blip.lines("knots")]
- assert "from" not in labels and "to" not in labels
- # And everything the aircraft itself said is still there.
- assert any(v == "N8765Q" for _l, v, _f in blip.lines("knots"))
-
-
-def test_a_route_it_could_be_flying_stays_in_the_box():
- from bandsaunter.adsb import Aircraft
- from bandsaunter.flights import Flight
-
- craft = Aircraft(icao="AD64CD", callsign="AAL2465")
- craft.latitude, craft.longitude = 33.1059, -110.5428
- entry = Flight(icao="AD64CD", origin="Santa Ana", origin_code="KSNA",
- origin_lat=33.68, origin_lon=-117.87,
- destination="Dallas", destination_code="KDFW",
- destination_lat=32.90, destination_lon=-97.04)
- blip = blip_for(craft, entry)
- assert blip.route_fits is True
- labels = [label for label, _v, _f in blip.lines("knots")]
- assert "from" in labels and "to" in labels
-
-
-# ---------------------------------------------------------------------------
-# A box that has to move swings there rather than jumping
-# ---------------------------------------------------------------------------
-
-def test_the_glide_moves_towards_the_target_without_passing_it():
- from bandsaunter.livemap import glide
-
- here = (0.0, 0.0)
- last = here
- for _ in range(40):
- here = glide(here, (100.0, -60.0), 0.05)
- assert 0.0 <= here[0] <= 100.0 and -60.0 <= here[1] <= 0.0
- assert here[0] >= last[0] and here[1] <= last[1]
- last = here
- assert here == (100.0, -60.0), "never actually arrived"
-
-
-def test_the_glide_arrives_and_stops_asking_for_frames():
- """It has to land exactly on the target, not approach it forever: the
- window repaints faster while anything is moving, and a box that is
- always a thousandth of a pixel out never lets it stop."""
- from bandsaunter.livemap import BOX_GLIDE, glide
-
- here = glide((0.0, 0.0), (50.0, 50.0), BOX_GLIDE * 4)
- assert here == (50.0, 50.0)
- assert glide(here, (50.0, 50.0), 0.1) == (50.0, 50.0)
-
-
-def test_the_glide_is_the_same_swing_at_any_frame_rate():
- """Eased by the distance left rather than counted in frames, so a busy
- machine drawing half as often makes the same movement, not a slower
- one."""
- from bandsaunter.livemap import glide
-
- fast = (0.0, 0.0)
- for _ in range(8):
- fast = glide(fast, (200.0, 0.0), 0.025)
- slow = (0.0, 0.0)
- for _ in range(2):
- slow = glide(slow, (200.0, 0.0), 0.1)
- assert abs(fast[0] - slow[0]) < 1.0
-
-
-def test_a_frame_that_took_no_time_moves_nothing():
- from bandsaunter.livemap import glide
-
- assert glide((3.0, 4.0), (99.0, 99.0), 0.0) == (3.0, 4.0)
-
-
-def test_a_window_left_buried_does_not_replay_the_moves():
- """Ten seconds behind another window is not ten seconds of animation
- owed; it is a box that should already be where it belongs."""
- from bandsaunter.livemap import glide
-
- assert glide((0.0, 0.0), (300.0, 300.0), 10.0) == (300.0, 300.0)
-
-
-@qt
-def test_a_box_that_need_not_move_does_not_move(app):
- """The whole point of remembering the spot: one aircraft shifting a
- pixel must not send its neighbour's box across the window."""
- from bandsaunter.livemap import SkyView
-
- sky = a_sky(a_blip(icao="A00001", callsign="AAL1", lat=32.60, lon=-111.30),
- a_blip(icao="A00002", callsign="AAL2", lat=32.20, lon=-110.60))
- view = SkyView(sky)
- view.resize(900, 650)
- _rendered(view)
- first = dict(view._box_want)
- assert first, "no box was placed at all"
- for _ in range(4):
- _rendered(view)
- assert dict(view._box_want) == first
-
-
-@qt
-def test_a_spot_that_still_works_is_kept(app):
- """Hysteresis, and the reason boxes stay still: a box is only moved
- when its own place is actually taken, never because the placer would
- now prefer a different one."""
- from bandsaunter.livemap import SkyView
-
- view = SkyView(a_sky(a_blip()))
- view.resize(900, 650)
- view._box_want["A00001"] = (50, 20)
- assert view._box_spot("A00001", 400, 300, (120, 60), []) == (450, 320)
-
-
-@qt
-def test_a_spot_that_has_been_taken_is_given_up(app):
- from bandsaunter.livemap import SkyView
-
- view = SkyView(a_sky(a_blip()))
- view.resize(900, 650)
- view._box_want["A00001"] = (50, 20)
- spot = view._box_spot("A00001", 400, 300, (120, 60),
- [(450, 320, 120, 60)])
- assert spot != (450, 320)
- assert view._box_want["A00001"] == (spot[0] - 400, spot[1] - 300)
-
-
-@qt
-def test_the_settled_place_is_what_is_spoken_for(app):
- """Laying the next box out against one still in mid-swing would move
- that one too, and again when the first arrived; the screen would never
- settle. So the target is what goes into the taken list."""
- from bandsaunter.livemap import SkyView
-
- sky = a_sky(a_blip(icao="A00001", callsign="AAL1", lat=32.55, lon=-111.20))
- view = SkyView(sky)
- view.resize(900, 650)
- _rendered(view)
- was = view._box_at["A00001"]
- view._box_want["A00001"] = (was[0] + 200, was[1] + 100)
- view._glide_at = time.time() - 0.05
- _rendered(view)
- # Mid-swing, and the spot it is heading for is the one it holds.
- assert view._box_at["A00001"] != view._box_want["A00001"]
- assert view._box_spot("A00001", 0, 0, (10, 10), []) == \
- tuple(view._box_want["A00001"])
-
-
-@qt
-def test_the_placer_is_shown_the_settled_spot_not_the_gliding_one(app, monkeypatch):
- """The one that decides whether the screen settles. If a box in mid-
- swing is what the next box is laid out against, that next box moves
- too -- and moves back when the first one arrives."""
- from bandsaunter.livemap import SkyView
-
- early, late = now() - 900, now() - 800
- sky = a_sky(a_blip(icao="A00001", callsign="AAL1", lat=32.55, lon=-111.20,
- first_seen=early),
- a_blip(icao="B00002", callsign="AAL2", lat=32.25, lon=-110.70,
- first_seen=late))
- view = SkyView(sky)
- view.resize(900, 650)
- _rendered(view)
- was = view._box_at["A00001"]
- settled = view._box_want["A00001"]
- view._box_want["A00001"] = (was[0] + 220, was[1] + 130)
- # The second one is made to ask for a place, so we can see what it is
- # shown when it does.
- view._box_want.pop("B00002")
- seen = []
- real = livemap.place_box
- monkeypatch.setattr(livemap, "place_box",
- lambda *a: (seen.append(list(a[4])), real(*a))[1])
- view._glide_at = time.time() - 0.05
- _rendered(view)
- assert seen, "the second box never asked for a place"
- offered = [(r[0], r[1]) for r in seen[-1]]
- # The offsets are from the symbol, so they only mean anything once the
- # symbol is where the picture put it.
- ax, ay = view.projection().xy(32.55, -111.20)
- target = view._box_want["A00001"]
- drawn = view._box_at["A00001"]
- assert drawn != settled and drawn != (float(target[0]), float(target[1])), \
- "the first box was not in mid-swing, so this proves nothing"
- going = (ax + target[0], ay + target[1])
- got_to = (int(round(ax + drawn[0])), int(round(ay + drawn[1])))
- assert going != got_to, "the two places are the same; nothing is proved"
- assert going in offered, "the settled spot was not spoken for"
- assert got_to not in offered, "laid out against a box in mid-swing"
-
-
-@qt
-def test_a_box_pushed_off_its_spot_swings_rather_than_jumps(app):
- """Drawn between where it was and where it now belongs, for several
- frames, instead of being in the new place on the very next one."""
- from bandsaunter.livemap import SkyView
-
- sky = a_sky(a_blip(icao="A00001", callsign="AAL1", lat=32.55, lon=-111.20))
- view = SkyView(sky)
- view.resize(900, 650)
- _rendered(view)
- was = view._box_at["A00001"]
- # Push its settled spot well away, as a crowd of new aircraft would.
- view._box_want["A00001"] = (was[0] + 240, was[1] + 120)
- view._glide_at = time.time() - 0.05 # a frame's worth of clock
- _rendered(view)
- # Read the target back rather than assuming it: a spot near the edge is
- # clamped into the window, so the swing may be shorter than it was asked
- # to be.
- target = view._box_want["A00001"]
- moved = view._box_at["A00001"]
- assert target != was, "the test did not actually displace it"
- assert moved != was, "it did not move at all"
- assert moved != target, "it jumped the whole way in one frame"
- part = (moved[0] - was[0]) / (target[0] - was[0])
- assert 0.05 < part < 0.60, f"one frame carried it {part:.0%} of the way"
- assert view._box_moving is True
- # And it does get there, given the frames.
- for _ in range(30):
- view._glide_at = time.time() - 0.05
- _rendered(view)
- assert view._box_at["A00001"] == (float(target[0]), float(target[1]))
- assert view._box_moving is False, "still asking for frames after arriving"
-
-
-@qt
-def test_a_box_follows_its_aeroplane_without_that_counting_as_a_move(app):
- """The offset is what is remembered, so an aircraft crossing the window
- carries its box along instead of dragging it there a frame late."""
- from bandsaunter.livemap import SkyView
-
- sky = a_sky(a_blip(icao="A00001", callsign="AAL1", lat=32.50, lon=-111.10))
- view = SkyView(sky)
- view.resize(900, 650)
- _rendered(view)
- settled = view._box_at["A00001"]
- sky.update([a_blip(icao="A00001", callsign="AAL1",
- lat=32.70, lon=-110.70)], 1, 1)
- _rendered(view)
- assert view._box_at["A00001"] == settled # same offset, new place
- assert view._box_moving is False
-
-
-@qt
-def test_an_aircraft_that_goes_is_forgotten(app):
- """Otherwise one that comes back an hour later glides in from wherever
- it was standing then, across the whole window."""
- from bandsaunter.livemap import SkyView
-
- sky = a_sky(a_blip(icao="A00001", callsign="AAL1"))
- view = SkyView(sky)
- view.resize(900, 650)
- _rendered(view)
- assert "A00001" in view._box_at
- view.detail = 0 # symbols only: no boxes
- _rendered(view)
- assert view._box_at == {} and view._box_want == {}
-
-
-@qt
-def test_a_box_in_motion_is_drawn_over_the_ones_standing_still(app):
- """It crosses its neighbours for a moment on the way, and the one that
- is moving is the one being followed, so it is the one that has to stay
- readable while it does."""
- from bandsaunter.livemap import SkyView
-
- sky = a_sky(a_blip(icao="A00001", callsign="AAL1", lat=32.55, lon=-111.20,
- first_seen=now() - 900),
- a_blip(icao="B00002", callsign="AAL2", lat=32.25, lon=-110.70,
- first_seen=now() - 800))
- view = SkyView(sky)
- view.resize(900, 650)
- _rendered(view)
- order = []
- real = SkyView._paint_label
- def watch(self, painter, laid):
- order.append((laid[0], laid[1].icao))
- return real(self, painter, laid)
- SkyView._paint_label = watch
- try:
- was = view._box_at["A00001"]
- # Away from the other one, so that only this box is on the move.
- view._box_want["A00001"] = (was[0] - 160, was[1] + 150)
- view._glide_at = time.time() - 0.05
- _rendered(view)
- finally:
- SkyView._paint_label = real
- assert [icao for moving, icao in order if moving] == ["A00001"], \
- f"expected only the displaced box to be moving, got {order}"
- # Everything standing still goes down before anything that is moving.
- assert order == sorted(order, key=lambda seen: seen[0])
- assert order[-1][1] == "A00001"
-
-
-# ---------------------------------------------------------------------------
-# The ground is dimmed once, not every frame
-# ---------------------------------------------------------------------------
-
-def _with_ground(width=900, height=650, brightness=0.70):
- from bandsaunter.livemap import SkyView
-
- sky = a_sky(a_blip(), brightness=brightness)
- view = SkyView(sky)
- view.resize(width, height)
- projection = view.projection()
- levels = np.random.default_rng(7).integers(
- 0, 32, (int(height * 1.3), int(width * 1.3)), dtype=np.uint8)
- sky.set_ground(levels, view.ground_key(projection),
- view.ground_box(projection))
- return sky, view
-
-
-@qt
-def test_the_dimmed_map_is_kept_between_frames(app):
- """Cutting the view out, dimming it and looking every level up in the
- palette is most of a tenth of a second over two megapixels. Doing it
- again for a frame in which nothing about it changed put a ceiling of a
- dozen frames a second on the window."""
- sky, view = _with_ground()
- _rendered(view)
- made = view._ground_pixels
- assert made is not None
- _rendered(view)
- assert view._ground_pixels is made, "the map was dimmed all over again"
-
-
-@qt
-def test_a_new_map_is_dimmed_afresh(app):
- """Same window, same brightness, same view: only the map itself is
- different, which is what the count on it is for."""
- sky, view = _with_ground()
- _rendered(view)
- made = view._ground_pixels
- projection = view.projection()
- sky.set_ground(np.full_like(sky._ground, 31),
- view.ground_key(projection), view.ground_box(projection))
- _rendered(view)
- assert view._ground_pixels is not made
-
-
-@qt
-def test_turning_the_brightness_up_redraws_the_map(app):
- sky, view = _with_ground()
- _rendered(view)
- made = view._ground_pixels.copy()
- sky.brightness = 0.30
- _rendered(view)
- assert not np.array_equal(view._ground_pixels, made)
-
-
-@qt
-def test_resizing_the_window_redraws_the_map(app):
- sky, view = _with_ground()
- _rendered(view)
- _rendered(view, width=700, height=500)
- assert view._ground_pixels.shape[:2] == (500, 700)
-
-
-def test_the_window_box_says_what_sort_of_aircraft_it_is():
- """The same two facts the animation shows, so the two look like the
- same program: what it broadcast, and whether its address is military."""
- military = a_blip(icao="AE07D3", callsign="PRIME04")
- military.category = "heavy"
- rows = military.lines("knots")
- assert ("class", "military heavy", "") in rows
- plain = a_blip(icao="AC4C44", callsign="SWA2444")
- plain.category = "large"
- assert ("class", "large", "") in plain.lines("knots")
- quiet = a_blip(icao="AC4C44", callsign="SWA2444")
- assert not any(label == "class" for label, _v, _f in quiet.lines("knots"))
-
-
-def test_the_category_reaches_the_window_from_the_air():
- from bandsaunter.adsb import Aircraft
-
- craft = Aircraft(icao="AE07D3", callsign="PRIME04", latitude=32.5,
- longitude=-111.0, category="heavy")
- assert blip_for(craft).category == "heavy"
-
-
-# ---------------------------------------------------------------------------
-# The aerodromes under the window
-# ---------------------------------------------------------------------------
-
-TUCSON_AIRPORTS = [("KTUS", 32.116, -110.941), ("KDMA", 32.166, -110.883),
- ("KAVQ", 32.409, -111.218)]
-
-
-def test_a_sky_does_not_reach_for_airports_unless_it_is_asked_to():
- """A question to a network the first time an area is drawn, so the
- program turns it on and a library user has to say so on purpose."""
- assert a_sky().show_airports is False
- assert Sky(airports=True).show_airports is True
-
-
-def test_the_airports_come_back_when_what_was_fetched_covers_the_view():
- sky = a_sky()
- sky.set_airports(TUCSON_AIRPORTS, (31.0, -112.0, 33.0, -110.0))
- assert sky.airports_covering(31.5, -111.5, 32.5, -110.5) == TUCSON_AIRPORTS
-
-
-def test_a_view_outside_what_was_fetched_asks_again():
- """None, not an empty list: nothing has been asked about this piece of
- world, which is a different thing from having asked and found none."""
- sky = a_sky()
- sky.set_airports(TUCSON_AIRPORTS, (31.0, -112.0, 33.0, -110.0))
- assert sky.airports_covering(40.0, -112.0, 41.0, -110.0) is None
- assert a_sky().airports_covering(31.5, -111.5, 32.5, -110.5) is None
-
-
-def test_an_area_with_no_aerodromes_is_not_asked_about_all_night():
- """An empty answer is an answer. Kept as an empty list, so that the
- difference between "there are none" and "nobody has looked" survives."""
- sky = a_sky()
- sky.want_airports((31.0, -112.0, 33.0, -110.0))
- sky.set_airports([], (31.0, -112.0, 33.0, -110.0))
- assert sky.airports_covering(31.5, -111.5, 32.5, -110.5) == []
- assert sky.wanted_airports() is None
-
-
-def test_the_aerodromes_do_not_wait_behind_the_tiles():
- """The bug this is here for. They used to be fetched only in the same
- pass as a piece of map, so 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."""
- import threading
-
- from bandsaunter import basemap
-
- sky = a_sky()
- sky.show_airports = True
- sky.want_airports((32.0, -112.0, 33.0, -110.0))
- real = basemap.airports_in
- slow = threading.Event()
-
- def tiles(*a, **kw): # a map that never arrives
- slow.wait(10.0)
- return None
-
- basemap.airports_in = lambda s, w, n, e, **kw: [
- {"code": c, "latitude": la, "longitude": lo}
- for c, la, lo in TUCSON_AIRPORTS]
- try:
- # A map is asked for too, and the fetching of it hangs.
- sky.want_ground("k", (32.0, -112.0, 33.0, -110.0), (40, 40))
- worker = threading.Thread(
- target=livemap.fetch_ground, args=(sky,),
- kwargs={"fetch": tiles}, daemon=True)
- worker.start()
- for _ in range(60):
- if sky.airports_covering(32.0, -112.0, 33.0, -110.0):
- break
- time.sleep(0.05)
- got = sky.airports_covering(32.0, -112.0, 33.0, -110.0)
- slow.set()
- sky.stopping = True
- worker.join(timeout=3.0)
- finally:
- basemap.airports_in = real
- slow.set()
- assert got == TUCSON_AIRPORTS, "they waited for a map that never came"
-
-
-def test_the_airports_are_fetched_off_the_painting_thread():
- import threading
-
- from bandsaunter import basemap
-
- sky = a_sky()
- sky.show_airports = True
- sky.want_airports((32.0, -112.0, 33.0, -110.0))
- real = basemap.airports_in
- basemap.airports_in = lambda s, w, n, e, **kw: [
- {"code": c, "latitude": la, "longitude": lo}
- for c, la, lo in TUCSON_AIRPORTS]
- try:
- worker = threading.Thread(
- target=livemap.fetch_ground, args=(sky,),
- kwargs={"fetch": lambda z, x, y, **kw: None}, daemon=True)
- worker.start()
- for _ in range(60):
- if sky.airports_covering(32.0, -112.0, 33.0, -110.0):
- break
- time.sleep(0.05)
- sky.stopping = True
- worker.join(timeout=2.0)
- finally:
- basemap.airports_in = real
- assert sky.airports_covering(32.0, -112.0, 33.0, -110.0) == TUCSON_AIRPORTS
-
-
-def test_an_area_that_could_not_be_asked_about_is_not_asked_about_again():
- import threading
-
- from bandsaunter import basemap
-
- sky = a_sky()
- sky.show_airports = True
- sky.want_airports((32.0, -112.0, 33.0, -110.0))
- real = basemap.airports_in
- tries = []
-
- def broken(*a, **kw):
- tries.append(1)
- raise OSError("no network tonight")
-
- basemap.airports_in = broken
- try:
- worker = threading.Thread(
- target=livemap.fetch_ground, args=(sky,),
- kwargs={"fetch": lambda z, x, y, **kw: None}, daemon=True)
- worker.start()
- time.sleep(0.6)
- sky.stopping = True
- worker.join(timeout=2.0)
- finally:
- basemap.airports_in = real
- assert len(tries) == 1, f"asked {len(tries)} times after failing once"
- assert sky.airports_covering(32.0, -112.0, 33.0, -110.0) == []
-
-
-def test_no_airports_are_fetched_when_they_are_turned_off():
- import threading
-
- from bandsaunter import basemap
-
- sky = a_sky() # show_airports is False
- sky.want_airports((32.0, -112.0, 33.0, -110.0))
- real = basemap.airports_in
-
- def refuse(*a, **kw):
- raise AssertionError("asked for airports with them turned off")
-
- basemap.airports_in = refuse
- try:
- worker = threading.Thread(
- target=livemap.fetch_ground, args=(sky,),
- kwargs={"fetch": lambda z, x, y, **kw: None}, daemon=True)
- worker.start()
- time.sleep(0.5)
- sky.stopping = True
- worker.join(timeout=2.0)
- finally:
- basemap.airports_in = real
- assert sky.airports_covering(32.0, -112.0, 33.0, -110.0) is None
-
-
-def _airport_pixels(picture) -> int:
- """Pixels in the aerodrome colour, which nothing else on the window is."""
- from bandsaunter.flightmap import AIRPORT, PALETTE
-
- want = PALETTE[AIRPORT]
- return int(((picture[:, :, 2] == want[0]) & (picture[:, :, 1] == want[1])
- & (picture[:, :, 0] == want[2])).sum())
-
-
-@qt
-def test_the_window_marks_the_aerodromes_under_it(app):
- from bandsaunter.livemap import SkyView
-
- sky = a_sky(a_blip(), airports=True)
- view = SkyView(sky)
- view.show_ground = False
- bare = _rendered(view)
- assert _airport_pixels(bare) == 0, "something else is already that colour"
- projection = view.projection()
- sky.set_airports(TUCSON_AIRPORTS,
- (projection.south - 1, projection.west - 1,
- projection.north + 1, projection.east + 1))
- marked = _rendered(view)
- assert _airport_pixels(marked) > 60, "no aerodrome was drawn"
-
-
-@qt
-def test_the_window_draws_no_aerodromes_when_they_are_turned_off(app):
- from bandsaunter.livemap import SkyView
-
- sky = a_sky(a_blip()) # show_airports is False
- view = SkyView(sky)
- view.show_ground = False
- projection = view.projection()
- sky.set_airports(TUCSON_AIRPORTS,
- (projection.south - 1, projection.west - 1,
- projection.north + 1, projection.east + 1))
- assert _airport_pixels(_rendered(view)) == 0
-
-
-@qt
-def test_an_aerodrome_outside_the_view_is_nowhere_on_the_picture(app):
- """What is fetched covers rather more world than is shown, so on a
- zoomed-in view most of the county's airports are outside it.
-
- Projecting one gives coordinates off the widget and Qt clips them, so
- this holds whether or not they are skipped first; it is here to catch
- anyone who later clamps a position into the view instead, which would
- pile every airport in the county along the border of the picture.
- """
- from bandsaunter.livemap import SkyView
-
- sky = a_sky(a_blip(), airports=True)
- view = SkyView(sky)
- view.show_ground = False
- projection = view.projection()
- box = (projection.south - 5, projection.west - 5,
- projection.north + 5, projection.east + 5)
- sky.set_airports([("KJFK", projection.north + 3, projection.east + 3)], box)
- assert _airport_pixels(_rendered(view)) == 0
-
-
-# ---------------------------------------------------------------------------
-# The window follows the theme
-# ---------------------------------------------------------------------------
-
-@qt
-def test_the_window_draws_in_whatever_theme_is_set(app):
- """Both drawings read the same palette, so setting a theme changes the
- window as well as the animation and there is nothing to keep in step."""
- from bandsaunter import flightmap as fm
- from bandsaunter.livemap import SkyView
-
- def colours(theme):
- fm.set_theme(theme)
- sky = a_sky(a_blip(), airports=True)
- view = SkyView(sky)
- view.show_ground = False
- projection = view.projection()
- sky.set_airports([("KTUS", projection.south + 0.1,
- projection.west + 0.1)],
- (projection.south - 1, projection.west - 1,
- projection.north + 1, projection.east + 1))
- picture = _rendered(view)
- return {tuple(int(v) for v in rgb) for rgb in
- picture[:, :, [2, 1, 0]].reshape(-1, 3)}
-
- try:
- night = colours("night")
- green = colours("phosphor")
- finally:
- fm.set_theme("night")
- assert (255, 64, 200) in night, "the night aerodrome colour is missing"
- assert (255, 64, 200) not in green, "a magenta aerodrome on a green screen"
- assert (255, 184, 72) in green, "the phosphor aerodrome colour is missing"
-
-
-@qt
-def test_a_vector_theme_lays_a_halo_round_what_it_draws(app):
- """The window does its glow by laying the same line down two or three
- times, wider and fainter each pass. So a themed window paints more
- distinct colours than a plain one drawing the same aircraft."""
- from bandsaunter import flightmap as fm
- from bandsaunter.livemap import SkyView
-
- def shades(theme):
- fm.set_theme(theme)
- sky = a_sky(a_blip())
- view = SkyView(sky)
- view.show_ground = False
- picture = _rendered(view)
- return len({tuple(int(v) for v in rgb) for rgb in
- picture[:, :, [2, 1, 0]].reshape(-1, 3)})
-
- try:
- plain = shades("night")
- glowing = shades("phosphor")
- finally:
- fm.set_theme("night")
- assert glowing > plain, f"{glowing} shades is no more than {plain}"
-
-
-# ---------------------------------------------------------------------------
-# The leader line, and the flag on the receiver
-# ---------------------------------------------------------------------------
-
-def _leader_pixels(view) -> int:
- """How much of the picture the leader line accounts for.
-
- Counted by drawing the view twice, once with the leader colour set to
- the background, and taking the difference. Matching the colour itself
- finds almost nothing: the line is smoothed and drawn with an alpha, so
- hardly a pixel of it comes out the pure colour.
- """
- from bandsaunter.flightmap import BG, LEADER, PALETTE
-
- was = PALETTE[LEADER].copy()
- try:
- PALETTE[LEADER] = PALETTE[BG]
- hidden = _rendered(view)
- finally:
- PALETTE[LEADER] = was
- shown = _rendered(view)
- return int((hidden != shown).any(axis=2).sum())
-
-
-@qt
-def test_the_leader_is_not_drawn_in_the_aircrafts_own_colour(app):
- """Drawn in the aircraft's colour it came out the same colour as that
- aircraft's trail, and a straight line running out of an aeroplane in
- the colour of the path behind it reads as more path."""
- from bandsaunter.flightmap import LEADER, PALETTE, RAMP, RAMP_STEPS
- from bandsaunter.livemap import SkyView
-
- view = SkyView(a_sky(a_blip()))
- view.show_ground = False
- assert _leader_pixels(view) > 10, "no leader was drawn at all"
- leader = tuple(int(v) for v in PALETTE[LEADER])
- ramp = {tuple(int(v) for v in PALETTE[RAMP + i]) for i in range(RAMP_STEPS)}
- assert leader not in ramp
-
-
-@qt
-def test_the_leader_is_dashed_rather_than_solid(app):
- """The same line with the dashes taken out paints noticeably more of
- itself, which is what a dashed line is."""
- from bandsaunter import livemap as lm
- from bandsaunter.livemap import SkyView
-
- def drawn(pattern):
- was, lm.LEADER_DASH = lm.LEADER_DASH, pattern
- try:
- view = SkyView(a_sky(a_blip()))
- view.show_ground = False
- return _leader_pixels(view)
- finally:
- lm.LEADER_DASH = was
-
- dashed = drawn(lm.LEADER_DASH)
- solid = drawn(None)
- assert solid > 10, "the solid line drew nothing to compare against"
- assert dashed < solid * 0.85, \
- f"{dashed} pixels against {solid}: that is not a dashed line"
-
-
-def test_a_dash_pattern_is_scaled_by_the_width_of_the_pen_drawing_it():
- """Qt measures a dash pattern in multiples of the pen's own width. A
- glowing line is the same line drawn two or three times at different
- widths, so without this its halo would have dashes three times the
- length of its core and it would come out as beads."""
- from bandsaunter.livemap import dash_for
-
- for width in (1.0, 3.4, 5.8):
- pattern = dash_for((5.0, 4.0), width)
- assert [round(step * width, 6) for step in pattern] == [5.0, 4.0]
-
-
-def test_a_dash_pattern_never_asks_for_a_step_of_nothing():
- """Qt refuses a zero-length dash, and a pen can be asked for at any
- width the glow happens to want."""
- from bandsaunter.livemap import dash_for
-
- for width in (0.0, -3.0, 1e6):
- assert all(step > 0 for step in dash_for((5.0, 4.0), width))
-
-
-@qt
-def test_a_flag_stands_where_the_receiver_was_told_it_is(app):
- from bandsaunter.flightmap import HOME_RED
- from bandsaunter.livemap import SkyView
-
- def flag_pixels(view):
- picture = _rendered(view)
- return int(((picture[:, :, 2] == HOME_RED[0])
- & (picture[:, :, 1] == HOME_RED[1])
- & (picture[:, :, 0] == HOME_RED[2])).sum())
-
- placed = SkyView(a_sky(a_blip(), home=(32.4325, -111.0841)))
- placed.show_ground = False
- assert flag_pixels(placed) > 20, "no flag where the receiver is"
-
-
-@qt
-def test_no_flag_where_nobody_said_the_receiver_is(app):
- """The middle is otherwise worked out from whatever flew past, which is
- not a place anybody is standing."""
- from bandsaunter.flightmap import HOME_RED
- from bandsaunter.livemap import SkyView
-
- view = SkyView(a_sky(a_blip(), home=None))
- view.show_ground = False
- picture = _rendered(view)
- assert int(((picture[:, :, 2] == HOME_RED[0])
- & (picture[:, :, 1] == HOME_RED[1])
- & (picture[:, :, 0] == HOME_RED[2])).sum()) == 0
-
-
-def test_both_pictures_draw_the_leader_and_the_flag_the_same():
- """They are meant to look like the same program, and two copies of a
- number are two chances to change only one of them."""
- from bandsaunter import flightmap as fm
- from bandsaunter import livemap as lm
-
- assert tuple(lm.LEADER_DASH) == tuple(float(x) for x in fm.LEADER_DASH)
- # Both read the one palette, so there is no second colour to drift.
- assert tuple(fm.PALETTE[fm.LEADER]) == tuple(fm.PALETTE[fm.LEADER])
- assert tuple(fm.PALETTE[fm.HOME]) == fm.HOME_RED
- # And the one set of measurements for the flag itself.
- for name in ("HOME_POLE", "HOME_FLY", "HOME_DROP"):
- assert hasattr(fm, name)
-
-
-# ---------------------------------------------------------------------------
-# Range rings on the window
-# ---------------------------------------------------------------------------
-
-def _lifted_pixels(view) -> int:
- """How much of the window the rings account for, by drawing it with and
- without them. The tint is an alpha over whatever is underneath, so
- there is no one colour to count."""
- was = view.sky.show_rings
- try:
- view.sky.show_rings = False
- without = _rendered(view)
- view.sky.show_rings = True
- with_them = _rendered(view)
- finally:
- view.sky.show_rings = was
- return int((without != with_them).any(axis=2).sum())
-
-
-def test_a_sky_does_not_draw_rings_unless_it_is_asked_to():
- assert a_sky().show_rings is False
- assert Sky(rings=True).show_rings is True
-
-
-@qt
-def test_the_window_draws_the_range_rings(app):
- from bandsaunter.livemap import SkyView
-
- view = SkyView(a_sky(a_blip(), rings=True))
- view.show_ground = False
- assert _lifted_pixels(view) > 5000, "no rings were drawn"
-
-
-@qt
-def test_no_rings_without_a_position_or_without_a_radius(app):
- """They are measured from the radius and centred on the receiver, so
- they mean nothing without both."""
- from bandsaunter.livemap import SkyView
-
- nowhere = SkyView(a_sky(a_blip(), rings=True, home=None))
- nowhere.show_ground = False
- assert _lifted_pixels(nowhere) == 0
-
- flat = SkyView(a_sky(a_blip(), rings=True, radius_nm=0.0))
- flat.show_ground = False
- assert _lifted_pixels(flat) == 0
-
-
-def test_the_rings_are_labelled_with_how_far_out_they_are():
- """Both pictures ask the same function for the wording, so the window
- and the drawings cannot come to different numbers for the same ring."""
- from bandsaunter.flightmap import ring_labels
-
- assert ring_labels(100.0, "knots") == [(25.0, "25 nm"), (50.0, "50 nm"),
- (75.0, "75 nm")]
-
-
-def test_the_ring_labels_are_in_the_unit_the_rest_of_the_window_uses():
- """Miles an hour beside a ring measured in nautical miles would be two
- different miles on one picture."""
- from bandsaunter.flightmap import ring_labels
-
- assert [text for _nm, text in ring_labels(104.0, "mph")] == \
- ["30 mi", "60 mi", "90 mi"]
- assert [text for _nm, text in ring_labels(100.0, "kph")] == \
- ["46 km", "93 km", "139 km"]
-
-
-# ---------------------------------------------------------------------------
-# The card behind a box, and the flag over everything
-# ---------------------------------------------------------------------------
-
-@qt
-def test_the_card_behind_a_box_is_as_solid_as_it_was_asked_to_be(app):
- """The words beside an aircraft are readable over water and not over a
- city, so a card goes behind them; how much of the map it hides is the
- setting."""
- from bandsaunter.livemap import SkyView
-
- from bandsaunter.flightmap import PALETTE, PANEL
-
- want = PALETTE[PANEL]
-
- def solid(part):
- """Pixels that came out the panel colour exactly, which only a card
- drawn at full strength does."""
- view = SkyView(a_sky(a_blip(), box_opacity=part))
- view.show_ground = False
- picture = _rendered(view)
- return int(((picture[:, :, 2] == want[0])
- & (picture[:, :, 1] == want[1])
- & (picture[:, :, 0] == want[2])).sum())
-
- # Measured against the picture with no card at all: the header strip is
- # drawn in the panel colour too, and that is not a card.
- none = solid(0.0)
- assert solid(1.0) - none > 500, "no solid card was drawn"
- # Half way is neither one nor the other: the map shows through it, so
- # hardly a pixel comes out the panel colour exactly.
- assert solid(0.5) - none < (solid(1.0) - none) / 4
-
-
-@qt
-def test_a_box_with_no_card_lets_the_map_through(app):
- from bandsaunter.livemap import SkyView
-
- sky = a_sky(a_blip(), box_opacity=0.0)
- assert sky.box_opacity == 0.0
- view = SkyView(sky)
- view.show_ground = False
- assert _rendered(view) is not None # it draws, and does not fail
-
-
-def test_the_card_setting_is_kept_inside_its_range():
- assert Sky(box_opacity=-2.0).box_opacity == 0.0
- assert Sky(box_opacity=9.0).box_opacity == 1.0
-
-
-@qt
-def test_nothing_in_the_window_is_drawn_over_the_flag(app):
- """It says where the receiver is standing, which is the one thing on
- the picture that must never end up behind an aeroplane that happened to
- fly over it, or behind the box belonging to one."""
- from bandsaunter.flightmap import HOME_POLE, HOME_RED
- from bandsaunter.livemap import SkyView
-
- home = (32.4325, -111.0841)
- # An aircraft sitting exactly on the receiver, with a solid box.
- sky = a_sky(a_blip(lat=home[0], lon=home[1]), home=home, box_opacity=1.0)
- view = SkyView(sky)
- view.show_ground = False
- picture = _rendered(view)
- projection = view.projection()
- x, y = projection.xy(*home)
-
- def is_flag(px, py):
- pixel = picture[py, px]
- return (int(pixel[2]), int(pixel[1]), int(pixel[0])) == HOME_RED
-
- assert is_flag(x, y), "the foot of the pole was painted over"
- assert all(is_flag(x, y - up) for up in range(HOME_POLE)), \
- "part of the pole was painted over"
diff --git a/tests/test_manpage.py b/tests/test_manpage.py
index 8f44802..4970291 100644
--- a/tests/test_manpage.py
+++ b/tests/test_manpage.py
@@ -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"
diff --git a/tests/test_schedules.py b/tests/test_schedules.py
deleted file mode 100644
index a4cf693..0000000
--- a/tests/test_schedules.py
+++ /dev/null
@@ -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
diff --git a/tests/test_signals_named.py b/tests/test_signals_named.py
index 50e36e1..9a66217 100644
--- a/tests/test_signals_named.py
+++ b/tests/test_signals_named.py
@@ -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)
diff --git a/tests/test_themes.py b/tests/test_themes.py
deleted file mode 100644
index 62a0e7a..0000000
--- a/tests/test_themes.py
+++ /dev/null
@@ -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)