Files
2026-10-20 14:18:35 +02:00

205 lines
6.8 KiB
Python

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),
)
]