"""End-to-end orchestration and JSON reporting for RTK--IMU calibration.""" from __future__ import annotations import csv import json from dataclasses import asdict, dataclass from pathlib import Path from typing import Any import numpy as np from imu_lidar.imu_io import load_imu_samples from .rtk_imu_rotation import RotationCalibrationResult, RotationSession, solve_rtk_imu_rotation from .rtk_imu_translation import TranslationCalibrationResult, solve_rtk_imu_translation from .rtk_io import load_rtk_csv DEFAULT_RTK_FRAME_DEFINITION = ( "right-handed vehicle-fixed frame: +X ANT1(main,left)->ANT2(secondary,right), " "+Y vehicle forward/IMU +Y, +Z vehicle up/IMU +Z" ) DEFAULT_RTK_REFERENCE_POINT = ( "GGA ANT1/main-antenna phase center, 1.916499878 m above ground" ) @dataclass(frozen=True) class InventoryEntry: session_id: str batch_id: str imu_csv: Path rtk_csv: Path def load_inventory(path: Path | str) -> list[InventoryEntry]: """Load the project RTK inventory and derive each paired IMU path.""" source = Path(path) entries: list[InventoryEntry] = [] with source.open("r", encoding="utf-8-sig", newline="") as handle: for row in csv.DictReader(handle): rtk_csv = Path(row["current_rtk_csv"]) imu_csv = rtk_csv.with_name("imu.csv") entries.append( InventoryEntry( session_id=row["session"], batch_id=row["batch"], imu_csv=imu_csv, rtk_csv=rtk_csv, ) ) if not entries: raise ValueError(f"empty RTK inventory: {source}") return entries def load_sessions(entries: list[InventoryEntry] | tuple[InventoryEntry, ...]) -> list[RotationSession]: sessions = [] for entry in entries: sessions.append( RotationSession( session_id=entry.session_id, batch_id=entry.batch_id, imu=load_imu_samples(entry.imu_csv), rtk=load_rtk_csv(entry.rtk_csv), ) ) return sessions def _jsonable(value: Any) -> Any: if isinstance(value, np.ndarray): return value.tolist() if isinstance(value, np.generic): return value.item() if isinstance(value, Path): return str(value) if hasattr(value, "__dataclass_fields__"): return {key: _jsonable(item) for key, item in asdict(value).items()} if isinstance(value, dict): return {str(key): _jsonable(item) for key, item in value.items()} if isinstance(value, (list, tuple)): return [_jsonable(item) for item in value] return value def dataset_audit(sessions: list[RotationSession]) -> dict[str, Any]: rows = [] for session in sessions: rtk = session.rtk valid_position = rtk.position_valid valid_attitude = rtk.attitude_valid & valid_position float_attitude = rtk.attitude_float & valid_position rows.append( { "session_id": session.session_id, "batch_id": session.batch_id, "imu_samples": int(session.imu.t_s.size), "rtk_samples": int(rtk.t_s.size), "fixed_position_ratio": float(np.mean(valid_position)), "fixed_attitude_ratio": float(np.mean(valid_attitude)), "float_attitude_ratio": float(np.mean(float_attitude)), "checksum_valid_ratio": float(np.mean(rtk.checksum_valid)), "common_time_span_s": [ float(max(session.imu.t_s[0], rtk.t_s[0])), float(min(session.imu.t_s[-1], rtk.t_s[-1])), ], "origin_geodetic": list(rtk.origin_geodetic), "imu_source": str(session.imu.t_s.size) + " normalized samples", "rtk_source": str(rtk.source), } ) return {"session_count": len(sessions), "sessions": rows} def run_calibration( sessions: list[RotationSession], output_directory: Path | str, *, rotation_only: bool = False, compute_loo: bool = True, knot_step_s: float = 2.0, rtk_frame_definition: str = DEFAULT_RTK_FRAME_DEFINITION, rtk_reference_point: str = DEFAULT_RTK_REFERENCE_POINT, ) -> tuple[RotationCalibrationResult, TranslationCalibrationResult | None]: """Run calibration and publish human-readable JSON artifacts.""" output = Path(output_directory) output.mkdir(parents=True, exist_ok=True) rotation = solve_rtk_imu_rotation(sessions, compute_loo=compute_loo) translation = None if not rotation_only and rotation.ok: translation = solve_rtk_imu_translation( sessions, rotation, knot_step_s=knot_step_s, compute_loo=compute_loo, ) audit_payload = dataset_audit(sessions) rotation_payload = _jsonable(rotation) translation_payload = None if translation is None else _jsonable(translation) (output / "dataset_audit.json").write_text( json.dumps(audit_payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" ) (output / "rotation_result.json").write_text( json.dumps(rotation_payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" ) if translation_payload is not None: (output / "translation_result.json").write_text( json.dumps(translation_payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" ) interpretation_complete = bool(rtk_frame_definition.strip() and rtk_reference_point.strip()) accepted = bool( rotation.ok and translation is not None and translation.ok and interpretation_complete ) blockers = [] if not rotation.ok: blockers.append('full RTK-to-IMU rotation is not observable from the lateral dual-antenna baseline') if translation is None or not translation.ok: blockers.append('translation is frozen until a full rotation is observable and accepted') if not rtk_frame_definition.strip(): blockers.append('RTK frame_definition is empty') if not rtk_reference_point.strip(): blockers.append('RTK reference_point is empty') summary = { "status": "accepted" if accepted else "diagnostic_not_accepted", "transform_convention": "T_RTK_IMU maps IMU coordinates into the RTK sensor frame", "rtk_frame_definition": rtk_frame_definition, "rtk_reference_point": rtk_reference_point, "interpretation_blockers": blockers, "R_RTK_IMU": rotation.R_RTK_IMU.tolist(), "t_RTK_IMU_m": None if translation is None else translation.t_RTK_IMU_m.tolist(), "T_RTK_IMU": None if translation is None else translation.T_RTK_IMU.tolist(), "rotation_ok": rotation.ok, "translation_ok": None if translation is None else translation.ok, "rotation_result": "rotation_result.json", "translation_result": None if translation is None else "translation_result.json", "dataset_audit": "dataset_audit.json", } (output / "summary.json").write_text( json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" ) return rotation, translation