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: {
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
}
+746 -122
View File
@@ -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 }
}
}
}
}
}
@@ -188,8 +188,15 @@ 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
@@ -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
ReplaceSensorDialog 1.0 ReplaceSensorDialog.qml
RailActionButton 1.0 RailActionButton.qml
SplitDialog 1.0 SplitDialog.qml
+1
View File
@@ -1,4 +1,5 @@
import logging
import os
import sys
from pathlib import Path
@@ -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()
@@ -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))
+31
View File
@@ -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