Adds an opt-in C5-internal orthorectifier (`_orthorectifier.py`) that emits at most one tile-aligned JPEG candidate per nav frame to the C6 `TileStore.write_tile` API. Quality gates fire before any OpenCV work: covariance Frobenius, inlier floor, source-label (`SATELLITE_ANCHORED` only), and once-per-frame rate limit. Cross-component import rule (AZ-507) is preserved: c5_state never imports c6_tile_cache. `runtime_root.state_factory` carries a new `_C6MidFlightIngestAdapter` that builds the canonical `TileMetadata` (`ONBOARD_INGEST` / `FRESH` / `PENDING`), hashes the JPEG, and translates `FreshnessRejectionError` to a `None` return so the orthorectifier silently swallows freshness rejection per AC-NEW-3. Wiring is opt-in via `C5StateConfig.orthorectifier.enabled`; existing tests/binaries default to disabled and are unaffected. Both `GtsamIsam2StateEstimator` and `EskfStateEstimator` participate through new `attach_orthorectifier` / `set_latest_nav_frame` extension methods (Protocol surface unchanged). Tests: 22 new unit tests cover AC-1..AC-9 plus inlier-floor gate plus the composition-root adapter. 216/216 c5_state and 38/38 runtime-root + compose tests pass. Co-authored-by: Cursor <cursoragent@cursor.com>
8.7 KiB
Batch 65 — Cycle 1 Report
Date: 2026-05-16 Tasks: AZ-389 (C5 orthorectifier → C6 mid-flight tile candidate emission) Verdict: COMPLETE — PASS (self-reviewed)
Summary
Closes the AZ-389 gap inside the C5 state estimator by introducing a
component-internal orthorectifier that emits at most one tile-aligned
JPEG candidate per nav-camera frame to C6 via the existing
TileStore.write_tile API.
The implementation respects the AZ-507 cross-component import rule
(enforced by test_az270_compose_root.test_ac6_only_compose_root_imports_concrete_strategies):
c5_state never imports c6_tile_cache. The composition root's
runtime_root.state_factory carries a new
_C6MidFlightIngestAdapter that wraps the C6 TileStore, builds the
canonical TileMetadata (TileSource.ONBOARD_INGEST,
FreshnessLabel.FRESH, VotingStatus.PENDING), hashes the JPEG
bytes, and translates FreshnessRejectionError into a None return
so the orthorectifier silently swallows freshness rejection per
AC-NEW-3 (opportunistic emission).
The orthorectifier runs entirely on the existing state-ingest thread
(Invariant 1) — no new threads, no additional locks. It is wired
opt-in: config.components['c5_state'].orthorectifier.enabled = false
keeps the legacy steady-state path bit-for-bit unchanged. Both
GtsamIsam2StateEstimator and EskfStateEstimator participate
through new attach_orthorectifier(...) and set_latest_nav_frame(...)
extension methods (concrete only — the StateEstimator Protocol
surface is unchanged so existing implementations and tests continue
to satisfy it).
Architecture decisions
- Per-frame, per-estimator hook — the hook fires after the
EstimatorOutput is built inside
current_estimate(). The buffered nav frame supplies the source pixels; the orthorectifier passes duck-typed pose + cov to its kernel and rate-limits itself to one tile perframe.frame_id(AC-4). - No new C6 API — uses
TileStore.write_tile(blob, metadata), the same atomic file + metadata insert that the C11 download path already uses. The composition-root adapter is the only new component-bridge. - Quality gates as cheap pre-checks — covariance Frobenius gate,
inlier-floor gate, source-label gate (only
SATELLITE_ANCHOREDpasses), and once-per-frame rate limit run BEFORE the OpenCV warp/encode work. - Best-effort kernel — any exception inside the warp / JPEG
encode path or any non-
FreshnessRejectionErrorwriter failure is swallowed with a WARNING log andNonereturn; the steady-statecurrent_estimateoutput is never disturbed. - AC-7 first-emission INFO log — emitted exactly once per flight, subsequent emissions log at DEBUG.
Files added / modified
Added (2)
src/gps_denied_onboard/components/c5_state/_orthorectifier.py— theMidFlightTileWriterProtocol cut,OrthorectifierThresholdsdataclass, andOrthorectifierclass with the homography construction (_ground_plane_homography,_compose_tile_to_image_homography,_invert_se3,_quat_to_rotation_matrix).tests/unit/c5_state/test_az389_orthorectifier.py— 22 tests covering AC-1..AC-9 plus the inlier-floor gate plus the composition-root_C6MidFlightIngestAdaptertranslation rules plusOrthorectifierConfigvalidation.
Modified (4)
src/gps_denied_onboard/components/c5_state/config.py— newOrthorectifierConfigdataclass nested asC5StateConfig.orthorectifier. Disabled by default; tunable thresholds + tile / zoom / JPEG knobs.src/gps_denied_onboard/components/c5_state/gtsam_isam2_estimator.py— orthorectifier state fields,attach_orthorectifier+set_latest_nav_frameextension methods,_maybe_emit_mid_flight_tilehook incurrent_estimate(), andcreate()factory now accepts the optionalmid_flight_tile_writer/camera_calibration/flight_id/companion_idparams.src/gps_denied_onboard/components/c5_state/eskf_baseline.py— same set of changes, plus_latest_viocache (ESKF historically did not retain the full VIO DTO).src/gps_denied_onboard/runtime_root/state_factory.py—_C6MidFlightIngestAdapterclass +build_state_estimatornow accepts optionaltile_store/camera_calibration/flight_id/companion_idand forwards them to the strategy factory when AZ-389 is enabled.
Task Results
| Task | Status | Files Modified | Focused tests | AC Coverage | Issues |
|---|---|---|---|---|---|
| AZ-389 | Done | 1 added + 4 modified under src/; 1 added under tests/unit/c5_state/; task spec moved _docs/02_tasks/todo/ → done/ |
22/22 pass | 9/9 covered | None |
AC Test Coverage: 9/9 covered
| AC | Test | Status |
|---|---|---|
| AC-1 | test_ac1_homography_projects_origin_to_principal_point + test_ac1_homography_projects_offset_within_one_pixel + test_compose_tile_to_image_homography_is_centred_on_camera |
Covered |
| AC-2 | test_ac2_cov_norm_above_threshold_blocks_emission + test_ac2_cov_norm_below_threshold_emits |
Covered |
| AC-3 | test_ac3_non_satellite_anchored_blocked (parametrised over VISUAL_PROPAGATED / DEAD_RECKONED) |
Covered |
| AC-4 | test_ac4_same_frame_id_processed_only_once + test_ac4_distinct_frame_ids_each_emit |
Covered |
| AC-5 | test_ac5_writer_called_with_onboard_ingest_metadata + test_adapter_calls_write_tile_with_onboard_ingest_metadata |
Covered |
| AC-6 | test_ac6_jpeg_bytes_decode_to_expected_shape_and_quality + adapter-side content_sha256_hex == hashlib.sha256(jpeg_bytes).hexdigest() assertion |
Covered |
| AC-7 | test_ac7_first_emission_logs_info_subsequent_logs_debug |
Covered |
| AC-8 | test_ac8_missing_inputs_silent_return_none (parametrised over frame / pose_estimate / cov_6x6) |
Covered |
| AC-9 | test_ac9_writer_returning_none_swallowed + test_ac9_writer_raising_swallowed_with_warning + test_adapter_translates_freshness_rejection_to_none |
Covered |
Code Review Verdict: PASS (self-reviewed)
Auto-Fix Attempts: 0
Stuck Agents: None
Cross-batch verification
tests/unit/c5_state/— 216 / 216 pass (all pre-AZ-389 tests still pass — Protocol surface unchanged, factory signature is backward-compatible via defaultNoneparams).tests/unit/test_az270_compose_root.py— 8 / 8 pass; the cross-component import lint still holds against the new_C6MidFlightIngestAdapter.tests/unit/test_runtime_root_env_gate.py+tests/unit/test_az401_compose_root_replay.py+tests/unit/test_ac3_compose_files.py— 38 / 38 pass.tests/unit/c6_tile_cache/— 126 / 126 in-process tests pass (Postgres-backed tests skipped; require Docker).
Notes / leftovers
- The Jira description for AZ-389 still references the
pre-AZ-559
tile_store.put_mid_flight_candidateAPI surface; the local task spec was rewritten againstwrite_tileper the History section. Logged as tracker hygiene; not blocking. - During investigation of the existing C11 download adapter
(
runtime_root/c11_factory.py::_C6DownloadAdapter.write_tile_for_download) we noticed it calls bothtile_store.write_tile(blob, metadata)andmetadata_store.insert_metadata(metadata)sequentially — given thatPostgresFilesystemStore.write_tileis itself atomic (file write + metadata insert in a single transaction) the second call is a probable redundancy. Out of scope for AZ-389; recorded here for a future hygiene ticket.