15 KiB
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.
Install
Python 3.11 or newer:
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:
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:
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:
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
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:
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:
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:
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.
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.
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.
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 UTC dates/times, callsign, ICAO identifier, and average reported barometric altitude. 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 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.
Offline synthetic demo
This creates invented tracks, map features, and terrain data, for checking the complete workflow without network access:
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
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, licensed under ODbL. Respect the chosen Overpass service's usage guidance; cache and avoid unnecessary requests.
- Flights: The OpenSky Network. This initial application is intended for non-commercial use, subject to its current terms and API documentation. 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.