187 lines
8.4 KiB
Python
187 lines
8.4 KiB
Python
"""Magnitude and phase rendering with owned plots, curves and legends."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Protocol
|
|
|
|
import numpy as np
|
|
import pyqtgraph as pg
|
|
from PyQt6.QtCore import Qt
|
|
from PyQt6.QtWidgets import QVBoxLayout, QWidget
|
|
|
|
from python_app.gui.controllers.renderers.common import create_plot
|
|
from python_app.models.dataset_model import ComboKey, ResultCollection, TraceData
|
|
from python_app.models.gui_profile_schema import GuiPassThroughStateModel
|
|
from python_app.models.run_config_validation import parse_combos_from_text
|
|
|
|
|
|
class WarningLogger(Protocol):
|
|
def __call__(self, text: str, *, details: str | None = None,
|
|
once_key: str | None = None) -> None: ...
|
|
|
|
|
|
CurveKey = tuple[ComboKey, int, str]
|
|
Series = tuple[CurveKey, np.ndarray, np.ndarray, str]
|
|
_PALETTE = ("#4cc9f0", "#f72585", "#b8f2e6", "#ffd166",
|
|
"#90be6d", "#ff595e", "#6a4c93", "#1982c4")
|
|
|
|
|
|
class _TracePanel:
|
|
"""One persistent curve surface and its combo legend."""
|
|
|
|
def __init__(self, label: str, units: str) -> None:
|
|
self.plot = create_plot("Pass-Through S21")
|
|
item = self.plot.getPlotItem()
|
|
assert item is not None
|
|
self.item = item
|
|
self.view = item.getViewBox()
|
|
self.item.showGrid(x=True, y=True, alpha=0.2)
|
|
self.item.setLabel("left", label, units=units)
|
|
self.item.setLabel("bottom", "Frequency", units="Hz")
|
|
self.item.setDownsampling(mode="peak")
|
|
self.item.setClipToView(True)
|
|
self.curves: dict[CurveKey, pg.PlotCurveItem] = {}
|
|
self.legend: pg.LegendItem | None = None
|
|
self._legend_sources: dict[ComboKey, pg.PlotCurveItem] = {}
|
|
|
|
def clear(self) -> None:
|
|
self.item.clear()
|
|
self.curves.clear()
|
|
self.sync_legend({})
|
|
|
|
def update_curve(self, key: CurveKey, x: np.ndarray, y: np.ndarray,
|
|
color: str, *, width: float, dashed: bool = False) -> pg.PlotCurveItem:
|
|
pen = pg.mkPen(color, width=width,
|
|
style=Qt.PenStyle.DashLine if dashed else Qt.PenStyle.SolidLine)
|
|
curve = self.curves.get(key)
|
|
if curve is None:
|
|
curve = pg.PlotCurveItem(pen=pen, antialias=False)
|
|
self.curves[key] = curve
|
|
self.item.addItem(curve)
|
|
else:
|
|
curve.setPen(pen)
|
|
curve.setData(x, y)
|
|
return curve
|
|
|
|
def retain_curves(self, active_keys: set[CurveKey]) -> None:
|
|
for key in self.curves.keys() - active_keys:
|
|
self.item.removeItem(self.curves.pop(key))
|
|
|
|
def sync_legend(self, sources: dict[ComboKey, pg.PlotCurveItem]) -> None:
|
|
if self._legend_sources == sources:
|
|
return
|
|
if self.legend is None and sources:
|
|
self.legend = self.item.addLegend(offset=(8, 8))
|
|
if self.legend is not None:
|
|
self.legend.clear()
|
|
for combo in sorted(sources, key=lambda key: (key.input, key.output)):
|
|
self.legend.addItem(sources[combo], f"in{combo.input}/out{combo.output}")
|
|
self.legend.setVisible(bool(sources))
|
|
self._legend_sources = sources
|
|
|
|
|
|
class TraceRenderer:
|
|
"""Render result traces or a captured trace using explicit display settings."""
|
|
|
|
def __init__(self, log_warning: WarningLogger, max_points: int = 800) -> None:
|
|
self._log_warning = log_warning
|
|
self.max_points = max_points
|
|
self.widget = QWidget()
|
|
layout = QVBoxLayout(self.widget)
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|
layout.setSpacing(6)
|
|
self.magnitude = _TracePanel("Magnitude", "dB")
|
|
self.phase = _TracePanel("Phase", "deg")
|
|
self.magnitude.item.showAxis("bottom", show=False)
|
|
for panel in (self.magnitude, self.phase):
|
|
layout.addWidget(panel.plot, stretch=1)
|
|
|
|
def clear(self) -> None:
|
|
self.magnitude.clear()
|
|
self.phase.clear()
|
|
|
|
def draw(self, collection: ResultCollection, settings: GuiPassThroughStateModel) -> bool:
|
|
combo_filter = self._combo_filter(settings.combo_filter)
|
|
colors: dict[ComboKey, str] = {}
|
|
series: list[Series] = []
|
|
for block in collection.blocks:
|
|
if combo_filter is not None and block.combo not in combo_filter:
|
|
continue
|
|
color = colors.setdefault(block.combo, _PALETTE[len(colors) % len(_PALETTE)])
|
|
for index, payload in enumerate(block.payloads):
|
|
if payload.kind != 1 or not payload.trace.size or payload.frequency_hz.size != payload.trace.size:
|
|
continue
|
|
series.append(((block.combo, index, payload.processing_name),
|
|
payload.frequency_hz, payload.trace, color))
|
|
return self._render(series, settings, "Pass-Through S21")
|
|
|
|
def draw_trace(self, trace: TraceData, title: str, settings: GuiPassThroughStateModel) -> None:
|
|
self.clear()
|
|
self._render([((trace.combo, 0, "__single_trace__"), trace.frequency_hz,
|
|
trace.s21, "#ffd166")], settings, title, single=True)
|
|
|
|
def _combo_filter(self, text: str) -> set[ComboKey] | None:
|
|
text = text.strip()
|
|
if not text:
|
|
return None
|
|
try:
|
|
return {ComboKey(combo.input, combo.output) for combo in parse_combos_from_text(text)}
|
|
except Exception as exc: # noqa: BLE001
|
|
self._log_warning("Invalid pass-through switch-combo filter.",
|
|
details=f"{exc}\nExpected format: input:output,input:output",
|
|
once_key=f"pass_through_combo_filter_invalid_{text}")
|
|
return None
|
|
|
|
def _render(self, series: list[Series], settings: GuiPassThroughStateModel,
|
|
title: str, *, single: bool = False) -> bool:
|
|
mag, phase = self.magnitude, self.phase
|
|
mag.plot.setVisible(settings.show_magnitude)
|
|
phase.plot.setVisible(settings.show_phase)
|
|
if not settings.show_magnitude and not settings.show_phase:
|
|
self.clear()
|
|
return False
|
|
mag.item.showAxis("bottom", show=not settings.show_phase)
|
|
active: set[CurveKey] = set()
|
|
mag_sources: dict[ComboKey, pg.PlotCurveItem] = {}
|
|
phase_sources: dict[ComboKey, pg.PlotCurveItem] = {}
|
|
x_min, x_max = np.inf, -np.inf
|
|
for key, frequency, samples, color in series:
|
|
if not samples.size or frequency.size != samples.size:
|
|
continue
|
|
active.add(key)
|
|
x_min, x_max = min(x_min, float(np.min(frequency))), max(x_max, float(np.max(frequency)))
|
|
step = max(1, int(np.ceil(samples.size / self.max_points))) if self.max_points > 0 else 1
|
|
if settings.show_magnitude:
|
|
magnitude = 20.0 * np.log10(np.maximum(np.abs(samples[::step]), 1e-12))
|
|
curve = mag.update_curve(key, frequency[::step], magnitude, color,
|
|
width=1.8 if single else 1.4)
|
|
mag_sources.setdefault(key[0], curve)
|
|
if settings.show_phase:
|
|
angles = np.angle(samples)
|
|
if settings.unwrap_phase:
|
|
angles = np.unwrap(angles)
|
|
curve = phase.update_curve(key, frequency[::step], np.degrees(angles[::step]),
|
|
"#80ed99" if single else color,
|
|
width=1.4 if single else 1.2, dashed=single)
|
|
phase_sources.setdefault(key[0], curve)
|
|
|
|
for panel, visible, sources in ((mag, settings.show_magnitude, mag_sources),
|
|
(phase, settings.show_phase, phase_sources)):
|
|
panel.retain_curves(active if visible else set())
|
|
panel.sync_legend(sources if not single else {})
|
|
if visible:
|
|
panel.item.setTitle(title)
|
|
panel.view.invertY(False)
|
|
if active and np.isfinite(x_min) and np.isfinite(x_max):
|
|
panel.view.setXRange(x_min, x_max, padding=0.02)
|
|
if settings.show_magnitude:
|
|
mag.view.enableAutoRange(y=not settings.fixed_y_enabled)
|
|
if settings.fixed_y_enabled:
|
|
mag.view.setYRange(min(settings.y_min_db, settings.y_max_db),
|
|
max(settings.y_min_db, settings.y_max_db), padding=0.0)
|
|
if settings.show_phase:
|
|
phase.view.enableAutoRange(y=settings.unwrap_phase)
|
|
if not settings.unwrap_phase:
|
|
phase.view.setYRange(-180.0, 180.0, padding=0.02)
|
|
return bool(active)
|