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