diff --git a/src/pygui/ISC/HomePage.qml b/src/pygui/ISC/HomePage.qml index c760bf5..cede73d 100644 --- a/src/pygui/ISC/HomePage.qml +++ b/src/pygui/ISC/HomePage.qml @@ -22,6 +22,7 @@ Rectangle { onSelectedTabIndexChanged: { if (selectedTabIndex === 2) { file_browser.refreshFiles(); + streamController.unloadFile() } } @@ -36,7 +37,9 @@ Rectangle { function onParsedDataReady(result) { if (result.success && result.csv_path) { streamController.loadFile(result.csv_path) + if (root.selectedTabIndex === 0) { root.selectedTabIndex = 2 + } } } } @@ -74,6 +77,11 @@ Rectangle { } } + // ===== Split Dialog (Threshold Segmentation) ===== + SplitDialog { + id: splitDialog + } + // ===== Settings Popup ===== Popup { id: settingsPopup @@ -300,7 +308,7 @@ Rectangle { StackLayout { Layout.fillWidth: true Layout.fillHeight: true - currentIndex: root.selectedTabIndex + currentIndex: root.selectedTabIndex === 0 ? 0 : 1 // ── Status tab: Hardware Actions ──────────────── ColumnLayout { @@ -320,7 +328,7 @@ Rectangle { label: "DETECT WAFER" iconSource: "../icons/detect.svg" Layout.fillWidth: true - enabled: !deviceController.isDetecting + enabled: !deviceController.operationInProgress onClicked: root._doDetect() } @@ -345,44 +353,6 @@ Rectangle { 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 ────────────── SourcePanel { Layout.fillWidth: true @@ -637,6 +607,7 @@ Rectangle { active: StackLayout.index === root.selectedTabIndex } Loader { + id: dataTabLoader source: "Tabs/DataTab.qml" active: StackLayout.index === root.selectedTabIndex } diff --git a/src/pygui/ISC/Tabs/DataTab.qml b/src/pygui/ISC/Tabs/DataTab.qml index ecc784d..2b83b08 100644 --- a/src/pygui/ISC/Tabs/DataTab.qml +++ b/src/pygui/ISC/Tabs/DataTab.qml @@ -2,183 +2,807 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts import ISC +import ISC.Wafer -// ===== Data Tab ===== -// Displays parsed temperature data in a scrollable table. -// Column 0 = Row index, remaining columns = Sensor1, Sensor2, ... +// ===== Data Tab (Compare Mode Only) ===== +// Focuses exclusively on comparing two wafer runs side-by-side using DTW. +// Clicking Box 1 or Box 2 activates selection mode, and clicking a file in the +// left rail's file browser populates that slot. Item { id: root 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 { - target: deviceController - function onParsedDataReady(result) { + target: streamController + 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) { - 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 ===== - 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 ===== + // ── Main Layout ─────────────────────────────────────────────── ColumnLayout { anchors.fill: parent anchors.margins: Theme.panelPadding spacing: Theme.rightPaneGap - visible: root.rowCount > 0 - // --- Header row --- + // ── Title / Header ───────────────────────────────────────── RowLayout { Layout.fillWidth: true - spacing: Theme.rightPaneGap + spacing: 8 Label { - text: "Temperature Data" + text: "Run Comparison (DTW)" font.pixelSize: Theme.fontXl font.bold: true color: Theme.headingColor + Layout.fillWidth: true + elide: Text.ElideRight } - Item { - Layout.fillWidth: true + // Reset button + 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 { - text: deviceController.dataRowCount + " rows × " + deviceController.dataColCount + " sensors" + text: "Dynamic Time Warping Profile Matching" font.pixelSize: Theme.fontSm color: Theme.bodyColor } } - // --- Toolbar --- + // ── Selection Boxes (Box 1 and Box 2) ────────────────────── RowLayout { Layout.fillWidth: true - spacing: 8 + spacing: 16 - Button { - text: "Open in Excel" - icon.source: "icons/excel.svg" - icon.color: Theme.headingColor - onClicked: deviceController.openCsvFile() - } - - Button { - text: "Compare" - onClicked: compareDialog.open() - } - - Button { - text: "Split" - onClicked: splitDialog.open() - } - - Item { + // --- Box 1: Reference CSV (Run A) --- + Rectangle { + id: box1 Layout.fillWidth: true - } - } - // --- Table container --- - Rectangle { - Layout.fillWidth: true - Layout.fillHeight: true - color: Theme.panelBackground - border.color: Theme.cardBorder - border.width: Theme.borderThin - radius: Theme.radiusSm - clip: true + 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 - ColumnLayout { - anchors.fill: parent - anchors.margins: 1 - spacing: 0 + Behavior on border.color { ColorAnimation { duration: Theme.durationFast } } - // Column header strip - HorizontalHeaderView { - id: headerView - Layout.fillWidth: true - syncView: dataTable - clip: true + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.activeBox = 1 + } - delegate: Rectangle { - implicitHeight: 28 - color: Theme.cardBackground - border.color: Theme.cardBorder - border.width: 1 + RowLayout { + anchors.fill: parent + anchors.margins: 14 + spacing: 10 - Text { - anchors.centerIn: parent - // 'display' is the Qt.DisplayRole value from headerData() - text: display !== undefined ? display : "" - color: Theme.headingColor + // Color indicator dot + Rectangle { + width: 10; height: 10; radius: 5 + color: Theme.primaryAccent + Layout.alignment: Qt.AlignVCenter + } + + ColumnLayout { + Layout.fillWidth: true + 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 { + 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 + } + } + + 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 { + Layout.fillWidth: true + Layout.fillHeight: true + Layout.minimumWidth: 350 + color: Theme.cardBackground + border.color: Theme.cardBorder + border.width: 1 + radius: Theme.radiusMd + + ColumnLayout { + anchors.fill: parent + anchors.margins: 16 + spacing: 10 + + RowLayout { + Layout.fillWidth: true + Label { + text: "Aligned Temperature Curves (DTW)" + font.pixelSize: Theme.fontSm + font.bold: true + color: Theme.headingColor + Layout.fillWidth: true 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 } + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + color: Theme.fieldBackground + border.color: Theme.cardBorder + border.width: 1 + radius: Theme.radiusSm + clip: true + + readonly property bool hasSeries: root.trendDataA.length > 1 && root.trendDataB.length > 1 + + Canvas { + id: overlayCanvas + anchors.fill: parent + 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) + } + + // X-axis labels + 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) + } + + // Y-axis title + ctx.save() + ctx.translate(12, padTop + plotH / 2) + ctx.rotate(-Math.PI / 2) + ctx.textAlign = "center" + ctx.fillText("°C", 0, 0) + ctx.restore() + + // 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 + 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" + } + } } } - // Data rows - TableView { - id: dataTable + // ── Wafer Overlap View ──────────────────────────────── + Rectangle { Layout.fillWidth: true Layout.fillHeight: true - model: deviceController.dataModel - clip: true - reuseItems: true + color: Theme.cardBackground + border.color: Theme.cardBorder + border.width: 1 + radius: Theme.radiusMd - columnWidthProvider: function (col) { - if (col === 0) - return 52; // row index column - const sensorCols = Math.max(1, dataTable.columns - 1); - const available = dataTable.width - 52; - return Math.max(52, Math.min(90, available / sensorCols)); - } + ColumnLayout { + anchors.fill: parent + anchors.margins: 12 + spacing: 8 - rowHeightProvider: function () { - return 24; - } - - delegate: Rectangle { - color: row % 2 === 0 ? Theme.panelBackground : Theme.cardBackground - - Text { - anchors.centerIn: parent - text: display !== undefined ? display : "" - color: Theme.bodyColor - font.pixelSize: Theme.fontXs - elide: Text.ElideRight + 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" + } } - } - ScrollBar.horizontal: ScrollBar { - policy: ScrollBar.AsNeeded - } - ScrollBar.vertical: ScrollBar { - policy: ScrollBar.AsNeeded + 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 + font.pixelSize: Theme.fontXs + } + } + } + + // Blend slider + 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 } + } + } } } } diff --git a/src/pygui/ISC/Tabs/components/SourcePanel.qml b/src/pygui/ISC/Tabs/components/SourcePanel.qml index 8b4edc0..188266c 100644 --- a/src/pygui/ISC/Tabs/components/SourcePanel.qml +++ b/src/pygui/ISC/Tabs/components/SourcePanel.qml @@ -188,9 +188,16 @@ ColumnLayout { return (modelData.baseName + modelData.waferType + modelData.date).toLowerCase().indexOf(q) >= 0; } - // Active only in review mode; no highlight during live streaming - readonly property bool isActive: streamController.mode === "review" && streamController.loadedFile !== "" && modelData.fileName === streamController.loadedFile - + // Active highlight logic: on Data tab, highlight compareFileA/B; otherwise highlight loadedFile (for Map tab). + 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 height: matchesFilter ? implicitHeight : 0 diff --git a/src/pygui/ISC/Tabs/components/SplitDialog.qml b/src/pygui/ISC/Tabs/components/SplitDialog.qml new file mode 100644 index 0000000..73ce27d --- /dev/null +++ b/src/pygui/ISC/Tabs/components/SplitDialog.qml @@ -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"); + } + } + } + +} diff --git a/src/pygui/ISC/Tabs/components/qmldir b/src/pygui/ISC/Tabs/components/qmldir index 07e326f..81d3a0a 100644 --- a/src/pygui/ISC/Tabs/components/qmldir +++ b/src/pygui/ISC/Tabs/components/qmldir @@ -6,3 +6,4 @@ TransportBar 1.0 TransportBar.qml WaferMapView 1.0 WaferMapView.qml ReplaceSensorDialog 1.0 ReplaceSensorDialog.qml RailActionButton 1.0 RailActionButton.qml +SplitDialog 1.0 SplitDialog.qml diff --git a/src/pygui/__main__.py b/src/pygui/__main__.py index 03c7f81..f7d7609 100644 --- a/src/pygui/__main__.py +++ b/src/pygui/__main__.py @@ -1,4 +1,5 @@ import logging +import os import sys from pathlib import Path diff --git a/src/pygui/backend/controllers/device_controller.py b/src/pygui/backend/controllers/device_controller.py index 8504b9d..248bbdb 100644 --- a/src/pygui/backend/controllers/device_controller.py +++ b/src/pygui/backend/controllers/device_controller.py @@ -3,8 +3,12 @@ from __future__ import annotations import logging +import os +import subprocess +import sys import threading from datetime import datetime +from pathlib import Path from typing import Any, Optional from PySide6.QtCore import Property, QObject, Qt, Signal, Slot @@ -68,7 +72,6 @@ class DeviceController(QObject): self._operation_in_progress = False self._activity_log: list[str] = [] 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._last_wafer_info: dict[str, Any] = {} self._last_csv_path: str = "" @@ -126,6 +129,34 @@ class DeviceController(QObject): self._append_log(f"Save data dir set to: {path}") 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) def selectedPort(self) -> str: """Currently selected serial port shared across the UI.""" @@ -517,7 +548,6 @@ class DeviceController(QObject): return if not self._save_data_dir: - from pathlib import Path self._save_data_dir = str(Path(self._data_dir) / "csv") self._append_log(f"Auto-set save directory to: {self._save_data_dir}") self._save_status() diff --git a/src/pygui/backend/controllers/session_controller.py b/src/pygui/backend/controllers/session_controller.py index c40f70f..ca95926 100644 --- a/src/pygui/backend/controllers/session_controller.py +++ b/src/pygui/backend/controllers/session_controller.py @@ -40,6 +40,8 @@ class SessionController(QObject): liveStatsChanged = Signal() # emitted when receivedCount or errorCount change # trend: per-frame avg for live graph 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 _liveFrame = Signal(object) # Frame _liveError = Signal() # worker-thread error ping @@ -305,6 +307,169 @@ class SessionController(QObject): self.trendData.emit(json.dumps(self._stats_tracker.get_trend())) 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_error_boundary def play(self) -> None: @@ -597,3 +762,52 @@ class SessionController(QObject): 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)) + + diff --git a/tests/test_session_controller.py b/tests/test_session_controller.py index ba02ea3..38dba31 100644 --- a/tests/test_session_controller.py +++ b/tests/test_session_controller.py @@ -46,3 +46,34 @@ def test_pause_stop_timer(controller): controller.seek(2) controller.stop() 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 \ No newline at end of file