"""RTK CSV loading for the independent RTK--IMU calibration path.""" from __future__ import annotations from dataclasses import dataclass from pathlib import Path import numpy as np from imu_lidar.geodesy import geodetic_to_enu @dataclass(frozen=True) class RtkSeries: """Normalized RTK observations on the IMU device clock.""" t_s: np.ndarray attitude_t_s: np.ndarray position_enu_m: np.ndarray heading_deg: np.ndarray pitch_deg: np.ndarray roll_deg: np.ndarray fix_quality: np.ndarray heading_quality: np.ndarray heading_satellites: np.ndarray heading_age_s: np.ndarray hdop: np.ndarray checksum_valid: np.ndarray origin_geodetic: tuple[float, float, float] source: Path @property def attitude_valid(self) -> np.ndarray: """Strict fixed dual-antenna solutions suitable for calibration.""" return ( np.isfinite(self.heading_deg) & np.isfinite(self.pitch_deg) & np.isfinite(self.roll_deg) & (self.heading_quality == 4.0) & self.checksum_valid ) @property def attitude_float(self) -> np.ndarray: """Float solutions retained for diagnostics but never calibration.""" return ( np.isfinite(self.heading_deg) & np.isfinite(self.pitch_deg) & np.isfinite(self.roll_deg) & (self.heading_quality == 5.0) & self.checksum_valid ) @property def position_valid(self) -> np.ndarray: return ( np.all(np.isfinite(self.position_enu_m), axis=1) & (self.fix_quality == 4.0) & self.checksum_valid ) def _column(data: np.ndarray, name: str, *, default: float = np.nan) -> np.ndarray: names = set(data.dtype.names or ()) if name not in names: return np.full(data.shape[0], default, dtype=float) return np.asarray(data[name], dtype=float).reshape(-1) def load_rtk_csv(path: Path | str) -> RtkSeries: """Load an exported G90 RTK CSV and convert its positions to local ENU. The required ``t`` column must already be NMEA measurement UTC mapped onto the IMU device clock. Host receive time is deliberately never accepted as a fallback because it is delayed by several seconds in the recorded data. """ source = Path(path) if not source.is_file(): raise FileNotFoundError(source) data = np.genfromtxt(source, delimiter=",", names=True, dtype=float, encoding="utf-8") if data.ndim == 0: data = np.array([data], dtype=data.dtype) names = set(data.dtype.names or ()) required = {"t", "lat_deg", "lon_deg", "altitude_m", "fix_quality"} if not required.issubset(names): raise ValueError(f"RTK CSV must contain {sorted(required)}, got {sorted(names)}") t_s = _column(data, "t") measurement_utc = _column(data, "t_measurement_utc_s") hpr_measurement_utc = _column(data, "hpr_measurement_utc_s") attitude_t = t_s.copy() has_hpr_time = np.isfinite(measurement_utc) & np.isfinite(hpr_measurement_utc) attitude_t[has_hpr_time] += hpr_measurement_utc[has_hpr_time] - measurement_utc[has_hpr_time] order = np.argsort(t_s) position, origin = geodetic_to_enu( _column(data, "lat_deg")[order], _column(data, "lon_deg")[order], _column(data, "altitude_m")[order], ) return RtkSeries( t_s=t_s[order], attitude_t_s=attitude_t[order], position_enu_m=position, heading_deg=_column(data, "heading_deg")[order], pitch_deg=_column(data, "pitch_deg")[order], roll_deg=_column(data, "roll_deg")[order], fix_quality=_column(data, "fix_quality", default=0.0)[order], heading_quality=_column(data, "heading_quality", default=0.0)[order], heading_satellites=_column(data, "heading_satellites")[order], heading_age_s=_column(data, "heading_age_s")[order], hdop=_column(data, "hdop")[order], checksum_valid=_column(data, "checksum_valid", default=1.0)[order] == 1.0, origin_geodetic=origin, source=source, ) def longest_valid_interval(t_s: np.ndarray, valid: np.ndarray, *, max_gap_s: float = 0.2) -> tuple[float, float]: """Return the longest contiguous valid time interval.""" times = np.asarray(t_s, dtype=float).reshape(-1) mask = np.asarray(valid, dtype=bool).reshape(-1) indices = np.flatnonzero(mask) if indices.size == 0: raise ValueError("no valid RTK samples") best_start = best_end = int(indices[0]) start = previous = int(indices[0]) for index in indices[1:]: index = int(index) if index != previous + 1 or times[index] - times[previous] > max_gap_s: if times[previous] - times[start] > times[best_end] - times[best_start]: best_start, best_end = start, previous start = index previous = index if times[previous] - times[start] > times[best_end] - times[best_start]: best_start, best_end = start, previous return float(times[best_start]), float(times[best_end])