#!/usr/bin/env python3 """Offline GPS/IMU fusion for the stationary Genesys2 capture. The two UART logs are captured sequentially, so their elapsed-time axes are replayed together. That is valid for the explicitly stationary experiment, but is not a substitute for a synchronous moving-platform capture. The IMU parser accepts both the original averaged ICM-4268x report and the timestamped raw LSM6DSOX stream from ``steval_mki217v1_test``. """ from __future__ import annotations import argparse import csv import json import math import re from dataclasses import dataclass from pathlib import Path import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt import numpy as np G = 9.80665 GPS_RE = re.compile( r"position lat=(?P[-+0-9.eE]+) lon=(?P[-+0-9.eE]+) " r"height=(?P[-+0-9.eE]+)" ) DOP_RE = re.compile( r"DOP GDOP=(?P[-+0-9.eE]+) PDOP=(?P[-+0-9.eE]+) " r"HDOP=(?P[-+0-9.eE]+) VDOP=(?P[-+0-9.eE]+)" ) IMU_RE = re.compile( r"(?P[0-9]+\.[0-9]+) s \| samples=\s*(?P[0-9]+) total=\s*(?P[0-9]+) \| " r"Temp=\s*(?P[-+0-9.]+) C \| Accel\[g\] " r"X=\s*(?P[-+0-9.]+) Y=\s*(?P[-+0-9.]+) Z=\s*(?P[-+0-9.]+) \| " r"Gyro\[dps\] X=\s*(?P[-+0-9.]+) Y=\s*(?P[-+0-9.]+) Z=\s*(?P[-+0-9.]+)" ) @dataclass class GpsSample: t: float unix_s: float lat: float lon: float height: float hdop: float vdop: float @dataclass class ImuSample: t: float unix_s: float samples: int temperature_c: float accel_mps2: np.ndarray gyro_rps: np.ndarray def read_uart_csv(path: Path): with path.open(newline="", encoding="utf-8") as stream: yield from csv.DictReader(stream) def parse_gps(path: Path) -> list[GpsSample]: samples: list[GpsSample] = [] last_dop = (math.nan, math.nan) for row in read_uart_csv(path): line = row["uart_line"] dop = DOP_RE.search(line) if dop: last_dop = (float(dop["hdop"]), float(dop["vdop"])) match = GPS_RE.search(line) if match: samples.append( GpsSample( t=float(row["host_monotonic_s"]), unix_s=float(row["host_unix_s"]), lat=float(match["lat"]), lon=float(match["lon"]), height=float(match["height"]), hdop=last_dop[0], vdop=last_dop[1], ) ) if len(samples) < 4: raise ValueError(f"only {len(samples)} GPS positions found in {path}") t0 = samples[0].t for sample in samples: sample.t -= t0 return samples def parse_imu(path: Path) -> list[ImuSample]: samples: list[ImuSample] = [] raw_meta: dict[str, float] | None = None first_raw_tick: int | None = None for row in read_uart_csv(path): line = row["uart_line"] if line.startswith("IMU_META,LSM6DSOX,"): fields = line.split(",")[2:] values: dict[str, float] = {} for field in fields: key, separator, value = field.partition("=") if not separator: continue values[key] = float(value) required = { "tick_hz", "odr_hz", "accel_ug_lsb", "gyro_udps_lsb", "temp_lsb_c", "temp_offset_millic", } missing = required.difference(values) if missing: raise ValueError( f"LSM6DSOX metadata missing {sorted(missing)} in {path}" ) if values["tick_hz"] <= 0.0 or values["temp_lsb_c"] <= 0.0: raise ValueError(f"invalid LSM6DSOX metadata in {path}") raw_meta = values continue if line.startswith("IMU_RAW,LSM6DSOX,"): if raw_meta is None: raise ValueError(f"LSM6DSOX raw sample precedes metadata in {path}") fields = line.split(",") if len(fields) != 11: raise ValueError(f"malformed LSM6DSOX raw sample in {path}: {line}") tick = int(fields[3]) if first_raw_tick is None: first_raw_tick = tick raw_accel = np.array([int(x) for x in fields[4:7]], dtype=float) raw_gyro = np.array([int(x) for x in fields[7:10]], dtype=float) raw_temperature = int(fields[10]) samples.append( ImuSample( t=(tick - first_raw_tick) / raw_meta["tick_hz"], unix_s=float(row["host_unix_s"]), samples=1, temperature_c=( raw_meta["temp_offset_millic"] / 1000.0 + raw_temperature / raw_meta["temp_lsb_c"] ), accel_mps2=( G * raw_accel * raw_meta["accel_ug_lsb"] * 1e-6 ), gyro_rps=np.deg2rad( raw_gyro * raw_meta["gyro_udps_lsb"] * 1e-6 ), ) ) continue match = IMU_RE.search(line) if not match: continue samples.append( ImuSample( t=float(match["t"]), unix_s=float(row["host_unix_s"]), samples=int(match["n"]), temperature_c=float(match["temp"]), accel_mps2=G * np.array( [float(match["ax"]), float(match["ay"]), float(match["az"])] ), gyro_rps=np.deg2rad( [float(match["gx"]), float(match["gy"]), float(match["gz"])] ), ) ) if len(samples) < 4: raise ValueError(f"only {len(samples)} IMU windows found in {path}") if first_raw_tick is None: t0 = samples[0].t for sample in samples: sample.t -= t0 return samples def geodetic_to_ecef(lat_deg, lon_deg, height_m): lat = np.deg2rad(np.asarray(lat_deg, dtype=float)) lon = np.deg2rad(np.asarray(lon_deg, dtype=float)) height = np.asarray(height_m, dtype=float) a = 6378137.0 e2 = 6.69437999014e-3 n = a / np.sqrt(1.0 - e2 * np.sin(lat) ** 2) x = (n + height) * np.cos(lat) * np.cos(lon) y = (n + height) * np.cos(lat) * np.sin(lon) z = (n * (1.0 - e2) + height) * np.sin(lat) return np.column_stack((x, y, z)) def gps_to_enu(samples: list[GpsSample]): lat0 = float(np.median([sample.lat for sample in samples])) lon0 = float(np.median([sample.lon for sample in samples])) h0 = float(np.median([sample.height for sample in samples])) origin = geodetic_to_ecef([lat0], [lon0], [h0])[0] xyz = geodetic_to_ecef( [sample.lat for sample in samples], [sample.lon for sample in samples], [sample.height for sample in samples], ) lat = math.radians(lat0) lon = math.radians(lon0) rotation = np.array( [ [-math.sin(lon), math.cos(lon), 0.0], [ -math.sin(lat) * math.cos(lon), -math.sin(lat) * math.sin(lon), math.cos(lat), ], [ math.cos(lat) * math.cos(lon), math.cos(lat) * math.sin(lon), math.sin(lat), ], ] ) return (xyz - origin) @ rotation.T, (lat0, lon0, h0) def ecef_to_geodetic(xyz): """Convert ECEF XYZ to WGS-84 latitude, longitude and ellipsoid height.""" xyz = np.atleast_2d(np.asarray(xyz, dtype=float)) x, y, z = xyz.T a = 6378137.0 e2 = 6.69437999014e-3 horizontal = np.hypot(x, y) lon = np.arctan2(y, x) lat = np.arctan2(z, horizontal * (1.0 - e2)) for _ in range(8): n = a / np.sqrt(1.0 - e2 * np.sin(lat) ** 2) height = horizontal / np.cos(lat) - n lat = np.arctan2(z, horizontal * (1.0 - e2 * n / (n + height))) n = a / np.sqrt(1.0 - e2 * np.sin(lat) ** 2) height = horizontal / np.cos(lat) - n return np.column_stack((np.rad2deg(lat), np.rad2deg(lon), height)) def enu_to_geodetic(enu, reference): """Convert local ENU positions back to WGS-84 coordinates.""" lat0, lon0, h0 = reference lat = math.radians(lat0) lon = math.radians(lon0) rotation = np.array( [ [-math.sin(lon), math.cos(lon), 0.0], [ -math.sin(lat) * math.cos(lon), -math.sin(lat) * math.sin(lon), math.cos(lat), ], [ math.cos(lat) * math.cos(lon), math.cos(lat) * math.sin(lon), math.sin(lat), ], ] ) origin = geodetic_to_ecef([lat0], [lon0], [h0])[0] xyz = origin + np.atleast_2d(np.asarray(enu, dtype=float)) @ rotation return ecef_to_geodetic(xyz) def skew(v): x, y, z = v return np.array([[0.0, -z, y], [z, 0.0, -x], [-y, x, 0.0]]) def quat_normalize(q): q = np.asarray(q, dtype=float) q /= np.linalg.norm(q) return q if q[0] >= 0.0 else -q def quat_multiply(q, r): w, x, y, z = q a, b, c, d = r return np.array( [ w * a - x * b - y * c - z * d, w * b + x * a + y * d - z * c, w * c - x * d + y * a + z * b, w * d + x * c - y * b + z * a, ] ) def rotvec_quat(v): angle = float(np.linalg.norm(v)) if angle < 1e-10: return quat_normalize(np.r_[1.0, 0.5 * np.asarray(v)]) return np.r_[math.cos(angle / 2.0), np.asarray(v) * math.sin(angle / 2.0) / angle] def quat_rotation(q): w, x, y, z = q return np.array( [ [1 - 2 * (y * y + z * z), 2 * (x * y - w * z), 2 * (x * z + w * y)], [2 * (x * y + w * z), 1 - 2 * (x * x + z * z), 2 * (y * z - w * x)], [2 * (x * z - w * y), 2 * (y * z + w * x), 1 - 2 * (x * x + y * y)], ] ) def align_quat(source, target): source = np.asarray(source, dtype=float) / np.linalg.norm(source) target = np.asarray(target, dtype=float) / np.linalg.norm(target) dot = float(np.dot(source, target)) if dot < -0.999999: axis = np.cross(source, [1.0, 0.0, 0.0]) if np.linalg.norm(axis) < 1e-6: axis = np.cross(source, [0.0, 1.0, 0.0]) axis /= np.linalg.norm(axis) return np.r_[0.0, axis] return quat_normalize(np.r_[1.0 + dot, np.cross(source, target)]) def robust_sigma(values, floor): values = np.asarray(values, dtype=float) mad = np.median(np.abs(values - np.median(values))) return max(float(1.4826 * mad), floor) class StationaryEskf: """15-state ESKF with right attitude error in a local ENU frame.""" def __init__(self, position, attitude, accel_sigma, gyro_sigma, gps_sigma): self.p = np.asarray(position, dtype=float).copy() self.v = np.zeros(3) self.q = quat_normalize(attitude) self.ba = np.zeros(3) self.bg = np.zeros(3) self.p_cov = np.diag( np.r_[gps_sigma**2, np.full(3, 1.0), np.deg2rad([5, 5, 30]) ** 2, np.full(3, 0.05**2), np.full(3, np.deg2rad(0.05) ** 2)] ) self.accel_sigma = np.asarray(accel_sigma) self.gyro_sigma = np.asarray(gyro_sigma) self.gps_r = np.diag(np.asarray(gps_sigma) ** 2) self.rejected_gps = 0 self.accepted_gps = 0 def propagate(self, accel, gyro, dt): if dt <= 0.0: return dt = min(float(dt), 1.0) omega = np.asarray(gyro) - self.bg specific_force = np.asarray(accel) - self.ba rotation = quat_rotation(self.q) acceleration = rotation @ specific_force + np.array([0.0, 0.0, -G]) self.p += self.v * dt + 0.5 * acceleration * dt * dt self.v += acceleration * dt self.q = quat_normalize(quat_multiply(self.q, rotvec_quat(omega * dt))) f = np.zeros((15, 15)) f[0:3, 3:6] = np.eye(3) f[3:6, 6:9] = -rotation @ skew(specific_force) f[3:6, 9:12] = -rotation f[6:9, 6:9] = -skew(omega) f[6:9, 12:15] = -np.eye(3) phi = np.eye(15) + f * dt noise_map = np.zeros((15, 12)) noise_map[3:6, 0:3] = rotation noise_map[6:9, 3:6] = np.eye(3) noise_map[9:12, 6:9] = np.eye(3) noise_map[12:15, 9:12] = np.eye(3) qc = np.diag( np.r_[ self.accel_sigma**2, self.gyro_sigma**2, np.full(3, 0.002**2), np.full(3, np.deg2rad(0.002) ** 2), ] ) self.p_cov = phi @ self.p_cov @ phi.T + noise_map @ qc @ noise_map.T * dt self.p_cov = 0.5 * (self.p_cov + self.p_cov.T) def _update(self, residual, h, r): innovation = h @ self.p_cov @ h.T + r gain = np.linalg.solve(innovation, h @ self.p_cov).T delta = gain @ residual identity = np.eye(15) ikh = identity - gain @ h self.p_cov = ikh @ self.p_cov @ ikh.T + gain @ r @ gain.T self.p += delta[0:3] self.v += delta[3:6] self.q = quat_normalize(quat_multiply(self.q, rotvec_quat(delta[6:9]))) self.ba += delta[9:12] self.bg += delta[12:15] self.p_cov = 0.5 * (self.p_cov + self.p_cov.T) return innovation def gps_update(self, position, gate=16.27): h = np.zeros((3, 15)) h[:, 0:3] = np.eye(3) residual = np.asarray(position) - self.p innovation = h @ self.p_cov @ h.T + self.gps_r mahalanobis = float(residual @ np.linalg.solve(innovation, residual)) if mahalanobis > gate: self.rejected_gps += 1 return False, mahalanobis self._update(residual, h, self.gps_r) self.accepted_gps += 1 return True, mahalanobis def zero_velocity_update(self, sigma=0.025): h = np.zeros((3, 15)) h[:, 3:6] = np.eye(3) self._update(-self.v, h, np.eye(3) * sigma**2) def zero_gyro_update(self, measured_gyro, sigma): h = np.zeros((3, 15)) h[:, 12:15] = np.eye(3) # z = bg + noise when the true angular rate is zero. self._update(np.asarray(measured_gyro) - self.bg, h, np.diag(sigma**2)) def run_filter(gps: list[GpsSample], imu: list[ImuSample], enu): accel = np.vstack([sample.accel_mps2 for sample in imu]) gyro = np.vstack([sample.gyro_rps for sample in imu]) mean_accel = np.mean(accel, axis=0) attitude = align_quat(mean_accel, [0.0, 0.0, 1.0]) accel_sigma = np.array([robust_sigma(accel[:, i], 0.003) for i in range(3)]) gyro_sigma = np.array( [robust_sigma(gyro[:, i], math.radians(0.001)) for i in range(3)] ) gps_sigma = np.array([robust_sigma(enu[:, i], 1.0 if i < 2 else 2.0) for i in range(3)]) eskf = StationaryEskf(enu[0], attitude, accel_sigma, gyro_sigma, gps_sigma) events = [(sample.t, 0, i) for i, sample in enumerate(imu)] events += [(sample.t, 1, i) for i, sample in enumerate(gps)] events.sort() current_accel = accel[0] current_gyro = gyro[0] last_t = 0.0 output = [] for t, kind, index in events: eskf.propagate(current_accel, current_gyro, t - last_t) last_t = t if kind == 0: current_accel = accel[index] current_gyro = gyro[index] eskf.zero_velocity_update() eskf.zero_gyro_update(current_gyro, np.maximum(gyro_sigma, math.radians(0.002))) else: accepted, mahalanobis = eskf.gps_update(enu[index]) output.append( { "t": t, "raw": enu[index].copy(), "fused": eskf.p.copy(), "velocity": eskf.v.copy(), "position_std": np.sqrt(np.maximum(np.diag(eskf.p_cov)[0:3], 0.0)), "accepted": accepted, "mahalanobis": mahalanobis, } ) return eskf, output, accel_sigma, gyro_sigma, gps_sigma, attitude def radial_95(values): centered = values - np.median(values, axis=0) return float(np.percentile(np.linalg.norm(centered, axis=1), 95)) def write_csvs(output_dir, gps, imu, enu, filtered): with (output_dir / "gps_positions.csv").open("w", newline="") as stream: writer = csv.writer(stream) writer.writerow(["t_s", "unix_s", "lat_deg", "lon_deg", "height_m", "east_m", "north_m", "up_m", "hdop", "vdop"]) for sample, local in zip(gps, enu): writer.writerow([sample.t, sample.unix_s, sample.lat, sample.lon, sample.height, *local, sample.hdop, sample.vdop]) with (output_dir / "imu_windows.csv").open("w", newline="") as stream: writer = csv.writer(stream) writer.writerow(["t_s", "unix_s", "samples", "temperature_c", "ax_mps2", "ay_mps2", "az_mps2", "gx_rps", "gy_rps", "gz_rps"]) for sample in imu: writer.writerow([sample.t, sample.unix_s, sample.samples, sample.temperature_c, *sample.accel_mps2, *sample.gyro_rps]) with (output_dir / "eskf_positions.csv").open("w", newline="") as stream: writer = csv.writer(stream) writer.writerow(["t_s", "gps_e_m", "gps_n_m", "gps_u_m", "eskf_e_m", "eskf_n_m", "eskf_u_m", "ve_mps", "vn_mps", "vu_mps", "sigma_e_m", "sigma_n_m", "sigma_u_m", "gps_accepted", "mahalanobis2"]) for row in filtered: writer.writerow([row["t"], *row["raw"], *row["fused"], *row["velocity"], *row["position_std"], int(row["accepted"]), row["mahalanobis"]]) def write_playback_map(output_dir, gps, filtered, reference): """Write a Leaflet map with a sample-by-sample GPS/ESKF player.""" fused_lla = enu_to_geodetic( np.vstack([row["fused"] for row in filtered]), reference ) records = [] for index, (sample, row, corrected) in enumerate(zip(gps, filtered, fused_lla)): records.append( { "i": index, "t": round(float(row["t"]), 6), "raw": [sample.lat, sample.lon, sample.height], "fused": [float(corrected[0]), float(corrected[1]), float(corrected[2])], "accepted": bool(row["accepted"]), "mahalanobis": round(float(row["mahalanobis"]), 3), "separation": round(float(np.linalg.norm(row["raw"][:2] - row["fused"][:2])), 3), } ) data_json = json.dumps(records, separators=(",", ":"), ensure_ascii=False) center_json = json.dumps([reference[0], reference[1]]) html = """ GPS / ESKF sample oynatıcı
1 / 10.0 s
""".replace("__DATA__", data_json).replace("__CENTER__", center_json) (output_dir / "trajectory_map.html").write_text(html, encoding="utf-8") def plot_results(output_dir, gps, imu, filtered): time = np.array([row["t"] for row in filtered]) raw = np.vstack([row["raw"] for row in filtered]) fused = np.vstack([row["fused"] for row in filtered]) labels = ["East", "North", "Up"] fig, axes = plt.subplots(3, 1, figsize=(10, 8), sharex=True) for i, axis in enumerate(axes): axis.plot(time, raw[:, i], ".", color="0.65", ms=3, label="GPS WLS") axis.plot(time, fused[:, i], color="tab:blue", lw=1.8, label="ESKF + ZUPT") axis.set_ylabel(f"{labels[i]} [m]") axis.grid(alpha=0.25) axes[0].legend() axes[-1].set_xlabel("Normalized elapsed time [s]") fig.suptitle("Stationary GPS / IMU error-state Kalman filter") fig.tight_layout() fig.savefig(output_dir / "position_timeseries.png", dpi=160) plt.close(fig) accepted = np.array([row["accepted"] for row in filtered]) fig, axis = plt.subplots(figsize=(7, 7)) axis.scatter(raw[accepted, 0], raw[accepted, 1], s=12, alpha=0.5, color="0.5", label="GPS WLS accepted") axis.scatter(raw[~accepted, 0], raw[~accepted, 1], marker="x", s=55, color="tab:red", label="GPS gated") axis.plot(fused[:, 0], fused[:, 1], color="tab:blue", lw=1.5, label="ESKF + ZUPT") axis.scatter(fused[-1, 0], fused[-1, 1], marker="x", s=70, color="tab:red", label="ESKF final") axis.set_aspect("equal", adjustable="box") axis.set_xlabel("East [m]") axis.set_ylabel("North [m]") axis.grid(alpha=0.25) axis.legend() center = np.median(raw[:, :2], axis=0) central_radius = max(20.0, 1.2 * float(np.percentile(np.abs(raw[:, :2] - center), 99))) axis.set_xlim(center[0] - central_radius, center[0] + central_radius) axis.set_ylim(center[1] - central_radius, center[1] + central_radius) axis.set_title(f"Horizontal stationary scatter (central view; gated={np.sum(~accepted)})") fig.tight_layout() fig.savefig(output_dir / "horizontal_scatter.png", dpi=160) plt.close(fig) t_imu = np.array([sample.t for sample in imu]) accel = np.vstack([sample.accel_mps2 for sample in imu]) / G gyro = np.rad2deg(np.vstack([sample.gyro_rps for sample in imu])) fig, axes = plt.subplots(2, 1, figsize=(10, 6), sharex=True) for i, name in enumerate("XYZ"): axes[0].plot(t_imu, accel[:, i], label=name) axes[1].plot(t_imu, gyro[:, i], label=name) axes[0].set_ylabel("Acceleration [g]") axes[1].set_ylabel("Angular rate [deg/s]") axes[1].set_xlabel("IMU elapsed time [s]") for axis in axes: axis.grid(alpha=0.25) axis.legend(ncol=3) fig.suptitle("ICM-4268x 500 ms window averages") fig.tight_layout() fig.savefig(output_dir / "imu_stationary.png", dpi=160) plt.close(fig) def main(): parser = argparse.ArgumentParser() parser.add_argument("--gps", type=Path, default=Path("logs/gps_60s.csv")) parser.add_argument("--imu", type=Path, default=Path("logs/imu_60s.csv")) parser.add_argument("--output", type=Path, default=Path("output")) args = parser.parse_args() args.output.mkdir(parents=True, exist_ok=True) gps = parse_gps(args.gps) imu = parse_imu(args.imu) enu, reference = gps_to_enu(gps) eskf, filtered, accel_sigma, gyro_sigma, gps_sigma, attitude = run_filter(gps, imu, enu) write_csvs(args.output, gps, imu, enu, filtered) plot_results(args.output, gps, imu, filtered) write_playback_map(args.output, gps, filtered, reference) raw = np.vstack([row["raw"] for row in filtered]) fused = np.vstack([row["fused"] for row in filtered]) raw_std = np.std(raw, axis=0, ddof=1) fused_std = np.std(fused, axis=0, ddof=1) raw_h95 = radial_95(raw[:, :2]) fused_h95 = radial_95(fused[:, :2]) imu_accel = np.vstack([sample.accel_mps2 for sample in imu]) imu_gyro = np.vstack([sample.gyro_rps for sample in imu]) mean_accel = np.mean(imu_accel, axis=0) mean_gyro = np.mean(imu_gyro, axis=0) lines = [ "Stationary GPS/IMU ESKF result", "================================", f"GPS positions: {len(gps)} over {gps[-1].t - gps[0].t:.3f} s", f"IMU windows: {len(imu)} over {imu[-1].t - imu[0].t:.3f} s", f"Underlying SPI samples: {sum(sample.samples for sample in imu)}", "Capture mode: sequential logs replayed on a common elapsed-time axis", f"ENU reference (capture median): lat={reference[0]:.10f} deg, lon={reference[1]:.10f} deg, h={reference[2]:.3f} m", f"GPS robust sigma E/N/U [m]: {gps_sigma[0]:.3f}, {gps_sigma[1]:.3f}, {gps_sigma[2]:.3f}", f"GPS accepted/rejected: {eskf.accepted_gps}/{eskf.rejected_gps}", f"Raw GPS std E/N/U [m]: {raw_std[0]:.3f}, {raw_std[1]:.3f}, {raw_std[2]:.3f}", f"ESKF std E/N/U [m]: {fused_std[0]:.3f}, {fused_std[1]:.3f}, {fused_std[2]:.3f}", f"Raw/ESKF horizontal radial 95% [m]: {raw_h95:.3f} / {fused_h95:.3f}", f"IMU mean acceleration XYZ [m/s^2]: {mean_accel[0]:.6f}, {mean_accel[1]:.6f}, {mean_accel[2]:.6f}", f"IMU mean angular rate XYZ [deg/s]: {np.rad2deg(mean_gyro)[0]:.6f}, {np.rad2deg(mean_gyro)[1]:.6f}, {np.rad2deg(mean_gyro)[2]:.6f}", f"IMU robust accel noise XYZ [m/s^2]: {accel_sigma[0]:.6f}, {accel_sigma[1]:.6f}, {accel_sigma[2]:.6f}", f"IMU robust gyro noise XYZ [deg/s]: {np.rad2deg(gyro_sigma)[0]:.6f}, {np.rad2deg(gyro_sigma)[1]:.6f}, {np.rad2deg(gyro_sigma)[2]:.6f}", f"Initial body-to-ENU quaternion [w,x,y,z]: {attitude[0]:.8f}, {attitude[1]:.8f}, {attitude[2]:.8f}, {attitude[3]:.8f}", f"Final ESKF position E/N/U [m]: {eskf.p[0]:.3f}, {eskf.p[1]:.3f}, {eskf.p[2]:.3f}", f"Final ESKF velocity E/N/U [m/s]: {eskf.v[0]:.6f}, {eskf.v[1]:.6f}, {eskf.v[2]:.6f}", "Caution: no surveyed ground truth; reported gains are precision/stability, not absolute accuracy.", "Caution: ZUPT is enabled because the board was declared stationary.", ] summary = "\n".join(lines) + "\n" (args.output / "summary.txt").write_text(summary, encoding="utf-8") print(summary, end="") if __name__ == "__main__": main()