From 6003bde84df81606a3f659258627595bd86891dc Mon Sep 17 00:00:00 2001 From: jack Date: Sun, 12 Jul 2026 16:54:24 -0700 Subject: [PATCH] feat(import): Z-wafer CSV import button with result banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - importZWaferCsv slot on FileBrowser — converts and adds to list - SourcePanel: import icon button → FileDialog → importSummary banner (auto-dismisses after 5s, loads the converted file into streamController) - wafer_map_item: showExtremes default → true --- CONTEXT.md | 25 ---- .../ISC/Tabs/components/ReadoutPanel.qml | 63 ++++++++-- src/pygui/ISC/Tabs/components/SourcePanel.qml | 88 +++++++++++++ src/pygui/ISC/Tabs/icons/import.svg | 15 +++ src/pygui/backend/data/file_browser.py | 61 +++++++++ .../backend/visualization/wafer_map_item.py | 2 +- src/pygui/backend/wafer/zwafer_parser.py | 27 ++++ tests/test_file_browser.py | 119 ++++++++++++++++++ 8 files changed, 363 insertions(+), 37 deletions(-) delete mode 100644 CONTEXT.md create mode 100644 src/pygui/ISC/Tabs/icons/import.svg diff --git a/CONTEXT.md b/CONTEXT.md deleted file mode 100644 index 54fe9d8..0000000 --- a/CONTEXT.md +++ /dev/null @@ -1,25 +0,0 @@ -# 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/ReadoutPanel.qml b/src/pygui/ISC/Tabs/components/ReadoutPanel.qml index 9366fd8..db1df8f 100644 --- a/src/pygui/ISC/Tabs/components/ReadoutPanel.qml +++ b/src/pygui/ISC/Tabs/components/ReadoutPanel.qml @@ -1,5 +1,6 @@ import QtQuick import QtQuick.Controls +import QtQuick.Controls.impl import QtQuick.Dialogs import QtQuick.Layouts import ISC @@ -146,16 +147,54 @@ ColumnLayout { SectionTitle { text: "DISPLAY" } - PanelCheckBox { - id: thicknessToggle - text: "Show Thickness" - checked: false - font.pixelSize: Theme.fontSm - // First check with no data prompts for the customer CSV; - // unchecking only hides the overlay, data stays loaded. - onToggled: { - if (checked && !root.hasThicknessData) - thicknessDialog.open(); + RowLayout { + Layout.fillWidth: true + spacing: 4 + + PanelCheckBox { + id: thicknessToggle + text: "Show Thickness" + checked: false + font.pixelSize: Theme.fontSm + hoverEnabled: true + // First check with no data prompts for the customer CSV; + // unchecking only hides the overlay, data stays loaded. + onToggled: { + if (checked && !root.hasThicknessData) + thicknessDialog.open(); + } + + AppToolTip { + visible: thicknessToggle.hovered + text: "Overlays customer-provided wafer thickness data\nto check if temperature spots line up with\nphysical thickness variation." + } + } + + Item { Layout.fillWidth: true } + + Button { + id: changeThicknessFileBtn + visible: root.hasThicknessData + implicitWidth: 26 + implicitHeight: 26 + hoverEnabled: true + background: Rectangle { + color: changeThicknessFileBtn.pressed ? Theme.buttonPressed + : changeThicknessFileBtn.hovered ? Theme.buttonNeutralHover : "transparent" + radius: Theme.radiusSm + } + contentItem: IconImage { + source: "../icons/folder.svg" + sourceSize.width: 16 + sourceSize.height: 16 + color: Theme.bodyColor + } + onClicked: thicknessDialog.open() + + AppToolTip { + visible: changeThicknessFileBtn.hovered + text: "Load a different thickness file" + } } } @@ -164,7 +203,9 @@ ColumnLayout { title: "Select Thickness Data File" nameFilters: ["CSV files (*.csv)"] onAccepted: root.thicknessFileChosen(String(selectedFile).replace(/^file:\/\//, "")) - onRejected: thicknessToggle.checked = false + // Only uncheck on cancel if there was nothing loaded yet — + // canceling a file swap shouldn't hide an already-visible overlay. + onRejected: if (!root.hasThicknessData) thicknessToggle.checked = false } PanelCheckBox { diff --git a/src/pygui/ISC/Tabs/components/SourcePanel.qml b/src/pygui/ISC/Tabs/components/SourcePanel.qml index bf8d0e1..5df2bac 100644 --- a/src/pygui/ISC/Tabs/components/SourcePanel.qml +++ b/src/pygui/ISC/Tabs/components/SourcePanel.qml @@ -11,6 +11,7 @@ ColumnLayout { spacing: 6 property string exportSummary: "" + property string importSummary: "" Connections { target: batchExportController @@ -20,12 +21,28 @@ ColumnLayout { } } + Connections { + target: file_browser + function onImportFinished(path, message) { + root.importSummary = message + importBannerHideTimer.restart() + if (path !== "") + streamController.loadFile(path) + } + } + Timer { id: exportBannerHideTimer interval: 5000 onTriggered: root.exportSummary = "" } + Timer { + id: importBannerHideTimer + interval: 5000 + onTriggered: root.importSummary = "" + } + // ── Section label & Refresh ─────────────────────────────────────────── RowLayout { Layout.fillWidth: true @@ -93,6 +110,29 @@ ColumnLayout { } } + Button { + id: importBtn + implicitWidth: 28 + implicitHeight: 28 + hoverEnabled: true + background: Rectangle { + color: importBtn.pressed ? Theme.buttonPressed : importBtn.hovered ? Theme.buttonNeutralHover : "transparent" + radius: Theme.radiusSm + } + contentItem: IconImage { + source: "../icons/import.svg" + sourceSize.width: 16 + sourceSize.height: 16 + color: Theme.bodyColor + } + onClicked: importFileDialog.open() + + AppToolTip { + visible: importBtn.hovered + text: "Import Data — bring in a raw Z-wafer export CSV" + } + } + Item { implicitWidth: 8 } Button { @@ -170,6 +210,54 @@ ColumnLayout { } } + // ── Import summary banner ───────────────────────────────────────────── + Rectangle { + visible: root.importSummary !== "" + Layout.fillWidth: true + height: importBannerRow.implicitHeight + 12 + radius: Theme.radiusSm + color: Theme.cardBackground + border.color: Theme.statusWarningColor + border.width: 1 + + RowLayout { + id: importBannerRow + anchors.fill: parent + anchors.margins: 6 + spacing: 6 + + Label { + text: root.importSummary + color: Theme.statusWarningColor + font.pixelSize: Theme.fontSm + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + Button { + flat: true + implicitWidth: 20 + implicitHeight: 20 + onClicked: root.importSummary = "" + background: Rectangle { color: "transparent" } + contentItem: Label { + text: "✕" + color: Theme.statusWarningColor + font.pixelSize: Theme.fontXs + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + } + } + } + + FileDialog { + id: importFileDialog + title: "Select Data File To Import" + nameFilters: ["CSV files (*.csv)"] + onAccepted: file_browser.importZWaferCsv(String(selectedFile).replace(/^file:\/\//, "")) + } + FolderDialog { id: batchExportDirDialog title: "Export Wafer Maps To…" diff --git a/src/pygui/ISC/Tabs/icons/import.svg b/src/pygui/ISC/Tabs/icons/import.svg new file mode 100644 index 0000000..bcc91e7 --- /dev/null +++ b/src/pygui/ISC/Tabs/icons/import.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/src/pygui/backend/data/file_browser.py b/src/pygui/backend/data/file_browser.py index f438e7e..372d49d 100644 --- a/src/pygui/backend/data/file_browser.py +++ b/src/pygui/backend/data/file_browser.py @@ -18,6 +18,7 @@ class FileBrowser(QObject): # ===== Change Signals ===== filesChanged = Signal() currentDirectoryChanged = Signal() + importFinished = Signal(str, str) # (new file path, or "" on failure; message) # ===== Lifecycle ===== def __init__(self, parent: QObject | None = None) -> None: @@ -110,6 +111,66 @@ class FileBrowser(QObject): self.filesChanged.emit() self._show_info(f"Saved metadata for:\n{csv_path.name}") + # ===== Z-Wafer Import ===== + @Slot(str) + def importZWaferCsv(self, file_path: str) -> None: + """Port of C#'s ImportZWaferCSV (Form1.cs:1711): parse a raw Z-wafer + export, strip its leading row-index column, and write it into the + current directory as a normalized `{serial}-{timestamp}.csv`. + + The timestamp is the source file's own acquisition date, not "now" — + deliberately, so re-importing the same source collides on purpose. + No fallback to wall-clock time: a file with no parseable acquisition + date still gets a deterministic (if degenerate) filename, so the + duplicate-import guard below still fires on a second attempt. + """ + source_path = Path(file_path) + parser_data, _ = self._parser.parse(file_path) + if parser_data is None: + self.importFinished.emit("", f"Failed to parse CSV:\n{source_path.name}") + return + + serial = parser_data.serial or "UNKNOWN" + new_filename = f"{serial}-{parser_data.date.strftime('%Y%m%d_%H%M%S')}.csv" + new_path = self._current_directory / new_filename + + if new_path.exists(): + self.importFinished.emit("", f"Already imported to:\n{new_filename}") + return + + try: + data_rows = self._parser.parse_data_rows(file_path) + except Exception as exc: + self.importFinished.emit("", f"Failed to read data rows:\n{exc}") + return + + try: + self._current_directory.mkdir(parents=True, exist_ok=True) + lines = [",".join(parser_data.csv_headers), *data_rows] + new_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + except Exception as exc: + self.importFinished.emit("", f"Failed to write imported file:\n{exc}") + return + + metadata = CSVFileMetadata( + wafer=serial, + date=CSVFileMetadata.format_date(parser_data.date), + chamber=parser_data.header.get("Chamber", ""), + notes=parser_data.header.get("Comments", ""), + filename=str(new_path), + ) + try: + self._sidecar_path(new_path).write_text( + json.dumps(metadata.to_dict(), indent=4), encoding="utf-8" + ) + except Exception as exc: # pragma: no cover - defensive UI path + self._refresh_files(show_empty_message=False) + self.importFinished.emit(str(new_path), f"Imported, but failed to save metadata:\n{exc}") + return + + self._refresh_files(show_empty_message=False) + self.importFinished.emit(str(new_path), f"Imported {new_filename}") + # ===== Internal Data Loading ===== def _refresh_files(self, show_empty_message: bool) -> None: selected_state = { diff --git a/src/pygui/backend/visualization/wafer_map_item.py b/src/pygui/backend/visualization/wafer_map_item.py index a07f2b7..a44bb61 100644 --- a/src/pygui/backend/visualization/wafer_map_item.py +++ b/src/pygui/backend/visualization/wafer_map_item.py @@ -124,7 +124,7 @@ class WaferMapItem(QQuickPaintedItem): self._margin: float = 1.0 self._blend: float = 0.0 self._show_labels: bool = True - self._show_extremes: bool = False + self._show_extremes: bool = True self._shape: str = "round" self._size: float = 300.0 self._thickness_data: list[list[float]] = [] # [x_mm, y_mm, t2] triples diff --git a/src/pygui/backend/wafer/zwafer_parser.py b/src/pygui/backend/wafer/zwafer_parser.py index d879a80..2291ac0 100644 --- a/src/pygui/backend/wafer/zwafer_parser.py +++ b/src/pygui/backend/wafer/zwafer_parser.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from datetime import datetime from pathlib import Path from typing import Optional, Tuple @@ -74,6 +76,31 @@ class ZWaferParser: return wafer_data # Incomplete file — return partial data + # ===== Data Row Extraction ===== + def parse_data_rows(self, file_path: str) -> list[str]: + """Return data rows after the 'data' marker, with the leading + row-index column stripped (each row realigned to `csv_headers`). + + Uses the same line normalization as `_process_header` so the split + point matches exactly what header parsing used. + """ + rows: list[str] = [] + seen_data_marker = False + with Path(file_path).open("r", encoding="utf-8") as f: + for raw_line in f: + line = raw_line.rstrip().rstrip(",").strip() + if not line or line.replace(",", "").strip() == "": + continue + + parts = [p.strip() for p in line.split(",")] + if not seen_data_marker: + if parts[0].lower() == "data": + seen_data_marker = True + continue + + rows.append(",".join(parts[1:])) + return rows + # ===== Metadata Parsing ===== def _parse_header_line(self, wafer_data: ZWaferData, parts: list) -> bool: """Parse key=value pairs from header line. Returns True if handled.""" diff --git a/tests/test_file_browser.py b/tests/test_file_browser.py index f8c3875..8d7a7fd 100644 --- a/tests/test_file_browser.py +++ b/tests/test_file_browser.py @@ -1,3 +1,4 @@ +import json from pathlib import Path from pygui.backend.data.file_browser import FileBrowser @@ -15,6 +16,24 @@ def _write_csv(path, wafer_id="P00001"): ) +def _write_raw_zwafer_export(path, wafer_id="P00001", chamber="Chamber A", comments="test run"): + """A raw fixture-export CSV, as ZWaferParser/ImportZWaferCSV expect it: + row-indexed data rows (leading column dropped on import), unlike the + already-normalized `{serial}-{timestamp}.csv` files _write_csv produces.""" + path.write_text( + f"Wafer ID={wafer_id}\n" + "Acquisition Date=03/26/2025\n" + f"Chamber={chamber}\n" + f"Comments={comments}\n" + "Label,1,2,3\n" + "X (mm),0,10,-10\n" + "Y (mm),10,-10,-10\n" + "data\n" + "0.0,149.0,148.5,150.6\n" + "1.0,149.2,148.7,150.9\n" + ) + + def test_recorded_live_file_flagged_as_recording(qapp, tmp_path): """Files named live__.csv (SessionController.startRecording) must be distinguishable in the browser from read-memory dumps.""" @@ -41,3 +60,103 @@ def test_set_current_directory_updates_dir_and_refreshes(qapp, tmp_path): assert browser.currentDirectory == str(tmp_path) assert len(browser.files) == 1 assert Path(browser.files[0]["fileName"]).name == "P00003-20260706_143500.csv" + + +def test_import_zwafer_csv_writes_normalized_file_and_sidecar(qapp, tmp_path): + """Mirrors C#'s ImportZWaferCSV (Form1.cs:1711): strip the leading row-index + column, rename to {serial}-{timestamp}.csv (no Z prefix — matches this app's + other write paths), write a metadata sidecar, and refresh the file list.""" + source_dir = tmp_path / "incoming" + source_dir.mkdir() + source = source_dir / "raw_export.csv" + _write_raw_zwafer_export(source) + + browser = FileBrowser() + browser._set_current_directory(tmp_path) + + signals = [] + browser.importFinished.connect(lambda path, message: signals.append((path, message))) + browser.importZWaferCsv(str(source)) + + new_path = tmp_path / "P00001-20250326_000000.csv" + assert new_path.exists() + assert new_path.read_text().splitlines() == [ + "1,2,3", + "149.0,148.5,150.6", + "149.2,148.7,150.9", + ] + + sidecar = json.loads((tmp_path / "P00001-20250326_000000.csv.json").read_text()) + assert sidecar["wafer"] == "P00001" + assert sidecar["chamber"] == "Chamber A" + assert sidecar["notes"] == "test run" + + assert len(signals) == 1 + assert signals[0][0] == str(new_path) + assert any(Path(row["fileName"]).name == new_path.name for row in browser.files) + + +def test_import_zwafer_csv_blocks_reimporting_same_source(qapp, tmp_path): + """Re-importing the identical source file must be blocked, not overwritten — + the output filename is derived from the source's own acquisition-date + header, so a second import of the same file collides on purpose.""" + source_dir = tmp_path / "incoming" + source_dir.mkdir() + source = source_dir / "raw_export.csv" + _write_raw_zwafer_export(source) + + browser = FileBrowser() + browser._set_current_directory(tmp_path) + + signals = [] + browser.importFinished.connect(lambda path, message: signals.append((path, message))) + + browser.importZWaferCsv(str(source)) + browser.importZWaferCsv(str(source)) + + assert len(signals) == 2 + assert signals[1][0] == "" + assert "already" in signals[1][1].lower() + + +def test_import_zwafer_csv_without_date_header_still_blocks_duplicate(qapp, tmp_path): + """No fallback to wall-clock time: a source missing 'Acquisition Date=' + must still get a deterministic filename, so re-importing it a second + time is blocked instead of silently succeeding with a fresh timestamp.""" + source_dir = tmp_path / "incoming" + source_dir.mkdir() + source = source_dir / "raw_export.csv" + source.write_text( + "Wafer ID=P00002\n" + "Label,1,2,3\n" + "X (mm),0,10,-10\n" + "Y (mm),10,-10,-10\n" + "data\n" + "0.0,149.0,148.5,150.6\n" + ) + + browser = FileBrowser() + browser._set_current_directory(tmp_path) + + signals = [] + browser.importFinished.connect(lambda path, message: signals.append((path, message))) + + browser.importZWaferCsv(str(source)) + browser.importZWaferCsv(str(source)) + + assert len(signals) == 2 + assert signals[0][0] != "" # first import succeeds + assert signals[1][0] == "" # second is blocked + assert "already" in signals[1][1].lower() + + +def test_import_zwafer_csv_reports_missing_source(qapp, tmp_path): + browser = FileBrowser() + browser._set_current_directory(tmp_path) + + signals = [] + browser.importFinished.connect(lambda path, message: signals.append((path, message))) + browser.importZWaferCsv(str(tmp_path / "missing.csv")) + + assert len(signals) == 1 + assert signals[0][0] == ""