From 1e0322778811a6441f92a9c9e39fa74470928bad Mon Sep 17 00:00:00 2001 From: jack Date: Tue, 7 Jul 2026 12:10:47 -0700 Subject: [PATCH] style: increase ReadoutPanel font sizes and add CSV test fixture for data logging --- pyproject.toml | 2 +- src/pygui/ISC/HomePage.qml | 5 +- src/pygui/ISC/Tabs/DataTab.qml | 285 ++++++++++++++---- src/pygui/ISC/Tabs/StatusTab.qml | 12 +- .../ISC/Tabs/components/ReadoutPanel.qml | 56 ++-- src/pygui/ISC/Tabs/components/SplitDialog.qml | 44 +++ .../backend/controllers/session_controller.py | 166 +++++----- src/pygui/backend/data/csv_recorder.py | 62 +++- ...liicense_manager.py => license_manager.py} | 0 tests/fixtures/sample_stream_b.csv | 11 + tests/test_csv_recorder.py | 67 ++++ tests/test_session_controller.py | 192 ++++-------- 12 files changed, 587 insertions(+), 315 deletions(-) rename src/pygui/backend/license/{liicense_manager.py => license_manager.py} (100%) create mode 100644 tests/fixtures/sample_stream_b.csv diff --git a/pyproject.toml b/pyproject.toml index 57acd77..a593b73 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ dependencies = [ "scipy>=1.17.1", "pyyaml>=6.0.3", "dtw-python", - "cryptograpgy>=41.0", + "cryptography>=41.0", ] [project.optional-dependencies] diff --git a/src/pygui/ISC/HomePage.qml b/src/pygui/ISC/HomePage.qml index c109de6..b96a336 100644 --- a/src/pygui/ISC/HomePage.qml +++ b/src/pygui/ISC/HomePage.qml @@ -384,8 +384,9 @@ Rectangle { Label { text: "CONNECTION FLOW" color: Theme.sideMutedText - font.pixelSize: Theme.fontXs - font.letterSpacing: 1.2 + font.family: Theme.uiFontFamily + font.pixelSize: Theme.fontSm + font.letterSpacing: 1.5 font.bold: true } diff --git a/src/pygui/ISC/Tabs/DataTab.qml b/src/pygui/ISC/Tabs/DataTab.qml index 38e58ae..6980699 100644 --- a/src/pygui/ISC/Tabs/DataTab.qml +++ b/src/pygui/ISC/Tabs/DataTab.qml @@ -22,11 +22,17 @@ Item { property string compareFileB: "" property real warpingDistance: -1 property int frameOffset: 0 + property real frameOffsetSeconds: 0 property real maxSensorDeviation: -1 property bool comparing: false property var trendDataA: [] property var trendDataB: [] + property var timeDataA: [] + property var timeDataB: [] + property int frameCountA: 0 + property int frameCountB: 0 property string errorMessage: "" + property string mismatchMessage: "" // Wafer overlap properties property var compareSensorLayout: [] @@ -45,15 +51,28 @@ Item { return path ? path.split("/").pop() : "" } + function scrubTimeLabel(idx) { + var t = idx < root.timeDataA.length ? root.timeDataA[idx] + : (idx < root.timeDataB.length ? root.timeDataB[idx] : null) + return t !== null ? " (" + t.toFixed(1) + "s)" : "" + } + function clearResults() { root.warpingDistance = -1 root.frameOffset = 0 + root.frameOffsetSeconds = 0 root.maxSensorDeviation = -1 root.trendDataA = [] root.trendDataB = [] + root.timeDataA = [] + root.timeDataB = [] + root.frameCountA = 0 + root.frameCountB = 0 root.compareSensorLayout = [] root.compareSensorDiff = [] root.errorMessage = "" + root.mismatchMessage = "" + mismatchHideTimer.stop() } function resetComparison() { @@ -66,6 +85,12 @@ Item { Component.onCompleted: file_browser.refreshFiles() + Timer { + id: mismatchHideTimer + interval: 6000 + onTriggered: root.mismatchMessage = "" + } + // Clicking a file in the left rail's browser populates the armed run slot Connections { target: streamController @@ -85,15 +110,31 @@ Item { if (result && result.success) { root.warpingDistance = result.distance root.frameOffset = result.frame_offset !== undefined ? result.frame_offset : 0 + root.frameOffsetSeconds = result.frame_offset_seconds !== undefined ? result.frame_offset_seconds : 0 root.maxSensorDeviation = result.max_sensor_deviation !== undefined ? result.max_sensor_deviation : -1 root.trendDataA = result.series_a || [] root.trendDataB = result.series_b || [] + root.timeDataA = result.time_a || [] + root.timeDataB = result.time_b || [] + root.frameCountA = result.frame_count_a || 0 + root.frameCountB = result.frame_count_b || 0 root.compareSensorLayout = result.sensor_layout || [] root.compareSensorDiff = result.sensor_diff || [] root.compareWaferShape = result.wafer_shape || "round" root.compareWaferSize = result.wafer_size || 300.0 root.errorMessage = "" scrubber.value = Math.floor(root.seriesLen / 2) + 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 + root.mismatchMessage = "Run A: " + root.frameCountA + " frames (" + timeA.toFixed(1) + "s) · " + + "Run B: " + root.frameCountB + " frames (" + timeB.toFixed(1) + "s). " + + "Deviation beyond the shorter run shows as \"none\"." + mismatchHideTimer.restart() + } else { + root.mismatchMessage = "" + mismatchHideTimer.stop() + } } else { root.clearResults() root.errorMessage = (result && result.error) ? result.error : "Comparison failed" @@ -103,17 +144,73 @@ Item { // ── Reusable pieces ──────────────────────────────────────────── component SectionTitle: Label { - font.pixelSize: Theme.fontXs + font.family: Theme.uiFontFamily + font.pixelSize: Theme.fontSm + font.letterSpacing: 1.5 font.bold: true - font.letterSpacing: 1.2 color: Theme.sideMutedText } component PanelBox: Rectangle { - color: Theme.cardBackground - border.color: Theme.cardBorder + color: Theme.sidePanelBackground + border.color: Theme.sideBorder border.width: 1 - radius: Theme.radiusMd + radius: Theme.sidePanelRadius + } + + // Checkbox styled to match MapTab's right-rail ReadoutPanel (square accent + // indicator + check icon) instead of the platform-default CheckBox look. + 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: "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 + } + } + + // Slider styled to match MapTab's right-rail heatmap slider (circular + // accent handle that grows slightly on press). + component PanelSlider: Slider { + id: panelSlider + handle: Rectangle { + x: panelSlider.leftPadding + panelSlider.visualPosition * (panelSlider.availableWidth - width) + y: panelSlider.topPadding + panelSlider.availableHeight / 2 - height / 2 + implicitWidth: 18 + implicitHeight: 18 + radius: 9 + color: Theme.primaryAccent + scale: panelSlider.pressed ? 1.15 : 1.0 + Behavior on scale { NumberAnimation { duration: Theme.durationFast; easing.type: Theme.easeStandard } } + } } // Mini stat box under the alignment scrubber (mockup's subtle-box trio) @@ -170,7 +267,7 @@ Item { spacing: 2 Label { text: readoutCard.label - font.pixelSize: 9 + font.pixelSize: Theme.fontXs font.bold: true font.letterSpacing: 0.2 color: Theme.sideMutedText @@ -225,7 +322,7 @@ Item { spacing: 2 Label { text: slotBox.title - font.pixelSize: 9 + font.pixelSize: Theme.fontXs font.bold: true font.letterSpacing: 1.0 color: root.activeBox === slotBox.boxIndex ? slotBox.accent : Theme.sideMutedText @@ -234,7 +331,7 @@ Item { text: slotBox.filePath !== "" ? root.shortName(slotBox.filePath) : (root.activeBox === slotBox.boxIndex ? "<- Select file from left panel..." : "Click to select") - font.pixelSize: Theme.fontXs + font.pixelSize: Theme.fontSm color: slotBox.filePath !== "" ? Theme.headingColor : Theme.bodyColor opacity: slotBox.filePath !== "" ? 1.0 : 0.6 elide: Text.ElideMiddle @@ -371,7 +468,7 @@ Item { RowLayout { Layout.fillWidth: true SectionTitle { - text: "ALIGNED TEMP CURVE OVERLAY (DTW)" + text: "CURVE OVERLAY" Layout.fillWidth: true } Row { @@ -406,8 +503,8 @@ Item { readonly property real padLeft: 50 readonly property real padRight: 16 - readonly property real padTop: 16 - readonly property real padBottom: 28 + readonly property real padTop: 24 + readonly property real padBottom: 36 function drawGrid(ctx, minV, maxV, dataLen) { var plotW = width - padLeft - padRight @@ -417,7 +514,7 @@ Item { ctx.strokeStyle = Theme.chartGridLine ctx.lineWidth = 1 ctx.fillStyle = Theme.chartAxisText - ctx.font = "10px sans-serif" + ctx.font = "10px " + Theme.uiFontFamily ctx.textAlign = "right" var ySteps = 5 @@ -440,21 +537,27 @@ Item { var xFrac = xi / xSteps var xPx = padLeft + plotW * xFrac var xIdx = Math.round(xFrac * (dataLen - 1)) - ctx.fillText(xIdx.toString(), xPx, height - 6) + ctx.fillText(xIdx.toString(), xPx, height - 20) } ctx.save() - ctx.translate(12, padTop + plotH / 2) + ctx.translate(14, padTop + plotH / 2) ctx.rotate(-Math.PI / 2) ctx.textAlign = "center" - ctx.fillText("°C", 0, 0) + ctx.font = "12px " + Theme.uiFontFamily + ctx.fillText("Celcius (°C)", 0, 0) ctx.restore() ctx.textAlign = "center" - ctx.fillText("Frame", padLeft + plotW / 2, height - 2) + ctx.font = "12px " + Theme.uiFontFamily + ctx.fillText("Frame", padLeft + plotW / 2, height - 4) } - function drawSeries(ctx, series, minV, range, color) { + function drawSeries(ctx, series, minV, range, color, dataLen) { + // x is mapped against the shared frame domain (dataLen), not the + // series' own length — otherwise a shorter run's line stretches to + // fill the whole plot instead of stopping where its data ends. + if (series.length < 2) return var plotW = width - padLeft - padRight var plotH = height - padTop - padBottom @@ -462,7 +565,7 @@ Item { ctx.lineWidth = 2 ctx.beginPath() for (var i = 0; i < series.length; i++) { - var x = padLeft + (i / (series.length - 1)) * plotW + var x = padLeft + (i / (dataLen - 1)) * plotW var y = padTop + plotH - ((series[i] - minV) / range) * plotH if (i === 0) ctx.moveTo(x, y) else ctx.lineTo(x, y) @@ -497,8 +600,8 @@ Item { var range = (maxV - minV) || 1 drawGrid(ctx, minV, maxV, root.seriesLen) - drawSeries(ctx, root.trendDataA, minV, range, Theme.primaryAccent) - drawSeries(ctx, root.trendDataB, minV, range, Theme.themeSkill) + drawSeries(ctx, root.trendDataA, minV, range, Theme.primaryAccent, root.seriesLen) + drawSeries(ctx, root.trendDataB, minV, range, Theme.themeSkill, root.seriesLen) drawScrubMarker(ctx) } @@ -556,19 +659,10 @@ Item { color: Theme.cardBorder } - RowLayout { + SectionTitle { + text: "TIMELINE SCRUBBER" Layout.fillWidth: true - SectionTitle { - text: "ALIGNMENT TIMELINE SCRUBBER" - Layout.fillWidth: true - } - Label { - text: "Frame " + Math.round(scrubber.value) + " / " + Math.max(0, root.seriesLen - 1) - font.pixelSize: Theme.fontXs - font.bold: true - font.family: Theme.codeFontFamily - color: Theme.headingColor - } + Layout.topMargin: 8 } Slider { @@ -586,24 +680,30 @@ Item { spacing: 8 readonly property int scrubIdx: Math.round(scrubber.value) - readonly property real tempA: root.trendDataA.length > 0 - ? root.trendDataA[Math.min(scrubIdx, root.trendDataA.length - 1)] : 0 - readonly property real tempB: root.trendDataB.length > 0 - ? root.trendDataB[Math.min(scrubIdx, root.trendDataB.length - 1)] : 0 + 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.tempA.toFixed(1) + "°C" + value: scrubReadout.hasA ? scrubReadout.tempA.toFixed(1) + "°C" : "none" valueColor: Theme.primaryAccent } ScrubStat { label: "Temp (Run B)" - value: scrubReadout.tempB.toFixed(1) + "°C" + value: scrubReadout.hasB ? scrubReadout.tempB.toFixed(1) + "°C" : "none" valueColor: Theme.themeSkill } ScrubStat { label: "Difference" - value: Math.abs(scrubReadout.tempA - scrubReadout.tempB).toFixed(1) + "°C" + value: (scrubReadout.hasA && scrubReadout.hasB) + ? Math.abs(scrubReadout.tempA - scrubReadout.tempB).toFixed(1) + "°C" : "none" } } } @@ -624,7 +724,7 @@ Item { RowLayout { Layout.fillWidth: true SectionTitle { - text: "WAFER MAP OVERLAP VIEW" + text: "WAFER OVERLAP" Layout.fillWidth: true } Label { @@ -742,7 +842,10 @@ Item { id: compareBtn Layout.fillWidth: true Layout.preferredHeight: 32 - enabled: root.compareFileA !== "" && root.compareFileB !== "" && !root.comparing + 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() @@ -761,11 +864,13 @@ Item { anchors.centerIn: parent Label { anchors.verticalCenter: parent.verticalCenter - text: compareBtn.enabled - ? (root.comparing ? "Comparing…" : "Run DTW Comparison") - : (root.comparing ? "Comparing…" : "Select both runs") + 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.fontXs + font.pixelSize: Theme.fontSm font.bold: true } BusyIndicator { @@ -790,22 +895,14 @@ Item { anchors.margins: 12 spacing: 8 - SectionTitle { text: "OVERLAP MAP SETTINGS" } + SectionTitle { text: "OVERLAP SETTINGS" } - CheckBox { + PanelCheckBox { id: enableOverlapCheck Layout.fillWidth: true checked: true text: "Enable Overlap Map" - font.pixelSize: Theme.fontXs - - contentItem: Label { - leftPadding: enableOverlapCheck.indicator.width + 6 - text: enableOverlapCheck.text - font.pixelSize: Theme.fontXs - color: Theme.headingColor - verticalAlignment: Text.AlignVCenter - } + font.pixelSize: Theme.fontSm } ColumnLayout { @@ -824,11 +921,11 @@ Item { spacing: 6 Label { text: "Blend" - font.pixelSize: Theme.fontXs + font.pixelSize: Theme.fontSm color: Theme.bodyColor Layout.preferredWidth: 40 } - Slider { + PanelSlider { id: blendSlider Layout.fillWidth: true from: 0; to: 100; value: 50 @@ -836,7 +933,7 @@ Item { } Label { text: Math.round(blendSlider.value) + "%" - font.pixelSize: Theme.fontXs + font.pixelSize: Theme.fontSm color: Theme.sideMutedText Layout.preferredWidth: 30 } @@ -849,17 +946,17 @@ Item { Row { spacing: 3 Rectangle { width: 8; height: 8; radius: 4; color: Theme.sensorLow; anchors.verticalCenter: parent.verticalCenter } - Label { text: "B cooler"; font.pixelSize: 9; color: Theme.sideMutedText } + Label { text: "B cooler"; font.pixelSize: Theme.fontXs; 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: 9; color: Theme.sideMutedText } + Label { text: "Match"; font.pixelSize: Theme.fontXs; 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: 9; color: Theme.sideMutedText } + Label { text: "B hotter"; font.pixelSize: Theme.fontXs; color: Theme.sideMutedText } } } } @@ -877,7 +974,7 @@ Item { anchors.margins: 12 spacing: 8 - SectionTitle { text: "DTW COMPARISON READOUT" } + SectionTitle { text: "DTW READOUT" } ReadoutCard { label: "DTW ALIGNMENT DISTANCE" @@ -889,7 +986,8 @@ Item { ReadoutCard { label: "TEMPORAL FRAME OFFSET" value: root.warpingDistance >= 0 - ? ((root.frameOffset >= 0 ? "+" : "") + root.frameOffset + " frames") + ? ((root.frameOffset >= 0 ? "+" : "") + root.frameOffset + " frames / " + + (root.frameOffsetSeconds >= 0 ? "+" : "") + root.frameOffsetSeconds.toFixed(1) + "s") : "--" valueColor: root.warpingDistance >= 0 ? Theme.headingColor : Theme.sideMutedText } @@ -907,4 +1005,63 @@ Item { } } } + + // ── Frame Mismatch Banner (amber, floats over content, auto-hides) ── + Rectangle { + id: mismatchBanner + visible: root.mismatchMessage !== "" + z: 100 + anchors.top: parent.top + anchors.horizontalCenter: parent.horizontalCenter + anchors.topMargin: 12 + width: Math.min(parent.width - 48, 640) + height: mismatchRow.implicitHeight + 16 + color: Theme.cardBackground + border.color: Theme.statusWarningColor + border.width: 1 + radius: Theme.radiusSm + + RowLayout { + id: mismatchRow + 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.statusWarningColor + Layout.alignment: Qt.AlignTop + } + + Label { + text: root.mismatchMessage + color: Theme.statusWarningColor + font.pixelSize: Theme.fontSm + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + Button { + flat: true + implicitWidth: 24 + implicitHeight: 24 + Layout.alignment: Qt.AlignTop + onClicked: { + root.mismatchMessage = "" + mismatchHideTimer.stop() + } + background: Rectangle { color: "transparent" } + contentItem: Label { + text: "✕" + color: Theme.statusWarningColor + font.pixelSize: Theme.fontXs + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + } + } + } } diff --git a/src/pygui/ISC/Tabs/StatusTab.qml b/src/pygui/ISC/Tabs/StatusTab.qml index 98fb309..7aa3a5a 100644 --- a/src/pygui/ISC/Tabs/StatusTab.qml +++ b/src/pygui/ISC/Tabs/StatusTab.qml @@ -149,9 +149,9 @@ ColumnLayout { Text { text: "CONNECTION STATUS" color: Theme.sideMutedText - font.pixelSize: Theme.fontXs - font.weight: Font.Medium - font.letterSpacing: 1.2 + font.pixelSize: Theme.fontSm + font.bold: true + font.letterSpacing: 1.5 font.family: Theme.uiFontFamily } @@ -297,9 +297,9 @@ ColumnLayout { Text { text: "DIRECTORY" color: Theme.sideMutedText - font.pixelSize: Theme.fontMd - font.weight: Font.Medium - font.letterSpacing: 1.2 + font.pixelSize: Theme.fontSm + font.bold: true + font.letterSpacing: 1.5 font.family: Theme.uiFontFamily } Text { diff --git a/src/pygui/ISC/Tabs/components/ReadoutPanel.qml b/src/pygui/ISC/Tabs/components/ReadoutPanel.qml index 617d1ad..d5015d8 100644 --- a/src/pygui/ISC/Tabs/components/ReadoutPanel.qml +++ b/src/pygui/ISC/Tabs/components/ReadoutPanel.qml @@ -101,7 +101,7 @@ ColumnLayout { text: "LIVE STREAM" color: Theme.sideMutedText font.family: Theme.uiFontFamily - font.pixelSize: Theme.fontXs + font.pixelSize: Theme.fontSm font.letterSpacing: 1.5 font.bold: true Layout.bottomMargin: 4 @@ -113,7 +113,7 @@ ColumnLayout { text: "Received" color: Theme.sideMutedText font.family: Theme.uiFontFamily - font.pixelSize: Theme.fontXs + font.pixelSize: Theme.fontSm font.bold: true } Item { Layout.fillWidth: true } @@ -121,7 +121,7 @@ ColumnLayout { text: streamController.receivedCount color: Theme.headingColor font.family: Theme.codeFontFamily - font.pixelSize: Theme.fontMd + font.pixelSize: Theme.fontLg font.bold: true } } @@ -138,7 +138,7 @@ ColumnLayout { text: "Errors" color: Theme.sideMutedText font.family: Theme.uiFontFamily - font.pixelSize: Theme.fontXs + font.pixelSize: Theme.fontSm font.bold: true } Item { Layout.fillWidth: true } @@ -146,7 +146,7 @@ ColumnLayout { text: streamController.errorCount color: streamController.errorCount > 0 ? Theme.statusWarningColor : Theme.headingColor font.family: Theme.codeFontFamily - font.pixelSize: Theme.fontMd + font.pixelSize: Theme.fontLg font.bold: true } } @@ -207,7 +207,7 @@ ColumnLayout { text: "READOUT" color: Theme.sideMutedText font.family: Theme.uiFontFamily - font.pixelSize: Theme.fontXs + font.pixelSize: Theme.fontSm font.letterSpacing: 1.5 font.bold: true Layout.leftMargin: 14 @@ -226,7 +226,7 @@ ColumnLayout { text: "MIN TEMP" color: Theme.sideMutedText font.family: Theme.uiFontFamily - font.pixelSize: Theme.fontXs + font.pixelSize: Theme.fontSm font.bold: true } Item { Layout.fillWidth: true } @@ -236,7 +236,7 @@ ColumnLayout { text: s.min !== undefined ? s.min : "—" color: Theme.sensorLow font.family: Theme.codeFontFamily - font.pixelSize: Theme.fontMd + font.pixelSize: Theme.fontLg font.bold: true } Label { @@ -267,7 +267,7 @@ ColumnLayout { text: "MAX TEMP" color: Theme.sideMutedText font.family: Theme.uiFontFamily - font.pixelSize: Theme.fontXs + font.pixelSize: Theme.fontSm font.bold: true } Item { Layout.fillWidth: true } @@ -277,7 +277,7 @@ ColumnLayout { text: s.max !== undefined ? s.max : "—" color: Theme.sensorHigh font.family: Theme.codeFontFamily - font.pixelSize: Theme.fontMd + font.pixelSize: Theme.fontLg font.bold: true } Label { @@ -308,7 +308,7 @@ ColumnLayout { text: "DIFF" color: Theme.sideMutedText font.family: Theme.uiFontFamily - font.pixelSize: Theme.fontXs + font.pixelSize: Theme.fontSm font.bold: true } Item { Layout.fillWidth: true } @@ -316,7 +316,7 @@ ColumnLayout { text: s.diff !== undefined ? s.diff : "—" color: Theme.diffAccent font.family: Theme.codeFontFamily - font.pixelSize: Theme.fontMd + font.pixelSize: Theme.fontLg font.bold: true } } @@ -337,7 +337,7 @@ ColumnLayout { text: "AVERAGE" color: Theme.sideMutedText font.family: Theme.uiFontFamily - font.pixelSize: Theme.fontXs + font.pixelSize: Theme.fontSm font.bold: true } Item { Layout.fillWidth: true } @@ -345,7 +345,7 @@ ColumnLayout { text: s.avg !== undefined ? s.avg : "—" color: Theme.headingColor font.family: Theme.codeFontFamily - font.pixelSize: Theme.fontMd + font.pixelSize: Theme.fontLg font.bold: true } } @@ -366,7 +366,7 @@ ColumnLayout { text: "SIGMA (Σ)" color: Theme.sideMutedText font.family: Theme.uiFontFamily - font.pixelSize: Theme.fontXs + font.pixelSize: Theme.fontSm font.bold: true } Item { Layout.fillWidth: true } @@ -374,7 +374,7 @@ ColumnLayout { text: s.sigma !== undefined ? s.sigma : "—" color: Theme.headingColor font.family: Theme.codeFontFamily - font.pixelSize: Theme.fontMd + font.pixelSize: Theme.fontLg font.bold: true } } @@ -395,7 +395,7 @@ ColumnLayout { text: "3Σ VALUE" color: Theme.sideMutedText font.family: Theme.uiFontFamily - font.pixelSize: Theme.fontXs + font.pixelSize: Theme.fontSm font.bold: true } Item { Layout.fillWidth: true } @@ -403,7 +403,7 @@ ColumnLayout { text: s.threeSigma !== undefined ? s.threeSigma : "—" color: Theme.headingColor font.family: Theme.codeFontFamily - font.pixelSize: Theme.fontMd + font.pixelSize: Theme.fontLg font.bold: true } } @@ -432,7 +432,7 @@ ColumnLayout { text: "DISPLAY" color: Theme.sideMutedText font.family: Theme.uiFontFamily - font.pixelSize: Theme.fontXs + font.pixelSize: Theme.fontSm font.letterSpacing: 1.5 font.bold: true Layout.bottomMargin: 4 @@ -442,21 +442,21 @@ ColumnLayout { id: thicknessToggle text: "Show Thickness" checked: false - font.pixelSize: Theme.fontXs + font.pixelSize: Theme.fontSm } PanelCheckBox { id: labelsToggle text: "Labels" checked: true - font.pixelSize: Theme.fontXs + font.pixelSize: Theme.fontSm } PanelCheckBox { id: clusterAverageToggle text: "Average Clusters" checked: streamController.clusterAveragingEnabled - font.pixelSize: Theme.fontXs + font.pixelSize: Theme.fontSm onCheckedChanged: streamController.clusterAveragingEnabled = checked } @@ -472,7 +472,7 @@ ColumnLayout { Label { text: "Heatmap" color: Theme.bodyColor - font.pixelSize: Theme.fontXs + font.pixelSize: Theme.fontSm Layout.preferredWidth: 52 } Slider { @@ -500,7 +500,7 @@ ColumnLayout { Label { text: Math.round(heatmapSlider.value * 100) + "%" color: Theme.bodyColor - font.pixelSize: Theme.fontXs + font.pixelSize: Theme.fontSm font.weight: Font.Medium Layout.preferredWidth: 32 horizontalAlignment: Text.AlignRight @@ -531,7 +531,7 @@ ColumnLayout { text: "THRESHOLDS" color: Theme.sideMutedText font.family: Theme.uiFontFamily - font.pixelSize: Theme.fontXs + font.pixelSize: Theme.fontSm font.letterSpacing: 1.5 font.bold: true Layout.bottomMargin: 4 @@ -540,7 +540,7 @@ ColumnLayout { Label { text: "Set Point (°C)" color: Theme.bodyColor - font.pixelSize: Theme.fontXs + font.pixelSize: Theme.fontSm } TextField { id: spField @@ -570,7 +570,7 @@ ColumnLayout { Label { text: "Margin (±°C)" color: Theme.bodyColor - font.pixelSize: Theme.fontXs + font.pixelSize: Theme.fontSm } TextField { id: mgField @@ -601,7 +601,7 @@ ColumnLayout { id: autoCheck text: "Auto range (mean ± 1σ)" checked: true - font.pixelSize: Theme.fontXs + font.pixelSize: Theme.fontSm onCheckedChanged: pushThresholds() } } diff --git a/src/pygui/ISC/Tabs/components/SplitDialog.qml b/src/pygui/ISC/Tabs/components/SplitDialog.qml index 73ce27d..a445e71 100644 --- a/src/pygui/ISC/Tabs/components/SplitDialog.qml +++ b/src/pygui/ISC/Tabs/components/SplitDialog.qml @@ -212,6 +212,7 @@ Popup { Label { text: "START (s)"; Layout.preferredWidth: 120; color: Theme.sideMutedText; font.bold: true; font.pixelSize: Theme.fontSm } Label { text: "END (s)"; Layout.preferredWidth: 120; color: Theme.sideMutedText; font.bold: true; font.pixelSize: Theme.fontSm } Label { text: "AVG TEMP (°C)"; Layout.fillWidth: true; color: Theme.sideMutedText; font.bold: true; font.pixelSize: Theme.fontSm } + Item { Layout.preferredWidth: 80 } } } @@ -267,6 +268,31 @@ Popup { color: Theme.bodyColor font.pixelSize: Theme.fontSm } + + Button { + id: exportBtn + text: "Export" + Layout.preferredWidth: 80 + Layout.preferredHeight: 26 + hoverEnabled: true + onClicked: streamController.exportSegment(index) + + background: Rectangle { + radius: Theme.radiusXs + color: exportBtn.pressed ? Theme.buttonNeutralPressed + : exportBtn.hovered ? Theme.buttonNeutralHover + : Theme.buttonNeutralBackground + border.color: Theme.fieldBorder + border.width: 1 + } + contentItem: Label { + text: exportBtn.text + color: Theme.buttonNeutralText + font.pixelSize: Theme.fontXs + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + } } } } @@ -295,6 +321,16 @@ Popup { color: Theme.bodyColor font.pixelSize: Theme.fontSm } + + // ── Export feedback ─────────────────────────────────────── + Label { + id: exportStatus + visible: text !== "" + color: Theme.bodyColor + font.pixelSize: Theme.fontSm + elide: Text.ElideMiddle + Layout.fillWidth: true + } } // ── Backend Connection ──────────────────────────────────────── @@ -302,6 +338,7 @@ Popup { target: streamController function onSplitResult(result) { root.segmenting = false; + exportStatus.text = ""; if (result && result.success) { root.segments = result.segments || []; console.log("[SplitDialog] Segmented into", root.segments.length, "phases"); @@ -310,6 +347,13 @@ Popup { console.log("[SplitDialog] Split failed:", result.error || "unknown"); } } + + function onSegmentExported(result) { + if (result && result.success) + exportStatus.text = "Exported: " + result.path.split("/").pop(); + else + exportStatus.text = "Export failed: " + (result.error || "unknown"); + } } } diff --git a/src/pygui/backend/controllers/session_controller.py b/src/pygui/backend/controllers/session_controller.py index 7e55a7f..5d677e9 100644 --- a/src/pygui/backend/controllers/session_controller.py +++ b/src/pygui/backend/controllers/session_controller.py @@ -4,7 +4,6 @@ from __future__ import annotations import json import logging import time -from pathlib import Path from typing import Optional from PySide6.QtCore import Property, QObject, Qt, QTimer, Signal, Slot @@ -36,7 +35,6 @@ class SessionController(QObject): recordingChanged = Signal() sensorsChanged = Signal() loadedFileChanged = Signal() - loadFileError = Signal(str) # emitted when loadFile() fails, so QML can show why clusterAveragingEnabledChanged = Signal() settingsChanged = Signal() # emitted when session state changes that should be persisted liveStatsChanged = Signal() # emitted when receivedCount or errorCount change @@ -46,6 +44,7 @@ class SessionController(QObject): # dicts to QML as an opaque empty wrapper with no accessible fields. comparisonResult = Signal(dict) # dict with success, distance, warping_path, max_sensor_deviation, series_a, series_b splitResult = Signal(dict) # dict with success, segments + segmentExported = Signal(dict) # dict with success, path | error # private: marshal a worker-thread frame onto the main thread _liveFrame = Signal(object) # Frame _liveError = Signal() # worker-thread error ping @@ -259,6 +258,8 @@ class SessionController(QObject): @Slot(str) @slot_error_boundary def loadFile(self, file_path: str) -> None: + from pathlib import Path + from pygui.backend.data.data_records import ( is_official_csv, read_data_records, @@ -275,30 +276,17 @@ class SessionController(QObject): try: data, _ = ZWaferParser().parse(file_path) except (ValueError, KeyError) as exc: - msg = f"Could not parse {Path(file_path).name}: {exc}" - log.warning("%s", msg) - self.loadFileError.emit(msg) + log.warning("Could not parse %s: %s", file_path, exc) return if data is None or not data.sensors: - msg = f"Could not parse {Path(file_path).name}" - log.warning("%s", msg) - self.loadFileError.emit(msg) + 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 not sensors: - msg = f"No sensor layout found in {Path(file_path).name}" - log.warning("%s", msg) - self.loadFileError.emit(msg) - return - if not frames: - # A well-formed header with zero data rows below it — most commonly - # a live recording that was stopped before any frames arrived. - msg = f"{Path(file_path).name} has no recorded frames" - log.warning("%s", msg) - self.loadFileError.emit(msg) + if not sensors or not frames: + log.warning("No sensors or data in %s", file_path) return wafer_id = "" @@ -317,10 +305,6 @@ class SessionController(QObject): self._model.reset() self._stats_tracker.reset() self._loaded_file = file_path - # A file was explicitly picked for review — show it regardless of - # whichever mode (e.g. Live) was previously active, otherwise a - # successful load can be invisible to the user. - self.setMode(MODE_REVIEW) self.loadedFileChanged.emit() self.sensorsChanged.emit() self._emit_current() @@ -347,46 +331,19 @@ class SessionController(QObject): @slot_error_boundary def compareFiles(self, file_a: str, file_b: str) -> None: """Run DTW comparison between two CSV files and emit result.""" + from pathlib import Path + from pygui.backend.comparison import compare_runs from pygui.backend.data.data_records import is_official_csv, read_data_records, read_official_csv + if Path(file_a).resolve() == Path(file_b).resolve(): + self.comparisonResult.emit({ + "success": False, + "error": "Cannot compare a file to itself. Choose two different runs." + }) + return + try: - # Recorded-live CSVs are named "live__.csv" by - # startRecording (see FileBrowser.isRecording), distinct from the - # "-.csv" convention used for read-memory dumps. - # Comparing across those categories doesn't make sense (a live - # capture vs. a full memory readout aren't the same kind of run), - # so gate on that before even looking at wafer type. - stem_a, stem_b = Path(file_a).stem, Path(file_b).stem - is_recording_a = stem_a.lower().startswith("live_") - is_recording_b = stem_b.lower().startswith("live_") - if is_recording_a != is_recording_b: - self.comparisonResult.emit({ - "success": False, - "error": "Cannot compare a recording file with a read-memory file" - }) - return - - # Gate on wafer type (family): comparing a P-wafer against an X-wafer - # (different sensor counts/layouts) would silently truncate the - # smaller sensor set and pair up unrelated physical sensors in the - # overlap view. Same convention as FileBrowser's waferType badge — - # first character of the filename stem, minus the "live_" prefix - # so the wafer letter is read from the serial, not the "L". - def _wafer_type(stem: str, is_recording: bool) -> str: - if is_recording: - stem = stem[len("live_"):] - return stem[0].upper() if stem else "" - - type_a = _wafer_type(stem_a, is_recording_a) - type_b = _wafer_type(stem_b, is_recording_b) - if type_a and type_b and type_a != type_b: - self.comparisonResult.emit({ - "success": False, - "error": f"Cannot compare different wafer types: {type_a} vs {type_b}" - }) - return - if is_official_csv(file_a): _, recs_a = read_official_csv(file_a) else: @@ -403,30 +360,32 @@ class SessionController(QObject): }) return - # Compare the full overlapping length of both runs. DTW's O(n*m) cost - # matrix is cheap even at a few thousand frames (~26MB at 1800x1800), - # so only cap against pathologically long files, not typical runs. - max_frames = min(5000, len(recs_a), len(recs_b)) - series_a = [recs_a[i].values[0] if recs_a[i].values else 0.0 - for i in range(max_frames)] - series_b = [recs_b[i].values[0] if recs_b[i].values else 0.0 - for i in range(max_frames)] + 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. + overlap_frames = min(len_a, len_b) + series_a = [recs_a[i].values[0] if recs_a[i].values else 0.0 for i in range(len_a)] + series_b = [recs_b[i].values[0] if recs_b[i].values else 0.0 for i in range(len_b)] + time_a = [round(recs_a[i].time, 3) for i in range(len_a)] + time_b = [round(recs_b[i].time, 3) for i in range(len_b)] - result = compare_runs(series_a, series_b) + result = compare_runs(series_a[:overlap_frames], series_b[:overlap_frames]) # Mean temporal shift along the DTW path: positive means run B # reaches the same profile features later than run A. path = result["warping_path"] frame_offset = round(sum(j - i for i, j in path) / len(path)) if path else 0 + frame_offset_seconds = round( + sum(time_b[j] - time_a[i] for i, j in path) / len(path), 2 + ) if path else 0.0 - # Max sensor deviation: walk the DTW-aligned frame pairs and take - # the largest per-sensor abs difference across all sensors, not - # just the sensor[0] series used for alignment. + # Max sensor deviation: walk the DTW-aligned frame pairs (bounded to + # the overlap both runs share) and take the largest per-sensor abs + # difference across all sensors, not just the sensor[0] series used + # for alignment. num_sensors = min(len(recs_a[0].values), len(recs_b[0].values)) if recs_a[0].values and recs_b[0].values else 0 max_sensor_deviation = 0.0 for i, j in zip(result["index_a"], result["index_b"]): - if i >= max_frames or j >= max_frames: - continue values_a = recs_a[i].values values_b = recs_b[j].values for s in range(min(num_sensors, len(values_a), len(values_b))): @@ -435,9 +394,8 @@ class SessionController(QObject): max_sensor_deviation = diff # Sensor layout + per-sensor diff for the wafer overlap view. Reuses the - # same sensor-parsing branch as loadFile(); file_a and file_b are now - # guaranteed to share a wafer type by the gate above. - from pygui.backend.data.data_records import is_official_csv, read_official_csv + # same sensor-parsing branch as loadFile() (file_a is assumed representative + # of both files' wafer type). from pygui.backend.wafer.wafer_layouts import resolve_shape_and_size from pygui.backend.wafer.zwafer_parser import ZWaferParser @@ -457,8 +415,8 @@ class SessionController(QObject): if sensors: wafer_shape, wafer_size = resolve_shape_and_size(sensors, wafer_id) - last_a = recs_a[max_frames - 1].values - last_b = recs_b[max_frames - 1].values + last_a = recs_a[overlap_frames - 1].values + last_b = recs_b[overlap_frames - 1].values n = min(len(sensors), len(last_a), len(last_b)) sensor_layout = [ { @@ -481,9 +439,14 @@ class SessionController(QObject): # Lists, not tuples — tuples don't survive QVariant conversion "warping_path": [list(p) for p in result["warping_path"][:50]], "frame_offset": frame_offset, + "frame_offset_seconds": frame_offset_seconds, "max_sensor_deviation": max_sensor_deviation, "series_a": series_a, "series_b": series_b, + "time_a": time_a, + "time_b": time_b, + "frame_count_a": len_a, + "frame_count_b": len_b, "sensor_layout": sensor_layout, "sensor_diff": sensor_diff, "wafer_shape": wafer_shape, @@ -520,6 +483,10 @@ class SessionController(QObject): avg_temps = [rec.values[0] if rec.values else 0.0 for rec in records] segments = segment_profile(avg_temps, threshold) + # cache for exportSegment (per-segment Export in SplitDialog) + self._last_split_file = file_path + self._last_split_segments = segments + self.splitResult.emit({ "success": True, "segments": [{ @@ -536,6 +503,48 @@ class SessionController(QObject): "error": str(exc) }) + @Slot(int) + @slot_error_boundary + def exportSegment(self, index: int) -> None: + """Write one segment from the last split as a standalone CSV. + + Output lands next to the source file as + ``_