195 lines
8.2 KiB
Python
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
|