some fixes

This commit is contained in:
Ayzen
2026-06-05 14:40:10 +03:00
parent 22942d9dc9
commit bbea744459
35 changed files with 1797 additions and 297 deletions
+144 -92
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import math
from typing import Any
from python_app.models.run_config_schema import (
@@ -38,9 +39,67 @@ def _read_str(payload: dict[str, Any], key: str, default: str) -> str:
value = payload.get(key, default)
if value is None:
return default
# A JSON array/object reaching a scalar field is a config error, not a
# str() fallback; surface it as ValueError to keep the error contract uniform.
if isinstance(value, (dict, list)):
raise ValueError(f"{key} must be a JSON string")
return str(value)
def _read_int(payload: dict[str, Any], key: str, default: int) -> int:
"""Return payload integer, treating an explicit JSON `null` as 'use default'.
Without this, `int(payload.get(key, default))` raises TypeError on an
explicit `null`. JSON arrays/objects (and other non-numeric scalars) are
rejected as ValueError so malformed types share the config-error contract.
"""
value = payload.get(key, default)
if value is None:
return default
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
raise ValueError(f"{key} must be a JSON integer")
try:
return int(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"{key} must be a JSON integer") from exc
def _read_float(payload: dict[str, Any], key: str, default: float) -> float:
"""Return payload float, treating an explicit JSON `null` as 'use default'.
Rejects JSON arrays/objects (and other non-numeric scalars) as ValueError,
and rejects non-finite values (NaN/Infinity) at decode time so the C++
pipeline never receives a value it cannot honor.
"""
value = payload.get(key, default)
if value is None:
return default
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
raise ValueError(f"{key} must be a JSON number")
try:
result = float(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"{key} must be a JSON number") from exc
if not math.isfinite(result):
raise ValueError(f"{key} must be a finite number")
return result
def _read_bool(payload: dict[str, Any], key: str, default: bool) -> bool:
"""Return payload boolean, treating an explicit JSON `null` as 'use default'.
Plain `bool(payload.get(key, default))` would silently flip the default to
`False` on an explicit `null`; here `null` keeps the default instead.
Non-boolean JSON types are rejected as ValueError.
"""
value = payload.get(key, default)
if value is None:
return default
if not isinstance(value, bool):
raise ValueError(f"{key} must be a JSON boolean")
return value
def _load_preprocess_asset(payload: dict[str, Any], target: PreprocessAssetModel) -> None:
"""Load preprocess asset fields into target model."""
target.set_name = _read_str(payload, "set_name", target.set_name)
@@ -105,21 +164,21 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
kamil_adc_payload = _as_dict(radar_payload.get("kamil_adc"), "radar.kamil_adc")
laser_control_payload = _as_dict(radar_payload.get("laser_control"), "radar.laser_control")
model.radar.model = str(radar_payload.get("model", model.radar.model))
model.radar.serial = str(radar_payload.get("serial", model.radar.serial))
model.radar.remote_host = str(radar_payload.get("remote_host", model.radar.remote_host))
model.radar.remote_port = int(radar_payload.get("remote_port", model.radar.remote_port))
model.radar.driver_mode = str(radar_payload.get("driver_mode", model.radar.driver_mode))
model.radar.mock_signal_hz = float(radar_payload.get("mock_signal_hz", model.radar.mock_signal_hz))
model.radar.visa_library = str(radar_payload.get("visa_library", model.radar.visa_library))
model.radar.model = _read_str(radar_payload, "model", model.radar.model)
model.radar.serial = _read_str(radar_payload, "serial", model.radar.serial)
model.radar.remote_host = _read_str(radar_payload, "remote_host", model.radar.remote_host)
model.radar.remote_port = _read_int(radar_payload, "remote_port", model.radar.remote_port)
model.radar.driver_mode = _read_str(radar_payload, "driver_mode", model.radar.driver_mode)
model.radar.mock_signal_hz = _read_float(radar_payload, "mock_signal_hz", model.radar.mock_signal_hz)
model.radar.visa_library = _read_str(radar_payload, "visa_library", model.radar.visa_library)
model.radar.sweep.start_hz = float(sweep_payload.get("start_hz", model.radar.sweep.start_hz))
model.radar.sweep.stop_hz = float(sweep_payload.get("stop_hz", model.radar.sweep.stop_hz))
model.radar.sweep.points = int(sweep_payload.get("points", model.radar.sweep.points))
model.radar.sweep.if_bandwidth_hz = float(
sweep_payload.get("if_bandwidth_hz", model.radar.sweep.if_bandwidth_hz)
model.radar.sweep.start_hz = _read_float(sweep_payload, "start_hz", model.radar.sweep.start_hz)
model.radar.sweep.stop_hz = _read_float(sweep_payload, "stop_hz", model.radar.sweep.stop_hz)
model.radar.sweep.points = _read_int(sweep_payload, "points", model.radar.sweep.points)
model.radar.sweep.if_bandwidth_hz = _read_float(
sweep_payload, "if_bandwidth_hz", model.radar.sweep.if_bandwidth_hz
)
model.radar.sweep.power_dbm = float(sweep_payload.get("stimulus_power_dbm", model.radar.sweep.power_dbm))
model.radar.sweep.power_dbm = _read_float(sweep_payload, "stimulus_power_dbm", model.radar.sweep.power_dbm)
slave_serials_payload = multi_device_payload.get(
"slave_serials",
multi_device_payload.get("slave_serial_numbers", model.radar.multi_device.slave_serials),
@@ -132,121 +191,114 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
for value in slave_serials_payload.split(",")
if value.strip()
]
model.radar.multi_device.force_external_reference = bool(
multi_device_payload.get(
"force_external_reference",
model.radar.multi_device.force_external_reference,
)
model.radar.multi_device.force_external_reference = _read_bool(
multi_device_payload,
"force_external_reference",
model.radar.multi_device.force_external_reference,
)
model.radar.multi_device.recovery_attempts = int(
multi_device_payload.get(
"recovery_attempts",
model.radar.multi_device.recovery_attempts,
)
model.radar.multi_device.recovery_attempts = _read_int(
multi_device_payload,
"recovery_attempts",
model.radar.multi_device.recovery_attempts,
)
model.radar.kamil_adc.project_dir = str(
kamil_adc_payload.get("project_dir", model.radar.kamil_adc.project_dir)
model.radar.kamil_adc.project_dir = _read_str(
kamil_adc_payload, "project_dir", model.radar.kamil_adc.project_dir
)
model.radar.kamil_adc.executable_path = str(
kamil_adc_payload.get("executable_path", model.radar.kamil_adc.executable_path)
model.radar.kamil_adc.executable_path = _read_str(
kamil_adc_payload, "executable_path", model.radar.kamil_adc.executable_path
)
model.radar.kamil_adc.tty_path = str(
kamil_adc_payload.get("tty_path", model.radar.kamil_adc.tty_path)
model.radar.kamil_adc.tty_path = _read_str(
kamil_adc_payload, "tty_path", model.radar.kamil_adc.tty_path
)
model.radar.kamil_adc.args = _load_string_list(kamil_adc_payload, "args", "radar.kamil_adc")
model.radar.kamil_adc.env = _load_string_dict(kamil_adc_payload, "env", "radar.kamil_adc")
model.radar.kamil_adc.startup_timeout_s = float(
kamil_adc_payload.get("startup_timeout_s", model.radar.kamil_adc.startup_timeout_s)
model.radar.kamil_adc.startup_timeout_s = _read_float(
kamil_adc_payload, "startup_timeout_s", model.radar.kamil_adc.startup_timeout_s
)
model.radar.kamil_adc.sweep_timeout_s = float(
kamil_adc_payload.get("sweep_timeout_s", model.radar.kamil_adc.sweep_timeout_s)
model.radar.kamil_adc.sweep_timeout_s = _read_float(
kamil_adc_payload, "sweep_timeout_s", model.radar.kamil_adc.sweep_timeout_s
)
model.radar.kamil_adc.stop_timeout_s = float(
kamil_adc_payload.get("stop_timeout_s", model.radar.kamil_adc.stop_timeout_s)
model.radar.kamil_adc.stop_timeout_s = _read_float(
kamil_adc_payload, "stop_timeout_s", model.radar.kamil_adc.stop_timeout_s
)
model.radar.laser_control.enabled = bool(
laser_control_payload.get("enabled", model.radar.laser_control.enabled)
model.radar.laser_control.enabled = _read_bool(
laser_control_payload, "enabled", model.radar.laser_control.enabled
)
model.radar.laser_control.port = str(
laser_control_payload.get("port", model.radar.laser_control.port)
model.radar.laser_control.port = _read_str(
laser_control_payload, "port", model.radar.laser_control.port
)
model.radar.laser_control.mode = str(
laser_control_payload.get("mode", model.radar.laser_control.mode)
model.radar.laser_control.mode = _read_str(
laser_control_payload, "mode", model.radar.laser_control.mode
)
model.radar.laser_control.pi_coeff1_p = int(
laser_control_payload.get("pi_coeff1_p", model.radar.laser_control.pi_coeff1_p)
model.radar.laser_control.pi_coeff1_p = _read_int(
laser_control_payload, "pi_coeff1_p", model.radar.laser_control.pi_coeff1_p
)
model.radar.laser_control.pi_coeff1_i = int(
laser_control_payload.get("pi_coeff1_i", model.radar.laser_control.pi_coeff1_i)
model.radar.laser_control.pi_coeff1_i = _read_int(
laser_control_payload, "pi_coeff1_i", model.radar.laser_control.pi_coeff1_i
)
model.radar.laser_control.pi_coeff2_p = int(
laser_control_payload.get("pi_coeff2_p", model.radar.laser_control.pi_coeff2_p)
model.radar.laser_control.pi_coeff2_p = _read_int(
laser_control_payload, "pi_coeff2_p", model.radar.laser_control.pi_coeff2_p
)
model.radar.laser_control.pi_coeff2_i = int(
laser_control_payload.get("pi_coeff2_i", model.radar.laser_control.pi_coeff2_i)
model.radar.laser_control.pi_coeff2_i = _read_int(
laser_control_payload, "pi_coeff2_i", model.radar.laser_control.pi_coeff2_i
)
laser_manual_payload = _as_dict(laser_control_payload.get("manual"), "radar.laser_control.manual")
model.radar.laser_control.manual.temp1 = float(
laser_manual_payload.get("temp1", model.radar.laser_control.manual.temp1)
model.radar.laser_control.manual.temp1 = _read_float(
laser_manual_payload, "temp1", model.radar.laser_control.manual.temp1
)
model.radar.laser_control.manual.temp2 = float(
laser_manual_payload.get("temp2", model.radar.laser_control.manual.temp2)
model.radar.laser_control.manual.temp2 = _read_float(
laser_manual_payload, "temp2", model.radar.laser_control.manual.temp2
)
model.radar.laser_control.manual.current1 = float(
laser_manual_payload.get("current1", model.radar.laser_control.manual.current1)
model.radar.laser_control.manual.current1 = _read_float(
laser_manual_payload, "current1", model.radar.laser_control.manual.current1
)
model.radar.laser_control.manual.current2 = float(
laser_manual_payload.get("current2", model.radar.laser_control.manual.current2)
model.radar.laser_control.manual.current2 = _read_float(
laser_manual_payload, "current2", model.radar.laser_control.manual.current2
)
laser_variation_payload = _as_dict(
laser_control_payload.get("variation"),
"radar.laser_control.variation",
)
model.radar.laser_control.variation.variation_type = str(
laser_variation_payload.get(
"variation_type",
model.radar.laser_control.variation.variation_type,
)
model.radar.laser_control.variation.variation_type = _read_str(
laser_variation_payload,
"variation_type",
model.radar.laser_control.variation.variation_type,
)
model.radar.laser_control.variation.static_temp1 = float(
laser_variation_payload.get(
"static_temp1",
model.radar.laser_control.variation.static_temp1,
)
model.radar.laser_control.variation.static_temp1 = _read_float(
laser_variation_payload,
"static_temp1",
model.radar.laser_control.variation.static_temp1,
)
model.radar.laser_control.variation.static_temp2 = float(
laser_variation_payload.get(
"static_temp2",
model.radar.laser_control.variation.static_temp2,
)
model.radar.laser_control.variation.static_temp2 = _read_float(
laser_variation_payload,
"static_temp2",
model.radar.laser_control.variation.static_temp2,
)
model.radar.laser_control.variation.static_current1 = float(
laser_variation_payload.get(
"static_current1",
model.radar.laser_control.variation.static_current1,
)
model.radar.laser_control.variation.static_current1 = _read_float(
laser_variation_payload,
"static_current1",
model.radar.laser_control.variation.static_current1,
)
model.radar.laser_control.variation.static_current2 = float(
laser_variation_payload.get(
"static_current2",
model.radar.laser_control.variation.static_current2,
)
model.radar.laser_control.variation.static_current2 = _read_float(
laser_variation_payload,
"static_current2",
model.radar.laser_control.variation.static_current2,
)
model.radar.laser_control.variation.min_value = float(
laser_variation_payload.get("min_value", model.radar.laser_control.variation.min_value)
model.radar.laser_control.variation.min_value = _read_float(
laser_variation_payload, "min_value", model.radar.laser_control.variation.min_value
)
model.radar.laser_control.variation.max_value = float(
laser_variation_payload.get("max_value", model.radar.laser_control.variation.max_value)
model.radar.laser_control.variation.max_value = _read_float(
laser_variation_payload, "max_value", model.radar.laser_control.variation.max_value
)
model.radar.laser_control.variation.step = float(
laser_variation_payload.get("step", model.radar.laser_control.variation.step)
model.radar.laser_control.variation.step = _read_float(
laser_variation_payload, "step", model.radar.laser_control.variation.step
)
model.radar.laser_control.variation.time_step = int(
laser_variation_payload.get("time_step", model.radar.laser_control.variation.time_step)
model.radar.laser_control.variation.time_step = _read_int(
laser_variation_payload, "time_step", model.radar.laser_control.variation.time_step
)
model.radar.laser_control.variation.delay_time = int(
laser_variation_payload.get("delay_time", model.radar.laser_control.variation.delay_time)
model.radar.laser_control.variation.delay_time = _read_int(
laser_variation_payload, "delay_time", model.radar.laser_control.variation.delay_time
)
load_switch_payload(port1_payload, model.output_switch)
+129 -23
View File
@@ -8,25 +8,68 @@ from python_app.models.run_config_schema import (
ComboModel,
ControlButtonModel,
GprModel,
RadarSweepModel,
RingEndpointModel,
SwitchModel,
)
# 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 an integer field, rejecting JSON arrays/objects with a named ValueError.
Bare ``int()`` raises ``TypeError`` on a list/dict, which escapes the
config-error contract; surface it as a ValueError naming the field instead.
"""
value = payload.get(key, default)
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
raise ValueError(f"{key} must be a JSON integer")
try:
return int(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"{key} must be a JSON integer") from exc
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 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 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."""
target.name = str(payload.get("name", target.name))
target.driver_mode = str(payload.get("driver_mode", target.driver_mode))
target.driver = str(payload.get("driver", target.driver))
target.radar_port = int(payload.get("radar_port", target.radar_port))
target.positions = int(payload.get("positions", target.positions))
target.default_position = int(payload.get("default_position", target.default_position))
target.gpio_chip = str(payload.get("gpio_chip", target.gpio_chip))
target.pin_a = int(payload.get("pin_a", target.pin_a))
target.pin_b = int(payload.get("pin_b", target.pin_b))
target.invert_logic = bool(payload.get("invert_logic", target.invert_logic))
# #53: 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(
@@ -34,20 +77,56 @@ def load_control_button_payload(
target: ControlButtonModel,
) -> None:
"""Populate control-button model from payload preserving defaults."""
target.enabled = bool(payload.get("enabled", target.enabled))
target.gpio_chip = str(payload.get("gpio_chip", target.gpio_chip))
target.pin = int(payload.get("pin", target.pin))
target.active_low = bool(payload.get("active_low", target.active_low))
target.bias = str(payload.get("bias", target.bias))
target.debounce_ms = int(payload.get("debounce_ms", target.debounce_ms))
target.action = str(payload.get("action", target.action))
# #53: 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."""
target.name = str(payload.get("name", target.name))
target.capacity = int(payload.get("capacity", target.capacity))
target.slot_size_bytes = int(payload.get("slot_size_bytes", target.slot_size_bytes))
# #53: 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)
# #36: enforce ring sizing in Python so a bad config fails here (in GUI/save and
# at config load) instead of crashing the C++ ring allocator at boot.
validate_ring_endpoint(target)
def validate_ring_endpoint(ring: RingEndpointModel) -> None:
"""Validate ring sizing against the constraints the C++ allocator requires."""
field = ring.name or "ring"
if ring.capacity <= 0:
raise ValueError(f"rings.{field}.capacity must be > 0")
if ring.slot_size_bytes <= 0:
raise ValueError(f"rings.{field}.slot_size_bytes must be > 0")
if ring.slot_size_bytes > _UINT32_MAX:
raise ValueError(f"rings.{field}.slot_size_bytes exceeds the uint32 wire limit")
# Overflow-safe: compare against the ceiling without ever forming the full
# product, so an attacker-sized capacity cannot wrap a fixed-width index.
if ring.capacity > _RING_SEGMENT_MAX_BYTES // ring.slot_size_bytes:
raise ValueError(
f"rings.{field} capacity * slot_size_bytes exceeds the maximum ring segment size"
)
def validate_sweep_model(sweep: RadarSweepModel) -> None:
"""Validate radar sweep bounds in Python so a bad sweep fails in the GUI/save
and at config load rather than aborting the C++ acquisition process at boot.
"""
# #36: 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(
@@ -55,8 +134,17 @@ def validate_gpr_model(
*,
input_switch_positions: int,
output_switch_positions: int,
sweep: RadarSweepModel | None = None,
) -> None:
"""Validate stable GPR config against current switch dimensions."""
"""Validate stable GPR config against current switch dimensions.
When ``sweep`` is supplied (load and GUI/save paths share this chokepoint),
its bounds are validated here too so #36 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")
@@ -93,8 +181,26 @@ def parse_combos_from_text(text: str) -> list[ComboModel]:
if ":" not in pair:
raise ValueError(f"Invalid combo syntax: {pair!r}. Expected input:output")
input_text, output_text = pair.split(":", 1)
combos.append(ComboModel(input=int(input_text.strip()), output=int(output_text.strip())))
# #57: 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))
# #57: 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")