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:
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user