little refactoring done

This commit is contained in:
Ayzen
2026-03-10 17:33:25 +03:00
parent 9c745f304e
commit 1f715ce22c
10 changed files with 520 additions and 245 deletions
@@ -179,12 +179,12 @@ class AppWindowConfigMixin:
if self._result_history:
self._sync_bscan_history_from_results()
if not self._draw_bscan_heatmap_from_history():
self._plot.clear()
self._bscan_plot.clear()
return
if self._result_history:
self._draw_results(self._result_history[-1])
return
self._plot.clear()
self._bscan_plot.clear()
self._clear_trace_plots()
def _on_radar_identity_changed(self, *_args) -> None:
@@ -317,6 +317,16 @@ class AppWindowConfigMixin:
def _sync_bscan_frequency_limits_with_radar(self) -> bool:
"""Synchronize B-scan start/stop MHz widget ranges with radar sweep bounds."""
required_widgets = (
"_start_hz_input",
"_stop_hz_input",
"_bscan_start_freq_mhz",
"_bscan_stop_freq_mhz",
)
if not all(hasattr(self, widget_name) for widget_name in required_widgets):
# Processing callbacks can fire while UI groups are still being built.
return False
try:
radar_start_hz = float(self._start_hz_input.text().strip())
radar_stop_hz = float(self._stop_hz_input.text().strip())
@@ -59,6 +59,29 @@ class AppWindowPlotMixin:
"""Clear pass-through magnitude and phase plots."""
self._trace_magnitude_plot.clear()
self._trace_phase_plot.clear()
self._clear_trace_legends()
self._trace_magnitude_curves.clear()
self._trace_phase_curves.clear()
def _clear_trace_legends(self) -> None:
"""Remove trace plot legends to avoid stale combo-color mappings."""
mag_legend = self._trace_magnitude_legend
if mag_legend is not None:
try:
self._trace_magnitude_plot.getPlotItem().removeItem(mag_legend)
except Exception: # noqa: BLE001
pass
self._trace_magnitude_legend = None
self._trace_magnitude_legend_combo_keys.clear()
phase_legend = self._trace_phase_legend
if phase_legend is not None:
try:
self._trace_phase_plot.getPlotItem().removeItem(phase_legend)
except Exception: # noqa: BLE001
pass
self._trace_phase_legend = None
self._trace_phase_legend_combo_keys.clear()
def _draw_trace_lines(self, collection: ResultCollection) -> bool:
"""Draw result payload traces as stacked magnitude/phase plots."""
@@ -69,8 +92,8 @@ class AppWindowPlotMixin:
magnitude_plot.setVisible(show_magnitude)
phase_plot.setVisible(show_phase)
self._clear_trace_plots()
if not show_magnitude and not show_phase:
self._clear_trace_plots()
return False
if show_magnitude:
@@ -103,44 +126,90 @@ class AppWindowPlotMixin:
"#1982c4",
]
color_index = 0
combo_colors: dict[tuple[int, int], str] = {}
legend_source_magnitude: dict[tuple[int, int], pg.PlotCurveItem] = {}
legend_source_phase: dict[tuple[int, int], pg.PlotCurveItem] = {}
active_magnitude_keys: set[tuple[int, int, int, str]] = set()
active_phase_keys: set[tuple[int, int, int, str]] = set()
has_data = False
x_min = np.inf
x_max = -np.inf
for block in collection.blocks:
for payload in block.payloads:
combo_key = (int(block.combo.input_pos), int(block.combo.output_pos))
if combo_key not in combo_colors:
combo_colors[combo_key] = palette[len(combo_colors) % len(palette)]
color = combo_colors[combo_key]
combo_label = f"in{combo_key[0]}/out{combo_key[1]}"
for payload_index, payload in enumerate(block.payloads):
if payload.kind != 1 or payload.trace.size == 0:
continue
if payload.frequency_hz.size == 0 or payload.frequency_hz.size != payload.trace.size:
continue
curve_key = (
combo_key[0],
combo_key[1],
int(payload_index),
str(payload.processing_name),
)
local_x_min = float(np.min(payload.frequency_hz))
local_x_max = float(np.max(payload.frequency_hz))
x_min = min(x_min, local_x_min)
x_max = max(x_max, local_x_max)
color = palette[color_index % len(palette)]
if show_magnitude:
magnitude_values = 20.0 * np.log10(np.maximum(np.abs(payload.trace), 1e-12))
magnitude_curve = pg.PlotCurveItem(
payload.frequency_hz,
magnitude_values,
pen=pg.mkPen(color, width=1.4),
)
magnitude_plot.addItem(magnitude_curve)
active_magnitude_keys.add(curve_key)
magnitude_curve = self._trace_magnitude_curves.get(curve_key)
if magnitude_curve is None:
magnitude_curve = pg.PlotCurveItem(pen=pg.mkPen(color, width=1.4))
self._trace_magnitude_curves[curve_key] = magnitude_curve
magnitude_plot.addItem(magnitude_curve)
else:
magnitude_curve.setPen(pg.mkPen(color, width=1.4))
magnitude_curve.setData(payload.frequency_hz, magnitude_values)
legend_source_magnitude.setdefault(combo_key, magnitude_curve)
has_data = True
if show_phase:
phase_values = np.degrees(np.angle(payload.trace))
phase_curve = pg.PlotCurveItem(
payload.frequency_hz,
phase_values,
pen=pg.mkPen(color, width=1.2, style=Qt.PenStyle.DashLine),
)
phase_plot.addItem(phase_curve)
active_phase_keys.add(curve_key)
phase_curve = self._trace_phase_curves.get(curve_key)
if phase_curve is None:
phase_curve = pg.PlotCurveItem(pen=pg.mkPen(color, width=1.2))
self._trace_phase_curves[curve_key] = phase_curve
phase_plot.addItem(phase_curve)
else:
phase_curve.setPen(pg.mkPen(color, width=1.2))
phase_x, phase_values = self._phase_display_arrays(payload.frequency_hz, payload.trace)
phase_curve.setData(phase_x, phase_values)
legend_source_phase.setdefault(combo_key, phase_curve)
has_data = True
color_index += 1
if show_magnitude:
self._remove_inactive_trace_curves(
plot=magnitude_plot,
cache=self._trace_magnitude_curves,
active_keys=active_magnitude_keys,
)
else:
self._remove_all_trace_curves(plot=magnitude_plot, cache=self._trace_magnitude_curves)
if show_phase:
self._remove_inactive_trace_curves(
plot=phase_plot,
cache=self._trace_phase_curves,
active_keys=active_phase_keys,
)
else:
self._remove_all_trace_curves(plot=phase_plot, cache=self._trace_phase_curves)
self._sync_trace_legends(
show_magnitude=show_magnitude,
show_phase=show_phase,
magnitude_sources=legend_source_magnitude,
phase_sources=legend_source_phase,
)
if has_data:
if np.isfinite(x_min) and np.isfinite(x_max):
@@ -152,6 +221,105 @@ class AppWindowPlotMixin:
phase_plot.setYRange(-180.0, 180.0, padding=0.02)
return has_data
@staticmethod
def _remove_inactive_trace_curves(
*,
plot: pg.PlotWidget,
cache: dict[tuple[int, int, int, str], pg.PlotCurveItem],
active_keys: set[tuple[int, int, int, str]],
) -> None:
"""Delete curve items no longer present in latest result collection."""
for key in list(cache.keys()):
if key in active_keys:
continue
curve = cache.pop(key)
plot.removeItem(curve)
@staticmethod
def _remove_all_trace_curves(
*,
plot: pg.PlotWidget,
cache: dict[tuple[int, int, int, str], pg.PlotCurveItem],
) -> None:
"""Delete all cached curves from selected plot."""
for curve in cache.values():
plot.removeItem(curve)
cache.clear()
def _phase_display_arrays(self, frequency_hz: np.ndarray, trace: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""Return phase display arrays with decimation for faster rendering."""
max_points = int(getattr(self, "_trace_phase_render_max_points", 1200))
if max_points > 0 and trace.size > max_points:
step = max(1, int(np.ceil(trace.size / max_points)))
frequency_hz = frequency_hz[::step]
trace = trace[::step]
phase_values = np.arctan2(trace.imag, trace.real) * (180.0 / np.pi)
return frequency_hz, phase_values
def _sync_trace_legends(
self,
*,
show_magnitude: bool,
show_phase: bool,
magnitude_sources: dict[tuple[int, int], pg.PlotCurveItem],
phase_sources: dict[tuple[int, int], pg.PlotCurveItem],
) -> None:
"""Rebuild legends only when active combo set changes."""
self._sync_single_trace_legend(
show=show_magnitude,
plot=self._trace_magnitude_plot,
legend_attr="_trace_magnitude_legend",
legend_keys_attr="_trace_magnitude_legend_combo_keys",
sources=magnitude_sources,
)
self._sync_single_trace_legend(
show=show_phase,
plot=self._trace_phase_plot,
legend_attr="_trace_phase_legend",
legend_keys_attr="_trace_phase_legend_combo_keys",
sources=phase_sources,
)
def _sync_single_trace_legend(
self,
*,
show: bool,
plot: pg.PlotWidget,
legend_attr: str,
legend_keys_attr: str,
sources: dict[tuple[int, int], pg.PlotCurveItem],
) -> None:
"""Rebuild one legend from provided combo->curve mapping when needed."""
legend = getattr(self, legend_attr)
existing_keys = getattr(self, legend_keys_attr)
active_keys = set(sources.keys())
if not show or not active_keys:
if legend is not None:
try:
plot.getPlotItem().removeItem(legend)
except Exception: # noqa: BLE001
pass
setattr(self, legend_attr, None)
existing_keys.clear()
return
if legend is not None and existing_keys == active_keys:
return
if legend is not None:
try:
plot.getPlotItem().removeItem(legend)
except Exception: # noqa: BLE001
pass
legend = plot.addLegend(offset=(8, 8))
for combo_key in sorted(active_keys):
curve = sources[combo_key]
legend.addItem(curve, f"in{combo_key[0]}/out{combo_key[1]}")
setattr(self, legend_attr, legend)
existing_keys.clear()
existing_keys.update(active_keys)
def _draw_bscan_heatmap(self, _collection: ResultCollection) -> bool:
"""Draw B-scan image rebuilt from processed result history."""
self._disable_phase_axis()
@@ -189,18 +357,18 @@ class AppWindowPlotMixin:
image_item.setLookupTable(self._bscan_lookup_table(axis_mode))
image_item.setLevels(self._bscan_levels(sweeps, axis_mode))
self._plot.clear()
view_box = self._plot.getViewBox()
self._bscan_plot.clear()
view_box = self._bscan_plot.getViewBox()
view_box.invertY(True)
view_box.enableAutoRange(x=False, y=False)
self._plot.getPlotItem().showAxis("left", show=True)
self._plot.getPlotItem().showAxis("bottom", show=True)
self._plot.setLabel("bottom", "Sweep #")
self._plot.setLabel("left", "Depth", units="m")
self._plot.addItem(image_item)
self._plot.setXRange(x_min, x_max, padding=0.02)
self._plot.setYRange(depth_min, depth_max, padding=0.02)
self._plot.setTitle(f"B-scan in{display_key[0]}/out{display_key[1]} | sweeps={sweep_count}")
self._bscan_plot.getPlotItem().showAxis("left", show=True)
self._bscan_plot.getPlotItem().showAxis("bottom", show=True)
self._bscan_plot.setLabel("bottom", "Sweep #")
self._bscan_plot.setLabel("left", "Depth", units="m")
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)
self._bscan_plot.setTitle(f"B-scan in{display_key[0]}/out{display_key[1]} | sweeps={sweep_count}")
return True
def _sync_bscan_history_from_results(self) -> None:
@@ -284,7 +452,7 @@ class AppWindowPlotMixin:
def _ensure_phase_view_box(self) -> pg.ViewBox:
"""Create or return secondary right-axis ViewBox for phase curves."""
plot_item = self._plot.getPlotItem()
plot_item = self._bscan_plot.getPlotItem()
phase_view_box = self._phase_viewbox
if phase_view_box is None:
phase_view_box = pg.ViewBox()
@@ -301,7 +469,7 @@ class AppWindowPlotMixin:
phase_view_box = self._phase_viewbox
if phase_view_box is None:
return
plot_item = self._plot.getPlotItem()
plot_item = self._bscan_plot.getPlotItem()
phase_view_box.setGeometry(plot_item.vb.sceneBoundingRect())
phase_view_box.linkedViewChanged(plot_item.vb, phase_view_box.XAxis)
@@ -358,6 +526,9 @@ class AppWindowPlotMixin:
pen=pg.mkPen("#ffd166", width=1.8),
)
magnitude_plot.addItem(magnitude_curve)
self._trace_magnitude_curves[
(int(trace.combo.input_pos), int(trace.combo.output_pos), 0, "__single_trace__")
] = magnitude_curve
if show_phase:
phase_deg = np.degrees(np.angle(trace.s21))
@@ -367,6 +538,9 @@ class AppWindowPlotMixin:
pen=pg.mkPen("#80ed99", width=1.4, style=Qt.PenStyle.DashLine),
)
phase_plot.addItem(phase_curve)
self._trace_phase_curves[
(int(trace.combo.input_pos), int(trace.combo.output_pos), 0, "__single_trace__")
] = phase_curve
phase_plot.setYRange(-180.0, 180.0, padding=0.02)
if np.size(trace.frequency_hz) > 1:
+111 -79
View File
@@ -1,4 +1,11 @@
"""UI construction mixin for the main radar control window."""
"""UI construction mixin for the main radar control window.
Layout is intentionally split into two independent plot surfaces:
- single `PlotWidget` for B-scan heatmap rendering;
- stacked magnitude/phase `PlotWidget`s for pass-through traces.
`_set_plot_mode()` switches between these surfaces via `QStackedWidget`.
"""
from __future__ import annotations
@@ -29,10 +36,21 @@ from python_app.gui.controllers.sections import (
class AppWindowUiMixin:
"""Builds and wires all static UI widgets."""
"""Build and wire all static widgets used by :class:`AppWindow`.
This mixin only creates UI objects and connects lightweight UI signals.
Runtime logic (pipeline start/stop, polling, drawing, snapshot saving)
is implemented in other mixins.
"""
def _build_ui(self) -> None:
"""Build main window widgets, plot area, and settings panel."""
"""Build complete main-window layout.
High-level structure:
1. Left: plot stack (`B-scan` page + `trace magnitude/phase` page).
2. Middle: narrow toggle button to collapse/expand settings.
3. Right: scrollable settings panel + status/history + runtime log.
"""
self.setWindowTitle("Radar System Control")
root = QWidget(self)
self.setCentralWidget(root)
@@ -41,15 +59,37 @@ class AppWindowUiMixin:
layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(14)
self._build_plot_area(layout, root)
self._build_settings_toggle(layout)
self._build_settings_panel(layout, root)
self._toggle_settings_panel(visible=True)
self.resize(1650, 940)
def _build_plot_area(self, root_layout: QHBoxLayout, root: QWidget) -> None:
"""Build left plot area with stacked B-scan and trace pages."""
# Plot stack holds mutually exclusive visualization surfaces.
# We create both upfront and only switch active page at runtime.
self._plot_stack = QStackedWidget(root)
self._build_bscan_plot_page()
self._build_trace_plot_page()
self._plot = pg.PlotWidget(background="#0f141c")
self._plot.showGrid(x=True, y=True, alpha=0.2)
self._plot.setLabel("bottom", "Frequency", units="Hz")
self._plot.setLabel("left", "Magnitude", units="dB")
self._plot_stack.addWidget(self._plot)
# Default view on startup is pass-through traces.
self._plot_stack.setCurrentWidget(self._trace_plots_container)
root_layout.addWidget(self._plot_stack, stretch=11)
self._trace_plots_container = QWidget(root)
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.showGrid(x=True, y=True, alpha=0.2)
self._plot_stack.addWidget(self._bscan_plot)
def _build_trace_plot_page(self) -> None:
"""Create pass-through page with magnitude and phase plots."""
# Pass-through surface: container with two synchronized line plots.
# Upper plot shows magnitude, lower plot shows phase.
self._trace_plots_container = QWidget()
trace_layout = QVBoxLayout(self._trace_plots_container)
trace_layout.setContentsMargins(0, 0, 0, 0)
trace_layout.setSpacing(6)
@@ -70,53 +110,40 @@ class AppWindowUiMixin:
self._trace_phase_plot.getPlotItem().setClipToView(True)
trace_layout.addWidget(self._trace_phase_plot, stretch=1)
self._plot_stack.addWidget(self._trace_plots_container)
self._plot_stack.setCurrentWidget(self._trace_plots_container)
layout.addWidget(self._plot_stack, stretch=11)
# Legends are created lazily only when trace mode draws line series.
self._trace_magnitude_legend = None
self._trace_phase_legend = None
self._trace_magnitude_legend_combo_keys = set()
self._trace_phase_legend_combo_keys = set()
self._trace_magnitude_curves = {}
self._trace_phase_curves = {}
self._trace_phase_render_max_points = 400
self._plot_stack.addWidget(self._trace_plots_container)
def _build_settings_toggle(self, root_layout: QHBoxLayout) -> None:
"""Create narrow button used to collapse or show settings panel."""
self._settings_toggle_button = QPushButton("<")
self._settings_toggle_button.setObjectName("settingsToggleButton")
self._settings_toggle_button.setFixedWidth(26)
self._settings_toggle_button.clicked.connect(self._toggle_settings_panel)
layout.addWidget(self._settings_toggle_button, stretch=0)
self._settings_toggle_button.clicked.connect(lambda: self._toggle_settings_panel())
root_layout.addWidget(self._settings_toggle_button, stretch=0)
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(659)
right_layout = QVBoxLayout(self._settings_panel)
right_layout.setContentsMargins(0, 0, 0, 0)
right_layout.setSpacing(10)
# Build log widget early so error handlers can safely write during UI construction.
# 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.setReadOnly(True)
self._log_box.setMinimumHeight(170)
pipeline_group = self._build_pipeline_group()
hardware_actions_group = self._build_hardware_actions_group()
data_actions_group = self._build_data_actions_group()
preprocess_summary_group = self._build_preprocess_summary_group()
radar_group = self._build_radar_group()
processing_group = self._build_processing_group()
switch_group = self._build_switch_group()
controls = QWidget(self._settings_panel)
controls_layout = QVBoxLayout(controls)
controls_layout.setContentsMargins(0, 0, 0, 0)
controls_layout.setSpacing(10)
controls_layout.addWidget(pipeline_group)
controls_layout.addWidget(hardware_actions_group)
controls_layout.addWidget(data_actions_group)
controls_layout.addWidget(preprocess_summary_group)
controls_layout.addWidget(processing_group)
controls_layout.addWidget(radar_group)
controls_layout.addWidget(switch_group)
controls_layout.addStretch(1)
scroll = QScrollArea(self._settings_panel)
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.Shape.NoFrame)
scroll.setWidget(controls)
right_layout.addWidget(scroll, stretch=1)
right_layout.addWidget(self._build_settings_scroll(), stretch=1)
self._status_label = QLabel("Status: idle", self._settings_panel)
self._status_label.setObjectName("statusLabel")
@@ -127,17 +154,46 @@ class AppWindowUiMixin:
right_layout.addWidget(self._history_label)
right_layout.addWidget(self._log_box, stretch=0)
root_layout.addWidget(self._settings_panel, stretch=8)
layout.addWidget(self._settings_panel, stretch=8)
self._set_settings_panel_visible(True)
self.resize(1650, 940)
def _build_settings_scroll(self) -> QScrollArea:
"""Build scroll area with all control groups in display order."""
# All control groups are placed into a scroll area so right panel
# remains usable on smaller screens and with future extra controls.
controls = QWidget(self._settings_panel)
controls_layout = QVBoxLayout(controls)
controls_layout.setContentsMargins(0, 0, 0, 0)
controls_layout.setSpacing(10)
for group in self._build_control_groups():
controls_layout.addWidget(group)
controls_layout.addStretch(1)
def _toggle_settings_panel(self) -> None:
"""Toggle settings panel visibility."""
self._set_settings_panel_visible(not self._settings_panel.isVisible())
scroll = QScrollArea(self._settings_panel)
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.Shape.NoFrame)
scroll.setWidget(controls)
return scroll
def _set_settings_panel_visible(self, visible: bool) -> None:
"""Set settings panel visibility and update toggle button glyph."""
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_data_actions_group(self),
build_preprocess_summary_group(self),
build_processing_group(self),
build_radar_group(self),
build_switch_group(self),
]
def _toggle_settings_panel(self, *, visible: bool | None = None) -> None:
"""Toggle settings panel visibility or force a specific state.
When `visible` is `None`, state is toggled.
When `visible` is set, panel visibility is forced to that value.
"""
if visible is None:
visible = not self._settings_panel.isVisible()
self._settings_panel.setVisible(visible)
if visible:
self._settings_toggle_button.setText(">")
@@ -147,40 +203,16 @@ class AppWindowUiMixin:
self._settings_toggle_button.setToolTip("Show settings panel")
def _set_plot_mode(self, mode: str) -> None:
"""Switch visible plot surface based on processing mode."""
"""Switch visible plot page according to processing mode.
`bscan` -> show `self._bscan_plot` (single heatmap surface)
otherwise -> show `self._trace_plots_container` (magnitude + phase)
"""
if mode == "bscan":
self._plot_stack.setCurrentWidget(self._plot)
self._plot_stack.setCurrentWidget(self._bscan_plot)
return
self._plot_stack.setCurrentWidget(self._trace_plots_container)
def _build_pipeline_group(self) -> QGroupBox:
"""Build pipeline controls section."""
return build_pipeline_group(self)
def _build_hardware_actions_group(self) -> QGroupBox:
"""Build hardware actions section."""
return build_hardware_actions_group(self)
def _build_data_actions_group(self) -> QGroupBox:
"""Build data actions section."""
return build_data_actions_group(self)
def _build_preprocess_summary_group(self) -> QGroupBox:
"""Build selected preprocess sets summary section."""
return build_preprocess_summary_group(self)
def _build_processing_group(self) -> QGroupBox:
"""Build processing mode section."""
return build_processing_group(self)
def _build_radar_group(self) -> QGroupBox:
"""Build radar settings section."""
return build_radar_group(self)
def _build_switch_group(self) -> QGroupBox:
"""Build switch settings section."""
return build_switch_group(self)
@staticmethod
def _set_combo_current_text(combo: QComboBox, value: str) -> None:
"""Select combo item by text, appending it when missing."""