fix: gate DTW comparisons on wafer type + recording category; surface load errors

This commit is contained in:
jack
2026-07-06 15:41:29 -07:00
parent 1989ba8e5a
commit 0014ccc184
5 changed files with 272 additions and 23 deletions
+53 -4
View File
@@ -1,5 +1,6 @@
import QtQuick import QtQuick
import QtQuick.Controls import QtQuick.Controls
import QtQuick.Controls.impl
import QtQuick.Dialogs import QtQuick.Dialogs
import QtQuick.Layouts import QtQuick.Layouts
import ISC import ISC
@@ -30,11 +31,23 @@ Item {
// into the trend chart's data property. The slot parseJsonToData() is defined // into the trend chart's data property. The slot parseJsonToData() is defined
// on the Python TrendChartItem; we use it so malformed payloads are logged // on the Python TrendChartItem; we use it so malformed payloads are logged
// there instead of crashing the QML binding. // there instead of crashing the QML binding.
property string loadErrorMessage: ""
Connections { Connections {
target: streamController target: streamController
function onTrendData(avgsJson) { function onTrendData(avgsJson) {
trendChart.setDataFromJson(avgsJson); trendChart.setDataFromJson(avgsJson);
} }
// A file was picked from the rail (any tab) — loadFile() switches the
// backend to review mode; mirror that in the toolbar toggle so a
// successful load is never hidden behind a stale "Live" selection.
function onLoadedFileChanged() {
root.loadErrorMessage = "";
if (streamController.mode === "review")
modeBar.currentIndex = 0;
}
function onLoadFileError(message) {
root.loadErrorMessage = message;
}
} }
ColumnLayout { ColumnLayout {
@@ -118,12 +131,13 @@ Item {
RowLayout { RowLayout {
visible: streamController.mode === "live" visible: streamController.mode === "live"
spacing: 5 spacing: 5
// WiFi icon (Unicode approximation; swap for SVG if available) // Live status dot (matches the connection-flow indicator dot elsewhere)
Label { Rectangle {
id: liveIndicator id: liveIndicator
text: "◉" width: 8; height: 8
radius: 4
Layout.alignment: Qt.AlignVCenter
color: (deviceController.connectionStatus === "Connected" && streamController.state !== "idle") ? Theme.liveColor : Theme.bodyColor color: (deviceController.connectionStatus === "Connected" && streamController.state !== "idle") ? Theme.liveColor : Theme.bodyColor
font.pixelSize: Theme.fontSm
SequentialAnimation on opacity { SequentialAnimation on opacity {
running: deviceController.connectionStatus === "Connected" && streamController.state !== "idle" running: deviceController.connectionStatus === "Connected" && streamController.state !== "idle"
@@ -250,6 +264,41 @@ Item {
} }
} }
// ── Load error banner ───────────────────────────────────────────
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: errorRow.implicitHeight + 16
visible: root.loadErrorMessage !== ""
color: Theme.errorSurface
border.color: Theme.statusErrorColor
border.width: 1
radius: Theme.radiusSm
RowLayout {
id: errorRow
anchors.fill: parent
anchors.margins: 8
spacing: 8
IconImage {
source: "icons/triangle-alert.svg"
width: 16; height: 16
sourceSize.width: 16
sourceSize.height: 16
color: Theme.errorTextSoft
Layout.alignment: Qt.AlignTop
}
Label {
text: root.loadErrorMessage
color: Theme.errorTextSoft
font.pixelSize: Theme.fontSm
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
}
}
// ── Body: 2 columns (wafer + trend + transport | readout) ────────── // ── Body: 2 columns (wafer + trend + transport | readout) ──────────
RowLayout { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
+56 -13
View File
@@ -122,10 +122,12 @@ ColumnLayout {
} }
contentItem: RowLayout { contentItem: RowLayout {
spacing: 6 spacing: 6
Label { IconImage {
text: "▤" source: "../icons/folder.svg"
width: 14; height: 14
sourceSize.width: 14
sourceSize.height: 14
color: Theme.bodyColor color: Theme.bodyColor
font.pixelSize: Theme.fontSm
} }
Label { Label {
text: file_browser.currentDirectory.split("/").pop() || file_browser.currentDirectory text: file_browser.currentDirectory.split("/").pop() || file_browser.currentDirectory
@@ -204,7 +206,7 @@ ColumnLayout {
onClicked: streamController.loadFile(modelData.fileName) onClicked: streamController.loadFile(modelData.fileName)
background: Rectangle { background: Rectangle {
color: fileItem.isActive ? Qt.rgba(1, 1, 1, 0.06) : fileItem.hovered ? Qt.rgba(1, 1, 1, 0.04) : "transparent" color: fileItem.isActive ? Theme.listActiveBackground : fileItem.hovered ? Theme.listHoverBackground : "transparent"
radius: Theme.radiusSm radius: Theme.radiusSm
Behavior on color { Behavior on color {
ColorAnimation { duration: Theme.durationFast } ColorAnimation { duration: Theme.durationFast }
@@ -225,11 +227,11 @@ ColumnLayout {
color: { color: {
var t = modelData.waferType; var t = modelData.waferType;
if (t === "A" || t === "E" || t === "P") if (t === "A" || t === "E" || t === "P")
return "#3B82F6"; return Theme.familyBlueAccent;
if (t === "B" || t === "C" || t === "D") if (t === "B" || t === "C" || t === "D")
return "#10B981"; return Theme.familyGreenAccent;
if (t === "Z") if (t === "Z")
return "#8B5CF6"; return Theme.familyVioletAccent;
return Theme.headingColor; return Theme.headingColor;
} }
} }
@@ -249,19 +251,36 @@ ColumnLayout {
color: { color: {
var t = modelData.waferType; var t = modelData.waferType;
if (t === "A" || t === "E" || t === "P") if (t === "A" || t === "E" || t === "P")
return "#1D4ED8"; return Theme.familyBlueFill;
if (t === "B" || t === "C" || t === "D") if (t === "B" || t === "C" || t === "D")
return "#065F46"; return Theme.familyGreenFill;
if (t === "Z") if (t === "Z")
return "#7C3AED"; return Theme.familyVioletFill;
return "#374151"; return Theme.familyNeutralFill;
} }
Label { Label {
anchors.centerIn: parent anchors.centerIn: parent
text: modelData.waferType || "?" text: modelData.waferType || "?"
font.pixelSize: Theme.fontMd font.pixelSize: Theme.fontMd
font.weight: Font.Bold font.weight: Font.Bold
color: "#FFFFFF" color: Theme.textOnColor
}
// Recording indicator dot — distinguishes a live-recorded CSV
// (SessionController.startRecording) from a read-memory dump
// (DeviceController.parseAndSaveData).
Rectangle {
visible: modelData.isRecording === true
width: 10
height: 10
radius: 5
color: Theme.recordColor
border.color: Theme.subtleSectionBackground
border.width: 1.5
anchors.top: parent.top
anchors.right: parent.right
anchors.topMargin: -2
anchors.rightMargin: -2
} }
} }
@@ -270,7 +289,11 @@ ColumnLayout {
spacing: 1 spacing: 1
Layout.fillWidth: true Layout.fillWidth: true
// Primary: wafer type + serial number // Primary: wafer type + serial number, plus a REC badge for recordings
RowLayout {
Layout.fillWidth: true
spacing: 6
Label { Label {
text: modelData.serialNumber ? (modelData.waferType + modelData.serialNumber) : modelData.baseName text: modelData.serialNumber ? (modelData.waferType + modelData.serialNumber) : modelData.baseName
color: Theme.headingColor color: Theme.headingColor
@@ -280,6 +303,26 @@ ColumnLayout {
Layout.fillWidth: true Layout.fillWidth: true
} }
Rectangle {
visible: modelData.isRecording === true
radius: 4
color: Qt.alpha(Theme.recordColor, 0.18)
implicitWidth: recLabel.implicitWidth + 10
implicitHeight: recLabel.implicitHeight + 4
Layout.alignment: Qt.AlignVCenter
Label {
id: recLabel
anchors.centerIn: parent
text: "REC"
color: Theme.recordColor
font.pixelSize: Theme.font2xs
font.bold: true
font.letterSpacing: 0.6
}
}
}
// Secondary: date · time // Secondary: date · time
RowLayout { RowLayout {
spacing: 3 spacing: 3
+7
View File
@@ -119,6 +119,11 @@ class FileBrowser(QObject):
return return
for csv_path in sorted(self._current_directory.glob("*.csv")): for csv_path in sorted(self._current_directory.glob("*.csv")):
# Recorded-live CSVs are named "live_<serial>_<timestamp>.csv" by
# SessionController.startRecording (see WaferMapTab's Record button),
# distinct from the "<serial>-<timestamp>.csv" convention used by
# DeviceController.parseAndSaveData for read-memory dumps.
is_recording = csv_path.stem.lower().startswith("live_")
try: try:
metadata = self._load_metadata(csv_path) metadata = self._load_metadata(csv_path)
wafer_type = metadata.get_wafer_type().upper() or (csv_path.stem[0].upper() if csv_path.stem else "") wafer_type = metadata.get_wafer_type().upper() or (csv_path.stem[0].upper() if csv_path.stem else "")
@@ -146,6 +151,7 @@ class FileBrowser(QObject):
"masterType": master_state.get(str(csv_path), "") or metadata.master_type, "masterType": master_state.get(str(csv_path), "") or metadata.master_type,
"fileName": str(csv_path), "fileName": str(csv_path),
"highlight": wafer_type in {"A", "B", "C"}, "highlight": wafer_type in {"A", "B", "C"},
"isRecording": is_recording,
} }
) )
except Exception: except Exception:
@@ -169,6 +175,7 @@ class FileBrowser(QObject):
"masterType": master_state.get(str(csv_path), ""), "masterType": master_state.get(str(csv_path), ""),
"fileName": str(csv_path), "fileName": str(csv_path),
"highlight": False, "highlight": False,
"isRecording": is_recording,
} }
) )
+30
View File
@@ -0,0 +1,30 @@
from pathlib import Path
from pygui.backend.data.file_browser import FileBrowser
def _write_csv(path, wafer_id="P00001"):
path.write_text(
f"Wafer ID={wafer_id}\n"
"Acquisition Date=03/26/2025\n"
"Label,1,2,3\n"
"X (mm),0,10,-10\n"
"Y (mm),10,-10,-10\n"
"data\n"
"0.0,149.0,148.5,150.6\n"
)
def test_recorded_live_file_flagged_as_recording(qapp, tmp_path):
"""Files named live_<serial>_<timestamp>.csv (SessionController.startRecording)
must be distinguishable in the browser from read-memory dumps."""
_write_csv(tmp_path / "live_P00001_20260706_143022.csv")
_write_csv(tmp_path / "P00002-20260706_143500.csv")
browser = FileBrowser()
browser._set_current_directory(tmp_path)
browser.refreshFiles()
files_by_name = {Path(row["fileName"]).name: row for row in browser.files}
assert files_by_name["live_P00001_20260706_143022.csv"]["isRecording"] is True
assert files_by_name["P00002-20260706_143500.csv"]["isRecording"] is False
+120
View File
@@ -47,6 +47,36 @@ def test_pause_stop_timer(controller):
controller.stop() controller.stop()
assert controller.frameIndex == 0 assert controller.frameIndex == 0
def test_load_file_emits_error_for_empty_recording(controller, tmp_path):
"""A recording with a valid header but zero data rows (e.g. Record was
started/stopped without any live frames arriving) must surface a clear
loadFileError instead of silently doing nothing."""
empty_recording = tmp_path / "live_A00001_20260706_120427.csv"
empty_recording.write_text(
"Wafer ID=A00001\n"
"Acquisition Date=07/06/2026\n"
"Label,1,2\n"
"X (mm),0.0,10.0\n"
"Y (mm),10.0,-10.0\n"
"data\n"
)
mock_slot = MagicMock()
controller.loadFileError.connect(mock_slot)
controller.loadFile(str(empty_recording))
assert mock_slot.call_count == 1
assert "no recorded frames" in mock_slot.call_args[0][0]
assert controller.loadedFile == ""
def test_load_file_switches_to_review_mode(controller):
"""Loading a file for playback must always land in review mode, even if
the controller was left in live mode from a previous session — otherwise
a successful load can be invisible behind a stale Live toggle."""
controller._mode = "live"
controller.loadFile("tests/fixtures/sample_stream.csv")
assert controller.mode == "review"
def test_compare_files_integration(controller): def test_compare_files_integration(controller):
# Mock the comparisonResult signal # Mock the comparisonResult signal
mock_slot = MagicMock() mock_slot = MagicMock()
@@ -65,6 +95,96 @@ def test_compare_files_integration(controller):
assert "series_b" in args assert "series_b" in args
assert args["frame_offset"] == 0 assert args["frame_offset"] == 0
def test_compare_files_uses_full_run_not_first_100_frames(controller, tmp_path):
"""compareFiles must score the whole run, not just an initial 100-frame window.
Regression test: max_frames used to be hard-capped at 100, so a >100-frame
run's soak/cool phases were silently excluded from the DTW comparison.
"""
num_frames = 150
csv_path = tmp_path / "long_run.csv"
lines = ["Wafer ID=A12", "Acquisition Date=03/26/2025", "Label,1,2,3",
"X (mm),0,10,-10", "Y (mm),10,-10,-10", "data"]
for i in range(num_frames):
t = i * 0.5
lines.append(f"{t},{149.0 + i * 0.01},{148.5 + i * 0.01},{150.6 + i * 0.01}")
csv_path.write_text("\n".join(lines) + "\n")
mock_slot = MagicMock()
controller.comparisonResult.connect(mock_slot)
controller.compareFiles(str(csv_path), str(csv_path))
assert mock_slot.call_count == 1
result = mock_slot.call_args[0][0]
assert result["success"] is True
assert len(result["series_a"]) == num_frames
assert len(result["series_b"]) == num_frames
def test_compare_files_rejects_mismatched_wafer_types(controller, tmp_path):
"""compareFiles must refuse to compare two different wafer families.
Otherwise the overlap view silently truncates to min(sensor_count) and
pairs up unrelated physical sensors between the two wafer types.
"""
header = ["Wafer ID=A12", "Acquisition Date=03/26/2025", "Label,1,2,3",
"X (mm),0,10,-10", "Y (mm),10,-10,-10", "data",
"0.0,149.0,148.5,150.6"]
file_a = tmp_path / "A00055-20250326.csv"
file_b = tmp_path / "X00108-20250326.csv"
file_a.write_text("\n".join(header) + "\n")
file_b.write_text("\n".join(header) + "\n")
mock_slot = MagicMock()
controller.comparisonResult.connect(mock_slot)
controller.compareFiles(str(file_a), str(file_b))
assert mock_slot.call_count == 1
result = mock_slot.call_args[0][0]
assert result["success"] is False
assert "A" in result["error"] and "X" in result["error"]
def test_compare_files_rejects_recording_vs_read_mem(controller, tmp_path):
"""A live_-prefixed recording must not be compared against a read-memory
dump. Regression: stem[0] on "live_P0001_..." is "L", which used to be
misreported as a wafer-type mismatch ("L vs P") instead of this clearer
recording-vs-read-mem error.
"""
header = ["Wafer ID=P0001", "Acquisition Date=03/26/2025", "Label,1,2,3",
"X (mm),0,10,-10", "Y (mm),10,-10,-10", "data",
"0.0,149.0,148.5,150.6"]
recording = tmp_path / "live_P0001_20260706_143022.csv"
read_mem = tmp_path / "P0001-20260706_143500.csv"
recording.write_text("\n".join(header) + "\n")
read_mem.write_text("\n".join(header) + "\n")
mock_slot = MagicMock()
controller.comparisonResult.connect(mock_slot)
controller.compareFiles(str(recording), str(read_mem))
assert mock_slot.call_count == 1
result = mock_slot.call_args[0][0]
assert result["success"] is False
assert result["error"] == "Cannot compare a recording file with a read-memory file"
def test_compare_files_allows_two_recordings_of_same_wafer_type(controller, tmp_path):
"""Two live recordings of the same wafer family should compare fine —
the "live_" prefix must not be mistaken for the wafer type letter."""
header = ["Wafer ID=P0001", "Acquisition Date=03/26/2025", "Label,1,2,3",
"X (mm),0,10,-10", "Y (mm),10,-10,-10", "data",
"0.0,149.0,148.5,150.6"]
file_a = tmp_path / "live_P0001_20260706_143022.csv"
file_b = tmp_path / "live_P0002_20260706_150000.csv"
file_a.write_text("\n".join(header) + "\n")
file_b.write_text("\n".join(header) + "\n")
mock_slot = MagicMock()
controller.comparisonResult.connect(mock_slot)
controller.compareFiles(str(file_a), str(file_b))
assert mock_slot.call_count == 1
result = mock_slot.call_args[0][0]
assert result["success"] is True
def test_comparison_result_reaches_qml(qapp, controller): def test_comparison_result_reaches_qml(qapp, controller):
"""The result dict must arrive in QML as a real JS object with fields. """The result dict must arrive in QML as a real JS object with fields.