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
|
id: readoutPanel
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
anchors.margins: 0
|
anchors.margins: 0
|
||||||
|
hasThicknessData: waferView.hasThickness
|
||||||
onExportRequested: function(filePath, extra) {
|
onExportRequested: function(filePath, extra) {
|
||||||
waferView.exportImage(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
|
||||||
import QtQuick.Controls
|
import QtQuick.Controls
|
||||||
import QtQuick.Controls.impl
|
import QtQuick.Controls.impl
|
||||||
|
import QtQuick.Dialogs
|
||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
import ISC
|
import ISC
|
||||||
|
|
||||||
@@ -15,8 +16,10 @@ ColumnLayout {
|
|||||||
property alias showExtremes: extremesToggle.checked
|
property alias showExtremes: extremesToggle.checked
|
||||||
property alias heatmapBlend: heatmapSlider.value
|
property alias heatmapBlend: heatmapSlider.value
|
||||||
property alias showThickness: thicknessToggle.checked
|
property alias showThickness: thicknessToggle.checked
|
||||||
|
property bool hasThicknessData: false
|
||||||
|
|
||||||
signal exportRequested(string filePath, string extra)
|
signal exportRequested(string filePath, string extra)
|
||||||
|
signal thicknessFileChosen(string filePath)
|
||||||
|
|
||||||
component PanelCheckBox: CheckBox {
|
component PanelCheckBox: CheckBox {
|
||||||
id: toggle
|
id: toggle
|
||||||
@@ -119,20 +122,23 @@ ColumnLayout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Min Temp Row
|
// Min Temp Row
|
||||||
RowLayout {
|
Item {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.leftMargin: 14
|
implicitHeight: 32
|
||||||
Layout.rightMargin: 14
|
|
||||||
Layout.preferredHeight: 32
|
|
||||||
Label {
|
Label {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 14
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
text: "MIN TEMP"
|
text: "MIN TEMP"
|
||||||
color: Theme.sideMutedText
|
color: Theme.sideMutedText
|
||||||
font.family: Theme.uiFontFamily
|
font.family: Theme.uiFontFamily
|
||||||
font.pixelSize: Theme.fontSm
|
font.pixelSize: Theme.fontSm
|
||||||
font.bold: true
|
font.bold: true
|
||||||
}
|
}
|
||||||
Item { Layout.fillWidth: true }
|
Row {
|
||||||
RowLayout {
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: 14
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
spacing: 4
|
spacing: 4
|
||||||
Label {
|
Label {
|
||||||
text: s.min !== undefined ? s.min : "—"
|
text: s.min !== undefined ? s.min : "—"
|
||||||
@@ -147,8 +153,8 @@ ColumnLayout {
|
|||||||
color: Theme.sideMutedText
|
color: Theme.sideMutedText
|
||||||
font.family: Theme.uiFontFamily
|
font.family: Theme.uiFontFamily
|
||||||
font.pixelSize: Theme.font2xs
|
font.pixelSize: Theme.font2xs
|
||||||
Layout.alignment: Qt.AlignBottom
|
anchors.bottom: parent.bottom
|
||||||
bottomPadding: 3
|
anchors.bottomMargin: 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -160,20 +166,23 @@ ColumnLayout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Max Temp Row
|
// Max Temp Row
|
||||||
RowLayout {
|
Item {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.leftMargin: 14
|
implicitHeight: 32
|
||||||
Layout.rightMargin: 14
|
|
||||||
Layout.preferredHeight: 32
|
|
||||||
Label {
|
Label {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 14
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
text: "MAX TEMP"
|
text: "MAX TEMP"
|
||||||
color: Theme.sideMutedText
|
color: Theme.sideMutedText
|
||||||
font.family: Theme.uiFontFamily
|
font.family: Theme.uiFontFamily
|
||||||
font.pixelSize: Theme.fontSm
|
font.pixelSize: Theme.fontSm
|
||||||
font.bold: true
|
font.bold: true
|
||||||
}
|
}
|
||||||
Item { Layout.fillWidth: true }
|
Row {
|
||||||
RowLayout {
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: 14
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
spacing: 4
|
spacing: 4
|
||||||
Label {
|
Label {
|
||||||
text: s.max !== undefined ? s.max : "—"
|
text: s.max !== undefined ? s.max : "—"
|
||||||
@@ -188,8 +197,8 @@ ColumnLayout {
|
|||||||
color: Theme.sideMutedText
|
color: Theme.sideMutedText
|
||||||
font.family: Theme.uiFontFamily
|
font.family: Theme.uiFontFamily
|
||||||
font.pixelSize: Theme.font2xs
|
font.pixelSize: Theme.font2xs
|
||||||
Layout.alignment: Qt.AlignBottom
|
anchors.bottom: parent.bottom
|
||||||
bottomPadding: 3
|
anchors.bottomMargin: 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -201,20 +210,23 @@ ColumnLayout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Differential Row
|
// Differential Row
|
||||||
RowLayout {
|
Item {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.leftMargin: 14
|
implicitHeight: 32
|
||||||
Layout.rightMargin: 14
|
|
||||||
Layout.preferredHeight: 32
|
|
||||||
Label {
|
Label {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 14
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
text: "DIFF"
|
text: "DIFF"
|
||||||
color: Theme.sideMutedText
|
color: Theme.sideMutedText
|
||||||
font.family: Theme.uiFontFamily
|
font.family: Theme.uiFontFamily
|
||||||
font.pixelSize: Theme.fontSm
|
font.pixelSize: Theme.fontSm
|
||||||
font.bold: true
|
font.bold: true
|
||||||
}
|
}
|
||||||
Item { Layout.fillWidth: true }
|
|
||||||
Label {
|
Label {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: 14
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
text: s.diff !== undefined ? s.diff : "—"
|
text: s.diff !== undefined ? s.diff : "—"
|
||||||
color: Theme.diffAccent
|
color: Theme.diffAccent
|
||||||
font.family: Theme.codeFontFamily
|
font.family: Theme.codeFontFamily
|
||||||
@@ -230,20 +242,23 @@ ColumnLayout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Average Row
|
// Average Row
|
||||||
RowLayout {
|
Item {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.leftMargin: 14
|
implicitHeight: 32
|
||||||
Layout.rightMargin: 14
|
|
||||||
Layout.preferredHeight: 32
|
|
||||||
Label {
|
Label {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 14
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
text: "AVERAGE"
|
text: "AVERAGE"
|
||||||
color: Theme.sideMutedText
|
color: Theme.sideMutedText
|
||||||
font.family: Theme.uiFontFamily
|
font.family: Theme.uiFontFamily
|
||||||
font.pixelSize: Theme.fontSm
|
font.pixelSize: Theme.fontSm
|
||||||
font.bold: true
|
font.bold: true
|
||||||
}
|
}
|
||||||
Item { Layout.fillWidth: true }
|
|
||||||
Label {
|
Label {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: 14
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
text: s.avg !== undefined ? s.avg : "—"
|
text: s.avg !== undefined ? s.avg : "—"
|
||||||
color: Theme.headingColor
|
color: Theme.headingColor
|
||||||
font.family: Theme.codeFontFamily
|
font.family: Theme.codeFontFamily
|
||||||
@@ -259,20 +274,23 @@ ColumnLayout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sigma Row
|
// Sigma Row
|
||||||
RowLayout {
|
Item {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.leftMargin: 14
|
implicitHeight: 32
|
||||||
Layout.rightMargin: 14
|
|
||||||
Layout.preferredHeight: 32
|
|
||||||
Label {
|
Label {
|
||||||
text: "SIGMA (Σ)"
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 14
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "SIGMA (\u03A3)"
|
||||||
color: Theme.sideMutedText
|
color: Theme.sideMutedText
|
||||||
font.family: Theme.uiFontFamily
|
font.family: Theme.uiFontFamily
|
||||||
font.pixelSize: Theme.fontSm
|
font.pixelSize: Theme.fontSm
|
||||||
font.bold: true
|
font.bold: true
|
||||||
}
|
}
|
||||||
Item { Layout.fillWidth: true }
|
|
||||||
Label {
|
Label {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: 14
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
text: s.sigma !== undefined ? s.sigma : "—"
|
text: s.sigma !== undefined ? s.sigma : "—"
|
||||||
color: Theme.headingColor
|
color: Theme.headingColor
|
||||||
font.family: Theme.codeFontFamily
|
font.family: Theme.codeFontFamily
|
||||||
@@ -288,20 +306,23 @@ ColumnLayout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3-Sigma Row
|
// 3-Sigma Row
|
||||||
RowLayout {
|
Item {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.leftMargin: 14
|
implicitHeight: 32
|
||||||
Layout.rightMargin: 14
|
|
||||||
Layout.preferredHeight: 32
|
|
||||||
Label {
|
Label {
|
||||||
text: "3Σ VALUE"
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 14
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "3\u03A3 VALUE"
|
||||||
color: Theme.sideMutedText
|
color: Theme.sideMutedText
|
||||||
font.family: Theme.uiFontFamily
|
font.family: Theme.uiFontFamily
|
||||||
font.pixelSize: Theme.fontSm
|
font.pixelSize: Theme.fontSm
|
||||||
font.bold: true
|
font.bold: true
|
||||||
}
|
}
|
||||||
Item { Layout.fillWidth: true }
|
|
||||||
Label {
|
Label {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: 14
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
text: s.threeSigma !== undefined ? s.threeSigma : "—"
|
text: s.threeSigma !== undefined ? s.threeSigma : "—"
|
||||||
color: Theme.headingColor
|
color: Theme.headingColor
|
||||||
font.family: Theme.codeFontFamily
|
font.family: Theme.codeFontFamily
|
||||||
@@ -309,6 +330,78 @@ ColumnLayout {
|
|||||||
font.bold: true
|
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"
|
text: "Show Thickness"
|
||||||
checked: false
|
checked: false
|
||||||
font.pixelSize: Theme.fontSm
|
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 {
|
PanelCheckBox {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ Item {
|
|||||||
property bool showLabels: true
|
property bool showLabels: true
|
||||||
property alias showExtremes: map.showExtremes
|
property alias showExtremes: map.showExtremes
|
||||||
property alias showThickness: map.showThickness
|
property alias showThickness: map.showThickness
|
||||||
|
property alias hasThickness: map.hasThickness
|
||||||
|
|
||||||
WaferMapItem {
|
WaferMapItem {
|
||||||
id: map
|
id: map
|
||||||
@@ -66,4 +67,8 @@ Item {
|
|||||||
function exportImage(filePath, extra) {
|
function exportImage(filePath, extra) {
|
||||||
return map.export_image(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.data.csv_recorder import CsvRecorder
|
||||||
from pygui.backend.models.frame import Frame
|
from pygui.backend.models.frame import Frame
|
||||||
from pygui.backend.models.frame_player import FramePlayer, frames_from_wafer_data
|
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.sensor_editor import SensorEditor
|
||||||
from pygui.backend.models.session_model import SessionModel, SessionUpdate
|
from pygui.backend.models.session_model import SessionModel, SessionUpdate
|
||||||
from pygui.backend.models.threshold_classifier import ThresholdConfig
|
from pygui.backend.models.threshold_classifier import ThresholdConfig
|
||||||
from pygui.backend.utils import slot_error_boundary
|
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_models import Sensor
|
||||||
from pygui.backend.wafer.zwafer_parser import ZWaferParser
|
from pygui.backend.wafer.zwafer_parser import ZWaferParser
|
||||||
from pygui.serialcomm.stream_reader import StreamReader
|
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._liveError.connect(self._on_live_error, Qt.ConnectionType.QueuedConnection)
|
||||||
|
|
||||||
self._sensor_editor = SensorEditor()
|
self._sensor_editor = SensorEditor()
|
||||||
|
self._ec_pairs: tuple[tuple[int, int], ...] = ()
|
||||||
self._last_raw_frame: Frame | None = None
|
self._last_raw_frame: Frame | None = None
|
||||||
self._loaded_file: str = ""
|
self._loaded_file: str = ""
|
||||||
self._cluster_averaging_enabled = False
|
self._cluster_averaging_enabled = False
|
||||||
@@ -180,10 +183,26 @@ class SessionController(QObject):
|
|||||||
if not self._last:
|
if not self._last:
|
||||||
return {}
|
return {}
|
||||||
s = self._last.stats
|
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,
|
"max": round(s.max, 2), "maxIndex": s.max_index + 1,
|
||||||
"diff": round(s.diff, 2), "avg": round(s.avg, 2),
|
"diff": round(s.diff, 2), "avg": round(s.avg, 2),
|
||||||
"sigma": round(s.sigma, 2), "threeSigma": round(s.three_sigma, 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)
|
@Property(str, notify=loadedFileChanged)
|
||||||
def loadedFile(self) -> str: return self._loaded_file
|
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
|
wafer_id = stem.split("-")[0] if "-" in stem else stem
|
||||||
|
|
||||||
shape, size = resolve_shape_and_size(sensors, wafer_id)
|
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._sensors = WaferLayout(sensors, shape=shape, size=size)
|
||||||
self._active_clusters = getattr(self._sensors, 'clusters', [])
|
self._active_clusters = getattr(self._sensors, 'clusters', [])
|
||||||
if not self._active_clusters:
|
if not self._active_clusters:
|
||||||
@@ -303,6 +323,7 @@ class SessionController(QObject):
|
|||||||
"""Clear the loaded file, resetting the player and frame states."""
|
"""Clear the loaded file, resetting the player and frame states."""
|
||||||
self._player.load([])
|
self._player.load([])
|
||||||
self._model.reset()
|
self._model.reset()
|
||||||
|
self._ec_pairs = ()
|
||||||
self._loaded_file = ""
|
self._loaded_file = ""
|
||||||
self.loadedFileChanged.emit()
|
self.loadedFileChanged.emit()
|
||||||
self.sensorsChanged.emit()
|
self.sensorsChanged.emit()
|
||||||
@@ -652,6 +673,7 @@ class SessionController(QObject):
|
|||||||
self.sensorsChanged.emit()
|
self.sensorsChanged.emit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning("Could not load layout for %s: %s", family_code, 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', [])
|
self._active_clusters = getattr(self._sensors, 'clusters', [])
|
||||||
if not self._active_clusters:
|
if not self._active_clusters:
|
||||||
|
|||||||
@@ -14,6 +14,44 @@ class Stats:
|
|||||||
sigma: float; three_sigma: float
|
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:
|
def compute_stats(values: list[float]) -> Stats:
|
||||||
clean = [(i, v) for i, v in enumerate(values) if not math.isnan(v)]
|
clean = [(i, v) for i, v in enumerate(values) if not math.isnan(v)]
|
||||||
if not clean:
|
if not clean:
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ class SensorEditor:
|
|||||||
def has_overrides(self) -> bool:
|
def has_overrides(self) -> bool:
|
||||||
return bool(self._replacements or self._offsets)
|
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]:
|
def active_indices(self) -> list[int]:
|
||||||
"""Return sorted sensor indices that have any override."""
|
"""Return sorted sensor indices that have any override."""
|
||||||
return sorted(set(self._replacements) | set(self._offsets))
|
return sorted(set(self._replacements) | set(self._offsets))
|
||||||
|
|||||||
@@ -38,6 +38,49 @@ from pygui.backend.wafer.zwafer_models import Sensor
|
|||||||
log = logging.getLogger(__name__)
|
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:
|
def readout_text(values: list[float]) -> str:
|
||||||
"""Full READOUT stats line for the export footer; '' when no data."""
|
"""Full READOUT stats line for the export footer; '' when no data."""
|
||||||
if not values:
|
if not values:
|
||||||
@@ -84,7 +127,7 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
self._show_extremes: bool = True
|
self._show_extremes: bool = True
|
||||||
self._shape: str = "round"
|
self._shape: str = "round"
|
||||||
self._size: float = 300.0
|
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._show_thickness: bool = False
|
||||||
self._thickness_heatmap:QImage | None = None
|
self._thickness_heatmap:QImage | None = None
|
||||||
# Hover highlight: index of the marker under the pointer (-1 = none)
|
# Hover highlight: index of the marker under the pointer (-1 = none)
|
||||||
@@ -233,13 +276,31 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
|
|
||||||
@Property("QVariantList", notify=thicknessChanged)
|
@Property("QVariantList", notify=thicknessChanged)
|
||||||
def thicknessData(self) -> list:
|
def thicknessData(self) -> list:
|
||||||
|
"""Thickness measurement points as [x_mm, y_mm, t2] triples."""
|
||||||
return self._thickness_data
|
return self._thickness_data
|
||||||
|
|
||||||
@thicknessData.setter # type: ignore[no-redef]
|
@thicknessData.setter # type: ignore[no-redef]
|
||||||
def thicknessData(self, val:list) -> None:
|
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._rebuild_thickness()
|
||||||
self.thicknessChanged.emit()
|
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
|
self.update
|
||||||
|
|
||||||
@Property(bool, notify=showThicknessChanged)
|
@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)
|
painter.drawEllipse(px - ring_r, py - ring_r, 2 * ring_r, 2 * ring_r)
|
||||||
|
|
||||||
def _rebuild_thickness(self) -> None:
|
def _rebuild_thickness(self) -> None:
|
||||||
"""Interpolate thickness data into a gray/orange heatmap QImage."""
|
"""Interpolate thickness points into a gray/orange heatmap QImage."""
|
||||||
if not self._sensors or not self._thickness_data:
|
if not self._thickness_data:
|
||||||
self._thickness_heatmap = None
|
self._thickness_heatmap = None
|
||||||
return
|
return
|
||||||
ds = self._draw_size()
|
ds = self._draw_size()
|
||||||
r_mm = self._wafer_radius_mm()
|
r_mm = self._wafer_radius_mm()
|
||||||
xs = np.array([s.x for s in self._sensors])
|
pts = np.array(self._thickness_data, dtype=float)
|
||||||
ys = np.array([s.y for s in self._sensors])
|
xs, ys, vs = pts[:, 0], pts[:, 1], pts[:, 2]
|
||||||
vs = np.array(self._thickness_data[:len(self._sensors)], dtype=float)
|
|
||||||
if len(vs) < len(self._sensors):
|
|
||||||
self._thickness_heatmap = None
|
|
||||||
return
|
|
||||||
try:
|
try:
|
||||||
# No round_clip — see _rebuild_heatmap for why the mask is analytic.
|
# No round_clip — see _rebuild_heatmap for why the mask is analytic.
|
||||||
field = interpolate_field(
|
field = interpolate_field(
|
||||||
|
|||||||
@@ -70,12 +70,35 @@ def _load_yaml(path: Path) -> dict:
|
|||||||
return loaded
|
return loaded
|
||||||
|
|
||||||
|
|
||||||
# TODO P6.3: expose this list to a new LayoutSelector.qml (4-button chooser)
|
# Edge→center sensor pair tables, ported verbatim from the C# original
|
||||||
# THINKING: this already returns everything the UI needs (family names) —
|
# (Form1.cs ec_map_*). Indices are 0-origin. Domain data, not derivable from
|
||||||
# the missing piece is a context-property exposure (SessionController or a
|
# ring geometry — see docs/adr/0002-edge-center-pair-tables.md.
|
||||||
# thin new property) and a setLayout(family) slot that calls load_layout()
|
_EC_MAPS: dict[str, tuple[tuple[int, int], ...]] = {
|
||||||
# below. No parsing work needed; this is a wiring-only task.
|
"AEP": tuple((e, 40 + e // 2) for e in range(16)),
|
||||||
# See docs/pending/alpha-release-polish-plan.md §6.3.
|
"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]:
|
def available_families() -> list[str]:
|
||||||
return [_family_name(_load_yaml(p)["name"]) for p in _LAYOUTS_DIR.glob("*.yaml")]
|
return [_family_name(_load_yaml(p)["name"]) for p in _LAYOUTS_DIR.glob("*.yaml")]
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import math
|
|||||||
|
|
||||||
import pytest
|
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():
|
def test_basic_stat():
|
||||||
@@ -28,3 +33,43 @@ def test_ignores_nan():
|
|||||||
assert s.avg == pytest.approx(150.0)
|
assert s.avg == pytest.approx(150.0)
|
||||||
assert s.sigma == pytest.approx(1.0)
|
assert s.sigma == pytest.approx(1.0)
|
||||||
assert s.three_sigma == pytest.approx(3.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]
|
elapsed, avg = payload[0]
|
||||||
assert elapsed == pytest.approx(2.5)
|
assert elapsed == pytest.approx(2.5)
|
||||||
assert avg == pytest.approx(20.0)
|
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."""
|
"""Tests for src/pygui/backend/wafer_layouts.py."""
|
||||||
import pytest
|
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
|
from pygui.backend.wafer.zwafer_models import Sensor
|
||||||
|
|
||||||
|
|
||||||
@@ -20,3 +24,27 @@ def test_loads_sensors_in_mm():
|
|||||||
def test_unknown_family_raises():
|
def test_unknown_family_raises():
|
||||||
with pytest.raises(KeyError):
|
with pytest.raises(KeyError):
|
||||||
load_layout("nope")
|
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():
|
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
|
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||||||
|
|
||||||
item = WaferMapItem()
|
item = WaferMapItem()
|
||||||
item._rebuild_thickness = MagicMock()
|
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._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():
|
def test_show_thickness_setter():
|
||||||
|
|||||||
Reference in New Issue
Block a user