Initial Vibe coded project
This commit is contained in:
commit
0891cf12a5
21 files changed
+2830
No files matched your search
+11
@@ -0,0 +1,11 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
*.egg-info/
|
||||
dist/
|
||||
*.sqlite
|
||||
*.sqlite-journal
|
||||
*.svg
|
||||
.flight-map-cache/
|
||||
@@ -0,0 +1,284 @@
|
||||
# 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.
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
|
||||
# Flight Map Requirements
|
||||
|
||||
## Purpose
|
||||
|
||||
Create a Python program that generates an SVG map of a rectangular geographic
|
||||
area with recorded aircraft flight tracks overlaid, suitable for drawing with
|
||||
a physical pen plotter.
|
||||
|
||||
## Separate collection and plotting workflow
|
||||
|
||||
- Provide separate command-line commands for flight-data collection and SVG
|
||||
generation. Run collection first; generate maps later from the saved database.
|
||||
- The collection command accepts optional opposite map corners and the SQLite
|
||||
database destination. If corners are omitted, use the default map area
|
||||
including Uithoorn in the Netherlands. It records flight data without
|
||||
generating an SVG or requiring map or terrain downloads.
|
||||
- Store a recording-session record containing a session identifier, the
|
||||
normalized requested map bounds, the expanded collection bounds, the flight
|
||||
data source, the collection interval, and the recording start and end times.
|
||||
Preserve this metadata across interruption or restart.
|
||||
- Associate every stored flight observation with its recording session so its
|
||||
map area and collection context remain identifiable.
|
||||
- The plotting command accepts the database, a recording-session selection,
|
||||
an optional time interval within that recording, and output settings.
|
||||
Use the selected session's stored map bounds rather than requiring the user
|
||||
to enter the map area again.
|
||||
- Plotting must not contact OpenSky or start flight-data collection. It may
|
||||
obtain OpenStreetMap-derived vector data and terrain elevation data
|
||||
separately as needed.
|
||||
- Allow multiple maps with different time selections and output settings to be
|
||||
generated from the same recording without modifying the recorded flight
|
||||
observations or session metadata.
|
||||
- Report missing sessions, invalid time selections, and absence of usable
|
||||
observations explicitly.
|
||||
- Provide a purge command requiring an exact session identifier and explicit
|
||||
confirmation to permanently delete that session's observations and metadata
|
||||
in one transaction. Preserve all other sessions and their observations.
|
||||
- Purging a missing session or database must report an error without creating
|
||||
a database. Stop collection for the selected session before purging it.
|
||||
|
||||
## Map input and output
|
||||
|
||||
- At collection time, accept the longitude and latitude of two opposite corners
|
||||
of the map, validate coordinates, and normalize their order.
|
||||
- Provide a predefined default rectangular map area that includes the city of
|
||||
Uithoorn in the Netherlands. Explicitly supplied corners override this default.
|
||||
- Default southwest corner: latitude 52.232025, longitude 4.784498.
|
||||
Default northeast corner: latitude 52.253985, longitude 4.863525.
|
||||
Command-line bounds use longitude first: 4.784498 52.232025 4.863525 52.253985.
|
||||
- Store the actual bounds used, including when the default is selected, in the
|
||||
recording session. Later plotting must use those stored bounds rather than
|
||||
a potentially changed application default.
|
||||
- At plotting time, load the map bounds from the selected recording session.
|
||||
- Obtain OpenStreetMap-derived vector map data, either as vector tiles or
|
||||
geographic feature data, following the source's usage policy and including
|
||||
the required attribution. Standard raster map tiles are not suitable for
|
||||
the pen-plotter output.
|
||||
- Cache downloaded vector map data persistently by source, geographic bounds,
|
||||
and feature query. Reuse it across plotting runs without downloading it
|
||||
again. Provide a configurable cache directory and an explicit refresh option.
|
||||
- Write cache entries atomically so interrupted downloads or writes do not
|
||||
replace a previously usable entry. Report damaged cache entries explicitly.
|
||||
- Generate an SVG using a consistent geographic projection for the map and
|
||||
flight tracks.
|
||||
- Handle invalid input and download failures with clear error messages.
|
||||
|
||||
## Pen-plotter output
|
||||
|
||||
- Render map features and flight tracks as vector paths or line geometry,
|
||||
without embedded raster images or reliance on filled areas, shading, or
|
||||
other screen-only effects.
|
||||
- Render all labels and attribution as paths, using single-line stroke
|
||||
lettering suitable for pen plotting rather than relying on installed fonts
|
||||
or tracing filled glyph outlines.
|
||||
- Resolve clipping into the exported geometry so map features stop at the map
|
||||
boundary and flight-track extensions remain within the output page. Do not
|
||||
rely on SVG clipping masks being supported by the plotter software.
|
||||
- Specify physical page dimensions and a matching SVG viewBox so the intended
|
||||
drawing size is preserved when imported into plotter software.
|
||||
- Keep map geometry, flight tracks, and lettering in separate named SVG groups
|
||||
for independent selection and plotting.
|
||||
- Select and simplify map features to keep the drawing legible at its physical
|
||||
output size. Omit minor pedestrian and cycle paths and suppress building
|
||||
outlines whose shorter dimension is smaller than three pen widths.
|
||||
|
||||
## Flight data and local recording
|
||||
|
||||
- Use OpenSky Network as the initial flight-data source.
|
||||
- Retrieve live aircraft state vectors for the selected geographic area.
|
||||
- Query an area slightly larger than the map so recorded flight paths can
|
||||
extend beyond the map boundary.
|
||||
- Collect positions periodically, respecting OpenSky's current authentication
|
||||
requirements, rate limits, and usage terms.
|
||||
- Store observations locally in SQLite so maps can be generated later without
|
||||
fetching those observations again.
|
||||
- Store the aircraft's ICAO identifier, callsign when available, position
|
||||
timestamp, longitude, latitude, and available altitude measurements.
|
||||
- Support generating a map from locally recorded observations for a selected
|
||||
time interval.
|
||||
- Do not require historical data from before local recording began. Unrestricted
|
||||
historical access is not assumed.
|
||||
- Report recording failures explicitly and handle missing fields and gaps in
|
||||
coverage without inventing observations.
|
||||
- Initial use is non-commercial, subject to OpenSky's applicable terms and
|
||||
attribution requirements.
|
||||
|
||||
## Flight-track rendering
|
||||
|
||||
- Overlay recorded flight paths that cross the requested map area.
|
||||
- Fit smooth interpolating curves through recorded flight positions rather
|
||||
than drawing angular polylines. Export flight paths as cubic Bezier geometry
|
||||
suitable for a pen plotter. Two-point tracks may remain straight.
|
||||
- Do not smooth across track gaps, on-ground interruptions, or flight changes.
|
||||
Handle repeated positions without generating invalid curve coordinates.
|
||||
- Clip the actual curves to the output bounds, not just their sample points,
|
||||
and use the same curved in-map portions for time and average-height selection.
|
||||
- Extend the visible tracks a little beyond the map boundary using actual
|
||||
recorded positions from the surrounding area, not fabricated extrapolation.
|
||||
- Keep the map background within its requested bounds and provide space in the
|
||||
SVG for the extended tracks and their labels.
|
||||
- Identify each flight track with its date, time, callsign or flight number when
|
||||
available, and average height above the map's average ground elevation.
|
||||
- Do not present an OpenSky callsign as a verified passenger-facing flight
|
||||
number. Callsign-to-flight-number lookup is not required for the initial
|
||||
version.
|
||||
- Calculate the map's average ground elevation from terrain elevation data
|
||||
covering the requested map bounds, excluding the surrounding collection area.
|
||||
Use an area-weighted mean or an equivalent equal-area sampling method.
|
||||
- Calculate each track's average height as its average aircraft altitude over
|
||||
the portion inside the requested map bounds minus the map's average ground
|
||||
elevation. Outside-map extensions must not affect this calculation.
|
||||
- Use compatible units and a common vertical reference for aircraft altitude
|
||||
and terrain elevation, applying conversions where needed. Do not subtract
|
||||
terrain elevation directly from uncorrected barometric pressure altitude.
|
||||
- Label the result explicitly as average height above the map's average ground
|
||||
level, with units. This is not height above the terrain directly beneath each
|
||||
aircraft position and must not be presented as terrain clearance.
|
||||
- Indicate when the height cannot be calculated because compatible aircraft
|
||||
altitude or sufficient terrain data is unavailable; do not silently substitute
|
||||
altitude above sea level or assume zero ground elevation.
|
||||
- Make labels readable and indicate unavailable metadata explicitly.
|
||||
|
||||
## Implementation and validation
|
||||
|
||||
- Implement the program in Python with a command-line interface.
|
||||
- Document installation, the separate collection and plotting commands,
|
||||
session selection, map generation, configuration, data-source limitations,
|
||||
and attribution.
|
||||
- Test coordinate handling, projection, local recording, track selection,
|
||||
average ground elevation, vertical-reference compatibility, average-height
|
||||
calculation, missing elevation data, and SVG generation.
|
||||
- Test persistence of session bounds and observation associations, plotting
|
||||
after collection has stopped, and repeated plotting without changing the
|
||||
recorded flight data. Verify that collection does not require map or terrain
|
||||
services and plotting makes no OpenSky requests.
|
||||
- Test that omitting map corners selects the Uithoorn default, explicit corners
|
||||
override it, and the selected bounds are persisted for later plotting.
|
||||
- Validate that the exported SVG contains drawable vector geometry, path-based
|
||||
lettering, correct physical dimensions, and geometry clipped to its intended
|
||||
bounds, with no raster images or dependence on text rendering or clipping
|
||||
masks.
|
||||
- Include an end-to-end test using fixed sample data without requiring live
|
||||
network access.
|
||||
- Test cache reuse, separate entries for different map areas, explicit refresh,
|
||||
and preservation of cached data when refresh fails.
|
||||
|
||||
## Initial implementation defaults and limitations
|
||||
|
||||
- Use Overpass to obtain OpenStreetMap roads, waterways, building outlines, and
|
||||
water outlines, with local Overpass JSON also accepted for offline plotting.
|
||||
- Default to A4 landscape (297 by 210 mm), 12 mm page margins, a 0.3 mm pen,
|
||||
and track extensions of up to 4 mm beyond the map border.
|
||||
- Collect every 30 seconds within a 2,000 m margin around the saved map area.
|
||||
- Display timestamps in UTC using date and 24-hour time, and heights in metres.
|
||||
- Accept local terrain GeoTIFFs with a declared vertical reference. For
|
||||
orthometric terrain, accept a matching geoid-separation raster for converting
|
||||
ellipsoidal aircraft heights. Require explicit confirmation of the geometric
|
||||
altitude interpretation before calculating heights; otherwise label them
|
||||
unavailable. Never silently substitute barometric altitude.
|
||||
- Estimate average ground elevation using a 64 by 64 equal-area sampling grid.
|
||||
Any missing terrain sample makes the average unavailable.
|
||||
- Use time-weighted aircraft altitude with linear interpolation between recorded
|
||||
timestamps, restricted to the smoothed path's portion inside the map. Use
|
||||
centripetal Catmull-Rom interpolation converted to cubic Bezier curves for
|
||||
flight paths. Split tracks at changes
|
||||
in callsign, on-ground observations, or gaps exceeding 120 seconds by default.
|
||||
- Reject antimeridian-crossing bounds and latitudes outside [-85, 85] explicitly.
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Create entirely synthetic offline demo inputs; no real map or flight observations."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import rasterio
|
||||
from rasterio.transform import from_bounds
|
||||
|
||||
from flight_map.geo import DEFAULT_BOUNDS
|
||||
from flight_map.storage import (
|
||||
Observation,
|
||||
checkpoint,
|
||||
create_session,
|
||||
database,
|
||||
initialize,
|
||||
save_observations,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) != 2:
|
||||
raise SystemExit("Usage: python examples/make_demo.py OUTPUT_DIRECTORY")
|
||||
folder = Path(sys.argv[1])
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
db = folder / "demo.sqlite"
|
||||
if db.exists():
|
||||
raise SystemExit(f"Refusing to replace {db}")
|
||||
started = 1_792_497_600.0
|
||||
with database(db) as connection:
|
||||
initialize(connection)
|
||||
session = create_session(connection, DEFAULT_BOUNDS, 2000, 30, started)
|
||||
items = []
|
||||
northings = [52.235, 52.237, 52.244, 52.247, 52.246]
|
||||
for index in range(5):
|
||||
items.append(
|
||||
Observation(
|
||||
"abc123",
|
||||
"DEMO123",
|
||||
started + index * 30,
|
||||
4.77 + index * 0.027,
|
||||
northings[index],
|
||||
900 + index * 30,
|
||||
1043 + index * 30,
|
||||
)
|
||||
)
|
||||
items.append(
|
||||
Observation(
|
||||
"def456",
|
||||
"DEMO456",
|
||||
started + index * 30,
|
||||
4.85 - index * 0.013,
|
||||
52.26 - index * 0.008,
|
||||
700,
|
||||
843,
|
||||
)
|
||||
)
|
||||
save_observations(connection, session, items, started + 150)
|
||||
checkpoint(connection, session.id, started + 150, "stopped")
|
||||
ways = [
|
||||
[(4.77, 52.235), (4.81, 52.24), (4.87, 52.25)],
|
||||
[(4.80, 52.23), (4.82, 52.245), (4.81, 52.26)],
|
||||
[(4.835, 52.23), (4.84, 52.26)],
|
||||
[(4.82, 52.242), (4.823, 52.242), (4.823, 52.244), (4.82, 52.244), (4.82, 52.242)],
|
||||
]
|
||||
(folder / "map.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"elements": [
|
||||
{
|
||||
"type": "way",
|
||||
"id": index,
|
||||
"geometry": [{"lon": lon, "lat": lat} for lon, lat in points],
|
||||
}
|
||||
for index, points in enumerate(ways)
|
||||
]
|
||||
}
|
||||
)
|
||||
)
|
||||
for name, value in (("terrain.tif", -2), ("geoid.tif", 43)):
|
||||
with rasterio.open(
|
||||
folder / name,
|
||||
"w",
|
||||
driver="GTiff",
|
||||
height=64,
|
||||
width=64,
|
||||
count=1,
|
||||
dtype="float32",
|
||||
crs="EPSG:4326",
|
||||
transform=from_bounds(*session.collection_bounds.values, 64, 64),
|
||||
) as dataset:
|
||||
dataset.write(np.full((1, 64, 64), value, dtype="float32"))
|
||||
print(f"Synthetic demo session: {session.id}")
|
||||
print(
|
||||
f"flight-map plot --db '{db}' --session {session.id} "
|
||||
f"--map-data '{folder / 'map.json'}' --terrain '{folder / 'terrain.tif'}' "
|
||||
f"--terrain-reference orthometric --geoid '{folder / 'geoid.tif'}' "
|
||||
f"--confirm-geometric-ellipsoid --output '{folder / 'demo.svg'}'"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
"""OpenSky recording and independent pen-plotter map generation."""
|
||||
@@ -0,0 +1,3 @@
|
||||
from flight_map.cli import main
|
||||
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,194 @@
|
||||
import argparse
|
||||
import logging
|
||||
import math
|
||||
import sqlite3
|
||||
import sys
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from flight_map.geo import DEFAULT_BOUNDS, Bounds
|
||||
from flight_map.storage import create_session, database, get_session, initialize, purge_session
|
||||
|
||||
|
||||
def positive(value: str) -> float:
|
||||
try:
|
||||
result = float(value)
|
||||
except ValueError as exc:
|
||||
raise argparse.ArgumentTypeError("Expected a positive finite number.") from exc
|
||||
if not math.isfinite(result) or result <= 0:
|
||||
raise argparse.ArgumentTypeError("Expected a positive finite number.")
|
||||
return result
|
||||
|
||||
|
||||
def timestamp(value: str) -> float:
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value)
|
||||
except ValueError as exc:
|
||||
raise argparse.ArgumentTypeError(
|
||||
"Use an ISO 8601 timestamp, e.g. 2026-10-20T12:00:00Z."
|
||||
) from exc
|
||||
if parsed.tzinfo is None:
|
||||
raise argparse.ArgumentTypeError("Timestamps must include a time zone, such as Z for UTC.")
|
||||
return parsed.timestamp()
|
||||
|
||||
|
||||
def parser() -> argparse.ArgumentParser:
|
||||
root = argparse.ArgumentParser(description="Record flights, then draw pen-plotter SVG maps.")
|
||||
sub = root.add_subparsers(dest="command", required=True)
|
||||
record = sub.add_parser("collect", help="Record OpenSky positions to SQLite; no map downloads")
|
||||
record.add_argument("--db", type=Path, required=True)
|
||||
record.add_argument(
|
||||
"--bounds",
|
||||
type=float,
|
||||
nargs=4,
|
||||
metavar=("LON1", "LAT1", "LON2", "LAT2"),
|
||||
help="Opposite corners, longitude first; default: Uithoorn",
|
||||
)
|
||||
record.add_argument(
|
||||
"--interval", type=positive, help="Seconds between polls (default: 30, min: 10)"
|
||||
)
|
||||
record.add_argument(
|
||||
"--collection-margin", type=positive, help="Metres outside map (default: 2000)"
|
||||
)
|
||||
record.add_argument(
|
||||
"--duration", type=positive, help="Seconds to record; omit to run until Ctrl-C"
|
||||
)
|
||||
record.add_argument(
|
||||
"--resume", metavar="SESSION", help="Resume a stopped/interrupted recording"
|
||||
)
|
||||
listing = sub.add_parser("sessions", help="List saved recording sessions")
|
||||
listing.add_argument("--db", type=Path, required=True)
|
||||
purge = sub.add_parser("purge", help="Permanently delete one session and its observations")
|
||||
purge.add_argument("--db", type=Path, required=True)
|
||||
purge.add_argument("--session", required=True, help="Exact recording session ID to delete")
|
||||
purge.add_argument(
|
||||
"--yes",
|
||||
action="store_true",
|
||||
required=True,
|
||||
help="Confirm permanent deletion; stop collection for this session first",
|
||||
)
|
||||
plot = sub.add_parser("plot", help="Draw from a saved session; never contacts OpenSky")
|
||||
plot.add_argument("--db", type=Path, required=True)
|
||||
plot.add_argument("--session", required=True)
|
||||
plot.add_argument("--output", type=Path, required=True)
|
||||
plot.add_argument("--from", dest="start", type=timestamp)
|
||||
plot.add_argument("--to", dest="end", type=timestamp)
|
||||
plot.add_argument("--width", type=positive, default=297, help="Page width in mm")
|
||||
plot.add_argument("--height", type=positive, default=210, help="Page height in mm")
|
||||
plot.add_argument("--margin", type=positive, default=12, help="Page margin in mm")
|
||||
plot.add_argument("--extension", type=positive, default=4, help="Track extension in mm")
|
||||
plot.add_argument("--pen-width", type=positive, default=0.3, help="Pen width in mm")
|
||||
plot.add_argument("--font-height", type=positive, default=1.8, help="Letter height in mm")
|
||||
plot.add_argument(
|
||||
"--max-gap", type=positive, default=120, help="Split tracks at gaps above seconds"
|
||||
)
|
||||
plot.add_argument("--cache", type=Path, default=Path(".flight-map-cache"))
|
||||
plot.add_argument(
|
||||
"--refresh-map",
|
||||
action="store_true",
|
||||
help="Download fresh vector map data and atomically replace its cache entry",
|
||||
)
|
||||
plot.add_argument(
|
||||
"--map-data", type=Path, help="Local Overpass JSON (out geom), for offline plotting"
|
||||
)
|
||||
plot.add_argument(
|
||||
"--terrain", type=Path, help="Local terrain GeoTIFF; elevations must be in metres"
|
||||
)
|
||||
plot.add_argument(
|
||||
"--terrain-reference",
|
||||
choices=("unknown", "wgs84-ellipsoid", "orthometric"),
|
||||
default="unknown",
|
||||
)
|
||||
plot.add_argument(
|
||||
"--geoid",
|
||||
type=Path,
|
||||
help="Metre-valued h-H raster matching the terrain's orthometric datum",
|
||||
)
|
||||
plot.add_argument(
|
||||
"--confirm-geometric-ellipsoid",
|
||||
action="store_true",
|
||||
help="Confirm OpenSky geometric heights can be treated as WGS84 ellipsoidal",
|
||||
)
|
||||
plot.add_argument("--force", action="store_true", help="Replace an existing SVG")
|
||||
return root
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = parser().parse_args(argv)
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
||||
try:
|
||||
if args.command == "collect":
|
||||
from flight_map.record import OpenSky, collect
|
||||
|
||||
if args.resume and (args.bounds or args.interval or args.collection_margin):
|
||||
raise ValueError("Resuming uses stored bounds and interval; do not override them.")
|
||||
client = OpenSky()
|
||||
if args.resume and not args.db.exists():
|
||||
raise ValueError("Cannot resume: recording database does not exist.")
|
||||
with database(args.db) as connection:
|
||||
initialize(connection)
|
||||
if args.resume:
|
||||
session = get_session(connection, args.resume)
|
||||
else:
|
||||
bounds = Bounds.corners(*args.bounds) if args.bounds else DEFAULT_BOUNDS
|
||||
session = create_session(
|
||||
connection,
|
||||
bounds,
|
||||
args.collection_margin or 2000,
|
||||
args.interval or 30,
|
||||
time.time(),
|
||||
)
|
||||
print(f"Session: {session.id}", flush=True)
|
||||
print(f"Map bounds (W,S,E,N): {session.bounds.values}", flush=True)
|
||||
collect(connection, session, client, args.duration)
|
||||
elif args.command == "sessions":
|
||||
with database(args.db, readonly=True) as connection:
|
||||
for row in connection.execute("SELECT id FROM sessions ORDER BY started"):
|
||||
session = get_session(connection, row["id"])
|
||||
count = connection.execute(
|
||||
"SELECT COUNT(*) FROM observations WHERE session_id = ?", (session.id,)
|
||||
).fetchone()[0]
|
||||
started = datetime.fromtimestamp(session.started, UTC).isoformat()
|
||||
ended = datetime.fromtimestamp(session.ended, UTC).isoformat()
|
||||
print(
|
||||
f"{session.id} {session.status} {started} to {ended} "
|
||||
f"{count} observations bounds={session.bounds.values}"
|
||||
)
|
||||
elif args.command == "purge":
|
||||
with database(args.db, must_exist=True) as connection:
|
||||
count = purge_session(connection, args.session)
|
||||
print(f"Purged session {args.session} and {count} observation(s).")
|
||||
else:
|
||||
from flight_map.plot import PlotOptions, render
|
||||
|
||||
options = PlotOptions(
|
||||
args.width,
|
||||
args.height,
|
||||
args.margin,
|
||||
args.extension,
|
||||
args.pen_width,
|
||||
args.font_height,
|
||||
args.max_gap,
|
||||
)
|
||||
count = render(
|
||||
args.db,
|
||||
args.session,
|
||||
args.output,
|
||||
start=args.start,
|
||||
end=args.end,
|
||||
options=options,
|
||||
cache=args.cache,
|
||||
local_map=args.map_data,
|
||||
terrain=args.terrain,
|
||||
terrain_reference=args.terrain_reference,
|
||||
geoid=args.geoid,
|
||||
confirm_geometric_ellipsoid=args.confirm_geometric_ellipsoid,
|
||||
force=args.force,
|
||||
refresh_map=args.refresh_map,
|
||||
)
|
||||
print(f"Wrote {args.output} with {count} flight track(s).")
|
||||
except (ValueError, RuntimeError, OSError, sqlite3.Error) as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Interpolating cubic flight paths and geometric (not mask-based) clipping."""
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
|
||||
Point2D = tuple[float, float]
|
||||
Rectangle = tuple[float, float, float, float]
|
||||
|
||||
|
||||
def mix(a: Point2D, b: Point2D, fraction: float) -> Point2D:
|
||||
return a[0] + (b[0] - a[0]) * fraction, a[1] + (b[1] - a[1]) * fraction
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Cubic:
|
||||
start: Point2D
|
||||
control1: Point2D
|
||||
control2: Point2D
|
||||
end: Point2D
|
||||
|
||||
@property
|
||||
def points(self) -> tuple[Point2D, Point2D, Point2D, Point2D]:
|
||||
return self.start, self.control1, self.control2, self.end
|
||||
|
||||
def point(self, fraction: float) -> Point2D:
|
||||
a, b, c = (
|
||||
mix(p, q, fraction)
|
||||
for p, q in (
|
||||
(self.start, self.control1),
|
||||
(self.control1, self.control2),
|
||||
(self.control2, self.end),
|
||||
)
|
||||
)
|
||||
return mix(mix(a, b, fraction), mix(b, c, fraction), fraction)
|
||||
|
||||
def split(self, fraction: float) -> tuple["Cubic", "Cubic"]:
|
||||
a, b, c = (
|
||||
mix(p, q, fraction)
|
||||
for p, q in (
|
||||
(self.start, self.control1),
|
||||
(self.control1, self.control2),
|
||||
(self.control2, self.end),
|
||||
)
|
||||
)
|
||||
d, e = mix(a, b, fraction), mix(b, c, fraction)
|
||||
middle = mix(d, e, fraction)
|
||||
return Cubic(self.start, a, d, middle), Cubic(middle, e, c, self.end)
|
||||
|
||||
def portion(self, first: float, last: float) -> "Cubic":
|
||||
if not 0 <= first < last <= 1:
|
||||
raise ValueError("Curve portion must be an increasing interval in [0, 1].")
|
||||
left = self if last == 1 else self.split(last)[0]
|
||||
return left if first == 0 else left.split(first / last)[1]
|
||||
|
||||
def transform(self, matrix: list[float]) -> "Cubic":
|
||||
a, b, d, e, xoff, yoff = matrix
|
||||
points = [(a * x + b * y + xoff, d * x + e * y + yoff) for x, y in self.points]
|
||||
return Cubic(*points)
|
||||
|
||||
def boundary_roots(self, axis: int, boundary: float) -> list[float]:
|
||||
p0, p1, p2, p3 = (point[axis] for point in self.points)
|
||||
a = -p0 + 3 * p1 - 3 * p2 + p3
|
||||
b = 3 * p0 - 6 * p1 + 3 * p2
|
||||
c = -3 * p0 + 3 * p1
|
||||
critical = [0.0, 1.0]
|
||||
if abs(a) < 1e-12:
|
||||
if abs(b) >= 1e-12:
|
||||
critical.append(-c / (2 * b))
|
||||
else:
|
||||
discriminant = 4 * b * b - 12 * a * c
|
||||
if discriminant >= 0:
|
||||
root = math.sqrt(discriminant)
|
||||
critical.extend(((-2 * b - root) / (6 * a), (-2 * b + root) / (6 * a)))
|
||||
critical = sorted(t for t in critical if 0 <= t <= 1)
|
||||
roots = []
|
||||
for first, last in zip(critical, critical[1:], strict=False):
|
||||
low, high = first, last
|
||||
f_low = self.point(low)[axis] - boundary
|
||||
f_high = self.point(high)[axis] - boundary
|
||||
if abs(f_low) < 1e-9:
|
||||
roots.append(low)
|
||||
if abs(f_high) < 1e-9:
|
||||
roots.append(high)
|
||||
if f_low * f_high >= 0:
|
||||
continue
|
||||
for _ in range(55):
|
||||
middle = (low + high) / 2
|
||||
f_middle = self.point(middle)[axis] - boundary
|
||||
if f_low * f_middle <= 0:
|
||||
high = middle
|
||||
else:
|
||||
low, f_low = middle, f_middle
|
||||
roots.append((low + high) / 2)
|
||||
return roots
|
||||
|
||||
def inside_intervals(self, rectangle: Rectangle) -> list[tuple[float, float]]:
|
||||
west, south, east, north = rectangle
|
||||
xs, ys = [p[0] for p in self.points], [p[1] for p in self.points]
|
||||
if min(xs) >= west and max(xs) <= east and min(ys) >= south and max(ys) <= north:
|
||||
return [(0.0, 1.0)]
|
||||
if max(xs) < west or min(xs) > east or max(ys) < south or min(ys) > north:
|
||||
return []
|
||||
parameters = [0.0, 1.0]
|
||||
for axis, boundary in ((0, west), (0, east), (1, south), (1, north)):
|
||||
parameters.extend(self.boundary_roots(axis, boundary))
|
||||
parameters.sort()
|
||||
unique = [parameters[0]]
|
||||
for value in parameters[1:]:
|
||||
if value - unique[-1] > 1e-12:
|
||||
unique.append(value)
|
||||
result: list[tuple[float, float]] = []
|
||||
for first, last in zip(unique, unique[1:], strict=False):
|
||||
x, y = self.point((first + last) / 2)
|
||||
if west - 1e-9 <= x <= east + 1e-9 and south - 1e-9 <= y <= north + 1e-9:
|
||||
if result and abs(result[-1][1] - first) < 1e-12:
|
||||
result[-1] = result[-1][0], last
|
||||
else:
|
||||
result.append((first, last))
|
||||
return result
|
||||
|
||||
def clipped(self, rectangle: Rectangle) -> list["Cubic"]:
|
||||
return [self.portion(first, last) for first, last in self.inside_intervals(rectangle)]
|
||||
|
||||
|
||||
def interpolate(points: list[Point2D]) -> list[Cubic]:
|
||||
"""Centripetal Catmull-Rom converted to cubic Beziers through every sample."""
|
||||
result = []
|
||||
for index in range(len(points) - 1):
|
||||
p1, p2 = points[index], points[index + 1]
|
||||
if p1 == p2:
|
||||
result.append(Cubic(p1, p1, p2, p2))
|
||||
continue
|
||||
p0 = points[index - 1] if index > 0 else p1
|
||||
p3 = points[index + 2] if index + 2 < len(points) else p2
|
||||
if p0 == p1:
|
||||
p0 = (2 * p1[0] - p2[0], 2 * p1[1] - p2[1])
|
||||
if p3 == p2:
|
||||
p3 = (2 * p2[0] - p1[0], 2 * p2[1] - p1[1])
|
||||
d01 = math.sqrt(math.dist(p0, p1))
|
||||
d12 = math.sqrt(math.dist(p1, p2))
|
||||
d23 = math.sqrt(math.dist(p2, p3))
|
||||
control1 = []
|
||||
control2 = []
|
||||
for axis in (0, 1):
|
||||
tangent1 = d12 * (
|
||||
(p1[axis] - p0[axis]) / d01
|
||||
- (p2[axis] - p0[axis]) / (d01 + d12)
|
||||
+ (p2[axis] - p1[axis]) / d12
|
||||
)
|
||||
tangent2 = d12 * (
|
||||
(p2[axis] - p1[axis]) / d12
|
||||
- (p3[axis] - p1[axis]) / (d12 + d23)
|
||||
+ (p3[axis] - p2[axis]) / d23
|
||||
)
|
||||
control1.append(p1[axis] + tangent1 / 3)
|
||||
control2.append(p2[axis] - tangent2 / 3)
|
||||
result.append(Cubic(p1, (control1[0], control1[1]), (control2[0], control2[1]), p2))
|
||||
return result
|
||||
@@ -0,0 +1,63 @@
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
|
||||
from shapely.geometry import Polygon, box
|
||||
|
||||
EARTH_RADIUS = 6_371_008.8
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Bounds:
|
||||
west: float
|
||||
south: float
|
||||
east: float
|
||||
north: float
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not all(math.isfinite(v) for v in self.values):
|
||||
raise ValueError("Map coordinates must be finite.")
|
||||
if not (-180 <= self.west < self.east <= 180):
|
||||
raise ValueError("Longitude bounds must increase within [-180, 180].")
|
||||
if self.east - self.west > 180:
|
||||
raise ValueError("Antimeridian-crossing maps are not supported.")
|
||||
if not (-85 <= self.south < self.north <= 85):
|
||||
raise ValueError("Latitude bounds must increase within [-85, 85].")
|
||||
|
||||
@property
|
||||
def values(self) -> tuple[float, float, float, float]:
|
||||
return self.west, self.south, self.east, self.north
|
||||
|
||||
@classmethod
|
||||
def corners(cls, lon1: float, lat1: float, lon2: float, lat2: float) -> "Bounds":
|
||||
return cls(min(lon1, lon2), min(lat1, lat2), max(lon1, lon2), max(lat1, lat2))
|
||||
|
||||
def expand(self, metres: float) -> "Bounds":
|
||||
if not math.isfinite(metres) or metres <= 0:
|
||||
raise ValueError("Collection margin must be a positive number of metres.")
|
||||
dy = math.degrees(metres / EARTH_RADIUS)
|
||||
dx = dy / math.cos(math.radians((self.south + self.north) / 2))
|
||||
return Bounds(self.west - dx, self.south - dy, self.east + dx, self.north + dy)
|
||||
|
||||
def contains(self, lon: float, lat: float) -> bool:
|
||||
return self.west <= lon <= self.east and self.south <= lat <= self.north
|
||||
|
||||
|
||||
DEFAULT_BOUNDS = Bounds(4.784, 52.232, 4.864, 52.258)
|
||||
|
||||
|
||||
class Projection:
|
||||
"""Local equirectangular projection: geographic bounds remain rectangular."""
|
||||
|
||||
def __init__(self, bounds: Bounds):
|
||||
self.lon0 = (bounds.west + bounds.east) / 2
|
||||
self.lat0 = (bounds.south + bounds.north) / 2
|
||||
self.x_factor = EARTH_RADIUS * math.cos(math.radians(self.lat0))
|
||||
west, south = self.point(bounds.west, bounds.south)
|
||||
east, north = self.point(bounds.east, bounds.north)
|
||||
self.rectangle: Polygon = box(west, south, east, north)
|
||||
|
||||
def point(self, lon: float, lat: float) -> tuple[float, float]:
|
||||
return (
|
||||
self.x_factor * math.radians(lon - self.lon0),
|
||||
EARTH_RADIUS * math.radians(lat - self.lat0),
|
||||
)
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Small original single-stroke alphabet; coordinates use a 4 by 6 grid."""
|
||||
|
||||
import unicodedata
|
||||
|
||||
from shapely.geometry import LineString
|
||||
|
||||
GLYPHS = {
|
||||
"A": ["06 20 46", "14 34"],
|
||||
"B": ["06 00 30 41 32 02", "32 44 35 06"],
|
||||
"C": ["40 10 01 05 16 46"],
|
||||
"D": ["06 00 20 41 45 26 06"],
|
||||
"E": ["40 00 06 46", "03 33"],
|
||||
"F": ["06 00 40", "03 33"],
|
||||
"G": ["40 10 01 05 16 46 43 23"],
|
||||
"H": ["00 06", "40 46", "03 43"],
|
||||
"I": ["00 40", "20 26", "06 46"],
|
||||
"J": ["00 40 45 36 16 05"],
|
||||
"K": ["00 06", "40 03 46"],
|
||||
"L": ["00 06 46"],
|
||||
"M": ["06 00 23 40 46"],
|
||||
"N": ["06 00 46 40"],
|
||||
"O": ["10 30 41 45 36 16 05 01 10"],
|
||||
"P": ["06 00 30 41 32 02"],
|
||||
"Q": ["10 30 41 45 36 16 05 01 10", "24 46"],
|
||||
"R": ["06 00 30 41 32 02", "22 46"],
|
||||
"S": ["40 10 01 12 33 44 35 06"],
|
||||
"T": ["00 40", "20 26"],
|
||||
"U": ["00 05 16 36 45 40"],
|
||||
"V": ["00 26 40"],
|
||||
"W": ["00 16 23 36 40"],
|
||||
"X": ["00 46", "40 06"],
|
||||
"Y": ["00 23 40", "23 26"],
|
||||
"Z": ["00 40 06 46"],
|
||||
"0": ["10 30 41 45 36 16 05 01 10", "05 41"],
|
||||
"1": ["02 20 26", "06 46"],
|
||||
"2": ["01 10 30 41 32 06 46"],
|
||||
"3": ["00 30 41 22 43 45 36 06"],
|
||||
"4": ["00 03 43", "40 46"],
|
||||
"5": ["40 00 03 33 44 35 06"],
|
||||
"6": ["40 10 01 05 16 36 45 34 03"],
|
||||
"7": ["00 40 16"],
|
||||
"8": ["12 01 10 30 41 32 12 03 05 16 36 45 43 32"],
|
||||
"9": ["43 12 01 10 30 41 45 36 06"],
|
||||
"-": ["03 43"],
|
||||
".": ["25 26"],
|
||||
",": ["25 16"],
|
||||
":": ["21 22", "24 25"],
|
||||
"/": ["06 40"],
|
||||
"(": ["30 11 15 36"],
|
||||
")": ["10 31 35 16"],
|
||||
"?": ["01 10 30 41 32 23 24", "25 26"],
|
||||
"+": ["03 43", "20 26"],
|
||||
"=": ["02 42", "04 44"],
|
||||
"'": ["20 21"],
|
||||
" ": [],
|
||||
}
|
||||
|
||||
|
||||
def lettering(text: str, x: float, y: float, height: float) -> list[LineString]:
|
||||
text = unicodedata.normalize("NFKD", text).upper()
|
||||
result = []
|
||||
for index, character in enumerate(text):
|
||||
if character not in GLYPHS:
|
||||
raise ValueError(f"Unsupported stroke lettering character: {character!r}")
|
||||
for stroke in GLYPHS[character]:
|
||||
points = [
|
||||
(x + index * height + int(p[0]) * height / 6, y + int(p[1]) * height / 6)
|
||||
for p in stroke.split()
|
||||
]
|
||||
result.append(LineString(points))
|
||||
return result
|
||||
@@ -0,0 +1,127 @@
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from shapely.geometry import LineString
|
||||
|
||||
from flight_map.geo import Bounds, Projection
|
||||
from flight_map.network import request_json
|
||||
|
||||
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def validate_map_data(data: Any) -> dict[str, Any]:
|
||||
if not isinstance(data, dict) or not isinstance(data.get("elements"), list):
|
||||
raise ValueError("Map data must be an Overpass JSON object containing elements.")
|
||||
if data.get("remark"):
|
||||
raise ValueError(f"Overpass could not complete the map query: {data['remark']}")
|
||||
return data
|
||||
|
||||
|
||||
def map_data(
|
||||
bounds: Bounds, cache: Path, local_file: Path | None = None, *, refresh: bool = False
|
||||
) -> dict[str, Any]:
|
||||
if local_file and refresh:
|
||||
raise ValueError("--refresh-map cannot be combined with --map-data.")
|
||||
bbox = f"{bounds.south},{bounds.west},{bounds.north},{bounds.east}"
|
||||
query = (
|
||||
"[out:json][timeout:60];("
|
||||
f'way["highway"]({bbox});way["waterway"]({bbox});'
|
||||
f'way["building"]({bbox});way["natural"="water"]({bbox});'
|
||||
");out geom;"
|
||||
)
|
||||
key = hashlib.sha256((OVERPASS_URL + "\n" + query).encode()).hexdigest()
|
||||
path = local_file or cache / f"osm-{key}.json"
|
||||
if path.exists() and (local_file or not refresh):
|
||||
LOGGER.info("Reading %s map data: %s", "local" if local_file else "cached", path)
|
||||
with path.open() as stream:
|
||||
try:
|
||||
data = json.load(stream)
|
||||
except (ValueError, UnicodeError) as exc:
|
||||
raise ValueError(
|
||||
f"Invalid map JSON in {path}; correct the file or use --refresh-map "
|
||||
"to replace a damaged cache entry."
|
||||
) from exc
|
||||
elif local_file:
|
||||
raise ValueError(f"Local map data file does not exist: {local_file}")
|
||||
else:
|
||||
LOGGER.info("Downloading vector map data from Overpass for %s", bounds.values)
|
||||
data = request_json(
|
||||
OVERPASS_URL,
|
||||
data=urlencode({"data": query}).encode(),
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
validate_map_data(data)
|
||||
cache.mkdir(parents=True, exist_ok=True)
|
||||
temporary: Path | None = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", encoding="utf-8", dir=cache, suffix=".tmp", delete=False
|
||||
) as stream:
|
||||
temporary = Path(stream.name)
|
||||
json.dump(data, stream)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, path)
|
||||
finally:
|
||||
if temporary and temporary.exists():
|
||||
temporary.unlink()
|
||||
LOGGER.info("Cached vector map data: %s", path)
|
||||
return validate_map_data(data)
|
||||
|
||||
|
||||
def map_lines(
|
||||
data: dict[str, Any], projection: Projection, *, min_building_size: float = 0
|
||||
) -> list[LineString]:
|
||||
result = []
|
||||
for element in data["elements"]:
|
||||
if not isinstance(element, dict):
|
||||
raise ValueError("Map data contains a malformed feature.")
|
||||
if element.get("type") != "way":
|
||||
continue
|
||||
tags = element.get("tags", {})
|
||||
if not isinstance(tags, dict):
|
||||
raise ValueError("Map feature tags must be an object.")
|
||||
if tags.get("highway") in {
|
||||
"footway",
|
||||
"path",
|
||||
"steps",
|
||||
"cycleway",
|
||||
"bridleway",
|
||||
"corridor",
|
||||
"construction",
|
||||
"proposed",
|
||||
}:
|
||||
continue
|
||||
geometry = element.get("geometry")
|
||||
if not isinstance(geometry, list) or len(geometry) < 2:
|
||||
raise ValueError("Map way has no usable geometry; export using 'out geom'.")
|
||||
try:
|
||||
positions = [(float(p["lon"]), float(p["lat"])) for p in geometry]
|
||||
except (KeyError, ValueError, TypeError) as exc:
|
||||
raise ValueError("Map way contains invalid geographic coordinates.") from exc
|
||||
if any(
|
||||
not math.isfinite(lon)
|
||||
or not math.isfinite(lat)
|
||||
or not -180 <= lon <= 180
|
||||
or not -90 <= lat <= 90
|
||||
for lon, lat in positions
|
||||
):
|
||||
raise ValueError("Map way coordinates must be finite and within geographic limits.")
|
||||
points = [projection.point(lon, lat) for lon, lat in positions]
|
||||
line = LineString(points)
|
||||
if "building" in tags:
|
||||
west, south, east, north = line.bounds
|
||||
if min(east - west, north - south) < min_building_size:
|
||||
continue
|
||||
result.append(line)
|
||||
if not result:
|
||||
raise ValueError("No drawable map features were found for this area.")
|
||||
return result
|
||||
@@ -0,0 +1,25 @@
|
||||
import json
|
||||
from typing import Any
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
USER_AGENT = "FlightMap/0.1 (local non-commercial flight mapping)"
|
||||
|
||||
|
||||
def request_json(
|
||||
url: str, *, data: bytes | None = None, headers: dict[str, str] | None = None
|
||||
) -> Any:
|
||||
request = Request(url, data=data, headers={"User-Agent": USER_AGENT, **(headers or {})})
|
||||
try:
|
||||
with urlopen(request, timeout=60) as response:
|
||||
return json.load(response)
|
||||
except HTTPError as exc:
|
||||
retry = exc.headers.get("Retry-After")
|
||||
suffix = f"; retry after {retry}" if retry else ""
|
||||
raise RuntimeError(f"Remote service returned HTTP {exc.code}{suffix}.") from exc
|
||||
except (URLError, TimeoutError) as exc:
|
||||
raise RuntimeError(
|
||||
f"Remote service connection failed: {exc.reason if isinstance(exc, URLError) else exc}"
|
||||
) from exc
|
||||
except (ValueError, UnicodeError) as exc:
|
||||
raise RuntimeError("Remote service returned invalid JSON.") from exc
|
||||
@@ -0,0 +1,315 @@
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import tempfile
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from shapely.affinity import affine_transform
|
||||
from shapely.geometry import LineString, box
|
||||
from shapely.geometry.base import BaseGeometry
|
||||
|
||||
from flight_map.curves import Cubic
|
||||
from flight_map.geo import Projection
|
||||
from flight_map.lettering import lettering
|
||||
from flight_map.maps import map_data, map_lines
|
||||
from flight_map.storage import database, get_session, load_observations
|
||||
from flight_map.terrain import Elevation
|
||||
from flight_map.tracks import make_tracks
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
SVG_NS = "http://www.w3.org/2000/svg"
|
||||
ET.register_namespace("", SVG_NS)
|
||||
|
||||
|
||||
def tag(name: str) -> str:
|
||||
return f"{{{SVG_NS}}}{name}"
|
||||
|
||||
|
||||
def lines(geometry: BaseGeometry) -> Iterator[LineString]:
|
||||
if isinstance(geometry, LineString):
|
||||
if geometry.length > 0:
|
||||
yield geometry
|
||||
elif hasattr(geometry, "geoms"):
|
||||
for part in geometry.geoms:
|
||||
yield from lines(part)
|
||||
|
||||
|
||||
def add_line(group: ET.Element, line: LineString) -> None:
|
||||
coordinates = list(line.coords)
|
||||
path = "M " + " L ".join(f"{x:.5f},{y:.5f}" for x, y in coordinates)
|
||||
ET.SubElement(group, tag("path"), {"d": path})
|
||||
|
||||
|
||||
def add_curves(group: ET.Element, curves: list[Cubic]) -> None:
|
||||
commands = []
|
||||
previous = None
|
||||
for curve in curves:
|
||||
if all(point == curve.start for point in curve.points):
|
||||
continue
|
||||
if previous is None or math.dist(previous, curve.start) > 1e-7:
|
||||
commands.append(f"M {curve.start[0]:.5f},{curve.start[1]:.5f}")
|
||||
commands.append(
|
||||
"C "
|
||||
+ " ".join(
|
||||
f"{x:.5f},{y:.5f}"
|
||||
for x, y in (
|
||||
curve.control1,
|
||||
curve.control2,
|
||||
curve.end,
|
||||
)
|
||||
)
|
||||
)
|
||||
previous = curve.end
|
||||
if commands:
|
||||
ET.SubElement(group, tag("path"), {"d": " ".join(commands)})
|
||||
|
||||
|
||||
def label(group: ET.Element, text: str, x: float, y: float, height: float) -> None:
|
||||
for line in lettering(text, x, y, height):
|
||||
add_line(group, line)
|
||||
|
||||
|
||||
def utc(timestamp: float) -> str:
|
||||
return datetime.fromtimestamp(timestamp, UTC).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlotOptions:
|
||||
width: float = 297
|
||||
height: float = 210
|
||||
margin: float = 12
|
||||
extension: float = 4
|
||||
pen_width: float = 0.3
|
||||
font_height: float = 1.8
|
||||
max_gap: float = 120
|
||||
|
||||
def validate(self) -> None:
|
||||
if not all(
|
||||
math.isfinite(value) and value > 0
|
||||
for value in (
|
||||
self.width,
|
||||
self.height,
|
||||
self.margin,
|
||||
self.extension,
|
||||
self.pen_width,
|
||||
self.font_height,
|
||||
self.max_gap,
|
||||
)
|
||||
):
|
||||
raise ValueError(
|
||||
"Page dimensions, margins, pen width and plot settings must be positive."
|
||||
)
|
||||
if 2 * (self.margin + self.extension) >= min(self.width, self.height):
|
||||
raise ValueError("Margins and extensions leave no room for the map.")
|
||||
|
||||
|
||||
def render(
|
||||
db: Path,
|
||||
session_id: str,
|
||||
output: Path,
|
||||
*,
|
||||
start: float | None = None,
|
||||
end: float | None = None,
|
||||
options: PlotOptions | None = None,
|
||||
cache: Path = Path(".flight-map-cache"),
|
||||
local_map: Path | None = None,
|
||||
terrain: Path | None = None,
|
||||
terrain_reference: str = "unknown",
|
||||
geoid: Path | None = None,
|
||||
confirm_geometric_ellipsoid: bool = False,
|
||||
force: bool = False,
|
||||
refresh_map: bool = False,
|
||||
) -> int:
|
||||
options = options or PlotOptions()
|
||||
options.validate()
|
||||
if output.exists() and not force:
|
||||
raise ValueError(f"Output already exists: {output}. Use --force to replace it.")
|
||||
if any(
|
||||
output.resolve() == source.resolve()
|
||||
for source in (db, local_map, terrain, geoid)
|
||||
if source is not None
|
||||
):
|
||||
raise ValueError("SVG output must not overwrite the recording database or input data.")
|
||||
with database(db, readonly=True) as connection:
|
||||
session = get_session(connection, session_id)
|
||||
start = session.started if start is None else start
|
||||
end = session.ended if end is None else end
|
||||
if (
|
||||
not math.isfinite(start)
|
||||
or not math.isfinite(end)
|
||||
or start < session.started
|
||||
or end > session.ended
|
||||
or start >= end
|
||||
):
|
||||
raise ValueError("Time selection must be a nonempty interval within the recording.")
|
||||
observations = load_observations(connection, session_id, start, end)
|
||||
if not observations:
|
||||
raise ValueError("No recorded observations in the selected interval.")
|
||||
projection = Projection(session.bounds)
|
||||
with Elevation(terrain, terrain_reference, geoid, confirm_geometric_ellipsoid) as elevation:
|
||||
ground = elevation.mean_ground(session.bounds)
|
||||
tracks = make_tracks(observations, projection, elevation, ground, options.max_gap)
|
||||
if not tracks:
|
||||
raise ValueError("No usable airborne tracks cross the map in the selected interval.")
|
||||
if ground is None:
|
||||
LOGGER.warning(
|
||||
"Average ground elevation unavailable: supply a complete terrain raster "
|
||||
"and its vertical reference."
|
||||
)
|
||||
missing = sum(track.average_height is None for track in tracks)
|
||||
if missing:
|
||||
LOGGER.warning(
|
||||
"%d track height(s) unavailable: compatible geometric altitude, terrain "
|
||||
"and vertical-reference conversion are required.",
|
||||
missing,
|
||||
)
|
||||
data = map_data(session.bounds, cache, local_map, refresh=refresh_map)
|
||||
row_height = options.font_height * 1.8
|
||||
legend_height = (len(tracks) + 4) * row_height
|
||||
available_width = options.width - 2 * (options.margin + options.extension)
|
||||
available_height = options.height - 2 * (options.margin + options.extension) - legend_height
|
||||
if available_height < 25:
|
||||
raise ValueError("Too many tracks for this page. Select a shorter interval or larger page.")
|
||||
west, south, east, north = projection.rectangle.bounds
|
||||
scale = min(available_width / (east - west), available_height / (north - south))
|
||||
features = map_lines(data, projection, min_building_size=3 * options.pen_width / scale)
|
||||
map_width, map_height = (east - west) * scale, (north - south) * scale
|
||||
left = (options.width - map_width) / 2
|
||||
top = options.margin + options.extension
|
||||
matrix = [scale, 0, 0, -scale, left - west * scale, top + north * scale]
|
||||
map_rectangle = box(left, top, left + map_width, top + map_height)
|
||||
track_rectangle = box(
|
||||
left - options.extension,
|
||||
top - options.extension,
|
||||
left + map_width + options.extension,
|
||||
top + map_height + options.extension,
|
||||
)
|
||||
root = ET.Element(
|
||||
tag("svg"),
|
||||
{
|
||||
"width": f"{options.width:g}mm",
|
||||
"height": f"{options.height:g}mm",
|
||||
"viewBox": f"0 0 {options.width:g} {options.height:g}",
|
||||
"fill": "none",
|
||||
"stroke": "black",
|
||||
"stroke-width": f"{options.pen_width:g}",
|
||||
"stroke-linecap": "round",
|
||||
"stroke-linejoin": "round",
|
||||
},
|
||||
)
|
||||
ET.SubElement(root, tag("title")).text = "Recorded flight map"
|
||||
ET.SubElement(root, tag("desc")).text = (
|
||||
f"Session {session.id}; bounds W,S,E,N={session.bounds.values}; "
|
||||
f"{utc(start)} to {utc(end)} UTC. Local equirectangular projection. "
|
||||
"Height is time-weighted aircraft altitude within the map minus the "
|
||||
"area-sampled mean ground elevation, not terrain clearance. "
|
||||
f"Terrain reference: {terrain_reference}; "
|
||||
f"mean ground elevation in metres: {ground}; "
|
||||
f"geometric ellipsoid interpretation confirmed: {confirm_geometric_ellipsoid}. "
|
||||
"Map data: OpenStreetMap contributors, ODbL, https://www.openstreetmap.org/copyright. "
|
||||
"Flight data: The OpenSky Network, https://opensky-network.org. "
|
||||
"Schaefer et al., Bringing Up OpenSky: A Large-scale ADS-B Sensor Network "
|
||||
"for Research, IPSN 2014, pp. 83-94."
|
||||
)
|
||||
map_group = ET.SubElement(root, tag("g"), {"id": "map"})
|
||||
flight_group = ET.SubElement(root, tag("g"), {"id": "flights"})
|
||||
text_group = ET.SubElement(root, tag("g"), {"id": "lettering"})
|
||||
add_line(map_group, LineString(map_rectangle.exterior.coords))
|
||||
seen = set()
|
||||
for feature in features:
|
||||
page_feature = affine_transform(feature, matrix).intersection(map_rectangle)
|
||||
for part in lines(page_feature):
|
||||
simplified = part.simplify(0.05)
|
||||
key = simplified.normalize().wkb
|
||||
if key not in seen:
|
||||
for simplified_part in lines(simplified):
|
||||
add_line(map_group, simplified_part)
|
||||
seen.add(key)
|
||||
occupied: list[BaseGeometry] = []
|
||||
for index, track in enumerate(tracks, 1):
|
||||
page_curves = [
|
||||
part
|
||||
for curve in track.curves
|
||||
for part in curve.transform(matrix).clipped(track_rectangle.bounds)
|
||||
]
|
||||
group = ET.SubElement(
|
||||
flight_group,
|
||||
tag("g"),
|
||||
{
|
||||
"id": f"flight-{index}",
|
||||
"data-icao24": track.observations[0].icao24,
|
||||
"data-callsign": track.observations[0].callsign or "unavailable",
|
||||
"data-start-utc": utc(track.start),
|
||||
"data-end-utc": utc(track.end),
|
||||
"data-average-height-metres": (
|
||||
f"{track.average_height:.3f}"
|
||||
if track.average_height is not None
|
||||
else "unavailable"
|
||||
),
|
||||
},
|
||||
)
|
||||
add_curves(group, page_curves)
|
||||
short_label = str(index)
|
||||
placed = False
|
||||
for fraction in (0.9, 0.1, 0.7, 0.3, 0.5):
|
||||
curve_index = min(int(fraction * len(page_curves)), len(page_curves) - 1)
|
||||
anchor_x, anchor_y = page_curves[curve_index].point(
|
||||
fraction * len(page_curves) - curve_index
|
||||
)
|
||||
for dx, dy in ((1, 1), (1, -3), (-4, 1), (-4, -3)):
|
||||
x, y = anchor_x + dx, anchor_y + dy
|
||||
extent = box(
|
||||
x - 0.3,
|
||||
y - 0.3,
|
||||
x + len(short_label) * options.font_height,
|
||||
y + options.font_height + 0.3,
|
||||
)
|
||||
if track_rectangle.covers(extent) and not any(
|
||||
extent.intersects(previous) for previous in occupied
|
||||
):
|
||||
label(text_group, short_label, x, y, options.font_height)
|
||||
occupied.append(extent)
|
||||
placed = True
|
||||
break
|
||||
if placed:
|
||||
break
|
||||
if not placed:
|
||||
raise ValueError("Cannot place track identifiers legibly; select fewer tracks.")
|
||||
legend_y = top + map_height + options.extension + row_height
|
||||
ground_label = "N/A" if ground is None else f"{ground:.1f} M"
|
||||
legend = [
|
||||
"FLIGHT TIMES UTC - HEIGHT ABOVE MAP AVERAGE GROUND, NOT TERRAIN CLEARANCE",
|
||||
f"MAP AVERAGE GROUND ELEVATION: {ground_label} - N/A MEANS UNAVAILABLE",
|
||||
]
|
||||
for index, track in enumerate(tracks, 1):
|
||||
first = track.observations[0]
|
||||
height_label = "N/A" if track.average_height is None else f"{track.average_height:.0f} M"
|
||||
legend.append(
|
||||
f"{index}: {utc(track.start)} TO {utc(track.end)} "
|
||||
f"{first.callsign or 'CALLSIGN N/A'} ({first.icao24.upper()}) HEIGHT {height_label}"
|
||||
)
|
||||
legend.extend(
|
||||
[
|
||||
"MAP: OPENSTREETMAP CONTRIBUTORS (ODBL) - OPENSTREETMAP.ORG/COPYRIGHT",
|
||||
"FLIGHTS: THE OPENSKY NETWORK - OPENSKY-NETWORK.ORG",
|
||||
]
|
||||
)
|
||||
for index, text in enumerate(legend):
|
||||
if len(text) * options.font_height > options.width - 2 * options.margin:
|
||||
raise ValueError("Legend is too wide; reduce --font-height or increase page width.")
|
||||
label(text_group, text, options.margin, legend_y + index * row_height, options.font_height)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
temp_path: Path | None = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(dir=output.parent, suffix=".svg", delete=False) as stream:
|
||||
temp_path = Path(stream.name)
|
||||
ET.ElementTree(root).write(stream, encoding="utf-8", xml_declaration=True)
|
||||
os.replace(temp_path, output)
|
||||
finally:
|
||||
if temp_path and temp_path.exists():
|
||||
temp_path.unlink()
|
||||
return len(tracks)
|
||||
@@ -0,0 +1,116 @@
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from flight_map.geo import Bounds
|
||||
from flight_map.network import request_json
|
||||
from flight_map.storage import Observation, Session, checkpoint, save_observations
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
TOKEN_URL = (
|
||||
"https://auth.opensky-network.org/auth/realms/opensky-network/protocol/openid-connect/token"
|
||||
)
|
||||
|
||||
|
||||
class OpenSky:
|
||||
def __init__(self) -> None:
|
||||
self.client_id = os.environ.get("OPENSKY_CLIENT_ID")
|
||||
self.secret = os.environ.get("OPENSKY_CLIENT_SECRET")
|
||||
self.token = os.environ.get("OPENSKY_ACCESS_TOKEN")
|
||||
self.expires = float("inf") if self.token else 0.0
|
||||
if bool(self.client_id) != bool(self.secret):
|
||||
raise ValueError("Set both OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET.")
|
||||
if not self.client_id and not self.token:
|
||||
LOGGER.warning("Using anonymous OpenSky access; daily credit limits apply.")
|
||||
|
||||
def observations(self, bounds: Bounds) -> list[Observation]:
|
||||
if self.client_id and time.time() >= self.expires:
|
||||
response = request_json(
|
||||
TOKEN_URL,
|
||||
data=urlencode(
|
||||
{
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.secret,
|
||||
}
|
||||
).encode(),
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
if not isinstance(response, dict) or not isinstance(response.get("access_token"), str):
|
||||
raise RuntimeError("OpenSky authentication returned no access token.")
|
||||
self.token = response["access_token"]
|
||||
self.expires = time.time() + float(response.get("expires_in", 300)) - 30
|
||||
headers = {"Authorization": f"Bearer {self.token}"} if self.token else {}
|
||||
query = urlencode(
|
||||
{
|
||||
"lomin": bounds.west,
|
||||
"lamin": bounds.south,
|
||||
"lomax": bounds.east,
|
||||
"lamax": bounds.north,
|
||||
}
|
||||
)
|
||||
response = request_json(
|
||||
"https://opensky-network.org/api/states/all?" + query, headers=headers
|
||||
)
|
||||
if not isinstance(response, dict) or "states" not in response:
|
||||
raise RuntimeError("OpenSky response is missing aircraft states.")
|
||||
states = response["states"]
|
||||
if states is None:
|
||||
return []
|
||||
if not isinstance(states, list):
|
||||
raise RuntimeError("OpenSky aircraft states are malformed.")
|
||||
observations = []
|
||||
for state in states:
|
||||
if not isinstance(state, list) or len(state) < 14:
|
||||
raise RuntimeError("OpenSky returned a malformed aircraft state.")
|
||||
if any(state[index] is None for index in (3, 5, 6)):
|
||||
continue
|
||||
try:
|
||||
observations.append(
|
||||
Observation(
|
||||
str(state[0]).lower(),
|
||||
str(state[1]).strip() or None if state[1] else None,
|
||||
float(state[3]),
|
||||
float(state[5]),
|
||||
float(state[6]),
|
||||
float(state[7]) if state[7] is not None else None,
|
||||
float(state[13]) if state[13] is not None else None,
|
||||
bool(state[8]),
|
||||
)
|
||||
)
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise RuntimeError("OpenSky returned invalid aircraft observation values.") from exc
|
||||
return observations
|
||||
|
||||
|
||||
def collect(
|
||||
connection: sqlite3.Connection,
|
||||
session: Session,
|
||||
client: OpenSky,
|
||||
duration: float | None = None,
|
||||
) -> None:
|
||||
started = time.monotonic()
|
||||
checkpoint(connection, session.id, time.time(), "recording")
|
||||
try:
|
||||
while True:
|
||||
tick = time.monotonic()
|
||||
observations = client.observations(session.collection_bounds)
|
||||
save_observations(connection, session, observations, time.time())
|
||||
LOGGER.info("Session %s: received %d observations", session.id, len(observations))
|
||||
remaining = None if duration is None else duration - (time.monotonic() - started)
|
||||
if remaining is not None and remaining <= 0:
|
||||
break
|
||||
delay = max(0, session.interval - (time.monotonic() - tick))
|
||||
time.sleep(delay if remaining is None else min(delay, remaining))
|
||||
if duration is not None and time.monotonic() - started >= duration:
|
||||
break
|
||||
except KeyboardInterrupt:
|
||||
checkpoint(connection, session.id, time.time(), "interrupted")
|
||||
LOGGER.info("Recording interrupted; committed observations are preserved.")
|
||||
return
|
||||
except (RuntimeError, ValueError, OSError, sqlite3.Error):
|
||||
checkpoint(connection, session.id, time.time(), "failed")
|
||||
raise
|
||||
checkpoint(connection, session.id, time.time(), "stopped")
|
||||
@@ -0,0 +1,204 @@
|
||||
import json
|
||||
import math
|
||||
import sqlite3
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from flight_map.geo import Bounds
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Observation:
|
||||
icao24: str
|
||||
callsign: str | None
|
||||
timestamp: float
|
||||
longitude: float
|
||||
latitude: float
|
||||
baro_altitude: float | None
|
||||
geo_altitude: float | None
|
||||
on_ground: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if len(self.icao24) != 6 or any(c not in "0123456789abcdef" for c in self.icao24):
|
||||
raise ValueError("Invalid ICAO24 aircraft identifier.")
|
||||
if not all(math.isfinite(v) for v in (self.timestamp, self.longitude, self.latitude)):
|
||||
raise ValueError("Observation contains non-finite coordinates or timestamp.")
|
||||
if not (-180 <= self.longitude <= 180 and -90 <= self.latitude <= 90):
|
||||
raise ValueError("Observation coordinates are outside geographic limits.")
|
||||
for altitude in (self.baro_altitude, self.geo_altitude):
|
||||
if altitude is not None and not math.isfinite(altitude):
|
||||
raise ValueError("Altitude must be finite or missing.")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Session:
|
||||
id: str
|
||||
bounds: Bounds
|
||||
collection_bounds: Bounds
|
||||
interval: float
|
||||
started: float
|
||||
ended: float
|
||||
status: str
|
||||
source: str
|
||||
|
||||
|
||||
@contextmanager
|
||||
def database(
|
||||
path: Path, *, readonly: bool = False, must_exist: bool = False
|
||||
) -> Iterator[sqlite3.Connection]:
|
||||
if readonly or must_exist:
|
||||
mode = "ro" if readonly else "rw"
|
||||
connection = sqlite3.connect(path.resolve().as_uri() + f"?mode={mode}", uri=True)
|
||||
else:
|
||||
connection = sqlite3.connect(path)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA foreign_keys = ON")
|
||||
try:
|
||||
yield connection
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def initialize(connection: sqlite3.Connection) -> None:
|
||||
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
||||
if version not in (0, 1):
|
||||
raise ValueError(f"Unsupported flight database version: {version}")
|
||||
connection.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY, bounds TEXT NOT NULL, collection_bounds TEXT NOT NULL,
|
||||
interval REAL NOT NULL, started REAL NOT NULL, ended REAL NOT NULL,
|
||||
status TEXT NOT NULL, source TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS observations (
|
||||
session_id TEXT NOT NULL REFERENCES sessions(id),
|
||||
icao24 TEXT NOT NULL, callsign TEXT, timestamp REAL NOT NULL,
|
||||
longitude REAL NOT NULL, latitude REAL NOT NULL,
|
||||
baro_altitude REAL, geo_altitude REAL, on_ground INTEGER NOT NULL,
|
||||
PRIMARY KEY (session_id, icao24, timestamp)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS observation_time
|
||||
ON observations(session_id, timestamp);
|
||||
PRAGMA user_version = 1;
|
||||
""")
|
||||
|
||||
|
||||
def create_session(
|
||||
connection: sqlite3.Connection, bounds: Bounds, margin: float, interval: float, now: float
|
||||
) -> Session:
|
||||
if not math.isfinite(interval) or interval < 10:
|
||||
raise ValueError("Recording interval must be at least 10 seconds.")
|
||||
session = Session(
|
||||
uuid.uuid4().hex,
|
||||
bounds,
|
||||
bounds.expand(margin),
|
||||
interval,
|
||||
now,
|
||||
now,
|
||||
"recording",
|
||||
"OpenSky Network",
|
||||
)
|
||||
with connection:
|
||||
connection.execute(
|
||||
"INSERT INTO sessions VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
session.id,
|
||||
json.dumps(bounds.values),
|
||||
json.dumps(session.collection_bounds.values),
|
||||
interval,
|
||||
now,
|
||||
now,
|
||||
session.status,
|
||||
session.source,
|
||||
),
|
||||
)
|
||||
return session
|
||||
|
||||
|
||||
def get_session(connection: sqlite3.Connection, session_id: str) -> Session:
|
||||
row = connection.execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone()
|
||||
if row is None:
|
||||
raise ValueError(f"Recording session not found: {session_id}")
|
||||
return Session(
|
||||
row["id"],
|
||||
Bounds(*json.loads(row["bounds"])),
|
||||
Bounds(*json.loads(row["collection_bounds"])),
|
||||
row["interval"],
|
||||
row["started"],
|
||||
row["ended"],
|
||||
row["status"],
|
||||
row["source"],
|
||||
)
|
||||
|
||||
|
||||
def checkpoint(connection: sqlite3.Connection, session_id: str, now: float, status: str) -> None:
|
||||
with connection:
|
||||
connection.execute(
|
||||
"UPDATE sessions SET ended = ?, status = ? WHERE id = ?", (now, status, session_id)
|
||||
)
|
||||
|
||||
|
||||
def purge_session(connection: sqlite3.Connection, session_id: str) -> int:
|
||||
"""Delete one session and its observations in a single write transaction."""
|
||||
with connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
exists = connection.execute("SELECT 1 FROM sessions WHERE id = ?", (session_id,)).fetchone()
|
||||
if exists is None:
|
||||
raise ValueError(f"Recording session not found: {session_id}")
|
||||
deleted = connection.execute(
|
||||
"DELETE FROM observations WHERE session_id = ?", (session_id,)
|
||||
).rowcount
|
||||
connection.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
|
||||
return deleted
|
||||
|
||||
|
||||
def save_observations(
|
||||
connection: sqlite3.Connection, session: Session, observations: list[Observation], now: float
|
||||
) -> None:
|
||||
with connection:
|
||||
for item in observations:
|
||||
if (
|
||||
item.timestamp < session.started
|
||||
or item.timestamp > now
|
||||
or not session.collection_bounds.contains(item.longitude, item.latitude)
|
||||
):
|
||||
continue
|
||||
connection.execute(
|
||||
"INSERT OR IGNORE INTO observations VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
session.id,
|
||||
item.icao24,
|
||||
item.callsign,
|
||||
item.timestamp,
|
||||
item.longitude,
|
||||
item.latitude,
|
||||
item.baro_altitude,
|
||||
item.geo_altitude,
|
||||
item.on_ground,
|
||||
),
|
||||
)
|
||||
connection.execute("UPDATE sessions SET ended = ? WHERE id = ?", (now, session.id))
|
||||
|
||||
|
||||
def load_observations(
|
||||
connection: sqlite3.Connection, session_id: str, start: float, end: float
|
||||
) -> list[Observation]:
|
||||
return [
|
||||
Observation(
|
||||
row["icao24"],
|
||||
row["callsign"],
|
||||
row["timestamp"],
|
||||
row["longitude"],
|
||||
row["latitude"],
|
||||
row["baro_altitude"],
|
||||
row["geo_altitude"],
|
||||
bool(row["on_ground"]),
|
||||
)
|
||||
for row in connection.execute(
|
||||
"""SELECT * FROM observations WHERE session_id = ? AND timestamp BETWEEN ? AND ?
|
||||
ORDER BY icao24, timestamp""",
|
||||
(session_id, start, end),
|
||||
)
|
||||
]
|
||||
@@ -0,0 +1,102 @@
|
||||
import math
|
||||
from contextlib import ExitStack
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import rasterio
|
||||
from pyproj import Transformer
|
||||
|
||||
from flight_map.geo import Bounds
|
||||
from flight_map.storage import Observation
|
||||
|
||||
|
||||
class Elevation:
|
||||
"""Metre-valued terrain and optional h-H geoid separation rasters."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
terrain: Path | None,
|
||||
reference: str = "unknown",
|
||||
geoid: Path | None = None,
|
||||
confirm_geometric_ellipsoid: bool = False,
|
||||
) -> None:
|
||||
self.terrain_path = terrain
|
||||
self.reference = reference
|
||||
self.geoid_path = geoid
|
||||
self.confirm_geometric_ellipsoid = confirm_geometric_ellipsoid
|
||||
self.stack = ExitStack()
|
||||
self.ground: Any = None
|
||||
self.geoid: Any = None
|
||||
self.ground_transform: Transformer | None = None
|
||||
self.geoid_transform: Transformer | None = None
|
||||
|
||||
def __enter__(self) -> "Elevation":
|
||||
with ExitStack() as stack:
|
||||
if self.terrain_path:
|
||||
self.ground = stack.enter_context(rasterio.open(self.terrain_path))
|
||||
self.ground_transform = self._transform(self.ground)
|
||||
if self.geoid_path:
|
||||
self.geoid = stack.enter_context(rasterio.open(self.geoid_path))
|
||||
self.geoid_transform = self._transform(self.geoid)
|
||||
self.stack = stack.pop_all()
|
||||
return self
|
||||
|
||||
def __exit__(self, *args: object) -> None:
|
||||
self.stack.close()
|
||||
|
||||
@staticmethod
|
||||
def _transform(dataset: Any) -> Transformer:
|
||||
if dataset.crs is None:
|
||||
raise ValueError("Elevation raster has no coordinate reference system.")
|
||||
if dataset.count < 1:
|
||||
raise ValueError("Elevation raster contains no data band.")
|
||||
return Transformer.from_crs("EPSG:4326", dataset.crs, always_xy=True)
|
||||
|
||||
@staticmethod
|
||||
def sample(dataset: Any, transform: Transformer, lon: float, lat: float) -> float | None:
|
||||
x, y = transform.transform(lon, lat)
|
||||
row, col = dataset.index(x, y)
|
||||
if row < 0 or col < 0 or row >= dataset.height or col >= dataset.width:
|
||||
return None
|
||||
value = next(dataset.sample([(x, y)], indexes=1, masked=True))[0]
|
||||
if bool(getattr(value, "mask", False)):
|
||||
return None
|
||||
result = float(value) * dataset.scales[0] + dataset.offsets[0]
|
||||
return result if math.isfinite(result) else None
|
||||
|
||||
def mean_ground(self, bounds: Bounds, samples: int = 64) -> float | None:
|
||||
if not self.ground or self.ground_transform is None or self.reference == "unknown":
|
||||
return None
|
||||
if samples < 2:
|
||||
raise ValueError("Terrain sampling requires at least two samples per axis.")
|
||||
sin_south, sin_north = (
|
||||
math.sin(math.radians(bounds.south)),
|
||||
math.sin(math.radians(bounds.north)),
|
||||
)
|
||||
values = []
|
||||
# Uniform longitude and sin(latitude) samples represent equal spherical areas.
|
||||
for row in range(samples):
|
||||
lat = math.degrees(
|
||||
math.asin(sin_south + (sin_north - sin_south) * (row + 0.5) / samples)
|
||||
)
|
||||
for col in range(samples):
|
||||
lon = bounds.west + (bounds.east - bounds.west) * (col + 0.5) / samples
|
||||
value = self.sample(self.ground, self.ground_transform, lon, lat)
|
||||
if value is None:
|
||||
return None
|
||||
values.append(value)
|
||||
return sum(values) / len(values)
|
||||
|
||||
def aircraft_altitude(self, observation: Observation) -> float | None:
|
||||
if not self.confirm_geometric_ellipsoid or observation.geo_altitude is None:
|
||||
return None
|
||||
if self.reference == "wgs84-ellipsoid":
|
||||
return observation.geo_altitude
|
||||
if self.reference != "orthometric" or not self.geoid or self.geoid_transform is None:
|
||||
return None
|
||||
separation = self.sample(
|
||||
self.geoid, self.geoid_transform, observation.longitude, observation.latitude
|
||||
)
|
||||
if separation is None:
|
||||
return None
|
||||
return observation.geo_altitude - separation
|
||||
@@ -0,0 +1,85 @@
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from itertools import groupby, pairwise
|
||||
|
||||
from flight_map.curves import Cubic, interpolate
|
||||
from flight_map.geo import Projection
|
||||
from flight_map.storage import Observation
|
||||
from flight_map.terrain import Elevation
|
||||
|
||||
|
||||
@dataclass
|
||||
class Track:
|
||||
observations: list[Observation]
|
||||
curves: list[Cubic]
|
||||
average_height: float | None
|
||||
start: float
|
||||
end: float
|
||||
|
||||
|
||||
def split_tracks(observations: list[Observation], max_gap: float) -> list[list[Observation]]:
|
||||
if not math.isfinite(max_gap) or max_gap <= 0:
|
||||
raise ValueError("Maximum track gap must be positive.")
|
||||
result = []
|
||||
for _, aircraft in groupby(
|
||||
sorted(observations, key=lambda o: (o.icao24, o.timestamp)), key=lambda o: o.icao24
|
||||
):
|
||||
current: list[Observation] = []
|
||||
for item in aircraft:
|
||||
if item.on_ground:
|
||||
if len(current) >= 2:
|
||||
result.append(current)
|
||||
current = []
|
||||
continue
|
||||
if current and (
|
||||
item.timestamp - current[-1].timestamp > max_gap
|
||||
or item.timestamp <= current[-1].timestamp
|
||||
or item.callsign != current[-1].callsign
|
||||
):
|
||||
if len(current) >= 2:
|
||||
result.append(current)
|
||||
current = []
|
||||
current.append(item)
|
||||
if len(current) >= 2:
|
||||
result.append(current)
|
||||
return result
|
||||
|
||||
|
||||
def make_tracks(
|
||||
observations: list[Observation],
|
||||
projection: Projection,
|
||||
elevation: Elevation,
|
||||
ground: float | None,
|
||||
max_gap: float,
|
||||
) -> list[Track]:
|
||||
result = []
|
||||
for items in split_tracks(observations, max_gap):
|
||||
points = [projection.point(o.longitude, o.latitude) for o in items]
|
||||
curves = interpolate(points)
|
||||
if all(curve.start == curve.end for curve in curves):
|
||||
continue
|
||||
total_time = 0.0
|
||||
altitude_integral = 0.0
|
||||
missing_height = ground is None
|
||||
start, end = math.inf, -math.inf
|
||||
for index, (first, last) in enumerate(pairwise(items)):
|
||||
duration = last.timestamp - first.timestamp
|
||||
h0, h1 = elevation.aircraft_altitude(first), elevation.aircraft_altitude(last)
|
||||
for f0, f1 in curves[index].inside_intervals(projection.rectangle.bounds):
|
||||
inside_duration = duration * (f1 - f0)
|
||||
total_time += inside_duration
|
||||
start = min(start, first.timestamp + f0 * duration)
|
||||
end = max(end, first.timestamp + f1 * duration)
|
||||
if h0 is None or h1 is None:
|
||||
missing_height = True
|
||||
else:
|
||||
entry = h0 + (h1 - h0) * f0
|
||||
exit_height = h0 + (h1 - h0) * f1
|
||||
altitude_integral += (entry + exit_height) / 2 * inside_duration
|
||||
if total_time == 0:
|
||||
continue
|
||||
average = None
|
||||
if not missing_height and ground is not None:
|
||||
average = altitude_integral / total_time - ground
|
||||
result.append(Track(items, curves, average, start, end))
|
||||
return sorted(result, key=lambda track: (track.start, track.observations[0].icao24))
|
||||
@@ -0,0 +1,35 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "flight-map"
|
||||
version = "0.1.0"
|
||||
description = "Record OpenSky flights and draw pen-plotter SVG maps"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = ["shapely>=2.0,<3", "pyproj>=3.6,<4", "rasterio>=1.4,<2"]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=8", "ruff>=0.11", "mypy>=1.15", "types-shapely>=2"]
|
||||
|
||||
[project.scripts]
|
||||
flight-map = "flight_map.cli:main"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E4", "E7", "E9", "F", "I", "B", "UP"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.11"
|
||||
check_untyped_defs = true
|
||||
disallow_untyped_defs = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = ["rasterio", "rasterio.*"]
|
||||
ignore_missing_imports = true
|
||||
@@ -0,0 +1,89 @@
|
||||
import math
|
||||
import random
|
||||
|
||||
import pytest
|
||||
|
||||
from flight_map.curves import Cubic, interpolate
|
||||
|
||||
|
||||
def test_spline_interpolates_samples_with_continuous_tangent() -> None:
|
||||
points = [(0.0, 0.0), (1.0, 2.0), (5.0, 3.0), (6.0, -1.0)]
|
||||
curves = interpolate(points)
|
||||
assert len(curves) == len(points) - 1
|
||||
for index, curve in enumerate(curves):
|
||||
assert curve.point(0) == pytest.approx(points[index])
|
||||
assert curve.point(1) == pytest.approx(points[index + 1])
|
||||
for first, second in zip(curves, curves[1:], strict=False):
|
||||
incoming = (first.end[0] - first.control2[0], first.end[1] - first.control2[1])
|
||||
outgoing = (second.control1[0] - second.start[0], second.control1[1] - second.start[1])
|
||||
cross = incoming[0] * outgoing[1] - incoming[1] * outgoing[0]
|
||||
dot = incoming[0] * outgoing[0] + incoming[1] * outgoing[1]
|
||||
assert abs(cross) < 1e-10 and dot > 0
|
||||
# A non-collinear path must actually curve, not just encode straight lines as cubics.
|
||||
assert curves[0].point(0.5) != pytest.approx((0.5, 1.0))
|
||||
|
||||
|
||||
def test_two_points_remain_a_straight_path_and_duplicates_are_finite() -> None:
|
||||
curve = interpolate([(0, 0), (9, 6)])[0]
|
||||
assert curve.point(0.5) == pytest.approx((4.5, 3))
|
||||
for segment in interpolate([(0, 0), (0, 0), (1, 2), (1, 2), (3, 0)]):
|
||||
assert all(math.isfinite(coordinate) for p in segment.points for coordinate in p)
|
||||
assert interpolate([]) == []
|
||||
assert interpolate([(0, 0)]) == []
|
||||
|
||||
|
||||
def test_curve_portion_preserves_original_shape() -> None:
|
||||
curve = Cubic((-2, 0), (1, 5), (3, -5), (6, 0))
|
||||
portion = curve.portion(0.2, 0.8)
|
||||
for fraction in (0, 0.1, 0.5, 0.8, 1):
|
||||
assert portion.point(fraction) == pytest.approx(curve.point(0.2 + fraction * 0.6))
|
||||
with pytest.raises(ValueError):
|
||||
curve.portion(0.8, 0.2)
|
||||
|
||||
|
||||
def test_curve_can_enter_bounds_when_both_endpoints_are_outside() -> None:
|
||||
curve = Cubic((-2, 0), (-1, 3), (1, 3), (2, 0))
|
||||
rectangle = (-1.0, 1.0, 1.0, 3.0)
|
||||
portions = curve.clipped(rectangle)
|
||||
assert len(portions) == 1
|
||||
assert portions[0].start[0] == pytest.approx(-1)
|
||||
assert portions[0].end[0] == pytest.approx(1)
|
||||
assert portions[0].point(0.5)[1] > 2
|
||||
|
||||
|
||||
def test_clipping_handles_multiple_entries_tangency_and_boundary_lines() -> None:
|
||||
curve = Cubic((-2, 0), (4, 0), (-4, 0), (2, 0))
|
||||
rectangle = (-0.1, -1.0, 0.1, 1.0)
|
||||
assert len(curve.inside_intervals(rectangle)) == 3
|
||||
boundary = Cubic((-2, 0), (-1, 0), (1, 0), (2, 0))
|
||||
assert len(boundary.clipped((-1, 0, 1, 1))) == 1
|
||||
# Only touches the lower edge; it has no visible interval inside the rectangle.
|
||||
tangent = Cubic((0, -3), (1, 1), (2, 1), (3, -3))
|
||||
assert tangent.clipped((-1, 0, 4, 4)) == []
|
||||
|
||||
|
||||
def test_random_clipping_matches_dense_curve_membership() -> None:
|
||||
randomizer = random.Random(42)
|
||||
rectangle = (-1.0, -1.0, 1.0, 1.0)
|
||||
for _ in range(100):
|
||||
points = [(randomizer.uniform(-3, 3), randomizer.uniform(-3, 3)) for _ in range(4)]
|
||||
curve = Cubic(*points)
|
||||
intervals = curve.inside_intervals(rectangle)
|
||||
for index in range(201):
|
||||
t = index / 200
|
||||
x, y = curve.point(t)
|
||||
inside = -1 <= x <= 1 and -1 <= y <= 1
|
||||
selected = any(first - 1e-10 <= t <= last + 1e-10 for first, last in intervals)
|
||||
assert selected == inside
|
||||
for portion in curve.clipped(rectangle):
|
||||
for index in range(51):
|
||||
x, y = portion.point(index / 50)
|
||||
assert -1 - 1e-8 <= x <= 1 + 1e-8 and -1 - 1e-8 <= y <= 1 + 1e-8
|
||||
|
||||
|
||||
def test_transformation_preserves_bezier_shape() -> None:
|
||||
curve = Cubic((0, 0), (1, 2), (2, -1), (3, 0))
|
||||
transformed = curve.transform([2, 0, 0, -2, 10, 20])
|
||||
for t in (0, 0.1, 0.6, 1):
|
||||
x, y = curve.point(t)
|
||||
assert transformed.point(t) == pytest.approx((2 * x + 10, -2 * y + 20))
|
||||
@@ -0,0 +1,563 @@
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import Mock
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import rasterio
|
||||
from rasterio.transform import from_bounds
|
||||
|
||||
from flight_map.cli import main, parser, timestamp
|
||||
from flight_map.geo import DEFAULT_BOUNDS, Bounds, Projection
|
||||
from flight_map.lettering import GLYPHS, lettering
|
||||
from flight_map.maps import map_data, map_lines
|
||||
from flight_map.network import request_json
|
||||
from flight_map.plot import SVG_NS, PlotOptions, render
|
||||
from flight_map.record import OpenSky, collect
|
||||
from flight_map.storage import (
|
||||
Observation,
|
||||
Session,
|
||||
checkpoint,
|
||||
create_session,
|
||||
database,
|
||||
get_session,
|
||||
initialize,
|
||||
load_observations,
|
||||
save_observations,
|
||||
)
|
||||
from flight_map.terrain import Elevation
|
||||
from flight_map.tracks import make_tracks, split_tracks
|
||||
|
||||
T0 = 1_792_497_600.0
|
||||
|
||||
|
||||
def observation(**changes: Any) -> Observation:
|
||||
return replace(Observation("abc123", "KLM123", T0 + 10, 4.80, 52.24, 900, 1043), **changes)
|
||||
|
||||
|
||||
def raster(path: Path, value: float, *, bounds: Bounds = DEFAULT_BOUNDS) -> Path:
|
||||
with rasterio.open(
|
||||
path,
|
||||
"w",
|
||||
driver="GTiff",
|
||||
height=10,
|
||||
width=10,
|
||||
count=1,
|
||||
dtype="float32",
|
||||
crs="EPSG:4326",
|
||||
transform=from_bounds(*bounds.values, 10, 10),
|
||||
nodata=-9999,
|
||||
) as dataset:
|
||||
dataset.write(np.full((1, 10, 10), value, dtype="float32"))
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def recorded(tmp_path: Path) -> tuple[Path, Session]:
|
||||
path = tmp_path / "flights.sqlite"
|
||||
with database(path) as connection:
|
||||
initialize(connection)
|
||||
session = create_session(connection, DEFAULT_BOUNDS, 2000, 30, T0)
|
||||
items = [
|
||||
observation(longitude=4.77, timestamp=T0 + 10, geo_altitude=843),
|
||||
observation(longitude=4.82, timestamp=T0 + 40, geo_altitude=1043),
|
||||
observation(longitude=4.88, timestamp=T0 + 70, geo_altitude=1243),
|
||||
]
|
||||
save_observations(connection, session, items, T0 + 80)
|
||||
checkpoint(connection, session.id, T0 + 90, "stopped")
|
||||
return path, session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def local_map(tmp_path: Path) -> Path:
|
||||
path = tmp_path / "map.json"
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"elements": [
|
||||
{
|
||||
"type": "way",
|
||||
"id": 1,
|
||||
"geometry": [
|
||||
{"lon": 4.77, "lat": 52.235},
|
||||
{"lon": 4.90, "lat": 52.245},
|
||||
],
|
||||
},
|
||||
{
|
||||
"type": "way",
|
||||
"id": 2,
|
||||
"geometry": [
|
||||
{"lon": 4.80, "lat": 52.22},
|
||||
{"lon": 4.83, "lat": 52.26},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
)
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
def test_default_and_reversed_bounds() -> None:
|
||||
assert DEFAULT_BOUNDS.values == (4.784498, 52.232025, 4.863525, 52.253985)
|
||||
assert Bounds.corners(4.863525, 52.253985, 4.784498, 52.232025) == DEFAULT_BOUNDS
|
||||
assert DEFAULT_BOUNDS.contains(4.83, 52.24)
|
||||
expanded = DEFAULT_BOUNDS.expand(2000)
|
||||
assert expanded.west < DEFAULT_BOUNDS.west and expanded.north > DEFAULT_BOUNDS.north
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"values",
|
||||
[
|
||||
(1, 1, 1, 2),
|
||||
(180, 1, 181, 2),
|
||||
(-179, 1, 179, 2),
|
||||
(1, -90, 2, 90),
|
||||
(1, 2, float("nan"), 3),
|
||||
],
|
||||
)
|
||||
def test_invalid_bounds(values: tuple[float, float, float, float]) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
Bounds(*values)
|
||||
|
||||
|
||||
def test_projection_corners() -> None:
|
||||
projection = Projection(DEFAULT_BOUNDS)
|
||||
southwest = projection.point(DEFAULT_BOUNDS.west, DEFAULT_BOUNDS.south)
|
||||
northeast = projection.point(DEFAULT_BOUNDS.east, DEFAULT_BOUNDS.north)
|
||||
assert projection.rectangle.bounds == (*southwest, *northeast)
|
||||
assert northeast[0] > southwest[0] and northeast[1] > southwest[1]
|
||||
|
||||
|
||||
def test_persistence_and_deduplication(recorded: tuple[Path, Session]) -> None:
|
||||
path, original = recorded
|
||||
with database(path) as connection:
|
||||
session = get_session(connection, original.id)
|
||||
assert session.bounds == DEFAULT_BOUNDS
|
||||
assert session.collection_bounds == original.collection_bounds
|
||||
assert session.status == "stopped"
|
||||
items = load_observations(connection, session.id, T0, T0 + 100)
|
||||
assert len(items) == 3
|
||||
save_observations(connection, session, items, T0 + 100)
|
||||
assert len(load_observations(connection, session.id, T0, T0 + 100)) == 3
|
||||
assert items[0].geo_altitude == 843
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
get_session(connection, "missing")
|
||||
|
||||
|
||||
def test_observations_outside_session_are_not_saved(recorded: tuple[Path, Session]) -> None:
|
||||
path, session = recorded
|
||||
with database(path) as connection:
|
||||
save_observations(
|
||||
connection,
|
||||
session,
|
||||
[
|
||||
observation(timestamp=T0 - 1),
|
||||
observation(timestamp=T0 + 500),
|
||||
observation(longitude=5.5),
|
||||
],
|
||||
T0 + 100,
|
||||
)
|
||||
assert len(load_observations(connection, session.id, T0, T0 + 100)) == 3
|
||||
|
||||
|
||||
def test_database_readonly(recorded: tuple[Path, Session]) -> None:
|
||||
path, _ = recorded
|
||||
with database(path, readonly=True) as connection:
|
||||
with pytest.raises(sqlite3.OperationalError, match="readonly"):
|
||||
connection.execute("DELETE FROM observations")
|
||||
|
||||
|
||||
def test_terrain_and_datum_conversion(tmp_path: Path) -> None:
|
||||
terrain = raster(tmp_path / "terrain.tif", -2)
|
||||
geoid = raster(tmp_path / "geoid.tif", 43)
|
||||
with Elevation(terrain, "orthometric", geoid, True) as elevation:
|
||||
assert elevation.mean_ground(DEFAULT_BOUNDS, 4) == -2
|
||||
assert elevation.aircraft_altitude(observation()) == 1000
|
||||
with Elevation(terrain, "orthometric", geoid, False) as elevation:
|
||||
assert elevation.aircraft_altitude(observation()) is None
|
||||
with Elevation(terrain, "orthometric", None, True) as elevation:
|
||||
assert elevation.aircraft_altitude(observation()) is None
|
||||
with Elevation(terrain, "wgs84-ellipsoid", None, True) as elevation:
|
||||
assert elevation.aircraft_altitude(observation()) == 1043
|
||||
assert elevation.aircraft_altitude(observation(geo_altitude=None)) is None
|
||||
|
||||
|
||||
def test_terrain_missing_data(tmp_path: Path) -> None:
|
||||
terrain = raster(tmp_path / "terrain.tif", -9999)
|
||||
with Elevation(terrain, "wgs84-ellipsoid") as elevation:
|
||||
assert elevation.mean_ground(DEFAULT_BOUNDS, 4) is None
|
||||
small = Bounds(4.80, 52.24, 4.81, 52.25)
|
||||
raster(terrain, 0, bounds=small)
|
||||
with Elevation(terrain, "wgs84-ellipsoid") as elevation:
|
||||
assert elevation.mean_ground(DEFAULT_BOUNDS, 4) is None
|
||||
|
||||
|
||||
def test_equal_area_ground_average(tmp_path: Path) -> None:
|
||||
terrain = raster(tmp_path / "terrain.tif", 0)
|
||||
with rasterio.open(terrain, "r+") as dataset:
|
||||
values = np.zeros((10, 10), dtype="float32")
|
||||
values[:, 5:] = 10
|
||||
dataset.write(values, 1)
|
||||
with Elevation(terrain, "wgs84-ellipsoid") as elevation:
|
||||
assert elevation.mean_ground(DEFAULT_BOUNDS, 10) == pytest.approx(5)
|
||||
|
||||
|
||||
def test_height_is_time_weighted_and_excludes_extensions() -> None:
|
||||
bounds = Bounds(0, 0, 2, 1)
|
||||
observations = [
|
||||
observation(longitude=-1, latitude=0.5, timestamp=T0, geo_altitude=0),
|
||||
observation(longitude=1, latitude=0.5, timestamp=T0 + 20, geo_altitude=200),
|
||||
observation(longitude=3, latitude=0.5, timestamp=T0 + 60, geo_altitude=600),
|
||||
]
|
||||
with Elevation(None, "wgs84-ellipsoid", None, True) as elevation:
|
||||
tracks = make_tracks(observations, Projection(bounds), elevation, 10, 120)
|
||||
# Inside: 10s at mean altitude 150 + 20s at mean altitude 300.
|
||||
assert len(tracks) == 1
|
||||
assert tracks[0].average_height == pytest.approx(240)
|
||||
assert tracks[0].start == pytest.approx(T0 + 10)
|
||||
assert tracks[0].end == pytest.approx(T0 + 40)
|
||||
|
||||
|
||||
def test_stationary_positions_and_missing_altitude() -> None:
|
||||
items = [
|
||||
observation(),
|
||||
observation(timestamp=T0 + 25),
|
||||
observation(timestamp=T0 + 40, longitude=4.82, geo_altitude=None),
|
||||
]
|
||||
with Elevation(None, "wgs84-ellipsoid", None, True) as elevation:
|
||||
tracks = make_tracks(items, Projection(DEFAULT_BOUNDS), elevation, -2, 120)
|
||||
assert len(tracks) == 1 and tracks[0].average_height is None
|
||||
with Elevation(None) as elevation:
|
||||
assert make_tracks(items[:2], Projection(DEFAULT_BOUNDS), elevation, None, 120) == []
|
||||
|
||||
|
||||
def test_track_splitting_and_ground_filter() -> None:
|
||||
items = [observation(timestamp=T0 + i * 30) for i in range(6)]
|
||||
items[2] = replace(items[2], on_ground=True)
|
||||
items[4] = replace(items[4], callsign="OTHER")
|
||||
items[5] = replace(items[5], callsign="OTHER")
|
||||
assert [len(track) for track in split_tracks(items, 120)] == [2, 2]
|
||||
assert split_tracks([observation(), observation(timestamp=T0 + 500)], 120) == []
|
||||
|
||||
|
||||
def test_stroke_font_has_no_fills_or_missing_common_characters() -> None:
|
||||
for character in GLYPHS:
|
||||
assert all(line.length > 0 for line in lettering(character, 0, 0, 2))
|
||||
with pytest.raises(ValueError, match="Unsupported"):
|
||||
lettering("@", 0, 0, 2)
|
||||
|
||||
|
||||
def test_end_to_end_offline_repeatable(
|
||||
recorded: tuple[Path, Session],
|
||||
local_map: Path,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
path, session = recorded
|
||||
monkeypatch.setattr("flight_map.record.OpenSky.observations", Mock(side_effect=AssertionError))
|
||||
monkeypatch.setattr("flight_map.maps.request_json", Mock(side_effect=AssertionError))
|
||||
terrain = raster(tmp_path / "terrain.tif", 41, bounds=session.collection_bounds)
|
||||
before = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
output = tmp_path / "map.svg"
|
||||
for name in ("map.svg", "second.svg"):
|
||||
assert (
|
||||
render(
|
||||
path,
|
||||
session.id,
|
||||
tmp_path / name,
|
||||
local_map=local_map,
|
||||
terrain=terrain,
|
||||
terrain_reference="wgs84-ellipsoid",
|
||||
confirm_geometric_ellipsoid=True,
|
||||
)
|
||||
== 1
|
||||
)
|
||||
assert output.read_bytes() == (tmp_path / "second.svg").read_bytes()
|
||||
assert hashlib.sha256(path.read_bytes()).hexdigest() == before
|
||||
root = ET.parse(output).getroot()
|
||||
assert root.attrib["width"] == "297mm" and root.attrib["height"] == "210mm"
|
||||
assert root.attrib["fill"] == "none"
|
||||
assert root.attrib["viewBox"] == "0 0 297 210"
|
||||
forbidden = {"image", "text", "clipPath", "mask", "use"}
|
||||
assert not any(element.tag.split("}")[-1] in forbidden for element in root.iter())
|
||||
assert {g.attrib.get("id") for g in root.findall(f"{{{SVG_NS}}}g")} == {
|
||||
"map",
|
||||
"flights",
|
||||
"lettering",
|
||||
}
|
||||
flight_paths = root.findall(f".//{{{SVG_NS}}}g[@id='flights']//{{{SVG_NS}}}path")
|
||||
assert flight_paths and all("C " in element.attrib["d"] for element in flight_paths)
|
||||
flight = root.find(f".//{{{SVG_NS}}}g[@id='flight-1']")
|
||||
assert flight is not None and flight.attrib["data-callsign"] == "KLM123"
|
||||
assert flight.attrib["data-average-height-metres"] != "unavailable"
|
||||
for element in root.iter(f"{{{SVG_NS}}}path"):
|
||||
assert "clip-path" not in element.attrib
|
||||
for x, y in re.findall(r"(-?\d+\.\d+),(-?\d+\.\d+)", element.attrib["d"]):
|
||||
assert 0 <= float(x) <= 297 and 0 <= float(y) <= 210
|
||||
assert "HEIGHT" not in output.read_text() # Lettering is geometry, not SVG text.
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
render(path, session.id, output, local_map=local_map)
|
||||
|
||||
|
||||
def test_invalid_plot_time_and_empty_data(recorded: tuple[Path, Session], tmp_path: Path) -> None:
|
||||
path, session = recorded
|
||||
with pytest.raises(ValueError, match="within"):
|
||||
render(path, session.id, tmp_path / "bad.svg", start=T0 - 10)
|
||||
with pytest.raises(ValueError, match="No recorded"):
|
||||
render(path, session.id, tmp_path / "bad.svg", start=T0 + 81, end=T0 + 85)
|
||||
with pytest.raises(ValueError, match="positive"):
|
||||
PlotOptions(width=float("nan")).validate()
|
||||
with pytest.raises(ValueError, match="no room"):
|
||||
PlotOptions(width=10).validate()
|
||||
with pytest.raises(ValueError, match="must not overwrite"):
|
||||
render(path, session.id, path, force=True)
|
||||
with pytest.raises(ValueError, match="must not overwrite"):
|
||||
render(
|
||||
path, session.id, tmp_path / "terrain.tif", terrain=tmp_path / "terrain.tif", force=True
|
||||
)
|
||||
|
||||
|
||||
def test_map_cache_and_errors(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
payload = {
|
||||
"elements": [
|
||||
{
|
||||
"type": "way",
|
||||
"geometry": [
|
||||
{"lon": 4.8, "lat": 52.24},
|
||||
{"lon": 4.82, "lat": 52.24},
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
fetch = Mock(return_value=payload)
|
||||
monkeypatch.setattr("flight_map.maps.request_json", fetch)
|
||||
assert map_data(DEFAULT_BOUNDS, tmp_path) == payload
|
||||
assert map_data(DEFAULT_BOUNDS, tmp_path) == payload
|
||||
fetch.assert_called_once()
|
||||
with pytest.raises(ValueError, match="No drawable"):
|
||||
map_lines({"elements": []}, Projection(DEFAULT_BOUNDS))
|
||||
with pytest.raises(ValueError, match="out geom"):
|
||||
map_lines({"elements": [{"type": "way"}]}, Projection(DEFAULT_BOUNDS))
|
||||
fetch.return_value = {"elements": [], "remark": "runtime error"}
|
||||
with pytest.raises(ValueError, match="could not complete"):
|
||||
map_data(DEFAULT_BOUNDS, tmp_path / "other")
|
||||
|
||||
|
||||
def test_cache_refresh_is_atomic_and_keys_include_area(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
old = {"elements": [], "version": 1}
|
||||
new = {"elements": [], "version": 2}
|
||||
fetch = Mock(return_value=old)
|
||||
monkeypatch.setattr("flight_map.maps.request_json", fetch)
|
||||
assert map_data(DEFAULT_BOUNDS, tmp_path) == old
|
||||
entry = next(tmp_path.glob("osm-*.json"))
|
||||
fetch.return_value = new
|
||||
assert map_data(DEFAULT_BOUNDS, tmp_path) == old
|
||||
assert fetch.call_count == 1
|
||||
assert map_data(DEFAULT_BOUNDS, tmp_path, refresh=True) == new
|
||||
assert json.loads(entry.read_text()) == new
|
||||
assert not list(tmp_path.glob("*.tmp"))
|
||||
fetch.side_effect = RuntimeError("offline")
|
||||
with pytest.raises(RuntimeError, match="offline"):
|
||||
map_data(DEFAULT_BOUNDS, tmp_path, refresh=True)
|
||||
assert json.loads(entry.read_text()) == new
|
||||
assert map_data(DEFAULT_BOUNDS, tmp_path) == new
|
||||
fetch.side_effect = None
|
||||
map_data(Bounds(4.8, 52.2, 4.9, 52.3), tmp_path)
|
||||
assert len(list(tmp_path.glob("osm-*.json"))) == 2
|
||||
entry.write_text("{")
|
||||
with pytest.raises(ValueError, match="refresh-map"):
|
||||
map_data(DEFAULT_BOUNDS, tmp_path)
|
||||
assert map_data(DEFAULT_BOUNDS, tmp_path, refresh=True) == new
|
||||
with pytest.raises(ValueError, match="cannot be combined"):
|
||||
map_data(DEFAULT_BOUNDS, tmp_path, entry, refresh=True)
|
||||
|
||||
|
||||
def test_interrupted_cache_write_preserves_existing_entry(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
payload = {"elements": []}
|
||||
monkeypatch.setattr("flight_map.maps.request_json", Mock(return_value=payload))
|
||||
map_data(DEFAULT_BOUNDS, tmp_path)
|
||||
entry = next(tmp_path.glob("osm-*.json"))
|
||||
before = entry.read_bytes()
|
||||
monkeypatch.setattr("flight_map.maps.os.replace", Mock(side_effect=OSError("write failed")))
|
||||
with pytest.raises(OSError, match="write failed"):
|
||||
map_data(DEFAULT_BOUNDS, tmp_path, refresh=True)
|
||||
assert entry.read_bytes() == before
|
||||
assert not list(tmp_path.glob("*.tmp"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"data",
|
||||
[
|
||||
{"elements": [None]},
|
||||
{
|
||||
"elements": [
|
||||
{
|
||||
"type": "way",
|
||||
"geometry": [
|
||||
{"lon": float("nan"), "lat": 52.2},
|
||||
{"lon": 4.8, "lat": 52.3},
|
||||
],
|
||||
}
|
||||
]
|
||||
},
|
||||
{"elements": [{"type": "way", "geometry": [None, None]}]},
|
||||
],
|
||||
)
|
||||
def test_invalid_map_geometry_is_reported(data: dict[str, Any]) -> None:
|
||||
with pytest.raises(ValueError, match="Map"):
|
||||
map_lines(data, Projection(DEFAULT_BOUNDS))
|
||||
|
||||
|
||||
def test_map_detail_is_selected_for_pen_width() -> None:
|
||||
geometry = [
|
||||
{"lon": 4.80, "lat": 52.24},
|
||||
{"lon": 4.8001, "lat": 52.2401},
|
||||
{"lon": 4.80, "lat": 52.24},
|
||||
]
|
||||
data = {
|
||||
"elements": [
|
||||
{"type": "way", "tags": {"building": "yes"}, "geometry": geometry},
|
||||
{"type": "way", "tags": {"highway": "footway"}, "geometry": geometry},
|
||||
{"type": "way", "tags": {"waterway": "stream"}, "geometry": geometry},
|
||||
]
|
||||
}
|
||||
projection = Projection(DEFAULT_BOUNDS)
|
||||
assert len(map_lines(data, projection, min_building_size=20)) == 1
|
||||
assert len(map_lines(data, projection, min_building_size=0)) == 2
|
||||
|
||||
|
||||
def test_opensky_parsing_and_auth(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
for key in ("OPENSKY_CLIENT_ID", "OPENSKY_CLIENT_SECRET", "OPENSKY_ACCESS_TOKEN"):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
state = ["abc123", " KLM123 ", "NL", T0, T0, 4.82, 52.24, 900, False, 100, 30, 0, None, 1043]
|
||||
fetch = Mock(return_value={"states": [state]})
|
||||
monkeypatch.setattr("flight_map.record.request_json", fetch)
|
||||
client = OpenSky()
|
||||
items = client.observations(DEFAULT_BOUNDS)
|
||||
assert items[0].callsign == "KLM123" and items[0].geo_altitude == 1043
|
||||
assert "lamin=52.232025" in fetch.call_args.args[0]
|
||||
state[5] = None
|
||||
assert client.observations(DEFAULT_BOUNDS) == []
|
||||
fetch.return_value = {"states": None}
|
||||
assert client.observations(DEFAULT_BOUNDS) == []
|
||||
fetch.return_value = {"wrong": []}
|
||||
with pytest.raises(RuntimeError, match="missing"):
|
||||
client.observations(DEFAULT_BOUNDS)
|
||||
monkeypatch.setenv("OPENSKY_CLIENT_ID", "test-client")
|
||||
with pytest.raises(ValueError, match="both"):
|
||||
OpenSky()
|
||||
monkeypatch.setenv("OPENSKY_CLIENT_SECRET", "test-secret")
|
||||
fetch.side_effect = [{"access_token": "test-token", "expires_in": 300}, {"states": []}]
|
||||
OpenSky().observations(DEFAULT_BOUNDS)
|
||||
assert fetch.call_args.kwargs["headers"]["Authorization"] == "Bearer test-token"
|
||||
|
||||
|
||||
def test_collection_checkpoints_without_map_services(
|
||||
recorded: tuple[Path, Session],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
path, session = recorded
|
||||
monkeypatch.setattr("flight_map.maps.request_json", Mock(side_effect=AssertionError))
|
||||
client = Mock(spec=OpenSky)
|
||||
client.observations.return_value = []
|
||||
with database(path) as connection:
|
||||
collect(connection, session, client, duration=0)
|
||||
assert get_session(connection, session.id).status == "stopped"
|
||||
client.observations.side_effect = RuntimeError("outage")
|
||||
with pytest.raises(RuntimeError, match="outage"):
|
||||
collect(connection, session, client, duration=0)
|
||||
assert get_session(connection, session.id).status == "failed"
|
||||
client.observations.side_effect = KeyboardInterrupt
|
||||
collect(connection, session, client)
|
||||
assert get_session(connection, session.id).status == "interrupted"
|
||||
|
||||
|
||||
def test_cli_defaults_custom_bounds_and_resume(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
path = tmp_path / "flights.sqlite"
|
||||
collector = Mock()
|
||||
monkeypatch.setattr("flight_map.record.collect", collector)
|
||||
monkeypatch.setattr("flight_map.record.OpenSky", Mock())
|
||||
assert main(["collect", "--db", str(path), "--duration", "1"]) == 0
|
||||
session = collector.call_args.args[1]
|
||||
assert session.bounds == DEFAULT_BOUNDS
|
||||
custom = ["--bounds", "4.90", "52.30", "4.80", "52.20"]
|
||||
assert main(["collect", "--db", str(path), *custom, "--duration", "1"]) == 0
|
||||
custom_session = collector.call_args.args[1]
|
||||
assert custom_session.bounds == Bounds(4.8, 52.2, 4.9, 52.3)
|
||||
assert main(["collect", "--db", str(path), "--resume", session.id, "--duration", "1"]) == 0
|
||||
assert collector.call_args.args[1].bounds == DEFAULT_BOUNDS
|
||||
assert main(["collect", "--db", str(path), "--resume", session.id, *custom]) == 1
|
||||
assert main(["sessions", "--db", str(path)]) == 0
|
||||
|
||||
|
||||
def test_cli_validation_and_plot(
|
||||
recorded: tuple[Path, Session],
|
||||
local_map: Path,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
path, session = recorded
|
||||
assert (
|
||||
main(
|
||||
[
|
||||
"plot",
|
||||
"--db",
|
||||
str(path),
|
||||
"--session",
|
||||
session.id,
|
||||
"--map-data",
|
||||
str(local_map),
|
||||
"--output",
|
||||
str(tmp_path / "cli.svg"),
|
||||
]
|
||||
)
|
||||
== 0
|
||||
)
|
||||
assert (
|
||||
main(
|
||||
[
|
||||
"plot",
|
||||
"--db",
|
||||
str(path),
|
||||
"--session",
|
||||
"missing",
|
||||
"--output",
|
||||
str(tmp_path / "bad.svg"),
|
||||
]
|
||||
)
|
||||
== 1
|
||||
)
|
||||
with pytest.raises(SystemExit):
|
||||
parser().parse_args(["collect", "--db", str(path), "--interval", "nan"])
|
||||
with pytest.raises(Exception, match="time zone"):
|
||||
timestamp("2026-10-20T12:00:00")
|
||||
assert timestamp("2026-10-20T12:00:00Z") == timestamp("2026-10-20T14:00:00+02:00")
|
||||
|
||||
|
||||
def test_network_errors(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from email.message import Message
|
||||
from urllib.error import HTTPError, URLError
|
||||
|
||||
headers = Message()
|
||||
headers["Retry-After"] = "60"
|
||||
fetch = Mock(side_effect=HTTPError("https://example.invalid", 429, "limited", headers, None))
|
||||
monkeypatch.setattr("flight_map.network.urlopen", fetch)
|
||||
with pytest.raises(RuntimeError, match="429.*60"):
|
||||
request_json("https://example.invalid")
|
||||
fetch.side_effect = URLError("offline")
|
||||
with pytest.raises(RuntimeError, match="connection failed"):
|
||||
request_json("https://example.invalid")
|
||||
@@ -0,0 +1,92 @@
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from flight_map.cli import main
|
||||
from flight_map.geo import DEFAULT_BOUNDS
|
||||
from flight_map.storage import (
|
||||
Observation,
|
||||
create_session,
|
||||
database,
|
||||
get_session,
|
||||
initialize,
|
||||
load_observations,
|
||||
purge_session,
|
||||
save_observations,
|
||||
)
|
||||
|
||||
|
||||
def seed(path: Path) -> tuple[str, str]:
|
||||
with database(path) as connection:
|
||||
initialize(connection)
|
||||
sessions = [create_session(connection, DEFAULT_BOUNDS, 2000, 30, 100) for _ in range(2)]
|
||||
for session in sessions:
|
||||
save_observations(
|
||||
connection,
|
||||
session,
|
||||
[
|
||||
Observation("abc123", "TEST", 110, 4.82, 52.24, 900, 1000),
|
||||
],
|
||||
120,
|
||||
)
|
||||
return sessions[0].id, sessions[1].id
|
||||
|
||||
|
||||
def test_purge_only_selected_session(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
path = tmp_path / "test.sqlite"
|
||||
target, other = seed(path)
|
||||
assert main(["purge", "--db", str(path), "--session", target, "--yes"]) == 0
|
||||
assert f"Purged session {target} and 1 observation(s)." in capsys.readouterr().out
|
||||
with database(path, readonly=True) as connection:
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
get_session(connection, target)
|
||||
assert load_observations(connection, target, 100, 120) == []
|
||||
assert get_session(connection, other).bounds == DEFAULT_BOUNDS
|
||||
assert len(load_observations(connection, other, 100, 120)) == 1
|
||||
assert connection.execute("PRAGMA foreign_key_check").fetchall() == []
|
||||
|
||||
|
||||
def test_confirmation_and_unknown_session_preserve_data(tmp_path: Path) -> None:
|
||||
path = tmp_path / "test.sqlite"
|
||||
target, _ = seed(path)
|
||||
before = path.read_bytes()
|
||||
with pytest.raises(SystemExit):
|
||||
main(["purge", "--db", str(path), "--session", target])
|
||||
assert path.read_bytes() == before
|
||||
assert main(["purge", "--db", str(path), "--session", "missing", "--yes"]) == 1
|
||||
assert path.read_bytes() == before
|
||||
|
||||
|
||||
def test_missing_database_is_not_created(tmp_path: Path) -> None:
|
||||
path = tmp_path / "missing.sqlite"
|
||||
assert main(["purge", "--db", str(path), "--session", "missing", "--yes"]) == 1
|
||||
assert not path.exists()
|
||||
|
||||
|
||||
def test_purge_rolls_back_observations_if_session_delete_fails(tmp_path: Path) -> None:
|
||||
path = tmp_path / "test.sqlite"
|
||||
target, _ = seed(path)
|
||||
with database(path) as connection:
|
||||
connection.execute("""
|
||||
CREATE TRIGGER prevent_session_delete BEFORE DELETE ON sessions
|
||||
BEGIN SELECT RAISE(ABORT, 'test failure'); END
|
||||
""")
|
||||
connection.commit()
|
||||
with pytest.raises(sqlite3.IntegrityError, match="test failure"):
|
||||
purge_session(connection, target)
|
||||
assert get_session(connection, target).id == target
|
||||
assert len(load_observations(connection, target, 100, 120)) == 1
|
||||
connection.execute("DROP TRIGGER prevent_session_delete")
|
||||
connection.commit()
|
||||
assert purge_session(connection, target) == 1
|
||||
|
||||
|
||||
def test_empty_session_can_be_purged(tmp_path: Path) -> None:
|
||||
path = tmp_path / "test.sqlite"
|
||||
with database(path) as connection:
|
||||
initialize(connection)
|
||||
session = create_session(connection, DEFAULT_BOUNDS, 2000, 30, 100)
|
||||
assert purge_session(connection, session.id) == 0
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
get_session(connection, session.id)
|
||||
Reference in new issue
Block a user