mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 10:21:13 +00:00
start over again
This commit is contained in:
@@ -1 +0,0 @@
|
||||
"""Source-root package marker for editable installs."""
|
||||
@@ -1,33 +0,0 @@
|
||||
"""Anchor verification component."""
|
||||
|
||||
from .interfaces import (
|
||||
AnchorVerifier,
|
||||
FeatureMatcher,
|
||||
GeometryGatedAnchorVerifier,
|
||||
KeypointRansacMatcher,
|
||||
)
|
||||
from .types import (
|
||||
AnchorFrame,
|
||||
AnchorVerificationResult,
|
||||
CandidateTile,
|
||||
GeometryGateConfig,
|
||||
MatchEvidence,
|
||||
MatcherBenchmarkReport,
|
||||
MatcherBenchmarkResult,
|
||||
MatcherProfile,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AnchorFrame",
|
||||
"AnchorVerificationResult",
|
||||
"AnchorVerifier",
|
||||
"CandidateTile",
|
||||
"FeatureMatcher",
|
||||
"GeometryGateConfig",
|
||||
"GeometryGatedAnchorVerifier",
|
||||
"KeypointRansacMatcher",
|
||||
"MatchEvidence",
|
||||
"MatcherBenchmarkReport",
|
||||
"MatcherBenchmarkResult",
|
||||
"MatcherProfile",
|
||||
]
|
||||
@@ -1,202 +0,0 @@
|
||||
"""Public anchor verification interfaces."""
|
||||
|
||||
from statistics import median
|
||||
from time import perf_counter
|
||||
from typing import Protocol
|
||||
|
||||
from shared.contracts import AnchorDecision
|
||||
|
||||
from .types import (
|
||||
AnchorFrame,
|
||||
AnchorVerificationResult,
|
||||
CandidateTile,
|
||||
GeometryGateConfig,
|
||||
MatchEvidence,
|
||||
MatcherBenchmarkReport,
|
||||
MatcherBenchmarkResult,
|
||||
MatcherProfile,
|
||||
)
|
||||
|
||||
|
||||
class AnchorVerifier(Protocol):
|
||||
"""Verifies retrieved candidates against camera observations."""
|
||||
|
||||
def verify(self, frame: AnchorFrame, evidence: MatchEvidence) -> AnchorVerificationResult:
|
||||
"""Return an anchor decision for one candidate."""
|
||||
|
||||
|
||||
class FeatureMatcher(Protocol):
|
||||
"""Computes correspondence evidence from local frame and tile inputs."""
|
||||
|
||||
def compute(
|
||||
self,
|
||||
frame: AnchorFrame,
|
||||
tile: CandidateTile,
|
||||
matcher_profile: MatcherProfile,
|
||||
) -> MatchEvidence:
|
||||
"""Return matcher and geometry evidence for one candidate tile."""
|
||||
|
||||
|
||||
class KeypointRansacMatcher:
|
||||
"""Small CPU matcher for keypoint fixtures and dependency-gated runs."""
|
||||
|
||||
def __init__(self, inlier_threshold_px: float = 2.0) -> None:
|
||||
self._inlier_threshold_px = inlier_threshold_px
|
||||
|
||||
def compute(
|
||||
self,
|
||||
frame: AnchorFrame,
|
||||
tile: CandidateTile,
|
||||
matcher_profile: MatcherProfile,
|
||||
) -> MatchEvidence:
|
||||
started = perf_counter()
|
||||
correspondences = tuple(zip(frame.keypoints, tile.keypoints))
|
||||
if len(correspondences) < 4:
|
||||
return MatchEvidence(
|
||||
candidate=tile.candidate,
|
||||
matcher_profile=matcher_profile,
|
||||
inliers=0,
|
||||
mean_reprojection_error_px=self._inlier_threshold_px + 1.0,
|
||||
homography=None,
|
||||
runtime_ms=(perf_counter() - started) * 1000.0,
|
||||
provenance_trusted=tile.provenance_trusted,
|
||||
evidence_source="computed_geometry",
|
||||
)
|
||||
|
||||
dx_values = tuple(tile_point[0] - frame_point[0] for frame_point, tile_point in correspondences)
|
||||
dy_values = tuple(tile_point[1] - frame_point[1] for frame_point, tile_point in correspondences)
|
||||
dx = median(dx_values)
|
||||
dy = median(dy_values)
|
||||
residuals = tuple(
|
||||
((frame_point[0] + dx - tile_point[0]) ** 2 + (frame_point[1] + dy - tile_point[1]) ** 2)
|
||||
** 0.5
|
||||
for frame_point, tile_point in correspondences
|
||||
)
|
||||
inlier_residuals = tuple(
|
||||
residual for residual in residuals if residual <= self._inlier_threshold_px
|
||||
)
|
||||
mean_error = (
|
||||
sum(inlier_residuals) / len(inlier_residuals)
|
||||
if inlier_residuals
|
||||
else self._inlier_threshold_px + 1.0
|
||||
)
|
||||
homography = (
|
||||
{
|
||||
"h00": 1.0,
|
||||
"h01": 0.0,
|
||||
"h02": dx,
|
||||
"h10": 0.0,
|
||||
"h11": 1.0,
|
||||
"h12": dy,
|
||||
"h20": 0.0,
|
||||
"h21": 0.0,
|
||||
"h22": 1.0,
|
||||
}
|
||||
if inlier_residuals
|
||||
else None
|
||||
)
|
||||
return MatchEvidence(
|
||||
candidate=tile.candidate,
|
||||
matcher_profile=matcher_profile,
|
||||
inliers=len(inlier_residuals),
|
||||
mean_reprojection_error_px=mean_error,
|
||||
homography=homography,
|
||||
runtime_ms=(perf_counter() - started) * 1000.0,
|
||||
provenance_trusted=tile.provenance_trusted,
|
||||
evidence_source="computed_geometry",
|
||||
)
|
||||
|
||||
|
||||
class GeometryGatedAnchorVerifier:
|
||||
"""Converts matcher evidence into accepted/rejected anchor decisions."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
gates: GeometryGateConfig | None = None,
|
||||
matcher: FeatureMatcher | None = None,
|
||||
) -> None:
|
||||
self._gates = gates or GeometryGateConfig()
|
||||
self._matcher = matcher or KeypointRansacMatcher()
|
||||
|
||||
def verify(self, frame: AnchorFrame, evidence: MatchEvidence) -> AnchorVerificationResult:
|
||||
accepted, reason = self._classify(frame, evidence)
|
||||
decision = AnchorDecision(
|
||||
candidate_id=evidence.candidate.chunk_id,
|
||||
accepted=accepted,
|
||||
estimated_pose=self._estimated_pose(evidence) if accepted else None,
|
||||
inliers=evidence.inliers,
|
||||
mean_reprojection_error_px=evidence.mean_reprojection_error_px,
|
||||
rejection_reason=None if accepted else reason,
|
||||
)
|
||||
return AnchorVerificationResult(
|
||||
decision=decision,
|
||||
matcher_profile=evidence.matcher_profile,
|
||||
reason=reason,
|
||||
homography=evidence.homography,
|
||||
freshness_status=evidence.candidate.freshness_status,
|
||||
)
|
||||
|
||||
def verify_candidate(
|
||||
self,
|
||||
frame: AnchorFrame,
|
||||
tile: CandidateTile,
|
||||
matcher_profile: MatcherProfile = "sift_orb",
|
||||
) -> AnchorVerificationResult:
|
||||
evidence = self._matcher.compute(frame, tile, matcher_profile)
|
||||
return self.verify(frame, evidence)
|
||||
|
||||
def benchmark(
|
||||
self, frame: AnchorFrame, evidences: tuple[MatchEvidence, ...]
|
||||
) -> MatcherBenchmarkReport:
|
||||
results: list[MatcherBenchmarkResult] = []
|
||||
for evidence in evidences:
|
||||
verification = self.verify(frame, evidence)
|
||||
results.append(
|
||||
MatcherBenchmarkResult(
|
||||
matcher_profile=evidence.matcher_profile,
|
||||
runtime_ms=evidence.runtime_ms,
|
||||
inliers=evidence.inliers,
|
||||
mean_reprojection_error_px=evidence.mean_reprojection_error_px,
|
||||
accepted=verification.decision.accepted,
|
||||
reason=verification.reason,
|
||||
)
|
||||
)
|
||||
return MatcherBenchmarkReport(
|
||||
results=tuple(results),
|
||||
)
|
||||
|
||||
def benchmark_candidates(
|
||||
self,
|
||||
frame: AnchorFrame,
|
||||
tiles: tuple[CandidateTile, ...],
|
||||
matcher_profile: MatcherProfile = "sift_orb",
|
||||
) -> MatcherBenchmarkReport:
|
||||
return self.benchmark(
|
||||
frame,
|
||||
tuple(self._matcher.compute(frame, tile, matcher_profile) for tile in tiles),
|
||||
)
|
||||
|
||||
def _classify(self, frame: AnchorFrame, evidence: MatchEvidence) -> tuple[bool, str]:
|
||||
if not frame.usable_for_anchor:
|
||||
return False, "frame_not_usable"
|
||||
if evidence.candidate.freshness_status != "fresh" or not evidence.provenance_trusted:
|
||||
return False, "stale_or_untrusted_provenance"
|
||||
if evidence.homography is None:
|
||||
return False, "geometry_failure"
|
||||
if evidence.inliers < self._gates.min_inliers:
|
||||
return False, "low_inliers"
|
||||
if evidence.mean_reprojection_error_px > self._gates.max_mean_reprojection_error_px:
|
||||
return False, "high_mre"
|
||||
return True, "accepted_geometry"
|
||||
|
||||
def _estimated_pose(self, evidence: MatchEvidence) -> dict[str, float]:
|
||||
footprint = evidence.candidate.footprint
|
||||
min_lat = footprint.get("min_lat", 0.0)
|
||||
max_lat = footprint.get("max_lat", min_lat)
|
||||
min_lon = footprint.get("min_lon", 0.0)
|
||||
max_lon = footprint.get("max_lon", min_lon)
|
||||
return {
|
||||
"latitude_deg": (min_lat + max_lat) / 2.0,
|
||||
"longitude_deg": (min_lon + max_lon) / 2.0,
|
||||
"mean_reprojection_error_px": evidence.mean_reprojection_error_px,
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
# Anchor Verification Matcher Package
|
||||
|
||||
Exports local feature matching and geometry verification boundaries owned by `anchor_verification`.
|
||||
@@ -1,5 +0,0 @@
|
||||
"""Anchor feature matching package exports."""
|
||||
|
||||
from anchor_verification.interfaces import FeatureMatcher, KeypointRansacMatcher
|
||||
|
||||
__all__ = ["FeatureMatcher", "KeypointRansacMatcher"]
|
||||
@@ -1,65 +0,0 @@
|
||||
"""Public anchor verification models."""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, NonNegativeFloat, NonNegativeInt
|
||||
|
||||
from shared.contracts import AnchorDecision, VprCandidate
|
||||
|
||||
|
||||
class AnchorVerificationModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
|
||||
MatcherProfile = Literal["aliked_lightglue", "disk_lightglue", "sift_orb"]
|
||||
|
||||
|
||||
class AnchorFrame(AnchorVerificationModel):
|
||||
frame_id: str = Field(min_length=1)
|
||||
image_ref: str = Field(min_length=1)
|
||||
usable_for_anchor: bool = True
|
||||
keypoints: tuple[tuple[float, float], ...] = ()
|
||||
|
||||
|
||||
class CandidateTile(AnchorVerificationModel):
|
||||
candidate: VprCandidate
|
||||
image_ref: str = Field(min_length=1)
|
||||
keypoints: tuple[tuple[float, float], ...] = ()
|
||||
provenance_trusted: bool = True
|
||||
|
||||
|
||||
class GeometryGateConfig(AnchorVerificationModel):
|
||||
min_inliers: NonNegativeInt = 20
|
||||
max_mean_reprojection_error_px: NonNegativeFloat = 3.0
|
||||
|
||||
|
||||
class MatchEvidence(AnchorVerificationModel):
|
||||
candidate: VprCandidate
|
||||
matcher_profile: MatcherProfile
|
||||
inliers: NonNegativeInt
|
||||
mean_reprojection_error_px: NonNegativeFloat
|
||||
homography: dict[str, float] | None = None
|
||||
runtime_ms: NonNegativeFloat
|
||||
provenance_trusted: bool = True
|
||||
evidence_source: Literal["computed_geometry", "external_evidence"] = "external_evidence"
|
||||
|
||||
|
||||
class AnchorVerificationResult(AnchorVerificationModel):
|
||||
decision: AnchorDecision
|
||||
matcher_profile: MatcherProfile
|
||||
reason: str = Field(min_length=1)
|
||||
homography: dict[str, float] | None = None
|
||||
freshness_status: Literal["fresh", "stale", "rejected"]
|
||||
|
||||
|
||||
class MatcherBenchmarkResult(AnchorVerificationModel):
|
||||
matcher_profile: MatcherProfile
|
||||
runtime_ms: NonNegativeFloat
|
||||
inliers: NonNegativeInt
|
||||
mean_reprojection_error_px: NonNegativeFloat
|
||||
accepted: bool
|
||||
reason: str = Field(min_length=1)
|
||||
|
||||
|
||||
class MatcherBenchmarkReport(AnchorVerificationModel):
|
||||
results: tuple[MatcherBenchmarkResult, ...] = Field(min_length=1)
|
||||
@@ -1,22 +0,0 @@
|
||||
"""Camera ingest and calibration component."""
|
||||
|
||||
from .interfaces import CameraFrameIngestor, FrameProvider
|
||||
from .types import (
|
||||
CalibrationMetadata,
|
||||
FrameQualityReport,
|
||||
IngestedFramePacket,
|
||||
NavigationFrame,
|
||||
NormalizationHint,
|
||||
OcclusionReport,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CalibrationMetadata",
|
||||
"CameraFrameIngestor",
|
||||
"FrameProvider",
|
||||
"FrameQualityReport",
|
||||
"IngestedFramePacket",
|
||||
"NavigationFrame",
|
||||
"NormalizationHint",
|
||||
"OcclusionReport",
|
||||
]
|
||||
@@ -1,96 +0,0 @@
|
||||
"""Public camera ingest interfaces."""
|
||||
|
||||
from typing import Any, Protocol
|
||||
|
||||
from shared.contracts import FramePacket
|
||||
|
||||
from .types import (
|
||||
CalibrationMetadata,
|
||||
FrameQualityReport,
|
||||
IngestedFramePacket,
|
||||
NavigationFrame,
|
||||
NormalizationHint,
|
||||
OcclusionReport,
|
||||
)
|
||||
|
||||
|
||||
class FrameProvider(Protocol):
|
||||
"""Source of navigation frames for downstream localization components."""
|
||||
|
||||
def next_frame(self) -> Any:
|
||||
"""Return the next frame packet."""
|
||||
|
||||
|
||||
class CameraFrameIngestor:
|
||||
"""Build metadata-only frame packets for downstream localization components."""
|
||||
|
||||
def ingest(
|
||||
self,
|
||||
frame: NavigationFrame,
|
||||
calibration: CalibrationMetadata,
|
||||
) -> IngestedFramePacket:
|
||||
quality = self.classify_quality(frame)
|
||||
occlusion = self.detect_occlusion(frame)
|
||||
hint = NormalizationHint(
|
||||
north_up_degrees=frame.north_up_degrees,
|
||||
should_normalize_downstream=frame.north_up_degrees not in (None, 0.0),
|
||||
)
|
||||
contract = FramePacket(
|
||||
frame_id=frame.frame_id,
|
||||
timestamp_ns=frame.timestamp_ns,
|
||||
image_ref=frame.image_ref,
|
||||
calibration_id=calibration.calibration_id,
|
||||
occlusion=occlusion.state,
|
||||
quality=quality.score,
|
||||
normalization_hint=(
|
||||
f"north_up_degrees={hint.north_up_degrees}"
|
||||
if hint.should_normalize_downstream
|
||||
else None
|
||||
),
|
||||
raw_frame_retained=False,
|
||||
)
|
||||
|
||||
return IngestedFramePacket(
|
||||
contract=contract,
|
||||
quality_report=quality,
|
||||
occlusion_report=occlusion,
|
||||
normalization_hint=hint,
|
||||
)
|
||||
|
||||
def classify_quality(self, frame: NavigationFrame) -> FrameQualityReport:
|
||||
if not frame.readable:
|
||||
return FrameQualityReport(score=0.0, state="unusable", reasons=("unreadable",))
|
||||
|
||||
score = min(frame.mean_luma, frame.contrast)
|
||||
reasons: list[str] = []
|
||||
if frame.mean_luma < 0.05:
|
||||
reasons.append("blackout")
|
||||
if frame.contrast < 0.05:
|
||||
reasons.append("low_contrast")
|
||||
|
||||
if reasons:
|
||||
return FrameQualityReport(score=score, state="unusable", reasons=tuple(reasons))
|
||||
if score < 0.25:
|
||||
return FrameQualityReport(score=score, state="degraded", reasons=("low_quality",))
|
||||
return FrameQualityReport(score=score, state="usable")
|
||||
|
||||
def detect_occlusion(self, frame: NavigationFrame) -> OcclusionReport:
|
||||
if not frame.readable:
|
||||
return OcclusionReport(
|
||||
state="unreadable",
|
||||
usable_for_vio=False,
|
||||
usable_for_anchor=False,
|
||||
)
|
||||
if frame.mean_luma < 0.05 or frame.contrast < 0.05:
|
||||
return OcclusionReport(
|
||||
state="total",
|
||||
usable_for_vio=False,
|
||||
usable_for_anchor=False,
|
||||
)
|
||||
if frame.mean_luma < 0.25 or frame.contrast < 0.25:
|
||||
return OcclusionReport(
|
||||
state="partial",
|
||||
usable_for_vio=True,
|
||||
usable_for_anchor=False,
|
||||
)
|
||||
return OcclusionReport(state="clear", usable_for_vio=True, usable_for_anchor=True)
|
||||
@@ -1,70 +0,0 @@
|
||||
"""Public camera ingest models."""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, NonNegativeInt, PositiveFloat
|
||||
from pydantic import model_validator
|
||||
|
||||
from shared.contracts import FramePacket
|
||||
|
||||
|
||||
class CameraIngestModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
|
||||
class CalibrationMetadata(CameraIngestModel):
|
||||
calibration_id: str = Field(min_length=1)
|
||||
camera_model: str = Field(min_length=1)
|
||||
image_width_px: int = Field(gt=0)
|
||||
image_height_px: int = Field(gt=0)
|
||||
focal_length_px: PositiveFloat
|
||||
distortion_model: str = Field(min_length=1)
|
||||
|
||||
|
||||
class NavigationFrame(CameraIngestModel):
|
||||
frame_id: str = Field(min_length=1)
|
||||
timestamp_ns: NonNegativeInt
|
||||
image_ref: str = Field(min_length=1)
|
||||
readable: bool = True
|
||||
mean_luma: float = Field(ge=0.0, le=1.0)
|
||||
contrast: float = Field(ge=0.0, le=1.0)
|
||||
north_up_degrees: float | None = Field(default=None, ge=-180.0, le=180.0)
|
||||
raw_frame_retained: bool = False
|
||||
|
||||
@model_validator(mode="after")
|
||||
def raw_payload_must_not_be_retained(self) -> "NavigationFrame":
|
||||
if self.raw_frame_retained:
|
||||
raise ValueError("camera ingest must retain references only, not raw frames")
|
||||
return self
|
||||
|
||||
|
||||
class FrameQualityReport(CameraIngestModel):
|
||||
score: float = Field(ge=0.0, le=1.0)
|
||||
state: Literal["usable", "degraded", "unusable"]
|
||||
reasons: tuple[str, ...] = ()
|
||||
|
||||
|
||||
class OcclusionReport(CameraIngestModel):
|
||||
state: Literal["clear", "partial", "total", "unreadable"]
|
||||
usable_for_vio: bool
|
||||
usable_for_anchor: bool
|
||||
|
||||
|
||||
class NormalizationHint(CameraIngestModel):
|
||||
north_up_degrees: float | None = Field(default=None, ge=-180.0, le=180.0)
|
||||
should_normalize_downstream: bool = False
|
||||
|
||||
|
||||
class IngestedFramePacket(CameraIngestModel):
|
||||
contract: FramePacket
|
||||
quality_report: FrameQualityReport
|
||||
occlusion_report: OcclusionReport
|
||||
normalization_hint: NormalizationHint
|
||||
|
||||
@property
|
||||
def usable_for_vio(self) -> bool:
|
||||
return self.occlusion_report.usable_for_vio and self.quality_report.state != "unusable"
|
||||
|
||||
@property
|
||||
def usable_for_anchor(self) -> bool:
|
||||
return self.occlusion_report.usable_for_anchor and self.quality_report.state != "unusable"
|
||||
@@ -1,22 +0,0 @@
|
||||
"""Flight data recorder and observability component."""
|
||||
|
||||
from .interfaces import FlightRecorder, InMemoryFlightRecorder
|
||||
from .types import (
|
||||
FdrAppendResult,
|
||||
FdrExportRequest,
|
||||
FdrExportResult,
|
||||
FdrHealth,
|
||||
FdrPayload,
|
||||
FdrSegmentSummary,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"FdrAppendResult",
|
||||
"FdrExportRequest",
|
||||
"FdrExportResult",
|
||||
"FdrHealth",
|
||||
"FdrPayload",
|
||||
"FdrSegmentSummary",
|
||||
"FlightRecorder",
|
||||
"InMemoryFlightRecorder",
|
||||
]
|
||||
@@ -1,121 +0,0 @@
|
||||
"""Public flight recorder interfaces."""
|
||||
|
||||
from typing import Any, Protocol
|
||||
|
||||
from shared.contracts import FdrEvent
|
||||
from shared.errors import ErrorEnvelope
|
||||
|
||||
from .types import (
|
||||
FdrAppendResult,
|
||||
FdrExportRequest,
|
||||
FdrExportResult,
|
||||
FdrHealth,
|
||||
FdrPayload,
|
||||
FdrSegmentSummary,
|
||||
)
|
||||
|
||||
|
||||
class FlightRecorder(Protocol):
|
||||
"""Append-only event recorder for runtime evidence."""
|
||||
|
||||
def append_event(self, event: Any) -> None:
|
||||
"""Persist one FDR event."""
|
||||
|
||||
def export(self) -> Any:
|
||||
"""Export recorded evidence for post-flight analysis."""
|
||||
|
||||
|
||||
class InMemoryFlightRecorder:
|
||||
"""Bounded append-only recorder for runtime evidence metadata."""
|
||||
|
||||
def __init__(self, segment_limit_bytes: int, storage_limit_bytes: int) -> None:
|
||||
if segment_limit_bytes <= 0:
|
||||
raise ValueError("segment_limit_bytes must be positive")
|
||||
if storage_limit_bytes < segment_limit_bytes:
|
||||
raise ValueError("storage_limit_bytes must be at least one segment")
|
||||
self._segment_limit_bytes = segment_limit_bytes
|
||||
self._storage_limit_bytes = storage_limit_bytes
|
||||
self._segments: list[list[FdrEvent]] = [[]]
|
||||
self._segment_bytes: list[int] = [0]
|
||||
self._used_bytes = 0
|
||||
|
||||
@property
|
||||
def health(self) -> FdrHealth:
|
||||
if self._used_bytes >= self._storage_limit_bytes:
|
||||
return FdrHealth(
|
||||
status="critical",
|
||||
used_bytes=self._used_bytes,
|
||||
max_bytes=self._storage_limit_bytes,
|
||||
message="fdr storage limit reached",
|
||||
)
|
||||
if self._used_bytes >= int(self._storage_limit_bytes * 0.9):
|
||||
return FdrHealth(
|
||||
status="degraded",
|
||||
used_bytes=self._used_bytes,
|
||||
max_bytes=self._storage_limit_bytes,
|
||||
message="fdr storage nearing limit",
|
||||
)
|
||||
return FdrHealth(
|
||||
status="ready",
|
||||
used_bytes=self._used_bytes,
|
||||
max_bytes=self._storage_limit_bytes,
|
||||
message="fdr storage ready",
|
||||
)
|
||||
|
||||
def append_event(self, event: FdrEvent, payload: FdrPayload) -> FdrAppendResult:
|
||||
if self._used_bytes + payload.size_bytes > self._storage_limit_bytes:
|
||||
return FdrAppendResult(
|
||||
appended=False,
|
||||
error=ErrorEnvelope(
|
||||
component="fdr_observability",
|
||||
category="resource",
|
||||
message="fdr storage limit reached",
|
||||
severity="critical",
|
||||
retryable=False,
|
||||
),
|
||||
)
|
||||
|
||||
rollover = False
|
||||
if self._segment_bytes[-1] + payload.size_bytes > self._segment_limit_bytes:
|
||||
self._segments.append([])
|
||||
self._segment_bytes.append(0)
|
||||
rollover = True
|
||||
|
||||
segment_index = len(self._segments) - 1
|
||||
stored_event = event.model_copy(update={"payload_ref": payload.ref})
|
||||
self._segments[segment_index].append(stored_event)
|
||||
self._segment_bytes[segment_index] += payload.size_bytes
|
||||
self._used_bytes += payload.size_bytes
|
||||
|
||||
return FdrAppendResult(
|
||||
appended=True,
|
||||
event=stored_event,
|
||||
segment_id=self._segment_id(segment_index),
|
||||
rollover=rollover,
|
||||
)
|
||||
|
||||
def export(self, request: FdrExportRequest) -> FdrExportResult:
|
||||
segments = tuple(
|
||||
FdrSegmentSummary(
|
||||
segment_id=self._segment_id(index),
|
||||
event_count=len(events),
|
||||
bytes_used=self._segment_bytes[index],
|
||||
)
|
||||
for index, events in enumerate(self._segments)
|
||||
if events
|
||||
)
|
||||
evidence_ref = f"fdr://exports/{request.mission_id}/{request.run_id}/evidence.json"
|
||||
analytics_ref = (
|
||||
f"fdr://exports/{request.mission_id}/{request.run_id}/analytics.parquet"
|
||||
if request.include_analytics
|
||||
else None
|
||||
)
|
||||
return FdrExportResult(
|
||||
produced=True,
|
||||
evidence_ref=evidence_ref,
|
||||
segments=segments,
|
||||
analytics_ref=analytics_ref,
|
||||
)
|
||||
|
||||
def _segment_id(self, index: int) -> str:
|
||||
return f"segment-{index + 1:04d}"
|
||||
@@ -1,52 +0,0 @@
|
||||
"""Public FDR models."""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, NonNegativeInt, PositiveInt
|
||||
|
||||
from shared.contracts import FdrEvent
|
||||
from shared.errors import ErrorEnvelope
|
||||
|
||||
|
||||
class FdrModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
|
||||
class FdrPayload(FdrModel):
|
||||
ref: str = Field(min_length=1)
|
||||
size_bytes: PositiveInt
|
||||
redacted: bool = True
|
||||
|
||||
|
||||
class FdrAppendResult(FdrModel):
|
||||
appended: bool
|
||||
event: FdrEvent | None = None
|
||||
segment_id: str | None = None
|
||||
rollover: bool = False
|
||||
error: ErrorEnvelope | None = None
|
||||
|
||||
|
||||
class FdrSegmentSummary(FdrModel):
|
||||
segment_id: str = Field(min_length=1)
|
||||
event_count: NonNegativeInt
|
||||
bytes_used: NonNegativeInt
|
||||
|
||||
|
||||
class FdrHealth(FdrModel):
|
||||
status: Literal["ready", "degraded", "critical"]
|
||||
used_bytes: NonNegativeInt
|
||||
max_bytes: PositiveInt
|
||||
message: str
|
||||
|
||||
|
||||
class FdrExportRequest(FdrModel):
|
||||
mission_id: str = Field(min_length=1)
|
||||
run_id: str = Field(min_length=1)
|
||||
include_analytics: bool = False
|
||||
|
||||
|
||||
class FdrExportResult(FdrModel):
|
||||
produced: bool
|
||||
evidence_ref: str = Field(min_length=1)
|
||||
segments: tuple[FdrSegmentSummary, ...]
|
||||
analytics_ref: str | None = None
|
||||
@@ -1,24 +0,0 @@
|
||||
"""MAVLink and GCS integration component."""
|
||||
|
||||
from .interfaces import InMemoryMavlinkGateway, MavlinkGateway
|
||||
from .types import (
|
||||
FlightControllerTelemetry,
|
||||
GpsEmissionResult,
|
||||
GpsInputPacket,
|
||||
OperatorStatusMessage,
|
||||
StatusEmissionResult,
|
||||
gps_input_from_estimate,
|
||||
normalize_telemetry,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"FlightControllerTelemetry",
|
||||
"GpsEmissionResult",
|
||||
"GpsInputPacket",
|
||||
"InMemoryMavlinkGateway",
|
||||
"MavlinkGateway",
|
||||
"OperatorStatusMessage",
|
||||
"StatusEmissionResult",
|
||||
"gps_input_from_estimate",
|
||||
"normalize_telemetry",
|
||||
]
|
||||
@@ -1,86 +0,0 @@
|
||||
"""Public MAVLink gateway interfaces."""
|
||||
|
||||
from typing import Any, Protocol
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from shared.contracts import PositionEstimate, TelemetrySample
|
||||
from shared.errors import ErrorEnvelope
|
||||
|
||||
from .types import (
|
||||
FlightControllerTelemetry,
|
||||
GpsEmissionResult,
|
||||
OperatorStatusMessage,
|
||||
StatusEmissionResult,
|
||||
gps_input_from_estimate,
|
||||
normalize_telemetry,
|
||||
)
|
||||
|
||||
|
||||
class MavlinkGateway(Protocol):
|
||||
"""Bridges FC telemetry inputs and localization GPS_INPUT outputs."""
|
||||
|
||||
def subscribe_telemetry(self) -> Any:
|
||||
"""Subscribe to flight-controller telemetry."""
|
||||
|
||||
def emit_gps_input(self, estimate: Any) -> None:
|
||||
"""Emit one localization estimate to the flight controller."""
|
||||
|
||||
|
||||
class InMemoryMavlinkGateway:
|
||||
"""Deterministic gateway boundary used by runtime adapters and tests."""
|
||||
|
||||
def __init__(self, status_rate_limit_ns: int) -> None:
|
||||
if status_rate_limit_ns < 0:
|
||||
raise ValueError("status_rate_limit_ns must be non-negative")
|
||||
self._status_rate_limit_ns = status_rate_limit_ns
|
||||
self._last_status_timestamp_by_text: dict[str, int] = {}
|
||||
self.emitted_gps_inputs: list[object] = []
|
||||
self.emitted_status_messages: list[OperatorStatusMessage] = []
|
||||
|
||||
def subscribe_telemetry(
|
||||
self,
|
||||
samples: list[FlightControllerTelemetry],
|
||||
) -> tuple[TelemetrySample, ...]:
|
||||
return tuple(normalize_telemetry(sample) for sample in samples)
|
||||
|
||||
def emit_gps_input(self, estimate: PositionEstimate) -> GpsEmissionResult:
|
||||
try:
|
||||
packet = gps_input_from_estimate(estimate)
|
||||
except ValidationError as error:
|
||||
return GpsEmissionResult(
|
||||
emitted=False,
|
||||
error=ErrorEnvelope(
|
||||
component="mavlink_gcs_integration",
|
||||
category="validation",
|
||||
message="position estimate is unsafe for GPS_INPUT emission",
|
||||
severity="error",
|
||||
retryable=False,
|
||||
cause=str(error),
|
||||
),
|
||||
)
|
||||
|
||||
self.emitted_gps_inputs.append(packet)
|
||||
return GpsEmissionResult(emitted=True, packet=packet)
|
||||
|
||||
def emit_status(
|
||||
self,
|
||||
messages: list[OperatorStatusMessage],
|
||||
) -> StatusEmissionResult:
|
||||
emitted: list[OperatorStatusMessage] = []
|
||||
suppressed: list[OperatorStatusMessage] = []
|
||||
|
||||
for message in messages:
|
||||
last_timestamp = self._last_status_timestamp_by_text.get(message.text)
|
||||
if (
|
||||
last_timestamp is not None
|
||||
and message.timestamp_ns - last_timestamp < self._status_rate_limit_ns
|
||||
):
|
||||
suppressed.append(message)
|
||||
continue
|
||||
|
||||
self._last_status_timestamp_by_text[message.text] = message.timestamp_ns
|
||||
self.emitted_status_messages.append(message)
|
||||
emitted.append(message)
|
||||
|
||||
return StatusEmissionResult(emitted=tuple(emitted), suppressed=tuple(suppressed))
|
||||
@@ -1,80 +0,0 @@
|
||||
"""Public MAVLink/GCS models."""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, NonNegativeFloat, NonNegativeInt
|
||||
|
||||
from shared.contracts import PositionEstimate, TelemetrySample
|
||||
from shared.errors import ErrorEnvelope
|
||||
|
||||
|
||||
class MavlinkModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
|
||||
class FlightControllerTelemetry(MavlinkModel):
|
||||
timestamp_ns: NonNegativeInt
|
||||
acceleration_mps2: tuple[float, float, float]
|
||||
attitude_rad: tuple[float, float, float]
|
||||
altitude_m: float
|
||||
airspeed_mps: NonNegativeFloat
|
||||
gps_health: Literal["healthy", "degraded", "lost", "spoofed"]
|
||||
|
||||
|
||||
class GpsInputPacket(MavlinkModel):
|
||||
timestamp_ns: NonNegativeInt
|
||||
latitude_deg: float = Field(ge=-90.0, le=90.0)
|
||||
longitude_deg: float = Field(ge=-180.0, le=180.0)
|
||||
altitude_m: float
|
||||
fix_type: int = Field(ge=2, le=3)
|
||||
horizontal_accuracy_m: NonNegativeFloat
|
||||
source_label: str = Field(min_length=1)
|
||||
|
||||
|
||||
class GpsEmissionResult(MavlinkModel):
|
||||
emitted: bool
|
||||
packet: GpsInputPacket | None = None
|
||||
error: ErrorEnvelope | None = None
|
||||
|
||||
|
||||
class OperatorStatusMessage(MavlinkModel):
|
||||
timestamp_ns: NonNegativeInt
|
||||
severity: Literal["info", "warning", "error", "critical"]
|
||||
text: str = Field(min_length=1)
|
||||
visible_to_qgc: bool = True
|
||||
|
||||
|
||||
class StatusEmissionResult(MavlinkModel):
|
||||
emitted: tuple[OperatorStatusMessage, ...]
|
||||
suppressed: tuple[OperatorStatusMessage, ...] = ()
|
||||
|
||||
|
||||
def normalize_telemetry(sample: FlightControllerTelemetry) -> TelemetrySample:
|
||||
return TelemetrySample(
|
||||
timestamp_ns=sample.timestamp_ns,
|
||||
imu={
|
||||
"accel_x": sample.acceleration_mps2[0],
|
||||
"accel_y": sample.acceleration_mps2[1],
|
||||
"accel_z": sample.acceleration_mps2[2],
|
||||
},
|
||||
attitude={
|
||||
"roll": sample.attitude_rad[0],
|
||||
"pitch": sample.attitude_rad[1],
|
||||
"yaw": sample.attitude_rad[2],
|
||||
},
|
||||
altitude_m=sample.altitude_m,
|
||||
airspeed_mps=sample.airspeed_mps,
|
||||
gps_health=sample.gps_health,
|
||||
)
|
||||
|
||||
|
||||
def gps_input_from_estimate(estimate: PositionEstimate) -> GpsInputPacket:
|
||||
return GpsInputPacket(
|
||||
timestamp_ns=estimate.timestamp_ns,
|
||||
latitude_deg=estimate.latitude_deg,
|
||||
longitude_deg=estimate.longitude_deg,
|
||||
altitude_m=estimate.altitude_m,
|
||||
fix_type=estimate.fix_type,
|
||||
horizontal_accuracy_m=estimate.horizontal_accuracy_m,
|
||||
source_label=estimate.source_label,
|
||||
)
|
||||
@@ -1,18 +0,0 @@
|
||||
"""Safety and anchor wrapper component."""
|
||||
|
||||
from .interfaces import LocalizationStateMachine, SafetyAnchorStateMachine
|
||||
from .types import (
|
||||
LocalizationSnapshot,
|
||||
SafetyStateConfig,
|
||||
TelemetryContext,
|
||||
TileWriteEligibility,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"LocalizationSnapshot",
|
||||
"LocalizationStateMachine",
|
||||
"SafetyAnchorStateMachine",
|
||||
"SafetyStateConfig",
|
||||
"TelemetryContext",
|
||||
"TileWriteEligibility",
|
||||
]
|
||||
@@ -1,151 +0,0 @@
|
||||
"""Public localization state-machine interfaces."""
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from shared.contracts import AnchorDecision, PositionEstimate, VioStatePacket
|
||||
|
||||
from .types import (
|
||||
LocalizationSnapshot,
|
||||
SafetyStateConfig,
|
||||
TelemetryContext,
|
||||
TileWriteEligibility,
|
||||
)
|
||||
|
||||
|
||||
class LocalizationStateMachine(Protocol):
|
||||
"""Coordinates VIO propagation and anchor promotion decisions."""
|
||||
|
||||
def update_vio(
|
||||
self, vio_state: VioStatePacket, telemetry: TelemetryContext
|
||||
) -> LocalizationSnapshot:
|
||||
"""Update the state machine with a VIO state packet."""
|
||||
|
||||
def consider_anchor(self, anchor_decision: AnchorDecision) -> LocalizationSnapshot:
|
||||
"""Evaluate a verified anchor decision."""
|
||||
|
||||
|
||||
class SafetyAnchorStateMachine:
|
||||
"""Owns authoritative source labels, covariance, and tile eligibility."""
|
||||
|
||||
def __init__(self, config: SafetyStateConfig | None = None) -> None:
|
||||
self._config = config or SafetyStateConfig()
|
||||
self._snapshot: LocalizationSnapshot | None = None
|
||||
|
||||
@property
|
||||
def snapshot(self) -> LocalizationSnapshot | None:
|
||||
return self._snapshot
|
||||
|
||||
def update_vio(
|
||||
self,
|
||||
vio_state: VioStatePacket,
|
||||
telemetry: TelemetryContext,
|
||||
) -> LocalizationSnapshot:
|
||||
covariance_m = self._covariance_from_vio(vio_state)
|
||||
estimate = PositionEstimate(
|
||||
timestamp_ns=vio_state.timestamp_ns,
|
||||
latitude_deg=telemetry.latitude_hint_deg,
|
||||
longitude_deg=telemetry.longitude_hint_deg,
|
||||
altitude_m=telemetry.altitude_m,
|
||||
covariance_semimajor_m=covariance_m,
|
||||
source_label="vo_extrapolated",
|
||||
fix_type=3,
|
||||
horizontal_accuracy_m=covariance_m,
|
||||
anchor_age_ms=0,
|
||||
)
|
||||
self._snapshot = LocalizationSnapshot(
|
||||
estimate=estimate,
|
||||
mode="vo_extrapolated",
|
||||
last_vio_state=vio_state,
|
||||
)
|
||||
return self._snapshot
|
||||
|
||||
def consider_anchor(self, anchor_decision: AnchorDecision) -> LocalizationSnapshot:
|
||||
self._require_snapshot()
|
||||
assert self._snapshot is not None
|
||||
if not anchor_decision.accepted:
|
||||
return self._snapshot
|
||||
|
||||
pose = anchor_decision.estimated_pose or {}
|
||||
covariance_m = max(anchor_decision.mean_reprojection_error_px, 0.5)
|
||||
estimate = PositionEstimate(
|
||||
timestamp_ns=self._snapshot.estimate.timestamp_ns,
|
||||
latitude_deg=float(pose.get("latitude_deg", self._snapshot.estimate.latitude_deg)),
|
||||
longitude_deg=float(pose.get("longitude_deg", self._snapshot.estimate.longitude_deg)),
|
||||
altitude_m=float(pose.get("altitude_m", self._snapshot.estimate.altitude_m)),
|
||||
covariance_semimajor_m=covariance_m,
|
||||
source_label="satellite_anchored",
|
||||
fix_type=3,
|
||||
horizontal_accuracy_m=covariance_m,
|
||||
anchor_age_ms=0,
|
||||
)
|
||||
self._snapshot = LocalizationSnapshot(
|
||||
estimate=estimate,
|
||||
mode="satellite_anchored",
|
||||
anchor_evidence=anchor_decision,
|
||||
last_vio_state=self._snapshot.last_vio_state,
|
||||
)
|
||||
return self._snapshot
|
||||
|
||||
def propagate_blackout(self, timestamp_ns: int) -> LocalizationSnapshot:
|
||||
self._require_snapshot()
|
||||
assert self._snapshot is not None
|
||||
previous = self._snapshot.estimate
|
||||
covariance_m = previous.covariance_semimajor_m + self._config.dead_reckoning_growth_m
|
||||
no_fix = covariance_m >= self._config.no_fix_covariance_threshold_m
|
||||
source_label = "no_fix" if no_fix else "dead_reckoned"
|
||||
fix_type = 0 if no_fix else 2
|
||||
estimate = PositionEstimate(
|
||||
timestamp_ns=timestamp_ns,
|
||||
latitude_deg=previous.latitude_deg,
|
||||
longitude_deg=previous.longitude_deg,
|
||||
altitude_m=previous.altitude_m,
|
||||
covariance_semimajor_m=covariance_m,
|
||||
source_label=source_label,
|
||||
fix_type=fix_type,
|
||||
horizontal_accuracy_m=max(covariance_m, 999.0 if no_fix else covariance_m),
|
||||
anchor_age_ms=previous.anchor_age_ms + 1_000,
|
||||
)
|
||||
self._snapshot = LocalizationSnapshot(
|
||||
estimate=estimate,
|
||||
mode=source_label,
|
||||
anchor_evidence=self._snapshot.anchor_evidence,
|
||||
last_vio_state=self._snapshot.last_vio_state,
|
||||
)
|
||||
return self._snapshot
|
||||
|
||||
def tile_write_eligibility(self) -> TileWriteEligibility:
|
||||
self._require_snapshot()
|
||||
assert self._snapshot is not None
|
||||
estimate = self._snapshot.estimate
|
||||
if estimate.source_label not in {"satellite_anchored", "vo_extrapolated"}:
|
||||
return TileWriteEligibility(
|
||||
eligible=False,
|
||||
reason="untrusted_source_label",
|
||||
estimate=estimate,
|
||||
)
|
||||
if estimate.covariance_semimajor_m > self._config.tile_write_covariance_max_m:
|
||||
return TileWriteEligibility(
|
||||
eligible=False,
|
||||
reason="covariance_too_high",
|
||||
estimate=estimate,
|
||||
)
|
||||
return TileWriteEligibility(
|
||||
eligible=True,
|
||||
reason="trusted_pose",
|
||||
estimate=estimate,
|
||||
)
|
||||
|
||||
def _covariance_from_vio(self, vio_state: VioStatePacket) -> float:
|
||||
if not vio_state.covariance_hint:
|
||||
return max(
|
||||
self._config.vio_covariance_floor_m,
|
||||
self._config.initial_covariance_m / max(vio_state.tracking_quality, 0.1),
|
||||
)
|
||||
diagonal = [
|
||||
row[index] for index, row in enumerate(vio_state.covariance_hint) if index < len(row)
|
||||
]
|
||||
return max(self._config.vio_covariance_floor_m, max(diagonal, default=0.0))
|
||||
|
||||
def _require_snapshot(self) -> None:
|
||||
if self._snapshot is None:
|
||||
raise RuntimeError("safety state requires a VIO update before this operation")
|
||||
@@ -1,39 +0,0 @@
|
||||
"""Public safety wrapper models."""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, NonNegativeFloat, NonNegativeInt
|
||||
|
||||
from shared.contracts import AnchorDecision, PositionEstimate, VioStatePacket
|
||||
|
||||
|
||||
class SafetyWrapperModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
|
||||
class TelemetryContext(SafetyWrapperModel):
|
||||
timestamp_ns: NonNegativeInt
|
||||
latitude_hint_deg: float = Field(ge=-90.0, le=90.0)
|
||||
longitude_hint_deg: float = Field(ge=-180.0, le=180.0)
|
||||
altitude_m: float
|
||||
|
||||
|
||||
class SafetyStateConfig(SafetyWrapperModel):
|
||||
initial_covariance_m: NonNegativeFloat = 2.0
|
||||
vio_covariance_floor_m: NonNegativeFloat = 1.0
|
||||
dead_reckoning_growth_m: NonNegativeFloat = 50.0
|
||||
no_fix_covariance_threshold_m: NonNegativeFloat = 500.0
|
||||
tile_write_covariance_max_m: NonNegativeFloat = 3.0
|
||||
|
||||
|
||||
class LocalizationSnapshot(SafetyWrapperModel):
|
||||
estimate: PositionEstimate
|
||||
mode: Literal["satellite_anchored", "vo_extrapolated", "dead_reckoned", "no_fix"]
|
||||
anchor_evidence: AnchorDecision | None = None
|
||||
last_vio_state: VioStatePacket | None = None
|
||||
|
||||
|
||||
class TileWriteEligibility(SafetyWrapperModel):
|
||||
eligible: bool
|
||||
reason: str = Field(min_length=1)
|
||||
estimate: PositionEstimate
|
||||
@@ -1,45 +0,0 @@
|
||||
"""Offline satellite retrieval and synchronization component."""
|
||||
|
||||
from .interfaces import (
|
||||
CpuFaissDescriptorIndex,
|
||||
DescriptorIndex,
|
||||
LocalVprRetriever,
|
||||
SatelliteService,
|
||||
SatelliteSyncBoundary,
|
||||
)
|
||||
from .types import (
|
||||
DescriptorFidelityReport,
|
||||
GeneratedTileUploadRecord,
|
||||
LocalVprIndexPackage,
|
||||
MissionCacheImportResult,
|
||||
MissionCachePackage,
|
||||
RelocalizationRequest,
|
||||
RuntimePhase,
|
||||
SatelliteSyncResult,
|
||||
SatelliteSyncStatus,
|
||||
UploadOutcome,
|
||||
VprDescriptorRecord,
|
||||
VprReadinessReport,
|
||||
VprRetrievalResult,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CpuFaissDescriptorIndex",
|
||||
"DescriptorFidelityReport",
|
||||
"DescriptorIndex",
|
||||
"GeneratedTileUploadRecord",
|
||||
"LocalVprIndexPackage",
|
||||
"LocalVprRetriever",
|
||||
"MissionCacheImportResult",
|
||||
"MissionCachePackage",
|
||||
"RelocalizationRequest",
|
||||
"RuntimePhase",
|
||||
"SatelliteService",
|
||||
"SatelliteSyncBoundary",
|
||||
"SatelliteSyncResult",
|
||||
"SatelliteSyncStatus",
|
||||
"UploadOutcome",
|
||||
"VprDescriptorRecord",
|
||||
"VprReadinessReport",
|
||||
"VprRetrievalResult",
|
||||
]
|
||||
@@ -1,335 +0,0 @@
|
||||
"""Public satellite service interfaces."""
|
||||
|
||||
from math import sqrt
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from time import perf_counter
|
||||
from typing import Protocol
|
||||
|
||||
from shared.contracts import VprCandidate
|
||||
from shared.errors import ErrorEnvelope
|
||||
from tile_manager import GeneratedTileSyncPackage
|
||||
|
||||
from .types import (
|
||||
DescriptorFidelityReport,
|
||||
GeneratedTileUploadRecord,
|
||||
LocalVprIndexPackage,
|
||||
MissionCacheImportResult,
|
||||
MissionCachePackage,
|
||||
RelocalizationRequest,
|
||||
RuntimePhase,
|
||||
SatelliteSyncResult,
|
||||
SatelliteSyncStatus,
|
||||
UploadOutcome,
|
||||
VprDescriptorRecord,
|
||||
VprReadinessReport,
|
||||
VprRetrievalResult,
|
||||
)
|
||||
|
||||
|
||||
class SatelliteService(Protocol):
|
||||
"""Retrieves offline VPR candidates from mission cache data."""
|
||||
|
||||
def load_index(self, package: LocalVprIndexPackage) -> VprReadinessReport:
|
||||
"""Load the local descriptor index."""
|
||||
|
||||
def retrieve(self, request: RelocalizationRequest) -> VprRetrievalResult:
|
||||
"""Return candidate anchor records for one frame."""
|
||||
|
||||
|
||||
class DescriptorIndex(Protocol):
|
||||
"""Search boundary for local descriptor packages."""
|
||||
|
||||
@property
|
||||
def record_count(self) -> int:
|
||||
"""Return the number of loaded descriptor records."""
|
||||
|
||||
def search(
|
||||
self,
|
||||
query_descriptor: tuple[float, ...],
|
||||
top_k: int,
|
||||
) -> tuple[tuple[float, VprDescriptorRecord], ...]:
|
||||
"""Return scored descriptor records in descending score order."""
|
||||
|
||||
|
||||
class CpuFaissDescriptorIndex:
|
||||
"""CPU vector index with a FAISS-compatible search contract."""
|
||||
|
||||
def __init__(self, records: tuple[VprDescriptorRecord, ...]) -> None:
|
||||
self._records = tuple(record for record in records if record.freshness_status != "rejected")
|
||||
|
||||
@property
|
||||
def record_count(self) -> int:
|
||||
return len(self._records)
|
||||
|
||||
def search(
|
||||
self,
|
||||
query_descriptor: tuple[float, ...],
|
||||
top_k: int,
|
||||
) -> tuple[tuple[float, VprDescriptorRecord], ...]:
|
||||
scored = sorted(
|
||||
(
|
||||
(self._cosine_similarity(query_descriptor, record.descriptor), record)
|
||||
for record in self._records
|
||||
),
|
||||
key=lambda item: item[0],
|
||||
reverse=True,
|
||||
)
|
||||
return tuple(scored[:top_k])
|
||||
|
||||
def _cosine_similarity(
|
||||
self,
|
||||
query_descriptor: tuple[float, ...],
|
||||
record_descriptor: tuple[float, ...],
|
||||
) -> float:
|
||||
max_length = max(len(query_descriptor), len(record_descriptor))
|
||||
padded_query = query_descriptor + (0.0,) * (max_length - len(query_descriptor))
|
||||
padded_record = record_descriptor + (0.0,) * (max_length - len(record_descriptor))
|
||||
dot_product = sum(
|
||||
query_value * record_value
|
||||
for query_value, record_value in zip(padded_query, padded_record)
|
||||
)
|
||||
query_norm = sqrt(sum(value * value for value in padded_query)) or 1.0
|
||||
record_norm = sqrt(sum(value * value for value in padded_record)) or 1.0
|
||||
return max(0.0, min(1.0, dot_product / (query_norm * record_norm)))
|
||||
|
||||
|
||||
class LocalVprRetriever:
|
||||
"""Triggered local VPR retrieval over mission-cache descriptor indexes."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._package: LocalVprIndexPackage | None = None
|
||||
self._index: DescriptorIndex | None = None
|
||||
self._load_error: ErrorEnvelope | None = None
|
||||
|
||||
def load_index(self, package: LocalVprIndexPackage) -> VprReadinessReport:
|
||||
self._package = package
|
||||
self._index = CpuFaissDescriptorIndex(package.records)
|
||||
self._load_error = None
|
||||
return VprReadinessReport(
|
||||
ready=self._index.record_count > 0,
|
||||
engine=package.engine,
|
||||
loaded_records=self._index.record_count,
|
||||
package_id=package.package_id,
|
||||
descriptor_model=package.descriptor_model,
|
||||
error=None
|
||||
if self._index.record_count > 0
|
||||
else self._error("local descriptor index has no searchable records", "empty_index"),
|
||||
)
|
||||
|
||||
def load_index_from_path(self, package_path: str | Path) -> VprReadinessReport:
|
||||
try:
|
||||
return self.load_index(LocalVprIndexPackage.from_json_file(package_path))
|
||||
except (FileNotFoundError, OSError, ValueError) as exc:
|
||||
self._package = None
|
||||
self._index = None
|
||||
self._load_error = self._error(
|
||||
f"local descriptor index package could not be loaded: {exc}",
|
||||
"index_package_invalid",
|
||||
)
|
||||
return VprReadinessReport(
|
||||
ready=False,
|
||||
engine="cpu_faiss",
|
||||
loaded_records=0,
|
||||
error=self._load_error,
|
||||
)
|
||||
|
||||
def readiness(self) -> VprReadinessReport:
|
||||
if self._load_error is not None:
|
||||
return VprReadinessReport(
|
||||
ready=False,
|
||||
engine="cpu_faiss",
|
||||
loaded_records=0,
|
||||
error=self._load_error,
|
||||
)
|
||||
if self._index is None or self._package is None:
|
||||
return VprReadinessReport(
|
||||
ready=False,
|
||||
engine="cpu_faiss",
|
||||
loaded_records=0,
|
||||
error=self._error("local descriptor index is not loaded", "index_not_loaded"),
|
||||
)
|
||||
return VprReadinessReport(
|
||||
ready=True,
|
||||
engine=self._package.engine,
|
||||
loaded_records=self._index.record_count,
|
||||
package_id=self._package.package_id,
|
||||
descriptor_model=self._package.descriptor_model,
|
||||
)
|
||||
|
||||
def retrieve(self, request: RelocalizationRequest) -> VprRetrievalResult:
|
||||
started = perf_counter()
|
||||
readiness = self.readiness()
|
||||
if not readiness.ready:
|
||||
return VprRetrievalResult(
|
||||
ready=False,
|
||||
degraded=True,
|
||||
retrieval_path="unavailable",
|
||||
error=readiness.error,
|
||||
)
|
||||
|
||||
if request.query_descriptor is None:
|
||||
return VprRetrievalResult(
|
||||
ready=True,
|
||||
degraded=True,
|
||||
retrieval_path="unavailable",
|
||||
error=self._error(
|
||||
"query descriptor is required for local descriptor index retrieval",
|
||||
"query_descriptor_missing",
|
||||
),
|
||||
)
|
||||
|
||||
assert self._index is not None
|
||||
scored = self._index.search(request.query_descriptor, request.top_k)
|
||||
candidates = tuple(
|
||||
VprCandidate(
|
||||
chunk_id=record.chunk_id,
|
||||
tile_id=record.tile_id,
|
||||
score=score,
|
||||
footprint=record.footprint,
|
||||
freshness_status=record.freshness_status,
|
||||
)
|
||||
for score, record in scored[: request.top_k]
|
||||
)
|
||||
latency_ms = (perf_counter() - started) * 1000.0
|
||||
if not candidates:
|
||||
return VprRetrievalResult(
|
||||
ready=True,
|
||||
degraded=True,
|
||||
retrieval_path="local_descriptor_index",
|
||||
latency_ms=latency_ms,
|
||||
error=self._error(
|
||||
"local descriptor index produced no valid candidates",
|
||||
"no_candidates",
|
||||
),
|
||||
)
|
||||
|
||||
return VprRetrievalResult(
|
||||
ready=True,
|
||||
degraded=False,
|
||||
candidates=candidates,
|
||||
retrieval_path="local_descriptor_index",
|
||||
latency_ms=latency_ms,
|
||||
)
|
||||
|
||||
def verify_descriptor_fidelity(
|
||||
self,
|
||||
reference_descriptor: tuple[float, ...],
|
||||
optimized_descriptor: tuple[float, ...],
|
||||
max_l2_delta: float,
|
||||
) -> DescriptorFidelityReport:
|
||||
observed_delta = self._l2_distance(reference_descriptor, optimized_descriptor)
|
||||
return DescriptorFidelityReport(
|
||||
accepted=observed_delta <= max_l2_delta,
|
||||
observed_l2_delta=observed_delta,
|
||||
max_l2_delta=max_l2_delta,
|
||||
)
|
||||
|
||||
def _l2_distance(
|
||||
self,
|
||||
reference_descriptor: tuple[float, ...],
|
||||
optimized_descriptor: tuple[float, ...],
|
||||
) -> float:
|
||||
max_length = max(len(reference_descriptor), len(optimized_descriptor))
|
||||
padded_reference = reference_descriptor + (0.0,) * (max_length - len(reference_descriptor))
|
||||
padded_optimized = optimized_descriptor + (0.0,) * (max_length - len(optimized_descriptor))
|
||||
return sqrt(
|
||||
sum(
|
||||
(reference_value - optimized_value) ** 2
|
||||
for reference_value, optimized_value in zip(padded_reference, padded_optimized)
|
||||
)
|
||||
)
|
||||
|
||||
def _error(self, message: str, cause: str) -> ErrorEnvelope:
|
||||
return ErrorEnvelope(
|
||||
component="satellite_service",
|
||||
category="runtime",
|
||||
message=message,
|
||||
severity="warning",
|
||||
retryable=False,
|
||||
cause=cause,
|
||||
)
|
||||
|
||||
|
||||
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,3 +0,0 @@
|
||||
# Satellite Service Descriptor Index Package
|
||||
|
||||
Exports local descriptor index search boundaries owned by `satellite_service`.
|
||||
@@ -1,5 +0,0 @@
|
||||
"""Local descriptor index package exports."""
|
||||
|
||||
from satellite_service.interfaces import CpuFaissDescriptorIndex, DescriptorIndex
|
||||
|
||||
__all__ = ["CpuFaissDescriptorIndex", "DescriptorIndex"]
|
||||
@@ -1,105 +0,0 @@
|
||||
"""Public satellite service models."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, PositiveInt, ValidationError
|
||||
|
||||
from shared.contracts import VprCandidate
|
||||
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
|
||||
|
||||
|
||||
class VprDescriptorRecord(SatelliteServiceModel):
|
||||
chunk_id: str = Field(min_length=1)
|
||||
tile_id: str = Field(min_length=1)
|
||||
descriptor: tuple[float, ...] = Field(min_length=1)
|
||||
footprint: dict[str, float]
|
||||
freshness_status: Literal["fresh", "stale", "rejected"]
|
||||
|
||||
|
||||
class LocalVprIndexPackage(SatelliteServiceModel):
|
||||
package_id: str = Field(min_length=1)
|
||||
engine: Literal["cpu_faiss"] = "cpu_faiss"
|
||||
descriptor_model: str = Field(default="dinov2_vlad", min_length=1)
|
||||
records: tuple[VprDescriptorRecord, ...] = Field(min_length=1)
|
||||
|
||||
@classmethod
|
||||
def from_json_file(cls, package_path: str | Path) -> "LocalVprIndexPackage":
|
||||
payload = json.loads(Path(package_path).read_text(encoding="utf-8"))
|
||||
return cls.model_validate(payload)
|
||||
|
||||
|
||||
class RelocalizationRequest(SatelliteServiceModel):
|
||||
frame_id: str = Field(min_length=1)
|
||||
image_ref: str = Field(min_length=1)
|
||||
trigger_reason: str = Field(min_length=1)
|
||||
top_k: PositiveInt = Field(le=50)
|
||||
query_descriptor: tuple[float, ...] | None = None
|
||||
|
||||
|
||||
class VprReadinessReport(SatelliteServiceModel):
|
||||
ready: bool
|
||||
engine: Literal["cpu_faiss"]
|
||||
loaded_records: int = Field(ge=0)
|
||||
package_id: str | None = None
|
||||
descriptor_model: str | None = None
|
||||
error: ErrorEnvelope | None = None
|
||||
|
||||
|
||||
class VprRetrievalResult(SatelliteServiceModel):
|
||||
ready: bool
|
||||
degraded: bool
|
||||
candidates: tuple[VprCandidate, ...] = ()
|
||||
retrieval_path: Literal["local_descriptor_index", "unavailable"] = "unavailable"
|
||||
latency_ms: float | None = Field(default=None, ge=0.0)
|
||||
error: ErrorEnvelope | None = None
|
||||
|
||||
|
||||
class DescriptorFidelityReport(SatelliteServiceModel):
|
||||
accepted: bool
|
||||
observed_l2_delta: float = Field(ge=0.0)
|
||||
max_l2_delta: float = Field(ge=0.0)
|
||||
|
||||
|
||||
RuntimePhase = Literal["pre_flight", "in_flight", "post_flight"]
|
||||
UploadOutcome = Literal["success", "retryable_failure", "rejected"]
|
||||
IndexLoadError = FileNotFoundError | json.JSONDecodeError | ValidationError | OSError
|
||||
@@ -1 +0,0 @@
|
||||
"""Shared runtime foundation packages."""
|
||||
@@ -1,5 +0,0 @@
|
||||
"""Runtime configuration helper namespace."""
|
||||
|
||||
from shared.config.models import RuntimeProfile, readiness_error, validate_runtime_profile
|
||||
|
||||
__all__ = ["RuntimeProfile", "readiness_error", "validate_runtime_profile"]
|
||||
@@ -1,59 +0,0 @@
|
||||
"""Runtime profile configuration and readiness validation."""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
|
||||
|
||||
from shared.errors import ErrorEnvelope, ResultEnvelope
|
||||
|
||||
|
||||
class RuntimeProfile(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
environment: Literal["development", "ci", "staging", "jetson", "production"]
|
||||
config_dir: str = Field(min_length=1)
|
||||
cache_dir: str | None = None
|
||||
fdr_dir: str | None = None
|
||||
database_url: str | None = None
|
||||
mavlink_url: str | None = None
|
||||
camera_source: str | None = None
|
||||
signing_key_ref: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def production_requires_runtime_paths(self) -> "RuntimeProfile":
|
||||
if self.environment != "production":
|
||||
return self
|
||||
|
||||
missing = [
|
||||
name
|
||||
for name in (
|
||||
"cache_dir",
|
||||
"fdr_dir",
|
||||
"database_url",
|
||||
"mavlink_url",
|
||||
"camera_source",
|
||||
"signing_key_ref",
|
||||
)
|
||||
if getattr(self, name) in (None, "")
|
||||
]
|
||||
if missing:
|
||||
raise ValueError(f"production profile missing required settings: {', '.join(missing)}")
|
||||
return self
|
||||
|
||||
|
||||
def readiness_error(component: str, message: str) -> ErrorEnvelope:
|
||||
return ErrorEnvelope(
|
||||
component=component,
|
||||
category="configuration",
|
||||
message=message,
|
||||
severity="critical",
|
||||
retryable=False,
|
||||
)
|
||||
|
||||
|
||||
def validate_runtime_profile(component: str, payload: dict[str, object]) -> ResultEnvelope:
|
||||
try:
|
||||
RuntimeProfile.model_validate(payload)
|
||||
except ValidationError as error:
|
||||
return ResultEnvelope.failure(readiness_error(component, str(error)))
|
||||
return ResultEnvelope.success()
|
||||
@@ -1,28 +0,0 @@
|
||||
"""Shared DTO and interface contract namespace."""
|
||||
|
||||
from shared.contracts.models import (
|
||||
AnchorDecision,
|
||||
CacheTileRecord,
|
||||
FdrEvent,
|
||||
FramePacket,
|
||||
PositionEstimate,
|
||||
RuntimeContractModel,
|
||||
TelemetrySample,
|
||||
VioStatePacket,
|
||||
VprCandidate,
|
||||
)
|
||||
|
||||
CONTRACT_VERSION = "1.0.0"
|
||||
|
||||
__all__ = [
|
||||
"AnchorDecision",
|
||||
"CONTRACT_VERSION",
|
||||
"CacheTileRecord",
|
||||
"FdrEvent",
|
||||
"FramePacket",
|
||||
"PositionEstimate",
|
||||
"RuntimeContractModel",
|
||||
"TelemetrySample",
|
||||
"VioStatePacket",
|
||||
"VprCandidate",
|
||||
]
|
||||
@@ -1,116 +0,0 @@
|
||||
"""Shared runtime DTO contracts.
|
||||
|
||||
These models intentionally carry only cross-component shape and validation rules.
|
||||
Component algorithms and storage choices stay in their owning packages.
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, NonNegativeFloat, NonNegativeInt, PositiveFloat
|
||||
from pydantic import model_validator
|
||||
|
||||
|
||||
class RuntimeContractModel(BaseModel):
|
||||
"""Base settings for public runtime contracts."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
|
||||
class FramePacket(RuntimeContractModel):
|
||||
frame_id: str = Field(min_length=1)
|
||||
timestamp_ns: NonNegativeInt
|
||||
image_ref: str = Field(min_length=1)
|
||||
calibration_id: str = Field(min_length=1)
|
||||
occlusion: Literal["clear", "partial", "total", "unreadable"]
|
||||
quality: float = Field(ge=0.0, le=1.0)
|
||||
normalization_hint: str | None = None
|
||||
raw_frame_retained: bool = False
|
||||
|
||||
@model_validator(mode="after")
|
||||
def raw_frames_must_not_be_retained(self) -> "FramePacket":
|
||||
if self.raw_frame_retained:
|
||||
raise ValueError("raw frame payloads must be referenced, not retained")
|
||||
return self
|
||||
|
||||
|
||||
class TelemetrySample(RuntimeContractModel):
|
||||
timestamp_ns: NonNegativeInt
|
||||
imu: dict[str, float]
|
||||
attitude: dict[str, float]
|
||||
altitude_m: float
|
||||
airspeed_mps: NonNegativeFloat
|
||||
gps_health: Literal["healthy", "degraded", "lost", "spoofed"]
|
||||
|
||||
|
||||
class VioStatePacket(RuntimeContractModel):
|
||||
timestamp_ns: NonNegativeInt
|
||||
relative_pose: dict[str, float]
|
||||
velocity_mps: tuple[float, float, float]
|
||||
bias_estimate: dict[str, float] | None = None
|
||||
tracking_quality: float = Field(ge=0.0, le=1.0)
|
||||
covariance_hint: list[list[float]] | None = None
|
||||
|
||||
|
||||
class PositionEstimate(RuntimeContractModel):
|
||||
timestamp_ns: NonNegativeInt
|
||||
latitude_deg: float = Field(ge=-90.0, le=90.0)
|
||||
longitude_deg: float = Field(ge=-180.0, le=180.0)
|
||||
altitude_m: float
|
||||
covariance_semimajor_m: NonNegativeFloat
|
||||
source_label: Literal["satellite_anchored", "vo_extrapolated", "dead_reckoned", "no_fix"]
|
||||
fix_type: int = Field(ge=0, le=3)
|
||||
horizontal_accuracy_m: NonNegativeFloat
|
||||
anchor_age_ms: NonNegativeInt
|
||||
|
||||
@model_validator(mode="after")
|
||||
def accuracy_must_not_under_report_covariance(self) -> "PositionEstimate":
|
||||
if self.horizontal_accuracy_m < self.covariance_semimajor_m:
|
||||
raise ValueError("horizontal_accuracy_m must not under-report covariance_semimajor_m")
|
||||
return self
|
||||
|
||||
|
||||
class VprCandidate(RuntimeContractModel):
|
||||
chunk_id: str = Field(min_length=1)
|
||||
tile_id: str = Field(min_length=1)
|
||||
score: float = Field(ge=0.0, le=1.0)
|
||||
footprint: dict[str, float]
|
||||
freshness_status: Literal["fresh", "stale", "rejected"]
|
||||
|
||||
|
||||
class AnchorDecision(RuntimeContractModel):
|
||||
candidate_id: str = Field(min_length=1)
|
||||
accepted: bool
|
||||
estimated_pose: dict[str, float] | None = None
|
||||
inliers: NonNegativeInt
|
||||
mean_reprojection_error_px: NonNegativeFloat
|
||||
rejection_reason: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def accepted_anchors_require_pose(self) -> "AnchorDecision":
|
||||
if self.accepted and self.estimated_pose is None:
|
||||
raise ValueError("accepted anchor decisions require estimated_pose")
|
||||
if self.accepted and self.rejection_reason is not None:
|
||||
raise ValueError("accepted anchor decisions must not include rejection_reason")
|
||||
return self
|
||||
|
||||
|
||||
class CacheTileRecord(RuntimeContractModel):
|
||||
tile_id: str = Field(min_length=1)
|
||||
crs: str = Field(min_length=1)
|
||||
meters_per_pixel: PositiveFloat
|
||||
capture_date: date
|
||||
signature_hash: str = Field(min_length=1)
|
||||
trust_level: Literal["trusted", "generated", "quarantined", "rejected"]
|
||||
freshness_status: Literal["fresh", "stale", "rejected"]
|
||||
provenance: str = Field(min_length=1)
|
||||
|
||||
|
||||
class FdrEvent(RuntimeContractModel):
|
||||
event_type: str = Field(min_length=1)
|
||||
timestamp_ns: NonNegativeInt
|
||||
component: str = Field(min_length=1)
|
||||
severity: Literal["debug", "info", "warning", "error", "critical"]
|
||||
payload_ref: str = Field(min_length=1)
|
||||
mission_id: str = Field(min_length=1)
|
||||
run_id: str = Field(min_length=1)
|
||||
@@ -1,5 +0,0 @@
|
||||
"""Shared error envelope namespace."""
|
||||
|
||||
from shared.errors.models import ErrorEnvelope, ResultEnvelope
|
||||
|
||||
__all__ = ["ErrorEnvelope", "ResultEnvelope"]
|
||||
@@ -1,38 +0,0 @@
|
||||
"""Shared structured error envelopes."""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class ErrorEnvelope(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
component: str = Field(min_length=1)
|
||||
category: Literal[
|
||||
"configuration",
|
||||
"dependency",
|
||||
"validation",
|
||||
"runtime",
|
||||
"security",
|
||||
"resource",
|
||||
]
|
||||
message: str = Field(min_length=1)
|
||||
severity: Literal["info", "warning", "error", "critical"]
|
||||
retryable: bool
|
||||
cause: str | None = None
|
||||
|
||||
|
||||
class ResultEnvelope(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
ok: bool
|
||||
error: ErrorEnvelope | None = None
|
||||
|
||||
@classmethod
|
||||
def success(cls) -> "ResultEnvelope":
|
||||
return cls(ok=True)
|
||||
|
||||
@classmethod
|
||||
def failure(cls, error: ErrorEnvelope) -> "ResultEnvelope":
|
||||
return cls(ok=False, error=error)
|
||||
@@ -1,21 +0,0 @@
|
||||
"""Geospatial geometry helper namespace."""
|
||||
|
||||
from shared.geo_geometry.models import (
|
||||
CameraFootprint,
|
||||
LocalNedCoordinate,
|
||||
Wgs84Coordinate,
|
||||
distance_m,
|
||||
local_to_wgs84,
|
||||
nadir_camera_footprint,
|
||||
wgs84_to_local,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CameraFootprint",
|
||||
"LocalNedCoordinate",
|
||||
"Wgs84Coordinate",
|
||||
"distance_m",
|
||||
"local_to_wgs84",
|
||||
"nadir_camera_footprint",
|
||||
"wgs84_to_local",
|
||||
]
|
||||
@@ -1,83 +0,0 @@
|
||||
"""Deterministic geospatial helper models and calculations."""
|
||||
|
||||
from math import cos, radians, sqrt
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, PositiveFloat
|
||||
|
||||
EARTH_RADIUS_M = 6_378_137.0
|
||||
|
||||
|
||||
class GeometryModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
|
||||
class Wgs84Coordinate(GeometryModel):
|
||||
latitude_deg: float = Field(ge=-90.0, le=90.0)
|
||||
longitude_deg: float = Field(ge=-180.0, le=180.0)
|
||||
altitude_m: float = 0.0
|
||||
|
||||
|
||||
class LocalNedCoordinate(GeometryModel):
|
||||
north_m: float
|
||||
east_m: float
|
||||
down_m: float = 0.0
|
||||
|
||||
|
||||
class CameraFootprint(GeometryModel):
|
||||
center: Wgs84Coordinate
|
||||
ground_width_m: PositiveFloat
|
||||
ground_height_m: PositiveFloat
|
||||
ground_sample_distance_m_per_px: PositiveFloat
|
||||
|
||||
|
||||
def wgs84_to_local(origin: Wgs84Coordinate, point: Wgs84Coordinate) -> LocalNedCoordinate:
|
||||
lat_delta_rad = radians(point.latitude_deg - origin.latitude_deg)
|
||||
lon_delta_rad = radians(point.longitude_deg - origin.longitude_deg)
|
||||
latitude_scale = cos(radians(origin.latitude_deg))
|
||||
|
||||
return LocalNedCoordinate(
|
||||
north_m=lat_delta_rad * EARTH_RADIUS_M,
|
||||
east_m=lon_delta_rad * EARTH_RADIUS_M * latitude_scale,
|
||||
down_m=origin.altitude_m - point.altitude_m,
|
||||
)
|
||||
|
||||
|
||||
def local_to_wgs84(origin: Wgs84Coordinate, local: LocalNedCoordinate) -> Wgs84Coordinate:
|
||||
latitude_deg = origin.latitude_deg + (local.north_m / EARTH_RADIUS_M) * (
|
||||
180.0 / 3.141592653589793
|
||||
)
|
||||
latitude_scale = cos(radians(origin.latitude_deg))
|
||||
longitude_deg = origin.longitude_deg + (local.east_m / (EARTH_RADIUS_M * latitude_scale)) * (
|
||||
180.0 / 3.141592653589793
|
||||
)
|
||||
|
||||
return Wgs84Coordinate(
|
||||
latitude_deg=latitude_deg,
|
||||
longitude_deg=longitude_deg,
|
||||
altitude_m=origin.altitude_m - local.down_m,
|
||||
)
|
||||
|
||||
|
||||
def distance_m(first: Wgs84Coordinate, second: Wgs84Coordinate) -> float:
|
||||
local = wgs84_to_local(first, second)
|
||||
return sqrt(local.north_m**2 + local.east_m**2 + local.down_m**2)
|
||||
|
||||
|
||||
def nadir_camera_footprint(
|
||||
center: Wgs84Coordinate,
|
||||
altitude_agl_m: PositiveFloat,
|
||||
sensor_width_px: int,
|
||||
sensor_height_px: int,
|
||||
ground_sample_distance_m_per_px: PositiveFloat,
|
||||
) -> CameraFootprint:
|
||||
if sensor_width_px <= 0 or sensor_height_px <= 0:
|
||||
raise ValueError("sensor dimensions must be positive")
|
||||
if altitude_agl_m <= 0:
|
||||
raise ValueError("altitude_agl_m must be positive")
|
||||
|
||||
return CameraFootprint(
|
||||
center=center,
|
||||
ground_width_m=sensor_width_px * ground_sample_distance_m_per_px,
|
||||
ground_height_m=sensor_height_px * ground_sample_distance_m_per_px,
|
||||
ground_sample_distance_m_per_px=ground_sample_distance_m_per_px,
|
||||
)
|
||||
@@ -1,5 +0,0 @@
|
||||
"""Structured telemetry and health metadata namespace."""
|
||||
|
||||
from shared.telemetry.models import HealthEvent, MetricsLabels
|
||||
|
||||
__all__ = ["HealthEvent", "MetricsLabels"]
|
||||
@@ -1,23 +0,0 @@
|
||||
"""FDR-safe health and metrics metadata."""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, NonNegativeInt
|
||||
|
||||
|
||||
class HealthEvent(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
component: str = Field(min_length=1)
|
||||
timestamp_ns: NonNegativeInt
|
||||
liveness: Literal["alive", "failed"]
|
||||
readiness: Literal["ready", "not_ready"]
|
||||
dependency_state: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class MetricsLabels(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
component: str = Field(min_length=1)
|
||||
action: str = Field(min_length=1)
|
||||
status: Literal["ok", "degraded", "failed"]
|
||||
@@ -1,15 +0,0 @@
|
||||
"""Clock-domain and timestamp helper namespace."""
|
||||
|
||||
from shared.time_sync.models import (
|
||||
TimeSyncViolation,
|
||||
TimeWindowResult,
|
||||
check_monotonic_timestamps,
|
||||
select_time_window,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"TimeSyncViolation",
|
||||
"TimeWindowResult",
|
||||
"check_monotonic_timestamps",
|
||||
"select_time_window",
|
||||
]
|
||||
@@ -1,77 +0,0 @@
|
||||
"""Timestamp alignment helpers."""
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, NonNegativeInt
|
||||
|
||||
|
||||
class TimeSyncModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
|
||||
class TimeSyncViolation(TimeSyncModel):
|
||||
category: str
|
||||
message: str
|
||||
|
||||
|
||||
class TimeWindowResult(TimeSyncModel):
|
||||
frame_timestamp_ns: NonNegativeInt
|
||||
sample_timestamps_ns: tuple[NonNegativeInt, ...]
|
||||
max_gap_ns: NonNegativeInt
|
||||
jitter_ns: NonNegativeInt
|
||||
violations: tuple[TimeSyncViolation, ...] = ()
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return not self.violations
|
||||
|
||||
|
||||
def check_monotonic_timestamps(timestamps_ns: list[int]) -> tuple[TimeSyncViolation, ...]:
|
||||
violations: list[TimeSyncViolation] = []
|
||||
for previous, current in zip(timestamps_ns, timestamps_ns[1:]):
|
||||
if current <= previous:
|
||||
violations.append(
|
||||
TimeSyncViolation(
|
||||
category="timestamp_mismatch",
|
||||
message="timestamps must be strictly increasing",
|
||||
)
|
||||
)
|
||||
break
|
||||
return tuple(violations)
|
||||
|
||||
|
||||
def select_time_window(
|
||||
frame_timestamp_ns: int,
|
||||
sample_timestamps_ns: list[int],
|
||||
tolerance_ns: int,
|
||||
) -> TimeWindowResult:
|
||||
if tolerance_ns < 0:
|
||||
raise ValueError("tolerance_ns must be non-negative")
|
||||
|
||||
violations = list(check_monotonic_timestamps(sample_timestamps_ns))
|
||||
selected = tuple(
|
||||
timestamp
|
||||
for timestamp in sample_timestamps_ns
|
||||
if abs(timestamp - frame_timestamp_ns) <= tolerance_ns
|
||||
)
|
||||
if not selected:
|
||||
violations.append(
|
||||
TimeSyncViolation(
|
||||
category="gap_exceeded",
|
||||
message="no telemetry samples fall within the frame tolerance window",
|
||||
)
|
||||
)
|
||||
|
||||
gaps = [
|
||||
current - previous
|
||||
for previous, current in zip(sample_timestamps_ns, sample_timestamps_ns[1:])
|
||||
if current > previous
|
||||
]
|
||||
max_gap_ns = max(gaps, default=0)
|
||||
jitter_ns = max(gaps, default=0) - min(gaps, default=0) if gaps else 0
|
||||
|
||||
return TimeWindowResult(
|
||||
frame_timestamp_ns=frame_timestamp_ns,
|
||||
sample_timestamps_ns=selected,
|
||||
max_gap_ns=max_gap_ns,
|
||||
jitter_ns=jitter_ns,
|
||||
violations=tuple(violations),
|
||||
)
|
||||
@@ -1,28 +0,0 @@
|
||||
"""Tile cache and generated tile lifecycle component."""
|
||||
|
||||
from .interfaces import LocalTileManager, TileManager
|
||||
from .types import (
|
||||
CacheValidationReport,
|
||||
GeneratedTileCandidate,
|
||||
GeneratedTileSidecar,
|
||||
GeneratedTileSyncPackage,
|
||||
TileGenerationRequest,
|
||||
TileManifestEntry,
|
||||
TileMetadataLookup,
|
||||
TileValidationDecision,
|
||||
freshness_status,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CacheValidationReport",
|
||||
"GeneratedTileCandidate",
|
||||
"GeneratedTileSidecar",
|
||||
"GeneratedTileSyncPackage",
|
||||
"LocalTileManager",
|
||||
"TileManager",
|
||||
"TileGenerationRequest",
|
||||
"TileManifestEntry",
|
||||
"TileMetadataLookup",
|
||||
"TileValidationDecision",
|
||||
"freshness_status",
|
||||
]
|
||||
@@ -1,199 +0,0 @@
|
||||
"""Public tile manager interfaces."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Protocol
|
||||
|
||||
from shared.contracts import CacheTileRecord
|
||||
from shared.errors import ErrorEnvelope
|
||||
|
||||
from .types import (
|
||||
CacheValidationReport,
|
||||
GeneratedTileCandidate,
|
||||
GeneratedTileSidecar,
|
||||
GeneratedTileSyncPackage,
|
||||
TileManifestEntry,
|
||||
TileGenerationRequest,
|
||||
TileMetadataLookup,
|
||||
TileValidationDecision,
|
||||
freshness_status,
|
||||
)
|
||||
|
||||
|
||||
class TileManager(Protocol):
|
||||
"""Validates and serves local cache tile records."""
|
||||
|
||||
def validate_cache(self) -> None:
|
||||
"""Validate local cache metadata and signatures."""
|
||||
|
||||
def get_tile_window(self, footprint: Any) -> list[Any]:
|
||||
"""Return tiles intersecting a requested footprint."""
|
||||
|
||||
|
||||
class LocalTileManager:
|
||||
"""Validates preloaded local cache metadata and serves trusted tile records."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
trusted_signature_hashes: set[str],
|
||||
now: datetime,
|
||||
postgis_available: bool = True,
|
||||
) -> None:
|
||||
self._trusted_signature_hashes = trusted_signature_hashes
|
||||
self._now = now
|
||||
self._postgis_available = postgis_available
|
||||
self._trusted_by_tile_id: dict[str, CacheTileRecord] = {}
|
||||
self._descriptor_by_tile_id: dict[str, str] = {}
|
||||
self._tile_id_by_chunk_id: dict[str, str] = {}
|
||||
self._generated_candidates: list[GeneratedTileCandidate] = []
|
||||
|
||||
def validate_cache(self, entries: list[TileManifestEntry]) -> CacheValidationReport:
|
||||
if not self._postgis_available:
|
||||
decisions = tuple(
|
||||
TileValidationDecision(
|
||||
tile_id=entry.tile_id,
|
||||
accepted=False,
|
||||
reason="postgis_unavailable",
|
||||
)
|
||||
for entry in entries
|
||||
)
|
||||
return CacheValidationReport(activated=False, decisions=decisions)
|
||||
|
||||
decisions = tuple(self._validate_entry(entry) for entry in entries)
|
||||
self._trusted_by_tile_id = {
|
||||
decision.record.tile_id: decision.record
|
||||
for decision in decisions
|
||||
if decision.record is not None
|
||||
}
|
||||
self._descriptor_by_tile_id = {
|
||||
entry.tile_id: entry.descriptor_ref
|
||||
for entry in entries
|
||||
if entry.tile_id in self._trusted_by_tile_id
|
||||
}
|
||||
self._tile_id_by_chunk_id = {
|
||||
entry.chunk_id: entry.tile_id
|
||||
for entry in entries
|
||||
if entry.tile_id in self._trusted_by_tile_id
|
||||
}
|
||||
|
||||
return CacheValidationReport(
|
||||
activated=bool(self._trusted_by_tile_id)
|
||||
and all(decision.accepted for decision in decisions),
|
||||
decisions=decisions,
|
||||
)
|
||||
|
||||
def get_tile_window(self, footprint: Any) -> list[CacheTileRecord]:
|
||||
if isinstance(footprint, dict) and "chunk_id" in footprint:
|
||||
tile_id = self._tile_id_by_chunk_id.get(str(footprint["chunk_id"]))
|
||||
return [self._trusted_by_tile_id[tile_id]] if tile_id is not None else []
|
||||
return list(self._trusted_by_tile_id.values())
|
||||
|
||||
def get_tile_metadata(self, chunk_id: str) -> TileMetadataLookup:
|
||||
tile_id = self._tile_id_by_chunk_id.get(chunk_id)
|
||||
if tile_id is None:
|
||||
return TileMetadataLookup(
|
||||
found=False,
|
||||
error=ErrorEnvelope(
|
||||
component="tile_manager",
|
||||
category="validation",
|
||||
message=f"no trusted tile metadata for chunk {chunk_id}",
|
||||
severity="warning",
|
||||
retryable=False,
|
||||
),
|
||||
)
|
||||
|
||||
return TileMetadataLookup(
|
||||
found=True,
|
||||
record=self._trusted_by_tile_id[tile_id],
|
||||
descriptor_ref=self._descriptor_by_tile_id[tile_id],
|
||||
)
|
||||
|
||||
def orthorectify_frame(self, request: TileGenerationRequest) -> GeneratedTileCandidate:
|
||||
if not request.frame_usable:
|
||||
return GeneratedTileCandidate(accepted=False, rejection_reason="frame_not_usable")
|
||||
if request.parent_covariance_m > 5.0:
|
||||
return GeneratedTileCandidate(accepted=False, rejection_reason="covariance_too_high")
|
||||
if request.quality_score < 0.25:
|
||||
return GeneratedTileCandidate(accepted=False, rejection_reason="quality_too_low")
|
||||
|
||||
trust_level = "generated" if request.parent_covariance_m <= 3.0 else "candidate"
|
||||
tile_id = f"generated-{request.mission_id}-{request.frame_id}"
|
||||
candidate = GeneratedTileCandidate(
|
||||
accepted=True,
|
||||
tile_id=tile_id,
|
||||
cog_ref=f"generated/{request.mission_id}/{tile_id}.cog.tif",
|
||||
sidecar=GeneratedTileSidecar(
|
||||
tile_id=tile_id,
|
||||
parent_frame_id=request.frame_id,
|
||||
parent_covariance_m=request.parent_covariance_m,
|
||||
quality_score=request.quality_score,
|
||||
trust_level=trust_level,
|
||||
provenance=request.source_provenance,
|
||||
),
|
||||
)
|
||||
self._generated_candidates.append(candidate)
|
||||
return candidate
|
||||
|
||||
def package_sync(self, mission_id: str) -> GeneratedTileSyncPackage:
|
||||
sidecars = tuple(
|
||||
candidate.sidecar
|
||||
for candidate in self._generated_candidates
|
||||
if candidate.sidecar is not None
|
||||
)
|
||||
manifest_delta = tuple(
|
||||
{
|
||||
"tile_id": sidecar.tile_id,
|
||||
"trust_level": sidecar.trust_level,
|
||||
"parent_covariance_m": sidecar.parent_covariance_m,
|
||||
"provenance": sidecar.provenance,
|
||||
}
|
||||
for sidecar in sidecars
|
||||
)
|
||||
return GeneratedTileSyncPackage(
|
||||
package_ref=f"generated/{mission_id}/sync-package.json",
|
||||
mission_id=mission_id,
|
||||
manifest_delta=manifest_delta,
|
||||
sidecars=sidecars,
|
||||
)
|
||||
|
||||
def _validate_entry(self, entry: TileManifestEntry) -> TileValidationDecision:
|
||||
if entry.signature_hash not in self._trusted_signature_hashes:
|
||||
return TileValidationDecision(
|
||||
tile_id=entry.tile_id,
|
||||
accepted=False,
|
||||
reason="signature_not_trusted",
|
||||
)
|
||||
|
||||
if entry.content_hash != entry.expected_content_hash:
|
||||
return TileValidationDecision(
|
||||
tile_id=entry.tile_id,
|
||||
accepted=False,
|
||||
reason="content_hash_mismatch",
|
||||
)
|
||||
|
||||
if entry.sidecar_hash != entry.expected_sidecar_hash:
|
||||
return TileValidationDecision(
|
||||
tile_id=entry.tile_id,
|
||||
accepted=False,
|
||||
reason="sidecar_hash_mismatch",
|
||||
)
|
||||
|
||||
freshness = freshness_status(entry.expires_at, self._now)
|
||||
if freshness == "stale":
|
||||
return TileValidationDecision(tile_id=entry.tile_id, accepted=False, reason="stale")
|
||||
|
||||
record = CacheTileRecord(
|
||||
tile_id=entry.tile_id,
|
||||
crs=entry.crs,
|
||||
meters_per_pixel=entry.meters_per_pixel,
|
||||
capture_date=entry.capture_date,
|
||||
signature_hash=entry.signature_hash,
|
||||
trust_level="trusted",
|
||||
freshness_status=freshness,
|
||||
provenance=entry.provenance,
|
||||
)
|
||||
return TileValidationDecision(
|
||||
tile_id=entry.tile_id,
|
||||
accepted=True,
|
||||
reason="trusted",
|
||||
record=record,
|
||||
)
|
||||
@@ -1,97 +0,0 @@
|
||||
"""Public tile manager models."""
|
||||
|
||||
from datetime import date, datetime, timezone
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, PositiveFloat
|
||||
|
||||
from shared.contracts import CacheTileRecord
|
||||
from shared.errors import ErrorEnvelope
|
||||
|
||||
|
||||
class TileManagerModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
|
||||
class TileManifestEntry(TileManagerModel):
|
||||
tile_id: str = Field(min_length=1)
|
||||
chunk_id: str = Field(min_length=1)
|
||||
crs: str = Field(min_length=1)
|
||||
meters_per_pixel: PositiveFloat
|
||||
capture_date: date
|
||||
expires_at: datetime
|
||||
content_hash: str = Field(min_length=1)
|
||||
expected_content_hash: str = Field(min_length=1)
|
||||
sidecar_hash: str = Field(min_length=1)
|
||||
expected_sidecar_hash: str = Field(min_length=1)
|
||||
signature_hash: str = Field(min_length=1)
|
||||
provenance: str = Field(min_length=1)
|
||||
footprint: dict[str, float]
|
||||
descriptor_ref: str = Field(min_length=1)
|
||||
|
||||
|
||||
class TileValidationDecision(TileManagerModel):
|
||||
tile_id: str = Field(min_length=1)
|
||||
accepted: bool
|
||||
reason: str
|
||||
record: CacheTileRecord | None = None
|
||||
|
||||
|
||||
class CacheValidationReport(TileManagerModel):
|
||||
activated: bool
|
||||
decisions: tuple[TileValidationDecision, ...]
|
||||
|
||||
@property
|
||||
def trusted_records(self) -> tuple[CacheTileRecord, ...]:
|
||||
return tuple(decision.record for decision in self.decisions if decision.record is not None)
|
||||
|
||||
|
||||
class TileMetadataLookup(TileManagerModel):
|
||||
found: bool
|
||||
record: CacheTileRecord | None = None
|
||||
descriptor_ref: str | None = None
|
||||
error: ErrorEnvelope | None = None
|
||||
|
||||
|
||||
class TileGenerationRequest(TileManagerModel):
|
||||
mission_id: str = Field(min_length=1)
|
||||
frame_id: str = Field(min_length=1)
|
||||
image_ref: str = Field(min_length=1)
|
||||
timestamp_ns: int = Field(ge=0)
|
||||
parent_covariance_m: float = Field(ge=0.0)
|
||||
frame_usable: bool
|
||||
quality_score: float = Field(ge=0.0, le=1.0)
|
||||
footprint: dict[str, float]
|
||||
source_provenance: str = Field(min_length=1)
|
||||
|
||||
|
||||
class GeneratedTileSidecar(TileManagerModel):
|
||||
tile_id: str = Field(min_length=1)
|
||||
parent_frame_id: str = Field(min_length=1)
|
||||
parent_covariance_m: float = Field(ge=0.0)
|
||||
quality_score: float = Field(ge=0.0, le=1.0)
|
||||
trust_level: Literal["generated", "candidate"]
|
||||
provenance: str = Field(min_length=1)
|
||||
|
||||
|
||||
class GeneratedTileCandidate(TileManagerModel):
|
||||
accepted: bool
|
||||
tile_id: str | None = None
|
||||
cog_ref: str | None = None
|
||||
sidecar: GeneratedTileSidecar | None = None
|
||||
rejection_reason: str | None = None
|
||||
|
||||
|
||||
class GeneratedTileSyncPackage(TileManagerModel):
|
||||
package_ref: str = Field(min_length=1)
|
||||
mission_id: str = Field(min_length=1)
|
||||
manifest_delta: tuple[dict[str, object], ...]
|
||||
sidecars: tuple[GeneratedTileSidecar, ...]
|
||||
|
||||
|
||||
def freshness_status(expires_at: datetime, now: datetime) -> Literal["fresh", "stale"]:
|
||||
normalized_expiry = expires_at
|
||||
if normalized_expiry.tzinfo is None:
|
||||
normalized_expiry = normalized_expiry.replace(tzinfo=timezone.utc)
|
||||
normalized_now = now if now.tzinfo is not None else now.replace(tzinfo=timezone.utc)
|
||||
return "fresh" if normalized_expiry >= normalized_now else "stale"
|
||||
@@ -1,39 +0,0 @@
|
||||
"""Replaceable VIO adapter component."""
|
||||
|
||||
from .interfaces import (
|
||||
ConfiguredNativeVioBackend,
|
||||
LocalVioAdapter,
|
||||
NativeVioBackend,
|
||||
NativeVioRunner,
|
||||
NativeVioRunnerFactory,
|
||||
ReplayVioBackend,
|
||||
VioAdapter,
|
||||
VioBackend,
|
||||
VioBackendError,
|
||||
create_vio_adapter,
|
||||
)
|
||||
from .types import (
|
||||
VioBackendEstimate,
|
||||
VioHealthReport,
|
||||
VioInputPacket,
|
||||
VioProcessingResult,
|
||||
VioRuntimeConfig,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ConfiguredNativeVioBackend",
|
||||
"LocalVioAdapter",
|
||||
"NativeVioBackend",
|
||||
"NativeVioRunner",
|
||||
"NativeVioRunnerFactory",
|
||||
"ReplayVioBackend",
|
||||
"VioAdapter",
|
||||
"VioBackend",
|
||||
"VioBackendError",
|
||||
"VioBackendEstimate",
|
||||
"VioHealthReport",
|
||||
"VioInputPacket",
|
||||
"VioProcessingResult",
|
||||
"VioRuntimeConfig",
|
||||
"create_vio_adapter",
|
||||
]
|
||||
@@ -1,328 +0,0 @@
|
||||
"""Public VIO adapter interfaces."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from time import perf_counter
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
|
||||
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,
|
||||
VioRuntimeConfig,
|
||||
)
|
||||
|
||||
|
||||
class VioAdapter(Protocol):
|
||||
"""Processes frame and telemetry inputs into relative VIO state."""
|
||||
|
||||
def initialize(self) -> None:
|
||||
"""Initialize adapter resources."""
|
||||
|
||||
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 VIO execution boundary."""
|
||||
|
||||
def initialize(self) -> None:
|
||||
"""Initialize native backend resources."""
|
||||
|
||||
def estimate(self, frame: Any, telemetry_window: tuple[Any, ...]) -> VioBackendEstimate:
|
||||
"""Return one relative VIO estimate."""
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class NativeVioRunner(Protocol):
|
||||
"""Runtime object supplied by the selected VIO engine package."""
|
||||
|
||||
def initialize(self) -> None:
|
||||
"""Prepare engine resources."""
|
||||
|
||||
def estimate(
|
||||
self, frame: Any, telemetry_window: tuple[Any, ...]
|
||||
) -> VioBackendEstimate | dict[str, Any]:
|
||||
"""Return an estimate payload for one synchronized replay frame."""
|
||||
|
||||
|
||||
class VioBackendError(RuntimeError):
|
||||
"""Raised when the configured VIO engine cannot produce an estimate."""
|
||||
|
||||
|
||||
NativeVioRunnerFactory = Callable[[], NativeVioRunner]
|
||||
|
||||
|
||||
class NativeVioBackend:
|
||||
"""Configurable backend adapter for native VIO engine packages."""
|
||||
|
||||
def __init__(self, runner: NativeVioRunner, backend_name: str = "native_vio") -> None:
|
||||
self._runner = runner
|
||||
self.backend_name = backend_name
|
||||
|
||||
def initialize(self) -> None:
|
||||
try:
|
||||
self._runner.initialize()
|
||||
except Exception as exc:
|
||||
raise VioBackendError(f"{self.backend_name} initialization failed: {exc}") from exc
|
||||
|
||||
def estimate(self, frame: Any, telemetry_window: tuple[Any, ...]) -> VioBackendEstimate:
|
||||
started = perf_counter()
|
||||
try:
|
||||
estimate = self._runner.estimate(frame, telemetry_window)
|
||||
except Exception as exc:
|
||||
raise VioBackendError(f"{self.backend_name} estimate failed: {exc}") from exc
|
||||
|
||||
try:
|
||||
if isinstance(estimate, VioBackendEstimate):
|
||||
if estimate.processing_latency_ms is not None:
|
||||
return estimate
|
||||
payload = estimate.model_dump()
|
||||
else:
|
||||
payload = dict(estimate)
|
||||
payload.setdefault("timestamp_ns", frame.timestamp_ns)
|
||||
payload["processing_latency_ms"] = (
|
||||
payload.get("processing_latency_ms") or (perf_counter() - started) * 1000.0
|
||||
)
|
||||
return VioBackendEstimate.model_validate(payload)
|
||||
except Exception as exc:
|
||||
raise VioBackendError(f"{self.backend_name} returned invalid estimate") from exc
|
||||
|
||||
|
||||
class ConfiguredNativeVioBackend:
|
||||
"""Lazily creates the configured native runner during adapter initialization."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
runner_factory: NativeVioRunnerFactory,
|
||||
backend_name: str = "basalt",
|
||||
) -> None:
|
||||
self._runner_factory = runner_factory
|
||||
self._backend: NativeVioBackend | None = None
|
||||
self.backend_name = backend_name
|
||||
|
||||
def initialize(self) -> None:
|
||||
try:
|
||||
runner = self._runner_factory()
|
||||
except Exception as exc:
|
||||
raise VioBackendError(f"{self.backend_name} runner creation failed") from exc
|
||||
|
||||
if not isinstance(runner, NativeVioRunner):
|
||||
raise VioBackendError(f"{self.backend_name} runner does not implement NativeVioRunner")
|
||||
|
||||
self._backend = NativeVioBackend(runner, backend_name=self.backend_name)
|
||||
self._backend.initialize()
|
||||
|
||||
def estimate(self, frame: Any, telemetry_window: tuple[Any, ...]) -> VioBackendEstimate:
|
||||
if self._backend is None:
|
||||
raise VioBackendError(f"{self.backend_name} runner is not initialized")
|
||||
return self._backend.estimate(frame, telemetry_window)
|
||||
|
||||
|
||||
class ReplayVioBackend:
|
||||
"""Small local backend for replay smoke tests when no engine is configured."""
|
||||
|
||||
backend_name = "replay_vio"
|
||||
|
||||
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,
|
||||
runtime_config: VioRuntimeConfig | None = None,
|
||||
timestamp_tolerance_ns: int = 5_000_000,
|
||||
degraded_quality_threshold: float = 0.35,
|
||||
) -> None:
|
||||
self._runtime_config = runtime_config or VioRuntimeConfig(mode="replay")
|
||||
self._backend = backend or _backend_from_runtime_config(self._runtime_config, None)
|
||||
self._backend_name = getattr(
|
||||
self._backend, "backend_name", self._backend.__class__.__name__
|
||||
)
|
||||
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,
|
||||
backend_name=self._backend_name,
|
||||
)
|
||||
|
||||
def initialize(self) -> None:
|
||||
try:
|
||||
self._backend.initialize()
|
||||
except Exception as exc:
|
||||
self._initialized = False
|
||||
self._health = VioHealthReport(
|
||||
initialized=False,
|
||||
state="failed",
|
||||
tracking_quality=0.0,
|
||||
backend_name=self._backend_name,
|
||||
error=self._backend_error(str(exc), "backend_initialization_failed", "error"),
|
||||
)
|
||||
return None
|
||||
self._initialized = True
|
||||
self._health = VioHealthReport(
|
||||
initialized=True,
|
||||
state="ready",
|
||||
tracking_quality=1.0,
|
||||
backend_name=self._backend_name,
|
||||
)
|
||||
|
||||
def process(self, packet: VioInputPacket) -> VioProcessingResult:
|
||||
if not self._initialized:
|
||||
self.initialize()
|
||||
if self._health.state == "failed":
|
||||
return VioProcessingResult(health=self._health, error=self._health.error)
|
||||
|
||||
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,
|
||||
backend_name=self._backend_name,
|
||||
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)
|
||||
)
|
||||
try:
|
||||
estimate = self._backend.estimate(packet.frame, telemetry_window)
|
||||
except Exception as exc:
|
||||
error = self._backend_error(str(exc), "backend_runtime_failed", "error")
|
||||
self._health = VioHealthReport(
|
||||
initialized=True,
|
||||
state="failed",
|
||||
tracking_quality=0.0,
|
||||
backend_name=self._backend_name,
|
||||
error=error,
|
||||
)
|
||||
return VioProcessingResult(health=self._health, error=error)
|
||||
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,
|
||||
backend_name=self._backend_name,
|
||||
)
|
||||
return VioProcessingResult(
|
||||
state_packet=state_packet,
|
||||
health=self._health,
|
||||
processing_latency_ms=estimate.processing_latency_ms,
|
||||
)
|
||||
|
||||
def health(self) -> VioHealthReport:
|
||||
return self._health
|
||||
|
||||
def _backend_error(self, message: str, cause: str, severity: str) -> ErrorEnvelope:
|
||||
return ErrorEnvelope(
|
||||
component="vio_adapter",
|
||||
category="runtime",
|
||||
message=message,
|
||||
severity=severity,
|
||||
retryable=False,
|
||||
cause=cause,
|
||||
)
|
||||
|
||||
|
||||
def create_vio_adapter(
|
||||
runtime_config: VioRuntimeConfig,
|
||||
native_runner_factory: NativeVioRunnerFactory | None = None,
|
||||
timestamp_tolerance_ns: int = 5_000_000,
|
||||
degraded_quality_threshold: float = 0.35,
|
||||
) -> LocalVioAdapter:
|
||||
backend = _backend_from_runtime_config(runtime_config, native_runner_factory)
|
||||
return LocalVioAdapter(
|
||||
backend=backend,
|
||||
runtime_config=runtime_config,
|
||||
timestamp_tolerance_ns=timestamp_tolerance_ns,
|
||||
degraded_quality_threshold=degraded_quality_threshold,
|
||||
)
|
||||
|
||||
|
||||
def _backend_from_runtime_config(
|
||||
runtime_config: VioRuntimeConfig,
|
||||
native_runner_factory: NativeVioRunnerFactory | None,
|
||||
) -> VioBackend:
|
||||
if runtime_config.effective_mode == "replay":
|
||||
return ReplayVioBackend()
|
||||
if native_runner_factory is None:
|
||||
native_runner_factory = _default_native_runner_factory(runtime_config)
|
||||
return ConfiguredNativeVioBackend(
|
||||
native_runner_factory,
|
||||
backend_name=runtime_config.native_backend_name,
|
||||
)
|
||||
|
||||
|
||||
def _default_native_runner_factory(runtime_config: VioRuntimeConfig) -> NativeVioRunnerFactory:
|
||||
def create_runner() -> NativeVioRunner:
|
||||
from vio_adapter.native.basalt import BasaltNativeRunner
|
||||
|
||||
return BasaltNativeRunner(
|
||||
module_name=runtime_config.native_runner_module,
|
||||
factory_name=runtime_config.native_runner_factory,
|
||||
config=runtime_config.native_runner_config,
|
||||
)
|
||||
|
||||
return create_runner
|
||||
@@ -1,3 +0,0 @@
|
||||
# VIO Native Backend Package
|
||||
|
||||
Exports the configured VIO backend package boundary owned by `vio_adapter`.
|
||||
@@ -1,6 +0,0 @@
|
||||
"""Native VIO backend package exports."""
|
||||
|
||||
from vio_adapter.interfaces import NativeVioBackend, NativeVioRunner, VioBackendError
|
||||
from vio_adapter.native.basalt import BasaltNativeRunner
|
||||
|
||||
__all__ = ["BasaltNativeRunner", "NativeVioBackend", "NativeVioRunner", "VioBackendError"]
|
||||
@@ -1,47 +0,0 @@
|
||||
"""Loader for installed BASALT-compatible VIO runtime packages."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
|
||||
from vio_adapter.interfaces import NativeVioRunner, VioBackendError
|
||||
from vio_adapter.types import VioBackendEstimate
|
||||
|
||||
|
||||
class BasaltNativeRunner:
|
||||
"""Adapts an installed BASALT binding to the VIO runner protocol."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
module_name: str = "basalt_vio",
|
||||
factory_name: str = "create_runner",
|
||||
config: Mapping[str, object] | None = None,
|
||||
) -> None:
|
||||
self._module_name = module_name
|
||||
self._factory_name = factory_name
|
||||
self._config = dict(config or {})
|
||||
self._runner: NativeVioRunner | None = None
|
||||
|
||||
def initialize(self) -> None:
|
||||
try:
|
||||
module = import_module(self._module_name)
|
||||
factory = getattr(module, self._factory_name)
|
||||
runner = factory(**self._config)
|
||||
except Exception as exc:
|
||||
raise VioBackendError(
|
||||
f"unable to load BASALT runtime {self._module_name}:{self._factory_name}"
|
||||
) from exc
|
||||
|
||||
if not isinstance(runner, NativeVioRunner):
|
||||
raise VioBackendError(
|
||||
f"BASALT runtime {self._module_name}:{self._factory_name} "
|
||||
"does not implement NativeVioRunner"
|
||||
)
|
||||
|
||||
self._runner = runner
|
||||
self._runner.initialize()
|
||||
|
||||
def estimate(self, frame: Any, telemetry_window: tuple[Any, ...]) -> VioBackendEstimate:
|
||||
if self._runner is None:
|
||||
raise VioBackendError("BASALT runtime is not initialized")
|
||||
return self._runner.estimate(frame, telemetry_window)
|
||||
@@ -1,69 +0,0 @@
|
||||
"""Public VIO adapter models."""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, NonNegativeInt, model_validator
|
||||
|
||||
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)
|
||||
|
||||
|
||||
VioRuntimeEnvironment = Literal["development", "ci", "staging", "jetson", "production"]
|
||||
VioRuntimeMode = Literal["replay", "native"]
|
||||
|
||||
|
||||
class VioRuntimeConfig(VioAdapterModel):
|
||||
environment: VioRuntimeEnvironment = "development"
|
||||
mode: VioRuntimeMode | None = None
|
||||
native_backend_name: str = Field(default="basalt", min_length=1)
|
||||
native_runner_module: str = Field(default="basalt_vio", min_length=1)
|
||||
native_runner_factory: str = Field(default="create_runner", min_length=1)
|
||||
native_runner_config: dict[str, object] = Field(default_factory=dict)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def production_requires_native_mode(self) -> "VioRuntimeConfig":
|
||||
if self.environment in {"jetson", "production"} and self.effective_mode != "native":
|
||||
raise ValueError("jetson and production VIO profiles require native runtime mode")
|
||||
return self
|
||||
|
||||
@property
|
||||
def effective_mode(self) -> VioRuntimeMode:
|
||||
if self.mode is not None:
|
||||
return self.mode
|
||||
if self.environment in {"jetson", "production"}:
|
||||
return "native"
|
||||
return "replay"
|
||||
|
||||
|
||||
class VioHealthReport(VioAdapterModel):
|
||||
initialized: bool
|
||||
state: Literal["not_initialized", "ready", "degraded", "failed"]
|
||||
tracking_quality: float = Field(ge=0.0, le=1.0)
|
||||
backend_name: str | None = None
|
||||
error: ErrorEnvelope | None = None
|
||||
|
||||
|
||||
class VioProcessingResult(VioAdapterModel):
|
||||
state_packet: VioStatePacket | None = None
|
||||
health: VioHealthReport
|
||||
processing_latency_ms: float | None = Field(default=None, ge=0.0)
|
||||
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
|
||||
processing_latency_ms: float | None = Field(default=None, ge=0.0)
|
||||
Reference in New Issue
Block a user