[AZ-270] [AZ-272] [AZ-279] [AZ-281] [AZ-283] Compose root + FDR schema + 3 Layer-1 helpers

AZ-270: composition root with strategy registry, tier-gated lookup,
topo-order construction, all-or-nothing teardown, StrategyNotLinkedError
payload.
AZ-272: orjson-backed FdrRecord serialise/parse with forward-compat for
unknown payload + top-level fields and canonical overrun-record shape.
AZ-279: pyproj-backed WGS84/ECEF/ENU + OSM slippy-map tile math with
WgsConversionError for shape/range/zoom guards.
AZ-281: strict EngineFilenameSchema build/parse/matches_host with
anchored regex + enum validation; round-trip identity by construction.
AZ-283: dtype-preserving (fp16/fp32) single + batch L2 normaliser with
zero-norm safety and descriptor_metric() source-of-truth.
pyproject.toml pins pyproj>=3.6 and orjson>=3.9 (named-backend deps per
the AZ-272 / AZ-279 contracts). New DTOs LatLonAlt + BoundingBox and
EngineCacheKey + HostCapabilities land in _types/ to back the helper
contracts.
203 unit tests pass (64 new). Review verdict: PASS_WITH_WARNINGS;
findings are perf-NFR deferrals + dep amendment + minor docstring polish.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-11 02:03:36 +03:00
parent 8e71f6c002
commit 3acc7f33dd
24 changed files with 2381 additions and 97 deletions
+38
View File
@@ -0,0 +1,38 @@
"""Geographic DTOs shared by every component that crosses the WGS / ENU / tile-pixel
boundary.
Consumed by `helpers.wgs_converter` (AZ-279), C4 / C5 / C6 / C8 / C10 / C11 / C12.
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class LatLonAlt:
"""A WGS84 geographic position. ``alt_m`` is height above the WGS84 ellipsoid."""
lat_deg: float
lon_deg: float
alt_m: float
@dataclass(frozen=True)
class BoundingBox:
"""An axis-aligned lat/lon bounding box.
The slippy-map tile bounds returned by ``WgsConverter.tile_xy_to_latlon_bounds``
use this shape. Latitude axis grows northward; longitude axis grows eastward.
"""
min_lat_deg: float
min_lon_deg: float
max_lat_deg: float
max_lon_deg: float
def contains(self, lat_deg: float, lon_deg: float) -> bool:
return (
self.min_lat_deg <= lat_deg <= self.max_lat_deg
and self.min_lon_deg <= lon_deg <= self.max_lon_deg
)
@@ -30,3 +30,34 @@ class EngineCacheEntry:
precision: str
content_hash: str
int8_calibration_path: str | None = None
@dataclass(frozen=True)
class EngineCacheKey:
"""Parsed tuple of a self-describing `.engine` filename (D-C10-7, AZ-281).
Filename schema: ``{model}__sm{SM}_jp{JP_dotted}_trt{TRT_dotted}_{precision}.engine``.
The dotted-version strings (``jetpack``, ``trt``) are stored verbatim so the
round-trip ``parse(build(*args)) == EngineCacheKey(*args)`` invariant holds.
"""
model_name: str
sm: int
jetpack: str
trt: str
precision: str
@dataclass(frozen=True)
class HostCapabilities:
"""Host-side TensorRT capability tuple consulted by AZ-281's ``matches_host``.
Captures the same five-tuple the engine filename encodes; ``matches_host`` is
true iff every element matches exactly. Precision and ``model_name`` are
excluded from the comparison: the predicate is host-vs-binary, not
engine-vs-engine.
"""
sm: int
jetpack: str
trt: str
+23 -2
View File
@@ -5,6 +5,27 @@ Producer-side API used by every component. Consumer-side writer lives in
"""
from gps_denied_onboard.fdr_client.client import FdrClient
from gps_denied_onboard.fdr_client.records import FdrRecord
from gps_denied_onboard.fdr_client.records import (
CURRENT_SCHEMA_VERSION,
KNOWN_KINDS,
MAX_INLINE_BLOB_BYTES,
OVERRUN_KIND,
OVERRUN_PRODUCER_ID,
FdrRecord,
FdrSchemaError,
parse,
serialise,
)
__all__ = ["FdrClient", "FdrRecord"]
__all__ = [
"CURRENT_SCHEMA_VERSION",
"KNOWN_KINDS",
"MAX_INLINE_BLOB_BYTES",
"OVERRUN_KIND",
"OVERRUN_PRODUCER_ID",
"FdrClient",
"FdrRecord",
"FdrSchemaError",
"parse",
"serialise",
]
+214 -13
View File
@@ -1,25 +1,226 @@
"""FDR record schema — STUB.
"""FDR record schema + versioned (de)serialiser (AZ-272 / E-CC-FDR-CLIENT).
Concrete schema (estimates / IMU / MAVLink / health / tile / thumbnail discriminated
record types) is owned by AZ-272. Bootstrap declares the umbrella DTO so every
producer can import it.
Public surface frozen by
`_docs/02_document/contracts/shared_fdr_client/fdr_record_schema.md` v1.0.0.
The library backing ``serialise`` / ``parse`` (``orjson``) is pinned in
``pyproject.toml`` and intentionally hidden from the public API — callers
trade ``FdrRecord <-> bytes`` only.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any
from typing import Any, Final
import orjson
__all__ = [
"CURRENT_SCHEMA_VERSION",
"KNOWN_KINDS",
"MAX_INLINE_BLOB_BYTES",
"OVERRUN_KIND",
"OVERRUN_PRODUCER_ID",
"FdrRecord",
"FdrSchemaError",
"parse",
"serialise",
]
CURRENT_SCHEMA_VERSION: Final[int] = 1
OVERRUN_KIND: Final[str] = "overrun"
OVERRUN_PRODUCER_ID: Final[str] = "shared.fdr_client"
# Per-kind allowed payload keys. The parser uses this to route unknown future
# fields into ``payload["extra"]`` (forward-compat AC-2). Unknown ``kind`` values
# bypass the table and are returned opaquely (AC-3).
KNOWN_PAYLOAD_KEYS: Final[dict[str, frozenset[str]]] = {
"log": frozenset({"level", "component", "frame_id", "kind", "msg", "kv", "exc"}),
"vio.tick": frozenset(
{"frame_id", "R", "t", "P", "last_anchor_age_ms", "mre_px", "imu_bias_norm"}
),
"state.tick": frozenset({"frame_id", "fused_pose", "covariance_2x2", "estimator_label"}),
"tile_match": frozenset({"frame_id", "tile_id", "score", "match_count", "ransac_inliers"}),
"overrun": frozenset({"producer_id", "dropped_count"}),
"segment_rollover": frozenset({"old_segment", "new_segment", "total_bytes_after"}),
"failed_tile_thumbnail": frozenset({"frame_id", "tile_id", "jpeg_bytes_b64"}),
"mid_flight_tile_snapshot": frozenset({"snapshot_path", "captured_at"}),
"flight_header": frozenset({"flight_id", "started_at", "schema_version", "build_info"}),
"flight_footer": frozenset({"flight_id", "ended_at", "records_written", "records_dropped"}),
}
KNOWN_KINDS: Final[frozenset[str]] = frozenset(KNOWN_PAYLOAD_KEYS.keys())
# Inline binary blob cap; bigger payloads must reference a sidecar path.
MAX_INLINE_BLOB_BYTES: Final[int] = 4 * 1024
class FdrSchemaError(ValueError):
"""Raised on schema-violation in ``serialise`` / ``parse`` (AZ-272).
The ONLY exception type either function raises on schema-violation; orjson
/ library-specific errors are wrapped at the public boundary.
"""
@dataclass(frozen=True)
class FdrRecord:
"""A single FDR record (record-type discriminator + payload).
"""Frozen FDR record envelope per contract v1.0.0."""
The full discriminated-union of record types is defined by AZ-272.
"""
record_type: str
timestamp: datetime
producer: str
schema_version: int
ts: str
producer_id: str
kind: str
payload: dict[str, Any] = field(default_factory=dict)
extra: dict[str, Any] = field(default_factory=dict)
_ENVELOPE_REQUIRED: Final[tuple[str, ...]] = (
"schema_version",
"ts",
"producer_id",
"kind",
"payload",
)
def _validate_envelope_outgoing(record: FdrRecord) -> None:
if not isinstance(record.schema_version, int) or isinstance(record.schema_version, bool):
raise FdrSchemaError(
f"FdrRecord.schema_version must be a non-bool integer; got {record.schema_version!r}"
)
if record.schema_version < 1:
raise FdrSchemaError(f"FdrRecord.schema_version must be >= 1; got {record.schema_version}")
if not isinstance(record.ts, str) or not record.ts:
raise FdrSchemaError(f"FdrRecord.ts must be a non-empty string; got {record.ts!r}")
if not isinstance(record.producer_id, str) or not record.producer_id:
raise FdrSchemaError(
f"FdrRecord.producer_id must be a non-empty string; got {record.producer_id!r}"
)
if not isinstance(record.kind, str) or not record.kind:
raise FdrSchemaError(f"FdrRecord.kind must be a non-empty string; got {record.kind!r}")
if not isinstance(record.payload, dict):
raise FdrSchemaError(
f"FdrRecord.payload must be a dict; got {type(record.payload).__name__}"
)
if record.extra:
raise FdrSchemaError(
"FdrRecord.extra is populated only by the parser; producers must leave it empty"
)
_validate_payload_size(record.payload)
if record.kind == OVERRUN_KIND:
_validate_overrun_payload(record.payload)
def _validate_payload_size(payload: dict[str, Any]) -> None:
"""Reject any single binary blob >MAX_INLINE_BLOB_BYTES inside the payload."""
for key, value in payload.items():
if isinstance(value, (bytes, bytearray)) and len(value) > MAX_INLINE_BLOB_BYTES:
raise FdrSchemaError(
f"FdrRecord.payload[{key!r}] is {len(value)} bytes; "
f"max inline blob is {MAX_INLINE_BLOB_BYTES} bytes — use a sidecar path"
)
def _validate_overrun_payload(payload: dict[str, Any]) -> None:
inner_producer_id = payload.get("producer_id")
if not isinstance(inner_producer_id, str) or not inner_producer_id:
raise FdrSchemaError(
"overrun record: payload.producer_id must be a non-empty string identifying the "
"originating producer"
)
dropped_count = payload.get("dropped_count")
if not isinstance(dropped_count, int) or isinstance(dropped_count, bool):
raise FdrSchemaError(
"overrun record: payload.dropped_count must be a non-bool integer; "
f"got {dropped_count!r}"
)
if dropped_count <= 0:
raise FdrSchemaError(
f"overrun record: payload.dropped_count must be > 0; got {dropped_count}"
)
def serialise(record: FdrRecord) -> bytes:
"""Encode ``record`` to wire bytes (single-line UTF-8 JSON, ``orjson``-backed)."""
_validate_envelope_outgoing(record)
envelope: dict[str, Any] = {
"schema_version": record.schema_version,
"ts": record.ts,
"producer_id": record.producer_id,
"kind": record.kind,
"payload": record.payload,
}
try:
return orjson.dumps(envelope)
except (TypeError, orjson.JSONEncodeError) as exc:
raise FdrSchemaError(f"failed to serialise FdrRecord: {exc}") from exc
def parse(buf: bytes) -> FdrRecord:
"""Decode wire bytes to an ``FdrRecord``; forward-compatible per contract AC-2/3."""
if not isinstance(buf, (bytes, bytearray)):
raise FdrSchemaError(f"parse expects bytes; got {type(buf).__name__}")
try:
decoded = orjson.loads(buf)
except orjson.JSONDecodeError as exc:
raise FdrSchemaError(f"failed to decode FdrRecord bytes: {exc}") from exc
if not isinstance(decoded, dict):
raise FdrSchemaError(
f"FdrRecord wire payload must decode to a dict; got {type(decoded).__name__}"
)
missing = [k for k in _ENVELOPE_REQUIRED if k not in decoded]
if missing:
raise FdrSchemaError(f"FdrRecord missing required field(s): {', '.join(missing)}")
schema_version = decoded.pop("schema_version")
if not isinstance(schema_version, int) or isinstance(schema_version, bool):
raise FdrSchemaError(
f"FdrRecord.schema_version must be a non-bool integer; got {schema_version!r}"
)
if schema_version < 1:
raise FdrSchemaError(f"FdrRecord.schema_version must be >= 1; got {schema_version}")
ts = decoded.pop("ts")
if not isinstance(ts, str) or not ts:
raise FdrSchemaError(f"FdrRecord.ts must be a non-empty string; got {ts!r}")
producer_id = decoded.pop("producer_id")
if not isinstance(producer_id, str) or not producer_id:
raise FdrSchemaError(
f"FdrRecord.producer_id must be a non-empty string; got {producer_id!r}"
)
kind = decoded.pop("kind")
if not isinstance(kind, str) or not kind:
raise FdrSchemaError(f"FdrRecord.kind must be a non-empty string; got {kind!r}")
payload = decoded.pop("payload")
if not isinstance(payload, dict):
raise FdrSchemaError(f"FdrRecord.payload must be a dict; got {type(payload).__name__}")
# Anything left at the top level after popping required + payload is forward-compat extra.
extra = dict(decoded)
# Forward-compat payload sweep: for known kinds, keys outside the v1.0.0
# set are stashed in payload["extra"] so future v1.x producers can add new
# fields without breaking v1.0 readers.
known_keys = KNOWN_PAYLOAD_KEYS.get(kind)
if known_keys is not None:
unknown_keys = [k for k in payload.keys() if k not in known_keys and k != "extra"]
if unknown_keys:
extra_bucket = dict(payload.get("extra") or {})
for key in unknown_keys:
extra_bucket[key] = payload.pop(key)
payload["extra"] = extra_bucket
if kind == OVERRUN_KIND:
_validate_overrun_payload(payload)
return FdrRecord(
schema_version=schema_version,
ts=ts,
producer_id=producer_id,
kind=kind,
payload=payload,
extra=extra,
)
@@ -5,6 +5,17 @@ ones that have landed so consumers can depend on a stable public surface
without reaching into the helper modules directly.
"""
from gps_denied_onboard.helpers.descriptor_normaliser import (
ALLOWED_DTYPES,
DescriptorNormaliser,
DescriptorNormaliserError,
)
from gps_denied_onboard.helpers.engine_filename_schema import (
ALLOWED_PRECISIONS,
ENGINE_SUFFIX,
EngineFilenameSchema,
EngineFilenameSchemaError,
)
from gps_denied_onboard.helpers.se3_utils import (
SE3,
Se3InvalidMatrixError,
@@ -20,13 +31,30 @@ from gps_denied_onboard.helpers.sha256_sidecar import (
Sha256Sidecar,
Sha256SidecarError,
)
from gps_denied_onboard.helpers.wgs_converter import (
MAX_ZOOM,
WEB_MERCATOR_MAX_LAT_DEG,
WgsConversionError,
WgsConverter,
)
__all__ = [
"ALLOWED_DTYPES",
"ALLOWED_PRECISIONS",
"ENGINE_SUFFIX",
"MAX_ZOOM",
"SE3",
"SIDECAR_SUFFIX",
"WEB_MERCATOR_MAX_LAT_DEG",
"DescriptorNormaliser",
"DescriptorNormaliserError",
"EngineFilenameSchema",
"EngineFilenameSchemaError",
"Se3InvalidMatrixError",
"Sha256Sidecar",
"Sha256SidecarError",
"WgsConversionError",
"WgsConverter",
"adjoint",
"exp_map",
"is_valid_rotation",
@@ -1,14 +1,97 @@
"""Descriptor-normalisation utility — STUB.
"""L2 descriptor normaliser aligning cosine similarity to FAISS inner-product (AZ-283).
Concrete impl owned by AZ-283. Contract:
`_docs/02_document/common-helpers/08_helper_descriptor_normaliser.md`.
Public surface frozen by
``_docs/02_document/contracts/shared_helpers/descriptor_normaliser.md`` v1.0.0.
Used on both the corpus side (C10 index build) and the query side (C2 runtime
lookup). The two sides MUST go through the same helper so the FAISS HNSW
search returns useful neighbours.
"""
from __future__ import annotations
from typing import Any
from typing import Final
import numpy as np
__all__ = [
"ALLOWED_DTYPES",
"DescriptorNormaliser",
"DescriptorNormaliserError",
]
# Allowed input dtypes; anything else is rejected to keep the FAISS index and
# query path on the same precision.
ALLOWED_DTYPES: Final[tuple[np.dtype, ...]] = (
np.dtype(np.float16),
np.dtype(np.float32),
)
_METRIC_VALUE: Final[str] = "inner_product"
def l2_normalise(descriptors: Any) -> Any:
"""L2-normalise a (N, D) descriptor matrix in-place semantics."""
raise NotImplementedError("descriptor_normaliser concrete impl is AZ-283")
class DescriptorNormaliserError(ValueError):
"""Raised on shape / dtype violations (AZ-283)."""
def _validate_dtype(arr: np.ndarray, label: str) -> None:
if arr.dtype not in ALLOWED_DTYPES:
raise DescriptorNormaliserError(
f"{label}: dtype {arr.dtype} not in allowed set (float16, float32)"
)
class DescriptorNormaliser:
"""Stateless L2-normalisation helper; dtype-preserving; zero-norm safe."""
@staticmethod
def l2_normalise(descriptor: np.ndarray) -> np.ndarray:
if not isinstance(descriptor, np.ndarray):
raise DescriptorNormaliserError(
f"l2_normalise: expected np.ndarray; got {type(descriptor).__name__}"
)
if descriptor.ndim != 1:
raise DescriptorNormaliserError(
f"l2_normalise: expected 1-D shape (D,); got shape {descriptor.shape}"
)
if descriptor.shape[0] < 1:
raise DescriptorNormaliserError(
f"l2_normalise: dimension must be >= 1; got shape {descriptor.shape}"
)
_validate_dtype(descriptor, "l2_normalise")
in_dtype = descriptor.dtype
# Compute norm in float32 to stabilise float16 inputs against overflow /
# underflow; cast back to the caller dtype so we never silently up-cast.
as_f32 = descriptor.astype(np.float32, copy=False)
norm = float(np.linalg.norm(as_f32))
if norm == 0.0:
return np.zeros_like(descriptor)
normalised_f32 = as_f32 / norm
return normalised_f32.astype(in_dtype, copy=False)
@staticmethod
def l2_normalise_batch(descriptors: np.ndarray) -> np.ndarray:
if not isinstance(descriptors, np.ndarray):
raise DescriptorNormaliserError(
f"l2_normalise_batch: expected np.ndarray; got {type(descriptors).__name__}"
)
if descriptors.ndim != 2:
raise DescriptorNormaliserError(
f"l2_normalise_batch: expected 2-D shape (N, D); got shape {descriptors.shape}"
)
if descriptors.shape[0] < 1 or descriptors.shape[1] < 1:
raise DescriptorNormaliserError(
f"l2_normalise_batch: N and D must be >= 1; got shape {descriptors.shape}"
)
_validate_dtype(descriptors, "l2_normalise_batch")
in_dtype = descriptors.dtype
as_f32 = descriptors.astype(np.float32, copy=False)
norms = np.linalg.norm(as_f32, axis=1, keepdims=True)
# Avoid division-by-zero: leave zero rows as zero.
safe = np.where(norms == 0.0, 1.0, norms)
normalised_f32 = np.where(norms == 0.0, 0.0, as_f32 / safe)
return normalised_f32.astype(in_dtype, copy=False)
@staticmethod
def descriptor_metric() -> str:
return _METRIC_VALUE
@@ -1,28 +1,127 @@
"""TensorRT engine filename schema — STUB.
"""Self-describing `.engine` filename schema (AZ-281 / D-C10-7).
D-C10-7 self-describing engine names. Concrete impl owned by AZ-281. Contract:
`_docs/02_document/common-helpers/06_helper_engine_filename_schema.md`.
Public surface frozen by
``_docs/02_document/contracts/shared_helpers/engine_filename_schema.md`` v1.0.0.
Filename format: ``{model}__sm{SM}_jp{JP_dotted}_trt{TRT_dotted}_{precision}.engine``
where ``model`` is ``[a-z0-9_]`` (no ``__``), versions are dotted
``<major>.<minor>``, and ``precision`` is one of ``fp16``, ``int8``, ``mixed``.
"""
from __future__ import annotations
from dataclasses import dataclass
import re
from typing import Final
from gps_denied_onboard._types.manifests import EngineCacheKey, HostCapabilities
__all__ = [
"ALLOWED_PRECISIONS",
"ENGINE_SUFFIX",
"EngineFilenameSchema",
"EngineFilenameSchemaError",
]
ENGINE_SUFFIX: Final[str] = ".engine"
ALLOWED_PRECISIONS: Final[frozenset[str]] = frozenset({"fp16", "int8", "mixed"})
_MODEL_RE: Final[re.Pattern[str]] = re.compile(r"^[a-z0-9_]+$")
_DOTTED_VERSION_RE: Final[re.Pattern[str]] = re.compile(r"^\d+\.\d+$")
_FILENAME_RE: Final[re.Pattern[str]] = re.compile(
r"^(?P<model>[a-z0-9_]+)__sm(?P<sm>\d+)_jp(?P<jetpack>\d+\.\d+)_trt(?P<trt>\d+\.\d+)_"
r"(?P<precision>fp16|int8|mixed)\.engine$"
)
@dataclass(frozen=True)
class EngineFilename:
"""Parsed parts of a self-describing engine filename."""
class EngineFilenameSchemaError(ValueError):
"""Raised by ``build`` / ``parse`` on validation / format violations (AZ-281)."""
model_name: str
sm_arch: str
jetpack_version: str
tensorrt_version: str
precision: str
content_hash: str
def render(self) -> str:
raise NotImplementedError("engine_filename_schema concrete impl is AZ-281")
class EngineFilenameSchema:
"""Stateless ``.engine`` filename builder / parser / host-match predicate."""
@classmethod
def parse(cls, filename: str) -> EngineFilename:
raise NotImplementedError("engine_filename_schema concrete impl is AZ-281")
@staticmethod
def build(model_name: str, sm: int, jetpack: str, trt: str, precision: str) -> str:
_validate_model_name(model_name)
_validate_sm(sm)
_validate_version(jetpack, "jetpack")
_validate_version(trt, "trt")
_validate_precision(precision)
return f"{model_name}__sm{sm}_jp{jetpack}_trt{trt}_{precision}{ENGINE_SUFFIX}"
@staticmethod
def parse(filename: str) -> EngineCacheKey:
if not isinstance(filename, str):
raise EngineFilenameSchemaError(f"parse expects str; got {type(filename).__name__}")
if not filename.endswith(ENGINE_SUFFIX):
raise EngineFilenameSchemaError(
f"parse: filename must end with {ENGINE_SUFFIX!r}; got {filename!r}"
)
match = _FILENAME_RE.match(filename)
if not match:
raise EngineFilenameSchemaError(
f"parse: filename {filename!r} does not match the engine-schema format "
"'{model}__sm{SM}_jp{JP}_trt{TRT}_{precision}.engine'"
)
model = match.group("model")
if "__" in model:
raise EngineFilenameSchemaError(
f"parse: model segment {model!r} contains reserved separator '__'"
)
return EngineCacheKey(
model_name=model,
sm=int(match.group("sm")),
jetpack=match.group("jetpack"),
trt=match.group("trt"),
precision=match.group("precision"),
)
@staticmethod
def matches_host(filename: str, host_capabilities: HostCapabilities) -> bool:
key = EngineFilenameSchema.parse(filename)
return (
key.sm == host_capabilities.sm
and key.jetpack == host_capabilities.jetpack
and key.trt == host_capabilities.trt
)
def _validate_model_name(model_name: str) -> None:
if not isinstance(model_name, str):
raise EngineFilenameSchemaError(f"model_name must be str; got {type(model_name).__name__}")
if not model_name:
raise EngineFilenameSchemaError("model_name must be a non-empty string")
if "__" in model_name:
raise EngineFilenameSchemaError(
f"model_name {model_name!r} contains reserved separator '__'"
)
if not _MODEL_RE.match(model_name):
raise EngineFilenameSchemaError(
f"model_name {model_name!r} must match [a-z0-9_]+ (lowercase, digits, underscores)"
)
if len(model_name) > 64:
raise EngineFilenameSchemaError(f"model_name {model_name!r} exceeds 64-character limit")
def _validate_sm(sm: int) -> None:
if not isinstance(sm, int) or isinstance(sm, bool):
raise EngineFilenameSchemaError(f"sm must be a non-bool integer; got {sm!r}")
if sm <= 0:
raise EngineFilenameSchemaError(f"sm must be > 0; got {sm}")
def _validate_version(version: str, field_name: str) -> None:
if not isinstance(version, str):
raise EngineFilenameSchemaError(f"{field_name} must be str; got {type(version).__name__}")
if not _DOTTED_VERSION_RE.match(version):
raise EngineFilenameSchemaError(
f"{field_name} {version!r} must match dotted '<major>.<minor>' format"
)
def _validate_precision(precision: str) -> None:
if precision not in ALLOWED_PRECISIONS:
raise EngineFilenameSchemaError(
f"precision {precision!r} not in allowed enum "
f"{{{', '.join(sorted(ALLOWED_PRECISIONS))}}}"
)
+162 -18
View File
@@ -1,26 +1,170 @@
"""WGS84 ↔ local-tangent-plane converter — STUB.
"""WGS84 ↔ ECEF ↔ ENU ↔ slippy-map tile-xy conversions (AZ-279 / E-CC-HELPERS).
Concrete implementation is owned by AZ-279. Contract:
`_docs/02_document/common-helpers/04_helper_wgs_converter.md`.
Public surface frozen by
``_docs/02_document/contracts/shared_helpers/wgs_converter.md`` v1.0.0.
Backed by ``pyproj`` for the geodesy primitives. Slippy-map tile math is hand
rolled to match OSM's `{zoom}/{x}/{y}.jpg` convention exactly so the on-disk
layout produced by ``satellite-provider`` round-trips byte-equal.
"""
from __future__ import annotations
import math
from typing import Final
def wgs84_to_ltp(
lat_deg: float,
lon_deg: float,
alt_m: float,
ref_lat_deg: float,
ref_lon_deg: float,
ref_alt_m: float,
) -> tuple[float, float, float]:
"""Convert a WGS-84 lat/lon/alt to local-tangent-plane east/north/up metres."""
raise NotImplementedError("wgs_converter concrete impl is AZ-279")
import numpy as np
from pyproj import Transformer # type: ignore[import-not-found]
from gps_denied_onboard._types.geo import BoundingBox, LatLonAlt
__all__ = ["MAX_ZOOM", "WEB_MERCATOR_MAX_LAT_DEG", "WgsConversionError", "WgsConverter"]
def ltp_to_wgs84(
e_m: float, n_m: float, u_m: float, ref_lat_deg: float, ref_lon_deg: float, ref_alt_m: float
) -> tuple[float, float, float]:
"""Inverse of wgs84_to_ltp."""
raise NotImplementedError("wgs_converter concrete impl is AZ-279")
WEB_MERCATOR_MAX_LAT_DEG: Final[float] = 85.0511287798066
MAX_ZOOM: Final[int] = 22
class WgsConversionError(ValueError):
"""Raised on shape / range violations in any ``WgsConverter`` static method."""
_ECEF_FROM_LLA: Final[Transformer] = Transformer.from_crs("EPSG:4326", "EPSG:4978", always_xy=True)
_LLA_FROM_ECEF: Final[Transformer] = Transformer.from_crs("EPSG:4978", "EPSG:4326", always_xy=True)
def _validate_finite_latlonalt(p: LatLonAlt, label: str) -> None:
if not (math.isfinite(p.lat_deg) and math.isfinite(p.lon_deg) and math.isfinite(p.alt_m)):
raise WgsConversionError(f"{label}: non-finite component in {p!r}")
if not (-90.0 <= p.lat_deg <= 90.0):
raise WgsConversionError(f"{label}: latitude {p.lat_deg} outside [-90, 90]")
if not (-180.0 <= p.lon_deg <= 180.0):
raise WgsConversionError(f"{label}: longitude {p.lon_deg} outside [-180, 180]")
def _enforce_ecef_shape(arr: np.ndarray, label: str) -> None:
if not isinstance(arr, np.ndarray):
raise WgsConversionError(
f"{label}: expected np.ndarray of shape (3,); got {type(arr).__name__}"
)
if arr.shape != (3,):
raise WgsConversionError(
f"{label}: expected np.ndarray of shape (3,); got shape {arr.shape}"
)
if not np.all(np.isfinite(arr)):
raise WgsConversionError(f"{label}: non-finite component in {arr!r}")
class WgsConverter:
"""Stateless WGS84 / ECEF / ENU / slippy-map-tile converter.
Every method is a pure function of its arguments; no module-level state
other than the cached ``pyproj`` transformer pair.
"""
@staticmethod
def latlonalt_to_ecef(p: LatLonAlt) -> np.ndarray:
_validate_finite_latlonalt(p, "latlonalt_to_ecef")
x, y, z = _ECEF_FROM_LLA.transform(p.lon_deg, p.lat_deg, p.alt_m)
return np.array([x, y, z], dtype=np.float64)
@staticmethod
def ecef_to_latlonalt(p_ecef: np.ndarray) -> LatLonAlt:
_enforce_ecef_shape(p_ecef, "ecef_to_latlonalt")
lon, lat, alt = _LLA_FROM_ECEF.transform(
float(p_ecef[0]), float(p_ecef[1]), float(p_ecef[2])
)
return LatLonAlt(lat_deg=float(lat), lon_deg=float(lon), alt_m=float(alt))
@staticmethod
def latlonalt_to_local_enu(origin: LatLonAlt, p: LatLonAlt) -> np.ndarray:
_validate_finite_latlonalt(origin, "latlonalt_to_local_enu/origin")
_validate_finite_latlonalt(p, "latlonalt_to_local_enu/p")
return _ecef_delta_to_enu(origin, WgsConverter.latlonalt_to_ecef(p))
@staticmethod
def local_enu_to_latlonalt(origin: LatLonAlt, p_enu: np.ndarray) -> LatLonAlt:
_validate_finite_latlonalt(origin, "local_enu_to_latlonalt/origin")
_enforce_ecef_shape(p_enu, "local_enu_to_latlonalt/p_enu")
origin_ecef = WgsConverter.latlonalt_to_ecef(origin)
rotation = _enu_to_ecef_rotation(origin.lat_deg, origin.lon_deg)
delta_ecef = rotation @ p_enu.astype(np.float64)
return WgsConverter.ecef_to_latlonalt(origin_ecef + delta_ecef)
@staticmethod
def latlon_to_tile_xy(zoom: int, lat: float, lon: float) -> tuple[int, int]:
_validate_zoom(zoom)
if not (math.isfinite(lat) and math.isfinite(lon)):
raise WgsConversionError(f"latlon_to_tile_xy: non-finite input (lat={lat}, lon={lon})")
if abs(lat) > WEB_MERCATOR_MAX_LAT_DEG:
raise WgsConversionError(
f"latlon_to_tile_xy: latitude {lat} outside Web-Mercator range "
f"[-{WEB_MERCATOR_MAX_LAT_DEG}, {WEB_MERCATOR_MAX_LAT_DEG}]"
)
if not (-180.0 <= lon <= 180.0):
raise WgsConversionError(f"latlon_to_tile_xy: longitude {lon} outside [-180, 180]")
n = 1 << zoom
lat_rad = math.radians(lat)
x = math.floor((lon + 180.0) / 360.0 * n)
y = math.floor(
(1.0 - math.log(math.tan(lat_rad) + 1.0 / math.cos(lat_rad)) / math.pi) / 2.0 * n
)
x = max(0, min(x, n - 1))
y = max(0, min(y, n - 1))
return x, y
@staticmethod
def tile_xy_to_latlon_bounds(zoom: int, x: int, y: int) -> BoundingBox:
_validate_zoom(zoom)
n = 1 << zoom
if not (0 <= x < n and 0 <= y < n):
raise WgsConversionError(
f"tile_xy_to_latlon_bounds: tile (x={x}, y={y}) outside [0, {n}) at zoom {zoom}"
)
return BoundingBox(
min_lat_deg=_tile_y_to_lat(y + 1, n),
min_lon_deg=_tile_x_to_lon(x, n),
max_lat_deg=_tile_y_to_lat(y, n),
max_lon_deg=_tile_x_to_lon(x + 1, n),
)
def _validate_zoom(zoom: int) -> None:
if not isinstance(zoom, int) or isinstance(zoom, bool):
raise WgsConversionError(f"zoom must be a non-bool integer; got {zoom!r}")
if not (0 <= zoom <= MAX_ZOOM):
raise WgsConversionError(f"zoom {zoom} outside supported range [0, {MAX_ZOOM}]")
def _tile_x_to_lon(x: int, n: int) -> float:
return x / n * 360.0 - 180.0
def _tile_y_to_lat(y: int, n: int) -> float:
t = math.pi * (1.0 - 2.0 * y / n)
return math.degrees(math.atan(math.sinh(t)))
def _enu_to_ecef_rotation(lat_deg: float, lon_deg: float) -> np.ndarray:
"""Rotation matrix mapping local ENU vectors to ECEF deltas at ``(lat, lon)``."""
lat = math.radians(lat_deg)
lon = math.radians(lon_deg)
sin_lat = math.sin(lat)
cos_lat = math.cos(lat)
sin_lon = math.sin(lon)
cos_lon = math.cos(lon)
return np.array(
[
[-sin_lon, -sin_lat * cos_lon, cos_lat * cos_lon],
[cos_lon, -sin_lat * sin_lon, cos_lat * sin_lon],
[0.0, cos_lat, sin_lat],
],
dtype=np.float64,
)
def _ecef_delta_to_enu(origin: LatLonAlt, p_ecef: np.ndarray) -> np.ndarray:
origin_ecef = WgsConverter.latlonalt_to_ecef(origin)
delta = p_ecef - origin_ecef
rotation = _enu_to_ecef_rotation(origin.lat_deg, origin.lon_deg)
return rotation.T @ delta
+330 -35
View File
@@ -1,26 +1,82 @@
"""Composition root (ADR-009 interface-first DI).
"""Composition root (AZ-270 / E-CC-CONF; ADR-009 interface-first DI).
The only place that may import concrete strategy implementations across
components. Per-binary `compose_*` entrypoints select the strategy graph for that
binary (airborne / research / operator-tooling / replay-cli) — gated by CMake
`BUILD_*` flags at compile time and validated again here at startup.
The single module allowed to import concrete strategy implementations across
components. Per-binary ``compose_*`` functions resolve the ``Config``-selected
strategies, validate them against the registry (ADR-002 gate #3), and assemble
the component graph in dependency order.
Bootstrap (AZ-263) ships the entrypoints as stubs that perform the required env-var
fail-fast (AC-8). Per-component wiring is added by each component's "wire-in"
implementation task.
Per-binary entrypoints:
* :func:`compose_root` - airborne runtime
* :func:`compose_operator` - operator-side tooling (pre-flight, post-landing)
* :func:`compose_replay` - replay-cli runtime (extension owned by AZ-401)
Public surface frozen by
``_docs/02_document/contracts/shared_config/composition_root_protocol.md`` v1.0.0.
"""
from __future__ import annotations
import os
import sys
from collections.abc import Iterable
from dataclasses import dataclass
from collections.abc import Callable, Iterable, Mapping
from dataclasses import dataclass, field
from typing import Any, Literal, get_args
from gps_denied_onboard.config import Config, load_config
__all__ = [
"REQUIRED_ENV_VARS",
"ConfigurationError",
"OperatorRoot",
"RuntimeRoot",
"StrategyNotLinkedError",
"StrategyTier",
"clear_strategy_registry",
"compose_operator",
"compose_replay",
"compose_root",
"list_registered_strategies",
"main",
"register_strategy",
]
StrategyTier = Literal["airborne", "operator", "shared"]
_ALL_TIERS: tuple[StrategyTier, ...] = get_args(StrategyTier)
class ConfigurationError(RuntimeError):
"""Raised when a required environment variable is missing or a strategy whose
CMake `BUILD_*` flag is OFF would otherwise be wired."""
"""Raised when a required environment variable is missing.
AC-8 (Bootstrap / AZ-263).
"""
class StrategyNotLinkedError(RuntimeError):
"""Raised when the config selects a strategy that is not linked into this binary.
Carries the offending strategy name, the owning component slug, and the
actually-linked strategies for that component — gives the operator a
clear next step (build with the right flag, or pick a different strategy).
"""
def __init__(
self,
strategy_name: str,
component_slug: str,
available_strategies: Iterable[str],
*,
reason: str = "not linked",
) -> None:
self.strategy_name: str = strategy_name
self.component_slug: str = component_slug
self.available_strategies: list[str] = sorted(set(available_strategies))
self.reason: str = reason
available_text = ", ".join(self.available_strategies) or "<none>"
super().__init__(
f"strategy {strategy_name!r} requested for component {component_slug!r} is "
f"{reason}; available strategies: [{available_text}]"
)
REQUIRED_ENV_VARS: tuple[str, ...] = (
@@ -36,11 +92,89 @@ REQUIRED_ENV_VARS: tuple[str, ...] = (
)
def _check_required_env(extra_required: Iterable[str] = ()) -> None:
"""Fail fast with a clear pointer at the first missing required env var.
@dataclass(frozen=True)
class _Registration:
component_slug: str
strategy_name: str
factory: Callable[..., Any]
tier: StrategyTier
depends_on: tuple[str, ...]
AC-8 (Bootstrap / AZ-263).
_STRATEGY_REGISTRY: dict[tuple[str, str], _Registration] = {}
def register_strategy(
component_slug: str,
strategy_name: str,
factory: Callable[..., Any],
*,
tier: StrategyTier = "shared",
depends_on: Iterable[str] = (),
) -> None:
"""Register a concrete strategy implementation for ``component_slug``.
The single allowed call site is the composition root or a binary-specific
bootstrap module (one module per ``BUILD_*`` flag combination). Calling
this from a component module is an architecture violation; AC-6 catches
that statically.
"""
if tier not in _ALL_TIERS:
raise ValueError(f"tier must be one of {_ALL_TIERS}; got {tier!r}")
key = (component_slug, strategy_name)
existing = _STRATEGY_REGISTRY.get(key)
incoming = _Registration(
component_slug=component_slug,
strategy_name=strategy_name,
factory=factory,
tier=tier,
depends_on=tuple(depends_on),
)
if existing is not None and existing != incoming:
raise StrategyNotLinkedError(
strategy_name=strategy_name,
component_slug=component_slug,
available_strategies=list_registered_strategies(component_slug),
reason="duplicate registration with conflicting attributes",
)
_STRATEGY_REGISTRY[key] = incoming
def clear_strategy_registry() -> None:
"""Reset the global registry; intended for unit-test isolation only."""
_STRATEGY_REGISTRY.clear()
def list_registered_strategies(component_slug: str) -> list[str]:
"""Return the strategy names registered for ``component_slug`` (sorted)."""
return sorted(
reg.strategy_name
for (slug, _name), reg in _STRATEGY_REGISTRY.items()
if slug == component_slug
)
@dataclass(frozen=True)
class RuntimeRoot:
"""Composed airborne runtime graph (every component slot populated)."""
binary: str
profile: str
components: Mapping[str, Any] = field(default_factory=dict)
construction_order: tuple[str, ...] = ()
@dataclass(frozen=True)
class OperatorRoot:
"""Composed operator-side runtime graph (operator-tier components only)."""
binary: str
profile: str
components: Mapping[str, Any] = field(default_factory=dict)
construction_order: tuple[str, ...] = ()
def _check_required_env(extra_required: Iterable[str] = ()) -> None:
missing = [name for name in (*REQUIRED_ENV_VARS, *extra_required) if name not in os.environ]
if missing:
raise ConfigurationError(
@@ -50,36 +184,197 @@ def _check_required_env(extra_required: Iterable[str] = ()) -> None:
)
@dataclass(frozen=True)
class RuntimeRoot:
"""Composed runtime graph (placeholder; per-component wiring is per-task)."""
binary: str
profile: str
def _resolve_strategy(
component_slug: str,
strategy_name: str,
allowed_tiers: frozenset[StrategyTier],
) -> _Registration:
"""Look up ``(component_slug, strategy_name)`` in the registry and gate by tier."""
key = (component_slug, strategy_name)
registration = _STRATEGY_REGISTRY.get(key)
if registration is None:
raise StrategyNotLinkedError(
strategy_name=strategy_name,
component_slug=component_slug,
available_strategies=list_registered_strategies(component_slug),
)
if registration.tier not in allowed_tiers:
raise StrategyNotLinkedError(
strategy_name=strategy_name,
component_slug=component_slug,
available_strategies=list_registered_strategies(component_slug),
reason=(
f"registered under tier={registration.tier!r}; "
f"not allowed in this binary (allowed tiers: {sorted(allowed_tiers)})"
),
)
return registration
def compose_root(yaml_config_path: str | None = None) -> RuntimeRoot:
"""Compose the airborne runtime graph."""
_check_required_env(extra_required=("MAVLINK_SIGNING_KEY",))
return RuntimeRoot(binary="airborne", profile=os.environ["GPS_DENIED_FC_PROFILE"])
def _topo_order(
target_slugs: Iterable[str], registrations: Mapping[str, _Registration]
) -> list[str]:
"""Return ``target_slugs`` in a dependency-respecting order (Kahn's algorithm)."""
ordered: list[str] = []
remaining = set(target_slugs)
visited: set[str] = set()
def visit(slug: str, stack: tuple[str, ...]) -> None:
if slug in visited:
return
if slug in stack:
raise StrategyNotLinkedError(
strategy_name=registrations[slug].strategy_name,
component_slug=slug,
available_strategies=list_registered_strategies(slug),
reason=f"dependency cycle detected: {' -> '.join((*stack, slug))}",
)
if slug not in registrations:
return
for dep in registrations[slug].depends_on:
visit(dep, (*stack, slug))
visited.add(slug)
ordered.append(slug)
for slug in sorted(remaining):
visit(slug, ())
return [slug for slug in ordered if slug in remaining]
def compose_operator(yaml_config_path: str | None = None) -> RuntimeRoot:
"""Compose the operator-tooling runtime graph (pre-flight + post-landing)."""
_check_required_env(extra_required=("SATELLITE_PROVIDER_URL",))
return RuntimeRoot(binary="operator-tooling", profile=os.environ["GPS_DENIED_FC_PROFILE"])
def _compose(
config: Config,
*,
binary: str,
allowed_tiers: frozenset[StrategyTier],
extra_required_env: Iterable[str],
) -> tuple[dict[str, Any], tuple[str, ...]]:
"""Shared composition path used by ``compose_root`` / ``compose_operator``."""
_check_required_env(extra_required=extra_required_env)
selections = _resolve_component_strategies(config, allowed_tiers)
resolved: dict[str, _Registration] = {
slug: _resolve_strategy(slug, strategy, allowed_tiers)
for slug, strategy in selections.items()
}
order = _topo_order(resolved.keys(), resolved)
constructed: dict[str, Any] = {}
for slug in order:
registration = resolved[slug]
try:
constructed[slug] = registration.factory(config, constructed)
except Exception:
# All-or-nothing: close anything already built before re-raising.
_close_partial_instances(constructed)
raise
_ = binary # documented but unused beyond labelling the returned root
return constructed, tuple(order)
def compose_replay(yaml_config_path: str | None = None) -> RuntimeRoot:
"""Compose the replay-cli runtime graph. Concrete wiring owned by AZ-401."""
_check_required_env()
return RuntimeRoot(binary="replay-cli", profile=os.environ["GPS_DENIED_FC_PROFILE"])
def _close_partial_instances(instances: Mapping[str, Any]) -> None:
"""Best-effort cleanup of partially-constructed components on failure.
Calls ``.close()`` on each instance that exposes one; swallows individual
close failures so the original exception propagates to the caller.
"""
for inst in instances.values():
close = getattr(inst, "close", None)
if callable(close):
try:
close()
except Exception:
continue
def _resolve_component_strategies(
config: Config, allowed_tiers: frozenset[StrategyTier]
) -> dict[str, str]:
"""Translate ``config.components[slug]`` into ``{slug: strategy_name}``.
Two recognised shapes for a per-component block:
* a dataclass with a ``strategy`` field (preferred);
* a mapping with a ``"strategy"`` key (fallback for raw YAML).
Blocks without a ``strategy`` field are skipped — they configure a
component whose strategy was hard-wired by the binary's bootstrap.
"""
_ = allowed_tiers # tier filtering happens in ``_resolve_strategy``
selections: dict[str, str] = {}
for slug, block in (config.components or {}).items():
strategy = _read_strategy_attr(block)
if strategy is None:
continue
if not isinstance(strategy, str) or not strategy:
raise StrategyNotLinkedError(
strategy_name=str(strategy),
component_slug=slug,
available_strategies=list_registered_strategies(slug),
reason="config.components[slug].strategy must be a non-empty string",
)
selections[slug] = strategy
return selections
def _read_strategy_attr(block: Any) -> Any:
if hasattr(block, "strategy"):
return block.strategy
if isinstance(block, Mapping):
return block.get("strategy")
return None
def compose_root(config: Config) -> RuntimeRoot:
"""Compose the airborne runtime graph (per contract v1.0.0)."""
components, order = _compose(
config,
binary="airborne",
allowed_tiers=frozenset({"airborne", "shared"}),
extra_required_env=("MAVLINK_SIGNING_KEY",),
)
return RuntimeRoot(
binary="airborne",
profile=os.environ["GPS_DENIED_FC_PROFILE"],
components=components,
construction_order=order,
)
def compose_operator(config: Config) -> OperatorRoot:
"""Compose the operator-tooling runtime graph (per contract v1.0.0)."""
components, order = _compose(
config,
binary="operator-tooling",
allowed_tiers=frozenset({"operator", "shared"}),
extra_required_env=("SATELLITE_PROVIDER_URL",),
)
return OperatorRoot(
binary="operator-tooling",
profile=os.environ["GPS_DENIED_FC_PROFILE"],
components=components,
construction_order=order,
)
def compose_replay(config: Config) -> RuntimeRoot:
"""Compose the replay-cli runtime graph. Concrete wiring is owned by AZ-401."""
components, order = _compose(
config,
binary="replay-cli",
allowed_tiers=frozenset({"airborne", "shared"}),
extra_required_env=(),
)
return RuntimeRoot(
binary="replay-cli",
profile=os.environ["GPS_DENIED_FC_PROFILE"],
components=components,
construction_order=order,
)
def main() -> int: # pragma: no cover — guarded entrypoint
try:
compose_root()
except ConfigurationError as exc:
config = load_config(env=os.environ, paths=())
compose_root(config)
except (ConfigurationError, StrategyNotLinkedError, RuntimeError) as exc:
print(f"runtime_root: {exc}", file=sys.stderr)
return 2
return 0