Files
pyGUI/src/pygui/backend/file_browser.py
T
jack ca1a514f23 feat: Wafer Map integration
## Backend — Session & playback
- Add SessionController (QML-facing live/review controller) with
  loadedFile property for active-file highlight binding
- Add SessionModel, FramePlayer, and CsvRecorder for state machine,
  frame seek/step/speed, and CSV recording
- Add SensorEditor for per-sensor value overrides (replace + offset)
2026-06-11 11:55:34 -07:00

287 lines
10 KiB
Python

from __future__ import annotations
import json
import re
from datetime import datetime
from pathlib import Path
from PySide6.QtCore import QObject, Property, QStandardPaths, Signal, Slot
from PySide6.QtWidgets import QFileDialog, QMessageBox
from pygui.backend.csv_file_metadata import CSVFileMetadata
from pygui.backend.zwafer_parser import ZWaferParser
# ===== File Browser Model =====
class FileBrowser(QObject):
# ===== Change Signals =====
filesChanged = Signal()
currentDirectoryChanged = Signal()
# ===== Lifecycle =====
def __init__(self, parent: QObject | None = None) -> None:
super().__init__(parent)
self._parser = ZWaferParser()
self._files: list[dict[str, object]] = []
self._current_directory = self._resolve_default_directory()
self._refresh_files(show_empty_message=False)
# ===== Exposed Properties =====
@Property("QVariantList", notify=filesChanged)
def files(self) -> list[dict[str, object]]:
return [dict(row) for row in self._files]
@Property(str, notify=currentDirectoryChanged)
def currentDirectory(self) -> str:
return str(self._current_directory)
# ===== Directory Selection =====
@Slot()
def chooseDirectory(self) -> None:
selected_dir = QFileDialog.getExistingDirectory(
None,
"Select CSV Folder",
self.currentDirectory,
)
if not selected_dir:
return
self._set_current_directory(Path(selected_dir))
self._refresh_files(show_empty_message=True)
@Slot()
def refreshFiles(self) -> None:
self._refresh_files(show_empty_message=False)
# ===== Metadata Persistence =====
@Slot(str, str, str, str, str, bool, str)
def saveMetadata(
self,
file_path: str,
wafer: str,
date: str,
chamber: str,
notes: str,
selected: bool,
master_type: str = "",
) -> None:
csv_path = Path(file_path)
if not csv_path.exists():
self._show_error(f"CSV file was not found:\n{file_path}")
return
row = self._find_row(file_path)
if row is None:
self._show_error("The selected CSV row is no longer available.")
return
row["wafer"] = wafer
row["date"] = date
row["chamber"] = chamber
row["notes"] = notes
row["selected"] = selected
row["masterType"] = master_type
payload = {
"wafer": wafer,
"date": date,
"chamber": chamber,
"notes": notes,
"masterType": master_type,
}
try:
sidecar_path = self._sidecar_path(csv_path)
sidecar_path.write_text(json.dumps(payload, indent=4), encoding="utf-8")
except Exception as exc: # pragma: no cover - defensive UI path
self._show_error(f"Failed to save metadata:\n{exc}")
return
self.filesChanged.emit()
self._show_info(f"Saved metadata for:\n{csv_path.name}")
# ===== Internal Data Loading =====
def _refresh_files(self, show_empty_message: bool) -> None:
selected_state = {
str(row.get("fileName", "")): bool(row.get("selected", False))
for row in self._files
}
master_state = {
str(row.get("fileName", "")): row.get("masterType", "") or ""
for row in self._files
}
self._files = []
if not self._current_directory.exists():
self.filesChanged.emit()
return
for csv_path in sorted(self._current_directory.glob("*.csv")):
try:
metadata = self._load_metadata(csv_path)
wafer_type = metadata.get_wafer_type().upper() or (csv_path.stem[0].upper() if csv_path.stem else "")
date_str = metadata.string_date_format()
serial = metadata.wafer[1:] if len(metadata.wafer) > 1 else ""
time_str = date_str[11:] if len(date_str) > 10 else ""
self._files.append(
{
"selected": selected_state.get(str(csv_path), False),
"baseName": csv_path.stem,
"wafer": metadata.wafer,
"waferType": wafer_type,
"serialNumber": serial,
"date": date_str,
"timeStr": time_str,
"chamber": metadata.chamber,
"notes": metadata.notes,
"masterType": master_state.get(str(csv_path), "") or metadata.master_type,
"fileName": str(csv_path),
"highlight": wafer_type in {"A", "B", "C"},
}
)
except Exception:
stem = csv_path.stem
self._files.append(
{
"selected": selected_state.get(str(csv_path), False),
"baseName": stem,
"wafer": stem,
"waferType": stem[0].upper() if stem else "",
"serialNumber": stem[1:] if len(stem) > 1 else "",
"date": "",
"timeStr": "",
"chamber": "",
"notes": "Unable to parse metadata",
"masterType": master_state.get(str(csv_path), ""),
"fileName": str(csv_path),
"highlight": False,
}
)
self.filesChanged.emit()
if show_empty_message and not self._files:
self._show_info("No CSV files were found in the selected folder.")
def _load_metadata(self, csv_path: Path) -> CSVFileMetadata:
sidecar_path = self._sidecar_path(csv_path)
if sidecar_path.exists():
try:
payload = json.loads(sidecar_path.read_text(encoding="utf-8"))
return CSVFileMetadata(
wafer=str(payload.get("wafer", "")),
date=str(payload.get("date", "")),
chamber=str(payload.get("chamber", "")),
notes=str(payload.get("notes", "")),
master_type=str(payload.get("masterType", "")),
filename=str(csv_path),
)
except Exception:
# Fall back to CSV/header parsing if sidecar is malformed.
pass
parser_data, _ = self._parser.parse(str(csv_path))
if parser_data is not None:
wafer = parser_data.serial or ""
date_text = ""
if parser_data.date != datetime.min:
date_text = CSVFileMetadata.format_date(parser_data.date)
return CSVFileMetadata(
wafer=wafer,
date=date_text,
chamber="",
notes="",
filename=str(csv_path),
)
return self._metadata_from_filename(csv_path)
def _metadata_from_filename(self, csv_path: Path) -> CSVFileMetadata:
match = re.match(
r"^(?P<wafer>[^-]+)-(?P<date>\d{8})(?:_(?P<time>\d{6}))?$",
csv_path.stem,
)
if not match:
return CSVFileMetadata(filename=str(csv_path))
date_text = ""
try:
date_part = match.group("date")
time_part = match.group("time") or "000000"
parsed = datetime.strptime(date_part + time_part, "%Y%m%d%H%M%S")
date_text = CSVFileMetadata.format_date(parsed)
except ValueError:
date_text = ""
return CSVFileMetadata(
wafer=match.group("wafer"),
date=date_text,
filename=str(csv_path),
)
# ===== Internal Helpers =====
def _resolve_default_directory(self) -> Path:
documents_dir = QStandardPaths.writableLocation(
QStandardPaths.DocumentsLocation
)
base_dir = Path(documents_dir) if documents_dir else (Path.home() / "Documents")
return base_dir / "isc_data"
def _set_current_directory(self, directory: Path) -> None:
normalized = Path(directory)
if normalized == self._current_directory:
return
self._current_directory = normalized
self.currentDirectoryChanged.emit()
def _find_row(self, file_path: str) -> dict[str, object] | None:
for row in self._files:
if str(row.get("fileName", "")) == file_path:
return row
return None
def _sidecar_path(self, csv_path: Path) -> Path:
return csv_path.with_suffix(f"{csv_path.suffix}.json")
def _show_error(self, message: str) -> None:
QMessageBox.critical(None, "iSenseCloud", message)
@Slot(str)
def showInfo(self, message: str) -> None:
self._show_info(message)
@Slot(str, result=dict)
def parseCsvMetadata(self, file_path: str) -> dict:
from pathlib import Path as _Path
csv_path = _Path(file_path)
if not csv_path.exists():
return {"success": False, "error": "File not found"}
parser_data, _ = self._parser.parse(file_path)
if parser_data is None:
return {"success": False, "error": "Failed to parse CSV"}
wafer = parser_data.serial or ""
date_text = ""
if parser_data.date != datetime.min:
date_text = CSVFileMetadata.format_date(parser_data.date)
columns = len(parser_data.csv_headers) if parser_data.csv_headers else 0
family_code = wafer[0].upper() if wafer else ""
return {
"success": True,
"wafer": wafer,
"date": date_text,
"columns": columns,
"familyCode": family_code,
}
def _show_info(self, message: str) -> None:
QMessageBox.information(None, "iSenseCloud", message)
# Backward-compatible alias while the QML dialog is still named SelectFileDialog.
SelectFileDialogModel = FileBrowser