From 337176eb703b79766354aad8bdfc584547b28135 Mon Sep 17 00:00:00 2001 From: Yuzviak Date: Thu, 16 Apr 2026 21:43:50 +0300 Subject: [PATCH] test(e2e): add SyntheticAdapter for harness self-tests Co-Authored-By: Claude Opus 4.7 (1M context) --- src/gps_denied/testing/datasets/synthetic.py | 93 ++++++++++++++++++++ tests/e2e/test_synthetic_adapter.py | 49 +++++++++++ 2 files changed, 142 insertions(+) create mode 100644 src/gps_denied/testing/datasets/synthetic.py create mode 100644 tests/e2e/test_synthetic_adapter.py diff --git a/src/gps_denied/testing/datasets/synthetic.py b/src/gps_denied/testing/datasets/synthetic.py new file mode 100644 index 0000000..5920d72 --- /dev/null +++ b/src/gps_denied/testing/datasets/synthetic.py @@ -0,0 +1,93 @@ +"""Synthetic dataset — straight-line eastward flight at constant altitude. + +Used for harness self-tests. No image files; image_path is empty so the harness +can substitute a generated checkerboard so existing OpenCV readers can open them. +""" + +from __future__ import annotations + +from typing import Iterator + +import numpy as np + +from gps_denied.testing.datasets.base import ( + DatasetAdapter, + DatasetCapabilities, + DatasetFrame, + DatasetIMU, + DatasetPose, + PlatformClass, +) + +GRAVITY = 9.81 +EARTH_R = 6_378_137.0 # WGS84 semi-major axis, metres + + +class SyntheticAdapter(DatasetAdapter): + """Deterministic straight-line eastward flight for harness smoke-tests.""" + + def __init__( + self, + num_frames: int = 100, + fps: float = 5.0, + imu_rate_hz: float = 100.0, + speed_m_s: float = 10.0, + altitude_m: float = 100.0, + origin_lat: float = 49.0, + origin_lon: float = 32.0, + ) -> None: + self._num_frames = num_frames + self._fps = fps + self._imu_rate_hz = imu_rate_hz + self._speed = speed_m_s + self._altitude = altitude_m + self._origin_lat = origin_lat + self._origin_lon = origin_lon + + @property + def name(self) -> str: + return "synthetic" + + @property + def capabilities(self) -> DatasetCapabilities: + return DatasetCapabilities( + has_raw_imu=True, + has_rtk_gt=True, + has_loop_closures=False, + platform_class=PlatformClass.SYNTHETIC, + ) + + def iter_frames(self) -> Iterator[DatasetFrame]: + period_ns = int(1e9 / self._fps) + for i in range(self._num_frames): + yield DatasetFrame( + frame_idx=i, + timestamp_ns=i * period_ns, + image_path="", # harness fills from generator + ) + + def iter_imu(self) -> Iterator[DatasetIMU]: + duration_s = self._num_frames / self._fps + n_samples = int(duration_s * self._imu_rate_hz) + period_ns = int(1e9 / self._imu_rate_hz) + for i in range(n_samples): + yield DatasetIMU( + timestamp_ns=i * period_ns, + accel=(0.0, 0.0, -GRAVITY), # gravity only; constant velocity + gyro=(0.0, 0.0, 0.0), + ) + + def iter_ground_truth(self) -> Iterator[DatasetPose]: + period_ns = int(1e9 / self._fps) + for i in range(self._num_frames): + t_s = i / self._fps + east_m = self._speed * t_s + # Local tangent plane approximation (small distance) + dlon = np.degrees(east_m / (EARTH_R * np.cos(np.radians(self._origin_lat)))) + yield DatasetPose( + timestamp_ns=i * period_ns, + lat=self._origin_lat, + lon=self._origin_lon + dlon, + alt=self._altitude, + qx=0.0, qy=0.0, qz=0.0, qw=1.0, + ) diff --git a/tests/e2e/test_synthetic_adapter.py b/tests/e2e/test_synthetic_adapter.py new file mode 100644 index 0000000..0ed7c07 --- /dev/null +++ b/tests/e2e/test_synthetic_adapter.py @@ -0,0 +1,49 @@ +"""SyntheticAdapter produces a deterministic straight-line trajectory.""" + +import numpy as np + +from gps_denied.testing.datasets.synthetic import SyntheticAdapter +from gps_denied.testing.datasets.base import PlatformClass + + +def test_synthetic_has_raw_imu(): + adapter = SyntheticAdapter(num_frames=10, fps=5.0) + assert adapter.capabilities.has_raw_imu is True + assert adapter.capabilities.platform_class == PlatformClass.SYNTHETIC + + +def test_synthetic_iter_frames_count(): + adapter = SyntheticAdapter(num_frames=10, fps=5.0) + frames = list(adapter.iter_frames()) + assert len(frames) == 10 + assert frames[0].frame_idx == 0 + assert frames[-1].frame_idx == 9 + + +def test_synthetic_frame_timestamps_monotonic(): + adapter = SyntheticAdapter(num_frames=10, fps=5.0) + ts = [f.timestamp_ns for f in adapter.iter_frames()] + assert ts == sorted(ts) + # 5 fps → 200 ms spacing + deltas = np.diff(ts) + assert np.allclose(deltas, 200_000_000) + + +def test_synthetic_imu_samples_cover_frames(): + adapter = SyntheticAdapter(num_frames=10, fps=5.0, imu_rate_hz=100.0) + imu = list(adapter.iter_imu()) + # 10 frames at 5 fps = 2 s of data, at 100 Hz = 200 samples + assert len(imu) == 200 + # Static trajectory → gravity on z, zero gyro + assert abs(imu[0].accel[2] + 9.81) < 1e-6 + assert imu[0].gyro == (0.0, 0.0, 0.0) + + +def test_synthetic_ground_truth_matches_frames(): + adapter = SyntheticAdapter(num_frames=10, fps=5.0) + poses = list(adapter.iter_ground_truth()) + assert len(poses) == 10 + # Straight-line east at 10 m/s → position at t=0.2s is 2m east of origin + # (synthetic puts GT at same timestamps as frames) + assert abs(poses[1].lat - poses[0].lat) < 1e-9 # no latitude change + assert poses[1].lon > poses[0].lon # moving east