Files
2026-10-21 09:24:12 +02:00

195 lines
8.2 KiB
Python

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(
"--no-terrain",
action="store_true",
help="Disable the separate land-elevation summary and automatic AHN download",
)
plot.add_argument(
"--refresh-terrain",
action="store_true",
help="Refresh the cached PDOK AHN DTM for this map",
)
plot.add_argument(
"--terrain-reference",
choices=("unknown", "wgs84-ellipsoid", "orthometric", "nap"),
default="unknown",
)
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,
force=args.force,
refresh_map=args.refresh_map,
no_terrain=args.no_terrain,
refresh_terrain=args.refresh_terrain,
)
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