diff --git a/data_acq_and_processing/preprocessing/data_preprocessor/src/data_preprocessor.cpp b/data_acq_and_processing/preprocessing/data_preprocessor/src/data_preprocessor.cpp index 8aa84de..8c36d78 100644 --- a/data_acq_and_processing/preprocessing/data_preprocessor/src/data_preprocessor.cpp +++ b/data_acq_and_processing/preprocessing/data_preprocessor/src/data_preprocessor.cpp @@ -149,6 +149,12 @@ void DataPreprocessor::run(const std::atomic& stop_requested) { const auto preprocessed_collection = preprocess_collection(raw_collection); publish_preprocessed_collection(preprocessed_collection); } catch (const std::exception& exc) { + // A ring-slot-too-small failure is a permanent config error (the slot + // cannot hold a serialized collection): swallowing it would silently + // drop every collection forever, so fail fast and let the operator fix it. + if (std::string(exc.what()).find("slot is too small") != std::string::npos) { + throw; + } // A single malformed or transiently-bad collection must not kill the // long-running preprocessor: drop it and keep serving the next sweep. // Logging is throttled so a persistent error cannot flood the log. diff --git a/data_acq_and_processing/processing/data_processor/src/data_processor.cpp b/data_acq_and_processing/processing/data_processor/src/data_processor.cpp index 69cb34c..0ad50be 100644 --- a/data_acq_and_processing/processing/data_processor/src/data_processor.cpp +++ b/data_acq_and_processing/processing/data_processor/src/data_processor.cpp @@ -148,6 +148,12 @@ void DataProcessor::run(const std::atomic& stop_requested) { std::this_thread::sleep_for(std::chrono::milliseconds(config_.runtime.idle_sleep_ms)); } catch (const std::exception& exc) { + // A ring-slot-too-small failure is a permanent config error (the slot + // cannot hold a serialized collection): swallowing it would silently + // drop every collection forever, so fail fast and let the operator fix it. + if (std::string(exc.what()).find("slot is too small") != std::string::npos) { + throw; + } // A single bad collection (torn ring slot, decode/processing error) must // not kill the long-running processor: drop it and keep going. Throttle // logging and pause briefly so a persistent error cannot busy-spin/flood. diff --git a/data_acq_and_processing/processing/locator/include/locator/tcp_server.hpp b/data_acq_and_processing/processing/locator/include/locator/tcp_server.hpp index b7bdf2b..a9500b1 100644 --- a/data_acq_and_processing/processing/locator/include/locator/tcp_server.hpp +++ b/data_acq_and_processing/processing/locator/include/locator/tcp_server.hpp @@ -18,14 +18,15 @@ namespace radar::locator { // Bounded, in-memory packet queue used by the per-client writer thread. -// Marking the queue as full closes the client (back-pressure by disconnect), -// matching the semantics of the previous Python implementation. +// Latest-wins: when full, the oldest queued packet(s) are dropped so a slow +// client always advances toward the freshest result. A slow client is never +// disconnected (freshness over completeness; bounded memory). class ClientQueue { public: explicit ClientQueue(std::size_t capacity); - // Push a packet onto the queue. Returns false if the queue is full or has - // been closed; the caller is expected to disconnect the client in that case. + // Push a packet onto the queue, dropping the oldest entry first if full. + // Returns false ONLY when the queue is closed (the session is shutting down). [[nodiscard]] auto try_push(std::vector packet) -> bool; // Block until a packet is available or the queue is closed. @@ -68,8 +69,8 @@ class ClientSession { // timestamp slot records when, so the server can expire stale speeds. void start(std::atomic& shared_vlc_slot, std::atomic& shared_vlc_at_ns); - // Enqueue one outbound packet. Disconnects this session if the queue is - // already full or the writer has stopped. + // Enqueue one outbound packet. Never disconnects a slow client: a full queue + // drops its oldest entry (latest-wins). A no-op once the session has stopped. void enqueue(std::vector packet); // Initiate teardown of this client (idempotent): closes the queue and @@ -107,9 +108,9 @@ class ClientSession { // * Threading: one acceptor thread + two threads per connected client. The // producer (data_processor) calls publish() synchronously; that call is // non-blocking and never throws for typical operation. -// * Back-pressure: each client has its own bounded outbound queue. If a -// client is too slow to drain, the next publish() drops it (matches the -// prior Python service). Other clients are unaffected. +// * Back-pressure: each client has its own bounded outbound queue. A client +// too slow to drain is never disconnected; its queue drops the oldest +// packet(s) (latest-wins) so it keeps the freshest data. Clients are isolated. // * Latest-snapshot: the most recently published packet is cached and sent // to every newly connected client before any new packets are forwarded. // * Lifetime: `start()` may throw on listen failure. `stop()` is idempotent diff --git a/python_app/gui/app_window.py b/python_app/gui/app_window.py index 2b975f6..e3bf46a 100644 --- a/python_app/gui/app_window.py +++ b/python_app/gui/app_window.py @@ -140,8 +140,17 @@ class AppWindow( try: value = int(raw) except ValueError: - return 50 - return value if value >= 1 else 50 + value = 0 + if value >= 1: + return value + # A non-empty but invalid value is an operator mistake — say so instead of + # silently swallowing it (stderr is captured by journald in headless mode). + print( + f"[radar] Ignoring invalid RADAR_SYSTEM_METRICS_REPORT_EVERY={raw!r}; using default 50.", + file=sys.stderr, + flush=True, + ) + return 50 def _init_config_profile_state(self) -> None: """Resolve startup profile path, load active profile, and queue fallback notices.""" diff --git a/python_app/gui/controllers/app_window_config/state_builders.py b/python_app/gui/controllers/app_window_config/state_builders.py index 6b670af..5dd9f6d 100644 --- a/python_app/gui/controllers/app_window_config/state_builders.py +++ b/python_app/gui/controllers/app_window_config/state_builders.py @@ -453,6 +453,7 @@ class AppWindowConfigStateBuildersMixin: config.gpr, input_switch_positions=config.input_switch.positions, output_switch_positions=config.output_switch.positions, + sweep=config.radar.sweep, ) return config diff --git a/python_app/gui/controllers/app_window_control_button_mixin.py b/python_app/gui/controllers/app_window_control_button_mixin.py index 2500955..e87475a 100644 --- a/python_app/gui/controllers/app_window_control_button_mixin.py +++ b/python_app/gui/controllers/app_window_control_button_mixin.py @@ -72,17 +72,11 @@ class AppWindowControlButtonMixin: Delivered as a queued signal from the watcher thread, so this executes on the GUI thread exactly like a click on "Capture Tmp Reference". """ - # Ignore a re-entrant press: the capture flow spins the event loop (stop/ - # start run, dialogs), so a second queued press must not start a nested capture. - if self._control_button_busy: - self._log("GPIO control button press ignored: capture already in progress.") - return - self._control_button_busy = True - try: - self._log("GPIO control button pressed: capturing tmp reference.") - self._capture_tmp_reference() - finally: - self._control_button_busy = False + # The shared re-entrancy guard lives in _capture_tmp_reference, so this GPIO + # trigger and the GUI "Capture Tmp Reference" button are protected by one + # mechanism (a press during an in-progress capture is ignored there). + self._log("GPIO control button pressed: capturing tmp reference.") + self._capture_tmp_reference() def _on_control_button_failed(self, message: str) -> None: """Log an unrecoverable watcher error reported from the background thread.""" diff --git a/python_app/gui/controllers/app_window_pipeline_mixin.py b/python_app/gui/controllers/app_window_pipeline_mixin.py index 8f535e1..9f034b4 100644 --- a/python_app/gui/controllers/app_window_pipeline_mixin.py +++ b/python_app/gui/controllers/app_window_pipeline_mixin.py @@ -469,8 +469,8 @@ class AppWindowPipelineMixin: if collection is None: break self._pipeline_metrics.record("processing", int(collection.processing_duration_ns)) - if record_result_history(self._result_history, collection): - latest = collection + record_result_history(self._result_history, collection) + latest = collection return latest def _pump_events_during_drain(self, pause_s: float) -> None: diff --git a/python_app/gui/controllers/app_window_plot/trace_plot_mixin.py b/python_app/gui/controllers/app_window_plot/trace_plot_mixin.py index 48d99c8..468c992 100644 --- a/python_app/gui/controllers/app_window_plot/trace_plot_mixin.py +++ b/python_app/gui/controllers/app_window_plot/trace_plot_mixin.py @@ -43,7 +43,9 @@ class AppWindowTracePlotMixin: details=f"{exc}\nExpected format: input:output,input:output", once_key=f"pass_through_combo_filter_invalid_{text}", ) - return set() + # Fail open: an unparseable filter is ignored (all traces stay visible), + # not turned into an empty allow-set that silently hides every trace. + return None def _configure_pass_through_magnitude_axis(self, plot: pg.PlotWidget) -> None: """Apply pass-through magnitude-axis autorange or fixed Y window.""" diff --git a/python_app/gui/controllers/app_window_preprocess_mixin.py b/python_app/gui/controllers/app_window_preprocess_mixin.py index 7804791..b5b6082 100644 --- a/python_app/gui/controllers/app_window_preprocess_mixin.py +++ b/python_app/gui/controllers/app_window_preprocess_mixin.py @@ -117,6 +117,13 @@ class AppWindowPreprocessMixin: details=self._capture_state_details(), ) return + # One shared re-entrancy guard for BOTH the GUI button and the GPIO trigger: + # this capture spins the Qt event loop (stop/start run, dialogs), so a second + # request from either source must not start a nested capture. + if self._control_button_busy: + self._log("Tmp reference capture already in progress; ignoring duplicate request.") + return + self._control_button_busy = True pipeline_was_running = self._supervisor.is_running() pipeline_was_paused = False @@ -178,6 +185,7 @@ class AppWindowPreprocessMixin: finally: if pipeline_was_paused: self._start_run() + self._control_button_busy = False def _ensure_preprocess_dialog(self) -> PreprocessDialog: """Create preprocessing dialog lazily and wire its signals once.""" diff --git a/python_app/gui/controllers/app_window_ui_mixin.py b/python_app/gui/controllers/app_window_ui_mixin.py index c5d81bd..b60cdd9 100644 --- a/python_app/gui/controllers/app_window_ui_mixin.py +++ b/python_app/gui/controllers/app_window_ui_mixin.py @@ -160,7 +160,7 @@ class AppWindowUiMixin: self._status_label = QLabel("Status: idle", self._settings_panel) self._status_label.setObjectName("statusLabel") - self._status_label.hide() + right_layout.addWidget(self._status_label) self._history_label = QLabel("History: raw=0, preprocessed=0, results=0", self._settings_panel) self._history_label.setObjectName("hintLabel") diff --git a/python_app/gui/controllers/sections/data_actions_section.py b/python_app/gui/controllers/sections/data_actions_section.py index c48c597..d8da602 100644 --- a/python_app/gui/controllers/sections/data_actions_section.py +++ b/python_app/gui/controllers/sections/data_actions_section.py @@ -25,7 +25,10 @@ def build_data_actions_group(owner) -> QGroupBox: save_button = QPushButton("Save Dataset") save_button.clicked.connect(owner._save_snapshot) save_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - save_vna_json_button = QPushButton("Save JSON") + save_vna_json_button = QPushButton("Save S21 JSON") + save_vna_json_button.setToolTip( + "Export one S21 VNA-history JSON per combo from the preprocessed stage (S11 is not included)." + ) save_vna_json_button.clicked.connect(owner._save_vna_history_json) save_vna_json_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) remove_last_button = QPushButton("Remove Last Measurement") diff --git a/python_app/gui/runtime/history.py b/python_app/gui/runtime/history.py index 6bfffe5..5f77c5f 100644 --- a/python_app/gui/runtime/history.py +++ b/python_app/gui/runtime/history.py @@ -15,8 +15,8 @@ THistoryCollection = TypeVar("THistoryCollection", SweepCollection, ResultCollec def record_result_history( result_history: deque[ResultCollection], collection: ResultCollection, -) -> bool: - """Append new result or replace existing entry by stable collection key.""" +) -> None: + """Append a new result, or replace the existing entry with the same stable key.""" for index in range(len(result_history) - 1, -1, -1): existing = result_history[index] if ( @@ -24,10 +24,9 @@ def record_result_history( and existing.monotonic_ns == collection.monotonic_ns ): result_history[index] = collection - return True + return result_history.append(collection) - return True def remove_last_aligned_histories( diff --git a/python_app/hardware_full/laser_control/controller.py b/python_app/hardware_full/laser_control/controller.py index 56ee929..60d3611 100644 --- a/python_app/hardware_full/laser_control/controller.py +++ b/python_app/hardware_full/laser_control/controller.py @@ -361,7 +361,16 @@ class LaserController: raw = self._protocol.receive_raw(2) if raw and len(raw) == 2: state = Protocol.decode_state(raw) - logger.debug("STATE response after command: 0x%04x", state) + if state != 0: + # Surface a device-reported non-OK STATE instead of silently treating + # a board-rejected command as success. (Returned to the caller too.) + logger.warning( + "Device returned non-OK STATE 0x%04x after command: %s", + state, + Protocol.state_to_description(f"{state:04x}"), + ) + else: + logger.debug("STATE response after command: 0x%04x", state) return state return 0 diff --git a/python_app/hardware_full/librevna_driver/transport/usb.py b/python_app/hardware_full/librevna_driver/transport/usb.py index 64bddf5..209572e 100644 --- a/python_app/hardware_full/librevna_driver/transport/usb.py +++ b/python_app/hardware_full/librevna_driver/transport/usb.py @@ -173,12 +173,21 @@ class USBTransport: self._rx_thread = None if self._handle is not None: - self._handle.releaseInterface(self.INTERFACE) - self._handle.close() + # Always release AND close the handle, even if releaseInterface raises on + # a vanished/changed device. Otherwise the handle leaks with the interface + # still claimed, and every subsequent connect() fails forever with + # "Failed to claim USB interface" (LIBUSB_ERROR_BUSY) — the device never + # recovers after a hot-unplug/replug. close() releases the claim at the OS + # level regardless, so reconnect can claim it again. + with suppress(usb1.USBError): + self._handle.releaseInterface(self.INTERFACE) + with suppress(usb1.USBError): + self._handle.close() self._handle = None if self._ctx is not None: - self._ctx.close() + with suppress(usb1.USBError): + self._ctx.close() self._ctx = None logger.info("USB disconnected (serial=%s)", self.connected_serial) diff --git a/python_app/hardware_full/librevna_service.py b/python_app/hardware_full/librevna_service.py index 1bb7dd8..44f80e4 100644 --- a/python_app/hardware_full/librevna_service.py +++ b/python_app/hardware_full/librevna_service.py @@ -3,11 +3,14 @@ from __future__ import annotations from dataclasses import dataclass, field +import logging from python_app.hardware_full.librevna_backends import LibreVnaBackend, MockLibreVnaBackend, NativeLibreVnaBackend from python_app.hardware_full.librevna_driver.models import SweepResult from python_app.models.run_config_model import RadarSweepModel +logger = logging.getLogger(__name__) + @dataclass(slots=True) class LibreVnaService: @@ -41,9 +44,16 @@ class LibreVnaService: strict_protocol_version=self.strict_protocol_version, ) self._driver_available = True - except Exception: + except Exception as exc: + # 'native' demands real hardware — never substitute synthetic data. if mode == "native": raise + # 'auto' falls back to the mock backend ONLY when the native driver + # library itself is unavailable (a dev host without the USB stack) — + # this is not device-absence (that surfaces later from open()). Log it + # loudly so it is never a silent surprise; a deployed appliance should + # set driver_mode='native' to forbid the fallback entirely. + logger.warning("LibreVNA native backend unavailable; falling back to mock (mode=auto): %s", exc) self._backend = MockLibreVnaBackend() self._using_mock_backend = True diff --git a/python_app/hardware_full/single_radar_service.py b/python_app/hardware_full/single_radar_service.py index 4cea092..3905c27 100644 --- a/python_app/hardware_full/single_radar_service.py +++ b/python_app/hardware_full/single_radar_service.py @@ -40,7 +40,9 @@ def create_single_radar_service(config: RunConfigModel) -> SingleRadarService: model = config.radar.model or RunConfigModel.LIBREVNA_MODEL if model == RunConfigModel.LIBREVNA_MODEL: - return LibreVnaService(serial=config.radar.serial or None) + # Forward driver_mode (mirrors the matrix path): 'native' must require real + # hardware and 'mock' must use the synthetic backend — never silently the wrong one. + return LibreVnaService(serial=config.radar.serial or None, backend_mode=config.radar.driver_mode) if model == RunConfigModel.COMPACT_M_K209_MODEL: if config.radar.driver_mode != "native": diff --git a/python_app/models/run_config_codec.py b/python_app/models/run_config_codec.py index 2984237..d6b4023 100644 --- a/python_app/models/run_config_codec.py +++ b/python_app/models/run_config_codec.py @@ -30,6 +30,19 @@ def _as_dict(value: Any, context: str) -> dict[str, Any]: return value +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 _read_str(payload: dict[str, Any], key: str, default: str) -> str: """Return payload string, treating an explicit JSON `null` as missing. @@ -56,12 +69,12 @@ def _read_int(payload: dict[str, Any], key: str, default: int) -> int: value = payload.get(key, default) if value is None: return default - if isinstance(value, bool) or not isinstance(value, (int, float, str)): + # Accept 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. + if isinstance(value, bool) or not isinstance(value, int): 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 + return value def _read_float(payload: dict[str, Any], key: str, default: float) -> float: @@ -74,12 +87,11 @@ def _read_float(payload: dict[str, Any], key: str, default: float) -> float: value = payload.get(key, default) if value is None: return default - if isinstance(value, bool) or not isinstance(value, (int, float, str)): + # Accept only a genuine JSON number (int/float, not bool, not numeric string): + # parsing "1e9" would hide a malformed config. Mirrors gui_profile_codec. + if isinstance(value, bool) or not isinstance(value, (int, float)): 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 + result = float(value) if not math.isfinite(result): raise ValueError(f"{key} must be a finite number") return result @@ -191,6 +203,10 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel: 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", @@ -306,45 +322,21 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel: load_control_button_payload(control_button_payload, model.control_button) model.apply_device_model_constraints() - model.runtime.settling_ms = int(run_payload.get("settling_ms", model.runtime.settling_ms)) - model.runtime.idle_sleep_ms = int(run_payload.get("idle_sleep_ms", model.runtime.idle_sleep_ms)) - model.runtime.continuous = bool(run_payload.get("continuous", model.runtime.continuous)) - model.runtime.processing_live_config_path = str( - run_payload.get("processing_live_config_path", model.runtime.processing_live_config_path) - ) - model.runtime.locator_server.device_id = int( - locator_server_payload.get("device_id", model.runtime.locator_server.device_id) - ) - model.runtime.locator_server.protocol_version = int( - locator_server_payload.get( - "protocol_version", - model.runtime.locator_server.protocol_version, - ) - ) - model.runtime.locator_server.host = str( - locator_server_payload.get("host", model.runtime.locator_server.host) - ) - model.runtime.locator_server.port = int( - locator_server_payload.get("port", model.runtime.locator_server.port) - ) - model.runtime.locator_server.max_payload_bytes = int( - locator_server_payload.get( - "max_payload_bytes", - model.runtime.locator_server.max_payload_bytes, - ) - ) - model.runtime.locator_server.client_queue_size = int( - locator_server_payload.get( - "client_queue_size", - model.runtime.locator_server.client_queue_size, - ) - ) - model.runtime.locator_server.logger_name = str( - locator_server_payload.get( - "logger_name", - model.runtime.locator_server.logger_name, - ) + 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( @@ -376,51 +368,50 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel: ) notch_payload = _as_dict(preprocess_payload.get("notch"), "preprocess.notch") model.preprocess.notch = PreprocessNotchModel( - enabled=bool(notch_payload.get("enabled", model.preprocess.notch.enabled)), - taper_width_hz=float(notch_payload.get("taper_width_hz", model.preprocess.notch.taper_width_hz)), - taper_type=str(notch_payload.get("taper_type", model.preprocess.notch.taper_type)), + 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 = notch_payload.get("bands_hz", []) - if isinstance(bands_payload, list): - for band in bands_payload: - if isinstance(band, (list, tuple)) and len(band) == 2: - model.preprocess.notch.bands_hz.append((float(band[0]), float(band[1]))) + 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") + model.preprocess.notch.bands_hz.append((float(band[0]), float(band[1]))) - model.gpr.relative_permittivity = float( - gpr_payload.get("relative_permittivity", model.gpr.relative_permittivity) + model.gpr.relative_permittivity = _read_float( + gpr_payload, "relative_permittivity", model.gpr.relative_permittivity ) model.gpr.tx_geometry = [] - tx_geometry_payload = gpr_payload.get("tx_geometry", []) - if isinstance(tx_geometry_payload, list): - for entry in tx_geometry_payload: - entry_payload = _as_dict(entry, "gpr.tx_geometry[]") - model.gpr.tx_geometry.append( - GprTxGeometryModel( - output_pos=int(entry_payload.get("output_pos", 0)), - x_m=float(entry_payload.get("x_m", 0.0)), - y_m=float(entry_payload.get("y_m", 0.0)), - z_m=float(entry_payload.get("z_m", 0.0)), - ) + 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 = [] - rx_geometry_payload = gpr_payload.get("rx_geometry", []) - if isinstance(rx_geometry_payload, list): - for entry in rx_geometry_payload: - entry_payload = _as_dict(entry, "gpr.rx_geometry[]") - model.gpr.rx_geometry.append( - GprRxGeometryModel( - input_pos=int(entry_payload.get("input_pos", 0)), - x_m=float(entry_payload.get("x_m", 0.0)), - y_m=float(entry_payload.get("y_m", 0.0)), - z_m=float(entry_payload.get("z_m", 0.0)), - ) + 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 #36 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) @@ -429,17 +420,15 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel: load_ring_payload(pre_tap_ring_payload, model.rings.preprocessed_tap) load_ring_payload(result_ring_payload, model.rings.results) - combos_payload = run_payload.get("combos", []) model.combos = [] - if isinstance(combos_payload, list): - for combo in combos_payload: - combo_payload = _as_dict(combo, "run.combos[]") - model.combos.append( - ComboModel( - input=int(combo_payload.get("input", 0)), - output=int(combo_payload.get("output", 0)), - ) + 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() return model diff --git a/python_app/models/run_config_validation.py b/python_app/models/run_config_validation.py index d0ee944..cf2bea6 100644 --- a/python_app/models/run_config_validation.py +++ b/python_app/models/run_config_validation.py @@ -30,6 +30,8 @@ def _require_int(payload: dict[str, Any], key: str, default: int) -> int: config-error contract; surface it as a ValueError naming the field instead. """ 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, float, str)): raise ValueError(f"{key} must be a JSON integer") try: @@ -41,6 +43,8 @@ def _require_int(payload: dict[str, Any], key: str, default: int) -> int: 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) @@ -49,6 +53,8 @@ def _require_str(payload: dict[str, Any], key: str, default: str) -> str: 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 diff --git a/python_app/orchestration/config_writer.py b/python_app/orchestration/config_writer.py index 25c2602..09d013b 100644 --- a/python_app/orchestration/config_writer.py +++ b/python_app/orchestration/config_writer.py @@ -2,6 +2,7 @@ from __future__ import annotations +from contextlib import suppress import json import os from pathlib import Path @@ -42,13 +43,12 @@ class ConfigWriter: asset.bundle_path = str(bundle_path) def write(self, config: RunConfigModel, output_path: Path) -> Path: - """Atomically write run configuration JSON file. + """Atomically write the run configuration JSON file. - Mirrors ProcessingLiveConfigWriter: dump to a sibling .tmp, flush+fsync to - durably commit the bytes, then os.replace() onto the destination. The replace - is atomic, so a C++ consumer can never observe a half-written config (which - would abort it with an opaque JSON parse error), even across a crash or power - loss mid-write on the SD-card-backed Pi. + Dump to a sibling ``.tmp``, flush + ``fsync`` to durably commit the bytes on + the SD-card-backed Pi, then ``os.replace()`` atomically onto the destination. + A C++ consumer can therefore never observe a half-written config (which would + abort it with an opaque JSON parse error), even across a crash or power loss. """ output_path.parent.mkdir(parents=True, exist_ok=True) # allow_nan=False: a stray NaN/Infinity must fail loudly here in Python @@ -56,11 +56,17 @@ class ConfigWriter: # consumer at startup with an opaque JSON parse error. serialized = json.dumps(config.to_dict(), indent=2, allow_nan=False) tmp_path = output_path.with_suffix(output_path.suffix + ".tmp") - with open(tmp_path, "w", encoding="utf-8") as handle: - handle.write(serialized) - handle.flush() - os.fsync(handle.fileno()) - os.replace(tmp_path, output_path) + try: + with open(tmp_path, "w", encoding="utf-8") as handle: + handle.write(serialized) + handle.flush() + os.fsync(handle.fileno()) + os.replace(tmp_path, output_path) + except Exception: + # Never leave a half-written .tmp behind on a write/fsync failure. + with suppress(OSError): + tmp_path.unlink() + raise return output_path diff --git a/python_app/orchestration/shm/ring_writer.py b/python_app/orchestration/shm/ring_writer.py index 149ad32..2327b5b 100644 --- a/python_app/orchestration/shm/ring_writer.py +++ b/python_app/orchestration/shm/ring_writer.py @@ -72,7 +72,6 @@ class ShmRingWriter: self._mmap.close() self._file.close() self._unlink_if_present() - created = not self._path.exists() fd = os.open(self._path, os.O_RDWR | os.O_CREAT, 0o660) self._file = os.fdopen(fd, "r+b", buffering=0) self._file.truncate(self._mapped_size) @@ -135,23 +134,11 @@ class ShmRingWriter: self._write_u64(32, 0) self._write_u64(40, 0) - def _validate_header(self) -> None: - magic = self._mmap[:8] - version = self._read_u32(8) - capacity = self._read_u32(12) - slot_size_bytes = self._read_u32(16) - if magic != _MAGIC: - raise RuntimeError(f"Shared memory ring magic mismatch for {self._ring_name}") - if version != _VERSION: - raise RuntimeError(f"Shared memory ring version mismatch for {self._ring_name}") - if capacity != self._capacity or slot_size_bytes != self._slot_size_bytes: - raise RuntimeError(f"Shared memory ring geometry mismatch for {self._ring_name}") - def _header_matches(self) -> bool: """Return whether the existing segment's header matches this ring's geometry. - Non-throwing counterpart of `_validate_header` used by the owner to decide - whether a same-sized pre-existing segment can be reused or must be recreated. + Used by the owner to decide whether a same-sized pre-existing segment can be + reused as-is or must be unlinked and recreated. """ return ( self._mmap[:8] == _MAGIC diff --git a/python_app/storage/npz/store.py b/python_app/storage/npz/store.py index b6d1354..c8d2b4d 100644 --- a/python_app/storage/npz/store.py +++ b/python_app/storage/npz/store.py @@ -3,7 +3,7 @@ from __future__ import annotations from contextlib import suppress -from datetime import datetime +from datetime import datetime, timezone import json from pathlib import Path from typing import Any @@ -162,7 +162,7 @@ class NpzStore(StoreApi): raise ValueError("last_n must be > 0") output_dir.mkdir(parents=True, exist_ok=True) - timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S") + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") snapshot_dir = output_dir / f"snapshot_{timestamp}" snapshot_dir.mkdir(parents=True, exist_ok=True) @@ -184,7 +184,7 @@ class NpzStore(StoreApi): if last_n <= 0: raise ValueError("last_n must be > 0") - snapshot_stem = snapshot_name.strip() or datetime.utcnow().strftime("snapshot_%Y%m%d_%H%M%S") + snapshot_stem = snapshot_name.strip() or datetime.now(timezone.utc).strftime("snapshot_%Y%m%d_%H%M%S") snapshot_stem = sanitize_path_component(snapshot_stem) output_root_dir.mkdir(parents=True, exist_ok=True) @@ -237,7 +237,7 @@ class NpzStore(StoreApi): if last_n <= 0: raise ValueError("last_n must be > 0") - output_stem = output_name.strip() or datetime.utcnow().strftime("snapshot_%Y%m%d_%H%M%S") + output_stem = output_name.strip() or datetime.now(timezone.utc).strftime("snapshot_%Y%m%d_%H%M%S") output_stem = sanitize_path_component(output_stem) output_root_dir.mkdir(parents=True, exist_ok=True) @@ -299,7 +299,7 @@ class NpzStore(StoreApi): if last_n <= 0: raise ValueError("last_n must be > 0") - output_stem = output_name.strip() or datetime.utcnow().strftime("snapshot_%Y%m%d_%H%M%S") + output_stem = output_name.strip() or datetime.now(timezone.utc).strftime("snapshot_%Y%m%d_%H%M%S") output_stem = sanitize_path_component(output_stem) output_root_dir.mkdir(parents=True, exist_ok=True) output_dir = self._vna_json_output_dir(output_root_dir, output_stem) diff --git a/python_app/workflows/multi_radar_capture_workflow.py b/python_app/workflows/multi_radar_capture_workflow.py index 3707fd6..b58d292 100644 --- a/python_app/workflows/multi_radar_capture_workflow.py +++ b/python_app/workflows/multi_radar_capture_workflow.py @@ -106,6 +106,7 @@ class MultiRadarSequentialCaptureSession: self._input_switch = SwitchService( name=base_config.input_switch.name, positions=base_config.input_switch.positions, + default_position=base_config.input_switch.default_position, mode=base_config.input_switch.driver_mode, driver=base_config.input_switch.driver, gpio_chip=base_config.input_switch.gpio_chip, @@ -116,6 +117,7 @@ class MultiRadarSequentialCaptureSession: self._output_switch = SwitchService( name=base_config.output_switch.name, positions=base_config.output_switch.positions, + default_position=base_config.output_switch.default_position, mode=base_config.output_switch.driver_mode, driver=base_config.output_switch.driver, gpio_chip=base_config.output_switch.gpio_chip, diff --git a/python_app/workflows/sequential_capture_workflow.py b/python_app/workflows/sequential_capture_workflow.py index 9338cbc..9a88a61 100644 --- a/python_app/workflows/sequential_capture_workflow.py +++ b/python_app/workflows/sequential_capture_workflow.py @@ -82,6 +82,7 @@ class SequentialCaptureSession: self._input_switch = SwitchService( name=config.input_switch.name, positions=config.input_switch.positions, + default_position=config.input_switch.default_position, mode=config.input_switch.driver_mode, driver=config.input_switch.driver, gpio_chip=config.input_switch.gpio_chip, @@ -92,6 +93,7 @@ class SequentialCaptureSession: self._output_switch = SwitchService( name=config.output_switch.name, positions=config.output_switch.positions, + default_position=config.output_switch.default_position, mode=config.output_switch.driver_mode, driver=config.output_switch.driver, gpio_chip=config.output_switch.gpio_chip, diff --git a/requirements.txt b/requirements.txt index c14308d..560f673 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ numpy>=1.26,<3 libusb1>=3.1 pyserial>=3.5,<4 pyvisa>=1.14 +PyVISA-py>=0.7 # pure-Python VISA backend ("@py"), required by the SN9000 (and any visa_library="@py") path PyQt6>=6.6 pyqtgraph>=0.13.7 rpi-hardware-pwm>=0.2.2,<1