# Batch 75 Report — sitl_observer FDR-replay + probe cleanup (cycle 1, batch 9 of test phase)

**Batch**: 75
**Date**: 2026-05-17
**Context**: Test implementation (greenfield Step 10 — Implement Tests)
**Tasks**: AZ-595 (3 cp) — 1 task (sitl_observer FDR-replay strategy + scenario probe cleanup)
**Cycle**: 1
**Verdict**: COMPLETE — PASS (self-reviewed; see `reviews/batch_75_review.md`,
K=3 cumulative `reviews/cumulative_73_75_review.md`)

## Summary

Closes the second half of the harness-stubs planning gap surfaced in
batch 74. Where batch 74 landed the three lowest-risk core helpers
(`fdr_reader`, `frame_source_replay`, `imu_replay`), batch 75 fills
the largest remaining stub: every `sitl_observer` surface that the
batch-71/72/73 scenarios reference. Strategy: **offline FDR-replay**,
not live pymavlink/yamspy plumbing. Each observer surface reads a
deterministic JSON fixture under `${E2E_SITL_REPLAY_DIR}` instead of
connecting to a real SITL container.

The same batch also collapses the per-scenario
`_harness_helpers_implemented` probe pattern (12 copies across
scenario files) into one shared session-scoped `sitl_replay_ready`
fixture in `e2e/tests/conftest.py`. Net: -636 LoC of duplicated
scenario gating, +17 LoC of shared fixture.

### AZ-595 — sitl_observer FDR-replay + probe cleanup (3 cp)

* **`runner/helpers/sitl_observer.py`** — 11 surfaces implemented:
  * `replay_dir()` / `replay_dir_available()` — env-var-rooted
    fixture resolver; the single reader of `E2E_SITL_REPLAY_DIR`.
  * `get_observer(fc_kind, host)` — frozen-dataclass
    `_FdrReplayObserver` reading `gps_state.json` once; exposes
    `read_gps_state()` + `read_parameter(name)`.
  * `read_ekf_divergence_events()`, `read_gps_health_samples()`,
    `read_consistency_check_events()` — `_load_optional_json_list`
    pattern (fixture absent → `[]`; malformed → `ValueError`).
  * `capture_ap_tlog(host, duration_s)` — returns a `Path` from the
    fixture; tlog binary is staged by the fixture builder.
  * `read_ap_parameter(host, name)` — loads `ap_parameters.json`.
  * `observe_inav_tcp_handshake(host, port, timeout_s)` — returns a
    `TcpHandshakeReport` from `inav_tcp_handshake.json`.
  * `collect_inav_msp_frames(host, port, window_s)` — returns a
    `MspFrameCapture` (`frames: list[MspFrameSample]` +
    `expected_num_sat`) from `inav_msp_frames.json`.
  * `query_inav_gps_state(host)` — returns an `InavGpsState` from
    `inav_gps_state.json`.
  * `prepare_sitl_cold_boot(host, fixture_path)` /
    `prepare_sitl_no_gps(host)` — no-ops in replay mode (the
    fixture builder bakes the prepared state into the JSONs); the
    `prepare_sitl_cold_boot` body still raises `RuntimeError` on
    `fixture_path=None` so callers can't accidentally pass empty.
* **`_load_optional_json_list` + `_load_required_json`** — the two
  fixture-loader helpers. Any present-but-malformed JSON still
  raises `ValueError` with the file path; only genuinely missing
  optional fixtures fall back to `[]`.
* **Public dataclasses added** (no consumer required edits — all
  field names match what the batch-72/73 evaluators already
  reference): `FcGpsState`, `EkfDivergenceEvent`, `GpsHealthSample`,
  `ConsistencyCheckEvent`, `TcpHandshakeReport`, `MspFrameSample`,
  `MspFrameCapture`, `InavGpsState`.
* **`e2e/tests/conftest.py`** — added the session-scoped
  `sitl_replay_ready: bool` fixture (returns
  `sitl_observer.replay_dir_available()`).
* **Scenarios refactored** — 12 scenarios stripped of their local
  `_harness_helpers_implemented` fixture (+ `_NullSink` /
  `_NullImuEmitter` helper classes) and rewired to consume
  `sitl_replay_ready`:
  * Positive: FT-P-01, FT-P-02, FT-P-03/14, FT-P-04, FT-P-05,
    FT-P-07, FT-P-08, FT-P-09-AP, FT-P-09-iNav, FT-P-10, FT-P-11.
  * Negative: FT-N-01, FT-N-02, FT-N-03, FT-N-04.
* **Stale docstrings updated** — FT-P-01, FT-P-02, FT-P-04 module
  docstrings used to claim "skip is keyed off `NotImplementedError`
  from the helper imports". They now point at the
  `sitl_replay_ready` fixture and the `E2E_SITL_REPLAY_DIR` env
  var. The FT-P-02 docstring also no longer claims that
  `imu_replay` raises `NotImplementedError` (batch 74 landed it).

## Out of scope (deferred)

* **Live SITL parameter loading** — `prepare_sitl_cold_boot` /
  `prepare_sitl_no_gps` only no-op in replay mode. A future
  live-mode observer ticket will own the pymavlink param-set path
  for hardware-in-the-loop runs.
* **`fc_proxy_runtime` driver** — FT-N-04 still depends on a
  runtime fc-proxy driver to inject spoofed GPS. The blackout-spoof
  scenario therefore continues to skip via `sitl_replay_ready`
  AND a future fc-proxy-runtime gate.
* **Fixture builder** — the JSON fixtures themselves
  (`gps_state.json`, `ekf_divergence_events.json`, …) are produced
  by a SITL runner that does not yet exist. Until it lands, every
  scenario keeps skipping cleanly via `sitl_replay_ready` — the
  unit tests cover all branches today by writing tmp_path JSONs.

## Test Results

* New unit tests: **38** (sitl_observer end-to-end — replay_dir
  resolution, every `read_*` / `capture_*` / `observe_*` /
  `collect_*` / `query_*` parse path, `get_observer` factory,
  `prepare_sitl_*` no-op semantics, error branches for every
  optional + required loader).
* Full `e2e/_unit_tests` suite: **596 passed in 123 s** (previous
  cumulative: 558 → +38 net).
* No new linter errors (`ReadLints` clean on `sitl_observer.py`,
  `test_sitl_observer.py`, `conftest.py`, and all 12 refactored
  scenario files).
* The pre-existing `/e2e-results/evidence` collection-time
  teardown warning persists when scenarios are collected outside
  docker; not caused by this batch.

## State

* Spec moved: `_docs/02_tasks/todo/AZ-595_sitl_observer_fdr_replay.md`
  → `_docs/02_tasks/done/`.
* `_docs/_autodev_state.md` advanced to `last_completed_batch: 75`.
* K=3 cumulative review for batches 73-75 written at
  `_docs/03_implementation/reviews/cumulative_73_75_review.md`
  (Verdict: PASS). `last_cumulative_review` advances to
  `batches_73-75`.
