Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 915720085b | |||
| e2d71662ff | |||
| 52cc747536 | |||
| 185e66ea3a | |||
| 3b919f576f | |||
| f604308466 | |||
| 0f813b2376 | |||
| 6996df73f8 | |||
| 3cc10ea7fe |
+11
-40
@@ -22,6 +22,7 @@ Rectangle {
|
|||||||
onSelectedTabIndexChanged: {
|
onSelectedTabIndexChanged: {
|
||||||
if (selectedTabIndex === 2) {
|
if (selectedTabIndex === 2) {
|
||||||
file_browser.refreshFiles();
|
file_browser.refreshFiles();
|
||||||
|
streamController.unloadFile()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,10 +37,12 @@ Rectangle {
|
|||||||
function onParsedDataReady(result) {
|
function onParsedDataReady(result) {
|
||||||
if (result.success && result.csv_path) {
|
if (result.success && result.csv_path) {
|
||||||
streamController.loadFile(result.csv_path)
|
streamController.loadFile(result.csv_path)
|
||||||
|
if (root.selectedTabIndex === 0) {
|
||||||
root.selectedTabIndex = 2
|
root.selectedTabIndex = 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Connections {
|
Connections {
|
||||||
target: deviceController
|
target: deviceController
|
||||||
function onReadResult(result) {
|
function onReadResult(result) {
|
||||||
@@ -74,6 +77,11 @@ Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Split Dialog (Threshold Segmentation) =====
|
||||||
|
SplitDialog {
|
||||||
|
id: splitDialog
|
||||||
|
}
|
||||||
|
|
||||||
// ===== Settings Popup =====
|
// ===== Settings Popup =====
|
||||||
Popup {
|
Popup {
|
||||||
id: settingsPopup
|
id: settingsPopup
|
||||||
@@ -300,7 +308,7 @@ Rectangle {
|
|||||||
StackLayout {
|
StackLayout {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
currentIndex: root.selectedTabIndex
|
currentIndex: root.selectedTabIndex === 0 ? 0 : 1
|
||||||
|
|
||||||
// ── Status tab: Hardware Actions ────────────────
|
// ── Status tab: Hardware Actions ────────────────
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
@@ -320,7 +328,7 @@ Rectangle {
|
|||||||
label: "DETECT WAFER"
|
label: "DETECT WAFER"
|
||||||
iconSource: "../icons/detect.svg"
|
iconSource: "../icons/detect.svg"
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
enabled: !deviceController.isDetecting
|
enabled: !deviceController.operationInProgress
|
||||||
onClicked: root._doDetect()
|
onClicked: root._doDetect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,44 +353,6 @@ Rectangle {
|
|||||||
Item { Layout.fillHeight: true }
|
Item { Layout.fillHeight: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Data tab: Data Operations ───────────────────
|
|
||||||
ColumnLayout {
|
|
||||||
spacing: 6
|
|
||||||
|
|
||||||
Label {
|
|
||||||
text: "DATA OPERATIONS"
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.pixelSize: Theme.fontSm
|
|
||||||
topPadding: 6
|
|
||||||
font.letterSpacing: 1.5
|
|
||||||
font.bold: true
|
|
||||||
}
|
|
||||||
|
|
||||||
RailActionButton {
|
|
||||||
label: "OPEN IN EXCEL"
|
|
||||||
iconSource: "../icons/excel.svg"
|
|
||||||
Layout.fillWidth: true
|
|
||||||
onClicked: deviceController.openCsvFile()
|
|
||||||
}
|
|
||||||
|
|
||||||
RailActionButton {
|
|
||||||
label: "COMPARE CSV"
|
|
||||||
iconSource: "../icons/compare.svg"
|
|
||||||
Layout.fillWidth: true
|
|
||||||
onClicked: console.log("[P3.1] Compare dialog not yet built")
|
|
||||||
}
|
|
||||||
|
|
||||||
RailActionButton {
|
|
||||||
label: "SPLIT DATA"
|
|
||||||
iconSource: "../icons/split.svg"
|
|
||||||
Layout.fillWidth: true
|
|
||||||
onClicked: console.log("[P3.2] Split dialog not yet built")
|
|
||||||
}
|
|
||||||
|
|
||||||
Item { Layout.fillHeight: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Map tab: Source file browser ──────────────
|
// ── Map tab: Source file browser ──────────────
|
||||||
SourcePanel {
|
SourcePanel {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
@@ -637,6 +607,7 @@ Rectangle {
|
|||||||
active: StackLayout.index === root.selectedTabIndex
|
active: StackLayout.index === root.selectedTabIndex
|
||||||
}
|
}
|
||||||
Loader {
|
Loader {
|
||||||
|
id: dataTabLoader
|
||||||
source: "Tabs/DataTab.qml"
|
source: "Tabs/DataTab.qml"
|
||||||
active: StackLayout.index === root.selectedTabIndex
|
active: StackLayout.index === root.selectedTabIndex
|
||||||
}
|
}
|
||||||
|
|||||||
+809
-105
@@ -2,186 +2,890 @@ 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 Content Only) =====
|
||||||
// Displays parsed temperature data in a scrollable table.
|
// Per docs/design/navigation-mock.html: the Data tab shows only the Compare
|
||||||
// Column 0 = Row index, remaining columns = Sensor1, Sensor2, ...
|
// Runs content — the raw CSV table ("Inspect Table" mode in earlier drafts)
|
||||||
|
// is intentionally not displayed in the application.
|
||||||
|
// Layout: left column = aligned-curves chart with alignment scrubber + wafer
|
||||||
|
// overlap map; right side panel = run selection dropdowns, overlap settings,
|
||||||
|
// and DTW readout cards.
|
||||||
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 int frameOffset: 0
|
||||||
|
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
|
||||||
|
|
||||||
|
readonly property bool hasSeries: trendDataA.length > 1 && trendDataB.length > 1
|
||||||
|
readonly property int seriesLen: Math.max(trendDataA.length, trendDataB.length)
|
||||||
|
|
||||||
|
function diffBands(diffs) {
|
||||||
|
return diffs.map(d => d > 1.0 ? "high" : d < -1.0 ? "low" : "in_range")
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortName(path) {
|
||||||
|
return path ? path.split("/").pop() : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearResults() {
|
||||||
|
root.warpingDistance = -1
|
||||||
|
root.frameOffset = 0
|
||||||
|
root.maxSensorDeviation = -1
|
||||||
|
root.trendDataA = []
|
||||||
|
root.trendDataB = []
|
||||||
|
root.compareSensorLayout = []
|
||||||
|
root.compareSensorDiff = []
|
||||||
|
root.errorMessage = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetComparison() {
|
||||||
|
root.compareFileA = ""
|
||||||
|
root.compareFileB = ""
|
||||||
|
root.comparing = false
|
||||||
|
root.activeBox = 1
|
||||||
|
clearResults()
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: file_browser.refreshFiles()
|
||||||
|
|
||||||
|
// Clicking a file in the left rail's browser populates the armed run slot
|
||||||
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.frameOffset = result.frame_offset !== undefined ? result.frame_offset : 0
|
||||||
|
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 = ""
|
||||||
|
scrubber.value = Math.floor(root.seriesLen / 2)
|
||||||
|
} else {
|
||||||
|
root.clearResults()
|
||||||
|
root.errorMessage = (result && result.error) ? result.error : "Comparison failed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Empty State =====
|
// ── Reusable pieces ────────────────────────────────────────────
|
||||||
Column {
|
component SectionTitle: Label {
|
||||||
anchors.centerIn: parent
|
font.pixelSize: Theme.fontXs
|
||||||
spacing: 16
|
|
||||||
visible: root.rowCount === 0
|
|
||||||
|
|
||||||
Label {
|
|
||||||
text: "No Data"
|
|
||||||
font.pixelSize: Theme.font2xl
|
|
||||||
font.bold: true
|
font.bold: true
|
||||||
color: Theme.headingColor
|
font.letterSpacing: 1.2
|
||||||
anchors.horizontalCenter: parent.horizontalCenter
|
color: Theme.sideMutedText
|
||||||
}
|
}
|
||||||
|
|
||||||
|
component PanelBox: Rectangle {
|
||||||
|
color: Theme.cardBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusMd
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mini stat box under the alignment scrubber (mockup's subtle-box trio)
|
||||||
|
component ScrubStat: Rectangle {
|
||||||
|
id: scrubStat
|
||||||
|
property string label
|
||||||
|
property string value
|
||||||
|
property color valueColor: Theme.headingColor
|
||||||
|
Layout.fillWidth: true
|
||||||
|
implicitHeight: 38
|
||||||
|
radius: 4
|
||||||
|
color: Qt.rgba(1, 1, 1, 0.02)
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.leftMargin: 6
|
||||||
|
anchors.rightMargin: 6
|
||||||
|
anchors.topMargin: 4
|
||||||
|
anchors.bottomMargin: 4
|
||||||
|
spacing: 0
|
||||||
Label {
|
Label {
|
||||||
text: "Read and parse wafer data to see temperature readings here."
|
text: scrubStat.label
|
||||||
font.pixelSize: Theme.fontMd
|
font.pixelSize: 9
|
||||||
color: Theme.bodyColor
|
color: Theme.sideMutedText
|
||||||
anchors.horizontalCenter: parent.horizontalCenter
|
}
|
||||||
wrapMode: Text.WordWrap
|
Label {
|
||||||
|
text: scrubStat.value
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.bold: true
|
||||||
|
font.family: Theme.codeFontFamily
|
||||||
|
color: scrubStat.valueColor
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Data Table =====
|
// Large labeled value card for the DTW readout panel
|
||||||
|
component ReadoutCard: Rectangle {
|
||||||
|
id: readoutCard
|
||||||
|
property string label
|
||||||
|
property string value
|
||||||
|
property color valueColor: Theme.headingColor
|
||||||
|
Layout.fillWidth: true
|
||||||
|
implicitHeight: 52
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: Theme.subtleSectionBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 8
|
||||||
|
spacing: 2
|
||||||
|
Label {
|
||||||
|
text: readoutCard.label
|
||||||
|
font.pixelSize: 9
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 0.2
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
text: readoutCard.value
|
||||||
|
font.pixelSize: Theme.fontLg
|
||||||
|
font.bold: true
|
||||||
|
font.family: Theme.codeFontFamily
|
||||||
|
color: readoutCard.valueColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run slot card: click to arm, then pick a file in the left rail's browser
|
||||||
|
component RunSlot: Rectangle {
|
||||||
|
id: slotBox
|
||||||
|
property int boxIndex: 1
|
||||||
|
property string title
|
||||||
|
property string filePath: ""
|
||||||
|
property color accent: Theme.primaryAccent
|
||||||
|
signal cleared()
|
||||||
|
|
||||||
|
Layout.fillWidth: true
|
||||||
|
implicitHeight: 58
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: Theme.fieldBackground
|
||||||
|
border.width: root.activeBox === boxIndex ? 2 : 1
|
||||||
|
border.color: root.activeBox === boxIndex ? accent : Theme.cardBorder
|
||||||
|
|
||||||
|
Behavior on border.color { ColorAnimation { duration: Theme.durationFast } }
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: root.activeBox = slotBox.boxIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 10
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: 8; height: 8; radius: 4
|
||||||
|
color: slotBox.accent
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 2
|
||||||
|
Label {
|
||||||
|
text: slotBox.title
|
||||||
|
font.pixelSize: 9
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 1.0
|
||||||
|
color: root.activeBox === slotBox.boxIndex ? slotBox.accent : Theme.sideMutedText
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
text: slotBox.filePath !== ""
|
||||||
|
? root.shortName(slotBox.filePath)
|
||||||
|
: (root.activeBox === slotBox.boxIndex ? "← Select file from left panel…" : "Click to select")
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
color: slotBox.filePath !== "" ? Theme.headingColor : Theme.bodyColor
|
||||||
|
opacity: slotBox.filePath !== "" ? 1.0 : 0.6
|
||||||
|
elide: Text.ElideMiddle
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
visible: slotBox.filePath !== ""
|
||||||
|
flat: true
|
||||||
|
implicitWidth: 24; implicitHeight: 24
|
||||||
|
onClicked: slotBox.cleared()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main Layout ───────────────────────────────────────────────
|
||||||
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 {
|
Button {
|
||||||
Layout.fillWidth: true
|
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: "↺"
|
||||||
|
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 ---
|
// ── Error Banner ───────────────────────────────────────────
|
||||||
RowLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
spacing: 8
|
|
||||||
|
|
||||||
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 {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// --- Table container ---
|
|
||||||
Rectangle {
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Body: main column | side panel ─────────────────────────
|
||||||
|
RowLayout {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
color: Theme.panelBackground
|
spacing: 16
|
||||||
|
|
||||||
|
// --- Left Column: chart + scrubber, wafer overlap map ---
|
||||||
|
ColumnLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
Layout.minimumWidth: 350
|
||||||
|
spacing: 12
|
||||||
|
|
||||||
|
// Chart card with alignment scrubber
|
||||||
|
PanelBox {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: chartCol.implicitHeight + 24
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: chartCol
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 12
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
SectionTitle {
|
||||||
|
text: "ALIGNED TEMP CURVE OVERLAY (DTW)"
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
Row {
|
||||||
|
spacing: 16
|
||||||
|
visible: root.hasSeries
|
||||||
|
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.preferredHeight: 200
|
||||||
|
color: Theme.fieldBackground
|
||||||
border.color: Theme.cardBorder
|
border.color: Theme.cardBorder
|
||||||
border.width: Theme.borderThin
|
border.width: 1
|
||||||
radius: Theme.radiusSm
|
radius: Theme.radiusSm
|
||||||
clip: true
|
clip: true
|
||||||
|
|
||||||
ColumnLayout {
|
Canvas {
|
||||||
|
id: overlayCanvas
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
anchors.margins: 1
|
visible: root.hasSeries
|
||||||
spacing: 0
|
|
||||||
|
|
||||||
// Column header strip
|
readonly property real padLeft: 50
|
||||||
HorizontalHeaderView {
|
readonly property real padRight: 16
|
||||||
id: headerView
|
readonly property real padTop: 16
|
||||||
Layout.fillWidth: true
|
readonly property real padBottom: 28
|
||||||
syncView: dataTable
|
|
||||||
clip: true
|
|
||||||
|
|
||||||
delegate: Rectangle {
|
function drawGrid(ctx, minV, maxV, dataLen) {
|
||||||
implicitHeight: 28
|
var plotW = width - padLeft - padRight
|
||||||
color: Theme.cardBackground
|
var plotH = height - padTop - padBottom
|
||||||
border.color: Theme.cardBorder
|
var range = (maxV - minV) || 1
|
||||||
border.width: 1
|
|
||||||
|
|
||||||
Text {
|
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"
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.save()
|
||||||
|
ctx.translate(12, padTop + plotH / 2)
|
||||||
|
ctx.rotate(-Math.PI / 2)
|
||||||
|
ctx.textAlign = "center"
|
||||||
|
ctx.fillText("°C", 0, 0)
|
||||||
|
ctx.restore()
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawScrubMarker(ctx) {
|
||||||
|
if (root.seriesLen < 2) return
|
||||||
|
var plotW = width - padLeft - padRight
|
||||||
|
var x = padLeft + (scrubber.value / (root.seriesLen - 1)) * plotW
|
||||||
|
ctx.strokeStyle = Theme.primaryAccent
|
||||||
|
ctx.lineWidth = 1.5
|
||||||
|
ctx.setLineDash([3, 3])
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(x, padTop)
|
||||||
|
ctx.lineTo(x, height - padBottom)
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.setLineDash([])
|
||||||
|
}
|
||||||
|
|
||||||
|
onPaint: {
|
||||||
|
var ctx = getContext("2d")
|
||||||
|
ctx.reset()
|
||||||
|
if (!root.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, root.seriesLen)
|
||||||
|
drawSeries(ctx, root.trendDataA, minV, range, Theme.primaryAccent)
|
||||||
|
drawSeries(ctx, root.trendDataB, minV, range, Theme.themeSkill)
|
||||||
|
drawScrubMarker(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: root
|
||||||
|
function onTrendDataAChanged() { overlayCanvas.requestPaint() }
|
||||||
|
function onTrendDataBChanged() { overlayCanvas.requestPaint() }
|
||||||
|
}
|
||||||
|
Connections {
|
||||||
|
target: scrubber
|
||||||
|
function onValueChanged() { overlayCanvas.requestPaint() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty state
|
||||||
|
ColumnLayout {
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
// 'display' is the Qt.DisplayRole value from headerData()
|
visible: !root.hasSeries
|
||||||
text: display !== undefined ? display : ""
|
spacing: 8
|
||||||
color: Theme.headingColor
|
|
||||||
|
Label {
|
||||||
|
Layout.alignment: Qt.AlignHCenter
|
||||||
|
text: root.comparing ? "" : "📊"
|
||||||
|
font.pixelSize: 32
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
Layout.alignment: Qt.AlignHCenter
|
||||||
|
text: root.comparing ? "Running DTW comparison…" : "Select two runs and run comparison"
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
Layout.alignment: Qt.AlignHCenter
|
||||||
|
visible: !root.comparing
|
||||||
|
text: "Click a run box on the right, then select a file from the left panel."
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Alignment Timeline Scrubber ────────────
|
||||||
|
ColumnLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 4
|
||||||
|
visible: root.hasSeries
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
height: 1
|
||||||
|
color: Theme.cardBorder
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
SectionTitle {
|
||||||
|
text: "ALIGNMENT TIMELINE SCRUBBER"
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
text: "Frame " + Math.round(scrubber.value) + " / " + Math.max(0, root.seriesLen - 1)
|
||||||
font.pixelSize: Theme.fontXs
|
font.pixelSize: Theme.fontXs
|
||||||
font.bold: true
|
font.bold: true
|
||||||
elide: Text.ElideRight
|
font.family: Theme.codeFontFamily
|
||||||
|
color: Theme.headingColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Slider {
|
||||||
|
id: scrubber
|
||||||
|
Layout.fillWidth: true
|
||||||
|
from: 0
|
||||||
|
to: Math.max(1, root.seriesLen - 1)
|
||||||
|
stepSize: 1
|
||||||
|
value: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
id: scrubReadout
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
readonly property int scrubIdx: Math.round(scrubber.value)
|
||||||
|
readonly property real tempA: root.trendDataA.length > 0
|
||||||
|
? root.trendDataA[Math.min(scrubIdx, root.trendDataA.length - 1)] : 0
|
||||||
|
readonly property real tempB: root.trendDataB.length > 0
|
||||||
|
? root.trendDataB[Math.min(scrubIdx, root.trendDataB.length - 1)] : 0
|
||||||
|
|
||||||
|
ScrubStat {
|
||||||
|
label: "Temp (Run A)"
|
||||||
|
value: scrubReadout.tempA.toFixed(1) + "°C"
|
||||||
|
valueColor: Theme.primaryAccent
|
||||||
|
}
|
||||||
|
ScrubStat {
|
||||||
|
label: "Temp (Run B)"
|
||||||
|
value: scrubReadout.tempB.toFixed(1) + "°C"
|
||||||
|
valueColor: Theme.themeSkill
|
||||||
|
}
|
||||||
|
ScrubStat {
|
||||||
|
label: "Difference"
|
||||||
|
value: Math.abs(scrubReadout.tempA - scrubReadout.tempB).toFixed(1) + "°C"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Data rows
|
// Wafer Map Overlap View card
|
||||||
TableView {
|
PanelBox {
|
||||||
id: dataTable
|
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
model: deviceController.dataModel
|
Layout.minimumHeight: 220
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 12
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
SectionTitle {
|
||||||
|
text: "WAFER MAP OVERLAP VIEW"
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
visible: root.compareFileA !== "" && root.compareFileB !== ""
|
||||||
|
text: (root.shortName(root.compareFileA).replace(".csv", "") + " vs "
|
||||||
|
+ root.shortName(root.compareFileB).replace(".csv", "")).toUpperCase()
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
elide: Text.ElideMiddle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
color: Theme.fieldBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusSm
|
||||||
clip: true
|
clip: true
|
||||||
reuseItems: true
|
|
||||||
|
|
||||||
columnWidthProvider: function (col) {
|
WaferMapItem {
|
||||||
if (col === 0)
|
id: overlapMapItem
|
||||||
return 52; // row index column
|
anchors.fill: parent
|
||||||
const sensorCols = Math.max(1, dataTable.columns - 1);
|
visible: root.compareSensorLayout.length > 0 && enableOverlapCheck.checked
|
||||||
const available = dataTable.width - 52;
|
sensors: root.compareSensorLayout
|
||||||
return Math.max(52, Math.min(90, available / sensorCols));
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
rowHeightProvider: function () {
|
ColumnLayout {
|
||||||
return 24;
|
|
||||||
}
|
|
||||||
|
|
||||||
delegate: Rectangle {
|
|
||||||
color: row % 2 === 0 ? Theme.panelBackground : Theme.cardBackground
|
|
||||||
|
|
||||||
Text {
|
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
text: display !== undefined ? display : ""
|
visible: root.compareSensorLayout.length === 0 || !enableOverlapCheck.checked
|
||||||
|
spacing: 4
|
||||||
|
Label {
|
||||||
|
Layout.alignment: Qt.AlignHCenter
|
||||||
|
text: "🗺"
|
||||||
|
font.pixelSize: 24
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
Layout.alignment: Qt.AlignHCenter
|
||||||
|
text: !enableOverlapCheck.checked ? "Overlap map disabled"
|
||||||
|
: "No sensor map available"
|
||||||
color: Theme.bodyColor
|
color: Theme.bodyColor
|
||||||
font.pixelSize: Theme.fontXs
|
font.pixelSize: Theme.fontXs
|
||||||
elide: Text.ElideRight
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ScrollBar.horizontal: ScrollBar {
|
// --- Right Column: run selection, overlap settings, readout ---
|
||||||
policy: ScrollBar.AsNeeded
|
ColumnLayout {
|
||||||
|
Layout.fillWidth: false
|
||||||
|
Layout.preferredWidth: 280
|
||||||
|
Layout.minimumWidth: 280
|
||||||
|
Layout.maximumWidth: 280
|
||||||
|
Layout.fillHeight: true
|
||||||
|
spacing: 12
|
||||||
|
|
||||||
|
// Bento 1: Run Selection
|
||||||
|
PanelBox {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: runSelCol.implicitHeight + 24
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: runSelCol
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 12
|
||||||
|
spacing: 10
|
||||||
|
|
||||||
|
SectionTitle { text: "RUN SELECTION" }
|
||||||
|
|
||||||
|
RunSlot {
|
||||||
|
title: "REFERENCE (RUN A)"
|
||||||
|
boxIndex: 1
|
||||||
|
accent: Theme.primaryAccent
|
||||||
|
filePath: root.compareFileA
|
||||||
|
onCleared: {
|
||||||
|
root.compareFileA = ""
|
||||||
|
root.activeBox = 1
|
||||||
|
root.clearResults()
|
||||||
}
|
}
|
||||||
ScrollBar.vertical: ScrollBar {
|
}
|
||||||
policy: ScrollBar.AsNeeded
|
|
||||||
|
RunSlot {
|
||||||
|
title: "SESSION (RUN B)"
|
||||||
|
boxIndex: 2
|
||||||
|
accent: Theme.themeSkill
|
||||||
|
filePath: root.compareFileB
|
||||||
|
onCleared: {
|
||||||
|
root.compareFileB = ""
|
||||||
|
root.activeBox = 2
|
||||||
|
root.clearResults()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: compareBtn
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: 32
|
||||||
|
enabled: root.compareFileA !== "" && root.compareFileB !== "" && !root.comparing
|
||||||
|
onClicked: {
|
||||||
|
root.comparing = true
|
||||||
|
root.clearResults()
|
||||||
|
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")
|
||||||
|
: (root.comparing ? "Comparing…" : "Select both runs")
|
||||||
|
color: compareBtn.enabled ? Theme.tone100 : Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
BusyIndicator {
|
||||||
|
running: root.comparing
|
||||||
|
visible: root.comparing
|
||||||
|
width: 14; height: 14
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bento 2: Overlap Map Settings
|
||||||
|
PanelBox {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: overlapCol.implicitHeight + 24
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: overlapCol
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 12
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
SectionTitle { text: "OVERLAP MAP SETTINGS" }
|
||||||
|
|
||||||
|
CheckBox {
|
||||||
|
id: enableOverlapCheck
|
||||||
|
Layout.fillWidth: true
|
||||||
|
checked: true
|
||||||
|
text: "Enable Overlap Map"
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
|
||||||
|
contentItem: Label {
|
||||||
|
leftPadding: enableOverlapCheck.indicator.width + 6
|
||||||
|
text: enableOverlapCheck.text
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
color: Theme.headingColor
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 6
|
||||||
|
visible: enableOverlapCheck.checked
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
height: 1
|
||||||
|
color: Theme.cardBorder
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 6
|
||||||
|
Label {
|
||||||
|
text: "Blend"
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
color: Theme.bodyColor
|
||||||
|
Layout.preferredWidth: 40
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diff color legend
|
||||||
|
Row {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 10
|
||||||
|
Row {
|
||||||
|
spacing: 3
|
||||||
|
Rectangle { width: 8; height: 8; radius: 4; color: Theme.sensorLow; anchors.verticalCenter: parent.verticalCenter }
|
||||||
|
Label { text: "B cooler"; font.pixelSize: 9; color: Theme.sideMutedText }
|
||||||
|
}
|
||||||
|
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: "B hotter"; font.pixelSize: 9; color: Theme.sideMutedText }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bento 3: DTW Comparison Readout
|
||||||
|
PanelBox {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: readoutCol.implicitHeight + 24
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: readoutCol
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 12
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
SectionTitle { text: "DTW COMPARISON READOUT" }
|
||||||
|
|
||||||
|
ReadoutCard {
|
||||||
|
label: "DTW ALIGNMENT DISTANCE"
|
||||||
|
value: root.warpingDistance >= 0 ? root.warpingDistance.toFixed(2) : "--"
|
||||||
|
valueColor: root.warpingDistance >= 0
|
||||||
|
? (root.warpingDistance < 50 ? "#6ee7b7" : root.warpingDistance < 100 ? "#fde047" : "#ef4444")
|
||||||
|
: Theme.sideMutedText
|
||||||
|
}
|
||||||
|
ReadoutCard {
|
||||||
|
label: "TEMPORAL FRAME OFFSET"
|
||||||
|
value: root.warpingDistance >= 0
|
||||||
|
? ((root.frameOffset >= 0 ? "+" : "") + root.frameOffset + " frames")
|
||||||
|
: "--"
|
||||||
|
valueColor: root.warpingDistance >= 0 ? Theme.headingColor : Theme.sideMutedText
|
||||||
|
}
|
||||||
|
ReadoutCard {
|
||||||
|
label: "MAX SENSOR DEVIATION"
|
||||||
|
value: root.maxSensorDeviation >= 0 ? root.maxSensorDeviation.toFixed(2) + "°C" : "--"
|
||||||
|
valueColor: root.maxSensorDeviation >= 0
|
||||||
|
? (root.maxSensorDeviation < 1.0 ? "#6ee7b7" : root.maxSensorDeviation < 3.0 ? "#fde047" : "#ef4444")
|
||||||
|
: Theme.sideMutedText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { Layout.fillHeight: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,222 +0,0 @@
|
|||||||
import QtQuick
|
|
||||||
import QtQuick.Controls
|
|
||||||
import QtQuick.Layouts
|
|
||||||
import ISC
|
|
||||||
import ISC.Wafer
|
|
||||||
|
|
||||||
// ===== Graph Tab =====
|
|
||||||
// Displays sensor temperature line charts using GraphQuickItem.
|
|
||||||
// Gets data from deviceController.getChartData() (parsed CSV, batch read)
|
|
||||||
// or from streamController.trendData (live streaming trend).
|
|
||||||
Item {
|
|
||||||
id: root
|
|
||||||
anchors.fill: parent
|
|
||||||
|
|
||||||
property var _chartData: null // cached result from getChartData()
|
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.margins: Theme.panelPadding
|
|
||||||
spacing: Theme.rightPaneGap
|
|
||||||
|
|
||||||
// ── Toolbar ───────────────────────────────────────────────────────────
|
|
||||||
RowLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
spacing: 10
|
|
||||||
|
|
||||||
Label {
|
|
||||||
text: "Line Chart"
|
|
||||||
color: Theme.headingColor
|
|
||||||
font.pixelSize: Theme.fontSm
|
|
||||||
font.bold: true
|
|
||||||
Layout.alignment: Qt.AlignVCenter
|
|
||||||
}
|
|
||||||
|
|
||||||
Item {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh button — pulls data from deviceController
|
|
||||||
Button {
|
|
||||||
id: refreshBtn
|
|
||||||
text: "REFRESH"
|
|
||||||
font.pixelSize: Theme.fontXs
|
|
||||||
font.weight: Font.Medium
|
|
||||||
implicitHeight: 26
|
|
||||||
implicitWidth: 72
|
|
||||||
hoverEnabled: true
|
|
||||||
background: Rectangle {
|
|
||||||
color: parent.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
|
|
||||||
border.color: Theme.cardBorder
|
|
||||||
border.width: 1
|
|
||||||
radius: Theme.radiusSm
|
|
||||||
}
|
|
||||||
contentItem: Text {
|
|
||||||
text: parent.text
|
|
||||||
color: Theme.headingColor
|
|
||||||
font: parent.font
|
|
||||||
horizontalAlignment: Text.AlignHCenter
|
|
||||||
verticalAlignment: Text.AlignVCenter
|
|
||||||
}
|
|
||||||
onClicked: reloadData()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Mode selector (Trend / Full chart) ─────────────────────────────
|
|
||||||
RowLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
spacing: 8
|
|
||||||
|
|
||||||
Label {
|
|
||||||
text: "Source:"
|
|
||||||
color: Theme.bodyColor
|
|
||||||
font.pixelSize: Theme.fontXs
|
|
||||||
Layout.alignment: Qt.AlignVCenter
|
|
||||||
}
|
|
||||||
|
|
||||||
ComboBox {
|
|
||||||
id: sourceSelector
|
|
||||||
model: ["Parsed Data", "Live Trend"]
|
|
||||||
currentIndex: 0
|
|
||||||
implicitHeight: 26
|
|
||||||
font.pixelSize: Theme.fontXs
|
|
||||||
background: Rectangle {
|
|
||||||
color: Theme.fieldBackground
|
|
||||||
border.color: Theme.fieldBorder
|
|
||||||
border.width: 1
|
|
||||||
radius: Theme.radiusSm
|
|
||||||
}
|
|
||||||
contentItem: Text {
|
|
||||||
text: sourceSelector.displayText
|
|
||||||
color: Theme.fieldText
|
|
||||||
font: sourceSelector.font
|
|
||||||
horizontalAlignment: Text.AlignLeft
|
|
||||||
verticalAlignment: Text.AlignVCenter
|
|
||||||
leftPadding: 8
|
|
||||||
}
|
|
||||||
onCurrentIndexChanged: reloadData()
|
|
||||||
}
|
|
||||||
|
|
||||||
Item {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
}
|
|
||||||
|
|
||||||
Label {
|
|
||||||
id: statusLabel
|
|
||||||
text: "Ready"
|
|
||||||
color: Theme.bodyColor
|
|
||||||
font.pixelSize: Theme.fontXs
|
|
||||||
font.italic: true
|
|
||||||
Layout.alignment: Qt.AlignVCenter
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Chart Area ─────────────────────────────────────────────────────────
|
|
||||||
Rectangle {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.fillHeight: true
|
|
||||||
color: Theme.cardBackground
|
|
||||||
border.color: Theme.cardBorder
|
|
||||||
border.width: 1
|
|
||||||
radius: Theme.radiusMd
|
|
||||||
clip: true
|
|
||||||
|
|
||||||
GraphQuickItem {
|
|
||||||
id: graph
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.margins: 2
|
|
||||||
backgroundColor: Theme.cardBackground
|
|
||||||
gridColor: Theme.softBorder
|
|
||||||
axisColor: Theme.bodyColor
|
|
||||||
textColor: Theme.headingColor
|
|
||||||
seriesColors: [Theme.sensorLow, Theme.sensorHigh, Theme.sensorInRange, "#FFA726", "#AB47BC", "#00BCD4"]
|
|
||||||
showLegend: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Data Loading ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function reloadData() {
|
|
||||||
if (sourceSelector.currentIndex === 0) {
|
|
||||||
// Parsed data from DeviceController
|
|
||||||
loadParsedData();
|
|
||||||
} else {
|
|
||||||
// Live trend from SessionController
|
|
||||||
loadLiveTrend();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadParsedData() {
|
|
||||||
statusLabel.text = "Loading...";
|
|
||||||
var result = deviceController.getChartData();
|
|
||||||
if (!result || !result.success) {
|
|
||||||
graph.seriesData = [];
|
|
||||||
graph.sensorNames = [];
|
|
||||||
graph.title = "Sensor Temperature Over Time";
|
|
||||||
statusLabel.text = "No data — read a wafer first";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
graph.seriesData = result.series || [];
|
|
||||||
graph.sensorNames = result.sensor_names || [];
|
|
||||||
graph.title = "Sensor Temperature Over Time";
|
|
||||||
statusLabel.text = (result.series ? result.series.length : 0) + " sensors loaded";
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadLiveTrend() {
|
|
||||||
// Live trend: connects to streamController.trendData
|
|
||||||
// The trend data comes as a JSON array of per-frame averages
|
|
||||||
statusLabel.text = "Connecting to live trend...";
|
|
||||||
// We need at least one data point
|
|
||||||
if (streamController.stats && streamController.stats.avg !== undefined) {
|
|
||||||
// Build a single-series graph from the trend buffer
|
|
||||||
// For now show a placeholder — the trendData signal handles updates
|
|
||||||
graph.title = "Live Average Temperature";
|
|
||||||
graph.yLabel = "Avg Temperature (°C)";
|
|
||||||
graph.xLabel = "Time (s)";
|
|
||||||
statusLabel.text = "Live trend — waiting for data...";
|
|
||||||
} else {
|
|
||||||
statusLabel.text = "No live data — start a stream on the Wafer Map tab";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Connections ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
Connections {
|
|
||||||
target: deviceController
|
|
||||||
function onParsedDataReady(result) {
|
|
||||||
if (sourceSelector.currentIndex === 0) {
|
|
||||||
root.reloadData();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Connections {
|
|
||||||
target: streamController
|
|
||||||
function onTrendData(avgsJson) {
|
|
||||||
if (sourceSelector.currentIndex === 1) {
|
|
||||||
try {
|
|
||||||
var avgs = JSON.parse(avgsJson);
|
|
||||||
if (avgs && avgs.length > 0) {
|
|
||||||
graph.seriesData = [avgs];
|
|
||||||
graph.sensorNames = ["Average"];
|
|
||||||
graph.title = "Live Average Temperature";
|
|
||||||
graph.yLabel = "Avg Temperature (°C)";
|
|
||||||
graph.xLabel = "Frame";
|
|
||||||
statusLabel.text = "Live: " + avgs.length + " data points";
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// ignore parse errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// On startup, try to load parsed data if available
|
|
||||||
Component.onCompleted: {
|
|
||||||
if (deviceController.dataRowCount > 0) {
|
|
||||||
reloadData();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -206,32 +206,36 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// LIVE timer
|
// Record start/stop toggle (Live mode)
|
||||||
// RowLayout {
|
Button {
|
||||||
// spacing: 5
|
visible: streamController.mode === "live"
|
||||||
// visible: streamController.mode === "live" && streamController.state !== "idle"
|
enabled: streamController.state !== "idle" || streamController.recording
|
||||||
// Rectangle {
|
text: streamController.recording ? "Stop REC" : "Record"
|
||||||
// width: 7; height: 7; radius: 4
|
onClicked: {
|
||||||
// color: Theme.liveColor
|
if (streamController.recording) {
|
||||||
// }
|
streamController.stopRecording();
|
||||||
// Label {
|
} else {
|
||||||
// text: "LIVE " + root.fmtTime(root._liveSecs)
|
var info = deviceController.lastWaferInfo;
|
||||||
// color: Theme.liveColor
|
var serial = (info && info.length > 1) ? info[1] : "";
|
||||||
// font.pixelSize: Theme.fontXs
|
var ts = Qt.formatDateTime(new Date(), "yyyyMMdd_HHmmss");
|
||||||
// font.weight: Font.Medium
|
var name = "live_" + (serial ? serial + "_" : "") + ts + ".csv";
|
||||||
// font.letterSpacing: 0.8
|
streamController.startRecording(deviceController.saveDataDir + "/" + name, serial);
|
||||||
// }
|
}
|
||||||
// }
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream statistics popup (Live mode)
|
||||||
|
Button {
|
||||||
|
visible: streamController.mode === "live"
|
||||||
|
text: "Stats"
|
||||||
|
onClicked: streamStatsDialog.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
StreamStatsDialog {
|
||||||
|
id: streamStatsDialog
|
||||||
|
elapsedSecs: root._liveSecs
|
||||||
|
}
|
||||||
|
|
||||||
// IDLE label (review / not connected)
|
|
||||||
// Label {
|
|
||||||
// visible: streamController.mode === "review" || streamController.state === "idle"
|
|
||||||
// text: streamController.state.toUpperCas`e`()
|
|
||||||
// color: Theme.bodyColor
|
|
||||||
// font.pixelSize: Theme.fontXs
|
|
||||||
// font.weight: Font.Medium
|
|
||||||
// font.letterSpacing: 1.2
|
|
||||||
// }
|
|
||||||
Button {
|
Button {
|
||||||
text: "Export PNG"
|
text: "Export PNG"
|
||||||
onClicked: exportDialog.open()
|
onClicked: exportDialog.open()
|
||||||
|
|||||||
@@ -188,8 +188,15 @@ ColumnLayout {
|
|||||||
return (modelData.baseName + modelData.waferType + modelData.date).toLowerCase().indexOf(q) >= 0;
|
return (modelData.baseName + modelData.waferType + modelData.date).toLowerCase().indexOf(q) >= 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Active only in review mode; no highlight during live streaming
|
// Active highlight logic: on Data tab, highlight compareFileA/B; otherwise highlight loadedFile (for Map tab).
|
||||||
readonly property bool isActive: streamController.mode === "review" && streamController.loadedFile !== "" && modelData.fileName === streamController.loadedFile
|
readonly property bool isActive:{
|
||||||
|
try {
|
||||||
|
if (root.selectedTabIndex === 1 && dataTabLoader.item) {
|
||||||
|
return modelData.fileName === dataTabLoader.item.compareFileA || modelData.fileName === dataTabLoader.item.compareFileB;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
return streamController.mode === "review" && streamController.loadedFile !== "" && modelData.fileName === streamController.loadedFile;
|
||||||
|
}
|
||||||
|
|
||||||
visible: matchesFilter
|
visible: matchesFilter
|
||||||
height: matchesFilter ? implicitHeight : 0
|
height: matchesFilter ? implicitHeight : 0
|
||||||
|
|||||||
@@ -0,0 +1,315 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Dialogs
|
||||||
|
import ISC
|
||||||
|
|
||||||
|
// ===== Split Dialog (Threshold Segmentation) =====
|
||||||
|
// Modal popup for segmenting temperature profile into Ramp/Soak/Cool phases.
|
||||||
|
// Backend: splitter.segment_profile() returns list of DataSegment objects.
|
||||||
|
|
||||||
|
Popup {
|
||||||
|
id: root
|
||||||
|
modal: true
|
||||||
|
dim: true
|
||||||
|
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||||
|
width: Math.min(parent.width - 100, 800)
|
||||||
|
height: Math.min(parent.height - 100, 600)
|
||||||
|
anchors.centerIn: Overlay.overlay
|
||||||
|
|
||||||
|
property real threshold: 40.0
|
||||||
|
property bool segmenting: false
|
||||||
|
property var segments: []
|
||||||
|
property string currentFile: streamController.loadedFile || ""
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusMd
|
||||||
|
color: Theme.cardBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 20
|
||||||
|
spacing: 16
|
||||||
|
|
||||||
|
// ── Header ────────────────────────────────────────────────
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 10
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "SPLIT DATA"
|
||||||
|
font.pixelSize: Theme.fontLg
|
||||||
|
font.bold: true
|
||||||
|
color: Theme.headingColor
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: "\u2715"
|
||||||
|
flat: true
|
||||||
|
Layout.preferredWidth: 32
|
||||||
|
Layout.preferredHeight: 32
|
||||||
|
onClicked: root.close()
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusXs
|
||||||
|
color: parent.hovered ? Theme.buttonNeutralHover : "transparent"
|
||||||
|
}
|
||||||
|
contentItem: Label {
|
||||||
|
text: parent.text
|
||||||
|
color: Theme.bodyColor
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Threshold Input ───────────────────────────────────────
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 16
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 4
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "THRESHOLD TEMPERATURE (°C)"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
SpinBox {
|
||||||
|
id: thresholdSpin
|
||||||
|
from: 0
|
||||||
|
to: 200
|
||||||
|
value: root.threshold
|
||||||
|
stepSize: 1
|
||||||
|
editable: true
|
||||||
|
Layout.fillWidth: true
|
||||||
|
background: Rectangle {
|
||||||
|
color: Theme.fieldBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "°C"
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 4
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "SESSION CSV"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: root.currentFile !== ""
|
||||||
|
? root.currentFile.split("/").pop()
|
||||||
|
: "No file loaded"
|
||||||
|
color: root.currentFile !== "" ? Theme.bodyColor : Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
elide: Text.ElideMiddle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Split Button ──────────────────────────────────────────
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 10
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: splitBtn
|
||||||
|
text: "SPLIT"
|
||||||
|
Layout.fillWidth: true
|
||||||
|
enabled: !root.segmenting && root.currentFile !== ""
|
||||||
|
onClicked: {
|
||||||
|
root.threshold = thresholdSpin.value;
|
||||||
|
root.segmenting = true;
|
||||||
|
streamController.splitData(root.currentFile, root.threshold);
|
||||||
|
}
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: splitBtn.enabled ? Theme.primaryAccent : Theme.buttonNeutralBackground
|
||||||
|
border.color: splitBtn.enabled ? Theme.primaryAccent : "transparent"
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
contentItem: Row {
|
||||||
|
spacing: 8
|
||||||
|
anchors.centerIn: parent
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: splitBtn.enabled ? (root.segmenting ? "Segmenting..." : "SPLIT") : "LOAD DATA FIRST"
|
||||||
|
color: splitBtn.enabled ? Theme.tone100 : Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
BusyIndicator {
|
||||||
|
running: root.segmenting
|
||||||
|
visible: root.segmenting
|
||||||
|
width: 16
|
||||||
|
height: 16
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Segments Table ────────────────────────────────────────
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: Theme.panelBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 0
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
// Table header
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: 36
|
||||||
|
color: Theme.subtleSectionBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 12
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Label { text: "PHASE"; Layout.preferredWidth: 100; color: Theme.sideMutedText; font.bold: true; font.pixelSize: Theme.fontSm }
|
||||||
|
Label { text: "START (s)"; Layout.preferredWidth: 120; color: Theme.sideMutedText; font.bold: true; font.pixelSize: Theme.fontSm }
|
||||||
|
Label { text: "END (s)"; Layout.preferredWidth: 120; color: Theme.sideMutedText; font.bold: true; font.pixelSize: Theme.fontSm }
|
||||||
|
Label { text: "AVG TEMP (°C)"; Layout.fillWidth: true; color: Theme.sideMutedText; font.bold: true; font.pixelSize: Theme.fontSm }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Table body (scrollable)
|
||||||
|
ScrollView {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
width: parent.width - 32
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.segments.length > 0 ? root.segments : []
|
||||||
|
delegate: Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: 40
|
||||||
|
color: index % 2 === 0 ? Theme.cardBackground : "transparent"
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 12
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: modelData.label || ""
|
||||||
|
Layout.preferredWidth: 100
|
||||||
|
color: modelData.label === "Soak" ? Theme.statusSuccessColor :
|
||||||
|
modelData.label === "Ramp" ? Theme.statusWarningColor : Theme.bodyColor
|
||||||
|
font.bold: true
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: (modelData.start_frame || 0).toString()
|
||||||
|
Layout.preferredWidth: 120
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: (modelData.end_frame || 0).toString()
|
||||||
|
Layout.preferredWidth: 120
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: (modelData.avg_temp || 0).toFixed(2) + " °C"
|
||||||
|
Layout.fillWidth: true
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty state
|
||||||
|
Label {
|
||||||
|
visible: root.segments.length === 0
|
||||||
|
text: root.currentFile !== "" ? "Click SPLIT to segment data" : "Load a CSV file first"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: 100
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Summary line ──────────────────────────────────────────
|
||||||
|
Label {
|
||||||
|
visible: root.segments.length > 0
|
||||||
|
text: root.segments.length + " segment" + (root.segments.length === 1 ? "" : "s")
|
||||||
|
+ " found above " + root.threshold.toFixed(0) + "°C threshold."
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Backend Connection ────────────────────────────────────────
|
||||||
|
Connections {
|
||||||
|
target: streamController
|
||||||
|
function onSplitResult(result) {
|
||||||
|
root.segmenting = false;
|
||||||
|
if (result && result.success) {
|
||||||
|
root.segments = result.segments || [];
|
||||||
|
console.log("[SplitDialog] Segmented into", root.segments.length, "phases");
|
||||||
|
} else {
|
||||||
|
root.segments = [];
|
||||||
|
console.log("[SplitDialog] Split failed:", result.error || "unknown");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import QtQuick.Controls
|
||||||
|
import ISC
|
||||||
|
|
||||||
|
// ===== Stream Stats Dialog =====
|
||||||
|
// Popup showing live stream statistics: received frames, errors,
|
||||||
|
// resync count, and elapsed time. Opened from the Live toolbar.
|
||||||
|
|
||||||
|
Popup {
|
||||||
|
id: root
|
||||||
|
modal: true
|
||||||
|
dim: true
|
||||||
|
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||||
|
width: 320
|
||||||
|
implicitHeight: content.implicitHeight + 40
|
||||||
|
anchors.centerIn: Overlay.overlay
|
||||||
|
|
||||||
|
// Elapsed seconds supplied by the Live timer in WaferMapTab.
|
||||||
|
property int elapsedSecs: 0
|
||||||
|
|
||||||
|
function fmtTime(s) {
|
||||||
|
var m = Math.floor(s / 60);
|
||||||
|
var ss = s % 60;
|
||||||
|
return (m < 10 ? "0" : "") + m + ":" + (ss < 10 ? "0" : "") + ss;
|
||||||
|
}
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusMd
|
||||||
|
color: Theme.cardBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
component StatRow: RowLayout {
|
||||||
|
property string label
|
||||||
|
property string value
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: label
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
text: value
|
||||||
|
color: Theme.headingColor
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.weight: Font.Medium
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: content
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 20
|
||||||
|
spacing: 16
|
||||||
|
|
||||||
|
// ── Header ────────────────────────────────────────────────
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 10
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "STREAM DATA"
|
||||||
|
font.pixelSize: Theme.fontLg
|
||||||
|
font.bold: true
|
||||||
|
color: Theme.headingColor
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: "✕"
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stats box ─────────────────────────────────────────────
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
implicitHeight: statRows.implicitHeight + 20
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: Theme.subtleSectionBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: statRows
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 10
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
StatRow {
|
||||||
|
label: "Received frames"
|
||||||
|
value: streamController.receivedCount
|
||||||
|
}
|
||||||
|
StatRow {
|
||||||
|
label: "Errors"
|
||||||
|
value: streamController.errorCount
|
||||||
|
}
|
||||||
|
StatRow {
|
||||||
|
label: "Resyncs"
|
||||||
|
value: streamController.resyncCount
|
||||||
|
}
|
||||||
|
StatRow {
|
||||||
|
label: "Elapsed"
|
||||||
|
value: root.fmtTime(root.elapsedSecs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,3 +6,5 @@ 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
|
||||||
|
StreamStatsDialog 1.0 StreamStatsDialog.qml
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
# ===== ISC Tabs Module =====
|
# ===== ISC Tabs Module =====
|
||||||
module ISC.Tabs
|
module ISC.Tabs
|
||||||
|
|
||||||
GraphTab 1.0 GraphTab.qml
|
|
||||||
WaferMapTab 1.0 WaferMapTab.qml
|
WaferMapTab 1.0 WaferMapTab.qml
|
||||||
DataTab 1.0 DataTab.qml
|
DataTab 1.0 DataTab.qml
|
||||||
SettingsTab 1.0 SettingsTab.qml
|
SettingsTab 1.0 SettingsTab.qml
|
||||||
|
|||||||
+1
-21
@@ -44,9 +44,6 @@ QtObject {
|
|||||||
readonly property int fontWeightMedium: 500
|
readonly property int fontWeightMedium: 500
|
||||||
readonly property int fontWeightBold: 700
|
readonly property int fontWeightBold: 700
|
||||||
|
|
||||||
// Legacy shim
|
|
||||||
readonly property int tabFontSize: fontSm
|
|
||||||
|
|
||||||
// ── 3. Motion ──────────────────────────────────────────────────────────
|
// ── 3. Motion ──────────────────────────────────────────────────────────
|
||||||
readonly property int durationFast: 120 // micro-interactions (focus ring, hover flash)
|
readonly property int durationFast: 120 // micro-interactions (focus ring, hover flash)
|
||||||
readonly property int durationBase: 180 // standard transitions (pill switch, row select)
|
readonly property int durationBase: 180 // standard transitions (pill switch, row select)
|
||||||
@@ -118,14 +115,8 @@ QtObject {
|
|||||||
// ── 7. Tracks (pill toggle, scrollbar thumb) ─────────────────────────────
|
// ── 7. Tracks (pill toggle, scrollbar thumb) ─────────────────────────────
|
||||||
readonly property color trackBackground: isDarkMode ? "#25252B" : "#E2E2DE"
|
readonly property color trackBackground: isDarkMode ? "#25252B" : "#E2E2DE"
|
||||||
|
|
||||||
// ── 8. Tabs (footer tab bar) ─────────────────────────────────────────────
|
// ── 8. Tabs (mode toggle pills) ──────────────────────────────────────────
|
||||||
readonly property color tabBarBackground: tone200
|
|
||||||
readonly property color tabBackground: "transparent"
|
|
||||||
readonly property color tabActiveBackground: isDarkMode ? "#25252B" : "#FFFFFF"
|
readonly property color tabActiveBackground: isDarkMode ? "#25252B" : "#FFFFFF"
|
||||||
readonly property color tabHoverBackground: tone250
|
|
||||||
readonly property color tabBorder: "transparent"
|
|
||||||
readonly property color tabText: toneMute
|
|
||||||
readonly property color tabActiveText: toneText
|
|
||||||
|
|
||||||
// ── 9. Side rail ─────────────────────────────────────────────────────────
|
// ── 9. Side rail ─────────────────────────────────────────────────────────
|
||||||
readonly property color sideRailBackground: tone150
|
readonly property color sideRailBackground: tone150
|
||||||
@@ -133,12 +124,8 @@ QtObject {
|
|||||||
readonly property color sideActiveBackground: isDarkMode ? "#25252B" : "#FFFFFF"
|
readonly property color sideActiveBackground: isDarkMode ? "#25252B" : "#FFFFFF"
|
||||||
readonly property color sideBorder: toneBorder
|
readonly property color sideBorder: toneBorder
|
||||||
readonly property color sideMutedText: toneMute
|
readonly property color sideMutedText: toneMute
|
||||||
readonly property color sideAccent: themeAccent
|
|
||||||
readonly property color sideRowHover: tone250
|
|
||||||
readonly property color sideFieldBackground: isDarkMode ? "#101014" : "#FFFFFF"
|
|
||||||
|
|
||||||
// ── 10a. Transport / toolbar surfaces ────────────────────────────────────
|
// ── 10a. Transport / toolbar surfaces ────────────────────────────────────
|
||||||
readonly property color transportBackground: isDarkMode ? "#101014" : "#EFEFED"
|
|
||||||
readonly property color transportButtonBg: tone250
|
readonly property color transportButtonBg: tone250
|
||||||
readonly property color transportButtonHover: tone300
|
readonly property color transportButtonHover: tone300
|
||||||
readonly property color liveColor: themeAdded
|
readonly property color liveColor: themeAdded
|
||||||
@@ -179,14 +166,7 @@ QtObject {
|
|||||||
readonly property int sidePanelRadius: 10
|
readonly property int sidePanelRadius: 10
|
||||||
readonly property int sideFooterConnection: 100
|
readonly property int sideFooterConnection: 100
|
||||||
readonly property int sideFooterUtility: 46
|
readonly property int sideFooterUtility: 46
|
||||||
// Tab bar layout
|
|
||||||
readonly property int tabBarHeight: 34
|
|
||||||
readonly property int sidePillHeight: 54
|
readonly property int sidePillHeight: 54
|
||||||
readonly property int sidePillButtonHeight: 36
|
|
||||||
readonly property int tabBarPadding: 6
|
|
||||||
readonly property int tabSpacing: 2
|
|
||||||
readonly property int tabRadius: 8
|
|
||||||
readonly property int tabButtonMinWidth: 80
|
|
||||||
// Settings page layout
|
// Settings page layout
|
||||||
readonly property int settingsPanelMaxWidth: 760
|
readonly property int settingsPanelMaxWidth: 760
|
||||||
readonly property int settingsOuterMargin: 40
|
readonly property int settingsOuterMargin: 40
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -20,7 +24,6 @@ from pygui.serialcomm.data_parser import (
|
|||||||
remove_trailing_zeros,
|
remove_trailing_zeros,
|
||||||
save_to_csv,
|
save_to_csv,
|
||||||
)
|
)
|
||||||
from pygui.serialcomm.device_service import DeviceService
|
|
||||||
from pygui.serialcomm.serial_port import WaferInfo
|
from pygui.serialcomm.serial_port import WaferInfo
|
||||||
|
|
||||||
# import pygui.backend.wafer_map_item
|
# import pygui.backend.wafer_map_item
|
||||||
@@ -61,6 +64,7 @@ class DeviceController(QObject):
|
|||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._settings = settings
|
self._settings = settings
|
||||||
self._data_dir = data_dir
|
self._data_dir = data_dir
|
||||||
|
from pygui.serialcomm.device_service import DeviceService
|
||||||
self._service = DeviceService(settings)
|
self._service = DeviceService(settings)
|
||||||
|
|
||||||
# Always start fresh — never restore connection status or logs from a prior session
|
# Always start fresh — never restore connection status or logs from a prior session
|
||||||
@@ -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,10 @@ 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
|
||||||
|
# NOTE: dict (QVariantMap), not object — Signal(object) delivers Python
|
||||||
|
# dicts to QML as an opaque empty wrapper with no accessible fields.
|
||||||
|
comparisonResult = Signal(dict) # dict with success, distance, warping_path, max_sensor_deviation, series_a, series_b
|
||||||
|
splitResult = Signal(dict) # dict with success, segments
|
||||||
# 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
|
||||||
@@ -226,6 +230,10 @@ class SessionController(QObject):
|
|||||||
def errorCount(self) -> int:
|
def errorCount(self) -> int:
|
||||||
return self._error_count
|
return self._error_count
|
||||||
|
|
||||||
|
@Property(int, notify=liveStatsChanged)
|
||||||
|
def resyncCount(self) -> int:
|
||||||
|
return self._reader.resync_count if self._reader else 0
|
||||||
|
|
||||||
# ---- mode + thresholds ----
|
# ---- mode + thresholds ----
|
||||||
@Slot(str)
|
@Slot(str)
|
||||||
@slot_error_boundary
|
@slot_error_boundary
|
||||||
@@ -305,6 +313,176 @@ 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)
|
||||||
|
|
||||||
|
# Mean temporal shift along the DTW path: positive means run B
|
||||||
|
# reaches the same profile features later than run A.
|
||||||
|
path = result["warping_path"]
|
||||||
|
frame_offset = round(sum(j - i for i, j in path) / len(path)) if path else 0
|
||||||
|
|
||||||
|
# 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"],
|
||||||
|
# Lists, not tuples — tuples don't survive QVariant conversion
|
||||||
|
"warping_path": [list(p) for p in result["warping_path"][:50]],
|
||||||
|
"frame_offset": frame_offset,
|
||||||
|
"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:
|
||||||
@@ -595,5 +773,3 @@ class SessionController(QObject):
|
|||||||
def _flush_trend(self) -> None:
|
def _flush_trend(self) -> None:
|
||||||
import json
|
import json
|
||||||
self.trendData.emit(json.dumps(self._trend_buffer))
|
self.trendData.emit(json.dumps(self._trend_buffer))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
# ===== Wafer Sub-package =====
|
# ===== Wafer Sub-package =====
|
||||||
|
from pygui.backend.wafer.family_spec import sensor_count_for
|
||||||
from pygui.backend.wafer.wafer_layouts import available_families, load_layout
|
from pygui.backend.wafer.wafer_layouts import available_families, load_layout
|
||||||
from pygui.backend.wafer.zwafer_models import DataRecord, Sensor, ZWaferData
|
from pygui.backend.wafer.zwafer_models import DataRecord, Sensor, ZWaferData
|
||||||
from pygui.backend.wafer.zwafer_parser import ZWaferParser
|
from pygui.backend.wafer.zwafer_parser import ZWaferParser
|
||||||
@@ -7,4 +8,5 @@ __all__ = [
|
|||||||
"ZWaferData", "Sensor", "DataRecord",
|
"ZWaferData", "Sensor", "DataRecord",
|
||||||
"ZWaferParser",
|
"ZWaferParser",
|
||||||
"load_layout", "available_families",
|
"load_layout", "available_families",
|
||||||
|
"sensor_count_for",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"""Single source of truth for the wafer sensor-count partition."""
|
||||||
|
|
||||||
|
|
||||||
|
def sensor_count_for(family_code: str) -> int:
|
||||||
|
"""Return the number of valid sensor readings for a wafer family code.
|
||||||
|
|
||||||
|
Returns 80 for the "X" family. Returns 244 for every other family
|
||||||
|
code."""
|
||||||
|
return 80 if family_code == "X" else 244
|
||||||
@@ -12,13 +12,9 @@ import logging
|
|||||||
import os
|
import os
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
from pygui.backend.wafer.family_spec import sensor_count_for
|
||||||
|
|
||||||
# Max DUTs (sensors) per row before overhead bytes are stripped
|
log = logging.getLogger(__name__)
|
||||||
# P wafer: 244 valid readings per 256-block (12 overhead bytes)
|
|
||||||
# X wafer: 80 valid readings per 256-block (14 overhead bytes)
|
|
||||||
MAXDUT_P = 244
|
|
||||||
MAXDUT_X = 80
|
|
||||||
|
|
||||||
|
|
||||||
def csv_column_count(family_code: str) -> int:
|
def csv_column_count(family_code: str) -> int:
|
||||||
@@ -156,26 +152,27 @@ def _parse_p_binary(data_bytes: bytes) -> list[list[str]]:
|
|||||||
"""Parse P-family (and A/B/C/D/E/F) binary data.
|
"""Parse P-family (and A/B/C/D/E/F) binary data.
|
||||||
|
|
||||||
Each block of 256 readings has 244 valid + 12 overhead.
|
Each block of 256 readings has 244 valid + 12 overhead.
|
||||||
Valid readings are chunked into rows of MAXDUT_P (244).
|
Valid readings are chunked into rows of the family's sensor count (244).
|
||||||
"""
|
"""
|
||||||
|
sensor_count = sensor_count_for("P")
|
||||||
readings: list[str] = []
|
readings: list[str] = []
|
||||||
|
|
||||||
# Read 2 bytes at a time (UInt16 little-endian)
|
# Read 2 bytes at a time (UInt16 little-endian)
|
||||||
# Each 256-word block has MAXDUT_P valid readings (first N words), rest are overhead
|
# Each 256-word block has `sensor_count` valid readings (first N words), rest are overhead
|
||||||
num_words = len(data_bytes) // 2
|
num_words = len(data_bytes) // 2
|
||||||
for i in range(num_words):
|
for i in range(num_words):
|
||||||
value = int.from_bytes(data_bytes[i * 2 : i * 2 + 2], byteorder="little")
|
value = int.from_bytes(data_bytes[i * 2 : i * 2 + 2], byteorder="little")
|
||||||
if i % 256 < MAXDUT_P:
|
if i % 256 < sensor_count:
|
||||||
readings.append(f"{value:04X}")
|
readings.append(f"{value:04X}")
|
||||||
|
|
||||||
# Chunk into rows of MAXDUT_P
|
# Chunk into rows of sensor_count
|
||||||
result: list[list[str]] = []
|
result: list[list[str]] = []
|
||||||
idx = 0
|
idx = 0
|
||||||
while idx + MAXDUT_P <= len(readings):
|
while idx + sensor_count <= len(readings):
|
||||||
result.append(readings[idx : idx + MAXDUT_P])
|
result.append(readings[idx : idx + sensor_count])
|
||||||
idx += MAXDUT_P
|
idx += sensor_count
|
||||||
|
|
||||||
log.info("Parsed P-family: %d rows × %d cols", len(result), MAXDUT_P)
|
log.info("Parsed P-family: %d rows × %d cols", len(result), sensor_count)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -183,24 +180,25 @@ def _parse_x_binary(data_bytes: bytes) -> list[list[str]]:
|
|||||||
"""Parse X-family binary data.
|
"""Parse X-family binary data.
|
||||||
|
|
||||||
Each block of 256 readings has 80 valid + 14 overhead.
|
Each block of 256 readings has 80 valid + 14 overhead.
|
||||||
Valid readings are chunked into rows of MAXDUT_X (80).
|
Valid readings are chunked into rows of the family's sensor count (80).
|
||||||
"""
|
"""
|
||||||
|
sensor_count = sensor_count_for("X")
|
||||||
readings: list[str] = []
|
readings: list[str] = []
|
||||||
|
|
||||||
num_words = len(data_bytes) // 2
|
num_words = len(data_bytes) // 2
|
||||||
for i in range(num_words):
|
for i in range(num_words):
|
||||||
value = int.from_bytes(data_bytes[i * 2 : i * 2 + 2], byteorder="little")
|
value = int.from_bytes(data_bytes[i * 2 : i * 2 + 2], byteorder="little")
|
||||||
if i % 256 < MAXDUT_X:
|
if i % 256 < sensor_count:
|
||||||
readings.append(f"{value:04X}")
|
readings.append(f"{value:04X}")
|
||||||
|
|
||||||
# Chunk into rows of MAXDUT_X
|
# Chunk into rows of sensor_count
|
||||||
result: list[list[str]] = []
|
result: list[list[str]] = []
|
||||||
idx = 0
|
idx = 0
|
||||||
while idx + MAXDUT_X <= len(readings):
|
while idx + sensor_count <= len(readings):
|
||||||
result.append(readings[idx : idx + MAXDUT_X])
|
result.append(readings[idx : idx + sensor_count])
|
||||||
idx += MAXDUT_X
|
idx += sensor_count
|
||||||
|
|
||||||
log.info("Parsed X-family: %d rows × %d cols", len(result), MAXDUT_X)
|
log.info("Parsed X-family: %d rows × %d cols", len(result), sensor_count)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,14 +19,6 @@ log = logging.getLogger(__name__)
|
|||||||
class DeviceService:
|
class DeviceService:
|
||||||
"""High-level wafer device communication."""
|
"""High-level wafer device communication."""
|
||||||
|
|
||||||
# Expected hex string lengths for known wafer families
|
|
||||||
# P wafer: 393216 hex chars (196608 bytes)
|
|
||||||
# X wafer: 1310720 hex chars (655360 bytes)
|
|
||||||
EXPECTED_HEX_LENGTHS = {
|
|
||||||
"P": 393216,
|
|
||||||
"X": 1310720,
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, settings: LocalSettings) -> None:
|
def __init__(self, settings: LocalSettings) -> None:
|
||||||
self._settings = settings
|
self._settings = settings
|
||||||
self._cached_ports: list[str] = []
|
self._cached_ports: list[str] = []
|
||||||
@@ -209,7 +201,3 @@ class DeviceService:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.error("Erase failed on %s: %s", port, exc)
|
log.error("Erase failed on %s: %s", port, exc)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def get_expected_hex_length(self, family_code: str) -> int:
|
|
||||||
"""Return expected hex string length for a wafer family."""
|
|
||||||
return self._settings.get_wafer_data_size(family_code)
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import time
|
|||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
from pygui.backend.models.frame import Frame
|
from pygui.backend.models.frame import Frame
|
||||||
|
from pygui.backend.wafer.family_spec import sensor_count_for
|
||||||
from pygui.serialcomm.data_parser import _convert_hex_to_temp
|
from pygui.serialcomm.data_parser import _convert_hex_to_temp
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@@ -30,6 +31,8 @@ class StreamReader:
|
|||||||
self._thread: threading.Thread | None = None
|
self._thread: threading.Thread | None = None
|
||||||
self._stop = threading.Event()
|
self._stop = threading.Event()
|
||||||
self.error_count = 0
|
self.error_count = 0
|
||||||
|
# Cumulative count of resync events (lost sync marker) for stream stats.
|
||||||
|
self.resync_count = 0
|
||||||
|
|
||||||
def start(self) -> None:
|
def start(self) -> None:
|
||||||
self._stop.clear()
|
self._stop.clear()
|
||||||
@@ -158,6 +161,7 @@ class StreamReader:
|
|||||||
else:
|
else:
|
||||||
# Resync: keep only trailing 0xAA if present, then read more.
|
# Resync: keep only trailing 0xAA if present, then read more.
|
||||||
resync_attempts += 1
|
resync_attempts += 1
|
||||||
|
self.resync_count += 1
|
||||||
if resync_attempts > _MAX_RESYNC_ATTEMPTS:
|
if resync_attempts > _MAX_RESYNC_ATTEMPTS:
|
||||||
log.error(
|
log.error(
|
||||||
"StreamReader: giving up after %d resync attempts "
|
"StreamReader: giving up after %d resync attempts "
|
||||||
@@ -179,7 +183,7 @@ class StreamReader:
|
|||||||
def _run_ascii(self, initial_bytes: bytes) -> None:
|
def _run_ascii(self, initial_bytes: bytes) -> None:
|
||||||
log.info("StreamReader: starting ASCII hex dump stream parsing")
|
log.info("StreamReader: starting ASCII hex dump stream parsing")
|
||||||
|
|
||||||
valid_words = 80 if self._family_code == "X" else 244
|
valid_words = sensor_count_for(self._family_code)
|
||||||
block_words = 256
|
block_words = 256
|
||||||
word_char_len = 4
|
word_char_len = 4
|
||||||
chars_needed = block_words * word_char_len # total hex chars per block
|
chars_needed = block_words * word_char_len # total hex chars per block
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ from pygui.serialcomm.data_parser import (
|
|||||||
remove_trailing_zeros,
|
remove_trailing_zeros,
|
||||||
save_to_csv,
|
save_to_csv,
|
||||||
_convert_hex_to_temp,
|
_convert_hex_to_temp,
|
||||||
MAXDUT_P,
|
|
||||||
MAXDUT_X,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── csv_column_count ──────────────────────────────────────────────────────────
|
# ── csv_column_count ──────────────────────────────────────────────────────────
|
||||||
@@ -106,7 +104,7 @@ def _make_p_block(num_blocks: int = 1, value: int = 0x0100) -> bytes:
|
|||||||
data = bytearray()
|
data = bytearray()
|
||||||
for _ in range(num_blocks):
|
for _ in range(num_blocks):
|
||||||
for i in range(256):
|
for i in range(256):
|
||||||
word = value if i < MAXDUT_P else 0
|
word = value if i < 244 else 0
|
||||||
data += word.to_bytes(2, byteorder="little")
|
data += word.to_bytes(2, byteorder="little")
|
||||||
return bytes(data)
|
return bytes(data)
|
||||||
|
|
||||||
@@ -120,7 +118,7 @@ def _make_x_block(num_blocks: int = 1, value: int = 0x0100) -> bytes:
|
|||||||
data = bytearray()
|
data = bytearray()
|
||||||
for _ in range(num_blocks):
|
for _ in range(num_blocks):
|
||||||
for i in range(256):
|
for i in range(256):
|
||||||
word = value if i < MAXDUT_X else 0
|
word = value if i < 80 else 0
|
||||||
data += word.to_bytes(2, byteorder="little")
|
data += word.to_bytes(2, byteorder="little")
|
||||||
return bytes(data)
|
return bytes(data)
|
||||||
|
|
||||||
@@ -136,7 +134,7 @@ class TestParseBinaryData:
|
|||||||
data = _make_p_block(1)
|
data = _make_p_block(1)
|
||||||
result = parse_binary_data(data, "P")
|
result = parse_binary_data(data, "P")
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert len(result[0]) == MAXDUT_P # 244
|
assert len(result[0]) == 244
|
||||||
|
|
||||||
def test_p_family_two_blocks_returns_two_rows(self):
|
def test_p_family_two_blocks_returns_two_rows(self):
|
||||||
data = _make_p_block(2)
|
data = _make_p_block(2)
|
||||||
@@ -161,14 +159,14 @@ class TestParseBinaryData:
|
|||||||
data = _make_x_block(1)
|
data = _make_x_block(1)
|
||||||
result = parse_binary_data(data, "X")
|
result = parse_binary_data(data, "X")
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert len(result[0]) == MAXDUT_X # 80
|
assert len(result[0]) == 80
|
||||||
|
|
||||||
def test_a_family_uses_p_parser(self):
|
def test_a_family_uses_p_parser(self):
|
||||||
# A/B/C/D/E/F all route to _parse_p_binary
|
# A/B/C/D/E/F all route to _parse_p_binary
|
||||||
data = _make_p_block(1)
|
data = _make_p_block(1)
|
||||||
result = parse_binary_data(data, "A")
|
result = parse_binary_data(data, "A")
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert len(result[0]) == MAXDUT_P
|
assert len(result[0]) == 244
|
||||||
|
|
||||||
def test_empty_bytes_returns_empty_list(self):
|
def test_empty_bytes_returns_empty_list(self):
|
||||||
result = parse_binary_data(b"", "P")
|
result = parse_binary_data(b"", "P")
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""Tests for the shared wafer family sensor-count lookup."""
|
||||||
|
from pygui.backend.wafer.family_spec import sensor_count_for
|
||||||
|
|
||||||
|
|
||||||
|
def test_x_family_returns_80():
|
||||||
|
assert sensor_count_for("X") == 80
|
||||||
|
|
||||||
|
|
||||||
|
def test_p_family_returns_244():
|
||||||
|
assert sensor_count_for("P") == 244
|
||||||
|
|
||||||
|
|
||||||
|
def test_other_known_families_return_244():
|
||||||
|
for code in ("A", "B", "C", "D", "E", "F"):
|
||||||
|
assert sensor_count_for(code) == 244
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_family_returns_244():
|
||||||
|
assert sensor_count_for("Z") == 244
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_string_returns_244():
|
||||||
|
assert sensor_count_for("") == 244
|
||||||
@@ -46,3 +46,90 @@ 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
|
||||||
|
assert args["frame_offset"] == 0
|
||||||
|
|
||||||
|
def test_comparison_result_reaches_qml(qapp, controller):
|
||||||
|
"""The result dict must arrive in QML as a real JS object with fields.
|
||||||
|
|
||||||
|
Signal(object) delivers Python dicts to QML as an opaque empty wrapper;
|
||||||
|
the signal must be declared with a dict/QVariantMap signature.
|
||||||
|
"""
|
||||||
|
from PySide6.QtQml import QQmlApplicationEngine
|
||||||
|
|
||||||
|
engine = QQmlApplicationEngine()
|
||||||
|
engine.rootContext().setContextProperty("streamController", controller)
|
||||||
|
engine.loadData(b"""
|
||||||
|
import QtQuick
|
||||||
|
Item {
|
||||||
|
id: probe
|
||||||
|
property bool gotSuccess: false
|
||||||
|
property real gotDistance: -1
|
||||||
|
Connections {
|
||||||
|
target: streamController
|
||||||
|
function onComparisonResult(result) {
|
||||||
|
probe.gotSuccess = result.success === true
|
||||||
|
probe.gotDistance = result.distance !== undefined ? result.distance : -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
assert engine.rootObjects(), "probe QML failed to load"
|
||||||
|
root = engine.rootObjects()[0]
|
||||||
|
|
||||||
|
csv_path = "tests/fixtures/sample_stream.csv"
|
||||||
|
controller.compareFiles(csv_path, csv_path)
|
||||||
|
qapp.processEvents()
|
||||||
|
|
||||||
|
assert root.property("gotSuccess") is True
|
||||||
|
assert root.property("gotDistance") >= 0
|
||||||
|
|
||||||
|
def test_recording_toggle(controller, tmp_path):
|
||||||
|
csv_path = str(tmp_path / "rec" / "live_test.csv")
|
||||||
|
controller.startRecording(csv_path, "SN123")
|
||||||
|
assert controller.recording == True
|
||||||
|
|
||||||
|
controller.stopRecording()
|
||||||
|
assert controller.recording == False
|
||||||
|
|
||||||
|
# Header written with wafer serial
|
||||||
|
content = open(csv_path).read()
|
||||||
|
assert "Wafer ID=SN123" in content
|
||||||
|
|
||||||
|
def test_resync_count_default(controller):
|
||||||
|
# No active reader -> zero
|
||||||
|
assert controller.resyncCount == 0
|
||||||
|
|
||||||
|
controller._reader = MagicMock(resync_count=7)
|
||||||
|
assert controller.resyncCount == 7
|
||||||
|
controller._reader = None
|
||||||
|
|
||||||
|
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