#!/usr/bin/env python3 """Prepare one static LiDAR frame and one yaw-only RTK body pose per station.""" from __future__ import annotations import argparse import csv import json import math import re import shutil import sys from pathlib import Path from typing import Any import numpy as np POSE_FIELDS = ["time", "x", "y", "z", "qx", "qy", "qz", "qw"] def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Select one static LiDAR frame per exported station and rebuild yaw-only RTK body poses." ) parser.add_argument("--export-root", type=Path, required=True, help="Directory containing exported station directories.") parser.add_argument("--output", type=Path, required=True, help="Output prepared directory.") parser.add_argument("--pose-name", default="rear_gga_raw_rear_to_front", help="Suffix of body_poses_.csv.") parser.add_argument("--heading-offset-deg", type=float, required=True, help="Added to raw_heading_deg before ENU yaw conversion.") parser.add_argument("--antenna-lever", type=float, nargs=3, metavar=("X", "Y", "Z"), required=True, help="Antenna position in body coordinates [m].") parser.add_argument("--expected-stations", type=int, default=0, help="Require exactly this many usable stations; 0 disables.") parser.add_argument("--min-stations", type=int, default=30, help="Fail below this many usable stations.") parser.add_argument("--groups", nargs="*", default=None, help="Optional explicit station directory names.") parser.add_argument("--accepted-fixes", type=int, nargs="+", default=[4, 5], help="Accepted GGA fix values.") parser.add_argument("--heading-std-limit-deg", type=float, default=float("inf"), help="Reject a station above this heading circular stddev.") parser.add_argument("--overwrite", action="store_true", help="Allow replacing generated files in a non-empty output directory.") return parser.parse_args() def truth(value: Any) -> bool: return str(value).strip().lower() in {"1", "true", "yes", "y"} def natural_key(value: str) -> list[Any]: return [int(part) if part.isdigit() else part.lower() for part in re.split(r"(\d+)", value)] def read_csv(path: Path) -> list[dict[str, str]]: with path.open("r", encoding="utf-8-sig", newline="") as stream: return list(csv.DictReader(stream)) def require_columns(rows: list[dict[str, str]], path: Path, columns: list[str]) -> None: if not rows: raise ValueError(f"{path}: no rows") missing = [column for column in columns if column not in rows[0]] if missing: raise ValueError(f"{path}: missing required columns: {', '.join(missing)}") def circular_mean_deg(values: np.ndarray) -> float: radians = np.deg2rad(values) return float(np.rad2deg(math.atan2(float(np.mean(np.sin(radians))), float(np.mean(np.cos(radians))))) % 360.0) def circular_std_deg(values: np.ndarray) -> float: radians = np.deg2rad(values) resultant = max(math.hypot(float(np.mean(np.cos(radians))), float(np.mean(np.sin(radians)))), 1e-12) return float(np.rad2deg(math.sqrt(-2.0 * math.log(resultant)))) def geodetic_to_ecef(lat_deg: float, lon_deg: float, height_m: float) -> np.ndarray: a, e2 = 6378137.0, 6.69437999014e-3 lat, lon = math.radians(lat_deg), math.radians(lon_deg) sin_lat, cos_lat, sin_lon, cos_lon = math.sin(lat), math.cos(lat), math.sin(lon), math.cos(lon) n = a / math.sqrt(1.0 - e2 * sin_lat * sin_lat) return np.array([(n + height_m) * cos_lat * cos_lon, (n + height_m) * cos_lat * sin_lon, (n * (1.0 - e2) + height_m) * sin_lat], dtype=float) def ecef_to_enu(ecef: np.ndarray, origin_ecef: np.ndarray, origin_lat_deg: float, origin_lon_deg: float) -> np.ndarray: lat, lon = math.radians(origin_lat_deg), math.radians(origin_lon_deg) sin_lat, cos_lat, sin_lon, cos_lon = math.sin(lat), math.cos(lat), math.sin(lon), math.cos(lon) rotation = np.array([[-sin_lon, cos_lon, 0.0], [-sin_lat * cos_lon, -sin_lat * sin_lon, cos_lat], [cos_lat * cos_lon, cos_lat * sin_lon, sin_lat]], dtype=float) return rotation @ (ecef - origin_ecef) def yaw_rotation(yaw: float) -> np.ndarray: c, s = math.cos(yaw), math.sin(yaw) return np.array([[c, -s, 0.0], [s, c, 0.0], [0.0, 0.0, 1.0]], dtype=float) def select_frame(station: Path) -> tuple[dict[str, str], int, int]: path = station / "reports" / "manifest.csv" rows = read_csv(path) require_columns(rows, path, ["status", "unix_time_ns", "output_file"]) exported = [row for row in rows if row["status"] in {"exported", "resumed"}] if not exported: raise ValueError(f"{station.name}: no exported/resumed LiDAR frames") valid = [row for row in exported if truth(row.get("rtk_position_valid")) and truth(row.get("rtk_heading_valid"))] candidates = valid or exported candidates.sort(key=lambda row: int(row["unix_time_ns"])) return candidates[len(candidates) // 2], len(exported), len(valid) def validation_checks(station: Path) -> dict[str, bool]: path = station / "reports" / "validation_report.json" if not path.exists(): return {} document = json.loads(path.read_text(encoding="utf-8")) return {str(item.get("name")): bool(item.get("ok")) for item in document.get("checks", [])} def process_station(station: Path, args: argparse.Namespace) -> tuple[dict[str, Any], dict[str, Any]]: rtk_path = station / "rtk" / "gps_post_z.csv" rows = read_csv(rtk_path) columns = ["lat", "lon", "alt_m", "raw_heading_deg", "fix", "position_valid", "heading_valid", "unix_time_ns"] require_columns(rows, rtk_path, columns) fixes = set(args.accepted_fixes) good = [row for row in rows if truth(row["position_valid"]) and truth(row["heading_valid"]) and int(row["fix"]) in fixes] if not good: raise ValueError(f"{station.name}: no RTK samples pass position/heading/fix filters") lat = np.asarray([float(row["lat"]) for row in good]) lon = np.asarray([float(row["lon"]) for row in good]) alt = np.asarray([float(row["alt_m"]) for row in good]) heading = np.asarray([float(row["raw_heading_deg"]) for row in good]) times = np.asarray([int(row["unix_time_ns"]) for row in good], dtype=np.int64) heading_std = circular_std_deg(heading) if heading_std > args.heading_std_limit_deg: raise ValueError(f"{station.name}: heading circular stddev {heading_std:.3f} deg exceeds {args.heading_std_limit_deg:.3f} deg") frame, frame_count, valid_frame_count = select_frame(station) source = station / frame["output_file"] if not source.is_file(): raise FileNotFoundError(f"{station.name}: selected frame is absent: {source}") checks = validation_checks(station) selected = {"station": station.name, "source_frame": source, "frame_time": int(frame["unix_time_ns"]) / 1e9, "lat_deg": float(np.mean(lat)), "lon_deg": float(np.mean(lon)), "alt_m": float(np.mean(alt)), "raw_heading_deg": circular_mean_deg(heading), "raw_heading_std_deg": heading_std} summary = {"station": station.name, "selected_frame": frame["output_file"], "exported_frames": frame_count, "frames_with_valid_rtk": valid_frame_count, "valid_rtk_samples": len(good), "raw_heading_mean_deg": selected["raw_heading_deg"], "raw_heading_std_deg": heading_std, "alt_std_m": float(np.std(alt)), "rtk_span_sec": float((times.max() - times.min()) / 1e9) if len(times) > 1 else 0.0, "payload_ok": checks.get("lidar_payload_length", ""), "rtk_parse_ok": checks.get("gps_post_z_parse", ""), "time_check_3s_ok": checks.get("rtk_time_alignment", "")} return selected, summary def main() -> int: args = parse_args() if args.min_stations < 2 or args.expected_stations < 0: raise ValueError("--min-stations must be >=2 and --expected-stations must be >=0") if not args.export_root.is_dir(): raise NotADirectoryError(args.export_root) names = args.groups or [path.name for path in args.export_root.iterdir() if path.is_dir()] stations = [args.export_root / name for name in sorted(names, key=natural_key)] missing = [str(path) for path in stations if not path.is_dir()] if missing: raise FileNotFoundError("station directories do not exist: " + ", ".join(missing)) selected, summaries, rejected = [], [], [] for station in stations: try: item, summary = process_station(station, args) selected.append(item); summaries.append(summary) except (ValueError, FileNotFoundError) as error: rejected.append({"station": station.name, "reason": str(error)}) if args.expected_stations and len(selected) != args.expected_stations: raise RuntimeError(f"expected {args.expected_stations} usable stations, got {len(selected)}; rejected={rejected}") if len(selected) < args.min_stations: raise RuntimeError(f"need at least {args.min_stations} usable stations, got {len(selected)}; rejected={rejected}") output, frames_dir = args.output, args.output / "frames_all" if output.exists() and any(output.iterdir()) and not args.overwrite: raise FileExistsError(f"{output} is non-empty; pass --overwrite to replace generated files") frames_dir.mkdir(parents=True, exist_ok=True) origin = selected[0] origin_ecef = geodetic_to_ecef(origin["lat_deg"], origin["lon_deg"], origin["alt_m"]) lever = np.asarray(args.antenna_lever, dtype=float) pose_rows = [] for index, item in enumerate(selected, 1): destination = frames_dir / f"station_{index:02d}.npz" shutil.copy2(item["source_frame"], destination) antenna_enu = ecef_to_enu(geodetic_to_ecef(item["lat_deg"], item["lon_deg"], item["alt_m"]), origin_ecef, origin["lat_deg"], origin["lon_deg"]) corrected_heading = (item["raw_heading_deg"] + args.heading_offset_deg) % 360.0 yaw = math.radians(90.0 - corrected_heading) body_position = antenna_enu - yaw_rotation(yaw) @ lever pose_rows.append(dict(zip(POSE_FIELDS, [item["frame_time"], *body_position, 0.0, 0.0, math.sin(yaw / 2.0), math.cos(yaw / 2.0)]))) summaries[index - 1].update({"sequence": index, "prepared_frame": destination.name, "corrected_heading_deg": corrected_heading}) pose_path = output / f"body_poses_{args.pose_name}.csv" with pose_path.open("w", encoding="utf-8", newline="") as stream: writer = csv.DictWriter(stream, fieldnames=POSE_FIELDS); writer.writeheader(); writer.writerows(pose_rows) fields = sorted({key for row in summaries for key in row}) with (output / "station_summary.csv").open("w", encoding="utf-8", newline="") as stream: writer = csv.DictWriter(stream, fieldnames=fields); writer.writeheader(); writer.writerows(summaries) manifest = {"source_export_root": str(args.export_root.resolve()), "station_count": len(selected), "pose_csv": pose_path.name, "frame_directory": "frames_all", "selection_policy": "middle frame among exported frames with valid RTK; falls back to middle exported frame", "rtk_filter": {"position_valid": True, "heading_valid": True, "accepted_fixes": args.accepted_fixes, "heading_std_limit_deg": args.heading_std_limit_deg}, "body_pose_configuration": {"raw_heading_offset_deg": args.heading_offset_deg, "antenna_lever_body_m": args.antenna_lever, "body_axes": "x forward, y left, z up; origin must match the supplied lever", "orientation_model": "yaw-only from raw_heading_deg; this tool does not reconstruct RTK pitch or roll"}, "enu_origin": {"lat_deg": origin["lat_deg"], "lon_deg": origin["lon_deg"], "alt_m": origin["alt_m"]}, "stations": [{"sequence": index + 1, "source_station": item["station"], "source_frame": str(item["source_frame"]), "prepared_frame": f"station_{index + 1:02d}.npz"} for index, item in enumerate(selected)]} (output / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") print(json.dumps({"prepared": str(output.resolve()), "stations": len(selected), "rejected": rejected, "pose_csv": pose_path.name}, ensure_ascii=False, indent=2)) return 0 if __name__ == "__main__": try: raise SystemExit(main()) except Exception as error: print(f"ERROR: {error}", file=sys.stderr) raise