"""Validation and normalization helpers for run configuration payloads.""" from __future__ import annotations from collections.abc import Iterable from dataclasses import asdict import ipaddress import logging import math import re from typing import Any from python_app.hardware_full.laser_control.conversions import variation_step_count from python_app.hardware_full.laser_control.validators import validate_variation from python_app.models.json_fields import ( finite_number, 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, GprRxGeometryModel, GprTxGeometryModel, RphRadarModel, RadarSweepModel, RingEndpointModel, RunConfigModel, SwitchModel, ) POSITION_CSV_PATTERN = r"\s*(?:[0-9]+\s*(?:,\s*[0-9]+\s*)*,?\s*)?" def parse_position_csv(value: str) -> list[int]: """Non-negative positions; empty means all, a trailing comma permits editing.""" if re.fullmatch(POSITION_CSV_PATTERN, value) is None: raise ValueError("Positions must be non-negative integers separated by commas") return [int(token) for token in value.split(",") if token.strip()] 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) if "driver" in payload and payload["driver"] != "h7992": raise ValueError(f"switches.{target.name}.driver: only H7992 is supported; check the switch and its two control pins") if "invert_logic" in payload and payload["invert_logic"] is not False: raise ValueError(f"switches.{target.name}.invert_logic is unsupported by H7992") 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) 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.""" 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.RPH_RADAR_MODEL, ): raise ValueError(f"Unsupported radar model: {config.radar.model}") if mode != "native" and config.is_rph_radar: raise ValueError(f"{config.radar.model} requires native driver mode") if config.is_rph_radar: validate_rph_radar_model(config.radar.rph_radar) if config.radar.rph_radar.source == "rfg_udp": validate_rfg_source(config) 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.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_multi_device and physical_positions > 1: positions, switch_mode = physical_positions, mode if switch_mode == "native": if not 1 <= positions <= 4: raise ValueError(f"{context}: native H7992 supports 1..4 positions") if not switch.gpio_chip or switch.pin_a < 0 or 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.rph_radar.band.points if config.is_rph_radar else config.radar.sweep.points # RAW3/PRP3 header, frequency/S21 arrays and capture windows. minimum = 44 + len(config.combos) * (28 + 12 * 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 parse_udp_bind(text: str) -> tuple[str, int]: """``HOST:PORT`` (empty host = all interfaces) -> socket address.""" host, colon, port = text.rpartition(":") try: ipaddress.IPv4Address(host or "0.0.0.0") except ValueError: colon = "" if not colon or not port.isdigit() or not 1 <= int(port) <= 65535: raise ValueError(f"radar.rph_radar.udp_bind must be IPV4:PORT, got {text!r}") return host or "0.0.0.0", int(port) def validate_rfg_source(config: RunConfigModel) -> None: """The RFG board numbers steps of the laser current sweep; its exact length must be known.""" laser = config.radar.laser_control if laser.mode.strip().lower() != "variation": raise ValueError("radar.rph_radar.source rfg_udp requires radar.laser_control.mode 'variation'") variation = laser.variation validate_variation(variation.min_value, variation.max_value, variation.step, variation.time_step, variation.delay_time) if variation_step_count(variation.min_value, variation.max_value, variation.step) < 2: raise ValueError("The laser current sweep must have at least two steps") # Without a source-side marker a sweep after a switch cannot be proven fresh, so every # capture path (run combos and the full calibration matrix) must see a single pair. if config.input_switch.positions != 1 or config.output_switch.positions != 1: raise ValueError("radar.rph_radar.source rfg_udp supports one antenna pair: both switches need 1 position") def validate_rph_radar_model(adc: RphRadarModel) -> None: """Reject phase/grid errors before stopping a running pipeline.""" if adc.source not in ("e502", "rfg_udp"): raise ValueError("radar.rph_radar.source must be e502 or rfg_udp") if adc.source == "rfg_udp": parse_udp_bind(adc.udp_bind) phase, band = adc.phase_calibration, adc.band for name, value in {**asdict(phase), **asdict(band)}.items(): if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value): raise ValueError(f"radar.rph_radar.{name} must be finite") if phase.phase0_rad == phase.phase1_rad or phase.freq0_hz == phase.freq1_hz: raise ValueError("radar.rph_radar.phase_calibration requires distinct phases and frequencies") if not 0 <= band.start_hz < band.stop_hz <= 3.4e38: raise ValueError("radar.rph_radar.band requires 0 <= start_hz < stop_hz within float32") if not isinstance(band.points, int) or not 2 <= band.points <= _UINT32_MAX: raise ValueError("radar.rph_radar.band.points must be an integer in [2, uint32 max]") for name in ("startup_timeout_s", "sweep_timeout_s", "stop_timeout_s"): value = getattr(adc, name) if not math.isfinite(value) or value <= 0: raise ValueError(f"radar.rph_radar.{name} must be finite and positive") 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 normalize_antenna_geometry( entries: Iterable[GprTxGeometryModel | GprRxGeometryModel | dict], position_field: str, position_count: int, ) -> list[dict]: """Validate model/web geometry and supply zero for omitted coordinates.""" context = "gpr.tx_geometry" if position_field == "output_pos" else "gpr.rx_geometry" records, positions = [], set() for entry in entries: record = asdict(entry) if isinstance(entry, (GprTxGeometryModel, GprRxGeometryModel)) else entry if not isinstance(record, dict): raise ValueError(f"{context} entries must be records") position = record.get(position_field) if isinstance(position, bool) or not isinstance(position, int): raise ValueError(f"{context} {position_field} must be an integer") if not 0 <= position < position_count: raise ValueError(f"{context} {position_field} is out of range [0, {position_count})") if position in positions: raise ValueError(f"{context} contains duplicate {position_field}: {position}") positions.add(position) coordinates = {key: finite_number(record.get(key, 0.0), f"{context}.{key}") for key in ("x_m", "y_m", "z_m")} if any(abs(value) > 3.4e38 for value in coordinates.values()): raise ValueError(f"{context} coordinates must fit finite float32 numbers") records.append({position_field: position, **coordinates}) return records 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 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) normalize_antenna_geometry(gpr.tx_geometry, "output_pos", output_switch_positions) normalize_antenna_geometry(gpr.rx_geometry, "input_pos", input_switch_positions) 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