From c3650d979dcd0da694f94eaa2c0b6eca130cba80 Mon Sep 17 00:00:00 2001 From: Oleksandr Bezdieniezhnykh Date: Sun, 3 May 2026 14:01:04 +0300 Subject: [PATCH] [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 --- .../AZ-221_shared_geometry_time_sync.md | 0 .../AZ-222_runtime_config_errors_telemetry.md | 0 .../batch_03_cycle1_report.md | 37 +++++++++ .../reviews/batch_03_review.md | 25 ++++++ _docs/_autodev_state.md | 2 +- src/shared/config/__init__.py | 4 + src/shared/config/models.py | 59 +++++++++++++ src/shared/errors/__init__.py | 4 + src/shared/errors/models.py | 38 +++++++++ src/shared/geo_geometry/__init__.py | 20 +++++ src/shared/geo_geometry/models.py | 83 +++++++++++++++++++ src/shared/telemetry/__init__.py | 4 + src/shared/telemetry/models.py | 23 +++++ src/shared/time_sync/__init__.py | 14 ++++ src/shared/time_sync/models.py | 77 +++++++++++++++++ .../shared/test_config_errors_telemetry.py | 65 +++++++++++++++ tests/unit/shared/test_geometry_time_sync.py | 41 +++++++++ 17 files changed, 495 insertions(+), 1 deletion(-) rename _docs/02_tasks/{todo => done}/AZ-221_shared_geometry_time_sync.md (100%) rename _docs/02_tasks/{todo => done}/AZ-222_runtime_config_errors_telemetry.md (100%) create mode 100644 _docs/03_implementation/batch_03_cycle1_report.md create mode 100644 _docs/03_implementation/reviews/batch_03_review.md create mode 100644 src/shared/config/models.py create mode 100644 src/shared/errors/models.py create mode 100644 src/shared/geo_geometry/models.py create mode 100644 src/shared/telemetry/models.py create mode 100644 src/shared/time_sync/models.py create mode 100644 tests/unit/shared/test_config_errors_telemetry.py create mode 100644 tests/unit/shared/test_geometry_time_sync.py diff --git a/_docs/02_tasks/todo/AZ-221_shared_geometry_time_sync.md b/_docs/02_tasks/done/AZ-221_shared_geometry_time_sync.md similarity index 100% rename from _docs/02_tasks/todo/AZ-221_shared_geometry_time_sync.md rename to _docs/02_tasks/done/AZ-221_shared_geometry_time_sync.md diff --git a/_docs/02_tasks/todo/AZ-222_runtime_config_errors_telemetry.md b/_docs/02_tasks/done/AZ-222_runtime_config_errors_telemetry.md similarity index 100% rename from _docs/02_tasks/todo/AZ-222_runtime_config_errors_telemetry.md rename to _docs/02_tasks/done/AZ-222_runtime_config_errors_telemetry.md diff --git a/_docs/03_implementation/batch_03_cycle1_report.md b/_docs/03_implementation/batch_03_cycle1_report.md new file mode 100644 index 0000000..9f995f2 --- /dev/null +++ b/_docs/03_implementation/batch_03_cycle1_report.md @@ -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 diff --git a/_docs/03_implementation/reviews/batch_03_review.md b/_docs/03_implementation/reviews/batch_03_review.md new file mode 100644 index 0000000..6ed02df --- /dev/null +++ b/_docs/03_implementation/reviews/batch_03_review.md @@ -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. diff --git a/_docs/_autodev_state.md b/_docs/_autodev_state.md index cf4f7d0..bf83571 100644 --- a/_docs/_autodev_state.md +++ b/_docs/_autodev_state.md @@ -9,6 +9,6 @@ tracker: jira sub_step: phase: 1 name: batch-loop - detail: "batch 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 diff --git a/src/shared/config/__init__.py b/src/shared/config/__init__.py index 9248259..a539b7a 100644 --- a/src/shared/config/__init__.py +++ b/src/shared/config/__init__.py @@ -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"] diff --git a/src/shared/config/models.py b/src/shared/config/models.py new file mode 100644 index 0000000..2ba278c --- /dev/null +++ b/src/shared/config/models.py @@ -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() diff --git a/src/shared/errors/__init__.py b/src/shared/errors/__init__.py index ca38b48..891c272 100644 --- a/src/shared/errors/__init__.py +++ b/src/shared/errors/__init__.py @@ -1 +1,5 @@ """Shared error envelope namespace.""" + +from shared.errors.models import ErrorEnvelope, ResultEnvelope + +__all__ = ["ErrorEnvelope", "ResultEnvelope"] diff --git a/src/shared/errors/models.py b/src/shared/errors/models.py new file mode 100644 index 0000000..06c07c5 --- /dev/null +++ b/src/shared/errors/models.py @@ -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) diff --git a/src/shared/geo_geometry/__init__.py b/src/shared/geo_geometry/__init__.py index 6f568b0..486aff2 100644 --- a/src/shared/geo_geometry/__init__.py +++ b/src/shared/geo_geometry/__init__.py @@ -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", +] diff --git a/src/shared/geo_geometry/models.py b/src/shared/geo_geometry/models.py new file mode 100644 index 0000000..c6528f6 --- /dev/null +++ b/src/shared/geo_geometry/models.py @@ -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, + ) diff --git a/src/shared/telemetry/__init__.py b/src/shared/telemetry/__init__.py index 9d28594..1ee9f7e 100644 --- a/src/shared/telemetry/__init__.py +++ b/src/shared/telemetry/__init__.py @@ -1 +1,5 @@ """Structured telemetry and health metadata namespace.""" + +from shared.telemetry.models import HealthEvent, MetricsLabels + +__all__ = ["HealthEvent", "MetricsLabels"] diff --git a/src/shared/telemetry/models.py b/src/shared/telemetry/models.py new file mode 100644 index 0000000..1633322 --- /dev/null +++ b/src/shared/telemetry/models.py @@ -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"] diff --git a/src/shared/time_sync/__init__.py b/src/shared/time_sync/__init__.py index 16f1a7e..ff71e86 100644 --- a/src/shared/time_sync/__init__.py +++ b/src/shared/time_sync/__init__.py @@ -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", +] diff --git a/src/shared/time_sync/models.py b/src/shared/time_sync/models.py new file mode 100644 index 0000000..d464a66 --- /dev/null +++ b/src/shared/time_sync/models.py @@ -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), + ) diff --git a/tests/unit/shared/test_config_errors_telemetry.py b/tests/unit/shared/test_config_errors_telemetry.py new file mode 100644 index 0000000..50a1036 --- /dev/null +++ b/tests/unit/shared/test_config_errors_telemetry.py @@ -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" diff --git a/tests/unit/shared/test_geometry_time_sync.py b/tests/unit/shared/test_geometry_time_sync.py new file mode 100644 index 0000000..5241b11 --- /dev/null +++ b/tests/unit/shared/test_geometry_time_sync.py @@ -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"