mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 22:41:13 +00:00
[AZ-223] [AZ-224] [AZ-225] [AZ-227] Add runtime gateways
Implement the first runtime component boundaries around the shared contracts so downstream batches can consume typed frame, MAVLink, tile, and FDR behavior with focused tests and batch evidence. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from camera_ingest_calibration import (
|
||||
CalibrationMetadata,
|
||||
CameraFrameIngestor,
|
||||
NavigationFrame,
|
||||
)
|
||||
|
||||
|
||||
def _calibration() -> CalibrationMetadata:
|
||||
return CalibrationMetadata(
|
||||
calibration_id="calib-front-1",
|
||||
camera_model="global-shutter",
|
||||
image_width_px=1920,
|
||||
image_height_px=1080,
|
||||
focal_length_px=840.0,
|
||||
distortion_model="plumb_bob",
|
||||
)
|
||||
|
||||
|
||||
def test_valid_frame_packet_contains_metadata_reports_and_normalization_hint() -> None:
|
||||
# Arrange
|
||||
frame = NavigationFrame(
|
||||
frame_id="frame-1",
|
||||
timestamp_ns=1_000,
|
||||
image_ref="replay/frame-1.jpg",
|
||||
mean_luma=0.7,
|
||||
contrast=0.6,
|
||||
north_up_degrees=12.5,
|
||||
)
|
||||
|
||||
# Act
|
||||
packet = CameraFrameIngestor().ingest(frame, _calibration())
|
||||
|
||||
# Assert
|
||||
assert packet.contract.timestamp_ns == 1_000
|
||||
assert packet.contract.calibration_id == "calib-front-1"
|
||||
assert packet.quality_report.state == "usable"
|
||||
assert packet.occlusion_report.state == "clear"
|
||||
assert packet.normalization_hint.should_normalize_downstream is True
|
||||
|
||||
|
||||
def test_total_occlusion_marks_frame_unusable_for_vio_and_anchor() -> None:
|
||||
# Arrange
|
||||
frame = NavigationFrame(
|
||||
frame_id="frame-blackout",
|
||||
timestamp_ns=2_000,
|
||||
image_ref="replay/frame-blackout.jpg",
|
||||
mean_luma=0.01,
|
||||
contrast=0.01,
|
||||
)
|
||||
|
||||
# Act
|
||||
packet = CameraFrameIngestor().ingest(frame, _calibration())
|
||||
|
||||
# Assert
|
||||
assert packet.occlusion_report.state == "total"
|
||||
assert packet.usable_for_vio is False
|
||||
assert packet.usable_for_anchor is False
|
||||
|
||||
|
||||
def test_raw_frame_payload_retention_is_rejected() -> None:
|
||||
# Act
|
||||
with pytest.raises(ValidationError) as error:
|
||||
NavigationFrame(
|
||||
frame_id="frame-raw",
|
||||
timestamp_ns=3_000,
|
||||
image_ref="replay/frame-raw.jpg",
|
||||
mean_luma=0.7,
|
||||
contrast=0.6,
|
||||
raw_frame_retained=True,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert "references only" in str(error.value)
|
||||
@@ -0,0 +1,64 @@
|
||||
from shared.contracts import FdrEvent
|
||||
|
||||
from fdr_observability import FdrExportRequest, FdrPayload, InMemoryFlightRecorder
|
||||
|
||||
|
||||
def _event(event_type: str = "anchor") -> FdrEvent:
|
||||
return FdrEvent(
|
||||
event_type=event_type,
|
||||
timestamp_ns=1_000,
|
||||
component="anchor_verification",
|
||||
severity="info",
|
||||
payload_ref="pending",
|
||||
mission_id="mission-1",
|
||||
run_id="run-1",
|
||||
)
|
||||
|
||||
|
||||
def test_valid_event_append_indexes_metadata_and_payload_reference() -> None:
|
||||
# Arrange
|
||||
recorder = InMemoryFlightRecorder(segment_limit_bytes=1_000, storage_limit_bytes=2_000)
|
||||
payload = FdrPayload(ref="fdr://segments/1/payloads/anchor-1.cbor", size_bytes=128)
|
||||
|
||||
# Act
|
||||
result = recorder.append_event(_event(), payload)
|
||||
|
||||
# Assert
|
||||
assert result.appended is True
|
||||
assert result.event is not None
|
||||
assert result.event.payload_ref == payload.ref
|
||||
assert result.segment_id == "segment-0001"
|
||||
assert recorder.health.status == "ready"
|
||||
|
||||
|
||||
def test_rollover_threshold_records_explicit_rollover_result() -> None:
|
||||
# Arrange
|
||||
recorder = InMemoryFlightRecorder(segment_limit_bytes=100, storage_limit_bytes=500)
|
||||
recorder.append_event(_event("first"), FdrPayload(ref="fdr://payloads/1", size_bytes=80))
|
||||
|
||||
# Act
|
||||
result = recorder.append_event(
|
||||
_event("second"), FdrPayload(ref="fdr://payloads/2", size_bytes=50)
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.appended is True
|
||||
assert result.rollover is True
|
||||
assert result.segment_id == "segment-0002"
|
||||
|
||||
|
||||
def test_export_request_produces_queryable_evidence_artifacts() -> None:
|
||||
# Arrange
|
||||
recorder = InMemoryFlightRecorder(segment_limit_bytes=1_000, storage_limit_bytes=2_000)
|
||||
recorder.append_event(_event(), FdrPayload(ref="fdr://payloads/1", size_bytes=128))
|
||||
|
||||
# Act
|
||||
result = recorder.export(
|
||||
FdrExportRequest(mission_id="mission-1", run_id="run-1", include_analytics=True)
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.produced is True
|
||||
assert result.evidence_ref == "fdr://exports/mission-1/run-1/evidence.json"
|
||||
assert result.analytics_ref == "fdr://exports/mission-1/run-1/analytics.parquet"
|
||||
assert result.segments[0].event_count == 1
|
||||
@@ -0,0 +1,72 @@
|
||||
from shared.contracts import PositionEstimate
|
||||
|
||||
from mavlink_gcs_integration import (
|
||||
FlightControllerTelemetry,
|
||||
InMemoryMavlinkGateway,
|
||||
OperatorStatusMessage,
|
||||
)
|
||||
|
||||
|
||||
def test_telemetry_subscription_emits_normalized_sample() -> None:
|
||||
# Arrange
|
||||
gateway = InMemoryMavlinkGateway(status_rate_limit_ns=1_000)
|
||||
telemetry = FlightControllerTelemetry(
|
||||
timestamp_ns=1_000,
|
||||
acceleration_mps2=(0.1, 0.2, -9.8),
|
||||
attitude_rad=(0.01, 0.02, 1.57),
|
||||
altitude_m=250.0,
|
||||
airspeed_mps=17.5,
|
||||
gps_health="lost",
|
||||
)
|
||||
|
||||
# Act
|
||||
samples = gateway.subscribe_telemetry([telemetry])
|
||||
|
||||
# Assert
|
||||
assert len(samples) == 1
|
||||
assert samples[0].imu["accel_z"] == -9.8
|
||||
assert samples[0].attitude["yaw"] == 1.57
|
||||
assert samples[0].gps_health == "lost"
|
||||
|
||||
|
||||
def test_invalid_gps_input_estimate_is_rejected_without_emission() -> None:
|
||||
# Arrange
|
||||
gateway = InMemoryMavlinkGateway(status_rate_limit_ns=1_000)
|
||||
estimate = PositionEstimate(
|
||||
timestamp_ns=2_000,
|
||||
latitude_deg=49.9,
|
||||
longitude_deg=36.2,
|
||||
altitude_m=250.0,
|
||||
covariance_semimajor_m=10.0,
|
||||
source_label="no_fix",
|
||||
fix_type=1,
|
||||
horizontal_accuracy_m=10.0,
|
||||
anchor_age_ms=0,
|
||||
)
|
||||
|
||||
# Act
|
||||
result = gateway.emit_gps_input(estimate)
|
||||
|
||||
# Assert
|
||||
assert result.emitted is False
|
||||
assert result.error is not None
|
||||
assert result.error.category == "validation"
|
||||
assert gateway.emitted_gps_inputs == []
|
||||
|
||||
|
||||
def test_operator_status_messages_are_rate_limited_by_text() -> None:
|
||||
# Arrange
|
||||
gateway = InMemoryMavlinkGateway(status_rate_limit_ns=1_000)
|
||||
messages = [
|
||||
OperatorStatusMessage(timestamp_ns=1_000, severity="warning", text="GPS denied"),
|
||||
OperatorStatusMessage(timestamp_ns=1_500, severity="warning", text="GPS denied"),
|
||||
OperatorStatusMessage(timestamp_ns=2_100, severity="warning", text="GPS denied"),
|
||||
]
|
||||
|
||||
# Act
|
||||
result = gateway.emit_status(messages)
|
||||
|
||||
# Assert
|
||||
assert [message.timestamp_ns for message in result.emitted] == [1_000, 2_100]
|
||||
assert [message.timestamp_ns for message in result.suppressed] == [1_500]
|
||||
assert len(gateway.emitted_status_messages) == 2
|
||||
@@ -0,0 +1,78 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from tile_manager import LocalTileManager, TileManifestEntry
|
||||
|
||||
NOW = datetime(2026, 5, 3, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _entry(**overrides: object) -> TileManifestEntry:
|
||||
payload: dict[str, object] = {
|
||||
"tile_id": "tile-1",
|
||||
"chunk_id": "chunk-1",
|
||||
"crs": "EPSG:3857",
|
||||
"meters_per_pixel": 0.3,
|
||||
"capture_date": "2026-05-01",
|
||||
"expires_at": "2026-06-01T00:00:00+00:00",
|
||||
"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": 50.0},
|
||||
"descriptor_ref": "descriptors/chunk-1.vlad",
|
||||
}
|
||||
payload.update(overrides)
|
||||
return TileManifestEntry.model_validate(payload)
|
||||
|
||||
|
||||
def test_valid_cache_manifest_activates_trusted_records() -> None:
|
||||
# Arrange
|
||||
manager = LocalTileManager(trusted_signature_hashes={"sig:trusted"}, now=NOW)
|
||||
|
||||
# Act
|
||||
report = manager.validate_cache([_entry()])
|
||||
|
||||
# Assert
|
||||
assert report.activated is True
|
||||
assert report.decisions[0].accepted is True
|
||||
assert report.trusted_records[0].trust_level == "trusted"
|
||||
|
||||
|
||||
def test_tampered_or_stale_tile_is_rejected_with_auditable_reason() -> None:
|
||||
# Arrange
|
||||
manager = LocalTileManager(trusted_signature_hashes={"sig:trusted"}, now=NOW)
|
||||
tampered = _entry(tile_id="tile-tampered", content_hash="sha256:bad")
|
||||
stale = _entry(
|
||||
tile_id="tile-stale",
|
||||
chunk_id="chunk-stale",
|
||||
expires_at="2026-05-01T00:00:00+00:00",
|
||||
)
|
||||
|
||||
# Act
|
||||
report = manager.validate_cache([tampered, stale])
|
||||
|
||||
# Assert
|
||||
assert report.activated is False
|
||||
assert [decision.reason for decision in report.decisions] == [
|
||||
"content_hash_mismatch",
|
||||
"stale",
|
||||
]
|
||||
|
||||
|
||||
def test_tile_metadata_lookup_returns_record_or_explicit_rejection() -> None:
|
||||
# Arrange
|
||||
manager = LocalTileManager(trusted_signature_hashes={"sig:trusted"}, now=NOW)
|
||||
manager.validate_cache([_entry()])
|
||||
|
||||
# Act
|
||||
found = manager.get_tile_metadata("chunk-1")
|
||||
missing = manager.get_tile_metadata("missing")
|
||||
|
||||
# Assert
|
||||
assert found.found is True
|
||||
assert found.record is not None
|
||||
assert found.descriptor_ref == "descriptors/chunk-1.vlad"
|
||||
assert missing.found is False
|
||||
assert missing.error is not None
|
||||
assert missing.error.category == "validation"
|
||||
Reference in New Issue
Block a user