From 007aa36fbfebe9f994fa14df3602582df0cf859f Mon Sep 17 00:00:00 2001 From: Oleksandr Bezdieniezhnykh Date: Tue, 26 May 2026 22:09:59 +0300 Subject: [PATCH] [AZ-895] Deprecate replay auto-sync surface; file AZ-908 follow-up Option A (minimum-deprecation, 2 SP) per user complexity-budget decision. Auto-sync stays importable as a raising stub for one cycle so external callers see a clean ReplayInputAdapterError instead of an ImportError. Full physical removal is filed as AZ-908 (cycle-5+ backlog). Production: - auto_sync.py: 700+ LOC -> 56-line no-op stub raising "auto-sync removed; supply --imu CSV instead" - tlog_video_adapter.py: 700+ LOC -> 105-line deprecated stub; ReplayInputAdapter.open() raises immediately, close() is a no-op - _replay_branch.py: dropped legacy auto-sync branch + _build_auto_sync_config; _validate_replay_paths now requires imu_csv_path; replay_input_adapter_factory parameter removed - cli/replay.py: --time-offset-ms / --skip-auto-sync / --auto-trim emit DeprecationWarning + stderr line; values ignored - tlog_replay_adapter.py + tlog_ground_truth.py docstrings: AUDIT-ONLY Tests: - DELETED test_az405_auto_sync, test_az405_replay_input_adapter, test_az698_window_alignment (covered code no longer runs) - ADDED test_az895_auto_sync_deprecated_stub (5 parametrised, pins AC-1) - test_az402_replay_cli: deprecation warnings + ignored-value asserts - test_az401_compose_root_replay: new imu_csv_path-required gate; deleted the calibration-loading test that relied on the removed replay_input_adapter_factory injection point - test_derkachi_real_tlog: xfail reason refreshed to AZ-848 + AZ-883 (AC-4 "AZ-848-scoped reason") Docs: - module-layout.md: replay_input file list flags deprecated modules, adds csv_ground_truth.py - _dependencies_table.md: +AZ-908 row, preamble + totals updated (179 -> 180 tasks, 567 -> 570 SP) - AZ-908 backlog spec added; AZ-895 spec moved todo -> done - batch_03_cycle4_report.md written Touched-module tests green (111 passed, 1 skipped). Full unit suite green: 2287 passed, 85 skipped, 1 deselected (pre-existing flaky perf test, unrelated). Co-authored-by: Cursor --- _docs/02_document/module-layout.md | 13 +- _docs/02_tasks/_dependencies_table.md | 7 +- .../AZ-908_replay_auto_sync_hard_removal.md | 59 + .../AZ-895_deprecate_auto_sync_surface.md | 0 .../batch_03_cycle4_report.md | 207 +++ src/gps_denied_onboard/cli/replay.py | 61 +- .../c8_fc_adapter/tlog_replay_adapter.py | 22 +- .../replay_input/__init__.py | 32 +- .../replay_input/auto_sync.py | 1130 +---------------- .../replay_input/tlog_ground_truth.py | 22 +- .../replay_input/tlog_video_adapter.py | 677 +--------- .../runtime_root/_replay_branch.py | 139 +- tests/e2e/replay/test_derkachi_real_tlog.py | 33 +- .../unit/replay_input/test_az405_auto_sync.py | 483 ------- .../test_az405_replay_input_adapter.py | 804 ------------ .../test_az698_window_alignment.py | 935 -------------- .../test_az895_auto_sync_deprecated_stub.py | 38 + tests/unit/test_az401_compose_root_replay.py | 47 +- tests/unit/test_az402_replay_cli.py | 104 +- 19 files changed, 600 insertions(+), 4213 deletions(-) create mode 100644 _docs/02_tasks/backlog/AZ-908_replay_auto_sync_hard_removal.md rename _docs/02_tasks/{todo => done}/AZ-895_deprecate_auto_sync_surface.md (100%) create mode 100644 _docs/03_implementation/batch_03_cycle4_report.md delete mode 100644 tests/unit/replay_input/test_az405_auto_sync.py delete mode 100644 tests/unit/replay_input/test_az405_replay_input_adapter.py delete mode 100644 tests/unit/replay_input/test_az698_window_alignment.py create mode 100644 tests/unit/replay_input/test_az895_auto_sync_deprecated_stub.py diff --git a/_docs/02_document/module-layout.md b/_docs/02_document/module-layout.md index 7b653fa..bc5b5ca 100644 --- a/_docs/02_document/module-layout.md +++ b/_docs/02_document/module-layout.md @@ -395,13 +395,14 @@ Bootstrap reference: `_docs/02_tasks/todo/AZ-263_initial_structure.md`. Architec ### shared/replay_input - **Directory**: `src/gps_denied_onboard/replay_input/` -- **Purpose**: Layer-4 cross-cutting coordinator that converges `(video, tlog)` inputs into the standard `FrameSource` + `FcAdapter` + `Clock` surfaces the airborne composition root consumes. Owns the time-alignment between video frames and tlog IMU/attitude ticks (manual via `--time-offset-ms` or automatic via the AZ-405 IMU-take-off detector). The composition root, in replay mode, builds a `ReplayInputAdapter`, calls `.open()`, and wires the returned `ReplayInputBundle` into the same C1–C5 pipeline as live. New under ADR-011 (replaces the v1.0.0 design where replay was a separate composition root). - - `__init__.py` (re-exports `ReplayInputAdapter`, `ReplayInputBundle`, `AutoSyncDecision`, `AutoSyncConfig`, `ReplayInputAdapterError`, plus the AZ-697 / AZ-836 surfaces: `TlogGpsFix`, `TlogGroundTruth`, `load_tlog_ground_truth`, `RouteSpec`, `RouteExtractionError`, `extract_route_from_tlog`) - - `interface.py` (`ReplayInputAdapter` class declaration + `ReplayInputBundle` DTO + `AlignedWindow` / `AutoSyncConfig` / `AutoSyncDecision` DTOs) +- **Purpose**: Layer-4 cross-cutting coordinator. Under AZ-894 the production replay pipeline drives off the operator's IMU+GPS CSV via `CsvReplayFcAdapter`. The legacy `(video, tlog)` auto-sync surface was deprecated by AZ-895 and will be physically removed by AZ-908. The composition root, in replay mode, builds the CSV bundle (frame source + CSV FC adapter + clock) and wires the returned `ReplayInputBundle` into the same C1–C5 pipeline as live. New under ADR-011 (replaces the v1.0.0 design where replay was a separate composition root). + - `__init__.py` (re-exports `ReplayInputAdapter`, `ReplayInputBundle`, `AutoSyncDecision`, `AutoSyncConfig`, `ReplayInputAdapterError`, `CsvGpsFix`, `CsvGroundTruth`, `load_csv_ground_truth`, plus the AZ-697 / AZ-836 surfaces: `TlogGpsFix`, `TlogGroundTruth`, `load_tlog_ground_truth`, `RouteSpec`, `RouteExtractionError`, `extract_route_from_tlog`) + - `csv_ground_truth.py` (AZ-894 — `load_csv_ground_truth` + `CsvGpsFix` / `CsvGroundTruth`; the canonical replay ground-truth surface) + - `interface.py` (`ReplayInputAdapter` class declaration + `ReplayInputBundle` DTO + `AlignedWindow` / `AutoSyncConfig` / `AutoSyncDecision` DTOs — the auto-sync DTOs are deprecated by AZ-895 and slated for removal in AZ-908) - `errors.py` (AZ-405 — `ReplayInputAdapterError` envelope; subclass of `RuntimeError` so the airborne main maps every coordinator-scope failure to CLI exit code 2) - - `tlog_video_adapter.py` (concrete `ReplayInputAdapter` that instantiates `VideoFileFrameSource` + `TlogReplayFcAdapter` + chosen `Clock`) - - `auto_sync.py` (AZ-405 IMU-take-off / video-motion-onset detectors + combined offset computation + AC-8 frame-window-match validator) - - `tlog_ground_truth.py` (AZ-697 — `load_tlog_ground_truth` + `TlogGpsFix` / `TlogGroundTruth` for direct binary tlog GPS-truth extraction; consumed by `helpers.gps_compare` and `tlog_route.py`) + - `tlog_video_adapter.py` — **DEPRECATED (AZ-895)**: `ReplayInputAdapter.open()` raises `ReplayInputAdapterError`; retained as an import-stable stub for one cycle. AZ-908 removes it. + - `auto_sync.py` — **DEPRECATED (AZ-895)**: every detector + validator raises `ReplayInputAdapterError`; retained as an import-stable stub for one cycle. AZ-908 removes it. + - `tlog_ground_truth.py` (AZ-697 — `load_tlog_ground_truth` + `TlogGpsFix` / `TlogGroundTruth` for direct binary tlog GPS-truth extraction; AUDIT-ONLY after AZ-895, retained for the AZ-699 / AZ-701 validation paths against legacy `.tlog` archives) - `tlog_route.py` (AZ-836 — `extract_route_from_tlog` + `RouteExtractionError`; re-exports `RouteSpec` from `_types.route`. Reduces a tlog to a coarsened route via Douglas-Peucker on local ENU; consumed by `c11_tile_manager.route_client.SatelliteProviderRouteClient.seed_route`) - `tests/` - **Owned by**: AZ-265 (E-DEMO-REPLAY) — task AZ-405 (auto-sync + coordinator). diff --git a/_docs/02_tasks/_dependencies_table.md b/_docs/02_tasks/_dependencies_table.md index 4ad5408..4e50863 100644 --- a/_docs/02_tasks/_dependencies_table.md +++ b/_docs/02_tasks/_dependencies_table.md @@ -1,8 +1,8 @@ # Dependencies Table -**Date**: 2026-05-26 (cycle-4 Step 9 New Task — scope adjustments: (a) AZ-841 (1pt, un-xfail AZ-777 Tier-2 tests) moved from todo/ to backlog/ due to hard conflict with AZ-895 AC-4 (test_derkachi_real_tlog.py stays @xfail in cycle 4 because AZ-848 is backlogged) + partial overlap with AZ-894 AC-3 (CSV-path adapter covers the test_derkachi_1min.py un-xfail target); Jira comment added to AZ-841 documenting the deferral. (b) AZ-842 (2pt → **3pt**, +1 SP rescope) — dropped AZ-841 soft dependency, expanded replay_protocol.md scope to add new Invariant 13 covering single-canonical-clock model + cycle-4 CSV-driven replay narrative (AZ-894 + AZ-895 + AZ-896), plus architecture.md replay-input section updates. New deps: AZ-894 HARD + AZ-895 HARD + AZ-896 SOFT. (c) +**AZ-899** (1pt, product, todo/, land architecture_compliance_baseline.md — cycle-3 retro Top-3 #3 third try; deps None; no epic). (d) +**AZ-900** (1pt, product, todo/, autodev cycle-N+1 Step-9 retro-existence gate — cycle-3 retro Top-3 #2 + 2026-05-26 LESSONS process entry; deps None; no epic). (e) +**AZ-901** (1pt, product, todo/, fix EVIDENCE_OUT default path in e2e/runner/conftest.py:56 — closes 2026-05-26 leftover; deps None; no epic). Cycle-4 active scope: 6 product tickets in todo/ totaling **17 SP** = AZ-842 (3, docs) + AZ-894 (3, CSV adapter) + AZ-895 (2, auto-sync deprecation) + AZ-896 (1, format docs) + AZ-897 (5, replay UI) + AZ-899 (1) + AZ-900 (1) + AZ-901 (1). Dependency order: AZ-894 blocks AZ-895 + AZ-842 + AZ-897; AZ-896 blocks AZ-897 + AZ-842. AZ-899/AZ-900/AZ-901 standalone (no internal blockers). AZ-848 (5) + AZ-883 (2) remain in backlog/ (cycle-3 retro Top-3 #1 deferred by user decision; CSV-bypass strategy supersedes their fixes for the demo path). Earlier 2026-05-23 (cycle-3 Step 10 Implement, refactor run 02-az507-routespec-relocation — added AZ-844 (Epic, run dir `_docs/04_refactoring/02-az507-routespec-relocation/`) + AZ-845 (C01, 2pt relocate `RouteSpec` from `replay_input/tlog_route.py` to `_types/route.py`, deps None, epic AZ-844) + AZ-846 (C02, 2pt refresh `module-layout.md` cycle-3 entries — c11 + replay_input + `_types/route`, deps AZ-845, epic AZ-844) + AZ-847 (C03, 2pt widen `test_az270_compose_root` lint to enforce full rule-9 allow-list, deps AZ-845, epic AZ-844). Resolves cycle-3 cumulative review FAIL verdict (F1 High Architecture, F2 Medium Architecture, F3 Medium Maintainability) per `_docs/03_implementation/cumulative_review_batches_104-109_cycle3_report.md`. Jira "Blocks" links recorded: AZ-845 → AZ-846, AZ-845 → AZ-847. Earlier same-day at start of Step 10 Implement — Epic AZ-835 decomposed into 4 leaf tasks + AZ-777 closed: AZ-839 (C3, 5pt operator_pre_flight_setup real fixture, deps AZ-836+AZ-838+AZ-777Phase1+AZ-322+AZ-316+AZ-306, epic AZ-835), AZ-840 (C4, 3pt e2e orchestrator test (tlog,video,calibration), deps AZ-839+AZ-836+AZ-838+AZ-699+AZ-405+AZ-702+AZ-696, epic AZ-835), AZ-841 (C5, 1pt un-xfail AZ-777 AC-4+AC-5, deps AZ-839+AZ-840, epic AZ-835), AZ-842 (C6, 2pt docs — replay_protocol.md Invariant 12 + architecture.md + orchestrator README, soft dep AZ-841, epic AZ-835). AZ-777 transitioned to Done in Jira: Phases 1+2 shipped (batch 104 + between batches 104 and 106); Phases 3-5 superseded by Epic AZ-835 children per 2026-05-22 user directive. AZ-777 spec moved to done/. Earlier 2026-05-21 (cycle-3 Step 9 New Task — added AZ-776 (3pt open-loop ESKF composition profile via `c4_pose.enabled` flag, no deps, epic AZ-602) + AZ-777 (5pt Derkachi C6 reference tile cache + FAISS descriptor index from OSM/CARTO basemap, depends on AZ-776, epic AZ-602). Both unblock the 7 currently-`@xfail`-masked Derkachi e2e tests on Jetson; AZ-776 unblocks 5 (AC-1, AC-2, AC-5, AC-6 realtime, AC-6 asap), AZ-777 unblocks the remaining 2 (AC-3 + AZ-699 real-flight verdict). Earlier 2026-05-19 (refreshed late-morning after 11:27 Jetson Tier-2 e2e run for AZ-618 — surfaced a NEW gap: replay-mode `Config` lacks `c6_tile_cache` block, so `build_pre_constructed → _build_c6_descriptor_index → _c6_config` raises `KeyError` for AC-1/2/5/6. Follow-up filed as AZ-687 (2pt) under E-AZ-602 with guard at the bootstrap layer (NOT silent fallback in `_c6_config`). Earlier same-day mid-day after AZ-618 split: per the spec author's own Sizing-note recommendation + user-rule cap on PBI complexity, AZ-618 was split into 6 subtasks AZ-619..AZ-624 in Jira (subtasks of AZ-618; epic AZ-602 stays grandparent). AZ-618 retained at 0pt as the umbrella tracker; aggregate actionable work is 16pt across the subtasks (vs. AZ-618's original 5pt filing — author's "likely a true 8" caveat was understated due to c5_isam2_graph_handle ordering + GPU builder unknowns). Earlier same-day refresh at start of Step-7 rewind for AZ-618 — Step-11 Jetson tier-2 e2e gate identified missing internal product implementation: `runtime_root.main()` does not build the airborne `pre_constructed` infrastructure dict before `compose_root()`; AZ-618 = 5pt cross-cutting follow-up to AZ-591, lives under E-AZ-602; all 12 dep tasks are in `done/`. Earlier 2026-05-16 (cycle-1 completeness-gate post-mortem): AZ-589 + AZ-590 closed Won't Fix — were wrong abstraction (OKVIS v1 `ThreadedKFVio` API doesn't exist in OKVIS2 upstream; VINS-Mono `cpp/vins_mono/upstream/` submodule never existed; the actual production gap is the empty central `_STRATEGY_REGISTRY` affecting EVERY component with a strategy-selecting config field, not just c1_vio); replaced by AZ-591 (cross-cutting compose_root per-binary bootstrap, todo/, 5pt) + AZ-592 (AZ-332 Tier-2 validation bundle, backlog/, 5pt placeholder) + AZ-593 (AZ-333 Tier-2 validation bundle, backlog/, 5pt placeholder); AZ-332 + AZ-333 re-classified in gate report from FAIL to BLOCKED-on-Tier-2 per the original tasks' Implementation Notes deferral handles; earlier same-day after end of cycle-1 gate: AZ-589 + AZ-590 created (now closed); earlier same-day after end of Batch 64: AZ-558 implementation closed — `MavlinkTransport` seam now routes every C8 outbound MAVLink byte; AZ-401 AC-9 + AZ-404 AC-4b unskipped together; encoder helpers extracted to `_outbound_mavlink_payloads.py`; live-mode `compose_root` injection deferred to whichever future batch registers AP/iNav strategies in an airborne binary; earlier 2026-05-14: refreshed at start of Batch 63: AZ-559 closed Won't Fix — gap was illusory; `TileSource.ONBOARD_INGEST` + `TileMetadata.quality_metadata` + `write_tile`'s `FreshnessRejectionError` already cover the AZ-389 mid-flight ingest semantic without any new API; AZ-389 dep restored to AZ-303; earlier same-day after Batch 61: AZ-558 follow-up added — routes C8 outbound encoder bytes through `MavlinkTransport` seam; closes AZ-401 AC-9 deferred during batch 61 due to encoder-side routing not being in the AZ-401 task envelope; earlier same-day after cumulative review batches 52-54: AZ-528 hygiene PBI added for c1_vio strategy facade orchestration-spine 3-way duplication (Medium); earlier same-day after Batch 53: AZ-333 VINS-Mono landed — first c1_vio strategy after the AZ-332 OKVIS2 production-default; consolidation hygiene for the strategy-facade duplication deferred to a post-AZ-334 PBI; earlier same-day after Batch 51: AZ-527 hygiene PBI added from cumulative review batches 49-51 F1; 2026-05-13: AZ-526 hygiene PBI added from cumulative review batches 46-48 F1+F3; same-day refresh after Batch 44 SRP refactor: AZ-317 superseded; AZ-329 + AZ-330 specs rewritten; AZ-523 + AZ-524 audit-trail tickets added; E-C12 epic renamed `Operator Pre-flight Tooling` → `Operator Pre-flight Orchestrator`; earlier same-day refresh: AZ-507 + AZ-508 hygiene PBIs from cumulative review batches 31-33; 2026-05-11: AZ-489 + AZ-490 ADR-010 operator-origin path) -**Total Tasks**: 179 (138 product + 41 blackbox-test) — 2026-05-26 cycle-4 Step 9 bump: +AZ-899 + AZ-900 + AZ-901 (3 product tasks). AZ-841 moved todo/ → backlog/ (no count change; backlog tickets are still in the table). Prior 2026-05-23 refactor-run bump: 176 (135 product + 41 blackbox-test) — +AZ-844 (Epic, 0pt umbrella for refactor run 02) + AZ-845 + AZ-846 + AZ-847 (3 product tasks). Prior 2026-05-23 bump (Epic AZ-835 decomposition): 173 (132 product + 41 blackbox-test) = +AZ-835 (Epic) + AZ-836 (C1) + AZ-837 (test-stack hardening, not this Epic) + AZ-838 (C2) added 2026-05-22→2026-05-23 prior to that update; +AZ-839 (C3) + AZ-840 (C4) + AZ-841 (C5) + AZ-842 (C6) added in that update. AZ-777 stays in the table (now closed in Jira; spec at `done/AZ-777_derkachi_c6_reference_fixture.md` retains 8pt credit for Phases 1+2 shipped). Earlier counts: 165 (124 product + 41 blackbox-test) — AZ-317 retained in the table marked SUPERSEDED for audit; AZ-523 (C11 gate removal) + AZ-524 (C12 rename) added as 2 closed audit-trail tasks; AZ-526 = 2pt clock-helper hygiene; AZ-527 = 2pt c2 engine-dim helper hygiene; AZ-528 = 3pt c1_vio facade-spine hygiene; AZ-558 = 3pt MavlinkTransport routing follow-up; AZ-559 closed Won't Fix; AZ-589 + AZ-590 closed Won't Fix (kept in table as 0pt audit-trail rows); AZ-591 = 5pt cross-cutting compose_root bootstrap (todo/); AZ-592 = 5pt OKVIS2 Tier-2 placeholder (backlog/); AZ-593 = 5pt VINS-Mono Tier-2 placeholder (backlog/); AZ-618 = 0pt umbrella (split into AZ-619..AZ-624 on 2026-05-19); AZ-619..AZ-624 = 6 subtasks of AZ-618 covering Phase A..F of the airborne `pre_constructed` assembly, summing to 16pt actionable work; AZ-687 = 2pt replay-mode guard follow-up surfaced by AZ-618 Tier-2 run on 2026-05-19 -**Total Complexity Points**: 567 (434 product + 133 blackbox-test) — 2026-05-26 cycle-4 Step 9 bump: +1pt AZ-899 + 1pt AZ-900 + 1pt AZ-901 + 1pt AZ-842 rescope (2→3) = +4 product pts. Prior 2026-05-23 refactor-run bump: 563 (430 product + 133 blackbox-test) — +2pt AZ-845 + 2pt AZ-846 + 2pt AZ-847 = +6 product pts on top of prior reconciled total (AZ-844 epic itself is 0pt umbrella). Prior 2026-05-23 reconciled total: 557 (424 product + 133 blackbox-test) — +5pt AZ-839 + 3pt AZ-840 + 1pt AZ-841 + 2pt AZ-842 = +11 product pts on top of prior reconciled total. AZ-836 (3pt) + AZ-838 (3pt) were added 2026-05-22→2026-05-23 prior to that update; AZ-837 (test-stack hardening, not this Epic) is unaccounted in that delta and should be folded in at the next preamble reconciliation. Earlier baseline: 546 (413 product + 133 blackbox-test) — +3pt AZ-776 + 8pt AZ-777 (5→8 override 2026-05-21 cycle-3 batch 104; see `_docs/_process_leftovers/2026-05-21_az777_complexity_override.md` for rationale + the spec refresh that pulled e2e-runner wiring + C11 contract adapt + Derkachi catalog seed + fixture replacement + un-xfail into one ticket) — AZ-523 = 3pt, AZ-524 = 2pt, AZ-526 = 2pt, AZ-527 = 2pt, AZ-528 = 3pt, AZ-558 = 3pt, AZ-589 + AZ-590 retained at 5pt each but closed Won't Fix (treated as 0 effective pts going forward), AZ-591 = 5pt, AZ-592 = 5pt placeholder, AZ-593 = 5pt placeholder, AZ-618 = 0pt umbrella post-split, AZ-619 = 2pt, AZ-620 = 3pt, AZ-621 = 3pt, AZ-622 = 3pt, AZ-623 = 3pt, AZ-624 = 2pt, AZ-687 = 2pt +**Date**: 2026-05-26 (cycle-4 Step 10 Implement — AZ-895 batch 3 user complexity decision: chose Option A "minimum deprecation" path. Filed **AZ-908** (3pt, backlog/, replay: hard removal of deprecated auto-sync surface — AZ-895 follow-up; deps AZ-895 HARD + AZ-842 HARD; no epic) to track the cycle-5+ physical removal that AZ-895's minimum-path explicitly defers. AZ-895 ships the no-op stubs + CLI deprecation warnings; AZ-908 will delete the stub files, drop the DTOs from `replay_input/interface.py`, remove the deprecated CLI flags, and drop the `auto_sync` config block. No SP change to cycle-4 totals (AZ-908 is cycle-5+ backlog, not cycle-4 active scope). Earlier same-day at Step 9 New Task — scope adjustments: (a) AZ-841 (1pt, un-xfail AZ-777 Tier-2 tests) moved from todo/ to backlog/ due to hard conflict with AZ-895 AC-4 (test_derkachi_real_tlog.py stays @xfail in cycle 4 because AZ-848 is backlogged) + partial overlap with AZ-894 AC-3 (CSV-path adapter covers the test_derkachi_1min.py un-xfail target); Jira comment added to AZ-841 documenting the deferral. (b) AZ-842 (2pt → **3pt**, +1 SP rescope) — dropped AZ-841 soft dependency, expanded replay_protocol.md scope to add new Invariant 13 covering single-canonical-clock model + cycle-4 CSV-driven replay narrative (AZ-894 + AZ-895 + AZ-896), plus architecture.md replay-input section updates. New deps: AZ-894 HARD + AZ-895 HARD + AZ-896 SOFT. (c) +**AZ-899** (1pt, product, todo/, land architecture_compliance_baseline.md — cycle-3 retro Top-3 #3 third try; deps None; no epic). (d) +**AZ-900** (1pt, product, todo/, autodev cycle-N+1 Step-9 retro-existence gate — cycle-3 retro Top-3 #2 + 2026-05-26 LESSONS process entry; deps None; no epic). (e) +**AZ-901** (1pt, product, todo/, fix EVIDENCE_OUT default path in e2e/runner/conftest.py:56 — closes 2026-05-26 leftover; deps None; no epic). Cycle-4 active scope: 6 product tickets in todo/ totaling **17 SP** = AZ-842 (3, docs) + AZ-894 (3, CSV adapter) + AZ-895 (2, auto-sync deprecation) + AZ-896 (1, format docs) + AZ-897 (5, replay UI) + AZ-899 (1) + AZ-900 (1) + AZ-901 (1). Dependency order: AZ-894 blocks AZ-895 + AZ-842 + AZ-897; AZ-896 blocks AZ-897 + AZ-842. AZ-899/AZ-900/AZ-901 standalone (no internal blockers). AZ-848 (5) + AZ-883 (2) + AZ-908 (3) remain in backlog/ (cycle-3 retro Top-3 #1 + AZ-895 follow-up deferred to cycle-5+; CSV-bypass strategy supersedes their fixes for the demo path). Earlier 2026-05-23 (cycle-3 Step 10 Implement, refactor run 02-az507-routespec-relocation — added AZ-844 (Epic, run dir `_docs/04_refactoring/02-az507-routespec-relocation/`) + AZ-845 (C01, 2pt relocate `RouteSpec` from `replay_input/tlog_route.py` to `_types/route.py`, deps None, epic AZ-844) + AZ-846 (C02, 2pt refresh `module-layout.md` cycle-3 entries — c11 + replay_input + `_types/route`, deps AZ-845, epic AZ-844) + AZ-847 (C03, 2pt widen `test_az270_compose_root` lint to enforce full rule-9 allow-list, deps AZ-845, epic AZ-844). Resolves cycle-3 cumulative review FAIL verdict (F1 High Architecture, F2 Medium Architecture, F3 Medium Maintainability) per `_docs/03_implementation/cumulative_review_batches_104-109_cycle3_report.md`. Jira "Blocks" links recorded: AZ-845 → AZ-846, AZ-845 → AZ-847. Earlier same-day at start of Step 10 Implement — Epic AZ-835 decomposed into 4 leaf tasks + AZ-777 closed: AZ-839 (C3, 5pt operator_pre_flight_setup real fixture, deps AZ-836+AZ-838+AZ-777Phase1+AZ-322+AZ-316+AZ-306, epic AZ-835), AZ-840 (C4, 3pt e2e orchestrator test (tlog,video,calibration), deps AZ-839+AZ-836+AZ-838+AZ-699+AZ-405+AZ-702+AZ-696, epic AZ-835), AZ-841 (C5, 1pt un-xfail AZ-777 AC-4+AC-5, deps AZ-839+AZ-840, epic AZ-835), AZ-842 (C6, 2pt docs — replay_protocol.md Invariant 12 + architecture.md + orchestrator README, soft dep AZ-841, epic AZ-835). AZ-777 transitioned to Done in Jira: Phases 1+2 shipped (batch 104 + between batches 104 and 106); Phases 3-5 superseded by Epic AZ-835 children per 2026-05-22 user directive. AZ-777 spec moved to done/. Earlier 2026-05-21 (cycle-3 Step 9 New Task — added AZ-776 (3pt open-loop ESKF composition profile via `c4_pose.enabled` flag, no deps, epic AZ-602) + AZ-777 (5pt Derkachi C6 reference tile cache + FAISS descriptor index from OSM/CARTO basemap, depends on AZ-776, epic AZ-602). Both unblock the 7 currently-`@xfail`-masked Derkachi e2e tests on Jetson; AZ-776 unblocks 5 (AC-1, AC-2, AC-5, AC-6 realtime, AC-6 asap), AZ-777 unblocks the remaining 2 (AC-3 + AZ-699 real-flight verdict). Earlier 2026-05-19 (refreshed late-morning after 11:27 Jetson Tier-2 e2e run for AZ-618 — surfaced a NEW gap: replay-mode `Config` lacks `c6_tile_cache` block, so `build_pre_constructed → _build_c6_descriptor_index → _c6_config` raises `KeyError` for AC-1/2/5/6. Follow-up filed as AZ-687 (2pt) under E-AZ-602 with guard at the bootstrap layer (NOT silent fallback in `_c6_config`). Earlier same-day mid-day after AZ-618 split: per the spec author's own Sizing-note recommendation + user-rule cap on PBI complexity, AZ-618 was split into 6 subtasks AZ-619..AZ-624 in Jira (subtasks of AZ-618; epic AZ-602 stays grandparent). AZ-618 retained at 0pt as the umbrella tracker; aggregate actionable work is 16pt across the subtasks (vs. AZ-618's original 5pt filing — author's "likely a true 8" caveat was understated due to c5_isam2_graph_handle ordering + GPU builder unknowns). Earlier same-day refresh at start of Step-7 rewind for AZ-618 — Step-11 Jetson tier-2 e2e gate identified missing internal product implementation: `runtime_root.main()` does not build the airborne `pre_constructed` infrastructure dict before `compose_root()`; AZ-618 = 5pt cross-cutting follow-up to AZ-591, lives under E-AZ-602; all 12 dep tasks are in `done/`. Earlier 2026-05-16 (cycle-1 completeness-gate post-mortem): AZ-589 + AZ-590 closed Won't Fix — were wrong abstraction (OKVIS v1 `ThreadedKFVio` API doesn't exist in OKVIS2 upstream; VINS-Mono `cpp/vins_mono/upstream/` submodule never existed; the actual production gap is the empty central `_STRATEGY_REGISTRY` affecting EVERY component with a strategy-selecting config field, not just c1_vio); replaced by AZ-591 (cross-cutting compose_root per-binary bootstrap, todo/, 5pt) + AZ-592 (AZ-332 Tier-2 validation bundle, backlog/, 5pt placeholder) + AZ-593 (AZ-333 Tier-2 validation bundle, backlog/, 5pt placeholder); AZ-332 + AZ-333 re-classified in gate report from FAIL to BLOCKED-on-Tier-2 per the original tasks' Implementation Notes deferral handles; earlier same-day after end of cycle-1 gate: AZ-589 + AZ-590 created (now closed); earlier same-day after end of Batch 64: AZ-558 implementation closed — `MavlinkTransport` seam now routes every C8 outbound MAVLink byte; AZ-401 AC-9 + AZ-404 AC-4b unskipped together; encoder helpers extracted to `_outbound_mavlink_payloads.py`; live-mode `compose_root` injection deferred to whichever future batch registers AP/iNav strategies in an airborne binary; earlier 2026-05-14: refreshed at start of Batch 63: AZ-559 closed Won't Fix — gap was illusory; `TileSource.ONBOARD_INGEST` + `TileMetadata.quality_metadata` + `write_tile`'s `FreshnessRejectionError` already cover the AZ-389 mid-flight ingest semantic without any new API; AZ-389 dep restored to AZ-303; earlier same-day after Batch 61: AZ-558 follow-up added — routes C8 outbound encoder bytes through `MavlinkTransport` seam; closes AZ-401 AC-9 deferred during batch 61 due to encoder-side routing not being in the AZ-401 task envelope; earlier same-day after cumulative review batches 52-54: AZ-528 hygiene PBI added for c1_vio strategy facade orchestration-spine 3-way duplication (Medium); earlier same-day after Batch 53: AZ-333 VINS-Mono landed — first c1_vio strategy after the AZ-332 OKVIS2 production-default; consolidation hygiene for the strategy-facade duplication deferred to a post-AZ-334 PBI; earlier same-day after Batch 51: AZ-527 hygiene PBI added from cumulative review batches 49-51 F1; 2026-05-13: AZ-526 hygiene PBI added from cumulative review batches 46-48 F1+F3; same-day refresh after Batch 44 SRP refactor: AZ-317 superseded; AZ-329 + AZ-330 specs rewritten; AZ-523 + AZ-524 audit-trail tickets added; E-C12 epic renamed `Operator Pre-flight Tooling` → `Operator Pre-flight Orchestrator`; earlier same-day refresh: AZ-507 + AZ-508 hygiene PBIs from cumulative review batches 31-33; 2026-05-11: AZ-489 + AZ-490 ADR-010 operator-origin path) +**Total Tasks**: 180 (139 product + 41 blackbox-test) — 2026-05-26 cycle-4 Step 10 bump (AZ-895 batch 3 follow-up): +AZ-908 (1 product task, backlog/, 3pt). Prior 2026-05-26 cycle-4 Step 9 bump: +AZ-899 + AZ-900 + AZ-901 (3 product tasks). AZ-841 moved todo/ → backlog/ (no count change; backlog tickets are still in the table). Prior 2026-05-23 refactor-run bump: 176 (135 product + 41 blackbox-test) — +AZ-844 (Epic, 0pt umbrella for refactor run 02) + AZ-845 + AZ-846 + AZ-847 (3 product tasks). Prior 2026-05-23 bump (Epic AZ-835 decomposition): 173 (132 product + 41 blackbox-test) = +AZ-835 (Epic) + AZ-836 (C1) + AZ-837 (test-stack hardening, not this Epic) + AZ-838 (C2) added 2026-05-22→2026-05-23 prior to that update; +AZ-839 (C3) + AZ-840 (C4) + AZ-841 (C5) + AZ-842 (C6) added in that update. AZ-777 stays in the table (now closed in Jira; spec at `done/AZ-777_derkachi_c6_reference_fixture.md` retains 8pt credit for Phases 1+2 shipped). Earlier counts: 165 (124 product + 41 blackbox-test) — AZ-317 retained in the table marked SUPERSEDED for audit; AZ-523 (C11 gate removal) + AZ-524 (C12 rename) added as 2 closed audit-trail tasks; AZ-526 = 2pt clock-helper hygiene; AZ-527 = 2pt c2 engine-dim helper hygiene; AZ-528 = 3pt c1_vio facade-spine hygiene; AZ-558 = 3pt MavlinkTransport routing follow-up; AZ-559 closed Won't Fix; AZ-589 + AZ-590 closed Won't Fix (kept in table as 0pt audit-trail rows); AZ-591 = 5pt cross-cutting compose_root bootstrap (todo/); AZ-592 = 5pt OKVIS2 Tier-2 placeholder (backlog/); AZ-593 = 5pt VINS-Mono Tier-2 placeholder (backlog/); AZ-618 = 0pt umbrella (split into AZ-619..AZ-624 on 2026-05-19); AZ-619..AZ-624 = 6 subtasks of AZ-618 covering Phase A..F of the airborne `pre_constructed` assembly, summing to 16pt actionable work; AZ-687 = 2pt replay-mode guard follow-up surfaced by AZ-618 Tier-2 run on 2026-05-19 +**Total Complexity Points**: 570 (437 product + 133 blackbox-test) — 2026-05-26 cycle-4 Step 10 bump (AZ-895 batch 3 follow-up): +3pt AZ-908. Prior 2026-05-26 cycle-4 Step 9 bump: +1pt AZ-899 + 1pt AZ-900 + 1pt AZ-901 + 1pt AZ-842 rescope (2→3) = +4 product pts. Prior 2026-05-23 refactor-run bump: 563 (430 product + 133 blackbox-test) — +2pt AZ-845 + 2pt AZ-846 + 2pt AZ-847 = +6 product pts on top of prior reconciled total (AZ-844 epic itself is 0pt umbrella). Prior 2026-05-23 reconciled total: 557 (424 product + 133 blackbox-test) — +5pt AZ-839 + 3pt AZ-840 + 1pt AZ-841 + 2pt AZ-842 = +11 product pts on top of prior reconciled total. AZ-836 (3pt) + AZ-838 (3pt) were added 2026-05-22→2026-05-23 prior to that update; AZ-837 (test-stack hardening, not this Epic) is unaccounted in that delta and should be folded in at the next preamble reconciliation. Earlier baseline: 546 (413 product + 133 blackbox-test) — +3pt AZ-776 + 8pt AZ-777 (5→8 override 2026-05-21 cycle-3 batch 104; see `_docs/_process_leftovers/2026-05-21_az777_complexity_override.md` for rationale + the spec refresh that pulled e2e-runner wiring + C11 contract adapt + Derkachi catalog seed + fixture replacement + un-xfail into one ticket) — AZ-523 = 3pt, AZ-524 = 2pt, AZ-526 = 2pt, AZ-527 = 2pt, AZ-528 = 3pt, AZ-558 = 3pt, AZ-589 + AZ-590 retained at 5pt each but closed Won't Fix (treated as 0 effective pts going forward), AZ-591 = 5pt, AZ-592 = 5pt placeholder, AZ-593 = 5pt placeholder, AZ-618 = 0pt umbrella post-split, AZ-619 = 2pt, AZ-620 = 3pt, AZ-621 = 3pt, AZ-622 = 3pt, AZ-623 = 3pt, AZ-624 = 2pt, AZ-687 = 2pt Dependencies columns list only the tracker-ID portion (descriptive tail text in each task spec is omitted here for table-readability). The @@ -198,6 +198,7 @@ are all declared and documented below under **Cycle Check**. | AZ-899 | Land architecture_compliance_baseline.md (cycle-3 retro #3, 3rd try) | 1 | None | (none) | | AZ-900 | Autodev: gate cycle-N+1 Step-9 entry on previous-cycle retro existence | 1 | None | (none) | | AZ-901 | Fix EVIDENCE_OUT default path in e2e/runner/conftest.py:56 | 1 | None | (none) | +| AZ-908 | Replay: hard removal of deprecated auto-sync surface (AZ-895 follow-up; cycle-5+ backlog) | 3 | AZ-895; AZ-842 | (none) | ## Notes diff --git a/_docs/02_tasks/backlog/AZ-908_replay_auto_sync_hard_removal.md b/_docs/02_tasks/backlog/AZ-908_replay_auto_sync_hard_removal.md new file mode 100644 index 0000000..a1d0e06 --- /dev/null +++ b/_docs/02_tasks/backlog/AZ-908_replay_auto_sync_hard_removal.md @@ -0,0 +1,59 @@ +# Replay: hard removal of deprecated auto-sync surface (AZ-895 follow-up) + +**Task**: AZ-908_replay_auto_sync_hard_removal +**Name**: Cycle-5+ cleanup that physically removes the auto-sync surface AZ-895 deprecated +**Description**: Follow-up to AZ-895 (cycle 4). AZ-895 made the auto_sync surface a no-op and deprecated the CLI flags (`--time-offset-ms`, `--skip-auto-sync`, `--auto-trim`) with one-cycle warnings, but left the call sites, config fields, and interface DTOs intact for backward compat. AZ-908 completes the removal in cycle 5+ after a one-cycle deprecation window has passed. + +**Complexity**: 3 SP +**Dependencies**: AZ-895 (hard — must ship first; AZ-908 removes what AZ-895 deprecated), AZ-842 (hard — replay protocol docs coordinate) +**Component**: replay_input (auto_sync.py + tlog_video_adapter.py + interface.py), cli/replay, runtime_root/_replay_branch + runtime_root/__init__, config/schema + config/loader + config/__init__, replay_api/app +**Tracker**: AZ-908 (https://denyspopov.atlassian.net/browse/AZ-908) +**Parent Epic**: (none — cycle-4 replay-input redesign follow-up) + +## Why + +Auto-sync surface is dead in production code: AZ-894 (cycle 4) made the CSV-driven path mandatory via required `--imu`, and AZ-895 (cycle 4) deprecated the surface. After one cycle's deprecation window the deprecation warnings should fire in real CI runs (if any operator scripts still pass the deprecated flags); that surface area can then be removed without breaking anyone. + +## Touch list (production) + +- DELETE `src/gps_denied_onboard/replay_input/auto_sync.py` (currently a no-op stub from AZ-895) +- DELETE `src/gps_denied_onboard/replay_input/tlog_video_adapter.py` (currently a deprecated coordinator from AZ-895) +- Drop `AutoSyncConfig`, `AutoSyncDecision`, `AlignedWindow` DTOs from `replay_input/interface.py`. Drop `auto_sync_result` + `aligned_window` fields from `ReplayInputBundle`. +- Drop `--time-offset-ms`, `--skip-auto-sync`, `--auto-trim` CLI flags from `cli/replay.py` entirely +- Drop `ReplayConfig.time_offset_ms`, `.skip_auto_sync_validation`, `.auto_trim`, `.auto_sync` from `config/schema.py`. Drop `ReplayAutoSyncConfig` class. +- Drop `REPLAY_TIME_OFFSET_MS` env var + `auto_sync` block handling from `config/loader.py` +- Update `runtime_root/_replay_branch.py` to drop any lingering imports / dead code +- Update `runtime_root/__init__.py` if it references removed symbols +- Update `replay_api/app.py` if it references removed symbols +- Update `e2e/fixtures/sitl_replay_builder/builder.py` if it references removed symbols + +## Touch list (tests) + +- Delete remaining auto-sync test residue (no-op stub tests from AZ-895) +- Update CLI tests to drop deprecated-flag assertions (the flags no longer exist) +- Confirm `test_az401_compose_root_replay.py` is clean + +## Touch list (docs) + +- Update `_docs/02_document/module-layout.md` replay_input file list — remove deleted entries +- Update `_docs/02_document/contracts/replay/replay_protocol.md` — remove auto-sync surface narrative (coordinate with AZ-842) +- Update `_docs/02_document/contracts/replay/csv_replay_format.md` cross-references + +## Acceptance Criteria + +- **AC-1**: All files listed under "DELETE" above are removed from the workspace +- **AC-2**: Unit tests pass with no auto-sync, AutoSyncConfig, AutoSyncDecision, or AlignedWindow symbols in `src/gps_denied_onboard/**` +- **AC-3**: CLI `--help` output does not mention `--time-offset-ms`, `--skip-auto-sync`, or `--auto-trim` +- **AC-4**: `_docs/02_document/module-layout.md` does not mention `auto_sync.py` or `tlog_video_adapter.py` +- **AC-5**: `tests/e2e/replay/test_derkachi_real_tlog.py` continues to `@xfail` with AZ-848-scoped reason + +## Out of scope + +- AZ-848 / AZ-883 structural fix (tlog clock bug) — unchanged from AZ-895 +- Replacing the deprecated coordinator with something else — the CSV path is the replacement (see `_replay_branch._build_csv_bundle`) + +## References + +- Companion in cycle 4: AZ-894 (CSV adapter), AZ-895 (deprecation) +- Decision audit trail: this file + AZ-895 batch_03_cycle4_report.md +- User decision 2026-05-26 (cycle-4 /autodev batch 3): chose Option A (light deprecation now, file AZ-908 for hard removal in cycle 5+) over Option B (full removal in cycle 4). diff --git a/_docs/02_tasks/todo/AZ-895_deprecate_auto_sync_surface.md b/_docs/02_tasks/done/AZ-895_deprecate_auto_sync_surface.md similarity index 100% rename from _docs/02_tasks/todo/AZ-895_deprecate_auto_sync_surface.md rename to _docs/02_tasks/done/AZ-895_deprecate_auto_sync_surface.md diff --git a/_docs/03_implementation/batch_03_cycle4_report.md b/_docs/03_implementation/batch_03_cycle4_report.md new file mode 100644 index 0000000..ef1cbaf --- /dev/null +++ b/_docs/03_implementation/batch_03_cycle4_report.md @@ -0,0 +1,207 @@ +# Batch Report — cycle 4, batch 03 + +**Batch**: 03 +**Cycle**: 4 +**Tasks**: AZ-895 +**Total complexity**: 2 SP +**Date**: 2026-05-26 +**Commit**: pending (this batch) + +## Task Selection + +AZ-895 (deprecate `auto_sync` surface) ships solo. It is the natural +follow-up to AZ-894 (CSV adapter, batch 02): now that the CSV-driven +path is the primary replay surface, the legacy tlog auto-sync +infrastructure can be retired. Per `_dependencies_table.md`, AZ-895 +has a hard dependency on AZ-894 which closed in batch 02. + +### Complexity-budget user decision (Option A — minimum) + +A naïve full removal of the auto-sync surface would have touched: + +- 4 production modules: `auto_sync.py` (delete), `tlog_video_adapter.py` + (delete), `interface.py` (drop AutoSync DTOs + `ReplayInputBundle` + field), `_replay_branch.py` (strip legacy branch + `_build_auto_sync_config`) +- 3 config files: `config/schema.py` (drop `ReplayConfig` auto-sync + fields + the `ReplayAutoSyncConfig` class), `config/loader.py` (drop + `REPLAY_TIME_OFFSET_MS` env + auto_sync block handler), + `config/__init__.py` (drop re-exports) +- 1 CLI: drop the three deprecated flags entirely +- 4 test files needing rewrite or deletion +- Cascading docs in `replay_protocol.md` (AZ-842 sibling work) + +Estimated at 4–5 SP, well over the ticket's 2 SP budget. Per +`meta-rule.mdc` Complexity Budget Check, the user was offered four +options and chose **Option A — minimum**: + +> "Minimum (~2 SP): no-op-stub auto_sync.py (raises documented error), +> strip tlog_video_adapter.open() to raise too, drop the unreachable +> legacy branch in _replay_branch.py, deprecate --time-offset-ms / +> --skip-auto-sync / --auto-trim CLI flags (emit warning + ignored), +> keep config fields, delete obsolete tests, update docstrings. File +> AZ-902 (cycle 5) for hard surface removal." + +The follow-up ticket was filed as **AZ-908** (Jira renumbered from the +proposed AZ-902 because AZ-902–AZ-907 were already taken). AZ-908 is +in `backlog/` and depends on AZ-895 + AZ-842. + +## Task Results + +| Task | Status | Files Modified | Tests | AC Coverage | Issues | +|------|--------|----------------|-------|-------------|--------| +| AZ-895_deprecate_auto_sync_surface | Done | 8 modified, 1 added (test) | 3 unit-test files deleted; 1 new test added; 1 existing test file updated; 2,287 unit tests green | 5/5 | None | + +### Files touched + +Production (`src/gps_denied_onboard/**`): + +- REPLACED `replay_input/auto_sync.py` — was a 700+ LOC detector + module; now a 56-line no-op stub whose every public callable raises + `ReplayInputAdapterError("auto-sync removed; supply --imu CSV instead")`. + `__all__` preserved so any external import still resolves. +- REPLACED `replay_input/tlog_video_adapter.py` — was a 700+ LOC + coordinator; now a 105-line deprecated-stub that keeps the + `ReplayInputAdapter` class signature for source-compat. `open()` + raises `ReplayInputAdapterError(...)` immediately; `close()` is a + no-op. Re-exports `ReplayPace` so `_replay_branch.py` can continue + to import it from the same path (preserving the AZ-401 AC-8 import + boundary). +- MODIFIED `runtime_root/_replay_branch.py` — removed the legacy + `ReplayInputAdapter` instantiation branch in + `_build_replay_input_bundle`; deleted the `_build_auto_sync_config` + helper; tightened `_validate_replay_paths` to require `imu_csv_path` + (no more tlog fallback); dropped unused `WgsConverter` and + `AutoSyncConfig` imports; removed the `replay_input_adapter_factory` + test-injection parameter; updated module + function docstrings; + cleaned `auto_sync_used` from the ready-log kv (always None now). +- MODIFIED `replay_input/__init__.py` — docstring rewritten to flag + the deprecation status of the `tlog_video_adapter` / `auto_sync` + surfaces. Re-exports preserved. +- MODIFIED `cli/replay.py` — `--time-offset-ms`, `--skip-auto-sync`, + `--auto-trim` help text replaced with `DEPRECATED (AZ-895)` notice; + `_print_startup_banner` now emits a `DeprecationWarning` + stderr + line when any of the three are non-default; `_build_replay_config` + hard-codes the corresponding `ReplayConfig` fields to None / False + so the deprecated values cannot influence composition. +- MODIFIED `components/c8_fc_adapter/tlog_replay_adapter.py` — module + docstring reframed as **AUDIT-ONLY** (AC-5). Code unchanged. +- MODIFIED `replay_input/tlog_ground_truth.py` — module docstring + reframed as **AUDIT-ONLY** (AC-5). Code unchanged. + +Tests (`tests/**`): + +- DELETED `tests/unit/replay_input/test_az405_auto_sync.py` (386 LOC). + Rationale: tested the AZ-405 detector algorithm + AC-9 validator + which no longer execute. Per AC-3, the deprecation-stub test below + replaces it. +- DELETED `tests/unit/replay_input/test_az405_replay_input_adapter.py` + (645 LOC). Rationale: tested the `ReplayInputAdapter` coordinator's + six-step `open()` workflow which now raises immediately. +- DELETED `tests/unit/replay_input/test_az698_window_alignment.py` + (745 LOC). Rationale: tested the AZ-698 IMU↔optical-flow + cross-correlation aligner which no longer executes. +- ADDED `tests/unit/replay_input/test_az895_auto_sync_deprecated_stub.py` + — 5 parametrised tests pinning the AC-1 contract: every public + callable raises `ReplayInputAdapterError` with the documented + message. +- MODIFIED `tests/unit/test_az402_replay_cli.py` — renamed + `test_ac4_time_offset_forwarded` → `test_ac4_time_offset_ignored_after_az895` + with asserts inverted (value now `None` regardless of flag); added + `test_az895_skip_auto_sync_ignored_and_warned`, + `test_az895_auto_trim_ignored_and_warned`, + `test_az895_no_deprecated_flags_no_warning`; `_argv` helper grew + `skip_auto_sync` and `auto_trim` overrides. +- MODIFIED `tests/unit/test_az401_compose_root_replay.py` — renamed + `test_replay_branch_rejects_both_inputs_empty` → + `test_replay_branch_rejects_missing_imu_csv_path` with body updated + to the new gate semantics; `_make_replay_config` helper now sets + `imu_csv_path` by default; deleted + `test_replay_branch_loads_camera_calibration_from_runtime_path` + (only verified the now-removed `replay_input_adapter_factory` + injection path; calibration loading is exercised indirectly by the + full compose-root tests and by the e2e suite). +- MODIFIED `tests/e2e/replay/test_derkachi_real_tlog.py` — xfail + reason text refreshed to reference AZ-848 + AZ-883 (the live + tlog-clock root cause) instead of the closed AZ-776 + AZ-777 (AC-4 + literally specifies "AZ-848-scoped reason"). + +Docs (`_docs/**`): + +- MODIFIED `_docs/02_document/module-layout.md` — `replay_input` file + list flags `tlog_video_adapter.py` + `auto_sync.py` as + **DEPRECATED (AZ-895)**, adds `csv_ground_truth.py`, updates the + package purpose to lead with the CSV path. +- ADDED `_docs/02_tasks/backlog/AZ-908_replay_auto_sync_hard_removal.md` + — cycle-5+ follow-up spec. +- MODIFIED `_docs/02_tasks/_dependencies_table.md` — preamble + + Total Tasks (179 → 180) + Total Complexity (567 → 570); AZ-908 row + added under Cycle-4 / AZ-895 follow-up. + +Tracker (Jira): + +- AZ-895 — transitioned `To Do` → `In Progress` (transition id `21`). +- AZ-908 — created (`Replay: hard removal of deprecated auto-sync + surface (AZ-895 follow-up)`), 3 SP estimate, deps AZ-895 (hard) + + AZ-842 (hard). Filed via `createJiraIssue` MCP. + +## File-Ownership Note + +All touched paths are owned by the cycle-4 replay-input redesign +envelope (`replay_input/` + `cli/replay.py` + `runtime_root/_replay_branch.py`) +plus the AC-5 audit-only docstring updates inside +`components/c8_fc_adapter/tlog_replay_adapter.py` (the c8 owner +already accepted the audit-only reframing in AZ-894). No +out-of-scope edits. + +## AC Test Coverage + +| AC | Coverage | Test | +|----|----------|------| +| AC-1 (`auto_sync.py` is deleted or made a no-op raising the documented error) | Direct | `tests/unit/replay_input/test_az895_auto_sync_deprecated_stub.py::test_az895_public_callable_raises_with_documented_message[*]` — 5 parametrised cases, one per public symbol (`detect_tlog_takeoff`, `detect_video_motion_onset`, `compute_offset`, `validate_offset_or_fail`, `find_aligned_window`); each asserts `ReplayInputAdapterError("auto-sync removed; supply --imu CSV instead")` | +| AC-2 (CLI flags removed or marked deprecated with one-cycle warning) | Direct | `test_az402_replay_cli.py::test_ac4_time_offset_ignored_after_az895`, `::test_az895_skip_auto_sync_ignored_and_warned`, `::test_az895_auto_trim_ignored_and_warned`, `::test_az895_no_deprecated_flags_no_warning` — assert `DeprecationWarning` is emitted, the stderr banner contains the documented `--flag is deprecated (AZ-895)` text, the value is ignored on the `ReplayConfig`, and the no-flag baseline emits no warning | +| AC-3 (`test_az405_auto_sync` tests pass against the new behaviour or are deleted with rationale recorded in the batch report) | Direct (rationale below) | Deleted; rationale: the AZ-405 tests covered the detector algorithm + AC-9 validator which AZ-895 makes unreachable. Replaced by the AC-1 deprecation-stub test above | +| AC-4 (`test_derkachi_real_tlog.py` continues to `@xfail` with the AZ-848-scoped reason) | Direct | `tests/e2e/replay/test_derkachi_real_tlog.py::test_az699_real_flight_validation_emits_verdict_and_report` — `@pytest.mark.xfail` decorator retained; `reason` text now names AZ-848 + AZ-883 as the live blocker | +| AC-5 (module docstrings of `tlog_replay_adapter.py` and `tlog_ground_truth.py` updated to call out their new audit-only roles) | Direct | Manual: both module docstrings now lead with `AUDIT-ONLY (AZ-895)` and explain the demotion; verified by inspection at the head of each file | + +## Test-Run Summary + +- Touched-module focused suite: 111 passed, 1 skipped (RUN_REPLAY_E2E + gate, expected). +- Full unit suite: 2,287 passed, 85 skipped (hardware/Docker gates), + 1 deselected (the timing-flaky perf test + `test_cli_console_script.py::TestConsoleScript::test_cold_start_under_1000ms_p99` + — unrelated to this batch, pre-existing). + +## Open Items → AZ-908 (cycle-5+ backlog) + +The deferred hard-removal surface (full spec in +`_docs/02_tasks/backlog/AZ-908_replay_auto_sync_hard_removal.md`): + +- Delete `replay_input/auto_sync.py` + `replay_input/tlog_video_adapter.py`. +- Drop `AutoSyncConfig` / `AutoSyncDecision` / `AlignedWindow` DTOs + + `ReplayInputBundle.auto_sync_result` / `aligned_window` fields. +- Drop the three deprecated CLI flags + their tests. +- Drop `ReplayConfig.time_offset_ms` / `.skip_auto_sync_validation` / + `.auto_trim` / `.auto_sync` + `ReplayAutoSyncConfig` class. +- Drop `BUILD_TLOG_REPLAY_ADAPTER` build flag from `REPLAY_BUILD_FLAGS`. +- Coordinate with AZ-842 to remove the auto-sync surface narrative + from `replay_protocol.md`. + +## Lessons Captured + +- The user-decision Choose A/B/C/D flow worked exactly as designed: + the agent surfaced the budget overrun before writing code, the + user picked the minimum path with a clear follow-up ticket, and + the batch shipped within its SP budget. +- Keeping deprecated symbols as raising stubs (rather than deleting + them outright in this cycle) gives operators one cycle of upgrade + signal: they import the same name, get a clean `ReplayInputAdapterError` + with a "supply --imu CSV instead" hint, and have a `DeprecationWarning` + to silence in any test fixtures. +- Architectural lint (`test_ac8_replay_branch_imports_only_public_apis`) + caught a mid-batch attempt to import `ReplayPace` directly from the + c8 internals — the lint forces the import to go through the + documented re-export path (`replay_input.tlog_video_adapter`). Even + though that re-export sits inside a deprecated module, the lint's + allow-list is the architectural contract; routing around it would + have been the wrong fix. diff --git a/src/gps_denied_onboard/cli/replay.py b/src/gps_denied_onboard/cli/replay.py index 5cb03f8..bab2f95 100644 --- a/src/gps_denied_onboard/cli/replay.py +++ b/src/gps_denied_onboard/cli/replay.py @@ -28,6 +28,7 @@ import logging import os import sys import traceback +import warnings from collections.abc import Callable, Sequence from dataclasses import replace from pathlib import Path @@ -158,8 +159,10 @@ def _build_argparser() -> argparse.ArgumentParser: type=int, default=None, help=( - "Manual offset between video and tlog clocks. When omitted, " - "ReplayInputAdapter (AZ-405) auto-detects via IMU take-off." + "DEPRECATED (AZ-895): the (video, tlog) auto-sync path was " + "removed. The CSV's Time column is the single canonical " + "clock by construction, so no offset is needed. Accepted " + "for one deprecation cycle but ignored; AZ-908 removes it." ), ) parser.add_argument( @@ -167,14 +170,9 @@ def _build_argparser() -> argparse.ArgumentParser: dest="skip_auto_sync_validation", action="store_true", help=( - "AZ-611: Also skip the AC-9 frame-window validator that " - "runs on the resolved offset. Only legal in combination " - "with --time-offset-ms (a manual offset is mandatory so " - "the bypass cannot mask a silent-zero auto-sync result). " - "Intended for fixtures where neither the IMU take-off " - "detector nor the video motion-onset detector can " - "produce a reliable signal (mid-flight clips, stationary " - "still-image scenarios)." + "DEPRECATED (AZ-895): the AC-9 auto-sync validator was " + "removed alongside the auto-sync surface. Accepted for " + "one deprecation cycle but ignored; AZ-908 removes it." ), ) parser.add_argument( @@ -182,13 +180,9 @@ def _build_argparser() -> argparse.ArgumentParser: dest="auto_trim", action="store_true", help=( - "AZ-698: Locate the video's playback window inside a " - "longer tlog via IMU↔optical-flow cross-correlation, " - "then trim the tlog stream to that window. Mutually " - "exclusive with --time-offset-ms. Below the configured " - "alignment confidence threshold the aligner falls back " - "to the AZ-405 head-takeoff path and the AC-9 validator " - "still gates the final offset." + "DEPRECATED (AZ-895): the IMU↔optical-flow aligner was " + "removed alongside the auto-sync surface. Accepted for " + "one deprecation cycle but ignored; AZ-908 removes it." ), ) parser.add_argument( @@ -274,6 +268,11 @@ def _build_replay_config( Per ADR-011 the CLI's only job after loading is to set ``config.mode = "replay"`` and populate ``config.replay`` from the operator's CLI args. Composition logic stays in ``compose_root``. + + AZ-895: ``--time-offset-ms``, ``--skip-auto-sync``, and + ``--auto-trim`` are deprecated. Their values are ignored here so + they cannot influence composition; the deprecation banner in + :func:`_print_startup_banner` already informed the operator. """ new_replay = ReplayConfig( video_path=str(args.video), @@ -281,9 +280,9 @@ def _build_replay_config( imu_csv_path=str(args.imu), output_path=str(args.output), pace=args.pace, - time_offset_ms=args.time_offset_ms, - skip_auto_sync_validation=bool(args.skip_auto_sync_validation), - auto_trim=bool(args.auto_trim), + time_offset_ms=None, + skip_auto_sync_validation=False, + auto_trim=False, target_fc_dialect=base_config.replay.target_fc_dialect, auto_sync=base_config.replay.auto_sync, max_duration_s=( @@ -324,13 +323,20 @@ def _build_replay_config( # Startup banner +_DEPRECATED_FLAGS_AZ895: Final[tuple[tuple[str, str], ...]] = ( + ("time_offset_ms", "--time-offset-ms"), + ("skip_auto_sync_validation", "--skip-auto-sync"), + ("auto_trim", "--auto-trim"), +) + + def _print_startup_banner(args: argparse.Namespace) -> None: """Print a sanitised one-line banner to stderr before logging boots. Logging is bootstrapped inside the airborne main; this banner gives the operator a single line confirming what the CLI parsed before any - further output. AZ-894: also surfaces the --tlog deprecation warning - inline so operators see it even when stderr is the only sink. + further output. AZ-894 / AZ-895: also surfaces deprecation warnings + inline so operators see them even when stderr is the only sink. """ sanitised = vars(args).copy() sanitised["mavlink_signing_key"] = "" @@ -347,6 +353,17 @@ def _print_startup_banner(args: argparse.Namespace) -> None: file=sys.stderr, flush=True, ) + for dest, flag in _DEPRECATED_FLAGS_AZ895: + value = getattr(args, dest, None) + if value in (None, False): + continue + msg = ( + f"{flag} is deprecated (AZ-895) and will be removed in AZ-908. " + "The (video, CSV) replay path has no auto-sync surface; " + "this flag is accepted but ignored. Remove it from your invocation." + ) + warnings.warn(msg, DeprecationWarning, stacklevel=2) + print(f"gps-denied-replay: WARNING {msg}", file=sys.stderr, flush=True) # ---------------------------------------------------------------------- diff --git a/src/gps_denied_onboard/components/c8_fc_adapter/tlog_replay_adapter.py b/src/gps_denied_onboard/components/c8_fc_adapter/tlog_replay_adapter.py index 44f7f29..4f84b1d 100644 --- a/src/gps_denied_onboard/components/c8_fc_adapter/tlog_replay_adapter.py +++ b/src/gps_denied_onboard/components/c8_fc_adapter/tlog_replay_adapter.py @@ -1,18 +1,26 @@ """``TlogReplayFcAdapter`` (AZ-399 / E-DEMO-REPLAY). -Replay-only :class:`FcAdapter` strategy parsing pymavlink ``.tlog`` -files. Implements the full Protocol from +AUDIT-ONLY (AZ-895): retained as a tlog-file parser strategy that +implements the :class:`FcAdapter` Protocol. The production replay +pipeline now composes :class:`CsvReplayFcAdapter` against the +operator's IMU+GPS CSV (AZ-894). This adapter remains in the tree as: + +- The source of :class:`ReplayPace` (shared enum used by every replay + adapter and the composition root). +- A one-off audit utility for inspecting historical ``.tlog`` files + outside the main replay flow. + +It is no longer instantiated by :func:`compose_root`'s replay branch +and AZ-908 will retire the ``BUILD_TLOG_REPLAY_ADAPTER`` build flag +that still guards its construction. + +Implements the full Protocol from ``_docs/02_document/contracts/c8_fc_adapter/fc_adapter_protocol.md`` plus the replay-specific Invariants 5, 6, 8 from ``_docs/02_document/contracts/replay/replay_protocol.md`` (no out-bound emission, pace honoured by injected :class:`Clock`, ``time_offset_ms`` shift baked at construction). -Build-time gating: the adapter refuses construction unless -``BUILD_TLOG_REPLAY_ADAPTER`` is ``ON``. Only the ``replay-cli`` -binary is expected to flip the flag ON; airborne / research / -operator binaries keep it OFF. - Stream-parse design: pymavlink's :class:`mavutil.mavlogfile` already streams from disk via :meth:`recv_match`. The adapter wraps it in a pre-scan pass (fail-fast on missing required message types per diff --git a/src/gps_denied_onboard/replay_input/__init__.py b/src/gps_denied_onboard/replay_input/__init__.py index 379cbd2..ad24915 100644 --- a/src/gps_denied_onboard/replay_input/__init__.py +++ b/src/gps_denied_onboard/replay_input/__init__.py @@ -1,22 +1,24 @@ """``replay_input/`` cross-cutting coordinator (AZ-405 / E-DEMO-REPLAY). -Layer-4 module per ``_docs/02_document/module-layout.md``. Converges -``(video, tlog)`` inputs into the standard :class:`FrameSource`, -:class:`FcAdapter`, and :class:`Clock` surfaces consumed by the -airborne composition root. Owns the time-alignment concern between -video frames and tlog IMU/attitude ticks (manual via -``--time-offset-ms`` or automatic via the AZ-405 IMU-take-off -detector). +Layer-4 module per ``_docs/02_document/module-layout.md``. Under +AZ-894 the production replay pipeline drives off the operator's +IMU+GPS CSV via :class:`CsvReplayFcAdapter`; the legacy ``(video, +tlog)`` auto-sync surface was deprecated by AZ-895 and will be removed +by AZ-908. -New under ADR-011 (replay-as-configuration) — replaces the v1.0.0 -design where replay had its own composition root. +The package retains: -Public surface re-exports the coordinator class, the bundle DTO, the -auto-sync decision DTO, the auto-sync config DTO, and the coordinator -error class. The detector functions in :mod:`auto_sync` are NOT -re-exported here so the public API stays focused on the composition -root's wiring needs; tests import the detectors via their full module -path. +- :class:`ReplayInputAdapter` and :class:`ReplayInputAdapterError` — + the latter is the canonical replay error class, used by every + replay adapter (CSV and tlog). +- :class:`ReplayInputBundle` — the DTO :func:`compose_root` consumes. +- :class:`AutoSyncConfig`, :class:`AutoSyncDecision`, + :class:`AlignedWindow` — kept on the public surface for one + deprecation cycle so any external caller's import does not break. +- Tlog ground-truth + route helpers used by AZ-697 / AZ-836 audit + paths. + +Hard removal of the deprecated symbols lands in AZ-908. """ from gps_denied_onboard._types.route import RouteSpec diff --git a/src/gps_denied_onboard/replay_input/auto_sync.py b/src/gps_denied_onboard/replay_input/auto_sync.py index 05d2341..c913b19 100644 --- a/src/gps_denied_onboard/replay_input/auto_sync.py +++ b/src/gps_denied_onboard/replay_input/auto_sync.py @@ -1,54 +1,25 @@ -"""Auto-sync detectors + offset compute + AC-9 validator (AZ-405). +"""DEPRECATED (AZ-895): auto-sync surface removed. -Three concerns: +The tlog↔video auto-sync detectors were the primary alignment mechanism +in the v1.0.0 replay design. As of AZ-894 (cycle 4) the replay pipeline +drives off a paired (video, CSV) input from the operator; the CSV's +``Time`` column is the single canonical clock by construction, so no +detection or alignment is needed. -1. **Tlog take-off detector** — walks the head of the tlog, looks for - a sustained vertical-acceleration excess + sustained attitude-rate - excess, returns ``(takeoff_ns, confidence)``. -2. **Video motion-onset detector** — runs OpenCV pyramidal optical - flow over the leading seconds of the video, returns - ``(motion_onset_ns, confidence)``. -3. **AC-9 frame-window match validator** — given a candidate offset - and the tlog/video timestamp series, returns 0 if ≥ 95 % of - video frames have an IMU sample within ± 100 ms after the offset - is applied; 2 otherwise. +This module retains the public function and DTO names for one +deprecation cycle so any external caller's import surfaces a clean +:class:`ReplayInputAdapterError` instead of an ``ImportError``. Hard +removal lands in AZ-908 (cycle 5+). -The detector functions are split into a thin path-reading wrapper -(``detect_tlog_takeoff`` / ``detect_video_motion_onset``) and a pure -sample-driven core (``_compute_tlog_takeoff_from_samples`` / -``_compute_video_onset_from_samples``). Tests exercise the pure cores -directly with synthetic fixtures; production calls the wrappers, -which read the tlog via ``pymavlink`` and the video via ``cv2``. - -Both wrappers accept an optional ``source_factory`` (tlog) / -``frames_factory`` (video) injection point so unit tests can swap in -fakes without touching the filesystem (mirrors AZ-399's pattern). +Operators with old scripts that auto-sync a tlog should switch to +``--imu PATH.csv``; see ``_docs/02_document/contracts/replay/csv_replay_format.md``. """ from __future__ import annotations -import bisect -import math -import os -from collections.abc import Callable, Iterable -from dataclasses import dataclass -from pathlib import Path -from typing import TYPE_CHECKING, Any - -from gps_denied_onboard._types.fc import FcKind from gps_denied_onboard.replay_input.errors import ReplayInputAdapterError -from gps_denied_onboard.replay_input.interface import ( - AlignedWindow, - AutoSyncConfig, - AutoSyncDecision, -) - -if TYPE_CHECKING: - import numpy as np - import numpy.typing as npt __all__ = [ - "TlogSamples", "compute_offset", "detect_tlog_takeoff", "detect_video_motion_onset", @@ -57,1079 +28,24 @@ __all__ = [ ] -# Conversion: MAVLink RAW_IMU / SCALED_IMU2 publish accelerometer -# components in mG (milli-G); 1 g ≡ 9.80665 m/s² by ISO 80000-3. -_MG_PER_G: float = 1000.0 -# Per the AZ-405 spec, the vertical-accel signal of interest is the -# magnitude excess above gravity (i.e., body acceleration regardless -# of frame orientation). At rest |a| ≈ 1 g; during upward thrust |a| -# > 1 g; during free-fall |a| ≈ 0 g. The take-off pattern is a -# sustained excess with positive sign (upward thrust), so we use -# ``|total_g - 1.0|`` as the criterion. -_REST_TOTAL_G: float = 1.0 +_REMOVED_MSG = "auto-sync removed; supply --imu CSV instead" -# --------------------------------------------------------------------- -# DTOs (internal — public API surfaces results via AutoSyncDecision) +def detect_tlog_takeoff(*args: object, **kwargs: object) -> None: + raise ReplayInputAdapterError(_REMOVED_MSG) -@dataclass(frozen=True, slots=True) -class _DetectorResult: - """Outcome of a single detector pass. +def detect_video_motion_onset(*args: object, **kwargs: object) -> None: + raise ReplayInputAdapterError(_REMOVED_MSG) - ``onset_ns`` is the best-guess event start (ns); ``confidence`` - is in [0, 1] and reflects how sustained the signal was relative - to the configured threshold + sustained-time requirement. - """ - onset_ns: int - confidence: float +def compute_offset(*args: object, **kwargs: object) -> None: + raise ReplayInputAdapterError(_REMOVED_MSG) -@dataclass(frozen=True, slots=True) -class TlogSamples: - """Pre-loaded tlog samples extracted by the take-off detector. +def validate_offset_or_fail(*args: object, **kwargs: object) -> None: + raise ReplayInputAdapterError(_REMOVED_MSG) - Used as the input shape for :func:`_compute_tlog_takeoff_from_samples` - so unit tests can build a deterministic fixture without parsing a - real ``.tlog`` file. - Attributes: - accel: Sequence of ``(ts_ns, total_accel_g)`` pairs sourced - from ``RAW_IMU`` / ``SCALED_IMU2`` messages. - attitude: Sequence of ``(ts_ns, roll_rad, pitch_rad, yaw_rad)`` - tuples sourced from ``ATTITUDE`` messages. - imu_count_by_type: Map of message-type-name → count, used for - the ``"tlog missing required message types: [...]"`` - error path (R-DEMO-3). - """ - - accel: tuple[tuple[int, float], ...] - attitude: tuple[tuple[int, float, float, float], ...] - imu_count_by_type: dict[str, int] - - -# --------------------------------------------------------------------- -# Public entrypoints - - -def detect_tlog_takeoff( - tlog_path: Path, - target_fc_dialect: FcKind, - config: AutoSyncConfig, - *, - source_factory: Callable[[str], Any] | None = None, -) -> _DetectorResult: - """Walk the tlog head, detect the take-off pattern, return result. - - Args: - tlog_path: Path to the tlog file. Existence is checked at - entry. - target_fc_dialect: ``ARDUPILOT_PLANE`` or ``INAV``. Both speak - ``ardupilotmega`` MAVLink on the GCS telemetry channel - (the iNav-side native MSP traffic is irrelevant here); - this parameter is accepted for parity with the rest of - the replay surface and is also used in the missing- - messages error to name the dialect explicitly. - config: Operator-tunable thresholds (see - :class:`AutoSyncConfig`). - source_factory: Test-only injection — when provided, replaces - the pymavlink open call with the factory's return value. - The factory must yield an object with ``recv_match`` / - ``close`` semantics matching pymavlink's - ``mavutil.mavlink_connection``. - - Raises: - ReplayInputAdapterError: When the tlog is missing - ``RAW_IMU`` / ``SCALED_IMU2`` (no IMU samples) or - ``ATTITUDE`` (no attitude samples). This is the R-DEMO-3 - fail-fast path — it surfaces BEFORE any video read in the - coordinator's ``open()`` flow. - """ - if target_fc_dialect not in (FcKind.ARDUPILOT_PLANE, FcKind.INAV): - raise ReplayInputAdapterError( - f"target_fc_dialect must be ARDUPILOT_PLANE or INAV; got {target_fc_dialect!r}" - ) - if not tlog_path.is_file(): - raise ReplayInputAdapterError(f"tlog file not found: {tlog_path}") - samples = _load_tlog_samples( - tlog_path, - config.prescan_max_messages, - source_factory=source_factory, - ) - return _compute_tlog_takeoff_from_samples(samples, config) - - -def detect_video_motion_onset( - video_path: Path, - config: AutoSyncConfig, - *, - frames_factory: Callable[[Path, float], Iterable[tuple[int, "np.ndarray"]]] - | None = None, -) -> _DetectorResult: - """Scan the leading video segment, detect motion onset, return result. - - Args: - video_path: Path to an MP4 / MKV / AVI file. - config: Operator-tunable thresholds (see - :class:`AutoSyncConfig`). - frames_factory: Test-only injection — when provided, returns - a synthetic iterable of ``(monotonic_ns, frame_bgr)`` - tuples. Must yield at least 2 frames for the pairwise - optical-flow magnitudes to compute. - - Raises: - ReplayInputAdapterError: When the video file is missing or - unreadable, or fewer than 2 frames are decoded. - """ - if not video_path.is_file(): - raise ReplayInputAdapterError(f"video file not found: {video_path}") - if frames_factory is None: - frames = list(_read_video_frames(video_path, config.video_motion_scan_seconds)) - else: - frames = list(frames_factory(video_path, config.video_motion_scan_seconds)) - if len(frames) < 2: - raise ReplayInputAdapterError( - f"video file unreadable or too short: {video_path} " - f"(decoded {len(frames)} frame(s); need ≥ 2)" - ) - flow_samples = _compute_flow_magnitudes(frames) - return _compute_video_onset_from_samples(flow_samples, config) - - -def compute_offset( - tlog_result: _DetectorResult, - video_result: _DetectorResult, -) -> AutoSyncDecision: - """Combine tlog + video detector outputs into an :class:`AutoSyncDecision`. - - Offset semantics (positive = video starts before take-off recorded - in tlog): ``offset_ns = tlog_takeoff_ns - video_motion_onset_ns``. - Combined confidence = ``min(tlog_confidence, video_confidence)`` — - the weakest signal dominates so downstream WARN-and-proceed (AC-6) - fires whenever either side is unreliable. - """ - offset_ns = tlog_result.onset_ns - video_result.onset_ns - combined = min(tlog_result.confidence, video_result.confidence) - return AutoSyncDecision( - offset_ms=offset_ns // 1_000_000, - tlog_takeoff_ns=tlog_result.onset_ns, - video_motion_onset_ns=video_result.onset_ns, - tlog_confidence=tlog_result.confidence, - video_confidence=video_result.confidence, - combined_confidence=combined, - ) - - -def validate_offset_or_fail( - offset_ms: int, - tlog_imu_timestamps_ns: Iterable[int], - video_frame_timestamps_ns: Iterable[int], - threshold_pct: float, - *, - window_ms: int = 100, -) -> int: - """AC-9 frame-window match validator. - - Returns ``0`` when ≥ ``threshold_pct`` % of video frames have an - IMU sample within ± ``window_ms`` after the offset is applied; - returns ``2`` otherwise (CLI exit code for AC-8 hard-fail). - - The check is symmetric in offset sign — the offset is added to - each video timestamp and the nearest tlog IMU timestamp is then - looked up by binary search. - """ - video_list = list(video_frame_timestamps_ns) - if not video_list: - # Degenerate input — no frames to match. The replay binary - # rejects empty videos earlier, so reaching this branch - # would be a bug; return 2 so the operator sees the hard-fail - # rather than a false PASS. - return 2 - tlog_sorted = sorted(tlog_imu_timestamps_ns) - if not tlog_sorted: - return 2 - offset_ns = int(offset_ms) * 1_000_000 - window_ns = int(window_ms) * 1_000_000 - matched = 0 - for vts in video_list: - target_ns = vts + offset_ns - idx = bisect.bisect_left(tlog_sorted, target_ns) - # The nearest IMU sample is whichever of the immediate - # neighbours of `target_ns` is closer. Either may be out of - # range at the ends of the array. - nearest: int | None = None - for j in (idx - 1, idx): - if 0 <= j < len(tlog_sorted): - cand = tlog_sorted[j] - if nearest is None or abs(cand - target_ns) < abs(nearest - target_ns): - nearest = cand - if nearest is not None and abs(nearest - target_ns) <= window_ns: - matched += 1 - match_pct = (matched / len(video_list)) * 100.0 - return 0 if match_pct >= threshold_pct else 2 - - -# --------------------------------------------------------------------- -# Pure compute kernels (testable without disk IO) - - -def _compute_tlog_takeoff_from_samples( - samples: TlogSamples, - config: AutoSyncConfig, -) -> _DetectorResult: - """Pure detector: turn pre-loaded tlog samples into a result. - - Algorithm: find the first sustained-window where (a) accel - magnitude excess above 1 g exceeds the threshold for at least - ``sustained_seconds``, and (b) attitude-rate magnitude exceeds - its threshold sustained over the same duration. Combined - confidence = ``min(accel_ratio, attitude_ratio)`` — both - signals must agree for a high-confidence take-off. - - Raises: - ReplayInputAdapterError: When the tlog had no IMU samples or - no ATTITUDE samples (R-DEMO-3 fail-fast). - """ - if not samples.accel: - missing = ["RAW_IMU", "SCALED_IMU2"] - raise ReplayInputAdapterError( - f"tlog missing required message types: {missing}" - ) - if not samples.attitude: - raise ReplayInputAdapterError( - "tlog missing required message types: ['ATTITUDE']" - ) - - sustained_ns = int(config.sustained_seconds * 1_000_000_000) - - # Pair-wise attitude rates (rad/s magnitude vector) — emitted at - # the timestamp of the LATER sample so the rate aligns with when - # it is observable downstream. - attitude_rates: list[tuple[int, float]] = [] - for i in range(1, len(samples.attitude)): - ts_prev, roll_prev, pitch_prev, yaw_prev = samples.attitude[i - 1] - ts_curr, roll_curr, pitch_curr, yaw_curr = samples.attitude[i] - dt_s = (ts_curr - ts_prev) / 1_000_000_000.0 - if dt_s <= 0.0: - continue - dr = roll_curr - roll_prev - dp = pitch_curr - pitch_prev - dy = _wrap_pi(yaw_curr - yaw_prev) - rate_mag = math.sqrt((dr / dt_s) ** 2 + (dp / dt_s) ** 2 + (dy / dt_s) ** 2) - attitude_rates.append((ts_curr, rate_mag)) - - accel_excess = tuple( - (ts, abs(total_g - _REST_TOTAL_G)) for ts, total_g in samples.accel - ) - - accel_event = _find_sustained_event( - accel_excess, - threshold=config.takeoff_accel_threshold_g, - sustained_ns=sustained_ns, - ) - attitude_event = _find_sustained_event( - tuple(attitude_rates), - threshold=config.takeoff_attitude_rate_threshold_rad_s, - sustained_ns=sustained_ns, - ) - - if accel_event is None and attitude_event is None: - # Neither signal crossed; best we can do is flag "no clear - # take-off" so the coordinator can WARN and continue with the - # tlog start as a fallback origin. - first_ns = samples.accel[0][0] - return _DetectorResult(onset_ns=first_ns, confidence=0.0) - - if accel_event is not None and attitude_event is not None: - # Both signals fired — they should both point at the same - # event. We adopt the EARLIER of the two onsets so the offset - # is referenced against the moment thrust began (the attitude - # body-rate spike usually trails the thrust by a few hundred - # ms during a vertical climb). - onset_ns = min(accel_event[0], attitude_event[0]) - # Confidence is the weakest of the two signals, scaled by - # how cleanly they agree. We keep it simple: min(). - confidence = min(accel_event[1], attitude_event[1]) - elif accel_event is not None: - # Only the accel signal — discount confidence so the - # combined offset eventually trips the WARN-and-proceed - # threshold (combined_confidence < 0.80 → AC-6). - onset_ns, raw_conf = accel_event - confidence = raw_conf * 0.6 - else: - # Only attitude rate — same rationale as above. The - # mypy-narrowing else covers attitude_event is not None. - assert attitude_event is not None - onset_ns, raw_conf = attitude_event - confidence = raw_conf * 0.6 - - return _DetectorResult(onset_ns=onset_ns, confidence=confidence) - - -def _compute_video_onset_from_samples( - flow_samples: tuple[tuple[int, float], ...], - config: AutoSyncConfig, -) -> _DetectorResult: - """Pure detector: turn pre-computed optical-flow magnitudes into a result. - - Algorithm: find the first sustained window where the flow - magnitude exceeds the configured threshold for at least - ``sustained_seconds``. Confidence = sustained ratio. - """ - if not flow_samples: - return _DetectorResult(onset_ns=0, confidence=0.0) - sustained_ns = int(config.sustained_seconds * 1_000_000_000) - event = _find_sustained_event( - flow_samples, - threshold=config.video_motion_threshold, - sustained_ns=sustained_ns, - ) - if event is None: - return _DetectorResult(onset_ns=flow_samples[0][0], confidence=0.0) - onset_ns, confidence = event - return _DetectorResult(onset_ns=onset_ns, confidence=confidence) - - -def _find_sustained_event( - samples: tuple[tuple[int, float], ...] | list[tuple[int, float]], - *, - threshold: float, - sustained_ns: int, -) -> tuple[int, float] | None: - """Sliding-window scan: return ``(start_ns, ratio)`` of the - earliest window where the fraction of samples above - ``threshold`` is maximised, provided that fraction is ≥ 0.5 - (signal-vs-noise floor) and the window covers at least 80 % of - ``sustained_ns`` (guards against truncated windows at the tail). - - Returns ``None`` when no qualifying window exists. - """ - seq = list(samples) - n = len(seq) - if n < 2: - return None - best_start_ns: int | None = None - best_ratio = 0.0 - min_window_ns = int(sustained_ns * 0.8) - for i in range(n): - start_ns = seq[i][0] - end_ns = start_ns + sustained_ns - # Walk j forward while still inside the window. - j = i - above = 0 - while j < n and seq[j][0] <= end_ns: - if seq[j][1] > threshold: - above += 1 - j += 1 - window_size = j - i - if window_size < 2: - continue - window_dur_ns = seq[j - 1][0] - start_ns - if window_dur_ns < min_window_ns: - continue - ratio = above / window_size - if ratio > best_ratio: - best_ratio = ratio - best_start_ns = start_ns - if best_start_ns is None or best_ratio < 0.5: - return None - return (best_start_ns, best_ratio) - - -def _wrap_pi(angle_rad: float) -> float: - """Wrap an angle delta into ``(-π, π]`` to handle yaw wrap-around.""" - twopi = 2.0 * math.pi - a = angle_rad % twopi - if a > math.pi: - a -= twopi - return a - - -# --------------------------------------------------------------------- -# Disk-reading wrappers (production paths) - - -_REQUIRED_TLOG_TYPES: tuple[str, ...] = ( - "RAW_IMU", - "SCALED_IMU2", - "ATTITUDE", -) - - -def _load_tlog_samples( - tlog_path: Path, - max_messages: int, - *, - source_factory: Callable[[str], Any] | None, -) -> TlogSamples: - """Stream the tlog head, capture IMU + ATTITUDE samples. - - Mirrors the AZ-399 source-factory test pattern: production builds - use ``pymavlink`` lazily; tests pass an in-memory fake. - """ - source = _open_tlog(tlog_path, source_factory=source_factory) - accel: list[tuple[int, float]] = [] - attitude: list[tuple[int, float, float, float]] = [] - counts: dict[str, int] = {} - try: - for _ in range(max_messages): - try: - msg = source.recv_match( - type=list(_REQUIRED_TLOG_TYPES), - blocking=False, - ) - except Exception as exc: # pragma: no cover — defensive. - raise ReplayInputAdapterError( - f"tlog scan failed on {tlog_path}: {exc!r}" - ) from exc - if msg is None: - break - msg_type = _safe_msg_type(msg) - if not msg_type: - continue - counts[msg_type] = counts.get(msg_type, 0) + 1 - ts_ns = _msg_timestamp_ns(msg) - if msg_type in ("RAW_IMU", "SCALED_IMU2"): - xa = float(getattr(msg, "xacc", 0.0)) / _MG_PER_G - ya = float(getattr(msg, "yacc", 0.0)) / _MG_PER_G - za = float(getattr(msg, "zacc", 0.0)) / _MG_PER_G - total_g = math.sqrt(xa * xa + ya * ya + za * za) - accel.append((ts_ns, total_g)) - elif msg_type == "ATTITUDE": - roll = float(getattr(msg, "roll", 0.0)) - pitch = float(getattr(msg, "pitch", 0.0)) - yaw = float(getattr(msg, "yaw", 0.0)) - attitude.append((ts_ns, roll, pitch, yaw)) - finally: - if hasattr(source, "close"): - try: - source.close() - except Exception: # pragma: no cover — defensive. - pass - return TlogSamples( - accel=tuple(accel), - attitude=tuple(attitude), - imu_count_by_type=counts, - ) - - -def _open_tlog( - tlog_path: Path, - *, - source_factory: Callable[[str], Any] | None, -) -> Any: - if source_factory is not None: - return source_factory(str(tlog_path)) - try: - from pymavlink import mavutil # type: ignore[import-not-found] - except ImportError as exc: - raise ReplayInputAdapterError( - "pymavlink is required for replay auto-sync but is not " - "importable in this binary" - ) from exc - return mavutil.mavlink_connection( - str(tlog_path), - dialect="ardupilotmega", - mavlink_version="2.0", - ) - - -def _safe_msg_type(msg: Any) -> str: - try: - if hasattr(msg, "get_type"): - return str(msg.get_type()) - except Exception: - return "" - return type(msg).__name__ - - -def _msg_timestamp_ns(msg: Any) -> int: - raw = getattr(msg, "_timestamp", None) - if raw is None: - raise ReplayInputAdapterError( - "tlog message missing _timestamp attribute; pymavlink " - "mavlogfile should populate it on every recv_match() return" - ) - return int(float(raw) * 1_000_000_000) - - -def _read_video_frames( - video_path: Path, - scan_seconds: float, -) -> Iterable[tuple[int, "np.ndarray"]]: - """Decode the leading ``scan_seconds`` of the video. - - Yields ``(monotonic_ns, frame_bgr)`` tuples where ``monotonic_ns`` - is the file's per-frame ``CAP_PROP_POS_MSEC × 1e6`` so the - returned timestamps align with what - :class:`VideoFileFrameSource` will report later. The Python - ``time.monotonic_ns()`` is NOT used — the auto-sync result has to - be deterministic across runs (AC-10) and tied to the video - timeline. - """ - try: - import cv2 as _cv2 # type: ignore[import-not-found] - except ImportError as exc: - raise ReplayInputAdapterError( - "opencv-python is required for replay auto-sync but is " - "not importable in this binary" - ) from exc - capture = _cv2.VideoCapture(str(video_path)) - if not capture.isOpened(): - capture.release() - raise ReplayInputAdapterError( - f"video file unreadable / unsupported codec: {video_path}" - ) - try: - max_pos_ms = scan_seconds * 1000.0 - while True: - ok, frame = capture.read() - if not ok or frame is None: - break - pos_ms = float(capture.get(_cv2.CAP_PROP_POS_MSEC)) - if pos_ms > max_pos_ms: - break - ts_ns = int(pos_ms * 1_000_000) - yield ts_ns, frame - finally: - capture.release() - - -def _compute_flow_magnitudes( - frames: list[tuple[int, "np.ndarray"]], -) -> tuple[tuple[int, float], ...]: - """Pairwise mean optical-flow magnitude between consecutive frames. - - Uses Farneback dense flow (``cv2.calcOpticalFlowFarneback``) - rather than pyramidal LK because Farneback returns a flow field - over the whole image with no per-frame feature-tracking state, so - the result is deterministic given the same input frames (AC-10). - - Returns ``((ts_ns_of_second_frame, mean_magnitude_px), ...)``. - """ - try: - import cv2 as _cv2 # type: ignore[import-not-found] - import numpy as _np # type: ignore[import-not-found] - except ImportError as exc: # pragma: no cover — guarded at call sites. - raise ReplayInputAdapterError( - "opencv-python + numpy are required for replay auto-sync" - ) from exc - if len(frames) < 2: - return () - # Convert all frames to grayscale once up-front so the per-pair - # cost is dominated by the optical-flow computation itself. - gray_frames = [] - for ts_ns, frame in frames: - gray = _cv2.cvtColor(frame, _cv2.COLOR_BGR2GRAY) - gray_frames.append((ts_ns, gray)) - out: list[tuple[int, float]] = [] - for i in range(1, len(gray_frames)): - prev_ts, prev = gray_frames[i - 1] - curr_ts, curr = gray_frames[i] - flow = _cv2.calcOpticalFlowFarneback( - prev, - curr, - None, - pyr_scale=0.5, - levels=3, - winsize=15, - iterations=3, - poly_n=5, - poly_sigma=1.2, - flags=0, - ) - # ``flow`` shape: (H, W, 2) — dx + dy per pixel. - magnitudes = _np.sqrt(flow[..., 0] ** 2 + flow[..., 1] ** 2) - mean_mag = float(magnitudes.mean()) - out.append((curr_ts, mean_mag)) - return tuple(out) - - -# Re-export the BUILD-flag check for symmetry with other replay modules. -def _build_flag_on(name: str) -> bool: - raw = os.environ.get(name, "") - return raw.strip().lower() in {"on", "1", "true", "yes"} - - -# --------------------------------------------------------------------- -# AZ-698 — mid-flight cross-correlation aligner -# -# The AZ-405 head-takeoff detector only works when the video covers -# the take-off moment. For mid-flight slices (e.g., video minutes -# 20–25 of a 30 min tlog) we need to LOCATE the window inside the -# tlog. The approach is a 1D normalised cross-correlation between -# two coarsely-resampled signals: -# -# - tlog: IMU energy ``|a_total| - 1g`` over the FULL tlog, -# resampled to ~10 Hz. -# - video: Mean optical-flow magnitude between consecutive frames -# over the FULL video (or up to a configurable scan ceiling). -# -# Both signals respond strongly to dynamic phases of flight -# (manoeuvres, turns, climbs). The peak of their cross-correlation -# gives the lag (tlog time at which the video starts). The peak -# strength (normalised) becomes the confidence — below -# ``alignment_low_confidence_threshold`` we fall back to the -# AZ-405 head-takeoff path so a degenerate steady-cruise alignment -# does not silently land at the wrong window. - - -def find_aligned_window( - tlog_path: Path, - video_path: Path, - config: AutoSyncConfig, - target_fc_dialect: FcKind, - *, - tlog_source_factory: Callable[[str], Any] | None = None, - video_frames_factory: Callable[ - [Path, float], Iterable[tuple[int, "npt.NDArray[np.uint8]"]] - ] - | None = None, -) -> AlignedWindow: - """Locate the video's playback window inside ``tlog_path`` (AZ-698). - - Args: - tlog_path: Binary ArduPilot tlog. The whole file is read up - to :attr:`AutoSyncConfig.prescan_max_messages` × 10 - (the aligner needs the FULL flight, not just the head). - video_path: Mp4 / mkv input. The leading - :attr:`AutoSyncConfig.alignment_video_scan_seconds` are - decoded to build the flow-magnitude stream. - config: Operator-tunable thresholds. - target_fc_dialect: ``ARDUPILOT_PLANE`` or ``INAV`` — same - parity contract as :func:`detect_tlog_takeoff`. - tlog_source_factory: Test injection — replaces the - ``pymavlink`` open call. - video_frames_factory: Test injection — replaces - ``cv2.VideoCapture`` frame iteration. - - Raises: - ReplayInputAdapterError: When the tlog or video is missing, - unreadable, or yields fewer than 2 samples after - resampling. - - Returns: - :class:`AlignedWindow` with ``tlog_start_ns`` / ``tlog_end_ns`` - identifying the located window, ``offset_ms`` plumbable into - :class:`TlogReplayFcAdapter`, and a peak ``confidence``. When - confidence falls below - :attr:`AutoSyncConfig.alignment_low_confidence_threshold` the - returned window comes from the AZ-405 head-takeoff path with - ``fallback_used=True``. - """ - if target_fc_dialect not in (FcKind.ARDUPILOT_PLANE, FcKind.INAV): - raise ReplayInputAdapterError( - f"target_fc_dialect must be ARDUPILOT_PLANE or INAV; got {target_fc_dialect!r}" - ) - if not tlog_path.is_file(): - raise ReplayInputAdapterError(f"tlog file not found: {tlog_path}") - if not video_path.is_file(): - raise ReplayInputAdapterError(f"video file not found: {video_path}") - - tlog_energy_full = _load_tlog_imu_energy_stream( - tlog_path, - max_messages=config.prescan_max_messages * 10, - source_factory=tlog_source_factory, - ) - if len(tlog_energy_full) < 2: - raise ReplayInputAdapterError( - f"tlog yielded {len(tlog_energy_full)} IMU sample(s); " - "need ≥ 2 for cross-correlation alignment" - ) - - # Multi-flight handling: a tlog may cover several takeoffs at the - # same field (engine starts between sorties); the uploaded video - # only covers ONE of them, conventionally the LAST. Segment the - # tlog first and restrict NCC to the last detected flight so the - # peak cannot lock onto an earlier sortie. - flight_segments = _segment_flights_from_imu_energy( - tlog_energy_full, - motion_threshold=config.alignment_segment_motion_threshold_g, - min_flight_duration_ns=int( - config.alignment_segment_min_flight_duration_seconds * 1_000_000_000 - ), - max_internal_gap_ns=int( - config.alignment_segment_max_internal_gap_seconds * 1_000_000_000 - ), - ) - if flight_segments: - seg_start_ns, seg_end_ns = flight_segments[-1] - tlog_energy = tuple( - (ts, e) for ts, e in tlog_energy_full - if seg_start_ns <= ts <= seg_end_ns - ) - flight_count_detected = len(flight_segments) - selected_flight_index = len(flight_segments) - 1 - else: - # No clear flight pattern detected (degenerate tlog: very - # short, all-quiet, or thresholds badly tuned). Fall through - # to whole-tlog NCC so we keep the AZ-405-equivalent - # behavior; surface this via flight_count_detected=0. - tlog_energy = tlog_energy_full - flight_count_detected = 0 - selected_flight_index = -1 - - if len(tlog_energy) < 2: - raise ReplayInputAdapterError( - f"selected flight segment yielded {len(tlog_energy)} IMU " - "sample(s); need ≥ 2 for cross-correlation alignment" - ) - - if video_frames_factory is None: - frames = list( - _read_video_frames(video_path, config.alignment_video_scan_seconds) - ) - else: - frames = list( - video_frames_factory(video_path, config.alignment_video_scan_seconds) - ) - if len(frames) < 2: - raise ReplayInputAdapterError( - f"video yielded {len(frames)} frame(s); " - "need ≥ 2 for cross-correlation alignment" - ) - flow_samples = _compute_flow_magnitudes(frames) - if len(flow_samples) < 2: - raise ReplayInputAdapterError( - f"video produced {len(flow_samples)} flow sample(s); " - "need ≥ 2 for cross-correlation alignment" - ) - - return _align_via_cross_correlation( - tlog_energy=tlog_energy, - flow_samples=flow_samples, - config=config, - target_fc_dialect=target_fc_dialect, - tlog_path=tlog_path, - tlog_source_factory=tlog_source_factory, - flight_count_detected=flight_count_detected, - selected_flight_index=selected_flight_index, - ) - - -def _align_via_cross_correlation( - *, - tlog_energy: tuple[tuple[int, float], ...], - flow_samples: tuple[tuple[int, float], ...], - config: AutoSyncConfig, - target_fc_dialect: FcKind, - tlog_path: Path, - tlog_source_factory: Callable[[str], Any] | None, - flight_count_detected: int = 0, - selected_flight_index: int = -1, -) -> AlignedWindow: - """Pure compute kernel: turn pre-loaded streams into an :class:`AlignedWindow`. - - Split out so unit tests can exercise the correlation arithmetic - directly with synthetic input without invoking pymavlink / cv2. - """ - import numpy as _np - - resample_hz = max(config.alignment_resample_hz, 1.0) - period_ns = int(1_000_000_000 / resample_hz) - - tlog_origin_ns = tlog_energy[0][0] - tlog_resampled = _resample_uniform(tlog_energy, period_ns, tlog_origin_ns) - if len(tlog_resampled) < 2: - raise ReplayInputAdapterError( - "tlog resampled stream has < 2 samples; cannot cross-correlate" - ) - - video_origin_ns = flow_samples[0][0] - flow_resampled = _resample_uniform(flow_samples, period_ns, video_origin_ns) - if len(flow_resampled) < 2: - raise ReplayInputAdapterError( - "video flow stream has < 2 samples; cannot cross-correlate" - ) - if len(flow_resampled) > len(tlog_resampled): - raise ReplayInputAdapterError( - "video flow stream is longer than the tlog energy stream; " - "auto-trim requires the video to be a slice of a longer tlog" - ) - - tlog_arr = _np.asarray(tlog_resampled, dtype=_np.float64) - flow_arr = _np.asarray(flow_resampled, dtype=_np.float64) - flow_centred = _zero_mean_normalise(flow_arr) - if _np.linalg.norm(flow_centred) == 0.0: - # Flat video → no information for correlation. Force the - # fallback path; confidence reported as 0. - peak_idx = 0 - confidence = 0.0 - else: - # Normalised cross-correlation: each sliding window of the - # tlog stream is zero-meaned + unit-normed independently - # before the dot product so the peak is invariant to local - # signal magnitude. Without per-window normalisation the - # tlog's full-length unit-norm drowns short bursts. - n_flow = len(flow_centred) - n_tlog = len(tlog_arr) - n_corr = n_tlog - n_flow + 1 - correlation = _np.zeros(n_corr, dtype=_np.float64) - for i in range(n_corr): - window = tlog_arr[i : i + n_flow] - win_centred = window - window.mean() - win_norm = float(_np.linalg.norm(win_centred)) - if win_norm > 0.0: - correlation[i] = float(_np.dot(win_centred / win_norm, flow_centred)) - peak_idx = int(_np.argmax(correlation)) - confidence = max(0.0, min(1.0, float(correlation[peak_idx]))) - - video_duration_ns = _stream_duration_ns(flow_samples) - if confidence < config.alignment_low_confidence_threshold: - return _fallback_to_head_takeoff( - tlog_path=tlog_path, - tlog_source_factory=tlog_source_factory, - target_fc_dialect=target_fc_dialect, - config=config, - tlog_energy=tlog_energy, - video_origin_ns=video_origin_ns, - video_flow_duration_ns=video_duration_ns, - confidence=confidence, - flight_count_detected=flight_count_detected, - selected_flight_index=selected_flight_index, - ) - - # Absolute tlog timeline value where video t=0 aligns. The - # adapter's seek check compares this against the raw pymavlink - # ``msg._timestamp`` so the value MUST be on the tlog timeline, - # NOT a delta. - tlog_start_ns = tlog_origin_ns + peak_idx * period_ns - tlog_end_ns = tlog_start_ns + video_duration_ns - # Offset that, added to a video timestamp, lands on the tlog - # timeline. Matches ``AutoSyncDecision.offset_ms`` semantics - # (``validate_offset_or_fail`` does ``vts + offset_ns``). - offset_ms = (tlog_start_ns - video_origin_ns) // 1_000_000 - return AlignedWindow( - tlog_start_ns=tlog_start_ns, - tlog_end_ns=tlog_end_ns, - offset_ms=offset_ms, - confidence=confidence, - fallback_used=False, - flight_count_detected=flight_count_detected, - selected_flight_index=selected_flight_index, - ) - - -def _stream_duration_ns( - samples: tuple[tuple[int, float], ...], -) -> int: - if not samples: - return 0 - return samples[-1][0] - samples[0][0] - - -def _fallback_to_head_takeoff( - *, - tlog_path: Path, - tlog_source_factory: Callable[[str], Any] | None, - target_fc_dialect: FcKind, - config: AutoSyncConfig, - tlog_energy: tuple[tuple[int, float], ...], - video_origin_ns: int, - video_flow_duration_ns: int, - confidence: float, - flight_count_detected: int = 0, - selected_flight_index: int = -1, -) -> AlignedWindow: - """Low-confidence fallback path. - - Two modes: - - * **Segmented tlog** (``flight_count_detected > 0``): the - pre-NCC segmenter already chose the LAST flight. We use - ``tlog_energy[0][0]`` — the start of that segment — as the - ``tlog_start_ns`` rather than re-running the AZ-405 - head-takeoff detector (which would lock onto FLIGHT 1's - takeoff on a multi-flight tlog and silently throw away the - segmenter's correct answer). This is the AZ-698-after-user- - feedback contract: "if 1 flight take it, if multiple take - the last" applies to the fallback path too. - - * **Un-segmented tlog** (``flight_count_detected == 0``): no - flight pattern fired in the segmenter (degenerate / very - short tlog). Fall back to the AZ-405 head-takeoff detector - as before — this preserves the single-flight behavior that - existed before AZ-698's segmentation stage. - - ``fallback_used`` is ``True`` in either case so callers + FDR - audit can record the divergence. The reported ``confidence`` is - the original (sub-threshold) cross-correlation peak — it is - informational only when the fallback path is taken. - """ - if flight_count_detected > 0 and tlog_energy: - tlog_start_ns = tlog_energy[0][0] - else: - takeoff = detect_tlog_takeoff( - tlog_path, - target_fc_dialect, - config, - source_factory=tlog_source_factory, - ) - if takeoff.confidence > 0.0: - tlog_start_ns = takeoff.onset_ns - elif tlog_energy: - tlog_start_ns = tlog_energy[0][0] - else: - tlog_start_ns = 0 - tlog_end_ns = tlog_start_ns + video_flow_duration_ns - offset_ms = (tlog_start_ns - video_origin_ns) // 1_000_000 - return AlignedWindow( - tlog_start_ns=tlog_start_ns, - tlog_end_ns=tlog_end_ns, - offset_ms=offset_ms, - confidence=confidence, - fallback_used=True, - flight_count_detected=flight_count_detected, - selected_flight_index=selected_flight_index, - ) - - -def _resample_uniform( - samples: tuple[tuple[int, float], ...], - period_ns: int, - origin_ns: int, -) -> list[float]: - """Resample irregular ``(ts_ns, value)`` samples to a uniform grid. - - Bins by floor-divide; each bin holds the mean of the samples - that fall inside it. Empty bins between data carry forward the - most recent in-bin mean (zero-order hold). Trailing bins past - the LAST sample's bin are dropped so the returned length - reflects the actual coverage — but bins that genuinely captured - a zero value are preserved. - """ - if not samples: - return [] - last_ts = samples[-1][0] - n_bins = max(1, ((last_ts - origin_ns) // period_ns) + 1) - bins: list[list[float]] = [[] for _ in range(n_bins)] - for ts, value in samples: - idx = (ts - origin_ns) // period_ns - if 0 <= idx < n_bins: - bins[idx].append(value) - # Drop trailing bins past the last data bin (n_bins is already - # sized to include the last sample's bin, so this is mostly a - # safety net for empty inputs). - last_filled = max( - (i for i, bucket in enumerate(bins) if bucket), default=-1 - ) - if last_filled < 0: - return [] - out: list[float] = [] - prev: float = 0.0 - for bucket in bins[: last_filled + 1]: - if bucket: - prev = sum(bucket) / len(bucket) - out.append(prev) - return out - - -def _zero_mean_normalise( - arr: "npt.NDArray[np.float64]", -) -> "npt.NDArray[np.float64]": - import numpy as _np - - centred: "npt.NDArray[np.float64]" = arr - arr.mean() - norm = float(_np.linalg.norm(centred)) - if norm == 0.0: - return centred - result: "npt.NDArray[np.float64]" = centred / norm - return result - - -def _segment_flights_from_imu_energy( - samples: tuple[tuple[int, float], ...], - *, - motion_threshold: float, - min_flight_duration_ns: int, - max_internal_gap_ns: int, -) -> list[tuple[int, int]]: - """Partition an IMU energy stream into distinct flight segments. - - A flight is a contiguous span where energy stayed ``>=`` the - threshold, with no sub-threshold run longer than - ``max_internal_gap_ns`` (cruise lulls don't split a flight). - Spans shorter than ``min_flight_duration_ns`` are discarded as - ground-startup noise. Returns ``(start_ns, end_ns)`` per flight, - in chronological order. - - AZ-698 / AZ-697 user constraint: a single tlog often spans - multiple takeoffs at the same field, but the uploaded video only - covers the **last** one. The aligner uses this segmenter to find - every flight, then restricts NCC search to the last segment so - the trim is unambiguous. ``_find_sustained_event`` (AZ-405) - returns only the FIRST qualifying window by design; partitioning - all flights needs this fresh one-pass walk. - """ - if not samples: - return [] - segments: list[tuple[int, int]] = [] - in_flight = False - flight_start_ns = 0 - last_above_ns = 0 - last_below_ns: int | None = None - for ts, energy in samples: - if energy >= motion_threshold: - if not in_flight: - in_flight = True - flight_start_ns = ts - last_above_ns = ts - last_below_ns = None - else: - if in_flight: - if last_below_ns is None: - last_below_ns = ts - if (ts - last_below_ns) >= max_internal_gap_ns: - if ( - last_above_ns - flight_start_ns - ) >= min_flight_duration_ns: - segments.append((flight_start_ns, last_above_ns)) - in_flight = False - last_below_ns = None - if in_flight and (last_above_ns - flight_start_ns) >= min_flight_duration_ns: - segments.append((flight_start_ns, last_above_ns)) - return segments - - -def _load_tlog_imu_energy_stream( - tlog_path: Path, - *, - max_messages: int, - source_factory: Callable[[str], Any] | None, -) -> tuple[tuple[int, float], ...]: - """Walk the WHOLE tlog (up to ``max_messages``) for IMU energy samples. - - Mirrors :func:`_load_tlog_samples` but only collects the - accelerometer total-magnitude excess above 1 g (the signal the - AZ-698 cross-correlation aligner consumes). The ATTITUDE channel - is not needed here. - """ - source = _open_tlog(tlog_path, source_factory=source_factory) - energy: list[tuple[int, float]] = [] - try: - for _ in range(max_messages): - try: - msg = source.recv_match( - type=["RAW_IMU", "SCALED_IMU2"], - blocking=False, - ) - except Exception as exc: # pragma: no cover — defensive. - raise ReplayInputAdapterError( - f"tlog scan failed on {tlog_path}: {exc!r}" - ) from exc - if msg is None: - break - ts_ns = _msg_timestamp_ns(msg) - xa = float(getattr(msg, "xacc", 0.0)) / _MG_PER_G - ya = float(getattr(msg, "yacc", 0.0)) / _MG_PER_G - za = float(getattr(msg, "zacc", 0.0)) / _MG_PER_G - total_g = math.sqrt(xa * xa + ya * ya + za * za) - energy.append((ts_ns, abs(total_g - _REST_TOTAL_G))) - finally: - if hasattr(source, "close"): - try: - source.close() - except Exception: # pragma: no cover — defensive. - pass - return tuple(energy) +def find_aligned_window(*args: object, **kwargs: object) -> None: + raise ReplayInputAdapterError(_REMOVED_MSG) diff --git a/src/gps_denied_onboard/replay_input/tlog_ground_truth.py b/src/gps_denied_onboard/replay_input/tlog_ground_truth.py index 7dc3b60..16a376f 100644 --- a/src/gps_denied_onboard/replay_input/tlog_ground_truth.py +++ b/src/gps_denied_onboard/replay_input/tlog_ground_truth.py @@ -1,19 +1,23 @@ """Direct binary-tlog GPS-truth extractor (AZ-697 / E-DEMO-REPLAY). -Streams ``GLOBAL_POSITION_INT`` (preferred) or ``GPS_RAW_INT`` (fallback) -from an ArduPilot binary tlog into a typed :class:`TlogGroundTruth` DTO, -suitable for the AZ-699 (real-flight validation) and AZ-701 (HTTP -replay API) comparison paths. +AUDIT-ONLY (AZ-895): the production replay pipeline now consumes +ground truth through :class:`CsvGroundTruth` (AZ-894) driven by the +operator's IMU+GPS CSV. This helper is retained for one-off audits and +the AZ-699 / AZ-701 validation paths that still operate against legacy +``.tlog`` archives; it is not part of the main replay composition root. -Design mirrors :mod:`gps_denied_onboard.replay_input.auto_sync`: +Streams ``GLOBAL_POSITION_INT`` (preferred) or ``GPS_RAW_INT`` (fallback) +from an ArduPilot binary tlog into a typed :class:`TlogGroundTruth` DTO. + +Design notes: * Lazy ``pymavlink.mavutil`` import — missing dependency raises :class:`ReplayInputAdapterError` rather than crashing the import. * Optional ``source_factory`` injection point so unit tests can swap in - a synthetic source (mirrors the AZ-399 / AZ-405 pattern). -* Production helper only — placed under ``replay_input/`` because the - GPS extraction is intrinsically tied to the tlog input pipeline; the - comparison kernels themselves live in :mod:`helpers.gps_compare`. + a synthetic source (mirrors the AZ-399 pattern). +* Placed under ``replay_input/`` because the GPS extraction is + intrinsically tied to the tlog input pipeline; the comparison kernels + themselves live in :mod:`helpers.gps_compare`. """ from __future__ import annotations diff --git a/src/gps_denied_onboard/replay_input/tlog_video_adapter.py b/src/gps_denied_onboard/replay_input/tlog_video_adapter.py index 1b2af0b..31af36e 100644 --- a/src/gps_denied_onboard/replay_input/tlog_video_adapter.py +++ b/src/gps_denied_onboard/replay_input/tlog_video_adapter.py @@ -1,134 +1,56 @@ -"""``ReplayInputAdapter`` (AZ-405 / E-DEMO-REPLAY). +"""DEPRECATED (AZ-895): ``ReplayInputAdapter`` retained as a raising stub. -Layer-4 cross-cutting coordinator that converges ``(video, tlog)`` -inputs into the standard :class:`FrameSource`, :class:`FcAdapter`, -and :class:`Clock` surfaces consumed by the airborne composition -root. Owns the time-alignment concern: either the operator's manual -``--time-offset-ms`` override or the AZ-405 IMU-take-off auto-detect. +The (video, tlog) coordinator was the v1.0.0 entry point into the +auto-sync surface. As of AZ-894 (cycle 4) the replay pipeline runs off +a paired (video, CSV) input — :class:`CsvReplayFcAdapter` plus the +:class:`CsvVideoBundle` builder in :mod:`gps_denied_onboard.runtime_root._replay_branch`. -``open()`` performs strict ordering so AC-13 holds: +This module keeps the :class:`ReplayInputAdapter` symbol live for one +deprecation cycle so external imports surface a clean +:class:`ReplayInputAdapterError` instead of an ``ImportError``. Hard +removal lands in AZ-908 (cycle 5+). -1. **Tlog message-type pre-validation** runs FIRST so a tlog missing - ``RAW_IMU`` / ``ATTITUDE`` raises before the video is ever read. -2. If the constructor received ``manual_time_offset_ms is None``, - the auto-sync detectors run; otherwise the manual offset is - adopted directly (AC-8 verifies the bypass). -3. The resolved offset is fed through the AC-9 frame-window match - validator; a hard-fail raises ``"auto-sync hard-fail: …"`` so - the shared main maps it to CLI exit code 2 (AC-7). -4. The :class:`Clock` strategy is constructed (``TlogDerivedClock`` - for ``pace=ASAP``, ``WallClock`` for ``pace=REALTIME``) — the - single instance the bundle ships to the composition root - (Invariant 2; AC-5). -5. :class:`VideoFileFrameSource` and :class:`TlogReplayFcAdapter` - are constructed against the offset + clock + dialect; the FC - adapter's own ``open()`` triggers its independent pre-scan (a - second sanity check; the operator gets the original error path - if step 1 was bypassed via a test fake). -6. The bundle is returned with ``auto_sync_result`` populated for - the auto path and ``None`` for the manual path. - -The coordinator is idempotent on ``close()`` — repeated calls are -no-ops once the underlying strategies have been released (AC-12). +Operators with old (video, tlog) scripts should switch to the +(video, CSV) input; see +``_docs/02_document/contracts/replay/csv_replay_format.md``. """ from __future__ import annotations -import logging from pathlib import Path from typing import TYPE_CHECKING, Any from gps_denied_onboard._types.fc import FcKind -from gps_denied_onboard.clock.tlog_derived import TlogDerivedClock -from gps_denied_onboard.clock.wall_clock import WallClock -from gps_denied_onboard.components.c8_fc_adapter.errors import ( - FcAdapterConfigError, - FcAdapterError, - FcOpenError, -) from gps_denied_onboard.components.c8_fc_adapter.tlog_replay_adapter import ( ReplayPace, - TlogReplayFcAdapter, -) -from gps_denied_onboard.fdr_client.records import FdrRecord -from gps_denied_onboard.frame_source.errors import ( - FrameSourceConfigError, - FrameSourceError, -) -from gps_denied_onboard.frame_source.video_file import VideoFileFrameSource -from gps_denied_onboard.helpers.iso_timestamps import iso_ts_now -from gps_denied_onboard.replay_input.auto_sync import ( - _load_tlog_samples, - compute_offset, - detect_video_motion_onset, - find_aligned_window, - validate_offset_or_fail, ) from gps_denied_onboard.replay_input.errors import ReplayInputAdapterError from gps_denied_onboard.replay_input.interface import ( - AlignedWindow, AutoSyncConfig, - AutoSyncDecision, ReplayInputBundle, ) if TYPE_CHECKING: from gps_denied_onboard._types.calibration import CameraCalibration - from gps_denied_onboard.clock import Clock from gps_denied_onboard.fdr_client.client import FdrClient from gps_denied_onboard.helpers.wgs_converter import WgsConverter -__all__ = ["ReplayInputAdapter"] +__all__ = ["ReplayInputAdapter", "ReplayPace"] -_FDR_PRODUCER_ID = "replay_input.tlog_video_adapter" - -_LOG_KIND_AUTO_SYNC_DETECTED = "replay.auto_sync.detected" -_LOG_KIND_AUTO_SYNC_LOW_CONF = "replay.auto_sync.low_confidence" -_LOG_KIND_AUTO_SYNC_AC8_FAIL = "replay.auto_sync.ac8_validation_failed" -_LOG_KIND_OPEN_MANUAL = "replay.input.opened_manual_offset" -_LOG_KIND_AUTO_TRIM_RESOLVED = "replay.auto_trim.resolved" -_LOG_KIND_AUTO_TRIM_FALLBACK = "replay.auto_trim.fallback_to_takeoff" +_REMOVED_MSG = ( + "tlog_video_adapter.ReplayInputAdapter is deprecated (AZ-895); " + "supply --imu CSV instead" +) class ReplayInputAdapter: - """Coordinator that converges ``(video, tlog)`` into the airborne strategies. + """DEPRECATED (AZ-895): :meth:`open` raises :class:`ReplayInputAdapterError`. - Constructor parameters: - - - ``video_path`` / ``tlog_path`` — filesystem inputs. - - ``camera_calibration`` — :class:`CameraCalibration` used to - derive the calibration ID propagated into every emitted - :class:`NavCameraFrame`. - - ``target_fc_dialect`` — ``ARDUPILOT_PLANE`` or ``INAV``; - passed through to :class:`TlogReplayFcAdapter`. - - ``wgs_converter`` — shared geodesy helper, constructor-injected - into :class:`TlogReplayFcAdapter`. - - ``fdr_client`` — FDR sink for the TlogReplayFcAdapter and for - the coordinator's own structured-event mirror. - - ``pace`` — :class:`ReplayPace` (``ASAP`` or ``REALTIME``). - - ``manual_time_offset_ms`` — ``None`` triggers auto-sync; an - integer bypasses auto-sync DETECTION but the AC-9 frame-window - validator still runs on the resolved offset (AC-8). - - ``skip_auto_sync_validation`` — when ``True``, ALSO skip the - AC-9 validator. Only legal in combination with a non-``None`` - ``manual_time_offset_ms`` (the coordinator refuses both-None - to avoid silent-zero offset bugs). Intended for fixtures where - neither the IMU take-off detector nor the video motion-onset - detector can produce a reliable signal (mid-flight clips, - stationary still-image scenarios — see AZ-611). Default - ``False``. - - ``auto_sync_config`` — :class:`AutoSyncConfig` thresholds. - - Behaviour: - - - :meth:`open` resolves the offset, validates AC-9, and returns a - :class:`ReplayInputBundle` with the wired strategies. Raises - :class:`ReplayInputAdapterError` on every coordinator-scope - failure so the shared main can map cleanly to CLI exit code 2. - - :meth:`close` releases the FC adapter and the frame source; - idempotent (AC-12). + Constructor remains tolerant so callers can still import the symbol + and instantiate it (e.g. for backwards-compatible plugin discovery), + but every meaningful use raises. Hard removal lands in AZ-908. """ __slots__ = ( @@ -143,14 +65,6 @@ class ReplayInputAdapter: "_skip_auto_sync_validation", "_auto_trim", "_auto_sync_config", - "_tlog_source_factory", - "_video_frames_factory", - "_video_timestamps_factory", - "_mavlink_transport", - "_log", - "_opened", - "_closed", - "_bundle", ) def __init__( @@ -172,54 +86,6 @@ class ReplayInputAdapter: video_timestamps_factory: Any | None = None, mavlink_transport: Any | None = None, ) -> None: - if not isinstance(video_path, Path): - raise ReplayInputAdapterError( - f"video_path must be a pathlib.Path; got {type(video_path).__name__}" - ) - if not isinstance(tlog_path, Path): - raise ReplayInputAdapterError( - f"tlog_path must be a pathlib.Path; got {type(tlog_path).__name__}" - ) - if target_fc_dialect not in (FcKind.ARDUPILOT_PLANE, FcKind.INAV): - raise ReplayInputAdapterError( - f"target_fc_dialect must be ARDUPILOT_PLANE or INAV; " - f"got {target_fc_dialect!r}" - ) - if not isinstance(pace, ReplayPace): - raise ReplayInputAdapterError( - f"pace must be a ReplayPace enum; got {type(pace).__name__}" - ) - if not isinstance(skip_auto_sync_validation, bool): - raise ReplayInputAdapterError( - "skip_auto_sync_validation must be a bool; got " - f"{type(skip_auto_sync_validation).__name__}" - ) - if skip_auto_sync_validation and manual_time_offset_ms is None: - # Mirror the ReplayConfig.__post_init__ gate. Without a - # manual offset there is no operator-acknowledged value - # to skip validation against — auto-sync would compute - # an offset of unknown quality and the validator that - # would catch a bad detection is disabled. Refuse so - # this can't silently mask a wrong offset. - raise ReplayInputAdapterError( - "skip_auto_sync_validation=True requires " - "manual_time_offset_ms to be set" - ) - if not isinstance(auto_trim, bool): - raise ReplayInputAdapterError( - "auto_trim must be a bool; got " - f"{type(auto_trim).__name__}" - ) - if auto_trim and manual_time_offset_ms is not None: - # Mirror the ReplayConfig.__post_init__ gate. An explicit - # manual offset means the operator has already aligned - # the streams; running the cross-correlation aligner on - # top of that would either re-resolve the same window - # (wasteful) or overwrite the operator's intent silently. - raise ReplayInputAdapterError( - "auto_trim=True is mutually exclusive with " - "manual_time_offset_ms" - ) self._video_path = video_path self._tlog_path = tlog_path self._camera_calibration = camera_calibration @@ -231,506 +97,9 @@ class ReplayInputAdapter: self._skip_auto_sync_validation = skip_auto_sync_validation self._auto_trim = auto_trim self._auto_sync_config = auto_sync_config - self._tlog_source_factory = tlog_source_factory - self._video_frames_factory = video_frames_factory - self._video_timestamps_factory = video_timestamps_factory - self._mavlink_transport = mavlink_transport - self._log = logging.getLogger("replay_input.tlog_video_adapter") - self._opened = False - self._closed = False - self._bundle: ReplayInputBundle | None = None def open(self) -> ReplayInputBundle: - """Resolve the offset, build the strategies, return the bundle. - - Idempotent only in the failure-then-retry sense — calling - ``open()`` twice without an intervening ``close()`` raises - :class:`ReplayInputAdapterError`. - """ - if self._opened: - raise ReplayInputAdapterError("ReplayInputAdapter already opened") - - # Step 1 — tlog presence + required-message check (R-DEMO-3, - # AC-13). Runs BEFORE any video read so a malformed tlog - # surfaces without paying the cv2.VideoCapture cost. - tlog_imu_timestamps_ns, tlog_samples_for_auto = self._load_and_validate_tlog() - - # Step 2 — resolve the offset (auto-sync, auto-trim, or - # manual override). - decision: AutoSyncDecision | None - aligned_window: AlignedWindow | None - if self._auto_trim: - aligned_window = self._run_auto_trim() - decision = None - resolved_offset_ms = aligned_window.offset_ms - # The prescan timestamps (step 1) only cover the tlog head. - # When the auto-trim window is far into the tlog, the prescan - # timestamps fall outside the window and the AC-9 validator - # would always return 0 % match → false hard-fail. Reload - # IMU timestamps from the discovered window so the validator - # sees the correct slice. - if aligned_window.tlog_start_ns > 0: - tlog_imu_timestamps_ns = self._load_tlog_imu_in_window( - aligned_window.tlog_start_ns, - aligned_window.tlog_end_ns, - ) - self._log.info( - "replay_input.ac9_window_reload: " - "tlog_start_ns=%d tlog_end_ns=%d loaded=%d imu_samples", - aligned_window.tlog_start_ns, - aligned_window.tlog_end_ns, - len(tlog_imu_timestamps_ns), - extra={ - "kind": "replay_input.ac9_window_reload", - "kv": { - "tlog_start_ns": aligned_window.tlog_start_ns, - "tlog_end_ns": aligned_window.tlog_end_ns, - "loaded_imu_count": len(tlog_imu_timestamps_ns), - }, - }, - ) - elif self._manual_time_offset_ms is None: - aligned_window = None - decision = self._run_auto_sync(tlog_samples_for_auto) - resolved_offset_ms = decision.offset_ms - else: - aligned_window = None - decision = None - resolved_offset_ms = int(self._manual_time_offset_ms) - self._log.info( - f"{_LOG_KIND_OPEN_MANUAL}: resolved_offset_ms={resolved_offset_ms}", - extra={ - "kind": _LOG_KIND_OPEN_MANUAL, - "kv": {"resolved_offset_ms": resolved_offset_ms}, - }, - ) - - # Step 3 — load video frame timestamps and run AC-9 validator - # unless the operator explicitly opted out via - # skip_auto_sync_validation (AZ-611). The opt-out is meant for - # mid-flight + stationary fixtures where neither detector can - # produce a reliable signal; the constructor already enforced - # that the opt-out requires a manual offset. - video_frame_timestamps_ns = self._load_video_timestamps() - if self._skip_auto_sync_validation: - self._log.info( - f"{_LOG_KIND_OPEN_MANUAL}: ac9_validator_skipped " - f"(resolved_offset_ms={resolved_offset_ms})", - extra={ - "kind": _LOG_KIND_OPEN_MANUAL, - "kv": { - "resolved_offset_ms": resolved_offset_ms, - "ac9_validator_skipped": True, - }, - }, - ) - else: - result_code = validate_offset_or_fail( - resolved_offset_ms, - tlog_imu_timestamps_ns, - video_frame_timestamps_ns, - threshold_pct=self._auto_sync_config.match_threshold_pct, - window_ms=self._auto_sync_config.match_window_ms, - ) - if result_code != 0: - self._raise_ac8_fail( - resolved_offset_ms, - len(tlog_imu_timestamps_ns), - len(video_frame_timestamps_ns), - ) - - # Step 4 — clock strategy (single instance per Invariant 2). - clock = self._build_clock() - - # Step 5 — concrete strategies. The frame source is built - # first because its constructor verifies the build flag and - # opens the cv2 capture handle — a failure here is a clean - # config error (no resources held). The FC adapter is built - # second; its open() launches the decode thread. - try: - frame_source = VideoFileFrameSource( - path=self._video_path, - camera_calibration_id=self._camera_calibration.camera_id, - clock=clock, - ) - except FrameSourceConfigError as exc: - raise ReplayInputAdapterError( - f"video file unreadable / unsupported codec: {self._video_path} " - f"({exc})" - ) from exc - except FrameSourceError as exc: - raise ReplayInputAdapterError( - f"video file decode error: {self._video_path} ({exc})" - ) from exc - - try: - fc_adapter = TlogReplayFcAdapter( - tlog_path=self._tlog_path, - target_fc_dialect=self._target_fc_dialect, - clock=clock, - wgs_converter=self._wgs_converter, - fdr_client=self._fdr_client, - time_offset_ms=resolved_offset_ms, - tlog_start_ns=( - aligned_window.tlog_start_ns - if aligned_window is not None - else None - ), - pace=self._pace, - source_factory=self._tlog_source_factory, - mavlink_transport=self._mavlink_transport, - ) - fc_adapter.open() - except (FcOpenError, FcAdapterConfigError, FcAdapterError) as exc: - # Release the already-built frame source so we do not - # leak the cv2 handle when the FC adapter fails after - # the video was opened. - try: - frame_source.close() - except Exception: # pragma: no cover — defensive. - self._log.debug( - "ReplayInputAdapter: frame_source.close() during FC adapter rollback failed", - exc_info=True, - ) - # Translate the FC error into the coordinator's single - # public failure shape so the CLI exit-code mapping - # remains single-source. Pre-scan failures naturally - # surface the "tlog missing required messages: …" prefix - # the contract mandates. - raise ReplayInputAdapterError(str(exc)) from exc - - # Step 6 — assemble + record the bundle. - bundle = ReplayInputBundle( - frame_source=frame_source, - fc_adapter=fc_adapter, - clock=clock, - resolved_time_offset_ms=resolved_offset_ms, - auto_sync_result=decision, - aligned_window=aligned_window, - ) - self._bundle = bundle - self._opened = True - return bundle + raise ReplayInputAdapterError(_REMOVED_MSG) def close(self) -> None: - """Release the FC adapter + frame source; idempotent (AC-12).""" - if self._closed: - self._log.debug( - "ReplayInputAdapter.close called twice; no-op" - ) - return - self._closed = True - bundle = self._bundle - self._bundle = None - if bundle is None: - return - try: - bundle.fc_adapter.close() - except Exception: # pragma: no cover — defensive. - self._log.debug( - "ReplayInputAdapter: fc_adapter.close() raised", exc_info=True - ) - try: - bundle.frame_source.close() - except Exception: # pragma: no cover — defensive. - self._log.debug( - "ReplayInputAdapter: frame_source.close() raised", exc_info=True - ) - - # ------------------------------------------------------------------ - # Internal helpers - - def _load_and_validate_tlog( - self, - ) -> tuple[list[int], Any]: - """Load tlog IMU + ATTITUDE samples; raise on missing types. - - Returns the IMU-only timestamp list (used by the AC-9 - validator) plus the full :class:`TlogSamples` so the auto- - sync path can reuse the same scan for take-off detection. - Raises :class:`ReplayInputAdapterError` for the R-DEMO-3 - missing-types path; this is the AC-13 fail-fast surface. - """ - if not self._tlog_path.is_file(): - raise ReplayInputAdapterError( - f"tlog file not found: {self._tlog_path}" - ) - samples = _load_tlog_samples( - self._tlog_path, - self._auto_sync_config.prescan_max_messages, - source_factory=self._tlog_source_factory, - ) - if not samples.accel: - raise ReplayInputAdapterError( - "tlog missing required message types: ['RAW_IMU', 'SCALED_IMU2']" - ) - if not samples.attitude: - raise ReplayInputAdapterError( - "tlog missing required message types: ['ATTITUDE']" - ) - return [ts for ts, _ in samples.accel], samples - - def _run_auto_trim(self) -> AlignedWindow: - """AZ-698 auto-trim path — cross-correlate IMU energy ↔ optical flow. - - Returns the located :class:`AlignedWindow`. When the - correlation peak falls below - :attr:`AutoSyncConfig.alignment_low_confidence_threshold`, - :func:`find_aligned_window` falls back to the AZ-405 - head-takeoff detector and sets ``fallback_used=True`` — the - coordinator logs WARN but still proceeds (the - AC-9 frame-window validator runs in Step 3 and will - hard-fail if the resolved offset is bad). - """ - window = find_aligned_window( - self._tlog_path, - self._video_path, - self._auto_sync_config, - self._target_fc_dialect, - tlog_source_factory=self._tlog_source_factory, - video_frames_factory=self._video_frames_factory, - ) - kind = ( - _LOG_KIND_AUTO_TRIM_FALLBACK - if window.fallback_used - else _LOG_KIND_AUTO_TRIM_RESOLVED - ) - level = "WARN" if window.fallback_used else "INFO" - kv = { - "tlog_start_ns": window.tlog_start_ns, - "tlog_end_ns": window.tlog_end_ns, - "offset_ms": window.offset_ms, - "confidence": window.confidence, - "fallback_used": window.fallback_used, - "flight_count_detected": window.flight_count_detected, - "selected_flight_index": window.selected_flight_index, - } - msg = ( - f"{kind}: tlog_start_ns={window.tlog_start_ns} " - f"offset_ms={window.offset_ms} confidence={window.confidence:.3f} " - f"flights_detected={window.flight_count_detected} " - f"selected_flight={window.selected_flight_index}" - ) - if window.fallback_used: - self._log.warning(msg, extra={"kind": kind, "kv": kv}) - else: - self._log.info(msg, extra={"kind": kind, "kv": kv}) - self._emit_fdr_event(level=level, log_kind=kind, msg=msg, kv=kv) - return window - - def _run_auto_sync(self, tlog_samples: Any) -> AutoSyncDecision: - """Auto path — compute the take-off / motion-onset / offset. - - Re-uses the already-loaded ``tlog_samples`` for the take-off - detector so the tlog is walked exactly once per ``open()`` - regardless of which path runs. - """ - from gps_denied_onboard.replay_input.auto_sync import ( - _compute_tlog_takeoff_from_samples, - ) - - tlog_result = _compute_tlog_takeoff_from_samples( - tlog_samples, self._auto_sync_config - ) - video_result = detect_video_motion_onset( - self._video_path, - self._auto_sync_config, - frames_factory=self._video_frames_factory, - ) - decision = compute_offset(tlog_result, video_result) - if decision.combined_confidence < self._auto_sync_config.low_confidence_threshold: - self._log_decision( - kind=_LOG_KIND_AUTO_SYNC_LOW_CONF, - level="WARN", - decision=decision, - extra_kv={"proceeding_with_best_guess": True}, - ) - else: - self._log_decision( - kind=_LOG_KIND_AUTO_SYNC_DETECTED, - level="INFO", - decision=decision, - extra_kv={}, - ) - return decision - - def _load_video_timestamps(self) -> list[int]: - """Decode the leading video segment, return per-frame timestamps. - - Used by the AC-9 frame-window match validator and as a - fallback when the auto-sync video scan was bypassed (manual - path). Stops at ``video_motion_scan_seconds`` so wildly long - clips do not hold up startup. - """ - if self._video_timestamps_factory is not None: - return list(self._video_timestamps_factory(self._video_path)) - try: - import cv2 as _cv2 # type: ignore[import-not-found] - except ImportError as exc: - raise ReplayInputAdapterError( - "opencv-python is required for replay auto-sync but is " - "not importable in this binary" - ) from exc - capture = _cv2.VideoCapture(str(self._video_path)) - if not capture.isOpened(): - capture.release() - raise ReplayInputAdapterError( - f"video file unreadable / unsupported codec: {self._video_path}" - ) - out: list[int] = [] - max_pos_ms = self._auto_sync_config.video_motion_scan_seconds * 1000.0 - try: - while True: - ok = capture.grab() - if not ok: - break - pos_ms = float(capture.get(_cv2.CAP_PROP_POS_MSEC)) - if pos_ms > max_pos_ms: - break - out.append(int(pos_ms * 1_000_000)) - finally: - capture.release() - return out - - def _load_tlog_imu_in_window( - self, - start_ns: int, - end_ns: int, - ) -> list[int]: - """Load tlog IMU timestamps from [start_ns, end_ns]. - - Used by the AC-9 validator in auto-trim mode. The prescan - (step 1) only covers the tlog head; when the identified window - is later in the file this method re-scans to find IMU samples - in the correct range. Sequential scan is unavoidable (pymavlink - does not seek), but only IMU message types are matched so the - scan is fast in practice. - """ - from gps_denied_onboard.replay_input.auto_sync import _open_tlog - - source = _open_tlog(self._tlog_path, source_factory=self._tlog_source_factory) - timestamps: list[int] = [] - try: - while True: - try: - msg = source.recv_match( - type=["RAW_IMU", "SCALED_IMU2"], - blocking=False, - ) - except Exception as exc: - raise ReplayInputAdapterError( - f"tlog scan for AC-9 window failed: {exc!r}" - ) from exc - if msg is None: - break - raw = getattr(msg, "_timestamp", None) - if raw is None: - continue - ts_ns = int(float(raw) * 1_000_000_000) - if ts_ns < start_ns: - continue - if ts_ns > end_ns: - break - timestamps.append(ts_ns) - finally: - if hasattr(source, "close"): - try: - source.close() - except Exception: - pass - return timestamps - - def _build_clock(self) -> "Clock": - """Pick the :class:`Clock` strategy per pace; single instance. - - The ``TlogDerivedClock`` is constructed against an empty - iterable here: the composition root (AZ-401) is responsible - for hooking the clock's source up to the live tlog cursor - once the FC adapter's decode thread starts streaming. The - empty-source default keeps unit tests self-contained. - """ - if self._pace is ReplayPace.ASAP: - return TlogDerivedClock(source=iter([])) - return WallClock() - - def _log_decision( - self, - *, - kind: str, - level: str, - decision: AutoSyncDecision, - extra_kv: dict[str, Any], - ) -> None: - kv: dict[str, Any] = { - "tlog_takeoff_ns": decision.tlog_takeoff_ns, - "video_motion_onset_ns": decision.video_motion_onset_ns, - "offset_ms": decision.offset_ms, - "tlog_confidence": decision.tlog_confidence, - "video_confidence": decision.video_confidence, - "combined_confidence": decision.combined_confidence, - } - kv.update(extra_kv) - msg = f"{kind}: offset_ms={decision.offset_ms} confidence={decision.combined_confidence:.3f}" - if level == "WARN": - self._log.warning(msg, extra={"kind": kind, "kv": kv}) - else: - self._log.info(msg, extra={"kind": kind, "kv": kv}) - self._emit_fdr_event(level=level, log_kind=kind, msg=msg, kv=kv) - - def _raise_ac8_fail( - self, - offset_ms: int, - imu_count: int, - frame_count: int, - ) -> None: - kv = { - "offset_ms": offset_ms, - "frame_window_match_pct_threshold": self._auto_sync_config.match_threshold_pct, - "imu_sample_count": imu_count, - "video_frame_count": frame_count, - } - msg = ( - f"auto-sync hard-fail: frame-window match below " - f"{self._auto_sync_config.match_threshold_pct}% with " - f"offset_ms={offset_ms}" - ) - self._log.error( - f"{_LOG_KIND_AUTO_SYNC_AC8_FAIL}: {msg}", - extra={"kind": _LOG_KIND_AUTO_SYNC_AC8_FAIL, "kv": kv}, - ) - self._emit_fdr_event( - level="ERROR", log_kind=_LOG_KIND_AUTO_SYNC_AC8_FAIL, msg=msg, kv=kv - ) - raise ReplayInputAdapterError(msg) - - def _emit_fdr_event( - self, - *, - level: str, - log_kind: str, - msg: str, - kv: dict[str, Any], - ) -> None: - record = FdrRecord( - schema_version=1, - ts=iso_ts_now(), - producer_id=_FDR_PRODUCER_ID, - kind="log", - payload={ - "level": level, - "component": "replay_input", - "kind": log_kind, - "msg": msg, - "kv": kv, - }, - ) - try: - self._fdr_client.enqueue(record) - except Exception as exc: - self._log.debug( - f"replay_input.fdr_enqueue_failed: {exc!r}", - extra={ - "kind": "replay_input.fdr_enqueue_failed", - "kv": {"error": repr(exc), "downstream_kind": log_kind}, - }, - ) \ No newline at end of file + return None diff --git a/src/gps_denied_onboard/runtime_root/_replay_branch.py b/src/gps_denied_onboard/runtime_root/_replay_branch.py index 26a44e9..473a0a2 100644 --- a/src/gps_denied_onboard/runtime_root/_replay_branch.py +++ b/src/gps_denied_onboard/runtime_root/_replay_branch.py @@ -16,14 +16,21 @@ shared composition spine while still exposing exactly one Build-flag gates (per replay protocol Invariant 9): - ``BUILD_VIDEO_FILE_FRAME_SOURCE`` — required for the - :class:`VideoFileFrameSource` instance returned by the coordinator. -- ``BUILD_TLOG_REPLAY_ADAPTER`` — required for the - :class:`TlogReplayFcAdapter` instance returned by the coordinator. + :class:`VideoFileFrameSource` instance. +- ``BUILD_TLOG_REPLAY_ADAPTER`` — historical guard. The tlog adapter + is no longer composed by replay (AZ-895 deprecated the (video, tlog) + path), but the flag remains in :data:`REPLAY_BUILD_FLAGS` for one + deprecation cycle so operator overrides keep their expected semantics. + AZ-908 will drop the flag. - ``BUILD_REPLAY_SINK_JSONL`` — shared by the JSONL sink and the noop outbound transport. All three default ON in the airborne binary (per ADR-011); flipping any OFF disables replay mode without affecting live mode. + +AZ-895: the replay composition exclusively uses the (video, CSV) path +via :class:`CsvReplayFcAdapter`. The legacy (video, tlog) auto-sync +branch was removed; ``imu_csv_path`` is required. """ from __future__ import annotations @@ -48,13 +55,8 @@ from gps_denied_onboard.components.c8_fc_adapter.replay_sink import ( from gps_denied_onboard.config import Config from gps_denied_onboard.fdr_client import make_fdr_client from gps_denied_onboard.frame_source.video_file import VideoFileFrameSource -from gps_denied_onboard.helpers.wgs_converter import WgsConverter from gps_denied_onboard.logging import get_logger -from gps_denied_onboard.replay_input import ( - AutoSyncConfig, - ReplayInputAdapter, - ReplayInputBundle, -) +from gps_denied_onboard.replay_input import ReplayInputBundle from gps_denied_onboard.replay_input.tlog_video_adapter import ReplayPace if TYPE_CHECKING: @@ -77,10 +79,6 @@ REPLAY_BUILD_FLAGS: Final[tuple[str, ...]] = ( "BUILD_REPLAY_SINK_JSONL", ) -# AZ-894: separate build flag for the CSV adapter so the replay binary -# can opt into the new path without disturbing the BUILD_TLOG_* gate -# (the tlog adapter is still composed by _build_replay_input_bundle's -# legacy branch until AZ-895 removes it). _CSV_REPLAY_BUILD_FLAG: Final[str] = "BUILD_CSV_REPLAY_ADAPTER" @@ -106,7 +104,6 @@ def build_replay_components( config: Config, *, fdr_client_factory: Any | None = None, - replay_input_adapter_factory: Any | None = None, sink_factory: Any | None = None, transport_factory: Any | None = None, ) -> tuple[dict[str, Any], tuple[str, ...]]: @@ -137,11 +134,10 @@ def build_replay_components( sink_fdr_client = fdr_factory("c8_fc_adapter.replay_sink", config) # AZ-558: build the outbound MAVLink transport BEFORE the FC adapter - # so it can be threaded through `ReplayInputAdapter` and into - # `TlogReplayFcAdapter`. The same instance is exposed as the - # ``mavlink_transport`` slot in ``components`` (replay protocol - # Invariant 5: encoders write through the seam in both modes; - # replay drops the bytes via NoopMavlinkTransport). + # so the same instance can be exposed as the ``mavlink_transport`` + # slot in ``components`` (replay protocol Invariant 5: encoders + # write through the seam in both modes; replay drops the bytes via + # NoopMavlinkTransport). if transport_factory is not None: transport = transport_factory(config) else: @@ -150,7 +146,6 @@ def build_replay_components( bundle = _build_replay_input_bundle( config, fdr_client=fdr_client, - adapter_factory=replay_input_adapter_factory, mavlink_transport=transport, ) @@ -187,19 +182,19 @@ def _validate_build_flags() -> None: def _validate_replay_paths(config: Config) -> None: """Reject empty / missing replay paths early with a precise message. - AZ-894: ``imu_csv_path`` is the canonical replay input. ``tlog_path`` - remains valid for the legacy auto-sync path until AZ-895 removes it, - but exactly one of the two must be set so the composition root can - pick a single branch. + AZ-895: ``imu_csv_path`` is the only supported replay input. The + legacy tlog auto-sync surface was deprecated in AZ-895 and will be + physically removed in AZ-908; until then ``tlog_path`` may remain + set in the config but is ignored by composition. """ if not config.replay.video_path: raise CompositionError( "config.replay.video_path is empty; replay mode requires a video path" ) - if not config.replay.imu_csv_path and not config.replay.tlog_path: + if not config.replay.imu_csv_path: raise CompositionError( - "config.replay.imu_csv_path is empty and no tlog_path fallback is set; " - "replay mode requires an IMU+GPS CSV (AZ-894) or a tlog file (legacy)" + "config.replay.imu_csv_path is empty; " + "replay mode requires an IMU+GPS CSV (--imu PATH.csv)" ) if not config.replay.output_path: raise CompositionError( @@ -211,61 +206,27 @@ def _build_replay_input_bundle( config: Config, *, fdr_client: "FdrClient", - adapter_factory: Any | None, mavlink_transport: Any | None = None, ) -> ReplayInputBundle: """Build the replay input bundle and open the underlying strategies. - AZ-894: branches on ``config.replay.imu_csv_path`` — when set, builds - the :class:`CsvReplayFcAdapter` + :class:`VideoFileFrameSource` pair - on a single canonical clock derived from the CSV's ``Time`` column; - when unset, falls back to the legacy :class:`ReplayInputAdapter` - tlog path (auto-sync + AC-9 validator). AZ-895 removes the legacy - branch. + AZ-895: the (video, CSV) path is the only supported composition. + The legacy (video, tlog) auto-sync branch was removed; the + :func:`_validate_replay_paths` gate above guarantees + ``imu_csv_path`` is set before this function runs. """ pace = _resolve_pace(config.replay.pace) target_fc_dialect = _resolve_fc_kind(config.replay.target_fc_dialect) camera_calibration = _load_camera_calibration(config) - wgs_converter = WgsConverter() - if config.replay.imu_csv_path: - return _build_csv_bundle( - config, - fdr_client=fdr_client, - pace=pace, - target_fc_dialect=target_fc_dialect, - camera_calibration=camera_calibration, - mavlink_transport=mavlink_transport, - ) - - auto_sync = _build_auto_sync_config(config) - if adapter_factory is not None: - adapter = adapter_factory( - config=config, - camera_calibration=camera_calibration, - target_fc_dialect=target_fc_dialect, - wgs_converter=wgs_converter, - fdr_client=fdr_client, - pace=pace, - auto_sync_config=auto_sync, - mavlink_transport=mavlink_transport, - ) - else: - adapter = ReplayInputAdapter( - video_path=Path(config.replay.video_path), - tlog_path=Path(config.replay.tlog_path), - camera_calibration=camera_calibration, - target_fc_dialect=target_fc_dialect, - wgs_converter=wgs_converter, - fdr_client=fdr_client, - pace=pace, - manual_time_offset_ms=config.replay.time_offset_ms, - skip_auto_sync_validation=config.replay.skip_auto_sync_validation, - auto_trim=config.replay.auto_trim, - auto_sync_config=auto_sync, - mavlink_transport=mavlink_transport, - ) - return adapter.open() + return _build_csv_bundle( + config, + fdr_client=fdr_client, + pace=pace, + target_fc_dialect=target_fc_dialect, + camera_calibration=camera_calibration, + mavlink_transport=mavlink_transport, + ) def _build_csv_bundle( @@ -339,35 +300,6 @@ def _resolve_fc_kind(raw: str) -> FcKind: ) -def _build_auto_sync_config(config: Config) -> AutoSyncConfig: - block = config.replay.auto_sync - return AutoSyncConfig( - takeoff_accel_threshold_g=block.takeoff_accel_threshold_g, - takeoff_attitude_rate_threshold_rad_s=( - block.takeoff_attitude_rate_threshold_rad_s - ), - sustained_seconds=block.sustained_seconds, - prescan_max_messages=block.prescan_max_messages, - video_motion_threshold=block.video_motion_threshold, - video_motion_scan_seconds=block.video_motion_scan_seconds, - match_threshold_pct=block.match_threshold_pct, - match_window_ms=block.match_window_ms, - low_confidence_threshold=block.low_confidence_threshold, - alignment_resample_hz=block.alignment_resample_hz, - alignment_video_scan_seconds=block.alignment_video_scan_seconds, - alignment_low_confidence_threshold=block.alignment_low_confidence_threshold, - alignment_segment_motion_threshold_g=( - block.alignment_segment_motion_threshold_g - ), - alignment_segment_min_flight_duration_seconds=( - block.alignment_segment_min_flight_duration_seconds - ), - alignment_segment_max_internal_gap_seconds=( - block.alignment_segment_max_internal_gap_seconds - ), - ) - - def _load_camera_calibration(config: Config) -> CameraCalibration: """Read the camera calibration JSON into a :class:`CameraCalibration` DTO. @@ -427,12 +359,11 @@ def _log_ready(config: Config, bundle: ReplayInputBundle) -> None: "kind": _LOG_KIND_READY, "kv": { "video_path": config.replay.video_path, - "tlog_path": config.replay.tlog_path, + "imu_csv_path": config.replay.imu_csv_path, "output_path": config.replay.output_path, "pace": config.replay.pace, "resolved_offset_ms": bundle.resolved_time_offset_ms, "calib_path": config.runtime.camera_calibration_path, - "auto_sync_used": bundle.auto_sync_result is not None, }, }, ) diff --git a/tests/e2e/replay/test_derkachi_real_tlog.py b/tests/e2e/replay/test_derkachi_real_tlog.py index 20db7ed..cd30a4f 100644 --- a/tests/e2e/replay/test_derkachi_real_tlog.py +++ b/tests/e2e/replay/test_derkachi_real_tlog.py @@ -173,25 +173,20 @@ def _load_full_ground_truth(tlog_path: Path) -> list[GroundTruthRow]: @pytest.mark.tier2 @pytest.mark.xfail( reason=( - "Blocked by AZ-776 + AZ-777. AZ-699 was implemented without " - "executing this test end-to-end on Tier-2 Jetson; once the " - "fixtures (real video + factory calibration) landed and the " - "test ran for real, two upstream gaps surfaced: (1) AZ-776 " - "— c4_pose ISam2GraphHandle Protocol rejects the ESKF stub " - "handle, so the c5_state=eskf composition variant cannot run; " - "(2) AZ-777 — Derkachi has no C6 reference tile cache / " - "descriptor index, so the default c5_state=gtsam_isam2 " - "composition reaches the per-frame loop but iSAM2.update " - "fails at frame 1 with key 'x2' not in Values (no C4 anchor " - "was ever inserted because C2/C3/C4 have nothing to match " - "against). Per AZ-777 AC-4: 'After AZ-776 + this ticket " - "both ship, test_ac3_within_100m_80pct_of_ticks can be " - "un-xfail'd and pass'. The AZ-699 verdict-on-real-flight is " - "tracked under those tickets; this xfail is the documented " - "mask until they ship. NOTE: this contradicts AZ-699 AC-1 " - "('no @xfail mask'); the dependency gap was discovered " - "post-implementation when the Jetson e2e harness ran for " - "the first time." + "Blocked by AZ-848 (+ AZ-883). The tlog adapter path is " + "structurally broken at the clock layer: VioOutput.emitted_at_ns " + "is sourced from process monotonic_ns (klt_ransac.py:274) while " + "ImuWindow.ts_end_ns comes from the FC IMU timebase, so the C5 " + "ESKF immediately diverges (mahalanobis² > 100) at frame 3 " + "with c5.state.eskf_out_of_order. AZ-883 is the related " + "SCALED_IMU2.ts_ns=0 default that exacerbates the mismatch on " + "some tlogs. AZ-895 made the (video, CSV) path the primary " + "replay surface, so AZ-848 is no longer bench-blocking — but " + "this test still exercises the legacy tlog path and will " + "remain xfail until AZ-848 / AZ-883 ship. The AZ-776 / AZ-777 " + "gaps that originally surfaced via this test were closed in " + "cycle 3, but the clock-mismatch root cause then surfaced " + "underneath." ), strict=False, ) diff --git a/tests/unit/replay_input/test_az405_auto_sync.py b/tests/unit/replay_input/test_az405_auto_sync.py deleted file mode 100644 index d4f12c3..0000000 --- a/tests/unit/replay_input/test_az405_auto_sync.py +++ /dev/null @@ -1,483 +0,0 @@ -"""AZ-405 — auto-sync detector + offset-compute + AC-9 validator. - -Covers AC-1..AC-10 of ``_docs/02_tasks/todo/AZ-405_replay_auto_sync.md``. - -Tests run against the pure compute kernels in -:mod:`gps_denied_onboard.replay_input.auto_sync` (no disk IO, no real -pymavlink, no real OpenCV) so the suite is fast + deterministic. - -Style: every test follows the Arrange / Act / Assert pattern. -""" - -from __future__ import annotations - -import math -from typing import Any - -import pytest - -from gps_denied_onboard.replay_input.auto_sync import ( - TlogSamples, - _compute_tlog_takeoff_from_samples, - _compute_video_onset_from_samples, - compute_offset, - validate_offset_or_fail, -) -from gps_denied_onboard.replay_input.errors import ReplayInputAdapterError -from gps_denied_onboard.replay_input.interface import AutoSyncConfig - - -# --------------------------------------------------------------------- -# Synthetic-fixture helpers - - -def _ns(seconds: float) -> int: - return int(seconds * 1_000_000_000) - - -def _flat_accel_samples( - *, start_s: float, end_s: float, hz: int, total_g: float -) -> list[tuple[int, float]]: - out: list[tuple[int, float]] = [] - period = 1.0 / hz - t = start_s - while t < end_s: - out.append((_ns(t), total_g)) - t += period - return out - - -def _flat_attitude_samples( - *, start_s: float, end_s: float, hz: int, roll: float, pitch: float, yaw: float -) -> list[tuple[int, float, float, float]]: - out: list[tuple[int, float, float, float]] = [] - period = 1.0 / hz - t = start_s - while t < end_s: - out.append((_ns(t), roll, pitch, yaw)) - t += period - return out - - -def _ramp_attitude_samples( - *, - start_s: float, - end_s: float, - hz: int, - base_roll: float, - base_pitch: float, - base_yaw: float, - rate_rad_s: float, -) -> list[tuple[int, float, float, float]]: - """Attitude that ramps in pitch at ``rate_rad_s`` rad/s.""" - out: list[tuple[int, float, float, float]] = [] - period = 1.0 / hz - t = start_s - while t < end_s: - dt = t - start_s - pitch = base_pitch + rate_rad_s * dt - out.append((_ns(t), base_roll, pitch, base_yaw)) - t += period - return out - - -def _build_takeoff_samples() -> TlogSamples: - """AC-1 fixture: 2 s flat hover, then 1.5 s sustained 2.2 g + 1.5 rad/s. - - Take-off onset is at t = 2.0 s (the first sample with the - elevated acceleration). Body-frame accelerometer convention: at - hover the proper-acceleration magnitude is 1 g (gravity reaction); - during a 1.2 g thrust climb it is 2.2 g, so the take-off excess - above 1 g rest is 1.2 g — well above the 0.5 g threshold. - """ - accel_pre = _flat_accel_samples(start_s=0.0, end_s=2.0, hz=200, total_g=1.0) - accel_post = _flat_accel_samples(start_s=2.0, end_s=3.5, hz=200, total_g=2.2) - accel = accel_pre + accel_post - - attitude_pre = _flat_attitude_samples( - start_s=0.0, end_s=2.0, hz=100, roll=0.0, pitch=0.0, yaw=0.0 - ) - attitude_post = _ramp_attitude_samples( - start_s=2.0, - end_s=3.5, - hz=100, - base_roll=0.0, - base_pitch=0.0, - base_yaw=0.0, - rate_rad_s=1.5, - ) - attitude = attitude_pre + attitude_post - - return TlogSamples( - accel=tuple(accel), - attitude=tuple(attitude), - imu_count_by_type={ - "RAW_IMU": len(accel), - "ATTITUDE": len(attitude), - }, - ) - - -def _build_low_amplitude_vibration_samples() -> TlogSamples: - """AC-2 fixture: 5 s of 0.3 g body-frame vibration (no take-off). - - Total proper-acceleration during vibration = 1.3 g (0.3 g excess - above the 1 g rest baseline) — strictly below the 0.5 g detector - threshold so the sustained-event search rejects every window. - """ - accel = _flat_accel_samples(start_s=0.0, end_s=5.0, hz=200, total_g=1.3) - attitude = _flat_attitude_samples( - start_s=0.0, end_s=5.0, hz=100, roll=0.0, pitch=0.0, yaw=0.0 - ) - return TlogSamples( - accel=tuple(accel), - attitude=tuple(attitude), - imu_count_by_type={ - "RAW_IMU": len(accel), - "ATTITUDE": len(attitude), - }, - ) - - -def _build_hand_launch_samples() -> TlogSamples: - """AC-3 fixture: 0.8 g impulse for 100 ms; not sustained for 0.5 s. - - Body-frame accelerometer convention (see ``_build_takeoff_samples``): - a 0.8 g impulse becomes 1.8 g total proper-acceleration during the - impulse window. - """ - accel_pre = _flat_accel_samples(start_s=0.0, end_s=2.0, hz=200, total_g=1.0) - accel_impulse = _flat_accel_samples( - start_s=2.0, end_s=2.1, hz=200, total_g=1.8 - ) - accel_post = _flat_accel_samples(start_s=2.1, end_s=3.0, hz=200, total_g=1.0) - accel = accel_pre + accel_impulse + accel_post - - attitude = _flat_attitude_samples( - start_s=0.0, end_s=3.0, hz=100, roll=0.0, pitch=0.0, yaw=0.0 - ) - return TlogSamples( - accel=tuple(accel), - attitude=tuple(attitude), - imu_count_by_type={ - "RAW_IMU": len(accel), - "ATTITUDE": len(attitude), - }, - ) - - -def _flow_samples_from_frames( - *, n_stationary: int, n_moving: int, fps: int = 30, motion_px: float = 4.0 -) -> tuple[tuple[int, float], ...]: - """Synthesise the flow-magnitude series the video detector consumes. - - The detector emits a ``(ts_ns, mean_magnitude_px)`` tuple for each - consecutive frame pair (skipping the first frame's pair). For - AC-4 we pretend frames 1..10 are stationary (mag ≈ 0) and frames - 11..60 are moving (mag = motion_px). - """ - out: list[tuple[int, float]] = [] - period_ns = int(1_000_000_000 / fps) - for i in range(1, n_stationary): - out.append((i * period_ns, 0.05)) - for j in range(n_stationary, n_stationary + n_moving): - out.append((j * period_ns, motion_px)) - return tuple(out) - - -# --------------------------------------------------------------------- -# AC-1 — tlog take-off detector (positive) - - -def test_ac1_tlog_takeoff_detector_positive_within_50ms_and_high_confidence() -> None: - # Arrange - config = AutoSyncConfig() - samples = _build_takeoff_samples() - - # Act - result = _compute_tlog_takeoff_from_samples(samples, config) - - # Assert - expected_onset_ns = _ns(2.0) - assert abs(result.onset_ns - expected_onset_ns) <= _ns(0.05), ( - f"detected onset {result.onset_ns / 1e9}s deviates >50ms from expected 2.0s" - ) - assert result.confidence >= 0.85, ( - f"confidence {result.confidence} below AC-1 minimum of 0.85" - ) - - -# --------------------------------------------------------------------- -# AC-2 — tlog take-off detector (ambiguous) - - -def test_ac2_tlog_takeoff_detector_low_amplitude_vibration_low_confidence() -> None: - # Arrange - config = AutoSyncConfig() - samples = _build_low_amplitude_vibration_samples() - - # Act - result = _compute_tlog_takeoff_from_samples(samples, config) - - # Assert - assert result.confidence < 0.50, ( - f"confidence {result.confidence} should be < 0.50 for ambiguous vibration" - ) - - -# --------------------------------------------------------------------- -# AC-3 — tlog take-off detector (hand launch) - - -def test_ac3_tlog_takeoff_detector_hand_launch_warn_regime() -> None: - # Arrange - config = AutoSyncConfig() - samples = _build_hand_launch_samples() - - # Act - result = _compute_tlog_takeoff_from_samples(samples, config) - - # Assert - assert result.confidence < 0.80, ( - f"confidence {result.confidence} should be < 0.80 for unsustained hand-launch" - ) - - -# --------------------------------------------------------------------- -# AC-4 — video motion-onset detector - - -def test_ac4_video_motion_onset_detected_within_one_frame() -> None: - # Arrange - config = AutoSyncConfig() - flow_samples = _flow_samples_from_frames(n_stationary=10, n_moving=50, fps=30) - period_ns = int(1_000_000_000 / 30) - expected_onset_ns = 10 * period_ns - - # Act - result = _compute_video_onset_from_samples(flow_samples, config) - - # Assert - assert abs(result.onset_ns - expected_onset_ns) <= period_ns, ( - f"detected motion onset {result.onset_ns} ns deviates >1 frame " - f"from expected {expected_onset_ns} ns" - ) - assert result.confidence > 0.80, ( - f"confidence {result.confidence} too low for clear motion onset" - ) - - -# --------------------------------------------------------------------- -# AC-5 — combined offset within ± 200 ms - - -def test_ac5_combined_offset_within_200ms_of_ground_truth() -> None: - # Arrange - config = AutoSyncConfig() - tlog_samples = _build_takeoff_samples() - tlog_result = _compute_tlog_takeoff_from_samples(tlog_samples, config) - - flow_samples = _flow_samples_from_frames(n_stationary=10, n_moving=50, fps=30) - video_result = _compute_video_onset_from_samples(flow_samples, config) - - # Ground-truth offset = tlog take-off (2.0 s) − video onset (10/30 s) - period_ns = int(1_000_000_000 / 30) - ground_truth_offset_ms = (_ns(2.0) - 10 * period_ns) // 1_000_000 - - # Act - decision = compute_offset(tlog_result, video_result) - - # Assert - assert abs(decision.offset_ms - ground_truth_offset_ms) <= 200, ( - f"offset {decision.offset_ms} ms deviates >200 ms from ground truth " - f"{ground_truth_offset_ms} ms" - ) - - -# --------------------------------------------------------------------- -# AC-6 — low combined confidence (verified via the coordinator test -# in test_az405_replay_input_adapter.py; here we only verify the -# combined-confidence aggregator picks min()) - - -def test_ac6_combined_confidence_takes_minimum_of_inputs() -> None: - # Arrange - from gps_denied_onboard.replay_input.auto_sync import _DetectorResult - - high = _DetectorResult(onset_ns=_ns(1.0), confidence=0.95) - low = _DetectorResult(onset_ns=_ns(2.0), confidence=0.50) - - # Act - decision = compute_offset(high, low) - - # Assert - assert decision.combined_confidence == pytest.approx(0.50) - assert decision.offset_ms == (_ns(1.0) - _ns(2.0)) // 1_000_000 - - -# --------------------------------------------------------------------- -# AC-7 — AC-9 validator hard-fail (the coordinator-level raise is -# covered in test_az405_replay_input_adapter.py) - - -def test_ac7_validator_hard_fail_returns_2_for_offset_outside_window() -> None: - # Arrange - fps = 30 - period_ns = int(1_000_000_000 / fps) - video_ts = [i * period_ns for i in range(60)] - # IMU sampled at 200 Hz from t=0 to t=2 (mismatch deliberate; the - # bad offset shifts everything outside the window). - imu_ts = [int(i / 200 * 1_000_000_000) for i in range(400)] - bad_offset_ms = 60_000 - - # Act - code = validate_offset_or_fail( - bad_offset_ms, - imu_ts, - video_ts, - threshold_pct=95.0, - window_ms=100, - ) - - # Assert - assert code == 2 - - -# --------------------------------------------------------------------- -# AC-9 — frame-window match-percentage validator (positive) - - -def test_ac9_validator_passes_for_well_matched_offset() -> None: - # Arrange - fps = 30 - period_ns = int(1_000_000_000 / fps) - video_ts = [i * period_ns for i in range(60)] - # IMU samples densely spanning the same time range — every video - # frame has an IMU sample within ± 100 ms. - imu_ts = [int(i / 200 * 1_000_000_000) for i in range(60 * 200 // 30)] - - # Act - code = validate_offset_or_fail( - 0, imu_ts, video_ts, threshold_pct=95.0, window_ms=100 - ) - - # Assert - assert code == 0 - - -def test_ac9_threshold_configurable() -> None: - # Arrange — set up a series where exactly 80% of frames match. - fps = 30 - period_ns = int(1_000_000_000 / fps) - video_ts = [i * period_ns for i in range(50)] - # IMU only covers the first 80% of the video timeline; the last - # 10 frames will be far outside the window. - imu_ts = [ - int(i / 200 * 1_000_000_000) for i in range(int(40 / 30 * 200)) - ] - - # Act / Assert - # Default 95% threshold → fail (80% < 95%). - assert validate_offset_or_fail( - 0, imu_ts, video_ts, threshold_pct=95.0, window_ms=100 - ) == 2 - # Lowered to 75% → pass. - assert validate_offset_or_fail( - 0, imu_ts, video_ts, threshold_pct=75.0, window_ms=100 - ) == 0 - - -# --------------------------------------------------------------------- -# AC-10 — confidence determinism - - -def test_ac10_confidence_score_deterministic_across_two_runs() -> None: - # Arrange - config = AutoSyncConfig() - samples = _build_takeoff_samples() - - # Act - first = _compute_tlog_takeoff_from_samples(samples, config) - second = _compute_tlog_takeoff_from_samples(samples, config) - - # Assert - assert first.onset_ns == second.onset_ns - assert math.isclose(first.confidence, second.confidence, abs_tol=1e-9) - - -def test_ac10_video_onset_deterministic_across_two_runs() -> None: - # Arrange - config = AutoSyncConfig() - flow_samples = _flow_samples_from_frames(n_stationary=5, n_moving=20, fps=30) - - # Act - first = _compute_video_onset_from_samples(flow_samples, config) - second = _compute_video_onset_from_samples(flow_samples, config) - - # Assert - assert first.onset_ns == second.onset_ns - assert math.isclose(first.confidence, second.confidence, abs_tol=1e-9) - - -# --------------------------------------------------------------------- -# R-DEMO-3 fail-fast on the pure compute path - - -def test_pure_takeoff_kernel_raises_on_no_imu_samples() -> None: - # Arrange - config = AutoSyncConfig() - samples = TlogSamples( - accel=(), - attitude=(), - imu_count_by_type={"ATTITUDE": 100}, - ) - - # Act / Assert - with pytest.raises(ReplayInputAdapterError, match="tlog missing required"): - _compute_takeoff_or_propagate(samples, config) - - -def test_pure_takeoff_kernel_raises_on_no_attitude_samples() -> None: - # Arrange - config = AutoSyncConfig() - accel = _flat_accel_samples(start_s=0.0, end_s=1.0, hz=200, total_g=1.0) - samples = TlogSamples( - accel=tuple(accel), - attitude=(), - imu_count_by_type={"RAW_IMU": len(accel)}, - ) - - # Act / Assert - with pytest.raises(ReplayInputAdapterError, match="tlog missing required"): - _compute_takeoff_or_propagate(samples, config) - - -def _compute_takeoff_or_propagate(samples: TlogSamples, config: AutoSyncConfig) -> Any: - """Local trampoline so the assertions are explicit even if the - underscore-named helper migrates.""" - return _compute_tlog_takeoff_from_samples(samples, config) - - -# --------------------------------------------------------------------- -# AC-9 edge cases - - -def test_validator_returns_2_on_empty_video_or_tlog() -> None: - # Arrange - imu_ts = [0, 1_000_000, 2_000_000] - video_ts: list[int] = [] - - # Act / Assert — empty video - assert ( - validate_offset_or_fail( - 0, imu_ts, video_ts, threshold_pct=95.0, window_ms=100 - ) - == 2 - ) - # Empty tlog - assert ( - validate_offset_or_fail( - 0, [], [0, 1_000_000], threshold_pct=95.0, window_ms=100 - ) - == 2 - ) diff --git a/tests/unit/replay_input/test_az405_replay_input_adapter.py b/tests/unit/replay_input/test_az405_replay_input_adapter.py deleted file mode 100644 index eb46202..0000000 --- a/tests/unit/replay_input/test_az405_replay_input_adapter.py +++ /dev/null @@ -1,804 +0,0 @@ -"""AZ-405 — ``ReplayInputAdapter`` coordinator unit tests. - -Covers AC-6 (low-confidence WARN-and-proceed), AC-7 (AC-8 hard-fail), -AC-8 (manual override bypass), AC-11 (open() returns a complete -bundle), AC-12 (idempotent close), and AC-13 (R-DEMO-3 fail-fast on -missing tlog message types). - -Synthetic videos use the same OpenCV-driven fixture pattern as -``tests/unit/frame_source/test_protocol_conformance.py``; the tlog -side is faked via the coordinator's ``tlog_source_factory`` injection -point so tests run without a real pymavlink connection. - -Style: every test follows the Arrange / Act / Assert pattern. -""" - -from __future__ import annotations - -from pathlib import Path -from types import SimpleNamespace -from typing import Any -from unittest import mock - -import cv2 -import numpy as np -import pytest - -from gps_denied_onboard._types.calibration import CameraCalibration -from gps_denied_onboard._types.fc import FcKind -from gps_denied_onboard.clock.tlog_derived import TlogDerivedClock -from gps_denied_onboard.clock.wall_clock import WallClock -from gps_denied_onboard.components.c8_fc_adapter.tlog_replay_adapter import ( - ReplayPace, - TlogReplayFcAdapter, -) -from gps_denied_onboard.frame_source.video_file import VideoFileFrameSource -from gps_denied_onboard.replay_input.errors import ReplayInputAdapterError -from gps_denied_onboard.replay_input.interface import ( - AutoSyncConfig, - AutoSyncDecision, - ReplayInputBundle, -) -from gps_denied_onboard.replay_input.tlog_video_adapter import ReplayInputAdapter - - -# --------------------------------------------------------------------- -# Fixtures - - -@pytest.fixture(autouse=True) -def _enable_build_flags(monkeypatch: pytest.MonkeyPatch) -> None: - """Both downstream strategies are gated by build flags (AZ-398 / AZ-399).""" - monkeypatch.setenv("BUILD_VIDEO_FILE_FRAME_SOURCE", "ON") - monkeypatch.setenv("BUILD_TLOG_REPLAY_ADAPTER", "ON") - - -@pytest.fixture -def fake_fdr_client() -> mock.MagicMock: - return mock.MagicMock(name="FdrClient") - - -@pytest.fixture -def fake_wgs_converter() -> mock.MagicMock: - return mock.MagicMock(name="WgsConverter") - - -@pytest.fixture -def camera_calibration() -> CameraCalibration: - return CameraCalibration( - camera_id="az405-test", - intrinsics_3x3=None, - distortion=None, - body_to_camera_se3=None, - acquisition_method="synthetic", - ) - - -def _make_synthetic_video(path: Path, n_frames: int = 60, fps: int = 30) -> Path: - """Write a 64×48 BGR MP4V file at ``path`` and return it.""" - fourcc = cv2.VideoWriter_fourcc(*"mp4v") - writer = cv2.VideoWriter(str(path), fourcc, fps, (64, 48)) - if not writer.isOpened(): - raise RuntimeError(f"OpenCV could not open writer at {path!s}") - try: - for i in range(n_frames): - frame = np.full((48, 64, 3), i % 256, dtype=np.uint8) - writer.write(frame) - finally: - writer.release() - return path - - -@pytest.fixture -def synthetic_video(tmp_path: Path) -> Path: - return _make_synthetic_video(tmp_path / "az405-video.mp4", n_frames=60, fps=30) - - -@pytest.fixture -def synthetic_tlog_path(tmp_path: Path) -> Path: - p = tmp_path / "az405.tlog" - p.write_bytes(b"fake-tlog") - return p - - -# --------------------------------------------------------------------- -# Fake pymavlink source - - -def _ns(seconds: float) -> int: - return int(seconds * 1_000_000_000) - - -def _fake_msg(msg_type: str, *, ts_s: float, **fields: Any) -> SimpleNamespace: - ns = SimpleNamespace(_timestamp=ts_s, **fields) - ns.get_type = lambda: msg_type - return ns - - -def _build_takeoff_messages( - *, - accel_pre_total_g: float = 1.0, - accel_post_total_g: float = 2.2, - accel_hz: int = 200, - pre_seconds: float = 2.0, - post_seconds: float = 1.5, -) -> list[SimpleNamespace]: - """A short tlog stream with a clear take-off pattern + GPS + heartbeat.""" - out: list[SimpleNamespace] = [] - accel_period = 1.0 / accel_hz - # Pre-takeoff: 1 g hover (z-acc = -1g in body, after sign). - t = 0.0 - while t < pre_seconds: - out.append( - _fake_msg( - "RAW_IMU", - ts_s=t, - xacc=0, - yacc=0, - zacc=-int(accel_pre_total_g * 1000), - xgyro=0, - ygyro=0, - zgyro=0, - ) - ) - t += accel_period - # Post-takeoff: 2.2 g sustained climb thrust. - while t < pre_seconds + post_seconds: - out.append( - _fake_msg( - "RAW_IMU", - ts_s=t, - xacc=0, - yacc=0, - zacc=-int(accel_post_total_g * 1000), - xgyro=0, - ygyro=0, - zgyro=0, - ) - ) - t += accel_period - - # Attitude: flat hover then 1.5 rad/s pitch ramp. - t = 0.0 - attitude_period = 1.0 / 100.0 - while t < pre_seconds: - out.append( - _fake_msg("ATTITUDE", ts_s=t, roll=0.0, pitch=0.0, yaw=0.0) - ) - t += attitude_period - pitch_rate = 1.5 - while t < pre_seconds + post_seconds: - dt = t - pre_seconds - out.append( - _fake_msg( - "ATTITUDE", - ts_s=t, - roll=0.0, - pitch=pitch_rate * dt, - yaw=0.0, - ) - ) - t += attitude_period - - # GPS_RAW_INT + HEARTBEAT (required by AZ-399 pre-scan). - out.append( - _fake_msg( - "GPS_RAW_INT", - ts_s=0.0, - fix_type=3, - lat=499910000, - lon=362210000, - alt=153_400, - ) - ) - out.append(_fake_msg("HEARTBEAT", ts_s=0.0, system_status=4, base_mode=0)) - out.sort(key=lambda m: m._timestamp) - return out - - -class _FakeTlog: - """Minimal pymavlink ``mavlink_connection`` stand-in. - - Returns each stored message once on ``recv_match``; ignores the - ``type=`` filter (the AZ-399 decode loop receives unfiltered - HEARTBEAT/IMU/ATTITUDE/GPS streams). - """ - - def __init__(self, messages: list[SimpleNamespace]) -> None: - self._iter = iter(messages) - self.closed = False - - def recv_match(self, **_kwargs: Any) -> SimpleNamespace | None: - return next(self._iter, None) - - def close(self) -> None: - self.closed = True - - -def _factory_for(messages: list[SimpleNamespace]) -> Any: - """Return a source factory that yields a fresh ``_FakeTlog`` per call. - - The coordinator opens the tlog twice (once for ``_load_tlog_samples`` - in the auto-sync path, once via the FC adapter's pre-scan + decode - handles), so the messages have to be re-emittable. - """ - - def _factory(_path: str) -> _FakeTlog: - return _FakeTlog(list(messages)) - - return _factory - - -def _frames_factory_with_motion( - *, - n_stationary: int = 10, - n_moving: int = 50, - fps: int = 30, -) -> Any: - """Return a frames_factory yielding the AC-4 motion-onset shape.""" - period_ns = int(1_000_000_000 / fps) - rng = np.random.default_rng(seed=0) - - def _factory(_path: Path, _scan_seconds: float) -> Any: - out: list[tuple[int, np.ndarray]] = [] - # Stationary: identical frames so optical flow ≈ 0. - base = np.full((48, 64, 3), 128, dtype=np.uint8) - for i in range(n_stationary): - out.append((i * period_ns, base.copy())) - # Moving: each frame replaces a 16×16 patch at a random offset - # so Farneback returns a clear non-zero magnitude. Determinism - # is preserved by the seeded RNG. - for j in range(n_moving): - frame = base.copy() - r = rng.integers(0, 32) - c = rng.integers(0, 48) - frame[r : r + 16, c : c + 16, :] = 240 - out.append(((n_stationary + j) * period_ns, frame)) - return out - - return _factory - - -def _video_timestamps_factory( - *, - n_frames: int = 60, - fps: int = 30, -) -> Any: - """Return a timestamps_factory with deterministic per-frame ts (ns).""" - period_ns = int(1_000_000_000 / fps) - - def _factory(_path: Path) -> list[int]: - return [i * period_ns for i in range(n_frames)] - - return _factory - - -# --------------------------------------------------------------------- -# AC-11 — open() returns a complete bundle - - -def test_ac11_open_returns_complete_bundle_with_correct_strategies( - synthetic_video: Path, - synthetic_tlog_path: Path, - camera_calibration: CameraCalibration, - fake_wgs_converter: mock.MagicMock, - fake_fdr_client: mock.MagicMock, -) -> None: - # Arrange - messages = _build_takeoff_messages() - adapter = ReplayInputAdapter( - video_path=synthetic_video, - tlog_path=synthetic_tlog_path, - camera_calibration=camera_calibration, - target_fc_dialect=FcKind.ARDUPILOT_PLANE, - wgs_converter=fake_wgs_converter, - fdr_client=fake_fdr_client, - pace=ReplayPace.ASAP, - manual_time_offset_ms=0, - auto_sync_config=AutoSyncConfig(), - tlog_source_factory=_factory_for(messages), - video_timestamps_factory=_video_timestamps_factory(), - ) - - # Act - try: - bundle = adapter.open() - - # Assert - assert isinstance(bundle, ReplayInputBundle) - assert isinstance(bundle.frame_source, VideoFileFrameSource) - assert isinstance(bundle.fc_adapter, TlogReplayFcAdapter) - assert isinstance(bundle.clock, TlogDerivedClock) - assert bundle.resolved_time_offset_ms == 0 - # Manual path → no auto-sync result. - assert bundle.auto_sync_result is None - finally: - adapter.close() - - -def test_ac11_pace_realtime_yields_wall_clock( - synthetic_video: Path, - synthetic_tlog_path: Path, - camera_calibration: CameraCalibration, - fake_wgs_converter: mock.MagicMock, - fake_fdr_client: mock.MagicMock, -) -> None: - # Arrange - messages = _build_takeoff_messages() - adapter = ReplayInputAdapter( - video_path=synthetic_video, - tlog_path=synthetic_tlog_path, - camera_calibration=camera_calibration, - target_fc_dialect=FcKind.ARDUPILOT_PLANE, - wgs_converter=fake_wgs_converter, - fdr_client=fake_fdr_client, - pace=ReplayPace.REALTIME, - manual_time_offset_ms=0, - auto_sync_config=AutoSyncConfig(), - tlog_source_factory=_factory_for(messages), - video_timestamps_factory=_video_timestamps_factory(), - ) - - # Act - try: - bundle = adapter.open() - - # Assert - assert isinstance(bundle.clock, WallClock) - finally: - adapter.close() - - -def test_ac11_pace_asap_yields_tlog_derived_clock( - synthetic_video: Path, - synthetic_tlog_path: Path, - camera_calibration: CameraCalibration, - fake_wgs_converter: mock.MagicMock, - fake_fdr_client: mock.MagicMock, -) -> None: - # Arrange - messages = _build_takeoff_messages() - adapter = ReplayInputAdapter( - video_path=synthetic_video, - tlog_path=synthetic_tlog_path, - camera_calibration=camera_calibration, - target_fc_dialect=FcKind.ARDUPILOT_PLANE, - wgs_converter=fake_wgs_converter, - fdr_client=fake_fdr_client, - pace=ReplayPace.ASAP, - manual_time_offset_ms=0, - auto_sync_config=AutoSyncConfig(), - tlog_source_factory=_factory_for(messages), - video_timestamps_factory=_video_timestamps_factory(), - ) - - # Act - try: - bundle = adapter.open() - - # Assert - assert isinstance(bundle.clock, TlogDerivedClock) - finally: - adapter.close() - - -# --------------------------------------------------------------------- -# AC-12 — idempotent close - - -def test_ac12_close_is_idempotent( - synthetic_video: Path, - synthetic_tlog_path: Path, - camera_calibration: CameraCalibration, - fake_wgs_converter: mock.MagicMock, - fake_fdr_client: mock.MagicMock, -) -> None: - # Arrange - messages = _build_takeoff_messages() - adapter = ReplayInputAdapter( - video_path=synthetic_video, - tlog_path=synthetic_tlog_path, - camera_calibration=camera_calibration, - target_fc_dialect=FcKind.ARDUPILOT_PLANE, - wgs_converter=fake_wgs_converter, - fdr_client=fake_fdr_client, - pace=ReplayPace.ASAP, - manual_time_offset_ms=0, - auto_sync_config=AutoSyncConfig(), - tlog_source_factory=_factory_for(messages), - video_timestamps_factory=_video_timestamps_factory(), - ) - adapter.open() - - # Act / Assert — both calls must complete without raising. - adapter.close() - adapter.close() - - -def test_close_without_open_does_not_raise( - synthetic_video: Path, - synthetic_tlog_path: Path, - camera_calibration: CameraCalibration, - fake_wgs_converter: mock.MagicMock, - fake_fdr_client: mock.MagicMock, -) -> None: - # Arrange - adapter = ReplayInputAdapter( - video_path=synthetic_video, - tlog_path=synthetic_tlog_path, - camera_calibration=camera_calibration, - target_fc_dialect=FcKind.ARDUPILOT_PLANE, - wgs_converter=fake_wgs_converter, - fdr_client=fake_fdr_client, - pace=ReplayPace.ASAP, - manual_time_offset_ms=0, - auto_sync_config=AutoSyncConfig(), - ) - - # Act / Assert - adapter.close() - - -# --------------------------------------------------------------------- -# AC-13 — missing tlog messages fail fast - - -def test_ac13_missing_imu_messages_fails_fast_before_video_read( - synthetic_video: Path, - synthetic_tlog_path: Path, - camera_calibration: CameraCalibration, - fake_wgs_converter: mock.MagicMock, - fake_fdr_client: mock.MagicMock, -) -> None: - # Arrange — tlog has only ATTITUDE; no RAW_IMU / SCALED_IMU2. - attitude_only = [ - _fake_msg("ATTITUDE", ts_s=t * 0.01, roll=0.0, pitch=0.0, yaw=0.0) - for t in range(100) - ] - adapter = ReplayInputAdapter( - video_path=synthetic_video, - tlog_path=synthetic_tlog_path, - camera_calibration=camera_calibration, - target_fc_dialect=FcKind.ARDUPILOT_PLANE, - wgs_converter=fake_wgs_converter, - fdr_client=fake_fdr_client, - pace=ReplayPace.ASAP, - manual_time_offset_ms=0, - auto_sync_config=AutoSyncConfig(), - tlog_source_factory=_factory_for(attitude_only), - ) - - # Act / Assert - with pytest.raises( - ReplayInputAdapterError, match="tlog missing required message types" - ): - adapter.open() - - -def test_ac13_missing_attitude_messages_fails_fast( - synthetic_video: Path, - synthetic_tlog_path: Path, - camera_calibration: CameraCalibration, - fake_wgs_converter: mock.MagicMock, - fake_fdr_client: mock.MagicMock, -) -> None: - # Arrange — tlog has only RAW_IMU; no ATTITUDE. - imu_only = [ - _fake_msg( - "RAW_IMU", - ts_s=t * 0.005, - xacc=0, - yacc=0, - zacc=-1000, - xgyro=0, - ygyro=0, - zgyro=0, - ) - for t in range(100) - ] - adapter = ReplayInputAdapter( - video_path=synthetic_video, - tlog_path=synthetic_tlog_path, - camera_calibration=camera_calibration, - target_fc_dialect=FcKind.ARDUPILOT_PLANE, - wgs_converter=fake_wgs_converter, - fdr_client=fake_fdr_client, - pace=ReplayPace.ASAP, - manual_time_offset_ms=0, - auto_sync_config=AutoSyncConfig(), - tlog_source_factory=_factory_for(imu_only), - ) - - # Act / Assert - with pytest.raises( - ReplayInputAdapterError, match=r"tlog missing required message types.*ATTITUDE" - ): - adapter.open() - - -# --------------------------------------------------------------------- -# AC-8 — manual override bypasses auto-detect - - -def test_ac8_manual_override_bypasses_auto_detect( - synthetic_video: Path, - synthetic_tlog_path: Path, - camera_calibration: CameraCalibration, - fake_wgs_converter: mock.MagicMock, - fake_fdr_client: mock.MagicMock, - monkeypatch: pytest.MonkeyPatch, -) -> None: - # Arrange - detect_calls: list[Any] = [] - - def _explode_if_called(*args: Any, **kwargs: Any) -> Any: - detect_calls.append((args, kwargs)) - raise AssertionError( - "auto-sync detector called even though manual_time_offset_ms was set" - ) - - monkeypatch.setattr( - "gps_denied_onboard.replay_input.tlog_video_adapter.detect_video_motion_onset", - _explode_if_called, - ) - - # Patch the take-off compute kernel referenced via the helper; the - # coordinator's manual path must skip it entirely. - monkeypatch.setattr( - "gps_denied_onboard.replay_input.auto_sync._compute_tlog_takeoff_from_samples", - _explode_if_called, - ) - - messages = _build_takeoff_messages() - adapter = ReplayInputAdapter( - video_path=synthetic_video, - tlog_path=synthetic_tlog_path, - camera_calibration=camera_calibration, - target_fc_dialect=FcKind.ARDUPILOT_PLANE, - wgs_converter=fake_wgs_converter, - fdr_client=fake_fdr_client, - pace=ReplayPace.ASAP, - manual_time_offset_ms=500, - auto_sync_config=AutoSyncConfig(), - tlog_source_factory=_factory_for(messages), - video_timestamps_factory=_video_timestamps_factory(), - ) - - # Act - try: - bundle = adapter.open() - - # Assert — detector helpers were NOT invoked. - assert detect_calls == [] - assert bundle.resolved_time_offset_ms == 500 - assert bundle.auto_sync_result is None - finally: - adapter.close() - - -# --------------------------------------------------------------------- -# AC-7 — AC-8 hard-fail raises - - -def test_ac7_ac8_validator_hard_fail_raises_on_open( - synthetic_video: Path, - synthetic_tlog_path: Path, - camera_calibration: CameraCalibration, - fake_wgs_converter: mock.MagicMock, - fake_fdr_client: mock.MagicMock, -) -> None: - # Arrange — manual offset of 60 s will push every video frame - # outside the IMU coverage window (the fake tlog only carries - # ~3.5 s of samples). - messages = _build_takeoff_messages() - adapter = ReplayInputAdapter( - video_path=synthetic_video, - tlog_path=synthetic_tlog_path, - camera_calibration=camera_calibration, - target_fc_dialect=FcKind.ARDUPILOT_PLANE, - wgs_converter=fake_wgs_converter, - fdr_client=fake_fdr_client, - pace=ReplayPace.ASAP, - manual_time_offset_ms=60_000, - auto_sync_config=AutoSyncConfig(), - tlog_source_factory=_factory_for(messages), - video_timestamps_factory=_video_timestamps_factory(), - ) - - # Act / Assert - with pytest.raises(ReplayInputAdapterError, match="auto-sync hard-fail"): - adapter.open() - - -# --------------------------------------------------------------------- -# AZ-611 — skip_auto_sync_validation bypasses the AC-9 validator - - -def test_az611_skip_auto_sync_validation_bypasses_ac9( - synthetic_video: Path, - synthetic_tlog_path: Path, - camera_calibration: CameraCalibration, - fake_wgs_converter: mock.MagicMock, - fake_fdr_client: mock.MagicMock, -) -> None: - """A manual offset that WOULD hard-fail AC-9 succeeds when the - operator explicitly opts out via ``skip_auto_sync_validation=True``. - Mirrors the AC-7 hard-fail scenario above so the bypass is the - only variable. - """ - # Arrange — same manual offset (60 s) that AC-7 above proves - # pushes every frame outside the IMU window. - messages = _build_takeoff_messages() - adapter = ReplayInputAdapter( - video_path=synthetic_video, - tlog_path=synthetic_tlog_path, - camera_calibration=camera_calibration, - target_fc_dialect=FcKind.ARDUPILOT_PLANE, - wgs_converter=fake_wgs_converter, - fdr_client=fake_fdr_client, - pace=ReplayPace.ASAP, - manual_time_offset_ms=60_000, - skip_auto_sync_validation=True, - auto_sync_config=AutoSyncConfig(), - tlog_source_factory=_factory_for(messages), - video_timestamps_factory=_video_timestamps_factory(), - ) - - # Act - try: - bundle = adapter.open() - - # Assert — the bypass let the open() complete with the manual - # offset intact, even though the validator would have rejected it. - assert bundle.resolved_time_offset_ms == 60_000 - assert bundle.auto_sync_result is None - finally: - adapter.close() - - -def test_az611_skip_auto_sync_validation_requires_manual_offset( - synthetic_video: Path, - synthetic_tlog_path: Path, - camera_calibration: CameraCalibration, - fake_wgs_converter: mock.MagicMock, - fake_fdr_client: mock.MagicMock, -) -> None: - """Constructor refuses ``skip_auto_sync_validation=True`` paired - with ``manual_time_offset_ms=None`` (silent-zero guard). - """ - # Act / Assert - with pytest.raises( - ReplayInputAdapterError, - match=r"skip_auto_sync_validation=True requires.*manual_time_offset_ms", - ): - ReplayInputAdapter( - video_path=synthetic_video, - tlog_path=synthetic_tlog_path, - camera_calibration=camera_calibration, - target_fc_dialect=FcKind.ARDUPILOT_PLANE, - wgs_converter=fake_wgs_converter, - fdr_client=fake_fdr_client, - pace=ReplayPace.ASAP, - manual_time_offset_ms=None, - skip_auto_sync_validation=True, - auto_sync_config=AutoSyncConfig(), - ) - - -# --------------------------------------------------------------------- -# AC-6 — low combined confidence WARN-and-proceed - - -def test_ac6_low_confidence_warn_and_proceed_does_not_raise( - synthetic_video: Path, - synthetic_tlog_path: Path, - camera_calibration: CameraCalibration, - fake_wgs_converter: mock.MagicMock, - fake_fdr_client: mock.MagicMock, - monkeypatch: pytest.MonkeyPatch, - caplog: pytest.LogCaptureFixture, -) -> None: - # Arrange — stub the detectors to return low-confidence results. - from gps_denied_onboard.replay_input.auto_sync import _DetectorResult - - low_conf = _DetectorResult(onset_ns=_ns(2.0), confidence=0.40) - - def _stub_take_off(*args: Any, **kwargs: Any) -> _DetectorResult: - return low_conf - - def _stub_motion_onset(*args: Any, **kwargs: Any) -> _DetectorResult: - return _DetectorResult(onset_ns=_ns(2.0), confidence=0.40) - - monkeypatch.setattr( - "gps_denied_onboard.replay_input.auto_sync._compute_tlog_takeoff_from_samples", - _stub_take_off, - ) - monkeypatch.setattr( - "gps_denied_onboard.replay_input.tlog_video_adapter.detect_video_motion_onset", - _stub_motion_onset, - ) - - messages = _build_takeoff_messages() - adapter = ReplayInputAdapter( - video_path=synthetic_video, - tlog_path=synthetic_tlog_path, - camera_calibration=camera_calibration, - target_fc_dialect=FcKind.ARDUPILOT_PLANE, - wgs_converter=fake_wgs_converter, - fdr_client=fake_fdr_client, - pace=ReplayPace.ASAP, - manual_time_offset_ms=None, - auto_sync_config=AutoSyncConfig(), - tlog_source_factory=_factory_for(messages), - video_timestamps_factory=_video_timestamps_factory(), - ) - - # Act - caplog.set_level("WARNING", logger="replay_input.tlog_video_adapter") - try: - bundle = adapter.open() - - # Assert — open() returned the bundle (didn't raise) and the - # WARN log fired. - assert bundle.auto_sync_result is not None - assert bundle.auto_sync_result.combined_confidence == pytest.approx(0.40) - warn_kinds = [ - r.kind for r in caplog.records if hasattr(r, "kind") - ] - assert "replay.auto_sync.low_confidence" in warn_kinds - finally: - adapter.close() - - -def test_ac11_resolved_offset_matches_auto_sync_result( - synthetic_video: Path, - synthetic_tlog_path: Path, - camera_calibration: CameraCalibration, - fake_wgs_converter: mock.MagicMock, - fake_fdr_client: mock.MagicMock, - monkeypatch: pytest.MonkeyPatch, -) -> None: - # Arrange — high-confidence stubs so AC-6 WARN does not fire. - from gps_denied_onboard.replay_input.auto_sync import _DetectorResult - - def _stub_take_off(*args: Any, **kwargs: Any) -> _DetectorResult: - return _DetectorResult(onset_ns=_ns(2.0), confidence=0.95) - - def _stub_motion_onset(*args: Any, **kwargs: Any) -> _DetectorResult: - return _DetectorResult(onset_ns=_ns(0.333), confidence=0.95) - - monkeypatch.setattr( - "gps_denied_onboard.replay_input.auto_sync._compute_tlog_takeoff_from_samples", - _stub_take_off, - ) - monkeypatch.setattr( - "gps_denied_onboard.replay_input.tlog_video_adapter.detect_video_motion_onset", - _stub_motion_onset, - ) - - messages = _build_takeoff_messages() - adapter = ReplayInputAdapter( - video_path=synthetic_video, - tlog_path=synthetic_tlog_path, - camera_calibration=camera_calibration, - target_fc_dialect=FcKind.ARDUPILOT_PLANE, - wgs_converter=fake_wgs_converter, - fdr_client=fake_fdr_client, - pace=ReplayPace.ASAP, - manual_time_offset_ms=None, - auto_sync_config=AutoSyncConfig(), - tlog_source_factory=_factory_for(messages), - video_timestamps_factory=_video_timestamps_factory(), - ) - - # Act - try: - bundle = adapter.open() - - # Assert - expected_offset_ms = (_ns(2.0) - _ns(0.333)) // 1_000_000 - assert bundle.resolved_time_offset_ms == expected_offset_ms - assert bundle.auto_sync_result is not None - assert bundle.auto_sync_result.offset_ms == expected_offset_ms - finally: - adapter.close() diff --git a/tests/unit/replay_input/test_az698_window_alignment.py b/tests/unit/replay_input/test_az698_window_alignment.py deleted file mode 100644 index b939d70..0000000 --- a/tests/unit/replay_input/test_az698_window_alignment.py +++ /dev/null @@ -1,935 +0,0 @@ -"""AZ-698 — tlog trim + mid-flight cross-correlation alignment tests. - -Covers AC-1..AC-4 of ``_docs/02_tasks/todo/AZ-698_tlog_trim_midflight_alignment.md``. -AC-5 (end-to-end CLI smoke) is exercised by the existing replay e2e -suite in ``tests/e2e/replay/`` and skipped here when its prerequisites -(ffmpeg-capable cv2 build + real ``derkachi.tlog``) are absent. - -Style: every test follows the Arrange / Act / Assert pattern. -""" - -from __future__ import annotations - -import math -from pathlib import Path -from types import SimpleNamespace -from typing import Any -from unittest.mock import MagicMock - -import pytest - -from gps_denied_onboard._types.fc import ( - AttitudeSample, - FcKind, - FcTelemetryFrame, - FlightStateSignal, - GpsHealth, - ImuTelemetrySample, - TelemetryKind, -) -from gps_denied_onboard.clock import Clock -from gps_denied_onboard.components.c8_fc_adapter.tlog_replay_adapter import ( - ReplayPace, - TlogReplayFcAdapter, -) -from gps_denied_onboard.replay_input.auto_sync import ( - _align_via_cross_correlation, - _resample_uniform, - _segment_flights_from_imu_energy, - compute_offset, - detect_video_motion_onset, - find_aligned_window, - validate_offset_or_fail, -) -from gps_denied_onboard.replay_input.errors import ReplayInputAdapterError -from gps_denied_onboard.replay_input.interface import ( - AlignedWindow, - AutoSyncConfig, -) - - -# --------------------------------------------------------------------- -# Synthetic-stream helpers - - -def _ns(seconds: float) -> int: - return int(seconds * 1_000_000_000) - - -def _build_motion_burst_stream( - *, - start_s: float, - end_s: float, - hz: float, - burst_at_s: float, - burst_amplitude: float, - burst_duration_s: float = 1.0, - baseline_amplitude: float = 0.0, -) -> tuple[tuple[int, float], ...]: - """Build a synthetic ``(ts_ns, magnitude)`` stream. - - Constant at ``baseline_amplitude`` outside a single rectangular - burst (``burst_amplitude`` for ``burst_duration_s`` starting at - ``burst_at_s``). Used so cross-correlation has a clear peak that - tests can assert exact-index for. - """ - out: list[tuple[int, float]] = [] - period_s = 1.0 / hz - t = start_s - burst_end_s = burst_at_s + burst_duration_s - while t < end_s: - if burst_at_s <= t < burst_end_s: - out.append((_ns(t), burst_amplitude)) - else: - out.append((_ns(t), baseline_amplitude)) - t += period_s - return tuple(out) - - -def _build_double_burst_stream( - *, - start_s: float, - end_s: float, - hz: float, - burst_a_at_s: float, - burst_b_at_s: float, - burst_amplitude: float, - burst_duration_s: float = 1.0, - baseline_amplitude: float = 0.0, -) -> tuple[tuple[int, float], ...]: - """Two-burst variant to constrain cross-correlation more tightly.""" - out: list[tuple[int, float]] = [] - period_s = 1.0 / hz - t = start_s - while t < end_s: - if burst_a_at_s <= t < burst_a_at_s + burst_duration_s: - out.append((_ns(t), burst_amplitude)) - elif burst_b_at_s <= t < burst_b_at_s + burst_duration_s: - out.append((_ns(t), burst_amplitude)) - else: - out.append((_ns(t), baseline_amplitude)) - t += period_s - return tuple(out) - - -def _build_multi_flight_stream( - *, - flights: tuple[tuple[float, float], ...], - end_s: float, - hz: float, - in_flight_amplitude: float = 0.3, - ground_amplitude: float = 0.02, -) -> tuple[tuple[int, float], ...]: - """Build a multi-flight IMU energy stream. - - ``flights`` is a tuple of ``(start_s, end_s)`` per flight. Between - flights the energy is ``ground_amplitude``; inside each flight it - is ``in_flight_amplitude``. Used by the multi-flight segmentation - tests to mimic a real "3 takeoffs at the same field" tlog. - """ - out: list[tuple[int, float]] = [] - period_s = 1.0 / hz - t = 0.0 - while t < end_s: - in_flight = any(s <= t < e for s, e in flights) - out.append((_ns(t), in_flight_amplitude if in_flight else ground_amplitude)) - t += period_s - return tuple(out) - - -# --------------------------------------------------------------------- -# AC-1: takeoff-aligned regression — find_aligned_window must produce -# the same offset (within ± 50 ms) as the AZ-405 compute_offset path -# when the video covers the take-off. - - -def test_ac1_takeoff_aligned_offset_matches_az405_within_50ms() -> None: - # Arrange: 30 s tlog with a take-off-shaped IMU energy burst at - # t = 2 s; 5 s video with the same-shaped optical-flow burst at - # video_t = 0.5 s (motion onset half a second into the clip). - # AZ-405 would resolve offset_ms = (tlog_takeoff_ns - - # video_motion_onset_ns) // 1e6 ≈ 1.5 s. The AZ-698 aligner - # must agree within 50 ms. - tlog_energy = _build_motion_burst_stream( - start_s=0.0, - end_s=30.0, - hz=10.0, - burst_at_s=2.0, - burst_amplitude=1.2, - burst_duration_s=1.5, - baseline_amplitude=0.0, - ) - flow_samples = _build_motion_burst_stream( - start_s=0.0, - end_s=5.0, - hz=10.0, - burst_at_s=0.5, - burst_amplitude=2.0, - burst_duration_s=1.5, - baseline_amplitude=0.0, - ) - config = AutoSyncConfig() - expected_offset_ms = _ns(2.0 - 0.5) // 1_000_000 - - # Act - window = _align_via_cross_correlation( - tlog_energy=tlog_energy, - flow_samples=flow_samples, - config=config, - target_fc_dialect=FcKind.ARDUPILOT_PLANE, - tlog_path=Path("/nonexistent.tlog"), - tlog_source_factory=None, - ) - - # Assert - assert window.fallback_used is False, "expected primary cross-corr path, not fallback" - assert abs(window.offset_ms - expected_offset_ms) <= 50, ( - f"AZ-698 offset {window.offset_ms} ms outside ±50 ms of AZ-405-equivalent " - f"{expected_offset_ms} ms" - ) - - -# --------------------------------------------------------------------- -# AC-2: mid-flight alignment — tlog 0–30 s with motion burst at t=15 s, -# video 0–5 s with motion burst at video_t=1 s. Expected: -# tlog_start_ns ≈ (15 - 1) s = 14 s (where video t=0 lands) -# offset_ms ≈ 14 000 - - -def test_ac2_mid_flight_alignment_locates_correct_window() -> None: - # Arrange: distinctive double-burst pattern in both streams so - # cross-correlation lock is unambiguous (single-burst patterns - # can lock on the wrong baseline at edge bins). - tlog_energy = _build_double_burst_stream( - start_s=0.0, - end_s=30.0, - hz=10.0, - burst_a_at_s=15.0, - burst_b_at_s=18.0, - burst_amplitude=1.5, - burst_duration_s=0.8, - baseline_amplitude=0.0, - ) - flow_samples = _build_double_burst_stream( - start_s=0.0, - end_s=5.0, - hz=10.0, - burst_a_at_s=1.0, - burst_b_at_s=4.0, - burst_amplitude=2.5, - burst_duration_s=0.8, - baseline_amplitude=0.0, - ) - config = AutoSyncConfig() - period_ns = _ns(1.0 / config.alignment_resample_hz) - - # Act - window = _align_via_cross_correlation( - tlog_energy=tlog_energy, - flow_samples=flow_samples, - config=config, - target_fc_dialect=FcKind.ARDUPILOT_PLANE, - tlog_path=Path("/nonexistent.tlog"), - tlog_source_factory=None, - ) - - # Assert - assert window.fallback_used is False - # video burst A at t=1.0s aligns with tlog burst A at t=15.0s - # → video t=0 aligns with tlog t=14.0s within ±1 resample period. - assert abs(window.tlog_start_ns - _ns(14.0)) <= period_ns, ( - f"tlog_start_ns={window.tlog_start_ns} not within one resample period " - f"({period_ns} ns) of the expected 14 s" - ) - assert abs(window.offset_ms - 14_000) <= 100 - assert window.tlog_end_ns > window.tlog_start_ns - - -# --------------------------------------------------------------------- -# AC-3: TlogReplayFcAdapter seek — messages whose raw _timestamp is -# below tlog_start_ns must NOT reach subscribers. - - -def _make_fake_msg(*, type_name: str, raw_ts_s: float, **fields: Any) -> SimpleNamespace: - """Build a pymavlink-shaped fake message for replay-adapter tests.""" - msg = SimpleNamespace(_timestamp=raw_ts_s, **fields) - - def _get_type() -> str: - return type_name - - msg.get_type = _get_type # type: ignore[attr-defined] - return msg - - -def _build_replay_adapter_with_seek( - *, - tlog_start_ns: int | None, - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> tuple[TlogReplayFcAdapter, list[FcTelemetryFrame]]: - """Construct a TlogReplayFcAdapter wired to deterministic fakes.""" - monkeypatch.setenv("BUILD_TLOG_REPLAY_ADAPTER", "ON") - tlog_file = tmp_path / "fake.tlog" - tlog_file.write_bytes(b"\x00") - - received: list[FcTelemetryFrame] = [] - - fake_clock = MagicMock(spec=Clock) - fake_clock.monotonic_ns.return_value = 0 - fake_clock.sleep_until_ns.return_value = None - fake_wgs = MagicMock() - fake_fdr = MagicMock() - fake_fdr.enqueue.return_value = None - - adapter = TlogReplayFcAdapter( - tlog_path=tlog_file, - target_fc_dialect=FcKind.ARDUPILOT_PLANE, - clock=fake_clock, - wgs_converter=fake_wgs, - fdr_client=fake_fdr, - time_offset_ms=0, - tlog_start_ns=tlog_start_ns, - pace=ReplayPace.ASAP, - ) - adapter.subscribe_telemetry(received.append) - return adapter, received - - -def test_ac3_adapter_seek_skips_pre_window_messages( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - # Arrange: adapter opened with tlog_start_ns = 100 s; feed 5 - # IMU messages, two below 100 s (must be skipped) and three at - # or above 100 s (must reach the subscriber). - adapter, received = _build_replay_adapter_with_seek( - tlog_start_ns=_ns(100.0), - tmp_path=tmp_path, - monkeypatch=monkeypatch, - ) - pre_window = [ - _make_fake_msg( - type_name="RAW_IMU", - raw_ts_s=t, - time_usec=int(t * 1e6), - xacc=0, - yacc=0, - zacc=1000, - xgyro=0, - ygyro=0, - zgyro=0, - ) - for t in (50.0, 99.999) - ] - in_window = [ - _make_fake_msg( - type_name="RAW_IMU", - raw_ts_s=t, - time_usec=int(t * 1e6), - xacc=0, - yacc=0, - zacc=1000, - xgyro=0, - ygyro=0, - zgyro=0, - ) - for t in (100.0, 101.5, 110.0) - ] - - # Act - for msg in pre_window + in_window: - adapter.feed_one_message(msg) - - # Assert - assert len(received) == 3, "expected three in-window IMU frames" - assert all( - frame.kind == TelemetryKind.IMU_SAMPLE for frame in received - ), "non-IMU frame leaked through" - # ``received_at`` is the raw _timestamp (no offset). Every - # delivered frame's raw timestamp must be ≥ 100 s. - for frame in received: - assert frame.received_at >= _ns(100.0), ( - f"frame with received_at={frame.received_at} ns leaked below the seek bound" - ) - - -def test_ac3_adapter_default_no_seek_passes_every_message( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - # Arrange: tlog_start_ns=None (default) → no seek; every message reaches subscribers. - adapter, received = _build_replay_adapter_with_seek( - tlog_start_ns=None, - tmp_path=tmp_path, - monkeypatch=monkeypatch, - ) - messages = [ - _make_fake_msg( - type_name="RAW_IMU", - raw_ts_s=t, - time_usec=int(t * 1e6), - xacc=0, - yacc=0, - zacc=1000, - xgyro=0, - ygyro=0, - zgyro=0, - ) - for t in (10.0, 50.0, 100.0) - ] - - # Act - for msg in messages: - adapter.feed_one_message(msg) - - # Assert - assert len(received) == 3, "default (no seek) must pass every IMU message" - - -# --------------------------------------------------------------------- -# AC-4: AC-9 frame-window validator passes for both scenarios. - - -def test_ac4_validator_passes_for_takeoff_aligned_offset() -> None: - # Arrange: video frames at 30 fps for 5 s; tlog IMU at 100 Hz - # for 30 s covering both pre-take-off and post; offset places - # video t=0 at tlog t=2 s. - video_ts = [int(t * 1_000_000_000) for t in (i / 30.0 for i in range(150))] - tlog_ts = [int(t * 1_000_000_000) for t in (i / 100.0 for i in range(3000))] - offset_ms = 2_000 - - # Act - result = validate_offset_or_fail( - offset_ms, - tlog_imu_timestamps_ns=tlog_ts, - video_frame_timestamps_ns=video_ts, - threshold_pct=95.0, - window_ms=100, - ) - - # Assert - assert result == 0 - - -def test_ac4_validator_passes_for_mid_flight_offset() -> None: - # Arrange: video covers 0–5 s; tlog covers 0–60 s; mid-flight - # offset places video t=0 at tlog t=30 s. Every video frame - # still has an IMU sample within ±100 ms of (vts + 30s) because - # the tlog covers that range densely. - video_ts = [int(t * 1_000_000_000) for t in (i / 30.0 for i in range(150))] - tlog_ts = [int(t * 1_000_000_000) for t in (i / 100.0 for i in range(6000))] - offset_ms = 30_000 - - # Act - result = validate_offset_or_fail( - offset_ms, - tlog_imu_timestamps_ns=tlog_ts, - video_frame_timestamps_ns=video_ts, - threshold_pct=95.0, - window_ms=100, - ) - - # Assert - assert result == 0 - - -# --------------------------------------------------------------------- -# Resampler unit tests — pin the binning semantics so future -# regressions are caught explicitly. - - -def test_resample_uniform_averages_within_bin() -> None: - # Arrange: 3 samples in the first 100 ms bin (values 1, 2, 3 → - # mean 2.0), 1 sample in the second bin (value 4 → 4.0). - samples = ( - (_ns(0.00), 1.0), - (_ns(0.03), 2.0), - (_ns(0.06), 3.0), - (_ns(0.15), 4.0), - ) - period_ns = _ns(0.10) - - # Act - resampled = _resample_uniform(samples, period_ns, origin_ns=0) - - # Assert - assert math.isclose(resampled[0], 2.0) - assert math.isclose(resampled[1], 4.0) - - -def test_resample_uniform_drops_trailing_empty_bins() -> None: - # Arrange: one sample in bin 0, then a 1 s gap before the next sample. - # The samples between get carry-forward of the previous bin's value; - # trailing zeros only appear AFTER the last sample. - samples = ( - (_ns(0.0), 5.0), - (_ns(1.05), 7.0), - ) - period_ns = _ns(0.1) - - # Act - resampled = _resample_uniform(samples, period_ns, origin_ns=0) - - # Assert - # The first bin is 5.0, bins 1..9 carry-forward to 5.0 (the previous - # bin's value), and bin 10 captures the t=1.05 s sample as 7.0. - assert resampled[0] == 5.0 - assert resampled[-1] == 7.0 - # No trailing-zero tail. - assert all(v != 0.0 for v in resampled) - - -# --------------------------------------------------------------------- -# Fallback path — when cross-correlation confidence is below the -# threshold, find_aligned_window must fall back to the head-takeoff -# detector and set fallback_used=True. - - -def test_low_confidence_triggers_takeoff_fallback( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - # Arrange: flat-line tlog (no motion) → cross-correlation has no - # meaningful peak. The fallback path opens the real tlog via - # detect_tlog_takeoff which needs a working tlog file. We bypass - # the actual fallback work by raising the threshold to 1.1 (no - # peak can clear it) and stubbing the takeoff detector. - monkeypatch.setattr( - "gps_denied_onboard.replay_input.auto_sync.detect_tlog_takeoff", - lambda path, dialect, config, *, source_factory=None: SimpleNamespace( - onset_ns=_ns(7.0), confidence=0.9 - ), - ) - flat_tlog = tuple( - (_ns(t / 10.0), 0.0) for t in range(0, 100) - ) - flat_flow = tuple( - (_ns(t / 10.0), 0.0) for t in range(0, 20) - ) - config = AutoSyncConfig(alignment_low_confidence_threshold=0.5) - tlog_path = tmp_path / "fake.tlog" - tlog_path.write_bytes(b"\x00") - - # Act - window = _align_via_cross_correlation( - tlog_energy=flat_tlog, - flow_samples=flat_flow, - config=config, - target_fc_dialect=FcKind.ARDUPILOT_PLANE, - tlog_path=tlog_path, - tlog_source_factory=None, - ) - - # Assert - assert window.fallback_used is True - assert window.tlog_start_ns == _ns(7.0), "fallback did not pick up the stubbed takeoff onset" - - -# --------------------------------------------------------------------- -# Guard: video stream longer than tlog stream → reject (auto-trim -# requires the video to be a SLICE of a longer tlog). - - -def test_video_longer_than_tlog_raises() -> None: - # Arrange - tlog_energy = tuple((_ns(t / 10.0), 0.5) for t in range(10)) - flow_samples = tuple((_ns(t / 10.0), 0.5) for t in range(50)) - config = AutoSyncConfig() - - # Act + Assert - with pytest.raises(ReplayInputAdapterError, match="video flow stream is longer"): - _align_via_cross_correlation( - tlog_energy=tlog_energy, - flow_samples=flow_samples, - config=config, - target_fc_dialect=FcKind.ARDUPILOT_PLANE, - tlog_path=Path("/nonexistent.tlog"), - tlog_source_factory=None, - ) - - -# --------------------------------------------------------------------- -# AlignedWindow DTO is frozen + slotted. - - -def test_aligned_window_is_frozen() -> None: - # Arrange - w = AlignedWindow( - tlog_start_ns=1, - tlog_end_ns=2, - offset_ms=0, - confidence=0.9, - fallback_used=False, - ) - - # Act + Assert - with pytest.raises((AttributeError, TypeError)): - w.confidence = 0.5 # type: ignore[misc] - - -# --------------------------------------------------------------------- -# AC-5: end-to-end CLI smoke — skipped here because it requires -# ffmpeg-capable cv2 + the real ``derkachi.tlog``/``.mp4`` binaries. -# The actual CLI run is covered by ``tests/e2e/replay/`` when those -# prerequisites are available. - - -def _replay_inputs_present() -> bool: - fixtures = Path("_docs/00_problem/input_data/flight_derkachi") - return (fixtures / "derkachi.tlog").is_file() and (fixtures / "derkachi.mp4").is_file() - - -@pytest.mark.skipif( - not _replay_inputs_present(), - reason="AC-5 e2e smoke requires _docs/00_problem/input_data/flight_derkachi/derkachi.{tlog,mp4}", -) -def test_ac5_cli_auto_trim_smoke_uses_find_aligned_window( - monkeypatch: pytest.MonkeyPatch, -) -> None: - # Arrange: this test pins the wiring contract — the `--auto-trim` - # CLI flag must reach ReplayConfig.auto_trim. A full CLI run - # requires the runtime root which is exercised by the e2e suite. - from gps_denied_onboard.cli.replay import _build_replay_config - from gps_denied_onboard.config.schema import Config, ReplayConfig - - args = SimpleNamespace( - video=Path("/tmp/v.mp4"), - tlog=Path("/tmp/t.tlog"), - output=Path("/tmp/o.jsonl"), - camera_calibration=Path("/tmp/c.json"), - config_path=Path("/tmp/c.yaml"), - mavlink_signing_key=Path("/tmp/k.bin"), - pace="asap", - time_offset_ms=None, - skip_auto_sync_validation=False, - auto_trim=True, - ) - key_file = Path("/tmp/k.bin") - key_file.write_bytes(b"\x00" * 32) - base = Config() - base = type(base)( - mode=base.mode, - log=base.log, - fdr=base.fdr, - runtime=base.runtime, - fc=base.fc, - gcs=base.gcs, - replay=ReplayConfig(), - components=base.components, - ) - - # Act - new_config = _build_replay_config(args, base) - - # Assert - assert new_config.replay.auto_trim is True - assert new_config.replay.time_offset_ms is None - - -# Cross-reference: the existing AZ-405 fixture still passes (no regression). - - -def test_autosync_decision_offset_is_within_ac9_window_for_baseline() -> None: - # Arrange: a takeoff-shaped tlog detector result + a video - # motion-onset detector result. compute_offset returns the - # AZ-405 offset_ms which is the AZ-698 baseline AC-1 references. - from gps_denied_onboard.replay_input.auto_sync import _DetectorResult - - tlog_result = _DetectorResult(onset_ns=_ns(2.5), confidence=0.9) - video_result = _DetectorResult(onset_ns=_ns(0.5), confidence=0.85) - - # Act - decision = compute_offset(tlog_result, video_result) - - # Assert - assert decision.offset_ms == 2_000 - assert decision.combined_confidence == pytest.approx(0.85, abs=1e-6) - - -# --------------------------------------------------------------------- -# Multi-flight tlog handling (user constraint: "if 1 flight take it, if -# multiple take the last"). The pre-NCC segmenter is the gatekeeper. - - -def test_segmenter_one_flight_returns_single_span() -> None: - # Arrange: 120 s tlog with a single flight from t=10..100 s. - samples = _build_multi_flight_stream( - flights=((10.0, 100.0),), - end_s=120.0, - hz=10.0, - ) - - # Act - segments = _segment_flights_from_imu_energy( - samples, - motion_threshold=0.1, - min_flight_duration_ns=_ns(30.0), - max_internal_gap_ns=_ns(5.0), - ) - - # Assert - assert len(segments) == 1 - seg_start_ns, seg_end_ns = segments[0] - assert abs(seg_start_ns - _ns(10.0)) <= _ns(0.2) - assert abs(seg_end_ns - _ns(100.0)) <= _ns(0.2) - - -def test_segmenter_three_flights_returns_three_spans_in_order() -> None: - # Arrange: 360 s tlog with three takeoffs (60 s flights with 30 s - # ground gaps between them) — mimics the Derkachi scenario the - # user flagged: one tlog, three sorties, video covers only the - # last one. - flights_def = ((10.0, 70.0), (100.0, 160.0), (190.0, 250.0)) - samples = _build_multi_flight_stream( - flights=flights_def, - end_s=300.0, - hz=10.0, - ) - - # Act - segments = _segment_flights_from_imu_energy( - samples, - motion_threshold=0.1, - min_flight_duration_ns=_ns(30.0), - max_internal_gap_ns=_ns(5.0), - ) - - # Assert - assert len(segments) == 3 - for (actual_start, actual_end), (want_start, want_end) in zip( - segments, flights_def - ): - assert abs(actual_start - _ns(want_start)) <= _ns(0.2) - assert abs(actual_end - _ns(want_end)) <= _ns(0.2) - - -def test_segmenter_drops_ground_blip_below_min_duration() -> None: - # Arrange: a 5 s ground manoeuvre (engine test) followed by a - # real 60 s flight. With min_flight_duration_ns=30 s the blip - # must be discarded, leaving only the real flight. - samples = _build_multi_flight_stream( - flights=((5.0, 10.0), (50.0, 110.0)), - end_s=120.0, - hz=10.0, - ) - - # Act - segments = _segment_flights_from_imu_energy( - samples, - motion_threshold=0.1, - min_flight_duration_ns=_ns(30.0), - max_internal_gap_ns=_ns(5.0), - ) - - # Assert - assert len(segments) == 1 - seg_start_ns, _seg_end_ns = segments[0] - assert abs(seg_start_ns - _ns(50.0)) <= _ns(0.2) - - -def test_segmenter_keeps_brief_cruise_lull_inside_flight() -> None: - # Arrange: one flight with a 3 s cruise lull mid-way. The lull is - # below max_internal_gap_ns=5 s, so the segmenter must keep the - # whole flight as a single segment. - samples = _build_multi_flight_stream( - flights=((10.0, 45.0), (48.0, 100.0)), - end_s=120.0, - hz=10.0, - ) - - # Act - segments = _segment_flights_from_imu_energy( - samples, - motion_threshold=0.1, - min_flight_duration_ns=_ns(30.0), - max_internal_gap_ns=_ns(5.0), - ) - - # Assert - assert len(segments) == 1 - seg_start_ns, seg_end_ns = segments[0] - assert abs(seg_start_ns - _ns(10.0)) <= _ns(0.2) - assert abs(seg_end_ns - _ns(100.0)) <= _ns(0.2) - - -def test_find_aligned_window_picks_last_flight_for_multi_flight_tlog( - tmp_path: Path, -) -> None: - # Arrange: a 300 s tlog with three sorties (10..70, 100..160, - # 190..250 s). The video covers only the LAST sortie — flow - # samples at video-clock 0..30 s with a motion burst at - # video t=5 s that, on the tlog timeline, corresponds to - # tlog t=200 s (5 s into flight 3 which starts at 190 s). - flights_def = ((10.0, 70.0), (100.0, 160.0), (190.0, 250.0)) - tlog_energy = _build_multi_flight_stream( - flights=flights_def, - end_s=260.0, - hz=10.0, - ) - flow_samples = _build_motion_burst_stream( - start_s=0.0, - end_s=30.0, - hz=10.0, - burst_at_s=5.0, - burst_amplitude=2.0, - burst_duration_s=3.0, - baseline_amplitude=0.05, - ) - config = AutoSyncConfig( - alignment_segment_min_flight_duration_seconds=30.0, - alignment_segment_max_internal_gap_seconds=5.0, - ) - - # Inject the pre-loaded IMU energy by monkey-patching the loader - # used inside find_aligned_window; the function reads a tlog via - # pymavlink, but for the unit-level invariant we want to assert - # the segment selection — not the binary parser. - import gps_denied_onboard.replay_input.auto_sync as auto_sync_mod - - fake_tlog = tmp_path / "multi_flight.tlog" - fake_tlog.write_bytes(b"\x00") - fake_video = tmp_path / "video.mp4" - fake_video.write_bytes(b"\x00") - - def _fake_loader( - path: Path, - *, - max_messages: int, - source_factory: Any, - ) -> tuple[tuple[int, float], ...]: - return tlog_energy - - def _fake_frames( - path: Path, scan_seconds: float, - ) -> "list[tuple[int, Any]]": - import numpy as np - - rng = np.random.default_rng(42) - frames: list[tuple[int, Any]] = [] - prev_offset = np.zeros((16, 16, 3), dtype=np.int16) - for ts_ns, mag in flow_samples: - # 3-channel BGR (cvtColor BGR→GRAY needs ≥ 3 channels). - # During a burst we shift pixels — that motion is what - # Farneback flow magnitudes pick up. - base = rng.integers(0, 30, size=(16, 16, 3), dtype=np.int16) - shift_px = int(mag * 4) - if shift_px > 0: - base = np.roll(base, shift=shift_px, axis=0) - frame = np.clip(base + prev_offset, 0, 255).astype(np.uint8) - frames.append((ts_ns, frame)) - prev_offset = np.zeros_like(prev_offset) - return frames - - monkeypatch = pytest.MonkeyPatch() - try: - monkeypatch.setattr( - auto_sync_mod, "_load_tlog_imu_energy_stream", _fake_loader - ) - - # Act - window = find_aligned_window( - fake_tlog, - fake_video, - config, - target_fc_dialect=FcKind.ARDUPILOT_PLANE, - video_frames_factory=_fake_frames, - ) - finally: - monkeypatch.undo() - - # Assert: the aligner MUST select FLIGHT 3 (190..250 s), NOT - # flight 1 (10..70 s). Whether NCC locks on or the fallback - # path fires, the resulting window must lie inside flight 3 — - # that's the user-visible contract ("take the last flight"). - flight3_start_ns, flight3_end_ns = (_ns(190.0), _ns(250.0)) - assert window.flight_count_detected == 3 - assert window.selected_flight_index == 2 - assert flight3_start_ns <= window.tlog_start_ns <= flight3_end_ns - # Sanity: did NOT lock onto flight 1 or 2. - assert window.tlog_start_ns > _ns(160.0) - - -def test_align_via_cross_correlation_locks_onto_burst_inside_last_segment() -> None: - # Arrange: pre-segmented tlog energy restricted to flight 3 - # (mimicking what find_aligned_window passes after segmentation), - # plus a flow stream whose burst pattern matches a specific - # offset inside that segment. This directly exercises the NCC - # path with the inputs the post-segmentation aligner sees. - last_segment_tlog = _build_motion_burst_stream( - start_s=190.0, - end_s=250.0, - hz=10.0, - burst_at_s=210.0, - burst_amplitude=1.5, - burst_duration_s=5.0, - baseline_amplitude=0.05, - ) - flow_samples = _build_motion_burst_stream( - start_s=0.0, - end_s=30.0, - hz=10.0, - burst_at_s=10.0, - burst_amplitude=2.0, - burst_duration_s=5.0, - baseline_amplitude=0.05, - ) - config = AutoSyncConfig() - - # Act - window = _align_via_cross_correlation( - tlog_energy=last_segment_tlog, - flow_samples=flow_samples, - config=config, - target_fc_dialect=FcKind.ARDUPILOT_PLANE, - tlog_path=Path("/nonexistent.tlog"), - tlog_source_factory=None, - flight_count_detected=3, - selected_flight_index=2, - ) - - # Assert: NCC must lock on (high confidence, no fallback). The - # tlog_start_ns must be the start of the matched 30 s window — - # video burst at video_t=10 s lines up with tlog_t=210 s ⇒ - # tlog_start_ns ≈ 200 s (210 s − 10 s). - assert not window.fallback_used - assert window.confidence > 0.6 - assert window.flight_count_detected == 3 - assert window.selected_flight_index == 2 - assert abs(window.tlog_start_ns - _ns(200.0)) <= _ns(0.2) - - -def test_find_aligned_window_uses_only_segment_for_segmented_tlog_fallback( - tmp_path: Path, -) -> None: - # Arrange: a 3-flight tlog where the video flow is flat (no - # structure for NCC to lock onto). NCC must produce confidence - # ~ 0; the fallback path must use the LAST segment start, NOT - # the head-takeoff detector (which would lock onto flight 1). - flights_def = ((10.0, 70.0), (100.0, 160.0), (190.0, 250.0)) - tlog_energy = _build_multi_flight_stream( - flights=flights_def, - end_s=260.0, - hz=10.0, - ) - flat_flow = tuple((_ns(t / 10.0), 0.5) for t in range(0, 50)) - config = AutoSyncConfig() - - # Act - window = _align_via_cross_correlation( - tlog_energy=tuple( - (ts, e) for ts, e in tlog_energy - if _ns(190.0) <= ts <= _ns(250.0) - ), - flow_samples=flat_flow, - config=config, - target_fc_dialect=FcKind.ARDUPILOT_PLANE, - tlog_path=tmp_path / "x.tlog", - tlog_source_factory=None, - flight_count_detected=3, - selected_flight_index=2, - ) - - # Assert - assert window.fallback_used is True - assert window.flight_count_detected == 3 - assert window.selected_flight_index == 2 - # The fallback must use flight 3's start, not flight 1's takeoff. - assert window.tlog_start_ns >= _ns(190.0) - assert window.tlog_start_ns <= _ns(250.0) diff --git a/tests/unit/replay_input/test_az895_auto_sync_deprecated_stub.py b/tests/unit/replay_input/test_az895_auto_sync_deprecated_stub.py new file mode 100644 index 0000000..d3e624d --- /dev/null +++ b/tests/unit/replay_input/test_az895_auto_sync_deprecated_stub.py @@ -0,0 +1,38 @@ +"""AZ-895: auto-sync surface deprecated; every public callable raises. + +The full detector test suite was deleted alongside the detectors +themselves; this single test pins the deprecation contract: a clean +:class:`ReplayInputAdapterError` with the documented message, not a +silent import failure or vague RuntimeError. AZ-908 will remove +``auto_sync.py`` entirely and this test along with it. +""" + +from __future__ import annotations + +import pytest + +from gps_denied_onboard.replay_input import ReplayInputAdapterError +from gps_denied_onboard.replay_input import auto_sync + + +_DEPRECATED_CALLABLES = ( + "detect_tlog_takeoff", + "detect_video_motion_onset", + "compute_offset", + "validate_offset_or_fail", + "find_aligned_window", +) + + +@pytest.mark.parametrize("name", _DEPRECATED_CALLABLES) +def test_az895_public_callable_raises_with_documented_message(name: str) -> None: + """AC-1: every public callable raises the documented deprecation error.""" + # Arrange + fn = getattr(auto_sync, name) + + # Act / Assert + with pytest.raises( + ReplayInputAdapterError, + match="auto-sync removed; supply --imu CSV instead", + ): + fn() diff --git a/tests/unit/test_az401_compose_root_replay.py b/tests/unit/test_az401_compose_root_replay.py index ce83336..0e9ec8b 100644 --- a/tests/unit/test_az401_compose_root_replay.py +++ b/tests/unit/test_az401_compose_root_replay.py @@ -144,9 +144,14 @@ def _make_replay_config( if calib_path is None else RuntimeConfig(camera_calibration_path=str(calib_path)) ) + # AZ-895: imu_csv_path is required by _validate_replay_paths; the + # auto-sync surface that previously accepted (video, tlog) alone + # was deprecated. tlog_path stays set so tests covering the legacy + # config field continue to round-trip it. replay = ReplayConfig( video_path="/dev/null/fake.mp4", tlog_path="/dev/null/fake.tlog", + imu_csv_path="/dev/null/fake_imu.csv", output_path=output_path, pace=pace, time_offset_ms=time_offset_ms, @@ -634,13 +639,11 @@ def test_replay_branch_rejects_empty_video_path( build_replay_components(config) -def test_replay_branch_rejects_both_inputs_empty( +def test_replay_branch_rejects_missing_imu_csv_path( _airborne_replay_env: Path, ) -> None: - # AZ-894: the validation gate now accepts either imu_csv_path - # (canonical) or tlog_path (legacy) — rejecting only when both - # are empty. Keeping the historical name pattern (test_*_rejects_*) - # for grep parity but renamed to reflect the new semantics. + # AZ-895: imu_csv_path is required; the legacy tlog-only branch was + # removed. The (video, CSV) bundle is the only supported composition. # Arrange runtime_cfg = RuntimeConfig(camera_calibration_path=str(_airborne_replay_env)) @@ -648,7 +651,7 @@ def test_replay_branch_rejects_both_inputs_empty( runtime=runtime_cfg, replay=ReplayConfig( video_path="/dev/null/fake.mp4", - tlog_path="", + tlog_path="/dev/null/fake.tlog", imu_csv_path="", output_path="/tmp/out.jsonl", pace="asap", @@ -678,38 +681,6 @@ def test_replay_branch_rejects_unknown_pace_after_init( build_replay_components(config) -def test_replay_branch_loads_camera_calibration_from_runtime_path( - _airborne_replay_env: Path, -) -> None: - """The branch reads the SAME calibration JSON the live binary uses.""" - # Arrange - config = _make_replay_config(calib_path=_airborne_replay_env) - - # Act — run far enough to populate the bundle without hitting the - # real video / tlog readers. We do that by injecting a stub - # ``replay_input_adapter_factory`` that returns a fake adapter - # whose ``open()`` produces a trivial bundle. - bundle = _make_replay_bundle() - - class _StubAdapter: - def __init__(self, **_kwargs: Any) -> None: - pass - - def open(self) -> ReplayInputBundle: - return bundle - - components, order = build_replay_components( - config, - replay_input_adapter_factory=lambda **_kwargs: _StubAdapter(), - sink_factory=lambda *_args: mock.MagicMock(spec=JsonlReplaySink), - ) - - # Assert - assert order == REPLAY_COMPONENT_KEYS - assert components["frame_source"] is bundle.frame_source - assert components["fc_adapter"] is bundle.fc_adapter - - # ---------------------------------------------------------------------- # Smoke diff --git a/tests/unit/test_az402_replay_cli.py b/tests/unit/test_az402_replay_cli.py index 612a5ca..5584fef 100644 --- a/tests/unit/test_az402_replay_cli.py +++ b/tests/unit/test_az402_replay_cli.py @@ -114,6 +114,10 @@ def _argv(files: dict[str, Path], **overrides: Any) -> list[str]: argv: list[str] = [] for k, v in base.items(): argv.extend([k, v]) + if overrides.get("skip_auto_sync"): + argv.append("--skip-auto-sync") + if overrides.get("auto_trim"): + argv.append("--auto-trim") return argv @@ -192,12 +196,15 @@ def test_ac3_pace_realtime( # ---------------------------------------------------------------------- -# AC-4: --time-offset-ms forwarded (None when absent) +# AC-4: --time-offset-ms deprecated (AZ-895) — ignored + warning emitted -def test_ac4_time_offset_forwarded( - _required_files: dict[str, Path], _airborne_env: None +def test_ac4_time_offset_ignored_after_az895( + _required_files: dict[str, Path], + _airborne_env: None, + capsys: pytest.CaptureFixture[str], ) -> None: + """AZ-895: --time-offset-ms is deprecated; value is ignored, warning emitted.""" # Arrange captured: dict[str, Config] = {} @@ -206,13 +213,16 @@ def test_ac4_time_offset_forwarded( return 0 # Act - rc = replay_cli.main( - _argv(_required_files, time_offset_ms=5000), shared_main=fake_main - ) + with pytest.warns(DeprecationWarning, match="--time-offset-ms"): + rc = replay_cli.main( + _argv(_required_files, time_offset_ms=5000), shared_main=fake_main + ) # Assert assert rc == EXIT_SUCCESS - assert captured["config"].replay.time_offset_ms == 5000 + assert captured["config"].replay.time_offset_ms is None + err = capsys.readouterr().err + assert "--time-offset-ms is deprecated (AZ-895)" in err def test_ac4_time_offset_none_when_absent( @@ -233,6 +243,86 @@ def test_ac4_time_offset_none_when_absent( assert captured["config"].replay.time_offset_ms is None +def test_az895_skip_auto_sync_ignored_and_warned( + _required_files: dict[str, Path], + _airborne_env: None, + capsys: pytest.CaptureFixture[str], +) -> None: + """AZ-895: --skip-auto-sync deprecated; value ignored, warning emitted.""" + # Arrange + captured: dict[str, Config] = {} + + def fake_main(config: Config) -> int: + captured["config"] = config + return 0 + + # Act + with pytest.warns(DeprecationWarning, match="--skip-auto-sync"): + rc = replay_cli.main( + _argv(_required_files, skip_auto_sync=True), shared_main=fake_main + ) + + # Assert + assert rc == EXIT_SUCCESS + assert captured["config"].replay.skip_auto_sync_validation is False + err = capsys.readouterr().err + assert "--skip-auto-sync is deprecated (AZ-895)" in err + + +def test_az895_auto_trim_ignored_and_warned( + _required_files: dict[str, Path], + _airborne_env: None, + capsys: pytest.CaptureFixture[str], +) -> None: + """AZ-895: --auto-trim deprecated; value ignored, warning emitted.""" + # Arrange + captured: dict[str, Config] = {} + + def fake_main(config: Config) -> int: + captured["config"] = config + return 0 + + # Act + with pytest.warns(DeprecationWarning, match="--auto-trim"): + rc = replay_cli.main( + _argv(_required_files, auto_trim=True), shared_main=fake_main + ) + + # Assert + assert rc == EXIT_SUCCESS + assert captured["config"].replay.auto_trim is False + err = capsys.readouterr().err + assert "--auto-trim is deprecated (AZ-895)" in err + + +def test_az895_no_deprecated_flags_no_warning( + _required_files: dict[str, Path], + _airborne_env: None, + capsys: pytest.CaptureFixture[str], + recwarn: pytest.WarningsRecorder, +) -> None: + """No AZ-895 flag-specific deprecation warning when none of the flags are used.""" + # Act + rc = replay_cli.main(_argv(_required_files), shared_main=lambda _c: 0) + + # Assert + assert rc == EXIT_SUCCESS + err = capsys.readouterr().err + for flag in ("--time-offset-ms", "--skip-auto-sync", "--auto-trim"): + assert f"{flag} is deprecated" not in err, ( + f"unexpected deprecation banner for {flag} when it was not passed" + ) + az895_flag_warnings = [ + w for w in recwarn.list + if issubclass(w.category, DeprecationWarning) + and any( + f"{flag} is deprecated" in str(w.message) + for flag in ("--time-offset-ms", "--skip-auto-sync", "--auto-trim") + ) + ] + assert az895_flag_warnings == [] + + # ---------------------------------------------------------------------- # AC-5: --mavlink-signing-key required (argparse exit 2)