feat: add file comparison, data splitting, and sensor modification functionality to SessionController
This commit is contained in:
+11
-40
@@ -22,6 +22,7 @@ Rectangle {
|
|||||||
onSelectedTabIndexChanged: {
|
onSelectedTabIndexChanged: {
|
||||||
if (selectedTabIndex === 2) {
|
if (selectedTabIndex === 2) {
|
||||||
file_browser.refreshFiles();
|
file_browser.refreshFiles();
|
||||||
|
streamController.unloadFile()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +37,9 @@ 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+746
-122
@@ -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
|
|
||||||
onClicked: deviceController.openCsvFile()
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
text: "Compare"
|
|
||||||
onClicked: compareDialog.open()
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
text: "Split"
|
|
||||||
onClicked: splitDialog.open()
|
|
||||||
}
|
|
||||||
|
|
||||||
Item {
|
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
}
|
Layout.preferredHeight: 70
|
||||||
}
|
radius: Theme.radiusMd
|
||||||
// --- Table container ---
|
color: Theme.cardBackground
|
||||||
Rectangle {
|
border.width: root.activeBox === 1 ? 2 : 1
|
||||||
Layout.fillWidth: true
|
border.color: root.activeBox === 1 ? Theme.primaryAccent : Theme.cardBorder
|
||||||
Layout.fillHeight: true
|
|
||||||
color: Theme.panelBackground
|
|
||||||
border.color: Theme.cardBorder
|
|
||||||
border.width: Theme.borderThin
|
|
||||||
radius: Theme.radiusSm
|
|
||||||
clip: true
|
|
||||||
|
|
||||||
ColumnLayout {
|
Behavior on border.color { ColorAnimation { duration: Theme.durationFast } }
|
||||||
anchors.fill: parent
|
|
||||||
anchors.margins: 1
|
|
||||||
spacing: 0
|
|
||||||
|
|
||||||
// Column header strip
|
MouseArea {
|
||||||
HorizontalHeaderView {
|
anchors.fill: parent
|
||||||
id: headerView
|
cursorShape: Qt.PointingHandCursor
|
||||||
Layout.fillWidth: true
|
onClicked: root.activeBox = 1
|
||||||
syncView: dataTable
|
}
|
||||||
clip: true
|
|
||||||
|
|
||||||
delegate: Rectangle {
|
RowLayout {
|
||||||
implicitHeight: 28
|
anchors.fill: parent
|
||||||
color: Theme.cardBackground
|
anchors.margins: 14
|
||||||
border.color: Theme.cardBorder
|
spacing: 10
|
||||||
border.width: 1
|
|
||||||
|
|
||||||
Text {
|
// Color indicator dot
|
||||||
anchors.centerIn: parent
|
Rectangle {
|
||||||
// 'display' is the Qt.DisplayRole value from headerData()
|
width: 10; height: 10; radius: 5
|
||||||
text: display !== undefined ? display : ""
|
color: Theme.primaryAccent
|
||||||
color: Theme.headingColor
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 2
|
||||||
|
Label {
|
||||||
|
text: "REFERENCE (RUN A)"
|
||||||
font.pixelSize: Theme.fontXs
|
font.pixelSize: Theme.fontXs
|
||||||
font.bold: true
|
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
|
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
|
// ── Wafer Overlap View ────────────────────────────────
|
||||||
TableView {
|
Rectangle {
|
||||||
id: dataTable
|
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
model: deviceController.dataModel
|
color: Theme.cardBackground
|
||||||
clip: true
|
border.color: Theme.cardBorder
|
||||||
reuseItems: true
|
border.width: 1
|
||||||
|
radius: Theme.radiusMd
|
||||||
|
|
||||||
columnWidthProvider: function (col) {
|
ColumnLayout {
|
||||||
if (col === 0)
|
anchors.fill: parent
|
||||||
return 52; // row index column
|
anchors.margins: 12
|
||||||
const sensorCols = Math.max(1, dataTable.columns - 1);
|
spacing: 8
|
||||||
const available = dataTable.width - 52;
|
|
||||||
return Math.max(52, Math.min(90, available / sensorCols));
|
|
||||||
}
|
|
||||||
|
|
||||||
rowHeightProvider: function () {
|
RowLayout {
|
||||||
return 24;
|
Layout.fillWidth: true
|
||||||
}
|
Label {
|
||||||
|
text: "SENSOR DEVIATION MAP"
|
||||||
delegate: Rectangle {
|
font.pixelSize: Theme.fontXs
|
||||||
color: row % 2 === 0 ? Theme.panelBackground : Theme.cardBackground
|
font.bold: true
|
||||||
|
font.letterSpacing: 1.2
|
||||||
Text {
|
color: Theme.sideMutedText
|
||||||
anchors.centerIn: parent
|
Layout.fillWidth: true
|
||||||
text: display !== undefined ? display : ""
|
}
|
||||||
color: Theme.bodyColor
|
CheckBox {
|
||||||
font.pixelSize: Theme.fontXs
|
id: enableOverlapCheck
|
||||||
elide: Text.ElideRight
|
checked: true
|
||||||
|
implicitWidth: 18; implicitHeight: 18
|
||||||
|
ToolTip.visible: hovered
|
||||||
|
ToolTip.text: "Toggle overlap heatmap"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
ScrollBar.horizontal: ScrollBar {
|
Rectangle {
|
||||||
policy: ScrollBar.AsNeeded
|
Layout.fillWidth: true
|
||||||
}
|
Layout.fillHeight: true
|
||||||
ScrollBar.vertical: ScrollBar {
|
color: Theme.fieldBackground
|
||||||
policy: ScrollBar.AsNeeded
|
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,9 +188,16 @@ 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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,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))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user