mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 23:41:13 +00:00
[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:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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))}}}"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user