mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 07:01:14 +00:00
[AZ-228] [AZ-229] Add VIO and satellite sync boundaries
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
# Batch Report
|
||||
|
||||
**Batch**: 6
|
||||
**Tasks**: AZ-228_vio_adapter, AZ-229_satellite_service_sync
|
||||
**Date**: 2026-05-03
|
||||
|
||||
## Task Results
|
||||
|
||||
| Task | Status | Files Modified | Tests | AC Coverage | Issues |
|
||||
|------|--------|----------------|-------|-------------|--------|
|
||||
| AZ-228_vio_adapter | Done | 4 files | Pass | 3/3 ACs covered | None |
|
||||
| AZ-229_satellite_service_sync | Done | 4 files | Pass | 3/3 ACs covered | None |
|
||||
|
||||
## AC Test Coverage: All covered
|
||||
|
||||
| AC Ref | Coverage |
|
||||
|--------|----------|
|
||||
| AZ-228 AC-1 | `test_valid_synchronized_packet_emits_vio_state` verifies synchronized frame/IMU processing emits a relative VIO state packet. |
|
||||
| AZ-228 AC-2 | `test_timestamp_mismatch_is_explicit_validation_error` verifies timestamp mismatch is rejected with an explicit error. |
|
||||
| AZ-228 AC-3 | `test_tracking_loss_degrades_health_without_emitting_absolute_position` verifies health reports degraded tracking state. |
|
||||
| AZ-229 AC-1 | `test_pre_flight_import_returns_package_for_tile_manager_validation` verifies mission cache packages are exposed for Tile Manager validation. |
|
||||
| AZ-229 AC-2 | `test_post_flight_upload_records_retryable_failure_for_audit` verifies upload outcomes are auditable and retryable failures retain packages. |
|
||||
| AZ-229 AC-3 | `test_in_flight_sync_is_blocked_without_calling_network_boundary` verifies in-flight sync is blocked before network/uploader calls. |
|
||||
|
||||
## Code Review Verdict: PASS
|
||||
|
||||
Review report: `_docs/03_implementation/reviews/batch_06_review.md`
|
||||
|
||||
## Auto-Fix Attempts: 0
|
||||
|
||||
## Stuck Agents: None
|
||||
|
||||
## Verification
|
||||
|
||||
- `.venv/bin/python -m black --check src tests e2e/replay` passed.
|
||||
- `.venv/bin/python -m ruff check src tests e2e/replay` passed.
|
||||
- `.venv/bin/python -m pytest` passed: 38 tests.
|
||||
|
||||
## Next Batch: AZ-230_satellite_service_vpr_retrieval
|
||||
@@ -0,0 +1,61 @@
|
||||
# Code Review Report
|
||||
|
||||
**Batch**: AZ-228_vio_adapter, AZ-229_satellite_service_sync
|
||||
**Date**: 2026-05-03
|
||||
**Verdict**: PASS
|
||||
|
||||
## Findings
|
||||
|
||||
No findings.
|
||||
|
||||
## Review Scope
|
||||
|
||||
- Task specs:
|
||||
- `_docs/02_tasks/todo/AZ-228_vio_adapter.md`
|
||||
- `_docs/02_tasks/todo/AZ-229_satellite_service_sync.md`
|
||||
- Changed files:
|
||||
- `src/vio_adapter/__init__.py`
|
||||
- `src/vio_adapter/interfaces.py`
|
||||
- `src/vio_adapter/types.py`
|
||||
- `src/satellite_service/__init__.py`
|
||||
- `src/satellite_service/interfaces.py`
|
||||
- `src/satellite_service/types.py`
|
||||
- `tests/unit/test_vio_adapter.py`
|
||||
- `tests/unit/test_satellite_service_sync.py`
|
||||
|
||||
## Phase Notes
|
||||
|
||||
### Spec Compliance
|
||||
|
||||
- AZ-228 AC-1 is covered by `test_valid_synchronized_packet_emits_vio_state`.
|
||||
- AZ-228 AC-2 is covered by `test_timestamp_mismatch_is_explicit_validation_error`.
|
||||
- AZ-228 AC-3 is covered by `test_tracking_loss_degrades_health_without_emitting_absolute_position`.
|
||||
- AZ-229 AC-1 is covered by `test_pre_flight_import_returns_package_for_tile_manager_validation`.
|
||||
- AZ-229 AC-2 is covered by `test_post_flight_upload_records_retryable_failure_for_audit`.
|
||||
- AZ-229 AC-3 is covered by `test_in_flight_sync_is_blocked_without_calling_network_boundary`.
|
||||
|
||||
### Code Quality
|
||||
|
||||
The implementation follows the existing Pydantic model style, keeps component logic inside the owning packages, and exposes only public API exports through component `__init__.py` files.
|
||||
|
||||
### Security Quick-Scan
|
||||
|
||||
No hardcoded secrets, shell execution, deserialization paths, SQL construction, or sensitive credential logging were introduced. The Satellite Service sync boundary explicitly rejects in-flight package exchange before invoking the uploader.
|
||||
|
||||
### Performance Scan
|
||||
|
||||
No unbounded network path or per-frame heavy retrieval path was introduced. The VIO adapter uses a bounded timestamp-window selection over the provided telemetry samples.
|
||||
|
||||
### Cross-Task Consistency
|
||||
|
||||
The VIO adapter and Satellite Service sync boundary remain independent batch outputs and share existing DTO/error-envelope conventions.
|
||||
|
||||
### Architecture Compliance
|
||||
|
||||
Imports respect `_docs/02_document/module-layout.md`: VIO imports only shared public APIs, and Satellite Service imports Tile Manager through the package public API.
|
||||
|
||||
## Verification
|
||||
|
||||
- `.venv/bin/python -m black --check src tests e2e/replay`
|
||||
- `.venv/bin/python -m ruff check src tests e2e/replay`
|
||||
- `.venv/bin/python -m pytest`
|
||||
@@ -9,6 +9,6 @@ tracker: jira
|
||||
sub_step:
|
||||
phase: 1
|
||||
name: batch-loop
|
||||
detail: "batch 5: AZ-226_generated_tile_orthorectification"
|
||||
detail: "batch 6: AZ-228_vio_adapter, AZ-229_satellite_service_sync"
|
||||
retry_count: 0
|
||||
cycle: 1
|
||||
|
||||
@@ -1 +1,24 @@
|
||||
"""Offline satellite retrieval and synchronization component."""
|
||||
|
||||
from .interfaces import SatelliteService, SatelliteSyncBoundary
|
||||
from .types import (
|
||||
GeneratedTileUploadRecord,
|
||||
MissionCacheImportResult,
|
||||
MissionCachePackage,
|
||||
RuntimePhase,
|
||||
SatelliteSyncResult,
|
||||
SatelliteSyncStatus,
|
||||
UploadOutcome,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"GeneratedTileUploadRecord",
|
||||
"MissionCacheImportResult",
|
||||
"MissionCachePackage",
|
||||
"RuntimePhase",
|
||||
"SatelliteService",
|
||||
"SatelliteSyncBoundary",
|
||||
"SatelliteSyncResult",
|
||||
"SatelliteSyncStatus",
|
||||
"UploadOutcome",
|
||||
]
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
"""Public satellite service interfaces."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Protocol
|
||||
|
||||
from shared.errors import ErrorEnvelope
|
||||
from tile_manager import GeneratedTileSyncPackage
|
||||
|
||||
from .types import (
|
||||
GeneratedTileUploadRecord,
|
||||
MissionCacheImportResult,
|
||||
MissionCachePackage,
|
||||
RuntimePhase,
|
||||
SatelliteSyncResult,
|
||||
SatelliteSyncStatus,
|
||||
UploadOutcome,
|
||||
)
|
||||
|
||||
|
||||
class SatelliteService(Protocol):
|
||||
"""Retrieves offline VPR candidates from mission cache data."""
|
||||
@@ -11,3 +25,87 @@ class SatelliteService(Protocol):
|
||||
|
||||
def retrieve(self, frame: Any) -> list[Any]:
|
||||
"""Return candidate anchor records for one frame."""
|
||||
|
||||
|
||||
class SatelliteSyncBoundary:
|
||||
"""Owns pre-flight and post-flight package exchange only."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
uploader: Callable[[GeneratedTileSyncPackage], UploadOutcome] | None = None,
|
||||
) -> None:
|
||||
self._uploader = uploader or self._default_uploader
|
||||
self._imports: dict[str, MissionCachePackage] = {}
|
||||
self._upload_records: list[GeneratedTileUploadRecord] = []
|
||||
|
||||
def import_mission_cache(
|
||||
self,
|
||||
package: MissionCachePackage,
|
||||
phase: RuntimePhase = "pre_flight",
|
||||
) -> MissionCacheImportResult:
|
||||
if phase != "pre_flight":
|
||||
return MissionCacheImportResult(
|
||||
package_id=package.package_id,
|
||||
mission_id=package.mission_id,
|
||||
ready_for_tile_validation=False,
|
||||
error=self._phase_error("mission cache import", phase),
|
||||
)
|
||||
|
||||
self._imports[package.package_id] = package
|
||||
return MissionCacheImportResult(
|
||||
package_id=package.package_id,
|
||||
mission_id=package.mission_id,
|
||||
ready_for_tile_validation=True,
|
||||
manifest_entries=package.manifest_entries,
|
||||
)
|
||||
|
||||
def upload_generated_tiles(
|
||||
self,
|
||||
package: GeneratedTileSyncPackage,
|
||||
phase: RuntimePhase = "post_flight",
|
||||
) -> SatelliteSyncResult:
|
||||
if phase != "post_flight":
|
||||
return SatelliteSyncResult(error=self._phase_error("generated tile upload", phase))
|
||||
|
||||
if not package.sidecars:
|
||||
record = GeneratedTileUploadRecord(
|
||||
package_ref=package.package_ref,
|
||||
mission_id=package.mission_id,
|
||||
status="rejected",
|
||||
reason="empty_generated_tile_package",
|
||||
retained_for_retry=False,
|
||||
)
|
||||
else:
|
||||
outcome = self._uploader(package)
|
||||
record = GeneratedTileUploadRecord(
|
||||
package_ref=package.package_ref,
|
||||
mission_id=package.mission_id,
|
||||
status=outcome,
|
||||
reason=outcome,
|
||||
retained_for_retry=outcome == "retryable_failure",
|
||||
)
|
||||
|
||||
self._upload_records.append(record)
|
||||
return SatelliteSyncResult(upload_record=record)
|
||||
|
||||
def status(self) -> SatelliteSyncStatus:
|
||||
return SatelliteSyncStatus(
|
||||
imported_package_ids=tuple(self._imports),
|
||||
upload_records=tuple(self._upload_records),
|
||||
retry_package_refs=tuple(
|
||||
record.package_ref for record in self._upload_records if record.retained_for_retry
|
||||
),
|
||||
)
|
||||
|
||||
def _phase_error(self, operation: str, phase: RuntimePhase) -> ErrorEnvelope:
|
||||
return ErrorEnvelope(
|
||||
component="satellite_service",
|
||||
category="security",
|
||||
message=f"{operation} is not allowed during {phase}",
|
||||
severity="warning",
|
||||
retryable=False,
|
||||
cause="mid_flight_network_blocked" if phase == "in_flight" else "phase_not_allowed",
|
||||
)
|
||||
|
||||
def _default_uploader(self, package: GeneratedTileSyncPackage) -> UploadOutcome:
|
||||
return "success"
|
||||
|
||||
@@ -1,5 +1,49 @@
|
||||
"""Public satellite service type aliases."""
|
||||
"""Public satellite service models."""
|
||||
|
||||
from typing import Any
|
||||
from typing import Literal
|
||||
|
||||
VprCandidateLike = Any
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from shared.errors import ErrorEnvelope
|
||||
from tile_manager import TileManifestEntry
|
||||
|
||||
|
||||
class SatelliteServiceModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
|
||||
class MissionCachePackage(SatelliteServiceModel):
|
||||
package_id: str = Field(min_length=1)
|
||||
mission_id: str = Field(min_length=1)
|
||||
manifest_entries: tuple[TileManifestEntry, ...] = Field(min_length=1)
|
||||
|
||||
|
||||
class MissionCacheImportResult(SatelliteServiceModel):
|
||||
package_id: str = Field(min_length=1)
|
||||
mission_id: str = Field(min_length=1)
|
||||
ready_for_tile_validation: bool
|
||||
manifest_entries: tuple[TileManifestEntry, ...] = ()
|
||||
error: ErrorEnvelope | None = None
|
||||
|
||||
|
||||
class GeneratedTileUploadRecord(SatelliteServiceModel):
|
||||
package_ref: str = Field(min_length=1)
|
||||
mission_id: str = Field(min_length=1)
|
||||
status: Literal["uploaded", "rejected", "retryable_failure"]
|
||||
reason: str
|
||||
retained_for_retry: bool
|
||||
|
||||
|
||||
class SatelliteSyncStatus(SatelliteServiceModel):
|
||||
imported_package_ids: tuple[str, ...]
|
||||
upload_records: tuple[GeneratedTileUploadRecord, ...]
|
||||
retry_package_refs: tuple[str, ...]
|
||||
|
||||
|
||||
class SatelliteSyncResult(SatelliteServiceModel):
|
||||
upload_record: GeneratedTileUploadRecord | None = None
|
||||
error: ErrorEnvelope | None = None
|
||||
|
||||
|
||||
RuntimePhase = Literal["pre_flight", "in_flight", "post_flight"]
|
||||
UploadOutcome = Literal["success", "retryable_failure", "rejected"]
|
||||
|
||||
@@ -1 +1,15 @@
|
||||
"""Replaceable VIO adapter component."""
|
||||
|
||||
from .interfaces import DeterministicVioBackend, LocalVioAdapter, VioAdapter, VioBackend
|
||||
from .types import VioBackendEstimate, VioHealthReport, VioInputPacket, VioProcessingResult
|
||||
|
||||
__all__ = [
|
||||
"DeterministicVioBackend",
|
||||
"LocalVioAdapter",
|
||||
"VioAdapter",
|
||||
"VioBackend",
|
||||
"VioBackendEstimate",
|
||||
"VioHealthReport",
|
||||
"VioInputPacket",
|
||||
"VioProcessingResult",
|
||||
]
|
||||
|
||||
@@ -2,6 +2,17 @@
|
||||
|
||||
from typing import Any, Protocol
|
||||
|
||||
from shared.contracts import VioStatePacket
|
||||
from shared.errors import ErrorEnvelope
|
||||
from shared.time_sync import select_time_window
|
||||
|
||||
from .types import (
|
||||
VioBackendEstimate,
|
||||
VioHealthReport,
|
||||
VioInputPacket,
|
||||
VioProcessingResult,
|
||||
)
|
||||
|
||||
|
||||
class VioAdapter(Protocol):
|
||||
"""Processes frame and telemetry inputs into relative VIO state."""
|
||||
@@ -9,5 +20,129 @@ class VioAdapter(Protocol):
|
||||
def initialize(self) -> None:
|
||||
"""Initialize adapter resources."""
|
||||
|
||||
def process(self, frame: Any, telemetry: Any) -> Any:
|
||||
def process(self, packet: VioInputPacket) -> VioProcessingResult:
|
||||
"""Process one synchronized frame/telemetry pair."""
|
||||
|
||||
def health(self) -> VioHealthReport:
|
||||
"""Return current readiness and degradation state."""
|
||||
|
||||
|
||||
class VioBackend(Protocol):
|
||||
"""Backend-neutral native bridge boundary."""
|
||||
|
||||
def initialize(self) -> None:
|
||||
"""Initialize native backend resources."""
|
||||
|
||||
def estimate(self, frame: Any, telemetry_window: tuple[Any, ...]) -> VioBackendEstimate:
|
||||
"""Return one relative VIO estimate."""
|
||||
|
||||
|
||||
class DeterministicVioBackend:
|
||||
"""Small deterministic backend used until a native bridge is attached."""
|
||||
|
||||
def initialize(self) -> None:
|
||||
return None
|
||||
|
||||
def estimate(self, frame: Any, telemetry_window: tuple[Any, ...]) -> VioBackendEstimate:
|
||||
quality = float(getattr(frame, "quality", 1.0))
|
||||
tracking_quality = max(0.0, min(1.0, quality))
|
||||
return VioBackendEstimate(
|
||||
timestamp_ns=frame.timestamp_ns,
|
||||
relative_pose={
|
||||
"x_m": tracking_quality,
|
||||
"y_m": 0.0,
|
||||
"z_m": 0.0,
|
||||
"yaw_rad": 0.0,
|
||||
},
|
||||
velocity_mps=(tracking_quality, 0.0, 0.0),
|
||||
tracking_quality=tracking_quality,
|
||||
bias_estimate={"sample_count": float(len(telemetry_window))},
|
||||
covariance_hint=[
|
||||
[1.0 / max(tracking_quality, 0.1), 0.0, 0.0],
|
||||
[0.0, 1.0 / max(tracking_quality, 0.1), 0.0],
|
||||
[0.0, 0.0, 1.0 / max(tracking_quality, 0.1)],
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class LocalVioAdapter:
|
||||
"""Backend-neutral adapter that exposes explicit health and mismatch behavior."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
backend: VioBackend | None = None,
|
||||
timestamp_tolerance_ns: int = 5_000_000,
|
||||
degraded_quality_threshold: float = 0.35,
|
||||
) -> None:
|
||||
self._backend = backend or DeterministicVioBackend()
|
||||
self._timestamp_tolerance_ns = timestamp_tolerance_ns
|
||||
self._degraded_quality_threshold = degraded_quality_threshold
|
||||
self._initialized = False
|
||||
self._health = VioHealthReport(
|
||||
initialized=False,
|
||||
state="not_initialized",
|
||||
tracking_quality=0.0,
|
||||
)
|
||||
|
||||
def initialize(self) -> None:
|
||||
self._backend.initialize()
|
||||
self._initialized = True
|
||||
self._health = VioHealthReport(
|
||||
initialized=True,
|
||||
state="ready",
|
||||
tracking_quality=1.0,
|
||||
)
|
||||
|
||||
def process(self, packet: VioInputPacket) -> VioProcessingResult:
|
||||
if not self._initialized:
|
||||
self.initialize()
|
||||
|
||||
telemetry_timestamps = [sample.timestamp_ns for sample in packet.telemetry_samples]
|
||||
time_window = select_time_window(
|
||||
packet.frame.timestamp_ns,
|
||||
telemetry_timestamps,
|
||||
self._timestamp_tolerance_ns,
|
||||
)
|
||||
if not time_window.ok:
|
||||
error = ErrorEnvelope(
|
||||
component="vio_adapter",
|
||||
category="validation",
|
||||
message="frame and telemetry timestamps are outside the VIO sync window",
|
||||
severity="warning",
|
||||
retryable=False,
|
||||
cause=time_window.violations[0].category,
|
||||
)
|
||||
self._health = VioHealthReport(
|
||||
initialized=True,
|
||||
state="degraded",
|
||||
tracking_quality=0.0,
|
||||
error=error,
|
||||
)
|
||||
return VioProcessingResult(health=self._health, error=error)
|
||||
|
||||
telemetry_window = tuple(
|
||||
sample
|
||||
for sample in packet.telemetry_samples
|
||||
if sample.timestamp_ns in set(time_window.sample_timestamps_ns)
|
||||
)
|
||||
estimate = self._backend.estimate(packet.frame, telemetry_window)
|
||||
state_packet = VioStatePacket(
|
||||
timestamp_ns=estimate.timestamp_ns,
|
||||
relative_pose=estimate.relative_pose,
|
||||
velocity_mps=estimate.velocity_mps,
|
||||
bias_estimate=estimate.bias_estimate,
|
||||
tracking_quality=estimate.tracking_quality,
|
||||
covariance_hint=estimate.covariance_hint,
|
||||
)
|
||||
health_state = (
|
||||
"degraded" if estimate.tracking_quality < self._degraded_quality_threshold else "ready"
|
||||
)
|
||||
self._health = VioHealthReport(
|
||||
initialized=True,
|
||||
state=health_state,
|
||||
tracking_quality=estimate.tracking_quality,
|
||||
)
|
||||
return VioProcessingResult(state_packet=state_packet, health=self._health)
|
||||
|
||||
def health(self) -> VioHealthReport:
|
||||
return self._health
|
||||
|
||||
@@ -1,5 +1,39 @@
|
||||
"""Public VIO type aliases."""
|
||||
"""Public VIO adapter models."""
|
||||
|
||||
from typing import Any
|
||||
from typing import Literal
|
||||
|
||||
VioStatePacketLike = Any
|
||||
from pydantic import BaseModel, ConfigDict, Field, NonNegativeInt
|
||||
|
||||
from shared.contracts import FramePacket, TelemetrySample, VioStatePacket
|
||||
from shared.errors import ErrorEnvelope
|
||||
|
||||
|
||||
class VioAdapterModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
|
||||
class VioInputPacket(VioAdapterModel):
|
||||
frame: FramePacket
|
||||
telemetry_samples: tuple[TelemetrySample, ...] = Field(min_length=1)
|
||||
|
||||
|
||||
class VioHealthReport(VioAdapterModel):
|
||||
initialized: bool
|
||||
state: Literal["not_initialized", "ready", "degraded", "failed"]
|
||||
tracking_quality: float = Field(ge=0.0, le=1.0)
|
||||
error: ErrorEnvelope | None = None
|
||||
|
||||
|
||||
class VioProcessingResult(VioAdapterModel):
|
||||
state_packet: VioStatePacket | None = None
|
||||
health: VioHealthReport
|
||||
error: ErrorEnvelope | None = None
|
||||
|
||||
|
||||
class VioBackendEstimate(VioAdapterModel):
|
||||
timestamp_ns: NonNegativeInt
|
||||
relative_pose: dict[str, float]
|
||||
velocity_mps: tuple[float, float, float]
|
||||
tracking_quality: float = Field(ge=0.0, le=1.0)
|
||||
bias_estimate: dict[str, float] | None = None
|
||||
covariance_hint: list[list[float]] | None = None
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from satellite_service import MissionCachePackage, SatelliteSyncBoundary
|
||||
from tile_manager import (
|
||||
GeneratedTileSidecar,
|
||||
GeneratedTileSyncPackage,
|
||||
TileManifestEntry,
|
||||
)
|
||||
|
||||
|
||||
def _manifest_entry() -> TileManifestEntry:
|
||||
return TileManifestEntry(
|
||||
tile_id="tile-1",
|
||||
chunk_id="chunk-1",
|
||||
crs="EPSG:3857",
|
||||
meters_per_pixel=0.3,
|
||||
capture_date="2026-05-01",
|
||||
expires_at=datetime(2026, 6, 1, tzinfo=timezone.utc),
|
||||
content_hash="sha256:tile",
|
||||
expected_content_hash="sha256:tile",
|
||||
sidecar_hash="sha256:sidecar",
|
||||
expected_sidecar_hash="sha256:sidecar",
|
||||
signature_hash="sig:trusted",
|
||||
provenance="suite-satellite-service",
|
||||
footprint={"min_lat": 49.0, "max_lat": 49.1},
|
||||
descriptor_ref="descriptors/chunk-1.vlad",
|
||||
)
|
||||
|
||||
|
||||
def _generated_package() -> GeneratedTileSyncPackage:
|
||||
sidecar = GeneratedTileSidecar(
|
||||
tile_id="generated-1",
|
||||
parent_frame_id="frame-1",
|
||||
parent_covariance_m=2.0,
|
||||
quality_score=0.8,
|
||||
trust_level="generated",
|
||||
provenance="nav-camera-generated",
|
||||
)
|
||||
return GeneratedTileSyncPackage(
|
||||
package_ref="generated/mission-1/sync-package.json",
|
||||
mission_id="mission-1",
|
||||
manifest_delta=({"tile_id": "generated-1", "trust_level": "generated"},),
|
||||
sidecars=(sidecar,),
|
||||
)
|
||||
|
||||
|
||||
def test_pre_flight_import_returns_package_for_tile_manager_validation() -> None:
|
||||
# Arrange
|
||||
boundary = SatelliteSyncBoundary()
|
||||
package = MissionCachePackage(
|
||||
package_id="pkg-1",
|
||||
mission_id="mission-1",
|
||||
manifest_entries=(_manifest_entry(),),
|
||||
)
|
||||
|
||||
# Act
|
||||
result = boundary.import_mission_cache(package, phase="pre_flight")
|
||||
|
||||
# Assert
|
||||
assert result.ready_for_tile_validation is True
|
||||
assert result.manifest_entries[0].tile_id == "tile-1"
|
||||
assert boundary.status().imported_package_ids == ("pkg-1",)
|
||||
|
||||
|
||||
def test_post_flight_upload_records_retryable_failure_for_audit() -> None:
|
||||
# Arrange
|
||||
boundary = SatelliteSyncBoundary(uploader=lambda package: "retryable_failure")
|
||||
|
||||
# Act
|
||||
result = boundary.upload_generated_tiles(_generated_package(), phase="post_flight")
|
||||
|
||||
# Assert
|
||||
assert result.upload_record is not None
|
||||
assert result.upload_record.status == "retryable_failure"
|
||||
assert result.upload_record.retained_for_retry is True
|
||||
assert boundary.status().retry_package_refs == ("generated/mission-1/sync-package.json",)
|
||||
|
||||
|
||||
def test_in_flight_sync_is_blocked_without_calling_network_boundary() -> None:
|
||||
# Arrange
|
||||
calls: list[str] = []
|
||||
|
||||
def uploader(package: GeneratedTileSyncPackage) -> str:
|
||||
calls.append(package.package_ref)
|
||||
return "success"
|
||||
|
||||
boundary = SatelliteSyncBoundary(uploader=uploader)
|
||||
|
||||
# Act
|
||||
result = boundary.upload_generated_tiles(_generated_package(), phase="in_flight")
|
||||
|
||||
# Assert
|
||||
assert result.upload_record is None
|
||||
assert result.error is not None
|
||||
assert result.error.cause == "mid_flight_network_blocked"
|
||||
assert calls == []
|
||||
@@ -0,0 +1,73 @@
|
||||
from shared.contracts import FramePacket, TelemetrySample
|
||||
from vio_adapter import LocalVioAdapter, VioInputPacket
|
||||
|
||||
|
||||
def _frame(**overrides: object) -> FramePacket:
|
||||
payload: dict[str, object] = {
|
||||
"frame_id": "frame-1",
|
||||
"timestamp_ns": 1_000_000,
|
||||
"image_ref": "replay/frame-1.jpg",
|
||||
"calibration_id": "calib-1",
|
||||
"occlusion": "clear",
|
||||
"quality": 0.85,
|
||||
}
|
||||
payload.update(overrides)
|
||||
return FramePacket.model_validate(payload)
|
||||
|
||||
|
||||
def _telemetry(timestamp_ns: int = 1_000_000) -> TelemetrySample:
|
||||
return TelemetrySample(
|
||||
timestamp_ns=timestamp_ns,
|
||||
imu={"accel_x": 0.1, "accel_y": 0.0, "accel_z": 9.8},
|
||||
attitude={"roll": 0.0, "pitch": 0.01, "yaw": 0.02},
|
||||
altitude_m=120.0,
|
||||
airspeed_mps=24.0,
|
||||
gps_health="lost",
|
||||
)
|
||||
|
||||
|
||||
def test_valid_synchronized_packet_emits_vio_state() -> None:
|
||||
# Arrange
|
||||
adapter = LocalVioAdapter()
|
||||
packet = VioInputPacket(frame=_frame(), telemetry_samples=(_telemetry(),))
|
||||
|
||||
# Act
|
||||
result = adapter.process(packet)
|
||||
|
||||
# Assert
|
||||
assert result.error is None
|
||||
assert result.state_packet is not None
|
||||
assert result.state_packet.timestamp_ns == 1_000_000
|
||||
assert result.state_packet.tracking_quality == 0.85
|
||||
assert result.health.state == "ready"
|
||||
|
||||
|
||||
def test_timestamp_mismatch_is_explicit_validation_error() -> None:
|
||||
# Arrange
|
||||
adapter = LocalVioAdapter(timestamp_tolerance_ns=1_000)
|
||||
packet = VioInputPacket(frame=_frame(), telemetry_samples=(_telemetry(2_000_000),))
|
||||
|
||||
# Act
|
||||
result = adapter.process(packet)
|
||||
|
||||
# Assert
|
||||
assert result.state_packet is None
|
||||
assert result.error is not None
|
||||
assert result.error.component == "vio_adapter"
|
||||
assert result.error.cause == "gap_exceeded"
|
||||
assert result.health.state == "degraded"
|
||||
|
||||
|
||||
def test_tracking_loss_degrades_health_without_emitting_absolute_position() -> None:
|
||||
# Arrange
|
||||
adapter = LocalVioAdapter(degraded_quality_threshold=0.35)
|
||||
packet = VioInputPacket(frame=_frame(quality=0.2), telemetry_samples=(_telemetry(),))
|
||||
|
||||
# Act
|
||||
result = adapter.process(packet)
|
||||
|
||||
# Assert
|
||||
assert result.state_packet is not None
|
||||
assert result.health.state == "degraded"
|
||||
assert "latitude_deg" not in result.state_packet.model_dump()
|
||||
assert "longitude_deg" not in result.state_packet.model_dump()
|
||||
Reference in New Issue
Block a user