UI updates

This commit is contained in:
Ayzen
2026-04-01 20:21:16 +03:00
parent 4abc95c372
commit 669205d8f8
43 changed files with 2055 additions and 973 deletions
@@ -2,12 +2,35 @@
from __future__ import annotations
from collections import deque
from contextlib import ExitStack
import json
from pathlib import Path
from PyQt6.QtCore import QSignalBlocker
from PyQt6.QtWidgets import QFileDialog
from python_app.hardware_full.librevna_service import LibreVnaService
from python_app.models.gui_profile_model import (
GuiBscanStateModel,
GuiDataActionsStateModel,
GuiGprStateModel,
GuiPassThroughStateModel,
GuiPreprocessDialogStateModel,
GuiProcessingStateModel,
GuiProfileModel,
GuiStateModel,
GuiSwitchStateModel,
)
from python_app.models.run_config_model import ComboModel, GprRxGeometryModel, GprTxGeometryModel, RunConfigModel
from python_app.models.run_config_validation import validate_gpr_model
from python_app.orchestration.config_writer import parse_combos_from_text
from python_app.orchestration.live_processing_config import ProcessingLiveConfig
from python_app.orchestration.preprocess_assets import PREPROCESS_ASSET_KEYS, preprocess_asset_model
from python_app.orchestration.preprocess_assets import (
PREPROCESS_ASSET_KEYS,
VISIBLE_PREPROCESS_ASSET_KEYS,
preprocess_asset_model,
)
from python_app.storage.npz_store import radar_key_from_config
@@ -66,63 +89,435 @@ class AppWindowConfigMixin:
)
return entries
@staticmethod
def _format_combos_text_from_config(config: RunConfigModel) -> str:
"""Render configured combos for UI text editor, keeping full matrix as empty."""
combos = list(config.combos)
full_combos = config.build_full_combos(config.input_switch.positions, config.output_switch.positions)
if len(combos) == len(full_combos) and all(
int(left.input) == int(right.input) and int(left.output) == int(right.output)
for left, right in zip(combos, full_combos, strict=True)
):
return ""
return ",".join(f"{int(combo.input)}:{int(combo.output)}" for combo in combos)
@staticmethod
def _default_gpr_input_positions_from_config(config: RunConfigModel) -> str:
"""Build default live GPR input-position selection from stable config."""
geometry_values = {int(entry.input_pos) for entry in config.gpr.rx_geometry}
combo_values = {int(combo.input) for combo in config.combos}
values = sorted(geometry_values & combo_values) or sorted(geometry_values)
return ",".join(str(value) for value in values)
@staticmethod
def _default_gpr_output_positions_from_config(config: RunConfigModel) -> str:
"""Build default live GPR output-position selection from stable config."""
geometry_values = {int(entry.output_pos) for entry in config.gpr.tx_geometry}
combo_values = {int(combo.output) for combo in config.combos}
values = sorted(geometry_values & combo_values) or sorted(geometry_values)
return ",".join(str(value) for value in values)
@staticmethod
def _history_limit_for_config(config: RunConfigModel) -> int:
"""Return unified GUI history limit derived from config ring capacities."""
return max(
1,
min(
int(config.rings.raw_tap.capacity),
int(config.rings.preprocessed_tap.capacity),
int(config.rings.results.capacity),
),
)
def _default_gui_state_for_config(self, config: RunConfigModel) -> GuiStateModel:
"""Build fallback GUI-only defaults for a stable run config."""
default_combo = config.combos[0] if config.combos else ComboModel(input=0, output=0)
default_mode = "single" if len(config.combos) == 1 else "text"
return GuiStateModel(
switches=GuiSwitchStateModel(
combo_mode=default_mode,
combos_text=self._format_combos_text_from_config(config),
single_input=str(int(default_combo.input)),
single_output=str(int(default_combo.output)),
),
processing=GuiProcessingStateModel(
selected_mode="pass_through",
pass_through=GuiPassThroughStateModel(
show_magnitude=True,
show_phase=True,
fixed_y_enabled=False,
y_min_db=-100.0,
y_max_db=0.0,
),
bscan=GuiBscanStateModel(
axis="abs",
cut_m=0.824,
max_depth_m=1.0,
gain=1.0,
start_freq_mhz=100.0,
stop_freq_mhz=8800.0,
),
gpr=GuiGprStateModel(
input_positions=self._default_gpr_input_positions_from_config(config),
output_positions=self._default_gpr_output_positions_from_config(config),
min_depth_m=2.0,
max_depth_m=14.0,
comp_power=0.2,
start_freq_mhz=3000.0,
stop_freq_mhz=6000.0,
background_subtract_enabled=True,
background_mean_count=10,
),
),
data_actions=GuiDataActionsStateModel(
save_count=10,
save_path=str(self._project_root / "python_app/data/snapshots"),
save_name="snapshot_manual",
),
preprocess_dialog=GuiPreprocessDialogStateModel(set_name="set_001"),
)
def _current_preprocess_set_name(self) -> str:
"""Return current preprocess dialog set name, even when dialog is still closed."""
if self._preprocess_dialog is not None:
self._preprocess_set_name = self._preprocess_dialog.set_name()
return self._preprocess_set_name
def _build_gui_state(self) -> GuiStateModel:
"""Build GUI-only persistent state from current widget values."""
return GuiStateModel(
switches=GuiSwitchStateModel(
combo_mode="single" if self._single_combo_select_button.isChecked() else "text",
combos_text=self._combos_text.text().strip(),
single_input=self._single_combo_input.text().strip(),
single_output=self._single_combo_output.text().strip(),
),
processing=GuiProcessingStateModel(
selected_mode=self._processing_mode.currentText(),
pass_through=GuiPassThroughStateModel(
show_magnitude=bool(self._show_magnitude_checkbox.isChecked()),
show_phase=bool(self._show_phase_checkbox.isChecked()),
fixed_y_enabled=bool(self._pass_through_fixed_y_enabled.isChecked()),
y_min_db=float(self._pass_through_y_min_db.value()),
y_max_db=float(self._pass_through_y_max_db.value()),
),
bscan=GuiBscanStateModel(
axis=self._bscan_axis.currentText(),
cut_m=float(self._bscan_cut_m.value()),
max_depth_m=float(self._bscan_max_depth_m.value()),
gain=float(self._bscan_gain.value()),
start_freq_mhz=float(self._bscan_start_freq_mhz.value()),
stop_freq_mhz=float(self._bscan_stop_freq_mhz.value()),
),
gpr=GuiGprStateModel(
input_positions=self._gpr_input_positions_input.text().strip(),
output_positions=self._gpr_output_positions_input.text().strip(),
min_depth_m=float(self._gpr_min_depth_m.value()),
max_depth_m=float(self._gpr_max_depth_m.value()),
comp_power=float(self._gpr_comp_power.value()),
start_freq_mhz=float(self._gpr_start_freq_mhz.value()),
stop_freq_mhz=float(self._gpr_stop_freq_mhz.value()),
background_subtract_enabled=bool(self._gpr_background_subtract_enabled.isChecked()),
background_mean_count=int(self._gpr_background_mean_count.value()),
),
),
data_actions=GuiDataActionsStateModel(
save_count=int(self._save_count.value()),
save_path=self._save_path_input.text().strip(),
save_name=self._save_name_input.text().strip(),
),
preprocess_dialog=GuiPreprocessDialogStateModel(
set_name=self._current_preprocess_set_name(),
),
)
def _build_gui_profile(self) -> GuiProfileModel:
"""Build full GUI config profile from current window state."""
return GuiProfileModel(
run_config=self._build_config(),
gui=self._build_gui_state(),
)
def _write_gui_profile_to_path(self, output_path: Path, *, allow_overwrite: bool = True) -> GuiProfileModel:
"""Serialize current full GUI profile to `output_path` and return the persisted model."""
if not allow_overwrite and output_path.exists():
raise FileExistsError(f"Config profile output already exists: {output_path}")
profile = self._build_gui_profile()
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(json.dumps(profile.to_dict(), indent=2), encoding="utf-8")
return profile
def _set_combo_selection_mode(self, mode: str) -> None:
"""Highlight current combo mode and enable only the relevant editors."""
text_selected = mode != "single"
self._run_combos_select_button.setChecked(text_selected)
self._single_combo_select_button.setChecked(not text_selected)
self._combos_text.setEnabled(text_selected)
self._single_combo_output.setEnabled(not text_selected)
self._single_combo_input.setEnabled(not text_selected)
def _sync_pass_through_y_controls(self) -> None:
"""Enable Y-range editors only when fixed Y mode is active."""
enabled = bool(self._pass_through_fixed_y_enabled.isChecked())
self._pass_through_y_min_db.setEnabled(enabled)
self._pass_through_y_max_db.setEnabled(enabled)
def _apply_history_limit_from_config(self, config: RunConfigModel) -> None:
"""Resize in-memory history buffers to match the loaded config."""
history_limit = self._history_limit_for_config(config)
self._raw_history = deque(self._raw_history, maxlen=history_limit)
self._pre_history = deque(self._pre_history, maxlen=history_limit)
self._result_history = deque(self._result_history, maxlen=history_limit)
self._bscan_history_limit = history_limit
self._clear_bscan_plot_history()
def _save_current_config(self) -> None:
"""Persist currently selected GUI settings into root run_config.json."""
"""Persist current full GUI profile to a user-selected JSON file."""
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}")
suggested_path = str(self._active_profile_path)
selected_path, _selected_filter = QFileDialog.getSaveFileName(
self,
"Save Config Profile",
suggested_path,
"JSON Files (*.json);;All Files (*)",
)
if not selected_path:
return
output_path = self._normalize_profile_path(Path(selected_path))
if not output_path.suffix:
output_path = output_path.with_suffix(".json")
profile = self._write_gui_profile_to_path(output_path)
self._defaults_config = profile.run_config.clone()
self._gui_defaults = profile.gui
self._remember_active_profile_path(output_path)
self._log(
f"Config profile saved: path={output_path}, "
f"combos={len(profile.run_config.combos)}, "
f"sweep={profile.run_config.radar.sweep.start_hz:g}.."
f"{profile.run_config.radar.sweep.stop_hz:g} Hz, "
f"points={profile.run_config.radar.sweep.points}, "
f"ifbw={profile.run_config.radar.sweep.if_bandwidth_hz:g} Hz, "
f"power={profile.run_config.radar.sweep.power_dbm:g} dBm, "
f"processing_mode={profile.gui.processing.selected_mode}"
)
except Exception as exc: # noqa: BLE001
self._show_error(f"Failed to save current config: {exc}")
self._show_exception("Failed to save config profile", exc)
def _load_config_from_dialog(self) -> None:
"""Load full GUI profile from a user-selected JSON file."""
if self._capture_session is not None:
self._show_error(
"Cannot load config during active capture sequence",
details=self._capture_state_details(),
)
return
if self._supervisor.is_running() or self._supervisor.is_processor_running():
self._show_error(
"Stop all pipeline processes before loading a config profile",
details=self._process_state_details(),
)
return
selected_path, _selected_filter = QFileDialog.getOpenFileName(
self,
"Load Config Profile",
str(self._active_profile_path),
"JSON Files (*.json);;All Files (*)",
)
if not selected_path:
return
try:
self._load_config_profile(Path(selected_path))
except Exception as exc: # noqa: BLE001
self._show_exception("Failed to load config profile", exc)
def _load_config_profile(self, profile_path: Path) -> None:
"""Load config profile from `profile_path` and atomically apply it to the UI."""
normalized_path = self._normalize_profile_path(profile_path)
profile = GuiProfileModel.load_from_path(normalized_path)
self._apply_loaded_profile(profile, normalized_path)
profile_kind = "legacy run config" if profile.gui is None else "full GUI profile"
self._log(
f"Config profile loaded: path={normalized_path}, "
f"kind={profile_kind}, "
f"combos={len(self._defaults_config.combos)}, "
f"processing_mode={self._processing_mode.currentText()}"
)
def _apply_loaded_profile(self, profile: GuiProfileModel, profile_path: Path) -> None:
"""Apply already parsed profile to GUI state without restarting the pipeline."""
config = profile.run_config.clone()
gui_state = profile.gui if profile.gui is not None else self._default_gui_state_for_config(config)
selected_preprocess_sets = {
key: str(preprocess_asset_model(config, key).set_name)
for key in VISIBLE_PREPROCESS_ASSET_KEYS
}
radio_widgets = (
self._serial_input,
self._radar_mode,
self._start_hz_input,
self._stop_hz_input,
self._points_input,
self._ifbw_input,
self._power_input,
self._settling_ms,
self._combos_text,
self._single_combo_output,
self._single_combo_input,
self._run_combos_select_button,
self._single_combo_select_button,
self._processing_mode,
self._show_magnitude_checkbox,
self._show_phase_checkbox,
self._pass_through_fixed_y_enabled,
self._pass_through_y_min_db,
self._pass_through_y_max_db,
self._bscan_axis,
self._bscan_cut_m,
self._bscan_max_depth_m,
self._bscan_gain,
self._bscan_start_freq_mhz,
self._bscan_stop_freq_mhz,
self._gpr_config_mode,
self._gpr_relative_permittivity,
self._gpr_tx_geometry_input,
self._gpr_rx_geometry_input,
self._gpr_input_positions_input,
self._gpr_output_positions_input,
self._gpr_min_depth_m,
self._gpr_max_depth_m,
self._gpr_comp_power,
self._gpr_start_freq_mhz,
self._gpr_stop_freq_mhz,
self._gpr_background_subtract_enabled,
self._gpr_background_mean_count,
self._save_count,
self._save_path_input,
self._save_name_input,
)
with ExitStack() as blockers:
for widget in radio_widgets:
blockers.enter_context(QSignalBlocker(widget))
self._serial_input.setText(str(config.radar.serial))
self._set_combo_current_text(self._radar_mode, str(config.radar.driver_mode))
self._start_hz_input.setText(f"{config.radar.sweep.start_hz:g}")
self._stop_hz_input.setText(f"{config.radar.sweep.stop_hz:g}")
self._points_input.setText(str(int(config.radar.sweep.points)))
self._ifbw_input.setText(f"{config.radar.sweep.if_bandwidth_hz:g}")
self._power_input.setText(f"{config.radar.sweep.power_dbm:g}")
self._settling_ms.setText(str(int(config.runtime.settling_ms)))
self._combos_text.setText(str(gui_state.switches.combos_text))
self._single_combo_output.setText(str(gui_state.switches.single_output))
self._single_combo_input.setText(str(gui_state.switches.single_input))
self._set_combo_selection_mode(gui_state.switches.combo_mode)
self._set_combo_current_text(self._processing_mode, gui_state.processing.selected_mode)
self._show_magnitude_checkbox.setChecked(bool(gui_state.processing.pass_through.show_magnitude))
self._show_phase_checkbox.setChecked(bool(gui_state.processing.pass_through.show_phase))
self._pass_through_fixed_y_enabled.setChecked(bool(gui_state.processing.pass_through.fixed_y_enabled))
self._pass_through_y_min_db.setValue(float(gui_state.processing.pass_through.y_min_db))
self._pass_through_y_max_db.setValue(float(gui_state.processing.pass_through.y_max_db))
self._set_combo_current_text(self._bscan_axis, gui_state.processing.bscan.axis)
self._bscan_cut_m.setValue(float(gui_state.processing.bscan.cut_m))
self._bscan_max_depth_m.setValue(float(gui_state.processing.bscan.max_depth_m))
self._bscan_gain.setValue(float(gui_state.processing.bscan.gain))
self._bscan_start_freq_mhz.setValue(float(gui_state.processing.bscan.start_freq_mhz))
self._bscan_stop_freq_mhz.setValue(float(gui_state.processing.bscan.stop_freq_mhz))
self._set_combo_current_text(self._gpr_config_mode, str(config.gpr.mode))
self._gpr_relative_permittivity.setValue(float(config.gpr.relative_permittivity))
self._gpr_tx_geometry_input.setPlainText(
"\n".join(
f"{int(entry.output_pos)} {float(entry.x_m):g}"
for entry in config.gpr.tx_geometry
)
)
self._gpr_rx_geometry_input.setPlainText(
"\n".join(
f"{int(entry.input_pos)} {float(entry.x_m):g}"
for entry in config.gpr.rx_geometry
)
)
self._gpr_input_positions_input.setText(str(gui_state.processing.gpr.input_positions))
self._gpr_output_positions_input.setText(str(gui_state.processing.gpr.output_positions))
self._gpr_min_depth_m.setValue(float(gui_state.processing.gpr.min_depth_m))
self._gpr_max_depth_m.setValue(float(gui_state.processing.gpr.max_depth_m))
self._gpr_comp_power.setValue(float(gui_state.processing.gpr.comp_power))
self._gpr_start_freq_mhz.setValue(float(gui_state.processing.gpr.start_freq_mhz))
self._gpr_stop_freq_mhz.setValue(float(gui_state.processing.gpr.stop_freq_mhz))
self._gpr_background_subtract_enabled.setChecked(
bool(gui_state.processing.gpr.background_subtract_enabled)
)
self._gpr_background_mean_count.setValue(int(gui_state.processing.gpr.background_mean_count))
self._save_count.setValue(int(gui_state.data_actions.save_count))
self._save_path_input.setText(str(gui_state.data_actions.save_path))
self._save_name_input.setText(str(gui_state.data_actions.save_name))
self._defaults_config = config
self._gui_defaults = gui_state
self._selected_preprocess_sets = selected_preprocess_sets
self._preprocess_set_name = str(gui_state.preprocess_dialog.set_name)
self._apply_history_limit_from_config(config)
self._gpr_geometry_signature = None
self._gpr_selected_geometry = None
self._sync_pass_through_y_controls()
self._refresh_preprocess_summary_labels()
if self._preprocess_dialog is not None:
with ExitStack() as dialog_blockers:
dialog_blockers.enter_context(QSignalBlocker(self._preprocess_dialog._set_name_input))
for combo in self._preprocess_dialog._set_combos.values():
dialog_blockers.enter_context(QSignalBlocker(combo))
self._preprocess_dialog.set_set_name(self._preprocess_set_name)
self._preprocess_dialog.set_selected_sets(self._selected_preprocess_sets, emit_signal=False)
self._apply_initial_radar_limits()
self._on_processing_mode_changed(gui_state.processing.selected_mode)
self._update_history_indicator()
self._remember_active_profile_path(profile_path)
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._single_combo_select_button.isChecked():
config.combos = [
ComboModel(
input=int(self._single_combo_input.text().strip()),
output=int(self._single_combo_output.text().strip()),
)
]
else:
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)]
for key in PREPROCESS_ASSET_KEYS:
asset = preprocess_asset_model(config, key)
asset.set_name = self._selected_preprocess_sets[key]
asset.bundle_path = ""
preprocess_asset_model(config, key).bundle_path = ""
for key in VISIBLE_PREPROCESS_ASSET_KEYS:
preprocess_asset_model(config, key).set_name = self._selected_preprocess_sets.get(key, "")
config.gpr.mode = self._gpr_config_mode.currentText()
config.gpr.relative_permittivity = float(self._gpr_relative_permittivity.value())
config.gpr.tx_geometry = self._parse_gpr_tx_geometry_text(self._gpr_tx_geometry_input.toPlainText())
@@ -154,14 +549,12 @@ class AppWindowConfigMixin:
y_max_db = float(self._pass_through_y_max_db.value())
return ProcessingLiveConfig(
processor_mode=self._processing_mode.currentText(),
gain_db=float(self._processing_gain_db.value()),
phase_deg=float(self._processing_phase_deg.value()),
pass_through_channel=self._pass_through_channel.currentText(),
pass_through_channel="s21",
pass_through_fixed_y_enabled=bool(self._pass_through_fixed_y_enabled.isChecked()),
pass_through_y_min_db=min(y_min_db, y_max_db),
pass_through_y_max_db=max(y_min_db, y_max_db),
bscan_axis=self._bscan_axis.currentText(),
bscan_channel=self._bscan_channel.currentText(),
bscan_channel="s21",
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()),
@@ -207,7 +600,7 @@ class AppWindowConfigMixin:
else:
self._clear_trace_plots()
except Exception as exc: # noqa: BLE001
self._show_error(f"Failed to update live processing settings: {exc}")
self._show_exception("Failed to update live processing settings", exc)
def _on_processing_mode_changed(self, mode: str) -> None:
"""Switch processing parameter page and refresh corresponding visualization."""
@@ -223,6 +616,33 @@ class AppWindowConfigMixin:
self._processing_mode_pages.setFixedHeight(current_page.sizeHint().height())
self._processing_mode_pages.updateGeometry()
self._on_processing_live_settings_changed()
if mode == "pass_through":
self._log(
"Processing mode selected: pass_through "
f"(show_magnitude={self._show_magnitude_checkbox.isChecked()}, "
f"show_phase={self._show_phase_checkbox.isChecked()}, "
f"fixed_y={self._pass_through_fixed_y_enabled.isChecked()}, "
f"y_range={self._pass_through_y_min_db.value():g}..{self._pass_through_y_max_db.value():g} dB)"
)
elif mode == "bscan":
self._log(
"Processing mode selected: bscan "
f"(axis={self._bscan_axis.currentText()}, "
f"cut={self._bscan_cut_m.value():g} m, "
f"max_depth={self._bscan_max_depth_m.value():g} m, "
f"gain={self._bscan_gain.value():g}, "
f"freq={self._bscan_start_freq_mhz.value():g}..{self._bscan_stop_freq_mhz.value():g} MHz)"
)
elif mode == "gpr":
self._log(
"Processing mode selected: gpr "
f"(inputs={self._gpr_input_positions_input.text().strip() or '<all>'}, "
f"outputs={self._gpr_output_positions_input.text().strip() or '<all>'}, "
f"depth={self._gpr_min_depth_m.value():g}..{self._gpr_max_depth_m.value():g} m, "
f"freq={self._gpr_start_freq_mhz.value():g}..{self._gpr_stop_freq_mhz.value():g} MHz, "
f"background_subtract={self._gpr_background_subtract_enabled.isChecked()}, "
f"mean_count={self._gpr_background_mean_count.value()})"
)
def _clear_history_mode_caches(self) -> None:
"""Drop cached render state for pass-through, B-scan, and GPR views."""
@@ -278,22 +698,20 @@ class AppWindowConfigMixin:
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}")
self._log_exception("Failed to query LibreVNA limits; using UI fallback", exc, level="WARN")
self._apply_radar_limits_to_ui(None)
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)
"""Handle unavailable native limits without mutating JSON-backed mode."""
self._log_warning(reason)
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."""
previous_limits = dict(self._radar_limits) if self._radar_limits is not None else None
if limits is None:
self._radar_limits = None
self._radar_start_label.setText("Start Hz")
@@ -356,6 +774,38 @@ class AppWindowConfigMixin:
or prev_power != self._power_input.text().strip()
)
applied_limits_changed = previous_limits != limits
if applied_limits_changed:
self._log(
"Applied radar device limits: "
f"freq={min_freq_hz:g}..{max_freq_hz:g} Hz, "
f"points=1..{max_points}, "
f"ifbw={min_ifbw_hz:g}..{max_ifbw_hz:g} Hz, "
f"power={min_power_dbm:g}..{max_power_dbm:g} dBm"
)
adjustments: list[str] = []
current_start = self._start_hz_input.text().strip()
current_stop = self._stop_hz_input.text().strip()
current_points = self._points_input.text().strip()
current_ifbw = self._ifbw_input.text().strip()
current_power = self._power_input.text().strip()
if prev_start != current_start:
adjustments.append(f"Start Hz: {prev_start or '<empty>'} -> {current_start}")
if prev_stop != current_stop:
adjustments.append(f"Stop Hz: {prev_stop or '<empty>'} -> {current_stop}")
if prev_points != current_points:
adjustments.append(f"Points: {prev_points or '<empty>'} -> {current_points}")
if prev_ifbw != current_ifbw:
adjustments.append(f"IF BW Hz: {prev_ifbw or '<empty>'} -> {current_ifbw}")
if prev_power != current_power:
adjustments.append(f"Stimulus Power dBm: {prev_power or '<empty>'} -> {current_power}")
if adjustments:
self._log_warning(
"Radar fields were adjusted to satisfy device limits.",
details="\n".join(adjustments),
)
self._sync_processing_frequency_limits_with_radar()
return changed
@@ -403,7 +853,14 @@ class AppWindowConfigMixin:
radar_max_mhz = max(radar_start_hz, radar_stop_hz) / 1_000_000.0
changed = False
widget_labels = {
"_bscan_start_freq_mhz": "B-scan Start MHz",
"_bscan_stop_freq_mhz": "B-scan Stop MHz",
"_gpr_start_freq_mhz": "GPR Start MHz",
"_gpr_stop_freq_mhz": "GPR Stop MHz",
}
widgets = [getattr(self, widget_name) for widget_name in widget_names]
previous_values = {widget_name: getattr(self, widget_name).value() for widget_name in widget_names}
for widget in widgets:
if widget.minimum() != radar_min_mhz or widget.maximum() != radar_max_mhz:
changed = True
@@ -418,6 +875,20 @@ class AppWindowConfigMixin:
widget.blockSignals(True)
widget.setValue(clamped_value)
widget.blockSignals(False)
clamped_fields = []
for widget_name in widget_names:
current_value = getattr(self, widget_name).value()
previous_value = previous_values[widget_name]
if current_value == previous_value:
continue
clamped_fields.append(
f"{widget_labels.get(widget_name, widget_name)}: {previous_value:g} -> {current_value:g}"
)
if clamped_fields:
self._log_warning(
"Processing frequency limits were clamped to the active radar sweep.",
details="\n".join(clamped_fields),
)
return changed
def _sync_bscan_frequency_limits_with_radar(self) -> bool:
@@ -9,7 +9,12 @@ from python_app.gui.runtime.history import build_run_history_signature, record_r
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.preprocess_assets import PREPROCESS_ASSET_KEYS, PREPROCESS_ASSET_SPECS, preprocess_asset_model
from python_app.orchestration.preprocess_assets import (
PREPROCESS_ASSET_SPECS,
REQUIRED_PREPROCESS_ASSET_KEYS,
preprocess_asset_model,
runtime_preprocess_asset_keys,
)
from python_app.orchestration.shm_reader import ShmRingReader
@@ -23,10 +28,13 @@ class AppWindowPipelineMixin:
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")
self._show_error(
"Cannot start pipeline during active capture sequence",
details=self._capture_state_details(),
)
return
if self._supervisor.is_running():
self._show_error("Pipeline is already running")
self._show_error("Pipeline is already running", details=self._process_state_details())
return
try:
@@ -41,7 +49,7 @@ class AppWindowPipelineMixin:
missing_assets = [
PREPROCESS_ASSET_SPECS[key].display_name
for key in PREPROCESS_ASSET_KEYS
for key in REQUIRED_PREPROCESS_ASSET_KEYS
if not preprocess_asset_model(config, key).set_name
]
if missing_assets:
@@ -51,8 +59,14 @@ class AppWindowPipelineMixin:
)
combo_keys = [ComboKey(input_pos=combo.input, output_pos=combo.output) for combo in config.combos]
active_preprocess_keys = runtime_preprocess_asset_keys(config)
preprocess_summary = "; ".join(
f"{PREPROCESS_ASSET_SPECS[key].display_name}={preprocess_asset_model(config, key).set_name}"
for key in active_preprocess_keys
)
self._log(f"Active preprocess assets for run: {preprocess_summary}")
for key in PREPROCESS_ASSET_KEYS:
for key in active_preprocess_keys:
spec = PREPROCESS_ASSET_SPECS[key]
asset = preprocess_asset_model(config, key)
if not self._store.has_combo_coverage(spec.set_kind, radar_key, asset.set_name, combo_keys):
@@ -65,6 +79,14 @@ class AppWindowPipelineMixin:
self._prepare_radar_for_native_acquisition(config)
config_path = self._config_writer.write(config, self._project_root / "python_app/runtime/run_config.json")
combo_preview = ", ".join(f"in{combo.input}/out{combo.output}" for combo in config.combos[:6])
if len(config.combos) > 6:
combo_preview += ", ..."
self._log(
f"Starting pipeline: mode={'single_capture' if single_capture else 'continuous'}, "
f"config={config_path}, combos={len(config.combos)}"
f"{', ' + combo_preview if combo_preview else ''}, radar_key={radar_key}"
)
if not single_capture:
should_reset_history = (
@@ -75,7 +97,7 @@ class AppWindowPipelineMixin:
self._log("History reset because run settings changed")
self._history_run_signature = run_signature
self._supervisor.start(config_path)
self._supervisor.start(config_path, allow_clean_orchestrator_exit=single_capture)
self._close_readers()
self._raw_reader = ShmRingReader(config.rings.raw_tap.name)
self._pre_reader = ShmRingReader(config.rings.preprocessed_tap.name)
@@ -88,25 +110,31 @@ class AppWindowPipelineMixin:
# Always drop unread payloads for all stages so single-capture starts
# from a clean boundary and does not retain stale results-only tail.
self._drop_pending_ring_payloads(include_results=True)
self._last_reader_error_signature = None
if single_capture:
self._single_capture_start_ns = time.monotonic_ns()
pid_map = self._supervisor.pids()
pid_text = ", ".join(f"{name}={pid}" for name, pid in sorted(pid_map.items())) or "none"
if single_capture:
self._status_label.setText("Status: single capture running")
self._log("Single capture started")
self._log(f"Single capture started; managed processes: {pid_text}")
else:
self._status_label.setText("Status: running")
self._log("Pipeline started")
self._log(f"Pipeline started; managed processes: {pid_text}")
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}")
self._show_exception("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")
self._show_error(
"Finish or abort capture sequence before applying radar settings",
details=self._capture_state_details(),
)
return
was_running = self._supervisor.is_running()
@@ -118,9 +146,16 @@ class AppWindowPipelineMixin:
self._refresh_radar_limits_from_device()
config = self._build_config()
self._prepare_radar_for_native_acquisition(config)
self._log("Radar settings applied")
self._log(
"Radar settings applied: "
f"start={config.radar.sweep.start_hz:g} Hz, "
f"stop={config.radar.sweep.stop_hz:g} Hz, "
f"points={config.radar.sweep.points}, "
f"ifbw={config.radar.sweep.if_bandwidth_hz:g} Hz, "
f"power={config.radar.sweep.power_dbm:g} dBm"
)
except Exception as exc: # noqa: BLE001
self._show_error(f"Failed to apply radar settings: {exc}")
self._show_exception("Failed to apply radar settings", exc)
finally:
if was_running:
self._start_run()
@@ -197,9 +232,12 @@ class AppWindowPipelineMixin:
def _poll_rings(self) -> None:
"""Poll readers, ingest history, and trigger rendering."""
for report in self._supervisor.collect_crash_reports():
for report in self._supervisor.collect_exit_reports():
if report.level == "INFO":
self._log(report.format())
continue
self._status_label.setText("Status: error")
self._log(report)
self._log_error(report.format())
try:
if self._raw_reader is not None:
@@ -207,6 +245,7 @@ class AppWindowPipelineMixin:
self._read_all_preprocessed()
result_latest = self._read_all_results() if self._result_reader is not None else None
self._update_history_indicator()
self._last_reader_error_signature = None
if self._single_capture_active:
if self._finish_single_capture_if_ready():
@@ -215,7 +254,11 @@ class AppWindowPipelineMixin:
self._draw_preferred_collection(result_latest=result_latest)
except Exception as exc: # noqa: BLE001
self._log(f"Reader error: {exc}")
signature = (type(exc).__name__, str(exc))
if self._last_reader_error_signature == signature:
return
self._last_reader_error_signature = signature
self._log_exception("Reader poll failed", exc, level="ERROR")
def _finish_single_capture_if_ready(self) -> bool:
"""Finalize single capture when the exact target result becomes available."""
@@ -86,8 +86,11 @@ class AppWindowPlotMixin:
if mag_legend is not None:
try:
self._trace_magnitude_plot.getPlotItem().removeItem(mag_legend)
except Exception: # noqa: BLE001
pass
except Exception as exc: # noqa: BLE001
self._log_warning(
f"Failed to remove pass-through magnitude legend: {type(exc).__name__}: {exc}",
once_key="plot_remove_magnitude_legend_failed",
)
self._trace_magnitude_legend = None
self._trace_magnitude_legend_combo_keys.clear()
@@ -95,8 +98,11 @@ class AppWindowPlotMixin:
if phase_legend is not None:
try:
self._trace_phase_plot.getPlotItem().removeItem(phase_legend)
except Exception: # noqa: BLE001
pass
except Exception as exc: # noqa: BLE001
self._log_warning(
f"Failed to remove pass-through phase legend: {type(exc).__name__}: {exc}",
once_key="plot_remove_phase_legend_failed",
)
self._trace_phase_legend = None
self._trace_phase_legend_combo_keys.clear()
@@ -106,7 +112,7 @@ class AppWindowPlotMixin:
show_phase = self._show_phase_curves()
magnitude_plot = self._trace_magnitude_plot
phase_plot = self._trace_phase_plot
pass_through_channel = self._pass_through_channel.currentText().upper()
pass_through_channel = "S21"
magnitude_plot.setVisible(show_magnitude)
phase_plot.setVisible(show_phase)
@@ -318,8 +324,11 @@ class AppWindowPlotMixin:
if legend is not None:
try:
plot.getPlotItem().removeItem(legend)
except Exception: # noqa: BLE001
pass
except Exception as exc: # noqa: BLE001
self._log_warning(
f"Failed to clear plot legend: {type(exc).__name__}: {exc}",
once_key=f"{legend_attr}_clear_failed",
)
setattr(self, legend_attr, None)
existing_keys.clear()
return
@@ -330,8 +339,11 @@ class AppWindowPlotMixin:
if legend is not None:
try:
plot.getPlotItem().removeItem(legend)
except Exception: # noqa: BLE001
pass
except Exception as exc: # noqa: BLE001
self._log_warning(
f"Failed to replace plot legend: {type(exc).__name__}: {exc}",
once_key=f"{legend_attr}_replace_failed",
)
legend = plot.addLegend(offset=(8, 8))
for combo_key in sorted(active_keys):
@@ -389,7 +401,7 @@ class AppWindowPlotMixin:
self._bscan_plot.addItem(image_item)
self._bscan_plot.setXRange(x_min, x_max, padding=0.02)
self._bscan_plot.setYRange(depth_min, depth_max, padding=0.02)
bscan_channel = self._bscan_channel.currentText().upper()
bscan_channel = "S21"
self._bscan_plot.setTitle(
f"B-scan {bscan_channel} in{display_key[0]}/out{display_key[1]} | sweeps={sweep_count}"
)
@@ -428,7 +440,24 @@ class AppWindowPlotMixin:
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)
display_key = pick_bscan_display_key(self._bscan_history_by_combo)
available_keys = sorted(self._bscan_history_by_combo.keys())
if display_key is not None and len(available_keys) > 1:
combo_signature = ",".join(f"{input_pos}:{output_pos}" for input_pos, output_pos in available_keys)
details = "\n".join(
f"- in{input_pos}/out{output_pos}"
for input_pos, output_pos in available_keys
)
self._log(
f"B-scan auto-selected combo in{display_key[0]}/out{display_key[1]} because multiple combos are available.",
once_key=f"bscan_auto_display_{combo_signature}",
)
self._log_warning(
"B-scan has multiple combo histories but the UI currently renders only one at a time.",
details=details,
once_key=f"bscan_multi_combo_warning_{combo_signature}",
)
return display_key
def _bscan_lookup_table(self, axis_mode: str) -> np.ndarray:
"""Return lookup table for current B-scan axis mode."""
@@ -578,8 +607,11 @@ class AppWindowPlotMixin:
for item in self._gpr_point_labels:
try:
self._gpr_plot.removeItem(item)
except Exception: # noqa: BLE001
pass
except Exception as exc: # noqa: BLE001
self._log_warning(
f"Failed to remove GPR point label: {type(exc).__name__}: {exc}",
once_key="gpr_remove_point_label_failed",
)
self._gpr_point_labels.clear()
def _clear_gpr_region_labels(self) -> None:
@@ -587,8 +619,11 @@ class AppWindowPlotMixin:
for item in self._gpr_region_center_labels:
try:
self._gpr_plot.removeItem(item)
except Exception: # noqa: BLE001
pass
except Exception as exc: # noqa: BLE001
self._log_warning(
f"Failed to remove GPR region label: {type(exc).__name__}: {exc}",
once_key="gpr_remove_region_label_failed",
)
self._gpr_region_center_labels.clear()
def _clear_gpr_region_masks(self) -> None:
@@ -596,8 +631,11 @@ class AppWindowPlotMixin:
for item in self._gpr_region_mask_items:
try:
self._gpr_plot.removeItem(item)
except Exception: # noqa: BLE001
pass
except Exception as exc: # noqa: BLE001
self._log_warning(
f"Failed to remove GPR region mask: {type(exc).__name__}: {exc}",
once_key="gpr_remove_region_mask_failed",
)
self._gpr_region_mask_items.clear()
self._gpr_region_contours.clear()
@@ -838,10 +876,10 @@ class AppWindowPlotMixin:
] = magnitude_curve
if show_phase:
phase_deg = np.degrees(np.angle(samples))
phase_values = np.degrees(np.angle(samples))
phase_curve = pg.PlotCurveItem(
trace.frequency_hz,
phase_deg,
phase_values,
pen=pg.mkPen("#80ed99", width=1.4, style=Qt.PenStyle.DashLine),
)
phase_plot.addItem(phase_curve)
@@ -4,8 +4,8 @@ from __future__ import annotations
from python_app.gui.preprocess_dialog import PreprocessDialog
from python_app.orchestration.preprocess_assets import (
PREPROCESS_ASSET_KEYS,
PREPROCESS_ASSET_SPECS,
VISIBLE_PREPROCESS_ASSET_KEYS,
preprocess_asset_channel,
preprocess_asset_display_name,
)
@@ -22,7 +22,7 @@ class AppWindowPreprocessMixin:
self._refresh_sets()
self._update_capture_dialog_state()
except Exception as exc: # noqa: BLE001
self._show_error(f"Failed to open preprocessing panel: {exc}")
self._show_exception("Failed to open preprocessing panel", exc)
return
dialog.showMaximized()
@@ -35,24 +35,39 @@ class AppWindowPreprocessMixin:
return self._preprocess_dialog
dialog = PreprocessDialog(self)
self._preprocess_dialog = dialog
dialog.set_set_name(self._preprocess_set_name)
dialog.set_selected_sets(self._selected_preprocess_sets, emit_signal=False)
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) -> None:
"""Persist selected preprocessing set names from dialog."""
dialog = self._ensure_preprocess_dialog()
previous_selection = dict(self._selected_preprocess_sets)
self._selected_preprocess_sets = dialog.selection_snapshot()
self._refresh_preprocess_summary_labels()
changes = []
for key in VISIBLE_PREPROCESS_ASSET_KEYS:
previous_value = previous_selection.get(key, "")
current_value = self._selected_preprocess_sets.get(key, "")
if previous_value == current_value:
continue
changes.append(
f"{preprocess_asset_display_name(key)}: "
f"{previous_value or '<not selected>'} -> {current_value or '<not selected>'}"
)
if changes:
self._log("Preprocess selection changed: " + "; ".join(changes))
def _refresh_preprocess_summary_labels(self) -> None:
"""Update compact summary labels in the main window."""
for key in PREPROCESS_ASSET_KEYS:
for key in VISIBLE_PREPROCESS_ASSET_KEYS:
self._selected_preprocess_labels[key].setText(self._selected_preprocess_sets.get(key, "") or "<not selected>")
def _refresh_sets(self) -> None:
@@ -63,22 +78,37 @@ class AppWindowPreprocessMixin:
available_sets = {
key: self._store.list_sets(PREPROCESS_ASSET_SPECS[key].set_kind, radar_key)
for key in PREPROCESS_ASSET_KEYS
for key in VISIBLE_PREPROCESS_ASSET_KEYS
}
dialog.set_available_sets(available_sets)
unavailable_selections: list[str] = []
for key, names in available_sets.items():
if self._selected_preprocess_sets.get(key, "") not in names:
self._selected_preprocess_sets[key] = names[0] if names else ""
current_value = self._selected_preprocess_sets.get(key, "")
if not current_value or current_value in names:
continue
unavailable_selections.append(
f"{preprocess_asset_display_name(key)}: "
f"{current_value} (not available for current radar key)"
)
dialog.set_selected_sets(self._selected_preprocess_sets)
self._refresh_preprocess_summary_labels()
self._log(f"Preprocess set lists refreshed for key={radar_key}")
available_counts = ", ".join(
f"{preprocess_asset_display_name(key)}={len(names)}"
for key, names in available_sets.items()
)
self._log(f"Preprocess set lists refreshed: radar_key={radar_key}, {available_counts}")
if unavailable_selections:
self._log_warning(
"Some selected preprocess sets are not currently available for this radar key.",
details="\n".join(unavailable_selections),
)
def _start_capture_sequence(self, kind: str) -> None:
"""Start sequential capture session for requested preprocess asset."""
if self._capture_session is not None:
self._show_error("Another capture sequence is already active")
self._show_error("Another capture sequence is already active", details=self._capture_state_details())
return
dialog = self._ensure_preprocess_dialog()
@@ -109,10 +139,13 @@ class AppWindowPreprocessMixin:
dialog.clear_capture_log()
dialog.set_status(f"{display_name} sequence started")
self._update_capture_dialog_state()
self._log(f"{display_name} sequence started for set={set_name}; fill all N*M combos")
self._log(
f"{display_name} sequence started: set={set_name}, radar_key={radar_key}, "
f"combos={session.state().total_count}"
)
except Exception as exc: # noqa: BLE001
self._cleanup_capture_session()
self._show_error(f"Failed to start {kind} sequence: {exc}")
self._show_exception(f"Failed to start {kind} sequence", exc)
self._resume_pipeline_if_needed()
def _capture_next_combo(self) -> None:
@@ -127,7 +160,6 @@ class AppWindowPreprocessMixin:
try:
trace = session.capture_current_combo()
state = session.state()
tx_label, rx_label = dialog.antenna_labels()
display_name = preprocess_asset_display_name(session.kind)
channel = preprocess_asset_channel(session.kind)
@@ -137,8 +169,6 @@ class AppWindowPreprocessMixin:
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"{display_name} captured", channel=channel)
@@ -164,7 +194,7 @@ class AppWindowPreprocessMixin:
else:
self._update_capture_dialog_state()
except Exception as exc: # noqa: BLE001
self._show_error(f"Failed to capture combo: {exc}")
self._show_exception("Failed to capture preprocess combo", exc)
self._abort_capture_sequence()
def _abort_capture_sequence(self, *, resume_pipeline: bool = True) -> None:
@@ -227,4 +257,4 @@ class AppWindowPreprocessMixin:
try:
self._start_run()
except Exception as exc: # noqa: BLE001
self._show_error(f"Failed to resume pipeline after capture: {exc}")
self._show_exception("Failed to resume pipeline after capture", exc)
@@ -12,12 +12,22 @@ from python_app.gui.runtime.history import record_result_history, remove_last_al
class AppWindowSnapshotMixin:
"""Saves runtime data snapshots and maintains ring-reader freshness."""
@staticmethod
def _snapshot_config_profile_path(snapshot_dir: Path) -> Path:
"""Return companion config-profile path inside a saved snapshot directory."""
return snapshot_dir / "config_profile.json"
@staticmethod
def _vna_json_config_profile_path(output_root: Path, output_stem: str) -> Path:
"""Return companion config-profile path for one VNA-history JSON export batch."""
return output_root / f"{output_stem}_config_profile.json"
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")
self._show_error("No runtime data is available for save", details=self._runtime_history_details())
return
try:
@@ -32,6 +42,19 @@ class AppWindowSnapshotMixin:
list(self._result_history),
last_n,
)
config_profile_path = self._snapshot_config_profile_path(snapshot_dir)
try:
self._write_gui_profile_to_path(config_profile_path, allow_overwrite=False)
except Exception as exc: # noqa: BLE001
self._show_error(
"Snapshot data was saved, but the adjacent config profile could not be written",
details=(
f"snapshot_dir={snapshot_dir}\n"
f"config_profile_path={config_profile_path}\n\n"
f"{self._exception_details(exc)}"
),
)
return
self._log(
f"Saved numpy snapshot: {snapshot_dir} "
f"(raw={summary.get('raw_count', 0)}, "
@@ -43,55 +66,77 @@ class AppWindowSnapshotMixin:
f"raw_missing={summary.get('raw_missing_count', 0)}, "
f"pre_missing={summary.get('preprocessed_missing_count', 0)}, "
f"result_missing={summary.get('result_missing_count', 0)}, "
f"requested_last_n={last_n})"
f"requested_last_n={last_n}, "
f"config_profile={config_profile_path})"
)
except Exception as exc: # noqa: BLE001
self._show_error(f"Failed to save snapshot: {exc}")
self._show_exception("Failed to save snapshot", exc)
def _save_vna_history_json(self) -> None:
"""Save runtime history as vna_system-compatible JSON file."""
"""Save one VNA-history JSON per available combo in runtime history."""
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")
self._show_error("No runtime data is available for save", details=self._runtime_history_details())
return
try:
last_n = int(self._save_count.value())
input_index = int(self._vna_json_input_index.value())
output_index = int(self._vna_json_output_index.value())
channel = self._vna_json_channel.currentText()
channel = "s21"
output_root = Path(self._save_path_input.text().strip()).expanduser()
output_name = self._save_name_input.text().strip()
output_path, summary = self._store.save_runtime_vna_history_json(
output_paths, summary = self._store.save_runtime_vna_history_json_batch(
output_root,
output_name,
list(self._raw_history),
list(self._pre_history),
list(self._result_history),
last_n,
input_index=input_index,
output_index=output_index,
channel=channel,
primary_stage="preprocessed",
)
output_stem = str(summary.get("output_stem", "")).strip()
if not output_stem:
raise RuntimeError("VNA history JSON export did not report output_stem for config companion save")
config_profile_path = self._vna_json_config_profile_path(output_root, output_stem)
try:
self._write_gui_profile_to_path(config_profile_path, allow_overwrite=False)
except Exception as exc: # noqa: BLE001
exported_preview = "\n".join(str(path) for path in output_paths[:8])
if len(output_paths) > 8:
exported_preview += "\n..."
self._show_error(
"VNA history JSON files were saved, but the adjacent config profile could not be written",
details=(
f"output_root={output_root}\n"
f"config_profile_path={config_profile_path}\n"
f"saved_json_files={len(output_paths)}\n"
f"{exported_preview}\n\n"
f"{self._exception_details(exc)}"
),
)
return
combos = summary.get("combos", [])
combo_preview = ", ".join(f"in{input_pos}/out{output_pos}" for input_pos, output_pos in combos[:6])
if len(combos) > 6:
combo_preview += ", ..."
self._log(
f"Saved VNA history JSON: {output_path} "
f"(sweeps={summary.get('sweep_count', 0)}, "
f"raw_records={summary.get('raw_record_count', 0)}, "
f"Saved VNA history JSON batch: files={len(output_paths)} "
f"(combos={summary.get('combo_count', 0)}"
f"{', ' + combo_preview if combo_preview else ''}, "
f"preprocessed_records={summary.get('preprocessed_record_count', 0)}, "
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')}, "
f"anchor={summary.get('anchor_stage', 'unknown')}, "
f"input={input_index}, "
f"output={output_index}, "
f"channel={channel}, "
f"requested_last_n={last_n})"
f"requested_last_n={last_n}, "
f"config_profile={config_profile_path})"
)
except Exception as exc: # noqa: BLE001
self._show_error(f"Failed to save VNA history JSON: {exc}")
self._show_exception("Failed to save VNA history JSON", exc)
def _remove_last_runtime_history(self) -> None:
"""Remove the newest runtime measurement from all stages and processor replay state."""
@@ -104,7 +149,10 @@ class AppWindowSnapshotMixin:
def _apply_runtime_history_deletion(self, *, remove_last_only: bool) -> None:
"""Apply destructive runtime-history deletion across readers, caches, and processor replay state."""
if self._capture_session is not None:
self._show_error("Cannot modify runtime history during active capture sequence")
self._show_error(
"Cannot modify runtime history during active capture sequence",
details=f"{self._capture_state_details()}\n\n{self._runtime_history_details()}",
)
return
resume_acquisition = self._supervisor.is_running()
@@ -161,7 +209,7 @@ class AppWindowSnapshotMixin:
if resume_acquisition:
self._start_run()
except Exception as exc: # noqa: BLE001
self._show_error(f"Failed to {error_action}: {exc}")
self._show_exception(f"Failed to {error_action}", exc)
def _browse_save_path(self) -> None:
"""Open directory picker for snapshot output path."""
@@ -238,7 +286,7 @@ class AppWindowSnapshotMixin:
continue
break
except Exception as exc: # noqa: BLE001
self._log(f"Snapshot drain warning: {exc}")
self._log_exception("Snapshot drain warning", exc, level="WARN")
def _drop_pending_ring_payloads(self, *, include_results: bool = True) -> None:
"""Drop unread payloads from active readers."""
@@ -16,10 +16,10 @@ from PyQt6.QtWidgets import (
QGroupBox,
QHBoxLayout,
QLabel,
QPlainTextEdit,
QPushButton,
QScrollArea,
QStackedWidget,
QTextEdit,
QVBoxLayout,
QWidget,
)
@@ -27,9 +27,7 @@ import pyqtgraph as pg
from python_app.gui.controllers.sections import (
build_data_actions_group,
build_gpr_config_group,
build_hardware_actions_group,
build_pipeline_group,
build_primary_actions_group,
build_preprocess_summary_group,
build_processing_group,
build_radar_group,
@@ -81,10 +79,15 @@ class AppWindowUiMixin:
self._plot_stack.setCurrentWidget(self._trace_plots_container)
root_layout.addWidget(self._plot_stack, stretch=12)
@staticmethod
def _create_plot_widget(*, background: str) -> pg.PlotWidget:
"""Create PlotWidget with pyqtgraph context menu disabled for PyQt6 compatibility."""
return pg.PlotWidget(background=background, enableMenu=False)
def _build_bscan_plot_page(self) -> None:
"""Create B-scan page in plot stack."""
# B-scan surface: one PlotWidget used as canvas for ImageItem heatmap.
self._bscan_plot = pg.PlotWidget(background="#0f141c")
self._bscan_plot = self._create_plot_widget(background="#0f141c")
self._bscan_plot.showGrid(x=True, y=True, alpha=0.2)
self._plot_stack.addWidget(self._bscan_plot)
@@ -97,7 +100,7 @@ class AppWindowUiMixin:
trace_layout.setContentsMargins(0, 0, 0, 0)
trace_layout.setSpacing(6)
self._trace_magnitude_plot = pg.PlotWidget(background="#0f141c")
self._trace_magnitude_plot = self._create_plot_widget(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)
@@ -105,7 +108,7 @@ class AppWindowUiMixin:
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 = self._create_plot_widget(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")
@@ -126,7 +129,7 @@ class AppWindowUiMixin:
def _build_gpr_plot_page(self) -> None:
"""Create GPR page in plot stack."""
self._gpr_plot = pg.PlotWidget(background="#0f141c")
self._gpr_plot = self._create_plot_widget(background="#0f141c")
self._gpr_plot.showGrid(x=True, y=True, alpha=0.2)
self._plot_stack.addWidget(self._gpr_plot)
@@ -141,17 +144,21 @@ class AppWindowUiMixin:
def _build_settings_panel(self, root_layout: QHBoxLayout, root: QWidget) -> None:
"""Build right settings panel with controls, status labels, and log."""
self._settings_panel = QWidget(root)
self._settings_panel.setMinimumWidth(530)
self._settings_panel.setMinimumWidth(610)
right_layout = QVBoxLayout(self._settings_panel)
right_layout.setContentsMargins(0, 0, 0, 0)
right_layout.setSpacing(10)
# Build log early so `_show_error()` can append text even during
# subsequent group construction if something fails.
self._log_box = QPlainTextEdit(self._settings_panel)
self._log_box = QTextEdit(self._settings_panel)
self._log_box.setObjectName("runtimeLogBox")
self._log_box.setReadOnly(True)
self._log_box.setUndoRedoEnabled(False)
self._log_box.setMinimumHeight(170)
self._log_box.document().setMaximumBlockCount(1200)
right_layout.addWidget(build_primary_actions_group(self), stretch=0)
right_layout.addWidget(self._build_settings_scroll(), stretch=1)
self._status_label = QLabel("Status: idle", self._settings_panel)
@@ -163,7 +170,7 @@ class AppWindowUiMixin:
right_layout.addWidget(self._history_label)
right_layout.addWidget(self._log_box, stretch=0)
root_layout.addWidget(self._settings_panel, stretch=6)
root_layout.addWidget(self._settings_panel, stretch=7)
def _build_settings_scroll(self) -> QScrollArea:
"""Build scroll area with all control groups in display order."""
@@ -186,14 +193,11 @@ class AppWindowUiMixin:
def _build_control_groups(self) -> list[QGroupBox]:
"""Create all settings groups in top-to-bottom order."""
return [
build_pipeline_group(self),
build_hardware_actions_group(self),
build_switch_group(self),
build_data_actions_group(self),
build_preprocess_summary_group(self),
build_processing_group(self),
build_gpr_config_group(self),
build_radar_group(self),
build_switch_group(self),
]
def _toggle_settings_panel(self, *, visible: bool | None = None) -> None:
@@ -1,9 +1,7 @@
"""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.gpr_config_section import build_gpr_config_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.primary_actions_section import build_primary_actions_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
@@ -11,9 +9,7 @@ from python_app.gui.controllers.sections.switch_section import build_switch_grou
__all__ = [
"build_data_actions_group",
"build_gpr_config_group",
"build_hardware_actions_group",
"build_pipeline_group",
"build_primary_actions_group",
"build_preprocess_summary_group",
"build_processing_group",
"build_radar_group",
@@ -2,8 +2,17 @@
from __future__ import annotations
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import QComboBox, QGroupBox, QHBoxLayout, QLabel, QLineEdit, QPushButton, QSpinBox, QVBoxLayout
from PyQt6.QtWidgets import (
QGridLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QSizePolicy,
QSpinBox,
QVBoxLayout,
)
def build_data_actions_group(owner) -> QGroupBox:
@@ -11,59 +20,46 @@ def build_data_actions_group(owner) -> QGroupBox:
group = QGroupBox("Data Actions")
layout = QVBoxLayout(group)
layout.setSpacing(8)
data_defaults = owner._gui_defaults.data_actions
save_button = QPushButton("Save Snapshot")
save_button = QPushButton("Save Dataset")
save_button.clicked.connect(owner._save_snapshot)
save_vna_json_button = QPushButton("Save VNA JSON")
save_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
save_vna_json_button = QPushButton("Save JSON")
save_vna_json_button.clicked.connect(owner._save_vna_history_json)
save_vna_json_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
remove_last_button = QPushButton("Remove Last Measurement")
remove_last_button.clicked.connect(owner._remove_last_runtime_history)
remove_last_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
clear_history_button = QPushButton("Clear Runtime History")
clear_history_button.clicked.connect(owner._clear_all_runtime_history)
clear_history_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
owner._save_count = QSpinBox()
owner._save_count.setMinimum(1)
owner._save_count.setMaximum(10_000)
owner._save_count.setValue(10)
owner._vna_json_input_index = QSpinBox()
owner._vna_json_input_index.setMinimum(0)
owner._vna_json_input_index.setMaximum(65_535)
owner._vna_json_input_index.setValue(0)
owner._vna_json_output_index = QSpinBox()
owner._vna_json_output_index.setMinimum(0)
owner._vna_json_output_index.setMaximum(65_535)
owner._vna_json_output_index.setValue(0)
owner._vna_json_channel = QComboBox()
owner._vna_json_channel.addItems(["s21", "s11"])
owner._save_count.setValue(int(data_defaults.save_count))
button_column = QVBoxLayout()
button_column.setSpacing(8)
button_column.addWidget(save_button, alignment=Qt.AlignmentFlag.AlignLeft)
button_column.addWidget(save_vna_json_button, alignment=Qt.AlignmentFlag.AlignLeft)
button_column.addWidget(remove_last_button, alignment=Qt.AlignmentFlag.AlignLeft)
button_column.addWidget(clear_history_button, alignment=Qt.AlignmentFlag.AlignLeft)
layout.addLayout(button_column)
button_grid = QGridLayout()
button_grid.setHorizontalSpacing(8)
button_grid.setVerticalSpacing(8)
button_grid.addWidget(save_button, 0, 0)
button_grid.addWidget(save_vna_json_button, 0, 1)
button_grid.addWidget(remove_last_button, 1, 0)
button_grid.addWidget(clear_history_button, 1, 1)
button_grid.setColumnStretch(0, 1)
button_grid.setColumnStretch(1, 1)
layout.addLayout(button_grid)
count_row = QHBoxLayout()
count_row.setSpacing(8)
count_row.addWidget(QLabel("Last N"))
count_row.addWidget(QLabel("Number of Measurements to Save"))
count_row.addWidget(owner._save_count)
count_row.addStretch(1)
layout.addLayout(count_row)
json_row = QHBoxLayout()
json_row.setSpacing(8)
json_row.addWidget(QLabel("JSON input"))
json_row.addWidget(owner._vna_json_input_index)
json_row.addWidget(QLabel("output"))
json_row.addWidget(owner._vna_json_output_index)
json_row.addWidget(QLabel("channel"))
json_row.addWidget(owner._vna_json_channel)
json_row.addStretch(1)
layout.addLayout(json_row)
path_row = QHBoxLayout()
path_row.setSpacing(8)
owner._save_path_input = QLineEdit(str(owner._project_root / "python_app/data/snapshots"))
owner._save_path_input = QLineEdit(str(data_defaults.save_path))
browse_button = QPushButton("Browse")
browse_button.clicked.connect(owner._browse_save_path)
path_row.addWidget(QLabel("Path"))
@@ -73,7 +69,7 @@ def build_data_actions_group(owner) -> QGroupBox:
name_row = QHBoxLayout()
name_row.setSpacing(8)
owner._save_name_input = QLineEdit("snapshot_manual")
owner._save_name_input = QLineEdit(str(data_defaults.save_name))
name_row.addWidget(QLabel("Name"))
name_row.addWidget(owner._save_name_input, stretch=1)
layout.addLayout(name_row)
@@ -1,53 +0,0 @@
"""Builder for stable GPR configuration section."""
from __future__ import annotations
from PyQt6.QtWidgets import QComboBox, QDoubleSpinBox, QFormLayout, QGroupBox, QPlainTextEdit
def _format_tx_geometry(owner) -> str:
"""Render Tx geometry defaults into editable line-based text."""
return "\n".join(
f"{int(entry.output_pos)} {float(entry.x_m):g}"
for entry in owner._defaults_config.gpr.tx_geometry
)
def _format_rx_geometry(owner) -> str:
"""Render Rx geometry defaults into editable line-based text."""
return "\n".join(
f"{int(entry.input_pos)} {float(entry.x_m):g}"
for entry in owner._defaults_config.gpr.rx_geometry
)
def build_gpr_config_group(owner) -> QGroupBox:
"""Create stable GPR config controls backed by run_config.json."""
group = QGroupBox("GPR Config")
form = QFormLayout(group)
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
defaults = owner._defaults_config.gpr
owner._gpr_config_mode = QComboBox()
owner._gpr_config_mode.addItems(["point", "extended"])
owner._set_combo_current_text(owner._gpr_config_mode, defaults.mode)
owner._gpr_relative_permittivity = QDoubleSpinBox()
owner._gpr_relative_permittivity.setDecimals(4)
owner._gpr_relative_permittivity.setRange(0.0001, 1000.0)
owner._gpr_relative_permittivity.setSingleStep(0.05)
owner._gpr_relative_permittivity.setValue(float(defaults.relative_permittivity))
owner._gpr_tx_geometry_input = QPlainTextEdit(_format_tx_geometry(owner))
owner._gpr_tx_geometry_input.setPlaceholderText("output_pos x_m")
owner._gpr_tx_geometry_input.setMinimumHeight(88)
owner._gpr_rx_geometry_input = QPlainTextEdit(_format_rx_geometry(owner))
owner._gpr_rx_geometry_input.setPlaceholderText("input_pos x_m")
owner._gpr_rx_geometry_input.setMinimumHeight(120)
form.addRow("Mode", owner._gpr_config_mode)
form.addRow("Relative Permittivity", owner._gpr_relative_permittivity)
form.addRow("Tx Geometry", owner._gpr_tx_geometry_input)
form.addRow("Rx Geometry", owner._gpr_rx_geometry_input)
return group
@@ -1,26 +0,0 @@
"""Builder for hardware actions section."""
from __future__ import annotations
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import QGroupBox, QPushButton, QVBoxLayout
def build_hardware_actions_group(owner) -> QGroupBox:
"""Create hardware action buttons section."""
group = QGroupBox("Hardware Actions")
layout = QVBoxLayout(group)
layout.setSpacing(8)
apply_radar_button = QPushButton("Apply Radar")
apply_radar_button.clicked.connect(owner._apply_radar_settings)
layout.addWidget(apply_radar_button, alignment=Qt.AlignmentFlag.AlignLeft)
save_config_button = QPushButton("Save Config")
save_config_button.clicked.connect(owner._save_current_config)
layout.addWidget(save_config_button, alignment=Qt.AlignmentFlag.AlignLeft)
preprocess_button = QPushButton("Preprocessing")
preprocess_button.clicked.connect(owner._open_preprocess_panel)
layout.addWidget(preprocess_button, alignment=Qt.AlignmentFlag.AlignLeft)
return group
@@ -1,30 +0,0 @@
"""Builder for pipeline control section."""
from __future__ import annotations
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import QGroupBox, QLabel, QPushButton, QVBoxLayout
def build_pipeline_group(owner) -> QGroupBox:
"""Create Start/Single/Stop controls section."""
group = QGroupBox("Pipeline")
layout = QVBoxLayout(group)
layout.setSpacing(8)
start_button = QPushButton("Start")
start_button.clicked.connect(owner._start_run)
layout.addWidget(start_button, alignment=Qt.AlignmentFlag.AlignLeft)
single_button = QPushButton("Single Capture")
single_button.clicked.connect(owner._start_single_capture)
layout.addWidget(single_button, alignment=Qt.AlignmentFlag.AlignLeft)
stop_button = QPushButton("Stop")
stop_button.clicked.connect(owner._stop_run)
layout.addWidget(stop_button, alignment=Qt.AlignmentFlag.AlignLeft)
hint = QLabel("Start continuous run or single processed collection capture.")
hint.setObjectName("hintLabel")
layout.addWidget(hint)
return group
@@ -4,7 +4,7 @@ from __future__ import annotations
from PyQt6.QtWidgets import QFormLayout, QGroupBox, QLabel
from python_app.orchestration.preprocess_assets import PREPROCESS_ASSET_KEYS, preprocess_asset_display_name
from python_app.orchestration.preprocess_assets import VISIBLE_PREPROCESS_ASSET_KEYS, preprocess_asset_display_name
def build_preprocess_summary_group(owner) -> QGroupBox:
@@ -14,7 +14,7 @@ def build_preprocess_summary_group(owner) -> QGroupBox:
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
owner._selected_preprocess_labels = {}
for key in PREPROCESS_ASSET_KEYS:
for key in VISIBLE_PREPROCESS_ASSET_KEYS:
label = QLabel("<not selected>")
owner._selected_preprocess_labels[key] = label
form.addRow(preprocess_asset_display_name(key), label)
@@ -0,0 +1,53 @@
"""Builder for pinned primary action controls."""
from __future__ import annotations
from PyQt6.QtWidgets import QGridLayout, QGroupBox, QPushButton, QSizePolicy
def _expanding_button(label: str) -> QPushButton:
"""Create horizontally expanding action button."""
button = QPushButton(label)
button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
return button
def build_primary_actions_group(owner) -> QGroupBox:
"""Create pinned action block combining pipeline and hardware actions."""
group = QGroupBox("Actions")
layout = QGridLayout(group)
layout.setHorizontalSpacing(8)
layout.setVerticalSpacing(8)
start_button = _expanding_button("Start")
start_button.clicked.connect(owner._start_run)
layout.addWidget(start_button, 0, 0)
single_button = _expanding_button("Single Capture")
single_button.clicked.connect(owner._start_single_capture)
layout.addWidget(single_button, 0, 1)
stop_button = _expanding_button("Stop")
stop_button.clicked.connect(owner._stop_run)
layout.addWidget(stop_button, 0, 2)
apply_radar_button = _expanding_button("Apply Radar")
apply_radar_button.clicked.connect(owner._apply_radar_settings)
layout.addWidget(apply_radar_button, 1, 0)
load_config_button = _expanding_button("Load Config")
load_config_button.clicked.connect(owner._load_config_from_dialog)
layout.addWidget(load_config_button, 1, 1)
save_config_button = _expanding_button("Save Config")
save_config_button.clicked.connect(owner._save_current_config)
layout.addWidget(save_config_button, 1, 2)
preprocess_button = _expanding_button("Preprocessing")
preprocess_button.clicked.connect(owner._open_preprocess_panel)
layout.addWidget(preprocess_button, 2, 0, 1, 3)
layout.setColumnStretch(0, 1)
layout.setColumnStretch(1, 1)
layout.setColumnStretch(2, 1)
return group
@@ -9,27 +9,27 @@ from PyQt6.QtWidgets import (
QFormLayout,
QGroupBox,
QLineEdit,
QPlainTextEdit,
QSizePolicy,
QSpinBox,
QStackedWidget,
QWidget,
)
def _default_gpr_input_positions(owner) -> str:
"""Build default live input-position selection from stable GPR config."""
geometry_values = {int(entry.input_pos) for entry in owner._defaults_config.gpr.rx_geometry}
combo_values = {int(combo.input) for combo in owner._defaults_config.combos}
values = sorted(geometry_values & combo_values) or sorted(geometry_values)
return ",".join(str(value) for value in values)
def _format_tx_geometry(owner) -> str:
"""Render Tx geometry defaults into editable line-based text."""
return "\n".join(
f"{int(entry.output_pos)} {float(entry.x_m):g}"
for entry in owner._defaults_config.gpr.tx_geometry
)
def _default_gpr_output_positions(owner) -> str:
"""Build default live output-position selection from stable GPR config."""
geometry_values = {int(entry.output_pos) for entry in owner._defaults_config.gpr.tx_geometry}
combo_values = {int(combo.output) for combo in owner._defaults_config.combos}
values = sorted(geometry_values & combo_values) or sorted(geometry_values)
return ",".join(str(value) for value in values)
def _format_rx_geometry(owner) -> str:
"""Render Rx geometry defaults into editable line-based text."""
return "\n".join(
f"{int(entry.input_pos)} {float(entry.x_m):g}"
for entry in owner._defaults_config.gpr.rx_geometry
)
def build_processing_group(owner) -> QGroupBox:
@@ -37,9 +37,14 @@ def build_processing_group(owner) -> QGroupBox:
group = QGroupBox("Processing")
form = QFormLayout(group)
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
processing_defaults = owner._gui_defaults.processing
pass_defaults = processing_defaults.pass_through
bscan_defaults = processing_defaults.bscan
gpr_live_defaults = processing_defaults.gpr
owner._processing_mode = QComboBox()
owner._processing_mode.addItems(["pass_through", "bscan", "gpr"])
owner._set_combo_current_text(owner._processing_mode, processing_defaults.selected_mode)
owner._processing_mode_pages = QStackedWidget(group)
owner._processing_mode_pages.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
@@ -49,52 +54,29 @@ def build_processing_group(owner) -> QGroupBox:
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._pass_through_channel = QComboBox()
owner._pass_through_channel.addItems(["s21", "s11"])
owner._show_magnitude_checkbox = QCheckBox("Show magnitude")
owner._show_magnitude_checkbox.setChecked(True)
owner._show_magnitude_checkbox.setChecked(bool(pass_defaults.show_magnitude))
owner._show_phase_checkbox = QCheckBox("Show phase")
owner._show_phase_checkbox.setChecked(True)
owner._show_phase_checkbox.setChecked(bool(pass_defaults.show_phase))
owner._pass_through_fixed_y_enabled = QCheckBox("Fix magnitude Y range")
owner._pass_through_fixed_y_enabled.setChecked(False)
owner._pass_through_fixed_y_enabled.setChecked(bool(pass_defaults.fixed_y_enabled))
owner._pass_through_y_min_db = QDoubleSpinBox()
owner._pass_through_y_min_db.setDecimals(1)
owner._pass_through_y_min_db.setRange(-240.0, 240.0)
owner._pass_through_y_min_db.setSingleStep(1.0)
owner._pass_through_y_min_db.setValue(-100.0)
owner._pass_through_y_min_db.setValue(float(pass_defaults.y_min_db))
owner._pass_through_y_max_db = QDoubleSpinBox()
owner._pass_through_y_max_db.setDecimals(1)
owner._pass_through_y_max_db.setRange(-240.0, 240.0)
owner._pass_through_y_max_db.setSingleStep(1.0)
owner._pass_through_y_max_db.setValue(0.0)
owner._pass_through_y_max_db.setValue(float(pass_defaults.y_max_db))
def sync_pass_through_y_controls() -> None:
enabled = owner._pass_through_fixed_y_enabled.isChecked()
owner._pass_through_y_min_db.setEnabled(enabled)
owner._pass_through_y_max_db.setEnabled(enabled)
owner._sync_pass_through_y_controls()
sync_pass_through_y_controls()
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("Channel", owner._pass_through_channel)
pass_through_form.addRow(owner._show_magnitude_checkbox)
pass_through_form.addRow(owner._show_phase_checkbox)
pass_through_form.addRow(owner._pass_through_fixed_y_enabled)
@@ -109,42 +91,39 @@ def build_processing_group(owner) -> QGroupBox:
owner._bscan_axis = QComboBox()
owner._bscan_axis.addItems(["abs", "real", "phase"])
owner._bscan_channel = QComboBox()
owner._bscan_channel.addItems(["s21", "s11"])
owner._set_combo_current_text(owner._bscan_axis, bscan_defaults.axis)
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_cut_m.setValue(float(bscan_defaults.cut_m))
owner._bscan_max_depth_m = QDoubleSpinBox()
owner._bscan_max_depth_m.setDecimals(1)
owner._bscan_max_depth_m.setRange(0.1, 20.0)
owner._bscan_max_depth_m.setSingleStep(0.1)
owner._bscan_max_depth_m.setValue(1.0)
owner._bscan_max_depth_m.setValue(float(bscan_defaults.max_depth_m))
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_gain.setValue(float(bscan_defaults.gain))
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_start_freq_mhz.setValue(float(bscan_defaults.start_freq_mhz))
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_stop_freq_mhz.setValue(float(bscan_defaults.stop_freq_mhz))
bscan_form.addRow("Axis", owner._bscan_axis)
bscan_form.addRow("Channel", owner._bscan_channel)
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)
@@ -156,50 +135,73 @@ def build_processing_group(owner) -> QGroupBox:
gpr_page.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
gpr_form = QFormLayout(gpr_page)
gpr_form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
gpr_defaults = owner._defaults_config.gpr
owner._gpr_input_positions_input = QLineEdit(_default_gpr_input_positions(owner))
owner._gpr_config_mode = QComboBox()
owner._gpr_config_mode.addItems(["point", "extended"])
owner._set_combo_current_text(owner._gpr_config_mode, gpr_defaults.mode)
owner._gpr_relative_permittivity = QDoubleSpinBox()
owner._gpr_relative_permittivity.setDecimals(4)
owner._gpr_relative_permittivity.setRange(0.0001, 1000.0)
owner._gpr_relative_permittivity.setSingleStep(0.05)
owner._gpr_relative_permittivity.setValue(float(gpr_defaults.relative_permittivity))
owner._gpr_tx_geometry_input = QPlainTextEdit(_format_tx_geometry(owner))
owner._gpr_tx_geometry_input.setPlaceholderText("output_pos x_m")
owner._gpr_tx_geometry_input.setMinimumHeight(88)
owner._gpr_rx_geometry_input = QPlainTextEdit(_format_rx_geometry(owner))
owner._gpr_rx_geometry_input.setPlaceholderText("input_pos x_m")
owner._gpr_rx_geometry_input.setMinimumHeight(120)
owner._gpr_input_positions_input = QLineEdit(str(gpr_live_defaults.input_positions))
owner._gpr_input_positions_input.setPlaceholderText("0,1,2")
owner._gpr_output_positions_input = QLineEdit(_default_gpr_output_positions(owner))
owner._gpr_output_positions_input = QLineEdit(str(gpr_live_defaults.output_positions))
owner._gpr_output_positions_input.setPlaceholderText("0,1")
owner._gpr_min_depth_m = QDoubleSpinBox()
owner._gpr_min_depth_m.setDecimals(2)
owner._gpr_min_depth_m.setRange(0.0, 50.0)
owner._gpr_min_depth_m.setSingleStep(0.1)
owner._gpr_min_depth_m.setValue(2.0)
owner._gpr_min_depth_m.setValue(float(gpr_live_defaults.min_depth_m))
owner._gpr_max_depth_m = QDoubleSpinBox()
owner._gpr_max_depth_m.setDecimals(2)
owner._gpr_max_depth_m.setRange(0.1, 50.0)
owner._gpr_max_depth_m.setSingleStep(0.1)
owner._gpr_max_depth_m.setValue(14.0)
owner._gpr_max_depth_m.setValue(float(gpr_live_defaults.max_depth_m))
owner._gpr_comp_power = QDoubleSpinBox()
owner._gpr_comp_power.setDecimals(3)
owner._gpr_comp_power.setRange(0.0, 5.0)
owner._gpr_comp_power.setSingleStep(0.05)
owner._gpr_comp_power.setValue(0.2)
owner._gpr_comp_power.setValue(float(gpr_live_defaults.comp_power))
owner._gpr_start_freq_mhz = QDoubleSpinBox()
owner._gpr_start_freq_mhz.setDecimals(1)
owner._gpr_start_freq_mhz.setRange(100.0, 8800.0)
owner._gpr_start_freq_mhz.setSingleStep(10.0)
owner._gpr_start_freq_mhz.setValue(3000.0)
owner._gpr_start_freq_mhz.setValue(float(gpr_live_defaults.start_freq_mhz))
owner._gpr_stop_freq_mhz = QDoubleSpinBox()
owner._gpr_stop_freq_mhz.setDecimals(1)
owner._gpr_stop_freq_mhz.setRange(100.0, 8800.0)
owner._gpr_stop_freq_mhz.setSingleStep(10.0)
owner._gpr_stop_freq_mhz.setValue(6000.0)
owner._gpr_stop_freq_mhz.setValue(float(gpr_live_defaults.stop_freq_mhz))
owner._gpr_background_subtract_enabled = QCheckBox("Subtract mean of previous collections")
owner._gpr_background_subtract_enabled.setChecked(True)
owner._gpr_background_subtract_enabled.setChecked(bool(gpr_live_defaults.background_subtract_enabled))
owner._gpr_background_mean_count = QSpinBox()
owner._gpr_background_mean_count.setRange(0, 10_000)
owner._gpr_background_mean_count.setValue(10)
owner._gpr_background_mean_count.setValue(int(gpr_live_defaults.background_mean_count))
gpr_form.addRow("Config mode", owner._gpr_config_mode)
gpr_form.addRow("Relative permittivity", owner._gpr_relative_permittivity)
gpr_form.addRow("Tx geometry", owner._gpr_tx_geometry_input)
gpr_form.addRow("Rx geometry", owner._gpr_rx_geometry_input)
gpr_form.addRow("Input positions", owner._gpr_input_positions_input)
gpr_form.addRow("Output positions", owner._gpr_output_positions_input)
gpr_form.addRow("Min depth m", owner._gpr_min_depth_m)
@@ -212,12 +214,9 @@ def build_processing_group(owner) -> QGroupBox:
owner._processing_mode_pages.addWidget(gpr_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._pass_through_channel.currentTextChanged.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)
owner._pass_through_fixed_y_enabled.toggled.connect(sync_pass_through_y_controls)
owner._pass_through_fixed_y_enabled.toggled.connect(owner._sync_pass_through_y_controls)
owner._pass_through_fixed_y_enabled.toggled.connect(owner._on_processing_live_settings_changed)
owner._pass_through_y_min_db.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._pass_through_y_max_db.valueChanged.connect(owner._on_processing_live_settings_changed)
@@ -225,7 +224,6 @@ def build_processing_group(owner) -> QGroupBox:
form.addRow(owner._processing_mode_pages)
owner._bscan_axis.currentTextChanged.connect(owner._on_processing_live_settings_changed)
owner._bscan_channel.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)
@@ -40,8 +40,6 @@ def build_radar_group(owner) -> QGroupBox:
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)
@@ -1,82 +1,68 @@
"""Builder for switch and combo settings section."""
"""Builder for switch timing and combo-selection settings section."""
from __future__ import annotations
from PyQt6.QtWidgets import QComboBox, QFormLayout, QGroupBox, QHBoxLayout, QLineEdit, QVBoxLayout
from PyQt6.QtWidgets import (
QButtonGroup,
QFormLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QVBoxLayout,
)
def build_switch_group(owner) -> QGroupBox:
"""Create input/output switch controls and run combos settings."""
"""Create switch timing field and two explicit combo-selection modes."""
group = QGroupBox("Switches")
layout = QVBoxLayout(group)
input_defaults = owner._defaults_config.input_switch
output_defaults = owner._defaults_config.output_switch
layout.setSpacing(8)
switch_defaults = owner._gui_defaults.switches
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()
owner._combos_text = QLineEdit(str(switch_defaults.combos_text))
owner._combos_text.setPlaceholderText("empty = full matrix, or input:output,input:output")
owner._run_combos_select_button = QPushButton("Select")
owner._run_combos_select_button.setCheckable(True)
input_group = QGroupBox("Input Switch (Radar Port 2)")
input_form = QFormLayout(input_group)
input_form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
combos_row = QHBoxLayout()
combos_row.setSpacing(8)
combos_row.addWidget(QLabel("Run combos"))
combos_row.addWidget(owner._combos_text, stretch=1)
combos_row.addWidget(owner._run_combos_select_button)
layout.addLayout(combos_row)
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")
owner._single_combo_output = QLineEdit(str(switch_defaults.single_output))
owner._single_combo_output.setPlaceholderText("0")
owner._single_combo_input = QLineEdit(str(switch_defaults.single_input))
owner._single_combo_input.setPlaceholderText("0")
owner._single_combo_select_button = QPushButton("Select")
owner._single_combo_select_button.setCheckable(True)
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)
single_row = QHBoxLayout()
single_row.setSpacing(8)
single_row.addWidget(QLabel("Single combo"))
single_row.addWidget(QLabel("Output"))
single_row.addWidget(owner._single_combo_output)
single_row.addWidget(QLabel("Input"))
single_row.addWidget(owner._single_combo_input)
single_row.addWidget(owner._single_combo_select_button)
layout.addLayout(single_row)
output_group = QGroupBox("Output Switch (Radar Port 1)")
output_form = QFormLayout(output_group)
output_form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
owner._combo_select_group = QButtonGroup(group)
owner._combo_select_group.setExclusive(True)
owner._combo_select_group.addButton(owner._run_combos_select_button)
owner._combo_select_group.addButton(owner._single_combo_select_button)
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)
owner._run_combos_select_button.clicked.connect(lambda: owner._set_combo_selection_mode("text"))
owner._single_combo_select_button.clicked.connect(lambda: owner._set_combo_selection_mode("single"))
owner._set_combo_selection_mode(str(switch_defaults.combo_mode))
return group