diff --git a/.gitignore b/.gitignore index 9bafc13..a1b437c 100644 --- a/.gitignore +++ b/.gitignore @@ -224,3 +224,6 @@ __marimo__/ .streamlit/secrets.toml python_app/runtime SHARE_INTERNET_TO_PI.md + +CLAUDE.md +./docs \ No newline at end of file diff --git a/python_app/gui/controllers/app_window_preprocess_mixin.py b/python_app/gui/controllers/app_window_preprocess_mixin.py index 382725b..143b6af 100644 --- a/python_app/gui/controllers/app_window_preprocess_mixin.py +++ b/python_app/gui/controllers/app_window_preprocess_mixin.py @@ -3,6 +3,7 @@ from __future__ import annotations from python_app.gui.preprocess_dialog import PreprocessDialog +from python_app.gui.trace_png_export import export_trace_png from python_app.orchestration.preprocess_assets import ( PREPROCESS_ASSET_SPECS, VISIBLE_PREPROCESS_ASSET_KEYS, @@ -701,6 +702,12 @@ class AppWindowPreprocessMixin: f"{display_name} sequence completed and saved: set={set_name}, " f"radar_variants={len(saved_sets)} [{saved_summary}]" ) + self._export_multi_radar_preview_pngs( + session=session, + saved_sets=saved_sets, + kind=kind, + set_name=set_name, + ) else: dialog.set_status(f"{display_name} set saved: {set_name} ({len(collection.traces)} traces)") self._log(f"{display_name} sequence completed and saved: set={set_name}, key={radar_key}") @@ -708,6 +715,50 @@ class AppWindowPreprocessMixin: except Exception as exc: # noqa: BLE001 self._show_exception("Failed to save preprocess set", exc) + def _export_multi_radar_preview_pngs( + self, + *, + session: MultiRadarSequentialCaptureSession, + saved_sets: list, + kind: str, + set_name: str, + ) -> None: + """Save amplitude/phase PNGs for every captured combo across all radar configs. + + The live preview only shows one trace per config, so this persists a graph for + every config and every combo (port) under the store's ``preview_png/`` tree. + Iterating per config over its full trace set (not the per-batch preview trace) + is what makes matrix radars save all ports instead of only the last one. The + set is already saved by the time we get here, so any rendering failure is + logged but never aborts the save. + """ + channel = preprocess_asset_channel(kind) + display_name = preprocess_asset_display_name(kind) + saved_count = 0 + for saved in saved_sets: + for trace in session.traces_for_radar_key(saved.radar_key): + combo_label = f"input={trace.combo.input} output={trace.combo.output}" + try: + png_path = self._store.preview_png_dir(kind, set_name, saved.radar_key) / ( + f"i{trace.combo.input}_o{trace.combo.output}.png" + ) + export_trace_png( + trace, + png_path, + channel=channel, + title=f"{display_name} | {saved.display_name} | {combo_label}", + ) + saved_count += 1 + except Exception as exc: # noqa: BLE001 + self._log( + f"Failed to save preview PNG for {saved.display_name} ({combo_label}): {exc}" + ) + if saved_count and saved_sets: + png_root = self._store.preview_png_dir(kind, set_name, saved_sets[0].radar_key).parent + self._log( + f"Saved {saved_count} preview PNG(s) for {len(saved_sets)} radar config(s) under {png_root}" + ) + def _abort_capture_sequence(self, *, resume_pipeline: bool = True) -> None: """Abort active capture session and optionally resume pipeline.""" if self._capture_session is None: diff --git a/python_app/gui/trace_png_export.py b/python_app/gui/trace_png_export.py new file mode 100644 index 0000000..ebb1c92 --- /dev/null +++ b/python_app/gui/trace_png_export.py @@ -0,0 +1,77 @@ +"""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() diff --git a/python_app/storage/npz/store.py b/python_app/storage/npz/store.py index f0bf7ce..dfdd26a 100644 --- a/python_app/storage/npz/store.py +++ b/python_app/storage/npz/store.py @@ -484,5 +484,15 @@ class NpzStore(StoreApi): """Return directory for set kind and radar key.""" return self._root_dir / kind / radar_key + def preview_png_dir(self, kind: str, set_name: str, radar_key: str) -> Path: + """Return directory for preview PNGs of one set/radar variant. + + Lives under ``preview_png/`` inside the store so saved graphs sit next to + the data they describe, grouped by set name then radar variant. The set + name is operator-supplied, so it is sanitized; ``radar_key`` is already + filesystem-safe by construction. + """ + return self._root_dir / "preview_png" / kind / sanitize_path_component(set_name) / radar_key + __all__ = ["NpzStore", "radar_key_from_config"] diff --git a/python_app/workflows/multi_radar_capture_workflow.py b/python_app/workflows/multi_radar_capture_workflow.py index 3796c99..09aa0d3 100644 --- a/python_app/workflows/multi_radar_capture_workflow.py +++ b/python_app/workflows/multi_radar_capture_workflow.py @@ -313,6 +313,15 @@ class MultiRadarSequentialCaptureSession: """Return completed combo batches in capture order.""" return list(self._captured_batches) + def traces_for_radar_key(self, radar_key: str) -> list[TraceData]: + """Return every captured trace (one per combo) for one radar variant. + + Unlike a batch's ``traces`` (one entry per variant, holding only the + preview trace), this returns the full per-combo set, so matrix radars + expose all ports rather than just the last one. + """ + return list(self._traces_by_radar_key.get(radar_key, [])) + def radar_variant_count(self) -> int: """Return how many radar variants are captured per combo.""" return len(self._radar_variants)