init commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""GUI package for radar system runtime control and visualization."""
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Main GUI window composed from focused mixins."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtCore import QTimer
|
||||
from PyQt6.QtWidgets import QMainWindow, QMessageBox
|
||||
|
||||
from python_app.gui.controllers.app_window_config_mixin import AppWindowConfigMixin
|
||||
from python_app.gui.controllers.app_window_pipeline_mixin import AppWindowPipelineMixin
|
||||
from python_app.gui.controllers.app_window_plot_mixin import AppWindowPlotMixin
|
||||
from python_app.gui.controllers.app_window_preprocess_mixin import AppWindowPreprocessMixin
|
||||
from python_app.gui.controllers.app_window_snapshot_mixin import AppWindowSnapshotMixin
|
||||
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.run_config_model import RunConfigModel
|
||||
from python_app.orchestration.config_writer import ConfigWriter
|
||||
from python_app.orchestration.live_processing_config import ProcessingLiveConfigWriter
|
||||
from python_app.orchestration.process_supervisor import ProcessSupervisor
|
||||
from python_app.orchestration.shm_reader import ShmRingReader
|
||||
from python_app.storage.npz_store import NpzStore
|
||||
from python_app.workflows.sequential_capture_workflow import SequentialCaptureSession
|
||||
|
||||
|
||||
class AppWindow(
|
||||
AppWindowUiMixin,
|
||||
AppWindowConfigMixin,
|
||||
AppWindowPreprocessMixin,
|
||||
AppWindowPlotMixin,
|
||||
AppWindowPipelineMixin,
|
||||
AppWindowSnapshotMixin,
|
||||
QMainWindow,
|
||||
):
|
||||
"""Top-level application window coordinating UI and acquisition runtime."""
|
||||
|
||||
def __init__(self, project_root: Path) -> None:
|
||||
"""Initialize application state, services, UI, and polling timer."""
|
||||
super().__init__()
|
||||
self._project_root = project_root
|
||||
self._defaults_config_path = project_root / "run_config.json"
|
||||
self._defaults_config = RunConfigModel.load_from_path(self._defaults_config_path)
|
||||
|
||||
self._store = NpzStore(project_root / "python_app/data")
|
||||
self._config_writer = ConfigWriter(project_root / "python_app/runtime")
|
||||
self._supervisor = ProcessSupervisor(project_root)
|
||||
self._live_config_writer = ProcessingLiveConfigWriter(project_root / "python_app/runtime/processing_live.json")
|
||||
|
||||
self._raw_reader: ShmRingReader | None = None
|
||||
self._pre_reader: ShmRingReader | None = None
|
||||
self._result_reader: ShmRingReader | None = None
|
||||
|
||||
self._preprocess_dialog: PreprocessDialog | None = None
|
||||
self._selected_calibration_set = str(self._defaults_config.preprocess.calibration_set)
|
||||
self._selected_reference_set = str(self._defaults_config.preprocess.reference_set)
|
||||
|
||||
self._capture_session: SequentialCaptureSession | None = None
|
||||
self._resume_pipeline_after_capture = False
|
||||
self._single_capture_active = False
|
||||
self._single_capture_start_ns: int | None = None
|
||||
self._single_capture_seen_raw = False
|
||||
self._single_capture_target_collection_id: int | None = None
|
||||
|
||||
self._raw_history: deque[SweepCollection] = deque(maxlen=512)
|
||||
self._pre_history: deque[SweepCollection] = deque(maxlen=512)
|
||||
result_history_limit = max(
|
||||
1,
|
||||
min(
|
||||
int(self._defaults_config.rings.preprocessed.capacity),
|
||||
int(self._defaults_config.rings.results.capacity),
|
||||
50,
|
||||
),
|
||||
)
|
||||
self._result_history: deque[ResultCollection] = deque(maxlen=result_history_limit)
|
||||
self._history_command_seq = self._load_history_command_seq(self._live_config_writer.path)
|
||||
self._bscan_history_limit = result_history_limit
|
||||
self._bscan_history_by_combo = {}
|
||||
self._bscan_depth_axis_by_combo = {}
|
||||
self._bscan_history_floor_collection_id = 0
|
||||
self._bscan_render_signature = None
|
||||
self._phase_viewbox = None
|
||||
self._history_run_signature = None
|
||||
self._radar_limits: dict[str, float | int] | None = None
|
||||
|
||||
self._max_pop_per_poll = 256
|
||||
self._max_pop_per_snapshot_drain = 4096
|
||||
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setInterval(50)
|
||||
self._timer.timeout.connect(self._poll_rings)
|
||||
|
||||
self._build_ui()
|
||||
self._refresh_preprocess_summary_labels()
|
||||
if self._radar_mode.currentText() == "native":
|
||||
self._refresh_radar_limits_from_device()
|
||||
else:
|
||||
self._apply_radar_limits_to_ui(None)
|
||||
self._write_live_processing_config()
|
||||
self._timer.start()
|
||||
|
||||
def _log(self, text: str) -> None:
|
||||
"""Append a line to the runtime log panel."""
|
||||
self._log_box.appendPlainText(text)
|
||||
|
||||
@staticmethod
|
||||
def _load_history_command_seq(config_path: Path) -> int:
|
||||
"""Load previously used live-command sequence from runtime config file."""
|
||||
try:
|
||||
payload = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
except Exception: # noqa: BLE001
|
||||
return 0
|
||||
|
||||
raw_value = payload.get("history_command_seq", 0)
|
||||
if isinstance(raw_value, bool):
|
||||
return 0
|
||||
if isinstance(raw_value, (int, float)):
|
||||
return max(0, int(raw_value))
|
||||
return 0
|
||||
|
||||
def _show_error(self, message: str) -> None:
|
||||
"""Log and present an error in a modal dialog."""
|
||||
self._log(f"ERROR: {message}")
|
||||
QMessageBox.critical(self, "Error", message)
|
||||
|
||||
def closeEvent(self, event) -> None: # noqa: N802
|
||||
"""Ensure workers and dialogs are closed before window destruction."""
|
||||
try:
|
||||
self._resume_pipeline_after_capture = False
|
||||
self._abort_capture_sequence(resume_pipeline=False)
|
||||
self._stop_all_processes()
|
||||
if self._preprocess_dialog is not None:
|
||||
self._preprocess_dialog.close()
|
||||
finally:
|
||||
super().closeEvent(event)
|
||||
@@ -0,0 +1 @@
|
||||
"""Controller mixins and orchestration helpers for GUI windows."""
|
||||
@@ -0,0 +1,356 @@
|
||||
"""Configuration and live-processing binding mixin for the main window."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from python_app.hardware_full.librevna_service import LibreVnaService
|
||||
from python_app.models.run_config_model import ComboModel, RunConfigModel
|
||||
from python_app.orchestration.config_writer import parse_combos_from_text
|
||||
from python_app.orchestration.live_processing_config import ProcessingLiveConfig
|
||||
from python_app.storage.npz_store import radar_key_from_config
|
||||
|
||||
|
||||
class AppWindowConfigMixin:
|
||||
"""Builds runtime config models from current UI state."""
|
||||
|
||||
def _save_current_config(self) -> None:
|
||||
"""Persist currently selected GUI settings into root run_config.json."""
|
||||
try:
|
||||
config = self._build_config()
|
||||
self._config_writer.write(config, self._defaults_config_path)
|
||||
self._defaults_config = config.clone()
|
||||
self._log(f"Current config saved: {self._defaults_config_path}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._show_error(f"Failed to save current config: {exc}")
|
||||
|
||||
def _build_config(self) -> RunConfigModel:
|
||||
"""Build `RunConfigModel` from current GUI widget values."""
|
||||
config = self._defaults_config.clone()
|
||||
|
||||
config.radar.serial = self._serial_input.text().strip()
|
||||
config.radar.driver_mode = self._radar_mode.currentText()
|
||||
|
||||
config.radar.sweep.start_hz = float(self._start_hz_input.text().strip())
|
||||
config.radar.sweep.stop_hz = float(self._stop_hz_input.text().strip())
|
||||
config.radar.sweep.points = int(self._points_input.text().strip())
|
||||
config.radar.sweep.if_bandwidth_hz = float(self._ifbw_input.text().strip())
|
||||
config.radar.sweep.power_dbm = float(self._power_input.text().strip())
|
||||
|
||||
config.input_switch.driver_mode = self._input_mode.currentText()
|
||||
config.output_switch.driver_mode = self._output_mode.currentText()
|
||||
|
||||
config.input_switch.driver = self._input_driver.currentText()
|
||||
config.output_switch.driver = self._output_driver.currentText()
|
||||
config.input_switch.radar_port = 2
|
||||
config.output_switch.radar_port = 1
|
||||
|
||||
config.input_switch.positions = int(self._input_positions.text().strip())
|
||||
config.output_switch.positions = int(self._output_positions.text().strip())
|
||||
|
||||
config.input_switch.gpio_chip = self._input_gpio_chip.text().strip()
|
||||
config.input_switch.pin_a = int(self._input_pin_a.text().strip())
|
||||
config.input_switch.pin_b = int(self._input_pin_b.text().strip())
|
||||
config.input_switch.invert_logic = self._input_invert_logic.currentText() == "true"
|
||||
|
||||
config.output_switch.gpio_chip = self._output_gpio_chip.text().strip()
|
||||
config.output_switch.pin_a = int(self._output_pin_a.text().strip())
|
||||
config.output_switch.pin_b = int(self._output_pin_b.text().strip())
|
||||
config.output_switch.invert_logic = self._output_invert_logic.currentText() == "true"
|
||||
|
||||
config.runtime.settling_ms = int(self._settling_ms.text().strip())
|
||||
config.runtime.processing_live_config_path = str(self._live_config_writer.path)
|
||||
|
||||
combo_text = self._combos_text.text()
|
||||
config.combos = parse_combos_from_text(combo_text)
|
||||
config.ensure_combos()
|
||||
if self._switches_are_effectively_static(config):
|
||||
config.combos = [ComboModel(input=0, output=0)]
|
||||
|
||||
config.preprocess.calibration_set = self._selected_calibration_set
|
||||
config.preprocess.reference_set = self._selected_reference_set
|
||||
return config
|
||||
|
||||
def _radar_key(self, config: RunConfigModel) -> str:
|
||||
"""Build radar key used by calibration/reference storage lookup."""
|
||||
return radar_key_from_config(
|
||||
model_name=config.radar.model,
|
||||
serial=config.radar.serial,
|
||||
sweep_start_hz=config.radar.sweep.start_hz,
|
||||
sweep_stop_hz=config.radar.sweep.stop_hz,
|
||||
sweep_points=config.radar.sweep.points,
|
||||
ifbw_hz=config.radar.sweep.if_bandwidth_hz,
|
||||
power_dbm=config.radar.sweep.power_dbm,
|
||||
)
|
||||
|
||||
def _live_processing_config(self, *, history_command: str = "none") -> ProcessingLiveConfig:
|
||||
"""Build live processing config from current processing widgets."""
|
||||
self._sync_bscan_frequency_limits_with_radar()
|
||||
return ProcessingLiveConfig(
|
||||
processor_mode=self._processing_mode.currentText(),
|
||||
gain_db=float(self._processing_gain_db.value()),
|
||||
phase_deg=float(self._processing_phase_deg.value()),
|
||||
bscan_axis=self._bscan_axis.currentText(),
|
||||
bscan_cut_m=float(self._bscan_cut_m.value()),
|
||||
bscan_max_depth_m=float(self._bscan_max_depth_m.value()),
|
||||
bscan_gain=float(self._bscan_gain.value()),
|
||||
bscan_start_freq_mhz=float(self._bscan_start_freq_mhz.value()),
|
||||
bscan_stop_freq_mhz=float(self._bscan_stop_freq_mhz.value()),
|
||||
history_command_seq=int(self._history_command_seq),
|
||||
history_command=str(history_command),
|
||||
)
|
||||
|
||||
def _write_live_processing_config(self, *, history_command: str = "none", bump_history_seq: bool = False) -> None:
|
||||
"""Persist current live processing config to runtime JSON file."""
|
||||
if bump_history_seq:
|
||||
self._history_command_seq += 1
|
||||
self._live_config_writer.write(self._live_processing_config(history_command=history_command))
|
||||
|
||||
def _on_processing_live_settings_changed(self, *_args) -> None:
|
||||
"""Handle live-processing setting changes and trigger redraw when needed."""
|
||||
try:
|
||||
self._write_live_processing_config()
|
||||
if self._processing_mode.currentText() == "bscan":
|
||||
self._drain_results_until_quiet(timeout_s=0.25, poll_s=0.01)
|
||||
self._sync_bscan_history_from_results()
|
||||
self._draw_bscan_heatmap_from_history()
|
||||
elif self._result_history:
|
||||
self._draw_results(self._result_history[-1])
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._show_error(f"Failed to update live processing settings: {exc}")
|
||||
|
||||
def _on_processing_mode_changed(self, mode: str) -> None:
|
||||
"""Switch processing parameter page and refresh corresponding visualization."""
|
||||
mode_to_page = {
|
||||
"pass_through": 0,
|
||||
"bscan": 1,
|
||||
}
|
||||
self._set_plot_mode(mode)
|
||||
self._processing_mode_pages.setCurrentIndex(mode_to_page.get(mode, 0))
|
||||
current_page = self._processing_mode_pages.currentWidget()
|
||||
if current_page is not None:
|
||||
self._processing_mode_pages.setFixedHeight(current_page.sizeHint().height())
|
||||
self._processing_mode_pages.updateGeometry()
|
||||
self._on_processing_live_settings_changed()
|
||||
|
||||
def _on_bscan_clear_history_clicked(self) -> None:
|
||||
"""Permanently clear all runtime histories, ring backlogs, and B-scan cache."""
|
||||
self._apply_bscan_history_deletion(remove_last_only=False)
|
||||
|
||||
def _on_bscan_remove_last_sweep_clicked(self) -> None:
|
||||
"""Permanently delete the latest sweep from runtime histories and rings."""
|
||||
self._apply_bscan_history_deletion(remove_last_only=True)
|
||||
|
||||
def _apply_bscan_history_deletion(self, *, remove_last_only: bool) -> None:
|
||||
"""Apply destructive B-scan history deletion via C++ processor history commands."""
|
||||
resume_acquisition = self._supervisor.is_running()
|
||||
history_command = "remove_last" if remove_last_only else "clear_all"
|
||||
action = "last sweep removed" if remove_last_only else "history fully cleared"
|
||||
dropped_results = 0
|
||||
|
||||
try:
|
||||
if resume_acquisition:
|
||||
self._stop_run()
|
||||
|
||||
if self._result_reader is not None:
|
||||
dropped_results = self._result_reader.drop_all()
|
||||
|
||||
if remove_last_only:
|
||||
if self._result_history:
|
||||
self._result_history.pop()
|
||||
else:
|
||||
self._result_history.clear()
|
||||
self._clear_bscan_plot_history()
|
||||
|
||||
self._write_live_processing_config(history_command=history_command, bump_history_seq=True)
|
||||
|
||||
if self._supervisor.is_processor_running():
|
||||
self._drain_results_until_quiet(timeout_s=0.35, poll_s=0.01)
|
||||
|
||||
self._update_history_indicator()
|
||||
self._redraw_after_history_deletion()
|
||||
if resume_acquisition:
|
||||
self._start_run()
|
||||
self._log(f"B-scan {action}; dropped pending results={dropped_results}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._show_error(f"Failed to delete B-scan history: {exc}")
|
||||
|
||||
def _redraw_after_history_deletion(self) -> None:
|
||||
"""Refresh plot immediately after destructive history deletion."""
|
||||
if self._processing_mode.currentText() == "bscan":
|
||||
if self._result_history:
|
||||
self._sync_bscan_history_from_results()
|
||||
if not self._draw_bscan_heatmap_from_history():
|
||||
self._plot.clear()
|
||||
return
|
||||
if self._result_history:
|
||||
self._draw_results(self._result_history[-1])
|
||||
return
|
||||
self._plot.clear()
|
||||
|
||||
def _on_radar_identity_changed(self, *_args) -> None:
|
||||
"""Refresh device limits when radar identity/mode changes."""
|
||||
if self._radar_mode.currentText() != "native":
|
||||
self._apply_radar_limits_to_ui(None)
|
||||
return
|
||||
changed = self._refresh_radar_limits_from_device()
|
||||
if changed:
|
||||
self._on_processing_live_settings_changed()
|
||||
|
||||
def _on_radar_sweep_limits_changed(self) -> None:
|
||||
"""Clamp B-scan frequency bounds after sweep start/stop edits."""
|
||||
if self._sync_bscan_frequency_limits_with_radar():
|
||||
self._on_processing_live_settings_changed()
|
||||
|
||||
def _refresh_radar_limits_from_device(self) -> bool:
|
||||
"""Query native LibreVNA limits and apply them to GUI fields."""
|
||||
serial = self._serial_input.text().strip()
|
||||
radar_service = LibreVnaService(serial=serial or None)
|
||||
if not radar_service.driver_available:
|
||||
self._fallback_to_mock_mode("LibreVNA Python driver is not available for device limits query")
|
||||
return False
|
||||
|
||||
try:
|
||||
limits = radar_service.read_device_limits()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._fallback_to_mock_mode(f"Failed to query LibreVNA limits: {exc}")
|
||||
return False
|
||||
|
||||
return self._apply_radar_limits_to_ui(limits)
|
||||
|
||||
def _fallback_to_mock_mode(self, reason: str) -> None:
|
||||
"""Fallback to mock mode when native limits cannot be queried."""
|
||||
self._log(f"{reason}; switched radar mode to mock")
|
||||
if self._radar_mode.currentText() != "mock":
|
||||
was_blocked = self._radar_mode.blockSignals(True)
|
||||
self._radar_mode.setCurrentText("mock")
|
||||
self._radar_mode.blockSignals(was_blocked)
|
||||
self._apply_radar_limits_to_ui(None)
|
||||
|
||||
def _apply_radar_limits_to_ui(self, limits: dict[str, float | int] | None) -> bool:
|
||||
"""Apply optional radar limits and clamp dependent GUI fields."""
|
||||
if limits is None:
|
||||
self._radar_limits = None
|
||||
self._radar_start_label.setText("Start Hz")
|
||||
self._radar_stop_label.setText("Stop Hz")
|
||||
self._radar_points_label.setText("Points")
|
||||
self._radar_ifbw_label.setText("IF BW Hz")
|
||||
self._radar_power_label.setText("Stimulus Power dBm")
|
||||
if self._radar_mode.currentText() == "native":
|
||||
self._radar_limits_hint.setText("Device limits unavailable in native mode (device not connected).")
|
||||
else:
|
||||
self._radar_limits_hint.setText("Mock mode: device limits are not applied.")
|
||||
self._power_input.setToolTip("Device power limits are available only in native mode.")
|
||||
return False
|
||||
|
||||
min_freq_hz = float(limits["min_frequency_hz"])
|
||||
max_freq_hz = float(limits["max_frequency_hz"])
|
||||
min_ifbw_hz = float(limits["min_ifbw_hz"])
|
||||
max_ifbw_hz = float(limits["max_ifbw_hz"])
|
||||
max_points = int(limits["max_points"])
|
||||
min_power_dbm = float(limits["min_power_dbm"])
|
||||
max_power_dbm = float(limits["max_power_dbm"])
|
||||
|
||||
self._radar_limits = limits
|
||||
|
||||
self._radar_start_label.setText(f"Start Hz ({min_freq_hz:g}..{max_freq_hz:g})")
|
||||
self._radar_stop_label.setText(f"Stop Hz ({min_freq_hz:g}..{max_freq_hz:g})")
|
||||
self._radar_points_label.setText(f"Points (1..{max_points:d})")
|
||||
self._radar_ifbw_label.setText(f"IF BW Hz ({min_ifbw_hz:g}..{max_ifbw_hz:g})")
|
||||
self._radar_power_label.setText(f"Stimulus Power dBm ({min_power_dbm:g}..{max_power_dbm:g})")
|
||||
self._radar_limits_hint.setText(
|
||||
f"Limits: Freq {min_freq_hz:g}..{max_freq_hz:g} Hz, Points 1..{max_points:d}, "
|
||||
f"IF BW {min_ifbw_hz:g}..{max_ifbw_hz:g} Hz, Power {min_power_dbm:g}..{max_power_dbm:g} dBm."
|
||||
)
|
||||
|
||||
changed = False
|
||||
prev_start = self._start_hz_input.text().strip()
|
||||
prev_stop = self._stop_hz_input.text().strip()
|
||||
prev_points = self._points_input.text().strip()
|
||||
prev_ifbw = self._ifbw_input.text().strip()
|
||||
prev_power = self._power_input.text().strip()
|
||||
|
||||
start_hz = self._clamp_line_edit_float(self._start_hz_input, min_freq_hz, max_freq_hz)
|
||||
stop_hz = self._clamp_line_edit_float(self._stop_hz_input, min_freq_hz, max_freq_hz)
|
||||
if start_hz > stop_hz:
|
||||
stop_hz = start_hz
|
||||
self._stop_hz_input.setText(f"{stop_hz:g}")
|
||||
changed = True
|
||||
|
||||
points = self._clamp_line_edit_int(self._points_input, 1, max_points)
|
||||
ifbw = self._clamp_line_edit_float(self._ifbw_input, min_ifbw_hz, max_ifbw_hz)
|
||||
power = self._clamp_line_edit_float(self._power_input, min_power_dbm, max_power_dbm)
|
||||
self._power_input.setToolTip(f"Device range: {min_power_dbm:g}..{max_power_dbm:g} dBm")
|
||||
|
||||
changed = (
|
||||
changed
|
||||
or prev_start != self._start_hz_input.text().strip()
|
||||
or prev_stop != self._stop_hz_input.text().strip()
|
||||
or prev_points != self._points_input.text().strip()
|
||||
or prev_ifbw != self._ifbw_input.text().strip()
|
||||
or prev_power != self._power_input.text().strip()
|
||||
)
|
||||
|
||||
self._sync_bscan_frequency_limits_with_radar()
|
||||
return changed
|
||||
|
||||
@staticmethod
|
||||
def _clamp_line_edit_float(widget, min_value: float, max_value: float) -> float:
|
||||
"""Clamp float line-edit value to inclusive range and rewrite widget text."""
|
||||
try:
|
||||
value = float(widget.text().strip())
|
||||
except ValueError:
|
||||
value = min_value
|
||||
value = min(max(value, min_value), max_value)
|
||||
widget.setText(f"{value:g}")
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _clamp_line_edit_int(widget, min_value: int, max_value: int) -> int:
|
||||
"""Clamp integer line-edit value to inclusive range and rewrite widget text."""
|
||||
try:
|
||||
value = int(float(widget.text().strip()))
|
||||
except ValueError:
|
||||
value = min_value
|
||||
value = min(max(value, min_value), max_value)
|
||||
widget.setText(str(value))
|
||||
return value
|
||||
|
||||
def _sync_bscan_frequency_limits_with_radar(self) -> bool:
|
||||
"""Synchronize B-scan start/stop MHz widget ranges with radar sweep bounds."""
|
||||
try:
|
||||
radar_start_hz = float(self._start_hz_input.text().strip())
|
||||
radar_stop_hz = float(self._stop_hz_input.text().strip())
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
radar_min_mhz = min(radar_start_hz, radar_stop_hz) / 1_000_000.0
|
||||
radar_max_mhz = max(radar_start_hz, radar_stop_hz) / 1_000_000.0
|
||||
|
||||
changed = False
|
||||
for widget in (self._bscan_start_freq_mhz, self._bscan_stop_freq_mhz):
|
||||
if widget.minimum() != radar_min_mhz or widget.maximum() != radar_max_mhz:
|
||||
changed = True
|
||||
widget.blockSignals(True)
|
||||
widget.setRange(radar_min_mhz, radar_max_mhz)
|
||||
widget.blockSignals(False)
|
||||
|
||||
clamped_start_mhz = min(max(self._bscan_start_freq_mhz.value(), radar_min_mhz), radar_max_mhz)
|
||||
clamped_stop_mhz = min(max(self._bscan_stop_freq_mhz.value(), radar_min_mhz), radar_max_mhz)
|
||||
if clamped_start_mhz != self._bscan_start_freq_mhz.value():
|
||||
changed = True
|
||||
self._bscan_start_freq_mhz.blockSignals(True)
|
||||
self._bscan_start_freq_mhz.setValue(clamped_start_mhz)
|
||||
self._bscan_start_freq_mhz.blockSignals(False)
|
||||
if clamped_stop_mhz != self._bscan_stop_freq_mhz.value():
|
||||
changed = True
|
||||
self._bscan_stop_freq_mhz.blockSignals(True)
|
||||
self._bscan_stop_freq_mhz.setValue(clamped_stop_mhz)
|
||||
self._bscan_stop_freq_mhz.blockSignals(False)
|
||||
|
||||
return changed
|
||||
|
||||
@staticmethod
|
||||
def _switches_are_effectively_static(config: RunConfigModel) -> bool:
|
||||
"""Return `True` when switch setup effectively yields one fixed combo."""
|
||||
has_single_position = config.input_switch.positions <= 1 and config.output_switch.positions <= 1
|
||||
both_mock = config.input_switch.driver_mode == "mock" and config.output_switch.driver_mode == "mock"
|
||||
return has_single_position or both_mock
|
||||
@@ -0,0 +1,380 @@
|
||||
"""Pipeline runtime lifecycle mixin for the main GUI window."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
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
|
||||
from python_app.hardware_full.librevna_service import LibreVnaService
|
||||
from python_app.models.dataset_model import ComboKey, ResultCollection, SweepCollection
|
||||
from python_app.models.run_config_model import RunConfigModel
|
||||
from python_app.orchestration.shm_reader import ShmRingReader
|
||||
|
||||
|
||||
class AppWindowPipelineMixin:
|
||||
"""Controls start/stop, readers, and periodic polling of pipeline rings."""
|
||||
|
||||
def _start_single_capture(self) -> None:
|
||||
"""Start acquisition in single-capture mode."""
|
||||
self._start_run(single_capture=True)
|
||||
|
||||
def _start_run(self, *, single_capture: bool = False) -> None:
|
||||
"""Start pipeline processes and ring readers."""
|
||||
if self._capture_session is not None:
|
||||
self._show_error("Cannot start pipeline during active capture sequence")
|
||||
return
|
||||
if self._supervisor.is_running():
|
||||
self._show_error("Pipeline is already running")
|
||||
return
|
||||
|
||||
try:
|
||||
processor_was_running = self._supervisor.is_processor_running()
|
||||
if not processor_was_running:
|
||||
self._reset_runtime_history()
|
||||
|
||||
config = self._build_config()
|
||||
self._validate_processing_mode_constraints(config)
|
||||
run_signature = self._build_run_history_signature(config)
|
||||
radar_key = self._radar_key(config)
|
||||
|
||||
if not config.preprocess.calibration_set or not config.preprocess.reference_set:
|
||||
raise RuntimeError("Select calibration and reference sets in Preprocessing Panel before Start")
|
||||
|
||||
combo_keys = [ComboKey(input_pos=combo.input, output_pos=combo.output) for combo in config.combos]
|
||||
|
||||
if not self._store.has_combo_coverage(
|
||||
"calibration", radar_key, config.preprocess.calibration_set, combo_keys
|
||||
):
|
||||
raise RuntimeError("Selected calibration set does not cover requested run combos")
|
||||
if not self._store.has_combo_coverage(
|
||||
"reference", radar_key, config.preprocess.reference_set, combo_keys
|
||||
):
|
||||
raise RuntimeError("Selected reference set does not cover requested run combos")
|
||||
|
||||
calibration_bundle, reference_bundle = self._config_writer.prepare_bundles(
|
||||
self._store,
|
||||
radar_key,
|
||||
config.preprocess.calibration_set,
|
||||
config.preprocess.reference_set,
|
||||
)
|
||||
config.preprocess.calibration_bundle_path = str(calibration_bundle)
|
||||
config.preprocess.reference_bundle_path = str(reference_bundle)
|
||||
config.runtime.continuous = not single_capture
|
||||
|
||||
if not single_capture:
|
||||
self._prepare_radar_for_native_acquisition(config)
|
||||
|
||||
config_path = self._config_writer.write(config, self._project_root / "python_app/runtime/run_config.json")
|
||||
|
||||
if not single_capture:
|
||||
should_reset_history = (
|
||||
self._history_run_signature is not None and self._history_run_signature != run_signature
|
||||
)
|
||||
if should_reset_history:
|
||||
self._reset_runtime_history()
|
||||
self._log("History reset because run settings changed")
|
||||
self._history_run_signature = run_signature
|
||||
|
||||
self._supervisor.start(config_path)
|
||||
self._close_readers()
|
||||
self._raw_reader = ShmRingReader(config.rings.raw_tap.name)
|
||||
self._pre_reader = ShmRingReader(config.rings.preprocessed_tap.name)
|
||||
self._result_reader = ShmRingReader(config.rings.results.name)
|
||||
self._single_capture_active = single_capture
|
||||
self._single_capture_start_ns = None
|
||||
self._single_capture_seen_raw = False
|
||||
self._single_capture_target_collection_id = None
|
||||
|
||||
self._drop_pending_ring_payloads(include_results=not single_capture)
|
||||
if single_capture:
|
||||
self._single_capture_start_ns = time.monotonic_ns()
|
||||
|
||||
if single_capture:
|
||||
self._status_label.setText("Status: single capture running")
|
||||
self._log("Single capture started")
|
||||
else:
|
||||
self._status_label.setText("Status: running")
|
||||
self._log("Pipeline started")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._single_capture_active = False
|
||||
self._single_capture_start_ns = None
|
||||
self._stop_all_processes()
|
||||
self._show_error(f"Failed to start pipeline: {exc}")
|
||||
|
||||
def _apply_radar_settings(self) -> None:
|
||||
"""Apply current radar settings by preconfiguring native device."""
|
||||
if self._capture_session is not None:
|
||||
self._show_error("Finish or abort capture sequence before applying radar settings")
|
||||
return
|
||||
|
||||
was_running = self._supervisor.is_running()
|
||||
if was_running:
|
||||
self._stop_run()
|
||||
|
||||
try:
|
||||
if self._radar_mode.currentText() == "native":
|
||||
self._refresh_radar_limits_from_device()
|
||||
config = self._build_config()
|
||||
self._prepare_radar_for_native_acquisition(config)
|
||||
self._log("Radar settings applied")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._show_error(f"Failed to apply radar settings: {exc}")
|
||||
finally:
|
||||
if was_running:
|
||||
self._start_run()
|
||||
|
||||
def _prepare_radar_for_native_acquisition(self, config: RunConfigModel) -> None:
|
||||
"""Preconfigure native LibreVNA using current sweep settings."""
|
||||
if config.radar.driver_mode != "native":
|
||||
self._log("Radar pre-configuration skipped (mock mode)")
|
||||
return
|
||||
|
||||
radar_service = LibreVnaService(serial=config.radar.serial or None)
|
||||
if not radar_service.driver_available:
|
||||
raise RuntimeError("LibreVNA Python driver is not available for native pre-configuration")
|
||||
|
||||
try:
|
||||
radar_service.open()
|
||||
radar_service.configure(config.radar.sweep)
|
||||
finally:
|
||||
radar_service.close()
|
||||
|
||||
self._log("Radar pre-configured via Python driver")
|
||||
|
||||
def _stop_run(self) -> None:
|
||||
"""Stop acquisition-side processes and close readers as needed."""
|
||||
was_running = self._supervisor.is_running()
|
||||
if was_running:
|
||||
self._supervisor.stop_orchestrator()
|
||||
self._drain_rings_until_quiet(timeout_s=0.35, poll_s=0.02)
|
||||
self._supervisor.stop_preprocessor()
|
||||
self._drain_rings_until_quiet(timeout_s=0.25, poll_s=0.02)
|
||||
else:
|
||||
self._supervisor.stop()
|
||||
self._drain_rings_once_for_history()
|
||||
keep_results_reader = self._supervisor.is_processor_running()
|
||||
self._close_readers(keep_results=keep_results_reader)
|
||||
self._single_capture_active = False
|
||||
self._single_capture_start_ns = None
|
||||
self._single_capture_seen_raw = False
|
||||
self._single_capture_target_collection_id = None
|
||||
self._update_history_indicator()
|
||||
self._status_label.setText("Status: idle")
|
||||
if was_running:
|
||||
if keep_results_reader:
|
||||
self._log("Acquisition stopped (data_processor kept running)")
|
||||
else:
|
||||
self._log("Pipeline stopped")
|
||||
|
||||
def _stop_all_processes(self) -> None:
|
||||
"""Stop all managed pipeline processes and close all readers."""
|
||||
was_running = self._supervisor.is_running() or self._supervisor.is_processor_running()
|
||||
self._supervisor.stop_all()
|
||||
self._drain_rings_until_quiet(timeout_s=0.25, poll_s=0.02)
|
||||
self._close_readers(keep_results=False)
|
||||
self._single_capture_active = False
|
||||
self._single_capture_start_ns = None
|
||||
self._single_capture_seen_raw = False
|
||||
self._single_capture_target_collection_id = None
|
||||
self._update_history_indicator()
|
||||
self._status_label.setText("Status: idle")
|
||||
if was_running:
|
||||
self._log("All pipeline processes stopped")
|
||||
|
||||
def _close_readers(self, *, keep_results: bool = False) -> None:
|
||||
"""Close active ring readers."""
|
||||
if self._raw_reader is not None:
|
||||
self._raw_reader.close()
|
||||
self._raw_reader = None
|
||||
if self._pre_reader is not None:
|
||||
self._pre_reader.close()
|
||||
self._pre_reader = None
|
||||
if not keep_results and self._result_reader is not None:
|
||||
self._result_reader.close()
|
||||
self._result_reader = None
|
||||
|
||||
def _poll_rings(self) -> None:
|
||||
"""Poll readers, ingest history, and trigger rendering."""
|
||||
for report in self._supervisor.collect_crash_reports():
|
||||
self._status_label.setText("Status: error")
|
||||
self._log(report)
|
||||
|
||||
try:
|
||||
if self._raw_reader is not None:
|
||||
self._read_all_raw()
|
||||
self._read_all_preprocessed()
|
||||
result_latest = self._read_all_results() if self._result_reader is not None else None
|
||||
self._update_history_indicator()
|
||||
|
||||
if self._single_capture_active:
|
||||
if self._finish_single_capture_if_ready(result_latest):
|
||||
return
|
||||
return
|
||||
|
||||
self._draw_preferred_collection(result_latest=result_latest)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._log(f"Reader error: {exc}")
|
||||
|
||||
def _finish_single_capture_if_ready(self, result_latest: ResultCollection | None) -> bool:
|
||||
"""Finalize single capture when new result matching start criteria is available."""
|
||||
if not self._single_capture_active:
|
||||
return False
|
||||
if result_latest is None:
|
||||
return False
|
||||
if self._single_capture_start_ns is None:
|
||||
return False
|
||||
if not self._single_capture_seen_raw:
|
||||
return False
|
||||
if (
|
||||
self._single_capture_target_collection_id is not None
|
||||
and result_latest.collection_id < self._single_capture_target_collection_id
|
||||
):
|
||||
return False
|
||||
if result_latest.monotonic_ns < self._single_capture_start_ns:
|
||||
return False
|
||||
if not self._result_collection_has_trace(result_latest):
|
||||
return False
|
||||
|
||||
self._draw_results(result_latest)
|
||||
self._log("Single capture completed")
|
||||
self._stop_run()
|
||||
return True
|
||||
|
||||
def _read_all_raw(self) -> SweepCollection | None:
|
||||
"""Read available raw collections from raw ring."""
|
||||
assert self._raw_reader is not None
|
||||
latest: SweepCollection | None = None
|
||||
for _ in range(self._max_pop_per_poll):
|
||||
collection = self._raw_reader.pop_raw_collection()
|
||||
if collection is None:
|
||||
break
|
||||
self._raw_history.append(collection)
|
||||
latest = collection
|
||||
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
|
||||
if self._single_capture_target_collection_id is None:
|
||||
self._single_capture_target_collection_id = collection.collection_id
|
||||
return latest
|
||||
|
||||
def _read_all_preprocessed(self) -> None:
|
||||
"""Read available preprocessed collections from preprocessed ring."""
|
||||
if self._pre_reader is None:
|
||||
return
|
||||
|
||||
for _ in range(self._max_pop_per_poll):
|
||||
collection = self._pre_reader.pop_preprocessed_collection()
|
||||
if collection is None:
|
||||
break
|
||||
self._pre_history.append(collection)
|
||||
|
||||
def _read_all_results(self) -> ResultCollection | None:
|
||||
"""Read available result collections from results ring."""
|
||||
assert self._result_reader is not None
|
||||
latest: ResultCollection | None = None
|
||||
for _ in range(self._max_pop_per_poll):
|
||||
collection = self._result_reader.pop_result_collection()
|
||||
if collection is None:
|
||||
break
|
||||
if self._record_result_history(collection):
|
||||
latest = collection
|
||||
return latest
|
||||
|
||||
def _record_result_history(self, collection: ResultCollection) -> bool:
|
||||
"""Merge collection into result history preserving de-dup semantics."""
|
||||
return record_result_history(self._result_history, collection)
|
||||
|
||||
def _drain_rings_once_for_history(self) -> None:
|
||||
"""Perform one non-blocking read pass to extend histories."""
|
||||
if self._raw_reader is not None:
|
||||
self._read_all_raw()
|
||||
self._read_all_preprocessed()
|
||||
if self._result_reader is not None:
|
||||
self._read_all_results()
|
||||
|
||||
def _drain_rings_until_quiet(self, *, timeout_s: float, poll_s: float) -> None:
|
||||
"""Drain rings until history sizes stabilize or timeout expires."""
|
||||
deadline = time.monotonic() + timeout_s
|
||||
stable_rounds = 0
|
||||
previous = (
|
||||
len(self._raw_history),
|
||||
len(self._pre_history),
|
||||
len(self._result_history),
|
||||
)
|
||||
|
||||
while time.monotonic() < deadline and stable_rounds < 2:
|
||||
self._drain_rings_once_for_history()
|
||||
current = (
|
||||
len(self._raw_history),
|
||||
len(self._pre_history),
|
||||
len(self._result_history),
|
||||
)
|
||||
if current == previous:
|
||||
stable_rounds += 1
|
||||
else:
|
||||
stable_rounds = 0
|
||||
previous = current
|
||||
time.sleep(poll_s)
|
||||
|
||||
def _drain_results_until_quiet(self, *, timeout_s: float, poll_s: float) -> None:
|
||||
"""Drain only results ring until size stabilizes or timeout expires."""
|
||||
if self._result_reader is None:
|
||||
return
|
||||
|
||||
deadline = time.monotonic() + timeout_s
|
||||
stable_rounds = 0
|
||||
|
||||
while time.monotonic() < deadline and stable_rounds < 2:
|
||||
latest = self._read_all_results()
|
||||
if latest is None:
|
||||
stable_rounds += 1
|
||||
else:
|
||||
stable_rounds = 0
|
||||
time.sleep(poll_s)
|
||||
|
||||
def _update_history_indicator(self) -> None:
|
||||
"""Update UI label with current history buffer sizes."""
|
||||
self._history_label.setText(
|
||||
f"History: raw={len(self._raw_history)}, "
|
||||
f"preprocessed={len(self._pre_history)}, "
|
||||
f"results={len(self._result_history)}"
|
||||
)
|
||||
|
||||
def _reset_runtime_history(self) -> None:
|
||||
"""Reset runtime history and B-scan caches."""
|
||||
self._replace_runtime_history(retained_raw=[], retained_pre=[], retained_result=[])
|
||||
self._bscan_history_floor_collection_id = 0
|
||||
self._clear_bscan_plot_history()
|
||||
self._update_history_indicator()
|
||||
|
||||
def _replace_runtime_history(
|
||||
self,
|
||||
*,
|
||||
retained_raw: list[SweepCollection],
|
||||
retained_pre: list[SweepCollection],
|
||||
retained_result: list[ResultCollection],
|
||||
) -> None:
|
||||
"""Replace history deques with provided retained tails."""
|
||||
raw_tail = retained_raw[-self._raw_history.maxlen :] if self._raw_history.maxlen is not None else retained_raw
|
||||
pre_tail = retained_pre[-self._pre_history.maxlen :] if self._pre_history.maxlen is not None else retained_pre
|
||||
result_tail = (
|
||||
retained_result[-self._result_history.maxlen :]
|
||||
if self._result_history.maxlen is not None
|
||||
else retained_result
|
||||
)
|
||||
|
||||
self._raw_history.clear()
|
||||
self._pre_history.clear()
|
||||
self._result_history.clear()
|
||||
self._raw_history.extend(raw_tail)
|
||||
self._pre_history.extend(pre_tail)
|
||||
self._result_history.extend(result_tail)
|
||||
|
||||
def _build_run_history_signature(self, config: RunConfigModel) -> tuple[object, ...]:
|
||||
"""Build signature used to decide when history should be reset."""
|
||||
return build_run_history_signature(config)
|
||||
|
||||
def _validate_processing_mode_constraints(self, config: RunConfigModel) -> None:
|
||||
"""Validate processing-mode constraints for run start."""
|
||||
validate_processing_mode_constraints(self._processing_mode.currentText(), config)
|
||||
@@ -0,0 +1,378 @@
|
||||
"""Plot rendering mixin for processed radar result collections."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtCore import QRectF, Qt
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
|
||||
from python_app.gui.plotting.bscan_history import (
|
||||
build_bscan_signature,
|
||||
pick_bscan_display_key,
|
||||
rebuild_bscan_history_from_results,
|
||||
)
|
||||
from python_app.gui.plotting.bscan_math import (
|
||||
bscan_levels,
|
||||
bscan_lookup_table,
|
||||
build_lut,
|
||||
)
|
||||
from python_app.models.dataset_model import ResultCollection, TraceData
|
||||
|
||||
|
||||
class AppWindowPlotMixin:
|
||||
"""Renders result collections on the main pyqtgraph plot."""
|
||||
|
||||
def _draw_preferred_collection(
|
||||
self,
|
||||
*,
|
||||
result_latest: ResultCollection | None,
|
||||
) -> None:
|
||||
"""Draw latest available result collection if present."""
|
||||
if result_latest is None:
|
||||
return
|
||||
self._draw_results(result_latest)
|
||||
|
||||
def _draw_results(self, collection: ResultCollection) -> bool:
|
||||
"""Draw collection based on currently selected processing mode."""
|
||||
if self._processing_mode.currentText() == "bscan":
|
||||
return self._draw_bscan_heatmap(collection)
|
||||
return self._draw_trace_lines(collection)
|
||||
|
||||
def _show_magnitude_curves(self) -> bool:
|
||||
"""Return whether magnitude curves should be rendered."""
|
||||
return self._show_magnitude_checkbox.isChecked()
|
||||
|
||||
def _show_phase_curves(self) -> bool:
|
||||
"""Return whether phase curves should be rendered."""
|
||||
return self._show_phase_checkbox.isChecked()
|
||||
|
||||
def _on_trace_visibility_changed(self, *_args) -> None:
|
||||
"""Redraw pass-through traces when magnitude/phase toggles changed."""
|
||||
if self._processing_mode.currentText() == "bscan":
|
||||
return
|
||||
if self._result_history:
|
||||
self._draw_results(self._result_history[-1])
|
||||
return
|
||||
self._clear_trace_plots()
|
||||
|
||||
def _clear_trace_plots(self) -> None:
|
||||
"""Clear pass-through magnitude and phase plots."""
|
||||
self._trace_magnitude_plot.clear()
|
||||
self._trace_phase_plot.clear()
|
||||
|
||||
def _draw_trace_lines(self, collection: ResultCollection) -> bool:
|
||||
"""Draw result payload traces as stacked magnitude/phase plots."""
|
||||
show_magnitude = self._show_magnitude_curves()
|
||||
show_phase = self._show_phase_curves()
|
||||
magnitude_plot = self._trace_magnitude_plot
|
||||
phase_plot = self._trace_phase_plot
|
||||
|
||||
magnitude_plot.setVisible(show_magnitude)
|
||||
phase_plot.setVisible(show_phase)
|
||||
self._clear_trace_plots()
|
||||
if not show_magnitude and not show_phase:
|
||||
return False
|
||||
|
||||
if show_magnitude:
|
||||
mag_item = magnitude_plot.getPlotItem()
|
||||
magnitude_plot.getViewBox().invertY(False)
|
||||
magnitude_plot.getViewBox().enableAutoRange(x=True, y=True)
|
||||
mag_item.showAxis("left", show=True)
|
||||
mag_item.showAxis("bottom", show=not show_phase)
|
||||
magnitude_plot.setLabel("left", "Magnitude", units="dB")
|
||||
if not show_phase:
|
||||
magnitude_plot.setLabel("bottom", "Frequency", units="Hz")
|
||||
|
||||
if show_phase:
|
||||
phase_item = phase_plot.getPlotItem()
|
||||
phase_plot.getViewBox().invertY(False)
|
||||
phase_plot.getViewBox().enableAutoRange(x=True, y=False)
|
||||
phase_item.showAxis("left", show=True)
|
||||
phase_item.showAxis("bottom", show=True)
|
||||
phase_plot.setLabel("left", "Phase", units="deg")
|
||||
phase_plot.setLabel("bottom", "Frequency", units="Hz")
|
||||
|
||||
palette = [
|
||||
"#4cc9f0",
|
||||
"#f72585",
|
||||
"#b8f2e6",
|
||||
"#ffd166",
|
||||
"#90be6d",
|
||||
"#ff595e",
|
||||
"#6a4c93",
|
||||
"#1982c4",
|
||||
]
|
||||
|
||||
color_index = 0
|
||||
has_data = False
|
||||
x_min = np.inf
|
||||
x_max = -np.inf
|
||||
for block in collection.blocks:
|
||||
for payload in block.payloads:
|
||||
if payload.kind != 1 or payload.trace.size == 0:
|
||||
continue
|
||||
if payload.frequency_hz.size == 0 or payload.frequency_hz.size != payload.trace.size:
|
||||
continue
|
||||
|
||||
local_x_min = float(np.min(payload.frequency_hz))
|
||||
local_x_max = float(np.max(payload.frequency_hz))
|
||||
x_min = min(x_min, local_x_min)
|
||||
x_max = max(x_max, local_x_max)
|
||||
color = palette[color_index % len(palette)]
|
||||
|
||||
if show_magnitude:
|
||||
magnitude_values = 20.0 * np.log10(np.maximum(np.abs(payload.trace), 1e-12))
|
||||
magnitude_curve = pg.PlotCurveItem(
|
||||
payload.frequency_hz,
|
||||
magnitude_values,
|
||||
pen=pg.mkPen(color, width=1.4),
|
||||
)
|
||||
magnitude_plot.addItem(magnitude_curve)
|
||||
has_data = True
|
||||
|
||||
if show_phase:
|
||||
phase_values = np.degrees(np.angle(payload.trace))
|
||||
phase_curve = pg.PlotCurveItem(
|
||||
payload.frequency_hz,
|
||||
phase_values,
|
||||
pen=pg.mkPen(color, width=1.2, style=Qt.PenStyle.DashLine),
|
||||
)
|
||||
phase_plot.addItem(phase_curve)
|
||||
has_data = True
|
||||
|
||||
color_index += 1
|
||||
|
||||
if has_data:
|
||||
if np.isfinite(x_min) and np.isfinite(x_max):
|
||||
if show_magnitude:
|
||||
magnitude_plot.setXRange(x_min, x_max, padding=0.02)
|
||||
if show_phase:
|
||||
phase_plot.setXRange(x_min, x_max, padding=0.02)
|
||||
if show_phase:
|
||||
phase_plot.setYRange(-180.0, 180.0, padding=0.02)
|
||||
return has_data
|
||||
|
||||
def _draw_bscan_heatmap(self, _collection: ResultCollection) -> bool:
|
||||
"""Draw B-scan image rebuilt from processed result history."""
|
||||
self._disable_phase_axis()
|
||||
self._sync_bscan_history_from_results()
|
||||
return self._draw_bscan_heatmap_from_history()
|
||||
|
||||
def _draw_bscan_heatmap_from_history(self) -> bool:
|
||||
"""Render B-scan heatmap from currently cached history arrays."""
|
||||
display_key = self._pick_bscan_display_key()
|
||||
if display_key is None:
|
||||
return False
|
||||
|
||||
history = self._bscan_history_by_combo.get(display_key)
|
||||
depth_axis = self._bscan_depth_axis_by_combo.get(display_key)
|
||||
if not history or depth_axis is None:
|
||||
return False
|
||||
|
||||
sweeps = np.vstack(history).astype(np.float32, copy=False)
|
||||
if sweeps.size == 0:
|
||||
return False
|
||||
|
||||
depth_min = float(np.min(depth_axis))
|
||||
depth_max = float(np.max(depth_axis))
|
||||
depth_span = max(depth_max - depth_min, 1e-6)
|
||||
sweep_count = sweeps.shape[0]
|
||||
sweep_width = float(max(sweep_count, 1))
|
||||
x_min = 0.5
|
||||
x_max = x_min + sweep_width
|
||||
|
||||
image_item = pg.ImageItem(axisOrder="row-major")
|
||||
image_item.setImage(sweeps.T, autoLevels=False)
|
||||
image_item.setRect(QRectF(x_min, depth_min, sweep_width, depth_span))
|
||||
|
||||
axis_mode = self._bscan_axis.currentText()
|
||||
image_item.setLookupTable(self._bscan_lookup_table(axis_mode))
|
||||
image_item.setLevels(self._bscan_levels(sweeps, axis_mode))
|
||||
|
||||
self._plot.clear()
|
||||
view_box = self._plot.getViewBox()
|
||||
view_box.invertY(True)
|
||||
view_box.enableAutoRange(x=False, y=False)
|
||||
self._plot.getPlotItem().showAxis("left", show=True)
|
||||
self._plot.getPlotItem().showAxis("bottom", show=True)
|
||||
self._plot.setLabel("bottom", "Sweep #")
|
||||
self._plot.setLabel("left", "Depth", units="m")
|
||||
self._plot.addItem(image_item)
|
||||
self._plot.setXRange(x_min, x_max, padding=0.02)
|
||||
self._plot.setYRange(depth_min, depth_max, padding=0.02)
|
||||
self._plot.setTitle(f"B-scan in{display_key[0]}/out{display_key[1]} | sweeps={sweep_count}")
|
||||
return True
|
||||
|
||||
def _sync_bscan_history_from_results(self) -> None:
|
||||
"""Rebuild B-scan history cache when live params or inputs changed."""
|
||||
self._advance_bscan_floor_to_cpp_window()
|
||||
signature = self._bscan_signature()
|
||||
if signature == self._bscan_render_signature:
|
||||
return
|
||||
self._rebuild_bscan_history_from_results()
|
||||
self._bscan_render_signature = signature
|
||||
|
||||
def _bscan_signature(self) -> tuple[object, ...]:
|
||||
"""Build state signature for B-scan history cache invalidation."""
|
||||
live_config = self._live_processing_config()
|
||||
result_history = list(self._result_history)
|
||||
return build_bscan_signature(
|
||||
live_config=live_config,
|
||||
result_history=result_history,
|
||||
history_limit=self._bscan_history_limit,
|
||||
floor_collection_id=self._bscan_history_floor_collection_id,
|
||||
)
|
||||
|
||||
def _rebuild_bscan_history_from_results(self) -> None:
|
||||
"""Recompute B-scan history cache from results history buffer."""
|
||||
result_history = list(self._result_history)
|
||||
history_by_combo, depth_axis_by_combo = rebuild_bscan_history_from_results(
|
||||
result_history=result_history,
|
||||
history_limit=self._bscan_history_limit,
|
||||
floor_collection_id=self._bscan_history_floor_collection_id,
|
||||
)
|
||||
self._bscan_history_by_combo = history_by_combo
|
||||
self._bscan_depth_axis_by_combo = depth_axis_by_combo
|
||||
|
||||
def _pick_bscan_display_key(self) -> tuple[int, int] | None:
|
||||
"""Choose combo history key to render."""
|
||||
return pick_bscan_display_key(self._bscan_history_by_combo)
|
||||
|
||||
def _bscan_lookup_table(self, axis_mode: str) -> np.ndarray:
|
||||
"""Return lookup table for current B-scan axis mode."""
|
||||
return bscan_lookup_table(axis_mode)
|
||||
|
||||
@staticmethod
|
||||
def _build_lut(stops: list[str], *, size: int = 256) -> np.ndarray:
|
||||
"""Backward-compatible wrapper around LUT builder."""
|
||||
return build_lut(stops, size=size)
|
||||
|
||||
@staticmethod
|
||||
def _bscan_levels(sweeps: np.ndarray, axis_mode: str) -> tuple[float, float]:
|
||||
"""Return display levels for B-scan image."""
|
||||
return bscan_levels(sweeps, axis_mode)
|
||||
|
||||
def _clear_bscan_plot_history(self) -> None:
|
||||
"""Drop cached B-scan history and invalidate cache signature."""
|
||||
self._bscan_history_by_combo.clear()
|
||||
self._bscan_depth_axis_by_combo.clear()
|
||||
self._bscan_render_signature = None
|
||||
|
||||
def _advance_bscan_floor_to_cpp_window(self) -> None:
|
||||
"""Clamp B-scan source history to C++ available replay window."""
|
||||
if not self._result_history:
|
||||
return
|
||||
|
||||
cpp_window_limit = min(
|
||||
int(self._defaults_config.rings.preprocessed.capacity),
|
||||
int(self._defaults_config.rings.results.capacity),
|
||||
)
|
||||
cpp_window_limit = max(1, cpp_window_limit)
|
||||
latest_collection_id = int(self._result_history[-1].collection_id)
|
||||
current_floor = int(self._bscan_history_floor_collection_id)
|
||||
|
||||
# Collection ids restart from 1 on new C++ run; release floor only while
|
||||
# acquisition is running, so manual "remove last" behavior in stopped mode
|
||||
# remains deterministic.
|
||||
if latest_collection_id < current_floor and self._supervisor.is_running():
|
||||
self._bscan_history_floor_collection_id = 0
|
||||
current_floor = 0
|
||||
|
||||
floor_candidate = max(0, latest_collection_id - cpp_window_limit)
|
||||
if floor_candidate > current_floor:
|
||||
self._bscan_history_floor_collection_id = floor_candidate
|
||||
|
||||
def _ensure_phase_view_box(self) -> pg.ViewBox:
|
||||
"""Create or return secondary right-axis ViewBox for phase curves."""
|
||||
plot_item = self._plot.getPlotItem()
|
||||
phase_view_box = self._phase_viewbox
|
||||
if phase_view_box is None:
|
||||
phase_view_box = pg.ViewBox()
|
||||
self._phase_viewbox = phase_view_box
|
||||
plot_item.scene().addItem(phase_view_box)
|
||||
plot_item.getAxis("right").linkToView(phase_view_box)
|
||||
phase_view_box.setXLink(plot_item.vb)
|
||||
plot_item.vb.sigResized.connect(self._update_phase_view_box_geometry)
|
||||
self._update_phase_view_box_geometry()
|
||||
return phase_view_box
|
||||
|
||||
def _update_phase_view_box_geometry(self) -> None:
|
||||
"""Keep right-axis ViewBox geometry in sync with main plot ViewBox."""
|
||||
phase_view_box = self._phase_viewbox
|
||||
if phase_view_box is None:
|
||||
return
|
||||
plot_item = self._plot.getPlotItem()
|
||||
phase_view_box.setGeometry(plot_item.vb.sceneBoundingRect())
|
||||
phase_view_box.linkedViewChanged(plot_item.vb, phase_view_box.XAxis)
|
||||
|
||||
def _clear_phase_overlay(self) -> None:
|
||||
"""Remove all phase curves from secondary ViewBox."""
|
||||
self._trace_phase_plot.clear()
|
||||
|
||||
def _disable_phase_axis(self) -> None:
|
||||
"""Hide right axis and clear phase overlay when phase is not rendered."""
|
||||
self._clear_phase_overlay()
|
||||
|
||||
def _result_collection_has_trace(self, collection: ResultCollection) -> bool:
|
||||
"""Return `True` when collection contains at least one trace payload."""
|
||||
for block in collection.blocks:
|
||||
for payload in block.payloads:
|
||||
if payload.kind == 1 and payload.trace.size > 0:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _draw_single_trace(self, trace: TraceData, title: str) -> None:
|
||||
"""Draw one trace on stacked magnitude/phase plots."""
|
||||
show_magnitude = self._show_magnitude_curves()
|
||||
show_phase = self._show_phase_curves()
|
||||
magnitude_plot = self._trace_magnitude_plot
|
||||
phase_plot = self._trace_phase_plot
|
||||
|
||||
magnitude_plot.setVisible(show_magnitude)
|
||||
phase_plot.setVisible(show_phase)
|
||||
self._clear_trace_plots()
|
||||
if not show_magnitude and not show_phase:
|
||||
return
|
||||
|
||||
if show_magnitude:
|
||||
magnitude_plot.getViewBox().invertY(False)
|
||||
magnitude_plot.getViewBox().enableAutoRange(x=True, y=True)
|
||||
magnitude_plot.getPlotItem().showAxis("bottom", show=not show_phase)
|
||||
magnitude_plot.setLabel("left", "Magnitude", units="dB")
|
||||
magnitude_plot.setTitle(title)
|
||||
if not show_phase:
|
||||
magnitude_plot.setLabel("bottom", "Frequency", units="Hz")
|
||||
if show_phase:
|
||||
phase_plot.getViewBox().invertY(False)
|
||||
phase_plot.getViewBox().enableAutoRange(x=True, y=False)
|
||||
phase_plot.getPlotItem().showAxis("bottom", show=True)
|
||||
phase_plot.setLabel("left", "Phase", units="deg")
|
||||
phase_plot.setLabel("bottom", "Frequency", units="Hz")
|
||||
phase_plot.setTitle(title)
|
||||
|
||||
if show_magnitude:
|
||||
magnitude_db = 20.0 * np.log10(np.maximum(np.abs(trace.s21), 1e-12))
|
||||
magnitude_curve = pg.PlotCurveItem(
|
||||
trace.frequency_hz,
|
||||
magnitude_db,
|
||||
pen=pg.mkPen("#ffd166", width=1.8),
|
||||
)
|
||||
magnitude_plot.addItem(magnitude_curve)
|
||||
|
||||
if show_phase:
|
||||
phase_deg = np.degrees(np.angle(trace.s21))
|
||||
phase_curve = pg.PlotCurveItem(
|
||||
trace.frequency_hz,
|
||||
phase_deg,
|
||||
pen=pg.mkPen("#80ed99", width=1.4, style=Qt.PenStyle.DashLine),
|
||||
)
|
||||
phase_plot.addItem(phase_curve)
|
||||
phase_plot.setYRange(-180.0, 180.0, padding=0.02)
|
||||
|
||||
if np.size(trace.frequency_hz) > 1:
|
||||
x_min = float(np.min(trace.frequency_hz))
|
||||
x_max = float(np.max(trace.frequency_hz))
|
||||
if show_magnitude:
|
||||
magnitude_plot.setXRange(x_min, x_max, padding=0.02)
|
||||
if show_phase:
|
||||
phase_plot.setXRange(x_min, x_max, padding=0.02)
|
||||
@@ -0,0 +1,224 @@
|
||||
"""Preprocessing-set selection and sequential capture workflow mixin."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from python_app.gui.preprocess_dialog import PreprocessDialog
|
||||
from python_app.workflows.sequential_capture_workflow import SequentialCaptureSession
|
||||
|
||||
|
||||
class AppWindowPreprocessMixin:
|
||||
"""Handles calibration/reference set management and capture workflow."""
|
||||
|
||||
def _open_preprocess_panel(self) -> None:
|
||||
"""Open preprocessing dialog and refresh available sets."""
|
||||
dialog = self._ensure_preprocess_dialog()
|
||||
try:
|
||||
self._refresh_sets()
|
||||
self._update_capture_dialog_state()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._show_error(f"Failed to open preprocessing panel: {exc}")
|
||||
return
|
||||
|
||||
dialog.show()
|
||||
dialog.raise_()
|
||||
dialog.activateWindow()
|
||||
|
||||
def _ensure_preprocess_dialog(self) -> PreprocessDialog:
|
||||
"""Create preprocessing dialog lazily and wire its signals once."""
|
||||
if self._preprocess_dialog is not None:
|
||||
return self._preprocess_dialog
|
||||
|
||||
dialog = PreprocessDialog(self)
|
||||
dialog.refresh_requested.connect(self._refresh_sets)
|
||||
dialog.selection_changed.connect(self._on_preprocess_selection_changed)
|
||||
dialog.start_sequence_requested.connect(self._start_capture_sequence)
|
||||
dialog.capture_next_requested.connect(self._capture_next_combo)
|
||||
dialog.abort_sequence_requested.connect(self._abort_capture_sequence)
|
||||
self._preprocess_dialog = dialog
|
||||
self._update_capture_dialog_state()
|
||||
return dialog
|
||||
|
||||
def _on_preprocess_selection_changed(self, calibration_set: str, reference_set: str) -> None:
|
||||
"""Persist selected preprocessing set names from dialog."""
|
||||
self._selected_calibration_set = calibration_set.strip()
|
||||
self._selected_reference_set = reference_set.strip()
|
||||
self._refresh_preprocess_summary_labels()
|
||||
|
||||
def _refresh_preprocess_summary_labels(self) -> None:
|
||||
"""Update compact summary labels in the main window."""
|
||||
self._selected_calibration_label.setText(self._selected_calibration_set or "<not selected>")
|
||||
self._selected_reference_label.setText(self._selected_reference_set or "<not selected>")
|
||||
|
||||
def _refresh_sets(self) -> None:
|
||||
"""Refresh calibration/reference set lists for current radar key."""
|
||||
config = self._build_config()
|
||||
radar_key = self._radar_key(config)
|
||||
calibration_sets = self._store.list_sets("calibration", radar_key)
|
||||
reference_sets = self._store.list_sets("reference", radar_key)
|
||||
|
||||
dialog = self._ensure_preprocess_dialog()
|
||||
dialog.set_calibration_sets(calibration_sets)
|
||||
dialog.set_reference_sets(reference_sets)
|
||||
|
||||
if self._selected_calibration_set not in calibration_sets:
|
||||
self._selected_calibration_set = calibration_sets[0] if calibration_sets else ""
|
||||
if self._selected_reference_set not in reference_sets:
|
||||
self._selected_reference_set = reference_sets[0] if reference_sets else ""
|
||||
|
||||
dialog.set_selected_sets(self._selected_calibration_set, self._selected_reference_set)
|
||||
self._refresh_preprocess_summary_labels()
|
||||
self._log(f"Set lists refreshed for key={radar_key}")
|
||||
|
||||
def _start_capture_sequence(self, kind: str) -> None:
|
||||
"""Start sequential capture session for requested preprocessing kind."""
|
||||
if self._capture_session is not None:
|
||||
self._show_error("Another capture sequence is already active")
|
||||
return
|
||||
|
||||
dialog = self._ensure_preprocess_dialog()
|
||||
set_name = dialog.set_name()
|
||||
if not set_name:
|
||||
self._show_error("Set name is required")
|
||||
return
|
||||
|
||||
was_running = self._supervisor.is_running()
|
||||
if was_running:
|
||||
self._log("Pipeline paused for exclusive hardware capture")
|
||||
self._stop_run()
|
||||
|
||||
self._resume_pipeline_after_capture = was_running
|
||||
|
||||
try:
|
||||
config = self._build_config()
|
||||
radar_key = self._radar_key(config)
|
||||
existing_sets = self._store.list_sets(kind, radar_key)
|
||||
if set_name in existing_sets:
|
||||
raise RuntimeError(f"Set '{set_name}' already exists for {kind} and cannot be overwritten")
|
||||
|
||||
session = SequentialCaptureSession(config=config, kind=kind, set_name=set_name)
|
||||
session.open()
|
||||
self._capture_session = session
|
||||
|
||||
dialog.clear_capture_log()
|
||||
dialog.set_status(f"{kind.title()} sequence started")
|
||||
self._update_capture_dialog_state()
|
||||
self._log(f"{kind.title()} sequence started for set={set_name}; fill all N*M combos")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._cleanup_capture_session()
|
||||
self._show_error(f"Failed to start {kind} sequence: {exc}")
|
||||
self._resume_pipeline_if_needed()
|
||||
|
||||
def _capture_next_combo(self) -> None:
|
||||
"""Capture next combo in active sequential capture session."""
|
||||
session = self._capture_session
|
||||
if session is None:
|
||||
self._show_error("No active capture sequence")
|
||||
return
|
||||
|
||||
dialog = self._ensure_preprocess_dialog()
|
||||
|
||||
try:
|
||||
trace = session.capture_current_combo()
|
||||
state = session.state()
|
||||
tx_label, rx_label = dialog.antenna_labels()
|
||||
|
||||
dialog.append_capture_log_entry(
|
||||
kind=session.kind,
|
||||
captured_count=state.captured_count,
|
||||
total_count=state.total_count,
|
||||
input_pos=trace.combo.input_pos,
|
||||
output_pos=trace.combo.output_pos,
|
||||
tx_label=tx_label,
|
||||
rx_label=rx_label,
|
||||
)
|
||||
|
||||
dialog.draw_last_trace(trace, title=f"{session.kind.title()} captured")
|
||||
self._draw_single_trace(trace, title=f"{session.kind.title()} last trace")
|
||||
|
||||
self._log(
|
||||
f"{session.kind.title()} capture: {state.captured_count}/{state.total_count} | "
|
||||
f"input={trace.combo.input_pos} output={trace.combo.output_pos}"
|
||||
)
|
||||
|
||||
if session.is_complete():
|
||||
radar_key, collection = session.finalize(self._store)
|
||||
set_name = session.set_name
|
||||
kind = session.kind
|
||||
self._cleanup_capture_session()
|
||||
|
||||
if kind == "calibration":
|
||||
self._selected_calibration_set = set_name
|
||||
else:
|
||||
self._selected_reference_set = set_name
|
||||
|
||||
self._refresh_sets()
|
||||
dialog.set_status(f"{kind.title()} set saved: {set_name} ({len(collection.traces)} traces)")
|
||||
self._log(f"{kind.title()} sequence completed and saved: set={set_name}, key={radar_key}")
|
||||
self._resume_pipeline_if_needed()
|
||||
else:
|
||||
self._update_capture_dialog_state()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._show_error(f"Failed to capture combo: {exc}")
|
||||
self._abort_capture_sequence()
|
||||
|
||||
def _abort_capture_sequence(self, *, resume_pipeline: bool = True) -> None:
|
||||
"""Abort active capture session and optionally resume pipeline."""
|
||||
if self._capture_session is None:
|
||||
return
|
||||
|
||||
kind = self._capture_session.kind
|
||||
self._cleanup_capture_session()
|
||||
dialog = self._ensure_preprocess_dialog()
|
||||
dialog.set_status(f"{kind.title()} sequence aborted")
|
||||
self._log(f"{kind.title()} sequence aborted")
|
||||
if resume_pipeline:
|
||||
self._resume_pipeline_if_needed()
|
||||
|
||||
def _update_capture_dialog_state(self) -> None:
|
||||
"""Sync dialog state widgets with active capture session."""
|
||||
if self._preprocess_dialog is None:
|
||||
return
|
||||
|
||||
if self._capture_session is None:
|
||||
self._preprocess_dialog.set_capture_state(
|
||||
kind=None,
|
||||
captured_count=0,
|
||||
total_count=0,
|
||||
next_input=None,
|
||||
next_output=None,
|
||||
)
|
||||
return
|
||||
|
||||
state = self._capture_session.state()
|
||||
next_input = None
|
||||
next_output = None
|
||||
if state.current_combo is not None:
|
||||
next_input = state.current_combo.input
|
||||
next_output = state.current_combo.output
|
||||
|
||||
self._preprocess_dialog.set_capture_state(
|
||||
kind=state.kind,
|
||||
captured_count=state.captured_count,
|
||||
total_count=state.total_count,
|
||||
next_input=next_input,
|
||||
next_output=next_output,
|
||||
)
|
||||
|
||||
def _cleanup_capture_session(self) -> None:
|
||||
"""Close and clear current capture session object."""
|
||||
if self._capture_session is not None:
|
||||
self._capture_session.close()
|
||||
self._capture_session = None
|
||||
self._update_capture_dialog_state()
|
||||
|
||||
def _resume_pipeline_if_needed(self) -> None:
|
||||
"""Resume acquisition pipeline if it was paused for capture session."""
|
||||
should_resume = self._resume_pipeline_after_capture
|
||||
self._resume_pipeline_after_capture = False
|
||||
if not should_resume:
|
||||
return
|
||||
|
||||
try:
|
||||
self._start_run()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._show_error(f"Failed to resume pipeline after capture: {exc}")
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Snapshot and ring-drain helper mixin for the main GUI window."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import time
|
||||
|
||||
from PyQt6.QtWidgets import QFileDialog
|
||||
|
||||
|
||||
class AppWindowSnapshotMixin:
|
||||
"""Saves runtime data snapshots and maintains ring-reader freshness."""
|
||||
|
||||
def _save_snapshot(self) -> None:
|
||||
"""Save runtime snapshot in numpy-directory format."""
|
||||
self._drain_runtime_rings_for_snapshot()
|
||||
|
||||
if not self._raw_history and not self._pre_history and not self._result_history:
|
||||
self._show_error("No runtime data is available for save")
|
||||
return
|
||||
|
||||
try:
|
||||
last_n = int(self._save_count.value())
|
||||
output_root = Path(self._save_path_input.text().strip()).expanduser()
|
||||
snapshot_name = self._save_name_input.text().strip()
|
||||
snapshot_dir, summary = self._store.save_runtime_snapshot_numpy(
|
||||
output_root,
|
||||
snapshot_name,
|
||||
list(self._raw_history),
|
||||
list(self._pre_history),
|
||||
list(self._result_history),
|
||||
last_n,
|
||||
)
|
||||
self._log(
|
||||
f"Saved numpy snapshot: {snapshot_dir} "
|
||||
f"(raw={summary.get('raw_count', 0)}, "
|
||||
f"preprocessed={summary.get('preprocessed_count', 0)}, "
|
||||
f"results={summary.get('result_count', 0)}, "
|
||||
f"mode={summary.get('selection_mode', 'unknown')})"
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._show_error(f"Failed to save snapshot: {exc}")
|
||||
|
||||
def _browse_save_path(self) -> None:
|
||||
"""Open directory picker for snapshot output path."""
|
||||
selected = QFileDialog.getExistingDirectory(
|
||||
self,
|
||||
"Select Snapshot Directory",
|
||||
self._save_path_input.text().strip() or str(self._project_root),
|
||||
)
|
||||
if selected:
|
||||
self._save_path_input.setText(selected)
|
||||
|
||||
def _drain_runtime_rings_for_snapshot(self) -> None:
|
||||
"""Drain readers before snapshot to reduce partial-history races."""
|
||||
if self._raw_reader is None and self._pre_reader is None and self._result_reader is None:
|
||||
return
|
||||
try:
|
||||
start_raw_count = len(self._raw_history)
|
||||
start_pre_count = len(self._pre_history)
|
||||
start_result_count = len(self._result_history)
|
||||
deadline = time.monotonic() + 0.8
|
||||
|
||||
while True:
|
||||
progress = False
|
||||
|
||||
if self._raw_reader is not None:
|
||||
for _ in range(self._max_pop_per_snapshot_drain):
|
||||
collection = self._raw_reader.pop_raw_collection()
|
||||
if collection is None:
|
||||
break
|
||||
self._raw_history.append(collection)
|
||||
progress = True
|
||||
|
||||
if self._pre_reader is not None:
|
||||
for _ in range(self._max_pop_per_snapshot_drain):
|
||||
collection = self._pre_reader.pop_preprocessed_collection()
|
||||
if collection is None:
|
||||
break
|
||||
self._pre_history.append(collection)
|
||||
progress = True
|
||||
|
||||
if self._result_reader is not None:
|
||||
for _ in range(self._max_pop_per_snapshot_drain):
|
||||
collection = self._result_reader.pop_result_collection()
|
||||
if collection is None:
|
||||
break
|
||||
self._record_result_history(collection)
|
||||
progress = True
|
||||
|
||||
if progress:
|
||||
continue
|
||||
|
||||
missing_raw = self._raw_reader is not None and len(self._raw_history) == start_raw_count
|
||||
missing_pre = self._pre_reader is not None and len(self._pre_history) == start_pre_count
|
||||
got_results = len(self._result_history) > start_result_count
|
||||
|
||||
if got_results and (missing_raw or missing_pre) and time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
continue
|
||||
break
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._log(f"Snapshot drain warning: {exc}")
|
||||
|
||||
def _drop_pending_ring_payloads(self, *, include_results: bool = True) -> None:
|
||||
"""Drop unread payloads from active readers."""
|
||||
dropped_raw = self._raw_reader.drop_all() if self._raw_reader is not None else 0
|
||||
dropped_pre = self._pre_reader.drop_all() if self._pre_reader is not None else 0
|
||||
dropped_results = 0
|
||||
if include_results and self._result_reader is not None:
|
||||
dropped_results = self._result_reader.drop_all()
|
||||
|
||||
if dropped_raw or dropped_pre or dropped_results:
|
||||
self._log(
|
||||
"Single capture ring reset: "
|
||||
f"dropped raw={dropped_raw}, "
|
||||
f"preprocessed={dropped_pre}, "
|
||||
f"results={dropped_results}"
|
||||
)
|
||||
@@ -0,0 +1,192 @@
|
||||
"""UI construction mixin for the main radar control window."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QComboBox,
|
||||
QFrame,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPlainTextEdit,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QStackedWidget,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
import pyqtgraph as pg
|
||||
|
||||
from python_app.gui.controllers.sections import (
|
||||
build_data_actions_group,
|
||||
build_hardware_actions_group,
|
||||
build_pipeline_group,
|
||||
build_preprocess_summary_group,
|
||||
build_processing_group,
|
||||
build_radar_group,
|
||||
build_switch_group,
|
||||
)
|
||||
|
||||
|
||||
class AppWindowUiMixin:
|
||||
"""Builds and wires all static UI widgets."""
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
"""Build main window widgets, plot area, and settings panel."""
|
||||
self.setWindowTitle("Radar System Control")
|
||||
root = QWidget(self)
|
||||
self.setCentralWidget(root)
|
||||
|
||||
layout = QHBoxLayout(root)
|
||||
layout.setContentsMargins(12, 12, 12, 12)
|
||||
layout.setSpacing(14)
|
||||
|
||||
self._plot_stack = QStackedWidget(root)
|
||||
|
||||
self._plot = pg.PlotWidget(background="#0f141c")
|
||||
self._plot.showGrid(x=True, y=True, alpha=0.2)
|
||||
self._plot.setLabel("bottom", "Frequency", units="Hz")
|
||||
self._plot.setLabel("left", "Magnitude", units="dB")
|
||||
self._plot_stack.addWidget(self._plot)
|
||||
|
||||
self._trace_plots_container = QWidget(root)
|
||||
trace_layout = QVBoxLayout(self._trace_plots_container)
|
||||
trace_layout.setContentsMargins(0, 0, 0, 0)
|
||||
trace_layout.setSpacing(6)
|
||||
|
||||
self._trace_magnitude_plot = pg.PlotWidget(background="#0f141c")
|
||||
self._trace_magnitude_plot.showGrid(x=True, y=True, alpha=0.2)
|
||||
self._trace_magnitude_plot.setLabel("left", "Magnitude", units="dB")
|
||||
self._trace_magnitude_plot.getPlotItem().showAxis("bottom", show=False)
|
||||
self._trace_magnitude_plot.getPlotItem().setDownsampling(mode="peak")
|
||||
self._trace_magnitude_plot.getPlotItem().setClipToView(True)
|
||||
trace_layout.addWidget(self._trace_magnitude_plot, stretch=1)
|
||||
|
||||
self._trace_phase_plot = pg.PlotWidget(background="#0f141c")
|
||||
self._trace_phase_plot.showGrid(x=True, y=True, alpha=0.2)
|
||||
self._trace_phase_plot.setLabel("left", "Phase", units="deg")
|
||||
self._trace_phase_plot.setLabel("bottom", "Frequency", units="Hz")
|
||||
self._trace_phase_plot.getPlotItem().setDownsampling(mode="peak")
|
||||
self._trace_phase_plot.getPlotItem().setClipToView(True)
|
||||
trace_layout.addWidget(self._trace_phase_plot, stretch=1)
|
||||
|
||||
self._plot_stack.addWidget(self._trace_plots_container)
|
||||
self._plot_stack.setCurrentWidget(self._trace_plots_container)
|
||||
layout.addWidget(self._plot_stack, stretch=11)
|
||||
|
||||
self._settings_toggle_button = QPushButton("<")
|
||||
self._settings_toggle_button.setObjectName("settingsToggleButton")
|
||||
self._settings_toggle_button.setFixedWidth(26)
|
||||
self._settings_toggle_button.clicked.connect(self._toggle_settings_panel)
|
||||
layout.addWidget(self._settings_toggle_button, stretch=0)
|
||||
|
||||
self._settings_panel = QWidget(root)
|
||||
self._settings_panel.setMinimumWidth(659)
|
||||
right_layout = QVBoxLayout(self._settings_panel)
|
||||
right_layout.setContentsMargins(0, 0, 0, 0)
|
||||
right_layout.setSpacing(10)
|
||||
|
||||
# Build log widget early so error handlers can safely write during UI construction.
|
||||
self._log_box = QPlainTextEdit(self._settings_panel)
|
||||
self._log_box.setReadOnly(True)
|
||||
self._log_box.setMinimumHeight(170)
|
||||
|
||||
pipeline_group = self._build_pipeline_group()
|
||||
hardware_actions_group = self._build_hardware_actions_group()
|
||||
data_actions_group = self._build_data_actions_group()
|
||||
preprocess_summary_group = self._build_preprocess_summary_group()
|
||||
radar_group = self._build_radar_group()
|
||||
processing_group = self._build_processing_group()
|
||||
switch_group = self._build_switch_group()
|
||||
|
||||
controls = QWidget(self._settings_panel)
|
||||
controls_layout = QVBoxLayout(controls)
|
||||
controls_layout.setContentsMargins(0, 0, 0, 0)
|
||||
controls_layout.setSpacing(10)
|
||||
controls_layout.addWidget(pipeline_group)
|
||||
controls_layout.addWidget(hardware_actions_group)
|
||||
controls_layout.addWidget(data_actions_group)
|
||||
controls_layout.addWidget(preprocess_summary_group)
|
||||
controls_layout.addWidget(processing_group)
|
||||
controls_layout.addWidget(radar_group)
|
||||
controls_layout.addWidget(switch_group)
|
||||
controls_layout.addStretch(1)
|
||||
|
||||
scroll = QScrollArea(self._settings_panel)
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setFrameShape(QFrame.Shape.NoFrame)
|
||||
scroll.setWidget(controls)
|
||||
right_layout.addWidget(scroll, stretch=1)
|
||||
|
||||
self._status_label = QLabel("Status: idle", self._settings_panel)
|
||||
self._status_label.setObjectName("statusLabel")
|
||||
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")
|
||||
right_layout.addWidget(self._history_label)
|
||||
|
||||
right_layout.addWidget(self._log_box, stretch=0)
|
||||
|
||||
layout.addWidget(self._settings_panel, stretch=8)
|
||||
self._set_settings_panel_visible(True)
|
||||
self.resize(1650, 940)
|
||||
|
||||
def _toggle_settings_panel(self) -> None:
|
||||
"""Toggle settings panel visibility."""
|
||||
self._set_settings_panel_visible(not self._settings_panel.isVisible())
|
||||
|
||||
def _set_settings_panel_visible(self, visible: bool) -> None:
|
||||
"""Set settings panel visibility and update toggle button glyph."""
|
||||
self._settings_panel.setVisible(visible)
|
||||
if visible:
|
||||
self._settings_toggle_button.setText(">")
|
||||
self._settings_toggle_button.setToolTip("Hide settings panel")
|
||||
else:
|
||||
self._settings_toggle_button.setText("<")
|
||||
self._settings_toggle_button.setToolTip("Show settings panel")
|
||||
|
||||
def _set_plot_mode(self, mode: str) -> None:
|
||||
"""Switch visible plot surface based on processing mode."""
|
||||
if mode == "bscan":
|
||||
self._plot_stack.setCurrentWidget(self._plot)
|
||||
return
|
||||
self._plot_stack.setCurrentWidget(self._trace_plots_container)
|
||||
|
||||
def _build_pipeline_group(self) -> QGroupBox:
|
||||
"""Build pipeline controls section."""
|
||||
return build_pipeline_group(self)
|
||||
|
||||
def _build_hardware_actions_group(self) -> QGroupBox:
|
||||
"""Build hardware actions section."""
|
||||
return build_hardware_actions_group(self)
|
||||
|
||||
def _build_data_actions_group(self) -> QGroupBox:
|
||||
"""Build data actions section."""
|
||||
return build_data_actions_group(self)
|
||||
|
||||
def _build_preprocess_summary_group(self) -> QGroupBox:
|
||||
"""Build selected preprocess sets summary section."""
|
||||
return build_preprocess_summary_group(self)
|
||||
|
||||
def _build_processing_group(self) -> QGroupBox:
|
||||
"""Build processing mode section."""
|
||||
return build_processing_group(self)
|
||||
|
||||
def _build_radar_group(self) -> QGroupBox:
|
||||
"""Build radar settings section."""
|
||||
return build_radar_group(self)
|
||||
|
||||
def _build_switch_group(self) -> QGroupBox:
|
||||
"""Build switch settings section."""
|
||||
return build_switch_group(self)
|
||||
|
||||
@staticmethod
|
||||
def _set_combo_current_text(combo: QComboBox, value: str) -> None:
|
||||
"""Select combo item by text, appending it when missing."""
|
||||
index = combo.findText(value)
|
||||
if index >= 0:
|
||||
combo.setCurrentIndex(index)
|
||||
return
|
||||
combo.addItem(value)
|
||||
combo.setCurrentIndex(combo.count() - 1)
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Composable UI section builders used by AppWindow UI mixin."""
|
||||
|
||||
from python_app.gui.controllers.sections.data_actions_section import build_data_actions_group
|
||||
from python_app.gui.controllers.sections.hardware_actions_section import build_hardware_actions_group
|
||||
from python_app.gui.controllers.sections.pipeline_section import build_pipeline_group
|
||||
from python_app.gui.controllers.sections.preprocess_summary_section import build_preprocess_summary_group
|
||||
from python_app.gui.controllers.sections.processing_section import build_processing_group
|
||||
from python_app.gui.controllers.sections.radar_section import build_radar_group
|
||||
from python_app.gui.controllers.sections.switch_section import build_switch_group
|
||||
|
||||
__all__ = [
|
||||
"build_data_actions_group",
|
||||
"build_hardware_actions_group",
|
||||
"build_pipeline_group",
|
||||
"build_preprocess_summary_group",
|
||||
"build_processing_group",
|
||||
"build_radar_group",
|
||||
"build_switch_group",
|
||||
]
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Builder for snapshot and storage actions section."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtWidgets import QGroupBox, QHBoxLayout, QLabel, QLineEdit, QPushButton, QSpinBox, QVBoxLayout
|
||||
|
||||
|
||||
def build_data_actions_group(owner) -> QGroupBox:
|
||||
"""Create snapshot save controls section."""
|
||||
group = QGroupBox("Data Actions")
|
||||
layout = QVBoxLayout(group)
|
||||
layout.setSpacing(8)
|
||||
|
||||
save_row = QHBoxLayout()
|
||||
save_row.setSpacing(8)
|
||||
|
||||
save_button = QPushButton("Save Numpy Snapshot")
|
||||
save_button.clicked.connect(owner._save_snapshot)
|
||||
owner._save_count = QSpinBox()
|
||||
owner._save_count.setMinimum(1)
|
||||
owner._save_count.setMaximum(10_000)
|
||||
owner._save_count.setValue(10)
|
||||
|
||||
save_row.addWidget(save_button)
|
||||
save_row.addWidget(QLabel("Last N"))
|
||||
save_row.addWidget(owner._save_count)
|
||||
save_row.addStretch(1)
|
||||
layout.addLayout(save_row)
|
||||
|
||||
path_row = QHBoxLayout()
|
||||
path_row.setSpacing(8)
|
||||
owner._save_path_input = QLineEdit(str(owner._project_root / "python_app/data/snapshots"))
|
||||
browse_button = QPushButton("Browse")
|
||||
browse_button.clicked.connect(owner._browse_save_path)
|
||||
path_row.addWidget(QLabel("Path"))
|
||||
path_row.addWidget(owner._save_path_input, stretch=1)
|
||||
path_row.addWidget(browse_button)
|
||||
layout.addLayout(path_row)
|
||||
|
||||
name_row = QHBoxLayout()
|
||||
name_row.setSpacing(8)
|
||||
owner._save_name_input = QLineEdit("snapshot_manual")
|
||||
name_row.addWidget(QLabel("Name"))
|
||||
name_row.addWidget(owner._save_name_input, stretch=1)
|
||||
layout.addLayout(name_row)
|
||||
return group
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Builder for hardware actions section."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtWidgets import QGroupBox, QHBoxLayout, QPushButton
|
||||
|
||||
|
||||
def build_hardware_actions_group(owner) -> QGroupBox:
|
||||
"""Create hardware action buttons section."""
|
||||
group = QGroupBox("Hardware Actions")
|
||||
layout = QHBoxLayout(group)
|
||||
layout.setSpacing(8)
|
||||
|
||||
apply_radar_button = QPushButton("Apply Radar Settings")
|
||||
apply_radar_button.clicked.connect(owner._apply_radar_settings)
|
||||
layout.addWidget(apply_radar_button)
|
||||
|
||||
save_config_button = QPushButton("Save Current Config")
|
||||
save_config_button.clicked.connect(owner._save_current_config)
|
||||
layout.addWidget(save_config_button)
|
||||
|
||||
preprocess_button = QPushButton("Preprocessing Panel")
|
||||
preprocess_button.clicked.connect(owner._open_preprocess_panel)
|
||||
layout.addWidget(preprocess_button)
|
||||
return group
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Builder for pipeline control section."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtWidgets import QGroupBox, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
|
||||
|
||||
|
||||
def build_pipeline_group(owner) -> QGroupBox:
|
||||
"""Create Start/Single/Stop controls section."""
|
||||
group = QGroupBox("Pipeline")
|
||||
layout = QVBoxLayout(group)
|
||||
layout.setSpacing(8)
|
||||
|
||||
action_row = QHBoxLayout()
|
||||
action_row.setSpacing(8)
|
||||
|
||||
start_button = QPushButton("Start")
|
||||
start_button.clicked.connect(owner._start_run)
|
||||
action_row.addWidget(start_button)
|
||||
|
||||
single_button = QPushButton("Single Capture")
|
||||
single_button.clicked.connect(owner._start_single_capture)
|
||||
action_row.addWidget(single_button)
|
||||
|
||||
stop_button = QPushButton("Stop")
|
||||
stop_button.clicked.connect(owner._stop_run)
|
||||
action_row.addWidget(stop_button)
|
||||
|
||||
layout.addLayout(action_row)
|
||||
|
||||
hint = QLabel("Start continuous run or single processed collection capture.")
|
||||
hint.setObjectName("hintLabel")
|
||||
layout.addWidget(hint)
|
||||
return group
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Builder for preprocess set summary section."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtWidgets import QFormLayout, QGroupBox, QLabel
|
||||
|
||||
|
||||
def build_preprocess_summary_group(owner) -> QGroupBox:
|
||||
"""Create selected calibration/reference summary section."""
|
||||
group = QGroupBox("Selected Preprocess Sets")
|
||||
form = QFormLayout(group)
|
||||
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
||||
|
||||
owner._selected_calibration_label = QLabel("<not selected>")
|
||||
owner._selected_reference_label = QLabel("<not selected>")
|
||||
|
||||
form.addRow("Calibration", owner._selected_calibration_label)
|
||||
form.addRow("Reference", owner._selected_reference_label)
|
||||
return group
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Builder for processing mode and live-parameter section."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDoubleSpinBox,
|
||||
QFormLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QStackedWidget,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
|
||||
def build_processing_group(owner) -> QGroupBox:
|
||||
"""Create processing mode section with pass-through and B-scan pages."""
|
||||
group = QGroupBox("Processing")
|
||||
form = QFormLayout(group)
|
||||
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
||||
|
||||
owner._processing_mode = QComboBox()
|
||||
owner._processing_mode.addItems(["pass_through", "bscan"])
|
||||
|
||||
owner._processing_mode_pages = QStackedWidget(group)
|
||||
owner._processing_mode_pages.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
||||
|
||||
pass_through_page = QWidget(owner._processing_mode_pages)
|
||||
pass_through_page.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
||||
pass_through_form = QFormLayout(pass_through_page)
|
||||
pass_through_form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
||||
|
||||
owner._processing_gain_db = QDoubleSpinBox()
|
||||
owner._processing_gain_db.setDecimals(2)
|
||||
owner._processing_gain_db.setRange(-40.0, 40.0)
|
||||
owner._processing_gain_db.setSingleStep(0.25)
|
||||
owner._processing_gain_db.setValue(0.0)
|
||||
|
||||
owner._processing_phase_deg = QDoubleSpinBox()
|
||||
owner._processing_phase_deg.setDecimals(1)
|
||||
owner._processing_phase_deg.setRange(-180.0, 180.0)
|
||||
owner._processing_phase_deg.setSingleStep(1.0)
|
||||
owner._processing_phase_deg.setValue(0.0)
|
||||
|
||||
owner._show_magnitude_checkbox = QCheckBox("Show magnitude")
|
||||
owner._show_magnitude_checkbox.setChecked(True)
|
||||
|
||||
owner._show_phase_checkbox = QCheckBox("Show phase")
|
||||
owner._show_phase_checkbox.setChecked(True)
|
||||
|
||||
pass_through_form.addRow("Gain dB (live)", owner._processing_gain_db)
|
||||
pass_through_form.addRow("Phase deg (live)", owner._processing_phase_deg)
|
||||
pass_through_form.addRow(owner._show_magnitude_checkbox)
|
||||
pass_through_form.addRow(owner._show_phase_checkbox)
|
||||
owner._processing_mode_pages.addWidget(pass_through_page)
|
||||
|
||||
bscan_page = QWidget(owner._processing_mode_pages)
|
||||
bscan_page.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
||||
bscan_form = QFormLayout(bscan_page)
|
||||
bscan_form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
||||
|
||||
owner._bscan_axis = QComboBox()
|
||||
owner._bscan_axis.addItems(["abs", "real", "phase"])
|
||||
|
||||
owner._bscan_cut_m = QDoubleSpinBox()
|
||||
owner._bscan_cut_m.setDecimals(3)
|
||||
owner._bscan_cut_m.setRange(0.0, 2.0)
|
||||
owner._bscan_cut_m.setSingleStep(0.001)
|
||||
owner._bscan_cut_m.setValue(0.824)
|
||||
|
||||
owner._bscan_max_depth_m = QDoubleSpinBox()
|
||||
owner._bscan_max_depth_m.setDecimals(1)
|
||||
owner._bscan_max_depth_m.setRange(0.1, 5.0)
|
||||
owner._bscan_max_depth_m.setSingleStep(0.1)
|
||||
owner._bscan_max_depth_m.setValue(1.0)
|
||||
|
||||
owner._bscan_gain = QDoubleSpinBox()
|
||||
owner._bscan_gain.setDecimals(1)
|
||||
owner._bscan_gain.setRange(0.0, 3.0)
|
||||
owner._bscan_gain.setSingleStep(0.1)
|
||||
owner._bscan_gain.setValue(1.0)
|
||||
|
||||
owner._bscan_start_freq_mhz = QDoubleSpinBox()
|
||||
owner._bscan_start_freq_mhz.setDecimals(1)
|
||||
owner._bscan_start_freq_mhz.setRange(100.0, 8800.0)
|
||||
owner._bscan_start_freq_mhz.setSingleStep(10.0)
|
||||
owner._bscan_start_freq_mhz.setValue(100.0)
|
||||
|
||||
owner._bscan_stop_freq_mhz = QDoubleSpinBox()
|
||||
owner._bscan_stop_freq_mhz.setDecimals(1)
|
||||
owner._bscan_stop_freq_mhz.setRange(100.0, 8800.0)
|
||||
owner._bscan_stop_freq_mhz.setSingleStep(10.0)
|
||||
owner._bscan_stop_freq_mhz.setValue(8800.0)
|
||||
|
||||
owner._bscan_clear_history_button = QPushButton("Clear B-scan History")
|
||||
owner._bscan_clear_history_button.clicked.connect(owner._on_bscan_clear_history_clicked)
|
||||
owner._bscan_remove_last_button = QPushButton("Remove Last Sweep")
|
||||
owner._bscan_remove_last_button.clicked.connect(owner._on_bscan_remove_last_sweep_clicked)
|
||||
bscan_actions = QWidget(owner._processing_mode_pages)
|
||||
bscan_actions_layout = QHBoxLayout(bscan_actions)
|
||||
bscan_actions_layout.setContentsMargins(0, 0, 0, 0)
|
||||
bscan_actions_layout.setSpacing(8)
|
||||
bscan_actions_layout.addWidget(owner._bscan_remove_last_button)
|
||||
bscan_actions_layout.addWidget(owner._bscan_clear_history_button)
|
||||
|
||||
bscan_form.addRow("Axis", owner._bscan_axis)
|
||||
bscan_form.addRow("Cut m", owner._bscan_cut_m)
|
||||
bscan_form.addRow("Max depth m", owner._bscan_max_depth_m)
|
||||
bscan_form.addRow("Gain", owner._bscan_gain)
|
||||
bscan_form.addRow("Start MHz", owner._bscan_start_freq_mhz)
|
||||
bscan_form.addRow("Stop MHz", owner._bscan_stop_freq_mhz)
|
||||
bscan_form.addRow(bscan_actions)
|
||||
owner._processing_mode_pages.addWidget(bscan_page)
|
||||
|
||||
owner._processing_mode.currentTextChanged.connect(owner._on_processing_mode_changed)
|
||||
owner._processing_gain_db.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||
owner._processing_phase_deg.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||
owner._show_magnitude_checkbox.toggled.connect(owner._on_trace_visibility_changed)
|
||||
owner._show_phase_checkbox.toggled.connect(owner._on_trace_visibility_changed)
|
||||
form.addRow("Mode", owner._processing_mode)
|
||||
form.addRow(owner._processing_mode_pages)
|
||||
|
||||
owner._bscan_axis.currentTextChanged.connect(owner._on_processing_live_settings_changed)
|
||||
owner._bscan_cut_m.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||
owner._bscan_max_depth_m.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||
owner._bscan_gain.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||
owner._bscan_start_freq_mhz.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||
owner._bscan_stop_freq_mhz.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||
|
||||
owner._on_processing_mode_changed(owner._processing_mode.currentText())
|
||||
return group
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Builder for radar settings section."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtWidgets import QComboBox, QFormLayout, QGroupBox, QLabel, QLineEdit
|
||||
|
||||
|
||||
def build_radar_group(owner) -> QGroupBox:
|
||||
"""Create radar settings controls and labels."""
|
||||
group = QGroupBox("Radar")
|
||||
form = QFormLayout(group)
|
||||
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
||||
defaults = owner._defaults_config.radar
|
||||
|
||||
owner._serial_input = QLineEdit(defaults.serial)
|
||||
owner._serial_input.setPlaceholderText("Optional: empty = auto-detect first LibreVNA")
|
||||
|
||||
owner._radar_mode = QComboBox()
|
||||
owner._radar_mode.addItems(["mock", "native"])
|
||||
owner._radar_mode.setToolTip("mock: synthetic signal, native: real LibreVNA hardware")
|
||||
owner._set_combo_current_text(owner._radar_mode, defaults.driver_mode)
|
||||
|
||||
owner._start_hz_input = QLineEdit(f"{defaults.sweep.start_hz:g}")
|
||||
owner._stop_hz_input = QLineEdit(f"{defaults.sweep.stop_hz:g}")
|
||||
owner._points_input = QLineEdit(str(defaults.sweep.points))
|
||||
owner._ifbw_input = QLineEdit(f"{defaults.sweep.if_bandwidth_hz:g}")
|
||||
owner._power_input = QLineEdit(f"{defaults.sweep.power_dbm:g}")
|
||||
owner._power_input.setToolTip("Device power limits are available only in native mode.")
|
||||
owner._serial_input.editingFinished.connect(owner._on_radar_identity_changed)
|
||||
owner._radar_mode.currentTextChanged.connect(owner._on_radar_identity_changed)
|
||||
owner._start_hz_input.editingFinished.connect(owner._on_radar_sweep_limits_changed)
|
||||
owner._stop_hz_input.editingFinished.connect(owner._on_radar_sweep_limits_changed)
|
||||
|
||||
owner._radar_start_label = QLabel("Start Hz")
|
||||
owner._radar_stop_label = QLabel("Stop Hz")
|
||||
owner._radar_points_label = QLabel("Points")
|
||||
owner._radar_ifbw_label = QLabel("IF BW Hz")
|
||||
owner._radar_power_label = QLabel("Stimulus Power dBm")
|
||||
|
||||
owner._radar_limits_hint = QLabel("Mock mode: device limits are not applied.")
|
||||
owner._radar_limits_hint.setObjectName("hintLabel")
|
||||
|
||||
form.addRow("Serial", owner._serial_input)
|
||||
form.addRow("Mode", owner._radar_mode)
|
||||
form.addRow(owner._radar_start_label, owner._start_hz_input)
|
||||
form.addRow(owner._radar_stop_label, owner._stop_hz_input)
|
||||
form.addRow(owner._radar_points_label, owner._points_input)
|
||||
form.addRow(owner._radar_ifbw_label, owner._ifbw_input)
|
||||
form.addRow(owner._radar_power_label, owner._power_input)
|
||||
form.addRow(owner._radar_limits_hint)
|
||||
return group
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Builder for switch and combo settings section."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtWidgets import QComboBox, QFormLayout, QGroupBox, QHBoxLayout, QLineEdit, QVBoxLayout
|
||||
|
||||
|
||||
def build_switch_group(owner) -> QGroupBox:
|
||||
"""Create input/output switch controls and run combos settings."""
|
||||
group = QGroupBox("Switches")
|
||||
layout = QVBoxLayout(group)
|
||||
input_defaults = owner._defaults_config.input_switch
|
||||
output_defaults = owner._defaults_config.output_switch
|
||||
|
||||
global_form = QFormLayout()
|
||||
global_form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
||||
owner._settling_ms = QLineEdit(str(owner._defaults_config.runtime.settling_ms))
|
||||
owner._combos_text = QLineEdit("")
|
||||
owner._combos_text.setPlaceholderText("input:output,input:output or empty for full")
|
||||
global_form.addRow("Settling ms", owner._settling_ms)
|
||||
global_form.addRow("Run combos", owner._combos_text)
|
||||
layout.addLayout(global_form)
|
||||
|
||||
switch_columns = QHBoxLayout()
|
||||
|
||||
input_group = QGroupBox("Input Switch (Radar Port 2)")
|
||||
input_form = QFormLayout(input_group)
|
||||
input_form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
||||
|
||||
owner._input_mode = QComboBox()
|
||||
owner._input_mode.addItems(["mock", "native"])
|
||||
owner._set_combo_current_text(owner._input_mode, input_defaults.driver_mode)
|
||||
owner._input_driver = QComboBox()
|
||||
owner._input_driver.addItems(["hmc349a", "h7992"])
|
||||
owner._set_combo_current_text(owner._input_driver, input_defaults.driver)
|
||||
owner._input_positions = QLineEdit(str(input_defaults.positions))
|
||||
owner._input_gpio_chip = QLineEdit(input_defaults.gpio_chip)
|
||||
owner._input_pin_a = QLineEdit(str(input_defaults.pin_a))
|
||||
owner._input_pin_b = QLineEdit(str(input_defaults.pin_b))
|
||||
owner._input_invert_logic = QComboBox()
|
||||
owner._input_invert_logic.addItems(["false", "true"])
|
||||
owner._set_combo_current_text(owner._input_invert_logic, "true" if input_defaults.invert_logic else "false")
|
||||
|
||||
input_form.addRow("Mode", owner._input_mode)
|
||||
input_form.addRow("Driver", owner._input_driver)
|
||||
input_form.addRow("Positions", owner._input_positions)
|
||||
input_form.addRow("GPIO chip", owner._input_gpio_chip)
|
||||
input_form.addRow("Pin A", owner._input_pin_a)
|
||||
input_form.addRow("Pin B", owner._input_pin_b)
|
||||
input_form.addRow("Invert logic", owner._input_invert_logic)
|
||||
|
||||
output_group = QGroupBox("Output Switch (Radar Port 1)")
|
||||
output_form = QFormLayout(output_group)
|
||||
output_form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
||||
|
||||
owner._output_mode = QComboBox()
|
||||
owner._output_mode.addItems(["mock", "native"])
|
||||
owner._set_combo_current_text(owner._output_mode, output_defaults.driver_mode)
|
||||
owner._output_driver = QComboBox()
|
||||
owner._output_driver.addItems(["h7992", "hmc349a"])
|
||||
owner._set_combo_current_text(owner._output_driver, output_defaults.driver)
|
||||
owner._output_positions = QLineEdit(str(output_defaults.positions))
|
||||
owner._output_gpio_chip = QLineEdit(output_defaults.gpio_chip)
|
||||
owner._output_pin_a = QLineEdit(str(output_defaults.pin_a))
|
||||
owner._output_pin_b = QLineEdit(str(output_defaults.pin_b))
|
||||
owner._output_invert_logic = QComboBox()
|
||||
owner._output_invert_logic.addItems(["false", "true"])
|
||||
owner._set_combo_current_text(owner._output_invert_logic, "true" if output_defaults.invert_logic else "false")
|
||||
|
||||
output_form.addRow("Mode", owner._output_mode)
|
||||
output_form.addRow("Driver", owner._output_driver)
|
||||
output_form.addRow("Positions", owner._output_positions)
|
||||
output_form.addRow("GPIO chip", owner._output_gpio_chip)
|
||||
output_form.addRow("Pin A", owner._output_pin_a)
|
||||
output_form.addRow("Pin B", owner._output_pin_b)
|
||||
output_form.addRow("Invert logic", owner._output_invert_logic)
|
||||
|
||||
switch_columns.addWidget(input_group)
|
||||
switch_columns.addWidget(output_group)
|
||||
layout.addLayout(switch_columns)
|
||||
|
||||
return group
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Application entry point for the PyQt GUI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import pyqtgraph as pg
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
|
||||
# Ensure imports are resolved when started as a script.
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from python_app.gui.app_window import AppWindow
|
||||
from python_app.gui.theme import apply_dark_theme
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Run Qt event loop and show main radar control window."""
|
||||
app = QApplication(sys.argv)
|
||||
apply_dark_theme(app)
|
||||
pg.setConfigOptions(antialias=True, foreground="#dbe4f1")
|
||||
window = AppWindow(PROJECT_ROOT)
|
||||
window.show()
|
||||
return app.exec()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Plotting helpers for trace and B-scan visualization."""
|
||||
|
||||
from python_app.gui.plotting.bscan_history import (
|
||||
build_bscan_signature,
|
||||
pick_bscan_display_key,
|
||||
rebuild_bscan_history_from_results,
|
||||
)
|
||||
from python_app.gui.plotting.bscan_math import (
|
||||
bscan_levels,
|
||||
bscan_lookup_table,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"bscan_levels",
|
||||
"bscan_lookup_table",
|
||||
"build_bscan_signature",
|
||||
"pick_bscan_display_key",
|
||||
"rebuild_bscan_history_from_results",
|
||||
]
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Helpers for B-scan history signatures and cache rebuilding."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
|
||||
import numpy as np
|
||||
|
||||
from python_app.models.dataset_model import ResultCollection
|
||||
from python_app.orchestration.live_processing_config import ProcessingLiveConfig
|
||||
|
||||
|
||||
def _result_tail(
|
||||
*,
|
||||
result_history: list[ResultCollection],
|
||||
history_limit: int,
|
||||
floor_collection_id: int,
|
||||
) -> list[ResultCollection]:
|
||||
"""Return filtered and de-duplicated result-history tail for B-scan usage."""
|
||||
filtered = [
|
||||
collection
|
||||
for collection in result_history[-history_limit:]
|
||||
if int(collection.collection_id) > int(floor_collection_id)
|
||||
]
|
||||
unique_tail: list[ResultCollection] = []
|
||||
seen_keys: set[tuple[int, int]] = set()
|
||||
for collection in filtered:
|
||||
key = (int(collection.collection_id), int(collection.monotonic_ns))
|
||||
if key in seen_keys:
|
||||
continue
|
||||
seen_keys.add(key)
|
||||
unique_tail.append(collection)
|
||||
return unique_tail
|
||||
|
||||
|
||||
def build_bscan_signature(
|
||||
live_config: ProcessingLiveConfig,
|
||||
result_history: list[ResultCollection],
|
||||
history_limit: int,
|
||||
floor_collection_id: int,
|
||||
) -> tuple[object, ...]:
|
||||
"""Build deterministic signature used to detect B-scan cache invalidation."""
|
||||
result_tail = _result_tail(
|
||||
result_history=result_history,
|
||||
history_limit=history_limit,
|
||||
floor_collection_id=floor_collection_id,
|
||||
)
|
||||
return (
|
||||
str(live_config.bscan_axis),
|
||||
float(live_config.bscan_cut_m),
|
||||
float(live_config.bscan_max_depth_m),
|
||||
float(live_config.bscan_gain),
|
||||
float(live_config.bscan_start_freq_mhz),
|
||||
float(live_config.bscan_stop_freq_mhz),
|
||||
int(floor_collection_id),
|
||||
tuple((int(collection.collection_id), int(collection.monotonic_ns), len(collection.blocks)) for collection in result_tail),
|
||||
)
|
||||
|
||||
|
||||
def rebuild_bscan_history_from_results(
|
||||
result_history: list[ResultCollection],
|
||||
history_limit: int,
|
||||
floor_collection_id: int,
|
||||
) -> tuple[dict[tuple[int, int], deque[np.ndarray]], dict[tuple[int, int], np.ndarray]]:
|
||||
"""Rebuild B-scan history and depth axes from processed result payloads."""
|
||||
history_by_combo: dict[tuple[int, int], deque[np.ndarray]] = {}
|
||||
depth_axis_by_combo: dict[tuple[int, int], np.ndarray] = {}
|
||||
|
||||
result_tail = _result_tail(
|
||||
result_history=result_history,
|
||||
history_limit=history_limit,
|
||||
floor_collection_id=floor_collection_id,
|
||||
)
|
||||
|
||||
for collection in result_tail:
|
||||
for block in collection.blocks:
|
||||
key = (block.combo.input_pos, block.combo.output_pos)
|
||||
for payload in block.payloads:
|
||||
if payload.kind != 1 or payload.processing_name != "bscan":
|
||||
continue
|
||||
if payload.frequency_hz.size == 0 or payload.trace.size == 0:
|
||||
continue
|
||||
if payload.frequency_hz.size != payload.trace.size:
|
||||
continue
|
||||
|
||||
depth_axis = np.asarray(payload.frequency_hz, dtype=np.float32)
|
||||
amplitudes = np.asarray(np.real(payload.trace), dtype=np.float32)
|
||||
if depth_axis.size == 0 or amplitudes.size == 0:
|
||||
continue
|
||||
|
||||
history = history_by_combo.get(key)
|
||||
stored_axis = depth_axis_by_combo.get(key)
|
||||
if (
|
||||
history is None
|
||||
or stored_axis is None
|
||||
or stored_axis.shape != depth_axis.shape
|
||||
or not np.allclose(stored_axis, depth_axis, rtol=1e-4, atol=1e-6)
|
||||
):
|
||||
history = deque(maxlen=history_limit)
|
||||
history_by_combo[key] = history
|
||||
depth_axis_by_combo[key] = depth_axis.copy()
|
||||
|
||||
history.append(amplitudes.copy())
|
||||
|
||||
return history_by_combo, depth_axis_by_combo
|
||||
|
||||
|
||||
def pick_bscan_display_key(
|
||||
history_by_combo: dict[tuple[int, int], deque[np.ndarray]],
|
||||
) -> tuple[int, int] | None:
|
||||
"""Choose combo key to display when multiple histories are present."""
|
||||
if not history_by_combo:
|
||||
return None
|
||||
return next(iter(history_by_combo.keys()))
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Color-scaling helpers for B-scan visualization."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
|
||||
def bscan_lookup_table(axis_mode: str) -> np.ndarray:
|
||||
"""Build B-scan colormap table for selected axis mode."""
|
||||
if axis_mode == "abs":
|
||||
return build_lut(["#440154", "#31688e", "#35b779", "#fde725"])
|
||||
return build_lut(["#2166ac", "#67a9cf", "#f7f7f7", "#ef8a62", "#b2182b"])
|
||||
|
||||
|
||||
def build_lut(stops: list[str], *, size: int = 256) -> np.ndarray:
|
||||
"""Interpolate hex color stops into 8-bit RGB LUT array."""
|
||||
stop_positions = np.linspace(0.0, 1.0, num=len(stops), dtype=np.float32)
|
||||
sample_positions = np.linspace(0.0, 1.0, num=size, dtype=np.float32)
|
||||
stop_colors = np.asarray([pg.mkColor(value).getRgb()[:3] for value in stops], dtype=np.float32)
|
||||
|
||||
lut = np.empty((size, 3), dtype=np.uint8)
|
||||
for channel in range(3):
|
||||
lut[:, channel] = np.interp(sample_positions, stop_positions, stop_colors[:, channel]).astype(np.uint8)
|
||||
return lut
|
||||
|
||||
|
||||
def bscan_levels(sweeps: np.ndarray, axis_mode: str) -> tuple[float, float]:
|
||||
"""Compute image levels for B-scan data based on axis mode."""
|
||||
min_value = float(np.min(sweeps))
|
||||
max_value = float(np.max(sweeps))
|
||||
if axis_mode == "abs":
|
||||
if max_value <= min_value:
|
||||
return min_value, min_value + 1e-6
|
||||
return min_value, max_value
|
||||
|
||||
max_abs = max(abs(min_value), abs(max_value), 1e-6)
|
||||
return -max_abs, max_abs
|
||||
@@ -0,0 +1,252 @@
|
||||
"""Dialog for calibration/reference set selection and sequential capture."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtCore import pyqtSignal
|
||||
from PyQt6.QtWidgets import (
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QFormLayout,
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPlainTextEdit,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
)
|
||||
import pyqtgraph as pg
|
||||
import numpy as np
|
||||
|
||||
from python_app.models.dataset_model import TraceData
|
||||
|
||||
|
||||
class PreprocessDialog(QDialog):
|
||||
"""Standalone dialog for preprocessing capture workflows."""
|
||||
|
||||
refresh_requested = pyqtSignal()
|
||||
selection_changed = pyqtSignal(str, str)
|
||||
start_sequence_requested = pyqtSignal(str)
|
||||
capture_next_requested = pyqtSignal()
|
||||
abort_sequence_requested = pyqtSignal()
|
||||
|
||||
def __init__(self, parent=None) -> None:
|
||||
"""Create dialog and build all widgets."""
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Preprocessing Setup")
|
||||
self.resize(1040, 760)
|
||||
self._build_ui()
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
"""Build dialog layout, controls, and preview plot."""
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
sets_group = QGroupBox("Calibration / Reference Sets", self)
|
||||
sets_layout = QGridLayout(sets_group)
|
||||
|
||||
self._set_name_input = QLineEdit("set_001", sets_group)
|
||||
self._calibration_combo = QComboBox(sets_group)
|
||||
self._reference_combo = QComboBox(sets_group)
|
||||
|
||||
refresh_button = QPushButton("Refresh Sets", sets_group)
|
||||
refresh_button.clicked.connect(self.refresh_requested.emit)
|
||||
|
||||
self._calibration_combo.currentTextChanged.connect(self._emit_selection_changed)
|
||||
self._reference_combo.currentTextChanged.connect(self._emit_selection_changed)
|
||||
|
||||
sets_layout.addWidget(QLabel("Set name"), 0, 0)
|
||||
sets_layout.addWidget(self._set_name_input, 0, 1)
|
||||
sets_layout.addWidget(refresh_button, 0, 2)
|
||||
|
||||
sets_layout.addWidget(QLabel("Calibration set"), 1, 0)
|
||||
sets_layout.addWidget(self._calibration_combo, 1, 1, 1, 2)
|
||||
|
||||
sets_layout.addWidget(QLabel("Reference set"), 2, 0)
|
||||
sets_layout.addWidget(self._reference_combo, 2, 1, 1, 2)
|
||||
|
||||
layout.addWidget(sets_group)
|
||||
|
||||
sequence_group = QGroupBox("Sequential Capture (Fill Full N*M)", self)
|
||||
sequence_layout = QGridLayout(sequence_group)
|
||||
|
||||
self._active_kind_label = QLabel("<none>", sequence_group)
|
||||
self._progress_label = QLabel("0 / 0", sequence_group)
|
||||
self._combo_label = QLabel("<none>", sequence_group)
|
||||
|
||||
self._tx_antenna_label_input = QLineEdit(sequence_group)
|
||||
self._rx_antenna_label_input = QLineEdit(sequence_group)
|
||||
self._tx_antenna_label_input.setPlaceholderText("e.g. TX_A")
|
||||
self._rx_antenna_label_input.setPlaceholderText("e.g. RX_B")
|
||||
|
||||
start_calibration_button = QPushButton("Start Calibration Sequence", sequence_group)
|
||||
start_calibration_button.clicked.connect(lambda: self.start_sequence_requested.emit("calibration"))
|
||||
|
||||
start_reference_button = QPushButton("Start Reference Sequence", sequence_group)
|
||||
start_reference_button.clicked.connect(lambda: self.start_sequence_requested.emit("reference"))
|
||||
|
||||
self._capture_next_button = QPushButton("Capture Current Combo", sequence_group)
|
||||
self._capture_next_button.clicked.connect(self.capture_next_requested.emit)
|
||||
self._capture_next_button.setEnabled(False)
|
||||
|
||||
self._abort_button = QPushButton("Abort Sequence", sequence_group)
|
||||
self._abort_button.clicked.connect(self.abort_sequence_requested.emit)
|
||||
self._abort_button.setEnabled(False)
|
||||
|
||||
button_row = QHBoxLayout()
|
||||
button_row.addWidget(start_calibration_button)
|
||||
button_row.addWidget(start_reference_button)
|
||||
button_row.addWidget(self._capture_next_button)
|
||||
button_row.addWidget(self._abort_button)
|
||||
|
||||
sequence_layout.addWidget(QLabel("Active type"), 0, 0)
|
||||
sequence_layout.addWidget(self._active_kind_label, 0, 1)
|
||||
sequence_layout.addWidget(QLabel("Progress"), 1, 0)
|
||||
sequence_layout.addWidget(self._progress_label, 1, 1)
|
||||
sequence_layout.addWidget(QLabel("Current combo"), 2, 0)
|
||||
sequence_layout.addWidget(self._combo_label, 2, 1)
|
||||
sequence_layout.addWidget(QLabel("TX antenna label"), 3, 0)
|
||||
sequence_layout.addWidget(self._tx_antenna_label_input, 3, 1)
|
||||
sequence_layout.addWidget(QLabel("RX antenna label"), 4, 0)
|
||||
sequence_layout.addWidget(self._rx_antenna_label_input, 4, 1)
|
||||
sequence_layout.addLayout(button_row, 5, 0, 1, 2)
|
||||
|
||||
self._capture_log = QPlainTextEdit(sequence_group)
|
||||
self._capture_log.setReadOnly(True)
|
||||
self._capture_log.setPlaceholderText("Capture history per combo")
|
||||
sequence_layout.addWidget(self._capture_log, 6, 0, 1, 2)
|
||||
|
||||
layout.addWidget(sequence_group)
|
||||
|
||||
self._status = QLabel("Ready", self)
|
||||
layout.addWidget(self._status)
|
||||
|
||||
self._plot = pg.PlotWidget(background="#101418")
|
||||
self._plot.showGrid(x=True, y=True, alpha=0.2)
|
||||
self._plot.setLabel("bottom", "Frequency", units="Hz")
|
||||
self._plot.setLabel("left", "Magnitude", units="dB")
|
||||
layout.addWidget(self._plot, stretch=1)
|
||||
|
||||
def set_name(self) -> str:
|
||||
"""Return requested target set name."""
|
||||
return self._set_name_input.text().strip()
|
||||
|
||||
def calibration_set(self) -> str:
|
||||
"""Return currently selected calibration set."""
|
||||
return self._calibration_combo.currentText().strip()
|
||||
|
||||
def reference_set(self) -> str:
|
||||
"""Return currently selected reference set."""
|
||||
return self._reference_combo.currentText().strip()
|
||||
|
||||
def antenna_labels(self) -> tuple[str, str]:
|
||||
"""Return optional TX/RX user labels used in capture logs."""
|
||||
return self._tx_antenna_label_input.text().strip(), self._rx_antenna_label_input.text().strip()
|
||||
|
||||
def clear_capture_log(self) -> None:
|
||||
"""Clear capture history text box."""
|
||||
self._capture_log.clear()
|
||||
|
||||
def append_capture_log_entry(
|
||||
self,
|
||||
*,
|
||||
kind: str,
|
||||
captured_count: int,
|
||||
total_count: int,
|
||||
input_pos: int,
|
||||
output_pos: int,
|
||||
tx_label: str,
|
||||
rx_label: str,
|
||||
) -> None:
|
||||
"""Append one capture progress row to dialog log."""
|
||||
tx_info = tx_label or "-"
|
||||
rx_info = rx_label or "-"
|
||||
self._capture_log.appendPlainText(
|
||||
f"{kind}: {captured_count}/{total_count} | "
|
||||
f"input={input_pos} output={output_pos} | "
|
||||
f"TX={tx_info} RX={rx_info}"
|
||||
)
|
||||
|
||||
def set_capture_state(
|
||||
self,
|
||||
*,
|
||||
kind: str | None,
|
||||
captured_count: int,
|
||||
total_count: int,
|
||||
next_input: int | None,
|
||||
next_output: int | None,
|
||||
) -> None:
|
||||
"""Update sequence progress/status widgets."""
|
||||
if kind is None:
|
||||
self._active_kind_label.setText("<none>")
|
||||
self._progress_label.setText("0 / 0")
|
||||
self._combo_label.setText("<none>")
|
||||
self._capture_next_button.setEnabled(False)
|
||||
self._abort_button.setEnabled(False)
|
||||
return
|
||||
|
||||
self._active_kind_label.setText(kind)
|
||||
self._progress_label.setText(f"{captured_count} / {total_count}")
|
||||
if next_input is None or next_output is None:
|
||||
self._combo_label.setText("<complete>")
|
||||
self._capture_next_button.setEnabled(False)
|
||||
self._abort_button.setEnabled(True)
|
||||
else:
|
||||
self._combo_label.setText(f"input={next_input}, output={next_output}")
|
||||
self._capture_next_button.setEnabled(True)
|
||||
self._abort_button.setEnabled(True)
|
||||
|
||||
def set_calibration_sets(self, names: list[str]) -> None:
|
||||
"""Replace calibration set choices while preserving current selection when possible."""
|
||||
self._set_combo_items(self._calibration_combo, names, self.calibration_set())
|
||||
|
||||
def set_reference_sets(self, names: list[str]) -> None:
|
||||
"""Replace reference set choices while preserving current selection when possible."""
|
||||
self._set_combo_items(self._reference_combo, names, self.reference_set())
|
||||
|
||||
def set_selected_sets(self, calibration_set: str, reference_set: str) -> None:
|
||||
"""Apply selected set names to both comboboxes and emit selection update."""
|
||||
if calibration_set:
|
||||
index = self._calibration_combo.findText(calibration_set)
|
||||
if index >= 0:
|
||||
self._calibration_combo.setCurrentIndex(index)
|
||||
|
||||
if reference_set:
|
||||
index = self._reference_combo.findText(reference_set)
|
||||
if index >= 0:
|
||||
self._reference_combo.setCurrentIndex(index)
|
||||
|
||||
self._emit_selection_changed()
|
||||
|
||||
def set_status(self, message: str) -> None:
|
||||
"""Set short human-readable status line."""
|
||||
self._status.setText(message)
|
||||
|
||||
def draw_last_trace(self, trace: TraceData, title: str) -> None:
|
||||
"""Draw the latest captured sweep trace in dB scale."""
|
||||
magnitude_db = 20.0 * np.log10(np.maximum(np.abs(trace.s21), 1e-12))
|
||||
self._plot.clear()
|
||||
self._plot.plot(
|
||||
trace.frequency_hz,
|
||||
magnitude_db,
|
||||
pen=pg.mkPen("#4cc9f0", width=1.8),
|
||||
)
|
||||
combo = trace.combo
|
||||
self._status.setText(
|
||||
f"{title}: input={combo.input_pos}, output={combo.output_pos}, points={trace.frequency_hz.size}"
|
||||
)
|
||||
|
||||
def _emit_selection_changed(self) -> None:
|
||||
"""Emit current calibration/reference selection."""
|
||||
self.selection_changed.emit(self.calibration_set(), self.reference_set())
|
||||
|
||||
@staticmethod
|
||||
def _set_combo_items(combo: QComboBox, names: list[str], current_text: str) -> None:
|
||||
"""Replace combo contents and keep previous value when still available."""
|
||||
combo.clear()
|
||||
combo.addItems(names)
|
||||
if not current_text:
|
||||
return
|
||||
index = combo.findText(current_text)
|
||||
if index >= 0:
|
||||
combo.setCurrentIndex(index)
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Runtime helpers for GUI polling, history management, and constraints."""
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"build_run_history_signature",
|
||||
"record_result_history",
|
||||
"validate_processing_mode_constraints",
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Validation helpers for GUI processing mode constraints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from python_app.models.run_config_model import RunConfigModel
|
||||
|
||||
|
||||
def validate_processing_mode_constraints(processing_mode: str, config: RunConfigModel) -> None:
|
||||
"""Validate mode-specific constraints for current run configuration."""
|
||||
if processing_mode != "bscan":
|
||||
return
|
||||
|
||||
any_native_switch = config.input_switch.driver_mode == "native" or config.output_switch.driver_mode == "native"
|
||||
if not any_native_switch:
|
||||
return
|
||||
|
||||
combo_count = len({(int(combo.input), int(combo.output)) for combo in config.combos})
|
||||
if combo_count != 1:
|
||||
raise RuntimeError(
|
||||
f"B-scan with native switches requires exactly one run combo (now {combo_count})"
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Helpers for GUI-side runtime history management."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
|
||||
from python_app.models.dataset_model import ResultCollection
|
||||
from python_app.models.run_config_model import RunConfigModel
|
||||
|
||||
|
||||
def record_result_history(
|
||||
result_history: deque[ResultCollection],
|
||||
collection: ResultCollection,
|
||||
) -> bool:
|
||||
"""Append new result or replace existing entry by stable collection key."""
|
||||
for index in range(len(result_history) - 1, -1, -1):
|
||||
existing = result_history[index]
|
||||
if (
|
||||
existing.collection_id == collection.collection_id
|
||||
and existing.monotonic_ns == collection.monotonic_ns
|
||||
):
|
||||
result_history[index] = collection
|
||||
return True
|
||||
|
||||
result_history.append(collection)
|
||||
return True
|
||||
|
||||
|
||||
def build_run_history_signature(
|
||||
config: RunConfigModel,
|
||||
) -> tuple[object, ...]:
|
||||
"""Build deterministic signature to detect run-settings changes (excluding live processing params)."""
|
||||
combos_signature = tuple((int(combo.input), int(combo.output)) for combo in config.combos)
|
||||
return (
|
||||
str(config.radar.driver_mode),
|
||||
str(config.radar.serial),
|
||||
float(config.radar.sweep.start_hz),
|
||||
float(config.radar.sweep.stop_hz),
|
||||
int(config.radar.sweep.points),
|
||||
float(config.radar.sweep.if_bandwidth_hz),
|
||||
float(config.radar.sweep.power_dbm),
|
||||
str(config.input_switch.driver_mode),
|
||||
str(config.input_switch.driver),
|
||||
int(config.input_switch.positions),
|
||||
bool(config.input_switch.invert_logic),
|
||||
str(config.output_switch.driver_mode),
|
||||
str(config.output_switch.driver),
|
||||
int(config.output_switch.positions),
|
||||
bool(config.output_switch.invert_logic),
|
||||
str(config.preprocess.calibration_set),
|
||||
str(config.preprocess.reference_set),
|
||||
combos_signature,
|
||||
)
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Application-wide Qt palette and stylesheet configuration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtGui import QColor, QPalette
|
||||
from PyQt6.QtWidgets import QApplication, QStyleFactory
|
||||
|
||||
|
||||
_DARK_STYLESHEET = """
|
||||
QMainWindow, QDialog {
|
||||
background-color: #0f131a;
|
||||
}
|
||||
|
||||
QWidget {
|
||||
color: #e7edf7;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
QGroupBox {
|
||||
background-color: #151b24;
|
||||
border: 1px solid #263142;
|
||||
border-radius: 10px;
|
||||
margin-top: 14px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
QGroupBox::title {
|
||||
subcontrol-origin: margin;
|
||||
left: 10px;
|
||||
padding: 0 6px;
|
||||
color: #9fb4ce;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
QPushButton {
|
||||
background-color: #1d2735;
|
||||
border: 1px solid #314055;
|
||||
border-radius: 8px;
|
||||
padding: 7px 12px;
|
||||
}
|
||||
|
||||
QPushButton:hover {
|
||||
background-color: #243246;
|
||||
}
|
||||
|
||||
QPushButton:pressed {
|
||||
background-color: #1a2432;
|
||||
}
|
||||
|
||||
QPushButton:disabled {
|
||||
color: #6b7d95;
|
||||
background-color: #151d27;
|
||||
border-color: #232e3d;
|
||||
}
|
||||
|
||||
QPushButton#settingsToggleButton {
|
||||
min-width: 26px;
|
||||
max-width: 26px;
|
||||
padding: 6px 0px;
|
||||
border-radius: 7px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
QLineEdit,
|
||||
QPlainTextEdit,
|
||||
QComboBox,
|
||||
QSpinBox,
|
||||
QDoubleSpinBox {
|
||||
background-color: #101721;
|
||||
border: 1px solid #2e3b4e;
|
||||
border-radius: 7px;
|
||||
padding: 5px 8px;
|
||||
selection-background-color: #2f7ee6;
|
||||
}
|
||||
|
||||
QLineEdit:focus,
|
||||
QPlainTextEdit:focus,
|
||||
QComboBox:focus,
|
||||
QSpinBox:focus,
|
||||
QDoubleSpinBox:focus {
|
||||
border: 1px solid #5d88bd;
|
||||
}
|
||||
|
||||
QComboBox::drop-down {
|
||||
border: none;
|
||||
width: 18px;
|
||||
}
|
||||
|
||||
QScrollArea {
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
QLabel#statusLabel {
|
||||
color: #94b4d9;
|
||||
font-weight: 600;
|
||||
padding: 2px 1px;
|
||||
}
|
||||
|
||||
QLabel#hintLabel {
|
||||
color: #7f94af;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def apply_dark_theme(app: QApplication) -> None:
|
||||
"""Apply a minimal modern dark theme shared by all windows."""
|
||||
app.setStyle(QStyleFactory.create("Fusion"))
|
||||
|
||||
palette = QPalette()
|
||||
palette.setColor(QPalette.ColorRole.Window, QColor("#0f131a"))
|
||||
palette.setColor(QPalette.ColorRole.WindowText, QColor("#e7edf7"))
|
||||
palette.setColor(QPalette.ColorRole.Base, QColor("#101721"))
|
||||
palette.setColor(QPalette.ColorRole.AlternateBase, QColor("#151b24"))
|
||||
palette.setColor(QPalette.ColorRole.ToolTipBase, QColor("#151b24"))
|
||||
palette.setColor(QPalette.ColorRole.ToolTipText, QColor("#e7edf7"))
|
||||
palette.setColor(QPalette.ColorRole.Text, QColor("#e7edf7"))
|
||||
palette.setColor(QPalette.ColorRole.Button, QColor("#1d2735"))
|
||||
palette.setColor(QPalette.ColorRole.ButtonText, QColor("#e7edf7"))
|
||||
palette.setColor(QPalette.ColorRole.BrightText, QColor("#ffffff"))
|
||||
palette.setColor(QPalette.ColorRole.Link, QColor("#5d88bd"))
|
||||
palette.setColor(QPalette.ColorRole.Highlight, QColor("#2f7ee6"))
|
||||
palette.setColor(QPalette.ColorRole.HighlightedText, QColor("#ffffff"))
|
||||
app.setPalette(palette)
|
||||
app.setStyleSheet(_DARK_STYLESHEET)
|
||||
Reference in New Issue
Block a user