mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 19:21:12 +00:00
64d961f60c
Batch 98 (cycle 2) — first two PBIs of epic AZ-696 (real-flight validation harness): AZ-697: direct binary-tlog GPS-truth extractor - New src/gps_denied_onboard/replay_input/tlog_ground_truth.py reads GLOBAL_POSITION_INT (with GPS_RAW_INT fallback) from a binary ArduPilot tlog via pymavlink.mavutil and returns a frozen+slotted TlogGroundTruth DTO with per-record ts_ns / lat_deg / lon_deg / alt_m / hdg_deg / vx_m_s / vy_m_s / vz_m_s. - Promoted l2_horizontal_m + match_percentage + GroundTruthRow from tests/e2e/replay/_helpers.py into the new production module src/gps_denied_onboard/helpers/gps_compare.py. The e2e helper now re-exports the same objects (identity, not copies) so existing test imports continue working untouched. - tests/e2e/replay/conftest.py prefers the real derkachi.tlog when present, falls back to the CSV synth path otherwise. - 22 new unit tests cover AC-1..AC-5 (mypy --strict subprocess test included). All passing. AZ-702: Topotek KHP20S30 factory-sheet camera calibration - New _docs/00_problem/input_data/flight_derkachi/khp20s30_factory.json: fx = fy = 4644.444, cx = 960, cy = 540, HFOV ~ 23.3 deg, VFOV ~ 13.2 deg, computed from the published 8.5 mm focal length + 1/2.8" sensor + 1920x1080 capture at lowest zoom step. Distortion zeroed, body_to_camera_se3 = identity with nadir convention. Acquisition method explicitly recorded as factory_sheet so downstream code can expect higher residual error than a lab calibration. - _docs/00_problem/input_data/flight_derkachi/camera_info.md updated to document the assumptions, expected residual error window, and conftest pick-up rule. - tests/e2e/replay/conftest.py::_calibration_path() prefers khp20s30_factory.json when present, falls back to adti26.json. - 9 new unit tests cover AC-1..AC-4 (schema, intrinsics traceback, doc reference, conftest pick-up). All passing. Test run: 45 new tests, all passing. Full-suite gate deferred to Step 16 (after the last batch in cycle 2 per the implement skill). Adjacent note (not fixed in this batch, recorded in the batch report): auto_sync.py has the same redundant pymavlink type:ignore + a few numpy/cv2 mypy --strict issues. None on this batch's path. Refs: _docs/03_implementation/batch_98_cycle2_report.md Refs: _docs/02_tasks/done/AZ-697_tlog_ground_truth_extractor.md Refs: _docs/02_tasks/done/AZ-702_khp20s30_calibration.md Co-authored-by: Cursor <cursoragent@cursor.com>
153 lines
4.3 KiB
Python
153 lines
4.3 KiB
Python
"""AZ-697 AC-5 — gps_compare helper-move snapshot.
|
|
|
|
The ``l2_horizontal_m`` / ``match_percentage`` / ``GroundTruthRow``
|
|
trio moved from ``tests/e2e/replay/_helpers.py`` into production code
|
|
at ``src/gps_denied_onboard/helpers/gps_compare.py``. This module
|
|
pins the post-move numerical behaviour so a future refactor of either
|
|
the helper or the test re-export can't silently drift.
|
|
|
|
The numerical reference values are hand-computed against the WGS84
|
|
mean Earth radius used by ``helpers/wgs_converter.py`` (AZ-279). The
|
|
``tests/e2e/replay/test_helpers.py`` module continues to import from
|
|
``tests/e2e/replay/_helpers`` (which now re-exports from the
|
|
production location), so both call sites are exercised.
|
|
|
|
Style: every test follows the Arrange / Act / Assert pattern.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from gps_denied_onboard.helpers.gps_compare import (
|
|
GroundTruthRow,
|
|
l2_horizontal_m,
|
|
match_percentage,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------
|
|
# Snapshot: production location vs prior test-helpers location
|
|
|
|
|
|
def test_l2_zero_at_same_point() -> None:
|
|
# Act
|
|
d = l2_horizontal_m(50.08, 36.11, 50.08, 36.11)
|
|
|
|
# Assert
|
|
assert d == pytest.approx(0.0, abs=1e-6)
|
|
|
|
|
|
def test_l2_one_degree_latitude_is_111km() -> None:
|
|
# Act
|
|
d = l2_horizontal_m(50.08, 36.11, 51.08, 36.11)
|
|
|
|
# Assert — one degree of latitude on a sphere of radius 6_371_008.8 m.
|
|
assert d == pytest.approx(111_195.0, rel=0.001)
|
|
|
|
|
|
def test_l2_symmetric() -> None:
|
|
# Arrange
|
|
a = (49.991, 36.221)
|
|
b = (50.080, 36.111)
|
|
|
|
# Act
|
|
d_ab = l2_horizontal_m(*a, *b)
|
|
d_ba = l2_horizontal_m(*b, *a)
|
|
|
|
# Assert
|
|
assert d_ab == pytest.approx(d_ba, rel=1e-12)
|
|
|
|
|
|
def test_l2_kharkiv_to_kyiv_known_pair() -> None:
|
|
# Arrange — externally known reference distance is ~411 km.
|
|
kharkiv_lat, kharkiv_lon = 49.9935, 36.2304
|
|
kyiv_lat, kyiv_lon = 50.4501, 30.5234
|
|
|
|
# Act
|
|
d = l2_horizontal_m(kharkiv_lat, kharkiv_lon, kyiv_lat, kyiv_lon)
|
|
|
|
# Assert
|
|
assert d == pytest.approx(411_000.0, rel=0.005)
|
|
|
|
|
|
def test_match_percentage_all_within_threshold() -> None:
|
|
# Arrange
|
|
gt = [GroundTruthRow(t_s=0.0, lat_deg=50.0, lon_deg=36.0, alt_m=100.0)]
|
|
emissions = [
|
|
{
|
|
"emitted_at": 0,
|
|
"position_wgs84": {"lat_deg": 50.0, "lon_deg": 36.0, "alt_m": 100.0},
|
|
}
|
|
]
|
|
|
|
# Act
|
|
pct = match_percentage(emissions, gt, threshold_m=100.0)
|
|
|
|
# Assert
|
|
assert pct == 1.0
|
|
|
|
|
|
def test_match_percentage_none_within_threshold() -> None:
|
|
# Arrange
|
|
gt = [GroundTruthRow(t_s=0.0, lat_deg=50.0, lon_deg=36.0, alt_m=100.0)]
|
|
emissions = [
|
|
{
|
|
"emitted_at": 0,
|
|
# ~111 km north of the GT row.
|
|
"position_wgs84": {"lat_deg": 51.0, "lon_deg": 36.0, "alt_m": 100.0},
|
|
}
|
|
]
|
|
|
|
# Act
|
|
pct = match_percentage(emissions, gt, threshold_m=100.0)
|
|
|
|
# Assert
|
|
assert pct == 0.0
|
|
|
|
|
|
def test_match_percentage_empty_emissions_zero() -> None:
|
|
# Arrange
|
|
gt = [GroundTruthRow(t_s=0.0, lat_deg=50.0, lon_deg=36.0, alt_m=100.0)]
|
|
|
|
# Act
|
|
pct = match_percentage([], gt, threshold_m=100.0)
|
|
|
|
# Assert
|
|
assert pct == 0.0
|
|
|
|
|
|
def test_match_percentage_empty_ground_truth_raises() -> None:
|
|
# Act / Assert
|
|
with pytest.raises(AssertionError, match="ground_truth must be non-empty"):
|
|
match_percentage(
|
|
[{"emitted_at": 0, "position_wgs84": {"lat_deg": 50, "lon_deg": 36}}],
|
|
[],
|
|
threshold_m=100.0,
|
|
)
|
|
|
|
|
|
def test_ground_truth_row_is_frozen() -> None:
|
|
# Arrange
|
|
row = GroundTruthRow(t_s=0.0, lat_deg=50.0, lon_deg=36.0, alt_m=100.0)
|
|
|
|
# Act / Assert
|
|
with pytest.raises((AttributeError, TypeError)):
|
|
row.lat_deg = 51.0 # type: ignore[misc]
|
|
|
|
|
|
# ---------------------------------------------------------------------
|
|
# Snapshot: re-export from prior test-helpers location returns the
|
|
# same object as the production import. Guarantees there is no second
|
|
# divergent copy under tests/.
|
|
|
|
|
|
def test_test_helpers_reexport_is_identical() -> None:
|
|
# Act
|
|
from tests.e2e.replay import _helpers as test_helpers_module
|
|
|
|
# Assert — identity, not just equality.
|
|
assert test_helpers_module.l2_horizontal_m is l2_horizontal_m
|
|
assert test_helpers_module.match_percentage is match_percentage
|
|
assert test_helpers_module.GroundTruthRow is GroundTruthRow
|