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
+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