Files
2026-10-21 10:55:16 +02:00

327 lines
15 KiB
Markdown

# Flight Map
A Python command-line application that **records OpenSky aircraft positions to
SQLite**, then **independently generates a pen-plotter SVG** from a saved session.
Collection never downloads maps or terrain; plotting never contacts OpenSky.
![](./example.svg)
## Install
Python 3.11 or newer:
```sh
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -e .
flight-map --help
```
Alternatively, use `uv venv .venv` and `uv pip install -e .`.
## 1. Collect flights
The default map rectangle is in Uithoorn, Netherlands:
| Corner | Latitude | Longitude |
| --- | --- | --- |
| Southwest | 52.232 | 4.784 |
| Northeast | 52.258 | 4.864 |
OpenSky supports OAuth client credentials. Obtain your own credentials through
OpenSky and set them in your shell, **not in source code**:
```sh
export OPENSKY_CLIENT_ID='your-client-id'
export OPENSKY_CLIENT_SECRET='your-client-secret'
flight-map collect --db flights.sqlite --duration 3600
```
An existing token can instead be supplied through `OPENSKY_ACCESS_TOKEN`.
Anonymous access is attempted when no credentials are supplied, with a warning;
availability and daily quotas depend on OpenSky's current policy. Client
credentials automatically obtain and refresh tokens; a manually provided token
does not refresh.
Collection prints the session ID immediately. Omit `--duration` to run until
Ctrl-C. Observations and progress are committed after each successful poll.
Ctrl-C preserves the observations and marks the session interrupted. Service
errors stop collection with a nonzero exit status and mark the session failed;
HTTP rate-limit responses include `Retry-After` when available.
Custom bounds are **longitude, latitude, longitude, latitude**, not latitude first:
```sh
flight-map collect --db flights.sqlite \
--bounds 4.784498 52.232025 4.863525 52.253985 \
--interval 30 --collection-margin 2000 --duration 3600
flight-map sessions --db flights.sqlite
flight-map collect --db flights.sqlite --resume SESSION_ID --duration 1800
```
Reversed corners are normalized. The requested map rectangle, expanded collection
rectangle, interval, source, session times, status, and observation associations
are saved in the database. Resume uses those saved settings; it does not change
the map area or interval. After an abrupt process termination, the last committed
checkpoint remains available even if the status still says `recording`.
Do not run two collectors against the same session simultaneously.
The default poll interval is 30 seconds, with a minimum of 10 seconds. **A valid
interval does not guarantee enough daily API credits**: select an interval and
duration appropriate to your account's current quota. No historical observations
from before collection starts are fetched.
## Purge a recording
To permanently delete one session, including its saved map bounds and all flight
observations:
```sh
flight-map sessions --db flights.sqlite
flight-map purge --db flights.sqlite --session SESSION_ID --yes
```
Stop any collector writing to that session first. The exact session ID and
`--yes` confirmation flag are required. Deletion is transactional: either both
observations and session metadata are removed, or neither is. Other sessions,
map cache entries, and previously exported SVGs are unchanged. Missing sessions
or databases produce an error; a mistyped database path does not create a file.
Deletion cannot be undone without a backup. This is logical deletion, not secure
erasure or database-file compaction.
## 2. Plot a recorded session
```sh
flight-map plot --db flights.sqlite --session SESSION_ID --output map.svg
```
The map area is read from the session, not from current application defaults.
Flight data is opened read-only. Supply a shorter recording interval if needed:
```sh
flight-map plot --db flights.sqlite --session SESSION_ID --output morning.svg \
--from 2026-10-20T08:00:00Z --to 2026-10-20T08:30:00Z
```
Timestamps require an explicit time zone; labels are UTC. Both endpoints must be
within the saved recording. Only observations within the selected interval are
used. At least two usable consecutive airborne observations are required per
track. Observations are separated into tracks at callsign changes, ground
observations, and gaps over `--max-gap` seconds (default 120); unknown missing
positions are not bridged beyond that threshold. Callsigns are not verified
passenger-facing flight numbers. Missing callsigns are labeled explicitly, with
the aircraft ICAO identifier retained.
Flight paths use smooth **centripetal Catmull-Rom interpolation**, exported as
cubic Bezier SVG paths passing through every recorded position. Two-point tracks
remain straight; duplicate positions are handled without invalid coordinates.
Smoothing never joins separate tracks or fills long recording gaps. Curves are
interpolations, not evidence of the aircraft's precise motion between samples.
The actual curves are clipped geometrically to the output rectangle, and their
in-map portions determine the time-weighted reported-altitude calculation.
### Map cache and offline plotting
The first plot downloads OpenStreetMap **vector features**, not raster tiles,
using Overpass. Roads, waterways, buildings, and water outlines are rendered as
unfilled lines. Minor pedestrian/cycle paths, steps, proposed roads, and roads
under construction are omitted from the drawing. Building outlines whose shorter
dimension would be smaller than three pen widths are omitted to prevent dense
housing from turning into ink blobs; larger paper or a finer pen retains more
buildings without another download. Water multipolygon relations (including
islands) are assembled for the land-elevation mask; the map drawing itself uses
returned way geometry.
Downloaded map data is cached in `.flight-map-cache/`. The cache key includes
the service URL, exact map bounds, and feature query. Different time ranges,
page sizes, and flight selections for the same map reuse that data, including
when offline. Cache entries do not expire automatically:
```sh
flight-map plot --db flights.sqlite --session SESSION_ID --output map.svg \
--cache /path/to/shared-map-cache
flight-map plot --db flights.sqlite --session SESSION_ID --output updated.svg \
--cache /path/to/shared-map-cache --refresh-map
```
Refresh validates the response before atomically replacing the cached entry.
A failed refresh leaves the previous entry intact and reports an error. A damaged
entry is reported rather than silently ignored; use `--refresh-map` to replace it.
Cached responses have no embedded credentials.
For entirely offline plotting, pass a local Overpass JSON export containing
`elements` with way `geometry`, produced by an `out geom;` query:
```sh
flight-map plot --db flights.sqlite --session SESSION_ID --output offline.svg \
--map-data local-map.json --no-terrain
```
Omit `--no-terrain` when using cached automatic AHN terrain or supply a local
`--terrain` raster for the separate land-elevation summary offline. For a complete water mask,
local exports must include closed water ways and water multipolygon relations
with full member geometry, including areas enclosing the requested map.
### Reported aircraft altitude
Flight labels use **average reported barometric altitude (pressure altitude)**,
in metres, from OpenSky's `baro_altitude` field. It is time-weighted over the
smoothed flight path's portion inside the saved map bounds, using linear
interpolation of altitude between observation timestamps. Outside-map extensions
do not contribute.
No terrain elevation is subtracted and no geoid, pressure, or vertical-reference
correction is applied. This is not height above ground, terrain clearance, or
necessarily true altitude above sea level. Geometric altitude is still stored
but is never substituted for missing barometric data. If a contributing segment
has missing barometric altitude, the track's altitude is `N/A` with a warning.
Zero and negative reported altitudes remain valid.
Flight altitude does not depend on terrain downloads or the water mask.
The former `--geoid` and `--confirm-geometric-ellipsoid` options have been removed.
SVG metadata uses `data-average-barometric-altitude-metres` and
`data-altitude-source="barometric"` instead of `data-average-height-metres`.
### Separate average land-elevation summary
For Dutch map bounds, plotting automatically downloads the **AHN digital terrain
model (DTM)** through the [PDOK WCS](https://service.pdok.nl/rws/ahn/wcs/v1_0?service=WCS&request=GetCapabilities).
It uses coverage `dtm_05m`, not a surface model containing roofs and vegetation.
The service currently describes this coverage as AHN4. Data is requested in
RD New (EPSG:28992) as a 512 by 512 nearest-neighbour GeoTIFF subset of the
source's 0.5 m raster. The downloaded subset is a sampled overview, not the
full-resolution source. Elevations are metres relative to **NAP**.
```sh
flight-map plot --db flights.sqlite --session SESSION_ID --output map.svg
flight-map plot --db flights.sqlite --session SESSION_ID --output refreshed.svg \
--refresh-terrain
```
Terrain is cached under `--cache/terrain/` (by default
`.flight-map-cache/terrain/`), keyed by the PDOK endpoint, coverage, projected
bounds, resolution, and request parameters. Validated downloads atomically replace
cache entries; refresh failures preserve the previous file but warn and leave
land elevation unavailable for that run. Files contain provenance and NAP metadata.
There is no automatic expiry. A changed vector-map query also gets a new cache
key; the first run after the water-mask update downloads fresh OSM data.
Outside AHN's coverage, on service/download failures, or when no valid land
elevations are available, the program **warns and continues plotting**. Ground
elevation is then `N/A`; reported aircraft altitudes remain available independently.
Use `--no-terrain` to disable the separate land-elevation summary and its downloads.
The land average is informational only and never corrects aircraft altitude.
Sampling uses an equal-area 64 by 64 geographic grid. Mapped OSM water areas
(`natural=water` or `waterway=riverbank`) are excluded, including multipolygon
water areas with islands retained as land. Unmapped water and coastline-derived
sea areas are not inferred. Water masking is only as complete as the OSM data.
If a water outline is unclosed or a multipolygon is incomplete, the program warns
and leaves land elevation unavailable rather than silently
counting that water as land. Map and flight geometry still render.
Valid samples on the remaining land are averaged, honoring raster nodata, scale,
and offset. Missing land samples are **not** assumed to be water: they are
excluded with a warning showing the valid land coverage percentage and sample
counts. Even small coverage gaps produce a warning. Coverage is included in
the plotted legend and SVG metadata; low coverage can bias the average. No
arbitrary minimum is imposed: no valid land samples means `N/A`. The grid is
an approximation, not a survey-grade terrain integral.
You can override automatic AHN with a local terrain GeoTIFF whose first band
contains ground elevation in metres and has a CRS. Declare its vertical reference
with `--terrain-reference` (`nap`, `orthometric`, or `wgs84-ellipsoid`).
`--refresh-terrain` only applies to automatic AHN, not local input.
### Pen-plotter settings
Defaults: A4 landscape, 12 mm page margin, 0.3 mm pen, 1.8 mm lettering, and up to
4 mm of recorded flight extension beyond the map border.
```sh
flight-map plot --db flights.sqlite --session SESSION_ID --output large.svg \
--width 420 --height 297 --pen-width 0.3 --font-height 2 --extension 5
```
SVG dimensions are in millimetres. A local equirectangular projection preserves
the rectangular geographic bounds and uses a common scale for map and tracks;
it is intended for local maps, not large geographic extents. Geometry is
physically clipped, not hidden with masks. Groups `map`, `flights`, and
`lettering` can be selected separately. Text, including attribution, is drawn
with an original single-stroke alphabet: no installed fonts, raster images,
filled glyphs, or SVG clipping support are required.
Each track has a numeric identifier and a legend entry with a single UTC date
and time at the midpoint between its in-map start and end times, callsign,
ICAO identifier, and average reported barometric altitude labeled `Alt`.
Flight entries occupy two balanced columns, reading down the left column
and then down the right. Full start/end times and the barometric altitude
source remain in SVG metadata. For dense recordings, select
shorter periods or larger paper. The program fails explicitly if the legend
does not fit or a track identifier cannot be placed legibly. Flight identifiers
are placed entirely outside the map border in the extension margin, near an
entry/exit point, with a vector leader connecting them to the track. Tracks
entirely inside the map also use margin labels with leaders. Identifiers never
fall back to positions inside the map; increase `--extension` or select fewer
tracks if margin space is insufficient. Flight identifiers
avoid each other; intersections with map geometry or flight paths are not fully
optimized. Map lines are simplified at 0.05 mm tolerance and identical paths
deduplicated; physical pen-path travel optimization is left to plotter software.
Existing output is protected unless `--force` is supplied. Map bounds crossing
the antimeridian, widths above 180 degrees, and latitudes outside [-85, 85] are
rejected.
### Example map
## Offline synthetic demo
This creates **invented** tracks, map features, and terrain data, for
checking the complete workflow without network access:
```sh
python examples/make_demo.py demo
```
Run the plotting command printed by the script. Altitude labels use the synthetic
barometric observations directly; these inputs are not a real Uithoorn dataset.
## Development
```sh
python -m pip install -e '.[dev]'
python -m pytest
ruff check flight_map tests examples
ruff format --check flight_map tests examples
mypy flight_map
```
Tests cover recording and resume settings, persistence, deduplication, bounds,
projection, track gaps, clipping, time-weighted reported barometric altitude,
missing altitude and terrain, cache reuse/refresh/failure, and repeatable offline SVG generation
without mutating the database.
## Data policies and attribution
- Map: [OpenStreetMap contributors](https://www.openstreetmap.org/copyright),
licensed under ODbL. Respect the chosen Overpass service's
[usage guidance](https://dev.overpass-api.de/overpass-doc/en/preface/commons.html);
cache and avoid unnecessary requests.
- Flights: [The OpenSky Network](https://opensky-network.org). This initial
application is intended for non-commercial use, subject to its current
[terms](https://opensky-network.org/about/terms-of-use) and
[API documentation](https://openskynetwork.github.io/opensky-api/rest.html).
Coverage and fields vary; collection does not guarantee complete tracks.
Cite Matthias Schaefer, Martin Strohmeier, Vincent Lenders, Ivan Martinovic,
and Matthias Wilhelm, "Bringing Up OpenSky: A Large-scale ADS-B Sensor Network
for Research", IPSN 2014, pp. 83-94, when publishing derived work.
- Local terrain: supplied by the operator; follow its separate license
and attribution requirements when distributing outputs. Automatic AHN DTM
through PDOK is CC0; its source and NAP reference are included in SVG metadata
and the plotted attribution.
Map and flight attribution is included as plotter paths, with further provenance
in SVG metadata. This is an artistic recording/plotting tool, not a navigation
or aviation safety product.