mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 08:41:12 +00:00
e4ecdaf619
AZ-294: MidFlightTileSnapshotSink writes orthorectified tile JPEGs atomically to flight_root/<flight_id>/tiles/<tile_id>.jpg, emits a kind="mid_flight_tile_snapshot" pointer record, and evicts the oldest tile when the per-flight 64 MiB cap is exceeded. Adds optional frame_id to the snapshot payload (fdr_record_schema bump). AZ-295: RecordKindPolicy with two paired gates: - enforce_or_raise (producer-side) raises RawFrameWriteForbiddenError for raw_nav_frame / raw_ai_cam_frame at the call site, defending AC-8.5 / RESTRICT-UAV-4. - gate_for_writer (writer-side) tumbling-window rate-caps failed_tile_thumbnail records at <= 0.1 Hz; over-cap drops are coalesced into kind="overrun" records with the originating producer slug. AZ-296: take_off() composition-root sequence with strict ordering (writer.__init__ -> start -> open_flight -> fc_adapter.__init__ -> fc_adapter.open). On FdrOpenError, logs ERROR record, calls writer.stop(), prints the documented FATAL line to stderr, and sys.exit(EXIT_FDR_OPEN_FAILURE=2). composition_root_protocol bumped to v1.1.0 with the new constants + takeoff-sequence section. 29 new tests; full suite 356 passed / 2 skipped / 0 failures. No new dependencies (stdlib only). Co-authored-by: Cursor <cursoragent@cursor.com>
280 lines
10 KiB
Python
280 lines
10 KiB
Python
"""Config dataclasses for E-CC-CONF (AZ-269 / AZ-246).
|
|
|
|
The outer `Config` aggregates one frozen nested dataclass per top-level
|
|
config block. Cross-cutting blocks (`log`, `fdr`, `runtime`) live here;
|
|
per-component blocks live with their own component epic and are
|
|
registered into `Config.components` via `register_component_block`.
|
|
|
|
Public surface frozen by `_docs/02_document/contracts/shared_config/composition_root_protocol.md` v1.0.0.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Mapping
|
|
from dataclasses import dataclass, field, fields, is_dataclass, replace
|
|
from typing import Any, Final
|
|
|
|
__all__ = [
|
|
"DEFAULT_FORBIDDEN_RECORD_KINDS",
|
|
"Config",
|
|
"ConfigError",
|
|
"FdrConfig",
|
|
"FdrWriterConfig",
|
|
"LogConfig",
|
|
"RecordKindPolicyConfig",
|
|
"RequiredFieldMissingError",
|
|
"RuntimeConfig",
|
|
"TileSnapshotConfig",
|
|
"register_component_block",
|
|
]
|
|
|
|
|
|
# Default raw-frame kinds that AZ-295's RecordKindPolicy must reject
|
|
# synchronously at the producer call site. Removing any of these from
|
|
# a Config requires an explicit `unsafe_remove_default_forbidden=True`
|
|
# flag (which is intentionally not present in any standard preset).
|
|
DEFAULT_FORBIDDEN_RECORD_KINDS: Final[frozenset[str]] = frozenset(
|
|
{"raw_nav_frame", "raw_ai_cam_frame"}
|
|
)
|
|
|
|
|
|
class ConfigError(RuntimeError):
|
|
"""Base class for all config-loader errors that should reach the caller."""
|
|
|
|
|
|
class RequiredFieldMissingError(ConfigError):
|
|
"""Raised when a required configuration value is absent from env + YAML + defaults.
|
|
|
|
Always names the missing variable and points at the runtime helper that
|
|
documents the full set (``.env.example``).
|
|
"""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class LogConfig:
|
|
"""Cross-cutting logging block (E-CC-LOG)."""
|
|
|
|
level: str = "INFO"
|
|
tier: int = 1
|
|
sink: str = "console"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FdrWriterConfig:
|
|
"""C13 writer-thread block (E-C13 / AZ-291..AZ-296).
|
|
|
|
``segment_size_bytes`` controls per-segment rotation; the writer
|
|
closes the current segment and opens the next once a record's
|
|
serialised size would push the segment past this cap.
|
|
|
|
``batch_size`` bounds the per-producer ``drain(max_records=N)`` call
|
|
so one slow producer cannot starve others.
|
|
|
|
``flight_cap_bytes`` is the AC-NEW-3 per-flight cap (default 64 GiB
|
|
exactly). Lowered in tests to exercise the cap policy on small
|
|
fixtures. There is no flag that disables cap enforcement (verified
|
|
by C13-ST-01).
|
|
|
|
``debug_log_per_record`` enables a per-record DEBUG log line —
|
|
OFF by default because a 100 Hz aggregate would flood logs.
|
|
"""
|
|
|
|
segment_size_bytes: int = 64 * 1024 * 1024
|
|
batch_size: int = 64
|
|
flight_cap_bytes: int = 64 * 1024**3
|
|
debug_log_per_record: bool = False
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TileSnapshotConfig:
|
|
"""C13 mid-flight tile snapshot sidecar block (AZ-294).
|
|
|
|
``tile_snapshot_cap_bytes`` is the per-flight ceiling on the
|
|
cumulative size of the ``tiles/`` subdirectory under the flight
|
|
root (default 64 MiB to comfortably hold the worst-case ~50 MB
|
|
from per-component description.md).
|
|
|
|
``jpeg_max_bytes`` rejects single tile JPEGs larger than this
|
|
bound (default 256 KiB; description.md gives 50-200 KiB).
|
|
"""
|
|
|
|
tile_snapshot_cap_bytes: int = 64 * 1024 * 1024
|
|
jpeg_max_bytes: int = 256 * 1024
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RecordKindPolicyConfig:
|
|
"""C13 record-kind policy block (AZ-295).
|
|
|
|
``forbidden_record_kinds`` lists FdrRecord ``kind`` values that
|
|
the producer-side ``enforce_or_raise`` gate rejects with
|
|
``RawFrameWriteForbiddenError``. The default set
|
|
(``DEFAULT_FORBIDDEN_RECORD_KINDS``) MUST be a subset of the
|
|
configured set — removing defaults is a security-review-required
|
|
path guarded by ``unsafe_remove_default_forbidden``.
|
|
|
|
``failed_tile_thumbnail_max_hz`` caps the writer-side rate of
|
|
``kind="failed_tile_thumbnail"`` records (default 0.1 Hz per
|
|
AC-8.5 + description.md § 7). Setting this to 0 is rejected at
|
|
config validation (would silence the kind entirely; that path is
|
|
intentionally not exposed).
|
|
"""
|
|
|
|
forbidden_record_kinds: frozenset[str] = field(
|
|
default_factory=lambda: DEFAULT_FORBIDDEN_RECORD_KINDS
|
|
)
|
|
failed_tile_thumbnail_max_hz: float = 0.1
|
|
unsafe_remove_default_forbidden: bool = False
|
|
|
|
def __post_init__(self) -> None:
|
|
if not isinstance(self.forbidden_record_kinds, frozenset):
|
|
raise ConfigError(
|
|
"RecordKindPolicyConfig.forbidden_record_kinds must be a frozenset; "
|
|
f"got {type(self.forbidden_record_kinds).__name__}"
|
|
)
|
|
if not self.unsafe_remove_default_forbidden:
|
|
missing_defaults = DEFAULT_FORBIDDEN_RECORD_KINDS - self.forbidden_record_kinds
|
|
if missing_defaults:
|
|
raise ConfigError(
|
|
"RecordKindPolicyConfig.forbidden_record_kinds removes default raw-frame "
|
|
f"kinds without unsafe_remove_default_forbidden=True: missing {sorted(missing_defaults)}"
|
|
)
|
|
if not (
|
|
isinstance(self.failed_tile_thumbnail_max_hz, (int, float))
|
|
and not isinstance(self.failed_tile_thumbnail_max_hz, bool)
|
|
):
|
|
raise ConfigError(
|
|
"RecordKindPolicyConfig.failed_tile_thumbnail_max_hz must be a number; "
|
|
f"got {self.failed_tile_thumbnail_max_hz!r}"
|
|
)
|
|
if self.failed_tile_thumbnail_max_hz <= 0:
|
|
raise ConfigError(
|
|
"RecordKindPolicyConfig.failed_tile_thumbnail_max_hz must be > 0; "
|
|
f"got {self.failed_tile_thumbnail_max_hz}"
|
|
)
|
|
if self.failed_tile_thumbnail_max_hz > 10.0:
|
|
raise ConfigError(
|
|
"RecordKindPolicyConfig.failed_tile_thumbnail_max_hz must be <= 10.0; "
|
|
f"got {self.failed_tile_thumbnail_max_hz}"
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FdrConfig:
|
|
"""Cross-cutting Flight Data Recorder block (E-CC-FDR-CLIENT / AZ-247).
|
|
|
|
``queue_size`` is the documented default capacity for every producer.
|
|
``per_producer_capacity`` carries per-producer overrides keyed by
|
|
producer slug (consumed by AZ-273 ``make_fdr_client``); blocks
|
|
that omit a producer fall back to ``queue_size``.
|
|
|
|
Sub-blocks (AZ-291..AZ-296): ``writer``, ``tile_snapshot``,
|
|
``record_policy``.
|
|
"""
|
|
|
|
queue_size: int = 4096
|
|
overrun_policy: str = "drop_oldest"
|
|
path: str = "/var/lib/gps-denied/fdr"
|
|
per_producer_capacity: Mapping[str, int] = field(default_factory=dict)
|
|
writer: FdrWriterConfig = field(default_factory=FdrWriterConfig)
|
|
tile_snapshot: TileSnapshotConfig = field(default_factory=TileSnapshotConfig)
|
|
record_policy: RecordKindPolicyConfig = field(default_factory=RecordKindPolicyConfig)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RuntimeConfig:
|
|
"""Top-level runtime descriptors that don't belong to a single component."""
|
|
|
|
fc_profile: str = "ardupilot_plane"
|
|
tier: int = 1
|
|
db_url: str = ""
|
|
camera_calibration_path: str = ""
|
|
inference_backend: str = "pytorch_fp16"
|
|
tile_cache_path: str = "/var/lib/gps-denied/tiles"
|
|
|
|
|
|
# Documented defaults for cross-cutting blocks ONLY. Per-component defaults
|
|
# live with their own component epic. The registry below is the single
|
|
# source of truth so two components cannot silently claim the same key.
|
|
_DEFAULT_BLOCKS: Final[dict[str, type]] = {
|
|
"log": LogConfig,
|
|
"fdr": FdrConfig,
|
|
"runtime": RuntimeConfig,
|
|
}
|
|
|
|
|
|
# Registry for per-component nested dataclasses. Each component epic
|
|
# calls ``register_component_block("c5_state", C5StateConfig)`` from its
|
|
# package import path; the composition root drives those imports before
|
|
# calling ``load_config``.
|
|
_COMPONENT_REGISTRY: dict[str, type] = {}
|
|
|
|
|
|
def register_component_block(slug: str, dataclass_type: type) -> None:
|
|
"""Register a per-component frozen dataclass under its component slug."""
|
|
if not is_dataclass(dataclass_type):
|
|
raise TypeError(
|
|
f"register_component_block({slug!r}, ...): block must be a dataclass; "
|
|
f"got {dataclass_type!r}"
|
|
)
|
|
existing = _COMPONENT_REGISTRY.get(slug)
|
|
if existing is not None and existing is not dataclass_type:
|
|
raise ConfigError(
|
|
f"duplicate component config registration for slug {slug!r}: "
|
|
f"{existing!r} vs {dataclass_type!r}"
|
|
)
|
|
_COMPONENT_REGISTRY[slug] = dataclass_type
|
|
|
|
|
|
def _resolve_default_blocks() -> dict[str, Any]:
|
|
"""Instantiate every documented cross-cutting block with its defaults."""
|
|
return {name: cls() for name, cls in _DEFAULT_BLOCKS.items()}
|
|
|
|
|
|
def _resolve_component_blocks() -> dict[str, Any]:
|
|
"""Instantiate every registered per-component block with its defaults."""
|
|
return {slug: cls() for slug, cls in _COMPONENT_REGISTRY.items()}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Config:
|
|
"""Outer composition-root config (frozen end-to-end).
|
|
|
|
Components consume only their own slice via ``config.components[slug]``;
|
|
the runtime / log / fdr cross-cutting blocks are read directly via
|
|
attribute access by the composition root.
|
|
"""
|
|
|
|
runtime: RuntimeConfig = field(default_factory=RuntimeConfig)
|
|
log: LogConfig = field(default_factory=LogConfig)
|
|
fdr: FdrConfig = field(default_factory=FdrConfig)
|
|
components: Mapping[str, Any] = field(default_factory=dict)
|
|
|
|
@classmethod
|
|
def with_blocks(cls, **blocks: Any) -> Config:
|
|
"""Build a `Config` from a flat name-to-instance map.
|
|
|
|
Cross-cutting names (``log``, ``fdr``, ``runtime``) become attributes;
|
|
every other key is treated as a component slug and goes into
|
|
``components``.
|
|
"""
|
|
runtime = blocks.pop("runtime", RuntimeConfig())
|
|
log = blocks.pop("log", LogConfig())
|
|
fdr = blocks.pop("fdr", FdrConfig())
|
|
return cls(runtime=runtime, log=log, fdr=fdr, components=dict(blocks))
|
|
|
|
|
|
def _block_field_names(block: Any) -> tuple[str, ...]:
|
|
return tuple(f.name for f in fields(block))
|
|
|
|
|
|
def _replace_block(block: Any, overrides: Mapping[str, Any]) -> Any:
|
|
"""Return ``replace(block, **overrides)`` after filtering unknown keys."""
|
|
if not overrides:
|
|
return block
|
|
known = set(_block_field_names(block))
|
|
filtered = {k: v for k, v in overrides.items() if k in known}
|
|
if not filtered:
|
|
return block
|
|
return replace(block, **filtered)
|