"""Hive-partitioned Parquet writer: the hot `current/` path plus sealing. Layout (identical to the on-board and warehouse design): dataset=/flight=/drone=[/sensor=]/ year=YYYY/month=MM/day=DD/hour=HH/ current/min_MM.parquet <- open window, small blocks data.parquet <- sealed on window close / shutdown """ from __future__ import annotations import time from pathlib import Path from typing import Any import pyarrow as pa import pyarrow.parquet as pq Row = dict[str, Any] def _hour_dir(root: Path, dataset: str, flight: str, drone: str, sensor: str | None, ts_ns: int) -> Path: t = time.gmtime(ts_ns / 1e9) parts = [f"dataset={dataset}", f"flight={flight}", f"drone={drone}"] if sensor is not None: parts.append(f"sensor={sensor}") parts += [ f"year={t.tm_year:04d}", f"month={t.tm_mon:02d}", f"day={t.tm_mday:02d}", f"hour={t.tm_hour:02d}", ] return root.joinpath(*parts) class PartitionWriter: """Buffers rows per minute and flushes small blocks into current/.""" def __init__(self, root: Path, dataset: str, flight: str, drone: str, sensor: str | None) -> None: self._root = root self._dataset = dataset self._flight = flight self._drone = drone self._sensor = sensor self._buffer: list[Row] = [] self._minute: int | None = None self._touched: set[Path] = set() def append(self, row: Row) -> None: minute = int(row["ts_ns"] // 60_000_000_000) if self._minute is not None and minute != self._minute: self._flush() self._minute = minute self._buffer.append(row) def _flush(self) -> None: if not self._buffer or self._minute is None: return first_ts = self._buffer[0]["ts_ns"] hour_dir = _hour_dir(self._root, self._dataset, self._flight, self._drone, self._sensor, first_ts) current = hour_dir / "current" current.mkdir(parents=True, exist_ok=True) table = pa.Table.from_pylist(self._buffer) minute_of_hour = self._minute % 60 pq.write_table(table, current / f"min_{minute_of_hour:02d}.parquet", compression="zstd") self._touched.add(hour_dir) self._buffer = [] def seal(self) -> None: """Compact every touched hour's current/ blocks into one data.parquet.""" self._flush() for hour_dir in sorted(self._touched): current = hour_dir / "current" blocks = sorted(current.glob("min_*.parquet")) if not blocks: continue table = pa.concat_tables([pq.read_table(b) for b in blocks]) pq.write_table(table, hour_dir / "data.parquet", compression="zstd", compression_level=9) for b in blocks: b.unlink() current.rmdir() def make_writers(root: Path, flight: str, drone: str, sensor_names: list[str]) -> dict[str, PartitionWriter]: """One telemetry writer per sensor + detections + state.""" writers = { name: PartitionWriter(root, "telemetry", flight, drone, name) for name in sensor_names } writers["detections"] = PartitionWriter(root, "detections", flight, drone, None) writers["state"] = PartitionWriter(root, "state", flight, drone, None) return writers