added timing

This commit is contained in:
Ayzen
2026-05-26 15:08:56 +03:00
parent 5b480f1b55
commit 83a934f251
42 changed files with 1680 additions and 740 deletions
+51 -60
View File
@@ -27,11 +27,10 @@ from python_app.gui.controllers.app_window_ui_mixin import AppWindowUiMixin
from python_app.gui.preprocess_dialog import PreprocessDialog
from python_app.models.dataset_model import ResultCollection, SweepCollection
from python_app.models.gui_profile_model import GuiProfileModel
from python_app.models.run_config_model import RunConfigModel
from python_app.orchestration.config_writer import ConfigWriter
from python_app.orchestration.gui_session_state import GuiSessionState, GuiSessionStateStore
from python_app.orchestration.live_processing_config import ProcessingLiveConfigWriter
from python_app.orchestration.locator_runtime import LocatorTcpService
from python_app.orchestration.pipeline_metrics import PipelineMetrics
from python_app.orchestration.preprocess_assets import VISIBLE_PREPROCESS_ASSET_KEYS, preprocess_asset_model
from python_app.orchestration.process_supervisor import ProcessSupervisor
from python_app.orchestration.shm_reader import ShmRingReader
@@ -83,7 +82,22 @@ class AppWindow(
self._supervisor = ProcessSupervisor(self._project_root)
self._live_config_writer = ProcessingLiveConfigWriter(runtime_dir / "processing_live.json")
self._gui_session_state_store = GuiSessionStateStore(runtime_dir / "gui_session_state.json")
self._locator_service: LocatorTcpService | None = None
# `log_sink` is attached after the runtime log widget exists.
self._pipeline_metrics = PipelineMetrics(
report_every=self._resolve_metrics_report_every()
)
@staticmethod
def _resolve_metrics_report_every() -> int:
"""Read the metrics flush threshold from env, falling back to 50."""
raw = os.environ.get("RADAR_SYSTEM_METRICS_REPORT_EVERY", "").strip()
if not raw:
return 50
try:
value = int(raw)
except ValueError:
return 50
return value if value >= 1 else 50
def _init_config_profile_state(self) -> None:
"""Resolve startup profile path, load active profile, and queue fallback notices."""
@@ -112,7 +126,6 @@ class AppWindow(
"INFO",
f"Loaded legacy run config without GUI defaults: {active_profile_path}",
)
self._locator_service = self._build_locator_service(self._defaults_config)
self._remember_active_profile_path(active_profile_path, startup=True)
def _init_reader_handles(self) -> None:
@@ -217,60 +230,13 @@ class AppWindow(
self._log(f"Active config profile: {self._active_profile_path}")
self._refresh_preprocess_summary_labels()
self._apply_initial_radar_limits()
self._start_locator_service()
self._on_processing_mode_changed(self._processing_mode.currentText())
self._write_live_processing_config()
# Defer the log sink wiring until the runtime log widget exists.
self._pipeline_metrics.set_log_sink(self._log)
self._timer.start()
self._maybe_auto_start_pipeline()
def _start_locator_service(self) -> None:
"""Start embedded locator TCP service without failing the GUI."""
try:
if self._locator_service is None:
self._locator_service = self._build_locator_service(self._defaults_config)
self._locator_service.start()
self._locator_service.publish_empty()
self._log(
f"Locator TCP server listening on "
f"{self._locator_service.host}:{self._locator_service.port}"
)
except Exception as exc: # noqa: BLE001
self._log_exception("Failed to start locator TCP server", exc, level="WARN")
def _build_locator_service(self, config: RunConfigModel) -> LocatorTcpService:
"""Create locator service instance from stable run config."""
locator_server = config.runtime.locator_server
return LocatorTcpService(
host=str(locator_server.host),
port=int(locator_server.port),
device_id=int(locator_server.device_id),
protocol_version=int(locator_server.protocol_version),
max_payload_bytes=int(locator_server.max_payload_bytes),
client_queue_size=int(locator_server.client_queue_size),
logger_name=str(locator_server.logger_name),
)
def _reload_locator_service_from_config(self) -> None:
"""Rebuild locator service using current stable config and restart if needed."""
previous_service = self._locator_service
was_running = previous_service is not None and previous_service.is_running()
if previous_service is not None:
previous_service.stop()
self._locator_service = self._build_locator_service(self._defaults_config)
if not was_running:
return
try:
self._locator_service.start()
self._locator_service.publish_empty()
self._log(
"Locator TCP server reloaded from config: "
f"{self._locator_service.host}:{self._locator_service.port}"
)
except Exception as exc: # noqa: BLE001
self._log_exception("Failed to reload locator TCP server from config", exc, level="WARN")
def _resolve_startup_profile_path(self) -> Path:
"""Resolve active profile path from session-state or root fallback path."""
env_profile_path = os.environ.get("RADAR_SYSTEM_PROFILE", "").strip()
@@ -300,12 +266,36 @@ class AppWindow(
return profile_path
def _maybe_auto_start_pipeline(self) -> None:
"""Schedule pipeline start when requested by launcher environment."""
auto_start = os.environ.get("RADAR_SYSTEM_AUTO_START", "").strip().lower()
if auto_start not in {"1", "true", "yes", "on"}:
"""Schedule pipeline start when requested by launcher environment.
With `RADAR_SYSTEM_AUTO_APPLY_RADAR=1` the launcher also reproduces the
"Apply Radar" click before "Start". This is the headless deployment
recipe: the GUI configures the device exactly as a human operator
would, then starts the capture pipeline.
"""
auto_start = self._is_truthy_env("RADAR_SYSTEM_AUTO_START")
if not auto_start:
return
self._log("Auto-start requested by launcher.")
QTimer.singleShot(500, self._start_run)
if self._is_truthy_env("RADAR_SYSTEM_AUTO_APPLY_RADAR"):
QTimer.singleShot(500, self._auto_apply_radar_then_start)
else:
QTimer.singleShot(500, self._start_run)
def _auto_apply_radar_then_start(self) -> None:
"""Apply current radar settings then start the pipeline (headless boot)."""
try:
self._apply_radar_settings()
except Exception as exc: # noqa: BLE001
self._log_exception("Auto apply-radar failed", exc, level="WARN")
# Hand control back to the event loop so widget updates from
# _apply_radar_settings can flush before _start_run takes over.
QTimer.singleShot(100, self._start_run)
@staticmethod
def _is_truthy_env(name: str) -> bool:
"""Return True when an environment variable is set to a truthy literal."""
return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"}
def _normalize_profile_path(self, path: Path) -> Path:
"""Return normalized absolute profile path."""
@@ -494,6 +484,8 @@ class AppWindow(
def _show_error(self, message: str, *, details: str | None = None) -> None:
"""Log and present an error in a modal dialog with optional detail text."""
self._log_error(message, details=details)
if self._is_truthy_env("RADAR_SYSTEM_HEADLESS"):
return
dialog = QMessageBox(self)
dialog.setIcon(QMessageBox.Icon.Critical)
dialog.setWindowTitle("Error")
@@ -505,6 +497,8 @@ class AppWindow(
def _show_exception(self, context: str, exc: Exception) -> None:
"""Log full exception details and show modal dialog with expandable traceback."""
message, details = self._log_exception(context, exc, level="ERROR")
if self._is_truthy_env("RADAR_SYSTEM_HEADLESS"):
return
dialog = QMessageBox(self)
dialog.setIcon(QMessageBox.Icon.Critical)
dialog.setWindowTitle("Error")
@@ -520,9 +514,6 @@ class AppWindow(
self._abort_capture_sequence(resume_pipeline=False)
# 2) Stop all managed processes/readers.
self._stop_all_processes()
# 3) Stop embedded locator service.
if self._locator_service is not None:
self._locator_service.stop()
# 3) Close auxiliary dialog windows.
if self._preprocess_dialog is not None:
self._preprocess_dialog.close()
@@ -89,6 +89,9 @@ class AppWindowLiveProcessingMixin:
gpr_background_mean_count=gpr_background_mean_count,
gpr_remove_sidelobe_objects_enabled=bool(self._gpr_remove_sidelobe_objects_enabled.isChecked()),
gpr_imaging_plane_y_m=float(self._gpr_imaging_plane_y_m.value()),
gpr_min_visible_score=float(self._gpr_min_visible_score.value()),
legacy_gpr_min_visible_pair_count=float(self._legacy_gpr_min_visible_pair_count.value()),
ignore_socket_speed=bool(self._legacy_gpr_ignore_socket_speed_enabled.isChecked()),
reprocess_current_result=bool(reprocess_current_result),
history_command_seq=int(self._history_command_seq),
history_command=str(history_command),
@@ -140,6 +143,14 @@ class AppWindowLiveProcessingMixin:
except Exception as exc: # noqa: BLE001
self._show_exception("Failed to update live processing settings", exc)
def _on_legacy_gpr_ignore_socket_speed_toggled(self, ignore_socket_speed: bool) -> None:
"""Reflect socket/manual speed authority in the GUI and live config."""
try:
self._legacy_gpr_speed_m_s.setEnabled(bool(ignore_socket_speed))
self._write_live_processing_config()
except Exception as exc: # noqa: BLE001
self._show_exception("Failed to update socket-speed mode", exc)
def _on_gpr_visual_settings_changed(self, *_args) -> None:
"""Redraw current GPR result using updated GUI-only render settings."""
if not self._is_gpr_processing_mode(self._processing_mode.currentText()):
@@ -152,22 +163,22 @@ class AppWindowLiveProcessingMixin:
self._show_exception("Failed to update GPR render settings", exc)
def _on_gpr_locator_threshold_changed(self, *_args) -> None:
"""Redraw GPR view and republish locator snapshot after threshold changes."""
"""Redraw GPR view; locator delivery lives in the C++ data_processor."""
self._on_gpr_visual_settings_changed()
if not self._is_gpr_processing_mode(self._processing_mode.currentText()):
return
try:
self._publish_locator_snapshot_from_latest_result()
self._write_live_processing_config()
except Exception as exc: # noqa: BLE001
self._show_exception("Failed to update locator GPR threshold", exc)
def _on_gpr_locator_window_changed(self, *_args) -> None:
"""Redraw GPR view and republish locator snapshot after visible X/Z changes."""
"""Redraw GPR view; locator delivery lives in the C++ data_processor."""
self._on_gpr_visual_settings_changed()
if not self._is_gpr_processing_mode(self._processing_mode.currentText()):
return
try:
self._publish_locator_snapshot_from_latest_result()
self._write_live_processing_config()
except Exception as exc: # noqa: BLE001
self._show_exception("Failed to update locator GPR window", exc)
@@ -205,10 +216,6 @@ class AppWindowLiveProcessingMixin:
self._set_plot_mode(mode)
self._set_processing_mode_page(mode)
self._on_processing_live_settings_changed()
if self._is_gpr_processing_mode(mode):
self._publish_locator_snapshot_from_latest_result()
elif self._is_gpr_processing_mode(previous_mode) and self._locator_service is not None:
self._locator_service.publish_empty()
if mode == "pass_through":
self._log(
"Processing mode selected: pass_through "
@@ -445,9 +445,11 @@ class AppWindowConfigProfileIOMixin:
self._legacy_gpr_start_freq_mhz.setValue(float(gui_state.processing.legacy_gpr.start_freq_mhz))
self._legacy_gpr_stop_freq_mhz.setValue(float(gui_state.processing.legacy_gpr.stop_freq_mhz))
self._legacy_gpr_speed_m_s.setValue(float(gui_state.processing.legacy_gpr.speed_m_s))
self._legacy_gpr_ignore_socket_speed_enabled.setChecked(
bool(gui_state.processing.legacy_gpr.ignore_socket_speed_enabled)
ignore_socket_speed_enabled = bool(
gui_state.processing.legacy_gpr.ignore_socket_speed_enabled
)
self._legacy_gpr_ignore_socket_speed_enabled.setChecked(ignore_socket_speed_enabled)
self._legacy_gpr_speed_m_s.setEnabled(ignore_socket_speed_enabled)
self._legacy_gpr_look_angle_deg.setValue(float(gui_state.processing.legacy_gpr.look_angle_deg))
self._legacy_gpr_background_subtract_enabled.setChecked(
bool(gui_state.processing.legacy_gpr.background_subtract_enabled)
@@ -494,7 +496,6 @@ class AppWindowConfigProfileIOMixin:
self._apply_initial_radar_limits()
if self._preprocess_dialog is not None:
self._refresh_sets()
self._reload_locator_service_from_config()
self._on_processing_mode_changed(gui_state.processing.selected_mode)
self._update_history_indicator()
self._remember_active_profile_path(profile_path)
@@ -3,6 +3,7 @@
from __future__ import annotations
from python_app.hardware_full.librevna_service import LibreVnaService
from python_app.hardware_full.matrix_radar_service import create_matrix_radar_service
from python_app.hardware_full.single_radar_service import create_single_radar_service
@@ -22,6 +23,8 @@ class AppWindowRadarLimitsMixin:
return self._apply_radar_limits_to_ui(None)
if config.is_multi_device:
radar_service = LibreVnaService(serial=config.radar.serial or None)
elif config.is_matrix_radar:
radar_service = create_matrix_radar_service(config)
else:
radar_service = create_single_radar_service(config)
@@ -399,8 +399,8 @@ class AppWindowConfigStateBuildersMixin:
config.runtime.settling_ms = int(self._settling_ms.text().strip())
config.runtime.processing_live_config_path = str(self._live_config_writer.path)
if config.is_multi_device:
if len(config.radar.multi_device.slave_serials) != 2:
if config.is_matrix_radar:
if config.is_multi_device and len(config.radar.multi_device.slave_serials) != 2:
raise ValueError("LibreVNA multi-device mode requires exactly two slave serials")
config.apply_device_model_constraints()
else:
@@ -4,7 +4,6 @@ from __future__ import annotations
import time
from PyQt6.QtCore import QSignalBlocker
from python_app.gui.runtime.constraints import validate_processing_mode_constraints
from python_app.gui.runtime.history import build_run_history_signature, record_result_history
@@ -12,7 +11,6 @@ from python_app.hardware_full.kamil_adc_service import apply_kamil_adc_laser_con
from python_app.hardware_full.single_radar_service import create_single_radar_service
from python_app.models.dataset_model import ComboKey, ResultCollection, SweepCollection
from python_app.models.run_config_model import RunConfigModel
from python_app.orchestration.gpr_locator import collection_has_gpr_payloads
from python_app.orchestration.preprocess_assets import (
PREPROCESS_ASSET_SPECS,
REQUIRED_PREPROCESS_ASSET_KEYS,
@@ -42,8 +40,18 @@ class AppWindowPipelineMixin:
)
return
if self._supervisor.is_running():
self._show_error("Pipeline is already running", details=self._process_state_details())
return
# A single-shot capture from a running continuous pipeline must
# restart acquisition with `runtime.continuous=false`; refusing
# here would leave the previous run streaming and the user could
# never reach the single-capture termination state.
if not single_capture:
self._show_error(
"Pipeline is already running",
details=self._process_state_details(),
)
return
self._log("Stopping continuous pipeline before single capture")
self._stop_run()
try:
processor_was_running = self._supervisor.is_processor_running()
@@ -198,6 +206,9 @@ class AppWindowPipelineMixin:
if config.is_multi_device:
self._log("Multi-device raw producer will configure all LibreVNA devices")
return
if config.is_matrix_radar:
self._log("Matrix raw producer will configure the matrix radar")
return
if config.is_kamil_adc:
if apply_kamil_adc_laser_control(config):
self._log("Kamil ADC laser_control applied via Apply Radar")
@@ -280,8 +291,6 @@ class AppWindowPipelineMixin:
self._log_error(report.format())
try:
self._drain_locator_speed_updates()
self._drain_locator_log_updates()
if self._raw_reader is not None:
self._read_all_raw()
self._read_all_preprocessed()
@@ -294,7 +303,14 @@ class AppWindowPipelineMixin:
return
return
self._draw_preferred_collection(result_latest=result_latest)
if result_latest is not None:
render_started_ns = time.monotonic_ns()
self._draw_preferred_collection(result_latest=result_latest)
self._pipeline_metrics.record(
"rendering", time.monotonic_ns() - render_started_ns
)
else:
self._draw_preferred_collection(result_latest=None)
except Exception as exc: # noqa: BLE001
signature = (type(exc).__name__, str(exc))
if self._last_reader_error_signature == signature:
@@ -347,6 +363,11 @@ class AppWindowPipelineMixin:
break
self._raw_history.append(collection)
latest = collection
# `capture_*_ns` are populated by the C++ sweep_orchestrator with
# wallclocks captured around the actual device read. Pre-orchestrator
# producers leave them zero, in which case PipelineMetrics drops it.
acquisition_ns = int(collection.capture_end_ns) - int(collection.capture_start_ns)
self._pipeline_metrics.record("acquisition", acquisition_ns)
if self._single_capture_active and self._single_capture_start_ns is not None:
if collection.monotonic_ns >= self._single_capture_start_ns:
self._single_capture_seen_raw = True
@@ -373,13 +394,9 @@ class AppWindowPipelineMixin:
collection = self._result_reader.pop_result_collection()
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
if (
self._is_gpr_processing_mode(self._processing_mode.currentText())
and collection_has_gpr_payloads(collection)
):
self._publish_locator_snapshot_from_collection(collection)
return latest
def _drain_rings_once_for_history(self) -> None:
@@ -484,60 +501,3 @@ class AppWindowPipelineMixin:
self._live_processing_config(),
)
def _drain_locator_speed_updates(self) -> None:
"""Apply the newest queued locator speed packet to live processing settings."""
if self._locator_service is None:
return
speed_m_s = self._locator_service.drain_speed_updates()
if speed_m_s is None:
return
if self._processing_mode.currentText() != "legacy_gpr":
return
if self._legacy_gpr_ignore_socket_speed_enabled.isChecked():
return
previous_speed_m_s = float(self._legacy_gpr_speed_m_s.value())
with QSignalBlocker(self._legacy_gpr_speed_m_s):
self._legacy_gpr_speed_m_s.setValue(float(speed_m_s))
current_speed_m_s = float(self._legacy_gpr_speed_m_s.value())
if current_speed_m_s == previous_speed_m_s:
return
self._write_live_processing_config(reprocess_current_result=False)
def _drain_locator_log_updates(self) -> None:
"""Append queued locator socket traffic messages to the runtime log."""
if self._locator_service is None:
return
for message in self._locator_service.drain_log_updates():
self._log(message)
def _publish_locator_snapshot_from_collection(self, collection: ResultCollection) -> None:
"""Publish one locator snapshot from a GPR result collection."""
if self._locator_service is None:
return
self._locator_service.publish_collection(
collection,
self._gpr_locator_threshold(),
visible_bounds=self._gpr_visible_object_bounds(),
object_draw_limits=self._gpr_draw_limits(),
)
def _publish_locator_snapshot_from_latest_result(self) -> None:
"""Publish current locator-visible snapshot from latest cached GPR result."""
if self._locator_service is None:
return
if not self._is_gpr_processing_mode(self._processing_mode.currentText()):
self._locator_service.publish_empty()
return
if not self._result_history:
self._locator_service.publish_empty()
return
latest = self._result_history[-1]
if not collection_has_gpr_payloads(latest):
self._locator_service.publish_empty()
return
self._publish_locator_snapshot_from_collection(latest)
@@ -390,6 +390,12 @@ def build_processing_group(owner) -> QGroupBox:
owner._legacy_gpr_ignore_socket_speed_enabled.setChecked(
bool(legacy_gpr_defaults.ignore_socket_speed_enabled)
)
# When the box is unchecked, an external TCP client controls the speed
# through the C++ locator server; disable the spinner so the GUI value
# cannot silently win against the live socket value.
owner._legacy_gpr_speed_m_s.setEnabled(
bool(legacy_gpr_defaults.ignore_socket_speed_enabled)
)
owner._legacy_gpr_look_angle_deg = QDoubleSpinBox()
owner._legacy_gpr_look_angle_deg.setDecimals(2)
@@ -516,6 +522,9 @@ def build_processing_group(owner) -> QGroupBox:
owner._legacy_gpr_start_freq_mhz.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._legacy_gpr_stop_freq_mhz.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._legacy_gpr_speed_m_s.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._legacy_gpr_ignore_socket_speed_enabled.toggled.connect(
owner._on_legacy_gpr_ignore_socket_speed_toggled
)
owner._legacy_gpr_look_angle_deg.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._legacy_gpr_background_subtract_enabled.toggled.connect(owner._on_processing_live_settings_changed)
owner._legacy_gpr_background_mean_count.valueChanged.connect(owner._on_processing_live_settings_changed)
+39 -1
View File
@@ -2,10 +2,13 @@
from __future__ import annotations
import os
from pathlib import Path
import signal
import sys
import pyqtgraph as pg
from PyQt6.QtCore import QTimer
from PyQt6.QtWidgets import QApplication
# Ensure imports are resolved when started as a script.
@@ -17,6 +20,38 @@ from python_app.gui.app_window import AppWindow
from python_app.gui.theme import apply_light_theme
def _is_headless() -> bool:
"""Return whether the launcher requested a non-interactive deployment."""
return os.environ.get("RADAR_SYSTEM_HEADLESS", "").strip().lower() in {
"1",
"true",
"yes",
"on",
}
def _install_unix_signal_handlers(app: QApplication, window: AppWindow) -> None:
"""Route SIGINT and SIGTERM through the Qt event loop into a clean shutdown.
`window.close()` runs `closeEvent`, which aborts any active capture and
shuts down managed C++ processes; only then does the Qt loop exit. A
short repeating timer keeps the Python interpreter pinned in the event
loop just long enough to deliver pending signals.
"""
def _request_shutdown(*_args: object) -> None:
window.close()
app.quit()
for sig in (signal.SIGINT, signal.SIGTERM):
signal.signal(sig, _request_shutdown)
keepalive_timer = QTimer(app)
keepalive_timer.setInterval(200)
keepalive_timer.timeout.connect(lambda: None)
keepalive_timer.start()
def main() -> int:
"""Run Qt event loop and show main radar control window."""
app = QApplication(sys.argv)
@@ -24,7 +59,10 @@ def main() -> int:
# PyQtGraph foreground controls axis lines, tick text, labels, and titles.
pg.setConfigOptions(antialias=True, background="#ffffff", foreground="#ffffff")
window = AppWindow(PROJECT_ROOT)
window.showMaximized()
if _is_headless():
_install_unix_signal_handlers(app, window)
else:
window.showMaximized()
return app.exec()
@@ -59,9 +59,11 @@ def create_matrix_radar_service(config: RunConfigModel) -> MatrixRadarService:
raise RuntimeError("SN9000 requires radar.driver_mode='native'")
from python_app.hardware_full.sn9000_service import Sn9000Service
visa_library = config.radar.visa_library or "@ivi"
return Sn9000Service(
host=config.radar.remote_host,
port=config.radar.remote_port,
visa_library=visa_library,
)
raise RuntimeError(f"Unsupported matrix radar model: {model}")
+42 -3
View File
@@ -60,8 +60,6 @@ class Sn9000Service:
if self.timeout_ms <= 0:
raise ValueError("SN9000 timeout_ms must be > 0")
self.visa_library = str(self.visa_library).strip() or "@ivi"
if self.visa_library == "@py" or self.visa_library.endswith("@py"):
raise ValueError("SN9000 requires an IVI/Vendor VISA backend, not pyvisa-py")
@property
def resource(self) -> str:
@@ -146,6 +144,10 @@ class Sn9000Service:
return {
"min_frequency_hz": float(instrument.query("SERV:SWE:FREQ:MIN?")),
"max_frequency_hz": float(instrument.query("SERV:SWE:FREQ:MAX?")),
# SN9000 SCPI does not expose IFBW capability queries; use the
# documented hardware sequence (1 Hz .. 300 kHz, manual p. 58, 1261).
"min_ifbw_hz": 1.0,
"max_ifbw_hz": 300_000.0,
"max_points": int(float(instrument.query("SERV:SWE:POIN?"))),
"min_power_dbm": float(instrument.query("SERV:SWE:POW:MIN?")),
"max_power_dbm": float(instrument.query("SERV:SWE:POW:MAX?")),
@@ -208,18 +210,37 @@ class Sn9000Service:
def _query_sweep_s_parameters(self, points: int) -> dict[str, np.ndarray]:
instrument = self._require_instrument()
if self._uses_pyvisa_py_backend():
# pyvisa-py HiSLIP loses synchronization when a single packet aggregates
# *OPC? plus multiple binary blocks, so issue trigger and data queries
# one at a time. The corrected-data buffer holds the last completed
# sweep, so reading each S-parameter sequentially is safe.
instrument.write("TRIG:SING")
self._expect_opc("*OPC?", context="SN9000 sweep")
complex_values: dict[str, np.ndarray] = {}
for parameter_name in _S_PARAMETER_QUERY_ORDER:
instrument.write(f"SENS:DATA:CORR? {parameter_name}")
interleaved = self._read_float32_block(
f"SENS:DATA:CORR? {parameter_name}", points * 2
)
complex_values[parameter_name] = self._complex_from_interleaved(interleaved)
return complex_values
data_queries = ";".join(f":SENS:DATA:CORR? {name}" for name in _S_PARAMETER_QUERY_ORDER)
instrument.write(f"TRIG:SING;*OPC?;{data_queries}")
opc_token = self._read_ascii_token()
if opc_token != "1":
raise RuntimeError(f"SN9000 sweep returned unexpected *OPC? response: {opc_token!r}")
complex_values: dict[str, np.ndarray] = {}
complex_values = {}
for parameter_name in _S_PARAMETER_QUERY_ORDER:
interleaved = self._read_float32_block(f"SENS:DATA:CORR? {parameter_name}", points * 2)
complex_values[parameter_name] = self._complex_from_interleaved(interleaved)
return complex_values
def _uses_pyvisa_py_backend(self) -> bool:
return self.visa_library == "@py" or self.visa_library.endswith("@py")
def _assemble_traces(self, s_parameters: dict[str, np.ndarray]) -> list[TraceData]:
frequency_hz = self._require_frequency_axis()
traces: list[TraceData] = []
@@ -286,8 +307,26 @@ class Sn9000Service:
f"SN9000 response for {context!r} returned {array.size} float32 values, "
f"expected {expected_values}"
)
self._drain_trailing_terminators()
return array
def _drain_trailing_terminators(self) -> None:
"""Consume the SCPI terminator that follows IEEE binary blocks.
SCPI responses end with `\\n`, which over HiSLIP closes the DataEnd
message group. pyvisa-py's HiSLIP layer needs the terminator drained
before the next request, otherwise it loses message-frame
synchronization on subsequent reads.
"""
instrument = self._require_instrument()
deadline = time.monotonic() + 0.2
while time.monotonic() < deadline:
try:
instrument.read_bytes(1, break_on_termchar=True)
return
except Exception:
return
def _read_response_bytes(self, count: int) -> bytes:
instrument = self._require_instrument()
data = instrument.read_bytes(count, break_on_termchar=False)
+3
View File
@@ -80,5 +80,8 @@ class ResultCollection:
collection_id: int
monotonic_ns: int
# Wall-clock nanoseconds spent by the data_processor on `process_collection`
# for this collection. Zero means the producer did not report a measurement.
processing_duration_ns: int = 0
collection_payloads: list[ResultPayload] = field(default_factory=list)
blocks: list[ResultBlock] = field(default_factory=list)
+2
View File
@@ -93,6 +93,7 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
model.radar.remote_port = int(radar_payload.get("remote_port", model.radar.remote_port))
model.radar.driver_mode = str(radar_payload.get("driver_mode", model.radar.driver_mode))
model.radar.mock_signal_hz = float(radar_payload.get("mock_signal_hz", model.radar.mock_signal_hz))
model.radar.visa_library = str(radar_payload.get("visa_library", model.radar.visa_library))
model.radar.sweep.start_hz = float(sweep_payload.get("start_hz", model.radar.sweep.start_hz))
model.radar.sweep.stop_hz = float(sweep_payload.get("stop_hz", model.radar.sweep.stop_hz))
@@ -393,6 +394,7 @@ def run_config_to_dict(model: RunConfigModel) -> dict[str, Any]:
"remote_port": model.radar.remote_port,
"driver_mode": model.radar.driver_mode,
"mock_signal_hz": model.radar.mock_signal_hz,
"visa_library": model.radar.visa_library,
"multi_device": {
"slave_serials": list(model.radar.multi_device.slave_serials),
"force_external_reference": model.radar.multi_device.force_external_reference,
+1
View File
@@ -103,6 +103,7 @@ class RadarModel:
remote_port: int = 50209
driver_mode: str = "mock"
mock_signal_hz: float = 1_000_000.0
visa_library: str = ""
sweep: RadarSweepModel = field(default_factory=RadarSweepModel)
multi_device: RadarMultiDeviceModel = field(default_factory=RadarMultiDeviceModel)
kamil_adc: KamilAdcModel = field(default_factory=KamilAdcModel)
+5 -65
View File
@@ -1,10 +1,11 @@
"""Helpers for extracting GPR objects and locator observations from results."""
"""Helpers for extracting GPR objects from result collections.
Locator TCP delivery now lives in the C++ data_processor. This module retains
only the inspection helpers that the GUI uses for plotting.
"""
from __future__ import annotations
from datetime import datetime
from typing import Any
import numpy as np
from python_app.models.dataset_model import ResultCollection, ResultPayload
@@ -64,64 +65,3 @@ def gpr_object_rows(collection: ResultCollection) -> np.ndarray:
return centers[:, :3]
return np.zeros((0, 3), dtype=np.float32)
def locator_observations_from_collection(
collection: ResultCollection,
min_score: float,
*,
visible_bounds: tuple[float, float, float, float] | None = None,
object_draw_limits: tuple[int, int] | None = None,
) -> list[dict[str, float]]:
"""Build locator observations from GPR rows using score threshold and optional X/Z bounds."""
rows = gpr_object_rows(collection)
if rows.size == 0:
return []
finite_mask = np.all(np.isfinite(rows[:, :3]), axis=1)
visible_mask = finite_mask & (rows[:, 2] >= float(min_score))
if visible_bounds is not None:
x_min, x_max, z_min, z_max = (float(value) for value in visible_bounds)
visible_mask &= (
(rows[:, 0] >= x_min)
& (rows[:, 0] <= x_max)
& (rows[:, 1] >= z_min)
& (rows[:, 1] <= z_max)
)
filtered = rows[visible_mask]
if object_draw_limits is not None and filtered.size > 0:
max_detected_objects, draw_top_objects = object_draw_limits
if filtered.shape[0] > int(max_detected_objects):
filtered = np.zeros((0, filtered.shape[1]), dtype=filtered.dtype)
else:
filtered = filtered[: max(0, int(draw_top_objects))]
observations: list[dict[str, float]] = []
for x_m, z_m, _score in filtered:
observations.append(
{
"dst": round(float(z_m), 2),
"crs": round(float(x_m), 2),
}
)
return observations
def build_locator_payload(
observations: list[dict[str, float]],
*,
protocol_version: int,
status: int = 1,
) -> dict[str, Any]:
"""Assemble one outbound locator payload from precomputed observations."""
return {
"ver": int(protocol_version),
"tim": _format_timestamp(),
"sts": int(status),
"obs": observations,
}
def _format_timestamp() -> str:
"""Return wall-clock timestamp with millisecond precision."""
return datetime.now().strftime("%H:%M:%S.%f")[:-3]
@@ -44,6 +44,12 @@ class ProcessingLiveConfig:
gpr_background_mean_count: int = 10
gpr_remove_sidelobe_objects_enabled: bool = True
gpr_imaging_plane_y_m: float = 0.0
# Locator filter parameters consumed by the C++ TCP locator server.
gpr_min_visible_score: float = 0.0
legacy_gpr_min_visible_pair_count: float = 0.0
# When true, the C++ data_processor ignores socket-supplied vlc updates
# and keeps using `gpr_speed_m_s` from this file.
ignore_socket_speed: bool = False
reprocess_current_result: bool = True
history_command_seq: int = 0
history_command: str = "none"
@@ -95,6 +101,9 @@ class ProcessingLiveConfig:
"gpr_background_mean_count": int(self.gpr_background_mean_count),
"gpr_remove_sidelobe_objects_enabled": bool(self.gpr_remove_sidelobe_objects_enabled),
"gpr_imaging_plane_y_m": float(self.gpr_imaging_plane_y_m),
"gpr_min_visible_score": float(self.gpr_min_visible_score),
"legacy_gpr_min_visible_pair_count": float(self.legacy_gpr_min_visible_pair_count),
"ignore_socket_speed": bool(self.ignore_socket_speed),
"reprocess_current_result": bool(self.reprocess_current_result),
"history_command_seq": int(self.history_command_seq),
"history_command": str(self.history_command),
-460
View File
@@ -1,460 +0,0 @@
"""Event-driven locator TCP service fed by already-consumed GUI GPR results."""
from __future__ import annotations
import asyncio
import contextlib
from dataclasses import dataclass
import json
import logging
import math
import queue
import struct
import threading
from typing import Any
from python_app.models.dataset_model import ResultCollection
from python_app.orchestration.gpr_locator import (
build_locator_payload,
locator_observations_from_collection,
)
_PACKET_HEADER_STRUCT = struct.Struct("<II")
def encode_packet(payload: dict[str, Any], device_id: int) -> bytes:
"""Serialize a JSON payload with the protocol binary header."""
payload_bytes = json.dumps(
payload,
ensure_ascii=True,
separators=(",", ":"),
).encode("utf-8")
return _PACKET_HEADER_STRUCT.pack(device_id, len(payload_bytes)) + payload_bytes
def decode_packet(header_bytes: bytes, payload_bytes: bytes) -> tuple[int, Any]:
"""Decode one protocol packet from its binary header and JSON payload."""
if len(header_bytes) != _PACKET_HEADER_STRUCT.size:
raise ValueError(f"Packet header must be exactly {_PACKET_HEADER_STRUCT.size} bytes long.")
device_id, payload_length = _PACKET_HEADER_STRUCT.unpack(header_bytes)
if payload_length != len(payload_bytes):
raise ValueError("Payload length does not match the header value.")
try:
payload = json.loads(payload_bytes.decode("utf-8"))
except UnicodeDecodeError as error:
raise ValueError("Payload is not valid UTF-8.") from error
except json.JSONDecodeError as error:
raise ValueError("Payload is not valid JSON.") from error
return device_id, payload
def parse_vlc(payload: dict[str, Any]) -> float:
"""Validate and normalize inbound speed payload."""
try:
vlc = float(payload["vlc"])
except (KeyError, TypeError, ValueError) as error:
raise ValueError("Payload field 'vlc' must be numeric.") from error
if not math.isfinite(vlc):
raise ValueError("Payload field 'vlc' must be finite.")
return vlc
def format_payload_for_log(payload: Any) -> str:
"""Return compact JSON-ish payload text for logs."""
return json.dumps(payload, ensure_ascii=True, separators=(",", ":"))
def decode_packet_for_log(packet: bytes) -> tuple[int, str]:
"""Decode an outbound packet into `(device_id, payload_text)` for logging."""
if len(packet) < _PACKET_HEADER_STRUCT.size:
raise ValueError("Packet is shorter than the locator header")
header_bytes = packet[: _PACKET_HEADER_STRUCT.size]
payload_bytes = packet[_PACKET_HEADER_STRUCT.size :]
device_id, payload = decode_packet(header_bytes, payload_bytes)
return device_id, format_payload_for_log(payload)
def format_peer_name(writer: asyncio.StreamWriter) -> str:
"""Return a readable peer address for logs."""
peer_name = writer.get_extra_info("peername")
if isinstance(peer_name, tuple) and len(peer_name) >= 2:
return f"{peer_name[0]}:{peer_name[1]}"
return str(peer_name or "unknown")
async def read_packet_with_limit(reader: asyncio.StreamReader, max_payload_bytes: int) -> tuple[int, Any]:
"""Read and decode a single packet using the requested payload limit."""
header_bytes = await reader.readexactly(_PACKET_HEADER_STRUCT.size)
_, payload_length = _PACKET_HEADER_STRUCT.unpack(header_bytes)
if payload_length > int(max_payload_bytes):
raise ValueError(
"Payload length %d exceeds the %d byte limit."
% (payload_length, int(max_payload_bytes))
)
payload_bytes = await reader.readexactly(payload_length)
return decode_packet(header_bytes, payload_bytes)
@dataclass(eq=False, slots=True)
class _ClientConnection:
"""Runtime state for one connected locator client."""
writer: asyncio.StreamWriter
peer_name: str
queue: asyncio.Queue[bytes]
closed: bool = False
class LocatorTcpService:
"""Background-thread TCP service for locator packets."""
def __init__(
self,
host: str,
port: int,
*,
device_id: int,
protocol_version: int,
max_payload_bytes: int,
client_queue_size: int,
logger_name: str,
logger: logging.Logger | None = None,
) -> None:
"""Create a stopped service instance."""
self._host = host
self._port = int(port)
self._device_id = int(device_id)
self._protocol_version = int(protocol_version)
self._max_payload_bytes = int(max_payload_bytes)
self._logger = logger or logging.getLogger(str(logger_name))
self._client_queue_size = int(client_queue_size)
self._speed_updates: queue.Queue[float] = queue.Queue()
self._log_updates: queue.Queue[str] = queue.Queue()
self._loop: asyncio.AbstractEventLoop | None = None
self._server: asyncio.AbstractServer | None = None
self._thread: threading.Thread | None = None
self._startup_event = threading.Event()
self._startup_error: Exception | None = None
self._clients: set[_ClientConnection] = set()
self._snapshot_lock = threading.Lock()
self._latest_packet: bytes | None = None
@property
def host(self) -> str:
"""Return bind host."""
return self._host
@property
def port(self) -> int:
"""Return bind port."""
return self._port
def start(self) -> None:
"""Start the background event loop and TCP listener."""
if self.is_running():
return
self._startup_event = threading.Event()
self._startup_error = None
self._thread = threading.Thread(
target=self._thread_main,
name="locator-tcp-service",
daemon=True,
)
self._thread.start()
if not self._startup_event.wait(timeout=5.0):
raise RuntimeError("Timed out waiting for locator TCP service startup.")
if self._startup_error is not None:
error = self._startup_error
self.stop()
raise RuntimeError(f"Failed to start locator TCP service: {error}") from error
def stop(self) -> None:
"""Stop listener, disconnect clients, and join the background thread."""
loop = self._loop
thread = self._thread
if loop is not None:
with contextlib.suppress(RuntimeError):
loop.call_soon_threadsafe(loop.stop)
if thread is not None:
thread.join(timeout=5.0)
self._thread = None
self._loop = None
self._server = None
self._clients.clear()
def is_running(self) -> bool:
"""Return whether the background loop is alive."""
return self._thread is not None and self._thread.is_alive() and self._loop is not None
def publish_collection(
self,
collection: ResultCollection,
min_score: float,
*,
visible_bounds: tuple[float, float, float, float] | None = None,
object_draw_limits: tuple[int, int] | None = None,
) -> None:
"""Publish one locator payload derived from a GPR result collection."""
observations = locator_observations_from_collection(
collection,
min_score,
visible_bounds=visible_bounds,
object_draw_limits=object_draw_limits,
)
payload = build_locator_payload(
observations,
protocol_version=self._protocol_version,
status=1,
)
self._publish_packet(encode_packet(payload, device_id=self._device_id))
def publish_empty(self) -> None:
"""Publish an empty locator snapshot."""
payload = build_locator_payload(
[],
protocol_version=self._protocol_version,
status=1,
)
self._publish_packet(encode_packet(payload, device_id=self._device_id))
def drain_speed_updates(self) -> float | None:
"""Drain queued speed updates and return the newest one, if any."""
latest: float | None = None
while True:
try:
latest = float(self._speed_updates.get_nowait())
except queue.Empty:
return latest
def drain_log_updates(self) -> list[str]:
"""Drain queued socket traffic log lines."""
lines: list[str] = []
while True:
try:
lines.append(str(self._log_updates.get_nowait()))
except queue.Empty:
return lines
def _queue_log_update(self, message: str) -> None:
"""Queue one socket traffic line for the GUI runtime log."""
self._log_updates.put(str(message))
def _log_socket_traffic(self, message: str) -> None:
"""Log socket traffic to both Python logging and the GUI-visible queue."""
self._logger.info(message)
self._queue_log_update(message)
def _publish_packet(self, packet: bytes) -> None:
"""Store latest packet and broadcast it to all connected clients."""
with self._snapshot_lock:
self._latest_packet = packet
loop = self._loop
if loop is None:
return
with contextlib.suppress(RuntimeError):
loop.call_soon_threadsafe(self._broadcast_packet, packet)
def _get_latest_packet(self) -> bytes | None:
"""Return the latest stored packet snapshot."""
with self._snapshot_lock:
return self._latest_packet
def _thread_main(self) -> None:
"""Own the event loop and TCP listener lifecycle."""
loop = asyncio.new_event_loop()
self._loop = loop
asyncio.set_event_loop(loop)
try:
self._server = loop.run_until_complete(
asyncio.start_server(self._handle_client, self._host, self._port)
)
except Exception as exc: # noqa: BLE001
self._startup_error = exc
self._startup_event.set()
self._loop = None
asyncio.set_event_loop(None)
loop.close()
return
self._startup_event.set()
try:
loop.run_forever()
finally:
with contextlib.suppress(Exception):
loop.run_until_complete(self._shutdown_async())
asyncio.set_event_loop(None)
loop.close()
self._server = None
self._loop = None
async def _shutdown_async(self) -> None:
"""Close listener and all active client connections."""
server = self._server
if server is not None:
server.close()
await server.wait_closed()
clients = list(self._clients)
self._clients.clear()
for client in clients:
client.closed = True
client.writer.close()
for client in clients:
with contextlib.suppress(BrokenPipeError, ConnectionResetError):
await client.writer.wait_closed()
pending = [
task
for task in asyncio.all_tasks()
if task is not asyncio.current_task()
]
for task in pending:
task.cancel()
for task in pending:
with contextlib.suppress(asyncio.CancelledError, Exception):
await task
async def _handle_client(
self,
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter,
) -> None:
"""Handle one client until disconnect or protocol failure."""
peer_name = format_peer_name(writer)
client = _ClientConnection(
writer=writer,
peer_name=peer_name,
queue=asyncio.Queue(maxsize=self._client_queue_size),
)
self._clients.add(client)
self._logger.info("Locator client connected: %s", peer_name)
latest_packet = self._get_latest_packet()
if latest_packet is not None:
self._enqueue_packet(client, latest_packet)
send_task = asyncio.create_task(
self._send_packets(client),
name=f"locator_send:{peer_name}",
)
receive_task = asyncio.create_task(
self._receive_packets(reader, client),
name=f"locator_receive:{peer_name}",
)
done, pending = await asyncio.wait(
{send_task, receive_task},
return_when=asyncio.FIRST_COMPLETED,
)
for task in pending:
task.cancel()
for task in pending:
with contextlib.suppress(asyncio.CancelledError):
await task
self._clients.discard(client)
client.closed = True
writer.close()
with contextlib.suppress(BrokenPipeError, ConnectionResetError):
await writer.wait_closed()
for task in done:
exception = task.exception()
if exception is None:
continue
if isinstance(exception, asyncio.IncompleteReadError):
self._logger.info("Locator client closed the connection: %s", peer_name)
continue
if isinstance(exception, (BrokenPipeError, ConnectionResetError)):
self._logger.info("Locator connection lost: %s", peer_name)
continue
if isinstance(exception, ValueError):
self._logger.warning(
"Closing locator client %s after protocol error: %s",
peer_name,
exception,
)
continue
self._logger.error(
"Unexpected locator client error: %s",
peer_name,
exc_info=(type(exception), exception, exception.__traceback__),
)
self._logger.info("Locator client disconnected: %s", peer_name)
async def _send_packets(self, client: _ClientConnection) -> None:
"""Drain one client's outbound queue."""
while True:
packet = await client.queue.get()
client.writer.write(packet)
await client.writer.drain()
try:
device_id, payload_text = decode_packet_for_log(packet)
self._log_socket_traffic(
"Locator socket sent to %s: device_id=%d payload=%s"
% (client.peer_name, device_id, payload_text)
)
except ValueError as error:
self._log_socket_traffic(
"Locator socket sent undecodable packet to %s: bytes=%d error=%s"
% (client.peer_name, len(packet), error)
)
async def _receive_packets(
self,
reader: asyncio.StreamReader,
client: _ClientConnection,
) -> None:
"""Receive inbound client packets and queue valid speed updates."""
while True:
device_id, payload = await read_packet_with_limit(reader, self._max_payload_bytes)
payload_text = format_payload_for_log(payload)
if isinstance(payload, dict) and "vlc" in payload:
speed_m_s = parse_vlc(payload)
self._speed_updates.put(speed_m_s)
self._log_socket_traffic(
"Locator socket received from %s: device_id=%d payload=%s speed_m_s=%g"
% (client.peer_name, device_id, payload_text, speed_m_s)
)
continue
self._log_socket_traffic(
"Locator socket received from %s: device_id=%d payload=%s"
% (client.peer_name, device_id, payload_text)
)
def _broadcast_packet(self, packet: bytes) -> None:
"""Enqueue one packet for all connected clients."""
for client in list(self._clients):
self._enqueue_packet(client, packet)
def _enqueue_packet(self, client: _ClientConnection, packet: bytes) -> None:
"""Enqueue one packet or disconnect a backpressured client."""
if client.closed:
return
try:
client.queue.put_nowait(packet)
except asyncio.QueueFull:
client.closed = True
self._logger.warning(
"Disconnecting locator client %s after outbound queue overflow.",
client.peer_name,
)
client.writer.close()
@@ -0,0 +1,104 @@
"""Rolling pipeline timing metrics emitted to the runtime log.
Three independent samples are accumulated:
* acquisition `capture_end_ns - capture_start_ns` from each raw sweep
* processing `processing_duration_ns` from each result collection
* rendering wall time of the Python render call
Each metric flushes an averaged report to a caller-supplied logger as soon as
its rolling buffer reaches `report_every` samples (default 50). Metrics are
strictly read-only: malformed or missing input is silently ignored so a busy
pipeline never blocks on a stray sample.
"""
from __future__ import annotations
from collections import deque
from dataclasses import dataclass
from typing import Callable, Iterable
@dataclass(frozen=True, slots=True)
class MetricReport:
"""Summary of one rolling-window flush.
All durations are in nanoseconds. `n` is the number of samples that fed the
summary never less than 1. `min_ns` / `max_ns` mark the extremes of the
window so spikes are visible even when the average stays calm.
"""
name: str
n: int
avg_ns: int
min_ns: int
max_ns: int
def format_ms(self) -> str:
"""Format the summary as a one-line `ms`-scaled log message."""
return (
f"metrics: {self.name} n={self.n} "
f"avg={self.avg_ns / 1_000_000:.2f}ms "
f"min={self.min_ns / 1_000_000:.2f}ms "
f"max={self.max_ns / 1_000_000:.2f}ms"
)
class PipelineMetrics:
"""Accumulate per-stage durations and flush averaged reports.
The caller supplies a `log_sink` (a function taking a single string) that
receives one report line per flushed metric. Wiring `log_sink` to the GUI
log writer keeps metric output co-located with the rest of the runtime
log; routing it to `print` keeps the class trivially unit-testable.
"""
def __init__(
self,
*,
report_every: int = 50,
log_sink: Callable[[str], None] | None = None,
) -> None:
"""Create a collector with a flush threshold and optional log sink."""
if report_every < 1:
raise ValueError("report_every must be >= 1")
self._report_every = int(report_every)
self._log_sink = log_sink
self._buffers: dict[str, deque[int]] = {}
def set_log_sink(self, log_sink: Callable[[str], None] | None) -> None:
"""Reassign the log sink (used when the GUI log appears after init)."""
self._log_sink = log_sink
def record(self, name: str, duration_ns: int) -> MetricReport | None:
"""Append one sample. Return a flushed report if the buffer is full."""
if duration_ns <= 0:
return None
buffer = self._buffers.setdefault(name, deque())
buffer.append(int(duration_ns))
if len(buffer) < self._report_every:
return None
samples = list(buffer)
buffer.clear()
report = self._summarize(name, samples)
if self._log_sink is not None:
self._log_sink(report.format_ms())
return report
def reset(self) -> None:
"""Discard all buffered samples without emitting a report."""
self._buffers.clear()
@staticmethod
def _summarize(name: str, samples: Iterable[int]) -> MetricReport:
"""Reduce a sample sequence to one report."""
sample_list = list(samples)
total = sum(sample_list)
count = len(sample_list)
return MetricReport(
name=name,
n=count,
avg_ns=total // count,
min_ns=min(sample_list),
max_ns=max(sample_list),
)
+3 -1
View File
@@ -16,7 +16,7 @@ from python_app.orchestration.shm.binary_cursor import ByteCursor
RAW_MAGIC = 0x32574152
PREPROC_MAGIC = 0x32525050
RESULT_MAGIC = 0x314C5352
RESULT_MAGIC = 0x324C5352 # RSL2: adds processing_duration_ns after monotonic_ns
def decode_trace_collection(payload: bytes, expected_magic: int) -> SweepCollection:
@@ -136,6 +136,7 @@ def decode_result_collection(payload: bytes) -> ResultCollection:
collection_id = cursor.read_u64()
monotonic_ns = cursor.read_u64()
processing_duration_ns = cursor.read_u64()
collection_payload_count = cursor.read_u32()
block_count = cursor.read_u32()
@@ -163,6 +164,7 @@ def decode_result_collection(payload: bytes) -> ResultCollection:
return ResultCollection(
collection_id=collection_id,
monotonic_ns=monotonic_ns,
processing_duration_ns=processing_duration_ns,
collection_payloads=collection_payloads,
blocks=blocks,
)
@@ -75,8 +75,8 @@ def _prepare_radar_if_needed(config_path: Path, *, strict: bool) -> str | None:
config = RunConfigModel.from_dict(config_payload)
if config.radar.driver_mode != "native":
return "Radar pre-configuration skipped (mock mode)."
if config.is_multi_device:
return "Radar pre-configuration skipped (multi-device producer config)."
if config.is_matrix_radar:
return "Radar pre-configuration skipped (matrix radar producer config)."
radar_service = create_single_radar_service(config)
if not getattr(radar_service, "driver_available", True):
+3 -2
View File
@@ -10,7 +10,7 @@ from python_app.models.dataset_model import ResultCollection, SweepCollection
RAW_MAGIC = 0x32574152
PREPROC_MAGIC = 0x32525050
RESULT_MAGIC = 0x314C5352
RESULT_MAGIC = 0x324C5352 # RSL2: adds processing_duration_ns after monotonic_ns
def _write_interleaved_complex(buffer: bytearray, values: np.ndarray) -> None:
@@ -109,10 +109,11 @@ def serialize_result_collection(collection: ResultCollection) -> bytes:
buffer = bytearray()
buffer.extend(
struct.pack(
"<IQQII",
"<IQQQII",
RESULT_MAGIC,
collection.collection_id,
collection.monotonic_ns,
int(collection.processing_duration_ns),
len(collection.collection_payloads),
len(collection.blocks),
)
+2 -2
View File
@@ -19,9 +19,9 @@ def capture_calibration_set(
median_sweep_count: int = DEFAULT_CALIBRATION_MEDIAN_SWEEP_COUNT,
) -> tuple[str, SweepCollection]:
"""Capture all switch combinations and persist them as calibration set."""
if config.is_multi_device:
if config.is_matrix_radar:
raise RuntimeError(
"LibreVNA multi-device S21 through calibration is not supported by this one-shot full-set helper. "
"Matrix-radar S21 through calibration is not supported by this one-shot full-set helper. "
"Use the sequential preprocess capture flow so each virtual combo can be connected through "
"and captured explicitly."
)
@@ -16,7 +16,7 @@ from python_app.models.run_config_model import ComboModel, RunConfigModel
from python_app.storage.npz_store import NpzStore
from python_app.workflows.radar_config_variants import RadarConfigVariant
from python_app.workflows.sequential_capture_workflow import (
MULTI_DEVICE_MANUAL_CAPTURE_KINDS,
MATRIX_RADAR_MANUAL_CAPTURE_KINDS,
SequentialCaptureState,
combine_collections_via_median,
combine_traces_via_median,
@@ -75,8 +75,9 @@ class MultiRadarSequentialCaptureSession:
self._radar_variants = list(radar_variants)
self._median_sweep_count = int(median_sweep_count)
self._is_matrix_radar = base_config.is_matrix_radar
self._is_multi_device = base_config.is_multi_device
self._manual_multi_device_capture = self._is_multi_device and kind in MULTI_DEVICE_MANUAL_CAPTURE_KINDS
self._manual_matrix_radar_capture = (
self._is_matrix_radar and kind in MATRIX_RADAR_MANUAL_CAPTURE_KINDS
)
self._combos = (
RunConfigModel.build_matrix_radar_virtual_combos()
if self._is_matrix_radar
@@ -169,7 +170,7 @@ class MultiRadarSequentialCaptureSession:
set_name=self._set_name,
captured_count=(
self._next_index
if self._is_matrix_radar and not self._manual_multi_device_capture
if self._is_matrix_radar and not self._manual_matrix_radar_capture
else len(self._captured_batches)
),
total_count=len(self._combos),
@@ -177,7 +178,7 @@ class MultiRadarSequentialCaptureSession:
can_undo=bool(self._captured_batches),
is_complete=self.is_complete(),
variant_count=len(self._radar_variants),
supports_batch_capture=not self._manual_multi_device_capture,
supports_batch_capture=not self._manual_matrix_radar_capture,
)
def capture_current_combo(self) -> MultiRadarCaptureBatch:
@@ -205,7 +206,7 @@ class MultiRadarSequentialCaptureSession:
f"Matrix radar variant {variant.display_name} returned no traces"
)
collections.append(collection)
if self._manual_multi_device_capture:
if self._manual_matrix_radar_capture:
per_sweep_traces = [select_trace_for_combo(collection, combo) for collection in collections]
trace = combine_traces_via_median(per_sweep_traces)
pending_traces_by_radar_key[variant.radar_key] = [trace]
@@ -251,7 +252,7 @@ class MultiRadarSequentialCaptureSession:
variant_labels=tuple(variant_labels),
)
self._captured_batches.append(batch)
if self._is_matrix_radar and not self._manual_multi_device_capture:
if self._is_matrix_radar and not self._manual_matrix_radar_capture:
self._next_index = len(self._combos)
else:
self._next_index += 1
@@ -264,7 +265,7 @@ class MultiRadarSequentialCaptureSession:
if not self._captured_batches or self._next_index <= 0:
raise RuntimeError("No captured combo is available to undo")
if self._is_matrix_radar and not self._manual_multi_device_capture:
if self._is_matrix_radar and not self._manual_matrix_radar_capture:
removed_batch = self._captured_batches[-1]
for variant in self._radar_variants:
traces = self._traces_by_radar_key[variant.radar_key]
@@ -15,7 +15,7 @@ from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
from python_app.models.run_config_model import ComboModel, RunConfigModel
from python_app.storage.npz_store import NpzStore, radar_key_from_config
MULTI_DEVICE_MANUAL_CAPTURE_KINDS = frozenset({"s21_calibration", "s11_open", "s11_short", "s11_load"})
MATRIX_RADAR_MANUAL_CAPTURE_KINDS = frozenset({"s21_calibration", "s11_open", "s11_short", "s11_load"})
DEFAULT_CALIBRATION_MEDIAN_SWEEP_COUNT = 5
@@ -58,8 +58,9 @@ class SequentialCaptureSession:
self._set_name = set_name
self._median_sweep_count = int(median_sweep_count)
self._is_matrix_radar = config.is_matrix_radar
self._is_multi_device = config.is_multi_device
self._manual_multi_device_capture = self._is_multi_device and kind in MULTI_DEVICE_MANUAL_CAPTURE_KINDS
self._manual_matrix_radar_capture = (
self._is_matrix_radar and kind in MATRIX_RADAR_MANUAL_CAPTURE_KINDS
)
self._combos = (
RunConfigModel.build_matrix_radar_virtual_combos()
if self._is_matrix_radar
@@ -148,7 +149,7 @@ class SequentialCaptureSession:
current_combo=current_combo,
can_undo=bool(self._traces),
is_complete=self.is_complete(),
supports_batch_capture=not self._manual_multi_device_capture,
supports_batch_capture=not self._manual_matrix_radar_capture,
)
def capture_current_combo(self) -> TraceData:
@@ -166,7 +167,7 @@ class SequentialCaptureSession:
if not collection.traces:
raise RuntimeError("Matrix radar capture returned no traces")
collections.append(collection)
if self._manual_multi_device_capture:
if self._manual_matrix_radar_capture:
per_sweep_traces = [select_trace_for_combo(collection, combo) for collection in collections]
trace = combine_traces_via_median(per_sweep_traces)
self._traces.append(trace)
@@ -208,7 +209,7 @@ class SequentialCaptureSession:
if not self._traces or self._next_index <= 0:
raise RuntimeError("No captured combo is available to undo")
if self._is_matrix_radar and not self._manual_multi_device_capture:
if self._is_matrix_radar and not self._manual_matrix_radar_capture:
if len(self._traces) != len(self._combos):
raise RuntimeError("Capture session state is inconsistent; matrix radar trace matrix is incomplete")
removed_trace = self._traces[-1]