refactor: reorganize backend modules into sub-packages for models, data, visualization, wafer, and controllers

This commit is contained in:
jack
2026-06-11 12:15:00 -07:00
parent b9f8032203
commit 72334795da
47 changed files with 155 additions and 60 deletions
+10
View File
@@ -0,0 +1,10 @@
# ===== Wafer Sub-package =====
from pygui.backend.wafer.zwafer_models import ZWaferData, Sensor, DataRecord
from pygui.backend.wafer.zwafer_parser import ZWaferParser
from pygui.backend.wafer.wafer_layouts import load_layout, available_families
__all__ = [
"ZWaferData", "Sensor", "DataRecord",
"ZWaferParser",
"load_layout", "available_families",
]
+79
View File
@@ -0,0 +1,79 @@
"""Load wafer sensor layouts from bundled YAML files.
YAML schema mirrors the replay app's wafer_desc.py format:
X/Y — sensor positions in mm relative to x_origin/y_origin
size — wafer diameter/edge in mm
shape — "round" or "square"
start_sn — first sensor number (usually 1)
x_origin — "left" | "right" | "center"
y_origin — "bottom" | "top" | "center"
Returned Sensor coords are center-origin mm (negative values toward the edge).
"""
from __future__ import annotations
from pathlib import Path
import yaml
from pygui.backend.wafer.zwafer_models import Sensor
_LAYOUTS_DIR = Path(__file__).parent.parent.parent / "assets" / "layouts"
def _family_name(raw_name: str) -> str:
return raw_name.replace("wafer", "").strip("_")
def _load_yaml(path: Path) -> dict:
with path.open(encoding="utf-8") as f:
return yaml.safe_load(f)
def available_families() -> list[str]:
return [_family_name(_load_yaml(p)["name"]) for p in _LAYOUTS_DIR.glob("*.yaml")]
def load_layout(family: str) -> list[Sensor]:
for path in _LAYOUTS_DIR.glob("*.yaml"):
data = _load_yaml(path)
if _family_name(data["name"]) == family:
return _to_sensors(data)
raise KeyError(f"Unknown wafer family: {family!r}")
def load_layout_for_wafer_id(wafer_id: str) -> list[Sensor]:
"""Match 'B00108' → bcdwafer by looking up the first char in each YAML's 'wafers' list."""
prefix = wafer_id[0].upper() if wafer_id else ""
for path in _LAYOUTS_DIR.glob("*.yaml"):
data = _load_yaml(path)
if prefix in data.get("wafers", []):
return _to_sensors(data)
raise KeyError(f"No layout found for wafer ID prefix {prefix!r}")
def _to_sensors(data: dict) -> list[Sensor]:
xs: list[float] = data["X"]
ys: list[float] = data["Y"]
size: float = float(data["size"])
start_sn: int = data.get("start_sn", 1)
reverse_x: bool = data.get("reverse_x", False)
reverse_y: bool = data.get("reverse_y", False)
x_orig: str = data.get("x_origin", "left")
y_orig: str = data.get("y_origin", "bottom")
x_shift = {"left": size / 2, "right": -(size / 2), "center": 0.0}.get(x_orig, 0.0)
y_shift = {"bottom": size / 2, "top": -(size / 2), "center": 0.0}.get(y_orig, 0.0)
sensors: list[Sensor] = []
for i, (x_mm, y_mm) in enumerate(zip(xs, ys)):
if reverse_x:
x_mm = -x_mm
if reverse_y:
y_mm = -y_mm
sensors.append(Sensor(
label=str(start_sn + i),
x=x_mm - x_shift,
y=y_mm - y_shift,
))
return sensors
+28
View File
@@ -0,0 +1,28 @@
from dataclasses import dataclass, field
from datetime import datetime
from typing import Dict, List, Optional
# ===== Sensor Geometry =====
@dataclass
class Sensor:
label: str
x: float
y: float
# ===== Data Row =====
@dataclass
class DataRecord:
time: float
values: List[float] = field(default_factory=list)
# ===== Parsed Z-Wafer Container =====
@dataclass
class ZWaferData:
date: datetime = field(default_factory=lambda: datetime.min)
serial: Optional[str] = None
header: Dict[str, str] = field(default_factory=dict)
sensors: List[Sensor] = field(default_factory=list)
csv_headers: List[str] = field(default_factory=list)
+137
View File
@@ -0,0 +1,137 @@
from pathlib import Path
from typing import Tuple, Optional
from datetime import datetime, timedelta
from pygui.backend.wafer.zwafer_models import ZWaferData, Sensor
# ===== Z-Wafer CSV Parser =====
class ZWaferParser:
"""Parses Z-wafer CSV files (header + sensor layout + data rows)."""
# ===== Public Parse API =====
def parse(self, file_path: str) -> Tuple[Optional[ZWaferData], Optional[Path]]:
"""
Parse a Z-wafer file.
Returns:
(ZWaferData, Path) on success
(None, None) on error (e.g., file not found)
"""
try:
path = Path(file_path)
if not path.exists():
raise FileNotFoundError(f"File not found: {file_path}")
with path.open("r", encoding="utf-8") as f:
return self._process_header(f), path
except (ValueError, KeyError):
raise
except Exception:
return None, None
# ===== Header Parsing =====
def _process_header(self, file_obj) -> ZWaferData:
"""Parse header and sensor layout from open file object.
Handles two CSV row orderings:
1. Label/X/Y rows BEFORE 'data' (fixture test format)
2. 'data' row BEFORE Label/X/Y (some real CSV files)
The C# parser handles both by collecting sensor layout rows
regardless of whether 'data' has been seen.
"""
wafer_data = ZWaferData()
labels: Optional[list] = None
x_coords: Optional[list] = None
y_coords: Optional[list] = None
for line in file_obj:
# Strip trailing comma + whitespace
line = line.rstrip().rstrip(",").strip()
if not line or line.replace(",", "").strip() == "":
continue
parts = [p.strip() for p in line.split(",")]
first_part = parts[0].lower()
# Collect sensor layout rows (Label, X (mm), Y (mm))
# regardless of whether 'data' has been seen.
# This matches the C# parser behavior.
if first_part == "label":
labels = parts[1:]
elif first_part == "x (mm)":
x_coords = parts[1:]
elif first_part == "y (mm)":
y_coords = parts[1:]
# Parse metadata (key=value pairs) — only when not a sensor layout row
elif first_part != "data":
self._parse_header_line(wafer_data, parts)
# Detect end of header section
if first_part == "data":
wafer_data.csv_headers = labels or []
self._build_sensor_layout(wafer_data, labels, x_coords, y_coords)
return wafer_data
return wafer_data # Incomplete file — return partial data
# ===== Metadata Parsing =====
def _parse_header_line(self, wafer_data: ZWaferData, parts: list) -> bool:
"""Parse key=value pairs from header line. Returns True if handled."""
non_empty_parts = [p for p in parts if p]
if not non_empty_parts:
return False
found_kv = False
for part in non_empty_parts:
eq_idx = part.find("=")
if eq_idx < 0:
continue
key = part[:eq_idx].strip()
value = part[eq_idx + 1 :].strip('=" ')
found_kv = True
wafer_data.header[key] = value
# Extract special fields
if key.lower() == "acquisition date":
try:
from datetime import datetime as dt
wafer_data.date = dt.strptime(value, "%m/%d/%Y")
except ValueError:
wafer_data.date = datetime.min # Fallback on parse error
elif key.lower() == "wafer id":
wafer_data.serial = value
if found_kv and wafer_data.date == datetime.min:
wafer_data.date = datetime.min
return found_kv
# ===== Sensor Layout Parsing =====
def _build_sensor_layout(
self,
wafer_data: ZWaferData,
labels: Optional[list],
x_coords: Optional[list],
y_coords: Optional[list],
) -> None:
"""Build sensor list from layout arrays."""
if not all([labels, x_coords, y_coords]):
raise ValueError("Sensor layout section is incomplete or missing.")
if len(labels) != len(x_coords) or len(labels) != len(y_coords):
raise ValueError(
f"Mismatched sensor columns: labels={len(labels)}, "
f"x={len(x_coords)}, y={len(y_coords)}"
)
for i in range(len(labels)):
try:
wafer_data.sensors.append(
Sensor(label=labels[i], x=float(x_coords[i]), y=float(y_coords[i]))
)
except ValueError as e:
raise ValueError(f"Invalid coordinate at index {i}: {e}")