some refactoring and socket server added

This commit is contained in:
Ayzen
2026-04-08 18:43:29 +03:00
parent 202993325d
commit 1d7d0a756e
25 changed files with 3093 additions and 2527 deletions
@@ -0,0 +1,11 @@
"""Plot-rendering mixins split by rendering mode."""
from python_app.gui.controllers.app_window_plot.bscan_plot_mixin import AppWindowBscanPlotMixin
from python_app.gui.controllers.app_window_plot.gpr_plot_mixin import AppWindowGprPlotMixin
from python_app.gui.controllers.app_window_plot.trace_plot_mixin import AppWindowTracePlotMixin
__all__ = [
"AppWindowBscanPlotMixin",
"AppWindowGprPlotMixin",
"AppWindowTracePlotMixin",
]
@@ -0,0 +1,342 @@
"""B-scan rendering and cache helpers."""
from __future__ import annotations
from collections import deque
from PyQt6.QtCore import QRectF
import numpy as np
import pyqtgraph as pg
from python_app.models.dataset_model import ResultCollection
from python_app.orchestration.live_processing_config import ProcessingLiveConfig
def _result_tail(
*,
result_history: list[ResultCollection],
history_limit: int,
floor_collection_id: int,
) -> list[ResultCollection]:
"""Return filtered and de-duplicated result-history tail for B-scan usage."""
filtered = [
collection
for collection in result_history[-history_limit:]
if int(collection.collection_id) > int(floor_collection_id)
]
unique_reversed_tail: list[ResultCollection] = []
seen_keys: set[tuple[int, int]] = set()
for collection in reversed(filtered):
key = (int(collection.collection_id), int(collection.monotonic_ns))
if key in seen_keys:
continue
seen_keys.add(key)
unique_reversed_tail.append(collection)
unique_reversed_tail.reverse()
return unique_reversed_tail
def build_bscan_signature(
live_config: ProcessingLiveConfig,
result_history: list[ResultCollection],
history_limit: int,
floor_collection_id: int,
) -> tuple[object, ...]:
"""Build deterministic signature used to detect B-scan cache invalidation."""
result_tail = _result_tail(
result_history=result_history,
history_limit=history_limit,
floor_collection_id=floor_collection_id,
)
return (
str(live_config.bscan_axis),
str(live_config.bscan_channel),
float(live_config.bscan_cut_m),
float(live_config.bscan_max_depth_m),
float(live_config.bscan_gain),
float(live_config.bscan_start_freq_mhz),
float(live_config.bscan_stop_freq_mhz),
int(floor_collection_id),
tuple((int(collection.collection_id), int(collection.monotonic_ns), len(collection.blocks)) for collection in result_tail),
)
def rebuild_bscan_history_from_results(
result_history: list[ResultCollection],
history_limit: int,
floor_collection_id: int,
) -> tuple[dict[tuple[int, int], deque[np.ndarray]], dict[tuple[int, int], np.ndarray]]:
"""Rebuild B-scan history and depth axes from processed result payloads."""
history_by_combo: dict[tuple[int, int], deque[np.ndarray]] = {}
depth_axis_by_combo: dict[tuple[int, int], np.ndarray] = {}
result_tail = _result_tail(
result_history=result_history,
history_limit=history_limit,
floor_collection_id=floor_collection_id,
)
for collection in result_tail:
for block in collection.blocks:
key = (block.combo.input_pos, block.combo.output_pos)
for payload in block.payloads:
if payload.kind != 1 or payload.processing_name != "bscan":
continue
if payload.frequency_hz.size == 0 or payload.trace.size == 0:
continue
if payload.frequency_hz.size != payload.trace.size:
continue
depth_axis = np.asarray(payload.frequency_hz, dtype=np.float32)
amplitudes = np.asarray(np.real(payload.trace), dtype=np.float32)
if depth_axis.size == 0 or amplitudes.size == 0:
continue
history = history_by_combo.get(key)
stored_axis = depth_axis_by_combo.get(key)
if (
history is None
or stored_axis is None
or stored_axis.shape != depth_axis.shape
or not np.allclose(stored_axis, depth_axis, rtol=1e-4, atol=1e-6)
):
history = deque(maxlen=history_limit)
history_by_combo[key] = history
depth_axis_by_combo[key] = depth_axis.copy()
history.append(amplitudes.copy())
return history_by_combo, depth_axis_by_combo
def pick_bscan_display_key(
history_by_combo: dict[tuple[int, int], deque[np.ndarray]],
) -> tuple[int, int] | None:
"""Choose combo key to display when multiple histories are present."""
if not history_by_combo:
return None
return next(iter(history_by_combo.keys()))
def bscan_lookup_table(axis_mode: str) -> np.ndarray:
"""Build B-scan colormap table for selected axis mode."""
if axis_mode == "abs":
return build_lut(["#440154", "#31688e", "#35b779", "#fde725"])
return build_lut(["#2166ac", "#67a9cf", "#f7f7f7", "#ef8a62", "#b2182b"])
def build_lut(stops: list[str], *, size: int = 256) -> np.ndarray:
"""Interpolate hex color stops into 8-bit RGB LUT array."""
stop_positions = np.linspace(0.0, 1.0, num=len(stops), dtype=np.float32)
sample_positions = np.linspace(0.0, 1.0, num=size, dtype=np.float32)
stop_colors = np.asarray([pg.mkColor(value).getRgb()[:3] for value in stops], dtype=np.float32)
lut = np.empty((size, 3), dtype=np.uint8)
for channel in range(3):
lut[:, channel] = np.interp(sample_positions, stop_positions, stop_colors[:, channel]).astype(np.uint8)
return lut
def bscan_levels(sweeps: np.ndarray, axis_mode: str) -> tuple[float, float]:
"""Compute image levels for B-scan data based on axis mode."""
min_value = float(np.min(sweeps))
max_value = float(np.max(sweeps))
if axis_mode == "abs":
if max_value <= min_value:
return min_value, min_value + 1e-6
return min_value, max_value
max_abs = max(abs(min_value), abs(max_value), 1e-6)
return -max_abs, max_abs
class AppWindowBscanPlotMixin:
"""Renders B-scan heatmaps and maintains B-scan history caches."""
def _draw_bscan_heatmap(self, _collection: ResultCollection) -> bool:
"""Draw B-scan image rebuilt from processed result history."""
self._disable_phase_axis()
self._sync_bscan_history_from_results()
return self._draw_bscan_heatmap_from_history()
def _configure_bscan_plot_axes(self) -> None:
"""Apply persistent B-scan plot axis labels and base view settings."""
plot = self._bscan_plot
plot_item = plot.getPlotItem()
plot_item.showAxis("left", show=True)
plot_item.showAxis("bottom", show=True)
plot.setLabel("bottom", "Sweep #")
plot.setLabel("left", "Range", units="m")
view_box = plot.getViewBox()
view_box.invertY(False)
view_box.enableAutoRange(x=False, y=False)
def _draw_bscan_heatmap_from_history(self) -> bool:
"""Render B-scan heatmap from currently cached history arrays."""
display_key = self._pick_bscan_display_key()
if display_key is None:
return False
history = self._bscan_history_by_combo.get(display_key)
depth_axis = self._bscan_depth_axis_by_combo.get(display_key)
if not history or depth_axis is None:
return False
sweeps = np.vstack(history).astype(np.float32, copy=False)
if sweeps.size == 0:
return False
depth_min = float(np.min(depth_axis))
depth_max = float(np.max(depth_axis))
depth_span = max(depth_max - depth_min, 1e-6)
sweep_count = sweeps.shape[0]
sweep_width = float(max(sweep_count, 1))
x_min = 0.5
x_max = x_min + sweep_width
image_item = pg.ImageItem(axisOrder="row-major")
image_item.setImage(sweeps.T, autoLevels=False)
image_item.setRect(QRectF(x_min, depth_min, sweep_width, depth_span))
axis_mode = self._bscan_axis.currentText()
image_item.setLookupTable(self._bscan_lookup_table(axis_mode))
image_item.setLevels(self._bscan_levels(sweeps, axis_mode))
self._bscan_plot.clear()
self._configure_bscan_plot_axes()
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 = "S21"
self._bscan_plot.setTitle(
f"B-scan {bscan_channel} in{display_key[0]}/out{display_key[1]} | sweeps={sweep_count}"
)
return True
def _sync_bscan_history_from_results(self) -> None:
"""Rebuild B-scan history cache when live params or inputs changed."""
self._advance_bscan_floor_to_cpp_window()
signature = self._bscan_signature()
if signature == self._bscan_render_signature:
return
self._rebuild_bscan_history_from_results()
self._bscan_render_signature = signature
def _bscan_signature(self) -> tuple[object, ...]:
"""Build state signature for B-scan history cache invalidation."""
live_config = self._live_processing_config()
result_history = list(self._result_history)
return build_bscan_signature(
live_config=live_config,
result_history=result_history,
history_limit=self._bscan_history_limit,
floor_collection_id=self._bscan_history_floor_collection_id,
)
def _rebuild_bscan_history_from_results(self) -> None:
"""Recompute B-scan history cache from results history buffer."""
result_history = list(self._result_history)
history_by_combo, depth_axis_by_combo = rebuild_bscan_history_from_results(
result_history=result_history,
history_limit=self._bscan_history_limit,
floor_collection_id=self._bscan_history_floor_collection_id,
)
self._bscan_history_by_combo = history_by_combo
self._bscan_depth_axis_by_combo = depth_axis_by_combo
def _pick_bscan_display_key(self) -> tuple[int, int] | None:
"""Choose combo history key to render."""
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."""
return bscan_lookup_table(axis_mode)
@staticmethod
def _build_lut(stops: list[str], *, size: int = 256) -> np.ndarray:
"""Backward-compatible wrapper around LUT builder."""
return build_lut(stops, size=size)
@staticmethod
def _bscan_levels(sweeps: np.ndarray, axis_mode: str) -> tuple[float, float]:
"""Return display levels for B-scan image."""
return bscan_levels(sweeps, axis_mode)
def _clear_bscan_plot_history(self) -> None:
"""Drop cached B-scan history and invalidate cache signature."""
self._bscan_history_by_combo.clear()
self._bscan_depth_axis_by_combo.clear()
self._bscan_render_signature = None
def _advance_bscan_floor_to_cpp_window(self) -> None:
"""Clamp B-scan source history to C++ available replay window."""
if not self._result_history:
return
cpp_window_limit = min(
int(self._defaults_config.rings.preprocessed.capacity),
int(self._defaults_config.rings.results.capacity),
)
cpp_window_limit = max(1, cpp_window_limit)
latest_collection_id = int(self._result_history[-1].collection_id)
current_floor = int(self._bscan_history_floor_collection_id)
# Collection ids restart from 1 on new C++ run; release floor only while
# acquisition is running, so manual "remove last" behavior in stopped mode
# remains deterministic.
if latest_collection_id < current_floor and self._supervisor.is_running():
self._bscan_history_floor_collection_id = 0
current_floor = 0
floor_candidate = max(0, latest_collection_id - cpp_window_limit)
if floor_candidate > current_floor:
self._bscan_history_floor_collection_id = floor_candidate
def _ensure_phase_view_box(self) -> pg.ViewBox:
"""Create or return secondary right-axis ViewBox for phase curves."""
plot_item = self._bscan_plot.getPlotItem()
phase_view_box = self._phase_viewbox
if phase_view_box is None:
phase_view_box = pg.ViewBox()
self._phase_viewbox = phase_view_box
plot_item.scene().addItem(phase_view_box)
plot_item.getAxis("right").linkToView(phase_view_box)
phase_view_box.setXLink(plot_item.vb)
plot_item.vb.sigResized.connect(self._update_phase_view_box_geometry)
self._update_phase_view_box_geometry()
return phase_view_box
def _update_phase_view_box_geometry(self) -> None:
"""Keep right-axis ViewBox geometry in sync with main plot ViewBox."""
phase_view_box = self._phase_viewbox
if phase_view_box is None:
return
plot_item = self._bscan_plot.getPlotItem()
phase_view_box.setGeometry(plot_item.vb.sceneBoundingRect())
phase_view_box.linkedViewChanged(plot_item.vb, phase_view_box.XAxis)
def _clear_phase_overlay(self) -> None:
"""Remove all phase curves from secondary ViewBox."""
self._trace_phase_plot.clear()
def _disable_phase_axis(self) -> None:
"""Hide right axis and clear phase overlay when phase is not rendered."""
self._clear_phase_overlay()
@@ -0,0 +1,503 @@
"""GPR heatmap and object-only rendering helpers."""
from __future__ import annotations
from PyQt6.QtCore import QRectF
import numpy as np
import pyqtgraph as pg
from python_app.models.dataset_model import ResultCollection
from python_app.orchestration.gpr_locator import (
collection_payload_by_name as gpr_collection_payload_by_name,
collection_payloads_by_prefix as gpr_collection_payloads_by_prefix,
gpr_object_rows as extract_gpr_object_rows,
)
class AppWindowGprPlotMixin:
"""Renders GPR accumulator heatmaps and detected object overlays."""
def _clear_gpr_plot(self) -> None:
"""Clear latest GPR plot surface."""
if not hasattr(self, "_gpr_plot"):
return
self._configure_gpr_plot_axes()
self._clear_gpr_point_labels()
self._clear_gpr_region_labels()
self._clear_gpr_region_masks()
if self._gpr_image_item is not None:
self._gpr_image_item.hide()
if self._gpr_tx_item is not None:
self._gpr_tx_item.setData(x=[], y=[])
self._gpr_tx_item.hide()
if self._gpr_rx_item is not None:
self._gpr_rx_item.setData(x=[], y=[])
self._gpr_rx_item.hide()
if self._gpr_points_item is not None:
self._gpr_points_item.setData(x=[], y=[])
self._gpr_points_item.hide()
if self._gpr_region_centers_item is not None:
self._gpr_region_centers_item.setData(x=[], y=[])
self._gpr_region_centers_item.hide()
self._gpr_plot.setTitle(f"GPR {self._gpr_config_mode.currentText()}")
def _configure_gpr_plot_axes(self) -> None:
"""Apply persistent GPR plot axis labels and base view settings."""
plot = self._gpr_plot
plot_item = plot.getPlotItem()
plot_item.showAxis("left", show=True)
plot_item.showAxis("bottom", show=True)
plot_item.setClipToView(True)
plot.setLabel("bottom", "X", units="m")
plot.setLabel("left", "Range", units="m")
view_box = plot.getViewBox()
view_box.invertY(False)
view_box.enableAutoRange(x=False, y=False)
def _ensure_gpr_plot_items(self) -> None:
"""Create persistent GPR plot items once and reuse them on redraw."""
if self._gpr_image_item is not None:
return
plot = self._gpr_plot
self._configure_gpr_plot_axes()
if self._gpr_lookup_table is None:
self._gpr_lookup_table = self._build_lut(["#081c15", "#1b4332", "#ffd166", "#f94144"])
self._gpr_image_item = pg.ImageItem(axisOrder="row-major")
self._gpr_image_item.setZValue(0)
self._gpr_image_item.hide()
plot.addItem(self._gpr_image_item)
self._gpr_tx_item = pg.ScatterPlotItem()
self._gpr_tx_item.setZValue(20)
self._gpr_tx_item.hide()
plot.addItem(self._gpr_tx_item)
self._gpr_rx_item = pg.ScatterPlotItem()
self._gpr_rx_item.setZValue(20)
self._gpr_rx_item.hide()
plot.addItem(self._gpr_rx_item)
self._gpr_points_item = pg.ScatterPlotItem()
self._gpr_points_item.setZValue(30)
self._gpr_points_item.hide()
plot.addItem(self._gpr_points_item)
self._gpr_region_centers_item = pg.ScatterPlotItem()
self._gpr_region_centers_item.setZValue(30)
self._gpr_region_centers_item.hide()
plot.addItem(self._gpr_region_centers_item)
def _clear_gpr_point_labels(self) -> None:
"""Remove dynamic point-score labels from GPR plot."""
for item in self._gpr_point_labels:
try:
self._gpr_plot.removeItem(item)
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:
"""Remove dynamic region labels from GPR plot."""
for item in self._gpr_region_center_labels:
try:
self._gpr_plot.removeItem(item)
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:
"""Remove dynamic region contour carriers from GPR plot."""
for item in self._gpr_region_mask_items:
try:
self._gpr_plot.removeItem(item)
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()
@staticmethod
def _collection_payload_by_name(collection: ResultCollection, name: str, kind: int | None = None):
"""Return first collection payload matching name and optional kind."""
return gpr_collection_payload_by_name(collection, name, kind)
@staticmethod
def _collection_payloads_by_prefix(collection: ResultCollection, prefix: str, kind: int | None = None):
"""Return collection payloads matching processing-name prefix."""
return gpr_collection_payloads_by_prefix(collection, prefix, kind)
def _selected_gpr_geometry(self) -> tuple[np.ndarray, np.ndarray]:
"""Resolve selected Tx/Rx geometry arrays for current GPR selection."""
requested_inputs = tuple(self._parse_csv_int_list(self._gpr_input_positions_input.text()))
requested_outputs = tuple(self._parse_csv_int_list(self._gpr_output_positions_input.text()))
signature = (
self._gpr_tx_geometry_input.toPlainText(),
self._gpr_rx_geometry_input.toPlainText(),
requested_inputs,
requested_outputs,
)
if signature == self._gpr_geometry_signature and self._gpr_selected_geometry is not None:
return self._gpr_selected_geometry
tx_entries = self._parse_gpr_tx_geometry_text(signature[0])
rx_entries = self._parse_gpr_rx_geometry_text(signature[1])
requested_input_set = set(requested_inputs)
requested_output_set = set(requested_outputs)
rx_entries = sorted(rx_entries, key=lambda entry: int(entry.input_pos))
tx_entries = sorted(tx_entries, key=lambda entry: int(entry.output_pos))
if requested_input_set:
rx_entries = [entry for entry in rx_entries if int(entry.input_pos) in requested_input_set]
if requested_output_set:
tx_entries = [entry for entry in tx_entries if int(entry.output_pos) in requested_output_set]
x_tx = np.asarray([float(entry.x_m) for entry in tx_entries], dtype=np.float32)
x_rx = np.asarray([float(entry.x_m) for entry in rx_entries], dtype=np.float32)
self._gpr_geometry_signature = signature
self._gpr_selected_geometry = (x_tx, x_rx)
return self._gpr_selected_geometry
def _draw_gpr_map(self, collection: ResultCollection) -> bool:
"""Draw latest collection-level GPR plot according to current render mode."""
if self._gpr_render_mode.currentText() == "objects_only":
return self._draw_gpr_objects_only(collection)
return self._draw_gpr_heatmap(collection)
def _draw_gpr_heatmap(self, collection: ResultCollection) -> bool:
"""Draw latest collection-level GPR accumulator and annotations."""
accumulator_payload = self._collection_payload_by_name(collection, "gpr_accumulator", kind=3)
if accumulator_payload is None:
self._clear_gpr_plot()
return False
image = np.asarray(accumulator_payload.image, dtype=np.float32)
x_axis = np.asarray(accumulator_payload.image_x_axis, dtype=np.float32)
y_axis = np.asarray(accumulator_payload.image_y_axis, dtype=np.float32)
if image.ndim != 2 or image.size == 0 or x_axis.size == 0 or y_axis.size == 0:
self._clear_gpr_plot()
return False
x_min = float(x_axis[0])
x_max = float(x_axis[-1])
y_min = float(y_axis[0])
y_max = float(y_axis[-1])
rect = QRectF(x_min, y_min, max(x_max - x_min, 1e-6), max(y_max - y_min, 1e-6))
plot = self._gpr_plot
plot.setUpdatesEnabled(False)
try:
self._ensure_gpr_plot_items()
plot.getViewBox().invertY(False)
self._clear_gpr_point_labels()
self._clear_gpr_region_labels()
self._clear_gpr_region_masks()
self._gpr_image_item.setImage(image, autoLevels=False)
self._gpr_image_item.setRect(rect)
self._gpr_image_item.setLookupTable(self._gpr_lookup_table)
self._gpr_image_item.setLevels((float(np.min(image)), float(np.max(image) + 1e-6)))
self._gpr_image_item.show()
plot.setXRange(x_min, x_max, padding=0.02)
plot.setYRange(self._gpr_display_y_min(y_min, y_max), y_max, padding=0.02)
self._draw_gpr_geometry_markers()
points_payload = self._collection_payload_by_name(collection, "gpr_points", kind=4)
if points_payload is not None and np.asarray(points_payload.table).size > 0:
points = np.asarray(points_payload.table, dtype=np.float32)
self._gpr_points_item.setData(
x=points[:, 0],
y=points[:, 1],
symbol="d",
size=11,
brush=pg.mkBrush("#ffffff"),
pen=pg.mkPen("#111111", width=1.1),
)
self._gpr_points_item.show()
for x_value, y_value, score in points:
label = pg.TextItem(text=f"{float(score):.0f}", color="#ffffff", anchor=(0.0, 1.0))
label.setZValue(40)
label.setPos(float(x_value), float(y_value))
plot.addItem(label)
self._gpr_point_labels.append(label)
else:
self._gpr_points_item.setData(x=[], y=[])
self._gpr_points_item.hide()
region_centers_payload = self._collection_payload_by_name(collection, "gpr_region_centers", kind=4)
if region_centers_payload is not None and np.asarray(region_centers_payload.table).size > 0:
centers = np.asarray(region_centers_payload.table, dtype=np.float32)
self._gpr_region_centers_item.setData(
x=centers[:, 0],
y=centers[:, 1],
symbol="o",
size=10,
brush=pg.mkBrush("#80ed99"),
pen=pg.mkPen("#081c15", width=1.1),
)
self._gpr_region_centers_item.show()
for row in centers:
label = pg.TextItem(text=f"{float(row[2]):.0f}", color="#d8f3dc", anchor=(0.0, 1.0))
label.setZValue(40)
label.setPos(float(row[0]), float(row[1]))
plot.addItem(label)
self._gpr_region_center_labels.append(label)
else:
self._gpr_region_centers_item.setData(x=[], y=[])
self._gpr_region_centers_item.hide()
for payload in self._collection_payloads_by_prefix(collection, "gpr_region_mask_", kind=3):
mask = np.asarray(payload.image, dtype=np.float32)
if mask.ndim != 2 or mask.size == 0:
continue
mask_image = pg.ImageItem(axisOrder="row-major")
mask_image.setZValue(5)
mask_image.setImage(mask, autoLevels=False)
mask_image.setRect(rect)
mask_image.setOpacity(0.0)
plot.addItem(mask_image)
contour = pg.IsocurveItem(data=mask, level=0.5, pen=pg.mkPen("#4cc9f0", width=1.3))
contour.setParentItem(mask_image)
self._gpr_region_mask_items.append(mask_image)
self._gpr_region_contours.append(contour)
plot.setTitle(f"GPR {self._gpr_config_mode.currentText()}")
finally:
plot.setUpdatesEnabled(True)
return True
@staticmethod
def _normalized_display_range(start: float, stop: float, *, minimum_span: float = 0.1) -> tuple[float, float]:
"""Return ordered display bounds with a non-zero span."""
lower = min(float(start), float(stop))
upper = max(float(start), float(stop))
if upper - lower >= minimum_span:
return lower, upper
center = 0.5 * (lower + upper)
half_span = 0.5 * minimum_span
return center - half_span, center + half_span
def _gpr_visible_object_bounds(self) -> tuple[float, float, float, float]:
"""Return normalized object-only visible X/Z bounds from GUI controls."""
x_min, x_max = self._normalized_display_range(
float(self._gpr_visible_x_min_m.value()),
float(self._gpr_visible_x_max_m.value()),
minimum_span=0.1,
)
z_min, z_max = self._normalized_display_range(
float(self._gpr_visible_z_min_m.value()),
float(self._gpr_visible_z_max_m.value()),
minimum_span=0.1,
)
return x_min, x_max, z_min, z_max
@staticmethod
def _gpr_display_y_min(z_min: float, z_max: float) -> float:
"""Return lower GPR display bound with a small negative margin for antenna markers."""
lower = min(0.0, float(z_min))
span = max(float(z_max) - float(z_min), 1e-6)
marker_margin = max(span * 0.03, 0.06)
return lower - marker_margin
def _draw_gpr_geometry_markers(self) -> None:
"""Render selected Tx/Rx geometry markers on current GPR plot."""
x_tx, x_rx = self._selected_gpr_geometry()
if x_tx.size > 0:
self._gpr_tx_item.setData(
x=x_tx,
y=np.zeros_like(x_tx),
symbol="t",
size=13,
brush=pg.mkBrush("#ff595e"),
pen=pg.mkPen("#ffca3a", width=1.0),
)
self._gpr_tx_item.show()
else:
self._gpr_tx_item.setData(x=[], y=[])
self._gpr_tx_item.hide()
if x_rx.size > 0:
self._gpr_rx_item.setData(
x=x_rx,
y=np.zeros_like(x_rx),
symbol="t1",
size=13,
brush=pg.mkBrush("#4cc9f0"),
pen=pg.mkPen("#e0fbfc", width=1.0),
)
self._gpr_rx_item.show()
else:
self._gpr_rx_item.setData(x=[], y=[])
self._gpr_rx_item.hide()
@staticmethod
def _format_gpr_object_label(x_m: float, z_m: float, pair_count: float) -> str:
"""Format object-only annotation text with pair count and coordinates."""
return f"{int(round(pair_count))} | x={x_m:.1f} | z={z_m:.1f}"
@staticmethod
def _expanded_scene_rect(rect: QRectF, *, padding_px: float = 4.0) -> QRectF:
"""Return scene rect padded to keep labels visually separated."""
return rect.adjusted(-padding_px, -padding_px, padding_px, padding_px)
@staticmethod
def _scene_rect_intersects_any(rect: QRectF, occupied_rects: list[QRectF]) -> bool:
"""Return whether candidate label rect intersects any already placed label."""
return any(rect.intersects(occupied_rect) for occupied_rect in occupied_rects)
@staticmethod
def _gpr_object_label_candidates(
x_m: float,
z_m: float,
*,
x_span: float,
z_span: float,
) -> list[tuple[float, float, tuple[float, float]]]:
"""Return candidate label placements around one object."""
x_offset = max(x_span * 0.015, 0.02)
z_offset = max(z_span * 0.02, 0.02)
return [
(x_m + x_offset, z_m - z_offset, (0.0, 1.0)),
(x_m + x_offset, z_m + z_offset, (0.0, 0.0)),
(x_m - x_offset, z_m - z_offset, (1.0, 1.0)),
(x_m - x_offset, z_m + z_offset, (1.0, 0.0)),
(x_m, z_m - (z_offset * 1.35), (0.5, 1.0)),
(x_m, z_m + (z_offset * 1.35), (0.5, 0.0)),
(x_m + (x_offset * 2.2), z_m - (z_offset * 1.5), (0.0, 1.0)),
(x_m + (x_offset * 2.2), z_m + (z_offset * 1.5), (0.0, 0.0)),
(x_m - (x_offset * 2.2), z_m - (z_offset * 1.5), (1.0, 1.0)),
(x_m - (x_offset * 2.2), z_m + (z_offset * 1.5), (1.0, 0.0)),
]
def _place_gpr_object_label(
self,
*,
label: pg.TextItem,
x_m: float,
z_m: float,
x_span: float,
z_span: float,
occupied_scene_rects: list[QRectF],
) -> None:
"""Place one object label using the first non-overlapping candidate position."""
last_rect: QRectF | None = None
for label_x, label_z, anchor in self._gpr_object_label_candidates(
x_m,
z_m,
x_span=x_span,
z_span=z_span,
):
label.setAnchor(anchor)
label.setPos(label_x, label_z)
candidate_rect = self._expanded_scene_rect(label.sceneBoundingRect())
last_rect = candidate_rect
if not self._scene_rect_intersects_any(candidate_rect, occupied_scene_rects):
occupied_scene_rects.append(candidate_rect)
return
if last_rect is not None:
occupied_scene_rects.append(last_rect)
def _gpr_object_rows(self, collection: ResultCollection) -> np.ndarray:
"""Return object rows as `[x_m, z_m, pair_count]` from current GPR result payload."""
return extract_gpr_object_rows(collection)
def _filtered_gpr_object_rows(self, collection: ResultCollection) -> np.ndarray:
"""Return object rows filtered by minimum pair count and visible X/Z bounds."""
rows = self._gpr_object_rows(collection)
if rows.size == 0:
return rows
x_min, x_max, z_min, z_max = self._gpr_visible_object_bounds()
min_pair_count = float(self._gpr_min_visible_pair_count.value())
finite_mask = np.all(np.isfinite(rows[:, :3]), axis=1)
visible_mask = (
finite_mask
& (rows[:, 2] >= min_pair_count)
& (rows[:, 0] >= x_min)
& (rows[:, 0] <= x_max)
& (rows[:, 1] >= z_min)
& (rows[:, 1] <= z_max)
)
return rows[visible_mask]
def _draw_gpr_objects_only(self, collection: ResultCollection) -> bool:
"""Draw only detected GPR objects inside configured X/Z bounds."""
accumulator_payload = self._collection_payload_by_name(collection, "gpr_accumulator", kind=3)
object_rows = self._filtered_gpr_object_rows(collection)
if accumulator_payload is None and object_rows.size == 0:
self._clear_gpr_plot()
return False
x_min, x_max, z_min, z_max = self._gpr_visible_object_bounds()
plot = self._gpr_plot
plot.setUpdatesEnabled(False)
try:
self._ensure_gpr_plot_items()
plot.getViewBox().invertY(False)
self._clear_gpr_point_labels()
self._clear_gpr_region_labels()
self._clear_gpr_region_masks()
self._gpr_image_item.hide()
self._draw_gpr_geometry_markers()
self._gpr_region_centers_item.setData(x=[], y=[])
self._gpr_region_centers_item.hide()
if object_rows.size > 0:
self._gpr_points_item.setData(
x=object_rows[:, 0],
y=object_rows[:, 1],
symbol="o",
size=18,
brush=pg.mkBrush("#ff4d4f"),
pen=pg.mkPen("#ff4d4f", width=1.6),
)
self._gpr_points_item.show()
occupied_scene_rects: list[QRectF] = []
x_span = x_max - x_min
z_span = z_max - z_min
for x_value, z_value, pair_count in object_rows:
label = pg.TextItem(
text=self._format_gpr_object_label(
float(x_value),
float(z_value),
float(pair_count),
),
color="#ffd6d9",
anchor=(0.0, 1.0),
)
label.setZValue(40)
plot.addItem(label)
self._place_gpr_object_label(
label=label,
x_m=float(x_value),
z_m=float(z_value),
x_span=x_span,
z_span=z_span,
occupied_scene_rects=occupied_scene_rects,
)
self._gpr_point_labels.append(label)
else:
self._gpr_points_item.setData(x=[], y=[])
self._gpr_points_item.hide()
plot.setXRange(x_min, x_max, padding=0.0)
plot.setYRange(self._gpr_display_y_min(z_min, z_max), z_max, padding=0.0)
plot.setTitle(f"GPR {self._gpr_config_mode.currentText()} Objects Only")
finally:
plot.setUpdatesEnabled(True)
return True
@@ -0,0 +1,388 @@
"""Trace plot rendering helpers for pass-through and single-trace views."""
from __future__ import annotations
from PyQt6.QtCore import Qt
import numpy as np
import pyqtgraph as pg
from python_app.models.dataset_model import ResultCollection, TraceData
class AppWindowTracePlotMixin:
"""Renders pass-through trace plots on stacked magnitude/phase widgets."""
def _show_magnitude_curves(self) -> bool:
"""Return whether magnitude curves should be rendered."""
return self._show_magnitude_checkbox.isChecked()
def _show_phase_curves(self) -> bool:
"""Return whether phase curves should be rendered."""
return self._show_phase_checkbox.isChecked()
def _pass_through_fixed_y_range(self) -> tuple[bool, float, float]:
"""Return normalized magnitude Y-range override for pass-through mode."""
y_min = float(self._pass_through_y_min_db.value())
y_max = float(self._pass_through_y_max_db.value())
return bool(self._pass_through_fixed_y_enabled.isChecked()), min(y_min, y_max), max(y_min, y_max)
def _configure_pass_through_magnitude_axis(self, plot: pg.PlotWidget) -> None:
"""Apply pass-through magnitude-axis autorange or fixed Y window."""
fixed_y_enabled, y_min, y_max = self._pass_through_fixed_y_range()
view_box = plot.getViewBox()
view_box.invertY(False)
view_box.enableAutoRange(x=True, y=not fixed_y_enabled)
if fixed_y_enabled:
plot.setYRange(y_min, y_max, padding=0.0)
def _on_trace_visibility_changed(self, *_args) -> None:
"""Redraw pass-through traces when magnitude/phase toggles changed."""
if self._processing_mode.currentText() in {"bscan", "gpr"}:
return
if self._result_history:
self._draw_results(self._result_history[-1])
return
self._clear_trace_plots()
def _clear_trace_plots(self) -> None:
"""Clear pass-through magnitude and phase plots."""
self._trace_magnitude_plot.clear()
self._trace_phase_plot.clear()
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 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()
phase_legend = self._trace_phase_legend
if phase_legend is not None:
try:
self._trace_phase_plot.getPlotItem().removeItem(phase_legend)
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()
def _draw_trace_lines(self, collection: ResultCollection) -> bool:
"""Draw result payload traces as stacked magnitude/phase plots."""
show_magnitude = self._show_magnitude_curves()
show_phase = self._show_phase_curves()
magnitude_plot = self._trace_magnitude_plot
phase_plot = self._trace_phase_plot
pass_through_channel = "S21"
magnitude_plot.setVisible(show_magnitude)
phase_plot.setVisible(show_phase)
if not show_magnitude and not show_phase:
self._clear_trace_plots()
return False
if show_magnitude:
mag_item = magnitude_plot.getPlotItem()
self._configure_pass_through_magnitude_axis(magnitude_plot)
mag_item.showAxis("left", show=True)
mag_item.showAxis("bottom", show=not show_phase)
magnitude_plot.setLabel("left", "Magnitude", units="dB")
magnitude_plot.setTitle(f"Pass-Through {pass_through_channel}")
if not show_phase:
magnitude_plot.setLabel("bottom", "Frequency", units="Hz")
if show_phase:
phase_item = phase_plot.getPlotItem()
phase_plot.getViewBox().invertY(False)
phase_plot.getViewBox().enableAutoRange(x=True, y=False)
phase_item.showAxis("left", show=True)
phase_item.showAxis("bottom", show=True)
phase_plot.setLabel("left", "Phase", units="deg")
phase_plot.setLabel("bottom", "Frequency", units="Hz")
phase_plot.setTitle(f"Pass-Through {pass_through_channel}")
palette = [
"#4cc9f0",
"#f72585",
"#b8f2e6",
"#ffd166",
"#90be6d",
"#ff595e",
"#6a4c93",
"#1982c4",
]
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:
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]
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)
if show_magnitude:
magnitude_values = 20.0 * np.log10(np.maximum(np.abs(payload.trace), 1e-12))
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:
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
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):
if show_magnitude:
magnitude_plot.setXRange(x_min, x_max, padding=0.02)
if show_phase:
phase_plot.setXRange(x_min, x_max, padding=0.02)
if show_phase:
phase_plot.setYRange(-180.0, 180.0, padding=0.02)
if show_magnitude:
self._configure_pass_through_magnitude_axis(magnitude_plot)
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 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
if legend is not None and existing_keys == active_keys:
return
if legend is not None:
try:
plot.getPlotItem().removeItem(legend)
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):
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_single_trace(self, trace: TraceData, title: str, *, channel: str = "s21") -> None:
"""Draw one trace on stacked magnitude/phase plots."""
show_magnitude = self._show_magnitude_curves()
show_phase = self._show_phase_curves()
magnitude_plot = self._trace_magnitude_plot
phase_plot = self._trace_phase_plot
samples = trace.s11 if channel == "s11" else trace.s21
magnitude_plot.setVisible(show_magnitude)
phase_plot.setVisible(show_phase)
self._clear_trace_plots()
if not show_magnitude and not show_phase:
return
if show_magnitude:
self._configure_pass_through_magnitude_axis(magnitude_plot)
magnitude_plot.getPlotItem().showAxis("bottom", show=not show_phase)
magnitude_plot.setLabel("left", "Magnitude", units="dB")
magnitude_plot.setTitle(title)
if not show_phase:
magnitude_plot.setLabel("bottom", "Frequency", units="Hz")
if show_phase:
phase_plot.getViewBox().invertY(False)
phase_plot.getViewBox().enableAutoRange(x=True, y=False)
phase_plot.getPlotItem().showAxis("bottom", show=True)
phase_plot.setLabel("left", "Phase", units="deg")
phase_plot.setLabel("bottom", "Frequency", units="Hz")
phase_plot.setTitle(title)
if show_magnitude:
magnitude_db = 20.0 * np.log10(np.maximum(np.abs(samples), 1e-12))
magnitude_curve = pg.PlotCurveItem(
trace.frequency_hz,
magnitude_db,
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_values = np.degrees(np.angle(samples))
phase_curve = pg.PlotCurveItem(
trace.frequency_hz,
phase_values,
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:
x_min = float(np.min(trace.frequency_hz))
x_max = float(np.max(trace.frequency_hz))
if show_magnitude:
magnitude_plot.setXRange(x_min, x_max, padding=0.02)
self._configure_pass_through_magnitude_axis(magnitude_plot)
if show_phase:
phase_plot.setXRange(x_min, x_max, padding=0.02)