#!/usr/bin/env python3 """Export audited H32/G90 static windows directly to RTK--LiDAR combined data. This adapter is for captures where multiple static stations live inside large DLog archives instead of one directory per station. It uses the H32 packet host-receive UTC ticks as the common software clock, parses G90 ``$GNGGA`` and ``$GNHPR`` from one or more V2 captures, and deliberately does not require IMU. Raw inputs are opened read-only. """ from __future__ import annotations import argparse import bisect import csv import json import shutil import struct import sys import zipfile from dataclasses import dataclass from datetime import datetime, timedelta, timezone from pathlib import Path from typing import BinaryIO, Iterator import numpy as np ROOT = Path(__file__).resolve().parent sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT / "rscap_v2")) from build_multisensor_npz import build_combined # noqa: E402 from h32_dlog.difop import DifopAngles, parse_difop_angles # noqa: E402 from h32_dlog.dotnet_bin import read_dotnet_string # noqa: E402 from h32_dlog.payload_v1 import parse_difop_payload, parse_msop_batch_payload # noqa: E402 from capture_format_v2 import read_capture # noqa: E402 from h32_msop import iter_h32_frames_polar_from_packets # noqa: E402 from pipeline_common_corrected import parse_rtk_capture, write_jsonl # noqa: E402 DOTNET_UNIX_EPOCH_TICKS = 621355968000000000 TICKS_PER_SECOND = 10_000_000 LOCAL_TZ = timezone(timedelta(hours=8)) MSOP_OBJECT = "frontlidar-msop-raw" DIFOP_OBJECT = "frontlidar-difop-raw" @dataclass(frozen=True) class Window: station_id: str start_ticks: int end_ticks: int def local_text_to_utc_ticks(text: str) -> int: value = datetime.strptime(text.strip(), "%Y-%m-%d %H:%M:%S.%f").replace(tzinfo=LOCAL_TZ) return int(round(value.timestamp() * TICKS_PER_SECOND)) + DOTNET_UNIX_EPOCH_TICKS def read_exact(stream: BinaryIO, length: int) -> bytes: value = stream.read(length) if len(value) != length: raise EOFError(f"expected {length} bytes, got {len(value)}") return value def iter_zip_dobject_payloads(path: Path) -> Iterator[tuple[str, bytes]]: """Sequentially read DObject records from a standard Medulla DLog ZIP.""" with zipfile.ZipFile(path) as archive: candidates = [ name for name in archive.namelist() if name.replace("\\", "/").endswith("dobject_recording/data.bin") ] if len(candidates) != 1: raise ValueError(f"{path}: expected one dobject_recording/data.bin, got {candidates}") with archive.open(candidates[0], "r") as stream: while True: try: name = read_dotnet_string(stream) except EOFError: break read_exact(stream, 8) # outer DObject tick read_dotnet_string(stream) # record id length = struct.unpack(" 128 * 1024 * 1024: raise ValueError(f"{path}: invalid DObject payload length {length}") yield name, read_exact(stream, length) def load_windows(path: Path) -> list[Window]: grouped: dict[str, list[tuple[int, int]]] = {} with path.open("r", encoding="utf-8-sig", newline="") as stream: for row in csv.DictReader(stream): station = row["station_id"].strip() grouped.setdefault(station, []).append( (local_text_to_utc_ticks(row["local_start"]), local_text_to_utc_ticks(row["local_end"])) ) merged: list[Window] = [] for station, ranges in grouped.items(): current: list[list[int]] = [] for start, end in sorted(ranges): if current and start <= current[-1][1]: current[-1][1] = max(current[-1][1], end) else: current.append([start, end]) merged.extend(Window(station, start, end) for start, end in current) merged.sort(key=lambda item: item.start_ticks) for previous, current in zip(merged, merged[1:]): if current.start_ticks <= previous.end_ticks and current.station_id != previous.station_id: raise ValueError(f"overlapping stations: {previous} and {current}") return merged def station_lookup(windows: list[Window]): starts = [item.start_ticks for item in windows] def lookup(ticks: int) -> str | None: index = bisect.bisect_right(starts, ticks) - 1 if index >= 0 and ticks <= windows[index].end_ticks: return windows[index].station_id return None return lookup def save_frames( station: str, packet_items: dict[tuple[str, int], tuple[int, int, bytes]], export_root: Path, angles: DifopAngles, source: Path, *, frame_stride: int, seen_frame_keys: set[tuple[str, int, int]], ) -> int: if not packet_items: return 0 ordered = sorted(packet_items.values(), key=lambda item: (item[0], item[1])) frames = iter_h32_frames_polar_from_packets( (item[2] for item in ordered), host_utc_ticks=[item[0] for item in ordered], frame_stride=frame_stride, min_frame_points=100, min_range_m=0.3, max_range_m=120.0, vertical_deg=angles.vertical_deg, horizontal_deg=angles.horizontal_deg, ) frames_dir = export_root / station / "frames" frames_dir.mkdir(parents=True, exist_ok=True) written = 0 for frame in frames: # Overlapping archives contain identical revolutions. The host stamp # and 0.1 s device bucket make the key stable without comparing points. key = (station, int(round(frame.host_receive_utc_ns / 10_000_000)), int(round(frame.t_start_s * 10))) if key in seen_frame_keys: continue seen_frame_keys.add(key) device_ns = int(round(frame.t_start_s * 1_000_000_000)) destination = frames_dir / f"h32_{frame.host_receive_utc_ns}_{device_ns}.npz" np.savez_compressed( destination, points_raw=np.asarray(frame.points_raw, dtype=np.float32), frame_counter=np.asarray([len(seen_frame_keys)], dtype=np.int32), point_count=np.asarray([len(frame.points_raw)], dtype=np.int32), unix_time_ns=np.asarray([device_ns], dtype=np.int64), device_time_s=np.asarray([frame.t_start_s], dtype=np.float64), device_time_end_s=np.asarray([frame.t_end_s], dtype=np.float64), host_receive_utc_ns=np.asarray([frame.host_receive_utc_ns], dtype=np.int64), source_file_utf8=np.frombuffer(str(source.resolve()).encode("utf-8"), dtype=np.uint8), ) written += 1 return written def scan_dlog_sources( sources: list[Path], windows: list[Window], export_root: Path, *, frame_stride: int, ) -> dict[str, object]: lookup = station_lookup(windows) angles: DifopAngles | None = None seen_packets: dict[str, set[tuple[str, int]]] = {} seen_frames: set[tuple[str, int, int]] = set() frame_counts: dict[str, int] = {} source_stats: list[dict[str, object]] = [] for source_index, source in enumerate(sources, 1): print(f"[dlog {source_index}/{len(sources)}] {source}", flush=True) packets: dict[str, dict[tuple[str, int], tuple[int, int, bytes]]] = {} msop_batches = difop_records = selected_packets = duplicates = 0 for object_name, payload in iter_zip_dobject_payloads(source): if object_name == DIFOP_OBJECT: difop_records += 1 if angles is None: try: angles = parse_difop_angles(parse_difop_payload(payload).raw) except (EOFError, ValueError): pass continue if object_name != MSOP_OBJECT: continue batch = parse_msop_batch_payload(payload) msop_batches += 1 for item in batch.packets: station = lookup(item.host_receive_utc_ticks) if station is None: continue packet_key = (batch.session_id, item.sequence) station_seen = seen_packets.setdefault(station, set()) if packet_key in station_seen: duplicates += 1 continue station_seen.add(packet_key) packets.setdefault(station, {})[packet_key] = ( item.host_receive_utc_ticks, item.sequence, item.raw, ) selected_packets += 1 if angles is None: raise RuntimeError(f"no valid H32 DIFOP angles found before decoding {source}") written = 0 for station, items in packets.items(): count = save_frames( station, items, export_root, angles, source, frame_stride=frame_stride, seen_frame_keys=seen_frames, ) frame_counts[station] = frame_counts.get(station, 0) + count written += count source_stats.append( { "source": str(source.resolve()), "msop_batches": msop_batches, "difop_records": difop_records, "selected_packets": selected_packets, "duplicate_packets": duplicates, "frames_written": written, } ) print(f" selected_packets={selected_packets} frames={written} duplicates={duplicates}", flush=True) return {"frame_counts": frame_counts, "sources": source_stats} def parse_rtk_sources(paths: list[Path], parsed_root: Path) -> dict[str, object]: rows = [] source_stats = [] for path in paths: capture_rows = parse_rtk_capture( read_capture(path), accepted_prefixes=("$GNGGA", "$GPGGA", "$GNHPR"), ) for row in capture_rows: row["capture_source"] = str(path.resolve()) rows.extend(capture_rows) source_stats.append( { "source": str(path.resolve()), "rows": len(capture_rows), "gga_valid": sum(row.get("type") == "GGA" and row.get("checksum_valid") for row in capture_rows), "gnhpr_valid": sum( row.get("type") == "GNHPR" and row.get("checksum_valid") and row.get("heading_valid") for row in capture_rows ), } ) parsed_root.mkdir(parents=True, exist_ok=True) write_jsonl(parsed_root / "rtk.jsonl", rows) write_jsonl(parsed_root / "imu.jsonl", []) return {"rows": len(rows), "sources": source_stats} def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--segments-csv", type=Path, required=True) parser.add_argument("--lidar-dlog", type=Path, action="append", default=[]) parser.add_argument("--rtk-rscap", type=Path, action="append", required=True) parser.add_argument("--out", type=Path, required=True) parser.add_argument("--expected-stations", type=int, default=0) parser.add_argument("--frame-stride", type=int, default=5) parser.add_argument("--rtk-max-dt-ms", type=float, default=200.0) parser.add_argument("--reuse-export", action="store_true", help="Keep existing export/ and resume parsed/combined stages.") parser.add_argument("--overwrite", action="store_true") return parser.parse_args() def main() -> int: args = parse_args() if args.frame_stride < 1: raise SystemExit("--frame-stride must be >= 1") if not args.reuse_export and not args.lidar_dlog: raise SystemExit("at least one --lidar-dlog is required unless --reuse-export is used") for source in [args.segments_csv, *args.lidar_dlog, *args.rtk_rscap]: if not source.is_file(): raise FileNotFoundError(source) if args.reuse_export: export_root = args.out / "export" if not export_root.is_dir(): raise FileNotFoundError(f"--reuse-export requested but missing {export_root}") for name in ("parsed", "combined", "export_summary.json"): target = args.out / name if target.is_dir(): shutil.rmtree(target) elif target.exists(): target.unlink() elif args.out.exists() and any(args.out.iterdir()): if not args.overwrite: raise FileExistsError(f"{args.out} is non-empty; pass --overwrite") for name in ("export", "parsed", "combined", "export_summary.json"): target = args.out / name if target.is_dir(): shutil.rmtree(target) elif target.exists(): target.unlink() args.out.mkdir(parents=True, exist_ok=True) windows = load_windows(args.segments_csv) expected_ids = sorted({item.station_id for item in windows}) if args.reuse_export: frame_counts = { station.name: len(list((station / "frames").glob("*.npz"))) for station in (args.out / "export").iterdir() if station.is_dir() } lidar_summary = {"frame_counts": frame_counts, "sources": [], "reused_export": True} else: lidar_summary = scan_dlog_sources( args.lidar_dlog, windows, args.out / "export", frame_stride=args.frame_stride, ) frame_counts = lidar_summary["frame_counts"] exported_ids = sorted(station for station, count in frame_counts.items() if count) missing = sorted(set(expected_ids) - set(exported_ids)) if missing: raise RuntimeError(f"stations without decoded H32 frames: {missing}") if args.expected_stations and len(exported_ids) != args.expected_stations: raise RuntimeError(f"expected {args.expected_stations} stations, exported {len(exported_ids)}") rtk_summary = parse_rtk_sources(args.rtk_rscap, args.out / "parsed") lidar_segments = [(station, args.out / "export" / station / "frames") for station in exported_ids] combined_summary = build_combined( lidar_segments, [args.out / "parsed" / "rtk.jsonl"], [], args.out / "combined", rtk_max_dt_ms=args.rtk_max_dt_ms, time_basis="host", overwrite=True, ) summary = { "role": "G90 GNGGA/GNHPR + H32 DLog static-window export", "segments_csv": str(args.segments_csv.resolve()), "time_basis": "H32 MSOP host_receive_utc_ticks <-> G90 rscap host_receive_utc_ns", "imu_used": False, "expected_station_ids": expected_ids, "station_count": len(exported_ids), "lidar": lidar_summary, "rtk": rtk_summary, "combined": combined_summary, "outputs": { "combined": str((args.out / "combined").resolve()), "manifest": str((args.out / "combined" / "manifest.csv").resolve()), }, } (args.out / "export_summary.json").write_text( json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", ) print(json.dumps({"stations": len(exported_ids), "combined": combined_summary}, ensure_ascii=False, indent=2)) return 0 if __name__ == "__main__": raise SystemExit(main())