mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 08:41:12 +00:00
f5366bbca1
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>
354 lines
12 KiB
Python
354 lines
12 KiB
Python
"""Replay-mode branch of :func:`compose_root` (AZ-401 / E-DEMO-REPLAY).
|
|
|
|
Internal module. Owns the wiring that turns a ``config.mode == "replay"``
|
|
:class:`Config` into a :class:`RuntimeRoot` whose components dict carries
|
|
the replay-only strategies (``frame_source``, ``fc_adapter``, ``clock``,
|
|
``mavlink_transport``, ``replay_sink``) plus whatever C1-C7+C13 strategies
|
|
the binary's bootstrap registered against
|
|
:data:`gps_denied_onboard.runtime_root._STRATEGY_REGISTRY`.
|
|
|
|
Per replay protocol v2.0.0 (ADR-011): replay is a configuration of the
|
|
single airborne composition root, not a sibling root. The branch lives
|
|
in this module to keep ``runtime_root/__init__.py`` focused on the
|
|
shared composition spine while still exposing exactly one
|
|
``compose_root(config)`` entrypoint.
|
|
|
|
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.
|
|
- ``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.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from collections.abc import Mapping
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Any, Final
|
|
|
|
from gps_denied_onboard._types.calibration import CameraCalibration
|
|
from gps_denied_onboard._types.fc import FcKind
|
|
from gps_denied_onboard.components.c8_fc_adapter.noop_mavlink_transport import (
|
|
NoopMavlinkTransport,
|
|
)
|
|
from gps_denied_onboard.components.c8_fc_adapter.replay_sink import (
|
|
JsonlReplaySink,
|
|
)
|
|
from gps_denied_onboard.config import Config
|
|
from gps_denied_onboard.fdr_client import make_fdr_client
|
|
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.tlog_video_adapter import ReplayPace
|
|
|
|
if TYPE_CHECKING:
|
|
from gps_denied_onboard.fdr_client.client import FdrClient
|
|
|
|
__all__ = [
|
|
"REPLAY_BUILD_FLAGS",
|
|
"REPLAY_COMPONENT_KEYS",
|
|
"CompositionError",
|
|
"build_replay_components",
|
|
]
|
|
|
|
|
|
_LOG_KIND_READY: Final[str] = "replay.compose_root.ready"
|
|
|
|
|
|
REPLAY_BUILD_FLAGS: Final[tuple[str, ...]] = (
|
|
"BUILD_VIDEO_FILE_FRAME_SOURCE",
|
|
"BUILD_TLOG_REPLAY_ADAPTER",
|
|
"BUILD_REPLAY_SINK_JSONL",
|
|
)
|
|
|
|
|
|
REPLAY_COMPONENT_KEYS: Final[tuple[str, ...]] = (
|
|
"frame_source",
|
|
"fc_adapter",
|
|
"clock",
|
|
"mavlink_transport",
|
|
"replay_sink",
|
|
)
|
|
|
|
|
|
class CompositionError(RuntimeError):
|
|
"""Raised when the replay-mode branch refuses to compose a runtime.
|
|
|
|
Carries the human-readable reason (build-flag OFF, missing path,
|
|
contradictory config) so the caller can surface it in the structured
|
|
log + on stderr without a second introspection pass.
|
|
"""
|
|
|
|
|
|
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, ...]]:
|
|
"""Construct the replay-mode component dict + construction order.
|
|
|
|
The factories are test-only injection points. Production callers
|
|
(just ``compose_root``) leave them ``None`` so the real constructors
|
|
run; unit tests pass fakes so they don't have to satisfy the full
|
|
OpenCV / pymavlink / FDR side-effects of the real strategies.
|
|
|
|
Returns:
|
|
``(components, construction_order)`` — the same shape
|
|
:func:`gps_denied_onboard.runtime_root._compose` returns. The
|
|
keys are the entries of :data:`REPLAY_COMPONENT_KEYS`; the
|
|
values are typed strategy instances.
|
|
"""
|
|
if config.mode != "replay":
|
|
raise CompositionError(
|
|
"build_replay_components called with non-replay config "
|
|
f"(mode={config.mode!r})"
|
|
)
|
|
_validate_build_flags()
|
|
_validate_replay_paths(config)
|
|
|
|
fdr_factory = fdr_client_factory or make_fdr_client
|
|
fdr_client = fdr_factory("replay_input", config)
|
|
|
|
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).
|
|
if transport_factory is not None:
|
|
transport = transport_factory(config)
|
|
else:
|
|
transport = NoopMavlinkTransport()
|
|
|
|
bundle = _build_replay_input_bundle(
|
|
config,
|
|
fdr_client=fdr_client,
|
|
adapter_factory=replay_input_adapter_factory,
|
|
mavlink_transport=transport,
|
|
)
|
|
|
|
if sink_factory is not None:
|
|
sink = sink_factory(config, sink_fdr_client)
|
|
else:
|
|
sink = JsonlReplaySink(
|
|
output_path=Path(config.replay.output_path),
|
|
fdr_client=sink_fdr_client,
|
|
)
|
|
|
|
components: dict[str, Any] = {
|
|
"frame_source": bundle.frame_source,
|
|
"fc_adapter": bundle.fc_adapter,
|
|
"clock": bundle.clock,
|
|
"mavlink_transport": transport,
|
|
"replay_sink": sink,
|
|
}
|
|
|
|
_log_ready(config, bundle)
|
|
return components, REPLAY_COMPONENT_KEYS
|
|
|
|
|
|
def _validate_build_flags() -> None:
|
|
"""Refuse construction when any replay-mode ``BUILD_*`` flag is OFF."""
|
|
for flag_name in REPLAY_BUILD_FLAGS:
|
|
raw = os.environ.get(flag_name, "ON").strip().upper()
|
|
if raw == "OFF":
|
|
raise CompositionError(
|
|
f"{flag_name} is OFF; replay mode requires it"
|
|
)
|
|
|
|
|
|
def _validate_replay_paths(config: Config) -> None:
|
|
"""Reject empty / missing replay paths early with a precise message."""
|
|
if not config.replay.video_path:
|
|
raise CompositionError(
|
|
"config.replay.video_path is empty; replay mode requires a video path"
|
|
)
|
|
if not config.replay.tlog_path:
|
|
raise CompositionError(
|
|
"config.replay.tlog_path is empty; replay mode requires a tlog path"
|
|
)
|
|
if not config.replay.output_path:
|
|
raise CompositionError(
|
|
"config.replay.output_path is empty; replay mode requires an output path"
|
|
)
|
|
|
|
|
|
def _build_replay_input_bundle(
|
|
config: Config,
|
|
*,
|
|
fdr_client: "FdrClient",
|
|
adapter_factory: Any | None,
|
|
mavlink_transport: Any | None = None,
|
|
) -> ReplayInputBundle:
|
|
"""Build the :class:`ReplayInputAdapter` and call ``open()``."""
|
|
pace = _resolve_pace(config.replay.pace)
|
|
target_fc_dialect = _resolve_fc_kind(config.replay.target_fc_dialect)
|
|
auto_sync = _build_auto_sync_config(config)
|
|
camera_calibration = _load_camera_calibration(config)
|
|
wgs_converter = WgsConverter()
|
|
|
|
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()
|
|
|
|
|
|
def _resolve_pace(raw: str) -> ReplayPace:
|
|
if raw == "asap":
|
|
return ReplayPace.ASAP
|
|
if raw == "realtime":
|
|
return ReplayPace.REALTIME
|
|
raise CompositionError(
|
|
f"config.replay.pace={raw!r} not in ('asap', 'realtime')"
|
|
)
|
|
|
|
|
|
def _resolve_fc_kind(raw: str) -> FcKind:
|
|
if raw == "ardupilot_plane":
|
|
return FcKind.ARDUPILOT_PLANE
|
|
if raw == "inav":
|
|
return FcKind.INAV
|
|
raise CompositionError(
|
|
f"config.replay.target_fc_dialect={raw!r} not in "
|
|
"('ardupilot_plane', 'inav')"
|
|
)
|
|
|
|
|
|
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.
|
|
|
|
The replay binary uses the SAME calibration file the live binary
|
|
loads; AZ-401 does not introduce a new on-disk format.
|
|
"""
|
|
import numpy as np
|
|
|
|
path = config.runtime.camera_calibration_path
|
|
if not path:
|
|
raise CompositionError(
|
|
"config.runtime.camera_calibration_path is empty; replay mode "
|
|
"requires a camera calibration JSON"
|
|
)
|
|
try:
|
|
blob = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
except OSError as exc:
|
|
raise CompositionError(
|
|
f"failed to read camera calibration from {path!r}: {exc!r}"
|
|
) from exc
|
|
except json.JSONDecodeError as exc:
|
|
raise CompositionError(
|
|
f"camera calibration {path!r} is not valid JSON: {exc!r}"
|
|
) from exc
|
|
if not isinstance(blob, Mapping):
|
|
raise CompositionError(
|
|
f"camera calibration {path!r} must decode to a mapping; "
|
|
f"got {type(blob).__name__}"
|
|
)
|
|
intrinsics = np.asarray(blob.get("intrinsics_3x3"), dtype=np.float64)
|
|
if intrinsics.shape != (3, 3):
|
|
raise CompositionError(
|
|
f"camera calibration {path!r} 'intrinsics_3x3' must be 3x3; "
|
|
f"got shape {intrinsics.shape}"
|
|
)
|
|
distortion = np.asarray(blob.get("distortion", []), dtype=np.float64)
|
|
body_to_camera = np.asarray(
|
|
blob.get("body_to_camera_se3", np.eye(4).tolist()),
|
|
dtype=np.float64,
|
|
)
|
|
return CameraCalibration(
|
|
camera_id=str(blob.get("camera_id", "replay-camera")),
|
|
intrinsics_3x3=intrinsics,
|
|
distortion=distortion,
|
|
body_to_camera_se3=body_to_camera,
|
|
acquisition_method=str(blob.get("acquisition_method", "operator")),
|
|
metadata=dict(blob.get("metadata", {})),
|
|
)
|
|
|
|
|
|
def _log_ready(config: Config, bundle: ReplayInputBundle) -> None:
|
|
log = get_logger("runtime_root.replay_branch")
|
|
log.info(
|
|
f"{_LOG_KIND_READY}: pace={config.replay.pace} "
|
|
f"resolved_offset_ms={bundle.resolved_time_offset_ms}",
|
|
extra={
|
|
"kind": _LOG_KIND_READY,
|
|
"kv": {
|
|
"video_path": config.replay.video_path,
|
|
"tlog_path": config.replay.tlog_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,
|
|
},
|
|
},
|
|
)
|