"""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