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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("") == ()
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user