Files
Oleksandr Bezdieniezhnykh 64d961f60c [AZ-697] [AZ-702] tlog GPS truth + KHP20S30 factory calibration
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>
2026-05-20 16:09:03 +03:00

185 lines
5.5 KiB
Python

"""AZ-702 — Topotek KHP20S30 factory-sheet calibration.
Covers AC-1, AC-3, AC-4 of
``_docs/02_tasks/todo/AZ-702_khp20s30_calibration.md``:
* AC-1 (JSON parses against the project schema) — same loader gate the
CLI ``replay.py::_load_calibration_json`` uses.
* AC-3 (field values match factory inputs) — ``fx == fy`` (square
pixels), principal point at image centre, zero distortion.
* AC-4 (T3 consumes this calibration) — covered by
``tests/e2e/replay/conftest.py::_calibration_path()`` returning this
file when present, exercised once T3 (AZ-699) lands.
AC-2 (`camera_info.md` updated) is a documentation AC and is verified
by inspection during code review; it does not lend itself to a runtime
assertion beyond the file-existence smoke test below.
Style: every test follows the Arrange / Act / Assert pattern.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import pytest
_FACTORY_JSON_PATH = (
Path(__file__).resolve().parents[3]
/ "_docs"
/ "00_problem"
/ "input_data"
/ "flight_derkachi"
/ "khp20s30_factory.json"
)
@pytest.fixture(scope="module")
def calibration_data() -> dict[str, Any]:
text = _FACTORY_JSON_PATH.read_text(encoding="utf-8")
return json.loads(text)
# ---------------------------------------------------------------------
# AC-1: JSON parses via the project's calibration schema gate
def test_ac1_required_schema_keys_present(
calibration_data: dict[str, Any],
) -> None:
"""Same gate ``cli/replay.py::_load_calibration_json`` enforces."""
# Assert
for key in ("intrinsics_3x3", "distortion", "body_to_camera_se3"):
assert key in calibration_data, f"missing required key: {key}"
def test_ac1_cli_loader_accepts_the_json(
calibration_data: dict[str, Any],
) -> None:
"""The CLI's strict loader (replay.py) returns without raising."""
# Arrange
from gps_denied_onboard.cli.replay import _load_calibration_json
# Act
loaded = _load_calibration_json(_FACTORY_JSON_PATH)
# Assert
assert loaded == calibration_data
# ---------------------------------------------------------------------
# AC-3: Field values match the documented factory inputs
def test_ac3_intrinsics_square_pixels_and_centred_principal_point(
calibration_data: dict[str, Any],
) -> None:
# Arrange
img_w, img_h = 1920, 1080
sensor_w_mm = 5.37
focal_mm = 4.7
expected_f = focal_mm * (img_w / sensor_w_mm)
K = calibration_data["intrinsics_3x3"]
# Assert — square pixels (fx == fy) and principal point at image centre.
fx, fy, cx, cy = K[0][0], K[1][1], K[0][2], K[1][2]
assert fx == pytest.approx(fy, rel=1e-12), "expected fx == fy (square pixels)"
assert fx == pytest.approx(expected_f, rel=1e-3), (
f"fx {fx} does not match factory-sheet derivation "
f"f * width/sensor_w = {expected_f}"
)
assert cx == pytest.approx(img_w / 2, abs=0.5)
assert cy == pytest.approx(img_h / 2, abs=0.5)
# Off-diagonal entries are zero (no skew).
assert K[0][1] == 0.0
assert K[1][0] == 0.0
assert K[2] == [0.0, 0.0, 1.0]
def test_ac3_distortion_all_zero_for_factory_sheet(
calibration_data: dict[str, Any],
) -> None:
# Assert — factory-sheet approximation skips per-unit distortion.
assert calibration_data["distortion"] == [0.0, 0.0, 0.0, 0.0, 0.0]
def test_ac3_body_to_camera_is_identity_for_nadir(
calibration_data: dict[str, Any],
) -> None:
# Arrange
expected = [
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
]
# Assert
assert calibration_data["body_to_camera_se3"] == expected
def test_ac3_acquisition_method_is_factory_sheet(
calibration_data: dict[str, Any],
) -> None:
# Assert
assert calibration_data["acquisition_method"] == "factory_sheet"
def test_metadata_documents_assumptions(
calibration_data: dict[str, Any],
) -> None:
"""Metadata must capture the factory inputs that produced K."""
# Arrange
meta = calibration_data["metadata"]
# Assert
assert meta["model"] == "Topotek KHP20S30"
assert meta["image_resolution_px"] == [1920, 1080]
assert meta["assumed_focal_length_mm"] == 4.7
assert meta["sensor_width_mm"] == 5.37
assert meta["residual_budget_pct"] > 0.0
assert "task" in meta and meta["task"] == "AZ-702"
# ---------------------------------------------------------------------
# AC-2 sanity: camera_info.md exists and references this calibration
def test_camera_info_md_references_calibration() -> None:
# Arrange
camera_info = (
Path(__file__).resolve().parents[3]
/ "_docs"
/ "00_problem"
/ "input_data"
/ "flight_derkachi"
/ "camera_info.md"
)
# Act
text = camera_info.read_text(encoding="utf-8")
# Assert
assert "khp20s30_factory.json" in text
assert "factory_sheet" in text or "factory-sheet" in text
# ---------------------------------------------------------------------
# AC-4 sanity: T3 will pick up this calibration when present
def test_ac4_conftest_picks_up_factory_calibration() -> None:
"""``tests/e2e/replay/conftest.py::_calibration_path()`` prefers this
file when present (the T3 / AZ-699 entry-point)."""
# Arrange
from tests.e2e.replay.conftest import _calibration_path
# Act
path = _calibration_path()
# Assert — the factory JSON is committed; conftest must prefer it.
assert path == _FACTORY_JSON_PATH