Refactor radar system
This commit is contained in:
@@ -6,6 +6,7 @@ import dataclasses
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from python_app.models.json_fields import finite_number, json_object as _as_dict
|
||||
from python_app.models.gui_profile_schema import (
|
||||
GuiBscanStateModel,
|
||||
GuiDataActionsStateModel,
|
||||
@@ -24,15 +25,6 @@ from python_app.models.run_config_model import RunConfigModel
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _as_dict(value: Any, context: str) -> dict[str, Any]:
|
||||
"""Validate payload node is object-like, treating missing values as empty object."""
|
||||
if value is None:
|
||||
return {}
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"{context} must be a JSON object")
|
||||
return value
|
||||
|
||||
|
||||
def _optional_string(object_payload: dict[str, Any], key: str, fallback: str, context: str) -> str:
|
||||
"""Return optional string field with type validation."""
|
||||
raw_value = object_payload.get(key, fallback)
|
||||
@@ -59,10 +51,7 @@ def _optional_int(object_payload: dict[str, Any], key: str, fallback: int, conte
|
||||
|
||||
def _optional_float(object_payload: dict[str, Any], key: str, fallback: float, context: str) -> float:
|
||||
"""Return optional numeric field with type validation."""
|
||||
raw_value = object_payload.get(key, fallback)
|
||||
if isinstance(raw_value, bool) or not isinstance(raw_value, (int, float)):
|
||||
raise ValueError(f"{context}.{key} must be a JSON number")
|
||||
return float(raw_value)
|
||||
return finite_number(object_payload.get(key, fallback), f"{context}.{key}")
|
||||
|
||||
|
||||
def _legacy_gpr_mode_from_algorithm(value: str) -> str | None:
|
||||
@@ -533,12 +522,6 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel:
|
||||
|
||||
data_actions_object = _as_dict(gui_object.get("data_actions"), "gui.data_actions")
|
||||
gui.data_actions = GuiDataActionsStateModel(
|
||||
save_count=_optional_int(
|
||||
data_actions_object,
|
||||
"save_count",
|
||||
gui.data_actions.save_count,
|
||||
"gui.data_actions",
|
||||
),
|
||||
save_path=_optional_string(
|
||||
data_actions_object,
|
||||
"save_path",
|
||||
@@ -598,102 +581,5 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel:
|
||||
def gui_profile_to_dict(model: GuiProfileModel) -> dict[str, Any]:
|
||||
"""Encode :class:`GuiProfileModel` into JSON-serializable dictionary."""
|
||||
payload = model.run_config.to_dict()
|
||||
gui = model.gui if model.gui is not None else GuiStateModel()
|
||||
payload["gui"] = {
|
||||
"version": gui.version,
|
||||
"switches": {
|
||||
"combo_mode": gui.switches.combo_mode,
|
||||
"combos_text": gui.switches.combos_text,
|
||||
"single_input": gui.switches.single_input,
|
||||
"single_output": gui.switches.single_output,
|
||||
},
|
||||
"processing": {
|
||||
"selected_mode": gui.processing.selected_mode,
|
||||
"pass_through": {
|
||||
"show_magnitude": gui.processing.pass_through.show_magnitude,
|
||||
"show_phase": gui.processing.pass_through.show_phase,
|
||||
"unwrap_phase": gui.processing.pass_through.unwrap_phase,
|
||||
"combo_filter": gui.processing.pass_through.combo_filter,
|
||||
"fixed_y_enabled": gui.processing.pass_through.fixed_y_enabled,
|
||||
"y_min_db": gui.processing.pass_through.y_min_db,
|
||||
"y_max_db": gui.processing.pass_through.y_max_db,
|
||||
},
|
||||
"bscan": {
|
||||
"axis": gui.processing.bscan.axis,
|
||||
"cut_m": gui.processing.bscan.cut_m,
|
||||
"max_depth_m": gui.processing.bscan.max_depth_m,
|
||||
"gain": gui.processing.bscan.gain,
|
||||
"start_freq_mhz": gui.processing.bscan.start_freq_mhz,
|
||||
"stop_freq_mhz": gui.processing.bscan.stop_freq_mhz,
|
||||
"subtract_mean_ascan": gui.processing.bscan.subtract_mean_ascan,
|
||||
"history_window_scans": gui.processing.bscan.history_window_scans,
|
||||
},
|
||||
"gpr": {
|
||||
"input_positions": gui.processing.gpr.input_positions,
|
||||
"output_positions": gui.processing.gpr.output_positions,
|
||||
"min_depth_m": gui.processing.gpr.min_depth_m,
|
||||
"max_depth_m": gui.processing.gpr.max_depth_m,
|
||||
"range_comp_power": gui.processing.gpr.range_comp_power,
|
||||
"object_min_frac": gui.processing.gpr.object_min_frac,
|
||||
"score_mode": gui.processing.gpr.score_mode,
|
||||
"motion_mode": gui.processing.gpr.motion_mode,
|
||||
"look_angle_deg": gui.processing.gpr.look_angle_deg,
|
||||
"direction_sign": gui.processing.gpr.direction_sign,
|
||||
"speed_m_s": gui.processing.gpr.speed_m_s,
|
||||
"ignore_socket_speed_enabled": gui.processing.gpr.ignore_socket_speed_enabled,
|
||||
"max_detected_objects_to_draw": gui.processing.gpr.max_detected_objects_to_draw,
|
||||
"draw_top_m_objects": gui.processing.gpr.draw_top_m_objects,
|
||||
"object_approach_min_frames": gui.processing.gpr.object_approach_min_frames,
|
||||
"start_freq_mhz": gui.processing.gpr.start_freq_mhz,
|
||||
"stop_freq_mhz": gui.processing.gpr.stop_freq_mhz,
|
||||
"background_subtract_enabled": gui.processing.gpr.background_subtract_enabled,
|
||||
"background_mean_count": gui.processing.gpr.background_mean_count,
|
||||
"remove_sidelobe_objects_enabled": gui.processing.gpr.remove_sidelobe_objects_enabled,
|
||||
"imaging_plane_y_m": gui.processing.gpr.imaging_plane_y_m,
|
||||
"render_mode": gui.processing.gpr.render_mode,
|
||||
"visible_x_min_m": gui.processing.gpr.visible_x_min_m,
|
||||
"visible_x_max_m": gui.processing.gpr.visible_x_max_m,
|
||||
"visible_z_min_m": gui.processing.gpr.visible_z_min_m,
|
||||
"visible_z_max_m": gui.processing.gpr.visible_z_max_m,
|
||||
},
|
||||
"legacy_gpr": {
|
||||
"mode": gui.processing.legacy_gpr.mode,
|
||||
"input_positions": gui.processing.legacy_gpr.input_positions,
|
||||
"output_positions": gui.processing.legacy_gpr.output_positions,
|
||||
"min_depth_m": gui.processing.legacy_gpr.min_depth_m,
|
||||
"max_depth_m": gui.processing.legacy_gpr.max_depth_m,
|
||||
"comp_power": gui.processing.legacy_gpr.comp_power,
|
||||
"start_freq_mhz": gui.processing.legacy_gpr.start_freq_mhz,
|
||||
"stop_freq_mhz": gui.processing.legacy_gpr.stop_freq_mhz,
|
||||
"speed_m_s": gui.processing.legacy_gpr.speed_m_s,
|
||||
"ignore_socket_speed_enabled": gui.processing.legacy_gpr.ignore_socket_speed_enabled,
|
||||
"look_angle_deg": gui.processing.legacy_gpr.look_angle_deg,
|
||||
"apply_freq_phase_correction": gui.processing.legacy_gpr.apply_freq_phase_correction,
|
||||
"reference_mode": gui.processing.legacy_gpr.reference_mode,
|
||||
"snr_thresh": gui.processing.legacy_gpr.snr_thresh,
|
||||
"snr_comp_max": gui.processing.legacy_gpr.snr_comp_max,
|
||||
"background_subtract_enabled": gui.processing.legacy_gpr.background_subtract_enabled,
|
||||
"background_mean_count": gui.processing.legacy_gpr.background_mean_count,
|
||||
"render_mode": gui.processing.legacy_gpr.render_mode,
|
||||
"min_visible_pair_count": gui.processing.legacy_gpr.min_visible_pair_count,
|
||||
"visible_x_min_m": gui.processing.legacy_gpr.visible_x_min_m,
|
||||
"visible_x_max_m": gui.processing.legacy_gpr.visible_x_max_m,
|
||||
"visible_z_min_m": gui.processing.legacy_gpr.visible_z_min_m,
|
||||
"visible_z_max_m": gui.processing.legacy_gpr.visible_z_max_m,
|
||||
},
|
||||
"sar3d": dataclasses.asdict(gui.processing.sar3d),
|
||||
},
|
||||
"data_actions": {
|
||||
"save_count": gui.data_actions.save_count,
|
||||
"save_path": gui.data_actions.save_path,
|
||||
"save_name": gui.data_actions.save_name,
|
||||
"record_count": gui.data_actions.record_count,
|
||||
},
|
||||
"preprocess_dialog": {
|
||||
"set_name": gui.preprocess_dialog.set_name,
|
||||
"radar_config_dir": gui.preprocess_dialog.radar_config_dir,
|
||||
"use_all_radar_configs": gui.preprocess_dialog.use_all_radar_configs,
|
||||
"median_sweep_count": gui.preprocess_dialog.median_sweep_count,
|
||||
},
|
||||
}
|
||||
payload["gui"] = dataclasses.asdict(model.gui if model.gui is not None else GuiStateModel())
|
||||
return payload
|
||||
|
||||
@@ -42,8 +42,8 @@ class GuiBscanStateModel:
|
||||
"""UI-only defaults for B-scan live settings."""
|
||||
|
||||
axis: str = "abs"
|
||||
cut_m: float = 0.824
|
||||
max_depth_m: float = 1.0
|
||||
cut_m: float = 0.0
|
||||
max_depth_m: float = 8.0
|
||||
gain: float = 1.0
|
||||
start_freq_mhz: float = 100.0
|
||||
stop_freq_mhz: float = 8800.0
|
||||
@@ -164,7 +164,6 @@ class GuiProcessingStateModel:
|
||||
class GuiDataActionsStateModel:
|
||||
"""UI-only defaults for snapshot/export controls."""
|
||||
|
||||
save_count: int = 10
|
||||
save_path: str = ""
|
||||
save_name: str = "snapshot_manual"
|
||||
# How many freshly acquired measurements the "Start + Record" action writes to
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Shared strict scalar and object readers for configuration codecs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
|
||||
def json_object(value: Any, context: str) -> dict[str, Any]:
|
||||
"""Read an object; a missing or null section keeps its defaults."""
|
||||
if value is None:
|
||||
return {}
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"{context} must be a JSON object")
|
||||
return value
|
||||
|
||||
|
||||
def _value(payload: dict[str, Any], key: str, default: Any) -> Any:
|
||||
value = payload.get(key, default)
|
||||
return default if value is None else value
|
||||
|
||||
|
||||
def read_int(payload: dict[str, Any], key: str, default: int) -> int:
|
||||
"""Read a JSON integer without truncation or boolean coercion."""
|
||||
value = _value(payload, key, default)
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ValueError(f"{key} must be a JSON integer")
|
||||
return value
|
||||
|
||||
|
||||
def read_str(payload: dict[str, Any], key: str, default: str) -> str:
|
||||
"""Read a JSON string without converting other scalar types."""
|
||||
value = _value(payload, key, default)
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(f"{key} must be a JSON string")
|
||||
return value
|
||||
|
||||
|
||||
def read_bool(payload: dict[str, Any], key: str, default: bool) -> bool:
|
||||
"""Read a JSON boolean."""
|
||||
value = _value(payload, key, default)
|
||||
if not isinstance(value, bool):
|
||||
raise ValueError(f"{key} must be a JSON boolean")
|
||||
return value
|
||||
|
||||
|
||||
def read_float(payload: dict[str, Any], key: str, default: float) -> float:
|
||||
"""Read a finite JSON number, including overflow-safe integer conversion."""
|
||||
return finite_number(_value(payload, key, default), key)
|
||||
|
||||
|
||||
def finite_number(value: Any, context: str) -> float:
|
||||
"""Validate a number without accepting booleans, strings or nonfinite values."""
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise ValueError(f"{context} must be a JSON number")
|
||||
try:
|
||||
result = float(value)
|
||||
except OverflowError as exc:
|
||||
raise ValueError(f"{context} must be a finite number") from exc
|
||||
if not math.isfinite(result):
|
||||
raise ValueError(f"{context} must be a finite number")
|
||||
return result
|
||||
@@ -3,10 +3,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from python_app.logging_setup import LOG_LEVELS
|
||||
from python_app.models.json_fields import (
|
||||
finite_number, json_object as _as_dict, read_bool as _read_bool, read_float as _read_float,
|
||||
read_int as _read_int, read_str as _read_str,
|
||||
)
|
||||
|
||||
from python_app.models.run_config_schema import (
|
||||
ComboModel,
|
||||
@@ -27,15 +30,6 @@ from python_app.models.run_config_validation import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _as_dict(value: Any, context: str) -> dict[str, Any]:
|
||||
"""Validate payload node is object-like, treating missing values as empty object."""
|
||||
if value is None:
|
||||
return {}
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"{context} must be a JSON object")
|
||||
return value
|
||||
|
||||
|
||||
def _as_list(value: Any, context: str) -> list[Any]:
|
||||
"""Validate payload node is array-like, treating missing values as an empty list.
|
||||
|
||||
@@ -49,71 +43,6 @@ def _as_list(value: Any, context: str) -> list[Any]:
|
||||
return value
|
||||
|
||||
|
||||
def _read_str(payload: dict[str, Any], key: str, default: str) -> str:
|
||||
"""Return a payload string, treating an explicit JSON ``null`` as 'use default'.
|
||||
|
||||
Keeping the default on ``null`` avoids coercing it to the literal string
|
||||
``"None"``. JSON arrays/objects reaching a scalar field are rejected as
|
||||
ValueError to keep the config-error contract uniform.
|
||||
"""
|
||||
value = payload.get(key, default)
|
||||
if value is None:
|
||||
return default
|
||||
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 a payload integer, treating an explicit JSON ``null`` as 'use default'.
|
||||
|
||||
Accepts only a genuine JSON integer (not bool, not float, not numeric
|
||||
string); silently truncating ``5.7`` or parsing ``"5"`` would hide a
|
||||
malformed config. Mirrors ``gui_profile_codec._optional_int`` so the two
|
||||
codecs agree.
|
||||
"""
|
||||
value = payload.get(key, default)
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ValueError(f"{key} must be a JSON integer")
|
||||
return value
|
||||
|
||||
|
||||
def _read_float(payload: dict[str, Any], key: str, default: float) -> float:
|
||||
"""Return a payload float, treating an explicit JSON ``null`` as 'use default'.
|
||||
|
||||
Accepts only a genuine JSON number (int/float, not bool, not numeric
|
||||
string); parsing ``"1e9"`` would hide a malformed config. Non-finite values
|
||||
(NaN/Infinity) are rejected at decode time so the C++ pipeline never receives
|
||||
a value it cannot honor. Mirrors ``gui_profile_codec._optional_float``.
|
||||
"""
|
||||
value = payload.get(key, default)
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise ValueError(f"{key} must be a JSON number")
|
||||
result = float(value)
|
||||
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 a payload boolean, treating an explicit JSON ``null`` as 'use default'.
|
||||
|
||||
Keeping the default on ``null`` avoids the silent flip to ``False`` that a
|
||||
plain ``bool(...)`` coercion would produce. 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)
|
||||
@@ -152,6 +81,8 @@ def _load_string_dict(payload: dict[str, Any], key: str, context: str) -> dict[s
|
||||
|
||||
def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
|
||||
"""Decode JSON-like payload into :class:`RunConfigModel`."""
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("Run config root must be a JSON object")
|
||||
# Schema carries only minimal-safe fallbacks; operational defaults live in run_config.json.
|
||||
model = RunConfigModel()
|
||||
|
||||
@@ -181,11 +112,7 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
|
||||
|
||||
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 = _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)
|
||||
@@ -429,7 +356,14 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
|
||||
for band in bands_payload:
|
||||
if not (isinstance(band, (list, tuple)) and len(band) == 2):
|
||||
raise ValueError("preprocess.notch.bands_hz entries must be [low_hz, high_hz] pairs")
|
||||
model.preprocess.notch.bands_hz.append((float(band[0]), float(band[1])))
|
||||
low_hz, high_hz = (finite_number(value, "preprocess.notch.bands_hz[]") for value in band)
|
||||
if high_hz < low_hz or max(abs(low_hz), abs(high_hz)) > 3.4e38:
|
||||
raise ValueError("preprocess.notch.bands_hz[] must be ordered finite float32 frequencies")
|
||||
model.preprocess.notch.bands_hz.append((low_hz, high_hz))
|
||||
if model.preprocess.notch.taper_width_hz < 0 or model.preprocess.notch.taper_width_hz > 3.4e38:
|
||||
raise ValueError("preprocess.notch.taper_width_hz must be a nonnegative float32 number")
|
||||
if model.preprocess.notch.taper_type not in ("cosine", "hard"):
|
||||
raise ValueError("preprocess.notch.taper_type must be cosine or hard")
|
||||
|
||||
model.gpr.relative_permittivity = _read_float(
|
||||
gpr_payload, "relative_permittivity", model.gpr.relative_permittivity
|
||||
@@ -514,11 +448,7 @@ def run_config_to_dict(model: RunConfigModel) -> dict[str, Any]:
|
||||
"radar": {
|
||||
"model": model.radar.model,
|
||||
"serial": model.radar.serial,
|
||||
"remote_host": model.radar.remote_host,
|
||||
"remote_port": model.radar.remote_port,
|
||||
"driver_mode": model.radar.driver_mode,
|
||||
"mock_signal_hz": model.radar.mock_signal_hz,
|
||||
"visa_library": model.radar.visa_library,
|
||||
"multi_device": {
|
||||
"slave_serials": list(model.radar.multi_device.slave_serials),
|
||||
"force_external_reference": model.radar.multi_device.force_external_reference,
|
||||
|
||||
@@ -10,6 +10,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
MAX_COMBOS = 4096
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -171,11 +172,7 @@ class RadarModel:
|
||||
|
||||
model: str = "librevna"
|
||||
serial: str = ""
|
||||
remote_host: str = "127.0.0.1"
|
||||
remote_port: int = 50209
|
||||
driver_mode: str = "mock"
|
||||
mock_signal_hz: float = 1_000_000.0
|
||||
visa_library: str = ""
|
||||
sweep: RadarSweepModel = field(default_factory=RadarSweepModel)
|
||||
multi_device: RadarMultiDeviceModel = field(default_factory=RadarMultiDeviceModel)
|
||||
replay: RadarReplayModel = field(default_factory=RadarReplayModel)
|
||||
@@ -377,9 +374,7 @@ class RunConfigModel:
|
||||
|
||||
LIBREVNA_MODEL = "librevna"
|
||||
LIBREVNA_MULTI_MODEL = "librevna_multi"
|
||||
COMPACT_M_K209_MODEL = "compact_m_k209"
|
||||
KAMIL_ADC_MODEL = "kamil_adc"
|
||||
SN9000_MODEL = "sn9000"
|
||||
MULTI_DEVICE_INPUT_POSITIONS = 4
|
||||
MULTI_DEVICE_OUTPUT_POSITIONS = 2
|
||||
# Stimulus ports the master itself owns. The virtual output axis is this times the
|
||||
@@ -390,39 +385,26 @@ class RunConfigModel:
|
||||
@staticmethod
|
||||
def build_full_combos(input_positions: int, output_positions: int) -> list[ComboModel]:
|
||||
"""Build full Cartesian product of input/output switch positions."""
|
||||
if any(isinstance(value, bool) or not isinstance(value, int) or value <= 0
|
||||
for value in (input_positions, output_positions)):
|
||||
raise ValueError("Switch positions must be positive integers")
|
||||
if input_positions * output_positions > MAX_COMBOS:
|
||||
raise ValueError(f"Too many combos: limit is {MAX_COMBOS}")
|
||||
return [
|
||||
ComboModel(input=input_pos, output=output_pos)
|
||||
for output_pos in range(output_positions)
|
||||
for input_pos in range(input_positions)
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def build_multi_device_virtual_combos(cls) -> list[ComboModel]:
|
||||
"""Build fixed virtual combo matrix for one master and two slave devices."""
|
||||
return cls.build_full_combos(
|
||||
cls.MULTI_DEVICE_INPUT_POSITIONS,
|
||||
cls.MULTI_DEVICE_OUTPUT_POSITIONS,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def build_matrix_radar_virtual_combos(cls) -> list[ComboModel]:
|
||||
"""Build the canonical 2x4 virtual combo matrix shared by matrix-mode radars."""
|
||||
return cls.build_multi_device_virtual_combos()
|
||||
|
||||
@property
|
||||
def is_multi_device(self) -> bool:
|
||||
"""Return whether this config targets synchronized multi-device acquisition."""
|
||||
return self.radar.model == self.LIBREVNA_MULTI_MODEL
|
||||
|
||||
@property
|
||||
def is_sn9000(self) -> bool:
|
||||
"""Return whether this config targets the SN9000 multi-port analyzer."""
|
||||
return self.radar.model == self.SN9000_MODEL
|
||||
|
||||
@property
|
||||
def is_matrix_radar(self) -> bool:
|
||||
"""Return whether this config acquires the full virtual switch matrix per sweep."""
|
||||
return self.is_multi_device or self.is_sn9000
|
||||
return self.is_multi_device
|
||||
|
||||
@property
|
||||
def matrix_output_switch_positions(self) -> int:
|
||||
|
||||
@@ -3,14 +3,20 @@
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -21,45 +27,6 @@ logger = logging.getLogger(__name__)
|
||||
# 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(
|
||||
@@ -117,18 +84,79 @@ def validate_ring_endpoint(ring: RingEndpointModel) -> None:
|
||||
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 // ring.slot_size_bytes:
|
||||
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:
|
||||
@@ -143,9 +171,17 @@ def validate_sweep_model(sweep: RadarSweepModel) -> None:
|
||||
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):
|
||||
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")
|
||||
|
||||
|
||||
@@ -167,12 +203,16 @@ def validate_gpr_model(
|
||||
if sweep is not None:
|
||||
validate_sweep_model(sweep)
|
||||
|
||||
if float(gpr.relative_permittivity) <= 0.0:
|
||||
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:
|
||||
output_pos = int(entry.output_pos)
|
||||
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:
|
||||
@@ -181,7 +221,11 @@ def validate_gpr_model(
|
||||
|
||||
seen_input_positions: set[int] = set()
|
||||
for entry in gpr.rx_geometry:
|
||||
input_pos = int(entry.input_pos)
|
||||
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:
|
||||
@@ -202,12 +246,18 @@ def validate_combos(
|
||||
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:
|
||||
if not 0 <= int(combo.input) < int(input_positions):
|
||||
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 <= int(combo.output) < int(output_positions):
|
||||
if not 0 <= combo.output < output_positions:
|
||||
raise ValueError(
|
||||
f"run.combos output {combo.output} is out of range [0, {output_positions})"
|
||||
)
|
||||
@@ -222,7 +272,7 @@ def parse_combos_from_text(text: str) -> list[ComboModel]:
|
||||
|
||||
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.
|
||||
than ``MAX_COMBOS`` entries, or a non-blank string that yields no combos.
|
||||
"""
|
||||
cleaned = text.strip()
|
||||
if not cleaned:
|
||||
@@ -238,8 +288,8 @@ def parse_combos_from_text(text: str) -> list[ComboModel]:
|
||||
|
||||
# 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}")
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user