Files
radar_system/python_app/gui/controllers/app_window_config_mixin.py
T
2026-03-19 19:29:08 +03:00

497 lines
23 KiB
Python

"""Configuration and live-processing binding mixin for the main window."""
from __future__ import annotations
from python_app.gui.runtime.history import remove_last_aligned_histories
from python_app.hardware_full.librevna_service import LibreVnaService
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.storage.npz_store import radar_key_from_config
class AppWindowConfigMixin:
"""Builds runtime config models from current UI state."""
@staticmethod
def _parse_csv_int_list(text: str) -> list[int]:
"""Parse comma-separated integer selection list."""
cleaned = text.strip()
if not cleaned:
return []
values: list[int] = []
for part in cleaned.split(","):
token = part.strip()
if not token:
continue
values.append(int(token))
return values
@staticmethod
def _parse_gpr_tx_geometry_text(text: str) -> list[GprTxGeometryModel]:
"""Parse line-based Tx geometry editor text."""
entries: list[GprTxGeometryModel] = []
for line_number, raw_line in enumerate(text.splitlines(), start=1):
line = raw_line.strip()
if not line:
continue
parts = line.split()
if len(parts) != 2:
raise ValueError(f"Invalid Tx geometry line {line_number}: expected `output_pos x_m`")
entries.append(
GprTxGeometryModel(
output_pos=int(parts[0]),
x_m=float(parts[1]),
)
)
return entries
@staticmethod
def _parse_gpr_rx_geometry_text(text: str) -> list[GprRxGeometryModel]:
"""Parse line-based Rx geometry editor text."""
entries: list[GprRxGeometryModel] = []
for line_number, raw_line in enumerate(text.splitlines(), start=1):
line = raw_line.strip()
if not line:
continue
parts = line.split()
if len(parts) != 2:
raise ValueError(f"Invalid Rx geometry line {line_number}: expected `input_pos x_m`")
entries.append(
GprRxGeometryModel(
input_pos=int(parts[0]),
x_m=float(parts[1]),
)
)
return entries
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
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())
config.gpr.rx_geometry = self._parse_gpr_rx_geometry_text(self._gpr_rx_geometry_input.toPlainText())
validate_gpr_model(
config.gpr,
input_switch_positions=config.input_switch.positions,
output_switch_positions=config.output_switch.positions,
)
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()
self._sync_gpr_frequency_limits_with_radar()
y_min_db = float(self._pass_through_y_min_db.value())
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_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_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()),
gpr_input_positions=self._parse_csv_int_list(self._gpr_input_positions_input.text()),
gpr_output_positions=self._parse_csv_int_list(self._gpr_output_positions_input.text()),
gpr_min_depth_m=float(self._gpr_min_depth_m.value()),
gpr_max_depth_m=float(self._gpr_max_depth_m.value()),
gpr_comp_power=float(self._gpr_comp_power.value()),
gpr_start_freq_mhz=float(self._gpr_start_freq_mhz.value()),
gpr_stop_freq_mhz=float(self._gpr_stop_freq_mhz.value()),
gpr_background_subtract_enabled=bool(self._gpr_background_subtract_enabled.isChecked()),
gpr_background_mean_count=int(self._gpr_background_mean_count.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()
current_mode = self._processing_mode.currentText()
if current_mode == "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 current_mode == "gpr":
self._drain_results_until_quiet(timeout_s=0.35, poll_s=0.01)
if self._result_history:
if not self._draw_results(self._result_history[-1]):
self._clear_gpr_plot()
else:
self._clear_gpr_plot()
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,
"gpr": 2,
}
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_history_mode_deletion(mode_label="B-scan", 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_history_mode_deletion(mode_label="B-scan", remove_last_only=True)
def _on_gpr_clear_history_clicked(self) -> None:
"""Permanently clear all runtime histories, ring backlogs, and GPR cache."""
self._apply_history_mode_deletion(mode_label="GPR", remove_last_only=False)
def _on_gpr_remove_last_measurement_clicked(self) -> None:
"""Permanently delete the latest measurement from runtime histories and rings."""
self._apply_history_mode_deletion(mode_label="GPR", remove_last_only=True)
def _clear_history_mode_caches(self) -> None:
"""Drop mode-specific cached render state."""
self._clear_bscan_plot_history()
if hasattr(self, "_gpr_plot"):
self._clear_gpr_plot()
def _apply_history_mode_deletion(self, *, mode_label: str, remove_last_only: bool) -> None:
"""Apply destructive 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 measurement 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:
retained_raw, retained_pre, retained_result = remove_last_aligned_histories(
list(self._raw_history),
list(self._pre_history),
list(self._result_history),
)
else:
retained_raw = []
retained_pre = []
retained_result = []
self._replace_runtime_history(
retained_raw=retained_raw,
retained_pre=retained_pre,
retained_result=retained_result,
)
self._clear_history_mode_caches()
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"{mode_label} {action}; dropped pending results={dropped_results}")
except Exception as exc: # noqa: BLE001
self._show_error(f"Failed to delete {mode_label} 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._bscan_plot.clear()
return
if self._processing_mode.currentText() == "gpr":
if self._result_history and self._draw_results(self._result_history[-1]):
return
self._clear_gpr_plot()
return
if self._result_history:
self._draw_results(self._result_history[-1])
return
self._bscan_plot.clear()
self._clear_trace_plots()
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 processing frequency bounds after sweep start/stop edits."""
if self._sync_processing_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_processing_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_processing_frequency_limits_with_radar(self) -> bool:
"""Synchronize all processing frequency widgets with radar sweep bounds."""
bscan_changed = self._sync_bscan_frequency_limits_with_radar()
gpr_changed = self._sync_gpr_frequency_limits_with_radar()
return bscan_changed or gpr_changed
def _sync_frequency_spinboxes_with_radar(self, widget_names: tuple[str, ...]) -> bool:
"""Synchronize one or more MHz spin boxes with current radar sweep bounds."""
required_widgets = ("_start_hz_input", "_stop_hz_input", *widget_names)
if not all(hasattr(self, widget_name) for widget_name in required_widgets):
return False
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
widgets = [getattr(self, widget_name) for widget_name in widget_names]
for widget in widgets:
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)
for widget in widgets:
clamped_value = min(max(widget.value(), radar_min_mhz), radar_max_mhz)
if clamped_value != widget.value():
changed = True
widget.blockSignals(True)
widget.setValue(clamped_value)
widget.blockSignals(False)
return changed
def _sync_bscan_frequency_limits_with_radar(self) -> bool:
"""Synchronize B-scan start/stop MHz widget ranges with radar sweep bounds."""
return self._sync_frequency_spinboxes_with_radar(
(
"_bscan_start_freq_mhz",
"_bscan_stop_freq_mhz",
)
)
def _sync_gpr_frequency_limits_with_radar(self) -> bool:
"""Synchronize GPR start/stop MHz widget ranges with radar sweep bounds."""
return self._sync_frequency_spinboxes_with_radar(
(
"_gpr_start_freq_mhz",
"_gpr_stop_freq_mhz",
)
)
@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