# Tlog trim + mid-flight alignment for replay

**Task**: AZ-698_tlog_trim_midflight_alignment
**Name**: Trim tlog to video window + align mid-flight slices via cross-correlation
**Description**: Extend `replay_input/auto_sync.py` and `TlogReplayFcAdapter` to handle the case where the video is a mid-flight slice of a longer tlog (not the takeoff). Adds `find_aligned_window` (cross-correlation of IMU energy vs video optical-flow magnitude) and a `--auto-trim` CLI flag.
**Complexity**: 5 points
**Dependencies**: AZ-697
**Component**: replay_input + c8_fc_adapter
**Tracker**: AZ-698
**Epic**: AZ-696

## Problem

`replay_input/auto_sync.py::detect_tlog_takeoff` walks the tlog HEAD for
the takeoff event (sustained vertical accel + attitude rate). When the
uploaded video covers a **mid-flight slice** (e.g., 20–25 min into a
30 min flight), takeoff detection lands at t=0 and the resulting offset
is garbage. The replay coordinator then streams the entire tlog
start-to-end, wasting I/O on the leading minutes and computing
estimates against stale tlog samples.

The user's pipeline framing: "tlog is usually bigger than video, and
usually the last chunk in tlog is relevant" — the system must locate
the video's window within the tlog and trim accordingly.

## Outcome

- A new `find_aligned_window(tlog_path, video_path, config) -> AlignedWindow`
  returns `(tlog_start_ns, tlog_end_ns, offset_ms, confidence)`.
- `TlogReplayFcAdapter.open()` honors `tlog_start_ns` — seeks past
  pre-window messages so downstream only sees the relevant slice.
- `gps-denied-replay --auto-trim` is the default for uploads that don't
  pass `--time-offset-ms` or `--skip-auto-sync`.
- Existing takeoff-aligned Derkachi clip continues to pass AC-9 (no
  regression on AZ-405).

## Scope

### Included
- New `find_aligned_window` algorithm — cross-correlation of:
  - IMU energy stream (10 Hz subsampled `|a| − 1g` from `RAW_IMU`/`SCALED_IMU2`)
  - Video optical-flow magnitude (existing `_compute_flow_magnitudes`)
- New `AlignedWindow` DTO under `replay_input/interface.py`.
- `TlogReplayFcAdapter._timestamp_filter(tlog_start_ns)` seek logic.
- `gps-denied-replay --auto-trim` CLI flag wiring.
- Tests: takeoff-aligned regression + synthetic mid-flight scenario.

### Excluded
- Real-flight validation runner — AZ-699 (T3).
- Map visualization — AZ-700 (T4).
- HTTP API — AZ-701 (T5).
- Camera calibration — AZ-702 (T6).

## Acceptance Criteria

**AC-1: Backward-compat on takeoff-aligned clip**
Given the existing Derkachi 60 s clip with synthesized tlog
When `find_aligned_window` runs
Then it returns `offset_ms` within ± 50 ms of the current `auto_sync.compute_offset` result

**AC-2: Mid-flight alignment**
Given a synthetic scenario: tlog covering 0–300 s, video covering 100–110 s with motion onset at tlog t=105 s
When `find_aligned_window` runs
Then `tlog_start_ns ≈ 100 s`, `tlog_end_ns ≈ 110 s`, `offset_ms` places video t=0 at tlog t=100 s

**AC-3: Tlog trim honored by replay adapter**
Given `TlogReplayFcAdapter` opened with `tlog_start_ns = 100 s`
When messages flow
Then only messages with `_timestamp ≥ 100 s` reach subscribers

**AC-4: AC-9 frame-window validator passes for both scenarios**
Given the resolved offset from AC-1 or AC-2
When the AC-9 validator runs on the aligned window
Then it returns 0 (≥ 95 % match)

**AC-5: End-to-end CLI smoke**
Given `gps-denied-replay --auto-trim --video derkachi.mp4 --tlog derkachi.tlog`
When the run completes
Then exit code is 0 and the output JSONL is non-empty

## Non-Functional Requirements

**Performance**
- Alignment over a 30-min tlog completes in < 30 s on Tier-1 hardware (10 Hz subsampled IMU stream).

**Reliability**
- Low confidence (< `low_confidence_threshold`) falls back to head-takeoff detection (existing behavior).

## Unit Tests

| AC Ref | What to Test | Required Outcome |
|--------|-------------|-----------------|
| AC-1 | Takeoff-aligned offset match | Within ± 50 ms of compute_offset |
| AC-2 | Mid-flight window discovery | Correct (start_ns, end_ns) |
| AC-3 | Adapter seek skips pre-window | First emitted ts ≥ tlog_start_ns |
| AC-4 | Validator on aligned scenarios | Returns 0 |

## Blackbox Tests

| AC Ref | Initial Data/Conditions | What to Test | Expected Behavior | NFR References |
|--------|------------------------|-------------|-------------------|----------------|
| AC-5 | Real derkachi inputs + --auto-trim | Full replay CLI run | Clean exit 0 + non-empty JSONL | — |

## Constraints

- Reuse the existing `_find_sustained_event` window-scan utility — no new generic algorithms.
- IMU subsampling MUST be deterministic (AC-10 across the rest of the replay path).
- `tlog_start_ns` seek MUST not break the existing AZ-611 `--skip-auto-sync` path.

## Risks & Mitigation

**Risk 1: False maxima during steady cruise**
- *Risk*: Cross-correlation of steady-state cruise IMU + uniform video flow can have multiple equal-height peaks.
- *Mitigation*: Report `combined_confidence`; below threshold falls back to head-takeoff or explicit offset.

**Risk 2: Performance on long tlogs**
- *Risk*: Multi-hour tlogs would slow naive correlation.
- *Mitigation*: Subsample both streams to 10 Hz before FFT-based correlation.

---

## Implementation Notes (Batch 99 — Cycle 2)

**Status**: In Testing (Jira AZ-698).

### Files changed

Production:
- `src/gps_denied_onboard/replay_input/interface.py` — added `AlignedWindow` DTO, new `alignment_*` fields on `AutoSyncConfig`, optional `aligned_window` on `ReplayInputBundle`.
- `src/gps_denied_onboard/replay_input/auto_sync.py` — added `find_aligned_window`, internal `_align_via_cross_correlation` (normalised cross-correlation per sliding window), `_fallback_to_head_takeoff`, `_resample_uniform`, `_zero_mean_normalise`, `_load_tlog_imu_energy_stream`, `_stream_duration_ns`.
- `src/gps_denied_onboard/replay_input/tlog_video_adapter.py` — added `_run_auto_trim` branch in `open()`, threads `tlog_start_ns` to the adapter and `AlignedWindow` onto the returned bundle, two new `_LOG_KIND_*` logs.
- `src/gps_denied_onboard/components/c8_fc_adapter/tlog_replay_adapter.py` — added `_tlog_start_ns` seek hook; `feed_one_message` skips messages with `_timestamp < _tlog_start_ns` and counts the drop.
- `src/gps_denied_onboard/config/schema.py` — `auto_trim: bool` on `ReplayConfig` (mutex with `time_offset_ms`); `alignment_*` knobs on `ReplayAutoSyncConfig`.
- `src/gps_denied_onboard/config/loader.py` — coercion entries for the new knobs.
- `src/gps_denied_onboard/runtime_root/_replay_branch.py` — passes `auto_trim` and the new alignment knobs into the replay adapter constructor.
- `src/gps_denied_onboard/cli/replay.py` — `--auto-trim` flag wired into `ReplayConfig`.

Tests:
- `tests/unit/replay_input/test_az698_window_alignment.py` — AC-1..AC-4 + fallback + immutability + CLI smoke (AC-5 skipped: real `flight_derkachi.mp4` is a 134 B placeholder).

### AC coverage

| AC | Test | Result |
|----|------|--------|
| AC-1 | `test_ac1_takeoff_aligned_offset_matches_az405_within_50ms` | PASS |
| AC-2 | `test_ac2_mid_flight_alignment_locates_correct_window` | PASS |
| AC-3 | `test_ac3_adapter_seek_skips_pre_window_messages`, `test_ac3_adapter_default_no_seek_passes_every_message` | PASS |
| AC-4 | `test_ac4_validator_passes_for_takeoff_aligned_offset`, `test_ac4_validator_passes_for_mid_flight_offset` | PASS |
| AC-5 | `test_ac5_cli_auto_trim_smoke_uses_find_aligned_window` | SKIPPED (real video missing) |

### Test results

50 passed, 2 skipped across the replay/c8 regression slice (`test_az698_window_alignment.py`, `test_az405_auto_sync.py`, `test_az405_replay_input_adapter.py`, `test_az399_tlog_replay_adapter.py`, `test_tlog_ground_truth.py`, `test_az697_gps_compare.py`, `test_khp20s30_factory.py`, `test_az687_pre_constructed_replay_mode.py`, `test_az269_config_loader.py`). No regressions.

### Strict typing

`mypy --strict` on the 8 modified `src/` files: 17 errors total, all pre-existing (verified by stashing this batch's `src/` changes and re-running). Zero new errors introduced by AZ-698.

### Known limitations

- AC-5 is a literal skip in this batch. The repo's `flight_derkachi.mp4` is a 134-byte placeholder, not a real recording. Real end-to-end CLI smoke against `derkachi.tlog` + the actual flight video is covered by AZ-699 (validation runner) once the video is sourced.
- Pre-existing `mypy --strict` errors in `auto_sync.py`, `tlog_replay_adapter.py`, `tlog_video_adapter.py`, `_replay_branch.py`, `cli/replay.py`, and `loader.py` are out of scope per `coderule.mdc` (only fix pre-existing lints in the modified area when necessary). They were not necessary for AZ-698.

### Algorithm note

Implementation uses **normalised cross-correlation with per-window unit-norm** (each `len(flow_arr)`-sized slice of the tlog energy stream is zero-meaned + unit-normed before the dot product with the unit-normed flow stream). This makes the peak confidence scale-invariant — a 10 s motion burst inside a 300 s tlog produces a peak ≥ 0.95, where the original FFT-style correlation with full-length normalisation produced ≤ 0.3 and tripped the low-confidence fallback. Cost is O(N·M); with the 10 Hz subsample and a typical 300 s tlog × 10 s flow window, that's ~3 000 inner products — well below the NFR perf budget.

### Follow-up: multi-flight tlog handling (post-batch-99 review)

User reported that real `derkachi.tlog` contains **three takeoffs at the same field**, but the uploaded video covers only the **last** one. The original AZ-698 implementation was vulnerable in two places:

1. NCC `argmax` returns the **first** index of the maximum — if all three flights produce comparable correlation peaks, the result would lock onto flight 1.
2. The low-confidence fallback called `detect_tlog_takeoff` on the whole tlog, which is the AZ-405 head-takeoff detector — also locks onto flight 1.

Both contradicted the spec line 22: *"the last chunk in tlog is relevant"*.

Resolution: added a pre-NCC flight segmenter and made the aligner explicitly select the last flight before running NCC. The fallback also now uses the last segment's start instead of head-takeoff detection.

#### New module surface

- `_segment_flights_from_imu_energy(samples, *, motion_threshold, min_flight_duration_ns, max_internal_gap_ns) -> list[(start_ns, end_ns)]` — partitions the IMU energy stream into distinct flights. A flight is a contiguous span where energy stayed above threshold, with sub-threshold runs (cruise lulls) shorter than `max_internal_gap_ns`. Reused logic note: `_find_sustained_event` (AZ-405) returns only the FIRST qualifying window by design; partitioning all flights needs the fresh one-pass walk.

#### New config knobs (`AutoSyncConfig` + `ReplayAutoSyncConfig`)

| Knob | Default | Meaning |
|------|---------|---------|
| `alignment_segment_motion_threshold_g` | `0.10` | Min IMU energy (`|a| − 1g`, g-units) for a sample to count as in-flight. 0.10 captures cruise oscillation while ignoring stationary sensor noise (~ 0.02). |
| `alignment_segment_min_flight_duration_seconds` | `30.0` | Discards short ground-startup blips. |
| `alignment_segment_max_internal_gap_seconds` | `30.0` | Sub-threshold gaps shorter than this stay inside a flight. |

#### New observability fields (`AlignedWindow`)

- `flight_count_detected: int` — how many distinct flights the segmenter found. `1` for a clean single-flight tlog. `> 1` means multi-flight; the aligner always selected the last one.
- `selected_flight_index: int` — zero-based; always `flight_count_detected - 1` when segmentation fired, else `-1` (segmenter found nothing — fall through to whole-tlog NCC, preserving pre-segmentation behaviour for degenerate inputs).

Surfaced via the `replay.auto_trim.resolved` / `replay.auto_trim.fallback_to_takeoff` log records' `kv` dict so the operator can audit segment selection from the FDR.

#### New tests

| Test | Asserts |
|------|---------|
| `test_segmenter_one_flight_returns_single_span` | Single-flight tlog → 1 segment, correct bounds |
| `test_segmenter_three_flights_returns_three_spans_in_order` | 3-flight tlog → 3 segments in chronological order |
| `test_segmenter_drops_ground_blip_below_min_duration` | < 30 s motion is filtered out |
| `test_segmenter_keeps_brief_cruise_lull_inside_flight` | 3 s mid-flight lull does NOT split a flight |
| `test_find_aligned_window_picks_last_flight_for_multi_flight_tlog` | Full `find_aligned_window` pipeline on a 3-flight tlog → resulting window is inside flight 3, not flight 1 or 2 |
| `test_align_via_cross_correlation_locks_onto_burst_inside_last_segment` | NCC path locks correctly on a pre-restricted-to-last-flight energy stream |
| `test_find_aligned_window_uses_only_segment_for_segmented_tlog_fallback` | Low-confidence fallback uses segment start (last flight), NOT head-takeoff (flight 1) |

All 19 AZ-698 tests pass, 1 expected skip (AC-5 real-video smoke). 113 tests pass in the broader regression slice — no regressions.

Backward-compat verified: AC-1 / AC-2 / AC-3 / AC-4 tests exercise `_align_via_cross_correlation` directly and continue to pass; the segmentation gate only fires through `find_aligned_window`'s public entry point.
