fix: gate DTW comparisons on wafer type + recording category; surface load errors
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user