"""Render captured traces (amplitude + phase) to standalone PNG files. The preprocess preview pane only shows the *last* radar variant of each combo, so during a multi-config reference capture the operator never sees the other variants. This helper renders the same amplitude/phase plot pair used in the preview into off-screen PNG files, letting us persist a graph for every config. """ from __future__ import annotations import logging from pathlib import Path import numpy as np import pyqtgraph as pg from PyQt6.QtCore import QRectF from pyqtgraph.exporters import ImageExporter from python_app.models.dataset_model import TraceData logger = logging.getLogger(__name__) # Matches the preview pane styling in preprocess_dialog so saved PNGs and the # on-screen preview look the same. _BACKGROUND = "#101418" _MAGNITUDE_PEN = pg.mkPen("#4cc9f0", width=1.8) _PHASE_PEN = pg.mkPen("#f48c06", width=1.8) _EXPORT_SIZE = (1400, 520) def export_trace_png( trace: TraceData, output_path: Path, *, channel: str, title: str, ) -> None: """Render one trace's amplitude (dB) and wrapped phase (deg) panes to a PNG. Builds an off-screen ``GraphicsLayoutWidget`` (never shown), plots the same curves as the live preview, and exports the scene as a PNG. Requires a running ``QApplication`` (always true inside the GUI). """ samples = trace.s11 if channel == "s11" else trace.s21 magnitude_db = 20.0 * np.log10(np.maximum(np.abs(samples), 1e-12)) phase_deg = np.degrees(np.angle(samples)) layout = pg.GraphicsLayoutWidget(show=False, size=_EXPORT_SIZE) layout.setBackground(_BACKGROUND) try: layout.addLabel(title, row=0, col=0, colspan=2) magnitude_plot = layout.addPlot(row=1, col=0) magnitude_plot.showGrid(x=True, y=True, alpha=0.2) magnitude_plot.setLabel("bottom", "Frequency", units="Hz") magnitude_plot.setLabel("left", "Magnitude", units="dB") magnitude_plot.plot(trace.frequency_hz, magnitude_db, pen=_MAGNITUDE_PEN) phase_plot = layout.addPlot(row=1, col=1) phase_plot.showGrid(x=True, y=True, alpha=0.2) phase_plot.setLabel("bottom", "Frequency", units="Hz") phase_plot.setLabel("left", "Phase", units="deg") phase_plot.plot(trace.frequency_hz, phase_deg, pen=_PHASE_PEN) # An off-screen widget never receives a resizeEvent, so its central # GraphicsLayout keeps its small preferred size and both plots would be # squeezed into the left edge of the image. Force the layout geometry to # the target size, doing manually what a resizeEvent normally triggers. layout.ci.setGeometry(QRectF(0, 0, _EXPORT_SIZE[0], _EXPORT_SIZE[1])) output_path.parent.mkdir(parents=True, exist_ok=True) exporter = ImageExporter(layout.scene()) exporter.parameters()["width"] = _EXPORT_SIZE[0] exporter.export(str(output_path)) finally: layout.close() layout.deleteLater()