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] == ""