Initial Vibe coded project
This commit is contained in:
commit
0891cf12a5
21 files changed
+2830
No files matched your search
@@ -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))
|
||||
Reference in new issue
Block a user