"""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)