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", "scipy>=1.17.1",
"pyyaml>=6.0.3", "pyyaml>=6.0.3",
"dtw-python", "dtw-python",
"cryptograpgy>=41.0", "cryptography>=41.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
+3 -2
View File
@@ -384,8 +384,9 @@ Rectangle {
Label { Label {
text: "CONNECTION FLOW" text: "CONNECTION FLOW"
color: Theme.sideMutedText color: Theme.sideMutedText
font.pixelSize: Theme.fontXs font.family: Theme.uiFontFamily
font.letterSpacing: 1.2 font.pixelSize: Theme.fontSm
font.letterSpacing: 1.5
font.bold: true font.bold: true
} }
+221 -64
View File
@@ -22,11 +22,17 @@ Item {
property string compareFileB: "" property string compareFileB: ""
property real warpingDistance: -1 property real warpingDistance: -1
property int frameOffset: 0 property int frameOffset: 0
property real frameOffsetSeconds: 0
property real maxSensorDeviation: -1 property real maxSensorDeviation: -1
property bool comparing: false property bool comparing: false
property var trendDataA: [] property var trendDataA: []
property var trendDataB: [] property var trendDataB: []
property var timeDataA: []
property var timeDataB: []
property int frameCountA: 0
property int frameCountB: 0
property string errorMessage: "" property string errorMessage: ""
property string mismatchMessage: ""
// Wafer overlap properties // Wafer overlap properties
property var compareSensorLayout: [] property var compareSensorLayout: []
@@ -45,15 +51,28 @@ Item {
return path ? path.split("/").pop() : "" 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() { function clearResults() {
root.warpingDistance = -1 root.warpingDistance = -1
root.frameOffset = 0 root.frameOffset = 0
root.frameOffsetSeconds = 0
root.maxSensorDeviation = -1 root.maxSensorDeviation = -1
root.trendDataA = [] root.trendDataA = []
root.trendDataB = [] root.trendDataB = []
root.timeDataA = []
root.timeDataB = []
root.frameCountA = 0
root.frameCountB = 0
root.compareSensorLayout = [] root.compareSensorLayout = []
root.compareSensorDiff = [] root.compareSensorDiff = []
root.errorMessage = "" root.errorMessage = ""
root.mismatchMessage = ""
mismatchHideTimer.stop()
} }
function resetComparison() { function resetComparison() {
@@ -66,6 +85,12 @@ Item {
Component.onCompleted: file_browser.refreshFiles() 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 // Clicking a file in the left rail's browser populates the armed run slot
Connections { Connections {
target: streamController target: streamController
@@ -85,15 +110,31 @@ Item {
if (result && result.success) { if (result && result.success) {
root.warpingDistance = result.distance root.warpingDistance = result.distance
root.frameOffset = result.frame_offset !== undefined ? result.frame_offset : 0 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.maxSensorDeviation = result.max_sensor_deviation !== undefined ? result.max_sensor_deviation : -1
root.trendDataA = result.series_a || [] root.trendDataA = result.series_a || []
root.trendDataB = result.series_b || [] 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.compareSensorLayout = result.sensor_layout || []
root.compareSensorDiff = result.sensor_diff || [] root.compareSensorDiff = result.sensor_diff || []
root.compareWaferShape = result.wafer_shape || "round" root.compareWaferShape = result.wafer_shape || "round"
root.compareWaferSize = result.wafer_size || 300.0 root.compareWaferSize = result.wafer_size || 300.0
root.errorMessage = "" root.errorMessage = ""
scrubber.value = Math.floor(root.seriesLen / 2) 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 { } else {
root.clearResults() root.clearResults()
root.errorMessage = (result && result.error) ? result.error : "Comparison failed" root.errorMessage = (result && result.error) ? result.error : "Comparison failed"
@@ -103,17 +144,73 @@ Item {
// ── Reusable pieces ──────────────────────────────────────────── // ── Reusable pieces ────────────────────────────────────────────
component SectionTitle: Label { component SectionTitle: Label {
font.pixelSize: Theme.fontXs font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.letterSpacing: 1.5
font.bold: true font.bold: true
font.letterSpacing: 1.2
color: Theme.sideMutedText color: Theme.sideMutedText
} }
component PanelBox: Rectangle { component PanelBox: Rectangle {
color: Theme.cardBackground color: Theme.sidePanelBackground
border.color: Theme.cardBorder border.color: Theme.sideBorder
border.width: 1 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) // Mini stat box under the alignment scrubber (mockup's subtle-box trio)
@@ -170,7 +267,7 @@ Item {
spacing: 2 spacing: 2
Label { Label {
text: readoutCard.label text: readoutCard.label
font.pixelSize: 9 font.pixelSize: Theme.fontXs
font.bold: true font.bold: true
font.letterSpacing: 0.2 font.letterSpacing: 0.2
color: Theme.sideMutedText color: Theme.sideMutedText
@@ -225,7 +322,7 @@ Item {
spacing: 2 spacing: 2
Label { Label {
text: slotBox.title text: slotBox.title
font.pixelSize: 9 font.pixelSize: Theme.fontXs
font.bold: true font.bold: true
font.letterSpacing: 1.0 font.letterSpacing: 1.0
color: root.activeBox === slotBox.boxIndex ? slotBox.accent : Theme.sideMutedText color: root.activeBox === slotBox.boxIndex ? slotBox.accent : Theme.sideMutedText
@@ -234,7 +331,7 @@ Item {
text: slotBox.filePath !== "" text: slotBox.filePath !== ""
? root.shortName(slotBox.filePath) ? root.shortName(slotBox.filePath)
: (root.activeBox === slotBox.boxIndex ? "<- Select file from left panel..." : "Click to select") : (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 color: slotBox.filePath !== "" ? Theme.headingColor : Theme.bodyColor
opacity: slotBox.filePath !== "" ? 1.0 : 0.6 opacity: slotBox.filePath !== "" ? 1.0 : 0.6
elide: Text.ElideMiddle elide: Text.ElideMiddle
@@ -371,7 +468,7 @@ Item {
RowLayout { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
SectionTitle { SectionTitle {
text: "ALIGNED TEMP CURVE OVERLAY (DTW)" text: "CURVE OVERLAY"
Layout.fillWidth: true Layout.fillWidth: true
} }
Row { Row {
@@ -406,8 +503,8 @@ Item {
readonly property real padLeft: 50 readonly property real padLeft: 50
readonly property real padRight: 16 readonly property real padRight: 16
readonly property real padTop: 16 readonly property real padTop: 24
readonly property real padBottom: 28 readonly property real padBottom: 36
function drawGrid(ctx, minV, maxV, dataLen) { function drawGrid(ctx, minV, maxV, dataLen) {
var plotW = width - padLeft - padRight var plotW = width - padLeft - padRight
@@ -417,7 +514,7 @@ Item {
ctx.strokeStyle = Theme.chartGridLine ctx.strokeStyle = Theme.chartGridLine
ctx.lineWidth = 1 ctx.lineWidth = 1
ctx.fillStyle = Theme.chartAxisText ctx.fillStyle = Theme.chartAxisText
ctx.font = "10px sans-serif" ctx.font = "10px " + Theme.uiFontFamily
ctx.textAlign = "right" ctx.textAlign = "right"
var ySteps = 5 var ySteps = 5
@@ -440,21 +537,27 @@ Item {
var xFrac = xi / xSteps var xFrac = xi / xSteps
var xPx = padLeft + plotW * xFrac var xPx = padLeft + plotW * xFrac
var xIdx = Math.round(xFrac * (dataLen - 1)) var xIdx = Math.round(xFrac * (dataLen - 1))
ctx.fillText(xIdx.toString(), xPx, height - 6) ctx.fillText(xIdx.toString(), xPx, height - 20)
} }
ctx.save() ctx.save()
ctx.translate(12, padTop + plotH / 2) ctx.translate(14, padTop + plotH / 2)
ctx.rotate(-Math.PI / 2) ctx.rotate(-Math.PI / 2)
ctx.textAlign = "center" ctx.textAlign = "center"
ctx.fillText("°C", 0, 0) ctx.font = "12px " + Theme.uiFontFamily
ctx.fillText("Celcius (°C)", 0, 0)
ctx.restore() ctx.restore()
ctx.textAlign = "center" 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 plotW = width - padLeft - padRight
var plotH = height - padTop - padBottom var plotH = height - padTop - padBottom
@@ -462,7 +565,7 @@ Item {
ctx.lineWidth = 2 ctx.lineWidth = 2
ctx.beginPath() ctx.beginPath()
for (var i = 0; i < series.length; i++) { 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 var y = padTop + plotH - ((series[i] - minV) / range) * plotH
if (i === 0) ctx.moveTo(x, y) if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y) else ctx.lineTo(x, y)
@@ -497,8 +600,8 @@ Item {
var range = (maxV - minV) || 1 var range = (maxV - minV) || 1
drawGrid(ctx, minV, maxV, root.seriesLen) drawGrid(ctx, minV, maxV, root.seriesLen)
drawSeries(ctx, root.trendDataA, minV, range, Theme.primaryAccent) drawSeries(ctx, root.trendDataA, minV, range, Theme.primaryAccent, root.seriesLen)
drawSeries(ctx, root.trendDataB, minV, range, Theme.themeSkill) drawSeries(ctx, root.trendDataB, minV, range, Theme.themeSkill, root.seriesLen)
drawScrubMarker(ctx) drawScrubMarker(ctx)
} }
@@ -556,19 +659,10 @@ Item {
color: Theme.cardBorder color: Theme.cardBorder
} }
RowLayout { SectionTitle {
text: "TIMELINE SCRUBBER"
Layout.fillWidth: true Layout.fillWidth: true
SectionTitle { Layout.topMargin: 8
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
}
} }
Slider { Slider {
@@ -586,24 +680,30 @@ Item {
spacing: 8 spacing: 8
readonly property int scrubIdx: Math.round(scrubber.value) readonly property int scrubIdx: Math.round(scrubber.value)
readonly property real tempA: root.trendDataA.length > 0 readonly property bool hasA: scrubIdx < root.trendDataA.length
? root.trendDataA[Math.min(scrubIdx, root.trendDataA.length - 1)] : 0 readonly property bool hasB: scrubIdx < root.trendDataB.length
readonly property real tempB: root.trendDataB.length > 0 readonly property real tempA: hasA ? root.trendDataA[scrubIdx] : 0
? root.trendDataB[Math.min(scrubIdx, root.trendDataB.length - 1)] : 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 { ScrubStat {
label: "Temp (Run A)" label: "Temp (Run A)"
value: scrubReadout.tempA.toFixed(1) + "°C" value: scrubReadout.hasA ? scrubReadout.tempA.toFixed(1) + "°C" : "none"
valueColor: Theme.primaryAccent valueColor: Theme.primaryAccent
} }
ScrubStat { ScrubStat {
label: "Temp (Run B)" label: "Temp (Run B)"
value: scrubReadout.tempB.toFixed(1) + "°C" value: scrubReadout.hasB ? scrubReadout.tempB.toFixed(1) + "°C" : "none"
valueColor: Theme.themeSkill valueColor: Theme.themeSkill
} }
ScrubStat { ScrubStat {
label: "Difference" 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 { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
SectionTitle { SectionTitle {
text: "WAFER MAP OVERLAP VIEW" text: "WAFER OVERLAP"
Layout.fillWidth: true Layout.fillWidth: true
} }
Label { Label {
@@ -742,7 +842,10 @@ Item {
id: compareBtn id: compareBtn
Layout.fillWidth: true Layout.fillWidth: true
Layout.preferredHeight: 32 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: { onClicked: {
root.comparing = true root.comparing = true
root.clearResults() root.clearResults()
@@ -761,11 +864,13 @@ Item {
anchors.centerIn: parent anchors.centerIn: parent
Label { Label {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: compareBtn.enabled text: root.comparing
? (root.comparing ? "Comparing…" : "Run DTW Comparison") ? "Comparing…"
: (root.comparing ? "Comparing…" : "Select both runs") : compareBtn.enabled
? "Run DTW Comparison"
: (compareBtn.sameFile ? "Choose different runs" : "Select both runs")
color: compareBtn.enabled ? Theme.tone100 : Theme.sideMutedText color: compareBtn.enabled ? Theme.tone100 : Theme.sideMutedText
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontSm
font.bold: true font.bold: true
} }
BusyIndicator { BusyIndicator {
@@ -790,22 +895,14 @@ Item {
anchors.margins: 12 anchors.margins: 12
spacing: 8 spacing: 8
SectionTitle { text: "OVERLAP MAP SETTINGS" } SectionTitle { text: "OVERLAP SETTINGS" }
CheckBox { PanelCheckBox {
id: enableOverlapCheck id: enableOverlapCheck
Layout.fillWidth: true Layout.fillWidth: true
checked: true checked: true
text: "Enable Overlap Map" text: "Enable Overlap Map"
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontSm
contentItem: Label {
leftPadding: enableOverlapCheck.indicator.width + 6
text: enableOverlapCheck.text
font.pixelSize: Theme.fontXs
color: Theme.headingColor
verticalAlignment: Text.AlignVCenter
}
} }
ColumnLayout { ColumnLayout {
@@ -824,11 +921,11 @@ Item {
spacing: 6 spacing: 6
Label { Label {
text: "Blend" text: "Blend"
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontSm
color: Theme.bodyColor color: Theme.bodyColor
Layout.preferredWidth: 40 Layout.preferredWidth: 40
} }
Slider { PanelSlider {
id: blendSlider id: blendSlider
Layout.fillWidth: true Layout.fillWidth: true
from: 0; to: 100; value: 50 from: 0; to: 100; value: 50
@@ -836,7 +933,7 @@ Item {
} }
Label { Label {
text: Math.round(blendSlider.value) + "%" text: Math.round(blendSlider.value) + "%"
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontSm
color: Theme.sideMutedText color: Theme.sideMutedText
Layout.preferredWidth: 30 Layout.preferredWidth: 30
} }
@@ -849,17 +946,17 @@ Item {
Row { Row {
spacing: 3 spacing: 3
Rectangle { width: 8; height: 8; radius: 4; color: Theme.sensorLow; anchors.verticalCenter: parent.verticalCenter } 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 { Row {
spacing: 3 spacing: 3
Rectangle { width: 8; height: 8; radius: 4; color: Theme.sensorInRange; anchors.verticalCenter: parent.verticalCenter } 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 { Row {
spacing: 3 spacing: 3
Rectangle { width: 8; height: 8; radius: 4; color: Theme.sensorHigh; anchors.verticalCenter: parent.verticalCenter } 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 anchors.margins: 12
spacing: 8 spacing: 8
SectionTitle { text: "DTW COMPARISON READOUT" } SectionTitle { text: "DTW READOUT" }
ReadoutCard { ReadoutCard {
label: "DTW ALIGNMENT DISTANCE" label: "DTW ALIGNMENT DISTANCE"
@@ -889,7 +986,8 @@ Item {
ReadoutCard { ReadoutCard {
label: "TEMPORAL FRAME OFFSET" label: "TEMPORAL FRAME OFFSET"
value: root.warpingDistance >= 0 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 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 {
text: "CONNECTION STATUS" text: "CONNECTION STATUS"
color: Theme.sideMutedText color: Theme.sideMutedText
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontSm
font.weight: Font.Medium font.bold: true
font.letterSpacing: 1.2 font.letterSpacing: 1.5
font.family: Theme.uiFontFamily font.family: Theme.uiFontFamily
} }
@@ -297,9 +297,9 @@ ColumnLayout {
Text { Text {
text: "DIRECTORY" text: "DIRECTORY"
color: Theme.sideMutedText color: Theme.sideMutedText
font.pixelSize: Theme.fontMd font.pixelSize: Theme.fontSm
font.weight: Font.Medium font.bold: true
font.letterSpacing: 1.2 font.letterSpacing: 1.5
font.family: Theme.uiFontFamily font.family: Theme.uiFontFamily
} }
Text { Text {
+28 -28
View File
@@ -101,7 +101,7 @@ ColumnLayout {
text: "LIVE STREAM" text: "LIVE STREAM"
color: Theme.sideMutedText color: Theme.sideMutedText
font.family: Theme.uiFontFamily font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontSm
font.letterSpacing: 1.5 font.letterSpacing: 1.5
font.bold: true font.bold: true
Layout.bottomMargin: 4 Layout.bottomMargin: 4
@@ -113,7 +113,7 @@ ColumnLayout {
text: "Received" text: "Received"
color: Theme.sideMutedText color: Theme.sideMutedText
font.family: Theme.uiFontFamily font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontSm
font.bold: true font.bold: true
} }
Item { Layout.fillWidth: true } Item { Layout.fillWidth: true }
@@ -121,7 +121,7 @@ ColumnLayout {
text: streamController.receivedCount text: streamController.receivedCount
color: Theme.headingColor color: Theme.headingColor
font.family: Theme.codeFontFamily font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontMd font.pixelSize: Theme.fontLg
font.bold: true font.bold: true
} }
} }
@@ -138,7 +138,7 @@ ColumnLayout {
text: "Errors" text: "Errors"
color: Theme.sideMutedText color: Theme.sideMutedText
font.family: Theme.uiFontFamily font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontSm
font.bold: true font.bold: true
} }
Item { Layout.fillWidth: true } Item { Layout.fillWidth: true }
@@ -146,7 +146,7 @@ ColumnLayout {
text: streamController.errorCount text: streamController.errorCount
color: streamController.errorCount > 0 ? Theme.statusWarningColor : Theme.headingColor color: streamController.errorCount > 0 ? Theme.statusWarningColor : Theme.headingColor
font.family: Theme.codeFontFamily font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontMd font.pixelSize: Theme.fontLg
font.bold: true font.bold: true
} }
} }
@@ -207,7 +207,7 @@ ColumnLayout {
text: "READOUT" text: "READOUT"
color: Theme.sideMutedText color: Theme.sideMutedText
font.family: Theme.uiFontFamily font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontSm
font.letterSpacing: 1.5 font.letterSpacing: 1.5
font.bold: true font.bold: true
Layout.leftMargin: 14 Layout.leftMargin: 14
@@ -226,7 +226,7 @@ ColumnLayout {
text: "MIN TEMP" text: "MIN TEMP"
color: Theme.sideMutedText color: Theme.sideMutedText
font.family: Theme.uiFontFamily font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontSm
font.bold: true font.bold: true
} }
Item { Layout.fillWidth: true } Item { Layout.fillWidth: true }
@@ -236,7 +236,7 @@ ColumnLayout {
text: s.min !== undefined ? s.min : "—" text: s.min !== undefined ? s.min : "—"
color: Theme.sensorLow color: Theme.sensorLow
font.family: Theme.codeFontFamily font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontMd font.pixelSize: Theme.fontLg
font.bold: true font.bold: true
} }
Label { Label {
@@ -267,7 +267,7 @@ ColumnLayout {
text: "MAX TEMP" text: "MAX TEMP"
color: Theme.sideMutedText color: Theme.sideMutedText
font.family: Theme.uiFontFamily font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontSm
font.bold: true font.bold: true
} }
Item { Layout.fillWidth: true } Item { Layout.fillWidth: true }
@@ -277,7 +277,7 @@ ColumnLayout {
text: s.max !== undefined ? s.max : "—" text: s.max !== undefined ? s.max : "—"
color: Theme.sensorHigh color: Theme.sensorHigh
font.family: Theme.codeFontFamily font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontMd font.pixelSize: Theme.fontLg
font.bold: true font.bold: true
} }
Label { Label {
@@ -308,7 +308,7 @@ ColumnLayout {
text: "DIFF" text: "DIFF"
color: Theme.sideMutedText color: Theme.sideMutedText
font.family: Theme.uiFontFamily font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontSm
font.bold: true font.bold: true
} }
Item { Layout.fillWidth: true } Item { Layout.fillWidth: true }
@@ -316,7 +316,7 @@ ColumnLayout {
text: s.diff !== undefined ? s.diff : "—" text: s.diff !== undefined ? s.diff : "—"
color: Theme.diffAccent color: Theme.diffAccent
font.family: Theme.codeFontFamily font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontMd font.pixelSize: Theme.fontLg
font.bold: true font.bold: true
} }
} }
@@ -337,7 +337,7 @@ ColumnLayout {
text: "AVERAGE" text: "AVERAGE"
color: Theme.sideMutedText color: Theme.sideMutedText
font.family: Theme.uiFontFamily font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontSm
font.bold: true font.bold: true
} }
Item { Layout.fillWidth: true } Item { Layout.fillWidth: true }
@@ -345,7 +345,7 @@ ColumnLayout {
text: s.avg !== undefined ? s.avg : "—" text: s.avg !== undefined ? s.avg : "—"
color: Theme.headingColor color: Theme.headingColor
font.family: Theme.codeFontFamily font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontMd font.pixelSize: Theme.fontLg
font.bold: true font.bold: true
} }
} }
@@ -366,7 +366,7 @@ ColumnLayout {
text: "SIGMA (Σ)" text: "SIGMA (Σ)"
color: Theme.sideMutedText color: Theme.sideMutedText
font.family: Theme.uiFontFamily font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontSm
font.bold: true font.bold: true
} }
Item { Layout.fillWidth: true } Item { Layout.fillWidth: true }
@@ -374,7 +374,7 @@ ColumnLayout {
text: s.sigma !== undefined ? s.sigma : "—" text: s.sigma !== undefined ? s.sigma : "—"
color: Theme.headingColor color: Theme.headingColor
font.family: Theme.codeFontFamily font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontMd font.pixelSize: Theme.fontLg
font.bold: true font.bold: true
} }
} }
@@ -395,7 +395,7 @@ ColumnLayout {
text: "3Σ VALUE" text: "3Σ VALUE"
color: Theme.sideMutedText color: Theme.sideMutedText
font.family: Theme.uiFontFamily font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontSm
font.bold: true font.bold: true
} }
Item { Layout.fillWidth: true } Item { Layout.fillWidth: true }
@@ -403,7 +403,7 @@ ColumnLayout {
text: s.threeSigma !== undefined ? s.threeSigma : "—" text: s.threeSigma !== undefined ? s.threeSigma : "—"
color: Theme.headingColor color: Theme.headingColor
font.family: Theme.codeFontFamily font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontMd font.pixelSize: Theme.fontLg
font.bold: true font.bold: true
} }
} }
@@ -432,7 +432,7 @@ ColumnLayout {
text: "DISPLAY" text: "DISPLAY"
color: Theme.sideMutedText color: Theme.sideMutedText
font.family: Theme.uiFontFamily font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontSm
font.letterSpacing: 1.5 font.letterSpacing: 1.5
font.bold: true font.bold: true
Layout.bottomMargin: 4 Layout.bottomMargin: 4
@@ -442,21 +442,21 @@ ColumnLayout {
id: thicknessToggle id: thicknessToggle
text: "Show Thickness" text: "Show Thickness"
checked: false checked: false
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontSm
} }
PanelCheckBox { PanelCheckBox {
id: labelsToggle id: labelsToggle
text: "Labels" text: "Labels"
checked: true checked: true
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontSm
} }
PanelCheckBox { PanelCheckBox {
id: clusterAverageToggle id: clusterAverageToggle
text: "Average Clusters" text: "Average Clusters"
checked: streamController.clusterAveragingEnabled checked: streamController.clusterAveragingEnabled
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontSm
onCheckedChanged: streamController.clusterAveragingEnabled = checked onCheckedChanged: streamController.clusterAveragingEnabled = checked
} }
@@ -472,7 +472,7 @@ ColumnLayout {
Label { Label {
text: "Heatmap" text: "Heatmap"
color: Theme.bodyColor color: Theme.bodyColor
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontSm
Layout.preferredWidth: 52 Layout.preferredWidth: 52
} }
Slider { Slider {
@@ -500,7 +500,7 @@ ColumnLayout {
Label { Label {
text: Math.round(heatmapSlider.value * 100) + "%" text: Math.round(heatmapSlider.value * 100) + "%"
color: Theme.bodyColor color: Theme.bodyColor
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontSm
font.weight: Font.Medium font.weight: Font.Medium
Layout.preferredWidth: 32 Layout.preferredWidth: 32
horizontalAlignment: Text.AlignRight horizontalAlignment: Text.AlignRight
@@ -531,7 +531,7 @@ ColumnLayout {
text: "THRESHOLDS" text: "THRESHOLDS"
color: Theme.sideMutedText color: Theme.sideMutedText
font.family: Theme.uiFontFamily font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontSm
font.letterSpacing: 1.5 font.letterSpacing: 1.5
font.bold: true font.bold: true
Layout.bottomMargin: 4 Layout.bottomMargin: 4
@@ -540,7 +540,7 @@ ColumnLayout {
Label { Label {
text: "Set Point (°C)" text: "Set Point (°C)"
color: Theme.bodyColor color: Theme.bodyColor
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontSm
} }
TextField { TextField {
id: spField id: spField
@@ -570,7 +570,7 @@ ColumnLayout {
Label { Label {
text: "Margin (±°C)" text: "Margin (±°C)"
color: Theme.bodyColor color: Theme.bodyColor
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontSm
} }
TextField { TextField {
id: mgField id: mgField
@@ -601,7 +601,7 @@ ColumnLayout {
id: autoCheck id: autoCheck
text: "Auto range (mean ± 1σ)" text: "Auto range (mean ± 1σ)"
checked: true checked: true
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontSm
onCheckedChanged: pushThresholds() 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: "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: "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 } 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 color: Theme.bodyColor
font.pixelSize: Theme.fontSm 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 color: Theme.bodyColor
font.pixelSize: Theme.fontSm 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 ──────────────────────────────────────── // ── Backend Connection ────────────────────────────────────────
@@ -302,6 +338,7 @@ Popup {
target: streamController target: streamController
function onSplitResult(result) { function onSplitResult(result) {
root.segmenting = false; root.segmenting = false;
exportStatus.text = "";
if (result && result.success) { if (result && result.success) {
root.segments = result.segments || []; root.segments = result.segments || [];
console.log("[SplitDialog] Segmented into", root.segments.length, "phases"); console.log("[SplitDialog] Segmented into", root.segments.length, "phases");
@@ -310,6 +347,13 @@ Popup {
console.log("[SplitDialog] Split failed:", result.error || "unknown"); 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 json
import logging import logging
import time import time
from pathlib import Path
from typing import Optional from typing import Optional
from PySide6.QtCore import Property, QObject, Qt, QTimer, Signal, Slot from PySide6.QtCore import Property, QObject, Qt, QTimer, Signal, Slot
@@ -36,7 +35,6 @@ class SessionController(QObject):
recordingChanged = Signal() recordingChanged = Signal()
sensorsChanged = Signal() sensorsChanged = Signal()
loadedFileChanged = Signal() loadedFileChanged = Signal()
loadFileError = Signal(str) # emitted when loadFile() fails, so QML can show why
clusterAveragingEnabledChanged = Signal() clusterAveragingEnabledChanged = Signal()
settingsChanged = Signal() # emitted when session state changes that should be persisted settingsChanged = Signal() # emitted when session state changes that should be persisted
liveStatsChanged = Signal() # emitted when receivedCount or errorCount change 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. # 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 comparisonResult = Signal(dict) # dict with success, distance, warping_path, max_sensor_deviation, series_a, series_b
splitResult = Signal(dict) # dict with success, segments 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 # private: marshal a worker-thread frame onto the main thread
_liveFrame = Signal(object) # Frame _liveFrame = Signal(object) # Frame
_liveError = Signal() # worker-thread error ping _liveError = Signal() # worker-thread error ping
@@ -259,6 +258,8 @@ class SessionController(QObject):
@Slot(str) @Slot(str)
@slot_error_boundary @slot_error_boundary
def loadFile(self, file_path: str) -> None: def loadFile(self, file_path: str) -> None:
from pathlib import Path
from pygui.backend.data.data_records import ( from pygui.backend.data.data_records import (
is_official_csv, is_official_csv,
read_data_records, read_data_records,
@@ -275,30 +276,17 @@ class SessionController(QObject):
try: try:
data, _ = ZWaferParser().parse(file_path) data, _ = ZWaferParser().parse(file_path)
except (ValueError, KeyError) as exc: except (ValueError, KeyError) as exc:
msg = f"Could not parse {Path(file_path).name}: {exc}" log.warning("Could not parse %s: %s", file_path, exc)
log.warning("%s", msg)
self.loadFileError.emit(msg)
return return
if data is None or not data.sensors: if data is None or not data.sensors:
msg = f"Could not parse {Path(file_path).name}" log.warning("Could not parse %s", file_path)
log.warning("%s", msg)
self.loadFileError.emit(msg)
return return
records = read_data_records(file_path) records = read_data_records(file_path)
sensors = data.sensors sensors = data.sensors
frames = frames_from_wafer_data(data, records) frames = frames_from_wafer_data(data, records)
if not sensors: if not sensors or not frames:
msg = f"No sensor layout found in {Path(file_path).name}" log.warning("No sensors or data in %s", file_path)
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)
return return
wafer_id = "" wafer_id = ""
@@ -317,10 +305,6 @@ class SessionController(QObject):
self._model.reset() self._model.reset()
self._stats_tracker.reset() self._stats_tracker.reset()
self._loaded_file = file_path 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.loadedFileChanged.emit()
self.sensorsChanged.emit() self.sensorsChanged.emit()
self._emit_current() self._emit_current()
@@ -347,46 +331,19 @@ class SessionController(QObject):
@slot_error_boundary @slot_error_boundary
def compareFiles(self, file_a: str, file_b: str) -> None: def compareFiles(self, file_a: str, file_b: str) -> None:
"""Run DTW comparison between two CSV files and emit result.""" """Run DTW comparison between two CSV files and emit result."""
from pathlib import Path
from pygui.backend.comparison import compare_runs from pygui.backend.comparison import compare_runs
from pygui.backend.data.data_records import is_official_csv, read_data_records, read_official_csv 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: 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): if is_official_csv(file_a):
_, recs_a = read_official_csv(file_a) _, recs_a = read_official_csv(file_a)
else: else:
@@ -403,30 +360,32 @@ class SessionController(QObject):
}) })
return return
# Compare the full overlapping length of both runs. DTW's O(n*m) cost len_a, len_b = len(recs_a), len(recs_b)
# matrix is cheap even at a few thousand frames (~26MB at 1800x1800), # DTW can only align frames both runs actually have; the longer
# so only cap against pathologically long files, not typical runs. # run's extra tail is still returned for display, just unaligned.
max_frames = min(5000, len(recs_a), len(recs_b)) overlap_frames = min(len_a, len_b)
series_a = [recs_a[i].values[0] if recs_a[i].values else 0.0 series_a = [recs_a[i].values[0] if recs_a[i].values else 0.0 for i in range(len_a)]
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(len_b)]
series_b = [recs_b[i].values[0] if recs_b[i].values else 0.0 time_a = [round(recs_a[i].time, 3) for i in range(len_a)]
for i in range(max_frames)] 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 # Mean temporal shift along the DTW path: positive means run B
# reaches the same profile features later than run A. # reaches the same profile features later than run A.
path = result["warping_path"] path = result["warping_path"]
frame_offset = round(sum(j - i for i, j in path) / len(path)) if path else 0 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 # Max sensor deviation: walk the DTW-aligned frame pairs (bounded to
# the largest per-sensor abs difference across all sensors, not # the overlap both runs share) and take the largest per-sensor abs
# just the sensor[0] series used for alignment. # 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 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 max_sensor_deviation = 0.0
for i, j in zip(result["index_a"], result["index_b"]): 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_a = recs_a[i].values
values_b = recs_b[j].values values_b = recs_b[j].values
for s in range(min(num_sensors, len(values_a), len(values_b))): for s in range(min(num_sensors, len(values_a), len(values_b))):
@@ -435,9 +394,8 @@ class SessionController(QObject):
max_sensor_deviation = diff max_sensor_deviation = diff
# Sensor layout + per-sensor diff for the wafer overlap view. Reuses the # 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 # same sensor-parsing branch as loadFile() (file_a is assumed representative
# guaranteed to share a wafer type by the gate above. # of both files' wafer type).
from pygui.backend.data.data_records import is_official_csv, read_official_csv
from pygui.backend.wafer.wafer_layouts import resolve_shape_and_size from pygui.backend.wafer.wafer_layouts import resolve_shape_and_size
from pygui.backend.wafer.zwafer_parser import ZWaferParser from pygui.backend.wafer.zwafer_parser import ZWaferParser
@@ -457,8 +415,8 @@ class SessionController(QObject):
if sensors: if sensors:
wafer_shape, wafer_size = resolve_shape_and_size(sensors, wafer_id) wafer_shape, wafer_size = resolve_shape_and_size(sensors, wafer_id)
last_a = recs_a[max_frames - 1].values last_a = recs_a[overlap_frames - 1].values
last_b = recs_b[max_frames - 1].values last_b = recs_b[overlap_frames - 1].values
n = min(len(sensors), len(last_a), len(last_b)) n = min(len(sensors), len(last_a), len(last_b))
sensor_layout = [ sensor_layout = [
{ {
@@ -481,9 +439,14 @@ class SessionController(QObject):
# Lists, not tuples — tuples don't survive QVariant conversion # Lists, not tuples — tuples don't survive QVariant conversion
"warping_path": [list(p) for p in result["warping_path"][:50]], "warping_path": [list(p) for p in result["warping_path"][:50]],
"frame_offset": frame_offset, "frame_offset": frame_offset,
"frame_offset_seconds": frame_offset_seconds,
"max_sensor_deviation": max_sensor_deviation, "max_sensor_deviation": max_sensor_deviation,
"series_a": series_a, "series_a": series_a,
"series_b": series_b, "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_layout": sensor_layout,
"sensor_diff": sensor_diff, "sensor_diff": sensor_diff,
"wafer_shape": wafer_shape, "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] avg_temps = [rec.values[0] if rec.values else 0.0 for rec in records]
segments = segment_profile(avg_temps, threshold) 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({ self.splitResult.emit({
"success": True, "success": True,
"segments": [{ "segments": [{
@@ -536,6 +503,48 @@ class SessionController(QObject):
"error": str(exc) "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()
@slot_error_boundary @slot_error_boundary
def play(self) -> None: def play(self) -> None:
@@ -826,3 +835,4 @@ class SessionController(QObject):
def _flush_trend(self) -> None: def _flush_trend(self) -> None:
import json import json
self.trendData.emit(json.dumps(self._trend_buffer)) 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: class CsvRecorder:
def __init__(self) -> None: def __init__(self) -> None:
self._file_handle: Optional[TextIO] = None self._file_handle: Optional[TextIO] = None
self._path: Optional[str] = None self._oath: Optional[str] = None
@property @property
@@ -47,9 +47,15 @@ class CsvRecorder:
return path return path
@staticmethod @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. """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: Args:
file_path: Output CSV path. file_path: Output CSV path.
source_path: Source CSV path with full wafer data source_path: Source CSV path with full wafer data
@@ -57,11 +63,53 @@ class CsvRecorder:
end_frame: Last frame index (inclusive) end_frame: Last frame index (inclusive)
Returns: Returns:
True on success. True on success (at least one frame written).
""" """
import pandas as pd from pygui.backend.data.data_records import is_official_csv
df = pd.read_csv(source_path)
segment = df.iloc[start_frame:end_frame + 1] if start_frame < 0 or end_frame < start_frame:
segment.to_csv(file_path, index=False) 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 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[0].values == [149.0, 148.0]
assert records[1].time == 0.5 assert records[1].time == 0.5
assert records[1].values == [149.5, 148.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 import pytest
from unittest.mock import MagicMock, patch
from pygui.backend.controllers.session_controller import SessionController from pygui.backend.controllers.session_controller import SessionController
@pytest.fixture @pytest.fixture
def controller(): def controller():
return SessionController() return SessionController()
@@ -12,7 +9,7 @@ def controller():
def test_initial_default(controller): def test_initial_default(controller):
assert controller.mode == "review" assert controller.mode == "review"
assert controller.state == "idle" assert controller.state == "idle"
assert not controller.recording assert controller.recording == False
def test_playback_flow(controller): def test_playback_flow(controller):
controller.loadFile("tests/fixtures/sample_stream.csv") controller.loadFile("tests/fixtures/sample_stream.csv")
@@ -50,44 +47,13 @@ def test_pause_stop_timer(controller):
controller.stop() controller.stop()
assert controller.frameIndex == 0 assert controller.frameIndex == 0
def test_load_file_emits_error_for_empty_recording(controller, tmp_path):
"""A recording with a valid header but zero data rows (e.g. Record was
started/stopped without any live frames arriving) must surface a clear
loadFileError instead of silently doing nothing."""
empty_recording = tmp_path / "live_A00001_20260706_120427.csv"
empty_recording.write_text(
"Wafer ID=A00001\n"
"Acquisition Date=07/06/2026\n"
"Label,1,2\n"
"X (mm),0.0,10.0\n"
"Y (mm),10.0,-10.0\n"
"data\n"
)
mock_slot = MagicMock()
controller.loadFileError.connect(mock_slot)
controller.loadFile(str(empty_recording))
assert mock_slot.call_count == 1
assert "no recorded frames" in mock_slot.call_args[0][0]
assert controller.loadedFile == ""
def test_load_file_switches_to_review_mode(controller):
"""Loading a file for playback must always land in review mode, even if
the controller was left in live mode from a previous session otherwise
a successful load can be invisible behind a stale Live toggle."""
controller._mode = "live"
controller.loadFile("tests/fixtures/sample_stream.csv")
assert controller.mode == "review"
def test_compare_files_integration(controller): def test_compare_files_integration(controller):
# Mock the comparisonResult signal # Mock the comparisonResult signal
mock_slot = MagicMock() mock_slot = MagicMock()
controller.comparisonResult.connect(mock_slot) controller.comparisonResult.connect(mock_slot)
# Use sample_stream.csv for both arguments to test identical files compare # Two runs with the same first 3 frames but run B has 2 extra trailing frames.
csv_path = "tests/fixtures/sample_stream.csv" controller.compareFiles("tests/fixtures/sample_stream.csv", "tests/fixtures/sample_stream_b.csv")
controller.compareFiles(csv_path, csv_path)
# Verify that the comparison signal is emitted with a success dict # Verify that the comparison signal is emitted with a success dict
assert mock_slot.call_count == 1 assert mock_slot.call_count == 1
@@ -96,97 +62,24 @@ def test_compare_files_integration(controller):
assert "distance" in args assert "distance" in args
assert "series_a" in args assert "series_a" in args
assert "series_b" in args assert "series_b" in args
assert args["frame_offset"] == 0 # 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]
def test_compare_files_uses_full_run_not_first_100_frames(controller, tmp_path): assert args["time_b"] == [0.0, 0.5, 1.0, 1.5, 2.0]
"""compareFiles must score the whole run, not just an initial 100-frame window. assert args["frame_count_a"] == 3
assert args["frame_count_b"] == 5
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")
def test_compare_files_same_file_is_gated(controller):
"""Comparing a run against itself should be rejected, not silently DTW'd."""
mock_slot = MagicMock() mock_slot = MagicMock()
controller.comparisonResult.connect(mock_slot) 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 assert mock_slot.call_count == 1
result = mock_slot.call_args[0][0] args = mock_slot.call_args[0][0]
assert result["success"] is True assert args["success"] is False
assert len(result["series_a"]) == num_frames assert "error" in args
assert len(result["series_b"]) == num_frames
def test_compare_files_rejects_mismatched_wafer_types(controller, tmp_path):
"""compareFiles must refuse to compare two different wafer families.
Otherwise the overlap view silently truncates to min(sensor_count) and
pairs up unrelated physical sensors between the two wafer types.
"""
header = ["Wafer ID=A12", "Acquisition Date=03/26/2025", "Label,1,2,3",
"X (mm),0,10,-10", "Y (mm),10,-10,-10", "data",
"0.0,149.0,148.5,150.6"]
file_a = tmp_path / "A00055-20250326.csv"
file_b = tmp_path / "X00108-20250326.csv"
file_a.write_text("\n".join(header) + "\n")
file_b.write_text("\n".join(header) + "\n")
mock_slot = MagicMock()
controller.comparisonResult.connect(mock_slot)
controller.compareFiles(str(file_a), str(file_b))
assert mock_slot.call_count == 1
result = mock_slot.call_args[0][0]
assert result["success"] is False
assert "A" in result["error"] and "X" in result["error"]
def test_compare_files_rejects_recording_vs_read_mem(controller, tmp_path):
"""A live_-prefixed recording must not be compared against a read-memory
dump. Regression: stem[0] on "live_P0001_..." is "L", which used to be
misreported as a wafer-type mismatch ("L vs P") instead of this clearer
recording-vs-read-mem error.
"""
header = ["Wafer ID=P0001", "Acquisition Date=03/26/2025", "Label,1,2,3",
"X (mm),0,10,-10", "Y (mm),10,-10,-10", "data",
"0.0,149.0,148.5,150.6"]
recording = tmp_path / "live_P0001_20260706_143022.csv"
read_mem = tmp_path / "P0001-20260706_143500.csv"
recording.write_text("\n".join(header) + "\n")
read_mem.write_text("\n".join(header) + "\n")
mock_slot = MagicMock()
controller.comparisonResult.connect(mock_slot)
controller.compareFiles(str(recording), str(read_mem))
assert mock_slot.call_count == 1
result = mock_slot.call_args[0][0]
assert result["success"] is False
assert result["error"] == "Cannot compare a recording file with a read-memory file"
def test_compare_files_allows_two_recordings_of_same_wafer_type(controller, tmp_path):
"""Two live recordings of the same wafer family should compare fine —
the "live_" prefix must not be mistaken for the wafer type letter."""
header = ["Wafer ID=P0001", "Acquisition Date=03/26/2025", "Label,1,2,3",
"X (mm),0,10,-10", "Y (mm),10,-10,-10", "data",
"0.0,149.0,148.5,150.6"]
file_a = tmp_path / "live_P0001_20260706_143022.csv"
file_b = tmp_path / "live_P0002_20260706_150000.csv"
file_a.write_text("\n".join(header) + "\n")
file_b.write_text("\n".join(header) + "\n")
mock_slot = MagicMock()
controller.comparisonResult.connect(mock_slot)
controller.compareFiles(str(file_a), str(file_b))
assert mock_slot.call_count == 1
result = mock_slot.call_args[0][0]
assert result["success"] is True
def test_comparison_result_reaches_qml(qapp, controller): def test_comparison_result_reaches_qml(qapp, controller):
"""The result dict must arrive in QML as a real JS object with fields. """The result dict must arrive in QML as a real JS object with fields.
@@ -216,26 +109,25 @@ Item {
assert engine.rootObjects(), "probe QML failed to load" assert engine.rootObjects(), "probe QML failed to load"
root = engine.rootObjects()[0] root = engine.rootObjects()[0]
csv_path = "tests/fixtures/sample_stream.csv" controller.compareFiles("tests/fixtures/sample_stream.csv", "tests/fixtures/sample_stream_b.csv")
controller.compareFiles(csv_path, csv_path)
qapp.processEvents() qapp.processEvents()
assert root.property("gotSuccess") is True assert root.property("gotSuccess") is True
assert root.property("gotDistance") >= 0 assert root.property("gotDistance") >= 0
def test_recording_toggle(controller, tmp_path): def test_recording_toggle(controller, tmp_path):
csv_path = str(tmp_path / "rec" / "live_test.csv") csv_path = str(tmp_path / "rec" / "live_test.csv")
controller.startRecording(csv_path, "SN123") controller.startRecording(csv_path, "SN123")
assert controller.recording assert controller.recording == True
controller.stopRecording() controller.stopRecording()
assert not controller.recording assert controller.recording == False
# Header written with wafer serial # Header written with wafer serial
content = open(csv_path).read() content = open(csv_path).read()
assert "Wafer ID=SN123" in content assert "Wafer ID=SN123" in content
def test_resync_count_default(controller): def test_resync_count_default(controller):
# No active reader -> zero # No active reader -> zero
assert controller.resyncCount == 0 assert controller.resyncCount == 0
@@ -257,3 +149,45 @@ def test_split_data_integration(controller):
args = mock_slot.call_args[0][0] args = mock_slot.call_args[0][0]
assert args["success"] is True assert args["success"] is True
assert "segments" in args 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