feat(import): Z-wafer CSV import button with result banner

- 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
This commit is contained in:
jack
2026-07-12 16:54:24 -07:00
parent 7d98759b43
commit 6003bde84d
8 changed files with 363 additions and 37 deletions
+52 -11
View File
@@ -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 {
@@ -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…"
+15
View File
@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 3v12" />
<path d="m8 11 4 4 4-4" />
<path d="M8 5H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-4" />
</svg>

After

Width:  |  Height:  |  Size: 350 B

+61
View File
@@ -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 = {
@@ -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
+27
View File
@@ -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."""