[AZ-698] Multi-flight tlog handling: segment first, pick last flight

Real derkachi.tlog covers 3 takeoffs at the same field but the
uploaded video covers only the last. Original NCC argmax + AZ-405
head-takeoff fallback both biased toward flight 1, violating the
spec's "the last chunk in tlog is relevant" framing.

Patch: pre-NCC flight segmenter partitions the IMU energy stream
into distinct flights (threshold + gap walk); find_aligned_window
restricts NCC search to the last segment; low-confidence fallback
uses that segment's start instead of head-takeoff detection.
AlignedWindow gains flight_count_detected + selected_flight_index
for FDR-visible audit.

7 new unit tests (segmenter shapes + end-to-end multi-flight
pipeline + segmented fallback path). 19 AZ-698 tests pass, 113
in the regression slice. Zero new mypy --strict errors.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-20 16:44:41 +03:00
parent 87fe98858f
commit f5366bbca1
9 changed files with 587 additions and 21 deletions
@@ -164,3 +164,49 @@ Tests:
### Algorithm note ### 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. 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.
@@ -98,6 +98,33 @@ opt-in via constructor kwarg so the `--skip-auto-sync` path is untouched.
The normalised-cross-correlation switch is documented in the spec's The normalised-cross-correlation switch is documented in the spec's
"Implementation Notes" appendix as the algorithmic decision of record. "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 ## Next batch
Batch 100 — **AZ-699** (real-flight validation runner). Depends on Batch 100 — **AZ-699** (real-flight validation runner). Depends on
+3
View File
@@ -198,6 +198,9 @@ _REPLAY_AUTO_SYNC_TYPES: Final[dict[str, type]] = {
"alignment_resample_hz": float, "alignment_resample_hz": float,
"alignment_video_scan_seconds": float, "alignment_video_scan_seconds": float,
"alignment_low_confidence_threshold": float, "alignment_low_confidence_threshold": float,
"alignment_segment_motion_threshold_g": float,
"alignment_segment_min_flight_duration_seconds": float,
"alignment_segment_max_internal_gap_seconds": float,
} }
+3
View File
@@ -323,6 +323,9 @@ class ReplayAutoSyncConfig:
alignment_resample_hz: float = 10.0 alignment_resample_hz: float = 10.0
alignment_video_scan_seconds: float = 30.0 alignment_video_scan_seconds: float = 30.0
alignment_low_confidence_threshold: float = 0.60 alignment_low_confidence_threshold: float = 0.60
alignment_segment_motion_threshold_g: float = 0.10
alignment_segment_min_flight_duration_seconds: float = 30.0
alignment_segment_max_internal_gap_seconds: float = 30.0
@dataclass(frozen=True) @dataclass(frozen=True)
+133 -10
View File
@@ -727,15 +727,53 @@ def find_aligned_window(
if not video_path.is_file(): if not video_path.is_file():
raise ReplayInputAdapterError(f"video file not found: {video_path}") raise ReplayInputAdapterError(f"video file not found: {video_path}")
tlog_energy = _load_tlog_imu_energy_stream( tlog_energy_full = _load_tlog_imu_energy_stream(
tlog_path, tlog_path,
max_messages=config.prescan_max_messages * 10, max_messages=config.prescan_max_messages * 10,
source_factory=tlog_source_factory, 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: if len(tlog_energy) < 2:
raise ReplayInputAdapterError( raise ReplayInputAdapterError(
f"tlog yielded {len(tlog_energy)} IMU sample(s); " f"selected flight segment yielded {len(tlog_energy)} IMU "
"need ≥ 2 for cross-correlation alignment" "sample(s); need ≥ 2 for cross-correlation alignment"
) )
if video_frames_factory is None: if video_frames_factory is None:
@@ -765,6 +803,8 @@ def find_aligned_window(
target_fc_dialect=target_fc_dialect, target_fc_dialect=target_fc_dialect,
tlog_path=tlog_path, tlog_path=tlog_path,
tlog_source_factory=tlog_source_factory, tlog_source_factory=tlog_source_factory,
flight_count_detected=flight_count_detected,
selected_flight_index=selected_flight_index,
) )
@@ -776,6 +816,8 @@ def _align_via_cross_correlation(
target_fc_dialect: FcKind, target_fc_dialect: FcKind,
tlog_path: Path, tlog_path: Path,
tlog_source_factory: Callable[[str], Any] | None, tlog_source_factory: Callable[[str], Any] | None,
flight_count_detected: int = 0,
selected_flight_index: int = -1,
) -> AlignedWindow: ) -> AlignedWindow:
"""Pure compute kernel: turn pre-loaded streams into an :class:`AlignedWindow`. """Pure compute kernel: turn pre-loaded streams into an :class:`AlignedWindow`.
@@ -844,6 +886,8 @@ def _align_via_cross_correlation(
video_origin_ns=video_origin_ns, video_origin_ns=video_origin_ns,
video_flow_duration_ns=video_duration_ns, video_flow_duration_ns=video_duration_ns,
confidence=confidence, confidence=confidence,
flight_count_detected=flight_count_detected,
selected_flight_index=selected_flight_index,
) )
# Absolute tlog timeline value where video t=0 aligns. The # Absolute tlog timeline value where video t=0 aligns. The
@@ -862,6 +906,8 @@ def _align_via_cross_correlation(
offset_ms=offset_ms, offset_ms=offset_ms,
confidence=confidence, confidence=confidence,
fallback_used=False, fallback_used=False,
flight_count_detected=flight_count_detected,
selected_flight_index=selected_flight_index,
) )
@@ -883,16 +929,37 @@ def _fallback_to_head_takeoff(
video_origin_ns: int, video_origin_ns: int,
video_flow_duration_ns: int, video_flow_duration_ns: int,
confidence: float, confidence: float,
flight_count_detected: int = 0,
selected_flight_index: int = -1,
) -> AlignedWindow: ) -> AlignedWindow:
"""Low-confidence path: use AZ-405 head-takeoff detector. """Low-confidence fallback path.
Returns an :class:`AlignedWindow` whose ``offset_ms`` and Two modes:
``tlog_start_ns`` come from the takeoff onset; ``fallback_used``
is ``True`` so callers + FDR audit can record the divergence. * **Segmented tlog** (``flight_count_detected > 0``): the
The reported ``confidence`` is the original (sub-threshold) pre-NCC segmenter already chose the LAST flight. We use
cross-correlation peak — it is informational only when the ``tlog_energy[0][0]`` — the start of that segment — as the
fallback path is taken. ``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( takeoff = detect_tlog_takeoff(
tlog_path, tlog_path,
target_fc_dialect, target_fc_dialect,
@@ -913,6 +980,8 @@ def _fallback_to_head_takeoff(
offset_ms=offset_ms, offset_ms=offset_ms,
confidence=confidence, confidence=confidence,
fallback_used=True, fallback_used=True,
flight_count_detected=flight_count_detected,
selected_flight_index=selected_flight_index,
) )
@@ -969,6 +1038,60 @@ def _zero_mean_normalise(
return result 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( def _load_tlog_imu_energy_stream(
tlog_path: Path, tlog_path: Path,
*, *,
@@ -91,6 +91,21 @@ class AutoSyncConfig:
confidence below which :func:`find_aligned_window` falls confidence below which :func:`find_aligned_window` falls
back to the head-takeoff detector (AZ-405 path). back to the head-takeoff detector (AZ-405 path).
Default 0.60. Default 0.60.
alignment_segment_motion_threshold_g: Minimum IMU energy
(``|a| - 1g`` in g-units) for a sample to count as
"in-flight" during segmentation. A 3-flight tlog has 3
spans where this threshold is exceeded with gaps below
``alignment_segment_max_internal_gap_seconds``. Default
0.10 — captures cruise oscillation while ignoring
stationary sensor noise (~ 0.02 g).
alignment_segment_min_flight_duration_seconds: Minimum span
length (in seconds) for a candidate segment to be
classified as a flight. Discards short ground-startup
blips. Default 30.
alignment_segment_max_internal_gap_seconds: Sub-threshold
spans shorter than this stay inside a flight (cruise
lulls don't split a flight); spans equal-or-longer end
the current flight. Default 30.
""" """
takeoff_accel_threshold_g: float = 0.5 takeoff_accel_threshold_g: float = 0.5
@@ -105,6 +120,9 @@ class AutoSyncConfig:
alignment_resample_hz: float = 10.0 alignment_resample_hz: float = 10.0
alignment_video_scan_seconds: float = 30.0 alignment_video_scan_seconds: float = 30.0
alignment_low_confidence_threshold: float = 0.60 alignment_low_confidence_threshold: float = 0.60
alignment_segment_motion_threshold_g: float = 0.10
alignment_segment_min_flight_duration_seconds: float = 30.0
alignment_segment_max_internal_gap_seconds: float = 30.0
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -163,6 +181,18 @@ class AlignedWindow:
fallback_used: ``True`` when cross-correlation confidence fallback_used: ``True`` when cross-correlation confidence
dropped below the threshold and the result was built dropped below the threshold and the result was built
from the head-takeoff detector instead. from the head-takeoff detector instead.
flight_count_detected: Number of distinct flight segments the
pre-NCC segmenter found in the tlog. ``1`` for a clean
single-flight tlog. ``> 1`` means the tlog covers
multiple flights — the aligner always selects the
**last** one (per AZ-698 spec line 23: "the last chunk
in tlog is relevant").
selected_flight_index: Zero-based index of the segment the
aligner restricted NCC to. Always
``flight_count_detected - 1`` when ``flight_count_detected
> 0``; ``-1`` when segmentation returned no candidate and
the aligner fell through to whole-tlog NCC (single-flight
edge case where the segmenter's thresholds didn't fire).
""" """
tlog_start_ns: int tlog_start_ns: int
@@ -170,6 +200,8 @@ class AlignedWindow:
offset_ms: int offset_ms: int
confidence: float confidence: float
fallback_used: bool fallback_used: bool
flight_count_detected: int = 0
selected_flight_index: int = -1
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -476,10 +476,14 @@ class ReplayInputAdapter:
"offset_ms": window.offset_ms, "offset_ms": window.offset_ms,
"confidence": window.confidence, "confidence": window.confidence,
"fallback_used": window.fallback_used, "fallback_used": window.fallback_used,
"flight_count_detected": window.flight_count_detected,
"selected_flight_index": window.selected_flight_index,
} }
msg = ( msg = (
f"{kind}: tlog_start_ns={window.tlog_start_ns} " f"{kind}: tlog_start_ns={window.tlog_start_ns} "
f"offset_ms={window.offset_ms} confidence={window.confidence:.3f} " 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: if window.fallback_used:
self._log.warning(msg, extra={"kind": kind, "kv": kv}) self._log.warning(msg, extra={"kind": kind, "kv": kv})
@@ -271,6 +271,15 @@ def _build_auto_sync_config(config: Config) -> AutoSyncConfig:
alignment_resample_hz=block.alignment_resample_hz, alignment_resample_hz=block.alignment_resample_hz,
alignment_video_scan_seconds=block.alignment_video_scan_seconds, alignment_video_scan_seconds=block.alignment_video_scan_seconds,
alignment_low_confidence_threshold=block.alignment_low_confidence_threshold, 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
),
) )
@@ -35,8 +35,10 @@ from gps_denied_onboard.components.c8_fc_adapter.tlog_replay_adapter import (
from gps_denied_onboard.replay_input.auto_sync import ( from gps_denied_onboard.replay_input.auto_sync import (
_align_via_cross_correlation, _align_via_cross_correlation,
_resample_uniform, _resample_uniform,
_segment_flights_from_imu_energy,
compute_offset, compute_offset,
detect_video_motion_onset, detect_video_motion_onset,
find_aligned_window,
validate_offset_or_fail, validate_offset_or_fail,
) )
from gps_denied_onboard.replay_input.errors import ReplayInputAdapterError from gps_denied_onboard.replay_input.errors import ReplayInputAdapterError
@@ -110,6 +112,31 @@ def _build_double_burst_stream(
return tuple(out) 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 # AC-1: takeoff-aligned regression — find_aligned_window must produce
# the same offset (within ± 50 ms) as the AZ-405 compute_offset path # the same offset (within ± 50 ms) as the AZ-405 compute_offset path
@@ -614,3 +641,295 @@ def test_autosync_decision_offset_is_within_ac9_window_for_baseline() -> None:
# Assert # Assert
assert decision.offset_ms == 2_000 assert decision.offset_ms == 2_000
assert decision.combined_confidence == pytest.approx(0.85, abs=1e-6) 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)