Files
radar_system/python_app/models/run_config_validation.py
T
2026-10-05 16:46:28 +03:00

314 lines
15 KiB
Python

"""Validation and normalization helpers for run configuration payloads."""
from __future__ import annotations
import logging
import math
from typing import Any
from python_app.models.json_fields import (
read_bool as _require_bool, read_int as _require_int, read_str as _require_str,
)
from python_app.models.run_config_schema import (
MAX_COMBOS,
ComboModel,
ControlButtonModel,
GprModel,
RadarSweepModel,
RingEndpointModel,
RunConfigModel,
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.
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"
for field, value in (("capacity", ring.capacity), ("slot_size_bytes", ring.slot_size_bytes)):
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"rings.{ring_name}.{field} must be an integer")
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")
if ring.slot_size_bytes % 8:
raise ValueError(f"rings.{ring_name}.slot_size_bytes must be a multiple of 8")
# 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 - 64) // (ring.slot_size_bytes + 16):
raise ValueError(
f"rings.{ring_name} capacity * slot_size_bytes exceeds the maximum ring segment size"
)
if ring.capacity > _UINT32_MAX:
raise ValueError(f"rings.{ring_name}.capacity exceeds the uint32 wire limit")
def validate_acquisition_config(config: RunConfigModel) -> None:
"""Reject unusable acquisition settings before starting processes or hardware.
Replay uses the recorded trace dimensions and needs no physical devices; its
producer checks the actual payload size before publishing a frame.
"""
if config.radar.replay.dataset_dir:
return
mode = config.radar.driver_mode
if mode not in ("mock", "native"):
raise ValueError("radar.driver_mode must be mock or native")
if config.radar.model not in (
config.LIBREVNA_MODEL, config.LIBREVNA_MULTI_MODEL, config.KAMIL_ADC_MODEL,
):
raise ValueError(f"Unsupported radar model: {config.radar.model}")
if mode != "native" and config.is_kamil_adc:
raise ValueError(f"{config.radar.model} requires native driver mode")
for switch, physical_positions in (
(config.input_switch, config.matrix_input_switch_positions),
(config.output_switch, config.matrix_output_switch_positions),
):
context = f"switches.{switch.name or switch.radar_port}"
if switch.driver_mode not in ("mock", "native"):
raise ValueError(f"{context}.driver_mode must be mock or native")
if switch.driver not in ("h7992", "hmc349a"):
raise ValueError(f"{context}.driver must be h7992 or hmc349a")
if switch.radar_port not in (1, 2):
raise ValueError(f"{context}.radar_port must be 1 or 2")
if not 0 <= switch.default_position < switch.positions:
raise ValueError(f"{context}.default_position must be within switch positions")
positions, switch_mode = switch.positions, switch.driver_mode
if config.is_matrix_radar and physical_positions > 1:
positions, switch_mode = physical_positions, mode
if switch_mode == "native":
limit = 4 if switch.driver == "h7992" else 2
if not 1 <= positions <= limit:
raise ValueError(f"{context}: native {switch.driver} supports 1..{limit} positions")
if not switch.gpio_chip or switch.pin_a < 0 or (
switch.driver == "h7992" and (switch.pin_b < 0 or switch.pin_b == switch.pin_a)
):
raise ValueError(f"{context}: GPIO chip and distinct nonnegative control pins are required")
points = config.radar.kamil_adc.band.points if config.is_kamil_adc else config.radar.sweep.points
# RAW/PPR header, each combo's frequency/S11/S21 arrays and capture windows.
minimum = 44 + len(config.combos) * (28 + 20 * points)
for name in ("raw", "raw_tap", "preprocessed", "preprocessed_tap"):
ring = getattr(config.rings, name)
if name.endswith("_tap") and not ring.name:
continue
if ring.slot_size_bytes < minimum:
raise ValueError(f"rings.{name}.slot_size_bytes must be at least {minimum} for this sweep")
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 not 0 < points <= _UINT32_MAX:
raise ValueError("radar.sweep.points must be in [1, uint32 max]")
for field in ("start_hz", "stop_hz", "if_bandwidth_hz", "power_dbm"):
value = getattr(sweep, field)
if isinstance(value, bool) or not isinstance(value, (int, float)) or abs(value) > 3.4e38 or not math.isfinite(value):
raise ValueError(f"radar.sweep.{field} must be a finite float32 number")
if sweep.start_hz < 0:
raise ValueError("radar.sweep.start_hz must be >= 0")
if sweep.if_bandwidth_hz <= 0:
raise ValueError("radar.sweep.if_bandwidth_hz must be > 0")
if sweep.stop_hz <= 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 not math.isfinite(gpr.relative_permittivity) or 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:
if any(not math.isfinite(value) or abs(value) > 3.4e38 for value in (entry.x_m, entry.y_m, entry.z_m)):
raise ValueError("gpr.tx_geometry coordinates must be finite numbers")
output_pos = entry.output_pos
if isinstance(output_pos, bool) or not isinstance(output_pos, int):
raise ValueError("gpr.tx_geometry output_pos must be an integer")
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:
if any(not math.isfinite(value) or abs(value) > 3.4e38 for value in (entry.x_m, entry.y_m, entry.z_m)):
raise ValueError("gpr.rx_geometry coordinates must be finite numbers")
input_pos = entry.input_pos
if isinstance(input_pos, bool) or not isinstance(input_pos, int):
raise ValueError("gpr.rx_geometry input_pos must be an integer")
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()
if len(combos) > MAX_COMBOS:
raise ValueError(f"Too many combos: limit is {MAX_COMBOS}")
for combo in combos:
for field in ("input", "output"):
value = getattr(combo, field)
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"run.combos {field} must be an integer")
if not 0 <= combo.input < input_positions:
raise ValueError(
f"run.combos input {combo.input} is out of range [0, {input_positions})"
)
if not 0 <= combo.output < 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