mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 07:01:14 +00:00
[AZ-221] [AZ-222] Add shared runtime helpers
Provide deterministic geometry/time-sync helpers and structured config, error, health, and telemetry primitives for downstream runtime components. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
# Batch Report
|
||||
|
||||
**Batch**: 3
|
||||
**Tasks**: AZ-221_shared_geometry_time_sync, AZ-222_runtime_config_errors_telemetry
|
||||
**Date**: 2026-05-03
|
||||
|
||||
## Task Results
|
||||
|
||||
| Task | Status | Files Modified | Tests | AC Coverage | Issues |
|
||||
|------|--------|----------------|-------|-------------|--------|
|
||||
| AZ-221_shared_geometry_time_sync | Done | 5 files | Pass | 2/2 ACs covered | None |
|
||||
| AZ-222_runtime_config_errors_telemetry | Done | 7 files | Pass | 2/2 ACs covered | None |
|
||||
|
||||
## AC Test Coverage: All covered
|
||||
|
||||
| AC Ref | Coverage |
|
||||
|--------|----------|
|
||||
| AZ-221 AC-1 | `test_wgs84_local_round_trip_is_deterministic` verifies deterministic WGS84/local conversion and metric output. |
|
||||
| AZ-221 AC-2 | `test_non_monotonic_timestamps_return_explicit_violation` and `test_time_window_reports_gap_instead_of_dropping_silently` verify explicit time-sync violation results. |
|
||||
| AZ-222 AC-1 | `test_missing_production_cache_dir_returns_readiness_failure` verifies missing production settings produce a structured readiness failure. |
|
||||
| AZ-222 AC-2 | `test_dependency_error_envelope_has_required_structured_fields` verifies dependency errors include component, category, severity, and retryability. |
|
||||
|
||||
## Code Review Verdict: PASS
|
||||
|
||||
Review report: `_docs/03_implementation/reviews/batch_03_review.md`
|
||||
|
||||
## Auto-Fix Attempts: 0
|
||||
|
||||
## Stuck Agents: None
|
||||
|
||||
## Verification
|
||||
|
||||
- `.venv/bin/python -m black --check src tests e2e/replay` passed.
|
||||
- `.venv/bin/python -m ruff check src tests e2e/replay` passed.
|
||||
- `.venv/bin/python -m pytest` passed: 17 tests.
|
||||
|
||||
## Next Batch: AZ-223_camera_ingest_calibration, AZ-224_mavlink_gcs_gateway, AZ-225_tile_manager_cache_manifest, AZ-227_fdr_event_recorder
|
||||
@@ -0,0 +1,25 @@
|
||||
# Code Review Report
|
||||
|
||||
**Batch**: AZ-221_shared_geometry_time_sync, AZ-222_runtime_config_errors_telemetry
|
||||
**Date**: 2026-05-03
|
||||
**Verdict**: PASS
|
||||
|
||||
## Findings
|
||||
|
||||
| # | Severity | Category | File:Line | Title |
|
||||
|---|----------|----------|-----------|-------|
|
||||
| - | - | - | - | No findings |
|
||||
|
||||
## Review Notes
|
||||
|
||||
- AZ-221 AC-1 is satisfied by deterministic WGS84/local conversion, distance, and footprint helpers under `shared/geo_geometry`.
|
||||
- AZ-221 AC-2 is satisfied by explicit time-sync violation models and window selection results under `shared/time_sync`.
|
||||
- AZ-222 AC-1 is satisfied by runtime profile validation and structured readiness failure envelopes under `shared/config`.
|
||||
- AZ-222 AC-2 is satisfied by reusable `ErrorEnvelope` / `ResultEnvelope` models plus FDR-safe health and metrics metadata.
|
||||
- The batch stays within shared foundation ownership and does not introduce component policy decisions.
|
||||
|
||||
## Verification
|
||||
|
||||
- `.venv/bin/python -m black --check src tests e2e/replay` passed.
|
||||
- `.venv/bin/python -m ruff check src tests e2e/replay` passed.
|
||||
- `.venv/bin/python -m pytest` passed: 17 tests.
|
||||
@@ -9,6 +9,6 @@ tracker: jira
|
||||
sub_step:
|
||||
phase: 1
|
||||
name: batch-loop
|
||||
detail: "batch 2: AZ-220_shared_runtime_contracts"
|
||||
detail: "batch 3: AZ-221_shared_geometry_time_sync, AZ-222_runtime_config_errors_telemetry"
|
||||
retry_count: 0
|
||||
cycle: 1
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
"""Runtime configuration helper namespace."""
|
||||
|
||||
from shared.config.models import RuntimeProfile, readiness_error, validate_runtime_profile
|
||||
|
||||
__all__ = ["RuntimeProfile", "readiness_error", "validate_runtime_profile"]
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""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 +1,5 @@
|
||||
"""Shared error envelope namespace."""
|
||||
|
||||
from shared.errors.models import ErrorEnvelope, ResultEnvelope
|
||||
|
||||
__all__ = ["ErrorEnvelope", "ResultEnvelope"]
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""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 +1,21 @@
|
||||
"""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",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""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 +1,5 @@
|
||||
"""Structured telemetry and health metadata namespace."""
|
||||
|
||||
from shared.telemetry.models import HealthEvent, MetricsLabels
|
||||
|
||||
__all__ = ["HealthEvent", "MetricsLabels"]
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""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 +1,15 @@
|
||||
"""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",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""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),
|
||||
)
|
||||
@@ -0,0 +1,65 @@
|
||||
from shared.config import validate_runtime_profile
|
||||
from shared.errors import ErrorEnvelope, ResultEnvelope
|
||||
from shared.telemetry import HealthEvent, MetricsLabels
|
||||
|
||||
|
||||
def test_missing_production_cache_dir_returns_readiness_failure() -> None:
|
||||
# Arrange
|
||||
payload = {
|
||||
"environment": "production",
|
||||
"config_dir": "/etc/gps-denied-onboard",
|
||||
"fdr_dir": "/var/lib/gps-denied/fdr",
|
||||
"database_url": "postgresql://localhost/gpsd",
|
||||
"mavlink_url": "serial:/dev/ttyTHS1:921600",
|
||||
"camera_source": "hardware",
|
||||
"signing_key_ref": "secret-ref",
|
||||
}
|
||||
|
||||
# Act
|
||||
result = validate_runtime_profile("runtime", payload)
|
||||
|
||||
# Assert
|
||||
assert result.ok is False
|
||||
assert result.error is not None
|
||||
assert result.error.component == "runtime"
|
||||
assert result.error.category == "configuration"
|
||||
assert result.error.severity == "critical"
|
||||
assert result.error.retryable is False
|
||||
|
||||
|
||||
def test_dependency_error_envelope_has_required_structured_fields() -> None:
|
||||
# Act
|
||||
result = ResultEnvelope.failure(
|
||||
ErrorEnvelope(
|
||||
component="tile_manager",
|
||||
category="dependency",
|
||||
message="postgis unavailable",
|
||||
severity="error",
|
||||
retryable=True,
|
||||
cause="connection refused",
|
||||
)
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.ok is False
|
||||
assert result.error is not None
|
||||
assert result.error.component == "tile_manager"
|
||||
assert result.error.category == "dependency"
|
||||
assert result.error.severity == "error"
|
||||
assert result.error.retryable is True
|
||||
|
||||
|
||||
def test_health_event_and_metrics_labels_are_fdr_safe_metadata() -> None:
|
||||
# Act
|
||||
health = HealthEvent(
|
||||
component="runtime",
|
||||
timestamp_ns=1,
|
||||
liveness="alive",
|
||||
readiness="ready",
|
||||
dependency_state={"postgis": "ready"},
|
||||
)
|
||||
labels = MetricsLabels(component="runtime", action="startup", status="ok")
|
||||
|
||||
# Assert
|
||||
assert health.dependency_state["postgis"] == "ready"
|
||||
assert labels.status == "ok"
|
||||
@@ -0,0 +1,41 @@
|
||||
from shared.geo_geometry import Wgs84Coordinate, distance_m, local_to_wgs84, wgs84_to_local
|
||||
from shared.time_sync import check_monotonic_timestamps, select_time_window
|
||||
|
||||
|
||||
def test_wgs84_local_round_trip_is_deterministic() -> None:
|
||||
# Arrange
|
||||
origin = Wgs84Coordinate(latitude_deg=49.9808, longitude_deg=36.2527, altitude_m=120.0)
|
||||
point = Wgs84Coordinate(latitude_deg=49.9811, longitude_deg=36.2531, altitude_m=118.0)
|
||||
|
||||
# Act
|
||||
local = wgs84_to_local(origin, point)
|
||||
round_trip = local_to_wgs84(origin, local)
|
||||
|
||||
# Assert
|
||||
assert round(round_trip.latitude_deg, 7) == round(point.latitude_deg, 7)
|
||||
assert round(round_trip.longitude_deg, 7) == round(point.longitude_deg, 7)
|
||||
assert round(round_trip.altitude_m, 7) == round(point.altitude_m, 7)
|
||||
assert distance_m(origin, point) > 0.0
|
||||
|
||||
|
||||
def test_non_monotonic_timestamps_return_explicit_violation() -> None:
|
||||
# Act
|
||||
violations = check_monotonic_timestamps([100, 200, 150])
|
||||
|
||||
# Assert
|
||||
assert len(violations) == 1
|
||||
assert violations[0].category == "timestamp_mismatch"
|
||||
|
||||
|
||||
def test_time_window_reports_gap_instead_of_dropping_silently() -> None:
|
||||
# Act
|
||||
result = select_time_window(
|
||||
frame_timestamp_ns=1_000,
|
||||
sample_timestamps_ns=[100, 200, 300],
|
||||
tolerance_ns=50,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.ok is False
|
||||
assert result.sample_timestamps_ns == ()
|
||||
assert result.violations[0].category == "gap_exceeded"
|
||||
Reference in New Issue
Block a user