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
+8
View File
@@ -206,9 +206,17 @@ Item {
id: readoutPanel
anchors.fill: parent
anchors.margins: 0
hasThicknessData: waferView.hasThickness
onExportRequested: function(filePath, extra) {
waferView.exportImage(filePath, extra);
}
onThicknessFileChosen: function(filePath) {
var err = waferView.loadThickness(filePath);
if (err !== "") {
root.loadErrorMessage = err;
readoutPanel.showThickness = false;
}
}
}
}
}
+145 -38
View File
@@ -1,6 +1,7 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Controls.impl
import QtQuick.Dialogs
import QtQuick.Layouts
import ISC
@@ -15,8 +16,10 @@ ColumnLayout {
property alias showExtremes: extremesToggle.checked
property alias heatmapBlend: heatmapSlider.value
property alias showThickness: thicknessToggle.checked
property bool hasThicknessData: false
signal exportRequested(string filePath, string extra)
signal thicknessFileChosen(string filePath)
component PanelCheckBox: CheckBox {
id: toggle
@@ -119,20 +122,23 @@ ColumnLayout {
}
// Min Temp Row
RowLayout {
Item {
Layout.fillWidth: true
Layout.leftMargin: 14
Layout.rightMargin: 14
Layout.preferredHeight: 32
implicitHeight: 32
Label {
anchors.left: parent.left
anchors.leftMargin: 14
anchors.verticalCenter: parent.verticalCenter
text: "MIN TEMP"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.bold: true
}
Item { Layout.fillWidth: true }
RowLayout {
Row {
anchors.right: parent.right
anchors.rightMargin: 14
anchors.verticalCenter: parent.verticalCenter
spacing: 4
Label {
text: s.min !== undefined ? s.min : "—"
@@ -147,8 +153,8 @@ ColumnLayout {
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.font2xs
Layout.alignment: Qt.AlignBottom
bottomPadding: 3
anchors.bottom: parent.bottom
anchors.bottomMargin: 2
}
}
}
@@ -160,20 +166,23 @@ ColumnLayout {
}
// Max Temp Row
RowLayout {
Item {
Layout.fillWidth: true
Layout.leftMargin: 14
Layout.rightMargin: 14
Layout.preferredHeight: 32
implicitHeight: 32
Label {
anchors.left: parent.left
anchors.leftMargin: 14
anchors.verticalCenter: parent.verticalCenter
text: "MAX TEMP"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.bold: true
}
Item { Layout.fillWidth: true }
RowLayout {
Row {
anchors.right: parent.right
anchors.rightMargin: 14
anchors.verticalCenter: parent.verticalCenter
spacing: 4
Label {
text: s.max !== undefined ? s.max : "—"
@@ -188,8 +197,8 @@ ColumnLayout {
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.font2xs
Layout.alignment: Qt.AlignBottom
bottomPadding: 3
anchors.bottom: parent.bottom
anchors.bottomMargin: 2
}
}
}
@@ -201,20 +210,23 @@ ColumnLayout {
}
// Differential Row
RowLayout {
Item {
Layout.fillWidth: true
Layout.leftMargin: 14
Layout.rightMargin: 14
Layout.preferredHeight: 32
implicitHeight: 32
Label {
anchors.left: parent.left
anchors.leftMargin: 14
anchors.verticalCenter: parent.verticalCenter
text: "DIFF"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.bold: true
}
Item { Layout.fillWidth: true }
Label {
anchors.right: parent.right
anchors.rightMargin: 14
anchors.verticalCenter: parent.verticalCenter
text: s.diff !== undefined ? s.diff : "—"
color: Theme.diffAccent
font.family: Theme.codeFontFamily
@@ -230,20 +242,23 @@ ColumnLayout {
}
// Average Row
RowLayout {
Item {
Layout.fillWidth: true
Layout.leftMargin: 14
Layout.rightMargin: 14
Layout.preferredHeight: 32
implicitHeight: 32
Label {
anchors.left: parent.left
anchors.leftMargin: 14
anchors.verticalCenter: parent.verticalCenter
text: "AVERAGE"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.bold: true
}
Item { Layout.fillWidth: true }
Label {
anchors.right: parent.right
anchors.rightMargin: 14
anchors.verticalCenter: parent.verticalCenter
text: s.avg !== undefined ? s.avg : "—"
color: Theme.headingColor
font.family: Theme.codeFontFamily
@@ -259,20 +274,23 @@ ColumnLayout {
}
// Sigma Row
RowLayout {
Item {
Layout.fillWidth: true
Layout.leftMargin: 14
Layout.rightMargin: 14
Layout.preferredHeight: 32
implicitHeight: 32
Label {
text: "SIGMA (Σ)"
anchors.left: parent.left
anchors.leftMargin: 14
anchors.verticalCenter: parent.verticalCenter
text: "SIGMA (\u03A3)"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.bold: true
}
Item { Layout.fillWidth: true }
Label {
anchors.right: parent.right
anchors.rightMargin: 14
anchors.verticalCenter: parent.verticalCenter
text: s.sigma !== undefined ? s.sigma : "—"
color: Theme.headingColor
font.family: Theme.codeFontFamily
@@ -288,20 +306,23 @@ ColumnLayout {
}
// 3-Sigma Row
RowLayout {
Item {
Layout.fillWidth: true
Layout.leftMargin: 14
Layout.rightMargin: 14
Layout.preferredHeight: 32
implicitHeight: 32
Label {
text: "3Σ VALUE"
anchors.left: parent.left
anchors.leftMargin: 14
anchors.verticalCenter: parent.verticalCenter
text: "3\u03A3 VALUE"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.bold: true
}
Item { Layout.fillWidth: true }
Label {
anchors.right: parent.right
anchors.rightMargin: 14
anchors.verticalCenter: parent.verticalCenter
text: s.threeSigma !== undefined ? s.threeSigma : "—"
color: Theme.headingColor
font.family: Theme.codeFontFamily
@@ -309,6 +330,78 @@ ColumnLayout {
font.bold: true
}
}
Rectangle {
visible: s.ecMinDelta !== undefined
Layout.fillWidth: true
height: 1
color: Theme.cardBorder
}
// Edge-Center \u0394 min Row (per-family pair tables — see ADR-0002)
Item {
visible: s.ecMinDelta !== undefined
Layout.fillWidth: true
implicitHeight: 32
Label {
anchors.left: parent.left
anchors.leftMargin: 14
anchors.verticalCenter: parent.verticalCenter
text: "E-C \u0394 MIN"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.bold: true
}
Label {
anchors.right: parent.right
anchors.rightMargin: 14
anchors.verticalCenter: parent.verticalCenter
text: s.ecMinDelta !== undefined
? s.ecMinDelta.toFixed(2) + " (#" + s.ecMinEdgeIndex + "\u2192#" + s.ecMinCenterIndex + ")"
: "—"
color: Theme.headingColor
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontSm
font.bold: true
}
}
Rectangle {
visible: s.ecMaxDelta !== undefined
Layout.fillWidth: true
height: 1
color: Theme.cardBorder
}
// Edge-Center \u0394 max Row
Item {
visible: s.ecMaxDelta !== undefined
Layout.fillWidth: true
implicitHeight: 32
Label {
anchors.left: parent.left
anchors.leftMargin: 14
anchors.verticalCenter: parent.verticalCenter
text: "E-C \u0394 MAX"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.bold: true
}
Label {
anchors.right: parent.right
anchors.rightMargin: 14
anchors.verticalCenter: parent.verticalCenter
text: s.ecMaxDelta !== undefined
? s.ecMaxDelta.toFixed(2) + " (#" + s.ecMaxEdgeIndex + "\u2192#" + s.ecMaxCenterIndex + ")"
: "—"
color: Theme.headingColor
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontSm
font.bold: true
}
}
}
}
@@ -345,6 +438,20 @@ ColumnLayout {
text: "Show Thickness"
checked: false
font.pixelSize: Theme.fontSm
// First check with no data prompts for the customer CSV;
// unchecking only hides the overlay, data stays loaded.
onToggled: {
if (checked && !root.hasThicknessData)
thicknessDialog.open();
}
}
FileDialog {
id: thicknessDialog
title: "Select Thickness Data File"
nameFilters: ["CSV files (*.csv)"]
onAccepted: root.thicknessFileChosen(String(selectedFile).replace(/^file:\/\//, ""))
onRejected: thicknessToggle.checked = false
}
PanelCheckBox {
@@ -9,6 +9,7 @@ Item {
property bool showLabels: true
property alias showExtremes: map.showExtremes
property alias showThickness: map.showThickness
property alias hasThickness: map.hasThickness
WaferMapItem {
id: map
@@ -66,4 +67,8 @@ Item {
function exportImage(filePath, extra) {
return map.export_image(filePath, extra || "");
}
function loadThickness(filePath) {
return map.loadThickness(filePath);
}
}
@@ -12,10 +12,12 @@ from pygui.backend.cluster_average import average_clusters, group_sensors_by_rad
from pygui.backend.data.csv_recorder import CsvRecorder
from pygui.backend.models.frame import Frame
from pygui.backend.models.frame_player import FramePlayer, frames_from_wafer_data
from pygui.backend.models.frame_stats import compute_edge_center
from pygui.backend.models.sensor_editor import SensorEditor
from pygui.backend.models.session_model import SessionModel, SessionUpdate
from pygui.backend.models.threshold_classifier import ThresholdConfig
from pygui.backend.utils import slot_error_boundary
from pygui.backend.wafer.wafer_layouts import ec_pairs_for_wafer_id
from pygui.backend.wafer.zwafer_models import Sensor
from pygui.backend.wafer.zwafer_parser import ZWaferParser
from pygui.serialcomm.stream_reader import StreamReader
@@ -75,6 +77,7 @@ class SessionController(QObject):
self._liveError.connect(self._on_live_error, Qt.ConnectionType.QueuedConnection)
self._sensor_editor = SensorEditor()
self._ec_pairs: tuple[tuple[int, int], ...] = ()
self._last_raw_frame: Frame | None = None
self._loaded_file: str = ""
self._cluster_averaging_enabled = False
@@ -180,10 +183,26 @@ class SessionController(QObject):
if not self._last:
return {}
s = self._last.stats
return {"min": round(s.min, 2), "minIndex": s.min_index + 1,
"max": round(s.max, 2), "maxIndex": s.max_index + 1,
"diff": round(s.diff, 2), "avg": round(s.avg, 2),
"sigma": round(s.sigma, 2), "threeSigma": round(s.three_sigma, 2)}
out = {"min": round(s.min, 2), "minIndex": s.min_index + 1,
"max": round(s.max, 2), "maxIndex": s.max_index + 1,
"diff": round(s.diff, 2), "avg": round(s.avg, 2),
"sigma": round(s.sigma, 2), "threeSigma": round(s.three_sigma, 2)}
ec = compute_edge_center(self._last.values, self._ec_pairs,
self._sensor_editor.replaced_indices())
if ec is not None:
out.update({
"ecMinEdgeIndex": ec.min_edge_index + 1,
"ecMinCenterIndex": ec.min_center_index + 1,
"ecMinEdgeValue": round(ec.min_edge, 2),
"ecMinCenterValue": round(ec.min_center, 2),
"ecMinDelta": round(ec.min_delta, 2),
"ecMaxEdgeIndex": ec.max_edge_index + 1,
"ecMaxCenterIndex": ec.max_center_index + 1,
"ecMaxEdgeValue": round(ec.max_edge, 2),
"ecMaxCenterValue": round(ec.max_center, 2),
"ecMaxDelta": round(ec.max_delta, 2),
})
return out
@Property(str, notify=loadedFileChanged)
def loadedFile(self) -> str: return self._loaded_file
@@ -286,6 +305,7 @@ class SessionController(QObject):
wafer_id = stem.split("-")[0] if "-" in stem else stem
shape, size = resolve_shape_and_size(sensors, wafer_id)
self._ec_pairs = ec_pairs_for_wafer_id(wafer_id)
self._sensors = WaferLayout(sensors, shape=shape, size=size)
self._active_clusters = getattr(self._sensors, 'clusters', [])
if not self._active_clusters:
@@ -303,6 +323,7 @@ class SessionController(QObject):
"""Clear the loaded file, resetting the player and frame states."""
self._player.load([])
self._model.reset()
self._ec_pairs = ()
self._loaded_file = ""
self.loadedFileChanged.emit()
self.sensorsChanged.emit()
@@ -652,6 +673,7 @@ class SessionController(QObject):
self.sensorsChanged.emit()
except Exception as e:
log.warning("Could not load layout for %s: %s", family_code, e)
self._ec_pairs = ec_pairs_for_wafer_id(family_code)
self._active_clusters = getattr(self._sensors, 'clusters', [])
if not self._active_clusters:
+38
View File
@@ -14,6 +14,44 @@ class Stats:
sigma: float; three_sigma: float
@dataclass(frozen=True)
class EdgeCenterStats:
min_edge_index: int; min_center_index: int
min_edge: float; min_center: float; min_delta: float
max_edge_index: int; max_center_index: int
max_edge: float; max_center: float; max_delta: float
def compute_edge_center(
values: list[float],
pairs: tuple[tuple[int, int], ...],
excluded: set[int],
) -> EdgeCenterStats | None:
"""Min/max |edge - center| over the family's pair table.
Pairs with an excluded (replaced) sensor, an index outside the frame, or a
NaN value are skipped. Returns None when no pair is computable.
"""
n = len(values)
best: list[tuple[float, int, int]] = []
for e, c in pairs:
if e in excluded or c in excluded or e >= n or c >= n:
continue
if math.isnan(values[e]) or math.isnan(values[c]):
continue
best.append((abs(values[e] - values[c]), e, c))
if not best:
return None
min_d, min_e, min_c = min(best)
max_d, max_e, max_c = max(best)
return EdgeCenterStats(
min_edge_index=min_e, min_center_index=min_c,
min_edge=values[min_e], min_center=values[min_c], min_delta=min_d,
max_edge_index=max_e, max_center_index=max_c,
max_edge=values[max_e], max_center=values[max_c], max_delta=max_d,
)
def compute_stats(values: list[float]) -> Stats:
clean = [(i, v) for i, v in enumerate(values) if not math.isnan(v)]
if not clean:
@@ -28,6 +28,11 @@ class SensorEditor:
def has_overrides(self) -> bool:
return bool(self._replacements or self._offsets)
def replaced_indices(self) -> set[int]:
"""Sensors whose value is a replacement (offsets don't count — an
offset sensor still reads from its physical location)."""
return set(self._replacements)
def active_indices(self) -> list[int]:
"""Return sorted sensor indices that have any override."""
return sorted(set(self._replacements) | set(self._offsets))
@@ -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(
+29 -6
View File
@@ -70,12 +70,35 @@ def _load_yaml(path: Path) -> dict:
return loaded
# TODO P6.3: expose this list to a new LayoutSelector.qml (4-button chooser)
# THINKING: this already returns everything the UI needs (family names) —
# the missing piece is a context-property exposure (SessionController or a
# thin new property) and a setLayout(family) slot that calls load_layout()
# below. No parsing work needed; this is a wiring-only task.
# See docs/pending/alpha-release-polish-plan.md §6.3.
# Edge→center sensor pair tables, ported verbatim from the C# original
# (Form1.cs ec_map_*). Indices are 0-origin. Domain data, not derivable from
# ring geometry — see docs/adr/0002-edge-center-pair-tables.md.
_EC_MAPS: dict[str, tuple[tuple[int, int], ...]] = {
"AEP": tuple((e, 40 + e // 2) for e in range(16)),
"BCD": tuple((e, 28) for e in range(12)),
"F": ((0, 18), (1, 19), (2, 19), (3, 19), (4, 20), (5, 20),
(6, 20), (7, 21), (8, 21), (9, 21), (10, 18), (11, 18)),
"X": ((5, 76), (0, 76), (39, 76), (6, 77), (11, 77), (16, 77),
(17, 78), (22, 78), (27, 78), (28, 79), (33, 79), (38, 79)),
"Z": tuple((e, 0) for e in range(49, 65)),
}
def ec_pairs_for_wafer_id(wafer_id: str) -> tuple[tuple[int, int], ...]:
"""Edge-center pairs for a wafer id's family letter; empty if unknown.
Unknown families deliberately get no pairs (C# fell through to Z).
"""
prefix = wafer_id[0].upper() if wafer_id else ""
for families, key in (("AEP", "AEP"), ("BCD", "BCD"), ("F", "F"),
("X", "X"), ("Z", "Z")):
if prefix and prefix in families:
return _EC_MAPS[key]
return ()
# P6.3 (LayoutSelector) dropped 2026-07-10 — the C# "layout" buttons only
# exported coordinate spreadsheets, decided obsolete. See MIGRATION.md.
def available_families() -> list[str]:
return [_family_name(_load_yaml(p)["name"]) for p in _LAYOUTS_DIR.glob("*.yaml")]
+46 -1
View File
@@ -2,7 +2,12 @@ import math
import pytest
from pygui.backend.models.frame_stats import Stats, compute_stats
from pygui.backend.models.frame_stats import (
EdgeCenterStats,
Stats,
compute_edge_center,
compute_stats,
)
def test_basic_stat():
@@ -28,3 +33,43 @@ def test_ignores_nan():
assert s.avg == pytest.approx(150.0)
assert s.sigma == pytest.approx(1.0)
assert s.three_sigma == pytest.approx(3.0)
# ---- edge-center delta (ADR-0002) ----
PAIRS = ((0, 4), (1, 4), (2, 5), (3, 5))
def test_edge_center_min_max_pairs():
# e0 e1 e2 e3 c4 c5
values = [150.0, 148.0, 151.0, 149.4, 149.0, 149.5]
ec = compute_edge_center(values, PAIRS, excluded=set())
# deltas: |150-149|=1.0, |148-149|=1.0, |151-149.5|=1.5, |149.4-149.5|=0.1
assert ec == EdgeCenterStats(
min_edge_index=3, min_center_index=5, min_edge=149.4, min_center=149.5,
min_delta=pytest.approx(0.1),
max_edge_index=2, max_center_index=5, max_edge=151.0, max_center=149.5,
max_delta=pytest.approx(1.5),
)
def test_edge_center_skips_excluded_sensors():
values = [150.0, 148.0, 151.0, 149.4, 149.0, 149.5]
ec = compute_edge_center(values, PAIRS, excluded={2, 3}) # both c5 pairs out
assert ec is not None
assert ec.max_delta == pytest.approx(1.0)
# excluding a center sensor kills all its pairs
assert compute_edge_center(values, PAIRS, excluded={4, 5}) is None
def test_edge_center_skips_nan_and_out_of_range():
values = [150.0, math.nan, 149.0] # only pair (0, 2) is computable
ec = compute_edge_center(values, ((0, 2), (1, 2), (0, 9)), excluded=set())
assert ec is not None
assert ec.min_delta == ec.max_delta == pytest.approx(1.0)
assert (ec.min_edge_index, ec.min_center_index) == (0, 2)
def test_edge_center_none_when_no_pairs():
assert compute_edge_center([1.0, 2.0], (), excluded=set()) is None
assert compute_edge_center([], PAIRS, excluded=set()) is None
+44
View File
@@ -248,3 +248,47 @@ def test_on_live_frame_emits_trend_delta_with_elapsed_seconds(controller, monkey
elapsed, avg = payload[0]
assert elapsed == pytest.approx(2.5)
assert avg == pytest.approx(20.0)
# ---- edge-center stats exposure (ADR-0002) ----
def _write_f_family_csv(path):
"""22-sensor F-family stream: pair (0,18) has delta 1.0, all others 0."""
n = 22
values = [149.0] * n
values[0] = 150.0
lines = [
"Wafer ID=F12",
"Acquisition Date=03/26/2025",
"Label," + ",".join(str(i + 1) for i in range(n)),
"X (mm)," + ",".join(str(i) for i in range(n)),
"Y (mm)," + ",".join(str(i) for i in range(n)),
"data",
"0.0," + ",".join(f"{v:.1f}" for v in values),
]
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def test_stats_include_edge_center_for_known_family(controller, tmp_path):
csv = tmp_path / "f_run.csv"
_write_f_family_csv(csv)
controller.loadFile(str(csv))
s = controller.stats
assert s["ecMaxDelta"] == pytest.approx(1.0)
assert s["ecMaxEdgeIndex"] == 1 and s["ecMaxCenterIndex"] == 19 # 1-origin
assert s["ecMaxEdgeValue"] == pytest.approx(150.0)
assert s["ecMaxCenterValue"] == pytest.approx(149.0)
assert s["ecMinDelta"] == pytest.approx(0.0)
def test_stats_edge_center_skips_replaced_sensor(controller, tmp_path):
csv = tmp_path / "f_run.csv"
_write_f_family_csv(csv)
controller.loadFile(str(csv))
controller.replaceSensor(0, 149.0) # replaced: pair (0,18) must be skipped
assert controller.stats["ecMaxDelta"] == pytest.approx(0.0)
def test_stats_edge_center_absent_for_unknown_or_short_family(controller):
controller.loadFile("tests/fixtures/sample_stream.csv") # A12, 3 sensors
assert "ecMaxDelta" not in controller.stats
+29 -1
View File
@@ -1,7 +1,11 @@
"""Tests for src/pygui/backend/wafer_layouts.py."""
import pytest
from pygui.backend.wafer.wafer_layouts import available_families, load_layout
from pygui.backend.wafer.wafer_layouts import (
available_families,
ec_pairs_for_wafer_id,
load_layout,
)
from pygui.backend.wafer.zwafer_models import Sensor
@@ -20,3 +24,27 @@ def test_loads_sensors_in_mm():
def test_unknown_family_raises():
with pytest.raises(KeyError):
load_layout("nope")
# ---- edge-center pair tables (ADR-0002) ----
def test_ec_pairs_aep_match_csharp_table():
pairs = ec_pairs_for_wafer_id("A00123")
assert pairs[0] == (0, 40)
assert pairs[-1] == (15, 47)
assert len(pairs) == 16
assert ec_pairs_for_wafer_id("E1") == pairs
assert ec_pairs_for_wafer_id("P1") == pairs
def test_ec_pairs_per_family_classes():
assert len(ec_pairs_for_wafer_id("B00108")) == 12
assert ec_pairs_for_wafer_id("C1") == ec_pairs_for_wafer_id("D1")
assert ec_pairs_for_wafer_id("F1")[0] == (0, 18)
assert ec_pairs_for_wafer_id("X1")[0] == (5, 76)
assert ec_pairs_for_wafer_id("Z1")[0] == (49, 0)
def test_ec_pairs_unknown_family_empty():
assert ec_pairs_for_wafer_id("Q999") == ()
assert ec_pairs_for_wafer_id("") == ()
+76 -3
View File
@@ -47,16 +47,89 @@ def test_thickness_properties():
def test_thickness_setter_updates_data():
"""Setting thicknessData updates _thickness_data."""
"""Setting thicknessData stores [x, y, t2] triples."""
from pygui.backend.visualization.wafer_map_item import WaferMapItem
item = WaferMapItem()
item._rebuild_thickness = MagicMock()
item.thicknessData = [1.0, 2.0, 3.0]
item.thicknessData = [[0.0, 10.0, 1.5], [5.0, -5.0, 2.0]]
assert item._thickness_data == [1.0, 2.0, 3.0]
assert item._thickness_data == [[0.0, 10.0, 1.5], [5.0, -5.0, 2.0]]
assert item._rebuild_thickness.called
assert item.hasThickness is True
THICKNESS_HEADER = "Lot Start Time,RC,Site#,Slot#,T2,NGOF,Wafer X,Wafer Y\n"
def _write_csv(tmp_path, body, header=THICKNESS_HEADER):
p = tmp_path / "thickness.csv"
p.write_text(header + body)
return str(p)
def test_parse_thickness_csv_valid(tmp_path):
from pygui.backend.visualization.wafer_map_item import parse_thickness_csv
path = _write_csv(tmp_path,
"t,1,1,7,1.5,0,10.0,-20.0\n"
"t,1,2,7,2.5,0,-30.0,40.0\n")
points, err = parse_thickness_csv(path)
assert err == ""
assert points == [[10.0, -20.0, 1.5], [-30.0, 40.0, 2.5]]
def test_parse_thickness_csv_first_slot_only(tmp_path):
"""Rows after the Slot# changes belong to another wafer and are skipped."""
from pygui.backend.visualization.wafer_map_item import parse_thickness_csv
path = _write_csv(tmp_path,
"t,1,1,7,1.5,0,10.0,-20.0\n"
"t,1,1,8,9.9,0,0.0,0.0\n")
points, err = parse_thickness_csv(path)
assert err == ""
assert points == [[10.0, -20.0, 1.5]]
def test_parse_thickness_csv_bad_header(tmp_path):
from pygui.backend.visualization.wafer_map_item import parse_thickness_csv
path = _write_csv(tmp_path, "t,1,1,7,1.5,0,10.0,-20.0\n", header="A,B,C\n")
points, err = parse_thickness_csv(path)
assert points == []
assert "header" in err
def test_parse_thickness_csv_malformed_value(tmp_path):
from pygui.backend.visualization.wafer_map_item import parse_thickness_csv
path = _write_csv(tmp_path, "t,1,1,7,oops,0,10.0,-20.0\n")
points, err = parse_thickness_csv(path)
assert points == []
assert "Malformed" in err
def test_parse_thickness_csv_missing_file():
from pygui.backend.visualization.wafer_map_item import parse_thickness_csv
points, err = parse_thickness_csv("/no/such/file.csv")
assert points == []
assert "Unable to read" in err
def test_load_thickness_slot(tmp_path):
"""loadThickness feeds parsed points into thicknessData."""
from pygui.backend.visualization.wafer_map_item import WaferMapItem
item = WaferMapItem()
path = _write_csv(tmp_path, "t,1,1,7,1.5,0,10.0,-20.0\n")
assert item.loadThickness(path) == ""
assert item.hasThickness is True
assert item.loadThickness("/no/such/file.csv") != ""
# Failed load keeps the previously loaded data.
assert item.hasThickness is True
def test_show_thickness_setter():