327 lines
15 KiB
Markdown
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.
|
|
|
|

|
|
|
|
## 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.
|