some fixes again
This commit is contained in:
@@ -149,6 +149,12 @@ void DataPreprocessor::run(const std::atomic<bool>& 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.
|
||||
|
||||
@@ -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));
|
||||
} 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.
|
||||
|
||||
@@ -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<std::uint8_t> 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<double>& shared_vlc_slot, std::atomic<std::int64_t>& 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<std::uint8_t> 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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user