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

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 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:

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.