diff --git a/_docs/02_tasks/todo/AZ-228_vio_adapter.md b/_docs/02_tasks/done/AZ-228_vio_adapter.md similarity index 100% rename from _docs/02_tasks/todo/AZ-228_vio_adapter.md rename to _docs/02_tasks/done/AZ-228_vio_adapter.md diff --git a/_docs/02_tasks/todo/AZ-229_satellite_service_sync.md b/_docs/02_tasks/done/AZ-229_satellite_service_sync.md similarity index 100% rename from _docs/02_tasks/todo/AZ-229_satellite_service_sync.md rename to _docs/02_tasks/done/AZ-229_satellite_service_sync.md diff --git a/_docs/03_implementation/batch_06_cycle1_report.md b/_docs/03_implementation/batch_06_cycle1_report.md new file mode 100644 index 0000000..9255788 --- /dev/null +++ b/_docs/03_implementation/batch_06_cycle1_report.md @@ -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 diff --git a/_docs/03_implementation/reviews/batch_06_review.md b/_docs/03_implementation/reviews/batch_06_review.md new file mode 100644 index 0000000..3d901a1 --- /dev/null +++ b/_docs/03_implementation/reviews/batch_06_review.md @@ -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` diff --git a/_docs/_autodev_state.md b/_docs/_autodev_state.md index 94027ee..34b4360 100644 --- a/_docs/_autodev_state.md +++ b/_docs/_autodev_state.md @@ -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 diff --git a/src/satellite_service/__init__.py b/src/satellite_service/__init__.py index 7f52eb5..1abbfe3 100644 --- a/src/satellite_service/__init__.py +++ b/src/satellite_service/__init__.py @@ -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", +] diff --git a/src/satellite_service/interfaces.py b/src/satellite_service/interfaces.py index b14373c..e40a015 100644 --- a/src/satellite_service/interfaces.py +++ b/src/satellite_service/interfaces.py @@ -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" diff --git a/src/satellite_service/types.py b/src/satellite_service/types.py index 93bea80..167003c 100644 --- a/src/satellite_service/types.py +++ b/src/satellite_service/types.py @@ -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"] diff --git a/src/vio_adapter/__init__.py b/src/vio_adapter/__init__.py index f2b41c6..382d983 100644 --- a/src/vio_adapter/__init__.py +++ b/src/vio_adapter/__init__.py @@ -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", +] diff --git a/src/vio_adapter/interfaces.py b/src/vio_adapter/interfaces.py index 80779ff..3f17a17 100644 --- a/src/vio_adapter/interfaces.py +++ b/src/vio_adapter/interfaces.py @@ -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 diff --git a/src/vio_adapter/types.py b/src/vio_adapter/types.py index 8eb0a01..fcf23e9 100644 --- a/src/vio_adapter/types.py +++ b/src/vio_adapter/types.py @@ -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 diff --git a/tests/unit/test_satellite_service_sync.py b/tests/unit/test_satellite_service_sync.py new file mode 100644 index 0000000..ffe70d8 --- /dev/null +++ b/tests/unit/test_satellite_service_sync.py @@ -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 == [] diff --git a/tests/unit/test_vio_adapter.py b/tests/unit/test_vio_adapter.py new file mode 100644 index 0000000..935bbff --- /dev/null +++ b/tests/unit/test_vio_adapter.py @@ -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()