style: increase ReadoutPanel font sizes and add CSV test fixture for data logging
This commit is contained in:
+1
-1
@@ -18,7 +18,7 @@ dependencies = [
|
||||
"scipy>=1.17.1",
|
||||
"pyyaml>=6.0.3",
|
||||
"dtw-python",
|
||||
"cryptograpgy>=41.0",
|
||||
"cryptography>=41.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+220
-63
@@ -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 {
|
||||
Layout.fillWidth: true
|
||||
SectionTitle {
|
||||
text: "ALIGNMENT TIMELINE SCRUBBER"
|
||||
text: "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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -50,6 +50,12 @@ class CsvRecorder:
|
||||
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
|
||||
|
||||
|
||||
Vendored
+11
@@ -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
|
||||
|
@@ -38,3 +38,70 @@ def test_recorded_data_rows_are_readable(tmp_path):
|
||||
assert records[0].values == [149.0, 148.0]
|
||||
assert records[1].time == 0.5
|
||||
assert records[1].values == [149.5, 148.5]
|
||||
|
||||
# ===== write_segment (P3.3) =====
|
||||
def _make_zwafer_csv(tmp_path, rows=5):
|
||||
sensors = [Sensor("1", 0.0, 1.0), Sensor("2", 1.0, 0.0)]
|
||||
path = tmp_path / "src.csv"
|
||||
rec = CsvRecorder()
|
||||
rec.start(str(path), sensors, serial="A12")
|
||||
for i in range(rows):
|
||||
rec.write(Frame(seq=i, time=i * 0.5, values=[100.0 + i, 200.0 + i]))
|
||||
rec.stop()
|
||||
return path
|
||||
|
||||
|
||||
def test_write_segment_zwafer_slice(tmp_path):
|
||||
src = _make_zwafer_csv(tmp_path, rows=5)
|
||||
dest = tmp_path / "seg.csv"
|
||||
assert CsvRecorder.write_segment(str(dest), str(src), 1, 3) is True
|
||||
|
||||
records = read_data_records(str(dest))
|
||||
assert len(records) == 3
|
||||
assert records[0].values == [101.0, 201.0]
|
||||
assert records[-1].values == [103.0, 203.0]
|
||||
|
||||
# header survives → still parses as a wafer CSV
|
||||
data, _ = ZWaferParser().parse(str(dest))
|
||||
assert data is not None
|
||||
assert [s.label for s in data.sensors] == ["1", "2"]
|
||||
|
||||
|
||||
def test_write_segment_full_range(tmp_path):
|
||||
src = _make_zwafer_csv(tmp_path, rows=4)
|
||||
dest = tmp_path / "seg.csv"
|
||||
assert CsvRecorder.write_segment(str(dest), str(src), 0, 3) is True
|
||||
assert len(read_data_records(str(dest))) == 4
|
||||
|
||||
|
||||
def test_write_segment_out_of_range(tmp_path):
|
||||
src = _make_zwafer_csv(tmp_path, rows=3)
|
||||
dest = tmp_path / "seg.csv"
|
||||
assert CsvRecorder.write_segment(str(dest), str(src), 10, 20) is False
|
||||
assert not dest.exists()
|
||||
|
||||
|
||||
def test_write_segment_invalid_range(tmp_path):
|
||||
src = _make_zwafer_csv(tmp_path, rows=3)
|
||||
dest = tmp_path / "seg.csv"
|
||||
assert CsvRecorder.write_segment(str(dest), str(src), 2, 1) is False
|
||||
assert CsvRecorder.write_segment(str(dest), str(src), -1, 1) is False
|
||||
|
||||
|
||||
def test_write_segment_official_csv(tmp_path):
|
||||
# official format: row 0 = sensor names, rows 1+ = values only
|
||||
src = tmp_path / "A00001-x.csv"
|
||||
src.write_text(
|
||||
"Sensor1,Sensor2\n"
|
||||
"10.0,20.0\n"
|
||||
"11.0,21.0\n"
|
||||
"12.0,22.0\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
dest = tmp_path / "A00001-x_seg.csv"
|
||||
assert CsvRecorder.write_segment(str(dest), str(src), 1, 2) is True
|
||||
lines = dest.read_text(encoding="utf-8").splitlines()
|
||||
assert lines[0] == "Sensor1,Sensor2"
|
||||
assert lines[1] == "11.0,21.0"
|
||||
assert lines[2] == "12.0,22.0"
|
||||
assert len(lines) == 3
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
from pygui.backend.controllers.session_controller import SessionController
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def controller():
|
||||
return SessionController()
|
||||
@@ -12,7 +9,7 @@ def controller():
|
||||
def test_initial_default(controller):
|
||||
assert controller.mode == "review"
|
||||
assert controller.state == "idle"
|
||||
assert not controller.recording
|
||||
assert controller.recording == False
|
||||
|
||||
def test_playback_flow(controller):
|
||||
controller.loadFile("tests/fixtures/sample_stream.csv")
|
||||
@@ -50,44 +47,13 @@ def test_pause_stop_timer(controller):
|
||||
controller.stop()
|
||||
assert controller.frameIndex == 0
|
||||
|
||||
def test_load_file_emits_error_for_empty_recording(controller, tmp_path):
|
||||
"""A recording with a valid header but zero data rows (e.g. Record was
|
||||
started/stopped without any live frames arriving) must surface a clear
|
||||
loadFileError instead of silently doing nothing."""
|
||||
empty_recording = tmp_path / "live_A00001_20260706_120427.csv"
|
||||
empty_recording.write_text(
|
||||
"Wafer ID=A00001\n"
|
||||
"Acquisition Date=07/06/2026\n"
|
||||
"Label,1,2\n"
|
||||
"X (mm),0.0,10.0\n"
|
||||
"Y (mm),10.0,-10.0\n"
|
||||
"data\n"
|
||||
)
|
||||
|
||||
mock_slot = MagicMock()
|
||||
controller.loadFileError.connect(mock_slot)
|
||||
controller.loadFile(str(empty_recording))
|
||||
|
||||
assert mock_slot.call_count == 1
|
||||
assert "no recorded frames" in mock_slot.call_args[0][0]
|
||||
assert controller.loadedFile == ""
|
||||
|
||||
def test_load_file_switches_to_review_mode(controller):
|
||||
"""Loading a file for playback must always land in review mode, even if
|
||||
the controller was left in live mode from a previous session — otherwise
|
||||
a successful load can be invisible behind a stale Live toggle."""
|
||||
controller._mode = "live"
|
||||
controller.loadFile("tests/fixtures/sample_stream.csv")
|
||||
assert controller.mode == "review"
|
||||
|
||||
def test_compare_files_integration(controller):
|
||||
# Mock the comparisonResult signal
|
||||
mock_slot = MagicMock()
|
||||
controller.comparisonResult.connect(mock_slot)
|
||||
|
||||
# Use sample_stream.csv for both arguments to test identical files compare
|
||||
csv_path = "tests/fixtures/sample_stream.csv"
|
||||
controller.compareFiles(csv_path, csv_path)
|
||||
# Two runs with the same first 3 frames but run B has 2 extra trailing frames.
|
||||
controller.compareFiles("tests/fixtures/sample_stream.csv", "tests/fixtures/sample_stream_b.csv")
|
||||
|
||||
# Verify that the comparison signal is emitted with a success dict
|
||||
assert mock_slot.call_count == 1
|
||||
@@ -96,97 +62,24 @@ def test_compare_files_integration(controller):
|
||||
assert "distance" in args
|
||||
assert "series_a" in args
|
||||
assert "series_b" in args
|
||||
assert args["frame_offset"] == 0
|
||||
|
||||
def test_compare_files_uses_full_run_not_first_100_frames(controller, tmp_path):
|
||||
"""compareFiles must score the whole run, not just an initial 100-frame window.
|
||||
|
||||
Regression test: max_frames used to be hard-capped at 100, so a >100-frame
|
||||
run's soak/cool phases were silently excluded from the DTW comparison.
|
||||
"""
|
||||
num_frames = 150
|
||||
csv_path = tmp_path / "long_run.csv"
|
||||
lines = ["Wafer ID=A12", "Acquisition Date=03/26/2025", "Label,1,2,3",
|
||||
"X (mm),0,10,-10", "Y (mm),10,-10,-10", "data"]
|
||||
for i in range(num_frames):
|
||||
t = i * 0.5
|
||||
lines.append(f"{t},{149.0 + i * 0.01},{148.5 + i * 0.01},{150.6 + i * 0.01}")
|
||||
csv_path.write_text("\n".join(lines) + "\n")
|
||||
# time_a/time_b expose per-frame timestamps in seconds for the mismatched run lengths.
|
||||
assert args["time_a"] == [0.0, 0.5, 1.0]
|
||||
assert args["time_b"] == [0.0, 0.5, 1.0, 1.5, 2.0]
|
||||
assert args["frame_count_a"] == 3
|
||||
assert args["frame_count_b"] == 5
|
||||
|
||||
def test_compare_files_same_file_is_gated(controller):
|
||||
"""Comparing a run against itself should be rejected, not silently DTW'd."""
|
||||
mock_slot = MagicMock()
|
||||
controller.comparisonResult.connect(mock_slot)
|
||||
controller.compareFiles(str(csv_path), str(csv_path))
|
||||
|
||||
csv_path = "tests/fixtures/sample_stream.csv"
|
||||
controller.compareFiles(csv_path, csv_path)
|
||||
|
||||
assert mock_slot.call_count == 1
|
||||
result = mock_slot.call_args[0][0]
|
||||
assert result["success"] is True
|
||||
assert len(result["series_a"]) == num_frames
|
||||
assert len(result["series_b"]) == num_frames
|
||||
|
||||
def test_compare_files_rejects_mismatched_wafer_types(controller, tmp_path):
|
||||
"""compareFiles must refuse to compare two different wafer families.
|
||||
|
||||
Otherwise the overlap view silently truncates to min(sensor_count) and
|
||||
pairs up unrelated physical sensors between the two wafer types.
|
||||
"""
|
||||
header = ["Wafer ID=A12", "Acquisition Date=03/26/2025", "Label,1,2,3",
|
||||
"X (mm),0,10,-10", "Y (mm),10,-10,-10", "data",
|
||||
"0.0,149.0,148.5,150.6"]
|
||||
file_a = tmp_path / "A00055-20250326.csv"
|
||||
file_b = tmp_path / "X00108-20250326.csv"
|
||||
file_a.write_text("\n".join(header) + "\n")
|
||||
file_b.write_text("\n".join(header) + "\n")
|
||||
|
||||
mock_slot = MagicMock()
|
||||
controller.comparisonResult.connect(mock_slot)
|
||||
controller.compareFiles(str(file_a), str(file_b))
|
||||
|
||||
assert mock_slot.call_count == 1
|
||||
result = mock_slot.call_args[0][0]
|
||||
assert result["success"] is False
|
||||
assert "A" in result["error"] and "X" in result["error"]
|
||||
|
||||
def test_compare_files_rejects_recording_vs_read_mem(controller, tmp_path):
|
||||
"""A live_-prefixed recording must not be compared against a read-memory
|
||||
dump. Regression: stem[0] on "live_P0001_..." is "L", which used to be
|
||||
misreported as a wafer-type mismatch ("L vs P") instead of this clearer
|
||||
recording-vs-read-mem error.
|
||||
"""
|
||||
header = ["Wafer ID=P0001", "Acquisition Date=03/26/2025", "Label,1,2,3",
|
||||
"X (mm),0,10,-10", "Y (mm),10,-10,-10", "data",
|
||||
"0.0,149.0,148.5,150.6"]
|
||||
recording = tmp_path / "live_P0001_20260706_143022.csv"
|
||||
read_mem = tmp_path / "P0001-20260706_143500.csv"
|
||||
recording.write_text("\n".join(header) + "\n")
|
||||
read_mem.write_text("\n".join(header) + "\n")
|
||||
|
||||
mock_slot = MagicMock()
|
||||
controller.comparisonResult.connect(mock_slot)
|
||||
controller.compareFiles(str(recording), str(read_mem))
|
||||
|
||||
assert mock_slot.call_count == 1
|
||||
result = mock_slot.call_args[0][0]
|
||||
assert result["success"] is False
|
||||
assert result["error"] == "Cannot compare a recording file with a read-memory file"
|
||||
|
||||
def test_compare_files_allows_two_recordings_of_same_wafer_type(controller, tmp_path):
|
||||
"""Two live recordings of the same wafer family should compare fine —
|
||||
the "live_" prefix must not be mistaken for the wafer type letter."""
|
||||
header = ["Wafer ID=P0001", "Acquisition Date=03/26/2025", "Label,1,2,3",
|
||||
"X (mm),0,10,-10", "Y (mm),10,-10,-10", "data",
|
||||
"0.0,149.0,148.5,150.6"]
|
||||
file_a = tmp_path / "live_P0001_20260706_143022.csv"
|
||||
file_b = tmp_path / "live_P0002_20260706_150000.csv"
|
||||
file_a.write_text("\n".join(header) + "\n")
|
||||
file_b.write_text("\n".join(header) + "\n")
|
||||
|
||||
mock_slot = MagicMock()
|
||||
controller.comparisonResult.connect(mock_slot)
|
||||
controller.compareFiles(str(file_a), str(file_b))
|
||||
|
||||
assert mock_slot.call_count == 1
|
||||
result = mock_slot.call_args[0][0]
|
||||
assert result["success"] is True
|
||||
args = mock_slot.call_args[0][0]
|
||||
assert args["success"] is False
|
||||
assert "error" in args
|
||||
|
||||
def test_comparison_result_reaches_qml(qapp, controller):
|
||||
"""The result dict must arrive in QML as a real JS object with fields.
|
||||
@@ -216,26 +109,25 @@ Item {
|
||||
assert engine.rootObjects(), "probe QML failed to load"
|
||||
root = engine.rootObjects()[0]
|
||||
|
||||
csv_path = "tests/fixtures/sample_stream.csv"
|
||||
controller.compareFiles(csv_path, csv_path)
|
||||
controller.compareFiles("tests/fixtures/sample_stream.csv", "tests/fixtures/sample_stream_b.csv")
|
||||
qapp.processEvents()
|
||||
|
||||
assert root.property("gotSuccess") is True
|
||||
assert root.property("gotDistance") >= 0
|
||||
|
||||
|
||||
def test_recording_toggle(controller, tmp_path):
|
||||
csv_path = str(tmp_path / "rec" / "live_test.csv")
|
||||
controller.startRecording(csv_path, "SN123")
|
||||
assert controller.recording
|
||||
assert controller.recording == True
|
||||
|
||||
controller.stopRecording()
|
||||
assert not controller.recording
|
||||
assert controller.recording == False
|
||||
|
||||
# Header written with wafer serial
|
||||
content = open(csv_path).read()
|
||||
assert "Wafer ID=SN123" in content
|
||||
|
||||
|
||||
def test_resync_count_default(controller):
|
||||
# No active reader -> zero
|
||||
assert controller.resyncCount == 0
|
||||
@@ -257,3 +149,45 @@ def test_split_data_integration(controller):
|
||||
args = mock_slot.call_args[0][0]
|
||||
assert args["success"] is True
|
||||
assert "segments" in args
|
||||
|
||||
|
||||
|
||||
def test_export_segment_after_split(qapp, controller, tmp_path):
|
||||
import shutil
|
||||
|
||||
src = tmp_path / "sample.csv"
|
||||
shutil.copyfile("tests/fixtures/sample_stream.csv", src)
|
||||
|
||||
controller.splitData(str(src), 149.0)
|
||||
|
||||
exported = MagicMock()
|
||||
controller.segmentExported.connect(exported)
|
||||
controller.exportSegment(0)
|
||||
|
||||
assert exported.call_count == 1
|
||||
result = exported.call_args[0][0]
|
||||
assert result["success"] is True
|
||||
out = result["path"]
|
||||
assert out.startswith(str(tmp_path))
|
||||
from pygui.backend.data.data_records import read_data_records
|
||||
assert len(read_data_records(out)) > 0
|
||||
|
||||
|
||||
def test_export_segment_without_split(qapp, controller):
|
||||
exported = MagicMock()
|
||||
controller.segmentExported.connect(exported)
|
||||
controller.exportSegment(0)
|
||||
result = exported.call_args[0][0]
|
||||
assert result["success"] is False
|
||||
|
||||
|
||||
def test_export_segment_bad_index(qapp, controller, tmp_path):
|
||||
import shutil
|
||||
src = tmp_path / "sample.csv"
|
||||
shutil.copyfile("tests/fixtures/sample_stream.csv", src)
|
||||
controller.splitData(str(src), 149.0)
|
||||
|
||||
exported = MagicMock()
|
||||
controller.segmentExported.connect(exported)
|
||||
controller.exportSegment(99)
|
||||
assert exported.call_args[0][0]["success"] is False
|
||||
|
||||
Reference in New Issue
Block a user