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
This commit is contained in:
parent
87b0954f0c
commit
8eb3bdbb86
20 changed files with 1758 additions and 66 deletions
36
INSTALL.md
36
INSTALL.md
|
|
@ -150,6 +150,42 @@ ask public registers about a callsign or a 24-bit address and cache the
|
||||||
answers for a month; `--no-lookup` turns them off, and what the address and
|
answers for a month; `--no-lookup` turns them off, and what the address and
|
||||||
the callsign say on their own is worked out offline either way.
|
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
|
## Speech transcription, step by step
|
||||||
|
|
|
||||||
102
README.md
102
README.md
|
|
@ -1116,6 +1116,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.
|
of an airline callsign are its ICAO designator, so `RYR1234` is Ryanair.
|
||||||
`--no-lookup` stops at that.
|
`--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
|
4008F6 BAW49
|
||||||
registration: G-VROS
|
registration: G-VROS
|
||||||
|
|
@ -1131,6 +1154,46 @@ of an airline callsign are its ICAO designator, so `RYR1234` is Ryanair.
|
||||||
speed: up to 480 kt
|
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
|
### The moving map
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -1278,16 +1341,37 @@ are met rather than assumed:
|
||||||
- **the attribution is drawn onto the picture**, because a GIF travels
|
- **the attribution is drawn onto the picture**, because a GIF travels
|
||||||
without the readme that would otherwise carry it.
|
without the readme that would otherwise carry it.
|
||||||
|
|
||||||
A drawing is capped at a few dozen tiles — past that the zoom drops, since a
|
`--no-basemap` draws the tracks on their own, `--tiles URL` points at another
|
||||||
coarser map still says where the coastline is. `--no-basemap` draws the tracks
|
server (your own, if you run one), and when there is no network and nothing
|
||||||
on their own, `--tiles URL` points at another server (your own, if you run
|
cached the picture falls back to the plain grid it drew before.
|
||||||
one), and when there is no network and nothing cached the picture falls back
|
|
||||||
to the plain grid it drew before.
|
|
||||||
|
|
||||||
The map is drawn at the resolution it was fetched at, not stretched: tiles are
|
**The zoom is chosen from how wide the picture is, not from the area alone.**
|
||||||
averaged down to the picture rather than point-sampled, so lettering and roads
|
A map asked for at 1920 pixels fetches finer tiles than the same map asked for
|
||||||
stay whole instead of breaking up, and the window fetches enough pixels to
|
at 960, and about 1.4× the width is fetched deliberately and then averaged
|
||||||
cover its margin at full detail rather than enlarging what it has.
|
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
|
`--map-brightness PERCENT` (70 by default) is how far up its range the map is
|
||||||
drawn. The ground has to stay dark enough that the aircraft are the brightest
|
drawn. The ground has to stay dark enough that the aircraft are the brightest
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ and transcribing speech.
|
||||||
# 2026-08-21_02 is the second build made on the 21st. The revision is padded
|
# 2026-08-21_02 is the second build made on the 21st. The revision is padded
|
||||||
# to two digits so versions sort as text.
|
# to two digits so versions sort as text.
|
||||||
VERSION_DATE = "2026-09-04"
|
VERSION_DATE = "2026-09-04"
|
||||||
VERSION_REVISION = 6
|
VERSION_REVISION = 9
|
||||||
|
|
||||||
__version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}"
|
__version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,7 @@ class AircraftOptions:
|
||||||
frames: bool = False
|
frames: bool = False
|
||||||
log: bool = True
|
log: bool = True
|
||||||
lookup: bool = True
|
lookup: bool = True
|
||||||
|
schedules: str = ""
|
||||||
kml: bool = False
|
kml: bool = False
|
||||||
hold: float = 45.0
|
hold: float = 45.0
|
||||||
speed_unit: str = "knots"
|
speed_unit: str = "knots"
|
||||||
|
|
@ -201,6 +202,22 @@ OPTIONS: tuple[Setting, ...] = (
|
||||||
"air are ever sent. What the address block and the callsign say on "
|
"air are ever sent. What the address block and the callsign say on "
|
||||||
"their own is worked out offline either way.",
|
"their own is worked out offline either way.",
|
||||||
flags=("--lookup",), off_flags=("--no-lookup",)),
|
flags=("--lookup",), off_flags=("--no-lookup",)),
|
||||||
|
O("schedules", "Schedule services", "Listening", "text",
|
||||||
|
"which paid schedule services to ask, in order (blank = all with keys)",
|
||||||
|
"A callsign is a flight number and an airline runs the same number over "
|
||||||
|
"several legs in a day, so the free route databases -- which hold one "
|
||||||
|
"route per number -- often name somebody else's leg. A commercial "
|
||||||
|
"schedule service holds the timetable and the day's movements and can "
|
||||||
|
"say which leg is in the air now. Four are wired up: flightaware, "
|
||||||
|
"flightradar24, oag and cirium. Each wants a key, none is required, "
|
||||||
|
"and a service with no key is skipped in silence. Keys are read from "
|
||||||
|
"the environment rather than kept here, because a settings file gets "
|
||||||
|
"copied between machines and pasted into messages asking for help: "
|
||||||
|
"BANDSAUNTER_AEROAPI_KEY, BANDSAUNTER_FR24_TOKEN, BANDSAUNTER_OAG_KEY, "
|
||||||
|
"and BANDSAUNTER_CIRIUM_APP_ID with BANDSAUNTER_CIRIUM_APP_KEY.",
|
||||||
|
example="flightaware,cirium",
|
||||||
|
guidance="Leave it blank unless you want one service tried before "
|
||||||
|
"another. With no keys set, nothing changes."),
|
||||||
O("kml", "Also write a KML", "Listening", "bool",
|
O("kml", "Also write a KML", "Listening", "bool",
|
||||||
"write the flight paths for Google Earth as well",
|
"write the flight paths for Google Earth as well",
|
||||||
"One line per aircraft on the globe, with a pin where it was last "
|
"One line per aircraft on the globe, with a pin where it was last "
|
||||||
|
|
@ -608,7 +625,8 @@ def listen(console, options: AircraftOptions, output_dir: str,
|
||||||
# The registers are asked while the listening runs rather than after it,
|
# The registers are asked while the listening runs rather than after it,
|
||||||
# so a registration and a route appear on the line as they arrive. The
|
# so a registration and a route appear on the line as they arrive. The
|
||||||
# book answers immediately with what it knows and fills itself in later.
|
# book answers immediately with what it knows and fills itself in later.
|
||||||
book = FlightBook(online=options.lookup)
|
book = FlightBook(online=options.lookup,
|
||||||
|
schedules=schedule_names(options))
|
||||||
display, live = _open_display(console, options, book, started)
|
display, live = _open_display(console, options, book, started)
|
||||||
|
|
||||||
def on_block(total: int) -> None:
|
def on_block(total: int) -> None:
|
||||||
|
|
@ -676,7 +694,8 @@ def watch(console, options: AircraftOptions, output_dir: str,
|
||||||
started = time.time()
|
started = time.time()
|
||||||
log = open_log(console, options, output_dir, started, log_path)
|
log = open_log(console, options, output_dir, started, log_path)
|
||||||
registry = AircraftRegistry()
|
registry = AircraftRegistry()
|
||||||
book = FlightBook(online=options.lookup)
|
book = FlightBook(online=options.lookup,
|
||||||
|
schedules=schedule_names(options))
|
||||||
sky = livemap.Sky(unit=options.speed_unit, hold=options.hold,
|
sky = livemap.Sky(unit=options.speed_unit, hold=options.hold,
|
||||||
home=read_position(options.location),
|
home=read_position(options.location),
|
||||||
radius_nm=radius_in_nm(options) or 100.0,
|
radius_nm=radius_in_nm(options) or 100.0,
|
||||||
|
|
@ -687,10 +706,19 @@ def watch(console, options: AircraftOptions, output_dir: str,
|
||||||
if options.simulate:
|
if options.simulate:
|
||||||
sky.note = "simulated"
|
sky.note = "simulated"
|
||||||
|
|
||||||
|
def told_about(craft):
|
||||||
|
"""What a register says about one aircraft, for the window."""
|
||||||
|
if not options.lookup:
|
||||||
|
return None
|
||||||
|
entry = book.get(craft.icao, craft.callsign)
|
||||||
|
if craft.located:
|
||||||
|
# A source that listed a whole day's stops has said which leg
|
||||||
|
# this is, once there is a position to read it with.
|
||||||
|
entry = book.resolve(entry, craft.latitude, craft.longitude)
|
||||||
|
return entry
|
||||||
|
|
||||||
def on_block(total: int) -> None:
|
def on_block(total: int) -> None:
|
||||||
sky.update([livemap.blip_for(craft,
|
sky.update([livemap.blip_for(craft, told_about(craft))
|
||||||
book.get(craft.icao, craft.callsign)
|
|
||||||
if options.lookup else None)
|
|
||||||
for craft in registry.aircraft.values()],
|
for craft in registry.aircraft.values()],
|
||||||
total, len(registry))
|
total, len(registry))
|
||||||
|
|
||||||
|
|
@ -838,6 +866,12 @@ def checked(console, options: AircraftOptions, tracks):
|
||||||
return tracks
|
return tracks
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_names(options: AircraftOptions) -> list[str]:
|
||||||
|
"""The schedule services to ask, in the order given."""
|
||||||
|
return [name.strip() for name in str(options.schedules or "").split(",")
|
||||||
|
if name.strip()]
|
||||||
|
|
||||||
|
|
||||||
def radius_in_nm(options: AircraftOptions) -> float:
|
def radius_in_nm(options: AircraftOptions) -> float:
|
||||||
"""The map radius as the drawing wants it, in nautical miles.
|
"""The map radius as the drawing wants it, in nautical miles.
|
||||||
|
|
||||||
|
|
@ -897,7 +931,8 @@ def draw_log(console, options: AircraftOptions, paths, out_path=None):
|
||||||
console.print(f"[yellow]{paths[0].name} holds no frames[/yellow]")
|
console.print(f"[yellow]{paths[0].name} holds no frames[/yellow]")
|
||||||
return None
|
return None
|
||||||
tracks = checked(console, options, tracks)
|
tracks = checked(console, options, tracks)
|
||||||
book = FlightBook(online=options.lookup)
|
book = FlightBook(online=options.lookup,
|
||||||
|
schedules=schedule_names(options))
|
||||||
if options.lookup:
|
if options.lookup:
|
||||||
for track in tracks:
|
for track in tracks:
|
||||||
book.get(track.icao, track.callsign)
|
book.get(track.icao, track.callsign)
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,8 @@ from . import __version__
|
||||||
|
|
||||||
__all__ = ["decode_png", "tile_of", "choose_zoom", "fetch_tile", "mosaic",
|
__all__ = ["decode_png", "tile_of", "choose_zoom", "fetch_tile", "mosaic",
|
||||||
"ground_under", "TILE_URL", "ATTRIBUTION", "MAX_TILES", "MAX_ZOOM",
|
"ground_under", "TILE_URL", "ATTRIBUTION", "MAX_TILES", "MAX_ZOOM",
|
||||||
"cache_dir", "PNGError", "airports_in", "AIRPORTS_URL"]
|
"cache_dir", "PNGError", "airports_in", "AIRPORTS_URL",
|
||||||
|
"tile_span"]
|
||||||
|
|
||||||
# The standard OpenStreetMap tiles. Any {z}/{x}/{y} server can be put here
|
# The standard OpenStreetMap tiles. Any {z}/{x}/{y} server can be put here
|
||||||
# instead; nothing below knows anything about this one in particular.
|
# instead; nothing below knows anything about this one in particular.
|
||||||
|
|
@ -48,10 +49,18 @@ TILE_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
# a GIF travels without its readme.
|
# a GIF travels without its readme.
|
||||||
ATTRIBUTION = "MAP DATA (C) OPENSTREETMAP CONTRIBUTORS"
|
ATTRIBUTION = "MAP DATA (C) OPENSTREETMAP CONTRIBUTORS"
|
||||||
|
|
||||||
# A drawing is worth a few dozen tiles and no more. Past that the zoom is
|
# The backstop on how many tiles one drawing may fetch. Reached only by a
|
||||||
# reduced instead: a coarser map still says where the coastline is.
|
# view so wide that no zoom covers it without fetching half a country; the
|
||||||
MAX_TILES = 90
|
# 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
|
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
|
MIN_ZOOM = 2
|
||||||
TILE_PIXELS = 256
|
TILE_PIXELS = 256
|
||||||
|
|
||||||
|
|
@ -219,17 +228,39 @@ def tile_of(lat: float, lon: float, zoom: int) -> tuple[float, float]:
|
||||||
return x, y
|
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,
|
def choose_zoom(south: float, west: float, north: float, east: float,
|
||||||
max_tiles: int = MAX_TILES, most: int = MAX_ZOOM) -> int:
|
width: int = 0, max_tiles: int = MAX_TILES,
|
||||||
"""The most detail that fits inside the tile budget."""
|
most: int = MAX_ZOOM) -> int:
|
||||||
for zoom in range(min(most, MAX_ZOOM), MIN_ZOOM - 1, -1):
|
"""The zoom to fetch at: enough for the picture, and no more.
|
||||||
x0, y0 = tile_of(north, west, zoom)
|
|
||||||
x1, y1 = tile_of(south, east, zoom)
|
``width`` is how many pixels across the picture will be. Without it
|
||||||
wide = int(math.floor(x1)) - int(math.floor(x0)) + 1
|
this fetched whatever the tile budget allowed, which is the wrong
|
||||||
tall = int(math.floor(y1)) - int(math.floor(y0)) + 1
|
question in both directions: on a small picture it fetched more than
|
||||||
if wide * tall <= max_tiles:
|
could be shown, and on a large one it fetched less and the map was
|
||||||
return zoom
|
blown up to fit -- which is what a low-resolution map looks like.
|
||||||
return MIN_ZOOM
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -468,7 +499,8 @@ def ground_under(south: float, west: float, north: float, east: float,
|
||||||
"""
|
"""
|
||||||
if width < 1 or height < 1 or north <= south or east <= west:
|
if width < 1 or height < 1 or north <= south or east <= west:
|
||||||
return None
|
return None
|
||||||
zoom = choose_zoom(south, west, north, east) if zoom is None else zoom
|
if zoom is None:
|
||||||
|
zoom = choose_zoom(south, west, north, east, width=width)
|
||||||
tiles, origin_x, origin_y = mosaic(south, west, north, east, zoom,
|
tiles, origin_x, origin_y = mosaic(south, west, north, east, zoom,
|
||||||
fetch=fetch, **kw)
|
fetch=fetch, **kw)
|
||||||
if tiles is None:
|
if tiles is None:
|
||||||
|
|
|
||||||
|
|
@ -174,6 +174,10 @@ examples:
|
||||||
help="listen without writing anything down")
|
help="listen without writing anything down")
|
||||||
ad.add_argument("--no-lookup", dest="lookup", action="store_false",
|
ad.add_argument("--no-lookup", dest="lookup", action="store_false",
|
||||||
help="do not ask the registers who the aircraft are")
|
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",
|
ad.add_argument("--kml", nargs="?", const="", default=None, metavar="FILE",
|
||||||
help="also write the flight paths for Google Earth")
|
help="also write the flight paths for Google Earth")
|
||||||
ad.add_argument("--map", nargs="?", const="", default=None, metavar="FILE",
|
ad.add_argument("--map", nargs="?", const="", default=None, metavar="FILE",
|
||||||
|
|
@ -224,6 +228,10 @@ examples:
|
||||||
help="report only, draw nothing")
|
help="report only, draw nothing")
|
||||||
fl.add_argument("--no-lookup", dest="lookup", action="store_false",
|
fl.add_argument("--no-lookup", dest="lookup", action="store_false",
|
||||||
help="do not ask the registers who the aircraft are")
|
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",
|
fl.add_argument("--kml", nargs="?", const="", default=None, metavar="FILE",
|
||||||
help="also write the flight paths for Google Earth")
|
help="also write the flight paths for Google Earth")
|
||||||
fl.add_argument("--report", nargs="?", const="", default=None, metavar="FILE",
|
fl.add_argument("--report", nargs="?", const="", default=None, metavar="FILE",
|
||||||
|
|
@ -1085,6 +1093,8 @@ def cmd_adsb(args) -> int:
|
||||||
options.speed_unit = args.speed_unit
|
options.speed_unit = args.speed_unit
|
||||||
if args.basemap is not None:
|
if args.basemap is not None:
|
||||||
options.basemap = args.basemap
|
options.basemap = args.basemap
|
||||||
|
if args.schedules is not None:
|
||||||
|
options.schedules = args.schedules
|
||||||
if args.map:
|
if args.map:
|
||||||
options.picture = Path(args.map).suffix.lstrip(".") or options.picture
|
options.picture = Path(args.map).suffix.lstrip(".") or options.picture
|
||||||
|
|
||||||
|
|
@ -1135,11 +1145,18 @@ def cmd_flights(args) -> int:
|
||||||
options.speed_unit = args.speed_unit
|
options.speed_unit = args.speed_unit
|
||||||
if args.recheck:
|
if args.recheck:
|
||||||
options.recheck = True
|
options.recheck = True
|
||||||
|
if args.schedules is not None:
|
||||||
|
options.schedules = args.schedules
|
||||||
tracks = air.checked(console, options, tracks)
|
tracks = air.checked(console, options, tracks)
|
||||||
book = FlightBook(online=args.lookup)
|
book = FlightBook(online=args.lookup,
|
||||||
|
schedules=air.schedule_names(options))
|
||||||
if args.lookup:
|
if args.lookup:
|
||||||
for track in tracks:
|
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.wait(20.0)
|
||||||
book.save()
|
book.save()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -679,6 +679,9 @@ def report(tracks: list[Track], book=None, title: str = "",
|
||||||
out.append(f"{track.icao}"
|
out.append(f"{track.icao}"
|
||||||
+ (f" {track.callsign}" if track.callsign else ""))
|
+ (f" {track.callsign}" if track.callsign else ""))
|
||||||
entry = book.get(track.icao, track.callsign) if book is not None else None
|
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:
|
if entry is not None:
|
||||||
for line in entry.details():
|
for line in entry.details():
|
||||||
# The route is printed as one line further down: "from" and
|
# The route is printed as one line further down: "from" and
|
||||||
|
|
@ -689,7 +692,7 @@ def report(tracks: list[Track], book=None, title: str = "",
|
||||||
if entry.country and not entry.owner_country:
|
if entry.country and not entry.owner_country:
|
||||||
out.append(f" registered in: {entry.country}")
|
out.append(f" registered in: {entry.country}")
|
||||||
if entry.route:
|
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 "
|
out.append(f" heard: {_clock(track.first_seen)} to "
|
||||||
f"{_clock(track.last_seen)} "
|
f"{_clock(track.last_seen)} "
|
||||||
f"({_span(track.seconds)}, {track.frames} frames)")
|
f"({_span(track.seconds)}, {track.frames} frames)")
|
||||||
|
|
@ -712,6 +715,25 @@ def report(tracks: list[Track], book=None, title: str = "",
|
||||||
return out
|
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:
|
def _clock(when: float) -> str:
|
||||||
return datetime.fromtimestamp(when).strftime("%Y-%m-%d %H:%M:%S") \
|
return datetime.fromtimestamp(when).strftime("%Y-%m-%d %H:%M:%S") \
|
||||||
if when else "—"
|
if when else "—"
|
||||||
|
|
|
||||||
|
|
@ -647,6 +647,15 @@ def label_lines(track: Track, now, unit: str = DEFAULT_SPEED_UNIT,
|
||||||
rows.append((f"{kind} {entry.registration}", home))
|
rows.append((f"{kind} {entry.registration}", home))
|
||||||
elif kind or entry.registration:
|
elif kind or entry.registration:
|
||||||
rows.append((kind or entry.registration, home))
|
rows.append((kind or entry.registration, home))
|
||||||
|
# Only when the aircraft could actually be flying it. A callsign is a
|
||||||
|
# flight number and an airline runs the same number over several legs in
|
||||||
|
# a day, so a register's one route for it is quite often somebody else's
|
||||||
|
# leg -- and a route drawn beside an aircraft reads as a statement about
|
||||||
|
# that aircraft.
|
||||||
|
from .flights import route_fits
|
||||||
|
|
||||||
|
if not route_fits(entry, now.latitude, now.longitude):
|
||||||
|
return rows
|
||||||
if entry.origin_code or entry.origin:
|
if entry.origin_code or entry.origin:
|
||||||
rows.append((_short_place(entry.origin_code, entry.origin),
|
rows.append((_short_place(entry.origin_code, entry.origin),
|
||||||
entry.origin_country))
|
entry.origin_country))
|
||||||
|
|
@ -1000,8 +1009,16 @@ def _known_from(book, tracks: list[Track]) -> dict:
|
||||||
"""
|
"""
|
||||||
if book is None:
|
if book is None:
|
||||||
return {}
|
return {}
|
||||||
return {track.icao: book.get(track.icao, track.callsign)
|
out = {}
|
||||||
for track in tracks}
|
for track in tracks:
|
||||||
|
entry = book.get(track.icao, track.callsign)
|
||||||
|
if track.located and hasattr(book, "resolve"):
|
||||||
|
# Where a source listed a whole day's stops rather than a leg,
|
||||||
|
# the aircraft's own position says which leg it is on.
|
||||||
|
here = track.fixes[len(track.fixes) // 2]
|
||||||
|
entry = book.resolve(entry, here.latitude, here.longitude)
|
||||||
|
out[track.icao] = entry
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _airports_from(known: dict, book=None):
|
def _airports_from(known: dict, book=None):
|
||||||
|
|
|
||||||
|
|
@ -27,10 +27,11 @@ import threading
|
||||||
import time
|
import time
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, replace
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
__all__ = ["Flight", "FlightBook", "describe_address", "airline_of",
|
__all__ = ["Flight", "FlightBook", "describe_address", "airline_of",
|
||||||
|
"route_fits", "ROUTE_SLACK_NM",
|
||||||
"AIRCRAFT_URL", "ROUTE_URL", "CACHE_VERSION"]
|
"AIRCRAFT_URL", "ROUTE_URL", "CACHE_VERSION"]
|
||||||
|
|
||||||
AIRCRAFT_URL = "https://api.adsbdb.com/v0/aircraft/{icao}"
|
AIRCRAFT_URL = "https://api.adsbdb.com/v0/aircraft/{icao}"
|
||||||
|
|
@ -332,6 +333,14 @@ class Flight:
|
||||||
airline: str = "" # who the callsign says, from the table
|
airline: str = "" # who the callsign says, from the table
|
||||||
country: str = "" # from the address block
|
country: str = "" # from the address block
|
||||||
owner_country: str = "" # from the register, which can differ
|
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_code: str = ""
|
||||||
origin: str = ""
|
origin: str = ""
|
||||||
origin_lat: float = 0.0
|
origin_lat: float = 0.0
|
||||||
|
|
@ -395,6 +404,41 @@ class Flight:
|
||||||
return out
|
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.
|
||||||
|
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:
|
def _cache_path() -> Path:
|
||||||
root = os.environ.get("XDG_CACHE_HOME") or "~/.cache"
|
root = os.environ.get("XDG_CACHE_HOME") or "~/.cache"
|
||||||
return Path(root).expanduser() / "bandsaunter" / "flights.json"
|
return Path(root).expanduser() / "bandsaunter" / "flights.json"
|
||||||
|
|
@ -420,7 +464,8 @@ class FlightBook:
|
||||||
route_url: str = ROUTE_URL,
|
route_url: str = ROUTE_URL,
|
||||||
backup_aircraft_url: str = BACKUP_AIRCRAFT_URL,
|
backup_aircraft_url: str = BACKUP_AIRCRAFT_URL,
|
||||||
backup_route_url: str = BACKUP_ROUTE_URL,
|
backup_route_url: str = BACKUP_ROUTE_URL,
|
||||||
airport_url: str = AIRPORT_URL):
|
airport_url: str = AIRPORT_URL,
|
||||||
|
schedules=None):
|
||||||
self.online = online
|
self.online = online
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.max_age = max_age
|
self.max_age = max_age
|
||||||
|
|
@ -429,6 +474,9 @@ class FlightBook:
|
||||||
self.backup_aircraft_url = backup_aircraft_url
|
self.backup_aircraft_url = backup_aircraft_url
|
||||||
self.backup_route_url = backup_route_url
|
self.backup_route_url = backup_route_url
|
||||||
self.airport_url = airport_url
|
self.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.cache_path = Path(cache) if cache is not None else _cache_path()
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._entries: dict[str, Flight] = {}
|
self._entries: dict[str, Flight] = {}
|
||||||
|
|
@ -495,7 +543,8 @@ class FlightBook:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# -- lookup -----------------------------------------------------------
|
# -- 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.
|
"""What is known about an aircraft now, starting a lookup if needed.
|
||||||
|
|
||||||
Called again with a callsign once one has been heard -- identity and
|
Called again with a callsign once one has been heard -- identity and
|
||||||
|
|
@ -523,9 +572,11 @@ class FlightBook:
|
||||||
if not self.online:
|
if not self.online:
|
||||||
return entry
|
return entry
|
||||||
if fresh or wanted_route:
|
if fresh or wanted_route:
|
||||||
thread = threading.Thread(target=self._fetch,
|
thread = threading.Thread(
|
||||||
args=(entry, fresh, wanted_route),
|
target=self._fetch,
|
||||||
daemon=True)
|
args=(entry, fresh, wanted_route,
|
||||||
|
time.time() if when is None else when),
|
||||||
|
daemon=True)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._threads.append(thread)
|
self._threads.append(thread)
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
@ -587,7 +638,8 @@ class FlightBook:
|
||||||
self._threads = [t for t in self._threads if t.is_alive()]
|
self._threads = [t for t in self._threads if t.is_alive()]
|
||||||
|
|
||||||
# -- the network ------------------------------------------------------
|
# -- 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
|
reached = False
|
||||||
if airframe:
|
if airframe:
|
||||||
for url, apply in ((self.aircraft_url, self._apply_aircraft),
|
for url, apply in ((self.aircraft_url, self._apply_aircraft),
|
||||||
|
|
@ -615,7 +667,12 @@ class FlightBook:
|
||||||
self._dirty = True
|
self._dirty = True
|
||||||
|
|
||||||
if route and entry.callsign:
|
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),
|
for url, read in ((self.route_url, self._read_route),
|
||||||
(self.backup_route_url, self._read_backup_route)):
|
(self.backup_route_url, self._read_backup_route)):
|
||||||
if not url or found:
|
if not url or found:
|
||||||
|
|
@ -633,6 +690,16 @@ class FlightBook:
|
||||||
self._apply_route(entry, found)
|
self._apply_route(entry, found)
|
||||||
self._dirty = True
|
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:
|
def _request(self, url: str) -> dict:
|
||||||
"""Ask one website one question.
|
"""Ask one website one question.
|
||||||
|
|
||||||
|
|
@ -719,26 +786,86 @@ class FlightBook:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _read_backup_route(body: dict) -> dict | None:
|
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()
|
raw = str((body or {}).get("route") or "").strip()
|
||||||
parts = [p.strip().upper() for p in raw.split("-") if p.strip()]
|
parts = [p.strip().upper() for p in raw.split("-") if p.strip()]
|
||||||
if len(parts) < 2:
|
if len(parts) < 2:
|
||||||
return None
|
return None
|
||||||
# A multi-leg route lists every stop; the ends are what a map wants.
|
|
||||||
# Nothing here says which country an airport is in, but its ICAO code
|
# Nothing here says which country an airport is in, but its ICAO code
|
||||||
# does: the first letter or two is a region.
|
# does: the first letter or two is a region.
|
||||||
from .flags import country_of_icao
|
from .flags import country_of_icao
|
||||||
|
|
||||||
return {"origin_code": parts[0], "origin": parts[0],
|
route = {"stops": tuple(parts), "airline": ""}
|
||||||
"origin_country": country_of_icao(parts[0]),
|
if len(parts) == 2:
|
||||||
"destination_code": parts[-1], "destination": parts[-1],
|
route.update({"origin_code": parts[0], "origin": parts[0],
|
||||||
"destination_country": country_of_icao(parts[-1]),
|
"origin_country": country_of_icao(parts[0]),
|
||||||
"airline": ""}
|
"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
|
@staticmethod
|
||||||
def _apply_route(entry: Flight, route: dict | None) -> None:
|
def _apply_route(entry: Flight, route: dict | None) -> None:
|
||||||
if not route:
|
if not route:
|
||||||
return
|
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_code = str(route.get("origin_code") or "")
|
||||||
entry.origin = str(route.get("origin") or "")
|
entry.origin = str(route.get("origin") or "")
|
||||||
entry.origin_lat = float(route.get("origin_lat") or 0.0)
|
entry.origin_lat = float(route.get("origin_lat") or 0.0)
|
||||||
|
|
|
||||||
|
|
@ -167,7 +167,11 @@ GLYPHS = {
|
||||||
"N": ("10001", "11001", "10101", "10011", "10001", "10001", "10001"),
|
"N": ("10001", "11001", "10101", "10011", "10001", "10001", "10001"),
|
||||||
"O": ("01110", "10001", "10001", "10001", "10001", "10001", "01110"),
|
"O": ("01110", "10001", "10001", "10001", "10001", "10001", "01110"),
|
||||||
"P": ("11110", "10001", "10001", "11110", "10000", "10000", "10000"),
|
"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"),
|
"R": ("11110", "10001", "10001", "11110", "10100", "10010", "10001"),
|
||||||
"S": ("01111", "10000", "10000", "01110", "00001", "00001", "11110"),
|
"S": ("01111", "10000", "10000", "01110", "00001", "00001", "11110"),
|
||||||
"T": ("11111", "00100", "00100", "00100", "00100", "00100", "00100"),
|
"T": ("11111", "00100", "00100", "00100", "00100", "00100", "00100"),
|
||||||
|
|
|
||||||
|
|
@ -64,9 +64,10 @@ RESETTLE_NM = 12.0
|
||||||
|
|
||||||
# How much map to fetch beyond what is being shown. A window that fetched
|
# How much map to fetch beyond what is being shown. A window that fetched
|
||||||
# exactly what it needed would fetch again on every resize and every small
|
# exactly what it needed would fetch again on every resize and every small
|
||||||
# drift; a third again in each direction covers both, at the cost of about
|
# drift. Modest, because the fetch is now at the picture's own resolution
|
||||||
# twice as many tiles the first time.
|
# and every extra degree of margin is a ring of tiles: with the middle of
|
||||||
GROUND_MARGIN = 0.33
|
# the map settling rather than wandering, a little is enough.
|
||||||
|
GROUND_MARGIN = 0.12
|
||||||
|
|
||||||
# The widest a line in a box is allowed to get before it is folded. An
|
# The widest a line in a box is allowed to get before it is folded. An
|
||||||
# airport's full name and the town it is in run to forty characters on their
|
# airport's full name and the town it is in run to forty characters on their
|
||||||
|
|
@ -162,6 +163,9 @@ class Blip:
|
||||||
origin_country: str = "" # two letters, for the flag beside it
|
origin_country: str = "" # two letters, for the flag beside it
|
||||||
destination: str = ""
|
destination: str = ""
|
||||||
destination_country: str = ""
|
destination_country: str = ""
|
||||||
|
# Whether this aircraft could be flying the route its callsign is given.
|
||||||
|
# A callsign is a flight number rather than a leg, so often it could not.
|
||||||
|
route_fits: bool = True
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def located(self) -> bool:
|
def located(self) -> bool:
|
||||||
|
|
@ -193,9 +197,9 @@ class Blip:
|
||||||
out.append(("", self.operator, ""))
|
out.append(("", self.operator, ""))
|
||||||
# The two ends of the flight on their own lines rather than joined by
|
# The two ends of the flight on their own lines rather than joined by
|
||||||
# an arrow, so that each can carry the flag of the country it is in.
|
# an arrow, so that each can carry the flag of the country it is in.
|
||||||
if self.origin:
|
if self.route_fits and self.origin:
|
||||||
out.append(("from", self.origin, self.origin_country))
|
out.append(("from", self.origin, self.origin_country))
|
||||||
if self.destination:
|
if self.route_fits and self.destination:
|
||||||
out.append(("to", self.destination, self.destination_country))
|
out.append(("to", self.destination, self.destination_country))
|
||||||
if self.altitude_ft:
|
if self.altitude_ft:
|
||||||
climb = ""
|
climb = ""
|
||||||
|
|
@ -291,6 +295,10 @@ def blip_for(craft, entry=None) -> Blip:
|
||||||
blip.origin_country = entry.origin_country
|
blip.origin_country = entry.origin_country
|
||||||
blip.destination = entry.destination or entry.destination_code
|
blip.destination = entry.destination or entry.destination_code
|
||||||
blip.destination_country = entry.destination_country
|
blip.destination_country = entry.destination_country
|
||||||
|
if blip.located:
|
||||||
|
from .flights import route_fits
|
||||||
|
|
||||||
|
blip.route_fits = route_fits(entry, blip.latitude, blip.longitude)
|
||||||
return blip
|
return blip
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1023,9 +1031,11 @@ def fetch_ground(sky: Sky, url: str = "", fetch=None) -> None:
|
||||||
extra = {"fetch": fetch} if fetch is not None else {}
|
extra = {"fetch": fetch} if fetch is not None else {}
|
||||||
if url:
|
if url:
|
||||||
extra["url"] = url
|
extra["url"] = url
|
||||||
# Fetched at the size of the window rather than the bigger box,
|
# The size asked for is the box's own, not the window's, so the
|
||||||
# so a wider view costs no more pixels; it is stretched back over
|
# piece of it the window shows comes out pixel for pixel with
|
||||||
# the box when it is drawn.
|
# the screen. Rendering the wider box into the window's pixels
|
||||||
|
# and stretching it back was a whole-map upscale of a fifth,
|
||||||
|
# which is what a sharp map looks like when it looks blurred.
|
||||||
levels = basemap.ground_under(south, west, north, east,
|
levels = basemap.ground_under(south, west, north, east,
|
||||||
width, height,
|
width, height,
|
||||||
shades=_ground_shades(), **extra)
|
shades=_ground_shades(), **extra)
|
||||||
|
|
|
||||||
454
bandsaunter/schedules.py
Normal file
454
bandsaunter/schedules.py
Normal 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
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
.\" Generated by packaging/make-man.py -- do not edit by hand.
|
.\" Generated by packaging/make-man.py -- do not edit by hand.
|
||||||
.TH BANDSAUNTER 1 "2026-09-04" "bandsaunter 2026-09-04_06" "User Commands"
|
.TH BANDSAUNTER 1 "2026-09-04" "bandsaunter 2026-09-04_09" "User Commands"
|
||||||
.SH NAME
|
.SH NAME
|
||||||
bandsaunter \- scan, record and identify radio signals with an RTL-SDR
|
bandsaunter \- scan, record and identify radio signals with an RTL-SDR
|
||||||
.SH SYNOPSIS
|
.SH SYNOPSIS
|
||||||
|
|
@ -1390,6 +1390,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.
|
the first three letters of an airline callsign are its ICAO designator.
|
||||||
.B \-\-no\-lookup
|
.B \-\-no\-lookup
|
||||||
stops at that.
|
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
|
.SS The moving map
|
||||||
.B bandsaunter flights
|
.B bandsaunter flights
|
||||||
reads a log back \[em] the newest one in the output directory unless told
|
reads a log back \[em] the newest one in the output directory unless told
|
||||||
|
|
@ -1476,8 +1524,20 @@ Tiles are cached in
|
||||||
.I ~/.cache/bandsaunter/tiles
|
.I ~/.cache/bandsaunter/tiles
|
||||||
and never fetched twice, every request identifies this program in its
|
and never fetched twice, every request identifies this program in its
|
||||||
User-Agent, and the attribution the tiles require is written onto the picture
|
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. A drawing
|
\[em] a GIF travels without the readme that would otherwise carry it.
|
||||||
is capped at a few dozen tiles; past that the zoom drops instead.
|
.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
|
.PP
|
||||||
.B \-\-no\-basemap
|
.B \-\-no\-basemap
|
||||||
draws the tracks on their own,
|
draws the tracks on their own,
|
||||||
|
|
|
||||||
|
|
@ -783,6 +783,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.
|
the first three letters of an airline callsign are its ICAO designator.
|
||||||
.B \-\-no\-lookup
|
.B \-\-no\-lookup
|
||||||
stops at that.
|
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
|
.SS The moving map
|
||||||
.B bandsaunter flights
|
.B bandsaunter flights
|
||||||
reads a log back \[em] the newest one in the output directory unless told
|
reads a log back \[em] the newest one in the output directory unless told
|
||||||
|
|
@ -869,8 +917,20 @@ Tiles are cached in
|
||||||
.I ~/.cache/bandsaunter/tiles
|
.I ~/.cache/bandsaunter/tiles
|
||||||
and never fetched twice, every request identifies this program in its
|
and never fetched twice, every request identifies this program in its
|
||||||
User-Agent, and the attribution the tiles require is written onto the picture
|
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. A drawing
|
\[em] a GIF travels without the readme that would otherwise carry it.
|
||||||
is capped at a few dozen tiles; past that the zoom drops instead.
|
.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
|
.PP
|
||||||
.B \-\-no\-basemap
|
.B \-\-no\-basemap
|
||||||
draws the tracks on their own,
|
draws the tracks on their own,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
.\" Generated by packaging/make-browse-man.py -- do not edit by hand.
|
.\" Generated by packaging/make-browse-man.py -- do not edit by hand.
|
||||||
.TH SAUNTERBROWSE 1 "2026-09-04" "bandsaunter 2026-09-04_06" "User Commands"
|
.TH SAUNTERBROWSE 1 "2026-09-04" "bandsaunter 2026-09-04_08" "User Commands"
|
||||||
.SH NAME
|
.SH NAME
|
||||||
saunterbrowse \- read and listen to what a bandsaunter scan collected
|
saunterbrowse \- read and listen to what a bandsaunter scan collected
|
||||||
.SH SYNOPSIS
|
.SH SYNOPSIS
|
||||||
|
|
|
||||||
|
|
@ -1186,3 +1186,26 @@ def test_the_faint_colours_are_the_same_hues_only_dimmer():
|
||||||
def test_the_palette_still_has_room_after_the_fade_colours():
|
def test_the_palette_still_has_room_after_the_fade_colours():
|
||||||
assert fm.FAINT + fm.RAMP_STEPS < fm.TRANSPARENT
|
assert fm.FAINT + fm.RAMP_STEPS < fm.TRANSPARENT
|
||||||
assert fm.PALETTE.shape == (256, 3)
|
assert fm.PALETTE.shape == (256, 3)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_route_the_aircraft_cannot_be_flying_is_not_drawn_beside_it():
|
||||||
|
"""A route beside an aircraft reads as a statement about that aircraft,
|
||||||
|
and a callsign is a flight number rather than a leg."""
|
||||||
|
track = straight(lat=32.7, lon=-110.4)
|
||||||
|
entry = _Entry(origin_code="KHOU", origin_lat=29.65, origin_lon=-95.28,
|
||||||
|
destination_code="KSAT", destination_lat=29.53,
|
||||||
|
destination_lon=-98.47)
|
||||||
|
said = [text for text, _flag in
|
||||||
|
fm.label_lines(track, track.fixes[0], "knots", entry)]
|
||||||
|
assert "KHOU" not in said and "KSAT" not in said
|
||||||
|
assert any("B739" in line for line in said), "the rest of it went too"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_route_it_could_be_flying_is_drawn():
|
||||||
|
track = straight(lat=32.7, lon=-110.4)
|
||||||
|
entry = _Entry(origin_code="KLAX", origin_lat=33.94, origin_lon=-118.41,
|
||||||
|
destination_code="KDFW", destination_lat=32.90,
|
||||||
|
destination_lon=-97.04)
|
||||||
|
said = [text for text, _flag in
|
||||||
|
fm.label_lines(track, track.fixes[0], "knots", entry)]
|
||||||
|
assert "KLAX" in said and "KDFW" in said
|
||||||
|
|
|
||||||
|
|
@ -598,3 +598,191 @@ def test_a_route_written_now_is_kept(tmp_path, register):
|
||||||
again = FlightBook(cache=book.cache_path)
|
again = FlightBook(cache=book.cache_path)
|
||||||
entry = again.get("4CA1FA", "RYR1234")
|
entry = again.get("4CA1FA", "RYR1234")
|
||||||
assert entry.origin_country == "GB"
|
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
|
||||||
|
|
|
||||||
|
|
@ -495,3 +495,24 @@ def test_a_simulated_transmission_becomes_a_file_on_disk(tmp_path):
|
||||||
if not p.name.endswith("_waterfall.png")]
|
if not p.name.endswith("_waterfall.png")]
|
||||||
assert written
|
assert written
|
||||||
assert _read_png(written[0]).shape[1] == 320
|
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
|
||||||
|
|
|
||||||
|
|
@ -967,3 +967,38 @@ def test_one_that_has_gone_quiet_is_not_counted_as_overhead(app):
|
||||||
assert _painted(picture) > 100 # both are drawn
|
assert _painted(picture) > 100 # both are drawn
|
||||||
assert len(sky.flying()) == 2
|
assert len(sky.flying()) == 2
|
||||||
assert sum(1 for b in sky.flying() if sky.strength(b) >= 1.0) == 1
|
assert sum(1 for b in sky.flying() if sky.strength(b) >= 1.0) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_route_the_aircraft_cannot_be_flying_is_left_out_of_the_box():
|
||||||
|
from bandsaunter.adsb import Aircraft
|
||||||
|
from bandsaunter.flights import Flight
|
||||||
|
|
||||||
|
craft = Aircraft(icao="AC0FB6", callsign="SWA930")
|
||||||
|
craft.latitude, craft.longitude = 32.7086, -110.4061
|
||||||
|
entry = Flight(icao="AC0FB6", origin="Houston", origin_code="KHOU",
|
||||||
|
origin_lat=29.65, origin_lon=-95.28,
|
||||||
|
destination="San Antonio", destination_code="KSAT",
|
||||||
|
destination_lat=29.53, destination_lon=-98.47,
|
||||||
|
registration="N8765Q")
|
||||||
|
blip = blip_for(craft, entry)
|
||||||
|
assert blip.route_fits is False
|
||||||
|
labels = [label for label, _v, _f in blip.lines("knots")]
|
||||||
|
assert "from" not in labels and "to" not in labels
|
||||||
|
# And everything the aircraft itself said is still there.
|
||||||
|
assert any(v == "N8765Q" for _l, v, _f in blip.lines("knots"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_route_it_could_be_flying_stays_in_the_box():
|
||||||
|
from bandsaunter.adsb import Aircraft
|
||||||
|
from bandsaunter.flights import Flight
|
||||||
|
|
||||||
|
craft = Aircraft(icao="AD64CD", callsign="AAL2465")
|
||||||
|
craft.latitude, craft.longitude = 33.1059, -110.5428
|
||||||
|
entry = Flight(icao="AD64CD", origin="Santa Ana", origin_code="KSNA",
|
||||||
|
origin_lat=33.68, origin_lon=-117.87,
|
||||||
|
destination="Dallas", destination_code="KDFW",
|
||||||
|
destination_lat=32.90, destination_lon=-97.04)
|
||||||
|
blip = blip_for(craft, entry)
|
||||||
|
assert blip.route_fits is True
|
||||||
|
labels = [label for label, _v, _f in blip.lines("knots")]
|
||||||
|
assert "from" in labels and "to" in labels
|
||||||
|
|
|
||||||
467
tests/test_schedules.py
Normal file
467
tests/test_schedules.py
Normal 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue