feat(map): add edge-center delta stats, thickness overlay, and layout fix

- Add edge-center sensor pair tables and delta calculations per wafer family.
- Parse and interpolate wafer thickness CSV data for offscreen visualization.
- Refactor ReadoutPanel layout using Item + anchors to fix right-margin clipping.
- Compact E-C Delta text format to fit standard sidebar width.
- Add pytest suite covering delta calculations, layout pairs, and thickness CSV.
This commit is contained in:
jack
2026-07-10 17:32:12 -07:00
parent 25fa7507ce
commit 94f917b116
12 changed files with 518 additions and 63 deletions
@@ -38,6 +38,49 @@ from pygui.backend.wafer.zwafer_models import Sensor
log = logging.getLogger(__name__)
def parse_thickness_csv(file_path: str) -> tuple[list[list[float]], str]:
"""Parse a customer thickness CSV into [x, y, t2] points (mm from center).
Expected columns (C# parity): Lot Start Time, RC, Site#, Slot#, T2,
NGOF, Wafer X, Wafer Y. Only the first wafer (first Slot# value) is read.
Returns (points, error) — error is "" on success.
"""
import csv
points: list[list[float]] = []
slot: str | None = None
try:
with open(file_path, newline="") as fh:
reader = csv.reader(fh)
header = next(reader, None)
if header is None:
return [], "Unable to read header row from CSV file."
if header and header[-1] == "": # trailing comma
header = header[:-1]
nfields = len(header)
if nfields != 8:
return [], "Incorrect field count in CSV header row."
for lno, row in enumerate(reader, start=2):
if row and row[-1] == "":
row = row[:-1]
if len(row) != nfields:
return [], f"Incorrect field count in CSV row {lno}"
if slot is None:
slot = row[3]
elif row[3] != slot:
break
try:
t2, x, y = float(row[4]), float(row[6]), float(row[7])
except ValueError:
return [], f"Malformed value in CSV row {lno}"
points.append([x, y, t2])
except OSError as exc:
return [], f"Unable to read CSV file: {exc}"
if not points:
return [], "No data found in CSV file."
return points, ""
def readout_text(values: list[float]) -> str:
"""Full READOUT stats line for the export footer; '' when no data."""
if not values:
@@ -84,7 +127,7 @@ class WaferMapItem(QQuickPaintedItem):
self._show_extremes: bool = True
self._shape: str = "round"
self._size: float = 300.0
self._thickness_data: list[float] = []
self._thickness_data: list[list[float]] = [] # [x_mm, y_mm, t2] triples
self._show_thickness: bool = False
self._thickness_heatmap:QImage | None = None
# Hover highlight: index of the marker under the pointer (-1 = none)
@@ -233,13 +276,31 @@ class WaferMapItem(QQuickPaintedItem):
@Property("QVariantList", notify=thicknessChanged)
def thicknessData(self) -> list:
"""Thickness measurement points as [x_mm, y_mm, t2] triples."""
return self._thickness_data
@thicknessData.setter # type: ignore[no-redef]
def thicknessData(self, val:list) -> None:
self._thickness_data = list(val or [])
self._thickness_data = [list(p) for p in (val or [])]
self._rebuild_thickness()
self.thicknessChanged.emit()
self.update()
@Property(bool, notify=thicknessChanged)
def hasThickness(self) -> bool:
return bool(self._thickness_data)
@Slot(str, result=str)
def loadThickness(self, file_path: str) -> str:
"""Load a customer thickness CSV; returns error message, '' on success."""
points, err = parse_thickness_csv(file_path)
if err:
return err
self._thickness_data = points
self._rebuild_thickness()
self.thicknessChanged.emit()
self.update()
return ""
self.update
@Property(bool, notify=showThicknessChanged)
@@ -383,18 +444,14 @@ class WaferMapItem(QQuickPaintedItem):
painter.drawEllipse(px - ring_r, py - ring_r, 2 * ring_r, 2 * ring_r)
def _rebuild_thickness(self) -> None:
"""Interpolate thickness data into a gray/orange heatmap QImage."""
if not self._sensors or not self._thickness_data:
"""Interpolate thickness points into a gray/orange heatmap QImage."""
if not self._thickness_data:
self._thickness_heatmap = None
return
ds = self._draw_size()
r_mm = self._wafer_radius_mm()
xs = np.array([s.x for s in self._sensors])
ys = np.array([s.y for s in self._sensors])
vs = np.array(self._thickness_data[:len(self._sensors)], dtype=float)
if len(vs) < len(self._sensors):
self._thickness_heatmap = None
return
pts = np.array(self._thickness_data, dtype=float)
xs, ys, vs = pts[:, 0], pts[:, 1], pts[:, 2]
try:
# No round_clip — see _rebuild_heatmap for why the mask is analytic.
field = interpolate_field(