264 lines
11 KiB
Python
264 lines
11 KiB
Python
"""Validation and normalization helpers for run configuration payloads."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Any
|
|
|
|
from python_app.models.run_config_schema import (
|
|
ComboModel,
|
|
ControlButtonModel,
|
|
GprModel,
|
|
RadarSweepModel,
|
|
RingEndpointModel,
|
|
SwitchModel,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Wire-format bounds shared with the C++ pipeline. The ring header stores the
|
|
# slot size as a uint32, and capacity * slot_size must address into a single
|
|
# shared-memory mapping, so reject values the C++ side cannot represent.
|
|
_UINT32_MAX = (1 << 32) - 1
|
|
_RING_SEGMENT_MAX_BYTES = 1 << 40 # 1 TiB upper bound on a single ring mapping.
|
|
# Defensive ceiling so a malformed combos string cannot expand into a list that
|
|
# stalls the GUI or the downstream acquisition loop.
|
|
_MAX_COMBOS = 4096
|
|
|
|
|
|
def _require_int(payload: dict[str, Any], key: str, default: int) -> int:
|
|
"""Read a strict JSON integer, treating an explicit ``null`` as 'use default'.
|
|
|
|
Accepts only a genuine JSON integer (not bool, not float, not numeric string),
|
|
because silently truncating ``5.7`` or parsing ``"5"`` would hide a malformed
|
|
config. Mirrors ``run_config_codec._read_int`` so every config integer reads
|
|
identically.
|
|
"""
|
|
value = payload.get(key, default)
|
|
if value is None: # explicit JSON null -> use the default, never coerce
|
|
return default
|
|
if isinstance(value, bool) or not isinstance(value, int):
|
|
raise ValueError(f"{key} must be a JSON integer")
|
|
return value
|
|
|
|
|
|
def _require_str(payload: dict[str, Any], key: str, default: str) -> str:
|
|
"""Read a string field, rejecting JSON arrays/objects with a named ValueError."""
|
|
value = payload.get(key, default)
|
|
if value is None: # explicit JSON null -> use the default, never coerce to "None"
|
|
return default
|
|
if isinstance(value, (dict, list)):
|
|
raise ValueError(f"{key} must be a JSON string")
|
|
return str(value)
|
|
|
|
|
|
def _require_bool(payload: dict[str, Any], key: str, default: bool) -> bool:
|
|
"""Read a boolean field, rejecting non-boolean JSON types with a named ValueError."""
|
|
value = payload.get(key, default)
|
|
if value is None: # explicit JSON null -> use the default
|
|
return default
|
|
if not isinstance(value, bool):
|
|
raise ValueError(f"{key} must be a JSON boolean")
|
|
return value
|
|
|
|
|
|
def load_switch_payload(
|
|
payload: dict[str, Any],
|
|
target: SwitchModel,
|
|
) -> None:
|
|
"""Populate switch model from payload preserving defaults for missing values."""
|
|
# Scalar reads reject array/object JSON types as ValueError (not TypeError).
|
|
target.name = _require_str(payload, "name", target.name)
|
|
target.driver_mode = _require_str(payload, "driver_mode", target.driver_mode)
|
|
target.driver = _require_str(payload, "driver", target.driver)
|
|
target.radar_port = _require_int(payload, "radar_port", target.radar_port)
|
|
target.positions = _require_int(payload, "positions", target.positions)
|
|
target.default_position = _require_int(payload, "default_position", target.default_position)
|
|
target.gpio_chip = _require_str(payload, "gpio_chip", target.gpio_chip)
|
|
target.pin_a = _require_int(payload, "pin_a", target.pin_a)
|
|
target.pin_b = _require_int(payload, "pin_b", target.pin_b)
|
|
target.invert_logic = _require_bool(payload, "invert_logic", target.invert_logic)
|
|
|
|
|
|
def load_control_button_payload(
|
|
payload: dict[str, Any],
|
|
target: ControlButtonModel,
|
|
) -> None:
|
|
"""Populate control-button model from payload preserving defaults."""
|
|
# Scalar reads reject array/object JSON types as ValueError (not TypeError).
|
|
target.enabled = _require_bool(payload, "enabled", target.enabled)
|
|
target.gpio_chip = _require_str(payload, "gpio_chip", target.gpio_chip)
|
|
target.pin = _require_int(payload, "pin", target.pin)
|
|
target.active_low = _require_bool(payload, "active_low", target.active_low)
|
|
target.bias = _require_str(payload, "bias", target.bias)
|
|
target.debounce_ms = _require_int(payload, "debounce_ms", target.debounce_ms)
|
|
target.action = _require_str(payload, "action", target.action)
|
|
|
|
|
|
def load_ring_payload(payload: dict[str, Any], target: RingEndpointModel) -> None:
|
|
"""Populate ring endpoint model from payload preserving defaults.
|
|
|
|
Validates the resulting ring sizing before returning, so a bad config fails
|
|
here (on GUI save and at config load) instead of crashing the C++ ring
|
|
allocator at boot.
|
|
"""
|
|
# Scalar reads reject array/object JSON types as ValueError (not TypeError).
|
|
target.name = _require_str(payload, "name", target.name)
|
|
target.capacity = _require_int(payload, "capacity", target.capacity)
|
|
target.slot_size_bytes = _require_int(payload, "slot_size_bytes", target.slot_size_bytes)
|
|
validate_ring_endpoint(target)
|
|
|
|
|
|
def validate_ring_endpoint(ring: RingEndpointModel) -> None:
|
|
"""Validate ring sizing against the constraints the C++ allocator requires.
|
|
|
|
Raises ValueError naming the offending ring when capacity or slot size is
|
|
non-positive, the slot size overflows the uint32 wire field, or the segment
|
|
would exceed the maximum single mapping.
|
|
"""
|
|
ring_name = ring.name or "ring"
|
|
if ring.capacity <= 0:
|
|
raise ValueError(f"rings.{ring_name}.capacity must be > 0")
|
|
if ring.slot_size_bytes <= 0:
|
|
raise ValueError(f"rings.{ring_name}.slot_size_bytes must be > 0")
|
|
if ring.slot_size_bytes > _UINT32_MAX:
|
|
raise ValueError(f"rings.{ring_name}.slot_size_bytes exceeds the uint32 wire limit")
|
|
# Overflow-safe: compare against the ceiling without ever forming the full
|
|
# product, so an oversized capacity cannot wrap a fixed-width index.
|
|
if ring.capacity > _RING_SEGMENT_MAX_BYTES // ring.slot_size_bytes:
|
|
raise ValueError(
|
|
f"rings.{ring_name} capacity * slot_size_bytes exceeds the maximum ring segment size"
|
|
)
|
|
|
|
|
|
def validate_sweep_model(sweep: RadarSweepModel) -> None:
|
|
"""Validate radar sweep bounds (point count and frequency span).
|
|
|
|
Runs in Python so a bad sweep fails on GUI save and at config load rather
|
|
than aborting the C++ acquisition process at boot. Raises ValueError when
|
|
``points`` is non-integral or non-positive, or when ``stop_hz`` does not
|
|
exceed ``start_hz``.
|
|
"""
|
|
# points must be a positive, integral count of frequency samples.
|
|
points = sweep.points
|
|
if isinstance(points, bool) or not isinstance(points, int):
|
|
raise ValueError("radar.sweep.points must be an integer")
|
|
if points <= 0:
|
|
raise ValueError("radar.sweep.points must be > 0")
|
|
if float(sweep.stop_hz) <= float(sweep.start_hz):
|
|
raise ValueError("radar.sweep.stop_hz must be > radar.sweep.start_hz")
|
|
|
|
|
|
def validate_gpr_model(
|
|
gpr: GprModel,
|
|
*,
|
|
input_switch_positions: int,
|
|
output_switch_positions: int,
|
|
sweep: RadarSweepModel | None = None,
|
|
) -> None:
|
|
"""Validate stable GPR config against current switch dimensions.
|
|
|
|
Checks that the relative permittivity is positive and that every tx/rx
|
|
geometry entry indexes a valid, non-duplicate switch position. When ``sweep``
|
|
is supplied (the load and GUI/save paths share this chokepoint), its bounds
|
|
are validated here too so sweep failures surface alongside the GPR checks
|
|
instead of as a C++ boot crash.
|
|
"""
|
|
if sweep is not None:
|
|
validate_sweep_model(sweep)
|
|
|
|
if float(gpr.relative_permittivity) <= 0.0:
|
|
raise ValueError("gpr.relative_permittivity must be > 0")
|
|
|
|
seen_output_positions: set[int] = set()
|
|
for entry in gpr.tx_geometry:
|
|
output_pos = int(entry.output_pos)
|
|
if output_pos < 0 or output_pos >= int(output_switch_positions):
|
|
raise ValueError("gpr.tx_geometry output_pos is out of range")
|
|
if output_pos in seen_output_positions:
|
|
raise ValueError("gpr.tx_geometry contains duplicate output_pos")
|
|
seen_output_positions.add(output_pos)
|
|
|
|
seen_input_positions: set[int] = set()
|
|
for entry in gpr.rx_geometry:
|
|
input_pos = int(entry.input_pos)
|
|
if input_pos < 0 or input_pos >= int(input_switch_positions):
|
|
raise ValueError("gpr.rx_geometry input_pos is out of range")
|
|
if input_pos in seen_input_positions:
|
|
raise ValueError("gpr.rx_geometry contains duplicate input_pos")
|
|
seen_input_positions.add(input_pos)
|
|
|
|
|
|
def validate_combos(
|
|
combos: list[ComboModel],
|
|
*,
|
|
input_positions: int,
|
|
output_positions: int,
|
|
) -> None:
|
|
"""Validate run combos against the configured switch dimensions.
|
|
|
|
Each combo's input/output must index a real switch position, and no
|
|
``input:output`` pair may repeat — an out-of-range or duplicate combo is a
|
|
config error that would otherwise produce missing or doubled traces downstream.
|
|
"""
|
|
seen: set[tuple[int, int]] = set()
|
|
for combo in combos:
|
|
if not 0 <= int(combo.input) < int(input_positions):
|
|
raise ValueError(
|
|
f"run.combos input {combo.input} is out of range [0, {input_positions})"
|
|
)
|
|
if not 0 <= int(combo.output) < int(output_positions):
|
|
raise ValueError(
|
|
f"run.combos output {combo.output} is out of range [0, {output_positions})"
|
|
)
|
|
pair = (int(combo.input), int(combo.output))
|
|
if pair in seen:
|
|
raise ValueError(f"run.combos contains duplicate combo {combo.input}:{combo.output}")
|
|
seen.add(pair)
|
|
|
|
|
|
def parse_combos_from_text(text: str) -> list[ComboModel]:
|
|
"""Parse a UI combos string in ``input:output,input:output`` format.
|
|
|
|
Returns an empty list for blank input. Raises ValueError (naming the
|
|
offending pair) on malformed syntax, empty sides, non-integer values, more
|
|
than ``_MAX_COMBOS`` entries, or a non-blank string that yields no combos.
|
|
"""
|
|
cleaned = text.strip()
|
|
if not cleaned:
|
|
return []
|
|
|
|
combos: list[ComboModel] = []
|
|
for item in cleaned.split(","):
|
|
pair = item.strip()
|
|
if not pair:
|
|
continue
|
|
if ":" not in pair:
|
|
raise ValueError(f"Invalid combo syntax: {pair!r}. Expected input:output")
|
|
|
|
# Cap the combo count so a pathological string cannot expand into a list
|
|
# large enough to stall the GUI or the acquisition loop.
|
|
if len(combos) >= _MAX_COMBOS:
|
|
raise ValueError(f"Too many combos: limit is {_MAX_COMBOS}")
|
|
|
|
input_text, output_text = (side.strip() for side in pair.split(":", 1))
|
|
# Reject empty sides and re-raise non-integer values naming the pair/side.
|
|
if not input_text:
|
|
raise ValueError(f"Invalid combo {pair!r}: input side is empty")
|
|
if not output_text:
|
|
raise ValueError(f"Invalid combo {pair!r}: output side is empty")
|
|
try:
|
|
input_value = int(input_text)
|
|
except ValueError as exc:
|
|
raise ValueError(f"Invalid combo {pair!r}: input {input_text!r} is not an integer") from exc
|
|
try:
|
|
output_value = int(output_text)
|
|
except ValueError as exc:
|
|
raise ValueError(f"Invalid combo {pair!r}: output {output_text!r} is not an integer") from exc
|
|
combos.append(ComboModel(input=input_value, output=output_value))
|
|
|
|
if not combos:
|
|
raise ValueError("No valid combos were provided")
|
|
logger.debug("Parsed %d combo(s) from UI text", len(combos))
|
|
return combos
|