style: increase ReadoutPanel font sizes and add CSV test fixture for data logging

This commit is contained in:
jack
2026-07-07 12:10:47 -07:00
parent 7df7fd4c6f
commit 1e03227788
12 changed files with 587 additions and 315 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ dependencies = [
"scipy>=1.17.1",
"pyyaml>=6.0.3",
"dtw-python",
"cryptograpgy>=41.0",
"cryptography>=41.0",
]
[project.optional-dependencies]
+3 -2
View File
@@ -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
}
+221 -64
View File
@@ -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
}
}
}
}
}
+6 -6
View File
@@ -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 {
+28 -28
View File
@@ -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()
}
}
@@ -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");
}
}
}
@@ -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_<serial>_<timestamp>.csv" by
# startRecording (see FileBrowser.isRecording), distinct from the
# "<serial>-<timestamp>.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
``<stem>_<label><index>.csv`` — the stem keeps its wafer-id prefix
so read_official_csv still resolves the layout on re-import.
Emits segmentExported with success + path (or error).
"""
from pathlib import Path
from pygui.backend.data.csv_recorder import CsvRecorder
source = getattr(self, "_last_split_file", "")
segments = getattr(self, "_last_split_segments", [])
if not source or not (0 <= index < len(segments)):
self.segmentExported.emit({
"success": False,
"error": "No split result to export"
})
return
seg = segments[index]
src = Path(source)
dest = src.with_name(f"{src.stem}_{seg.label.lower()}{index + 1}.csv")
try:
ok = CsvRecorder.write_segment(
str(dest), str(src), seg.start_frame, seg.end_frame)
except Exception as exc:
log.warning("Segment export failed: %s", exc)
self.segmentExported.emit({"success": False, "error": str(exc)})
return
if ok:
self.segmentExported.emit({"success": True, "path": str(dest)})
else:
self.segmentExported.emit({
"success": False,
"error": "No frames in range"
})
@Slot()
@slot_error_boundary
def play(self) -> None:
@@ -826,3 +835,4 @@ class SessionController(QObject):
def _flush_trend(self) -> None:
import json
self.trendData.emit(json.dumps(self._trend_buffer))
+55 -7
View File
@@ -12,7 +12,7 @@ from pygui.backend.wafer.zwafer_models import Sensor
class CsvRecorder:
def __init__(self) -> None:
self._file_handle: Optional[TextIO] = None
self._path: Optional[str] = None
self._oath: Optional[str] = None
@property
@@ -47,9 +47,15 @@ class CsvRecorder:
return path
@staticmethod
def write_segment(file_path: str, source_path: str, start_frame: int, end_frame: int) ->bool:
def write_segment(file_path: str, source_path: str, start_frame: int, end_frame: int) -> bool:
"""Copy a range of frames from source CSV into a new file.
Preserves the source's header structure so the segment re-imports
through FileBrowser unchanged: official CSVs keep their sensor-name
row, Z-wafer CSVs keep every metadata line through the "data"
sentinel. Frame indices count only valid data rows, matching how
the readers in data_records.py number frames.
Args:
file_path: Output CSV path.
source_path: Source CSV path with full wafer data
@@ -57,11 +63,53 @@ class CsvRecorder:
end_frame: Last frame index (inclusive)
Returns:
True on success.
True on success (at least one frame written).
"""
import pandas as pd
df = pd.read_csv(source_path)
segment = df.iloc[start_frame:end_frame + 1]
segment.to_csv(file_path, index=False)
from pygui.backend.data.data_records import is_official_csv
if start_frame < 0 or end_frame < start_frame:
return False
def _is_data_row(stripped: str) -> bool:
cols = [c.strip() for c in stripped.split(",") if c.strip()]
if not cols:
return False
try:
[float(c) for c in cols]
except ValueError:
return False
return True
with open(source_path, "r", encoding="utf-8") as f:
lines = f.readlines()
out: list[str] = []
written = 0
frame_idx = -1
in_data = is_official_csv(source_path)
for i, line in enumerate(lines):
stripped = line.rstrip().rstrip(",").strip()
if not in_data:
out.append(line)
if stripped and stripped.split(",")[0].lower() == "data":
in_data = True
continue
if i == 0:
# official format: row 0 is the sensor-name header
out.append(line)
continue
if not stripped or not _is_data_row(stripped):
continue
frame_idx += 1
if start_frame <= frame_idx <= end_frame:
out.append(line if line.endswith("\n") else line + "\n")
written += 1
if written == 0:
return False
Path(file_path).parent.mkdir(parents=True, exist_ok=True)
with open(file_path, "w", encoding="utf-8", newline="") as f:
f.writelines(out)
return True
+11
View File
@@ -0,0 +1,11 @@
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,150.0,149.5,151.6
0.5,150.2,149.4,151.7
1.0,150.1,149.6,151.5
1.5,150.3,149.7,151.4
2.0,150.4,149.8,151.3
1 Wafer ID=A12
2 Acquisition Date=03/26/2025
3 Label,1,2,3
4 X (mm),0,10,-10
5 Y (mm),10,-10,-10
6 data
7 0.0,150.0,149.5,151.6
8 0.5,150.2,149.4,151.7
9 1.0,150.1,149.6,151.5
10 1.5,150.3,149.7,151.4
11 2.0,150.4,149.8,151.3
+67
View File
@@ -38,3 +38,70 @@ def test_recorded_data_rows_are_readable(tmp_path):
assert records[0].values == [149.0, 148.0]
assert records[1].time == 0.5
assert records[1].values == [149.5, 148.5]
# ===== write_segment (P3.3) =====
def _make_zwafer_csv(tmp_path, rows=5):
sensors = [Sensor("1", 0.0, 1.0), Sensor("2", 1.0, 0.0)]
path = tmp_path / "src.csv"
rec = CsvRecorder()
rec.start(str(path), sensors, serial="A12")
for i in range(rows):
rec.write(Frame(seq=i, time=i * 0.5, values=[100.0 + i, 200.0 + i]))
rec.stop()
return path
def test_write_segment_zwafer_slice(tmp_path):
src = _make_zwafer_csv(tmp_path, rows=5)
dest = tmp_path / "seg.csv"
assert CsvRecorder.write_segment(str(dest), str(src), 1, 3) is True
records = read_data_records(str(dest))
assert len(records) == 3
assert records[0].values == [101.0, 201.0]
assert records[-1].values == [103.0, 203.0]
# header survives → still parses as a wafer CSV
data, _ = ZWaferParser().parse(str(dest))
assert data is not None
assert [s.label for s in data.sensors] == ["1", "2"]
def test_write_segment_full_range(tmp_path):
src = _make_zwafer_csv(tmp_path, rows=4)
dest = tmp_path / "seg.csv"
assert CsvRecorder.write_segment(str(dest), str(src), 0, 3) is True
assert len(read_data_records(str(dest))) == 4
def test_write_segment_out_of_range(tmp_path):
src = _make_zwafer_csv(tmp_path, rows=3)
dest = tmp_path / "seg.csv"
assert CsvRecorder.write_segment(str(dest), str(src), 10, 20) is False
assert not dest.exists()
def test_write_segment_invalid_range(tmp_path):
src = _make_zwafer_csv(tmp_path, rows=3)
dest = tmp_path / "seg.csv"
assert CsvRecorder.write_segment(str(dest), str(src), 2, 1) is False
assert CsvRecorder.write_segment(str(dest), str(src), -1, 1) is False
def test_write_segment_official_csv(tmp_path):
# official format: row 0 = sensor names, rows 1+ = values only
src = tmp_path / "A00001-x.csv"
src.write_text(
"Sensor1,Sensor2\n"
"10.0,20.0\n"
"11.0,21.0\n"
"12.0,22.0\n",
encoding="utf-8",
)
dest = tmp_path / "A00001-x_seg.csv"
assert CsvRecorder.write_segment(str(dest), str(src), 1, 2) is True
lines = dest.read_text(encoding="utf-8").splitlines()
assert lines[0] == "Sensor1,Sensor2"
assert lines[1] == "11.0,21.0"
assert lines[2] == "12.0,22.0"
assert len(lines) == 3
+63 -129
View File
@@ -1,10 +1,7 @@
from unittest.mock import MagicMock
import pytest
from unittest.mock import MagicMock, patch
from pygui.backend.controllers.session_controller import SessionController
@pytest.fixture
def controller():
return SessionController()
@@ -12,7 +9,7 @@ def controller():
def test_initial_default(controller):
assert controller.mode == "review"
assert controller.state == "idle"
assert not controller.recording
assert controller.recording == False
def test_playback_flow(controller):
controller.loadFile("tests/fixtures/sample_stream.csv")
@@ -50,44 +47,13 @@ def test_pause_stop_timer(controller):
controller.stop()
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):
# Mock the comparisonResult signal
mock_slot = MagicMock()
controller.comparisonResult.connect(mock_slot)
# Use sample_stream.csv for both arguments to test identical files compare
csv_path = "tests/fixtures/sample_stream.csv"
controller.compareFiles(csv_path, csv_path)
# Two runs with the same first 3 frames but run B has 2 extra trailing frames.
controller.compareFiles("tests/fixtures/sample_stream.csv", "tests/fixtures/sample_stream_b.csv")
# Verify that the comparison signal is emitted with a success dict
assert mock_slot.call_count == 1
@@ -96,97 +62,24 @@ def test_compare_files_integration(controller):
assert "distance" in args
assert "series_a" in args
assert "series_b" in args
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")
# time_a/time_b expose per-frame timestamps in seconds for the mismatched run lengths.
assert args["time_a"] == [0.0, 0.5, 1.0]
assert args["time_b"] == [0.0, 0.5, 1.0, 1.5, 2.0]
assert args["frame_count_a"] == 3
assert args["frame_count_b"] == 5
def test_compare_files_same_file_is_gated(controller):
"""Comparing a run against itself should be rejected, not silently DTW'd."""
mock_slot = MagicMock()
controller.comparisonResult.connect(mock_slot)
controller.compareFiles(str(csv_path), str(csv_path))
csv_path = "tests/fixtures/sample_stream.csv"
controller.compareFiles(csv_path, 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
args = mock_slot.call_args[0][0]
assert args["success"] is False
assert "error" in args
def test_comparison_result_reaches_qml(qapp, controller):
"""The result dict must arrive in QML as a real JS object with fields.
@@ -216,26 +109,25 @@ Item {
assert engine.rootObjects(), "probe QML failed to load"
root = engine.rootObjects()[0]
csv_path = "tests/fixtures/sample_stream.csv"
controller.compareFiles(csv_path, csv_path)
controller.compareFiles("tests/fixtures/sample_stream.csv", "tests/fixtures/sample_stream_b.csv")
qapp.processEvents()
assert root.property("gotSuccess") is True
assert root.property("gotDistance") >= 0
def test_recording_toggle(controller, tmp_path):
csv_path = str(tmp_path / "rec" / "live_test.csv")
controller.startRecording(csv_path, "SN123")
assert controller.recording
assert controller.recording == True
controller.stopRecording()
assert not controller.recording
assert controller.recording == False
# Header written with wafer serial
content = open(csv_path).read()
assert "Wafer ID=SN123" in content
def test_resync_count_default(controller):
# No active reader -> zero
assert controller.resyncCount == 0
@@ -257,3 +149,45 @@ def test_split_data_integration(controller):
args = mock_slot.call_args[0][0]
assert args["success"] is True
assert "segments" in args
def test_export_segment_after_split(qapp, controller, tmp_path):
import shutil
src = tmp_path / "sample.csv"
shutil.copyfile("tests/fixtures/sample_stream.csv", src)
controller.splitData(str(src), 149.0)
exported = MagicMock()
controller.segmentExported.connect(exported)
controller.exportSegment(0)
assert exported.call_count == 1
result = exported.call_args[0][0]
assert result["success"] is True
out = result["path"]
assert out.startswith(str(tmp_path))
from pygui.backend.data.data_records import read_data_records
assert len(read_data_records(out)) > 0
def test_export_segment_without_split(qapp, controller):
exported = MagicMock()
controller.segmentExported.connect(exported)
controller.exportSegment(0)
result = exported.call_args[0][0]
assert result["success"] is False
def test_export_segment_bad_index(qapp, controller, tmp_path):
import shutil
src = tmp_path / "sample.csv"
shutil.copyfile("tests/fixtures/sample_stream.csv", src)
controller.splitData(str(src), 149.0)
exported = MagicMock()
controller.segmentExported.connect(exported)
controller.exportSegment(99)
assert exported.call_args[0][0]["success"] is False