From 92f130b3bdfc4ad491a07a0a25819ef28e89a563 Mon Sep 17 00:00:00 2001 From: jack Date: Tue, 7 Jul 2026 15:05:22 -0700 Subject: [PATCH] feat: implement master file configuration, UI indicators, and a Run Comparison tool for the Data tab --- src/pygui/ISC/HomePage.qml | 294 +++++++- src/pygui/ISC/Tabs/DataTab.qml | 665 +++++++++--------- src/pygui/ISC/Tabs/WaferMapTab.qml | 94 ++- src/pygui/ISC/Tabs/components/SourcePanel.qml | 66 +- src/pygui/ISC/Theme.qml | 2 +- .../backend/controllers/session_controller.py | 62 +- .../backend/visualization/trend_chart_item.py | 31 +- .../backend/visualization/wafer_map_item.py | 12 +- 8 files changed, 798 insertions(+), 428 deletions(-) diff --git a/src/pygui/ISC/HomePage.qml b/src/pygui/ISC/HomePage.qml index b96a336..0c64155 100644 --- a/src/pygui/ISC/HomePage.qml +++ b/src/pygui/ISC/HomePage.qml @@ -181,8 +181,6 @@ Rectangle { Layout.preferredWidth: Theme.sideRailWidth Layout.fillHeight: true color: Theme.sideRailBackground - border.color: Theme.sideBorder - border.width: 1 ColumnLayout { anchors.fill: parent @@ -290,6 +288,286 @@ Rectangle { } } + // ── BOX 1.5: Run Comparison (Data tab only) ──────────────── + Rectangle { + id: runComparisonBox + Layout.fillWidth: true + Layout.preferredHeight: runCompCol.implicitHeight + 28 + visible: root.selectedTabIndex === 1 + radius: Theme.sidePanelRadius + border.color: Theme.sideBorder + border.width: 1 + color: Theme.sidePanelBackground + + readonly property var dataTab: dataTabLoader.item + + component RunSlot: Rectangle { + id: slotBox + property string title + property string filePath: "" + property color accent: Theme.primaryAccent + property bool active: false + property bool locked: false + property string placeholderText: "Click to select" + signal cleared() + signal armed() + + Layout.fillWidth: true + implicitHeight: 58 + radius: Theme.radiusSm + color: Theme.fieldBackground + opacity: slotBox.locked ? 0.65 : 1.0 + border.width: slotBox.active ? 2 : 1 + border.color: slotBox.active ? accent : Theme.sideBorder + + Behavior on border.color { ColorAnimation { duration: Theme.durationFast } } + + MouseArea { + anchors.fill: parent + enabled: !slotBox.locked + cursorShape: slotBox.locked ? Qt.ArrowCursor : Qt.PointingHandCursor + onClicked: slotBox.armed() + } + + RowLayout { + anchors.fill: parent + anchors.margins: 10 + spacing: 8 + + Rectangle { + width: 8; height: 8; radius: 4 + color: slotBox.accent + Layout.alignment: Qt.AlignVCenter + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + Label { + text: slotBox.title + font.pixelSize: Theme.fontXs + font.bold: true + font.letterSpacing: 1.0 + color: slotBox.active ? slotBox.accent : Theme.sideMutedText + } + Label { + text: slotBox.filePath !== "" + ? slotBox.filePath.split("/").pop() + : (slotBox.locked ? slotBox.placeholderText : (slotBox.active ? "<- Select file below..." : "Click to select")) + font.pixelSize: Theme.fontSm + color: slotBox.filePath !== "" ? Theme.headingColor : Theme.bodyColor + opacity: slotBox.filePath !== "" ? 1.0 : 0.6 + elide: Text.ElideMiddle + Layout.fillWidth: true + } + } + + Button { + visible: slotBox.filePath !== "" && !slotBox.locked + flat: true + implicitWidth: 24; implicitHeight: 24 + onClicked: slotBox.cleared() + background: Rectangle { + radius: Theme.radiusSm + color: parent.hovered ? Theme.flatButtonHover : "transparent" + } + contentItem: IconImage { + source: "Tabs/icons/x.svg" + width: 14; height: 14 + sourceSize.width: 14 + sourceSize.height: 14 + color: Theme.bodyColor + anchors.centerIn: parent + } + } + } + } + + component PanelCheckBox: CheckBox { + id: toggle + indicator: Rectangle { + implicitWidth: 18 + implicitHeight: 18 + x: toggle.leftPadding + y: parent.height / 2 - height / 2 + radius: Theme.radiusXs + color: toggle.checked ? Theme.primaryAccent : "transparent" + border.width: Theme.borderThin + border.color: toggle.checked ? Theme.primaryAccent : Theme.fieldBorder + + Behavior on color { ColorAnimation { duration: Theme.durationFast } } + Behavior on border.color { ColorAnimation { duration: Theme.durationFast } } + + IconImage { + anchors.centerIn: parent + source: "Tabs/icons/check.svg" + width: 12; height: 12 + sourceSize.width: 12 + sourceSize.height: 12 + color: Theme.panelBackground + opacity: toggle.checked ? 1.0 : 0.0 + Behavior on opacity { NumberAnimation { duration: Theme.durationFast } } + } + } + + contentItem: Text { + text: toggle.text + color: Theme.headingColor + font.family: Theme.uiFontFamily + verticalAlignment: Text.AlignVCenter + leftPadding: toggle.indicator.width + toggle.spacing + font.pixelSize: toggle.font.pixelSize || Theme.fontXs + } + } + + ColumnLayout { + id: runCompCol + anchors.fill: parent + anchors.margins: 14 + spacing: 10 + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Label { + text: "RUN COMPARISON (DTW)" + color: Theme.sideMutedText + font.family: Theme.uiFontFamily + font.pixelSize: Theme.fontSm + font.letterSpacing: 1.5 + font.bold: true + Layout.fillWidth: true + elide: Text.ElideRight + } + + Button { + id: resetRunBtn + flat: true + implicitWidth: 32 + implicitHeight: 32 + visible: true + onClicked: if (runComparisonBox.dataTab) runComparisonBox.dataTab.resetComparison() + + background: Rectangle { + radius: Theme.radiusSm + color: resetRunBtn.hovered ? Theme.flatButtonHover : "transparent" + } + contentItem: IconImage { + source: "Tabs/icons/rotate-ccw.svg" + width: 18; height: 18 + sourceSize.width: 18 + sourceSize.height: 18 + color: Theme.bodyColor + anchors.centerIn: parent + } + ToolTip.visible: resetRunBtn.hovered + ToolTip.text: "Reset comparison" + } + } + + PanelCheckBox { + id: useMasterCheck + Layout.fillWidth: true + text: "Compare vs Master File" + checked: runComparisonBox.dataTab ? runComparisonBox.dataTab.useMasterFile : false + onToggled: { + if (!runComparisonBox.dataTab) return + runComparisonBox.dataTab.useMasterFile = checked + if (checked) runComparisonBox.dataTab.activeBox = 2 + } + } + + RunSlot { + readonly property bool useMaster: runComparisonBox.dataTab ? runComparisonBox.dataTab.useMasterFile : false + title: useMaster ? "MASTER FILE" : "REFERENCE (RUN A)" + accent: Theme.primaryAccent + locked: useMaster + placeholderText: useMaster + ? (runComparisonBox.dataTab.runBWaferType !== "" + ? "No master file for type " + runComparisonBox.dataTab.runBWaferType + : "Select Run B to resolve master") + : "Click to select" + active: runComparisonBox.dataTab ? (!useMaster && runComparisonBox.dataTab.activeBox === 1) : false + filePath: runComparisonBox.dataTab + ? (useMaster ? runComparisonBox.dataTab.masterFileForRunB : runComparisonBox.dataTab.compareFileA) + : "" + onArmed: if (runComparisonBox.dataTab && !useMaster) runComparisonBox.dataTab.activeBox = 1 + onCleared: { + if (!runComparisonBox.dataTab) return + runComparisonBox.dataTab.compareFileA = "" + runComparisonBox.dataTab.activeBox = 1 + runComparisonBox.dataTab.clearResults() + } + } + + RunSlot { + title: "SESSION (RUN B)" + accent: Theme.themeSkill + active: runComparisonBox.dataTab ? runComparisonBox.dataTab.activeBox === 2 : false + filePath: runComparisonBox.dataTab ? runComparisonBox.dataTab.compareFileB : "" + onArmed: if (runComparisonBox.dataTab) runComparisonBox.dataTab.activeBox = 2 + onCleared: { + if (!runComparisonBox.dataTab) return + runComparisonBox.dataTab.compareFileB = "" + runComparisonBox.dataTab.activeBox = 2 + runComparisonBox.dataTab.clearResults() + } + } + + Button { + id: compareRunBtn + Layout.fillWidth: true + Layout.preferredHeight: 32 + readonly property var dt: runComparisonBox.dataTab + readonly property string fileA: !!dt ? (dt.useMasterFile ? dt.masterFileForRunB : dt.compareFileA) : "" + readonly property bool masterMissing: !!dt && dt.useMasterFile && dt.masterFileForRunB === "" + readonly property bool sameFile: !!dt && fileA !== "" && fileA === dt.compareFileB + enabled: !!dt && fileA !== "" && dt.compareFileB !== "" && !sameFile && !dt.comparing && !masterMissing + ToolTip.visible: (sameFile || masterMissing) && hovered + ToolTip.text: masterMissing + ? "No master file registered for this wafer type (Settings → Master Files)" + : "Choose two different runs to compare" + onClicked: { + dt.comparing = true + dt.clearResults() + streamController.compareFiles(fileA, dt.compareFileB) + } + + background: Rectangle { + radius: Theme.radiusSm + color: compareRunBtn.enabled + ? (compareRunBtn.hovered ? Qt.lighter(Theme.primaryAccent, 1.1) : Theme.primaryAccent) + : Theme.buttonNeutralBackground + Behavior on color { ColorAnimation { duration: Theme.durationFast } } + } + contentItem: Row { + spacing: 8 + anchors.centerIn: parent + Label { + anchors.verticalCenter: parent.verticalCenter + text: !compareRunBtn.dt ? "Select both runs" + : compareRunBtn.dt.comparing + ? "Comparing…" + : compareRunBtn.enabled + ? "Run DTW Comparison" + : (compareRunBtn.masterMissing ? "No master for this type" + : compareRunBtn.sameFile ? "Choose different runs" : "Select both runs") + color: compareRunBtn.enabled ? Theme.tone100 : Theme.sideMutedText + font.pixelSize: Theme.fontSm + font.bold: true + } + BusyIndicator { + running: !!compareRunBtn.dt && compareRunBtn.dt.comparing + visible: !!compareRunBtn.dt && compareRunBtn.dt.comparing + width: 14; height: 14 + anchors.verticalCenter: parent.verticalCenter + } + } + } + } + } + // ── BOX 2: Context-Sensitive Rail Panel ──────────────────── Rectangle { Layout.fillWidth: true @@ -363,20 +641,23 @@ Rectangle { } // ── BOX 3: Hardware Status Footer ────────────────────────── + // Not relevant on the Data tab — Run Comparison takes its place above. Rectangle { Layout.fillWidth: true Layout.preferredHeight: Theme.sideFooterConnection + visible: root.selectedTabIndex !== 1 radius: Theme.sidePanelRadius border.color: Theme.sideBorder border.width: 1 color: Theme.sidePanelBackground + clip: true ColumnLayout { anchors.fill: parent anchors.margins: 14 spacing: 6 - // Row 1: Title + status badge (unchanged) + // Row 1: Title + status badge RowLayout { spacing: 8 Layout.fillWidth: true @@ -388,12 +669,15 @@ Rectangle { font.pixelSize: Theme.fontSm font.letterSpacing: 1.5 font.bold: true + Layout.fillWidth: true + elide: Text.ElideRight } Rectangle { height: 18 width: badgeLabel.implicitWidth + 14 radius: 9 + Layout.alignment: Qt.AlignVCenter color: { var s = deviceController.connectionStatus if (s === "Connected") return Theme.statusSuccessColor @@ -410,8 +694,6 @@ Rectangle { font.bold: true } } - - Item { Layout.fillWidth: true } } // Row 2: Status dot + descriptive text (replaces dotted trail) @@ -595,8 +877,6 @@ Rectangle { Layout.fillWidth: true Layout.fillHeight: true color: Theme.workspaceBackground - border.color: Theme.workspaceBorder - border.width: 1 StackLayout { anchors.fill: parent diff --git a/src/pygui/ISC/Tabs/DataTab.qml b/src/pygui/ISC/Tabs/DataTab.qml index 6980699..b9258ec 100644 --- a/src/pygui/ISC/Tabs/DataTab.qml +++ b/src/pygui/ISC/Tabs/DataTab.qml @@ -20,6 +20,23 @@ Item { property int activeBox: 1 // 1 for Reference (Run A), 2 for Session (Run B), 0 for inactive property string compareFileA: "" property string compareFileB: "" + property bool useMasterFile: false // Run A comes from settingsModel.masters[runBWaferType] instead of manual selection + + function waferTypeForFile(path) { + if (!path) return "" + var rows = file_browser.files + for (var i = 0; i < rows.length; i++) { + if (rows[i].fileName === path) return (rows[i].waferType || "").toUpperCase() + } + var base = path.split("/").pop() + return base ? base.charAt(0).toUpperCase() : "" + } + + // Master file must match Run B's wafer family — looking it up by that + // family is itself the comparability gate (a mismatched-family master + // simply won't exist under this key). + readonly property string runBWaferType: root.waferTypeForFile(root.compareFileB) + readonly property string masterFileForRunB: root.runBWaferType !== "" ? (settingsModel.masters[root.runBWaferType] || "") : "" property real warpingDistance: -1 property int frameOffset: 0 property real frameOffsetSeconds: 0 @@ -57,6 +74,27 @@ Item { return t !== null ? " (" + t.toFixed(1) + "s)" : "" } + function formatMinutes(secondsValue) { + var mins = Math.floor(secondsValue / 60) + var secs = secondsValue % 60 + return mins + ":" + (secs < 10 ? "0" : "") + secs.toFixed(1) + } + + function frameForTime(t) { + var times = root.timeDataA.length > 0 ? root.timeDataA : root.timeDataB + if (times.length === 0) return 0 + var closestIdx = 0 + var minDiff = Math.abs(times[0] - t) + for (var i = 1; i < times.length; i++) { + var diff = Math.abs(times[i] - t) + if (diff < minDiff) { + minDiff = diff + closestIdx = i + } + } + return closestIdx + } + function clearResults() { root.warpingDistance = -1 root.frameOffset = 0 @@ -78,6 +116,7 @@ Item { function resetComparison() { root.compareFileA = "" root.compareFileB = "" + root.useMasterFile = false root.comparing = false root.activeBox = 1 clearResults() @@ -124,6 +163,7 @@ Item { root.compareWaferSize = result.wafer_size || 300.0 root.errorMessage = "" scrubber.value = Math.floor(root.seriesLen / 2) + scrubber.forceActiveFocus() if (root.frameCountA !== root.frameCountB) { var timeA = root.timeDataA.length > 0 ? root.timeDataA[root.timeDataA.length - 1] : 0 var timeB = root.timeDataB.length > 0 ? root.timeDataB[root.timeDataB.length - 1] : 0 @@ -213,34 +253,33 @@ Item { } } - // Mini stat box under the alignment scrubber (mockup's subtle-box trio) + // Mini stat box under the alignment scrubber (styled to match DTW ReadoutCard) component ScrubStat: Rectangle { id: scrubStat property string label property string value property color valueColor: Theme.headingColor Layout.fillWidth: true - implicitHeight: 38 - radius: 4 - color: Theme.cardWash + implicitHeight: 52 + radius: Theme.radiusSm + color: Theme.subtleSectionBackground border.color: Theme.cardBorder border.width: 1 ColumnLayout { anchors.fill: parent - anchors.leftMargin: 6 - anchors.rightMargin: 6 - anchors.topMargin: 4 - anchors.bottomMargin: 4 - spacing: 0 + anchors.margins: 8 + spacing: 2 Label { - text: scrubStat.label - font.pixelSize: 9 + text: scrubStat.label.toUpperCase() + font.pixelSize: Theme.fontXs + font.bold: true + font.letterSpacing: 0.2 color: Theme.sideMutedText } Label { text: scrubStat.value - font.pixelSize: Theme.fontSm + font.pixelSize: Theme.fontLg font.bold: true font.family: Theme.codeFontFamily color: scrubStat.valueColor @@ -248,6 +287,111 @@ Item { } } + // Editable stat box for scrubber readout (styled to match DTW ReadoutCard) + component EditableScrubStat: Rectangle { + id: edStat + property string label + property real value + property string unit + property string extraText: "" + property bool editable: true + signal valueEdited(real newValue) + + Layout.fillWidth: true + implicitHeight: edStatRow.implicitHeight + 16 + radius: Theme.radiusSm + color: Theme.subtleSectionBackground + border.color: (edStat.editable && inputField.activeFocus) ? Theme.fieldBorderFocus : Theme.cardBorder + border.width: (edStat.editable && inputField.activeFocus) ? Theme.borderStrong : Theme.borderThin + + RowLayout { + id: edStatRow + anchors.fill: parent + anchors.margins: 8 + spacing: 6 + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + Label { + text: edStat.label.toUpperCase() + font.pixelSize: Theme.fontXs + font.bold: true + font.letterSpacing: 0.2 + color: Theme.sideMutedText + } + RowLayout { + spacing: 4 + TextField { + id: inputField + readOnly: !edStat.editable + hoverEnabled: edStat.editable + font.pixelSize: Theme.fontLg + font.bold: true + font.family: Theme.codeFontFamily + color: edStat.editable ? Theme.headingColor : Theme.sideMutedText + verticalAlignment: TextInput.AlignVCenter + horizontalAlignment: TextInput.AlignHCenter + background: Rectangle { + visible: edStat.editable + color: Theme.fieldBackground + radius: Theme.radiusXs + border.color: inputField.activeFocus ? Theme.fieldBorderFocus : (inputField.hovered ? Theme.fieldBorderFocus : Theme.fieldBorder) + border.width: 1 + } + padding: 0 + leftPadding: edStat.editable ? 6 : 0 + rightPadding: edStat.editable ? 6 : 0 + topPadding: edStat.editable ? 5 : 0 + bottomPadding: edStat.editable ? 5 : 0 + selectByMouse: edStat.editable + inputMethodHints: edStat.label === "Time" ? Qt.ImhFormattedNumbersOnly : Qt.ImhDigitsOnly + Layout.preferredWidth: edStat.editable ? Math.max(36, contentWidth + 12) : contentWidth + Layout.preferredHeight: edStat.editable ? 32 : implicitHeight + + text: edStat.value.toFixed(edStat.label === "Time" ? 1 : 0) + + onEditingFinished: { + var val = parseFloat(text) + if (!isNaN(val)) { + edStat.valueEdited(val) + } + text = edStat.value.toFixed(edStat.label === "Time" ? 1 : 0) + scrubber.forceActiveFocus() + } + + Connections { + target: edStat + function onValueChanged() { + if (!inputField.activeFocus) { + inputField.text = edStat.value.toFixed(edStat.label === "Time" ? 1 : 0) + } + } + } + } + Label { + text: edStat.unit + font.pixelSize: Theme.fontLg + font.bold: true + font.family: Theme.codeFontFamily + color: edStat.editable ? Theme.headingColor : Theme.sideMutedText + Layout.alignment: Qt.AlignVCenter + } + } + } + + Label { + visible: edStat.extraText !== "" + text: edStat.extraText + font.pixelSize: Theme.fontLg + font.bold: true + font.family: Theme.codeFontFamily + color: Theme.headingColor + Layout.alignment: Qt.AlignVCenter + } + } + } + // Large labeled value card for the DTW readout panel component ReadoutCard: Rectangle { id: readoutCard @@ -282,128 +426,13 @@ Item { } } - // Run slot card: click to arm, then pick a file in the left rail's browser - component RunSlot: Rectangle { - id: slotBox - property int boxIndex: 1 - property string title - property string filePath: "" - property color accent: Theme.primaryAccent - signal cleared() - - Layout.fillWidth: true - implicitHeight: 58 - radius: Theme.radiusSm - color: Theme.fieldBackground - border.width: root.activeBox === boxIndex ? 2 : 1 - border.color: root.activeBox === boxIndex ? accent : Theme.cardBorder - - Behavior on border.color { ColorAnimation { duration: Theme.durationFast } } - - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: root.activeBox = slotBox.boxIndex - } - - RowLayout { - anchors.fill: parent - anchors.margins: 10 - spacing: 8 - - Rectangle { - width: 8; height: 8; radius: 4 - color: slotBox.accent - Layout.alignment: Qt.AlignVCenter - } - - ColumnLayout { - Layout.fillWidth: true - spacing: 2 - Label { - text: slotBox.title - font.pixelSize: Theme.fontXs - font.bold: true - font.letterSpacing: 1.0 - color: root.activeBox === slotBox.boxIndex ? slotBox.accent : Theme.sideMutedText - } - Label { - text: slotBox.filePath !== "" - ? root.shortName(slotBox.filePath) - : (root.activeBox === slotBox.boxIndex ? "<- Select file from left panel..." : "Click to select") - font.pixelSize: Theme.fontSm - color: slotBox.filePath !== "" ? Theme.headingColor : Theme.bodyColor - opacity: slotBox.filePath !== "" ? 1.0 : 0.6 - elide: Text.ElideMiddle - Layout.fillWidth: true - } - } - - Button { - visible: slotBox.filePath !== "" - flat: true - implicitWidth: 24; implicitHeight: 24 - onClicked: slotBox.cleared() - background: Rectangle { - radius: Theme.radiusSm - color: parent.hovered ? Theme.flatButtonHover : "transparent" - } - contentItem: IconImage { - source: "icons/x.svg" - width: 14; height: 14 - sourceSize.width: 14 - sourceSize.height: 14 - color: Theme.bodyColor - anchors.centerIn: parent - } - } - } - } - // ── Main Layout ─────────────────────────────────────────────── ColumnLayout { anchors.fill: parent anchors.margins: Theme.panelPadding spacing: Theme.rightPaneGap - // ── Title / Header ───────────────────────────────────────── - RowLayout { - Layout.fillWidth: true - spacing: 8 - Label { - text: "Run Comparison (DTW)" - font.pixelSize: Theme.fontXl - font.bold: true - color: Theme.headingColor - Layout.fillWidth: true - elide: Text.ElideRight - } - - Button { - id: resetBtn - flat: true - implicitWidth: 32 - implicitHeight: 32 - visible: root.compareFileA !== "" || root.compareFileB !== "" - onClicked: root.resetComparison() - - background: Rectangle { - radius: Theme.radiusSm - color: resetBtn.hovered ? Theme.flatButtonHover : "transparent" - } - contentItem: IconImage { - source: "icons/rotate-ccw.svg" - width: 18; height: 18 - sourceSize.width: 18 - sourceSize.height: 18 - color: Theme.bodyColor - anchors.centerIn: parent - } - ToolTip.visible: resetBtn.hovered - ToolTip.text: "Reset comparison" - } - } // ── Error Banner ─────────────────────────────────────────── Rectangle { @@ -454,6 +483,79 @@ Item { Layout.minimumWidth: 350 spacing: 12 + // Wafer Map Overlap View card + PanelBox { + Layout.fillWidth: true + Layout.fillHeight: true + Layout.minimumHeight: 220 + + ColumnLayout { + anchors.fill: parent + anchors.margins: 12 + spacing: 8 + + RowLayout { + Layout.fillWidth: true + SectionTitle { + text: "WAFER OVERLAP" + Layout.fillWidth: true + } + } + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + color: Theme.fieldBackground + border.color: Theme.cardBorder + border.width: 1 + radius: Theme.radiusSm + clip: true + + WaferMapItem { + id: overlapMapItem + anchors.fill: parent + visible: root.compareSensorLayout.length > 0 && enableOverlapCheck.checked + sensors: root.compareSensorLayout + values: streamController.getSensorDiffAt(scrubReadout.scrubIdx) + bands: root.diffBands(overlapMapItem.values) + shape: root.compareWaferShape + size: root.compareWaferSize + target: 0 + margin: 1.0 + blend: blendSlider.value / 100 + showLabels: true + ringColor: Theme.waferRingColor + axisColor: Theme.waferAxisColor + lowColor: Theme.sensorLow + inRangeColor: Theme.sensorInRange + highColor: Theme.sensorHigh + textColor: Theme.headingColor + } + + ColumnLayout { + anchors.centerIn: parent + visible: root.compareSensorLayout.length === 0 || !enableOverlapCheck.checked + spacing: 4 + IconImage { + Layout.alignment: Qt.AlignHCenter + source: "icons/map.svg" + width: 24; height: 24 + sourceSize.width: 24 + sourceSize.height: 24 + color: Theme.sideMutedText + } + Label { + Layout.alignment: Qt.AlignHCenter + text: !enableOverlapCheck.checked ? "Overlap map disabled" + : "No sensor map available" + color: Theme.bodyColor + font.pixelSize: Theme.fontXs + } + } + } + } + } + // Chart card with alignment scrubber PanelBox { Layout.fillWidth: true @@ -478,6 +580,7 @@ Item { spacing: 4 Rectangle { width: 12; height: 3; radius: 1.5; color: Theme.primaryAccent; anchors.verticalCenter: parent.verticalCenter } Label { text: "Run A"; font.pixelSize: Theme.fontXs; color: Theme.bodyColor } + } Row { spacing: 4 @@ -668,129 +771,19 @@ Item { Slider { id: scrubber Layout.fillWidth: true + leftPadding: overlayCanvas.padLeft + rightPadding: overlayCanvas.padRight from: 0 to: Math.max(1, root.seriesLen - 1) stepSize: 1 value: 0 } - RowLayout { - id: scrubReadout - Layout.fillWidth: true - spacing: 8 - readonly property int scrubIdx: Math.round(scrubber.value) - readonly property bool hasA: scrubIdx < root.trendDataA.length - readonly property bool hasB: scrubIdx < root.trendDataB.length - readonly property real tempA: hasA ? root.trendDataA[scrubIdx] : 0 - readonly property real tempB: hasB ? root.trendDataB[scrubIdx] : 0 - - ScrubStat { - label: "Position" - value: scrubReadout.scrubIdx + " / " + Math.max(0, root.seriesLen - 1) - + root.scrubTimeLabel(scrubReadout.scrubIdx) - } - ScrubStat { - label: "Temp (Run A)" - value: scrubReadout.hasA ? scrubReadout.tempA.toFixed(1) + "°C" : "none" - valueColor: Theme.primaryAccent - } - ScrubStat { - label: "Temp (Run B)" - value: scrubReadout.hasB ? scrubReadout.tempB.toFixed(1) + "°C" : "none" - valueColor: Theme.themeSkill - } - ScrubStat { - label: "Difference" - value: (scrubReadout.hasA && scrubReadout.hasB) - ? Math.abs(scrubReadout.tempA - scrubReadout.tempB).toFixed(1) + "°C" : "none" - } - } } } } - - // Wafer Map Overlap View card - PanelBox { - Layout.fillWidth: true - Layout.fillHeight: true - Layout.minimumHeight: 220 - - ColumnLayout { - anchors.fill: parent - anchors.margins: 12 - spacing: 8 - - RowLayout { - Layout.fillWidth: true - SectionTitle { - text: "WAFER OVERLAP" - Layout.fillWidth: true - } - Label { - visible: root.compareFileA !== "" && root.compareFileB !== "" - text: (root.shortName(root.compareFileA).replace(".csv", "") + " vs " - + root.shortName(root.compareFileB).replace(".csv", "")).toUpperCase() - font.pixelSize: Theme.fontXs - color: Theme.sideMutedText - elide: Text.ElideMiddle - } - } - - Rectangle { - Layout.fillWidth: true - Layout.fillHeight: true - color: Theme.fieldBackground - border.color: Theme.cardBorder - border.width: 1 - radius: Theme.radiusSm - clip: true - - WaferMapItem { - id: overlapMapItem - anchors.fill: parent - visible: root.compareSensorLayout.length > 0 && enableOverlapCheck.checked - sensors: root.compareSensorLayout - values: root.compareSensorDiff - bands: root.diffBands(root.compareSensorDiff) - shape: root.compareWaferShape - size: root.compareWaferSize - target: 0 - margin: 1.0 - blend: blendSlider.value / 100 - showLabels: true - ringColor: Theme.waferRingColor - axisColor: Theme.waferAxisColor - lowColor: Theme.sensorLow - inRangeColor: Theme.sensorInRange - highColor: Theme.sensorHigh - textColor: Theme.headingColor - } - - ColumnLayout { - anchors.centerIn: parent - visible: root.compareSensorLayout.length === 0 || !enableOverlapCheck.checked - spacing: 4 - IconImage { - Layout.alignment: Qt.AlignHCenter - source: "icons/map.svg" - width: 24; height: 24 - sourceSize.width: 24 - sourceSize.height: 24 - color: Theme.sideMutedText - } - Label { - Layout.alignment: Qt.AlignHCenter - text: !enableOverlapCheck.checked ? "Overlap map disabled" - : "No sensor map available" - color: Theme.bodyColor - font.pixelSize: Theme.fontXs - } - } - } - } - } - } +} // --- Right Column: run selection, overlap settings, readout --- ColumnLayout { @@ -801,85 +794,40 @@ Item { Layout.fillHeight: true spacing: 12 - // Bento 1: Run Selection + // Bento 3: DTW Comparison Readout PanelBox { Layout.fillWidth: true - Layout.preferredHeight: runSelCol.implicitHeight + 24 + Layout.preferredHeight: readoutCol.implicitHeight + 24 ColumnLayout { - id: runSelCol + id: readoutCol anchors.fill: parent anchors.margins: 12 - spacing: 10 + spacing: 8 - SectionTitle { text: "RUN SELECTION" } + SectionTitle { text: "DTW READOUT" } - RunSlot { - title: "REFERENCE (RUN A)" - boxIndex: 1 - accent: Theme.primaryAccent - filePath: root.compareFileA - onCleared: { - root.compareFileA = "" - root.activeBox = 1 - root.clearResults() - } + ReadoutCard { + label: "DTW ALIGNMENT DISTANCE" + value: root.warpingDistance >= 0 ? root.warpingDistance.toFixed(2) : "--" + valueColor: root.warpingDistance >= 0 + ? (root.warpingDistance < 50 ? Theme.metricGood : root.warpingDistance < 100 ? Theme.metricWarn : Theme.metricBad) + : Theme.sideMutedText } - - RunSlot { - title: "SESSION (RUN B)" - boxIndex: 2 - accent: Theme.themeSkill - filePath: root.compareFileB - onCleared: { - root.compareFileB = "" - root.activeBox = 2 - root.clearResults() - } + ReadoutCard { + label: "TEMPORAL FRAME OFFSET" + value: root.warpingDistance >= 0 + ? ((root.frameOffset >= 0 ? "+" : "") + root.frameOffset + " frames / " + + (root.frameOffsetSeconds >= 0 ? "+" : "") + root.frameOffsetSeconds.toFixed(1) + "s") + : "--" + valueColor: root.warpingDistance >= 0 ? Theme.headingColor : Theme.sideMutedText } - - Button { - id: compareBtn - Layout.fillWidth: true - Layout.preferredHeight: 32 - readonly property bool sameFile: root.compareFileA !== "" && root.compareFileA === root.compareFileB - enabled: root.compareFileA !== "" && root.compareFileB !== "" && !sameFile && !root.comparing - ToolTip.visible: sameFile && hovered - ToolTip.text: "Choose two different runs to compare" - onClicked: { - root.comparing = true - root.clearResults() - streamController.compareFiles(root.compareFileA, root.compareFileB) - } - - background: Rectangle { - radius: Theme.radiusSm - color: compareBtn.enabled - ? (compareBtn.hovered ? Qt.lighter(Theme.primaryAccent, 1.1) : Theme.primaryAccent) - : Theme.buttonNeutralBackground - Behavior on color { ColorAnimation { duration: Theme.durationFast } } - } - contentItem: Row { - spacing: 8 - anchors.centerIn: parent - Label { - anchors.verticalCenter: parent.verticalCenter - text: root.comparing - ? "Comparing…" - : compareBtn.enabled - ? "Run DTW Comparison" - : (compareBtn.sameFile ? "Choose different runs" : "Select both runs") - color: compareBtn.enabled ? Theme.tone100 : Theme.sideMutedText - font.pixelSize: Theme.fontSm - font.bold: true - } - BusyIndicator { - running: root.comparing - visible: root.comparing - width: 14; height: 14 - anchors.verticalCenter: parent.verticalCenter - } - } + ReadoutCard { + label: "MAX SENSOR DEVIATION" + value: root.maxSensorDeviation >= 0 ? root.maxSensorDeviation.toFixed(2) + "°C" : "--" + valueColor: root.maxSensorDeviation >= 0 + ? (root.maxSensorDeviation < 1.0 ? Theme.metricGood : root.maxSensorDeviation < 3.0 ? Theme.metricWarn : Theme.metricBad) + : Theme.sideMutedText } } } @@ -946,57 +894,104 @@ Item { Row { spacing: 3 Rectangle { width: 8; height: 8; radius: 4; color: Theme.sensorLow; anchors.verticalCenter: parent.verticalCenter } - Label { text: "B cooler"; font.pixelSize: Theme.fontXs; color: Theme.sideMutedText } + Label { text: "B cooler"; font.pixelSize: Theme.fontSm; color: Theme.sideMutedText } } Row { spacing: 3 Rectangle { width: 8; height: 8; radius: 4; color: Theme.sensorInRange; anchors.verticalCenter: parent.verticalCenter } - Label { text: "Match"; font.pixelSize: Theme.fontXs; color: Theme.sideMutedText } + Label { text: "Match"; font.pixelSize: Theme.fontSm; color: Theme.sideMutedText } } Row { spacing: 3 Rectangle { width: 8; height: 8; radius: 4; color: Theme.sensorHigh; anchors.verticalCenter: parent.verticalCenter } - Label { text: "B hotter"; font.pixelSize: Theme.fontXs; color: Theme.sideMutedText } + Label { text: "B hotter"; font.pixelSize: Theme.fontSm; color: Theme.sideMutedText } } } } } } - // Bento 3: DTW Comparison Readout + // Bento: Frame / Time Controls PanelBox { Layout.fillWidth: true - Layout.preferredHeight: readoutCol.implicitHeight + 24 + Layout.preferredHeight: frameTimeCol.implicitHeight + 32 ColumnLayout { - id: readoutCol + id: frameTimeCol + anchors.fill: parent + anchors.margins: 16 + spacing: 20 + + SectionTitle { text: "FRAME / TIME" } + + EditableScrubStat { + label: "Frame" + value: scrubReadout.scrubIdx + unit: "/ " + Math.max(0, root.seriesLen - 1) + (Math.max(0, root.seriesLen - 1) <= 1 ? " frame" : " frames") + editable: root.hasSeries + onValueEdited: (newValue) => { + var maxVal = Math.max(0, root.seriesLen - 1) + var val = Math.max(0, Math.min(newValue, maxVal)) + scrubber.value = val + } + } + + EditableScrubStat { + label: "Time" + value: scrubReadout.scrubTime + unit: "/ " + scrubReadout.maxTime.toFixed(1) + "s" + editable: root.hasSeries + onValueEdited: (newValue) => { + var closestIdx = root.frameForTime(newValue) + scrubber.value = closestIdx + } + } + } + } + + // Bento: Scrub Readout + PanelBox { + Layout.fillWidth: true + Layout.preferredHeight: scrubReadout.implicitHeight + 24 + + ColumnLayout { + id: scrubReadout anchors.fill: parent anchors.margins: 12 spacing: 8 - SectionTitle { text: "DTW READOUT" } + readonly property int scrubIdx: Math.round(scrubber.value) + readonly property bool hasA: scrubIdx < root.trendDataA.length + readonly property bool hasB: scrubIdx < root.trendDataB.length + readonly property real tempA: hasA ? root.trendDataA[scrubIdx] : 0 + readonly property real tempB: hasB ? root.trendDataB[scrubIdx] : 0 + readonly property real scrubTime: { + var t = scrubIdx < root.timeDataA.length ? root.timeDataA[scrubIdx] + : (scrubIdx < root.timeDataB.length ? root.timeDataB[scrubIdx] : 0) + return t + } + readonly property real maxTime: { + var ta = root.timeDataA.length > 0 ? root.timeDataA[root.timeDataA.length - 1] : 0 + var tb = root.timeDataB.length > 0 ? root.timeDataB[root.timeDataB.length - 1] : 0 + return Math.max(ta, tb) + } - ReadoutCard { - label: "DTW ALIGNMENT DISTANCE" - value: root.warpingDistance >= 0 ? root.warpingDistance.toFixed(2) : "--" - valueColor: root.warpingDistance >= 0 - ? (root.warpingDistance < 50 ? Theme.metricGood : root.warpingDistance < 100 ? Theme.metricWarn : Theme.metricBad) - : Theme.sideMutedText + SectionTitle { text: "SCRUBBER READOUT" } + + ScrubStat { + label: "Temp (Run A)" + value: scrubReadout.hasA ? scrubReadout.tempA.toFixed(1) + "°C" : "none" + valueColor: Theme.primaryAccent } - ReadoutCard { - label: "TEMPORAL FRAME OFFSET" - value: root.warpingDistance >= 0 - ? ((root.frameOffset >= 0 ? "+" : "") + root.frameOffset + " frames / " - + (root.frameOffsetSeconds >= 0 ? "+" : "") + root.frameOffsetSeconds.toFixed(1) + "s") - : "--" - valueColor: root.warpingDistance >= 0 ? Theme.headingColor : Theme.sideMutedText + ScrubStat { + label: "Temp (Run B)" + value: scrubReadout.hasB ? scrubReadout.tempB.toFixed(1) + "°C" : "none" + valueColor: Theme.themeSkill } - ReadoutCard { - label: "MAX SENSOR DEVIATION" - value: root.maxSensorDeviation >= 0 ? root.maxSensorDeviation.toFixed(2) + "°C" : "--" - valueColor: root.maxSensorDeviation >= 0 - ? (root.maxSensorDeviation < 1.0 ? Theme.metricGood : root.maxSensorDeviation < 3.0 ? Theme.metricWarn : Theme.metricBad) - : Theme.sideMutedText + ScrubStat { + label: "Difference" + value: (scrubReadout.hasA && scrubReadout.hasB) + ? Math.abs(scrubReadout.tempA - scrubReadout.tempB).toFixed(1) + "°C" : "none" } } } diff --git a/src/pygui/ISC/Tabs/WaferMapTab.qml b/src/pygui/ISC/Tabs/WaferMapTab.qml index 1755020..ba30d09 100644 --- a/src/pygui/ISC/Tabs/WaferMapTab.qml +++ b/src/pygui/ISC/Tabs/WaferMapTab.qml @@ -74,8 +74,8 @@ Item { } TabButton { text: "Review" - implicitWidth: 64 - implicitHeight: 28 + implicitWidth: 84 + implicitHeight: 34 background: Rectangle { color: parent.checked ? Theme.tabActiveBackground : "transparent" radius: Theme.radiusSm - 1 @@ -83,29 +83,61 @@ Item { contentItem: Text { text: parent.text color: parent.checked ? Theme.headingColor : Theme.bodyColor - font.pixelSize: Theme.fontSm + font.pixelSize: Theme.fontMd font.weight: parent.checked ? Font.Medium : Font.Normal horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter } } TabButton { + id: liveTab text: "Live" enabled: deviceController.connectionStatus === "Connected" opacity: enabled ? 1.0 : 0.4 - implicitWidth: 58 - implicitHeight: 28 + implicitWidth: 84 + implicitHeight: 34 + // Disabled controls swallow no events at all, so hover for the + // "why is this greyed out" tooltip needs its own MouseArea. + ToolTip.visible: disabledHover.containsMouse && !enabled + ToolTip.text: "Connect a wafer to enable Live mode" + ToolTip.delay: 300 + MouseArea { + id: disabledHover + anchors.fill: parent + enabled: !liveTab.enabled + hoverEnabled: true + acceptedButtons: Qt.NoButton + } background: Rectangle { color: parent.checked ? Theme.tabActiveBackground : "transparent" radius: Theme.radiusSm - 1 } - contentItem: Text { - text: parent.text - color: parent.checked ? Theme.headingColor : Theme.bodyColor - font.pixelSize: Theme.fontSm - font.weight: parent.checked ? Font.Medium : Font.Normal - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter + contentItem: Row { + spacing: 5 + leftPadding: 8 + Rectangle { + id: liveTabDot + width: 7; height: 7; radius: 3.5 + anchors.verticalCenter: parent.verticalCenter + color: liveTab.enabled ? Theme.metricGood : Theme.sideMutedText + + // Flash while actually streaming live + SequentialAnimation on opacity { + running: streamController.mode === "live" && streamController.state !== "idle" + loops: Animation.Infinite + NumberAnimation { to: 0.2; duration: 600; easing.type: Easing.InOutQuad } + NumberAnimation { to: 1.0; duration: 600; easing.type: Easing.InOutQuad } + onRunningChanged: if (!running) liveTabDot.opacity = 1.0 + } + } + Text { + text: liveTab.text + color: liveTab.checked ? Theme.headingColor : Theme.bodyColor + font.pixelSize: Theme.fontMd + font.weight: liveTab.checked ? Font.Medium : Font.Normal + verticalAlignment: Text.AlignVCenter + height: parent.height + } } } onCurrentIndexChanged: if (currentIndex === 1) { @@ -127,44 +159,6 @@ Item { } } - // Live source info: WiFi icon + port · serial - RowLayout { - visible: streamController.mode === "live" - spacing: 5 - // Live status dot (matches the connection-flow indicator dot elsewhere) - Rectangle { - id: liveIndicator - width: 8; height: 8 - radius: 4 - Layout.alignment: Qt.AlignVCenter - color: (deviceController.connectionStatus === "Connected" && streamController.state !== "idle") ? Theme.liveColor : Theme.bodyColor - - SequentialAnimation on opacity { - running: deviceController.connectionStatus === "Connected" && streamController.state !== "idle" - loops: Animation.Infinite - NumberAnimation { - to: 0.2 - duration: 600 - easing.type: Easing.InOutQuad - } - NumberAnimation { - to: 1.0 - duration: 600 - easing.type: Easing.InOutQuad - } - onRunningChanged: { - if (!running) - liveIndicator.opacity = 1.0; - } - } - } - Label { - text: "Live stream" - color: Theme.bodyColor - font.pixelSize: Theme.fontXs - } - } - Item { Layout.fillWidth: true } diff --git a/src/pygui/ISC/Tabs/components/SourcePanel.qml b/src/pygui/ISC/Tabs/components/SourcePanel.qml index 3075e5a..3e4dbec 100644 --- a/src/pygui/ISC/Tabs/components/SourcePanel.qml +++ b/src/pygui/ISC/Tabs/components/SourcePanel.qml @@ -190,14 +190,35 @@ ColumnLayout { return (modelData.baseName + modelData.waferType + modelData.date).toLowerCase().indexOf(q) >= 0; } - // Active highlight logic: on Data tab, highlight compareFileA/B; otherwise highlight loadedFile (for Map tab). - readonly property bool isActive:{ + readonly property var dataTab: { try { - if (root.selectedTabIndex === 1 && dataTabLoader.item) { - return modelData.fileName === dataTabLoader.item.compareFileA || modelData.fileName === dataTabLoader.item.compareFileB; - } - } catch (e) {} - return streamController.mode === "review" && streamController.loadedFile !== "" && modelData.fileName === streamController.loadedFile; + return (root.selectedTabIndex === 1 && dataTabLoader.item) ? dataTabLoader.item : null; + } catch (e) { return null; } + } + + // Run A's effective file: the master file when "Compare vs Master + // File" is toggled on, otherwise the manually selected file. + readonly property string effectiveRunAFile: fileItem.dataTab + ? (fileItem.dataTab.useMasterFile ? fileItem.dataTab.masterFileForRunB : fileItem.dataTab.compareFileA) + : "" + readonly property bool isRunA: fileItem.dataTab !== null + && fileItem.effectiveRunAFile !== "" && modelData.fileName === fileItem.effectiveRunAFile + readonly property bool isRunB: fileItem.dataTab !== null + && modelData.fileName === fileItem.dataTab.compareFileB + + // Active highlight logic: on Data tab, highlight the Run A/Run B files (or the + // resolved master file standing in for Run A); otherwise highlight loadedFile (Map tab). + readonly property bool isActive: fileItem.dataTab !== null + ? (fileItem.isRunA || fileItem.isRunB) + : (streamController.mode === "review" && streamController.loadedFile !== "" && modelData.fileName === streamController.loadedFile) + + // A file registered as any family's master in Settings → Master Files. + readonly property bool isMasterFile: { + var m = settingsModel.masters + for (var fam in m) { + if (m[fam] && m[fam] === modelData.fileName) return true + } + return false } readonly property bool isLiveFile: (modelData.baseName || "").toLowerCase().indexOf("live") >= 0 @@ -233,6 +254,8 @@ ColumnLayout { bottomMargin: 5 } color: { + if (fileItem.isRunA) return Theme.primaryAccent; + if (fileItem.isRunB) return Theme.themeSkill; var t = modelData.waferType; if (t === "A" || t === "E" || t === "P") return Theme.familyBlueAccent; @@ -290,6 +313,16 @@ ColumnLayout { anchors.topMargin: -2 anchors.rightMargin: -2 } + + // Accent ring — this file is registered as a master (Settings → Master Files). + Rectangle { + visible: fileItem.isMasterFile + anchors.fill: parent + radius: parent.radius + color: "transparent" + border.color: Theme.primaryAccent + border.width: 2 + } } // Typography hierarchy @@ -329,6 +362,25 @@ ColumnLayout { font.letterSpacing: 0.6 } } + + Rectangle { + visible: fileItem.isMasterFile + radius: 4 + color: Qt.alpha(Theme.primaryAccent, 0.18) + implicitWidth: masterLabel.implicitWidth + 10 + implicitHeight: masterLabel.implicitHeight + 4 + Layout.alignment: Qt.AlignVCenter + + Label { + id: masterLabel + anchors.centerIn: parent + text: "MASTER" + color: Theme.primaryAccent + font.pixelSize: Theme.font2xs + font.bold: true + font.letterSpacing: 0.6 + } + } } // Secondary: date · time diff --git a/src/pygui/ISC/Theme.qml b/src/pygui/ISC/Theme.qml index c9a9c8f..24f1c07 100644 --- a/src/pygui/ISC/Theme.qml +++ b/src/pygui/ISC/Theme.qml @@ -205,7 +205,7 @@ QtObject { readonly property int rightPaneGap: 6 readonly property int panelPadding: 12 // Side rail layout - readonly property int sideRailWidth: 280 + readonly property int sideRailWidth: 300 readonly property int sideRailMargin: 16 readonly property int sideRailSpacing: 16 readonly property int sideButtonHeight: 44 diff --git a/src/pygui/backend/controllers/session_controller.py b/src/pygui/backend/controllers/session_controller.py index 5d677e9..b18b90b 100644 --- a/src/pygui/backend/controllers/session_controller.py +++ b/src/pygui/backend/controllers/session_controller.py @@ -35,6 +35,7 @@ class SessionController(QObject): recordingChanged = Signal() sensorsChanged = Signal() loadedFileChanged = Signal() + loadFileError = Signal(str) clusterAveragingEnabledChanged = Signal() settingsChanged = Signal() # emitted when session state changes that should be persisted liveStatsChanged = Signal() # emitted when receivedCount or errorCount change @@ -90,6 +91,11 @@ class SessionController(QObject): self._received_count: int = 0 self._error_count: int = 0 + # Comparison cache for dynamic overlap mapping + self._compare_recs_a: list = [] + self._compare_recs_b: list = [] + self._compare_alignment_map: dict[int, int] = {} + # Restore persisted state if available self._load_settings(settings or {}) @@ -269,24 +275,25 @@ class SessionController(QObject): frames = [] - if is_official_csv(file_path): - sensors, records = read_official_csv(file_path) - frames = frames_from_wafer_data(None, records) - else: - try: + try: + if is_official_csv(file_path): + sensors, records = read_official_csv(file_path) + frames = frames_from_wafer_data(None, records) + else: data, _ = ZWaferParser().parse(file_path) - except (ValueError, KeyError) as exc: - log.warning("Could not parse %s: %s", file_path, exc) - return - if data is None or not data.sensors: - log.warning("Could not parse %s", file_path) - return - records = read_data_records(file_path) - sensors = data.sensors - frames = frames_from_wafer_data(data, records) + if data is None or not data.sensors: + self.loadFileError.emit("Invalid layout or missing sensors in custom CSV.") + return + records = read_data_records(file_path) + sensors = data.sensors + frames = frames_from_wafer_data(data, records) - if not sensors or not frames: - log.warning("No sensors or data in %s", file_path) + if not sensors or not frames: + self.loadFileError.emit("No sensors or frames found in data file.") + return + except Exception as exc: + log.warning("Could not parse file %s: %s", file_path, exc) + self.loadFileError.emit(f"Load error: {exc}") return wafer_id = "" @@ -360,6 +367,9 @@ class SessionController(QObject): }) return + self._compare_recs_a = recs_a + self._compare_recs_b = recs_b + len_a, len_b = len(recs_a), len(recs_b) # DTW can only align frames both runs actually have; the longer # run's extra tail is still returned for display, just unaligned. @@ -370,6 +380,7 @@ class SessionController(QObject): time_b = [round(recs_b[i].time, 3) for i in range(len_b)] result = compare_runs(series_a[:overlap_frames], series_b[:overlap_frames]) + self._compare_alignment_map = {i: j for i, j in result["warping_path"]} # Mean temporal shift along the DTW path: positive means run B # reaches the same profile features later than run A. @@ -459,6 +470,25 @@ class SessionController(QObject): "error": str(exc) }) + @Slot(int, result="QVariantList") + def getSensorDiffAt(self, scrub_idx: int) -> list[float]: + """Return the per-sensor temp diff (Run B - Run A) at the given scrub frame index of Run A, using DTW alignment.""" + if not self._compare_recs_a or not self._compare_recs_b: + return [] + + idx_a = min(max(0, scrub_idx), len(self._compare_recs_a) - 1) + if idx_a not in self._compare_alignment_map: + # DTW path only covers indices up to the shorter run's length — + # past that, Run B has no data at this frame, not a clampable one. + return [] + idx_b = self._compare_alignment_map[idx_a] + + values_a = self._compare_recs_a[idx_a].values + values_b = self._compare_recs_b[idx_b].values + + n = min(len(values_a), len(values_b)) + return [round(values_b[i] - values_a[i], 3) for i in range(n)] + # ---- splitting: threshold-based segmentation ---- @Slot(str, float) @slot_error_boundary diff --git a/src/pygui/backend/visualization/trend_chart_item.py b/src/pygui/backend/visualization/trend_chart_item.py index 7b5c063..0d5e5a6 100644 --- a/src/pygui/backend/visualization/trend_chart_item.py +++ b/src/pygui/backend/visualization/trend_chart_item.py @@ -12,6 +12,7 @@ from __future__ import annotations import json import logging +import math from typing import Optional from PySide6.QtCore import Property, QPoint, QRectF, Qt, Signal, Slot @@ -190,9 +191,14 @@ class TrendChartItem(QQuickPaintedItem): lo = min(self._data) hi = max(self._data) if hi - lo < 1e-9: - return lo - 5.0, hi + 5.0 - buf = (hi - lo) * 0.1 - return lo - buf, hi + buf + lo, hi = lo - 5.0, hi + 5.0 + # Snap to a "nice" tick step (1/2/5 × 10^k) so the axis holds still + # while streaming and only moves when data crosses a step boundary — + # raw min/max rescaled every frame, which read as an unstable chart. + raw_step = (hi - lo) / 4 + mag = 10 ** math.floor(math.log10(raw_step)) + step = next(s * mag for s in (1, 2, 5, 10) if s * mag >= raw_step) + return math.floor(lo / step) * step, math.ceil(hi / step) * step def _x_to_px(self, i: int, n: int, plot_rect: QRectF) -> float: if n <= 1: @@ -234,7 +240,7 @@ class TrendChartItem(QQuickPaintedItem): t = i / rows y_val = y_max - t * (y_max - y_min) y_px = plot_rect.top() + t * plot_rect.height() - label = f"{y_val:.1f}" + label = f"{y_val:g}" painter.drawText(QRectF(0, y_px - 7, label_w, 14), Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, label) @@ -250,12 +256,17 @@ class TrendChartItem(QQuickPaintedItem): for i in range(cols + 1): idx = round((i / cols) * (n - 1)) x_px = self._x_to_px(idx, n, plot_rect) - align = (Qt.AlignmentFlag.AlignLeft if i == 0 - else Qt.AlignmentFlag.AlignRight if i == cols - else Qt.AlignmentFlag.AlignHCenter) - painter.drawText(QRectF(x_px - 20, y_px, 40, 14), - align | Qt.AlignmentFlag.AlignTop, - str(idx)) + if i == 0: + rect = QRectF(x_px, y_px, 40, 14) + align = Qt.AlignmentFlag.AlignLeft + elif i == cols: + # Right-anchor inside the plot so the label never clips off-widget + rect = QRectF(x_px - 40, y_px, 40, 14) + align = Qt.AlignmentFlag.AlignRight + else: + rect = QRectF(x_px - 20, y_px, 40, 14) + align = Qt.AlignmentFlag.AlignHCenter + painter.drawText(rect, align | Qt.AlignmentFlag.AlignTop, str(idx)) def _draw_line( self, diff --git a/src/pygui/backend/visualization/wafer_map_item.py b/src/pygui/backend/visualization/wafer_map_item.py index 41ff2f0..b47074d 100644 --- a/src/pygui/backend/visualization/wafer_map_item.py +++ b/src/pygui/backend/visualization/wafer_map_item.py @@ -347,6 +347,10 @@ class WaferMapItem(QQuickPaintedItem): self._thickness_heatmap = None return field = refine_field(field) + # ponytail: RBF overshoots around outlier sensors, inventing values no + # sensor produced (e.g. negative diffs when all diffs are positive). + # Clamp to the real data range so color only reflects measured values. + field = np.clip(field, vs.min(), vs.max()) if self._shape == "round": alpha = ellipse_alpha(*field.shape) else: @@ -467,6 +471,10 @@ class WaferMapItem(QQuickPaintedItem): self._heatmap = None return field = refine_field(field) + # ponytail: RBF overshoots around outlier sensors, inventing values no + # sensor produced (e.g. negative diffs when all diffs are positive). + # Clamp to the real data range so color only reflects measured values. + field = np.clip(field, vs.min(), vs.max()) if self._shape == "round": alpha = ellipse_alpha(*field.shape) else: @@ -609,11 +617,11 @@ class WaferMapItem(QQuickPaintedItem): font_scale = 0.85 id_font = QFont() - id_font.setPointSize(max(4, int(r * font_scale))) + id_font.setPointSize(max(9, int(r * font_scale))) id_font.setBold(True) temp_font = QFont() - temp_font.setPointSize(max(3, int((r - 1) * font_scale))) + temp_font.setPointSize(max(9, int(r * font_scale))) band_color = { "in_range": self._in_range_color,