Initial Vibe coded project

This commit is contained in:
Olaf committed 2026-10-20 14:18:35 +02:00
commit 0891cf12a5
21 files changed
+2830

No files matched your search

+1
View File
@@ -0,0 +1 @@
"""OpenSky recording and independent pen-plotter map generation."""
+3
View File
@@ -0,0 +1,3 @@
from flight_map.cli import main
raise SystemExit(main())
+194
View File
@@ -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
+158
View File
@@ -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
+63
View File
@@ -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),
)
+71
View File
@@ -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
+127
View File
@@ -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
+25
View File
@@ -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
+315
View File
@@ -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)
+116
View File
@@ -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")
+204
View File
@@ -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),
)
]
+102
View File
@@ -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
+85
View File
@@ -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))