Compare commits

...

10 commits

Author SHA1 Message Date
The Dust Council
8ed01f991f Cover every option in the help, the manual and the readme, and say what to install
An audit rather than a feature, prompted by wanting this fit to hand to
somebody else.

Five options had no command-line flag written down in the table the manual
is generated from -- location, hold, schedules, tile_url and speed_unit --
so five flags that exist were missing from the manual.  Four of them did
exist under other names and are now recorded; hold had no flag at all and
has one.

Four switches could be turned off from the command line and not back on:
--no-lookup, --no-basemap, --no-airports and --no-labels had no positive
halves, so an option turned off in the saved settings could not be turned on
again for one run.  All four now have both.

And adsb, which opens the window and draws a map when it stops, could not be
given any of the settings that decide what those look like: no --at, no
--radius, no --tiles, no --map-brightness, no --width, --fps, --trail,
--fade, --stale, --airports or --labels.  It takes all of them now.

The manual had no list of the aircraft options at all -- the ADS-B sections
were hand-written prose -- so five of them appeared nowhere in it.  It now
generates an AIRCRAFT OPTIONS section from the same table the menu and the
flags come from, and the readme carries a table of all thirty-four with
their flags and defaults.  Three tests hold the three of them together: one
that every option records its flag, one that every flag the table claims
actually exists on a command, and one that the readme names them all.

The installing instructions now list every dependency rather than only the
optional ones: the four Python packages with their names in Debian, Fedora
and Arch, and librtlsdr, which is a C library and therefore the one thing
pip cannot bring and a virtual environment cannot supply.  What reaches a
network is written down too -- which host, when, and which file it is cached
in -- since somebody installing this on a metered or air-gapped machine has
to be able to see that nothing is fetched behind their back.  build-repo.sh
needs dpkg-dev and apt-utils, which a minimal system does not have, and now
says so.

Verified rather than asserted: a clean virtual environment, pip install from
this tree, and a real log read back through the installed command.  It pulls
seven wheels rather than the four the page claimed, the other three being
what Rich brings with it.

Also: matplotlib is gone from the readme's dependency table, nothing having
imported it; ffmpeg and Qt are in it, both having been missing; ffmpeg is a
Suggests on the package; and resume.sh is ignored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-09-06 14:12:33 -07:00
The Dust Council
6d2436cde1 Let the map brightness reach the map, and put the options in groups
The map brightness setting could not make the map visible on a vector
theme, which is the one place it was needed.  Those themes want 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 -- and that was
done by multiplying the setting by about a quarter.  A multiplier is a
ceiling: turned the whole way up, the setting still gave a map at a tenth
the brightness the default theme gives, which is to say invisible, and no
amount of turning it up did anything about that.

It is a curve now rather than a ceiling.  The theme raises the setting to a
power, so the middle of the range is still quiet -- seventy per cent lands
where the old quarter did, which is the look these themes are for -- and the
top of the range is a full-brightness map on every theme there is.  On the
green phosphor the setting now spans a luminance of six to seventy where it
used to stop at twenty-one.

And the options are in six groups rather than one list: receiver, listening,
aircraft, animation, the map, labels.  Thirty-three of them on one screen is
a wall rather than a menu.  A number opens a group and a number inside it
changes an option, with the numbers still being each option's place in the
whole list so that the same number means the same option wherever it is
typed -- which meant reordering the list so that every group is contiguous,
and there is a test that says so.

A group menu makes a known option harder to reach than a flat list did, so
the name works too: typing "map brightness" at the top goes straight to it,
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 happens to mention the word.

One thing to know: a bare number at the top of the menu now opens a group
where it used to edit the option of that number.  The tests that drove the
menu that way would have gone on silently editing whatever option shared the
number, so they ask by name now, and one of them checks that a group number
changes nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-09-06 12:26:00 -07:00
The Dust Council
2da1d1f245 Tell the boxes from the flight paths, and say how far out things are
Three changes to what is on the picture, and both drawings got all three.

The line from an information box to its aircraft was drawn in the
aircraft's own colour, which made it the same colour as that aircraft's
trail: a straight solid line running out of an aeroplane, in the colour of
the path behind the aeroplane, reads as more path, and on a busy picture
that is a heading nobody flew.  It has its own colour now, and is dashed.
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, which only came out when the two
were held up against each other: a label pushed into one of the outward
rings by a crowd had nothing tying it to the aeroplane it was about.  It has
one now, 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 and neither runs past the aircraft it points at.

A red flag stands where the receiver was told it is standing, from the
coordinates in the settings.  The foot of the pole is the position and the
pennant flies up and to the right, so nothing the flag is made of covers the
place it points at.  It is pure red in every theme: that is the one mark on
the picture 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 -- a softer one 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.  It is
drawn only where a position was actually given, since a middle worked out
from whatever flew past is not a place anybody is standing.

And the range rings: faint discs at a quarter, a half and three quarters of
the radius, concentric on the receiver and labelled with the distance.  They
are translucent and they stack, so the ground inside the innermost is lifted
three times and the outer once, which gives a sense of how far away a thing
is without measuring anything.  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.  Separate settings for the two, because a picture is studied and
a window is glanced at.

Two bugs found by the tests rather than by looking.  The rings were built
across the whole canvas instead of the part of it the map is drawn on, so
they came out stretched by the height of the title bar -- four per cent,
which is invisible and wrong.  And a radius of zero killed the window: the
projection collapses, every pixel maps to one point, and the graticule asks
for lines across a span of nothing until Qt aborts.  The program never
passes a zero, but a caller could, and a crash is not an answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-09-06 11:49:48 -07:00
The Dust Council
2e20c48971 Mark the aerodromes in the window, and draw the lot on a vector display
The aerodromes were never on the window at all: only the animation drew
them, and the window's map had whatever airport glyphs the tiles happened to
carry.  They are drawn there now, in the same colour and the same square.

The first attempt at that had a bug worth naming, because it would have
looked like the feature simply not working.  They were fetched inside the
same pass of the fetching loop 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, so they were never fetched again.  They are
their own question now, asked on the same thread but not behind the tiles,
and they arrive whether the tiles do or not.  An area that has been asked
about and has none in it is remembered as none, rather than asked about
again five times a second for the rest of the night.  And the thread now
starts if either the map or the aerodromes are wanted, so --no-basemap no
longer quietly takes the airports with it.

Then the themes, which change the window and the animated pictures together
because both read their colours out of the same palette.  night is what this
program has always drawn and is untouched.  digital, phosphor, amber and red
are the screens the phrase "air defence display" actually calls to mind: a
black tube, one phosphor, and thin bright vector lines with a halo round
them.

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, which is the trade those
displays made.  The map underneath drops to about a quarter 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.

The glow is done twice, differently, because the two are different kinds of
picture.  The window lays each line down two or three times, wider and
fainter each pass, with the core last: trails, symbols, leader lines, box
borders and the aerodrome squares.  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 -- and the aircraft colours
already had dimmed copies, since 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 get two rings each in
the palette for the purpose.  The halo goes over the map, the grid and the
background and over nothing else that was drawn, since a halo is what light
does to the dark around a line; where two rings meet the nearer wins.  It
costs about 55 ms a frame at 1400 by 1258 and the default theme skips the
pass entirely.

The palette is written over in place rather than replaced, because both
drawings and every one of their helpers hold a reference to that array and a
new one would leave half the program painting in the colours of the theme
before.  There is a test that every theme keeps the aerodrome colour more
than forty units of CIELAB from every altitude colour, stated as the
distance rather than as the colour, so that a new theme cannot quietly walk
an aircraft back into the airports.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-09-05 00:39:20 -07:00
The Dust Council
f9b21f7e35 Give the aerodromes a colour of their own, and say what each aircraft is
Four things about the animated pictures, and the window kept in step with
them.

The aerodromes were amber, and so is an aeroplane at twelve thousand feet.
Sixteen units of CIELAB apart is not two colours, it is one: an aircraft low
over a field was drawn in the field's own colour and neither could be picked
out from the other.  They are magenta now, fifty-six units from the nearest
altitude colour, which is what the ramp leaves free once red, amber, green,
cyan and violet have gone on height -- and what an aeronautical chart marks
an aerodrome in anyway.  The test states that as the distance rather than as
the colour, so that changing the ramp cannot quietly walk an aircraft back
into the airports.

The height beside an aircraft was the flight level, which is shorter and is
what an aviator reads, but "376" is only a height to somebody who already
knows it is one.  It is feet with the unit on it now, rounded to the
twenty-five feet Mode S reports altitude in: a real reading is a multiple of
that and comes through untouched, while a moment interpolated between two
reports stops claiming to know the height to the foot.

The flag of the country of registration now comes off the address block
where no register answered.  Taking it from the register's answer alone left
the flag off exactly the aircraft that had nothing else beside them either.
Mexico was missing from the address table while we were in there, which a
receiver in the American southwest notices; two registers independently give
XA- registrations for that block.

And what sort of aircraft it is, which is two facts and not one.  What it is
comes off the air: every identification message carries three bits under its
type code saying whether it is light, large, heavy, a rotorcraft, a glider,
a drone or a van on the apron, and that is the only word about what an
aircraft *is* that needs no register.  They were being decoded and thrown
away.  They are inside the identification frame the log already writes down
in full, so every log this program has ever written has them, including the
ones written before anything here knew to look.

Whether it is military comes off no air at all -- a tanker calls itself
heavy exactly as an airliner does -- and is read from the address block
instead.  On one evening here AE07D3 broadcast "heavy" and sat in the United
States military block; the register, asked separately, came back with a
C-17A Globemaster III, tail 90-0534, United States Air Force.

Labels in an animation now behave as the boxes in the window do.  One keeps
its place for as long as that place still works and is moved only when
something takes it, which on a real log is about half as many moves as
deciding afresh every frame; the moves that are left are eased over half a
second of playback; and what the next label is laid out against is where a
moving one is going rather than where it has reached.  A still has no frame
before it and places its labels exactly as it always did.

The fading was only half done.  A label's name faded, because it is drawn in
the aircraft's own colour, but its rows and its flag were fixed colours and
stayed at full brightness -- so the brightest thing on that part of the
picture was the one aeroplane nothing had been heard from.  An indexed
picture cannot blend, so the row grey and all twelve flag colours now have
dimmed copies at each of the four fade steps, and the whole box goes
together.  A crowded frame, which drops back to the short label, drops the
class and the flag with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-09-04 22:45:41 -07:00
The Dust Council
8eb3bdbb86 Ask a service that knows which leg it is, and fetch a map worth the screen
A callsign is a flight number rather than a leg, and the free registers hold
one route per number, so an aircraft over Arizona kept being handed a hop
between two airports in Texas.  Nothing on the air settles it: ADS-B carries
no origin or destination.  A commercial schedule service does know, because
it holds the day's actual movements.

Four are wired up and all four are optional: FlightAware AeroAPI,
Flightradar24, OAG and Cirium.  Each is asked before the free databases and
each answers for the moment the aircraft was overhead rather than for the
flight number in general, so the leg chosen is the one that was in the air.
With no keys set nothing changes at all: a source with no key is skipped
rather than asked and refused, and the free databases answer as before.

Keys come from the environment and are never written to 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.  There is a test that
holds that line.

None of the four has been run against its live service, since each wants a
paid account.  They were written from the published response shapes and are
tested against those shapes, so each reader finds what it recognises and
returns nothing otherwise: a service that has changed since costs a route
rather than a scan.  Cirium's plain departureTime is local and carries no
offset, so the UTC field is preferred where it is there -- reading the local
one as UTC is up to half a day out, which is exactly far enough to pick the
wrong leg of the same number.  Reading now happens inside the same guard as
asking, as an answer shaped differently from the documented one is the
failure most likely to actually happen.

And the map.  The zoom is now chosen from how wide the picture is rather
than from the area alone, with half again over the width fetched and
averaged down, since a downscaled tile is sharp and an upscaled one is not.
The window fetches a little more world than it shows so panning does not
leave the ground blank, and now fetches that bigger piece at the bigger
piece's own size: rendering it into the window's own pixels and stretching
it back was a fifth of an upscale over the whole map, which is what a sharp
map looks like when it looks blurred.  The comment in the fetcher said the
opposite of what the code did, which is how it stayed hidden.

At 1920 by 1080 over a hundred miles the tiles now hold about 1.6 times the
pixels the window wants.  At 3840 by 2160 the tile budget is reached, the
zoom stops climbing and the map is enlarged after all; a smaller radius buys
the detail back, and somebody else's tile server is not a thing to fetch a
thousand tiles from for one picture.  The README says so rather than
implying otherwise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-09-04 20:19:53 -07:00
The Dust Council
87b0954f0c A sharper map, and aircraft that fade rather than blink out
Three things asked for, and a fourth found while doing them.

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-09-04 19:04:09 -07:00
The Dust Council
df080f6571 A window on the sky, and flags on the routes
The terminal board says what is overhead.  This says where: a real map
with the aircraft moving on it as the frames arrive, and beside each one a
box carrying everything known about the flight -- type and registration,
who operates it, where it came from and where it is going, height with a
rate of climb, speed and heading, how far away and on what bearing, its
position, how many frames it has sent and how long since the last one.

Qt is asked for and not required.  Four bindings are tried, the module
imports on a machine with none of them, and asking for the window without
one gets the instructions rather than a traceback -- before the receiver
is opened, since nothing is gained by taking the dongle for a window that
cannot be drawn.

In the menu, "listen now" is now "passive capture" with a realtime
display beside it.  Closing the window leaves exactly the files pressing
control-C leaves, because listen and watch share one read loop and one
finishing step; the receiver runs on its own thread, so a slow repaint
cannot cost a frame and a slow tile fetch cannot stall the picture.

The animation's labels grew to match: flight level and speed, type and
registration, and both ends of the route, each with a small flag of the
country its airport is in.  The flags are a table rather than a network --
twelve pixels by eight, where a flag is the arrangement that makes one
recognisable rather than a rendering of the real thing -- and a country
not in the table is named by its two letters, since a flag that is nearly
another country's is worse than none.  Where a route arrives as bare
codes the country comes from the ICAO prefix.

Four things found on the way.  The window ignored --seconds, so "listen
for ten minutes" meant something different with a window open; it closes
itself now.  The register was being asked twice per aircraft, once for
labels and once for airport positions.  Cached routes had no country in
them, so the first real redraw drew no flags at all -- routes are
versioned now.  And past fourteen aircraft on one frame the labels go
back to the callsign, the height and the speed, because five lines beside
each of three hundred aircraft is a page of overlapping text with a map
somewhere behind it.

Long names are folded rather than allowed to stretch a box, breaking at
the arrow of a route so the two ends stay whole; and the animation's
label placement gained the same ring search the window uses, having only
ever tried four spots.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-09-04 13:38:47 -07:00
The Dust Council
e50d43d6e2 Frame the map on the receiver, and throw out what never happened
A first real capture came back as a map spanning 240 degrees north to 20
south, with the aircraft an indistinguishable smudge in one corner.  Two
separate faults, one of them mine from the start.

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.  The registry kept the last of each forever and paired them
regardless of age, so an even frame from ten minutes ago decoded against
a fresh odd one to a place on the wrong side of the world.  Measured: a
pair 300 seconds apart puts the aircraft 2,566 nm from where it is, and
one night's log had it happening to two aircraft in three, with eleven
positions off the planet altogether.  A pair is now good for ten seconds,
the answer has to be on Earth, and the aircraft has to have been able to
reach it.

--radius, defaulting to a hundred, frames the picture on the receiver
rather than on whatever was heard, so the scale is the same from one
evening to the next.  In the same unit as the speeds.  The centre is the
median of everything heard, which a handful of wrong positions cannot
move, or --at LAT,LON says where the aerial is.

--recheck repairs a log recorded before all this: for each aircraft it
keeps the longest run of positions that could describe one aeroplane.
Not a forward walk dropping whatever disagrees with the last position
kept -- that lets one bad fix become the reference, and on the same log
it discarded a fifth of everything, most of it the truth.

Two calibrations came from the recording rather than from taste.  The
failures separate cleanly -- a hundred artefacts under a mile, twelve
hundred real errors over fifty, and nothing in between -- because
positions are stamped to the millisecond, so two a thousandth of a second
apart imply thousands of knots across a few yards.  Nothing under two
miles is called an error.  Afterwards the worst surviving jump is 513 kt.

One rendering bug the tighter frame exposed: an aircraft just off the top
left painted 743,774 pixels of an 844,200-pixel picture, because the dot
at a marker's centre clamped its near edge and left the far one alone, and
numpy reads a negative slice end as counting back from the far side.  And
a still of a whole evening was dead-reckoning every aircraft forward to
the final moment, which for a ten-hour log flew 291 of 335 clean off the
picture; a still now draws each where it was last actually heard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-09-04 12:25:39 -07:00
The Dust Council
96fc21ac7d Aircraft, from the menus, on a live board, over a real map
Four things the ADS-B mode was missing, and one it was actively getting
wrong.

The band plan lists 1090 MHz because that is where ADS-B is, so choosing
it from the band plan is the obvious thing to do -- and it records the
bursts as clicks in a WAV file and decodes nothing, silently.  Both the
scanner and the menus now say so, before the sweep starts, and name the
mode that does decode it.  It is not refused: looking at the raw spectrum
is a fair thing to want.

Menu 5, Aircraft (ADS-B), is the whole mode without a command line.  Every
option on one screen with a line saying what it does, ?N for the long
version and the flag it corresponds to, l to listen, m to draw a map from
any log, s to keep the options.  The listening and the drawing moved into
bandsaunter/aircraft.py so the menus and the command line run the same
code.

While it listens the screen is a live board: one line per aircraft in the
order first heard, the counter climbing as frames arrive, height coloured
low warm to high cold with an arrow for climb or descent, the age of the
last report going green to red, and the line removed once nothing has been
heard for --hold seconds, everything below moving up.  The registers are
asked while it runs, so registration, type, operator and route fill
themselves in as the answers arrive.

--speed-unit knots|mph|kph changes the heading of that board, the speed
beside every aircraft on the map and the speeds in the report, and moves
the distances with it so that one picture never carries two different
miles.  The log stays in knots, which is what the aircraft broadcast.

And there is a real map under the flight paths: {z}/{x}/{y} tiles fetched
once, cached in ~/.cache/bandsaunter/tiles, reprojected from Web Mercator
pixel by pixel, inverted and dimmed so the aircraft stay the brightest
thing on the picture.  The PNGs are decoded here -- zlib and the five row
filters from the specification, checked byte for byte against Pillow on
real tiles -- so nothing new is depended on.  Tiles are cached and never
re-fetched, every request says who is asking, and the attribution is drawn
onto the picture, because a GIF travels without its readme.

conftest now fails any test that reaches for a tile server or a register.
It caught four of these on the way in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-09-04 00:05:32 -07:00
38 changed files with 15463 additions and 400 deletions

3
.gitignore vendored
View file

@ -16,3 +16,6 @@ recordings/
# local environments
.venv/
venv/
# a personal helper, not part of the program
resume.sh

View file

@ -59,6 +59,13 @@ 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
@ -80,8 +87,10 @@ python3 -m venv .venv
pip install . # a few wheels; 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.
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.
Use `pip install -e .` instead if you intend to change the code.
@ -123,26 +132,120 @@ if not.
---
## Optional dependencies
## Every dependency, in one table
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.
**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.
| Install | Gives you | Without it |
|---|---|---|
| `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 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 rtl-sdr` | `rtl_test`, `rtl_sdr` and friends, for diagnosing hardware | nothing missing from bandsaunter itself |
| `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 |
| `pip install faster-whisper` | speech transcription of recorded voice — see [the step-by-step below](#speech-transcription-step-by-step) (~250 MB installed, plus a 148 MB model) | transcription is off; scans report that no recogniser is installed |
| `pip install vosk` | a smaller, weaker recogniser (~10 MB plus a 40 MB model) | as above |
| `pip install openai-whisper` | the reference Whisper, slower and heavier than faster-whisper | as above |
| `pip install pocketsphinx` | a tiny recogniser, poor on radio audio | as above |
| whisper.cpp (`whisper-cli` on the PATH) | transcription with no Python dependencies at all | as above |
| `pip install pyte` | the terminal-resize tests | those tests skip |
| `pip install pytest` | running the test suite | you cannot run the tests |
`bandsaunter transcribe --list` says which recognisers it can actually see,
and which one it would use.
**Nothing is downloaded behind your back.** The things that reach a network do
so only when you ask, and each is cached on disk afterwards:
| What | Where from | When | Kept in |
|---|---|---|---|
| aircraft and route lookups | `api.adsbdb.com`, `hexdb.io` | while listening, unless `--no-lookup` | `~/.cache/bandsaunter/flights.json`, a month |
| amateur callsign lookups | `callook.info`, `api.hamdb.org` | when a scan hears a callsign, unless turned off | `~/.cache/bandsaunter/callsigns.json`, a month |
| map tiles | `tile.openstreetmap.org`, or `--tiles URL` | when a map is drawn, unless `--no-basemap` | `~/.cache/bandsaunter/tiles`, for ever |
| aerodrome positions | `overpass-api.de` | when a map is drawn, unless `--no-airports` | `~/.cache/bandsaunter/airports`, a month |
| flight schedules | the four paid services below | only if you have set a key | a month |
Every request identifies itself as `bandsaunter` and carries nothing but the
question — a callsign, a 24-bit address, or a box of the world. No identity,
no position, no key.
**The aircraft window needs Qt**, and any of four bindings will do — PyQt6,
PyQt5, PySide6 or PySide2 — because distributions disagree about which they
package. `python3-pyqt6` on Debian and Fedora, `python-pyqt6` on Arch, or
`pip install PyQt6` in the environment bandsaunter runs from. Without it the
window is the only thing missing, and the program says so rather than failing.
**Aircraft and callsign lookups need no installation**, only a network. They
ask public registers about a callsign or a 24-bit address and cache the
answers for a month; `--no-lookup` turns them off, and what the address and
the callsign say on their own is worked out offline either way.
### Optional: a paid schedule service
Nothing here needs installing either — these are accounts, not packages, and
all four are optional. They answer the one question the free registers cannot:
an airline runs the same flight number over several legs in a day, and a free
register holds one route per number, so it will often name somebody else's
leg. A schedule service holds the day's actual movements.
| Service | Sign up at | Set |
|---|---|---|
| FlightAware AeroAPI | <https://www.flightaware.com/commercial/aeroapi/> | `BANDSAUNTER_AEROAPI_KEY` |
| Flightradar24 | <https://fr24api.flightradar24.com/> | `BANDSAUNTER_FR24_TOKEN` |
| OAG Flight Info | <https://developer.oag.com/> | `BANDSAUNTER_OAG_KEY` |
| Cirium (FlightStats) | <https://developer.cirium.com/> | `BANDSAUNTER_CIRIUM_APP_ID` and `BANDSAUNTER_CIRIUM_APP_KEY` |
Put the ones you have in your shell profile:
```sh
echo 'export BANDSAUNTER_AEROAPI_KEY=your-key-here' >> ~/.bashrc
. ~/.bashrc
```
**Keys are read from the environment and never written to the settings file**,
on purpose: a settings file gets copied between machines and pasted into
messages asking for help, and an API key should not travel that way.
Any service whose key is set is asked; one whose key is not set is skipped
silently, and the free registers answer exactly as they did before.
`--schedules flightaware,oag` picks which to ask and in what order. Only the
callsign and the time are ever sent.
These readers were written from each service's published response format and
tested against it, but none has been run against a live service, because each
one needs a paid account. Each is written to return nothing rather than guess,
so a service that has changed its format costs you a route, not a scan.
---
## Speech transcription, step by step

681
README.md
View file

@ -111,9 +111,16 @@ 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 |
| *optional* | Matplotlib | `python3-matplotlib` | `python3-matplotlib` | `python-matplotlib` | nothing yet; reserved for plots |
**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.
Two notes on the optional ones:
@ -949,6 +956,229 @@ 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 realtime window](docs/realtime.png)
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.
@ -1014,6 +1244,29 @@ 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
@ -1029,6 +1282,46 @@ of an airline callsign are its ICAO designator, so `RYR1234` is Ryanair.
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
@ -1047,17 +1340,397 @@ 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.
@ -1739,6 +2412,12 @@ deleted.
## Built-in help
Menu **5, Aircraft (ADS-B)**, is the whole of the aircraft mode without a
command line: nineteen options on one screen, each with a line saying what it
does, `?N` for the long version with the flag it corresponds to, `l` to listen
and `m` to draw a map from any log in the recordings directory. `s` saves the
options to `~/.config/bandsaunter/aircraft.yaml`.
Press `h` in the menus for topics covering setup, how the sweep works, why
nothing (or too much) is being recorded, capturing conversations, where files
go, trunked systems, HF reception and the keys available during a scan. Typing a setting name

View file

@ -8,8 +8,8 @@ and transcribing speech.
# Versions are the release date and a revision within that day, so
# 2026-08-21_02 is the second build made on the 21st. The revision is padded
# to two digits so versions sort as text.
VERSION_DATE = "2026-09-03"
VERSION_REVISION = 5
VERSION_DATE = "2026-09-06"
VERSION_REVISION = 3
__version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}"

View file

@ -30,7 +30,8 @@ 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"]
"VirtualAircraft", "default_sky", "category_name",
"EMITTER_CATEGORIES"]
ADSB_HZ = 1_090_000_000.0
@ -38,6 +39,19 @@ 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
@ -55,6 +69,35 @@ 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.
@ -86,6 +129,7 @@ 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
@ -264,6 +308,10 @@ 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"
@ -379,10 +427,12 @@ 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:
@ -426,6 +476,8 @@ 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:
@ -438,19 +490,73 @@ class AircraftRegistry:
seen._odd = frame
else:
seen._even = frame
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
self._place(seen)
return seen
def _place(self, seen: Aircraft) -> None:
"""Work out where an aircraft is, from the last even and odd frames.
The pair has to be recent, and the answer has to be reachable. Both
checks are the difference between a map and a scatter of nonsense:
an unpaired frame kept from ten minutes ago decodes against a fresh
one to a position on the wrong side of the world, because compact
position reporting sends a fraction of a zone and the two fractions
are then read as though the aircraft had not moved between them.
Measured against one night's recording, that produced positions up
to seven thousand miles out, on two aircraft in three.
"""
even, odd = seen._even, seen._odd
if even is None or odd is None:
return
if abs(even.received_at - odd.received_at) > CPR_PAIR_SECONDS:
return
# Whichever of the pair arrived later is the one the position is
# reported at. Compared by arrival rather than by sample offset: the
# offset restarts at zero every block, so a pair that straddles two
# blocks would otherwise be read backwards and put the aircraft in
# the wrong zone.
even_first = (even.received_at, even.at_sample) > \
(odd.received_at, odd.at_sample)
found = global_position(even, odd, even_first)
if found is None or not _on_earth(*found):
return
when = max(even.received_at, odd.received_at)
if not seen.located or self._reachable(seen, found, when):
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)]

1099
bandsaunter/aircraft.py Normal file

File diff suppressed because it is too large Load diff

538
bandsaunter/basemap.py Normal file
View file

@ -0,0 +1,538 @@
"""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)

View file

@ -7,7 +7,6 @@ import json
import signal
import sys
import time
from datetime import datetime
from pathlib import Path
from rich.console import Console
@ -23,8 +22,7 @@ from .bandplan import CATEGORIES, PRESETS, fmt_hz, in_category, search
from .config import (DEFAULT_CONFIG_DIR, DEFAULT_CONFIG_PATH, ScanConfig,
is_first_run, list_profiles, load_config, load_default,
save_config, save_default)
from .device import (RtlSdrDevice, RtlSdrError, list_devices,
set_driver_messages)
from .device import RtlSdrError, list_devices, set_driver_messages
from .librtlsdr import load_error
from . import settings as st
from .ranges import (RangeError, ScanRange, build_plan, parse_range_list)
@ -57,7 +55,9 @@ 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,8 +174,15 @@ examples:
"(default: adsb_<time>.jsonl in the output directory)")
ad.add_argument("--no-log", dest="log_frames", action="store_false",
help="listen without writing anything down")
ad.add_argument("--lookup", dest="lookup",
action="store_true", default=None,
help="ask the public registers who each aircraft is")
ad.add_argument("--no-lookup", dest="lookup", action="store_false",
help="do not ask the registers who the aircraft are")
ad.add_argument("--schedules", default=None, metavar="NAMES",
help="which schedule services to ask, comma separated: "
"flightaware, flightradar24, oag, cirium "
"(each needs a key in the environment)")
ad.add_argument("--kml", nargs="?", const="", default=None, metavar="FILE",
help="also write the flight paths for Google Earth")
ad.add_argument("--map", nargs="?", const="", default=None, metavar="FILE",
@ -184,6 +191,82 @@ 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 --------------------------------------------------------------
@ -207,16 +290,78 @@ 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 ------------------------------------------------------------
@ -338,6 +483,33 @@ def _maybe_first_run(cfg: ScanConfig, args) -> None:
console.print()
def _warn_about_aircraft_bands(cfg: ScanConfig) -> None:
"""Say so when a sweep is pointed at something it cannot decode.
The band plan lists 1090 MHz because that is where ADS-B is, so choosing
it from the band plan is the obvious thing to do and the wrong one. The
sweep is not stopped -- looking at the spectrum there is a fair thing to
want -- but it no longer happens silently.
"""
from . import aircraft as air
warning = air.scanning_aircraft_band(cfg.ranges)
if not warning:
return
console.print(Panel(
Text.from_markup(
f"{escape(warning)}\n\n"
"[bold]bandsaunter adsb[/bold] decodes it properly: aircraft, "
"positions, altitudes and speeds, written to a log.\n"
"[bold]bandsaunter flights[/bold] then draws where they went.\n\n"
"[grey62]Both are in the menus as well, under Aircraft "
"(ADS-B). Scanning it anyway is fine if what you want is the "
"raw spectrum \u2014 add --save-iq to keep the samples."
"[/grey62]"),
title="[yellow]this band needs the aircraft mode",
border_style="yellow", padding=(0, 1)))
def _make_device(cfg: ScanConfig, simulate: bool):
if simulate:
from .simulator import SimulatedDevice
@ -394,6 +566,8 @@ def cmd_scan(args) -> int:
console.print(f"[red]{e}[/red]")
return 2
_warn_about_aircraft_bands(cfg)
if args.dry_run:
_print_plan(cfg)
return 0
@ -991,15 +1165,11 @@ def cmd_adsb(args) -> int:
and a half kilohertz wide before anything sees it, and a megabit will not
go through that.
Everything heard goes into a log as it arrives, because an aircraft is
overhead for four minutes and then gone: the summary on the screen is for
the person watching, and the log is for everything afterwards -- the
report, the map and the animation.
The listening itself is in :mod:`bandsaunter.aircraft`, because the menus
do exactly the same thing and neither front end should own it.
"""
from .adsb import (ADSB_HZ, AircraftRegistry, SAMPLE_RATE, SimulatedSky,
decode_frames, default_sky)
from .flightlog import FlightLog, read_logs, report, write_kml
from .flights import FlightBook
from .adsb import SAMPLE_RATE
from . import aircraft as air
cfg, _ = load_default()
if args.rate < SAMPLE_RATE:
@ -1007,216 +1177,61 @@ def cmd_adsb(args) -> int:
f"{args.rate/1e6:g} is not enough to see a bit.[/red]")
return 2
if args.simulate:
sky = default_sky(*_near(args.near)) if args.near else default_sky()
device = SimulatedSky(sky, sample_rate=args.rate,
realtime=True).open()
console.print("[yellow]simulated: these aircraft are not there."
"[/yellow]")
else:
try:
device = RtlSdrDevice(index=args.device, sample_rate=int(args.rate),
gain=args.gain, agc=args.gain == "auto")
device.open()
except RtlSdrError as exc:
console.print(Panel(Text(str(exc)),
title="[red]cannot open the receiver",
border_style="red"))
options = air.load_options()
options.seconds = args.seconds
options.rate = args.rate
options.gain = args.gain
options.device = args.device
options.frames = args.frames
options.log = args.log_frames
options.lookup = args.lookup
options.simulate = args.simulate
options.kml = args.kml is not None
options.draw_after = args.map is not None
if args.near:
options.near = args.near
if args.speed_unit:
options.speed_unit = args.speed_unit
if args.basemap is not None:
options.basemap = args.basemap
if args.schedules is not None:
options.schedules = args.schedules
if getattr(args, "theme", None):
options.theme = args.theme
if getattr(args, "box_opacity", None) is not None:
options.box_opacity = args.box_opacity
if getattr(args, "rings", None) is not None:
options.rings = args.rings
if getattr(args, "window_rings", None) is not None:
options.window_rings = args.window_rings
for flag, key in (("at", "location"), ("radius", "radius"),
("hold", "hold"), ("tiles", "tile_url"),
("map_brightness", "map_brightness"),
("width", "width"), ("fps", "fps"), ("trail", "trail"),
("fade", "fade"), ("stale", "stale"),
("airports", "airports"), ("labels", "labels"),
("basemap", "basemap"), ("lookup", "lookup")):
value = getattr(args, flag, None)
if value is not None:
setattr(options, key, value)
if args.map:
options.picture = Path(args.map).suffix.lstrip(".") or options.picture
run = air.watch if args.window else air.listen
heard = run(console, options, cfg.output_dir, log_path=args.log)
if not heard.aircraft:
return 1
started = time.time()
log = None
if args.log_frames:
stamp = datetime.fromtimestamp(started).strftime("%Y-%m-%d_%H_%M_%S")
where = Path(args.log).expanduser() if args.log else \
Path(cfg.output_dir).expanduser() / f"adsb_{stamp}.jsonl"
try:
log = FlightLog(where, frequency=ADSB_HZ, sample_rate=args.rate,
receiver="simulated" if args.simulate else
f"device {args.device}", started=started)
except OSError as exc:
console.print(f"[red]cannot write {where}: {exc}[/red]")
log = None
registry = AircraftRegistry()
total = 0
console.print(f"[grey62]listening on {ADSB_HZ/1e6:g} MHz at "
f"{args.rate/1e6:g} MS/s — control-C to stop[/grey62]")
if log is not None:
console.print(f"[grey62]writing {log.path}[/grey62]")
try:
device.tune(ADSB_HZ)
block = int(args.rate) # a second at a time
while True:
at = time.time()
samples = device.read_samples(block)
if samples is None or samples.size == 0:
break
for frame in decode_frames(samples, args.rate):
# The real time the frame arrived, not its offset in the
# block: everything downstream is a clock, and a log that
# started again from zero every second would be unusable.
when = at + frame.at_sample / args.rate
craft = registry.add(frame, when=when)
total += 1
if log is not None:
log.append(frame, craft, when=when)
if args.frames:
console.print(f"[cyan]{frame.icao}[/cyan] "
f"{escape(frame.describe())}",
highlight=False)
if not args.frames and total:
console.print(f"[grey62]{len(registry)} aircraft, "
f"{total} frames[/grey62]", highlight=False)
if args.seconds and time.time() - started >= args.seconds:
break
except KeyboardInterrupt:
pass
finally:
device.close()
if log is not None:
log.close()
if not registry:
console.print("[yellow]nothing heard. ADS-B needs an aerial cut for "
"1090 MHz; the whip that came with the dongle will "
"hear the airport and not much else.[/yellow]")
return 1
book = FlightBook(online=args.lookup)
_aircraft_table(registry, book, total)
if args.lookup:
book.wait(12.0)
book.save()
_lookup_table(registry, book)
tracks = read_logs(log.path) if log is not None else _tracks_from(registry)
if args.kml is not None:
where = Path(args.kml).expanduser() if args.kml else \
(log.path.with_suffix(".kml") if log is not None
else Path("aircraft.kml"))
written = write_kml(where, tracks, book if args.lookup else None)
console.print(f"[green]{written}[/green]" if written
else "[yellow]nothing was placed on the map[/yellow]")
if log is not None:
told = log.path.with_suffix(".txt")
try:
told.write_text("\n".join(report(
tracks, book if args.lookup else None,
title=f"bandsaunter — aircraft heard "
f"{datetime.fromtimestamp(started):%Y-%m-%d %H:%M}")))
console.print(f"[green]{told}[/green]")
except OSError as exc:
console.print(f"[red]cannot write {told}: {exc}[/red]")
if args.map is not None:
_draw_flights(tracks, args.map or (log.path.with_suffix(".gif")
if log is not None
else Path("aircraft.gif")),
book if args.lookup else None)
elif log is not None:
console.print(f"[grey62]draw it: bandsaunter flights {log.path}"
if args.kml and heard.kml_path is None:
# An explicit path was given, so honour it rather than the one beside
# the log that `listen` writes by default.
from .flightlog import write_kml
write_kml(Path(args.kml).expanduser(), heard.tracks)
if heard.log_path is not None and not options.draw_after:
console.print(f"[grey62]draw it: bandsaunter flights {heard.log_path}"
"[/grey62]")
return 0
def _near(text: str) -> tuple[float, float]:
"""Read a LAT,LON pair, falling back to the default sky."""
try:
lat, lon = (float(x) for x in str(text).split(",", 1))
return lat, lon
except (TypeError, ValueError):
console.print(f"[yellow]cannot read {text!r} as a latitude and "
"longitude; flying somewhere else instead[/yellow]")
return 47.55, -122.30
def _tracks_from(registry):
"""Tracks from a registry, for a session that wrote no log.
One position each: what is on the screen is all there is, because nothing
kept the ones before it.
"""
from .flightlog import Fix, Track
out = []
for craft in sorted(registry.aircraft.values(), key=lambda a: a.icao):
track = Track(icao=craft.icao, callsign=craft.callsign,
frames=craft.messages, first_seen=craft.first_seen,
last_seen=craft.last_seen)
if craft.located:
track.fixes.append(Fix(at=craft.last_seen, latitude=craft.latitude,
longitude=craft.longitude,
altitude_ft=craft.altitude_ft,
ground_speed_kt=craft.ground_speed_kt,
track_deg=craft.track_deg,
vertical_rate_fpm=craft.vertical_rate_fpm))
out.append(track)
return out
def _aircraft_table(registry, book, total: int) -> None:
"""What was heard, as it was heard: no register, only the air."""
t = Table(title=f"{len(registry)} aircraft, {total} frames", box=None,
header_style="bold")
for column in ("ICAO", "callsign", "altitude", "position", "speed",
"frames"):
t.add_column(column)
for craft in sorted(registry.aircraft.values(), key=lambda a: a.icao):
t.add_row(craft.icao, craft.callsign or "",
f"{craft.altitude_ft:,} ft" if craft.altitude_ft else "",
(f"{craft.latitude:.4f}, {craft.longitude:.4f}"
if craft.located else ""),
(f"{craft.ground_speed_kt:.0f} kt {craft.track_deg:.0f}°"
if craft.ground_speed_kt else ""),
str(craft.messages))
console.print(t)
def _lookup_table(registry, book) -> None:
"""And what the registers say about them, kept separate on purpose."""
rows = []
for craft in sorted(registry.aircraft.values(), key=lambda a: a.icao):
entry = book.get(craft.icao, craft.callsign)
told = entry.summary()
if told or entry.country:
rows.append((craft.icao, craft.callsign or "", entry.country,
told or "—"))
if not rows:
return
t = Table(title="what the registers say", box=None, header_style="bold")
for column in ("ICAO", "callsign", "registered", "aircraft, operator, route"):
t.add_column(column, overflow="fold")
for row in rows:
t.add_row(*row)
console.print(t)
def _draw_flights(tracks, out_path, book, **over) -> bool:
"""Draw the animation, saying what it is drawing and what came out."""
from .flightmap import animate, ffmpeg_available
out_path = Path(out_path).expanduser()
if out_path.suffix.lower() in (".mp4", ".mov", ".m4v") and \
not ffmpeg_available():
console.print("[yellow]ffmpeg is not installed; writing a GIF "
"instead[/yellow]")
out_path = out_path.with_suffix(".gif")
console.print(f"[grey62]drawing {out_path.name}…[/grey62]")
try:
drawn = animate(tracks, out_path, book=book, **over)
except (OSError, RuntimeError, ValueError) as exc:
console.print(f"[red]{exc}[/red]")
return False
if drawn is None:
console.print("[yellow]nothing was placed on the map: no aircraft "
"reported a position[/yellow]")
return False
size = drawn.path.stat().st_size / 1e6
console.print(f"[green]{drawn.path}[/green] "
f"[grey62]{drawn.summary()}, {size:.1f} MB[/grey62]")
return True
def cmd_flights(args) -> int:
"""Turn a log of ADS-B frames into something worth looking at.
@ -1224,12 +1239,13 @@ def cmd_flights(args) -> int:
back, asks who the aircraft were, prints what it found and draws the
whole evening as a map with the clock running.
"""
from . import aircraft as air
from .flightlog import read_logs, report, write_kml
from .flights import FlightBook
cfg, _ = load_default()
paths = [Path(p).expanduser() for p in args.path] if args.path \
else _newest_log(Path(cfg.output_dir).expanduser())
else air.logs_in(cfg.output_dir)[:1]
if not paths:
console.print("[yellow]no ADS-B logs found. Record one with "
"`bandsaunter adsb`.[/yellow]")
@ -1243,15 +1259,37 @@ def cmd_flights(args) -> int:
if not tracks:
console.print(f"[yellow]{paths[0].name} holds no frames[/yellow]")
return 1
book = FlightBook(online=args.lookup)
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))
if args.lookup:
for track in tracks:
book.get(track.icao, track.callsign)
# 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.wait(20.0)
book.save()
title = f"bandsaunter — {paths[0].name}"
lines = report(tracks, book if args.lookup else None, title=title)
lines = report(tracks, book if args.lookup else None, title=title,
unit=options.speed_unit)
console.print(escape("\n".join(lines)), highlight=False)
if args.report is not None:
where = Path(args.report).expanduser() if args.report \
@ -1264,31 +1302,43 @@ def cmd_flights(args) -> int:
if args.kml is not None:
where = Path(args.kml).expanduser() if args.kml \
else paths[0].with_suffix(".kml")
written = write_kml(where, tracks, book if args.lookup else None)
written = write_kml(where, tracks, book if args.lookup else None,
unit=options.speed_unit)
console.print(f"[green]{written}[/green]" if written
else "[yellow]nothing was placed on the map[/yellow]")
if not args.draw:
return 0
options.fps = args.fps
options.length = args.seconds
options.speed = args.speed
options.width = args.width
options.trail = args.trail
options.stale = args.stale
options.labels = args.labels
if args.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(".gif")
drawn = _draw_flights(tracks, out, book if args.lookup else None,
fps=args.fps, seconds=args.seconds, speed=args.speed,
width=args.width, trail_seconds=args.trail,
stale=args.stale, labels=args.labels)
paths[0].with_suffix("." + options.picture)
drawn = air.draw(console, options, tracks, out,
book if args.lookup else None)
return 0 if drawn else 1
def _newest_log(directory: Path) -> list[Path]:
"""The last ADS-B log written, which is nearly always the one wanted."""
try:
logs = sorted(directory.glob("adsb_*.jsonl"),
key=lambda p: p.stat().st_mtime)
except OSError:
return []
return logs[-1:]
def cmd_analyze(args) -> int:
import numpy as np
from .classify import classify

320
bandsaunter/flags.py Normal file
View file

@ -0,0 +1,320 @@
"""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 ""

View file

@ -21,12 +21,17 @@ from __future__ import annotations
import json
import math
import time
from dataclasses import dataclass, field
from statistics import median
from dataclasses import dataclass, field, replace
from datetime import datetime
from pathlib import Path
__all__ = ["Fix", "Track", "FlightLog", "read_logs", "report",
"write_kml", "LOG_VERSION", "EARTH_NM"]
"write_kml", "LOG_VERSION", "EARTH_NM", "SPEED_UNITS",
"speed_label", "in_speed", "distance_label", "in_distance",
"DEFAULT_SPEED_UNIT", "centre_of", "read_position",
"box_around", "within", "recheck", "implied_speed_kt",
"MAX_GROUND_SPEED_KT"]
LOG_VERSION = 1
@ -34,6 +39,48 @@ LOG_VERSION = 1
# every other number in this file is already in.
EARTH_NM = 3440.065
# What the aircraft says is knots and nautical miles, because that is what
# the standard sends and what the log therefore holds. Anything else is a
# conversion done at the moment of showing it to somebody, so the recorded
# data is never the one that had arithmetic applied to it.
#
# name: (speed label, knots -> this, distance label, nautical miles -> this)
SPEED_UNITS: dict[str, tuple[str, float, str, float]] = {
"knots": ("kt", 1.0, "nm", 1.0),
"mph": ("mph", 1.150779, "mi", 1.150779),
"kph": ("km/h", 1.852, "km", 1.852),
}
DEFAULT_SPEED_UNIT = "knots"
def speed_unit(name: str) -> tuple[str, float, str, float]:
"""The conversion for a unit name, falling back to what aircraft use."""
return SPEED_UNITS.get((name or "").strip().lower(),
SPEED_UNITS[DEFAULT_SPEED_UNIT])
def speed_label(name: str = DEFAULT_SPEED_UNIT) -> str:
"""What to write after a speed: kt, mph or km/h."""
return speed_unit(name)[0]
def in_speed(knots: float, name: str = DEFAULT_SPEED_UNIT) -> float:
"""A speed in knots, as the unit asked for."""
return float(knots) * speed_unit(name)[1]
def distance_label(name: str = DEFAULT_SPEED_UNIT) -> str:
"""The distance unit that goes with a speed unit: nm, mi or km.
Miles an hour with distances in nautical miles would be two different
miles on one picture, which is worse than either on its own.
"""
return speed_unit(name)[2]
def in_distance(nm: float, name: str = DEFAULT_SPEED_UNIT) -> float:
return float(nm) * speed_unit(name)[3]
@dataclass
class Fix:
@ -95,6 +142,175 @@ 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."""
@ -106,6 +322,14 @@ 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:
@ -191,7 +415,7 @@ class Track:
out.append(now)
return out
def describe(self) -> str:
def describe(self, unit: str = DEFAULT_SPEED_UNIT) -> str:
"""One line: who, where from and to, how far and how high."""
bits = [self.icao]
if self.callsign:
@ -200,12 +424,14 @@ class Track:
first, last = self.fixes[0], self.fixes[-1]
bits.append(f"{first.latitude:.3f},{first.longitude:.3f} → "
f"{last.latitude:.3f},{last.longitude:.3f}")
bits.append(f"{self.distance_nm:.0f} nm")
bits.append(f"{in_distance(self.distance_nm, unit):.0f} "
f"{distance_label(unit)}")
low, high = self.altitude_range
if high:
bits.append(f"{low}–{high} ft" if low != high else f"{high} ft")
if self.top_speed_kt:
bits.append(f"{self.top_speed_kt:.0f} kt")
bits.append(f"{in_speed(self.top_speed_kt, unit):.0f} "
f"{speed_label(unit)}")
bits.append(f"{self.frames} frames")
return " ".join(bits)
@ -343,6 +569,13 @@ 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.
@ -379,6 +612,22 @@ 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:
@ -438,7 +687,8 @@ def _carry_forward(track: Track) -> None:
# The readable report
# ---------------------------------------------------------------------------
def report(tracks: list[Track], book=None, title: str = "") -> list[str]:
def report(tracks: list[Track], book=None, title: str = "",
unit: str = DEFAULT_SPEED_UNIT) -> list[str]:
"""One block per aircraft, in the order they were first heard.
``book`` is a FlightBook, or None. Everything it can add is printed under
@ -460,6 +710,9 @@ def report(tracks: list[Track], book=None, title: str = "") -> list[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
@ -469,8 +722,13 @@ def report(tracks: list[Track], book=None, title: str = "") -> list[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}")
out.append(f" route: {entry.route}{_doubtful(entry, track)}")
out.append(f" heard: {_clock(track.first_seen)} to "
f"{_clock(track.last_seen)} "
f"({_span(track.seconds)}, {track.frames} frames)")
@ -478,18 +736,40 @@ def report(tracks: list[Track], book=None, title: str = "") -> list[str]:
first, last = track.fixes[0], track.fixes[-1]
out.append(f" from: {first.latitude:.4f}, {first.longitude:.4f}")
out.append(f" to: {last.latitude:.4f}, {last.longitude:.4f}")
out.append(f" flew: {track.distance_nm:.1f} nm over "
out.append(f" flew: {in_distance(track.distance_nm, unit):.1f} "
f"{distance_label(unit)} over "
f"{len(track.fixes)} positions")
low, high = track.altitude_range
if high:
out.append(f" altitude: {low:,} to {high:,} ft"
if low != high else f" altitude: {high:,} ft")
if track.top_speed_kt:
out.append(f" speed: up to {track.top_speed_kt:.0f} kt")
out.append(f" speed: up to "
f"{in_speed(track.top_speed_kt, unit):.0f} "
f"{speed_label(unit)}")
out.append("")
return out
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 "—"
@ -510,7 +790,8 @@ def _span(seconds: float) -> str:
# ---------------------------------------------------------------------------
def write_kml(path, tracks: list[Track], book=None,
title: str = "bandsaunter — aircraft heard") -> Path | None:
title: str = "bandsaunter — aircraft heard",
unit: str = DEFAULT_SPEED_UNIT) -> Path | None:
"""Every track as a line on the globe, with a pin where it was last seen."""
from xml.sax.saxutils import escape as xml_escape
@ -524,7 +805,7 @@ def write_kml(path, tracks: list[Track], book=None,
"<width>2</width></LineStyle></Style>"]
for track in located:
entry = book.get(track.icao, track.callsign) if book is not None else None
told = "\n".join([track.describe()]
told = "\n".join([track.describe(unit)]
+ (entry.details() if entry is not None else []))
name = f"{track.icao} {track.callsign}".strip()
line = " ".join(f"{f.longitude:.6f},{f.latitude:.6f},"

File diff suppressed because it is too large Load diff

View file

@ -27,20 +27,26 @@ import threading
import time
import urllib.parse
import urllib.request
from dataclasses import dataclass
from dataclasses import dataclass, replace
from pathlib import Path
__all__ = ["Flight", "FlightBook", "describe_address", "airline_of",
"AIRCRAFT_URL", "ROUTE_URL", "CACHE_VERSION"]
"route_fits", "ROUTE_SLACK_NM",
"AIRCRAFT_URL", "ROUTE_URL", "CACHE_VERSION",
"MILITARY_BLOCKS", "is_military", "aircraft_class"]
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.
CACHE_VERSION = 1
# 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
# ---------------------------------------------------------------------------
@ -188,6 +194,12 @@ 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"),
@ -328,14 +340,24 @@ 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
@ -389,6 +411,75 @@ 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"
@ -413,7 +504,9 @@ 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):
backup_route_url: str = BACKUP_ROUTE_URL,
airport_url: str = AIRPORT_URL,
schedules=None):
self.online = online
self.timeout = timeout
self.max_age = max_age
@ -421,10 +514,18 @@ 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
@ -450,9 +551,16 @@ class FlightBook:
entry.version >= CACHE_VERSION and \
now - entry.fetched_at < self.max_age:
self._entries[key] = entry
for key, body in (raw.get("routes") or {}).items():
for key, body in (raw.get("airports") or {}).items():
if isinstance(body, dict) and \
now - float(body.get("fetched_at") or 0) < self.max_age:
self._airports[key] = body
for key, body in (raw.get("routes") or {}).items():
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:
@ -464,6 +572,7 @@ 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:
@ -475,7 +584,8 @@ class FlightBook:
pass
# -- lookup -----------------------------------------------------------
def get(self, icao: str, callsign: str = "") -> Flight:
def get(self, icao: str, callsign: str = "",
when: float | None = None) -> 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
@ -503,14 +613,60 @@ class FlightBook:
if not self.online:
return entry
if fresh or wanted_route:
thread = threading.Thread(target=self._fetch,
args=(entry, fresh, wanted_route),
thread = threading.Thread(
target=self._fetch,
args=(entry, fresh, wanted_route,
time.time() if when is None else when),
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]
@ -523,7 +679,8 @@ 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) -> None:
def _fetch(self, entry: Flight, airframe: bool, route: bool,
when: float | None = None) -> None:
reached = False
if airframe:
for url, apply in ((self.aircraft_url, self._apply_aircraft),
@ -551,7 +708,12 @@ class FlightBook:
self._dirty = True
if route and entry.callsign:
found = None
# 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)
for url, read in ((self.route_url, self._read_route),
(self.backup_route_url, self._read_backup_route)):
if not url or found:
@ -564,10 +726,21 @@ 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.
@ -606,14 +779,16 @@ class FlightBook:
entry.operator = str(body.get("RegisteredOwners") or "").strip()
@staticmethod
def _airport(record) -> tuple[str, str, float, float]:
"""An airport as a code, a readable place and where it is.
def _airport(record) -> tuple[str, str, float, float, str]:
"""An airport as a code, a readable place, where it is and whose.
The position is kept because a map can draw it: a route is two dots
and a line long before any aircraft is heard flying it.
and a line long before any aircraft is heard flying it. The country
is kept so a flag can be drawn beside the name, which says where a
flight came from faster than reading the name does.
"""
if not isinstance(record, dict):
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()
@ -625,7 +800,12 @@ class FlightBook:
lon = float(record.get("longitude") or 0.0)
except (TypeError, ValueError):
lat = lon = 0.0
return code.strip(), (where or code).strip(), lat, lon
country = str(record.get("country_iso_name") or "").strip().upper()
if not country:
from .flags import country_of_icao
country = country_of_icao(code)
return code.strip(), (where or code).strip(), lat, lon, country
@classmethod
def _read_route(cls, body: dict) -> dict | None:
@ -639,33 +819,103 @@ 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."""
"""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.
"""
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
# 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": ""}
# 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))
@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 "")

View file

@ -167,7 +167,11 @@ 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"),
"Q": ("01110", "10001", "10001", "10001", "10101", "10010", "01101"),
# 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"),
"R": ("11110", "10001", "10001", "11110", "10100", "10010", "10001"),
"S": ("01111", "10000", "10000", "01110", "00001", "00001", "11110"),
"T": ("11111", "00100", "00100", "00100", "00100", "00100", "00100"),

1586
bandsaunter/livemap.py Normal file

File diff suppressed because it is too large Load diff

454
bandsaunter/schedules.py Normal file
View file

@ -0,0 +1,454 @@
"""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

248
bandsaunter/themes.py Normal file
View file

@ -0,0 +1,248 @@
"""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]

View file

@ -24,7 +24,7 @@ from .config import (DEFAULT_CONFIG_DIR, DEFAULT_CONFIG_PATH,
from .ranges import RangeError, ScanRange, parse_frequency
__all__ = ["run_tui", "show_ranges", "settings_menu", "help_screen",
"first_run_setup", "TUIAbort"]
"aircraft_menu", "first_run_setup", "TUIAbort"]
_BACK = ("", "b", "back", "q", "quit", "x")
@ -192,6 +192,31 @@ def _pick_from(console: Console, cfg: ScanConfig,
console.print(" [yellow]some of these are below 24 MHz — they need "
"direct sampling and an HF antenna.[/yellow]")
console.print(f" [green]added {added} range(s)[/green]")
warn_about_aircraft_bands(console, cfg)
def warn_about_aircraft_bands(console: Console, cfg: ScanConfig) -> None:
"""Say so the moment a band that needs the aircraft mode is chosen.
The band plan lists 1090 MHz because that is where ADS-B is, so picking
it here is the obvious thing to do and the wrong one. It is not refused
-- looking at the spectrum there is a fair thing to want -- but it does
not happen silently, and the menu that does decode it is named.
"""
from . import aircraft as air
warning = air.scanning_aircraft_band(cfg.ranges)
if not warning:
return
console.print(Panel(Text.from_markup(
f"{warning}\n\n"
"Menu [cyan]5[/cyan], [bold]Aircraft (ADS-B)[/bold], listens to it "
"properly and draws where the aircraft went.\n\n"
"[grey62]Scanning it anyway is fine if what you want is the raw "
"spectrum \u2014 turn on 'Save raw IQ' to keep the samples."
"[/grey62]"),
title="[yellow]this band needs the aircraft mode",
border_style="yellow", padding=(0, 1)))
def _expand(answer: str, items: list) -> list:
@ -277,8 +302,8 @@ def _settings_table(console: Console, group: str, cfg: ScanConfig) -> list[st.Se
def setting_help(console: Console, setting: st.Setting,
cfg: ScanConfig) -> None:
default = ScanConfig()
cfg: ScanConfig, default=None) -> None:
default = ScanConfig() if default is None else default
body = [f"[bold]{setting.label}[/bold] [grey62]({setting.key})[/grey62]",
"", setting.help.capitalize() + "."]
if setting.detail:
@ -302,10 +327,17 @@ def setting_help(console: Console, setting: st.Setting,
border_style="blue", padding=(0, 1)))
def edit_setting(console: Console, setting: st.Setting, cfg: ScanConfig) -> bool:
"""Prompt for one value. Returns True if it changed."""
def edit_setting(console: Console, setting: st.Setting, cfg: ScanConfig,
default=None, show_help=None) -> bool:
"""Prompt for one value. Returns True if it changed.
``cfg`` is whatever holds the value -- the scan config, or the aircraft
options, which are described by the same kind of table and so can be
edited by the same code. ``default`` is the object the built-in defaults
come from, and ``show_help`` the panel to print above the prompt.
"""
current = getattr(cfg, setting.key)
setting_help(console, setting, cfg)
(show_help or setting_help)(console, setting, cfg)
hint = "yes/no" if setting.kind == "bool" else (
"/".join(setting.choices) if setting.choices else
(setting.example or setting.metavar or "value"))
@ -317,7 +349,8 @@ def edit_setting(console: Console, setting: st.Setting, cfg: ScanConfig) -> bool
if raw.strip() == "" or raw == st.format_value(setting, current):
return False
if raw.strip().lower() in ("d", "default"):
value = getattr(ScanConfig(), setting.key)
value = getattr(default if default is not None else ScanConfig(),
setting.key)
else:
try:
value = st.parse_value(setting, raw)
@ -634,6 +667,343 @@ 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")
@ -750,6 +1120,8 @@ def _main_loop(console: Console, cfg: ScanConfig) -> ScanConfig | None:
f" [cyan]3[/cyan] Settings "
f"[grey62]{_summary(cfg)}[/grey62]\n"
f" [cyan]4[/cyan] Saved settings and profiles\n"
f" [cyan]5[/cyan] Aircraft (ADS-B) "
f"[grey62]listen on 1090 MHz, draw where they went[/grey62]\n"
f" [cyan]h[/cyan] Help\n"
f" [cyan]s[/cyan] [bold green]Start scanning[/bold green]\n"
f" [cyan]q[/cyan] Quit\n")
@ -763,6 +1135,8 @@ def _main_loop(console: Console, cfg: ScanConfig) -> ScanConfig | None:
settings_menu(console, cfg)
elif choice == "4":
cfg = profiles_menu(console, cfg)
elif choice == "5":
aircraft_menu(console, cfg)
elif choice in ("h", "?", "help"):
help_screen(console)
elif choice in ("s", "start", "go"):

View file

@ -18,10 +18,12 @@ from rich.table import Table
from rich.text import Text
from .bandplan import band_label, fmt_hz, shorten_band
from .flightlog import in_speed, speed_label
from .recorder import HitRecord
from .scanner import Detection, Scanner
__all__ = ["ScanDisplay", "KeyReader", "print_hit", "print_band_table"]
__all__ = ["ScanDisplay", "AircraftDisplay", "KeyReader", "print_hit",
"print_band_table"]
_SPARK = " ▁▂▃▄▅▆▇█"
@ -518,6 +520,218 @@ class ScanDisplay:
return Group(*parts)
# ---------------------------------------------------------------------------
# Aircraft, while they are overhead
# ---------------------------------------------------------------------------
# Altitude in colour, low warm to high cold: the same convention the map uses,
# so a height can be read off either without a key.
_ALTITUDE_BANDS = ((1_500, "bright_red"), (5_000, "red"), (10_000, "dark_orange"),
(18_000, "yellow"), (24_000, "green"),
(30_000, "bright_cyan"), (36_000, "cyan"),
(99_000, "bright_blue"))
# How fresh a report is, in colour. Past the last of these the aircraft is
# about to be dropped from the display.
_AGE_STYLES = ((5.0, "green"), (15.0, "yellow"), (1e9, "red"))
_COMPASS = ("N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
"S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW")
def altitude_style(feet: float) -> str:
"""The colour a height is drawn in."""
for limit, style in _ALTITUDE_BANDS:
if feet < limit:
return style
return _ALTITUDE_BANDS[-1][1]
def age_style(seconds: float) -> str:
for limit, style in _AGE_STYLES:
if seconds < limit:
return style
return "red"
def compass(degrees: float) -> str:
"""A heading as a point of the compass, which is easier to read at a glance."""
return _COMPASS[int((degrees % 360.0) / 22.5 + 0.5) % 16]
class AircraftDisplay:
"""One line per aircraft, updated in place while listening.
An aircraft appears when its first frame arrives, its counter climbs as
more come in, and it disappears once nothing has been heard from it for
``hold`` seconds -- at which point everything below moves up. The order
is the order they were first heard, so a row does not jump about under
the eye while it is being read.
Everything the frames say is here; everything a register says is here as
it arrives, on a second line under the aircraft it belongs to, because
the two are different kinds of knowledge and should not be mistaken for
each other.
"""
def __init__(self, console: Console, book=None, hold: float = 45.0,
title: str = "", unit: str = "knots"):
self.console = console
self.book = book
self.hold = hold
self.title = title
# Aircraft broadcast knots; this is only what they are shown in.
self.unit = unit
self.frames = 0
self.started = time.time()
self.log_path = None
self.registry = None
self.gone = 0
# -- what the listener tells it ---------------------------------------
def update(self, registry, frames: int, log_path=None) -> None:
self.registry = registry
self.frames = frames
if log_path is not None:
self.log_path = log_path
def showing(self, now: float | None = None) -> list:
"""The aircraft still worth drawing, oldest first heard at the top."""
if self.registry is None:
return []
now = time.time() if now is None else now
alive = [craft for craft in self.registry.aircraft.values()
if now - craft.last_seen <= self.hold]
return sorted(alive, key=lambda c: (c.first_seen, c.icao))
# -- drawing ----------------------------------------------------------
def render(self, now: float | None = None, width: int | None = None):
"""The whole display.
``width`` is the terminal it is going to be drawn on; it decides how
many columns there is room for, and defaults to the console this
display was built with, which is the one the live view uses.
"""
now = time.time() if now is None else now
width = self.console.size.width if width is None else width
flying = self.showing(now)
parts = [self._header(flying, now)]
if flying:
parts.append(self._table(flying, now, width))
else:
parts.append(Panel(_one_line(
"[grey62]nothing heard yet — ADS-B needs an aerial cut for "
"1090 MHz[/grey62]"), border_style="grey37", padding=(0, 1)))
return Group(*parts)
def _header(self, flying, now: float) -> Panel:
elapsed = max(0.001, now - self.started)
heard = len(self.registry.aircraft) if self.registry is not None else 0
rate = self.frames / elapsed
where = f" [grey62]{self.log_path.name}[/grey62]" if self.log_path \
else ""
return Panel(_one_line(
f"[bold cyan]1090 MHz[/bold cyan] "
f"[bold]{len(flying)}[/bold] overhead "
f"[grey62]{heard} seen[/grey62] "
f"[bold]{self.frames}[/bold] frames "
f"[grey62]{rate:.0f}/s {_dur(elapsed)}[/grey62]{where} "
f"[grey62]control-C to stop[/grey62]"),
border_style="blue", padding=(0, 1))
def _table(self, flying, now: float, width: int) -> Table:
"""As many columns as the terminal has room for, widest first.
A narrow terminal keeps what only the aircraft can say -- who, how
high, how fast, how many frames -- and drops what a website said,
because that can be read afterwards and the aeroplane cannot.
"""
t = Table(box=None, header_style="bold", pad_edge=False, expand=False)
t.add_column("callsign", width=9, no_wrap=True)
t.add_column("ICAO", width=6, style="grey62", no_wrap=True)
if width >= 96:
t.add_column("aircraft", width=17, overflow="ellipsis",
no_wrap=True)
t.add_column("altitude", width=10, justify="right", no_wrap=True)
# Wide enough for the heading it is given: "speed km/h" is longer
# than "speed kt", and a truncated unit is a wrong unit.
speeds = f"speed {speed_label(self.unit)}"
t.add_column(speeds, width=max(9, len(speeds)), justify="right",
no_wrap=True)
t.add_column("track", width=8, no_wrap=True)
if width >= 114:
t.add_column("position", width=19, no_wrap=True)
t.add_column("frames", width=6, justify="right", no_wrap=True)
t.add_column("last", width=5, justify="right", no_wrap=True)
if width >= 132:
t.add_column("operator / route", overflow="ellipsis", no_wrap=True,
style="grey58")
for craft in flying:
self._row(t, craft, now, width)
return t
def _row(self, t: Table, craft, now: float, width: int) -> None:
entry = self.book.get(craft.icao, craft.callsign) \
if self.book is not None else None
age = max(0.0, now - craft.last_seen)
cells = [Text(craft.callsign or "—",
style="bold white" if craft.callsign else "grey62"),
Text(craft.icao)]
if width >= 96:
cells.append(Text(self._kind(entry), style="grey62"))
cells += [self._altitude(craft),
Text(f"{in_speed(craft.ground_speed_kt, self.unit):.0f}"
if craft.ground_speed_kt else "", style="white"),
self._track(craft)]
if width >= 114:
cells.append(Text(f"{craft.latitude:8.4f},{craft.longitude:9.4f}"
if craft.located else " no fix yet",
style="cyan" if craft.located else "grey37"))
cells.append(Text(f"{craft.messages}", style="bold"))
cells.append(Text(f"{age:.0f}s", style=age_style(age)))
if width >= 132:
cells.append(Text(self._told(entry)))
t.add_row(*cells)
@staticmethod
def _kind(entry) -> str:
"""The airframe in as few characters as say anything: type and mark."""
if entry is None:
return ""
if entry.type_code and entry.registration:
return f"{entry.type_code} {entry.registration}"
return entry.type_code or entry.registration or ""
@staticmethod
def _altitude(craft) -> Text:
if not craft.altitude_ft:
return Text("")
arrow = ""
if craft.vertical_rate_fpm > 100:
arrow = "[green]↑[/green]"
elif craft.vertical_rate_fpm < -100:
arrow = "[red]↓[/red]"
style = altitude_style(craft.altitude_ft)
return Text.from_markup(
f"[{style}]{craft.altitude_ft:,}[/{style}]{arrow}")
@staticmethod
def _track(craft) -> Text:
if not craft.ground_speed_kt and not craft.track_deg:
return Text("")
return Text(f"{craft.track_deg:03.0f}° {compass(craft.track_deg)}",
style="grey62")
@staticmethod
def _told(entry) -> str:
"""What a register says about this aircraft, if anything yet."""
if entry is None:
return ""
bits = [x for x in (entry.operator or entry.airline, entry.route,
entry.country) if x]
return " · ".join(bits)
def _dur(seconds: float) -> str:
seconds = int(seconds)
h, rem = divmod(seconds, 3600)

BIN
docs/realtime.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

View file

@ -1,5 +1,5 @@
.\" Generated by packaging/make-man.py -- do not edit by hand.
.TH BANDSAUNTER 1 "2026-09-03" "bandsaunter 2026-09-03_05" "User Commands"
.TH BANDSAUNTER 1 "2026-09-06" "bandsaunter 2026-09-06_03" "User Commands"
.SH NAME
bandsaunter \- scan, record and identify radio signals with an RTL-SDR
.SH SYNOPSIS
@ -1285,6 +1285,115 @@ 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
@ -1296,6 +1405,54 @@ 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
@ -1324,6 +1481,520 @@ 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.
@ -1398,9 +2069,17 @@ frequency to dial into a radio.
.I ~/.config/bandsaunter/config.yaml
The settings every run starts from.
.TP
.I ~/.config/bandsaunter/aircraft.yaml
The aircraft options, as saved from the menus.
.TP
.I ~/.config/bandsaunter/*.yaml
Named profiles.
.TP
.IR adsb_ * .jsonl
Every ADS-B frame heard in one listening session, with a
.I .txt
report and any picture drawn from it beside it.
.TP
.I ~/bandsaunter/
Where recordings, transcripts and logs are written, unless
.B \-\-output

View file

@ -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
Suggests: rtl-sdr, python3-pyqt6, ffmpeg
Maintainer: bandsaunter
Installed-Size: $(du -ks "$pkgdir" | cut -f1)
Description: signal scanner and recorder for RTL-SDR receivers

View file

@ -56,6 +56,43 @@ 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
@ -678,6 +715,115 @@ 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
@ -689,6 +835,54 @@ 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
@ -717,6 +911,241 @@ 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.
@ -791,9 +1220,17 @@ frequency to dial into a radio.
.I ~/.config/bandsaunter/config.yaml
The settings every run starts from.
.TP
.I ~/.config/bandsaunter/aircraft.yaml
The aircraft options, as saved from the menus.
.TP
.I ~/.config/bandsaunter/*.yaml
Named profiles.
.TP
.IR adsb_ * .jsonl
Every ADS-B frame heard in one listening session, with a
.I .txt
report and any picture drawn from it beside it.
.TP
.I ~/bandsaunter/
Where recordings, transcripts and logs are written, unless
.B \-\-output
@ -903,6 +1340,8 @@ 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")

View file

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

View file

@ -22,6 +22,9 @@ 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.

View file

@ -45,6 +45,43 @@ def no_licence_lookups(monkeypatch):
monkeypatch.setattr(bandsaunter.callsign.CallsignBook, "_request", refuse)
class NoNetwork(BaseException):
"""Raised when a test reaches for the network.
Deliberately not an ``Exception``: the code that fetches map tiles and
looks aircraft up treats any ordinary failure as "no map today" and
carries on, which would turn this guard into a silent pass.
"""
@pytest.fixture(autouse=True)
def no_aircraft_lookups(monkeypatch):
"""Nor may a test ask a register who an aircraft is."""
import bandsaunter.flights
def refuse(self, url):
raise NoNetwork(f"a test tried to fetch {url} for real")
monkeypatch.setattr(bandsaunter.flights.FlightBook, "_request", refuse)
@pytest.fixture(autouse=True)
def no_map_tiles(monkeypatch):
"""Nor fetch map tiles from somebody's tile server.
A test that wants a map builds its own tiles and passes them in; one
that forgets fails here rather than drawing an evening's worth of
requests at a volunteer-funded service.
"""
import bandsaunter.basemap
def refuse(request, timeout=None):
where = getattr(request, "full_url", request)
raise NoNetwork(f"a test tried to fetch {where} for real")
monkeypatch.setattr(bandsaunter.basemap.urllib.request, "urlopen", refuse)
@pytest.fixture(autouse=True)
def isolated_settings(tmp_path_factory, monkeypatch):
where = tmp_path_factory.mktemp("config")

View file

@ -0,0 +1,322 @@
"""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"

594
tests/test_aircraft_menu.py Normal file
View file

@ -0,0 +1,594 @@
"""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"

679
tests/test_basemap.py Normal file
View file

@ -0,0 +1,679 @@
"""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()

139
tests/test_flags.py Normal file
View file

@ -0,0 +1,139 @@
"""The little flags, and where a country comes from.
A flag at twelve pixels by eight is not a rendering of the real thing, so
what is checked here is what it has to get right to be worth drawing: the
right shape of arrangement, the right colours, and never a flag that belongs
to somebody else.
"""
import numpy as np
import pytest
from bandsaunter import flags
def test_every_flag_is_the_size_it_says_it_is():
for code, rows in flags.FLAGS.items():
assert len(rows) == flags.FLAG_H, code
assert all(len(row) == flags.FLAG_W for row in rows), code
def test_every_flag_uses_colours_that_exist():
"""A typo in a flag would otherwise come out as silent white."""
used = {letter for rows in flags.FLAGS.values()
for row in rows for letter in row}
assert used <= set(flags.COLOURS), used - set(flags.COLOURS)
def test_the_colours_are_a_fixed_order():
"""An index into the animation's palette has to mean one colour for the
life of the file it is written into."""
assert flags.COLOUR_ORDER == tuple(flags.COLOURS)
assert len(flags.COLOUR_ORDER) == len(set(flags.COLOUR_ORDER))
def test_a_country_with_no_flag_here_gets_none_rather_than_a_wrong_one():
assert flags.flag_for("ZZ") is None
assert flags.pixels_for("ZZ") is None
assert flags.known("ZZ") is False
assert flags.known("us") is True # case does not matter
def test_a_flag_comes_back_as_pixels():
picture = flags.pixels_for("JP")
assert picture.shape == (flags.FLAG_H, flags.FLAG_W, 3)
assert picture.dtype == np.uint8
# White at the corner, red in the middle: that is the flag of Japan.
assert tuple(picture[0, 0]) == flags.COLOURS["w"]
assert tuple(picture[4, 6]) == flags.COLOURS["r"]
@pytest.mark.parametrize("code,hoist,middle,fly", [
("FR", "b", "w", "r"), # blue at the hoist, white, red at the fly
("IT", "g", "w", "r"),
("IE", "g", "w", "o"),
("BE", "k", "y", "r"),
])
def test_a_vertical_tricolour_runs_left_to_right(code, hoist, middle, fly):
picture = flags.pixels_for(code)
assert tuple(picture[4, 0]) == flags.COLOURS[hoist]
assert tuple(picture[4, 6]) == flags.COLOURS[middle]
assert tuple(picture[4, 11]) == flags.COLOURS[fly]
@pytest.mark.parametrize("code,top,middle,bottom", [
("DE", "k", "r", "y"), # black over red over gold
("NL", "r", "w", "b"),
("RU", "w", "b", "r"),
("HU", "r", "w", "g"),
])
def test_a_horizontal_tricolour_runs_top_to_bottom(code, top, middle, bottom):
picture = flags.pixels_for(code)
assert tuple(picture[0, 6]) == flags.COLOURS[top]
assert tuple(picture[4, 6]) == flags.COLOURS[middle]
assert tuple(picture[7, 6]) == flags.COLOURS[bottom]
def test_the_two_ways_round_are_not_the_same_flag():
"""Ireland and Italy are the same three colours in the same order; the
Netherlands and Russia are the same three the other way up."""
assert flags.flag_for("IE") != flags.flag_for("IT")
assert flags.flag_for("NL") != flags.flag_for("RU")
def test_a_nordic_cross_is_off_towards_the_hoist():
"""It is what makes those five flags recognisable at any size."""
rows = flags.flag_for("SE")
upright = [x for x in range(flags.FLAG_W) if rows[0][x] == "y"]
assert upright, "no cross at all"
assert max(upright) < flags.FLAG_W // 2 + 2, upright
def test_the_two_countries_most_likely_to_be_confused_are_not():
"""The United States and Malaysia really do look alike; they must at
least differ here."""
assert flags.flag_for("US") != flags.flag_for("MY")
def test_the_flags_a_receiver_actually_needs_are_all_here():
for code in ("US", "CA", "MX", "GB", "IE", "FR", "DE", "NL", "ES", "IT",
"PT", "CH", "AT", "DK", "NO", "SE", "FI", "PL", "RU", "TR",
"GR", "JP", "CN", "KR", "IN", "AU", "NZ", "BR", "AR", "ZA",
"AE", "QA", "SA", "IL", "EG", "SG", "TH", "PH"):
assert flags.known(code), code
# ---------------------------------------------------------------------------
# Which country an airport is in
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("code,country", [
("KSEA", "US"), ("KATL", "US"), ("CYYZ", "CA"), ("EGLL", "GB"),
("EIDW", "IE"), ("LFPG", "FR"), ("EDDF", "DE"), ("EHAM", "NL"),
("LEMD", "ES"), ("LIRF", "IT"), ("RJTT", "JP"), ("ZBAA", "CN"),
("YSSY", "AU"), ("NZAA", "NZ"), ("SBGR", "BR"), ("OMDB", "AE"),
("VIDP", "IN"), ("WSSS", "SG"), ("MMMX", "MX"), ("FAOR", "ZA"),
])
def test_an_airport_code_says_which_country_it_is_in(code, country):
"""Some routes arrive as nothing but a pair of codes, and the code is
enough: the first letter or two is a region."""
assert flags.country_of_icao(code) == country
def test_the_longer_prefix_wins():
"""K is the United States and KE is not a prefix at all, but LE is Spain
while L on its own is nothing."""
assert flags.country_of_icao("LEMD") == "ES"
assert flags.country_of_icao("KMIA") == "US"
@pytest.mark.parametrize("code", ["", "XX", "XXXX", "K", "1234", "KSE", None])
def test_something_that_is_not_an_airport_code_says_nothing(code):
assert flags.country_of_icao(code) == ""
def test_a_country_with_no_flag_still_has_a_code_to_fall_back_on():
"""The point of the fallback: an airport in a country not drawn here is
still labelled, just in letters."""
country = flags.country_of_icao("FQMA") # Mozambique
assert country == "MZ"
assert flags.flag_for(country) is None

File diff suppressed because it is too large Load diff

View file

@ -486,3 +486,404 @@ 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 == ""

View file

@ -495,3 +495,24 @@ 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

View file

@ -128,9 +128,43 @@ 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"):
"rtl-sdr", "pyqt6", "openai-whisper", "pocketsphinx"):
assert optional in body, optional
assert "Optional dependencies" in body
# 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
def test_it_says_how_to_install_the_recogniser_and_the_model():

1921
tests/test_livemap.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -135,3 +135,15 @@ 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"

467
tests/test_schedules.py Normal file
View file

@ -0,0 +1,467 @@
"""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

View file

@ -392,3 +392,107 @@ 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)

336
tests/test_themes.py Normal file
View file

@ -0,0 +1,336 @@
"""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)