feat: add file comparison, data splitting, and sensor modification functionality to SessionController

This commit is contained in:
jack
2026-07-06 11:58:26 -07:00
parent ecab3d81b1
commit 3cc10ea7fe
9 changed files with 1361 additions and 167 deletions
+11 -40
View File
@@ -22,6 +22,7 @@ Rectangle {
onSelectedTabIndexChanged: { onSelectedTabIndexChanged: {
if (selectedTabIndex === 2) { if (selectedTabIndex === 2) {
file_browser.refreshFiles(); file_browser.refreshFiles();
streamController.unloadFile()
} }
} }
@@ -36,10 +37,12 @@ Rectangle {
function onParsedDataReady(result) { function onParsedDataReady(result) {
if (result.success && result.csv_path) { if (result.success && result.csv_path) {
streamController.loadFile(result.csv_path) streamController.loadFile(result.csv_path)
if (root.selectedTabIndex === 0) {
root.selectedTabIndex = 2 root.selectedTabIndex = 2
} }
} }
} }
}
Connections { Connections {
target: deviceController target: deviceController
function onReadResult(result) { function onReadResult(result) {
@@ -74,6 +77,11 @@ Rectangle {
} }
} }
// ===== Split Dialog (Threshold Segmentation) =====
SplitDialog {
id: splitDialog
}
// ===== Settings Popup ===== // ===== Settings Popup =====
Popup { Popup {
id: settingsPopup id: settingsPopup
@@ -300,7 +308,7 @@ Rectangle {
StackLayout { StackLayout {
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
currentIndex: root.selectedTabIndex currentIndex: root.selectedTabIndex === 0 ? 0 : 1
// ── Status tab: Hardware Actions ──────────────── // ── Status tab: Hardware Actions ────────────────
ColumnLayout { ColumnLayout {
@@ -320,7 +328,7 @@ Rectangle {
label: "DETECT WAFER" label: "DETECT WAFER"
iconSource: "../icons/detect.svg" iconSource: "../icons/detect.svg"
Layout.fillWidth: true Layout.fillWidth: true
enabled: !deviceController.isDetecting enabled: !deviceController.operationInProgress
onClicked: root._doDetect() onClicked: root._doDetect()
} }
@@ -345,44 +353,6 @@ Rectangle {
Item { Layout.fillHeight: true } Item { Layout.fillHeight: true }
} }
// ── Data tab: Data Operations ───────────────────
ColumnLayout {
spacing: 6
Label {
text: "DATA OPERATIONS"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
topPadding: 6
font.letterSpacing: 1.5
font.bold: true
}
RailActionButton {
label: "OPEN IN EXCEL"
iconSource: "../icons/excel.svg"
Layout.fillWidth: true
onClicked: deviceController.openCsvFile()
}
RailActionButton {
label: "COMPARE CSV"
iconSource: "../icons/compare.svg"
Layout.fillWidth: true
onClicked: console.log("[P3.1] Compare dialog not yet built")
}
RailActionButton {
label: "SPLIT DATA"
iconSource: "../icons/split.svg"
Layout.fillWidth: true
onClicked: console.log("[P3.2] Split dialog not yet built")
}
Item { Layout.fillHeight: true }
}
// ── Map tab: Source file browser ────────────── // ── Map tab: Source file browser ──────────────
SourcePanel { SourcePanel {
Layout.fillWidth: true Layout.fillWidth: true
@@ -637,6 +607,7 @@ Rectangle {
active: StackLayout.index === root.selectedTabIndex active: StackLayout.index === root.selectedTabIndex
} }
Loader { Loader {
id: dataTabLoader
source: "Tabs/DataTab.qml" source: "Tabs/DataTab.qml"
active: StackLayout.index === root.selectedTabIndex active: StackLayout.index === root.selectedTabIndex
} }
+727 -103
View File
@@ -2,183 +2,807 @@ import QtQuick
import QtQuick.Controls import QtQuick.Controls
import QtQuick.Layouts import QtQuick.Layouts
import ISC import ISC
import ISC.Wafer
// ===== Data Tab ===== // ===== Data Tab (Compare Mode Only) =====
// Displays parsed temperature data in a scrollable table. // Focuses exclusively on comparing two wafer runs side-by-side using DTW.
// Column 0 = Row index, remaining columns = Sensor1, Sensor2, ... // Clicking Box 1 or Box 2 activates selection mode, and clicking a file in the
// left rail's file browser populates that slot.
Item { Item {
id: root id: root
anchors.fill: parent anchors.fill: parent
readonly property int rowCount: deviceController.dataRowCount // Properties for DTW Comparison
property int activeBox: 1 // 1 for Reference (Run A), 2 for Session (Run B), 0 for inactive
property string compareFileA: ""
property string compareFileB: ""
property real warpingDistance: -1
property real maxSensorDeviation: -1
property bool comparing: false
property var trendDataA: []
property var trendDataB: []
property string errorMessage: ""
// Re-layout columns whenever the model is reset // Wafer overlap properties
property var compareSensorLayout: []
property var compareSensorDiff: []
property string compareWaferShape: "round"
property real compareWaferSize: 300.0
function diffBands(diffs) {
return diffs.map(d => d > 1.0 ? "high" : d < -1.0 ? "low" : "in_range")
}
function resetComparison() {
root.compareFileA = ""
root.compareFileB = ""
root.warpingDistance = -1
root.maxSensorDeviation = -1
root.comparing = false
root.trendDataA = []
root.trendDataB = []
root.compareSensorLayout = []
root.compareSensorDiff = []
root.errorMessage = ""
root.activeBox = 1
}
// Connections to handle clicked/selected files on left rail file browser
Connections { Connections {
target: deviceController target: streamController
function onParsedDataReady(result) { function onLoadedFileChanged() {
if (streamController.loadedFile && root.activeBox > 0) {
if (root.activeBox === 1) {
root.compareFileA = streamController.loadedFile
root.activeBox = 2 // Auto-advance to box 2
} else if (root.activeBox === 2) {
root.compareFileB = streamController.loadedFile
root.activeBox = 0 // Finished selecting
}
}
}
function onComparisonResult(result) {
root.comparing = false
if (result && result.success) { if (result && result.success) {
dataTable.forceLayout(); root.warpingDistance = result.distance
root.maxSensorDeviation = result.max_sensor_deviation !== undefined ? result.max_sensor_deviation : -1
root.trendDataA = result.series_a || []
root.trendDataB = result.series_b || []
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 = ""
} else {
root.warpingDistance = -1
root.maxSensorDeviation = -1
root.trendDataA = []
root.trendDataB = []
root.compareSensorLayout = []
root.compareSensorDiff = []
root.errorMessage = (result && result.error) ? result.error : "Comparison failed"
} }
} }
} }
// ===== Empty State ===== // ── Main Layout ───────────────────────────────────────────────
Column {
anchors.centerIn: parent
spacing: 16
visible: root.rowCount === 0
Label {
text: "No Data"
font.pixelSize: Theme.font2xl
font.bold: true
color: Theme.headingColor
anchors.horizontalCenter: parent.horizontalCenter
}
Label {
text: "Read and parse wafer data to see temperature readings here."
font.pixelSize: Theme.fontMd
color: Theme.bodyColor
anchors.horizontalCenter: parent.horizontalCenter
wrapMode: Text.WordWrap
}
}
// ===== Data Table =====
ColumnLayout { ColumnLayout {
anchors.fill: parent anchors.fill: parent
anchors.margins: Theme.panelPadding anchors.margins: Theme.panelPadding
spacing: Theme.rightPaneGap spacing: Theme.rightPaneGap
visible: root.rowCount > 0
// --- Header row --- // ── Title / Header ─────────────────────────────────────────
RowLayout { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
spacing: Theme.rightPaneGap spacing: 8
Label { Label {
text: "Temperature Data" text: "Run Comparison (DTW)"
font.pixelSize: Theme.fontXl font.pixelSize: Theme.fontXl
font.bold: true font.bold: true
color: Theme.headingColor color: Theme.headingColor
Layout.fillWidth: true
elide: Text.ElideRight
} }
Item { // Reset button
Layout.fillWidth: true Button {
id: resetBtn
flat: true
implicitWidth: 32
implicitHeight: 32
visible: root.compareFileA !== "" || root.compareFileB !== ""
onClicked: root.resetComparison()
background: Rectangle {
radius: Theme.radiusSm
color: resetBtn.hovered ? Qt.rgba(1, 1, 1, 0.08) : "transparent"
}
contentItem: Label {
text: "\u21BA"
font.pixelSize: Theme.fontLg
color: Theme.bodyColor
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
ToolTip.visible: resetBtn.hovered
ToolTip.text: "Reset comparison"
} }
Label { Label {
text: deviceController.dataRowCount + " rows × " + deviceController.dataColCount + " sensors" text: "Dynamic Time Warping Profile Matching"
font.pixelSize: Theme.fontSm font.pixelSize: Theme.fontSm
color: Theme.bodyColor color: Theme.bodyColor
} }
} }
// --- Toolbar --- // ── Selection Boxes (Box 1 and Box 2) ──────────────────────
RowLayout { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
spacing: 8 spacing: 16
Button { // --- Box 1: Reference CSV (Run A) ---
text: "Open in Excel" Rectangle {
icon.source: "icons/excel.svg" id: box1
icon.color: Theme.headingColor Layout.fillWidth: true
onClicked: deviceController.openCsvFile() Layout.preferredHeight: 70
radius: Theme.radiusMd
color: Theme.cardBackground
border.width: root.activeBox === 1 ? 2 : 1
border.color: root.activeBox === 1 ? Theme.primaryAccent : Theme.cardBorder
Behavior on border.color { ColorAnimation { duration: Theme.durationFast } }
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.activeBox = 1
} }
Button { RowLayout {
text: "Compare" anchors.fill: parent
onClicked: compareDialog.open() anchors.margins: 14
spacing: 10
// Color indicator dot
Rectangle {
width: 10; height: 10; radius: 5
color: Theme.primaryAccent
Layout.alignment: Qt.AlignVCenter
} }
Button { ColumnLayout {
text: "Split" Layout.fillWidth: true
onClicked: splitDialog.open() spacing: 2
Label {
text: "REFERENCE (RUN A)"
font.pixelSize: Theme.fontXs
font.bold: true
font.letterSpacing: 1.0
color: root.activeBox === 1 ? Theme.primaryAccent : Theme.sideMutedText
} }
Label {
Item { text: root.compareFileA !== ""
? root.compareFileA.split("/").pop()
: (root.activeBox === 1 ? "← Select file from left panel..." : "Click to select")
font.pixelSize: Theme.fontSm
color: root.compareFileA !== "" ? Theme.headingColor : Theme.bodyColor
opacity: root.compareFileA !== "" ? 1.0 : 0.6
elide: Text.ElideMiddle
Layout.fillWidth: true Layout.fillWidth: true
} }
} }
// --- Table container ---
Button {
text: "✕"
visible: root.compareFileA !== ""
flat: true
implicitWidth: 24; implicitHeight: 24
onClicked: {
root.compareFileA = ""
root.activeBox = 1
root.warpingDistance = -1
root.trendDataA = []
root.trendDataB = []
root.compareSensorLayout = []
root.compareSensorDiff = []
}
background: Rectangle {
radius: Theme.radiusSm
color: parent.hovered ? Qt.rgba(1, 1, 1, 0.08) : "transparent"
}
contentItem: Label {
text: "✕"
color: Theme.bodyColor
font.pixelSize: Theme.fontSm
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
}
}
// --- Box 2: Session CSV (Run B) ---
Rectangle {
id: box2
Layout.fillWidth: true
Layout.preferredHeight: 70
radius: Theme.radiusMd
color: Theme.cardBackground
border.width: root.activeBox === 2 ? 2 : 1
border.color: root.activeBox === 2 ? Theme.themeSkill : Theme.cardBorder
Behavior on border.color { ColorAnimation { duration: Theme.durationFast } }
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.activeBox = 2
}
RowLayout {
anchors.fill: parent
anchors.margins: 14
spacing: 10
// Color indicator dot
Rectangle {
width: 10; height: 10; radius: 5
color: Theme.themeSkill
Layout.alignment: Qt.AlignVCenter
}
ColumnLayout {
Layout.fillWidth: true
spacing: 2
Label {
text: "SESSION (RUN B)"
font.pixelSize: Theme.fontXs
font.bold: true
font.letterSpacing: 1.0
color: root.activeBox === 2 ? Theme.themeSkill : Theme.sideMutedText
}
Label {
text: root.compareFileB !== ""
? root.compareFileB.split("/").pop()
: (root.activeBox === 2 ? "← Select file from left panel..." : "Click to select")
font.pixelSize: Theme.fontSm
color: root.compareFileB !== "" ? Theme.headingColor : Theme.bodyColor
opacity: root.compareFileB !== "" ? 1.0 : 0.6
elide: Text.ElideMiddle
Layout.fillWidth: true
}
}
Button {
text: "✕"
visible: root.compareFileB !== ""
flat: true
implicitWidth: 24; implicitHeight: 24
onClicked: {
root.compareFileB = ""
root.activeBox = 2
root.warpingDistance = -1
root.trendDataA = []
root.trendDataB = []
root.compareSensorLayout = []
root.compareSensorDiff = []
}
background: Rectangle {
radius: Theme.radiusSm
color: parent.hovered ? Qt.rgba(1, 1, 1, 0.08) : "transparent"
}
contentItem: Label {
text: "✕"
color: Theme.bodyColor
font.pixelSize: Theme.fontSm
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
}
}
}
// ── Compare Button ─────────────────────────────────────────
Button {
id: compareBtn
Layout.fillWidth: true
Layout.preferredHeight: 40
enabled: root.compareFileA !== "" && root.compareFileB !== "" && !root.comparing
onClicked: {
root.comparing = true
root.warpingDistance = -1
root.maxSensorDeviation = -1
root.trendDataA = []
root.trendDataB = []
root.errorMessage = ""
streamController.compareFiles(root.compareFileA, root.compareFileB)
}
background: Rectangle {
radius: Theme.radiusSm
color: compareBtn.enabled
? (compareBtn.hovered ? Qt.lighter(Theme.primaryAccent, 1.1) : Theme.primaryAccent)
: Theme.buttonNeutralBackground
Behavior on color { ColorAnimation { duration: Theme.durationFast } }
}
contentItem: Row {
spacing: 8
anchors.centerIn: parent
Label {
anchors.verticalCenter: parent.verticalCenter
text: compareBtn.enabled
? (root.comparing ? "Comparing…" : "▶ RUN DTW COMPARISON")
: "SELECT BOTH RUNS ABOVE"
color: compareBtn.enabled ? Theme.tone100 : Theme.sideMutedText
font.pixelSize: Theme.fontSm
font.bold: true
font.letterSpacing: 0.8
}
BusyIndicator {
running: root.comparing
visible: root.comparing
width: 16; height: 16
anchors.verticalCenter: parent.verticalCenter
}
}
}
// ── Error Banner ───────────────────────────────────────────
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: errorLabel.implicitHeight + 16
visible: root.errorMessage !== ""
color: Qt.rgba(0.93, 0.27, 0.27, 0.15)
border.color: "#ef4444"
border.width: 1
radius: Theme.radiusSm
Label {
id: errorLabel
anchors.fill: parent
anchors.margins: 8
text: "⚠ " + root.errorMessage
color: "#fca5a5"
font.pixelSize: Theme.fontSm
wrapMode: Text.WordWrap
}
}
// ── DTW Chart + Overlap Wafer Map Row ──────────────────────
RowLayout {
Layout.fillWidth: true
Layout.fillHeight: true
spacing: 16
// --- Left Column: Aligned Curves Chart ---
Rectangle { Rectangle {
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
color: Theme.panelBackground Layout.minimumWidth: 350
border.color: Theme.cardBorder
border.width: Theme.borderThin
radius: Theme.radiusSm
clip: true
ColumnLayout {
anchors.fill: parent
anchors.margins: 1
spacing: 0
// Column header strip
HorizontalHeaderView {
id: headerView
Layout.fillWidth: true
syncView: dataTable
clip: true
delegate: Rectangle {
implicitHeight: 28
color: Theme.cardBackground color: Theme.cardBackground
border.color: Theme.cardBorder border.color: Theme.cardBorder
border.width: 1 border.width: 1
radius: Theme.radiusMd
Text { ColumnLayout {
anchors.centerIn: parent anchors.fill: parent
// 'display' is the Qt.DisplayRole value from headerData() anchors.margins: 16
text: display !== undefined ? display : "" spacing: 10
color: Theme.headingColor
font.pixelSize: Theme.fontXs RowLayout {
Layout.fillWidth: true
Label {
text: "Aligned Temperature Curves (DTW)"
font.pixelSize: Theme.fontSm
font.bold: true font.bold: true
color: Theme.headingColor
Layout.fillWidth: true
elide: Text.ElideRight elide: Text.ElideRight
} }
// Legend
Row {
spacing: 16
visible: root.trendDataA.length > 1
Row {
spacing: 4
Rectangle { width: 12; height: 3; radius: 1.5; color: Theme.primaryAccent; anchors.verticalCenter: parent.verticalCenter }
Label { text: "Run A"; font.pixelSize: Theme.fontXs; color: Theme.bodyColor }
}
Row {
spacing: 4
Rectangle { width: 12; height: 3; radius: 1.5; color: Theme.themeSkill; anchors.verticalCenter: parent.verticalCenter }
Label { text: "Run B"; font.pixelSize: Theme.fontXs; color: Theme.bodyColor }
}
} }
} }
// Data rows Rectangle {
TableView {
id: dataTable
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
model: deviceController.dataModel color: Theme.fieldBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusSm
clip: true clip: true
reuseItems: true
columnWidthProvider: function (col) { readonly property bool hasSeries: root.trendDataA.length > 1 && root.trendDataB.length > 1
if (col === 0)
return 52; // row index column Canvas {
const sensorCols = Math.max(1, dataTable.columns - 1); id: overlayCanvas
const available = dataTable.width - 52; anchors.fill: parent
return Math.max(52, Math.min(90, available / sensorCols)); anchors.margins: 0
visible: parent.hasSeries
readonly property real padLeft: 50
readonly property real padRight: 16
readonly property real padTop: 16
readonly property real padBottom: 28
function drawGrid(ctx, minV, maxV, dataLen) {
var plotW = width - padLeft - padRight
var plotH = height - padTop - padBottom
var range = (maxV - minV) || 1
ctx.strokeStyle = Qt.rgba(1, 1, 1, 0.08)
ctx.lineWidth = 1
ctx.fillStyle = Qt.rgba(1, 1, 1, 0.35)
ctx.font = "10px sans-serif"
ctx.textAlign = "right"
// Y-axis gridlines + labels
var ySteps = 5
for (var yi = 0; yi <= ySteps; yi++) {
var yFrac = yi / ySteps
var yPx = padTop + plotH * yFrac
var yVal = maxV - yFrac * range
ctx.beginPath()
ctx.moveTo(padLeft, yPx)
ctx.lineTo(padLeft + plotW, yPx)
ctx.stroke()
ctx.fillText(yVal.toFixed(1), padLeft - 6, yPx + 3)
} }
rowHeightProvider: function () { // X-axis labels
return 24; ctx.textAlign = "center"
var xSteps = Math.min(5, dataLen - 1)
for (var xi = 0; xi <= xSteps; xi++) {
var xFrac = xi / xSteps
var xPx = padLeft + plotW * xFrac
var xIdx = Math.round(xFrac * (dataLen - 1))
ctx.fillText(xIdx.toString(), xPx, height - 6)
} }
delegate: Rectangle { // Y-axis title
color: row % 2 === 0 ? Theme.panelBackground : Theme.cardBackground ctx.save()
ctx.translate(12, padTop + plotH / 2)
ctx.rotate(-Math.PI / 2)
ctx.textAlign = "center"
ctx.fillText("°C", 0, 0)
ctx.restore()
Text { // X-axis title
ctx.textAlign = "center"
ctx.fillText("Frame", padLeft + plotW / 2, height - 2)
}
function drawSeries(ctx, series, minV, range, color) {
var plotW = width - padLeft - padRight
var plotH = height - padTop - padBottom
ctx.strokeStyle = color
ctx.lineWidth = 2
ctx.beginPath()
for (var i = 0; i < series.length; i++) {
var x = padLeft + (i / (series.length - 1)) * plotW
var y = padTop + plotH - ((series[i] - minV) / range) * plotH
if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
}
ctx.stroke()
}
onPaint: {
var ctx = getContext("2d")
ctx.reset()
if (!parent.hasSeries) return
var all = root.trendDataA.concat(root.trendDataB)
var minV = Math.min.apply(null, all)
var maxV = Math.max.apply(null, all)
var padding = (maxV - minV) * 0.05
minV -= padding
maxV += padding
var range = (maxV - minV) || 1
drawGrid(ctx, minV, maxV, Math.max(root.trendDataA.length, root.trendDataB.length))
drawSeries(ctx, root.trendDataA, minV, range, Theme.primaryAccent)
drawSeries(ctx, root.trendDataB, minV, range, Theme.themeSkill)
}
Connections {
target: root
function onTrendDataAChanged() { overlayCanvas.requestPaint() }
function onTrendDataBChanged() { overlayCanvas.requestPaint() }
}
}
// Empty state
ColumnLayout {
anchors.centerIn: parent anchors.centerIn: parent
text: display !== undefined ? display : "" visible: !parent.hasSeries
spacing: 8
Label {
Layout.alignment: Qt.AlignHCenter
text: root.comparing ? "" : "📊"
font.pixelSize: 32
}
Label {
Layout.alignment: Qt.AlignHCenter
text: root.comparing ? "Running DTW comparison…" : "Select two files and run comparison"
color: Theme.bodyColor
font.pixelSize: Theme.fontSm
}
Label {
Layout.alignment: Qt.AlignHCenter
visible: !root.comparing
text: "Click Box 1, then a file. Click Box 2, then a file."
color: Theme.sideMutedText
font.pixelSize: Theme.fontXs
}
}
}
}
}
// --- Right Column: Wafer Map Overlap & Readouts ---
ColumnLayout {
Layout.fillWidth: false
Layout.preferredWidth: 280
Layout.minimumWidth: 280
Layout.maximumWidth: 280
Layout.fillHeight: true
spacing: 12
// ── DTW Readout Cards ─────────────────────────────────
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: dtwReadoutCol.implicitHeight + 24
color: Theme.cardBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusMd
ColumnLayout {
id: dtwReadoutCol
anchors.fill: parent
anchors.margins: 12
spacing: 8
Label {
text: "COMPARISON RESULTS"
font.pixelSize: Theme.fontXs
font.bold: true
font.letterSpacing: 1.2
color: Theme.sideMutedText
Layout.fillWidth: true
}
// DTW Distance
Rectangle {
Layout.fillWidth: true
height: 52
color: Theme.panelBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusSm
ColumnLayout {
anchors.fill: parent
anchors.margins: 8
spacing: 2
Label {
text: "DTW Alignment Distance"
font.pixelSize: Theme.fontXs
color: Theme.bodyColor
}
Label {
text: root.warpingDistance >= 0 ? root.warpingDistance.toFixed(2) : "--"
font.pixelSize: Theme.fontLg
font.bold: true
color: root.warpingDistance >= 0
? (root.warpingDistance < 50 ? "#6ee7b7" : root.warpingDistance < 100 ? "#fde047" : "#ef4444")
: Theme.sideMutedText
}
}
}
// Max Sensor Deviation
Rectangle {
Layout.fillWidth: true
height: 52
color: Theme.panelBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusSm
ColumnLayout {
anchors.fill: parent
anchors.margins: 8
spacing: 2
Label {
text: "Max Sensor Deviation"
font.pixelSize: Theme.fontXs
color: Theme.bodyColor
}
Label {
text: root.maxSensorDeviation >= 0 ? root.maxSensorDeviation.toFixed(2) + "\u00B0C" : "--"
font.pixelSize: Theme.fontLg
font.bold: true
color: root.maxSensorDeviation >= 0
? (root.maxSensorDeviation < 1.0 ? "#6ee7b7" : root.maxSensorDeviation < 3.0 ? "#fde047" : "#ef4444")
: Theme.sideMutedText
}
}
}
// Match Quality Badge
Rectangle {
Layout.fillWidth: true
height: 28
visible: root.warpingDistance >= 0
radius: Theme.radiusSm
color: root.warpingDistance < 50 ? Qt.rgba(0.43, 0.91, 0.72, 0.12)
: root.warpingDistance < 100 ? Qt.rgba(0.99, 0.88, 0.28, 0.12)
: Qt.rgba(0.94, 0.27, 0.27, 0.12)
Label {
anchors.centerIn: parent
text: root.warpingDistance < 50 ? "✓ Excellent Match"
: root.warpingDistance < 100 ? "⚠ Moderate Deviation"
: "✗ Poor Match"
font.pixelSize: Theme.fontXs
font.bold: true
color: root.warpingDistance < 50 ? "#6ee7b7"
: root.warpingDistance < 100 ? "#fde047"
: "#ef4444"
}
}
}
}
// ── Wafer Overlap View ────────────────────────────────
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
color: Theme.cardBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusMd
ColumnLayout {
anchors.fill: parent
anchors.margins: 12
spacing: 8
RowLayout {
Layout.fillWidth: true
Label {
text: "SENSOR DEVIATION MAP"
font.pixelSize: Theme.fontXs
font.bold: true
font.letterSpacing: 1.2
color: Theme.sideMutedText
Layout.fillWidth: true
}
CheckBox {
id: enableOverlapCheck
checked: true
implicitWidth: 18; implicitHeight: 18
ToolTip.visible: hovered
ToolTip.text: "Toggle overlap heatmap"
}
}
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
color: Theme.fieldBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusSm
clip: true
WaferMapItem {
id: overlapMapItem
anchors.fill: parent
visible: root.compareSensorLayout.length > 0 && enableOverlapCheck.checked
sensors: root.compareSensorLayout
values: root.compareSensorDiff
bands: root.diffBands(root.compareSensorDiff)
shape: root.compareWaferShape
size: root.compareWaferSize
target: 0
margin: 1.0
blend: blendSlider.value / 100
showLabels: true
ringColor: Theme.waferRingColor
axisColor: Theme.waferAxisColor
lowColor: Theme.sensorLow
inRangeColor: Theme.sensorInRange
highColor: Theme.sensorHigh
textColor: Theme.headingColor
}
ColumnLayout {
anchors.centerIn: parent
visible: root.compareSensorLayout.length === 0
spacing: 4
Label {
Layout.alignment: Qt.AlignHCenter
text: "🗺"
font.pixelSize: 24
}
Label {
Layout.alignment: Qt.AlignHCenter
text: "No sensor map available"
color: Theme.bodyColor color: Theme.bodyColor
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontXs
elide: Text.ElideRight }
} }
} }
ScrollBar.horizontal: ScrollBar { // Blend slider
policy: ScrollBar.AsNeeded RowLayout {
Layout.fillWidth: true
spacing: 6
Label {
text: "Blend"
font.pixelSize: Theme.fontXs
color: Theme.bodyColor
}
Slider {
id: blendSlider
Layout.fillWidth: true
from: 0; to: 100; value: 50
stepSize: 1
}
Label {
text: Math.round(blendSlider.value) + "%"
font.pixelSize: Theme.fontXs
color: Theme.sideMutedText
Layout.preferredWidth: 30
}
}
// Color legend
Row {
Layout.fillWidth: true
spacing: 8
Row {
spacing: 3
Rectangle { width: 8; height: 8; radius: 4; color: Theme.sensorLow; anchors.verticalCenter: parent.verticalCenter }
Label { text: "Cool"; font.pixelSize: 9; 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 }
}
Row {
spacing: 3
Rectangle { width: 8; height: 8; radius: 4; color: Theme.sensorHigh; anchors.verticalCenter: parent.verticalCenter }
Label { text: "Hot"; font.pixelSize: 9; color: Theme.sideMutedText }
}
} }
ScrollBar.vertical: ScrollBar {
policy: ScrollBar.AsNeeded
} }
} }
} }
@@ -188,8 +188,15 @@ ColumnLayout {
return (modelData.baseName + modelData.waferType + modelData.date).toLowerCase().indexOf(q) >= 0; return (modelData.baseName + modelData.waferType + modelData.date).toLowerCase().indexOf(q) >= 0;
} }
// Active only in review mode; no highlight during live streaming // Active highlight logic: on Data tab, highlight compareFileA/B; otherwise highlight loadedFile (for Map tab).
readonly property bool isActive: streamController.mode === "review" && streamController.loadedFile !== "" && modelData.fileName === streamController.loadedFile readonly property bool isActive:{
try {
if (root.selectedTabIndex === 1 && dataTabLoader.item) {
return modelData.fileName === dataTabLoader.item.compareFileA || modelData.fileName === dataTabLoader.item.compareFileB;
}
} catch (e) {}
return streamController.mode === "review" && streamController.loadedFile !== "" && modelData.fileName === streamController.loadedFile;
}
visible: matchesFilter visible: matchesFilter
height: matchesFilter ? implicitHeight : 0 height: matchesFilter ? implicitHeight : 0
@@ -0,0 +1,315 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import QtQuick.Dialogs
import ISC
// ===== Split Dialog (Threshold Segmentation) =====
// Modal popup for segmenting temperature profile into Ramp/Soak/Cool phases.
// Backend: splitter.segment_profile() returns list of DataSegment objects.
Popup {
id: root
modal: true
dim: true
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
width: Math.min(parent.width - 100, 800)
height: Math.min(parent.height - 100, 600)
anchors.centerIn: Overlay.overlay
property real threshold: 40.0
property bool segmenting: false
property var segments: []
property string currentFile: streamController.loadedFile || ""
background: Rectangle {
radius: Theme.radiusMd
color: Theme.cardBackground
border.color: Theme.cardBorder
border.width: 1
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 20
spacing: 16
// ── Header ────────────────────────────────────────────────
RowLayout {
Layout.fillWidth: true
spacing: 10
Label {
text: "SPLIT DATA"
font.pixelSize: Theme.fontLg
font.bold: true
color: Theme.headingColor
Layout.fillWidth: true
}
Button {
text: "\u2715"
flat: true
Layout.preferredWidth: 32
Layout.preferredHeight: 32
onClicked: root.close()
background: Rectangle {
radius: Theme.radiusXs
color: parent.hovered ? Theme.buttonNeutralHover : "transparent"
}
contentItem: Label {
text: parent.text
color: Theme.bodyColor
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
}
// ── Threshold Input ───────────────────────────────────────
RowLayout {
Layout.fillWidth: true
spacing: 16
ColumnLayout {
Layout.fillWidth: true
spacing: 4
Label {
text: "THRESHOLD TEMPERATURE (°C)"
color: Theme.sideMutedText
font.pixelSize: Theme.fontSm
font.bold: true
font.letterSpacing: 1.0
}
RowLayout {
Layout.fillWidth: true
spacing: 8
SpinBox {
id: thresholdSpin
from: 0
to: 200
value: root.threshold
stepSize: 1
editable: true
Layout.fillWidth: true
background: Rectangle {
color: Theme.fieldBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusSm
}
}
Label {
text: "°C"
color: Theme.bodyColor
font.pixelSize: Theme.fontMd
verticalAlignment: Text.AlignVCenter
}
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 4
Label {
text: "SESSION CSV"
color: Theme.sideMutedText
font.pixelSize: Theme.fontSm
font.bold: true
font.letterSpacing: 1.0
}
Label {
Layout.fillWidth: true
text: root.currentFile !== ""
? root.currentFile.split("/").pop()
: "No file loaded"
color: root.currentFile !== "" ? Theme.bodyColor : Theme.sideMutedText
font.pixelSize: Theme.fontSm
elide: Text.ElideMiddle
}
}
}
// ── Split Button ──────────────────────────────────────────
RowLayout {
Layout.fillWidth: true
spacing: 10
Button {
id: splitBtn
text: "SPLIT"
Layout.fillWidth: true
enabled: !root.segmenting && root.currentFile !== ""
onClicked: {
root.threshold = thresholdSpin.value;
root.segmenting = true;
streamController.splitData(root.currentFile, root.threshold);
}
background: Rectangle {
radius: Theme.radiusSm
color: splitBtn.enabled ? Theme.primaryAccent : Theme.buttonNeutralBackground
border.color: splitBtn.enabled ? Theme.primaryAccent : "transparent"
border.width: 1
}
contentItem: Row {
spacing: 8
anchors.centerIn: parent
Label {
text: splitBtn.enabled ? (root.segmenting ? "Segmenting..." : "SPLIT") : "LOAD DATA FIRST"
color: splitBtn.enabled ? Theme.tone100 : Theme.sideMutedText
font.pixelSize: Theme.fontSm
font.bold: true
font.letterSpacing: 1.0
}
BusyIndicator {
running: root.segmenting
visible: root.segmenting
width: 16
height: 16
}
}
}
}
// ── Segments Table ────────────────────────────────────────
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
radius: Theme.radiusSm
color: Theme.panelBackground
border.color: Theme.cardBorder
border.width: 1
ColumnLayout {
anchors.fill: parent
anchors.margins: 0
spacing: 0
// Table header
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 36
color: Theme.subtleSectionBackground
border.color: Theme.cardBorder
border.width: 1
RowLayout {
anchors.fill: parent
anchors.margins: 12
spacing: 8
Label { text: "PHASE"; Layout.preferredWidth: 100; color: Theme.sideMutedText; font.bold: true; font.pixelSize: Theme.fontSm }
Label { text: "START (s)"; Layout.preferredWidth: 120; color: Theme.sideMutedText; font.bold: true; font.pixelSize: Theme.fontSm }
Label { text: "END (s)"; Layout.preferredWidth: 120; color: Theme.sideMutedText; font.bold: true; font.pixelSize: Theme.fontSm }
Label { text: "AVG TEMP (°C)"; Layout.fillWidth: true; color: Theme.sideMutedText; font.bold: true; font.pixelSize: Theme.fontSm }
}
}
// Table body (scrollable)
ScrollView {
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
ColumnLayout {
width: parent.width - 32
spacing: 0
Repeater {
model: root.segments.length > 0 ? root.segments : []
delegate: Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 40
color: index % 2 === 0 ? Theme.cardBackground : "transparent"
border.color: Theme.cardBorder
RowLayout {
anchors.fill: parent
anchors.margins: 12
spacing: 8
Label {
text: modelData.label || ""
Layout.preferredWidth: 100
color: modelData.label === "Soak" ? Theme.statusSuccessColor :
modelData.label === "Ramp" ? Theme.statusWarningColor : Theme.bodyColor
font.bold: true
font.pixelSize: Theme.fontSm
}
Label {
text: (modelData.start_frame || 0).toString()
Layout.preferredWidth: 120
color: Theme.bodyColor
font.pixelSize: Theme.fontSm
}
Label {
text: (modelData.end_frame || 0).toString()
Layout.preferredWidth: 120
color: Theme.bodyColor
font.pixelSize: Theme.fontSm
}
Label {
text: (modelData.avg_temp || 0).toFixed(2) + " °C"
Layout.fillWidth: true
color: Theme.bodyColor
font.pixelSize: Theme.fontSm
}
}
}
}
// Empty state
Label {
visible: root.segments.length === 0
text: root.currentFile !== "" ? "Click SPLIT to segment data" : "Load a CSV file first"
color: Theme.sideMutedText
font.pixelSize: Theme.fontSm
Layout.fillWidth: true
Layout.preferredHeight: 100
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
}
}
}
// ── Summary line ──────────────────────────────────────────
Label {
visible: root.segments.length > 0
text: root.segments.length + " segment" + (root.segments.length === 1 ? "" : "s")
+ " found above " + root.threshold.toFixed(0) + "°C threshold."
color: Theme.bodyColor
font.pixelSize: Theme.fontSm
}
}
// ── Backend Connection ────────────────────────────────────────
Connections {
target: streamController
function onSplitResult(result) {
root.segmenting = false;
if (result && result.success) {
root.segments = result.segments || [];
console.log("[SplitDialog] Segmented into", root.segments.length, "phases");
} else {
root.segments = [];
console.log("[SplitDialog] Split failed:", result.error || "unknown");
}
}
}
}
+1
View File
@@ -6,3 +6,4 @@ TransportBar 1.0 TransportBar.qml
WaferMapView 1.0 WaferMapView.qml WaferMapView 1.0 WaferMapView.qml
ReplaceSensorDialog 1.0 ReplaceSensorDialog.qml ReplaceSensorDialog 1.0 ReplaceSensorDialog.qml
RailActionButton 1.0 RailActionButton.qml RailActionButton 1.0 RailActionButton.qml
SplitDialog 1.0 SplitDialog.qml
+1
View File
@@ -1,4 +1,5 @@
import logging import logging
import os
import sys import sys
from pathlib import Path from pathlib import Path
@@ -3,8 +3,12 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import os
import subprocess
import sys
import threading import threading
from datetime import datetime from datetime import datetime
from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
from PySide6.QtCore import Property, QObject, Qt, Signal, Slot from PySide6.QtCore import Property, QObject, Qt, Signal, Slot
@@ -68,7 +72,6 @@ class DeviceController(QObject):
self._operation_in_progress = False self._operation_in_progress = False
self._activity_log: list[str] = [] self._activity_log: list[str] = []
self._raw_bytes: Optional[bytes] = None self._raw_bytes: Optional[bytes] = None
from pathlib import Path
self._save_data_dir: str = getattr(settings, 'save_data_dir', "") or str(Path(self._data_dir) / "csv") self._save_data_dir: str = getattr(settings, 'save_data_dir', "") or str(Path(self._data_dir) / "csv")
self._last_wafer_info: dict[str, Any] = {} self._last_wafer_info: dict[str, Any] = {}
self._last_csv_path: str = "" self._last_csv_path: str = ""
@@ -126,6 +129,34 @@ class DeviceController(QObject):
self._append_log(f"Save data dir set to: {path}") self._append_log(f"Save data dir set to: {path}")
self._save_status() self._save_status()
@Slot(str)
@slot_error_boundary
def openCsvFile(self, file_path: str) -> None:
"""Open a CSV file in the system default spreadsheet application."""
if not file_path:
self._append_log("No file path provided to openCsvFile")
return
try:
path = Path(file_path)
if not path.exists():
self._append_log(f"File not found: {file_path}")
return
# Platform-appropriate file opener
if sys.platform == "darwin":
subprocess.Popen(["open", str(path)])
elif sys.platform == "linux":
subprocess.Popen(["xdg-open", str(path)])
else: # Windows
os.startfile(str(path))
self._append_log(f"Opened file: {file_path}")
except Exception as exc:
log.warning("Failed to open file %s: %s", file_path, exc)
self._append_log(f"Error opening file: {exc}")
@Property(str, notify=selectedPortChanged) @Property(str, notify=selectedPortChanged)
def selectedPort(self) -> str: def selectedPort(self) -> str:
"""Currently selected serial port shared across the UI.""" """Currently selected serial port shared across the UI."""
@@ -517,7 +548,6 @@ class DeviceController(QObject):
return return
if not self._save_data_dir: if not self._save_data_dir:
from pathlib import Path
self._save_data_dir = str(Path(self._data_dir) / "csv") self._save_data_dir = str(Path(self._data_dir) / "csv")
self._append_log(f"Auto-set save directory to: {self._save_data_dir}") self._append_log(f"Auto-set save directory to: {self._save_data_dir}")
self._save_status() self._save_status()
@@ -40,6 +40,8 @@ class SessionController(QObject):
liveStatsChanged = Signal() # emitted when receivedCount or errorCount change liveStatsChanged = Signal() # emitted when receivedCount or errorCount change
# trend: per-frame avg for live graph # trend: per-frame avg for live graph
trendData = Signal(str) # JSON array of floats trendData = Signal(str) # JSON array of floats
comparisonResult = Signal(object) # dict with success, distance, warping_path, max_sensor_deviation, series_a, series_b
splitResult = Signal(object) # dict with success, segments
# private: marshal a worker-thread frame onto the main thread # private: marshal a worker-thread frame onto the main thread
_liveFrame = Signal(object) # Frame _liveFrame = Signal(object) # Frame
_liveError = Signal() # worker-thread error ping _liveError = Signal() # worker-thread error ping
@@ -305,6 +307,169 @@ class SessionController(QObject):
self.trendData.emit(json.dumps(self._stats_tracker.get_trend())) self.trendData.emit(json.dumps(self._stats_tracker.get_trend()))
self.settingsChanged.emit() self.settingsChanged.emit()
@Slot()
@slot_error_boundary
def unloadFile(self) -> None:
"""Clear the loaded file, resetting the player and frame states."""
self._player.load([])
self._model.reset()
self._stats_tracker.reset()
self._loaded_file = ""
self.loadedFileChanged.emit()
self.sensorsChanged.emit()
self.settingsChanged.emit()
# ---- comparison: DTW between two CSV files ----
@Slot(str, str)
@slot_error_boundary
def compareFiles(self, file_a: str, file_b: str) -> None:
"""Run DTW comparison between two CSV files and emit result."""
from pygui.backend.comparison import compare_runs
from pygui.backend.data.data_records import is_official_csv, read_data_records, read_official_csv
try:
if is_official_csv(file_a):
_, recs_a = read_official_csv(file_a)
else:
recs_a = read_data_records(file_a)
if is_official_csv(file_b):
_, recs_b = read_official_csv(file_b)
else:
recs_b = read_data_records(file_b)
if not recs_a or not recs_b:
self.comparisonResult.emit({
"success": False,
"error": "No data in one or both files"
})
return
# Use first 100 frames of each for comparison (avoid O(n²) blowup)
max_frames = min(100, 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)]
result = compare_runs(series_a, series_b)
# 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.
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))):
diff = abs(values_a[s] - values_b[s])
if diff > max_sensor_deviation:
max_sensor_deviation = diff
# Sensor layout + per-sensor diff for the wafer overlap view. Reuses the
# same sensor-parsing branch as loadFile() (file_a is assumed representative
# of both files' wafer type).
from pathlib import Path
from pygui.backend.data.data_records import is_official_csv, read_official_csv
from pygui.backend.wafer.wafer_layouts import resolve_shape_and_size
from pygui.backend.wafer.zwafer_parser import ZWaferParser
sensor_layout: list[dict] = []
sensor_diff: list[float] = []
wafer_shape = "round"
wafer_size = 300.0
try:
if is_official_csv(file_a):
sensors, _ = read_official_csv(file_a)
stem = Path(file_a).stem
wafer_id = stem.split("-")[0] if "-" in stem else stem
else:
parsed, _ = ZWaferParser().parse(file_a)
sensors = parsed.sensors if parsed else []
wafer_id = parsed.serial if (parsed and parsed.serial) else ""
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
n = min(len(sensors), len(last_a), len(last_b))
sensor_layout = [
{
"label": s.label,
"x": s.x,
"y": s.y,
"side": getattr(s, "side", "right"),
"offset_x": getattr(s, "offset_x", 0.0),
"offset_y": getattr(s, "offset_y", 0.0),
}
for s in sensors[:n]
]
sensor_diff = [round(last_b[i] - last_a[i], 3) for i in range(n)]
except Exception as layout_exc:
log.warning("Could not resolve sensor layout for overlap view: %s", layout_exc)
self.comparisonResult.emit({
"success": True,
"distance": result["distance"],
"warping_path": result["warping_path"][:50], # limit path size
"max_sensor_deviation": max_sensor_deviation,
"series_a": series_a,
"series_b": series_b,
"sensor_layout": sensor_layout,
"sensor_diff": sensor_diff,
"wafer_shape": wafer_shape,
"wafer_size": wafer_size,
})
except Exception as exc:
log.warning("Comparison failed: %s", exc)
self.comparisonResult.emit({
"success": False,
"error": str(exc)
})
# ---- splitting: threshold-based segmentation ----
@Slot(str, float)
@slot_error_boundary
def splitData(self, file_path: str, threshold: float) -> None:
"""Segment temperature profile into Ramp/Soak/Cool phases."""
from pygui.backend.data.data_records import is_official_csv, read_data_records, read_official_csv
from pygui.backend.splitter import segment_profile
try:
if is_official_csv(file_path):
_, records = read_official_csv(file_path)
else:
records = read_data_records(file_path)
if not records:
self.splitResult.emit({
"success": False,
"error": "No data in file"
})
return
# Extract average temperatures (use first sensor as proxy)
avg_temps = [rec.values[0] if rec.values else 0.0 for rec in records]
segments = segment_profile(avg_temps, threshold)
self.splitResult.emit({
"success": True,
"segments": [{
"label": seg.label,
"start_frame": seg.start_frame,
"end_frame": seg.end_frame,
"avg_temp": seg.avg_temp
} for seg in segments]
})
except Exception as exc:
log.warning("Split failed: %s", exc)
self.splitResult.emit({
"success": False,
"error": str(exc)
})
@Slot() @Slot()
@slot_error_boundary @slot_error_boundary
def play(self) -> None: def play(self) -> None:
@@ -597,3 +762,52 @@ class SessionController(QObject):
self.trendData.emit(json.dumps(self._trend_buffer)) self.trendData.emit(json.dumps(self._trend_buffer))
self.liveStatsChanged.emit()
# ---- recording ----
@Slot(str, str)
@slot_error_boundary
def startRecording(self, path: str, serial: str = "") -> None:
self._recorder.start(path, self._sensors, serial)
self.recordingChanged.emit()
@Slot()
@slot_error_boundary
def stopRecording(self) -> None:
if self._recorder.is_recording:
self._recorder.stop()
self.recordingChanged.emit()
@Slot(int, float)
@slot_error_boundary
def replaceSensor(self, index: int, value: float) -> None:
"""Override sensor `index` to display `value` every frame."""
self._sensor_editor.set_replacement(index, value)
self._reprocess_current()
@Slot(int, float)
@slot_error_boundary
def offsetSensor(self, index: int, delta: float) -> None:
"""Shift sensor `index` by `delta` every frame."""
self._sensor_editor.set_offset(index, delta)
self._reprocess_current()
@Slot(int)
@slot_error_boundary
def clearSensorEdit(self, index: int) -> None:
"""Remove all overrides for sensor `index`."""
self._sensor_editor.clear(index)
self._reprocess_current()
@Slot()
@slot_error_boundary
def clearSensorEdits(self) -> None:
"""Remove all sensor overrides."""
self._sensor_editor.clear()
self._reprocess_current()
def _flush_trend(self) -> None:
import json
self.trendData.emit(json.dumps(self._trend_buffer))
+31
View File
@@ -46,3 +46,34 @@ def test_pause_stop_timer(controller):
controller.seek(2) controller.seek(2)
controller.stop() controller.stop()
assert controller.frameIndex == 0 assert controller.frameIndex == 0
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)
# Verify that the comparison signal is emitted with a success dict
assert mock_slot.call_count == 1
args = mock_slot.call_args[0][0]
assert args["success"] is True
assert "distance" in args
assert "series_a" in args
assert "series_b" in args
def test_split_data_integration(controller):
# Mock the splitResult signal
mock_slot = MagicMock()
controller.splitResult.connect(mock_slot)
csv_path = "tests/fixtures/sample_stream.csv"
controller.splitData(csv_path, 149.0)
# Verify that the split signal is emitted
assert mock_slot.call_count == 1
args = mock_slot.call_args[0][0]
assert args["success"] is True
assert "segments" in args