some fixes again

This commit is contained in:
Ayzen
2026-06-05 17:50:59 +03:00
parent bbea744459
commit 3c30a12d4a
24 changed files with 209 additions and 157 deletions
@@ -149,6 +149,12 @@ void DataPreprocessor::run(const std::atomic<bool>& stop_requested) {
const auto preprocessed_collection = preprocess_collection(raw_collection); const auto preprocessed_collection = preprocess_collection(raw_collection);
publish_preprocessed_collection(preprocessed_collection); publish_preprocessed_collection(preprocessed_collection);
} catch (const std::exception& exc) { } 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 // A single malformed or transiently-bad collection must not kill the
// long-running preprocessor: drop it and keep serving the next sweep. // long-running preprocessor: drop it and keep serving the next sweep.
// Logging is throttled so a persistent error cannot flood the log. // Logging is throttled so a persistent error cannot flood the log.
@@ -148,6 +148,12 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
std::this_thread::sleep_for(std::chrono::milliseconds(config_.runtime.idle_sleep_ms)); std::this_thread::sleep_for(std::chrono::milliseconds(config_.runtime.idle_sleep_ms));
} catch (const std::exception& exc) { } 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 // A single bad collection (torn ring slot, decode/processing error) must
// not kill the long-running processor: drop it and keep going. Throttle // not kill the long-running processor: drop it and keep going. Throttle
// logging and pause briefly so a persistent error cannot busy-spin/flood. // logging and pause briefly so a persistent error cannot busy-spin/flood.
@@ -18,14 +18,15 @@
namespace radar::locator { namespace radar::locator {
// Bounded, in-memory packet queue used by the per-client writer thread. // Bounded, in-memory packet queue used by the per-client writer thread.
// Marking the queue as full closes the client (back-pressure by disconnect), // Latest-wins: when full, the oldest queued packet(s) are dropped so a slow
// matching the semantics of the previous Python implementation. // client always advances toward the freshest result. A slow client is never
// disconnected (freshness over completeness; bounded memory).
class ClientQueue { class ClientQueue {
public: public:
explicit ClientQueue(std::size_t capacity); explicit ClientQueue(std::size_t capacity);
// Push a packet onto the queue. Returns false if the queue is full or has // Push a packet onto the queue, dropping the oldest entry first if full.
// been closed; the caller is expected to disconnect the client in that case. // Returns false ONLY when the queue is closed (the session is shutting down).
[[nodiscard]] auto try_push(std::vector<std::uint8_t> packet) -> bool; [[nodiscard]] auto try_push(std::vector<std::uint8_t> packet) -> bool;
// Block until a packet is available or the queue is closed. // 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. // timestamp slot records when, so the server can expire stale speeds.
void start(std::atomic<double>& shared_vlc_slot, std::atomic<std::int64_t>& shared_vlc_at_ns); void start(std::atomic<double>& shared_vlc_slot, std::atomic<std::int64_t>& shared_vlc_at_ns);
// Enqueue one outbound packet. Disconnects this session if the queue is // Enqueue one outbound packet. Never disconnects a slow client: a full queue
// already full or the writer has stopped. // drops its oldest entry (latest-wins). A no-op once the session has stopped.
void enqueue(std::vector<std::uint8_t> packet); void enqueue(std::vector<std::uint8_t> packet);
// Initiate teardown of this client (idempotent): closes the queue and // 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 // * Threading: one acceptor thread + two threads per connected client. The
// producer (data_processor) calls publish() synchronously; that call is // producer (data_processor) calls publish() synchronously; that call is
// non-blocking and never throws for typical operation. // non-blocking and never throws for typical operation.
// * Back-pressure: each client has its own bounded outbound queue. If a // * Back-pressure: each client has its own bounded outbound queue. A client
// client is too slow to drain, the next publish() drops it (matches the // too slow to drain is never disconnected; its queue drops the oldest
// prior Python service). Other clients are unaffected. // packet(s) (latest-wins) so it keeps the freshest data. Clients are isolated.
// * Latest-snapshot: the most recently published packet is cached and sent // * Latest-snapshot: the most recently published packet is cached and sent
// to every newly connected client before any new packets are forwarded. // to every newly connected client before any new packets are forwarded.
// * Lifetime: `start()` may throw on listen failure. `stop()` is idempotent // * Lifetime: `start()` may throw on listen failure. `stop()` is idempotent
+11 -2
View File
@@ -140,8 +140,17 @@ class AppWindow(
try: try:
value = int(raw) value = int(raw)
except ValueError: except ValueError:
return 50 value = 0
return value if value >= 1 else 50 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: def _init_config_profile_state(self) -> None:
"""Resolve startup profile path, load active profile, and queue fallback notices.""" """Resolve startup profile path, load active profile, and queue fallback notices."""
@@ -453,6 +453,7 @@ class AppWindowConfigStateBuildersMixin:
config.gpr, config.gpr,
input_switch_positions=config.input_switch.positions, input_switch_positions=config.input_switch.positions,
output_switch_positions=config.output_switch.positions, output_switch_positions=config.output_switch.positions,
sweep=config.radar.sweep,
) )
return config return config
@@ -72,17 +72,11 @@ class AppWindowControlButtonMixin:
Delivered as a queued signal from the watcher thread, so this executes Delivered as a queued signal from the watcher thread, so this executes
on the GUI thread exactly like a click on "Capture Tmp Reference". 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/ # The shared re-entrancy guard lives in _capture_tmp_reference, so this GPIO
# start run, dialogs), so a second queued press must not start a nested capture. # trigger and the GUI "Capture Tmp Reference" button are protected by one
if self._control_button_busy: # mechanism (a press during an in-progress capture is ignored there).
self._log("GPIO control button press ignored: capture already in progress.") self._log("GPIO control button pressed: capturing tmp reference.")
return self._capture_tmp_reference()
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
def _on_control_button_failed(self, message: str) -> None: def _on_control_button_failed(self, message: str) -> None:
"""Log an unrecoverable watcher error reported from the background thread.""" """Log an unrecoverable watcher error reported from the background thread."""
@@ -469,8 +469,8 @@ class AppWindowPipelineMixin:
if collection is None: if collection is None:
break break
self._pipeline_metrics.record("processing", int(collection.processing_duration_ns)) self._pipeline_metrics.record("processing", int(collection.processing_duration_ns))
if record_result_history(self._result_history, collection): record_result_history(self._result_history, collection)
latest = collection latest = collection
return latest return latest
def _pump_events_during_drain(self, pause_s: float) -> None: def _pump_events_during_drain(self, pause_s: float) -> None:
@@ -43,7 +43,9 @@ class AppWindowTracePlotMixin:
details=f"{exc}\nExpected format: input:output,input:output", details=f"{exc}\nExpected format: input:output,input:output",
once_key=f"pass_through_combo_filter_invalid_{text}", 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: def _configure_pass_through_magnitude_axis(self, plot: pg.PlotWidget) -> None:
"""Apply pass-through magnitude-axis autorange or fixed Y window.""" """Apply pass-through magnitude-axis autorange or fixed Y window."""
@@ -117,6 +117,13 @@ class AppWindowPreprocessMixin:
details=self._capture_state_details(), details=self._capture_state_details(),
) )
return 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_running = self._supervisor.is_running()
pipeline_was_paused = False pipeline_was_paused = False
@@ -178,6 +185,7 @@ class AppWindowPreprocessMixin:
finally: finally:
if pipeline_was_paused: if pipeline_was_paused:
self._start_run() self._start_run()
self._control_button_busy = False
def _ensure_preprocess_dialog(self) -> PreprocessDialog: def _ensure_preprocess_dialog(self) -> PreprocessDialog:
"""Create preprocessing dialog lazily and wire its signals once.""" """Create preprocessing dialog lazily and wire its signals once."""
@@ -160,7 +160,7 @@ class AppWindowUiMixin:
self._status_label = QLabel("Status: idle", self._settings_panel) self._status_label = QLabel("Status: idle", self._settings_panel)
self._status_label.setObjectName("statusLabel") 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 = QLabel("History: raw=0, preprocessed=0, results=0", self._settings_panel)
self._history_label.setObjectName("hintLabel") self._history_label.setObjectName("hintLabel")
@@ -25,7 +25,10 @@ def build_data_actions_group(owner) -> QGroupBox:
save_button = QPushButton("Save Dataset") save_button = QPushButton("Save Dataset")
save_button.clicked.connect(owner._save_snapshot) save_button.clicked.connect(owner._save_snapshot)
save_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) 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.clicked.connect(owner._save_vna_history_json)
save_vna_json_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) save_vna_json_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
remove_last_button = QPushButton("Remove Last Measurement") remove_last_button = QPushButton("Remove Last Measurement")
+3 -4
View File
@@ -15,8 +15,8 @@ THistoryCollection = TypeVar("THistoryCollection", SweepCollection, ResultCollec
def record_result_history( def record_result_history(
result_history: deque[ResultCollection], result_history: deque[ResultCollection],
collection: ResultCollection, collection: ResultCollection,
) -> bool: ) -> None:
"""Append new result or replace existing entry by stable collection key.""" """Append a new result, or replace the existing entry with the same stable key."""
for index in range(len(result_history) - 1, -1, -1): for index in range(len(result_history) - 1, -1, -1):
existing = result_history[index] existing = result_history[index]
if ( if (
@@ -24,10 +24,9 @@ def record_result_history(
and existing.monotonic_ns == collection.monotonic_ns and existing.monotonic_ns == collection.monotonic_ns
): ):
result_history[index] = collection result_history[index] = collection
return True return
result_history.append(collection) result_history.append(collection)
return True
def remove_last_aligned_histories( def remove_last_aligned_histories(
@@ -361,7 +361,16 @@ class LaserController:
raw = self._protocol.receive_raw(2) raw = self._protocol.receive_raw(2)
if raw and len(raw) == 2: if raw and len(raw) == 2:
state = Protocol.decode_state(raw) 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 state
return 0 return 0
@@ -173,12 +173,21 @@ class USBTransport:
self._rx_thread = None self._rx_thread = None
if self._handle is not None: if self._handle is not None:
self._handle.releaseInterface(self.INTERFACE) # Always release AND close the handle, even if releaseInterface raises on
self._handle.close() # 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 self._handle = None
if self._ctx is not None: if self._ctx is not None:
self._ctx.close() with suppress(usb1.USBError):
self._ctx.close()
self._ctx = None self._ctx = None
logger.info("USB disconnected (serial=%s)", self.connected_serial) logger.info("USB disconnected (serial=%s)", self.connected_serial)
+11 -1
View File
@@ -3,11 +3,14 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
import logging
from python_app.hardware_full.librevna_backends import LibreVnaBackend, MockLibreVnaBackend, NativeLibreVnaBackend from python_app.hardware_full.librevna_backends import LibreVnaBackend, MockLibreVnaBackend, NativeLibreVnaBackend
from python_app.hardware_full.librevna_driver.models import SweepResult from python_app.hardware_full.librevna_driver.models import SweepResult
from python_app.models.run_config_model import RadarSweepModel from python_app.models.run_config_model import RadarSweepModel
logger = logging.getLogger(__name__)
@dataclass(slots=True) @dataclass(slots=True)
class LibreVnaService: class LibreVnaService:
@@ -41,9 +44,16 @@ class LibreVnaService:
strict_protocol_version=self.strict_protocol_version, strict_protocol_version=self.strict_protocol_version,
) )
self._driver_available = True self._driver_available = True
except Exception: except Exception as exc:
# 'native' demands real hardware — never substitute synthetic data.
if mode == "native": if mode == "native":
raise 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._backend = MockLibreVnaBackend()
self._using_mock_backend = True self._using_mock_backend = True
@@ -40,7 +40,9 @@ def create_single_radar_service(config: RunConfigModel) -> SingleRadarService:
model = config.radar.model or RunConfigModel.LIBREVNA_MODEL model = config.radar.model or RunConfigModel.LIBREVNA_MODEL
if model == 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 model == RunConfigModel.COMPACT_M_K209_MODEL:
if config.radar.driver_mode != "native": if config.radar.driver_mode != "native":
+78 -89
View File
@@ -30,6 +30,19 @@ def _as_dict(value: Any, context: str) -> dict[str, Any]:
return value 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: def _read_str(payload: dict[str, Any], key: str, default: str) -> str:
"""Return payload string, treating an explicit JSON `null` as missing. """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) value = payload.get(key, default)
if value is None: if value is None:
return default 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") raise ValueError(f"{key} must be a JSON integer")
try: return value
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: 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) value = payload.get(key, default)
if value is None: if value is None:
return default 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") raise ValueError(f"{key} must be a JSON number")
try: result = float(value)
result = float(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"{key} must be a JSON number") from exc
if not math.isfinite(result): if not math.isfinite(result):
raise ValueError(f"{key} must be a finite number") raise ValueError(f"{key} must be a finite number")
return result return result
@@ -191,6 +203,10 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
for value in slave_serials_payload.split(",") for value in slave_serials_payload.split(",")
if value.strip() 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( model.radar.multi_device.force_external_reference = _read_bool(
multi_device_payload, multi_device_payload,
"force_external_reference", "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) load_control_button_payload(control_button_payload, model.control_button)
model.apply_device_model_constraints() model.apply_device_model_constraints()
model.runtime.settling_ms = int(run_payload.get("settling_ms", model.runtime.settling_ms)) runtime = model.runtime
model.runtime.idle_sleep_ms = int(run_payload.get("idle_sleep_ms", model.runtime.idle_sleep_ms)) locator = runtime.locator_server
model.runtime.continuous = bool(run_payload.get("continuous", model.runtime.continuous)) runtime.settling_ms = _read_int(run_payload, "settling_ms", runtime.settling_ms)
model.runtime.processing_live_config_path = str( runtime.idle_sleep_ms = _read_int(run_payload, "idle_sleep_ms", runtime.idle_sleep_ms)
run_payload.get("processing_live_config_path", model.runtime.processing_live_config_path) runtime.continuous = _read_bool(run_payload, "continuous", runtime.continuous)
) runtime.processing_live_config_path = _read_str(
model.runtime.locator_server.device_id = int( run_payload, "processing_live_config_path", runtime.processing_live_config_path
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,
)
) )
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") s21_preprocess_payload = _as_dict(preprocess_payload.get("s21"), "preprocess.s21")
_load_preprocess_asset( _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") notch_payload = _as_dict(preprocess_payload.get("notch"), "preprocess.notch")
model.preprocess.notch = PreprocessNotchModel( model.preprocess.notch = PreprocessNotchModel(
enabled=bool(notch_payload.get("enabled", model.preprocess.notch.enabled)), enabled=_read_bool(notch_payload, "enabled", model.preprocess.notch.enabled),
taper_width_hz=float(notch_payload.get("taper_width_hz", model.preprocess.notch.taper_width_hz)), taper_width_hz=_read_float(notch_payload, "taper_width_hz", model.preprocess.notch.taper_width_hz),
taper_type=str(notch_payload.get("taper_type", model.preprocess.notch.taper_type)), taper_type=_read_str(notch_payload, "taper_type", model.preprocess.notch.taper_type),
bands_hz=[], bands_hz=[],
) )
bands_payload = notch_payload.get("bands_hz", []) bands_payload = _as_list(notch_payload.get("bands_hz"), "preprocess.notch.bands_hz")
if isinstance(bands_payload, list): for band in bands_payload:
for band in bands_payload: if not (isinstance(band, (list, tuple)) and len(band) == 2):
if 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.preprocess.notch.bands_hz.append((float(band[0]), float(band[1])))
model.gpr.relative_permittivity = float( model.gpr.relative_permittivity = _read_float(
gpr_payload.get("relative_permittivity", model.gpr.relative_permittivity) gpr_payload, "relative_permittivity", model.gpr.relative_permittivity
) )
model.gpr.tx_geometry = [] model.gpr.tx_geometry = []
tx_geometry_payload = gpr_payload.get("tx_geometry", []) for entry in _as_list(gpr_payload.get("tx_geometry"), "gpr.tx_geometry"):
if isinstance(tx_geometry_payload, list): entry_payload = _as_dict(entry, "gpr.tx_geometry[]")
for entry in tx_geometry_payload: model.gpr.tx_geometry.append(
entry_payload = _as_dict(entry, "gpr.tx_geometry[]") GprTxGeometryModel(
model.gpr.tx_geometry.append( output_pos=_read_int(entry_payload, "output_pos", 0),
GprTxGeometryModel( x_m=_read_float(entry_payload, "x_m", 0.0),
output_pos=int(entry_payload.get("output_pos", 0)), y_m=_read_float(entry_payload, "y_m", 0.0),
x_m=float(entry_payload.get("x_m", 0.0)), z_m=_read_float(entry_payload, "z_m", 0.0),
y_m=float(entry_payload.get("y_m", 0.0)),
z_m=float(entry_payload.get("z_m", 0.0)),
)
) )
)
model.gpr.rx_geometry = [] model.gpr.rx_geometry = []
rx_geometry_payload = gpr_payload.get("rx_geometry", []) for entry in _as_list(gpr_payload.get("rx_geometry"), "gpr.rx_geometry"):
if isinstance(rx_geometry_payload, list): entry_payload = _as_dict(entry, "gpr.rx_geometry[]")
for entry in rx_geometry_payload: model.gpr.rx_geometry.append(
entry_payload = _as_dict(entry, "gpr.rx_geometry[]") GprRxGeometryModel(
model.gpr.rx_geometry.append( input_pos=_read_int(entry_payload, "input_pos", 0),
GprRxGeometryModel( x_m=_read_float(entry_payload, "x_m", 0.0),
input_pos=int(entry_payload.get("input_pos", 0)), y_m=_read_float(entry_payload, "y_m", 0.0),
x_m=float(entry_payload.get("x_m", 0.0)), z_m=_read_float(entry_payload, "z_m", 0.0),
y_m=float(entry_payload.get("y_m", 0.0)),
z_m=float(entry_payload.get("z_m", 0.0)),
)
) )
)
model.apply_device_model_constraints() 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( validate_gpr_model(
model.gpr, model.gpr,
input_switch_positions=model.input_switch.positions, input_switch_positions=model.input_switch.positions,
output_switch_positions=model.output_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_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(pre_tap_ring_payload, model.rings.preprocessed_tap)
load_ring_payload(result_ring_payload, model.rings.results) load_ring_payload(result_ring_payload, model.rings.results)
combos_payload = run_payload.get("combos", [])
model.combos = [] model.combos = []
if isinstance(combos_payload, list): for combo in _as_list(run_payload.get("combos"), "run.combos"):
for combo in combos_payload: combo_payload = _as_dict(combo, "run.combos[]")
combo_payload = _as_dict(combo, "run.combos[]") model.combos.append(
model.combos.append( ComboModel(
ComboModel( input=_read_int(combo_payload, "input", 0),
input=int(combo_payload.get("input", 0)), output=_read_int(combo_payload, "output", 0),
output=int(combo_payload.get("output", 0)),
)
) )
)
model.ensure_combos() model.ensure_combos()
return model return model
@@ -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. config-error contract; surface it as a ValueError naming the field instead.
""" """
value = payload.get(key, default) 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)): if isinstance(value, bool) or not isinstance(value, (int, float, str)):
raise ValueError(f"{key} must be a JSON integer") raise ValueError(f"{key} must be a JSON integer")
try: 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: def _require_str(payload: dict[str, Any], key: str, default: str) -> str:
"""Read a string field, rejecting JSON arrays/objects with a named ValueError.""" """Read a string field, rejecting JSON arrays/objects with a named ValueError."""
value = payload.get(key, default) 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)): if isinstance(value, (dict, list)):
raise ValueError(f"{key} must be a JSON string") raise ValueError(f"{key} must be a JSON string")
return str(value) 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: 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.""" """Read a boolean field, rejecting non-boolean JSON types with a named ValueError."""
value = payload.get(key, default) value = payload.get(key, default)
if value is None: # explicit JSON null -> use the default
return default
if not isinstance(value, bool): if not isinstance(value, bool):
raise ValueError(f"{key} must be a JSON boolean") raise ValueError(f"{key} must be a JSON boolean")
return value return value
+17 -11
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from contextlib import suppress
import json import json
import os import os
from pathlib import Path from pathlib import Path
@@ -42,13 +43,12 @@ class ConfigWriter:
asset.bundle_path = str(bundle_path) asset.bundle_path = str(bundle_path)
def write(self, config: RunConfigModel, output_path: Path) -> 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 Dump to a sibling ``.tmp``, flush + ``fsync`` to durably commit the bytes on
durably commit the bytes, then os.replace() onto the destination. The replace the SD-card-backed Pi, then ``os.replace()`` atomically onto the destination.
is atomic, so a C++ consumer can never observe a half-written config (which A C++ consumer can therefore never observe a half-written config (which would
would abort it with an opaque JSON parse error), even across a crash or power abort it with an opaque JSON parse error), even across a crash or power loss.
loss mid-write on the SD-card-backed Pi.
""" """
output_path.parent.mkdir(parents=True, exist_ok=True) output_path.parent.mkdir(parents=True, exist_ok=True)
# allow_nan=False: a stray NaN/Infinity must fail loudly here in Python # 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. # consumer at startup with an opaque JSON parse error.
serialized = json.dumps(config.to_dict(), indent=2, allow_nan=False) serialized = json.dumps(config.to_dict(), indent=2, allow_nan=False)
tmp_path = output_path.with_suffix(output_path.suffix + ".tmp") tmp_path = output_path.with_suffix(output_path.suffix + ".tmp")
with open(tmp_path, "w", encoding="utf-8") as handle: try:
handle.write(serialized) with open(tmp_path, "w", encoding="utf-8") as handle:
handle.flush() handle.write(serialized)
os.fsync(handle.fileno()) handle.flush()
os.replace(tmp_path, output_path) 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 return output_path
+2 -15
View File
@@ -72,7 +72,6 @@ class ShmRingWriter:
self._mmap.close() self._mmap.close()
self._file.close() self._file.close()
self._unlink_if_present() self._unlink_if_present()
created = not self._path.exists()
fd = os.open(self._path, os.O_RDWR | os.O_CREAT, 0o660) fd = os.open(self._path, os.O_RDWR | os.O_CREAT, 0o660)
self._file = os.fdopen(fd, "r+b", buffering=0) self._file = os.fdopen(fd, "r+b", buffering=0)
self._file.truncate(self._mapped_size) self._file.truncate(self._mapped_size)
@@ -135,23 +134,11 @@ class ShmRingWriter:
self._write_u64(32, 0) self._write_u64(32, 0)
self._write_u64(40, 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: def _header_matches(self) -> bool:
"""Return whether the existing segment's header matches this ring's geometry. """Return whether the existing segment's header matches this ring's geometry.
Non-throwing counterpart of `_validate_header` used by the owner to decide Used by the owner to decide whether a same-sized pre-existing segment can be
whether a same-sized pre-existing segment can be reused or must be recreated. reused as-is or must be unlinked and recreated.
""" """
return ( return (
self._mmap[:8] == _MAGIC self._mmap[:8] == _MAGIC
+5 -5
View File
@@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
from contextlib import suppress from contextlib import suppress
from datetime import datetime from datetime import datetime, timezone
import json import json
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -162,7 +162,7 @@ class NpzStore(StoreApi):
raise ValueError("last_n must be > 0") raise ValueError("last_n must be > 0")
output_dir.mkdir(parents=True, exist_ok=True) 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 = output_dir / f"snapshot_{timestamp}"
snapshot_dir.mkdir(parents=True, exist_ok=True) snapshot_dir.mkdir(parents=True, exist_ok=True)
@@ -184,7 +184,7 @@ class NpzStore(StoreApi):
if last_n <= 0: if last_n <= 0:
raise ValueError("last_n must be > 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) snapshot_stem = sanitize_path_component(snapshot_stem)
output_root_dir.mkdir(parents=True, exist_ok=True) output_root_dir.mkdir(parents=True, exist_ok=True)
@@ -237,7 +237,7 @@ class NpzStore(StoreApi):
if last_n <= 0: if last_n <= 0:
raise ValueError("last_n must be > 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_stem = sanitize_path_component(output_stem)
output_root_dir.mkdir(parents=True, exist_ok=True) output_root_dir.mkdir(parents=True, exist_ok=True)
@@ -299,7 +299,7 @@ class NpzStore(StoreApi):
if last_n <= 0: if last_n <= 0:
raise ValueError("last_n must be > 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_stem = sanitize_path_component(output_stem)
output_root_dir.mkdir(parents=True, exist_ok=True) output_root_dir.mkdir(parents=True, exist_ok=True)
output_dir = self._vna_json_output_dir(output_root_dir, output_stem) output_dir = self._vna_json_output_dir(output_root_dir, output_stem)
@@ -106,6 +106,7 @@ class MultiRadarSequentialCaptureSession:
self._input_switch = SwitchService( self._input_switch = SwitchService(
name=base_config.input_switch.name, name=base_config.input_switch.name,
positions=base_config.input_switch.positions, positions=base_config.input_switch.positions,
default_position=base_config.input_switch.default_position,
mode=base_config.input_switch.driver_mode, mode=base_config.input_switch.driver_mode,
driver=base_config.input_switch.driver, driver=base_config.input_switch.driver,
gpio_chip=base_config.input_switch.gpio_chip, gpio_chip=base_config.input_switch.gpio_chip,
@@ -116,6 +117,7 @@ class MultiRadarSequentialCaptureSession:
self._output_switch = SwitchService( self._output_switch = SwitchService(
name=base_config.output_switch.name, name=base_config.output_switch.name,
positions=base_config.output_switch.positions, positions=base_config.output_switch.positions,
default_position=base_config.output_switch.default_position,
mode=base_config.output_switch.driver_mode, mode=base_config.output_switch.driver_mode,
driver=base_config.output_switch.driver, driver=base_config.output_switch.driver,
gpio_chip=base_config.output_switch.gpio_chip, gpio_chip=base_config.output_switch.gpio_chip,
@@ -82,6 +82,7 @@ class SequentialCaptureSession:
self._input_switch = SwitchService( self._input_switch = SwitchService(
name=config.input_switch.name, name=config.input_switch.name,
positions=config.input_switch.positions, positions=config.input_switch.positions,
default_position=config.input_switch.default_position,
mode=config.input_switch.driver_mode, mode=config.input_switch.driver_mode,
driver=config.input_switch.driver, driver=config.input_switch.driver,
gpio_chip=config.input_switch.gpio_chip, gpio_chip=config.input_switch.gpio_chip,
@@ -92,6 +93,7 @@ class SequentialCaptureSession:
self._output_switch = SwitchService( self._output_switch = SwitchService(
name=config.output_switch.name, name=config.output_switch.name,
positions=config.output_switch.positions, positions=config.output_switch.positions,
default_position=config.output_switch.default_position,
mode=config.output_switch.driver_mode, mode=config.output_switch.driver_mode,
driver=config.output_switch.driver, driver=config.output_switch.driver,
gpio_chip=config.output_switch.gpio_chip, gpio_chip=config.output_switch.gpio_chip,
+1
View File
@@ -2,6 +2,7 @@ numpy>=1.26,<3
libusb1>=3.1 libusb1>=3.1
pyserial>=3.5,<4 pyserial>=3.5,<4
pyvisa>=1.14 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 PyQt6>=6.6
pyqtgraph>=0.13.7 pyqtgraph>=0.13.7
rpi-hardware-pwm>=0.2.2,<1 rpi-hardware-pwm>=0.2.2,<1