Files
gps-denied-onboard/src/gps_denied_onboard/config/schema.py
T
Oleksandr Bezdieniezhnykh e4ecdaf619 [AZ-294] [AZ-295] [AZ-296] Finish C13: tile snapshot + record-kind policy + takeoff abort
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>
2026-05-11 03:52:07 +03:00

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)