285 lines
12 KiB
Markdown
285 lines
12 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.232025 | 4.784498 |
|
|
| Northeast | 52.253985 | 4.863525 |
|
|
|
|
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 height 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. Multipolygon relations are not assembled in this initial version;
|
|
only returned way geometry is drawn.
|
|
|
|
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
|
|
```
|
|
|
|
### Height above the map's average ground level
|
|
|
|
**Without suitable elevation inputs the map still plots, but heights are `N/A`
|
|
and the program emits warnings.** It never substitutes pressure altitude, zero
|
|
ground elevation, or sea-level altitude.
|
|
|
|
The calculation is:
|
|
|
|
```text
|
|
height = time-weighted mean aircraft altitude inside the saved map bounds
|
|
minus area-weighted mean ground elevation inside those same bounds
|
|
```
|
|
|
|
Outside-map extensions do not contribute. Heights are in metres, not terrain
|
|
clearance under the aircraft.
|
|
|
|
Supply a terrain GeoTIFF whose first band contains ground elevation in metres,
|
|
has a CRS, and covers the entire map rectangle. Its declared vertical reference
|
|
must be known. Sampling uses an equal-area 64 by 64 geographic grid and honors
|
|
nodata, raster band scale, and offset. Any missing sampled ground value makes
|
|
the mean unavailable. Choose terrain resolution appropriate to the map; the
|
|
grid is an approximation, not a survey-grade terrain integral.
|
|
|
|
For terrain already referenced to the WGS84 ellipsoid:
|
|
|
|
```sh
|
|
flight-map plot --db flights.sqlite --session SESSION_ID --output heights.svg \
|
|
--terrain terrain-ellipsoid.tif --terrain-reference wgs84-ellipsoid \
|
|
--confirm-geometric-ellipsoid
|
|
```
|
|
|
|
For orthometric terrain, also supply a geoid separation GeoTIFF in metres,
|
|
representing **N = h - H**, matching the terrain's vertical datum and covering
|
|
the track observations used for conversion:
|
|
|
|
```sh
|
|
flight-map plot --db flights.sqlite --session SESSION_ID --output heights.svg \
|
|
--terrain terrain.tif --terrain-reference orthometric \
|
|
--geoid matching-geoid.tif --confirm-geometric-ellipsoid
|
|
```
|
|
|
|
The application computes `H = h - N` for each geometric aircraft altitude.
|
|
OpenSky documents its field as geometric altitude but does not establish every
|
|
observation's datum or accuracy. The confirmation flag is a deliberate assertion
|
|
by the operator that treating these heights as WGS84 ellipsoidal is appropriate.
|
|
Do not set it without verifying your source's interpretation. A missing geometric
|
|
altitude or geoid value on a contributing segment makes that track's height
|
|
unavailable. Pressure altitude is recorded for provenance, not used for this
|
|
calculation. For Dutch terrain in NAP, use a conversion consistent with NAP;
|
|
do not assume an arbitrary global geoid matches it.
|
|
|
|
### 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 UTC dates/times,
|
|
callsign, ICAO identifier, and average height. 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, terrain, and geoid data, for
|
|
checking the complete workflow without network access:
|
|
|
|
```sh
|
|
python examples/make_demo.py demo
|
|
```
|
|
|
|
Run the plotting command printed by the script. Its heights use synthetic,
|
|
known-compatible references; 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 heights, missing terrain, vertical
|
|
references, 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.
|
|
- Terrain and geoid: supplied by the operator; follow their separate licenses
|
|
and attribution requirements when distributing outputs.
|
|
|
|
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.
|