"""Generate a tiny synthetic session for V1 smoke tests.""" from __future__ import annotations from pathlib import Path import numpy as np from imu_lidar.contracts import ImuSeries, LidarFrame from imu_lidar.geometry import so3_exp from imu_lidar.imu_io import save_imu_csv from imu_lidar.lidar_io import save_lidar_session def _wall_cloud(rng: np.random.Generator, n: int = 800) -> np.ndarray: yz = rng.uniform([-5, -1], [5, 3], size=(n // 3, 2)) wall_x = np.column_stack([np.full(n // 3, 8.0), yz[:, 0], yz[:, 1]]) xz = rng.uniform([-5, -1], [5, 3], size=(n // 3, 2)) wall_y = np.column_stack([xz[:, 0], np.full(n // 3, 6.0), xz[:, 1]]) xy = rng.uniform([-5, -5], [5, 5], size=(n - 2 * (n // 3), 2)) ground = np.column_stack([xy[:, 0], xy[:, 1], np.full(xy.shape[0], -1.0)]) return np.vstack([wall_x, wall_y, ground]) def generate_synthetic_session( output_root: Path, *, delta_t_s: float = 0.17, yaw_extrinsic_deg: float = 25.0, seed: int = 0, ) -> dict[str, float]: """Write IMU CSV + LiDAR frames with known extrinsic rotation and time offset.""" rng = np.random.default_rng(seed) output_root = Path(output_root) output_root.mkdir(parents=True, exist_ok=True) r_x = so3_exp(np.deg2rad(np.array([2.0, -1.5, yaw_extrinsic_deg]))) map_points = _wall_cloud(rng) lidar_hz = 10.0 duration = 8.0 lidar_times = np.arange(0.0, duration, 1.0 / lidar_hz) # Non-yaw excitation is required for unique SO(3) hand-eye observability. yaw = 0.5 * np.sin(0.8 * lidar_times) + 0.12 * lidar_times pitch = 0.18 * np.sin(1.3 * lidar_times + 0.4) roll = 0.12 * np.sin(1.7 * lidar_times + 1.0) yaw_rate = np.gradient(yaw, lidar_times) pitch_rate = np.gradient(pitch, lidar_times) roll_rate = np.gradient(roll, lidar_times) frames: list[LidarFrame] = [] for index, (t, yaw_i, pitch_i, roll_i) in enumerate(zip(lidar_times, yaw, pitch, roll)): r_wl = so3_exp(np.array([roll_i, pitch_i, yaw_i])) t_wl = np.array([0.4 * t, 0.05 * np.sin(0.5 * t), 0.0]) points = (map_points - t_wl) @ r_wl points = points + rng.normal(0.0, 0.01, size=points.shape) frames.append( LidarFrame( frame_id=str(index), t_start_s=float(t), t_end_s=float(t + 0.08), points_xyz=points.astype(float), ) ) save_lidar_session(output_root / "lidar", frames) imu_hz = 100.0 t_lidar_grid = np.arange(0.0, duration, 1.0 / imu_hz) omega_lidar = np.column_stack( [ np.interp(t_lidar_grid, lidar_times, roll_rate), np.interp(t_lidar_grid, lidar_times, pitch_rate), np.interp(t_lidar_grid, lidar_times, yaw_rate), ] ) omega_imu = omega_lidar @ r_x.T g_world = np.array([0.0, 0.0, 9.80665]) acc_rows = [] for yaw_i, pitch_i, roll_i in zip( np.interp(t_lidar_grid, lidar_times, yaw), np.interp(t_lidar_grid, lidar_times, pitch), np.interp(t_lidar_grid, lidar_times, roll), ): r_wl = so3_exp(np.array([roll_i, pitch_i, yaw_i])) g_in_lidar = r_wl.T @ g_world acc_rows.append(r_x @ g_in_lidar) acc = np.asarray(acc_rows, dtype=float) static_t = np.arange(-1.0, 0.0, 1.0 / imu_hz) static_gyro = np.zeros((static_t.size, 3)) static_acc = np.tile(r_x @ g_world, (static_t.size, 1)) t_imu = np.concatenate([static_t + delta_t_s, t_lidar_grid + delta_t_s]) gyro = np.vstack([static_gyro, omega_imu]) + rng.normal(0.0, 0.001, size=(t_imu.size, 3)) acc_all = np.vstack([static_acc, acc]) + rng.normal(0.0, 0.01, size=(t_imu.size, 3)) imu = ImuSeries(t_s=t_imu, gyro_rad_s=gyro, acc_m_s2=acc_all) save_imu_csv(output_root / "imu.csv", imu) return { "delta_t_s": float(delta_t_s), "yaw_extrinsic_deg": float(yaw_extrinsic_deg), } if __name__ == "__main__": import json out = Path("examples/synthetic_session") meta = generate_synthetic_session(out) (out / "meta.json").write_text(json.dumps(meta, indent=2), encoding="utf-8") print(f"wrote {out}") print(meta)