"""Run the IMU attitude EKF over local CSV files and write a viewer.""" from __future__ import annotations import argparse import csv from dataclasses import dataclass import json import math from pathlib import Path import numpy as np from scripts import imu_ekf_core as ekf from scripts import imu_static_calibrator as static_calibrator REQUIRED_COLUMNS = ( "sensor_uptime_s", "temp_c", "acc_x_g", "acc_y_g", "acc_z_g", "gyro_x_dps", "gyro_y_dps", "gyro_z_dps", ) RESTART_PREVIOUS_MIN_S = 1.0 RESTART_CURRENT_MAX_S = 0.01 @dataclass(frozen=True) class ImuRow: sensor_uptime_s: float temp_c: float acc_g: tuple[float, float, float] gyro_dps: tuple[float, float, float] @dataclass(frozen=True) class EkfFileResult: input_csv: Path output_csv: Path input_rows: int metadata: dict[str, str] samples: list[dict[str, float]] def read_imu_csv(path: Path): metadata, _fieldnames = _read_metadata_and_header(path) return metadata, iter_imu_rows(path) def iter_imu_rows(path: Path): reader = csv.DictReader(_iter_data_lines(path)) _validate_columns(reader.fieldnames or [], path) for data_row_index, record in enumerate(reader, start=1): yield _row_from_record(record, path, data_row_index) def process_file( input_csv: Path, output_dir: Path, init_seconds: int = 3, yaw_bias_seconds: int = 60, static_correction_seconds: float = 2.0, static_gyro_threshold_dps: float = 0.5, static_acc_norm_tolerance_g: float = 0.2, static_acc_stability_threshold_g: float = 0.02, max_points: int = 2500, ) -> EkfFileResult: init_seconds = _validate_init_seconds(init_seconds) yaw_bias_seconds = _validate_yaw_bias_seconds(yaw_bias_seconds) static_correction_seconds = _validate_static_correction_seconds(static_correction_seconds) static_gyro_threshold_dps = _validate_positive_float( static_gyro_threshold_dps, "static_gyro_threshold_dps" ) static_acc_norm_tolerance_g = _validate_positive_float( static_acc_norm_tolerance_g, "static_acc_norm_tolerance_g" ) static_acc_stability_threshold_g = _validate_positive_float( static_acc_stability_threshold_g, "static_acc_stability_threshold_g" ) input_csv = Path(input_csv) output_dir = Path(output_dir) output_dir.mkdir(parents=True, exist_ok=True) metadata, _fieldnames = _read_metadata_and_header(input_csv) output_csv = output_dir / f"{input_csv.stem}_ekf.csv" samples: list[dict[str, float]] = [] input_rows = 0 rows_seen = 0 with output_csv.open("w", encoding="utf-8", newline="") as handle: fieldnames = [ "sensor_uptime_s", "segment_id", "roll_deg", "pitch_deg", "yaw_deg", "relative_yaw_deg", "qw", "qx", "qy", "qz", "gyro_bias_x_dps", "gyro_bias_y_dps", "gyro_bias_z_dps", "fixed_yaw_bias_z_dps", "active_yaw_bias_z_dps", "is_static", "acc_residual_norm", "dt_s", "acc_update_used", ] writer = csv.DictWriter(handle, fieldnames=fieldnames) writer.writeheader() segment_id = 0 state: ekf.ImuEkfState | None = None static_state: static_calibrator.StaticCorrectionState | None = None yaw_bias_buffer: list[ImuRow] = [] init_buffer: list[ImuRow] = [] fixed_yaw_bias_enabled = yaw_bias_seconds > 0 fixed_yaw_bias_z_dps: float | None = 0.0 if yaw_bias_seconds == 0 else None active_yaw_bias_z_dps: float | None = fixed_yaw_bias_z_dps static_correction_enabled = static_correction_seconds > 0.0 wrapper_yaw_bias_enabled = fixed_yaw_bias_enabled or static_correction_enabled static_config = None if static_correction_enabled: static_config = static_calibrator.StaticCorrectionConfig( enter_seconds=static_correction_seconds, gyro_threshold_dps=static_gyro_threshold_dps, acc_norm_tolerance_g=static_acc_norm_tolerance_g, acc_stability_threshold_g=static_acc_stability_threshold_g, ) previous_input_time: float | None = None previous_output_time: float | None = None relative_yaw_deg = 0.0 previous_yaw_deg: float | None = None def reset_segment() -> None: nonlocal state, static_state, yaw_bias_buffer, init_buffer nonlocal fixed_yaw_bias_z_dps, active_yaw_bias_z_dps nonlocal previous_output_time, relative_yaw_deg, previous_yaw_deg state = None static_state = None yaw_bias_buffer = [] init_buffer = [] fixed_yaw_bias_z_dps = 0.0 if yaw_bias_seconds == 0 else None active_yaw_bias_z_dps = fixed_yaw_bias_z_dps previous_output_time = None relative_yaw_deg = 0.0 previous_yaw_deg = None def write_row(row: ImuRow) -> None: nonlocal input_rows, previous_output_time, relative_yaw_deg, previous_yaw_deg nonlocal active_yaw_bias_z_dps if state is None: raise ValueError("EKF state is not initialized") if fixed_yaw_bias_z_dps is None: raise ValueError("fixed yaw bias is not initialized") if active_yaw_bias_z_dps is None: raise ValueError("active yaw bias is not initialized") if wrapper_yaw_bias_enabled: state.gyro_bias_rad_s[2] = 0.0 dt_s = 0.0 if previous_output_time is None else row.sensor_uptime_s - previous_output_time previous_output_time = row.sensor_uptime_s is_static = False if static_state is not None: bias_xy_dps = np.degrees(state.gyro_bias_rad_s[0:2]) is_static = static_calibrator.step( static_state, dt_s, np.array(row.acc_g, dtype=float), np.array(row.gyro_dps, dtype=float), bias_xy_dps, ) active_yaw_bias_z_dps = static_state.active_yaw_bias_z_dps corrected_row = _row_with_yaw_bias(row, active_yaw_bias_z_dps) used_update, residual_norm = ekf.step( state, dt_s, _acc_mps2(corrected_row), _gyro_rad_s(corrected_row) ) if wrapper_yaw_bias_enabled: state.gyro_bias_rad_s[2] = 0.0 roll, pitch, yaw = ekf.quaternion_to_euler_deg(state.q) if previous_yaw_deg is None: relative_yaw_deg = 0.0 previous_yaw_deg = yaw elif is_static: previous_yaw_deg = yaw else: relative_yaw_deg += _unwrap_delta_deg(yaw - previous_yaw_deg) previous_yaw_deg = yaw bias_dps = tuple(math.degrees(value) for value in state.gyro_bias_rad_s) out = { "sensor_uptime_s": row.sensor_uptime_s, "segment_id": segment_id, "roll_deg": roll, "pitch_deg": pitch, "yaw_deg": yaw, "relative_yaw_deg": relative_yaw_deg, "qw": float(state.q[0]), "qx": float(state.q[1]), "qy": float(state.q[2]), "qz": float(state.q[3]), "gyro_bias_x_dps": bias_dps[0], "gyro_bias_y_dps": bias_dps[1], "gyro_bias_z_dps": bias_dps[2], "fixed_yaw_bias_z_dps": fixed_yaw_bias_z_dps, "active_yaw_bias_z_dps": active_yaw_bias_z_dps, "is_static": int(is_static), "acc_residual_norm": residual_norm, "dt_s": dt_s, "acc_update_used": int(used_update), } _validate_finite_mapping(out, "EKF output") writer.writerow(out) input_rows += 1 _append_bounded(samples, _sample_for_html(out), max_points) def initialize_and_write_buffer() -> None: nonlocal state, static_state, init_buffer if active_yaw_bias_z_dps is None: raise ValueError("active yaw bias is not initialized") corrected_init_rows = [ _row_with_yaw_bias(row, active_yaw_bias_z_dps) for row in init_buffer ] state = _initialize_state(corrected_init_rows, init_seconds) if wrapper_yaw_bias_enabled: state.gyro_bias_rad_s[2] = 0.0 if static_config is not None: static_state = static_calibrator.initialize( static_config, initial_yaw_bias_z_dps=active_yaw_bias_z_dps, ) buffered_rows = init_buffer init_buffer = [] for buffered_row in buffered_rows: write_row(buffered_row) def process_row_with_yaw_bias(row: ImuRow) -> None: if active_yaw_bias_z_dps is None: raise ValueError("active yaw bias is not initialized") if state is None: init_buffer.append(row) if init_seconds == 0 or row.sensor_uptime_s - init_buffer[0].sensor_uptime_s >= init_seconds: initialize_and_write_buffer() else: write_row(row) def initialize_fixed_yaw_bias_from_buffer() -> None: nonlocal yaw_bias_buffer, fixed_yaw_bias_z_dps, active_yaw_bias_z_dps if fixed_yaw_bias_z_dps is not None: return if not yaw_bias_buffer: raise ValueError("at least one IMU row is required for fixed yaw bias initialization") fixed_yaw_bias_z_dps = _fixed_yaw_bias_z_dps(yaw_bias_buffer) active_yaw_bias_z_dps = fixed_yaw_bias_z_dps buffered_rows = yaw_bias_buffer yaw_bias_buffer = [] for buffered_row in buffered_rows: process_row_with_yaw_bias(buffered_row) def flush_segment() -> None: if fixed_yaw_bias_z_dps is None and yaw_bias_buffer: initialize_fixed_yaw_bias_from_buffer() if state is None and init_buffer: initialize_and_write_buffer() for data_row_index, row in enumerate(iter_imu_rows(input_csv), start=1): rows_seen += 1 if previous_input_time is not None and row.sensor_uptime_s < previous_input_time: if _is_device_restart(previous_input_time, row.sensor_uptime_s): flush_segment() segment_id += 1 reset_segment() else: raise ValueError( f"{input_csv} timestamp decreased at data row {data_row_index}: " f"previous {previous_input_time}, current {row.sensor_uptime_s}" ) if fixed_yaw_bias_z_dps is None: if yaw_bias_buffer and row.sensor_uptime_s - yaw_bias_buffer[0].sensor_uptime_s >= yaw_bias_seconds: initialize_fixed_yaw_bias_from_buffer() process_row_with_yaw_bias(row) else: yaw_bias_buffer.append(row) else: process_row_with_yaw_bias(row) previous_input_time = row.sensor_uptime_s if rows_seen == 0: raise ValueError(f"{input_csv} has no IMU rows") flush_segment() return EkfFileResult( input_csv=input_csv, output_csv=output_csv, input_rows=input_rows, metadata=metadata, samples=samples, ) def write_html_report(results: list[EkfFileResult], html_path: Path, max_points: int = 2500) -> None: html_path = Path(html_path) html_path.parent.mkdir(parents=True, exist_ok=True) payload = [] for result in results: payload.append( { "input_csv": str(result.input_csv.name), "output_csv": str(result.output_csv), "input_rows": result.input_rows, "metadata": result.metadata, "samples": result.samples[:max_points], } ) data_json = _json_for_script(payload) html_text = f"""