429 lines
20 KiB
Python
429 lines
20 KiB
Python
"""Encoding and decoding logic for :mod:`python_app.models.run_config_schema`."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import asdict
|
|
import logging
|
|
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,
|
|
GprRxGeometryModel,
|
|
GprTxGeometryModel,
|
|
PreprocessAssetModel,
|
|
PreprocessNotchModel,
|
|
RunConfigModel,
|
|
)
|
|
from python_app.models.run_config_validation import (
|
|
load_control_button_payload,
|
|
load_ring_payload,
|
|
load_switch_payload,
|
|
validate_combos,
|
|
validate_gpr_model,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _as_list(value: Any, context: str) -> list[Any]:
|
|
"""Validate payload node is array-like, treating missing values as an empty list.
|
|
|
|
A present-but-non-array value is rejected (rather than silently dropped) so a
|
|
malformed config section fails loudly instead of quietly emptying out.
|
|
"""
|
|
if value is None:
|
|
return []
|
|
if not isinstance(value, list):
|
|
raise ValueError(f"{context} must be a JSON array")
|
|
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)
|
|
target.bundle_path = _read_str(payload, "bundle_path", target.bundle_path)
|
|
|
|
|
|
def _load_string_list(payload: dict[str, Any], key: str, context: str) -> list[str]:
|
|
"""Load an optional list of strings with strict shape validation."""
|
|
raw_value = payload.get(key, [])
|
|
if raw_value is None:
|
|
return []
|
|
if not isinstance(raw_value, list):
|
|
raise ValueError(f"{context}.{key} must be a JSON array")
|
|
values: list[str] = []
|
|
for index, item in enumerate(raw_value):
|
|
if not isinstance(item, str):
|
|
raise ValueError(f"{context}.{key}[{index}] must be a JSON string")
|
|
values.append(item)
|
|
return values
|
|
|
|
|
|
def _load_string_dict(payload: dict[str, Any], key: str, context: str) -> dict[str, str]:
|
|
"""Load an optional string-to-string dictionary with strict shape validation."""
|
|
raw_value = payload.get(key, {})
|
|
if raw_value is None:
|
|
return {}
|
|
if not isinstance(raw_value, dict):
|
|
raise ValueError(f"{context}.{key} must be a JSON object")
|
|
values: dict[str, str] = {}
|
|
for item_key, item_value in raw_value.items():
|
|
if not isinstance(item_key, str) or not isinstance(item_value, str):
|
|
raise ValueError(f"{context}.{key} must contain only string keys and values")
|
|
values[item_key] = item_value
|
|
return values
|
|
|
|
|
|
def run_config_from_dict(payload: object) -> 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()
|
|
|
|
radar_payload = _as_dict(payload.get("radar"), "radar")
|
|
sweep_payload = _as_dict(radar_payload.get("sweep"), "radar.sweep")
|
|
switches_payload = _as_dict(payload.get("switches"), "switches")
|
|
port1_payload = _as_dict(switches_payload.get("port1"), "switches.port1")
|
|
port2_payload = _as_dict(switches_payload.get("port2"), "switches.port2")
|
|
control_button_payload = _as_dict(payload.get("control_button"), "control_button")
|
|
run_payload = _as_dict(payload.get("run"), "run")
|
|
preprocess_payload = _as_dict(payload.get("preprocess"), "preprocess")
|
|
gpr_payload = _as_dict(payload.get("gpr"), "gpr")
|
|
rings_payload = _as_dict(payload.get("rings"), "rings")
|
|
raw_ring_payload = _as_dict(rings_payload.get("raw"), "rings.raw")
|
|
raw_tap_ring_payload = _as_dict(rings_payload.get("raw_tap"), "rings.raw_tap")
|
|
pre_ring_payload = _as_dict(rings_payload.get("preprocessed"), "rings.preprocessed")
|
|
pre_tap_ring_payload = _as_dict(rings_payload.get("preprocessed_tap"), "rings.preprocessed_tap")
|
|
result_ring_payload = _as_dict(rings_payload.get("results"), "rings.results")
|
|
locator_server_payload = _as_dict(
|
|
run_payload.get("locator_server", run_payload.get("locator")),
|
|
"run.locator_server",
|
|
)
|
|
if radar_payload.get("model") == "kamil_adc":
|
|
raise ValueError("radar.model kamil_adc was renamed: use rph_radar and move radar.kamil_adc to radar.rph_radar")
|
|
multi_device_payload = _as_dict(radar_payload.get("multi_device"), "radar.multi_device")
|
|
rph_radar_payload = _as_dict(radar_payload.get("rph_radar"), "radar.rph_radar")
|
|
laser_control_payload = _as_dict(radar_payload.get("laser_control"), "radar.laser_control")
|
|
|
|
model.radar.model = _read_str(radar_payload, "model", model.radar.model)
|
|
model.radar.serial = _read_str(radar_payload, "serial", model.radar.serial)
|
|
model.radar.driver_mode = _read_str(radar_payload, "driver_mode", model.radar.driver_mode)
|
|
|
|
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 = _read_float(sweep_payload, "stimulus_power_dbm", model.radar.sweep.power_dbm)
|
|
model.radar.sweep.dwell_us = _read_int(sweep_payload, "dwell_us", model.radar.sweep.dwell_us)
|
|
slave_serials_payload = multi_device_payload.get(
|
|
"slave_serials",
|
|
multi_device_payload.get("slave_serial_numbers", model.radar.multi_device.slave_serials),
|
|
)
|
|
if isinstance(slave_serials_payload, list):
|
|
model.radar.multi_device.slave_serials = [str(value).strip() for value in slave_serials_payload if str(value).strip()]
|
|
elif isinstance(slave_serials_payload, str):
|
|
model.radar.multi_device.slave_serials = [
|
|
value.strip()
|
|
for value in slave_serials_payload.split(",")
|
|
if value.strip()
|
|
]
|
|
else:
|
|
raise ValueError(
|
|
"radar.multi_device.slave_serials must be a JSON array or comma-separated string"
|
|
)
|
|
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 = _read_int(
|
|
multi_device_payload,
|
|
"recovery_attempts",
|
|
model.radar.multi_device.recovery_attempts,
|
|
)
|
|
model.radar.multi_device.output_switch_positions = _read_int(
|
|
multi_device_payload,
|
|
"output_switch_positions",
|
|
model.radar.multi_device.output_switch_positions,
|
|
)
|
|
model.radar.multi_device.input_switch_positions = _read_int(
|
|
multi_device_payload,
|
|
"input_switch_positions",
|
|
model.radar.multi_device.input_switch_positions,
|
|
)
|
|
model.radar.multi_device.stimulus_switch_positions = _read_int(
|
|
multi_device_payload,
|
|
"stimulus_switch_positions",
|
|
model.radar.multi_device.stimulus_switch_positions,
|
|
)
|
|
model.radar.rph_radar.source = _read_str(rph_radar_payload, "source", model.radar.rph_radar.source)
|
|
model.radar.rph_radar.udp_bind = _read_str(rph_radar_payload, "udp_bind", model.radar.rph_radar.udp_bind)
|
|
model.radar.rph_radar.project_dir = _read_str(
|
|
rph_radar_payload, "project_dir", model.radar.rph_radar.project_dir
|
|
)
|
|
model.radar.rph_radar.executable_path = _read_str(
|
|
rph_radar_payload, "executable_path", model.radar.rph_radar.executable_path
|
|
)
|
|
model.radar.rph_radar.tty_path = _read_str(
|
|
rph_radar_payload, "tty_path", model.radar.rph_radar.tty_path
|
|
)
|
|
model.radar.rph_radar.args = _load_string_list(rph_radar_payload, "args", "radar.rph_radar")
|
|
model.radar.rph_radar.env = _load_string_dict(rph_radar_payload, "env", "radar.rph_radar")
|
|
model.radar.rph_radar.startup_timeout_s = _read_float(
|
|
rph_radar_payload, "startup_timeout_s", model.radar.rph_radar.startup_timeout_s
|
|
)
|
|
model.radar.rph_radar.sweep_timeout_s = _read_float(
|
|
rph_radar_payload, "sweep_timeout_s", model.radar.rph_radar.sweep_timeout_s
|
|
)
|
|
model.radar.rph_radar.stop_timeout_s = _read_float(
|
|
rph_radar_payload, "stop_timeout_s", model.radar.rph_radar.stop_timeout_s
|
|
)
|
|
phase_calibration_payload = _as_dict(
|
|
rph_radar_payload.get("phase_calibration"), "radar.rph_radar.phase_calibration"
|
|
)
|
|
calibration = model.radar.rph_radar.phase_calibration
|
|
calibration.phase0_rad = _read_float(phase_calibration_payload, "phase0_rad", calibration.phase0_rad)
|
|
calibration.freq0_hz = _read_float(phase_calibration_payload, "freq0_hz", calibration.freq0_hz)
|
|
calibration.phase1_rad = _read_float(phase_calibration_payload, "phase1_rad", calibration.phase1_rad)
|
|
calibration.freq1_hz = _read_float(phase_calibration_payload, "freq1_hz", calibration.freq1_hz)
|
|
band_payload = _as_dict(rph_radar_payload.get("band"), "radar.rph_radar.band")
|
|
band = model.radar.rph_radar.band
|
|
band.start_hz = _read_float(band_payload, "start_hz", band.start_hz)
|
|
band.stop_hz = _read_float(band_payload, "stop_hz", band.stop_hz)
|
|
band.points = _read_int(band_payload, "points", band.points)
|
|
|
|
model.radar.laser_control.enabled = _read_bool(
|
|
laser_control_payload, "enabled", model.radar.laser_control.enabled
|
|
)
|
|
model.radar.laser_control.port = _read_str(
|
|
laser_control_payload, "port", model.radar.laser_control.port
|
|
)
|
|
model.radar.laser_control.mode = _read_str(
|
|
laser_control_payload, "mode", model.radar.laser_control.mode
|
|
)
|
|
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 = _read_int(
|
|
laser_control_payload, "pi_coeff1_i", model.radar.laser_control.pi_coeff1_i
|
|
)
|
|
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 = _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 = _read_float(
|
|
laser_manual_payload, "temp1", model.radar.laser_control.manual.temp1
|
|
)
|
|
model.radar.laser_control.manual.temp2 = _read_float(
|
|
laser_manual_payload, "temp2", model.radar.laser_control.manual.temp2
|
|
)
|
|
model.radar.laser_control.manual.current1 = _read_float(
|
|
laser_manual_payload, "current1", model.radar.laser_control.manual.current1
|
|
)
|
|
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 = _read_str(
|
|
laser_variation_payload,
|
|
"variation_type",
|
|
model.radar.laser_control.variation.variation_type,
|
|
)
|
|
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 = _read_float(
|
|
laser_variation_payload,
|
|
"static_temp2",
|
|
model.radar.laser_control.variation.static_temp2,
|
|
)
|
|
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 = _read_float(
|
|
laser_variation_payload,
|
|
"static_current2",
|
|
model.radar.laser_control.variation.static_current2,
|
|
)
|
|
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 = _read_float(
|
|
laser_variation_payload, "max_value", model.radar.laser_control.variation.max_value
|
|
)
|
|
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 = _read_int(
|
|
laser_variation_payload, "time_step", model.radar.laser_control.variation.time_step
|
|
)
|
|
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)
|
|
load_switch_payload(port2_payload, model.input_switch)
|
|
load_control_button_payload(control_button_payload, model.control_button)
|
|
|
|
logging_payload = _as_dict(payload.get("logging"), "logging")
|
|
level = _read_str(logging_payload, "level", model.logging.level).strip().lower()
|
|
if level.upper() not in LOG_LEVELS:
|
|
valid = ", ".join(name.lower() for name in LOG_LEVELS)
|
|
raise ValueError(f"logging.level must be one of: {valid}")
|
|
model.logging.level = level
|
|
|
|
model.apply_device_model_constraints()
|
|
|
|
runtime = model.runtime
|
|
locator = runtime.locator_server
|
|
runtime.settling_ms = _read_int(run_payload, "settling_ms", runtime.settling_ms)
|
|
runtime.idle_sleep_ms = _read_int(run_payload, "idle_sleep_ms", runtime.idle_sleep_ms)
|
|
runtime.continuous = _read_bool(run_payload, "continuous", runtime.continuous)
|
|
runtime.processing_live_config_path = _read_str(
|
|
run_payload, "processing_live_config_path", runtime.processing_live_config_path
|
|
)
|
|
locator.device_id = _read_int(locator_server_payload, "device_id", locator.device_id)
|
|
locator.protocol_version = _read_int(locator_server_payload, "protocol_version", locator.protocol_version)
|
|
locator.host = _read_str(locator_server_payload, "host", locator.host)
|
|
locator.port = _read_int(locator_server_payload, "port", locator.port)
|
|
locator.max_payload_bytes = _read_int(locator_server_payload, "max_payload_bytes", locator.max_payload_bytes)
|
|
locator.client_queue_size = _read_int(locator_server_payload, "client_queue_size", locator.client_queue_size)
|
|
locator.logger_name = _read_str(locator_server_payload, "logger_name", locator.logger_name)
|
|
|
|
s21_preprocess_payload = _as_dict(preprocess_payload.get("s21"), "preprocess.s21")
|
|
_load_preprocess_asset(
|
|
_as_dict(s21_preprocess_payload.get("calibration"), "preprocess.s21.calibration"),
|
|
model.preprocess.s21.calibration,
|
|
)
|
|
_load_preprocess_asset(
|
|
_as_dict(s21_preprocess_payload.get("reference"), "preprocess.s21.reference"),
|
|
model.preprocess.s21.reference,
|
|
)
|
|
_load_preprocess_asset(
|
|
_as_dict(s21_preprocess_payload.get("object_calibration"), "preprocess.s21.object_calibration"),
|
|
model.preprocess.s21.object_calibration,
|
|
)
|
|
|
|
notch_payload = _as_dict(preprocess_payload.get("notch"), "preprocess.notch")
|
|
model.preprocess.notch = PreprocessNotchModel(
|
|
enabled=_read_bool(notch_payload, "enabled", model.preprocess.notch.enabled),
|
|
taper_width_hz=_read_float(notch_payload, "taper_width_hz", model.preprocess.notch.taper_width_hz),
|
|
taper_type=_read_str(notch_payload, "taper_type", model.preprocess.notch.taper_type),
|
|
bands_hz=[],
|
|
)
|
|
bands_payload = _as_list(notch_payload.get("bands_hz"), "preprocess.notch.bands_hz")
|
|
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")
|
|
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.tx_geometry = []
|
|
for entry in _as_list(gpr_payload.get("tx_geometry"), "gpr.tx_geometry"):
|
|
entry_payload = _as_dict(entry, "gpr.tx_geometry[]")
|
|
model.gpr.tx_geometry.append(
|
|
GprTxGeometryModel(
|
|
output_pos=_read_int(entry_payload, "output_pos", 0),
|
|
x_m=_read_float(entry_payload, "x_m", 0.0),
|
|
y_m=_read_float(entry_payload, "y_m", 0.0),
|
|
z_m=_read_float(entry_payload, "z_m", 0.0),
|
|
)
|
|
)
|
|
model.gpr.rx_geometry = []
|
|
for entry in _as_list(gpr_payload.get("rx_geometry"), "gpr.rx_geometry"):
|
|
entry_payload = _as_dict(entry, "gpr.rx_geometry[]")
|
|
model.gpr.rx_geometry.append(
|
|
GprRxGeometryModel(
|
|
input_pos=_read_int(entry_payload, "input_pos", 0),
|
|
x_m=_read_float(entry_payload, "x_m", 0.0),
|
|
y_m=_read_float(entry_payload, "y_m", 0.0),
|
|
z_m=_read_float(entry_payload, "z_m", 0.0),
|
|
)
|
|
)
|
|
model.apply_device_model_constraints()
|
|
# Pass sweep= so the sweep bounds (points > 0, stop_hz > start_hz) are validated
|
|
# on the config-load path instead of crashing the C++ acquisition process at boot.
|
|
validate_gpr_model(
|
|
model.gpr,
|
|
input_switch_positions=model.input_switch.positions,
|
|
output_switch_positions=model.output_switch.positions,
|
|
sweep=model.radar.sweep,
|
|
)
|
|
|
|
load_ring_payload(raw_ring_payload, model.rings.raw)
|
|
load_ring_payload(raw_tap_ring_payload, model.rings.raw_tap)
|
|
load_ring_payload(pre_ring_payload, model.rings.preprocessed)
|
|
load_ring_payload(pre_tap_ring_payload, model.rings.preprocessed_tap)
|
|
load_ring_payload(result_ring_payload, model.rings.results)
|
|
|
|
model.combos = []
|
|
for combo in _as_list(run_payload.get("combos"), "run.combos"):
|
|
combo_payload = _as_dict(combo, "run.combos[]")
|
|
model.combos.append(
|
|
ComboModel(
|
|
input=_read_int(combo_payload, "input", 0),
|
|
output=_read_int(combo_payload, "output", 0),
|
|
)
|
|
)
|
|
|
|
model.ensure_combos()
|
|
validate_combos(
|
|
model.combos,
|
|
input_positions=model.input_switch.positions,
|
|
output_positions=model.output_switch.positions,
|
|
)
|
|
logger.debug(
|
|
"Decoded run config: radar.model=%s driver_mode=%s combos=%d",
|
|
model.radar.model,
|
|
model.radar.driver_mode,
|
|
len(model.combos),
|
|
)
|
|
return model
|
|
|
|
|
|
def run_config_to_dict(model: RunConfigModel) -> dict[str, Any]:
|
|
"""Encode the model, spelling only fields whose wire names differ."""
|
|
model.ensure_combos()
|
|
radar = asdict(model.radar)
|
|
sweep = radar["sweep"]
|
|
sweep["stimulus_power_dbm"] = sweep.pop("power_dbm")
|
|
if model.is_rph_radar:
|
|
sweep.pop("points")
|
|
preprocess = asdict(model.preprocess)
|
|
preprocess["notch"]["bands_hz"] = [list(band) for band in model.preprocess.notch.bands_hz]
|
|
return {
|
|
"radar": radar,
|
|
"switches": {"port1": asdict(model.output_switch), "port2": asdict(model.input_switch)},
|
|
"control_button": asdict(model.control_button),
|
|
"logging": asdict(model.logging),
|
|
"run": {**asdict(model.runtime), "combos": [asdict(combo) for combo in model.combos]},
|
|
"preprocess": preprocess,
|
|
"gpr": asdict(model.gpr),
|
|
"rings": asdict(model.rings),
|
|
}
|