diff --git a/src/pygui/ISC/Tabs/WaferMapTab.qml b/src/pygui/ISC/Tabs/WaferMapTab.qml index 3bfc32d..c5c1ad9 100644 --- a/src/pygui/ISC/Tabs/WaferMapTab.qml +++ b/src/pygui/ISC/Tabs/WaferMapTab.qml @@ -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; + } + } } } } diff --git a/src/pygui/ISC/Tabs/components/ReadoutPanel.qml b/src/pygui/ISC/Tabs/components/ReadoutPanel.qml index da34d9e..3a58edb 100644 --- a/src/pygui/ISC/Tabs/components/ReadoutPanel.qml +++ b/src/pygui/ISC/Tabs/components/ReadoutPanel.qml @@ -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 { diff --git a/src/pygui/ISC/Tabs/components/WaferMapView.qml b/src/pygui/ISC/Tabs/components/WaferMapView.qml index 59068d9..267b159 100644 --- a/src/pygui/ISC/Tabs/components/WaferMapView.qml +++ b/src/pygui/ISC/Tabs/components/WaferMapView.qml @@ -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); + } } diff --git a/src/pygui/backend/controllers/session_controller.py b/src/pygui/backend/controllers/session_controller.py index a3ce425..bc4c61b 100644 --- a/src/pygui/backend/controllers/session_controller.py +++ b/src/pygui/backend/controllers/session_controller.py @@ -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: diff --git a/src/pygui/backend/models/frame_stats.py b/src/pygui/backend/models/frame_stats.py index f8c1bdd..d12bc30 100644 --- a/src/pygui/backend/models/frame_stats.py +++ b/src/pygui/backend/models/frame_stats.py @@ -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: diff --git a/src/pygui/backend/models/sensor_editor.py b/src/pygui/backend/models/sensor_editor.py index 75a82b9..52938c8 100644 --- a/src/pygui/backend/models/sensor_editor.py +++ b/src/pygui/backend/models/sensor_editor.py @@ -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)) diff --git a/src/pygui/backend/visualization/wafer_map_item.py b/src/pygui/backend/visualization/wafer_map_item.py index 4d0d05d..a44bb61 100644 --- a/src/pygui/backend/visualization/wafer_map_item.py +++ b/src/pygui/backend/visualization/wafer_map_item.py @@ -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( diff --git a/src/pygui/backend/wafer/wafer_layouts.py b/src/pygui/backend/wafer/wafer_layouts.py index fd1848d..b2371f0 100644 --- a/src/pygui/backend/wafer/wafer_layouts.py +++ b/src/pygui/backend/wafer/wafer_layouts.py @@ -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")] diff --git a/tests/test_frame_stats.py b/tests/test_frame_stats.py index 047de8d..a06c23d 100644 --- a/tests/test_frame_stats.py +++ b/tests/test_frame_stats.py @@ -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 diff --git a/tests/test_session_controller.py b/tests/test_session_controller.py index 360e11b..6e295d6 100644 --- a/tests/test_session_controller.py +++ b/tests/test_session_controller.py @@ -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 diff --git a/tests/test_wafer_layouts.py b/tests/test_wafer_layouts.py index 57ed111..bf72844 100644 --- a/tests/test_wafer_layouts.py +++ b/tests/test_wafer_layouts.py @@ -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("") == () diff --git a/tests/test_wafer_map_item.py b/tests/test_wafer_map_item.py index ec60a2b..c1b3d72 100644 --- a/tests/test_wafer_map_item.py +++ b/tests/test_wafer_map_item.py @@ -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():