diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..54fe9d8 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,25 @@ +# Ubiquitous Language — ISenseCloud + +Glossary only. No implementation details. Terms verified against the C# original (`~/WA/temp_ui`) and the pyGUI redesign. + +## Terms + +**Wafer family** — the wafer type letter (A, B, C, D, E, F, P, X, Z) identifying sensor count and physical layout. Families group into layout classes: A/E/P (150C 48pt), B/C/D (250C 29pt), F (250C 22pt), X (150C 80pt). + +**Master (master file)** — the golden-reference CSV assigned per wafer family, used as the comparison baseline. Assigning a master is a per-family setting, not a layout choice. (C#: `btnAMaster`…`btnZMaster`; pyGUI: Settings tab master picker.) + +**Layout template** — a spreadsheet of sensor coordinates for a layout class, exported for external use. The C# "Layout" page buttons export these files; they do not change what is displayed. Not to be confused with a layout *selector*. + +**Edge–Center pair** — a fixed per-family mapping from each edge sensor to its radially-corresponding center sensor. Pairs involving a replaced sensor are skipped. + +**Edge–Center delta** — |edge temperature − paired center temperature| for one pair in one frame. The Edge-Center readout reports the min and max delta across pairs, with each pair's sensor numbers and temperatures. + +**Setpoint** — the target temperature the wafer run is trying to hold during the Set phase. + +**Margin** — the ± tolerance band around the color target used to color sensors/heatmap. + +**Sigma color (auto margin)** — coloring mode where the target is the frame average and the margin is the frame's σ, instead of the manual setpoint/margin. + +**Max range** — a temperature span used to find the largest set of sensors that fit within it (centered near the average/setpoint); sensors outside the set are flagged out-of-range. + +**Phase (Idle / Ramp / Set)** — the replay-time state of a frame: idle (flat, off-target), ramp (moving, colored by derivative sign), set (holding at setpoint). diff --git a/src/pygui/ISC/Tabs/components/SourcePanel.qml b/src/pygui/ISC/Tabs/components/SourcePanel.qml index 2a059db..bf8d0e1 100644 --- a/src/pygui/ISC/Tabs/components/SourcePanel.qml +++ b/src/pygui/ISC/Tabs/components/SourcePanel.qml @@ -1,13 +1,31 @@ import QtQuick import QtQuick.Controls +import QtQuick.Dialogs import QtQuick.Layouts import QtQuick.Controls.impl import ISC import ".." ColumnLayout { + id: root spacing: 6 + property string exportSummary: "" + + Connections { + target: batchExportController + function onExportFinished(summary) { + root.exportSummary = summary + exportBannerHideTimer.restart() + } + } + + Timer { + id: exportBannerHideTimer + interval: 5000 + onTriggered: root.exportSummary = "" + } + // ── Section label & Refresh ─────────────────────────────────────────── RowLayout { Layout.fillWidth: true @@ -26,6 +44,29 @@ ColumnLayout { Layout.alignment: Qt.AlignVCenter } + Button { + id: refreshBtn + implicitWidth: 28 + implicitHeight: 28 + hoverEnabled: true + background: Rectangle { + color: refreshBtn.pressed ? Theme.buttonPressed : refreshBtn.hovered ? Theme.buttonNeutralHover : "transparent" + radius: Theme.radiusSm + } + contentItem: IconImage { + source: "../icons/refresh.svg" + sourceSize.width: 16 + sourceSize.height: 16 + color: Theme.bodyColor + } + onClicked: file_browser.refreshFiles() + + AppToolTip { + visible: refreshBtn.hovered + text: "Refresh" + } + } + Button { id: editBtn implicitWidth: 28 @@ -52,27 +93,90 @@ ColumnLayout { } } + Item { implicitWidth: 8 } + Button { - id: refreshBtn + id: batchExportBtn implicitWidth: 28 implicitHeight: 28 hoverEnabled: true + enabled: file_browser.files.length > 0 && !batchExportController.busy + opacity: enabled ? 1.0 : 0.4 background: Rectangle { - color: refreshBtn.pressed ? Theme.buttonPressed : refreshBtn.hovered ? Theme.buttonNeutralHover : "transparent" + color: batchExportBtn.pressed ? Theme.buttonPressed : batchExportBtn.hovered ? Theme.buttonNeutralHover : "transparent" radius: Theme.radiusSm } contentItem: IconImage { - source: "../icons/refresh.svg" + source: "../icons/images.svg" sourceSize.width: 16 sourceSize.height: 16 color: Theme.bodyColor } - onClicked: file_browser.refreshFiles() + onClicked: batchExportDirDialog.open() AppToolTip { - visible: refreshBtn.hovered - text: "Refresh" + visible: batchExportBtn.hovered && batchExportBtn.enabled + text: "Batch Export — render every listed file's wafer map to a PNG in select folder" } + + AppToolTip { + visible: batchExportBtn.hovered && !batchExportBtn.enabled + text: batchExportController.busy + ? "Export already in progress…" + : "No files to export — load a folder with CSV files first" + } + } + } + + // ── Batch export progress / summary banner ────────────────────────────── + Rectangle { + visible: batchExportController.busy || root.exportSummary !== "" + Layout.fillWidth: true + height: exportBannerRow.implicitHeight + 12 + radius: Theme.radiusSm + color: Theme.cardBackground + border.color: Theme.statusWarningColor + border.width: 1 + + RowLayout { + id: exportBannerRow + anchors.fill: parent + anchors.margins: 6 + spacing: 6 + + Label { + text: batchExportController.busy ? "Exporting wafer maps…" : root.exportSummary + color: Theme.statusWarningColor + font.pixelSize: Theme.fontSm + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + Button { + visible: !batchExportController.busy + flat: true + implicitWidth: 20 + implicitHeight: 20 + onClicked: root.exportSummary = "" + background: Rectangle { color: "transparent" } + contentItem: Label { + text: "✕" + color: Theme.statusWarningColor + font.pixelSize: Theme.fontXs + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + } + } + } + + FolderDialog { + id: batchExportDirDialog + title: "Export Wafer Maps To…" + onAccepted: { + var dir = String(selectedFolder).replace(/^file:\/\//, ""); + var paths = file_browser.files.map(function(row) { return row.fileName; }); + batchExportController.start(paths, dir); } } diff --git a/src/pygui/ISC/Tabs/components/StreamControlPanel.qml b/src/pygui/ISC/Tabs/components/StreamControlPanel.qml index 187669d..b6cbf2d 100644 --- a/src/pygui/ISC/Tabs/components/StreamControlPanel.qml +++ b/src/pygui/ISC/Tabs/components/StreamControlPanel.qml @@ -280,8 +280,16 @@ Rectangle { opacity: enabled ? 1 : 0.4 onClicked: { - // Default into /Export (created on demand) - exportDialog.selectedFile = "file://" + deviceController.exportDir() + "/wafer_map.png" + // Default into /Export (created on demand), + // named after the loaded CSV so exports are traceable + // back to their source (falls back to a generic name for + // a live stream with no review file loaded). + var loaded = streamController.loadedFile + var baseName = loaded + ? loaded.split("/").pop().replace(/\.[^.]+$/, "") + : "wafer_map" + var ts = Qt.formatDateTime(new Date(), "yyyyMMdd_HHmmss") + exportDialog.selectedFile = "file://" + deviceController.exportDir() + "/" + baseName + "_" + ts + ".png" exportDialog.open() } diff --git a/src/pygui/ISC/Tabs/icons/images.svg b/src/pygui/ISC/Tabs/icons/images.svg new file mode 100644 index 0000000..02fb5a8 --- /dev/null +++ b/src/pygui/ISC/Tabs/icons/images.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/src/pygui/__main__.py b/src/pygui/__main__.py index 7631d62..72c5cae 100644 --- a/src/pygui/__main__.py +++ b/src/pygui/__main__.py @@ -7,6 +7,7 @@ from PySide6.QtQml import QQmlApplicationEngine from PySide6.QtQuickControls2 import QQuickStyle from PySide6.QtWidgets import QApplication +from pygui.backend.controllers.batch_export_controller import BatchExportController from pygui.backend.controllers.device_controller import DeviceController from pygui.backend.controllers.session_controller import SessionController from pygui.backend.data.constants import default_data_dir @@ -79,6 +80,10 @@ def main() -> int: stream_controller = SessionController() engine.rootContext().setContextProperty("streamController", stream_controller) + # ===== Batch Export Controller (SourcePanel batch PNG export) ===== + batch_export_controller = BatchExportController() + engine.rootContext().setContextProperty("batchExportController", batch_export_controller) + # ===== QML Startup ===== # The "ISC" QML module lives alongside this file (src/pygui/ISC), so the # package directory is the import path the engine searches for qmldir. diff --git a/src/pygui/backend/controllers/batch_export_controller.py b/src/pygui/backend/controllers/batch_export_controller.py new file mode 100644 index 0000000..1e295e3 --- /dev/null +++ b/src/pygui/backend/controllers/batch_export_controller.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import logging +import threading + +from PySide6.QtCore import Property, QObject, Qt, Signal, Slot + +from pygui.backend.utils import slot_error_boundary +from pygui.backend.wafer.batch_export import BatchExportResult, export_batch + +log = logging.getLogger(__name__) + + +class BatchExportController(QObject): + busyChanged = Signal() + exportFinished = Signal(str) + + _batchFinished = Signal(list) + + def __init__(self, parent: QObject | None = None) -> None: + super().__init__(parent) + self._busy = False + self._batchFinished.connect(self._handle_batch_finished, Qt.ConnectionType.QueuedConnection) + + @Property(bool, notify=busyChanged) + def busy(self) -> bool: + return self._busy + + @Slot("QVariantList", str) + def start(self, file_paths: list, output_dir: str) -> None: + if self._busy: + return + self._busy = True + self.busyChanged.emit() + threading.Thread( + target=self._worker, args=(list(file_paths), output_dir), daemon=True + ).start() + + def _worker(self, file_paths: list[str], output_dir: str) -> None: + try: + results = export_batch(file_paths, output_dir) + except Exception as exc: + log.exception("Batch export worker crashed: %s", exc) + results = [] + self._batchFinished.emit(results) + + @Slot(list) + @slot_error_boundary + def _handle_batch_finished(self, results: list[BatchExportResult]) -> None: + try: + total = len(results) + succeeded = sum(1 for r in results if r.success) + failed = total - succeeded + summary = ( + f"Exported {succeeded}/{total} — {failed} failed" + if failed + else f"Exported {succeeded}/{total}" + ) + self.exportFinished.emit(summary) + finally: + self._busy = False + self.busyChanged.emit() diff --git a/src/pygui/backend/wafer/batch_export.py b/src/pygui/backend/wafer/batch_export.py new file mode 100644 index 0000000..4cb616e --- /dev/null +++ b/src/pygui/backend/wafer/batch_export.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path + +from pygui.backend.data.data_records import ( + is_official_csv, + read_data_records, + read_official_csv, +) +from pygui.backend.models.frame_player import frames_from_wafer_data +from pygui.backend.visualization.wafer_map_item import WaferMapItem +from pygui.backend.wafer.zwafer_parser import ZWaferParser + +_EXPORT_SIZE = 600 + + +@dataclass +class BatchExportResult: + file_path: str + output_path: str + success: bool + error: str = "" + + +def export_batch( + file_paths: list[str], output_dir: str, timestamp: str | None = None +) -> list[BatchExportResult]: + """Render the last frame of each file to a PNG in output_dir. + + Skips files that fail to parse or render, continuing with the rest. + """ + ts = timestamp or datetime.now().strftime("%Y%m%d_%H%M%S") + results = [] + for file_path in file_paths: + results.append(_export_one(file_path, output_dir, ts)) + return results + + +def _export_one(file_path: str, output_dir: str, ts: str) -> BatchExportResult: + base_name = Path(file_path).stem + output_path = str(Path(output_dir) / f"{base_name}_{ts}.png") + try: + if is_official_csv(file_path): + sensors, records = read_official_csv(file_path) + else: + data, _ = ZWaferParser().parse(file_path) + if data is None or not data.sensors: + return BatchExportResult(file_path, output_path, False, "Invalid layout or missing sensors") + sensors = data.sensors + records = read_data_records(file_path) + + frames = frames_from_wafer_data(None, records) + if not sensors or not frames: + return BatchExportResult(file_path, output_path, False, "No sensors or frames found") + + item = WaferMapItem() + item.setWidth(_EXPORT_SIZE) + item.setHeight(_EXPORT_SIZE) + item.sensors = [{"label": s.label, "x": s.x, "y": s.y} for s in sensors] # type: ignore[method-assign] + item.values = frames[-1].values # type: ignore[method-assign] + + if not item.export_image(output_path): + return BatchExportResult(file_path, output_path, False, "Export render failed") + return BatchExportResult(file_path, output_path, True) + except Exception as exc: + return BatchExportResult(file_path, output_path, False, str(exc)) diff --git a/tests/test_batch_export.py b/tests/test_batch_export.py new file mode 100644 index 0000000..4f10c5b --- /dev/null +++ b/tests/test_batch_export.py @@ -0,0 +1,93 @@ +"""Tests for src/pygui/backend/wafer/batch_export.py.""" +import pytest + +pytestmark = pytest.mark.usefixtures("qapp") + + +def _write_single_frame_csv(path, wafer_id="F12"): + lines = [ + f"Wafer ID={wafer_id}", + "Acquisition Date=03/26/2025", + "Label,1,2,3", + "X (mm),0,10,20", + "Y (mm),0,10,20", + "data", + "0.0,149.0,150.0,151.0", + ] + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def test_export_batch_writes_png_for_valid_file(tmp_path): + from pygui.backend.wafer.batch_export import export_batch + + csv_path = tmp_path / "f_run.csv" + _write_single_frame_csv(csv_path) + out_dir = tmp_path / "out" + out_dir.mkdir() + + results = export_batch([str(csv_path)], str(out_dir), timestamp="20260101_000000") + + assert len(results) == 1 + assert results[0].success is True + expected_png = out_dir / "f_run_20260101_000000.png" + assert expected_png.exists() + assert results[0].output_path == str(expected_png) + + +def test_export_batch_skips_bad_file_and_continues(tmp_path): + from pygui.backend.wafer.batch_export import export_batch + + bad_csv = tmp_path / "corrupt.csv" + bad_csv.write_text("not a valid wafer csv\n", encoding="utf-8") + + good_csv = tmp_path / "f_run.csv" + _write_single_frame_csv(good_csv) + + out_dir = tmp_path / "out" + out_dir.mkdir() + + results = export_batch( + [str(bad_csv), str(good_csv)], str(out_dir), timestamp="20260101_000000" + ) + + assert len(results) == 2 + assert results[0].success is False + assert results[0].error != "" + assert not (out_dir / "corrupt_20260101_000000.png").exists() + + assert results[1].success is True + assert (out_dir / "f_run_20260101_000000.png").exists() + + +def _write_two_frame_csv(path, wafer_id="F12"): + lines = [ + f"Wafer ID={wafer_id}", + "Acquisition Date=03/26/2025", + "Label,1,2,3", + "X (mm),0,10,20", + "Y (mm),0,10,20", + "data", + "0.0,100.0,101.0,102.0", + "1.0,149.0,150.0,151.0", + ] + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def test_export_batch_renders_last_frame_of_multiframe_file(tmp_path): + from pygui.backend.wafer.batch_export import export_batch + + multi_csv = tmp_path / "multi.csv" + _write_two_frame_csv(multi_csv) + + single_csv = tmp_path / "single.csv" + _write_single_frame_csv(single_csv) # same values as multi's 2nd (last) row + + out_dir = tmp_path / "out" + out_dir.mkdir() + + export_batch([str(multi_csv)], str(out_dir), timestamp="20260101_000000") + export_batch([str(single_csv)], str(out_dir), timestamp="20260101_000000") + + multi_png = (out_dir / "multi_20260101_000000.png").read_bytes() + single_png = (out_dir / "single_20260101_000000.png").read_bytes() + assert multi_png == single_png diff --git a/tests/test_batch_export_controller.py b/tests/test_batch_export_controller.py new file mode 100644 index 0000000..492b5d1 --- /dev/null +++ b/tests/test_batch_export_controller.py @@ -0,0 +1,59 @@ +"""Tests for src/pygui/backend/controllers/batch_export_controller.py.""" +from unittest.mock import MagicMock, patch + +import pytest + +from pygui.backend.controllers.batch_export_controller import BatchExportController +from pygui.backend.wafer.batch_export import BatchExportResult + +pytestmark = pytest.mark.usefixtures("qapp") + + +@pytest.fixture +def controller(): + return BatchExportController() + + +def test_start_spawns_thread_and_sets_busy(controller): + with patch("threading.Thread") as mock_thread: + controller.start(["a.csv", "b.csv"], "/tmp/out") + mock_thread.assert_called_once() + assert controller.busy is True + + +def test_start_ignores_call_while_already_busy(controller): + with patch("threading.Thread") as mock_thread: + controller.start(["a.csv"], "/tmp/out") + controller.start(["b.csv"], "/tmp/out") + mock_thread.assert_called_once() + + +def test_handle_batch_finished_reports_all_succeeded(controller): + finished = MagicMock() + controller.exportFinished.connect(finished) + controller._busy = True + + results = [ + BatchExportResult("a.csv", "a.png", True), + BatchExportResult("b.csv", "b.png", True), + BatchExportResult("c.csv", "c.png", True), + ] + controller._handle_batch_finished(results) + + finished.assert_called_once_with("Exported 3/3") + assert controller.busy is False + + +def test_handle_batch_finished_reports_failures(controller): + finished = MagicMock() + controller.exportFinished.connect(finished) + controller._busy = True + + results = [ + BatchExportResult("a.csv", "a.png", True), + BatchExportResult("b.csv", "b.png", False, "parse error"), + ] + controller._handle_batch_finished(results) + + finished.assert_called_once_with("Exported 1/2 — 1 failed") + assert controller.busy is False