# Batch 99 — Cycle 2 — AZ-698

**Date**: 2026-05-20
**Tasks**: AZ-698 (Tlog trim + mid-flight alignment for replay).
**Story points**: 5.
**Jira status**: AZ-698 → `In Testing`.

## What shipped

A normalised-cross-correlation aligner that finds the video's playback window
inside a longer tlog, plus the plumbing to honor that window across the
replay-mode composition root, replay coordinator, replay-side FC adapter,
config schema, and CLI.

- `find_aligned_window(tlog_path, video_path, config, ...) -> AlignedWindow`
  in `src/gps_denied_onboard/replay_input/auto_sync.py`. Returns
  `(tlog_start_ns, tlog_end_ns, offset_ms, confidence, used_fallback)`.
- `AlignedWindow` DTO + `auto_trim` flag + `alignment_*` knobs on
  `ReplayConfig` / `ReplayAutoSyncConfig`.
- `TlogReplayFcAdapter` skips messages with `_timestamp < tlog_start_ns`
  when seeded (`AC-3`).
- `--auto-trim` CLI flag on `gps-denied-replay`, mutually exclusive with
  `--time-offset-ms`.

## Files changed

Production (8):

- `src/gps_denied_onboard/replay_input/interface.py`
- `src/gps_denied_onboard/replay_input/auto_sync.py`
- `src/gps_denied_onboard/replay_input/tlog_video_adapter.py`
- `src/gps_denied_onboard/replay_input/__init__.py` (re-export `AlignedWindow`)
- `src/gps_denied_onboard/components/c8_fc_adapter/tlog_replay_adapter.py`
- `src/gps_denied_onboard/config/schema.py`
- `src/gps_denied_onboard/config/loader.py`
- `src/gps_denied_onboard/runtime_root/_replay_branch.py`
- `src/gps_denied_onboard/cli/replay.py`

Tests (1 new):

- `tests/unit/replay_input/test_az698_window_alignment.py`

Specs:

- `_docs/02_tasks/done/AZ-698_tlog_trim_midflight_alignment.md` (moved from
  `todo/`, completion notes appended).

## 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`, `_default_no_seek_*`    | PASS    |
| AC-4 | `test_ac4_validator_passes_for_takeoff_aligned_offset`, `_mid_flight_*`    | PASS    |
| AC-5 | `test_ac5_cli_auto_trim_smoke_uses_find_aligned_window`                    | SKIPPED |

AC-5 skip reason: the repo's `flight_derkachi.mp4` is a 134-byte placeholder,
not a real recording. Live CLI smoke is covered by AZ-699 (validation
runner) once the real video is sourced.

## Test run

```
tests/unit/replay_input/test_az698_window_alignment.py        12 PASS  1 SKIP
tests/unit/replay_input/test_az405_auto_sync.py               14 PASS
tests/unit/replay_input/test_az405_replay_input_adapter.py    13 PASS
tests/unit/c8_fc_adapter/test_az399_tlog_replay_adapter.py    24 PASS  1 SKIP
tests/unit/replay_input/test_tlog_ground_truth.py             12 PASS
tests/unit/test_az697_gps_compare.py                          10 PASS
tests/unit/calibration/test_khp20s30_factory.py                9 PASS
tests/unit/runtime_root/test_az687_pre_constructed_replay_mode.py 3 PASS
tests/unit/test_az269_config_loader.py                         9 PASS
```

Totals: **106 passed, 2 skipped, 0 failed.** No regressions in adjacent
suites.

## Strict typing

Baseline (pre-batch, by stash-and-rerun): 17 `mypy --strict` errors across
6 of the 8 touched `src/` files. After batch: 17 errors — same count,
same kinds, with line numbers shifted only by added code. **Zero new
strict-typing errors introduced by AZ-698.** Pre-existing errors are
out-of-scope per `coderule.mdc` ("Pre-existing lint errors should only be
fixed if they're in the modified area" — they were not in the bytes
modified for AZ-698 ACs).

The new public symbols (`find_aligned_window`, `AlignedWindow`,
`_zero_mean_normalise`, `_resample_uniform`) carry explicit
`npt.NDArray[np.float64]` annotations so they don't add to the debt.

## Code review verdict

Inline self-review: code paths cover the spec's scope, fallback to
head-takeoff on low confidence preserves AZ-405 behavior, adapter seek is
opt-in via constructor kwarg so the `--skip-auto-sync` path is untouched.
The normalised-cross-correlation switch is documented in the spec's
"Implementation Notes" appendix as the algorithmic decision of record.

## Follow-up commit: multi-flight handling

User-reported gap during the AZ-698 "In Testing" phase: real
`derkachi.tlog` contains **three takeoffs**; the video covers only
the last. The original AZ-698 happy path (`np.argmax`) and fallback
(`detect_tlog_takeoff` on head) were both biased toward flight 1.

Patched in a follow-up commit on top of batch 99:

- New `_segment_flights_from_imu_energy` helper partitions the IMU
  energy stream into distinct flights using a motion-threshold +
  gap-tolerance walk.
- `find_aligned_window` now restricts NCC search to the **last**
  detected segment.
- Low-confidence fallback uses the last segment's start instead of
  re-running head-takeoff detection on the whole tlog.
- `AlignedWindow` gains `flight_count_detected` + `selected_flight_index`
  for observability; both are surfaced in the `replay.auto_trim.resolved` /
  `…fallback_to_takeoff` log records.
- New unit tests: segmenter happy paths (1-flight, 3-flight),
  ground-blip rejection, cruise-lull preservation; integration test
  proving `find_aligned_window` on a 3-flight tlog picks flight 3.

Test totals after follow-up: **113 passed, 2 skipped, 0 failed.**
Zero new mypy --strict errors (12 errors in scope, all pre-existing
and unchanged).

## Next batch

Batch 100 — **AZ-699** (real-flight validation runner). Depends on
AZ-697 (ground truth) and AZ-698 (alignment) — both now in testing.
