Compare commits
12 Commits
4ee139ad8f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e3bec9031 | |||
| fed4d9b590 | |||
| 4bb855a940 | |||
| 94f917b116 | |||
| 25fa7507ce | |||
| 034f13b717 | |||
| 69753e35f9 | |||
| 278a48a2e4 | |||
| e2ce8778e0 | |||
| bdc667b2ed | |||
| b6249c2b8e | |||
| 2fe8aef805 |
@@ -9,8 +9,8 @@ Popup {
|
|||||||
modal: true
|
modal: true
|
||||||
dim: true
|
dim: true
|
||||||
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||||
width: 480
|
width: 600
|
||||||
height: 440
|
height: 500
|
||||||
anchors.centerIn: Overlay.overlay
|
anchors.centerIn: Overlay.overlay
|
||||||
|
|
||||||
onOpened: licenseModel.refresh()
|
onOpened: licenseModel.refresh()
|
||||||
@@ -31,8 +31,8 @@ Popup {
|
|||||||
|
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
anchors.margins: 24
|
anchors.margins: 28
|
||||||
spacing: 16
|
spacing: 20
|
||||||
|
|
||||||
// ── Title ──
|
// ── Title ──
|
||||||
Label {
|
Label {
|
||||||
@@ -44,27 +44,20 @@ Popup {
|
|||||||
|
|
||||||
// ── App info ──
|
// ── App info ──
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
spacing: 4
|
spacing: 6
|
||||||
|
|
||||||
Label {
|
Label {
|
||||||
text: "ISenseCloud (ISC)"
|
text: "ISenseCloud"
|
||||||
font.pixelSize: Theme.fontLg
|
font.pixelSize: Theme.fontXl
|
||||||
|
font.bold: true
|
||||||
color: Theme.headingColor
|
color: Theme.headingColor
|
||||||
}
|
}
|
||||||
|
|
||||||
Label {
|
Label {
|
||||||
text: "Version " + appVersion
|
text: "Version " + appVersion
|
||||||
font.pixelSize: Theme.fontSm
|
font.pixelSize: Theme.fontMd
|
||||||
color: Theme.bodyColor
|
color: Theme.bodyColor
|
||||||
}
|
}
|
||||||
|
|
||||||
Label {
|
|
||||||
text: "Temperature-sensing wafer monitoring"
|
|
||||||
font.pixelSize: Theme.fontSm
|
|
||||||
color: Theme.bodyColor
|
|
||||||
wrapMode: Text.WordWrap
|
|
||||||
Layout.fillWidth: true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Separator ──
|
// ── Separator ──
|
||||||
@@ -78,7 +71,7 @@ Popup {
|
|||||||
Rectangle {
|
Rectangle {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
Layout.minimumHeight: 120
|
Layout.minimumHeight: 150
|
||||||
radius: Theme.radiusSm
|
radius: Theme.radiusSm
|
||||||
color: Theme.panelBackground
|
color: Theme.panelBackground
|
||||||
border.color: Theme.cardBorder
|
border.color: Theme.cardBorder
|
||||||
@@ -86,8 +79,8 @@ Popup {
|
|||||||
|
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
anchors.margins: 10
|
anchors.margins: 14
|
||||||
spacing: 2
|
spacing: 6
|
||||||
|
|
||||||
RowLayout {
|
RowLayout {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
@@ -95,7 +88,8 @@ Popup {
|
|||||||
Label {
|
Label {
|
||||||
text: "LICENSE"
|
text: "LICENSE"
|
||||||
color: Theme.panelTitleText
|
color: Theme.panelTitleText
|
||||||
font.pixelSize: Theme.fontXs
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.bold: true
|
||||||
font.letterSpacing: 0.5
|
font.letterSpacing: 0.5
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
}
|
}
|
||||||
@@ -105,18 +99,18 @@ Popup {
|
|||||||
visible: false
|
visible: false
|
||||||
text: "Invalid license file"
|
text: "Invalid license file"
|
||||||
color: Theme.statusWarningColor
|
color: Theme.statusWarningColor
|
||||||
font.pixelSize: Theme.fontXs
|
font.pixelSize: Theme.fontSm
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grid header
|
// Grid header
|
||||||
RowLayout {
|
RowLayout {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
spacing: 4
|
spacing: 8
|
||||||
Label { text: "Wafer SN"; font.pixelSize: Theme.fontXs; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 100 }
|
Label { text: "Wafer SN"; font.pixelSize: Theme.fontSm; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 150 }
|
||||||
Label { text: "Mfg Date"; font.pixelSize: Theme.fontXs; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 90 }
|
Label { text: "Mfg Date"; font.pixelSize: Theme.fontSm; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 120 }
|
||||||
Label { text: "Level"; font.pixelSize: Theme.fontXs; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 50 }
|
Label { text: "Level"; font.pixelSize: Theme.fontSm; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 80 }
|
||||||
Label { text: "License Date"; font.pixelSize: Theme.fontXs; font.bold: true; color: Theme.subheadingColor; Layout.fillWidth: true }
|
Label { text: "License Date"; font.pixelSize: Theme.fontSm; font.bold: true; color: Theme.subheadingColor; Layout.fillWidth: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
@@ -133,11 +127,11 @@ Popup {
|
|||||||
model: licenseModel.licenses
|
model: licenseModel.licenses
|
||||||
delegate: RowLayout {
|
delegate: RowLayout {
|
||||||
width: ListView.view.width
|
width: ListView.view.width
|
||||||
spacing: 4
|
spacing: 8
|
||||||
Label { text: modelData.serial; font.pixelSize: Theme.fontSm; color: Theme.bodyColor; Layout.preferredWidth: 100 }
|
Label { text: modelData.serial; font.pixelSize: Theme.fontMd; color: Theme.bodyColor; Layout.preferredWidth: 150 }
|
||||||
Label { text: modelData.mfgDate; font.pixelSize: Theme.fontSm; color: Theme.bodyColor; Layout.preferredWidth: 90 }
|
Label { text: modelData.mfgDate; font.pixelSize: Theme.fontMd; color: Theme.bodyColor; Layout.preferredWidth: 120 }
|
||||||
Label { text: modelData.level; font.pixelSize: Theme.fontSm; color: Theme.bodyColor; Layout.preferredWidth: 50 }
|
Label { text: modelData.level; font.pixelSize: Theme.fontMd; color: Theme.bodyColor; Layout.preferredWidth: 80 }
|
||||||
Label { text: modelData.licDate; font.pixelSize: Theme.fontSm; color: Theme.bodyColor; Layout.fillWidth: true }
|
Label { text: modelData.licDate; font.pixelSize: Theme.fontMd; color: Theme.bodyColor; Layout.fillWidth: true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,7 +139,7 @@ Popup {
|
|||||||
visible: licenseModel.licenses.length === 0
|
visible: licenseModel.licenses.length === 0
|
||||||
text: "No license loaded"
|
text: "No license loaded"
|
||||||
color: Theme.bodyColor
|
color: Theme.bodyColor
|
||||||
font.pixelSize: Theme.fontSm
|
font.pixelSize: Theme.fontMd
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
verticalAlignment: Text.AlignVCenter
|
verticalAlignment: Text.AlignVCenter
|
||||||
@@ -157,11 +151,12 @@ Popup {
|
|||||||
// ── Buttons ──
|
// ── Buttons ──
|
||||||
RowLayout {
|
RowLayout {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
|
spacing: 12
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
text: "Load License"
|
text: "Load License"
|
||||||
Layout.preferredWidth: 120
|
Layout.preferredWidth: 140
|
||||||
Layout.preferredHeight: 32
|
Layout.preferredHeight: 36
|
||||||
onClicked: licenseFileDialog.open()
|
onClicked: licenseFileDialog.open()
|
||||||
|
|
||||||
background: Rectangle {
|
background: Rectangle {
|
||||||
@@ -173,6 +168,8 @@ Popup {
|
|||||||
contentItem: Label {
|
contentItem: Label {
|
||||||
text: parent.text
|
text: parent.text
|
||||||
color: Theme.buttonNeutralText
|
color: Theme.buttonNeutralText
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.bold: true
|
||||||
horizontalAlignment: Text.AlignHCenter
|
horizontalAlignment: Text.AlignHCenter
|
||||||
verticalAlignment: Text.AlignVCenter
|
verticalAlignment: Text.AlignVCenter
|
||||||
}
|
}
|
||||||
@@ -183,7 +180,7 @@ Popup {
|
|||||||
Button {
|
Button {
|
||||||
text: "Close"
|
text: "Close"
|
||||||
Layout.preferredWidth: 100
|
Layout.preferredWidth: 100
|
||||||
Layout.preferredHeight: 32
|
Layout.preferredHeight: 36
|
||||||
onClicked: root.close()
|
onClicked: root.close()
|
||||||
|
|
||||||
background: Rectangle {
|
background: Rectangle {
|
||||||
@@ -195,6 +192,8 @@ Popup {
|
|||||||
contentItem: Label {
|
contentItem: Label {
|
||||||
text: parent.text
|
text: parent.text
|
||||||
color: Theme.buttonNeutralText
|
color: Theme.buttonNeutralText
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.bold: true
|
||||||
horizontalAlignment: Text.AlignHCenter
|
horizontalAlignment: Text.AlignHCenter
|
||||||
verticalAlignment: Text.AlignVCenter
|
verticalAlignment: Text.AlignVCenter
|
||||||
}
|
}
|
||||||
|
|||||||
+43
-13
@@ -23,9 +23,14 @@ Rectangle {
|
|||||||
if (selectedTabIndex === 2) {
|
if (selectedTabIndex === 2) {
|
||||||
file_browser.refreshFiles();
|
file_browser.refreshFiles();
|
||||||
streamController.unloadFile()
|
streamController.unloadFile()
|
||||||
|
} else if (streamController.mode === "live") {
|
||||||
|
// Leaving the map tab mid-stream: stop and blank the live map.
|
||||||
|
streamController.setMode("review")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
property bool memoryRead: false
|
property bool memoryRead: false
|
||||||
property bool waferDetected: false
|
property bool waferDetected: false
|
||||||
// TODO P2.2: add `property bool waferLicensed: false`; set it in
|
// TODO P2.2: add `property bool waferLicensed: false`; set it in
|
||||||
@@ -86,7 +91,9 @@ Rectangle {
|
|||||||
id: saveDirDialog
|
id: saveDirDialog
|
||||||
title: "Choose a folder to save Data"
|
title: "Choose a folder to save Data"
|
||||||
onAccepted: {
|
onAccepted: {
|
||||||
deviceController.setSaveDataDir(root.cleanFolderUrl(selectedFolder))
|
var dir = root.cleanFolderUrl(selectedFolder)
|
||||||
|
deviceController.setSaveDataDir(dir)
|
||||||
|
file_browser.setCurrentDirectory(dir)
|
||||||
root._doDetect()
|
root._doDetect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -213,9 +220,30 @@ Rectangle {
|
|||||||
|
|
||||||
ListModel {
|
ListModel {
|
||||||
id: pillModel
|
id: pillModel
|
||||||
ListElement { label: "STATUS"; icon: "Tabs/icons/status.svg"; expandW: 86 }
|
ListElement {
|
||||||
ListElement { label: "DATA"; icon: "Tabs/icons/data.svg"; expandW: 66 }
|
label: "STATUS"
|
||||||
ListElement { label: "MAP"; icon: "Tabs/icons/map.svg"; expandW: 58 }
|
icon: "Tabs/icons/status.svg"
|
||||||
|
expandW: 86
|
||||||
|
desc: "Monitor real-time device connection, serial communication, and execute wafer actions."
|
||||||
|
}
|
||||||
|
ListElement {
|
||||||
|
label: "DATA"
|
||||||
|
icon: "Tabs/icons/data.svg"
|
||||||
|
expandW: 66
|
||||||
|
desc: "Manage, review, compare (DTW), and split historical sensor data records."
|
||||||
|
}
|
||||||
|
ListElement {
|
||||||
|
label: "MAP"
|
||||||
|
icon: "Tabs/icons/map.svg"
|
||||||
|
expandW: 58
|
||||||
|
desc: "Visualize spatial sensor readings on a 2D thin-plate spline RBF interpolated heatmap."
|
||||||
|
}
|
||||||
|
ListElement {
|
||||||
|
label: "GRAPH"
|
||||||
|
icon: "Tabs/icons/bar-chart.svg"
|
||||||
|
expandW: 70
|
||||||
|
desc: "Whole-run multi-sensor temperature chart for the currently loaded file."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
RowLayout {
|
RowLayout {
|
||||||
@@ -227,15 +255,6 @@ Rectangle {
|
|||||||
model: pillModel
|
model: pillModel
|
||||||
delegate: Button {
|
delegate: Button {
|
||||||
id: pillBtn
|
id: pillBtn
|
||||||
// TODO P3.1: lock MAP pill (index 2) when no license:
|
|
||||||
// `enabled: index !== 2 || licenseModel.licenses.length > 0`
|
|
||||||
// and dim icon/label when disabled. licensesChanged
|
|
||||||
// notify already fires on Load License → unlocks live,
|
|
||||||
// no restart. Rule is "any valid license" (not
|
|
||||||
// per-wafer): Map also replays CSVs with no wafer
|
|
||||||
// attached. Also gate the Map Loader (see TODO at the
|
|
||||||
// StackLayout below) and the auto-switch at
|
|
||||||
// onParsedDataReady. See plan §3.1.
|
|
||||||
checked: root.selectedTabIndex === index
|
checked: root.selectedTabIndex === index
|
||||||
flat: true
|
flat: true
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
@@ -305,6 +324,11 @@ Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AppToolTip {
|
||||||
|
visible: pillBtn.hovered
|
||||||
|
text: "<b>" + model.label + "</b>: " + model.desc
|
||||||
|
}
|
||||||
|
|
||||||
onClicked: root.selectedTabIndex = index
|
onClicked: root.selectedTabIndex = index
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -598,6 +622,8 @@ Rectangle {
|
|||||||
UtilityFooter {
|
UtilityFooter {
|
||||||
id: utilFooter
|
id: utilFooter
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
|
settingsPopup: settingsPopup
|
||||||
|
aboutDialog: aboutDialog
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -628,6 +654,10 @@ Rectangle {
|
|||||||
// so the tab never instantiates while locked. See plan §3.1.
|
// so the tab never instantiates while locked. See plan §3.1.
|
||||||
active: StackLayout.index === root.selectedTabIndex
|
active: StackLayout.index === root.selectedTabIndex
|
||||||
}
|
}
|
||||||
|
Loader {
|
||||||
|
source: "Tabs/GraphTab.qml"
|
||||||
|
active: StackLayout.index === root.selectedTabIndex
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ Item {
|
|||||||
if (root.runAWaferType === "" || root.runBWaferType === "")
|
if (root.runAWaferType === "" || root.runBWaferType === "")
|
||||||
return "Recording has no wafer family — register it as a master file (Settings → Master Files)"
|
return "Recording has no wafer family — register it as a master file (Settings → Master Files)"
|
||||||
if (root.runAWaferType !== root.runBWaferType)
|
if (root.runAWaferType !== root.runBWaferType)
|
||||||
return "Wafer family mismatch: " + root.runAWaferType + " vs " + root.runBWaferType
|
return "Wafer Family Mismatch: " + root.runAWaferType + " vs " + root.runBWaferType
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
property real warpingDistance: -1
|
property real warpingDistance: -1
|
||||||
@@ -292,9 +292,9 @@ Item {
|
|||||||
ComparisonSidePanel {
|
ComparisonSidePanel {
|
||||||
id: sidePanel
|
id: sidePanel
|
||||||
Layout.fillWidth: false
|
Layout.fillWidth: false
|
||||||
Layout.preferredWidth: 280
|
Layout.preferredWidth: Theme.rightRailWidth
|
||||||
Layout.minimumWidth: 280
|
Layout.minimumWidth: Theme.rightRailWidth
|
||||||
Layout.maximumWidth: 280
|
Layout.maximumWidth: Theme.rightRailWidth
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
warpingDistance: root.warpingDistance
|
warpingDistance: root.warpingDistance
|
||||||
frameOffset: root.frameOffset
|
frameOffset: root.frameOffset
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ISC
|
||||||
|
import ISC.Tabs.components
|
||||||
|
|
||||||
|
// ===== Graph Tab =====
|
||||||
|
// Whole-run multi-sensor line chart (C# parity: tabGraph/chartData). Static
|
||||||
|
// snapshot recomputed whenever a file is loaded — not live-updating during
|
||||||
|
// a stream. RunChart adds wheel zoom, drag pan, and a visible-range
|
||||||
|
// min/max/avg strip. See specs/plans/2026-07-10-graph-tab.md and
|
||||||
|
// docs/adr/0003-pyqtgraph-for-multi-sensor-charts.md.
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
anchors.fill: parent
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
if (!streamController.loadedFile) {
|
||||||
|
chart.seriesData = []
|
||||||
|
chart.sensorNames = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
chart.sensorNames = streamController.graphSensorNames
|
||||||
|
? streamController.graphSensorNames.split(",") : []
|
||||||
|
chart.seriesData = JSON.parse(streamController.graphSeriesJson || "[]")
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: streamController
|
||||||
|
function onLoadedFileChanged() { root.refresh() }
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: root.refresh()
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Theme.panelPadding
|
||||||
|
color: Theme.cardBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusMd
|
||||||
|
|
||||||
|
RunChart {
|
||||||
|
id: chart
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 8
|
||||||
|
title: "Sensor Temperature Over Time"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -122,7 +122,9 @@ ColumnLayout {
|
|||||||
id: saveDirDialog
|
id: saveDirDialog
|
||||||
title: "Choose a folder to save."
|
title: "Choose a folder to save."
|
||||||
onAccepted: {
|
onAccepted: {
|
||||||
deviceController.setSaveDataDir(root.cleanFolderUrl(selectedFolder));
|
var dir = root.cleanFolderUrl(selectedFolder);
|
||||||
|
deviceController.setSaveDataDir(dir);
|
||||||
|
file_browser.setCurrentDirectory(dir);
|
||||||
root.parseAndSavePendingRead();
|
root.parseAndSavePendingRead();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,7 +134,11 @@ ColumnLayout {
|
|||||||
FolderDialog {
|
FolderDialog {
|
||||||
id: dirOnlyDialog
|
id: dirOnlyDialog
|
||||||
title: "Choose a folder to save Data"
|
title: "Choose a folder to save Data"
|
||||||
onAccepted: deviceController.setSaveDataDir(root.cleanFolderUrl(selectedFolder))
|
onAccepted: {
|
||||||
|
var dir = root.cleanFolderUrl(selectedFolder);
|
||||||
|
deviceController.setSaveDataDir(dir);
|
||||||
|
file_browser.setCurrentDirectory(dir);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Post-read metadata editor (C# parity: EditCSVMetadataDialog after read).
|
// Post-read metadata editor (C# parity: EditCSVMetadataDialog after read).
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import QtQuick.Controls
|
import QtQuick.Controls
|
||||||
import QtQuick.Controls.impl
|
import QtQuick.Controls.impl
|
||||||
import QtQuick.Dialogs
|
|
||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
import ISC
|
import ISC
|
||||||
import ISC.Tabs.components
|
import ISC.Tabs.components
|
||||||
@@ -11,39 +10,23 @@ Item {
|
|||||||
id: root
|
id: root
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
|
|
||||||
// Live elapsed-time counter
|
// Wire streamController.trendDelta (Signal(str) carrying one new
|
||||||
property int _liveSecs: 0
|
// [elapsed_s, value] pair per live frame) into the trend chart, which
|
||||||
Timer {
|
// accumulates its own bounded 60s window. trendReset clears it when a
|
||||||
id: liveTimer
|
// new stream starts. Parsing happens on the Python TrendChartItem side
|
||||||
interval: 1000
|
// so malformed payloads are logged there instead of crashing the QML
|
||||||
repeat: true
|
// binding.
|
||||||
running: streamController.mode === "live" && streamController.state !== "idle"
|
|
||||||
onTriggered: root._liveSecs++
|
|
||||||
onRunningChanged: if (!running)
|
|
||||||
root._liveSecs = 0
|
|
||||||
}
|
|
||||||
function fmtTime(s) {
|
|
||||||
var m = Math.floor(s / 60);
|
|
||||||
var ss = s % 60;
|
|
||||||
return (m < 10 ? "0" : "") + m + ":" + (ss < 10 ? "0" : "") + ss;
|
|
||||||
}
|
|
||||||
// Wire streamController.trendData (Signal(str) carrying JSON list of floats)
|
|
||||||
// into the trend chart's data property. The slot parseJsonToData() is defined
|
|
||||||
// on the Python TrendChartItem; we use it so malformed payloads are logged
|
|
||||||
// there instead of crashing the QML binding.
|
|
||||||
property string loadErrorMessage: ""
|
property string loadErrorMessage: ""
|
||||||
Connections {
|
Connections {
|
||||||
target: streamController
|
target: streamController
|
||||||
function onTrendData(avgsJson) {
|
function onTrendDelta(deltaJson) {
|
||||||
trendChart.setDataFromJson(avgsJson);
|
trendChart.appendDelta(deltaJson);
|
||||||
|
}
|
||||||
|
function onTrendReset() {
|
||||||
|
trendChart.clearTrend();
|
||||||
}
|
}
|
||||||
// A file was picked from the rail (any tab) — loadFile() switches the
|
|
||||||
// backend to review mode; mirror that in the toolbar toggle so a
|
|
||||||
// successful load is never hidden behind a stale "Live" selection.
|
|
||||||
function onLoadedFileChanged() {
|
function onLoadedFileChanged() {
|
||||||
root.loadErrorMessage = "";
|
root.loadErrorMessage = "";
|
||||||
if (streamController.mode === "review")
|
|
||||||
modeBar.currentIndex = 0;
|
|
||||||
}
|
}
|
||||||
function onLoadFileError(message) {
|
function onLoadFileError(message) {
|
||||||
root.loadErrorMessage = message;
|
root.loadErrorMessage = message;
|
||||||
@@ -60,107 +43,6 @@ Item {
|
|||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
spacing: 10
|
spacing: 10
|
||||||
|
|
||||||
// Mode toggle
|
|
||||||
TabBar {
|
|
||||||
id: modeBar
|
|
||||||
currentIndex: 0 // Default to "Review"
|
|
||||||
spacing: 2
|
|
||||||
padding: 2
|
|
||||||
background: Rectangle {
|
|
||||||
color: Theme.subtleSectionBackground
|
|
||||||
radius: Theme.radiusSm
|
|
||||||
border.color: Theme.cardBorder
|
|
||||||
border.width: Theme.borderThin
|
|
||||||
}
|
|
||||||
TabButton {
|
|
||||||
text: "Review"
|
|
||||||
implicitWidth: 84
|
|
||||||
implicitHeight: 34
|
|
||||||
background: Rectangle {
|
|
||||||
color: parent.checked ? Theme.tabActiveBackground : "transparent"
|
|
||||||
radius: Theme.radiusSm - 1
|
|
||||||
}
|
|
||||||
contentItem: Text {
|
|
||||||
text: parent.text
|
|
||||||
color: parent.checked ? Theme.headingColor : Theme.bodyColor
|
|
||||||
font.pixelSize: Theme.fontMd
|
|
||||||
font.weight: parent.checked ? Font.Medium : Font.Normal
|
|
||||||
horizontalAlignment: Text.AlignHCenter
|
|
||||||
verticalAlignment: Text.AlignVCenter
|
|
||||||
}
|
|
||||||
}
|
|
||||||
TabButton {
|
|
||||||
id: liveTab
|
|
||||||
text: "Live"
|
|
||||||
enabled: deviceController.connectionStatus === "Connected"
|
|
||||||
opacity: enabled ? 1.0 : 0.4
|
|
||||||
implicitWidth: 84
|
|
||||||
implicitHeight: 34
|
|
||||||
// Disabled controls swallow no events at all, so hover for the
|
|
||||||
// "why is this greyed out" tooltip needs its own MouseArea.
|
|
||||||
AppToolTip {
|
|
||||||
visible: disabledHover.containsMouse && !liveTab.enabled
|
|
||||||
text: "Connect a wafer to enable Live mode"
|
|
||||||
delay: 300
|
|
||||||
}
|
|
||||||
MouseArea {
|
|
||||||
id: disabledHover
|
|
||||||
anchors.fill: parent
|
|
||||||
enabled: !liveTab.enabled
|
|
||||||
hoverEnabled: true
|
|
||||||
acceptedButtons: Qt.NoButton
|
|
||||||
}
|
|
||||||
background: Rectangle {
|
|
||||||
color: parent.checked ? Theme.tabActiveBackground : "transparent"
|
|
||||||
radius: Theme.radiusSm - 1
|
|
||||||
}
|
|
||||||
contentItem: Row {
|
|
||||||
spacing: 5
|
|
||||||
leftPadding: 8
|
|
||||||
Rectangle {
|
|
||||||
id: liveTabDot
|
|
||||||
width: 7; height: 7; radius: 3.5
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
color: liveTab.enabled ? Theme.metricGood : Theme.sideMutedText
|
|
||||||
|
|
||||||
// Flash while actually streaming live
|
|
||||||
SequentialAnimation on opacity {
|
|
||||||
running: streamController.mode === "live" && streamController.state !== "idle"
|
|
||||||
loops: Animation.Infinite
|
|
||||||
NumberAnimation { to: 0.2; duration: 600; easing.type: Easing.InOutQuad }
|
|
||||||
NumberAnimation { to: 1.0; duration: 600; easing.type: Easing.InOutQuad }
|
|
||||||
onRunningChanged: if (!running) liveTabDot.opacity = 1.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text {
|
|
||||||
text: liveTab.text
|
|
||||||
color: liveTab.checked ? Theme.headingColor : Theme.bodyColor
|
|
||||||
font.pixelSize: Theme.fontMd
|
|
||||||
font.weight: liveTab.checked ? Font.Medium : Font.Normal
|
|
||||||
verticalAlignment: Text.AlignVCenter
|
|
||||||
height: parent.height
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onCurrentIndexChanged: if (currentIndex === 1) {
|
|
||||||
// Guard: revert to Review if not connected
|
|
||||||
if (deviceController.connectionStatus !== "Connected") {
|
|
||||||
currentIndex = 0;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Entering Live mode
|
|
||||||
streamController.setMode("live");
|
|
||||||
var fc = "";
|
|
||||||
if (deviceController.lastWaferInfo && deviceController.lastWaferInfo.length > 0) {
|
|
||||||
fc = deviceController.lastWaferInfo[0];
|
|
||||||
}
|
|
||||||
streamController.startStream(deviceController.selectedPort, fc);
|
|
||||||
} else {
|
|
||||||
// Entering Review Mode (automatically calls stopStream in backend)
|
|
||||||
streamController.setMode("review");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
}
|
}
|
||||||
@@ -215,49 +97,6 @@ Item {
|
|||||||
font.letterSpacing: 1.2
|
font.letterSpacing: 1.2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record start/stop toggle (Live mode)
|
|
||||||
Button {
|
|
||||||
visible: streamController.mode === "live"
|
|
||||||
enabled: streamController.state !== "idle" || streamController.recording
|
|
||||||
text: streamController.recording ? "Stop REC" : "Record"
|
|
||||||
onClicked: {
|
|
||||||
if (streamController.recording) {
|
|
||||||
streamController.stopRecording();
|
|
||||||
} else {
|
|
||||||
var info = deviceController.lastWaferInfo;
|
|
||||||
var serial = (info && info.length > 1) ? info[1] : "";
|
|
||||||
var ts = Qt.formatDateTime(new Date(), "yyyyMMdd_HHmmss");
|
|
||||||
var name = "live_" + (serial ? serial + "_" : "") + ts + ".csv";
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
text: "Export PNG"
|
|
||||||
onClicked: exportDialog.open()
|
|
||||||
}
|
|
||||||
|
|
||||||
FileDialog {
|
|
||||||
id: exportDialog
|
|
||||||
title: "Export Wafer Map"
|
|
||||||
fileMode: FileDialog.SaveFile
|
|
||||||
nameFilters: ["PNG files (*.png)"]
|
|
||||||
onAccepted: waferView.exportImage(String(selectedFile).replace(/^file:\/\//, ""))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Load error banner ───────────────────────────────────────────
|
// ── Load error banner ───────────────────────────────────────────
|
||||||
@@ -305,55 +144,78 @@ Item {
|
|||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
spacing: 8
|
spacing: 16
|
||||||
|
|
||||||
WaferMapView {
|
WaferMapView {
|
||||||
id: waferView
|
id: waferView
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
|
Layout.preferredHeight: 400
|
||||||
|
Layout.minimumHeight: 280
|
||||||
blend: readoutPanel.heatmapBlend
|
blend: readoutPanel.heatmapBlend
|
||||||
showLabels: readoutPanel.showLabels
|
showLabels: readoutPanel.showLabels
|
||||||
|
showExtremes: readoutPanel.showExtremes
|
||||||
showThickness: readoutPanel.showThickness
|
showThickness: readoutPanel.showThickness
|
||||||
}
|
}
|
||||||
Rectangle {
|
Rectangle {
|
||||||
id: trendPane
|
id: trendPane
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
implicitHeight: 160
|
Layout.fillHeight: false
|
||||||
|
Layout.preferredHeight: 220
|
||||||
|
Layout.minimumHeight: 120
|
||||||
visible: trendChart.hasData && streamController.mode === "live"
|
visible: trendChart.hasData && streamController.mode === "live"
|
||||||
color: Theme.cardBackground
|
color: Theme.cardBackground
|
||||||
border.color: Theme.cardBorder
|
border.color: Theme.cardBorder
|
||||||
border.width: 1
|
border.width: 1
|
||||||
radius: Theme.radiusMd
|
radius: Theme.radiusMd
|
||||||
|
|
||||||
TrendChartItem {
|
ColumnLayout {
|
||||||
id: trendChart
|
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
anchors.margins: 8
|
anchors.margins: 8
|
||||||
|
spacing: 10
|
||||||
|
|
||||||
|
TrendChartItem {
|
||||||
|
id: trendChart
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
implicitHeight: 16
|
visible: transportBar.hasContent
|
||||||
|
implicitHeight: trendPane.visible ? 52 : 16
|
||||||
}
|
}
|
||||||
TransportBar {
|
TransportBar {
|
||||||
|
id: transportBar
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Readout panel — floating wrapper box
|
// Readout panel — scrolls instead of clipping so the last card
|
||||||
Rectangle {
|
// (Thresholds) is always reachable even if content outgrows
|
||||||
Layout.preferredWidth: 220
|
// the available height (e.g. once E-C delta rows appear).
|
||||||
|
ScrollView {
|
||||||
|
Layout.preferredWidth: Theme.rightRailWidth
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
color: "transparent"
|
|
||||||
border.color: "transparent"
|
|
||||||
border.width: 0
|
|
||||||
clip: true
|
clip: true
|
||||||
|
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
|
||||||
|
|
||||||
ReadoutPanel {
|
ReadoutPanel {
|
||||||
id: readoutPanel
|
id: readoutPanel
|
||||||
anchors.fill: parent
|
width: parent.width
|
||||||
anchors.margins: 0
|
hasThicknessData: waferView.hasThickness
|
||||||
|
onExportRequested: function(filePath, extra) {
|
||||||
|
waferView.exportImage(filePath, extra);
|
||||||
|
}
|
||||||
|
onThicknessFileChosen: function(filePath) {
|
||||||
|
var err = waferView.loadThickness(filePath);
|
||||||
|
if (err !== "") {
|
||||||
|
root.loadErrorMessage = err;
|
||||||
|
readoutPanel.showThickness = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ISC
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
property string label
|
||||||
|
property string value
|
||||||
|
property color valueColor: Theme.headingColor
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 4
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: label
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.bold: true
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
text: value
|
||||||
|
color: valueColor
|
||||||
|
font.family: Theme.codeFontFamily
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,9 @@ Rectangle {
|
|||||||
property string label: ""
|
property string label: ""
|
||||||
property string iconSource: ""
|
property string iconSource: ""
|
||||||
property bool destructive: false
|
property bool destructive: false
|
||||||
|
// Set true to show the button as "active" (e.g. a one-shot toggle
|
||||||
|
// that's mid-operation), independent of hover/press state.
|
||||||
|
property bool toggled: false
|
||||||
property color _textColor: !root.enabled ? Theme.sideMutedText
|
property color _textColor: !root.enabled ? Theme.sideMutedText
|
||||||
: root.destructive ? Theme.statusErrorColor
|
: root.destructive ? Theme.statusErrorColor
|
||||||
: Theme.headingColor
|
: Theme.headingColor
|
||||||
@@ -20,7 +23,7 @@ Rectangle {
|
|||||||
|
|
||||||
implicitHeight: Theme.sideButtonHeight
|
implicitHeight: Theme.sideButtonHeight
|
||||||
radius: 8
|
radius: 8
|
||||||
color: mouseArea.containsMouse || mouseArea.pressed
|
color: root.toggled || mouseArea.containsMouse || mouseArea.pressed
|
||||||
? Theme.sideActiveBackground : "transparent"
|
? Theme.sideActiveBackground : "transparent"
|
||||||
border.width: 0
|
border.width: 0
|
||||||
|
|
||||||
|
|||||||
@@ -1,61 +1,26 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import QtQuick.Controls
|
import QtQuick.Controls
|
||||||
import QtQuick.Controls.impl
|
import QtQuick.Dialogs
|
||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
import ISC
|
import ISC
|
||||||
|
|
||||||
// ===== Readout Panel =====
|
// ===== Readout Panel =====
|
||||||
// Right-rail panel with three bordered sections: READOUT, DISPLAY, THRESHOLDS.
|
// Right-rail panel with three bento-tile sections: READOUT, DISPLAY,
|
||||||
// Cards match the left-rail SOURCE / CONNECTION FLOW style.
|
// THRESHOLDS. Uses the same PanelBox / SectionTitle / ReadoutStat /
|
||||||
|
// PanelCheckBox / PanelSlider components as the Data tab's
|
||||||
|
// ComparisonSidePanel, so both right rails share one visual language.
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
|
id: root
|
||||||
spacing: 6
|
spacing: 6
|
||||||
property var s: streamController.stats
|
property var s: streamController.stats
|
||||||
property alias showLabels: labelsToggle.checked
|
property alias showLabels: labelsToggle.checked
|
||||||
|
property alias showExtremes: extremesToggle.checked
|
||||||
property alias heatmapBlend: heatmapSlider.value
|
property alias heatmapBlend: heatmapSlider.value
|
||||||
property alias showThickness: thicknessToggle.checked
|
property alias showThickness: thicknessToggle.checked
|
||||||
|
property bool hasThicknessData: false
|
||||||
|
|
||||||
component PanelCheckBox: CheckBox {
|
signal exportRequested(string filePath, string extra)
|
||||||
id: toggle
|
signal thicknessFileChosen(string filePath)
|
||||||
indicator: Rectangle {
|
|
||||||
implicitWidth: 18
|
|
||||||
implicitHeight: 18
|
|
||||||
x: toggle.leftPadding
|
|
||||||
y: parent.height / 2 - height / 2
|
|
||||||
radius: Theme.radiusXs
|
|
||||||
color: toggle.checked ? Theme.primaryAccent : "transparent"
|
|
||||||
border.width: Theme.borderThin
|
|
||||||
border.color: toggle.checked ? Theme.primaryAccent : Theme.fieldBorder
|
|
||||||
|
|
||||||
Behavior on color {
|
|
||||||
ColorAnimation { duration: Theme.durationFast }
|
|
||||||
}
|
|
||||||
Behavior on border.color {
|
|
||||||
ColorAnimation { duration: Theme.durationFast }
|
|
||||||
}
|
|
||||||
|
|
||||||
IconImage {
|
|
||||||
anchors.centerIn: parent
|
|
||||||
source: "../icons/check.svg"
|
|
||||||
width: 12; height: 12
|
|
||||||
sourceSize.width: 12
|
|
||||||
sourceSize.height: 12
|
|
||||||
color: Theme.panelBackground
|
|
||||||
opacity: toggle.checked ? 1.0 : 0.0
|
|
||||||
Behavior on opacity {
|
|
||||||
NumberAnimation { duration: Theme.durationFast }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
contentItem: Text {
|
|
||||||
text: toggle.text
|
|
||||||
color: Theme.headingColor
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
verticalAlignment: Text.AlignVCenter
|
|
||||||
leftPadding: toggle.indicator.width + toggle.spacing
|
|
||||||
font.pixelSize: toggle.font.pixelSize || Theme.fontXs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
component BadgePill: Rectangle {
|
component BadgePill: Rectangle {
|
||||||
implicitWidth: badgeLabel.implicitWidth + 10
|
implicitWidth: badgeLabel.implicitWidth + 10
|
||||||
@@ -75,374 +40,106 @@ ColumnLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Live-mode stream stats + stop — moved here from the footer transport bar
|
StreamControlPanel {
|
||||||
// so that bar collapses to zero height in live mode, freeing vertical
|
|
||||||
// space for the trend chart.
|
|
||||||
Rectangle {
|
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
visible: streamController.mode === "live"
|
onExportRequested: function(filePath, extra) {
|
||||||
implicitHeight: visible ? liveStatsCard.implicitHeight + 24 : 0
|
root.exportRequested(filePath, extra);
|
||||||
color: Theme.sidePanelBackground
|
}
|
||||||
radius: Theme.sidePanelRadius
|
}
|
||||||
border.color: Theme.sideBorder
|
|
||||||
border.width: 1
|
// ── READOUT ─────────────────────────────────────────────────────
|
||||||
|
PanelBox {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: readoutCol.implicitHeight + 24
|
||||||
|
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
id: liveStatsCard
|
id: readoutCol
|
||||||
anchors {
|
anchors.fill: parent
|
||||||
left: parent.left
|
anchors.margins: 12
|
||||||
right: parent.right
|
|
||||||
top: parent.top
|
|
||||||
margins: 12
|
|
||||||
}
|
|
||||||
spacing: 8
|
spacing: 8
|
||||||
|
|
||||||
Label {
|
SectionTitle { text: "READOUT" }
|
||||||
text: "LIVE STREAM"
|
|
||||||
color: Theme.sideMutedText
|
ReadoutStat {
|
||||||
font.family: Theme.uiFontFamily
|
label: "Min Temp"
|
||||||
font.pixelSize: Theme.fontSm
|
value: s.min !== undefined
|
||||||
font.letterSpacing: 1.5
|
? s.min + ((s.minIndex !== undefined && s.minIndex >= 0) ? " #" + s.minIndex : "")
|
||||||
font.bold: true
|
: "—"
|
||||||
Layout.bottomMargin: 4
|
valueColor: Theme.sensorLow
|
||||||
}
|
}
|
||||||
|
ReadoutStat {
|
||||||
RowLayout {
|
label: "Max Temp"
|
||||||
Layout.fillWidth: true
|
value: s.max !== undefined
|
||||||
Label {
|
? s.max + ((s.maxIndex !== undefined && s.maxIndex >= 0) ? " #" + s.maxIndex : "")
|
||||||
text: "Received"
|
: "—"
|
||||||
color: Theme.sideMutedText
|
valueColor: Theme.sensorHigh
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.pixelSize: Theme.fontSm
|
|
||||||
font.bold: true
|
|
||||||
}
|
|
||||||
Item { Layout.fillWidth: true }
|
|
||||||
Label {
|
|
||||||
text: streamController.receivedCount
|
|
||||||
color: Theme.headingColor
|
|
||||||
font.family: Theme.codeFontFamily
|
|
||||||
font.pixelSize: Theme.fontLg
|
|
||||||
font.bold: true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
ReadoutStat {
|
||||||
Rectangle {
|
label: "Diff"
|
||||||
Layout.fillWidth: true
|
value: s.diff !== undefined ? s.diff : "—"
|
||||||
height: 1
|
valueColor: Theme.diffAccent
|
||||||
color: Theme.cardBorder
|
|
||||||
}
|
}
|
||||||
|
ReadoutStat {
|
||||||
RowLayout {
|
label: "Average"
|
||||||
Layout.fillWidth: true
|
value: s.avg !== undefined ? s.avg : "—"
|
||||||
Label {
|
|
||||||
text: "Errors"
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.pixelSize: Theme.fontSm
|
|
||||||
font.bold: true
|
|
||||||
}
|
|
||||||
Item { Layout.fillWidth: true }
|
|
||||||
Label {
|
|
||||||
text: streamController.errorCount
|
|
||||||
color: streamController.errorCount > 0 ? Theme.statusWarningColor : Theme.headingColor
|
|
||||||
font.family: Theme.codeFontFamily
|
|
||||||
font.pixelSize: Theme.fontLg
|
|
||||||
font.bold: true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
ReadoutStat {
|
||||||
Button {
|
label: "Sigma (Σ)"
|
||||||
id: stopStreamBtn
|
value: s.sigma !== undefined ? s.sigma : "—"
|
||||||
Layout.fillWidth: true
|
}
|
||||||
Layout.topMargin: 4
|
ReadoutStat {
|
||||||
hoverEnabled: true
|
label: "3Σ Value"
|
||||||
text: "STOP"
|
value: s.threeSigma !== undefined ? s.threeSigma : "—"
|
||||||
font.family: Theme.uiFontFamily
|
}
|
||||||
font.pixelSize: Theme.fontSm
|
ReadoutStat {
|
||||||
font.weight: Font.Medium
|
visible: s.ecMinDelta !== undefined
|
||||||
font.letterSpacing: 1.0
|
label: "E-C Δ Min"
|
||||||
|
value: s.ecMinDelta !== undefined
|
||||||
background: Rectangle {
|
? s.ecMinDelta.toFixed(2) + " (#" + s.ecMinEdgeIndex + "→#" + s.ecMinCenterIndex + ")"
|
||||||
radius: 8
|
: "—"
|
||||||
color: stopStreamBtn.down ? Theme.transportButtonHover
|
}
|
||||||
: (stopStreamBtn.hovered ? Theme.transportButtonBg : "transparent")
|
ReadoutStat {
|
||||||
border.width: Theme.borderThin
|
visible: s.ecMaxDelta !== undefined
|
||||||
border.color: Theme.sideBorder
|
label: "E-C Δ Max"
|
||||||
Behavior on color {
|
value: s.ecMaxDelta !== undefined
|
||||||
ColorAnimation { duration: Theme.durationFast }
|
? s.ecMaxDelta.toFixed(2) + " (#" + s.ecMaxEdgeIndex + "→#" + s.ecMaxCenterIndex + ")"
|
||||||
}
|
: "—"
|
||||||
}
|
|
||||||
contentItem: Text {
|
|
||||||
text: stopStreamBtn.text
|
|
||||||
color: Theme.headingColor
|
|
||||||
font: stopStreamBtn.font
|
|
||||||
horizontalAlignment: Text.AlignHCenter
|
|
||||||
verticalAlignment: Text.AlignVCenter
|
|
||||||
}
|
|
||||||
onClicked: streamController.stopStream()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle {
|
// ── DISPLAY ─────────────────────────────────────────────────────
|
||||||
|
PanelBox {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
implicitHeight: readoutCard.implicitHeight + 24
|
Layout.preferredHeight: displayCard.implicitHeight + 24
|
||||||
color: Theme.sidePanelBackground
|
|
||||||
radius: Theme.sidePanelRadius
|
|
||||||
border.color: Theme.sideBorder
|
|
||||||
border.width: 1
|
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
id: readoutCard
|
|
||||||
anchors {
|
|
||||||
left: parent.left
|
|
||||||
right: parent.right
|
|
||||||
top: parent.top
|
|
||||||
topMargin: 12
|
|
||||||
}
|
|
||||||
spacing: 0
|
|
||||||
|
|
||||||
// Header Inside the Box
|
|
||||||
Label {
|
|
||||||
text: "READOUT"
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.pixelSize: Theme.fontSm
|
|
||||||
font.letterSpacing: 1.5
|
|
||||||
font.bold: true
|
|
||||||
Layout.leftMargin: 14
|
|
||||||
Layout.rightMargin: 14
|
|
||||||
Layout.topMargin: 2
|
|
||||||
Layout.bottomMargin: 10
|
|
||||||
}
|
|
||||||
|
|
||||||
// Min Temp Row
|
|
||||||
RowLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.leftMargin: 14
|
|
||||||
Layout.rightMargin: 14
|
|
||||||
Layout.preferredHeight: 32
|
|
||||||
Label {
|
|
||||||
text: "MIN TEMP"
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.pixelSize: Theme.fontSm
|
|
||||||
font.bold: true
|
|
||||||
}
|
|
||||||
Item { Layout.fillWidth: true }
|
|
||||||
RowLayout {
|
|
||||||
spacing: 4
|
|
||||||
Label {
|
|
||||||
text: s.min !== undefined ? s.min : "—"
|
|
||||||
color: Theme.sensorLow
|
|
||||||
font.family: Theme.codeFontFamily
|
|
||||||
font.pixelSize: Theme.fontLg
|
|
||||||
font.bold: true
|
|
||||||
}
|
|
||||||
Label {
|
|
||||||
visible: (s.minIndex !== undefined) && s.minIndex >= 0
|
|
||||||
text: "#" + (s.minIndex !== undefined ? s.minIndex : "")
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.pixelSize: Theme.font2xs
|
|
||||||
Layout.alignment: Qt.AlignBottom
|
|
||||||
bottomPadding: 3
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
height: 1
|
|
||||||
color: Theme.cardBorder
|
|
||||||
}
|
|
||||||
|
|
||||||
// Max Temp Row
|
|
||||||
RowLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.leftMargin: 14
|
|
||||||
Layout.rightMargin: 14
|
|
||||||
Layout.preferredHeight: 32
|
|
||||||
Label {
|
|
||||||
text: "MAX TEMP"
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.pixelSize: Theme.fontSm
|
|
||||||
font.bold: true
|
|
||||||
}
|
|
||||||
Item { Layout.fillWidth: true }
|
|
||||||
RowLayout {
|
|
||||||
spacing: 4
|
|
||||||
Label {
|
|
||||||
text: s.max !== undefined ? s.max : "—"
|
|
||||||
color: Theme.sensorHigh
|
|
||||||
font.family: Theme.codeFontFamily
|
|
||||||
font.pixelSize: Theme.fontLg
|
|
||||||
font.bold: true
|
|
||||||
}
|
|
||||||
Label {
|
|
||||||
visible: (s.maxIndex !== undefined) && s.maxIndex >= 0
|
|
||||||
text: "#" + (s.maxIndex !== undefined ? s.maxIndex : "")
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.pixelSize: Theme.font2xs
|
|
||||||
Layout.alignment: Qt.AlignBottom
|
|
||||||
bottomPadding: 3
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
height: 1
|
|
||||||
color: Theme.cardBorder
|
|
||||||
}
|
|
||||||
|
|
||||||
// Differential Row
|
|
||||||
RowLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.leftMargin: 14
|
|
||||||
Layout.rightMargin: 14
|
|
||||||
Layout.preferredHeight: 32
|
|
||||||
Label {
|
|
||||||
text: "DIFF"
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.pixelSize: Theme.fontSm
|
|
||||||
font.bold: true
|
|
||||||
}
|
|
||||||
Item { Layout.fillWidth: true }
|
|
||||||
Label {
|
|
||||||
text: s.diff !== undefined ? s.diff : "—"
|
|
||||||
color: Theme.diffAccent
|
|
||||||
font.family: Theme.codeFontFamily
|
|
||||||
font.pixelSize: Theme.fontLg
|
|
||||||
font.bold: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
height: 1
|
|
||||||
color: Theme.cardBorder
|
|
||||||
}
|
|
||||||
|
|
||||||
// Average Row
|
|
||||||
RowLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.leftMargin: 14
|
|
||||||
Layout.rightMargin: 14
|
|
||||||
Layout.preferredHeight: 32
|
|
||||||
Label {
|
|
||||||
text: "AVERAGE"
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.pixelSize: Theme.fontSm
|
|
||||||
font.bold: true
|
|
||||||
}
|
|
||||||
Item { Layout.fillWidth: true }
|
|
||||||
Label {
|
|
||||||
text: s.avg !== undefined ? s.avg : "—"
|
|
||||||
color: Theme.headingColor
|
|
||||||
font.family: Theme.codeFontFamily
|
|
||||||
font.pixelSize: Theme.fontLg
|
|
||||||
font.bold: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
height: 1
|
|
||||||
color: Theme.cardBorder
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sigma Row
|
|
||||||
RowLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.leftMargin: 14
|
|
||||||
Layout.rightMargin: 14
|
|
||||||
Layout.preferredHeight: 32
|
|
||||||
Label {
|
|
||||||
text: "SIGMA (Σ)"
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.pixelSize: Theme.fontSm
|
|
||||||
font.bold: true
|
|
||||||
}
|
|
||||||
Item { Layout.fillWidth: true }
|
|
||||||
Label {
|
|
||||||
text: s.sigma !== undefined ? s.sigma : "—"
|
|
||||||
color: Theme.headingColor
|
|
||||||
font.family: Theme.codeFontFamily
|
|
||||||
font.pixelSize: Theme.fontLg
|
|
||||||
font.bold: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
height: 1
|
|
||||||
color: Theme.cardBorder
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3-Sigma Row
|
|
||||||
RowLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.leftMargin: 14
|
|
||||||
Layout.rightMargin: 14
|
|
||||||
Layout.preferredHeight: 32
|
|
||||||
Label {
|
|
||||||
text: "3Σ VALUE"
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.pixelSize: Theme.fontSm
|
|
||||||
font.bold: true
|
|
||||||
}
|
|
||||||
Item { Layout.fillWidth: true }
|
|
||||||
Label {
|
|
||||||
text: s.threeSigma !== undefined ? s.threeSigma : "—"
|
|
||||||
color: Theme.headingColor
|
|
||||||
font.family: Theme.codeFontFamily
|
|
||||||
font.pixelSize: Theme.fontLg
|
|
||||||
font.bold: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
implicitHeight: displayCard.implicitHeight + 24
|
|
||||||
color: Theme.sidePanelBackground
|
|
||||||
radius: Theme.sidePanelRadius
|
|
||||||
border.color: Theme.sideBorder
|
|
||||||
border.width: 1
|
|
||||||
|
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
id: displayCard
|
id: displayCard
|
||||||
anchors {
|
anchors.fill: parent
|
||||||
left: parent.left
|
anchors.margins: 12
|
||||||
right: parent.right
|
|
||||||
top: parent.top
|
|
||||||
margins: 12
|
|
||||||
}
|
|
||||||
spacing: 8
|
spacing: 8
|
||||||
|
|
||||||
Label {
|
SectionTitle { text: "DISPLAY" }
|
||||||
text: "DISPLAY"
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.pixelSize: Theme.fontSm
|
|
||||||
font.letterSpacing: 1.5
|
|
||||||
font.bold: true
|
|
||||||
Layout.bottomMargin: 4
|
|
||||||
}
|
|
||||||
|
|
||||||
PanelCheckBox {
|
PanelCheckBox {
|
||||||
id: thicknessToggle
|
id: thicknessToggle
|
||||||
text: "Show Thickness"
|
text: "Show Thickness"
|
||||||
checked: false
|
checked: false
|
||||||
font.pixelSize: Theme.fontSm
|
font.pixelSize: Theme.fontSm
|
||||||
|
// First check with no data prompts for the customer CSV;
|
||||||
|
// unchecking only hides the overlay, data stays loaded.
|
||||||
|
onToggled: {
|
||||||
|
if (checked && !root.hasThicknessData)
|
||||||
|
thicknessDialog.open();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FileDialog {
|
||||||
|
id: thicknessDialog
|
||||||
|
title: "Select Thickness Data File"
|
||||||
|
nameFilters: ["CSV files (*.csv)"]
|
||||||
|
onAccepted: root.thicknessFileChosen(String(selectedFile).replace(/^file:\/\//, ""))
|
||||||
|
onRejected: thicknessToggle.checked = false
|
||||||
}
|
}
|
||||||
|
|
||||||
PanelCheckBox {
|
PanelCheckBox {
|
||||||
@@ -452,6 +149,13 @@ ColumnLayout {
|
|||||||
font.pixelSize: Theme.fontSm
|
font.pixelSize: Theme.fontSm
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PanelCheckBox {
|
||||||
|
id: extremesToggle
|
||||||
|
text: "Highlight Min/Max"
|
||||||
|
checked: true
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
}
|
||||||
|
|
||||||
PanelCheckBox {
|
PanelCheckBox {
|
||||||
id: clusterAverageToggle
|
id: clusterAverageToggle
|
||||||
text: "Average Clusters"
|
text: "Average Clusters"
|
||||||
@@ -475,7 +179,7 @@ ColumnLayout {
|
|||||||
font.pixelSize: Theme.fontSm
|
font.pixelSize: Theme.fontSm
|
||||||
Layout.preferredWidth: 52
|
Layout.preferredWidth: 52
|
||||||
}
|
}
|
||||||
Slider {
|
PanelSlider {
|
||||||
id: heatmapSlider
|
id: heatmapSlider
|
||||||
from: 0
|
from: 0
|
||||||
to: 1
|
to: 1
|
||||||
@@ -486,19 +190,6 @@ ColumnLayout {
|
|||||||
visible: heatmapSlider.hovered
|
visible: heatmapSlider.hovered
|
||||||
text: Math.round(heatmapSlider.value * 100) + "%"
|
text: Math.round(heatmapSlider.value * 100) + "%"
|
||||||
}
|
}
|
||||||
|
|
||||||
handle: Rectangle {
|
|
||||||
x: heatmapSlider.leftPadding + heatmapSlider.visualPosition * (heatmapSlider.availableWidth - width)
|
|
||||||
y: heatmapSlider.topPadding + heatmapSlider.availableHeight / 2 - height / 2
|
|
||||||
implicitWidth: 18
|
|
||||||
implicitHeight: 18
|
|
||||||
radius: 9
|
|
||||||
color: Theme.primaryAccent
|
|
||||||
scale: heatmapSlider.pressed ? 1.15 : 1.0
|
|
||||||
Behavior on scale {
|
|
||||||
NumberAnimation { duration: Theme.durationFast; easing.type: Theme.easeStandard }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Label {
|
Label {
|
||||||
text: Math.round(heatmapSlider.value * 100) + "%"
|
text: Math.round(heatmapSlider.value * 100) + "%"
|
||||||
@@ -512,33 +203,18 @@ ColumnLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle {
|
// ── THRESHOLDS ──────────────────────────────────────────────────
|
||||||
|
PanelBox {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
implicitHeight: thresholdCard.implicitHeight + 24
|
Layout.preferredHeight: thresholdCard.implicitHeight + 24
|
||||||
color: Theme.sidePanelBackground
|
|
||||||
radius: Theme.sidePanelRadius
|
|
||||||
border.color: Theme.sideBorder
|
|
||||||
border.width: 1
|
|
||||||
|
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
id: thresholdCard
|
id: thresholdCard
|
||||||
anchors {
|
anchors.fill: parent
|
||||||
left: parent.left
|
anchors.margins: 12
|
||||||
right: parent.right
|
|
||||||
top: parent.top
|
|
||||||
margins: 12
|
|
||||||
}
|
|
||||||
spacing: 6
|
spacing: 6
|
||||||
|
|
||||||
Label {
|
SectionTitle { text: "THRESHOLDS" }
|
||||||
text: "THRESHOLDS"
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.pixelSize: Theme.fontSm
|
|
||||||
font.letterSpacing: 1.5
|
|
||||||
font.bold: true
|
|
||||||
Layout.bottomMargin: 4
|
|
||||||
}
|
|
||||||
|
|
||||||
Label {
|
Label {
|
||||||
text: "Set Point (°C)"
|
text: "Set Point (°C)"
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ISC
|
||||||
|
import ISC.Wafer
|
||||||
|
|
||||||
|
// ===== Run Chart =====
|
||||||
|
// Zoomable whole-run multi-sensor chart (C# parity: PopupChartForm.cs).
|
||||||
|
// Lives in the Graph tab. Mouse wheel zooms around the cursor, drag pans;
|
||||||
|
// the strip below shows the aggregate min/max/avg over the *visible* range
|
||||||
|
// (not the whole run), matching PopupChartForm's recalc_stats. Cursor/
|
||||||
|
// playhead tracking is out of scope — see specs/plans/2026-07-10-popup-chart.md
|
||||||
|
// and docs/adr/0004-graphquickitem-viewport-for-replay-chart.md.
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property alias sensorNames: chart.sensorNames
|
||||||
|
property alias seriesData: chart.seriesData
|
||||||
|
property alias title: chart.title
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Item {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
|
||||||
|
GraphQuickItem {
|
||||||
|
id: chart
|
||||||
|
anchors.fill: parent
|
||||||
|
showMinMaxMarkers: true
|
||||||
|
xLabel: "Measurement Interval"
|
||||||
|
yLabel: "Temperature (°C)"
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: interaction
|
||||||
|
anchors.fill: parent
|
||||||
|
acceptedButtons: Qt.LeftButton
|
||||||
|
property real lastX: 0
|
||||||
|
|
||||||
|
onWheel: function(wheel) {
|
||||||
|
var frac = Math.max(0, Math.min(1, wheel.x / width));
|
||||||
|
var factor = wheel.angleDelta.y > 0 ? 0.8 : 1.25;
|
||||||
|
chart.zoomAtFraction(frac, factor);
|
||||||
|
wheel.accepted = true;
|
||||||
|
}
|
||||||
|
onPressed: function(mouse) {
|
||||||
|
lastX = mouse.x;
|
||||||
|
}
|
||||||
|
onPositionChanged: function(mouse) {
|
||||||
|
if (pressed && width > 0) {
|
||||||
|
chart.panByFraction(-(mouse.x - lastX) / width);
|
||||||
|
lastX = mouse.x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Min/Max/Avg readout strip + Reset Zoom ─────────────────────
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 16
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "Min:"
|
||||||
|
color: Theme.sensorLow
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.weight: Font.Medium
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
text: chart.viewMin.toFixed(2) + (chart.viewMinSensor ? " (" + chart.viewMinSensor + ")" : "")
|
||||||
|
color: Theme.headingColor
|
||||||
|
font.family: Theme.codeFontFamily
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "Max:"
|
||||||
|
color: Theme.sensorHigh
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.weight: Font.Medium
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
text: chart.viewMax.toFixed(2) + (chart.viewMaxSensor ? " (" + chart.viewMaxSensor + ")" : "")
|
||||||
|
color: Theme.headingColor
|
||||||
|
font.family: Theme.codeFontFamily
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "Avg:"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.weight: Font.Medium
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
text: chart.viewAvg.toFixed(2)
|
||||||
|
color: Theme.headingColor
|
||||||
|
font.family: Theme.codeFontFamily
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { Layout.fillWidth: true }
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: resetZoomBtn
|
||||||
|
implicitHeight: 32
|
||||||
|
hoverEnabled: true
|
||||||
|
text: "Reset Zoom"
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.weight: Font.Medium
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: resetZoomBtn.pressed ? Theme.transportButtonHover : Theme.transportButtonBg
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
contentItem: Text {
|
||||||
|
text: resetZoomBtn.text
|
||||||
|
color: Theme.headingColor
|
||||||
|
font: resetZoomBtn.font
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
onClicked: chart.resetZoom()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,6 +57,17 @@ ColumnLayout {
|
|||||||
onClicked: deviceController.eraseMemory(
|
onClicked: deviceController.eraseMemory(
|
||||||
deviceController.selectedPort)
|
deviceController.selectedPort)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RailActionButton {
|
||||||
|
label: "READ DEBUG"
|
||||||
|
iconSource: "../icons/read.svg"
|
||||||
|
Layout.fillWidth: true
|
||||||
|
// One-shot toggle: "on" while the debug read is in flight,
|
||||||
|
// resets automatically once deviceController flips status.
|
||||||
|
toggled: deviceController.connectionStatus === "Reading debug..."
|
||||||
|
enabled: deviceController.waferDetected && !deviceController.operationInProgress
|
||||||
|
onClicked: deviceController.readDebug(deviceController.selectedPort)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,374 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Controls.impl
|
||||||
|
import QtQuick.Dialogs
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ISC
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: root
|
||||||
|
Layout.fillWidth: true
|
||||||
|
implicitHeight: contentCol.implicitHeight + 24
|
||||||
|
|
||||||
|
color: Theme.sidePanelBackground
|
||||||
|
radius: Theme.sidePanelRadius
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
signal exportRequested(string filePath, string extra)
|
||||||
|
|
||||||
|
property int _liveSecs: 0
|
||||||
|
Connections {
|
||||||
|
target: streamController
|
||||||
|
function onModeChanged() {
|
||||||
|
if (streamController.mode === "review" && modeBar.currentIndex !== 0) {
|
||||||
|
modeBar.currentIndex = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Timer {
|
||||||
|
id: liveTimer
|
||||||
|
interval: 1000
|
||||||
|
repeat: true
|
||||||
|
running: streamController.mode === "live" && streamController.state !== "idle"
|
||||||
|
onTriggered: root._liveSecs++
|
||||||
|
onRunningChanged: if (!running) root._liveSecs = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(s) {
|
||||||
|
var m = Math.floor(s / 60);
|
||||||
|
var ss = s % 60;
|
||||||
|
return (m < 10 ? "0" : "") + m + ":" + (ss < 10 ? "0" : "") + ss;
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchToReview() {
|
||||||
|
modeBar.currentIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: contentCol
|
||||||
|
anchors {
|
||||||
|
fill: parent
|
||||||
|
topMargin: 10
|
||||||
|
leftMargin: 12
|
||||||
|
rightMargin: 12
|
||||||
|
bottomMargin: 12
|
||||||
|
}
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
TabBar {
|
||||||
|
id: modeBar
|
||||||
|
currentIndex: 0
|
||||||
|
spacing: 2
|
||||||
|
padding: 2
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: 28
|
||||||
|
background: Rectangle {
|
||||||
|
color: Theme.subtleSectionBackground
|
||||||
|
radius: Theme.radiusXs
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
TabButton {
|
||||||
|
text: "Review"
|
||||||
|
Layout.fillWidth: true
|
||||||
|
implicitHeight: 24
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.weight: parent.checked ? Font.Medium : Font.Normal
|
||||||
|
background: Rectangle {
|
||||||
|
color: parent.checked ? Theme.tabActiveBackground : "transparent"
|
||||||
|
radius: Theme.radiusXs - 1
|
||||||
|
}
|
||||||
|
contentItem: Text {
|
||||||
|
text: parent.text
|
||||||
|
color: parent.checked ? Theme.headingColor : Theme.bodyColor
|
||||||
|
font: parent.font
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TabButton {
|
||||||
|
id: liveTab
|
||||||
|
text: "Live"
|
||||||
|
enabled: deviceController.connectionStatus === "Connected"
|
||||||
|
opacity: enabled ? 1.0 : 0.4
|
||||||
|
Layout.fillWidth: true
|
||||||
|
implicitHeight: 24
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.weight: parent.checked ? Font.Medium : Font.Normal
|
||||||
|
|
||||||
|
AppToolTip {
|
||||||
|
visible: disabledHover.containsMouse && !liveTab.enabled
|
||||||
|
text: "Connect a wafer to enable Live mode"
|
||||||
|
delay: 300
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: disabledHover
|
||||||
|
anchors.fill: parent
|
||||||
|
enabled: !liveTab.enabled
|
||||||
|
hoverEnabled: true
|
||||||
|
acceptedButtons: Qt.NoButton
|
||||||
|
}
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
color: parent.checked ? Theme.tabActiveBackground : "transparent"
|
||||||
|
radius: Theme.radiusXs - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
contentItem: Item {
|
||||||
|
Row {
|
||||||
|
spacing: 4
|
||||||
|
anchors.centerIn: parent
|
||||||
|
Rectangle {
|
||||||
|
id: liveTabDot
|
||||||
|
width: 5; height: 5; radius: 2.5
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
color: liveTab.enabled ? Theme.metricGood : Theme.sideMutedText
|
||||||
|
|
||||||
|
SequentialAnimation on opacity {
|
||||||
|
running: streamController.mode === "live" && streamController.state !== "idle"
|
||||||
|
loops: Animation.Infinite
|
||||||
|
NumberAnimation { to: 0.2; duration: 600; easing.type: Easing.InOutQuad }
|
||||||
|
NumberAnimation { to: 1.0; duration: 600; easing.type: Easing.InOutQuad }
|
||||||
|
onRunningChanged: if (!running) liveTabDot.opacity = 1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: liveTab.text
|
||||||
|
color: liveTab.checked ? Theme.headingColor : Theme.bodyColor
|
||||||
|
font: liveTab.font
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
height: parent.height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onCurrentIndexChanged: {
|
||||||
|
if (currentIndex === 1) {
|
||||||
|
if (deviceController.connectionStatus !== "Connected") {
|
||||||
|
currentIndex = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
streamController.setMode("live");
|
||||||
|
var fc = "";
|
||||||
|
if (deviceController.lastWaferInfo && deviceController.lastWaferInfo.length > 0) {
|
||||||
|
fc = deviceController.lastWaferInfo[0];
|
||||||
|
}
|
||||||
|
streamController.startStream(deviceController.selectedPort, fc);
|
||||||
|
} else {
|
||||||
|
streamController.setMode("review");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
height: 1
|
||||||
|
color: Theme.cardBorder
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "METRICS"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.letterSpacing: 1.5
|
||||||
|
font.bold: true
|
||||||
|
Layout.bottomMargin: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
MetricRow {
|
||||||
|
label: "Received Frames"
|
||||||
|
value: modeBar.currentIndex === 1 ? streamController.receivedCount : "—"
|
||||||
|
}
|
||||||
|
|
||||||
|
MetricRow {
|
||||||
|
label: "Errors"
|
||||||
|
value: modeBar.currentIndex === 1 ? streamController.errorCount : "—"
|
||||||
|
valueColor: streamController.errorCount > 0 ? Theme.statusWarningColor : Theme.headingColor
|
||||||
|
}
|
||||||
|
|
||||||
|
MetricRow {
|
||||||
|
label: "Resyncs"
|
||||||
|
value: modeBar.currentIndex === 1 ? streamController.resyncCount : "—"
|
||||||
|
}
|
||||||
|
|
||||||
|
MetricRow {
|
||||||
|
label: "Elapsed"
|
||||||
|
value: modeBar.currentIndex === 1 ? root.fmtTime(root._liveSecs) : "—"
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
height: 1
|
||||||
|
color: Theme.cardBorder
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "ACTIONS"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.letterSpacing: 1.5
|
||||||
|
font.bold: true
|
||||||
|
Layout.bottomMargin: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: recordBtn
|
||||||
|
Layout.fillWidth: true
|
||||||
|
implicitHeight: 28
|
||||||
|
enabled: streamController.mode === "live"
|
||||||
|
opacity: enabled ? 1.0 : 0.4
|
||||||
|
text: streamController.recording ? "Stop REC" : "Record"
|
||||||
|
|
||||||
|
AppToolTip {
|
||||||
|
visible: recordBtn.hovered && !recordBtn.enabled
|
||||||
|
text: "Connect to Live to start recording"
|
||||||
|
delay: 300
|
||||||
|
}
|
||||||
|
|
||||||
|
onClicked: {
|
||||||
|
if (streamController.recording) {
|
||||||
|
streamController.stopRecording();
|
||||||
|
} else {
|
||||||
|
var info = deviceController.lastWaferInfo;
|
||||||
|
var serial = (info && info.length > 1) ? info[1] : "";
|
||||||
|
var ts = Qt.formatDateTime(new Date(), "yyyyMMdd_HHmmss");
|
||||||
|
var name = "live_" + (serial ? serial + "_" : "") + ts + ".csv";
|
||||||
|
streamController.startRecording(deviceController.saveDataDir + "/" + name, serial);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: recordBtn.down ? Theme.transportButtonHover
|
||||||
|
: (recordBtn.hovered ? Theme.transportButtonBg : "transparent")
|
||||||
|
border.width: Theme.borderThin
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
}
|
||||||
|
contentItem: Text {
|
||||||
|
text: recordBtn.text
|
||||||
|
color: Theme.headingColor
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.weight: Font.Medium
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: exportBtn
|
||||||
|
Layout.fillWidth: true
|
||||||
|
implicitHeight: 28
|
||||||
|
text: "Export"
|
||||||
|
// Nothing to export until a file is loaded or a stream has played
|
||||||
|
enabled: streamController.sensorValues.length > 0
|
||||||
|
opacity: enabled ? 1 : 0.4
|
||||||
|
|
||||||
|
onClicked: {
|
||||||
|
// Default into <saveDataDir>/Export (created on demand)
|
||||||
|
exportDialog.selectedFile = "file://" + deviceController.exportDir() + "/wafer_map.png"
|
||||||
|
exportDialog.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: exportBtn.down ? Theme.transportButtonHover
|
||||||
|
: (exportBtn.hovered ? Theme.transportButtonBg : "transparent")
|
||||||
|
border.width: Theme.borderThin
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
}
|
||||||
|
contentItem: Text {
|
||||||
|
text: exportBtn.text
|
||||||
|
color: Theme.headingColor
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.weight: Font.Medium
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FileDialog {
|
||||||
|
id: exportDialog
|
||||||
|
title: "Export Wafer Map"
|
||||||
|
fileMode: FileDialog.SaveFile
|
||||||
|
defaultSuffix: "png"
|
||||||
|
nameFilters: ["PNG files (*.png)"]
|
||||||
|
onAccepted: {
|
||||||
|
var path = String(selectedFile).replace(/^file:\/\//, "");
|
||||||
|
var extra = "";
|
||||||
|
if (streamController.mode === "live") {
|
||||||
|
extra = "Frames: " + streamController.receivedCount
|
||||||
|
+ " Errors: " + streamController.errorCount
|
||||||
|
+ " Resyncs: " + streamController.resyncCount
|
||||||
|
+ " Elapsed: " + root.fmtTime(root._liveSecs);
|
||||||
|
}
|
||||||
|
root.exportRequested(path, extra);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
height: 1
|
||||||
|
color: Theme.cardBorder
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: primaryBtn
|
||||||
|
Layout.fillWidth: true
|
||||||
|
implicitHeight: 32
|
||||||
|
text: streamController.mode === "live" ? "STOP" : "START"
|
||||||
|
enabled: streamController.mode === "live"
|
||||||
|
? true
|
||||||
|
: deviceController.connectionStatus === "Connected"
|
||||||
|
opacity: enabled ? 1.0 : 0.4
|
||||||
|
|
||||||
|
AppToolTip {
|
||||||
|
visible: primaryBtn.hovered && !primaryBtn.enabled
|
||||||
|
text: "Connect a wafer to enable Live mode"
|
||||||
|
delay: 300
|
||||||
|
}
|
||||||
|
|
||||||
|
onClicked: {
|
||||||
|
if (streamController.mode === "live") {
|
||||||
|
streamController.stopStream();
|
||||||
|
modeBar.currentIndex = 0;
|
||||||
|
} else {
|
||||||
|
modeBar.currentIndex = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: primaryBtn.down ? Theme.transportButtonHover
|
||||||
|
: (primaryBtn.hovered ? Theme.transportButtonBg : "transparent")
|
||||||
|
border.width: Theme.borderThin
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
}
|
||||||
|
contentItem: Text {
|
||||||
|
text: primaryBtn.text
|
||||||
|
color: Theme.headingColor
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.weight: Font.Medium
|
||||||
|
font.letterSpacing: 1.0
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
import QtQuick
|
|
||||||
import QtQuick.Layouts
|
|
||||||
import QtQuick.Controls
|
|
||||||
import QtQuick.Controls.impl
|
|
||||||
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 {
|
|
||||||
flat: true
|
|
||||||
Layout.preferredWidth: 32
|
|
||||||
Layout.preferredHeight: 32
|
|
||||||
onClicked: root.close()
|
|
||||||
|
|
||||||
background: Rectangle {
|
|
||||||
radius: Theme.radiusXs
|
|
||||||
color: parent.hovered ? Theme.buttonNeutralHover : "transparent"
|
|
||||||
}
|
|
||||||
contentItem: IconImage {
|
|
||||||
source: "../icons/x.svg"
|
|
||||||
width: 14; height: 14
|
|
||||||
sourceSize.width: 14
|
|
||||||
sourceSize.height: 14
|
|
||||||
color: Theme.bodyColor
|
|
||||||
anchors.centerIn: parent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -42,15 +42,69 @@ Item {
|
|||||||
spacing: 24
|
spacing: 24
|
||||||
|
|
||||||
// Frame counter
|
// Frame counter
|
||||||
Text {
|
Row {
|
||||||
|
spacing: 6
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
text: "Frame " + (streamController.frameIndex + 1)
|
|
||||||
+ " / " + streamController.frameTotal
|
Label {
|
||||||
color: Theme.sideMutedText
|
text: "Frame"
|
||||||
font.family: Theme.uiFontFamily
|
color: Theme.sideMutedText
|
||||||
font.pixelSize: Theme.fontMd
|
font.family: Theme.uiFontFamily
|
||||||
font.weight: Font.Medium
|
font.pixelSize: Theme.fontMd
|
||||||
leftPadding: 8
|
font.weight: Font.Medium
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField {
|
||||||
|
id: frameInput
|
||||||
|
text: String(streamController.frameIndex + 1)
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.weight: Font.Medium
|
||||||
|
font.family: Theme.codeFontFamily
|
||||||
|
color: Theme.headingColor
|
||||||
|
verticalAlignment: TextInput.AlignVCenter
|
||||||
|
horizontalAlignment: TextInput.AlignHCenter
|
||||||
|
selectByMouse: true
|
||||||
|
inputMethodHints: Qt.ImhDigitsOnly
|
||||||
|
padding: 0
|
||||||
|
width: Math.max(44, contentWidth + 16)
|
||||||
|
height: 28
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
color: Theme.fieldBackground
|
||||||
|
radius: Theme.radiusXs
|
||||||
|
border.color: frameInput.activeFocus ? Theme.fieldBorderFocus : (frameInput.hovered ? Theme.fieldBorderFocus : Theme.sideBorder)
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
onEditingFinished: {
|
||||||
|
var val = parseInt(text)
|
||||||
|
if (!isNaN(val)) {
|
||||||
|
var targetFrame = Math.max(1, Math.min(val, streamController.frameTotal))
|
||||||
|
streamController.seek(targetFrame - 1)
|
||||||
|
}
|
||||||
|
text = String(streamController.frameIndex + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: streamController
|
||||||
|
function onFrameUpdated() {
|
||||||
|
if (!frameInput.activeFocus) {
|
||||||
|
frameInput.text = String(streamController.frameIndex + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "/ " + streamController.frameTotal
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.weight: Font.Medium
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Separator 1
|
// Separator 1
|
||||||
@@ -124,7 +178,7 @@ Item {
|
|||||||
Button {
|
Button {
|
||||||
id: playPauseBtn
|
id: playPauseBtn
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
implicitWidth: 64
|
implicitWidth: 56
|
||||||
implicitHeight: 56
|
implicitHeight: 56
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
background: Rectangle {
|
background: Rectangle {
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ Rectangle {
|
|||||||
border.width: 1
|
border.width: 1
|
||||||
color: Theme.sidePanelBackground
|
color: Theme.sidePanelBackground
|
||||||
|
|
||||||
|
property var settingsPopup: null
|
||||||
|
property var aboutDialog: null
|
||||||
|
|
||||||
RowLayout {
|
RowLayout {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
anchors.margins: 4
|
anchors.margins: 4
|
||||||
@@ -25,7 +28,7 @@ Rectangle {
|
|||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
icon.source: "../icons/settings.svg"
|
icon.source: "../icons/settings.svg"
|
||||||
onClicked: utilFooter.parent.settingsPopup.open()
|
onClicked: if (settingsPopup) settingsPopup.open()
|
||||||
|
|
||||||
background: Rectangle {
|
background: Rectangle {
|
||||||
radius: 8
|
radius: 8
|
||||||
@@ -73,7 +76,7 @@ Rectangle {
|
|||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
icon.source: "../icons/about.svg"
|
icon.source: "../icons/about.svg"
|
||||||
onClicked: utilFooter.parent.aboutDialog.open()
|
onClicked: if (aboutDialog) aboutDialog.open()
|
||||||
|
|
||||||
background: Rectangle {
|
background: Rectangle {
|
||||||
radius: 8
|
radius: 8
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ Item {
|
|||||||
id: root
|
id: root
|
||||||
property real blend: 0.0
|
property real blend: 0.0
|
||||||
property bool showLabels: true
|
property bool showLabels: true
|
||||||
|
property alias showExtremes: map.showExtremes
|
||||||
property alias showThickness: map.showThickness
|
property alias showThickness: map.showThickness
|
||||||
|
property alias hasThickness: map.hasThickness
|
||||||
|
|
||||||
WaferMapItem {
|
WaferMapItem {
|
||||||
id: map
|
id: map
|
||||||
@@ -39,7 +41,9 @@ Item {
|
|||||||
HoverHandler {
|
HoverHandler {
|
||||||
id: hoverHandler
|
id: hoverHandler
|
||||||
onPointChanged: {
|
onPointChanged: {
|
||||||
map.hoveredIndex = map.which_marker(hoverHandler.point.position.x, hoverHandler.point.position.y);
|
map.hoveredIndex = streamController.mode === "live"
|
||||||
|
? -1
|
||||||
|
: map.which_marker(hoverHandler.point.position.x, hoverHandler.point.position.y);
|
||||||
}
|
}
|
||||||
onHoveredChanged: if (!hoverHandler.hovered) map.hoveredIndex = -1
|
onHoveredChanged: if (!hoverHandler.hovered) map.hoveredIndex = -1
|
||||||
cursorShape: map.hoveredIndex >= 0 ? Qt.PointingHandCursor : Qt.ArrowCursor
|
cursorShape: map.hoveredIndex >= 0 ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||||
@@ -47,6 +51,8 @@ Item {
|
|||||||
|
|
||||||
TapHandler {
|
TapHandler {
|
||||||
onTapped: ev => {
|
onTapped: ev => {
|
||||||
|
if (streamController.mode === "live")
|
||||||
|
return;
|
||||||
var idx = map.which_marker(ev.position.x, ev.position.y);
|
var idx = map.which_marker(ev.position.x, ev.position.y);
|
||||||
if (idx >= 0)
|
if (idx >= 0)
|
||||||
replaceDialog.openFor(idx);
|
replaceDialog.openFor(idx);
|
||||||
@@ -58,7 +64,11 @@ Item {
|
|||||||
id: replaceDialog
|
id: replaceDialog
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportImage(filePath) {
|
function exportImage(filePath, extra) {
|
||||||
return map.export_image(filePath);
|
return map.export_image(filePath, extra || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadThickness(filePath) {
|
||||||
|
return map.loadThickness(filePath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,13 @@ ReplaceSensorDialog 1.0 ReplaceSensorDialog.qml
|
|||||||
RailActionButton 1.0 RailActionButton.qml
|
RailActionButton 1.0 RailActionButton.qml
|
||||||
SelectFileDialog 1.0 SelectFileDialog.qml
|
SelectFileDialog 1.0 SelectFileDialog.qml
|
||||||
SplitDialog 1.0 SplitDialog.qml
|
SplitDialog 1.0 SplitDialog.qml
|
||||||
StreamStatsDialog 1.0 StreamStatsDialog.qml
|
StreamControlPanel 1.0 StreamControlPanel.qml
|
||||||
StatusActionsPanel 1.0 StatusActionsPanel.qml
|
StatusActionsPanel 1.0 StatusActionsPanel.qml
|
||||||
ConnectionFooter 1.0 ConnectionFooter.qml
|
ConnectionFooter 1.0 ConnectionFooter.qml
|
||||||
UtilityFooter 1.0 UtilityFooter.qml
|
UtilityFooter 1.0 UtilityFooter.qml
|
||||||
PanelCheckBox 1.0 PanelCheckBox.qml
|
PanelCheckBox 1.0 PanelCheckBox.qml
|
||||||
ReadoutStat 1.0 ReadoutStat.qml
|
ReadoutStat 1.0 ReadoutStat.qml
|
||||||
|
MetricRow 1.0 MetricRow.qml
|
||||||
AppToolTip 1.0 AppToolTip.qml
|
AppToolTip 1.0 AppToolTip.qml
|
||||||
SectionTitle 1.0 SectionTitle.qml
|
SectionTitle 1.0 SectionTitle.qml
|
||||||
PanelBox 1.0 PanelBox.qml
|
PanelBox 1.0 PanelBox.qml
|
||||||
@@ -21,4 +22,5 @@ PanelSlider 1.0 PanelSlider.qml
|
|||||||
EditableScrubStat 1.0 EditableScrubStat.qml
|
EditableScrubStat 1.0 EditableScrubStat.qml
|
||||||
ComparisonChartCard 1.0 ComparisonChartCard.qml
|
ComparisonChartCard 1.0 ComparisonChartCard.qml
|
||||||
OverlapMapCard 1.0 OverlapMapCard.qml
|
OverlapMapCard 1.0 OverlapMapCard.qml
|
||||||
ComparisonSidePanel 1.0 ComparisonSidePanel.qml
|
ComparisonSidePanel 1.0 ComparisonSidePanel.qml
|
||||||
|
RunChart 1.0 RunChart.qml
|
||||||
@@ -206,6 +206,7 @@ QtObject {
|
|||||||
readonly property int panelPadding: 12
|
readonly property int panelPadding: 12
|
||||||
// Side rail layout
|
// Side rail layout
|
||||||
readonly property int sideRailWidth: 300
|
readonly property int sideRailWidth: 300
|
||||||
|
readonly property int rightRailWidth: 280
|
||||||
readonly property int sideRailMargin: 16
|
readonly property int sideRailMargin: 16
|
||||||
readonly property int sideRailSpacing: 16
|
readonly property int sideRailSpacing: 16
|
||||||
readonly property int sideButtonHeight: 44
|
readonly property int sideButtonHeight: 44
|
||||||
|
|||||||
@@ -74,6 +74,9 @@ def main() -> int:
|
|||||||
device_controller = DeviceController(raw_settings, data_dir)
|
device_controller = DeviceController(raw_settings, data_dir)
|
||||||
engine.rootContext().setContextProperty("deviceController", device_controller)
|
engine.rootContext().setContextProperty("deviceController", device_controller)
|
||||||
|
|
||||||
|
# Source panel browses the same folder recordings/reads are saved to.
|
||||||
|
select_file_dialog_model.setCurrentDirectory(str(device_controller.saveDataDir))
|
||||||
|
|
||||||
# ===== License Model (About dialog grid, replay trial) =====
|
# ===== License Model (About dialog grid, replay trial) =====
|
||||||
license_model = LicenseModel(data_dir)
|
license_model = LicenseModel(data_dir)
|
||||||
engine.rootContext().setContextProperty("licenseModel", license_model)
|
engine.rootContext().setContextProperty("licenseModel", license_model)
|
||||||
|
|||||||
@@ -18,9 +18,11 @@ from pygui.backend.models.data_model import TemperatureTableModel
|
|||||||
from pygui.backend.utils import slot_error_boundary
|
from pygui.backend.utils import slot_error_boundary
|
||||||
from pygui.backend.visualization.graph_view import GraphView
|
from pygui.backend.visualization.graph_view import GraphView
|
||||||
from pygui.serialcomm.data_parser import (
|
from pygui.serialcomm.data_parser import (
|
||||||
|
convert_to_debug_temperatures,
|
||||||
convert_to_temperatures,
|
convert_to_temperatures,
|
||||||
parse_binary_data,
|
parse_binary_data,
|
||||||
remove_trailing_zeros,
|
remove_trailing_zeros,
|
||||||
|
save_debug_csv,
|
||||||
save_to_csv,
|
save_to_csv,
|
||||||
)
|
)
|
||||||
from pygui.serialcomm.serial_port import WaferInfo
|
from pygui.serialcomm.serial_port import WaferInfo
|
||||||
@@ -139,6 +141,14 @@ 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(result=str)
|
||||||
|
@slot_error_boundary
|
||||||
|
def exportDir(self) -> str:
|
||||||
|
"""Ensure and return the Export subfolder under saveDataDir."""
|
||||||
|
path = Path(self._save_data_dir) / "Export"
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return str(path)
|
||||||
|
|
||||||
@Slot(str)
|
@Slot(str)
|
||||||
@slot_error_boundary
|
@slot_error_boundary
|
||||||
def openCsvFile(self, file_path: str) -> None:
|
def openCsvFile(self, file_path: str) -> None:
|
||||||
@@ -547,10 +557,29 @@ class DeviceController(QObject):
|
|||||||
self._debugFinished.emit({"error": "F1 read failed"})
|
self._debugFinished.emit({"error": "F1 read failed"})
|
||||||
return
|
return
|
||||||
|
|
||||||
|
fc = (
|
||||||
|
self._last_wafer_info.get("familyCode", "")
|
||||||
|
if self._last_wafer_info
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
sensor_hex = parse_binary_data(sensor_data, fc)
|
||||||
|
debug_hex = parse_binary_data(debug_data, fc)
|
||||||
|
if not sensor_hex or not debug_hex:
|
||||||
|
self._debugFinished.emit({"error": "Debug binary parse failed"})
|
||||||
|
return
|
||||||
|
|
||||||
|
temp_data = convert_to_temperatures(sensor_hex, fc)
|
||||||
|
debug_temp_data = convert_to_debug_temperatures(debug_hex)
|
||||||
|
csv_path = save_debug_csv(temp_data, debug_temp_data, self._save_data_dir)
|
||||||
|
if csv_path is None:
|
||||||
|
self._debugFinished.emit({"error": "Debug CSV save failed"})
|
||||||
|
return
|
||||||
|
|
||||||
self._debugFinished.emit({
|
self._debugFinished.emit({
|
||||||
"success": True,
|
"success": True,
|
||||||
"sensor_bytes": len(sensor_data),
|
"sensor_bytes": len(sensor_data),
|
||||||
"debug_bytes": len(debug_data),
|
"debug_bytes": len(debug_data),
|
||||||
|
"csv_path": csv_path,
|
||||||
})
|
})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.exception("Debug worker crashed: %s", exc)
|
log.exception("Debug worker crashed: %s", exc)
|
||||||
@@ -564,10 +593,11 @@ class DeviceController(QObject):
|
|||||||
self._set_connection_status("Connected")
|
self._set_connection_status("Connected")
|
||||||
sensor_bytes = result.get("sensor_bytes", 0)
|
sensor_bytes = result.get("sensor_bytes", 0)
|
||||||
debug_bytes = result.get("debug_bytes", 0)
|
debug_bytes = result.get("debug_bytes", 0)
|
||||||
self._append_log(
|
csv_path = result.get("csv_path", "")
|
||||||
f"Debug read complete: {sensor_bytes} sensor bytes, "
|
msg = f"Debug read complete: {sensor_bytes} sensor bytes, {debug_bytes} debug bytes"
|
||||||
f"{debug_bytes} debug bytes"
|
if csv_path:
|
||||||
)
|
msg += f", CSV written to {csv_path}"
|
||||||
|
self._append_log(msg)
|
||||||
else:
|
else:
|
||||||
self._set_connection_status("Disconnected")
|
self._set_connection_status("Disconnected")
|
||||||
err_msg = result.get("error", "Debug read failed")
|
err_msg = result.get("error", "Debug read failed")
|
||||||
|
|||||||
@@ -11,12 +11,17 @@ from PySide6.QtCore import Property, QObject, Qt, QTimer, Signal, Slot
|
|||||||
from pygui.backend.cluster_average import average_clusters, group_sensors_by_radius
|
from pygui.backend.cluster_average import average_clusters, group_sensors_by_radius
|
||||||
from pygui.backend.data.csv_recorder import CsvRecorder
|
from pygui.backend.data.csv_recorder import CsvRecorder
|
||||||
from pygui.backend.models.frame import Frame
|
from pygui.backend.models.frame import Frame
|
||||||
from pygui.backend.models.frame_player import FramePlayer, frames_from_wafer_data
|
from pygui.backend.models.frame_player import (
|
||||||
from pygui.backend.models.replay_stats import ReplayStatsTracker
|
FramePlayer,
|
||||||
|
all_sensor_series,
|
||||||
|
frames_from_wafer_data,
|
||||||
|
)
|
||||||
|
from pygui.backend.models.frame_stats import compute_edge_center
|
||||||
from pygui.backend.models.sensor_editor import SensorEditor
|
from pygui.backend.models.sensor_editor import SensorEditor
|
||||||
from pygui.backend.models.session_model import SessionModel, SessionUpdate
|
from pygui.backend.models.session_model import SessionModel, SessionUpdate
|
||||||
from pygui.backend.models.threshold_classifier import ThresholdConfig
|
from pygui.backend.models.threshold_classifier import ThresholdConfig
|
||||||
from pygui.backend.utils import slot_error_boundary
|
from pygui.backend.utils import slot_error_boundary
|
||||||
|
from pygui.backend.wafer.wafer_layouts import ec_pairs_for_wafer_id
|
||||||
from pygui.backend.wafer.zwafer_models import Sensor
|
from pygui.backend.wafer.zwafer_models import Sensor
|
||||||
from pygui.backend.wafer.zwafer_parser import ZWaferParser
|
from pygui.backend.wafer.zwafer_parser import ZWaferParser
|
||||||
from pygui.serialcomm.stream_reader import StreamReader
|
from pygui.serialcomm.stream_reader import StreamReader
|
||||||
@@ -39,7 +44,8 @@ class SessionController(QObject):
|
|||||||
clusterAveragingEnabledChanged = Signal()
|
clusterAveragingEnabledChanged = Signal()
|
||||||
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
|
trendDelta = Signal(str) # JSON [[elapsed_s, avg]] — one new point per live frame
|
||||||
|
trendReset = Signal() # live trend buffer cleared (new stream started)
|
||||||
# NOTE: dict (QVariantMap), not object — Signal(object) delivers Python
|
# NOTE: dict (QVariantMap), not object — Signal(object) delivers Python
|
||||||
# dicts to QML as an opaque empty wrapper with no accessible fields.
|
# 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
|
comparisonResult = Signal(dict) # dict with success, distance, warping_path, max_sensor_deviation, series_a, series_b
|
||||||
@@ -59,13 +65,7 @@ class SessionController(QObject):
|
|||||||
self._sensors: list[Sensor] = []
|
self._sensors: list[Sensor] = []
|
||||||
self._last: Optional[SessionUpdate] = None
|
self._last: Optional[SessionUpdate] = None
|
||||||
self._elapsed = 0.0
|
self._elapsed = 0.0
|
||||||
self._trend_buffer: list[float] = [] # rolling window
|
self._stream_start_time: float = 0.0
|
||||||
self._trend_timestamps: list[float] = [] # monotonic timestamps
|
|
||||||
|
|
||||||
# Trend refresh timer - update every 1s
|
|
||||||
self._trend_timer = QTimer(self)
|
|
||||||
self._trend_timer.setInterval(1000)
|
|
||||||
self._trend_timer.timeout.connect(self._flush_trend)
|
|
||||||
|
|
||||||
self._play_timer = QTimer(self)
|
self._play_timer = QTimer(self)
|
||||||
self._play_timer.timeout.connect(self._advance)
|
self._play_timer.timeout.connect(self._advance)
|
||||||
@@ -81,11 +81,11 @@ class SessionController(QObject):
|
|||||||
self._liveError.connect(self._on_live_error, Qt.ConnectionType.QueuedConnection)
|
self._liveError.connect(self._on_live_error, Qt.ConnectionType.QueuedConnection)
|
||||||
|
|
||||||
self._sensor_editor = SensorEditor()
|
self._sensor_editor = SensorEditor()
|
||||||
|
self._ec_pairs: tuple[tuple[int, int], ...] = ()
|
||||||
self._last_raw_frame: Frame | None = None
|
self._last_raw_frame: Frame | None = None
|
||||||
self._loaded_file: str = ""
|
self._loaded_file: str = ""
|
||||||
self._cluster_averaging_enabled = False
|
self._cluster_averaging_enabled = False
|
||||||
self._active_clusters: list[list[int]] = []
|
self._active_clusters: list[list[int]] = []
|
||||||
self._stats_tracker = ReplayStatsTracker()
|
|
||||||
self._received_count: int = 0
|
self._received_count: int = 0
|
||||||
self._error_count: int = 0
|
self._error_count: int = 0
|
||||||
|
|
||||||
@@ -187,14 +187,42 @@ class SessionController(QObject):
|
|||||||
if not self._last:
|
if not self._last:
|
||||||
return {}
|
return {}
|
||||||
s = self._last.stats
|
s = self._last.stats
|
||||||
return {"min": round(s.min, 2), "minIndex": s.min_index + 1,
|
out = {"min": round(s.min, 2), "minIndex": s.min_index + 1,
|
||||||
"max": round(s.max, 2), "maxIndex": s.max_index + 1,
|
"max": round(s.max, 2), "maxIndex": s.max_index + 1,
|
||||||
"diff": round(s.diff, 2), "avg": round(s.avg, 2),
|
"diff": round(s.diff, 2), "avg": round(s.avg, 2),
|
||||||
"sigma": round(s.sigma, 2), "threeSigma": round(s.three_sigma, 2)}
|
"sigma": round(s.sigma, 2), "threeSigma": round(s.three_sigma, 2)}
|
||||||
|
ec = compute_edge_center(self._last.values, self._ec_pairs,
|
||||||
|
self._sensor_editor.replaced_indices())
|
||||||
|
if ec is not None:
|
||||||
|
out.update({
|
||||||
|
"ecMinEdgeIndex": ec.min_edge_index + 1,
|
||||||
|
"ecMinCenterIndex": ec.min_center_index + 1,
|
||||||
|
"ecMinEdgeValue": round(ec.min_edge, 2),
|
||||||
|
"ecMinCenterValue": round(ec.min_center, 2),
|
||||||
|
"ecMinDelta": round(ec.min_delta, 2),
|
||||||
|
"ecMaxEdgeIndex": ec.max_edge_index + 1,
|
||||||
|
"ecMaxCenterIndex": ec.max_center_index + 1,
|
||||||
|
"ecMaxEdgeValue": round(ec.max_edge, 2),
|
||||||
|
"ecMaxCenterValue": round(ec.max_center, 2),
|
||||||
|
"ecMaxDelta": round(ec.max_delta, 2),
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
@Property(str, notify=loadedFileChanged)
|
@Property(str, notify=loadedFileChanged)
|
||||||
def loadedFile(self) -> str: return self._loaded_file
|
def loadedFile(self) -> str: return self._loaded_file
|
||||||
|
|
||||||
|
@Property(str, notify=loadedFileChanged)
|
||||||
|
def graphSensorNames(self) -> str:
|
||||||
|
"""Comma-joined sensor names for the whole-run Graph tab."""
|
||||||
|
names, _ = all_sensor_series(list(self._player.frames))
|
||||||
|
return ",".join(names)
|
||||||
|
|
||||||
|
@Property(str, notify=loadedFileChanged)
|
||||||
|
def graphSeriesJson(self) -> str:
|
||||||
|
"""JSON per-sensor value series (one list per sensor) for the Graph tab."""
|
||||||
|
_, series = all_sensor_series(list(self._player.frames))
|
||||||
|
return json.dumps(series)
|
||||||
|
|
||||||
@Property("QVariantList", notify=frameUpdated) # type: ignore[arg-type]
|
@Property("QVariantList", notify=frameUpdated) # type: ignore[arg-type]
|
||||||
def overriddenSensors(self) -> list[int]:
|
def overriddenSensors(self) -> list[int]:
|
||||||
"""Indices of sensors that currently have a replacement or offset."""
|
"""Indices of sensors that currently have a replacement or offset."""
|
||||||
@@ -232,6 +260,12 @@ class SessionController(QObject):
|
|||||||
return
|
return
|
||||||
if mode != "live":
|
if mode != "live":
|
||||||
self.stopStream()
|
self.stopStream()
|
||||||
|
if self._mode == "live":
|
||||||
|
# Leaving a live session: blank the wafer map instead of
|
||||||
|
# freezing the last streamed frame on screen.
|
||||||
|
self._last = None
|
||||||
|
self._last_raw_frame = None
|
||||||
|
self.frameUpdated.emit()
|
||||||
self._play_timer.stop()
|
self._play_timer.stop()
|
||||||
self._mode = mode
|
self._mode = mode
|
||||||
self.modeChanged.emit()
|
self.modeChanged.emit()
|
||||||
@@ -287,21 +321,17 @@ class SessionController(QObject):
|
|||||||
wafer_id = stem.split("-")[0] if "-" in stem else stem
|
wafer_id = stem.split("-")[0] if "-" in stem else stem
|
||||||
|
|
||||||
shape, size = resolve_shape_and_size(sensors, wafer_id)
|
shape, size = resolve_shape_and_size(sensors, wafer_id)
|
||||||
|
self._ec_pairs = ec_pairs_for_wafer_id(wafer_id)
|
||||||
self._sensors = WaferLayout(sensors, shape=shape, size=size)
|
self._sensors = WaferLayout(sensors, shape=shape, size=size)
|
||||||
self._active_clusters = getattr(self._sensors, 'clusters', [])
|
self._active_clusters = getattr(self._sensors, 'clusters', [])
|
||||||
if not self._active_clusters:
|
if not self._active_clusters:
|
||||||
self._active_clusters = group_sensors_by_radius(self._sensors)
|
self._active_clusters = group_sensors_by_radius(self._sensors)
|
||||||
self._player.load(frames)
|
self._player.load(frames)
|
||||||
self._model.reset()
|
self._model.reset()
|
||||||
self._stats_tracker.reset()
|
|
||||||
self._loaded_file = file_path
|
self._loaded_file = file_path
|
||||||
self.loadedFileChanged.emit()
|
self.loadedFileChanged.emit()
|
||||||
self.sensorsChanged.emit()
|
self.sensorsChanged.emit()
|
||||||
self._emit_current()
|
self._emit_current()
|
||||||
# Populate stats tracker from all loaded frames for trend chart (P3.1)
|
|
||||||
for frame in self._player._frames:
|
|
||||||
self._stats_tracker.track(frame.seq, frame.values)
|
|
||||||
self.trendData.emit(json.dumps(self._stats_tracker.get_trend()))
|
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
@slot_error_boundary
|
@slot_error_boundary
|
||||||
@@ -309,7 +339,7 @@ class SessionController(QObject):
|
|||||||
"""Clear the loaded file, resetting the player and frame states."""
|
"""Clear the loaded file, resetting the player and frame states."""
|
||||||
self._player.load([])
|
self._player.load([])
|
||||||
self._model.reset()
|
self._model.reset()
|
||||||
self._stats_tracker.reset()
|
self._ec_pairs = ()
|
||||||
self._loaded_file = ""
|
self._loaded_file = ""
|
||||||
self.loadedFileChanged.emit()
|
self.loadedFileChanged.emit()
|
||||||
self.sensorsChanged.emit()
|
self.sensorsChanged.emit()
|
||||||
@@ -659,6 +689,7 @@ class SessionController(QObject):
|
|||||||
self.sensorsChanged.emit()
|
self.sensorsChanged.emit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning("Could not load layout for %s: %s", family_code, e)
|
log.warning("Could not load layout for %s: %s", family_code, e)
|
||||||
|
self._ec_pairs = ec_pairs_for_wafer_id(family_code)
|
||||||
|
|
||||||
self._active_clusters = getattr(self._sensors, 'clusters', [])
|
self._active_clusters = getattr(self._sensors, 'clusters', [])
|
||||||
if not self._active_clusters:
|
if not self._active_clusters:
|
||||||
@@ -689,10 +720,7 @@ class SessionController(QObject):
|
|||||||
|
|
||||||
# Clear out any old data from the prev sessions
|
# Clear out any old data from the prev sessions
|
||||||
self._model.reset()
|
self._model.reset()
|
||||||
self._trend_buffer.clear()
|
self._reset_live_trend()
|
||||||
self._trend_timestamps.clear()
|
|
||||||
self._trend_timer.start()
|
|
||||||
self._stats_tracker.reset()
|
|
||||||
self._last = None
|
self._last = None
|
||||||
self._last_raw_frame = None
|
self._last_raw_frame = None
|
||||||
self._received_count = 0
|
self._received_count = 0
|
||||||
@@ -734,7 +762,6 @@ class SessionController(QObject):
|
|||||||
@slot_error_boundary
|
@slot_error_boundary
|
||||||
def stopStream(self) -> None:
|
def stopStream(self) -> None:
|
||||||
self._repaint_timer.stop()
|
self._repaint_timer.stop()
|
||||||
self._trend_timer.stop()
|
|
||||||
self._received_count = 0
|
self._received_count = 0
|
||||||
self._error_count = 0
|
self._error_count = 0
|
||||||
self.liveStatsChanged.emit()
|
self.liveStatsChanged.emit()
|
||||||
@@ -760,10 +787,8 @@ class SessionController(QObject):
|
|||||||
@Slot(object)
|
@Slot(object)
|
||||||
@slot_error_boundary
|
@slot_error_boundary
|
||||||
def _on_live_frame(self, frame: Frame) -> None:
|
def _on_live_frame(self, frame: Frame) -> None:
|
||||||
import json
|
|
||||||
self._received_count += 1
|
self._received_count += 1
|
||||||
self.liveStatsChanged.emit()
|
self.liveStatsChanged.emit()
|
||||||
self._stats_tracker.track(frame.seq, frame.values)
|
|
||||||
# Main thread (queued). Record raw, process edited; mark dirty for repaint.
|
# Main thread (queued). Record raw, process edited; mark dirty for repaint.
|
||||||
self._last_raw_frame = frame
|
self._last_raw_frame = frame
|
||||||
edited = Frame(seq=frame.seq, time=frame.time,
|
edited = Frame(seq=frame.seq, time=frame.time,
|
||||||
@@ -772,22 +797,11 @@ class SessionController(QObject):
|
|||||||
if self._recorder.is_recording:
|
if self._recorder.is_recording:
|
||||||
self._recorder.write(frame) # always record raw values, never edited
|
self._recorder.write(frame) # always record raw values, never edited
|
||||||
self._dirty = True
|
self._dirty = True
|
||||||
self.trendData.emit(json.dumps(self._stats_tracker.get_trend()))
|
|
||||||
|
|
||||||
if self._last and self._last.stats:
|
if self._last and self._last.stats:
|
||||||
avg = self._last.stats.avg
|
avg = self._last.stats.avg
|
||||||
now = time.monotonic()
|
elapsed = time.monotonic() - self._stream_start_time
|
||||||
self._trend_buffer.append(avg)
|
self.trendDelta.emit(json.dumps([[elapsed, avg]]))
|
||||||
self._trend_timestamps.append(now)
|
|
||||||
|
|
||||||
# drop entries older than 60 secs
|
|
||||||
cutoff = now -60
|
|
||||||
while self._trend_timestamps and self._trend_timestamps[0] < cutoff:
|
|
||||||
self._trend_timestamps.pop(0)
|
|
||||||
self._trend_buffer.pop(0)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _flush_repaint(self) -> None:
|
def _flush_repaint(self) -> None:
|
||||||
if not self._dirty:
|
if not self._dirty:
|
||||||
@@ -843,7 +857,8 @@ class SessionController(QObject):
|
|||||||
self._sensor_editor.clear()
|
self._sensor_editor.clear()
|
||||||
self._reprocess_current()
|
self._reprocess_current()
|
||||||
|
|
||||||
def _flush_trend(self) -> None:
|
def _reset_live_trend(self) -> None:
|
||||||
import json
|
"""Mark a fresh stream start for elapsed-time trend deltas."""
|
||||||
self.trendData.emit(json.dumps(self._trend_buffer))
|
self._stream_start_time = time.monotonic()
|
||||||
|
self.trendReset.emit()
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,16 @@ class FileBrowser(QObject):
|
|||||||
return str(self._current_directory)
|
return str(self._current_directory)
|
||||||
|
|
||||||
# ===== Directory Selection =====
|
# ===== Directory Selection =====
|
||||||
|
@Slot(str)
|
||||||
|
def setCurrentDirectory(self, path: str) -> None:
|
||||||
|
"""Point the browser at `path` without opening a native dialog.
|
||||||
|
|
||||||
|
Used to keep this directory in sync with DeviceController.saveDataDir
|
||||||
|
when the user picks a save folder elsewhere in the app.
|
||||||
|
"""
|
||||||
|
self._set_current_directory(Path(path))
|
||||||
|
self._refresh_files(show_empty_message=False)
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
def chooseDirectory(self) -> None:
|
def chooseDirectory(self) -> None:
|
||||||
selected_dir = QFileDialog.getExistingDirectory(
|
selected_dir = QFileDialog.getExistingDirectory(
|
||||||
|
|||||||
@@ -8,6 +8,19 @@ def frames_from_wafer_data(data: ZWaferData | None, records) -> list[Frame]:
|
|||||||
"""Build Frames from parsed DataRecords (records = list[DataRecord].)"""
|
"""Build Frames from parsed DataRecords (records = list[DataRecord].)"""
|
||||||
return [Frame(seq=i, time=r.time, values=list(r.values)) for i, r in enumerate(records)]
|
return [Frame(seq=i, time=r.time, values=list(r.values)) for i, r in enumerate(records)]
|
||||||
|
|
||||||
|
|
||||||
|
def all_sensor_series(frames: list[Frame]) -> tuple[list[str], list[list[float]]]:
|
||||||
|
"""Per-sensor value series across every frame, for the whole-run Graph tab.
|
||||||
|
|
||||||
|
Sensor names are 1-based ("Sensor 1", ...), matching the C# original.
|
||||||
|
"""
|
||||||
|
if not frames:
|
||||||
|
return [], []
|
||||||
|
n_sensors = len(frames[0].values)
|
||||||
|
names = [f"Sensor {i + 1}" for i in range(n_sensors)]
|
||||||
|
series = [[frame.values[i] for frame in frames] for i in range(n_sensors)]
|
||||||
|
return names, series
|
||||||
|
|
||||||
class FramePlayer:
|
class FramePlayer:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._frames: list[Frame] =[]
|
self._frames: list[Frame] =[]
|
||||||
@@ -22,6 +35,10 @@ class FramePlayer:
|
|||||||
def total(self) -> int:
|
def total(self) -> int:
|
||||||
return len(self._frames)
|
return len(self._frames)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def frames(self) -> tuple[Frame, ...]:
|
||||||
|
return tuple(self._frames)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def index(self) -> int:
|
def index(self) -> int:
|
||||||
return self._i
|
return self._i
|
||||||
|
|||||||
@@ -14,6 +14,44 @@ class Stats:
|
|||||||
sigma: float; three_sigma: float
|
sigma: float; three_sigma: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EdgeCenterStats:
|
||||||
|
min_edge_index: int; min_center_index: int
|
||||||
|
min_edge: float; min_center: float; min_delta: float
|
||||||
|
max_edge_index: int; max_center_index: int
|
||||||
|
max_edge: float; max_center: float; max_delta: float
|
||||||
|
|
||||||
|
|
||||||
|
def compute_edge_center(
|
||||||
|
values: list[float],
|
||||||
|
pairs: tuple[tuple[int, int], ...],
|
||||||
|
excluded: set[int],
|
||||||
|
) -> EdgeCenterStats | None:
|
||||||
|
"""Min/max |edge - center| over the family's pair table.
|
||||||
|
|
||||||
|
Pairs with an excluded (replaced) sensor, an index outside the frame, or a
|
||||||
|
NaN value are skipped. Returns None when no pair is computable.
|
||||||
|
"""
|
||||||
|
n = len(values)
|
||||||
|
best: list[tuple[float, int, int]] = []
|
||||||
|
for e, c in pairs:
|
||||||
|
if e in excluded or c in excluded or e >= n or c >= n:
|
||||||
|
continue
|
||||||
|
if math.isnan(values[e]) or math.isnan(values[c]):
|
||||||
|
continue
|
||||||
|
best.append((abs(values[e] - values[c]), e, c))
|
||||||
|
if not best:
|
||||||
|
return None
|
||||||
|
min_d, min_e, min_c = min(best)
|
||||||
|
max_d, max_e, max_c = max(best)
|
||||||
|
return EdgeCenterStats(
|
||||||
|
min_edge_index=min_e, min_center_index=min_c,
|
||||||
|
min_edge=values[min_e], min_center=values[min_c], min_delta=min_d,
|
||||||
|
max_edge_index=max_e, max_center_index=max_c,
|
||||||
|
max_edge=values[max_e], max_center=values[max_c], max_delta=max_d,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def compute_stats(values: list[float]) -> Stats:
|
def compute_stats(values: list[float]) -> Stats:
|
||||||
clean = [(i, v) for i, v in enumerate(values) if not math.isnan(v)]
|
clean = [(i, v) for i, v in enumerate(values) if not math.isnan(v)]
|
||||||
if not clean:
|
if not clean:
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ class SensorEditor:
|
|||||||
def has_overrides(self) -> bool:
|
def has_overrides(self) -> bool:
|
||||||
return bool(self._replacements or self._offsets)
|
return bool(self._replacements or self._offsets)
|
||||||
|
|
||||||
|
def replaced_indices(self) -> set[int]:
|
||||||
|
"""Sensors whose value is a replacement (offsets don't count — an
|
||||||
|
offset sensor still reads from its physical location)."""
|
||||||
|
return set(self._replacements)
|
||||||
|
|
||||||
def active_indices(self) -> list[int]:
|
def active_indices(self) -> list[int]:
|
||||||
"""Return sorted sensor indices that have any override."""
|
"""Return sorted sensor indices that have any override."""
|
||||||
return sorted(set(self._replacements) | set(self._offsets))
|
return sorted(set(self._replacements) | set(self._offsets))
|
||||||
|
|||||||
@@ -31,3 +31,22 @@ def index_to_pixel(index: float, count: int, pixel_start: float, pixel_span: flo
|
|||||||
return pixel_start + pixel_span / 2
|
return pixel_start + pixel_span / 2
|
||||||
fraction = index / (count - 1)
|
fraction = index / (count - 1)
|
||||||
return pixel_start + fraction * pixel_span
|
return pixel_start + fraction * pixel_span
|
||||||
|
|
||||||
|
|
||||||
|
def elapsed_to_pixel(
|
||||||
|
elapsed: float,
|
||||||
|
window_start: float,
|
||||||
|
window_end: float,
|
||||||
|
pixel_start: float,
|
||||||
|
pixel_span: float,
|
||||||
|
) -> float:
|
||||||
|
"""Map an elapsed-seconds value onto a pixel range, left to right.
|
||||||
|
|
||||||
|
Unlike `value_to_pixel`, this is not inverted: larger elapsed values sit
|
||||||
|
at larger pixel x-coordinates, matching how time flows left-to-right.
|
||||||
|
"""
|
||||||
|
window_span = window_end - window_start
|
||||||
|
if window_span < 1e-9:
|
||||||
|
return pixel_start + pixel_span
|
||||||
|
fraction = (elapsed - window_start) / window_span
|
||||||
|
return pixel_start + fraction * pixel_span
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ Usage from QML::
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from PySide6.QtCore import Property, QRectF, Qt, Signal
|
import math
|
||||||
|
|
||||||
|
from PySide6.QtCore import Property, QRectF, Qt, Signal, Slot
|
||||||
from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPen
|
from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPen
|
||||||
from PySide6.QtQml import QmlElement
|
from PySide6.QtQml import QmlElement
|
||||||
from PySide6.QtQuick import QQuickPaintedItem
|
from PySide6.QtQuick import QQuickPaintedItem
|
||||||
@@ -32,6 +34,47 @@ QML_IMPORT_NAME = "ISC.Wafer"
|
|||||||
QML_IMPORT_MAJOR_VERSION = 1
|
QML_IMPORT_MAJOR_VERSION = 1
|
||||||
|
|
||||||
|
|
||||||
|
def viewport_stats(
|
||||||
|
series: list[list[float]],
|
||||||
|
names: list[str],
|
||||||
|
start: int,
|
||||||
|
end: int,
|
||||||
|
) -> tuple[float, float, float, str, str]:
|
||||||
|
"""Aggregate min/max/avg across every sensor's points in `[start, end]`.
|
||||||
|
|
||||||
|
Mirrors PopupChartForm.cs's `recalc_stats`: one running min/max/avg over
|
||||||
|
every visible point from every series, plus which sensor achieved each
|
||||||
|
extreme. Non-numeric values are skipped. No points in range -> zeros and
|
||||||
|
empty sensor names.
|
||||||
|
"""
|
||||||
|
vmin = math.inf
|
||||||
|
vmax = -math.inf
|
||||||
|
total = 0.0
|
||||||
|
count = 0
|
||||||
|
min_sensor = ""
|
||||||
|
max_sensor = ""
|
||||||
|
for i, s in enumerate(series):
|
||||||
|
name = names[i] if i < len(names) else ""
|
||||||
|
lo = max(0, start)
|
||||||
|
hi = min(len(s) - 1, end)
|
||||||
|
for j in range(lo, hi + 1):
|
||||||
|
try:
|
||||||
|
v = float(s[j])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if v < vmin:
|
||||||
|
vmin = v
|
||||||
|
min_sensor = name
|
||||||
|
if v > vmax:
|
||||||
|
vmax = v
|
||||||
|
max_sensor = name
|
||||||
|
total += v
|
||||||
|
count += 1
|
||||||
|
if count == 0:
|
||||||
|
return (0.0, 0.0, 0.0, "", "")
|
||||||
|
return (vmin, vmax, total / count, min_sensor, max_sensor)
|
||||||
|
|
||||||
|
|
||||||
@QmlElement
|
@QmlElement
|
||||||
class GraphQuickItem(QQuickPaintedItem):
|
class GraphQuickItem(QQuickPaintedItem):
|
||||||
"""Painted line chart; driven by series data passed via QML property bindings."""
|
"""Painted line chart; driven by series data passed via QML property bindings."""
|
||||||
@@ -42,6 +85,8 @@ class GraphQuickItem(QQuickPaintedItem):
|
|||||||
xLabelChanged = Signal()
|
xLabelChanged = Signal()
|
||||||
yLabelChanged = Signal()
|
yLabelChanged = Signal()
|
||||||
colorsChanged = Signal()
|
colorsChanged = Signal()
|
||||||
|
viewportChanged = Signal()
|
||||||
|
viewStatsChanged = Signal()
|
||||||
|
|
||||||
def __init__(self, parent=None) -> None:
|
def __init__(self, parent=None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -74,6 +119,18 @@ class GraphQuickItem(QQuickPaintedItem):
|
|||||||
self._max_y: float = 150.0
|
self._max_y: float = 150.0
|
||||||
self._padding: dict[str, int] = {"left": 60, "right": 20, "top": 30, "bottom": 50}
|
self._padding: dict[str, int] = {"left": 60, "right": 20, "top": 30, "bottom": 50}
|
||||||
|
|
||||||
|
# Viewport (replay chart zoom/pan): defaults reproduce "whole series",
|
||||||
|
# so the Graph tab (which never sets these) is unaffected. See
|
||||||
|
# docs/adr/0004-graphquickitem-viewport-for-replay-chart.md.
|
||||||
|
self._view_start: int = 0
|
||||||
|
self._view_end: int = -1
|
||||||
|
self._show_min_max_markers: bool = False
|
||||||
|
self._view_min: float = 0.0
|
||||||
|
self._view_max: float = 0.0
|
||||||
|
self._view_avg: float = 0.0
|
||||||
|
self._view_min_sensor: str = ""
|
||||||
|
self._view_max_sensor: str = ""
|
||||||
|
|
||||||
# ── Qt properties (QML-bindable) ──────────────────────────────────────────
|
# ── Qt properties (QML-bindable) ──────────────────────────────────────────
|
||||||
|
|
||||||
@Property("QVariantList", notify=seriesDataChanged)
|
@Property("QVariantList", notify=seriesDataChanged)
|
||||||
@@ -83,7 +140,16 @@ class GraphQuickItem(QQuickPaintedItem):
|
|||||||
@seriesData.setter # type: ignore[no-redef]
|
@seriesData.setter # type: ignore[no-redef]
|
||||||
def seriesData(self, val: list) -> None:
|
def seriesData(self, val: list) -> None:
|
||||||
self._series_data = [list(s) for s in (val or [])]
|
self._series_data = [list(s) for s in (val or [])]
|
||||||
|
# New data resets the viewport to the full range -- matches
|
||||||
|
# PopupChartForm.cs's SetData(), which resets AxisX.Minimum/Maximum
|
||||||
|
# before RecalculateAxesScale(). Without this, a stale viewport left
|
||||||
|
# over from a previous (longer) series can point entirely past the
|
||||||
|
# new data -- every per-series slice becomes empty and the chart
|
||||||
|
# silently renders nothing instead of the new data.
|
||||||
|
self._view_start = 0
|
||||||
|
self._view_end = -1
|
||||||
self._auto_range()
|
self._auto_range()
|
||||||
|
self._recompute_view_stats()
|
||||||
self.seriesDataChanged.emit()
|
self.seriesDataChanged.emit()
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
@@ -94,9 +160,130 @@ class GraphQuickItem(QQuickPaintedItem):
|
|||||||
@sensorNames.setter # type: ignore[no-redef]
|
@sensorNames.setter # type: ignore[no-redef]
|
||||||
def sensorNames(self, val: list) -> None:
|
def sensorNames(self, val: list) -> None:
|
||||||
self._sensor_names = list(val or [])
|
self._sensor_names = list(val or [])
|
||||||
|
self._recompute_view_stats()
|
||||||
self.sensorNamesChanged.emit()
|
self.sensorNamesChanged.emit()
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
|
# ── Viewport properties (replay chart zoom/pan) ────────────────────────
|
||||||
|
|
||||||
|
@Property(int, notify=viewportChanged)
|
||||||
|
def viewStartIndex(self) -> int:
|
||||||
|
return self._view_start
|
||||||
|
|
||||||
|
@viewStartIndex.setter # type: ignore[no-redef]
|
||||||
|
def viewStartIndex(self, val: int) -> None:
|
||||||
|
v = max(0, int(val))
|
||||||
|
if v == self._view_start:
|
||||||
|
return
|
||||||
|
self._view_start = v
|
||||||
|
self._on_viewport_changed()
|
||||||
|
|
||||||
|
@Property(int, notify=viewportChanged)
|
||||||
|
def viewEndIndex(self) -> int:
|
||||||
|
return self._view_end
|
||||||
|
|
||||||
|
@viewEndIndex.setter # type: ignore[no-redef]
|
||||||
|
def viewEndIndex(self, val: int) -> None:
|
||||||
|
v = int(val)
|
||||||
|
if v == self._view_end:
|
||||||
|
return
|
||||||
|
self._view_end = v
|
||||||
|
self._on_viewport_changed()
|
||||||
|
|
||||||
|
@Property(bool, notify=viewportChanged)
|
||||||
|
def showMinMaxMarkers(self) -> bool:
|
||||||
|
return self._show_min_max_markers
|
||||||
|
|
||||||
|
@showMinMaxMarkers.setter # type: ignore[no-redef]
|
||||||
|
def showMinMaxMarkers(self, val: bool) -> None:
|
||||||
|
v = bool(val)
|
||||||
|
if v == self._show_min_max_markers:
|
||||||
|
return
|
||||||
|
self._show_min_max_markers = v
|
||||||
|
self.viewportChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property(float, notify=viewStatsChanged)
|
||||||
|
def viewMin(self) -> float:
|
||||||
|
return self._view_min
|
||||||
|
|
||||||
|
@Property(float, notify=viewStatsChanged)
|
||||||
|
def viewMax(self) -> float:
|
||||||
|
return self._view_max
|
||||||
|
|
||||||
|
@Property(float, notify=viewStatsChanged)
|
||||||
|
def viewAvg(self) -> float:
|
||||||
|
return self._view_avg
|
||||||
|
|
||||||
|
@Property(str, notify=viewStatsChanged)
|
||||||
|
def viewMinSensor(self) -> str:
|
||||||
|
return self._view_min_sensor
|
||||||
|
|
||||||
|
@Property(str, notify=viewStatsChanged)
|
||||||
|
def viewMaxSensor(self) -> str:
|
||||||
|
return self._view_max_sensor
|
||||||
|
|
||||||
|
# ── Zoom/pan slots (replay chart interaction; QML MouseArea calls these
|
||||||
|
# with fractions derived from wheel/drag pixel deltas) ───────────────
|
||||||
|
|
||||||
|
@Slot(float, float)
|
||||||
|
def zoomAtFraction(self, frac: float, factor: float) -> None:
|
||||||
|
"""Zoom the viewport around a fractional X position within it.
|
||||||
|
|
||||||
|
`frac` in [0, 1] is where within the current viewport to zoom around;
|
||||||
|
`factor < 1` narrows the window (zoom in), `factor > 1` widens it
|
||||||
|
(zoom out). Clamped to data bounds and a minimum 1-index window.
|
||||||
|
"""
|
||||||
|
max_len = max((len(s) for s in self._series_data), default=0)
|
||||||
|
if max_len < 2:
|
||||||
|
return
|
||||||
|
start = float(self._view_start)
|
||||||
|
end = float(self._resolved_view_end())
|
||||||
|
width = max(1.0, end - start)
|
||||||
|
f = max(0.0, min(1.0, frac))
|
||||||
|
center = start + f * width
|
||||||
|
new_width = max(1.0, min(float(max_len - 1), width * max(0.01, factor)))
|
||||||
|
new_start = center - f * new_width
|
||||||
|
new_end = new_start + new_width
|
||||||
|
self._set_viewport_clamped(new_start, new_end, max_len)
|
||||||
|
|
||||||
|
@Slot(float)
|
||||||
|
def panByFraction(self, delta_frac: float) -> None:
|
||||||
|
"""Shift the viewport by `delta_frac * window_width` indices."""
|
||||||
|
max_len = max((len(s) for s in self._series_data), default=0)
|
||||||
|
if max_len < 2:
|
||||||
|
return
|
||||||
|
start = float(self._view_start)
|
||||||
|
end = float(self._resolved_view_end())
|
||||||
|
width = max(1.0, end - start)
|
||||||
|
shift = delta_frac * width
|
||||||
|
self._set_viewport_clamped(start + shift, end + shift, max_len)
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def resetZoom(self) -> None:
|
||||||
|
"""Restore the full-range viewport sentinel (0, -1)."""
|
||||||
|
self._set_viewport(0, -1)
|
||||||
|
|
||||||
|
def _set_viewport_clamped(self, new_start: float, new_end: float, max_len: int) -> None:
|
||||||
|
if new_start < 0.0:
|
||||||
|
new_end -= new_start
|
||||||
|
new_start = 0.0
|
||||||
|
if new_end > max_len - 1:
|
||||||
|
new_start -= new_end - (max_len - 1)
|
||||||
|
new_end = float(max_len - 1)
|
||||||
|
new_start = max(0.0, new_start)
|
||||||
|
self._set_viewport(int(round(new_start)), int(round(new_end)))
|
||||||
|
|
||||||
|
def _set_viewport(self, start: int, end: int) -> None:
|
||||||
|
"""Set the viewport bounds directly (not through the QML-facing
|
||||||
|
property setters, so this stays a plain attribute write for mypy)."""
|
||||||
|
new_start = max(0, start)
|
||||||
|
if new_start == self._view_start and end == self._view_end:
|
||||||
|
return
|
||||||
|
self._view_start = new_start
|
||||||
|
self._view_end = end
|
||||||
|
self._on_viewport_changed()
|
||||||
|
|
||||||
@Property(str, notify=titleChanged)
|
@Property(str, notify=titleChanged)
|
||||||
def title(self) -> str:
|
def title(self) -> str:
|
||||||
return self._title
|
return self._title
|
||||||
@@ -194,13 +381,38 @@ class GraphQuickItem(QQuickPaintedItem):
|
|||||||
|
|
||||||
# ── internal ──────────────────────────────────────────────────────────────
|
# ── internal ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _resolved_view_end(self) -> int:
|
||||||
|
"""`viewEndIndex` with the -1 ("last index") sentinel resolved."""
|
||||||
|
max_len = max((len(s) for s in self._series_data), default=0)
|
||||||
|
if self._view_end < 0 or self._view_end > max_len - 1:
|
||||||
|
return max_len - 1
|
||||||
|
return self._view_end
|
||||||
|
|
||||||
|
def _on_viewport_changed(self) -> None:
|
||||||
|
self._auto_range()
|
||||||
|
self._recompute_view_stats()
|
||||||
|
self.viewportChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def _recompute_view_stats(self) -> None:
|
||||||
|
end = self._resolved_view_end()
|
||||||
|
vmin, vmax, avg, min_sensor, max_sensor = viewport_stats(
|
||||||
|
self._series_data, self._sensor_names, self._view_start, end
|
||||||
|
)
|
||||||
|
self._view_min, self._view_max, self._view_avg = vmin, vmax, avg
|
||||||
|
self._view_min_sensor, self._view_max_sensor = min_sensor, max_sensor
|
||||||
|
self.viewStatsChanged.emit()
|
||||||
|
|
||||||
def _auto_range(self) -> None:
|
def _auto_range(self) -> None:
|
||||||
"""Set Y range to cover all data with 10% padding."""
|
"""Set Y range to cover the current viewport's data with 10% padding."""
|
||||||
|
start = max(0, self._view_start)
|
||||||
|
end = self._resolved_view_end()
|
||||||
all_vals: list[float] = []
|
all_vals: list[float] = []
|
||||||
for series in self._series_data:
|
for series in self._series_data:
|
||||||
for v in series:
|
hi = min(len(series) - 1, end)
|
||||||
|
for i in range(start, hi + 1):
|
||||||
try:
|
try:
|
||||||
all_vals.append(float(v))
|
all_vals.append(float(series[i]))
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
if not all_vals:
|
if not all_vals:
|
||||||
@@ -319,18 +531,23 @@ class GraphQuickItem(QQuickPaintedItem):
|
|||||||
self._x_label,
|
self._x_label,
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- X-axis ticks ---
|
# --- Viewport slice (replay chart zoom/pan; full range by default,
|
||||||
num_points = max(len(s) for s in self._series_data) if self._series_data else 0
|
# so unset viewport reproduces the pre-viewport rendering) ---
|
||||||
if num_points > 1:
|
view_start = max(0, self._view_start)
|
||||||
n_x_ticks = min(num_points, max(2, int(plot_w // 80)))
|
view_end = self._resolved_view_end()
|
||||||
x_tick_step = (num_points - 1) / max(1, n_x_ticks - 1)
|
view_len = max(1, view_end - view_start + 1)
|
||||||
|
|
||||||
|
# --- X-axis ticks (labelled in original, non-sliced index space) ---
|
||||||
|
if view_len > 1:
|
||||||
|
n_x_ticks = min(view_len, max(2, int(plot_w // 80)))
|
||||||
|
x_tick_step = (view_len - 1) / max(1, n_x_ticks - 1)
|
||||||
for i in range(n_x_ticks):
|
for i in range(n_x_ticks):
|
||||||
idx = int(round(i * x_tick_step))
|
rel_idx = int(round(i * x_tick_step))
|
||||||
x_px = index_to_pixel(idx, num_points, plot_left, plot_w)
|
x_px = index_to_pixel(rel_idx, view_len, plot_left, plot_w)
|
||||||
painter.setPen(axis_pen)
|
painter.setPen(axis_pen)
|
||||||
painter.drawText(
|
painter.drawText(
|
||||||
int(x_px - 12), int(plot_top + plot_h + 14),
|
int(x_px - 12), int(plot_top + plot_h + 14),
|
||||||
str(idx + 1),
|
str(view_start + rel_idx + 1),
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- Axes boundary ---
|
# --- Axes boundary ---
|
||||||
@@ -339,28 +556,36 @@ class GraphQuickItem(QQuickPaintedItem):
|
|||||||
painter.drawLine(int(plot_left), int(plot_top), int(plot_left), int(plot_top + plot_h))
|
painter.drawLine(int(plot_left), int(plot_top), int(plot_left), int(plot_top + plot_h))
|
||||||
painter.drawLine(int(plot_left), int(plot_top + plot_h), int(plot_left + plot_w), int(plot_top + plot_h))
|
painter.drawLine(int(plot_left), int(plot_top + plot_h), int(plot_left + plot_w), int(plot_top + plot_h))
|
||||||
|
|
||||||
# --- Figure out max series length ---
|
# --- Draw each series (sliced to the current viewport) ---
|
||||||
max_len = max(len(s) for s in self._series_data) if self._series_data else 0
|
min_pt: tuple[float, float] | None = None
|
||||||
if max_len < 1:
|
max_pt: tuple[float, float] | None = None
|
||||||
return
|
min_seen = math.inf
|
||||||
|
max_seen = -math.inf
|
||||||
# --- Draw each series ---
|
|
||||||
for si, series in enumerate(self._series_data):
|
for si, series in enumerate(self._series_data):
|
||||||
if len(series) < 1:
|
hi = min(len(series) - 1, view_end)
|
||||||
|
sliced = series[view_start:hi + 1] if view_start <= hi else []
|
||||||
|
if not sliced:
|
||||||
continue
|
continue
|
||||||
color = self._series_colors[si % len(self._series_colors)]
|
color = self._series_colors[si % len(self._series_colors)]
|
||||||
pen = QPen(color, 2)
|
pen = QPen(color, 2)
|
||||||
painter.setPen(pen)
|
painter.setPen(pen)
|
||||||
|
|
||||||
pts: list[tuple[float, float]] = []
|
pts: list[tuple[float, float]] = []
|
||||||
for i, val in enumerate(series):
|
for i, val in enumerate(sliced):
|
||||||
try:
|
try:
|
||||||
v = float(val)
|
v = float(val)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
continue
|
continue
|
||||||
x_px = index_to_pixel(i, max_len, plot_left, plot_w)
|
x_px = index_to_pixel(i, view_len, plot_left, plot_w)
|
||||||
y_px = value_to_pixel(v, self._min_y, self._max_y, plot_top, plot_h)
|
y_px = value_to_pixel(v, self._min_y, self._max_y, plot_top, plot_h)
|
||||||
pts.append((x_px, y_px))
|
pts.append((x_px, y_px))
|
||||||
|
if self._show_min_max_markers:
|
||||||
|
if v < min_seen:
|
||||||
|
min_seen = v
|
||||||
|
min_pt = (x_px, y_px)
|
||||||
|
if v > max_seen:
|
||||||
|
max_seen = v
|
||||||
|
max_pt = (x_px, y_px)
|
||||||
|
|
||||||
if len(pts) < 2:
|
if len(pts) < 2:
|
||||||
if len(pts) == 1:
|
if len(pts) == 1:
|
||||||
@@ -373,6 +598,19 @@ class GraphQuickItem(QQuickPaintedItem):
|
|||||||
int(pts[i + 1][0]), int(pts[i + 1][1]),
|
int(pts[i + 1][0]), int(pts[i + 1][1]),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# --- Min/max markers (replay chart only; PopupChartForm.cs parity) ---
|
||||||
|
if self._show_min_max_markers:
|
||||||
|
marker_font = QFont()
|
||||||
|
marker_font.setPixelSize(14)
|
||||||
|
marker_font.setBold(True)
|
||||||
|
painter.setFont(marker_font)
|
||||||
|
if max_pt is not None:
|
||||||
|
painter.setPen(QPen(QColor("#FF5757")))
|
||||||
|
painter.drawText(int(max_pt[0] - 6), int(max_pt[1] - 8), "▼")
|
||||||
|
if min_pt is not None:
|
||||||
|
painter.setPen(QPen(QColor("#42A5F5")))
|
||||||
|
painter.drawText(int(min_pt[0] - 6), int(min_pt[1] + 18), "▲")
|
||||||
|
|
||||||
# --- Legend ---
|
# --- Legend ---
|
||||||
if self._show_legend and self._sensor_names:
|
if self._show_legend and self._sensor_names:
|
||||||
legend_font = QFont()
|
legend_font = QFont()
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
"""QQuickPaintedItem trend chart — running-average temperature over time.
|
"""QQuickPaintedItem trend chart — running-average temperature over time.
|
||||||
|
|
||||||
Renders a single polyline series (per-frame average temperatures) using QPainter.
|
Renders a single polyline series (per-frame average temperatures) over a fixed
|
||||||
Mirrors the @QmlElement registration pattern used by `WaferMapItem` so it can
|
0-200°C Y axis and an elapsed-seconds X axis, using a bounded 60-second
|
||||||
be embedded directly in QML via `import ISC.Wafer` and used as `TrendChartItem { }`.
|
rolling window. Mirrors the @QmlElement registration pattern used by
|
||||||
|
`WaferMapItem` so it can be embedded directly in QML via `import ISC.Wafer`
|
||||||
|
and used as `TrendChartItem { }`.
|
||||||
|
|
||||||
The series is driven by the existing `streamController.trendData` signal, which
|
The series is driven by `streamController.trendDelta` (one new
|
||||||
emits a JSON-encoded list of floats from both review-mode replay and live-mode
|
`[elapsed_s, value]` point per live frame) and cleared on
|
||||||
streaming.
|
`streamController.trendReset` (new stream started). See
|
||||||
|
docs/adr/0001-trend-chart-axes-standardization.md.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import math
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from PySide6.QtCore import Property, QPoint, QRectF, Qt, Signal, Slot
|
from PySide6.QtCore import Property, QPoint, QRectF, Qt, Signal, Slot
|
||||||
@@ -20,19 +22,23 @@ from PySide6.QtGui import QColor, QFont, QPainter, QPen, QPolygon
|
|||||||
from PySide6.QtQml import QmlElement
|
from PySide6.QtQml import QmlElement
|
||||||
from PySide6.QtQuick import QQuickPaintedItem
|
from PySide6.QtQuick import QQuickPaintedItem
|
||||||
|
|
||||||
from pygui.backend.visualization.chart_geometry import index_to_pixel, value_to_pixel
|
from pygui.backend.visualization.chart_geometry import elapsed_to_pixel, value_to_pixel
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
QML_IMPORT_NAME = "ISC.Wafer"
|
QML_IMPORT_NAME = "ISC.Wafer"
|
||||||
QML_IMPORT_MAJOR_VERSION = 1
|
QML_IMPORT_MAJOR_VERSION = 1
|
||||||
|
|
||||||
|
WINDOW_SECONDS = 60.0
|
||||||
|
Y_MIN = 0.0
|
||||||
|
Y_MAX = 200.0
|
||||||
|
Y_TICKS = (0, 40, 80, 120, 160, 200)
|
||||||
|
|
||||||
|
|
||||||
@QmlElement
|
@QmlElement
|
||||||
class TrendChartItem(QQuickPaintedItem):
|
class TrendChartItem(QQuickPaintedItem):
|
||||||
"""Painted trend chart; driven by a list of floats via the `data` property."""
|
"""Painted trend chart; driven by incremental (elapsed_s, value) points."""
|
||||||
|
|
||||||
dataChanged = Signal()
|
|
||||||
hasDataChanged = Signal()
|
hasDataChanged = Signal()
|
||||||
lineColorChanged = Signal()
|
lineColorChanged = Signal()
|
||||||
gridColorChanged = Signal()
|
gridColorChanged = Signal()
|
||||||
@@ -45,40 +51,29 @@ class TrendChartItem(QQuickPaintedItem):
|
|||||||
self.setAntialiasing(True)
|
self.setAntialiasing(True)
|
||||||
self.setFillColor(QColor("transparent"))
|
self.setFillColor(QColor("transparent"))
|
||||||
|
|
||||||
self._data: list[float] = []
|
self._points: list[tuple[float, float]] = []
|
||||||
self._padding: int = 8
|
self._padding: int = 8
|
||||||
# Separate from `_padding` (breathing room around the plot area): these
|
# Separate from `_padding` (breathing room around the plot area): these
|
||||||
# reserve space for the axis labels themselves, which are wider than
|
# reserve space for the axis labels themselves, which are wider than
|
||||||
# `_padding` alone — that mismatch is what clipped y-axis text before.
|
# `_padding` alone — that mismatch is what clipped y-axis text before.
|
||||||
self._margin_left: int = 34
|
self._margin_left: int = 48
|
||||||
self._margin_bottom: int = 16
|
self._margin_bottom: int = 30
|
||||||
|
# Top tick label is drawn centered on the plot's top edge (y_px - 7),
|
||||||
|
# so the plot needs headroom or the label kisses the card border.
|
||||||
|
self._margin_top: int = 10
|
||||||
self._line_color = QColor("#5B9DF5")
|
self._line_color = QColor("#5B9DF5")
|
||||||
self._grid_color = QColor("#2A3441")
|
self._grid_color = QColor("#2A3441")
|
||||||
self._text_color = QColor("#CBD5E1")
|
self._text_color = QColor("#CBD5E1")
|
||||||
|
|
||||||
# ── Qt properties ─────────────────────────────────────────────────────
|
# ── Qt properties ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Property("QVariantList", notify=dataChanged)
|
|
||||||
def data(self) -> list[float]:
|
|
||||||
return self._data
|
|
||||||
|
|
||||||
@data.setter # type: ignore[no-redef]
|
|
||||||
def data(self, val) -> None:
|
|
||||||
coerced = self._coerce_floats(val)
|
|
||||||
if coerced == self._data:
|
|
||||||
return
|
|
||||||
self._data = coerced
|
|
||||||
self.dataChanged.emit()
|
|
||||||
self.hasDataChanged.emit()
|
|
||||||
self.update()
|
|
||||||
|
|
||||||
@Property(bool, notify=hasDataChanged)
|
@Property(bool, notify=hasDataChanged)
|
||||||
def hasData(self) -> bool:
|
def hasData(self) -> bool:
|
||||||
"""True when the data list has at least one point.
|
"""True when at least one point is buffered.
|
||||||
|
|
||||||
QML can bind to this safely (QVariantList `.length` is not bindable).
|
QML can bind to this safely (a plain list length is not bindable).
|
||||||
"""
|
"""
|
||||||
return len(self._data) > 0
|
return len(self._points) > 0
|
||||||
|
|
||||||
@Property(QColor, notify=lineColorChanged)
|
@Property(QColor, notify=lineColorChanged)
|
||||||
def lineColor(self) -> QColor:
|
def lineColor(self) -> QColor:
|
||||||
@@ -129,24 +124,48 @@ class TrendChartItem(QQuickPaintedItem):
|
|||||||
self.paddingChanged.emit()
|
self.paddingChanged.emit()
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
# ── Convenience slot for QML: accept a JSON string from a Signal(str) ──
|
# ── slots for QML: streamController.trendDelta / trendReset ────────────
|
||||||
|
|
||||||
@Slot(str)
|
@Slot(str)
|
||||||
def setDataFromJson(self, avgs_json: str) -> None:
|
def appendDelta(self, delta_json: str) -> None:
|
||||||
"""Slot for QML Connections handler: parse JSON array and update data.
|
"""Append new (elapsed_s, value) points and prune the 60s window.
|
||||||
|
|
||||||
Mirrors `streamController.trendData` (which emits JSON-encoded floats).
|
Mirrors `streamController.trendDelta`, which emits one new pair per
|
||||||
|
live frame as `[[elapsed_s, value], ...]`.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
parsed = json.loads(avgs_json) if avgs_json else []
|
parsed = json.loads(delta_json) if delta_json else []
|
||||||
except (json.JSONDecodeError, TypeError) as exc:
|
except (json.JSONDecodeError, TypeError) as exc:
|
||||||
log.error("TrendChartItem: failed to parse trend JSON: %s", exc)
|
log.error("TrendChartItem: failed to parse trend delta JSON: %s", exc)
|
||||||
return
|
return
|
||||||
coerced = self._coerce_floats(parsed)
|
|
||||||
if coerced == self._data:
|
had_data = len(self._points) > 0
|
||||||
|
added = False
|
||||||
|
for pair in parsed:
|
||||||
|
try:
|
||||||
|
elapsed, value = float(pair[0]), float(pair[1])
|
||||||
|
except (TypeError, ValueError, IndexError):
|
||||||
|
continue
|
||||||
|
self._points.append((elapsed, value))
|
||||||
|
added = True
|
||||||
|
|
||||||
|
if not added:
|
||||||
return
|
return
|
||||||
self._data = coerced
|
|
||||||
self.dataChanged.emit()
|
latest_elapsed = self._points[-1][0]
|
||||||
|
cutoff = latest_elapsed - WINDOW_SECONDS
|
||||||
|
self._points = [p for p in self._points if p[0] >= cutoff]
|
||||||
|
|
||||||
|
if not had_data:
|
||||||
|
self.hasDataChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def clearTrend(self) -> None:
|
||||||
|
"""Clear all buffered points (new stream started)."""
|
||||||
|
if not self._points:
|
||||||
|
return
|
||||||
|
self._points = []
|
||||||
self.hasDataChanged.emit()
|
self.hasDataChanged.emit()
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
@@ -162,58 +181,38 @@ class TrendChartItem(QQuickPaintedItem):
|
|||||||
return
|
return
|
||||||
|
|
||||||
left = self._padding + self._margin_left
|
left = self._padding + self._margin_left
|
||||||
|
top = self._padding + self._margin_top
|
||||||
bottom_inset = self._padding + self._margin_bottom
|
bottom_inset = self._padding + self._margin_bottom
|
||||||
plot_rect = QRectF(left, self._padding,
|
plot_rect = QRectF(left, top,
|
||||||
max(1, w - left - self._padding),
|
max(1, w - left - self._padding),
|
||||||
max(1, h - self._padding - bottom_inset))
|
max(1, h - top - bottom_inset))
|
||||||
|
|
||||||
self._draw_grid(painter, plot_rect)
|
self._draw_grid(painter, plot_rect)
|
||||||
|
|
||||||
if len(self._data) < 2:
|
if len(self._points) < 2:
|
||||||
return
|
return
|
||||||
|
|
||||||
y_min, y_max = self._y_range()
|
window_end = self._points[-1][0]
|
||||||
self._draw_axes_labels(painter, plot_rect, y_min, y_max)
|
window_start = max(0.0, window_end - WINDOW_SECONDS)
|
||||||
self._draw_line(painter, plot_rect, y_min, y_max)
|
self._draw_axes_labels(painter, plot_rect, window_start, window_end)
|
||||||
|
self._draw_line(painter, plot_rect, window_start, window_end)
|
||||||
|
|
||||||
# ── internals ─────────────────────────────────────────────────────────
|
# ── internals ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _coerce_floats(self, val) -> list[float]:
|
def _x_to_px(self, elapsed: float, window_start: float, window_end: float, plot_rect: QRectF) -> float:
|
||||||
if not val:
|
return elapsed_to_pixel(elapsed, window_start, window_end, plot_rect.left(), plot_rect.width())
|
||||||
return []
|
|
||||||
out: list[float] = []
|
|
||||||
for v in val:
|
|
||||||
try:
|
|
||||||
out.append(float(v))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
continue
|
|
||||||
return out
|
|
||||||
|
|
||||||
def _y_range(self) -> tuple[float, float]:
|
def _y_to_px(self, value: float, plot_rect: QRectF) -> float:
|
||||||
lo = min(self._data)
|
px = value_to_pixel(value, Y_MIN, Y_MAX, plot_rect.top(), plot_rect.height())
|
||||||
hi = max(self._data)
|
# Clamp: a sensor reading outside 0-200°C shouldn't paint outside the widget.
|
||||||
if hi - lo < 1e-9:
|
return max(plot_rect.top(), min(plot_rect.bottom(), px))
|
||||||
lo, hi = lo - 5.0, hi + 5.0
|
|
||||||
# Snap to a "nice" tick step (1/2/5 × 10^k) so the axis holds still
|
|
||||||
# while streaming and only moves when data crosses a step boundary —
|
|
||||||
# raw min/max rescaled every frame, which read as an unstable chart.
|
|
||||||
raw_step = (hi - lo) / 4
|
|
||||||
mag = 10 ** math.floor(math.log10(raw_step))
|
|
||||||
step = next(s * mag for s in (1, 2, 5, 10) if s * mag >= raw_step)
|
|
||||||
return math.floor(lo / step) * step, math.ceil(hi / step) * step
|
|
||||||
|
|
||||||
def _x_to_px(self, i: int, n: int, plot_rect: QRectF) -> float:
|
|
||||||
return index_to_pixel(i, n, plot_rect.left(), plot_rect.width())
|
|
||||||
|
|
||||||
def _y_to_px(self, v: float, y_min: float, y_max: float, plot_rect: QRectF) -> float:
|
|
||||||
return value_to_pixel(v, y_min, y_max, plot_rect.top(), plot_rect.height())
|
|
||||||
|
|
||||||
def _draw_grid(self, painter: QPainter, plot_rect: QRectF) -> None:
|
def _draw_grid(self, painter: QPainter, plot_rect: QRectF) -> None:
|
||||||
pen = QPen(self._grid_color)
|
pen = QPen(self._grid_color)
|
||||||
pen.setWidthF(1.0)
|
pen.setWidthF(1.0)
|
||||||
pen.setCosmetic(True)
|
pen.setCosmetic(True)
|
||||||
painter.setPen(pen)
|
painter.setPen(pen)
|
||||||
rows = 4
|
rows = len(Y_TICKS) - 1
|
||||||
for i in range(rows + 1):
|
for i in range(rows + 1):
|
||||||
y = plot_rect.top() + (i / rows) * plot_rect.height()
|
y = plot_rect.top() + (i / rows) * plot_rect.height()
|
||||||
painter.drawLine(int(plot_rect.left()), int(y),
|
painter.drawLine(int(plot_rect.left()), int(y),
|
||||||
@@ -223,36 +222,29 @@ class TrendChartItem(QQuickPaintedItem):
|
|||||||
self,
|
self,
|
||||||
painter: QPainter,
|
painter: QPainter,
|
||||||
plot_rect: QRectF,
|
plot_rect: QRectF,
|
||||||
y_min: float,
|
window_start: float,
|
||||||
y_max: float,
|
window_end: float,
|
||||||
) -> None:
|
) -> None:
|
||||||
painter.setPen(self._text_color)
|
painter.setPen(self._text_color)
|
||||||
font = QFont(painter.font())
|
font = QFont(painter.font())
|
||||||
font.setPointSizeF(max(7.0, font.pointSizeF() * 0.85))
|
font.setPointSizeF(max(7.0, font.pointSizeF() * 0.85))
|
||||||
painter.setFont(font)
|
painter.setFont(font)
|
||||||
|
|
||||||
rows = 4
|
title_w = 14 # leftmost sliver reserved for the rotated "Temp" title
|
||||||
label_w = self._margin_left - 4 # 4px gutter before the plot area
|
label_w = self._margin_left - 4 - title_w # 4px gutter before the plot area
|
||||||
for i in range(rows + 1):
|
for tick in reversed(Y_TICKS):
|
||||||
t = i / rows
|
y_px = self._y_to_px(tick, plot_rect)
|
||||||
y_val = y_max - t * (y_max - y_min)
|
painter.drawText(QRectF(title_w, y_px - 7, label_w, 14),
|
||||||
y_px = plot_rect.top() + t * plot_rect.height()
|
|
||||||
label = f"{y_val:g}"
|
|
||||||
painter.drawText(QRectF(0, y_px - 7, label_w, 14),
|
|
||||||
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
|
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
|
||||||
label)
|
str(tick))
|
||||||
|
|
||||||
# X-axis: sample index. `trendData` carries plain averages with no
|
# X-axis: elapsed seconds within the current rolling window.
|
||||||
# timestamps attached, so index is the only x-coordinate available —
|
cols = 4
|
||||||
# it reads correctly for review mode (index == frame number) and as a
|
|
||||||
# relative recency order for the live rolling window. paint() already
|
|
||||||
# guarantees len(self._data) >= 2 before calling this.
|
|
||||||
n = len(self._data)
|
|
||||||
cols = min(4, n - 1)
|
|
||||||
y_px = plot_rect.bottom() + 4
|
y_px = plot_rect.bottom() + 4
|
||||||
for i in range(cols + 1):
|
for i in range(cols + 1):
|
||||||
idx = round((i / cols) * (n - 1))
|
t_val = window_start + (i / cols) * (window_end - window_start)
|
||||||
x_px = self._x_to_px(idx, n, plot_rect)
|
x_px = self._x_to_px(t_val, window_start, window_end, plot_rect)
|
||||||
|
label = f"{t_val:.0f}"
|
||||||
if i == 0:
|
if i == 0:
|
||||||
rect = QRectF(x_px, y_px, 40, 14)
|
rect = QRectF(x_px, y_px, 40, 14)
|
||||||
align = Qt.AlignmentFlag.AlignLeft
|
align = Qt.AlignmentFlag.AlignLeft
|
||||||
@@ -263,16 +255,36 @@ class TrendChartItem(QQuickPaintedItem):
|
|||||||
else:
|
else:
|
||||||
rect = QRectF(x_px - 20, y_px, 40, 14)
|
rect = QRectF(x_px - 20, y_px, 40, 14)
|
||||||
align = Qt.AlignmentFlag.AlignHCenter
|
align = Qt.AlignmentFlag.AlignHCenter
|
||||||
painter.drawText(rect, align | Qt.AlignmentFlag.AlignTop, str(idx))
|
painter.drawText(rect, align | Qt.AlignmentFlag.AlignTop, label)
|
||||||
|
|
||||||
|
self._draw_axis_titles(painter, plot_rect, title_w, y_px + 18)
|
||||||
|
|
||||||
|
def _draw_axis_titles(
|
||||||
|
self,
|
||||||
|
painter: QPainter,
|
||||||
|
plot_rect: QRectF,
|
||||||
|
title_w: float,
|
||||||
|
x_label_bottom: float,
|
||||||
|
) -> None:
|
||||||
|
# Y axis: "Temp", rotated upright, centered along the plot's height.
|
||||||
|
painter.save()
|
||||||
|
painter.translate(title_w / 2, plot_rect.center().y())
|
||||||
|
painter.rotate(-90)
|
||||||
|
painter.drawText(QRectF(-plot_rect.height() / 2, -7, plot_rect.height(), 14),
|
||||||
|
Qt.AlignmentFlag.AlignCenter, "Temp (°C)")
|
||||||
|
painter.restore()
|
||||||
|
|
||||||
|
# X axis: "Time", centered under the elapsed-seconds tick row.
|
||||||
|
painter.drawText(QRectF(plot_rect.left(), x_label_bottom, plot_rect.width(), 14),
|
||||||
|
Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop, "Time")
|
||||||
|
|
||||||
def _draw_line(
|
def _draw_line(
|
||||||
self,
|
self,
|
||||||
painter: QPainter,
|
painter: QPainter,
|
||||||
plot_rect: QRectF,
|
plot_rect: QRectF,
|
||||||
y_min: float,
|
window_start: float,
|
||||||
y_max: float,
|
window_end: float,
|
||||||
) -> None:
|
) -> None:
|
||||||
n = len(self._data)
|
|
||||||
pen = QPen(self._line_color)
|
pen = QPen(self._line_color)
|
||||||
pen.setWidthF(2.0)
|
pen.setWidthF(2.0)
|
||||||
pen.setCosmetic(True)
|
pen.setCosmetic(True)
|
||||||
@@ -281,15 +293,16 @@ class TrendChartItem(QQuickPaintedItem):
|
|||||||
painter.setPen(pen)
|
painter.setPen(pen)
|
||||||
|
|
||||||
poly = QPolygon()
|
poly = QPolygon()
|
||||||
for i, v in enumerate(self._data):
|
for elapsed, value in self._points:
|
||||||
px = self._x_to_px(i, n, plot_rect)
|
px = self._x_to_px(elapsed, window_start, window_end, plot_rect)
|
||||||
py = self._y_to_px(v, y_min, y_max, plot_rect)
|
py = self._y_to_px(value, plot_rect)
|
||||||
poly.append(QPoint(int(px), int(py)))
|
poly.append(QPoint(int(px), int(py)))
|
||||||
painter.drawPolyline(poly)
|
painter.drawPolyline(poly)
|
||||||
|
|
||||||
# Trailing dot at the last data point
|
# Trailing dot at the last data point
|
||||||
last_x = int(self._x_to_px(n - 1, n, plot_rect))
|
last_elapsed, last_value = self._points[-1]
|
||||||
last_y = int(self._y_to_px(self._data[-1], y_min, y_max, plot_rect))
|
last_x = int(self._x_to_px(last_elapsed, window_start, window_end, plot_rect))
|
||||||
|
last_y = int(self._y_to_px(last_value, plot_rect))
|
||||||
painter.setBrush(self._line_color)
|
painter.setBrush(self._line_color)
|
||||||
painter.setPen(Qt.PenStyle.NoPen)
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
radius = 3
|
radius = 3
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import logging
|
|||||||
import math
|
import math
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from PySide6.QtCore import Property, QPoint, Qt, Signal, Slot
|
from PySide6.QtCore import Property, QPoint, QRectF, Qt, Signal, Slot
|
||||||
from PySide6.QtGui import (
|
from PySide6.QtGui import (
|
||||||
QBrush,
|
QBrush,
|
||||||
QColor,
|
QColor,
|
||||||
@@ -27,6 +27,7 @@ from PySide6.QtGui import (
|
|||||||
from PySide6.QtQml import QmlElement
|
from PySide6.QtQml import QmlElement
|
||||||
from PySide6.QtQuick import QQuickPaintedItem
|
from PySide6.QtQuick import QQuickPaintedItem
|
||||||
|
|
||||||
|
from pygui.backend.models.frame_stats import compute_stats
|
||||||
from pygui.backend.visualization.rbf_heatmap import (
|
from pygui.backend.visualization.rbf_heatmap import (
|
||||||
ellipse_alpha,
|
ellipse_alpha,
|
||||||
interpolate_field,
|
interpolate_field,
|
||||||
@@ -36,6 +37,59 @@ from pygui.backend.wafer.zwafer_models import Sensor
|
|||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_thickness_csv(file_path: str) -> tuple[list[list[float]], str]:
|
||||||
|
"""Parse a customer thickness CSV into [x, y, t2] points (mm from center).
|
||||||
|
|
||||||
|
Expected columns (C# parity): Lot Start Time, RC, Site#, Slot#, T2,
|
||||||
|
NGOF, Wafer X, Wafer Y. Only the first wafer (first Slot# value) is read.
|
||||||
|
Returns (points, error) — error is "" on success.
|
||||||
|
"""
|
||||||
|
import csv
|
||||||
|
|
||||||
|
points: list[list[float]] = []
|
||||||
|
slot: str | None = None
|
||||||
|
try:
|
||||||
|
with open(file_path, newline="") as fh:
|
||||||
|
reader = csv.reader(fh)
|
||||||
|
header = next(reader, None)
|
||||||
|
if header is None:
|
||||||
|
return [], "Unable to read header row from CSV file."
|
||||||
|
if header and header[-1] == "": # trailing comma
|
||||||
|
header = header[:-1]
|
||||||
|
nfields = len(header)
|
||||||
|
if nfields != 8:
|
||||||
|
return [], "Incorrect field count in CSV header row."
|
||||||
|
for lno, row in enumerate(reader, start=2):
|
||||||
|
if row and row[-1] == "":
|
||||||
|
row = row[:-1]
|
||||||
|
if len(row) != nfields:
|
||||||
|
return [], f"Incorrect field count in CSV row {lno}"
|
||||||
|
if slot is None:
|
||||||
|
slot = row[3]
|
||||||
|
elif row[3] != slot:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
t2, x, y = float(row[4]), float(row[6]), float(row[7])
|
||||||
|
except ValueError:
|
||||||
|
return [], f"Malformed value in CSV row {lno}"
|
||||||
|
points.append([x, y, t2])
|
||||||
|
except OSError as exc:
|
||||||
|
return [], f"Unable to read CSV file: {exc}"
|
||||||
|
if not points:
|
||||||
|
return [], "No data found in CSV file."
|
||||||
|
return points, ""
|
||||||
|
|
||||||
|
|
||||||
|
def readout_text(values: list[float]) -> str:
|
||||||
|
"""Full READOUT stats line for the export footer; '' when no data."""
|
||||||
|
if not values:
|
||||||
|
return ""
|
||||||
|
s = compute_stats(values)
|
||||||
|
return (f"Sensors: {len(values)} Min: {s.min:.2f}°C (#{s.min_index + 1}) "
|
||||||
|
f"Max: {s.max:.2f}°C (#{s.max_index + 1}) Diff: {s.diff:.2f} "
|
||||||
|
f"Avg: {s.avg:.2f} σ: {s.sigma:.2f} 3σ: {s.three_sigma:.2f}")
|
||||||
|
|
||||||
QML_IMPORT_NAME = "ISC.Wafer"
|
QML_IMPORT_NAME = "ISC.Wafer"
|
||||||
QML_IMPORT_MAJOR_VERSION = 1
|
QML_IMPORT_MAJOR_VERSION = 1
|
||||||
|
|
||||||
@@ -51,6 +105,7 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
marginChanged = Signal()
|
marginChanged = Signal()
|
||||||
blendChanged = Signal()
|
blendChanged = Signal()
|
||||||
showLabelsChanged = Signal()
|
showLabelsChanged = Signal()
|
||||||
|
showExtremesChanged = Signal()
|
||||||
colorsChanged = Signal()
|
colorsChanged = Signal()
|
||||||
shapeChanged = Signal()
|
shapeChanged = Signal()
|
||||||
sizeChanged = Signal()
|
sizeChanged = Signal()
|
||||||
@@ -69,9 +124,10 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
self._margin: float = 1.0
|
self._margin: float = 1.0
|
||||||
self._blend: float = 0.0
|
self._blend: float = 0.0
|
||||||
self._show_labels: bool = True
|
self._show_labels: bool = True
|
||||||
|
self._show_extremes: bool = True
|
||||||
self._shape: str = "round"
|
self._shape: str = "round"
|
||||||
self._size: float = 300.0
|
self._size: float = 300.0
|
||||||
self._thickness_data: list[float] = []
|
self._thickness_data: list[list[float]] = [] # [x_mm, y_mm, t2] triples
|
||||||
self._show_thickness: bool = False
|
self._show_thickness: bool = False
|
||||||
self._thickness_heatmap:QImage | None = None
|
self._thickness_heatmap:QImage | None = None
|
||||||
# Hover highlight: index of the marker under the pointer (-1 = none)
|
# Hover highlight: index of the marker under the pointer (-1 = none)
|
||||||
@@ -188,6 +244,16 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
self.showLabelsChanged.emit()
|
self.showLabelsChanged.emit()
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
|
@Property(bool, notify=showExtremesChanged)
|
||||||
|
def showExtremes(self) -> bool:
|
||||||
|
return self._show_extremes
|
||||||
|
|
||||||
|
@showExtremes.setter # type: ignore[no-redef]
|
||||||
|
def showExtremes(self, val: bool) -> None:
|
||||||
|
self._show_extremes = bool(val)
|
||||||
|
self.showExtremesChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
@Property(str, notify=shapeChanged)
|
@Property(str, notify=shapeChanged)
|
||||||
def shape(self) -> str:
|
def shape(self) -> str:
|
||||||
return self._shape
|
return self._shape
|
||||||
@@ -210,13 +276,31 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
|
|
||||||
@Property("QVariantList", notify=thicknessChanged)
|
@Property("QVariantList", notify=thicknessChanged)
|
||||||
def thicknessData(self) -> list:
|
def thicknessData(self) -> list:
|
||||||
|
"""Thickness measurement points as [x_mm, y_mm, t2] triples."""
|
||||||
return self._thickness_data
|
return self._thickness_data
|
||||||
|
|
||||||
@thicknessData.setter # type: ignore[no-redef]
|
@thicknessData.setter # type: ignore[no-redef]
|
||||||
def thicknessData(self, val:list) -> None:
|
def thicknessData(self, val:list) -> None:
|
||||||
self._thickness_data = list(val or [])
|
self._thickness_data = [list(p) for p in (val or [])]
|
||||||
self._rebuild_thickness()
|
self._rebuild_thickness()
|
||||||
self.thicknessChanged.emit()
|
self.thicknessChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property(bool, notify=thicknessChanged)
|
||||||
|
def hasThickness(self) -> bool:
|
||||||
|
return bool(self._thickness_data)
|
||||||
|
|
||||||
|
@Slot(str, result=str)
|
||||||
|
def loadThickness(self, file_path: str) -> str:
|
||||||
|
"""Load a customer thickness CSV; returns error message, '' on success."""
|
||||||
|
points, err = parse_thickness_csv(file_path)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
self._thickness_data = points
|
||||||
|
self._rebuild_thickness()
|
||||||
|
self.thicknessChanged.emit()
|
||||||
|
self.update()
|
||||||
|
return ""
|
||||||
self.update
|
self.update
|
||||||
|
|
||||||
@Property(bool, notify=showThicknessChanged)
|
@Property(bool, notify=showThicknessChanged)
|
||||||
@@ -301,41 +385,73 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
# chosen output dir, plus an optional summary CSV of (file, min/max/mean).
|
# chosen output dir, plus an optional summary CSV of (file, min/max/mean).
|
||||||
# No new rendering code needed — see docs/pending/alpha-release-polish-plan.md §6.1.
|
# No new rendering code needed — see docs/pending/alpha-release-polish-plan.md §6.1.
|
||||||
@Slot(str, result=bool)
|
@Slot(str, result=bool)
|
||||||
def export_image(self, file_path: str) -> bool:
|
@Slot(str, str, result=bool)
|
||||||
|
def export_image(self, file_path: str, extra: str = "") -> bool:
|
||||||
"""Export the current wafer map rendering to a PNG file
|
"""Export the current wafer map rendering to a PNG file
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
file_pat: Absolute path for the output PNG.
|
file_path: Absolute path for the output PNG.
|
||||||
|
extra: Optional second footer line (live stream metrics).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True on success, False on failure
|
True on success, False on failure
|
||||||
"""
|
"""
|
||||||
if not file_path:
|
if not file_path or not self._values:
|
||||||
|
# No file loaded / no stream played yet — only the empty template
|
||||||
|
# would render, which isn't a meaningful export.
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
result = self.grabToImage()
|
# Render synchronously via paint() into our own image —
|
||||||
img = result.image() # type: ignore[attr-defined]
|
# grabToImage() resolves on a later render frame, so its result
|
||||||
img.save(file_path, "PNG")
|
# is null when read immediately (the old silent failure).
|
||||||
return True
|
w, h = int(self.width()), int(self.height())
|
||||||
|
if w <= 0 or h <= 0:
|
||||||
|
return False
|
||||||
|
lines = [ln for ln in (readout_text(self._values), extra) if ln]
|
||||||
|
footer_h = 28 * len(lines)
|
||||||
|
img = QImage(w, h + footer_h, QImage.Format.Format_ARGB32)
|
||||||
|
img.fill(0) # transparent background
|
||||||
|
painter = QPainter(img)
|
||||||
|
self.paint(painter)
|
||||||
|
if lines:
|
||||||
|
painter.fillRect(QRectF(0, h, w, footer_h), QColor("#101014"))
|
||||||
|
painter.setPen(QColor("#CBD5E1"))
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
painter.drawText(QRectF(0, h + 28 * i, w, 28),
|
||||||
|
Qt.AlignmentFlag.AlignCenter, line)
|
||||||
|
painter.end()
|
||||||
|
return bool(img.save(file_path))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error("Export failed: %s", e)
|
log.error("Export failed: %s", e)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
# ── internal ─────────────────────────────────────────────────────────
|
# ── internal ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _paint_extremes(self, painter: QPainter) -> None:
|
||||||
|
"""Ring the hottest (high color) and coldest (low color) markers."""
|
||||||
|
if not self._show_extremes or not self._values or not self._markers:
|
||||||
|
return
|
||||||
|
s = compute_stats(self._values)
|
||||||
|
ring_r = self._marker_r + 5
|
||||||
|
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||||
|
for idx, color in ((s.max_index, self._high_color),
|
||||||
|
(s.min_index, self._low_color)):
|
||||||
|
if idx not in self._markers:
|
||||||
|
continue
|
||||||
|
px, py = self._markers[idx]
|
||||||
|
painter.setPen(QPen(color, 2))
|
||||||
|
painter.drawEllipse(px - ring_r, py - ring_r, 2 * ring_r, 2 * ring_r)
|
||||||
|
|
||||||
def _rebuild_thickness(self) -> None:
|
def _rebuild_thickness(self) -> None:
|
||||||
"""Interpolate thickness data into a gray/orange heatmap QImage."""
|
"""Interpolate thickness points into a gray/orange heatmap QImage."""
|
||||||
if not self._sensors or not self._thickness_data:
|
if not self._thickness_data:
|
||||||
self._thickness_heatmap = None
|
self._thickness_heatmap = None
|
||||||
return
|
return
|
||||||
ds = self._draw_size()
|
ds = self._draw_size()
|
||||||
r_mm = self._wafer_radius_mm()
|
r_mm = self._wafer_radius_mm()
|
||||||
xs = np.array([s.x for s in self._sensors])
|
pts = np.array(self._thickness_data, dtype=float)
|
||||||
ys = np.array([s.y for s in self._sensors])
|
xs, ys, vs = pts[:, 0], pts[:, 1], pts[:, 2]
|
||||||
vs = np.array(self._thickness_data[:len(self._sensors)], dtype=float)
|
|
||||||
if len(vs) < len(self._sensors):
|
|
||||||
self._thickness_heatmap = None
|
|
||||||
return
|
|
||||||
try:
|
try:
|
||||||
# No round_clip — see _rebuild_heatmap for why the mask is analytic.
|
# No round_clip — see _rebuild_heatmap for why the mask is analytic.
|
||||||
field = interpolate_field(
|
field = interpolate_field(
|
||||||
@@ -554,6 +670,7 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
painter.setOpacity(1.0)
|
painter.setOpacity(1.0)
|
||||||
|
|
||||||
self._paint_markers(painter)
|
self._paint_markers(painter)
|
||||||
|
self._paint_extremes(painter)
|
||||||
|
|
||||||
def _paint_template(self, painter: QPainter, cx: int, cy: int, r_px: int) -> None:
|
def _paint_template(self, painter: QPainter, cx: int, cy: int, r_px: int) -> None:
|
||||||
ds = self._draw_size()
|
ds = self._draw_size()
|
||||||
|
|||||||
@@ -70,12 +70,35 @@ def _load_yaml(path: Path) -> dict:
|
|||||||
return loaded
|
return loaded
|
||||||
|
|
||||||
|
|
||||||
# TODO P6.3: expose this list to a new LayoutSelector.qml (4-button chooser)
|
# Edge→center sensor pair tables, ported verbatim from the C# original
|
||||||
# THINKING: this already returns everything the UI needs (family names) —
|
# (Form1.cs ec_map_*). Indices are 0-origin. Domain data, not derivable from
|
||||||
# the missing piece is a context-property exposure (SessionController or a
|
# ring geometry — see docs/adr/0002-edge-center-pair-tables.md.
|
||||||
# thin new property) and a setLayout(family) slot that calls load_layout()
|
_EC_MAPS: dict[str, tuple[tuple[int, int], ...]] = {
|
||||||
# below. No parsing work needed; this is a wiring-only task.
|
"AEP": tuple((e, 40 + e // 2) for e in range(16)),
|
||||||
# See docs/pending/alpha-release-polish-plan.md §6.3.
|
"BCD": tuple((e, 28) for e in range(12)),
|
||||||
|
"F": ((0, 18), (1, 19), (2, 19), (3, 19), (4, 20), (5, 20),
|
||||||
|
(6, 20), (7, 21), (8, 21), (9, 21), (10, 18), (11, 18)),
|
||||||
|
"X": ((5, 76), (0, 76), (39, 76), (6, 77), (11, 77), (16, 77),
|
||||||
|
(17, 78), (22, 78), (27, 78), (28, 79), (33, 79), (38, 79)),
|
||||||
|
"Z": tuple((e, 0) for e in range(49, 65)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def ec_pairs_for_wafer_id(wafer_id: str) -> tuple[tuple[int, int], ...]:
|
||||||
|
"""Edge-center pairs for a wafer id's family letter; empty if unknown.
|
||||||
|
|
||||||
|
Unknown families deliberately get no pairs (C# fell through to Z).
|
||||||
|
"""
|
||||||
|
prefix = wafer_id[0].upper() if wafer_id else ""
|
||||||
|
for families, key in (("AEP", "AEP"), ("BCD", "BCD"), ("F", "F"),
|
||||||
|
("X", "X"), ("Z", "Z")):
|
||||||
|
if prefix and prefix in families:
|
||||||
|
return _EC_MAPS[key]
|
||||||
|
return ()
|
||||||
|
|
||||||
|
|
||||||
|
# P6.3 (LayoutSelector) dropped 2026-07-10 — the C# "layout" buttons only
|
||||||
|
# exported coordinate spreadsheets, decided obsolete. See MIGRATION.md.
|
||||||
def available_families() -> list[str]:
|
def available_families() -> list[str]:
|
||||||
return [_family_name(_load_yaml(p)["name"]) for p in _LAYOUTS_DIR.glob("*.yaml")]
|
return [_family_name(_load_yaml(p)["name"]) for p in _LAYOUTS_DIR.glob("*.yaml")]
|
||||||
|
|
||||||
|
|||||||
@@ -107,6 +107,23 @@ def _convert_aep(binary_bits: list[int]) -> float:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_debug_temp(hex_str: str) -> float:
|
||||||
|
"""Convert a 4-char hex string to a debug/cold-junction temperature.
|
||||||
|
|
||||||
|
Mirrors C#'s ConvertBinaryArrayToDebugTemp: bits 4-14 scaled by
|
||||||
|
2**(i-8), sign bit at bit 0. No family-code branching — this formula
|
||||||
|
applies regardless of wafer family, unlike _convert_hex_to_temp.
|
||||||
|
"""
|
||||||
|
bits = _hex_to_binary(hex_str)
|
||||||
|
value = 0.0
|
||||||
|
for i in range(4, 15):
|
||||||
|
if bits[i]:
|
||||||
|
value += 2.0 ** (i - 8)
|
||||||
|
if bits[0]:
|
||||||
|
value = -value
|
||||||
|
return round(value, 2)
|
||||||
|
|
||||||
|
|
||||||
def _convert_hex_to_temp(hex_str: str, family_code: str) -> float:
|
def _convert_hex_to_temp(hex_str: str, family_code: str) -> float:
|
||||||
"""Convert a singi hale 4-char hex string to a float temperature."""
|
"""Convert a singi hale 4-char hex string to a float temperature."""
|
||||||
bits = _hex_to_binary(hex_str)
|
bits = _hex_to_binary(hex_str)
|
||||||
@@ -225,6 +242,15 @@ def convert_to_temperatures(
|
|||||||
return temp_value
|
return temp_value
|
||||||
|
|
||||||
|
|
||||||
|
def convert_to_debug_temperatures(hex_data: list[list[str]]) -> list[list[str]]:
|
||||||
|
"""Convert debug/cold-junction hex values to temperature strings.
|
||||||
|
|
||||||
|
Same shape contract as convert_to_temperatures, but uses the
|
||||||
|
family-independent debug formula (_convert_debug_temp).
|
||||||
|
"""
|
||||||
|
return [[str(_convert_debug_temp(h)) for h in row] for row in hex_data]
|
||||||
|
|
||||||
|
|
||||||
def remove_trailing_zeros(data: list[list[str]]) -> None:
|
def remove_trailing_zeros(data: list[list[str]]) -> None:
|
||||||
"""Remove rows of all-zero values from the end of data (in-place).
|
"""Remove rows of all-zero values from the end of data (in-place).
|
||||||
|
|
||||||
@@ -294,3 +320,46 @@ def save_to_csv(
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.error("CSV save failed: %s", exc)
|
log.error("CSV save failed: %s", exc)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def save_debug_csv(
|
||||||
|
temp_data: list[list[str]],
|
||||||
|
debug_data: list[list[str]],
|
||||||
|
output_dir: str,
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Save sensor + debug temperatures side-by-side to debug_info.csv.
|
||||||
|
|
||||||
|
Mirrors C#'s writeDebugCSV: alternating Sensor{j+1},Debug{j+1} columns,
|
||||||
|
truncated to the shorter of the two row/column counts. Fixed filename
|
||||||
|
(not timestamped), unlike save_to_csv.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
temp_data: 2D array of sensor temperature strings (D1, decoded via
|
||||||
|
convert_to_temperatures).
|
||||||
|
debug_data: 2D array of debug temperature strings (F1, decoded via
|
||||||
|
convert_to_debug_temperatures).
|
||||||
|
output_dir: Directory to save the CSV file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Full file path on success, None on empty input or write failure.
|
||||||
|
"""
|
||||||
|
if not temp_data or not debug_data:
|
||||||
|
log.error("Debug CSV save failed: temp or debug data is empty")
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
num_cols = min(len(temp_data[0]), len(debug_data[0]))
|
||||||
|
num_rows = min(len(temp_data), len(debug_data))
|
||||||
|
filepath = os.path.join(output_dir, "debug_info.csv")
|
||||||
|
with open(filepath, "w", encoding="utf-8") as f:
|
||||||
|
headers = [f"Sensor{j + 1},Debug{j + 1}" for j in range(num_cols)]
|
||||||
|
f.write(",".join(headers) + "\n")
|
||||||
|
for i in range(num_rows):
|
||||||
|
row = [f"{temp_data[i][j]},{debug_data[i][j]}" for j in range(num_cols)]
|
||||||
|
f.write(",".join(row) + "\n")
|
||||||
|
|
||||||
|
log.info("Saved debug CSV: %d rows × %d cols to %s", num_rows, num_cols, filepath)
|
||||||
|
return filepath
|
||||||
|
except Exception as exc:
|
||||||
|
log.error("Debug CSV save failed: %s", exc)
|
||||||
|
return None
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from pygui.backend.visualization.chart_geometry import index_to_pixel, value_to_pixel
|
from pygui.backend.visualization.chart_geometry import (
|
||||||
|
elapsed_to_pixel,
|
||||||
|
index_to_pixel,
|
||||||
|
value_to_pixel,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_value_to_pixel_min_maps_to_bottom():
|
def test_value_to_pixel_min_maps_to_bottom():
|
||||||
@@ -26,3 +30,23 @@ def test_index_to_pixel_first_and_last():
|
|||||||
|
|
||||||
def test_index_to_pixel_single_point_centers():
|
def test_index_to_pixel_single_point_centers():
|
||||||
assert index_to_pixel(0, 1, pixel_start=0.0, pixel_span=100.0) == pytest.approx(50.0)
|
assert index_to_pixel(0, 1, pixel_start=0.0, pixel_span=100.0) == pytest.approx(50.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_elapsed_to_pixel_window_start_maps_to_left():
|
||||||
|
assert elapsed_to_pixel(10.0, window_start=10.0, window_end=70.0,
|
||||||
|
pixel_start=0.0, pixel_span=200.0) == pytest.approx(0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_elapsed_to_pixel_window_end_maps_to_right():
|
||||||
|
assert elapsed_to_pixel(70.0, window_start=10.0, window_end=70.0,
|
||||||
|
pixel_start=0.0, pixel_span=200.0) == pytest.approx(200.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_elapsed_to_pixel_midpoint():
|
||||||
|
assert elapsed_to_pixel(40.0, window_start=10.0, window_end=70.0,
|
||||||
|
pixel_start=0.0, pixel_span=200.0) == pytest.approx(100.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_elapsed_to_pixel_degenerate_window_pins_right():
|
||||||
|
assert elapsed_to_pixel(5.0, window_start=5.0, window_end=5.0,
|
||||||
|
pixel_start=0.0, pixel_span=200.0) == pytest.approx(200.0)
|
||||||
|
|||||||
@@ -3,11 +3,14 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from pygui.serialcomm.data_parser import (
|
from pygui.serialcomm.data_parser import (
|
||||||
|
_convert_debug_temp,
|
||||||
_convert_hex_to_temp,
|
_convert_hex_to_temp,
|
||||||
|
convert_to_debug_temperatures,
|
||||||
convert_to_temperatures,
|
convert_to_temperatures,
|
||||||
csv_column_count,
|
csv_column_count,
|
||||||
parse_binary_data,
|
parse_binary_data,
|
||||||
remove_trailing_zeros,
|
remove_trailing_zeros,
|
||||||
|
save_debug_csv,
|
||||||
save_to_csv,
|
save_to_csv,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -321,3 +324,97 @@ class TestSaveToCsv:
|
|||||||
assert len(headers) == 244, f"expected 244 cols, got {len(headers)}"
|
assert len(headers) == 244, f"expected 244 cols, got {len(headers)}"
|
||||||
assert len(data_row) == 244, f"expected 244 data cols, got {len(data_row)}"
|
assert len(data_row) == 244, f"expected 244 data cols, got {len(data_row)}"
|
||||||
assert data_row[243] == "243.0", "last column value must be preserved"
|
assert data_row[243] == "243.0", "last column value must be preserved"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Debug temperature conversion ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestConvertDebugTemp:
|
||||||
|
"""Spot-check known hex → debug temperature values.
|
||||||
|
|
||||||
|
Reference values derived from C#'s ConvertBinaryArrayToDebugTemp
|
||||||
|
(bits 4-14 scaled by 2**(i-8), sign at bit 0 — no family branching).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_zero_hex_gives_zero(self):
|
||||||
|
assert _convert_debug_temp("0000") == 0.0
|
||||||
|
|
||||||
|
def test_bit8_gives_one(self):
|
||||||
|
# bit index 8 set (value 0x0080) → contributes 2**(8-8) = 1.0
|
||||||
|
assert _convert_debug_temp("0080") == pytest.approx(1.0, abs=0.01)
|
||||||
|
|
||||||
|
def test_sign_bit_negates(self):
|
||||||
|
# sign bit (bit 0) + bit 8 → -1.0
|
||||||
|
assert _convert_debug_temp("8080") == pytest.approx(-1.0, abs=0.01)
|
||||||
|
|
||||||
|
def test_no_family_branching(self):
|
||||||
|
# Debug conversion takes no family code — same result regardless
|
||||||
|
assert _convert_debug_temp("0080") == _convert_debug_temp("0080")
|
||||||
|
|
||||||
|
|
||||||
|
class TestConvertToDebugTemperatures:
|
||||||
|
def test_returns_same_shape(self):
|
||||||
|
hex_data = [["0080", "0000"], ["8080", "0000"]]
|
||||||
|
result = convert_to_debug_temperatures(hex_data)
|
||||||
|
assert len(result) == 2
|
||||||
|
assert len(result[0]) == 2
|
||||||
|
|
||||||
|
def test_values_are_strings(self):
|
||||||
|
result = convert_to_debug_temperatures([["0080"]])
|
||||||
|
assert isinstance(result[0][0], str)
|
||||||
|
|
||||||
|
def test_converts_known_value(self):
|
||||||
|
result = convert_to_debug_temperatures([["0080"]])
|
||||||
|
assert float(result[0][0]) == pytest.approx(1.0, abs=0.01)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Debug CSV export ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestSaveDebugCsv:
|
||||||
|
def test_creates_file_with_fixed_name(self, tmp_path):
|
||||||
|
result = save_debug_csv([["25.0"]], [["1.0"]], str(tmp_path))
|
||||||
|
assert result is not None
|
||||||
|
assert result.endswith("debug_info.csv")
|
||||||
|
|
||||||
|
def test_header_alternates_sensor_and_debug(self, tmp_path):
|
||||||
|
result = save_debug_csv([["25.0", "24.5"]], [["1.0", "2.0"]], str(tmp_path))
|
||||||
|
assert result is not None
|
||||||
|
header = open(result).readline().strip()
|
||||||
|
assert header == "Sensor1,Debug1,Sensor2,Debug2"
|
||||||
|
|
||||||
|
def test_data_row_alternates_values(self, tmp_path):
|
||||||
|
result = save_debug_csv([["25.0", "24.5"]], [["1.0", "2.0"]], str(tmp_path))
|
||||||
|
assert result is not None
|
||||||
|
lines = open(result).readlines()
|
||||||
|
assert lines[1].strip() == "25.0,1.0,24.5,2.0"
|
||||||
|
|
||||||
|
def test_truncates_to_shorter_row_count(self, tmp_path):
|
||||||
|
temp_data = [["25.0"], ["24.0"], ["23.0"]]
|
||||||
|
debug_data = [["1.0"], ["2.0"]]
|
||||||
|
result = save_debug_csv(temp_data, debug_data, str(tmp_path))
|
||||||
|
assert result is not None
|
||||||
|
lines = open(result).readlines()
|
||||||
|
assert len(lines) == 3 # 1 header + 2 data rows (shorter of the two)
|
||||||
|
|
||||||
|
def test_truncates_to_shorter_column_count(self, tmp_path):
|
||||||
|
temp_data = [["25.0", "24.0", "23.0"]]
|
||||||
|
debug_data = [["1.0", "2.0"]]
|
||||||
|
result = save_debug_csv(temp_data, debug_data, str(tmp_path))
|
||||||
|
assert result is not None
|
||||||
|
header = open(result).readline().strip()
|
||||||
|
assert header == "Sensor1,Debug1,Sensor2,Debug2"
|
||||||
|
|
||||||
|
def test_returns_none_on_empty_temp_data(self, tmp_path):
|
||||||
|
assert save_debug_csv([], [["1.0"]], str(tmp_path)) is None
|
||||||
|
|
||||||
|
def test_returns_none_on_empty_debug_data(self, tmp_path):
|
||||||
|
assert save_debug_csv([["25.0"]], [], str(tmp_path)) is None
|
||||||
|
|
||||||
|
def test_creates_output_dir_if_missing(self, tmp_path):
|
||||||
|
nested = str(tmp_path / "a" / "b")
|
||||||
|
result = save_debug_csv([["25.0"]], [["1.0"]], nested)
|
||||||
|
assert result is not None
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
assert Path(result).exists()
|
||||||
|
|||||||
@@ -133,6 +133,64 @@ def test_read_debug_handler(controller):
|
|||||||
assert controller.connectionStatus == "Connected"
|
assert controller.connectionStatus == "Connected"
|
||||||
assert not controller.operationInProgress
|
assert not controller.operationInProgress
|
||||||
|
|
||||||
|
def _make_p_family_bytes(value: int = 0x0100) -> bytes:
|
||||||
|
"""Synthetic single-block P-family binary: 244 valid words + 12 overhead."""
|
||||||
|
data = bytearray()
|
||||||
|
for i in range(256):
|
||||||
|
word = value if i < 244 else 0
|
||||||
|
data += word.to_bytes(2, byteorder="little")
|
||||||
|
return bytes(data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_debug_worker_decodes_and_saves_csv(controller, tmp_path):
|
||||||
|
controller._last_wafer_info = {"familyCode": "P"}
|
||||||
|
controller._save_data_dir = str(tmp_path)
|
||||||
|
controller._service.read_wafer_data = MagicMock(
|
||||||
|
side_effect=[_make_p_family_bytes(), _make_p_family_bytes()]
|
||||||
|
)
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
controller._debugFinished.connect(captured.update)
|
||||||
|
|
||||||
|
controller._debug_worker("COM1")
|
||||||
|
|
||||||
|
assert captured.get("success") is True
|
||||||
|
assert captured.get("sensor_bytes") == 512
|
||||||
|
assert captured.get("debug_bytes") == 512
|
||||||
|
assert "csv_path" in captured
|
||||||
|
from pathlib import Path
|
||||||
|
assert Path(captured["csv_path"]).exists()
|
||||||
|
assert Path(captured["csv_path"]).name == "debug_info.csv"
|
||||||
|
|
||||||
|
|
||||||
|
def test_debug_worker_reports_error_when_csv_save_fails(controller, tmp_path):
|
||||||
|
controller._last_wafer_info = {"familyCode": "P"}
|
||||||
|
# Point save dir somewhere save_debug_csv cannot write to, so it returns None.
|
||||||
|
controller._save_data_dir = str(tmp_path / "debug_info.csv") # a file, not a dir
|
||||||
|
(tmp_path / "debug_info.csv").write_text("occupied")
|
||||||
|
controller._service.read_wafer_data = MagicMock(
|
||||||
|
side_effect=[_make_p_family_bytes(), _make_p_family_bytes()]
|
||||||
|
)
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
controller._debugFinished.connect(captured.update)
|
||||||
|
|
||||||
|
controller._debug_worker("COM1")
|
||||||
|
|
||||||
|
assert captured.get("success") is not True
|
||||||
|
assert "error" in captured
|
||||||
|
|
||||||
|
|
||||||
|
def test_debug_handler_logs_csv_path(controller):
|
||||||
|
controller._handle_debug_finished({
|
||||||
|
"success": True,
|
||||||
|
"sensor_bytes": 512,
|
||||||
|
"debug_bytes": 512,
|
||||||
|
"csv_path": "/tmp/debug_info.csv",
|
||||||
|
})
|
||||||
|
assert "/tmp/debug_info.csv" in controller.activityLog
|
||||||
|
|
||||||
|
|
||||||
def test_parse_and_save_data_and_get_chart_data(controller):
|
def test_parse_and_save_data_and_get_chart_data(controller):
|
||||||
res = controller.getChartData()
|
res = controller.getChartData()
|
||||||
assert res == {"success": False}
|
assert res == {"success": False}
|
||||||
@@ -233,3 +291,12 @@ def test_device_service_port_caching(controller):
|
|||||||
service.enumerate_ports()
|
service.enumerate_ports()
|
||||||
assert mock_run.call_count == 2
|
assert mock_run.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_dir_created_under_save_data_dir(controller):
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
result = controller.exportDir()
|
||||||
|
|
||||||
|
assert result == str(Path(controller.saveDataDir) / "Export")
|
||||||
|
assert Path(result).is_dir()
|
||||||
|
|||||||
@@ -28,3 +28,16 @@ def test_recorded_live_file_flagged_as_recording(qapp, tmp_path):
|
|||||||
files_by_name = {Path(row["fileName"]).name: row for row in browser.files}
|
files_by_name = {Path(row["fileName"]).name: row for row in browser.files}
|
||||||
assert files_by_name["live_P00001_20260706_143022.csv"]["isRecording"] is True
|
assert files_by_name["live_P00001_20260706_143022.csv"]["isRecording"] is True
|
||||||
assert files_by_name["P00002-20260706_143500.csv"]["isRecording"] is False
|
assert files_by_name["P00002-20260706_143500.csv"]["isRecording"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_current_directory_updates_dir_and_refreshes(qapp, tmp_path):
|
||||||
|
"""setCurrentDirectory (no native dialog) is how the Status tab's save-dir
|
||||||
|
picker keeps the Source panel pointed at the same folder."""
|
||||||
|
_write_csv(tmp_path / "P00003-20260706_143500.csv")
|
||||||
|
|
||||||
|
browser = FileBrowser()
|
||||||
|
browser.setCurrentDirectory(str(tmp_path))
|
||||||
|
|
||||||
|
assert browser.currentDirectory == str(tmp_path)
|
||||||
|
assert len(browser.files) == 1
|
||||||
|
assert Path(browser.files[0]["fileName"]).name == "P00003-20260706_143500.csv"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from pygui.backend.models.frame import Frame
|
from pygui.backend.models.frame import Frame
|
||||||
from pygui.backend.models.frame_player import FramePlayer
|
from pygui.backend.models.frame_player import FramePlayer, all_sensor_series
|
||||||
|
|
||||||
|
|
||||||
def frames(n):
|
def frames(n):
|
||||||
@@ -29,3 +29,24 @@ def test_at_end():
|
|||||||
assert not p.at_end
|
assert not p.at_end
|
||||||
p.seek(1)
|
p.seek(1)
|
||||||
assert p.at_end
|
assert p.at_end
|
||||||
|
|
||||||
|
def test_frames_property_exposes_loaded_frames():
|
||||||
|
p = FramePlayer(); p.load(frames(3))
|
||||||
|
assert p.frames == tuple(frames(3))
|
||||||
|
|
||||||
|
|
||||||
|
# ---- all_sensor_series (Graph tab whole-run assembly) ----
|
||||||
|
|
||||||
|
def test_all_sensor_series_empty_frames():
|
||||||
|
assert all_sensor_series([]) == ([], [])
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_sensor_series_names_and_columns():
|
||||||
|
fs = [
|
||||||
|
Frame(seq=0, time=0.0, values=[150.0, 148.0]),
|
||||||
|
Frame(seq=1, time=1.0, values=[151.0, 147.5]),
|
||||||
|
Frame(seq=2, time=2.0, values=[152.0, 147.0]),
|
||||||
|
]
|
||||||
|
names, series = all_sensor_series(fs)
|
||||||
|
assert names == ["Sensor 1", "Sensor 2"]
|
||||||
|
assert series == [[150.0, 151.0, 152.0], [148.0, 147.5, 147.0]]
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import math
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from pygui.backend.models.frame_stats import Stats, compute_stats
|
from pygui.backend.models.frame_stats import (
|
||||||
|
EdgeCenterStats,
|
||||||
|
Stats,
|
||||||
|
compute_edge_center,
|
||||||
|
compute_stats,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_basic_stat():
|
def test_basic_stat():
|
||||||
@@ -28,3 +33,43 @@ def test_ignores_nan():
|
|||||||
assert s.avg == pytest.approx(150.0)
|
assert s.avg == pytest.approx(150.0)
|
||||||
assert s.sigma == pytest.approx(1.0)
|
assert s.sigma == pytest.approx(1.0)
|
||||||
assert s.three_sigma == pytest.approx(3.0)
|
assert s.three_sigma == pytest.approx(3.0)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- edge-center delta (ADR-0002) ----
|
||||||
|
|
||||||
|
PAIRS = ((0, 4), (1, 4), (2, 5), (3, 5))
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_center_min_max_pairs():
|
||||||
|
# e0 e1 e2 e3 c4 c5
|
||||||
|
values = [150.0, 148.0, 151.0, 149.4, 149.0, 149.5]
|
||||||
|
ec = compute_edge_center(values, PAIRS, excluded=set())
|
||||||
|
# deltas: |150-149|=1.0, |148-149|=1.0, |151-149.5|=1.5, |149.4-149.5|=0.1
|
||||||
|
assert ec == EdgeCenterStats(
|
||||||
|
min_edge_index=3, min_center_index=5, min_edge=149.4, min_center=149.5,
|
||||||
|
min_delta=pytest.approx(0.1),
|
||||||
|
max_edge_index=2, max_center_index=5, max_edge=151.0, max_center=149.5,
|
||||||
|
max_delta=pytest.approx(1.5),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_center_skips_excluded_sensors():
|
||||||
|
values = [150.0, 148.0, 151.0, 149.4, 149.0, 149.5]
|
||||||
|
ec = compute_edge_center(values, PAIRS, excluded={2, 3}) # both c5 pairs out
|
||||||
|
assert ec is not None
|
||||||
|
assert ec.max_delta == pytest.approx(1.0)
|
||||||
|
# excluding a center sensor kills all its pairs
|
||||||
|
assert compute_edge_center(values, PAIRS, excluded={4, 5}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_center_skips_nan_and_out_of_range():
|
||||||
|
values = [150.0, math.nan, 149.0] # only pair (0, 2) is computable
|
||||||
|
ec = compute_edge_center(values, ((0, 2), (1, 2), (0, 9)), excluded=set())
|
||||||
|
assert ec is not None
|
||||||
|
assert ec.min_delta == ec.max_delta == pytest.approx(1.0)
|
||||||
|
assert (ec.min_edge_index, ec.min_center_index) == (0, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_center_none_when_no_pairs():
|
||||||
|
assert compute_edge_center([1.0, 2.0], (), excluded=set()) is None
|
||||||
|
assert compute_edge_center([], PAIRS, excluded=set()) is None
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
"""Tests for src/pygui/backend/visualization/graph_quick_item.py."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.usefixtures("qapp")
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_data_initially():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||||
|
|
||||||
|
item = GraphQuickItem()
|
||||||
|
assert item.seriesData == []
|
||||||
|
assert item.sensorNames == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_series_data_setter_stores_series():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||||
|
|
||||||
|
item = GraphQuickItem()
|
||||||
|
item.seriesData = [[150.0, 151.0], [148.0, 147.5]]
|
||||||
|
|
||||||
|
assert item.seriesData == [[150.0, 151.0], [148.0, 147.5]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_series_data_auto_ranges_y_with_padding():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||||
|
|
||||||
|
item = GraphQuickItem()
|
||||||
|
item.seriesData = [[150.0, 100.0], [200.0, 120.0]]
|
||||||
|
|
||||||
|
# min=100, max=200, range=100, 10% pad => [90, 210]
|
||||||
|
assert item._min_y == pytest.approx(90.0)
|
||||||
|
assert item._max_y == pytest.approx(210.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_series_data_empty_keeps_default_range():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||||
|
|
||||||
|
item = GraphQuickItem()
|
||||||
|
item.seriesData = []
|
||||||
|
|
||||||
|
assert item._min_y == 0.0
|
||||||
|
assert item._max_y == 150.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_sensor_names_setter_stores_names():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||||
|
|
||||||
|
item = GraphQuickItem()
|
||||||
|
item.sensorNames = ["Sensor 1", "Sensor 2"]
|
||||||
|
|
||||||
|
assert item.sensorNames == ["Sensor 1", "Sensor 2"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_numeric_values_are_skipped_not_crashing():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||||
|
|
||||||
|
item = GraphQuickItem()
|
||||||
|
item.seriesData = [[150.0, "bad", 151.0]]
|
||||||
|
|
||||||
|
assert item._min_y == pytest.approx(149.9)
|
||||||
|
assert item._max_y == pytest.approx(151.1)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- viewport_stats (replay chart min/max/avg readouts) ----
|
||||||
|
# Independently hand-computed against PopupChartForm.cs's recalc_stats
|
||||||
|
# semantics: aggregate across every sensor's points in [start, end], plus
|
||||||
|
# which sensor achieved each extreme.
|
||||||
|
|
||||||
|
|
||||||
|
def test_viewport_stats_full_range_aggregate():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import viewport_stats
|
||||||
|
|
||||||
|
series = [[150.0, 151.0, 152.0], [148.0, 147.5, 147.0]]
|
||||||
|
names = ["Sensor 1", "Sensor 2"]
|
||||||
|
|
||||||
|
vmin, vmax, avg, min_sensor, max_sensor = viewport_stats(series, names, 0, 2)
|
||||||
|
|
||||||
|
assert vmin == pytest.approx(147.0)
|
||||||
|
assert vmax == pytest.approx(152.0)
|
||||||
|
assert avg == pytest.approx(149.25)
|
||||||
|
assert min_sensor == "Sensor 2"
|
||||||
|
assert max_sensor == "Sensor 1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_viewport_stats_restricted_to_subrange():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import viewport_stats
|
||||||
|
|
||||||
|
series = [[150.0, 151.0, 152.0], [148.0, 147.5, 147.0]]
|
||||||
|
names = ["Sensor 1", "Sensor 2"]
|
||||||
|
|
||||||
|
vmin, vmax, avg, min_sensor, max_sensor = viewport_stats(series, names, 0, 1)
|
||||||
|
|
||||||
|
assert vmin == pytest.approx(147.5)
|
||||||
|
assert vmax == pytest.approx(151.0)
|
||||||
|
assert avg == pytest.approx(149.125)
|
||||||
|
assert min_sensor == "Sensor 2"
|
||||||
|
assert max_sensor == "Sensor 1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_viewport_stats_empty_series_returns_zeros():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import viewport_stats
|
||||||
|
|
||||||
|
assert viewport_stats([], [], 0, -1) == (0.0, 0.0, 0.0, "", "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_viewport_stats_skips_non_numeric():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import viewport_stats
|
||||||
|
|
||||||
|
series = [[150.0, "bad", 151.0]]
|
||||||
|
names = ["Sensor 1"]
|
||||||
|
|
||||||
|
vmin, vmax, avg, min_sensor, max_sensor = viewport_stats(series, names, 0, 2)
|
||||||
|
|
||||||
|
assert vmin == pytest.approx(150.0)
|
||||||
|
assert vmax == pytest.approx(151.0)
|
||||||
|
assert avg == pytest.approx(150.5)
|
||||||
|
assert min_sensor == "Sensor 1"
|
||||||
|
assert max_sensor == "Sensor 1"
|
||||||
|
|
||||||
|
|
||||||
|
# ---- GraphQuickItem viewport properties (replay chart) ----
|
||||||
|
# Default viewport (0, -1) means "whole series" so the already-shipped Graph
|
||||||
|
# tab (which never sets these) is unaffected — see docs/adr/0004.
|
||||||
|
|
||||||
|
|
||||||
|
def test_viewport_defaults_to_whole_range_and_markers_off():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||||
|
|
||||||
|
item = GraphQuickItem()
|
||||||
|
|
||||||
|
assert item.viewStartIndex == 0
|
||||||
|
assert item.viewEndIndex == -1
|
||||||
|
assert item.showMinMaxMarkers is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_view_stats_reflect_default_full_range_viewport():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||||
|
|
||||||
|
item = GraphQuickItem()
|
||||||
|
item.sensorNames = ["Sensor 1", "Sensor 2"]
|
||||||
|
item.seriesData = [[150.0, 151.0, 152.0], [148.0, 147.5, 147.0]]
|
||||||
|
|
||||||
|
assert item.viewMin == pytest.approx(147.0)
|
||||||
|
assert item.viewMax == pytest.approx(152.0)
|
||||||
|
assert item.viewAvg == pytest.approx(149.25)
|
||||||
|
assert item.viewMinSensor == "Sensor 2"
|
||||||
|
assert item.viewMaxSensor == "Sensor 1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_view_stats_restrict_to_narrowed_viewport():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||||
|
|
||||||
|
item = GraphQuickItem()
|
||||||
|
item.sensorNames = ["Sensor 1", "Sensor 2"]
|
||||||
|
item.seriesData = [[150.0, 151.0, 152.0], [148.0, 147.5, 147.0]]
|
||||||
|
|
||||||
|
item.viewStartIndex = 0
|
||||||
|
item.viewEndIndex = 1
|
||||||
|
|
||||||
|
assert item.viewMin == pytest.approx(147.5)
|
||||||
|
assert item.viewMax == pytest.approx(151.0)
|
||||||
|
assert item.viewAvg == pytest.approx(149.125)
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_range_y_restricted_to_narrowed_viewport():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||||
|
|
||||||
|
item = GraphQuickItem()
|
||||||
|
item.seriesData = [[150.0, 100.0], [200.0, 120.0]]
|
||||||
|
# Full-range Y (pre-existing behavior): min=100, max=200 -> [90, 210]
|
||||||
|
assert item._min_y == pytest.approx(90.0)
|
||||||
|
assert item._max_y == pytest.approx(210.0)
|
||||||
|
|
||||||
|
# Narrow the viewport to index 0 only: min=150, max=200, range=50, 10% pad
|
||||||
|
item.viewStartIndex = 0
|
||||||
|
item.viewEndIndex = 0
|
||||||
|
|
||||||
|
assert item._min_y == pytest.approx(145.0)
|
||||||
|
assert item._max_y == pytest.approx(205.0)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- zoomAtFraction / panByFraction / resetZoom (replay chart interaction) ----
|
||||||
|
# 11-point series (indices 0..10) chosen so the zoom/pan arithmetic lands on
|
||||||
|
# whole numbers -- avoids rounding ambiguity in the independent hand trace.
|
||||||
|
|
||||||
|
|
||||||
|
def _eleven_point_item():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||||
|
|
||||||
|
item = GraphQuickItem()
|
||||||
|
item.seriesData = [[float(i) for i in range(11)]]
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def test_zoom_in_centers_on_fraction():
|
||||||
|
item = _eleven_point_item()
|
||||||
|
|
||||||
|
# Full range is (0, 10), width 10. Zooming to 40% width centered at the
|
||||||
|
# midpoint (frac=0.5): center=5, new_width=4 -> new_start=3, new_end=7.
|
||||||
|
item.zoomAtFraction(0.5, 0.4)
|
||||||
|
|
||||||
|
assert item.viewStartIndex == 3
|
||||||
|
assert item.viewEndIndex == 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_zoom_out_clamps_to_data_bounds():
|
||||||
|
item = _eleven_point_item()
|
||||||
|
item.viewStartIndex = 3
|
||||||
|
item.viewEndIndex = 7
|
||||||
|
|
||||||
|
# width=4, zoom out 3x centered at midpoint (frac=0.5): requested
|
||||||
|
# new_width=12 exceeds the data's max width of 10, so it clamps to the
|
||||||
|
# full range (0, 10).
|
||||||
|
item.zoomAtFraction(0.5, 3.0)
|
||||||
|
|
||||||
|
assert item.viewStartIndex == 0
|
||||||
|
assert item.viewEndIndex == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_pan_shifts_viewport_by_fraction_of_width():
|
||||||
|
item = _eleven_point_item()
|
||||||
|
item.viewStartIndex = 4
|
||||||
|
item.viewEndIndex = 8
|
||||||
|
|
||||||
|
# width=4, pan by 50% of width (2 indices): (4,8) -> (6,10)
|
||||||
|
item.panByFraction(0.5)
|
||||||
|
|
||||||
|
assert item.viewStartIndex == 6
|
||||||
|
assert item.viewEndIndex == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_pan_clamps_at_right_edge_preserving_width():
|
||||||
|
item = _eleven_point_item()
|
||||||
|
item.viewStartIndex = 4
|
||||||
|
item.viewEndIndex = 8
|
||||||
|
|
||||||
|
# width=4, pan by 100% of width (4 indices) would be (8,12); index 12 is
|
||||||
|
# out of bounds (max index 10), so the whole window shifts back by 2 to
|
||||||
|
# stay in bounds while keeping width=4: (6,10).
|
||||||
|
item.panByFraction(1.0)
|
||||||
|
|
||||||
|
assert item.viewStartIndex == 6
|
||||||
|
assert item.viewEndIndex == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_zoom_restores_full_range_sentinel():
|
||||||
|
item = _eleven_point_item()
|
||||||
|
item.viewStartIndex = 3
|
||||||
|
item.viewEndIndex = 7
|
||||||
|
|
||||||
|
item.resetZoom()
|
||||||
|
|
||||||
|
assert item.viewStartIndex == 0
|
||||||
|
assert item.viewEndIndex == -1
|
||||||
|
|
||||||
|
|
||||||
|
def test_zoom_noop_when_fewer_than_two_points():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||||
|
|
||||||
|
item = GraphQuickItem()
|
||||||
|
item.seriesData = [[42.0]]
|
||||||
|
|
||||||
|
item.zoomAtFraction(0.5, 0.5)
|
||||||
|
|
||||||
|
assert item.viewStartIndex == 0
|
||||||
|
assert item.viewEndIndex == -1
|
||||||
|
|
||||||
|
|
||||||
|
def test_loading_new_series_while_zoomed_resets_viewport_to_full_range():
|
||||||
|
"""Regression: zooming near the tail of a long run, then loading a new
|
||||||
|
(shorter) file, left the stale viewport pointing past the new data --
|
||||||
|
every per-series slice became empty and the chart went blank with a
|
||||||
|
stale Y-range and zeroed-out min/max/avg readouts. New data must reset
|
||||||
|
the viewport to the full range (PopupChartForm.cs's SetData() parity).
|
||||||
|
"""
|
||||||
|
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||||
|
|
||||||
|
item = GraphQuickItem()
|
||||||
|
item.seriesData = [[float(i) for i in range(20)]]
|
||||||
|
item.viewStartIndex = 15
|
||||||
|
item.viewEndIndex = 19
|
||||||
|
|
||||||
|
item.seriesData = [[100.0, 101.0, 102.0, 103.0, 104.0]]
|
||||||
|
|
||||||
|
assert item.viewStartIndex == 0
|
||||||
|
assert item.viewEndIndex == -1
|
||||||
|
assert item.viewMin == pytest.approx(100.0)
|
||||||
|
assert item.viewMax == pytest.approx(104.0)
|
||||||
|
assert item.viewAvg == pytest.approx(102.0)
|
||||||
@@ -66,6 +66,51 @@ class TestUpdateTrend:
|
|||||||
gv.updateTrend("[1, 2, 3]")
|
gv.updateTrend("[1, 2, 3]")
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpdateChart:
|
||||||
|
def test_plots_one_series_per_sensor(self):
|
||||||
|
from pygui.backend.visualization.graph_view import GraphView
|
||||||
|
|
||||||
|
gv = GraphView()
|
||||||
|
gv._plot_widget = _make_plot_widget()
|
||||||
|
|
||||||
|
series = [[150.0, 151.0, 152.0], [148.5, 148.4, 148.6]]
|
||||||
|
gv.updateChart("Sensor 1,Sensor 2", json.dumps(series))
|
||||||
|
|
||||||
|
assert gv._plot_widget.clear.called
|
||||||
|
assert gv._plot_widget.plot.call_count == 2
|
||||||
|
assert gv._sensor_names == ["Sensor 1", "Sensor 2"]
|
||||||
|
|
||||||
|
def test_rejects_invalid_json(self):
|
||||||
|
from pygui.backend.visualization.graph_view import GraphView
|
||||||
|
|
||||||
|
gv = GraphView()
|
||||||
|
gv._plot_widget = _make_plot_widget()
|
||||||
|
|
||||||
|
gv.updateChart("Sensor 1", "not json")
|
||||||
|
|
||||||
|
assert not gv._plot_widget.plot.called
|
||||||
|
|
||||||
|
def test_empty_series_clears_without_plotting(self):
|
||||||
|
from pygui.backend.visualization.graph_view import GraphView
|
||||||
|
|
||||||
|
gv = GraphView()
|
||||||
|
gv._plot_widget = _make_plot_widget()
|
||||||
|
|
||||||
|
gv.updateChart("", "[]")
|
||||||
|
|
||||||
|
assert gv._plot_widget.clear.called
|
||||||
|
assert not gv._plot_widget.plot.called
|
||||||
|
|
||||||
|
def test_no_plot_widget(self):
|
||||||
|
from pygui.backend.visualization.graph_view import GraphView
|
||||||
|
|
||||||
|
gv = GraphView()
|
||||||
|
assert gv._plot_widget is None
|
||||||
|
|
||||||
|
# Should not crash
|
||||||
|
gv.updateChart("Sensor 1", json.dumps([[1.0, 2.0]]))
|
||||||
|
|
||||||
|
|
||||||
class TestUpdateComparison:
|
class TestUpdateComparison:
|
||||||
def test_parses_valid_json(self):
|
def test_parses_valid_json(self):
|
||||||
from pygui.backend.visualization.graph_view import GraphView
|
from pygui.backend.visualization.graph_view import GraphView
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
import json
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
import pygui.backend.controllers.session_controller as session_controller_module
|
||||||
from pygui.backend.controllers.session_controller import SessionController
|
from pygui.backend.controllers.session_controller import SessionController
|
||||||
|
from pygui.backend.models.frame import Frame
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -50,6 +53,49 @@ def test_pause_stop_timer(controller):
|
|||||||
controller.stop()
|
controller.stop()
|
||||||
assert controller.frameIndex == 0
|
assert controller.frameIndex == 0
|
||||||
|
|
||||||
|
def test_graph_data_empty_before_load(controller):
|
||||||
|
assert controller.graphSensorNames == ""
|
||||||
|
assert controller.graphSeriesJson == "[]"
|
||||||
|
|
||||||
|
def test_graph_data_after_load(controller):
|
||||||
|
controller.loadFile("tests/fixtures/sample_stream.csv")
|
||||||
|
assert controller.graphSensorNames == "Sensor 1,Sensor 2,Sensor 3"
|
||||||
|
series = json.loads(controller.graphSeriesJson)
|
||||||
|
assert series == [
|
||||||
|
[149.0, 149.2, 149.1],
|
||||||
|
[148.5, 148.4, 148.6],
|
||||||
|
[150.6, 150.7, 150.5],
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_graph_data_cleared_after_unload(controller):
|
||||||
|
controller.loadFile("tests/fixtures/sample_stream.csv")
|
||||||
|
controller.unloadFile()
|
||||||
|
assert controller.graphSensorNames == ""
|
||||||
|
assert controller.graphSeriesJson == "[]"
|
||||||
|
|
||||||
|
def test_leaving_live_mode_blanks_wafer_map(controller):
|
||||||
|
"""Stopping a live session (tab switch → setMode) must not leave stale
|
||||||
|
sensor values on the wafer map — it should read as blank."""
|
||||||
|
frame_signal = MagicMock()
|
||||||
|
controller.frameUpdated.connect(frame_signal)
|
||||||
|
|
||||||
|
controller.setMode("live")
|
||||||
|
controller._on_live_frame(Frame(seq=1, time=0.0, values=[100.0] * 22))
|
||||||
|
assert controller.sensorValues, "sanity: live frame should populate the map"
|
||||||
|
|
||||||
|
controller.setMode("review")
|
||||||
|
assert controller.sensorValues == []
|
||||||
|
assert controller.stats == {}
|
||||||
|
assert frame_signal.called, "QML needs frameUpdated to repaint blank"
|
||||||
|
|
||||||
|
|
||||||
|
def test_leaving_live_mode_stops_stream(controller):
|
||||||
|
controller.setMode("live")
|
||||||
|
controller.setMode("review")
|
||||||
|
assert controller._reader is None
|
||||||
|
assert not controller._repaint_timer.isActive()
|
||||||
|
|
||||||
|
|
||||||
def test_compare_files_integration(controller):
|
def test_compare_files_integration(controller):
|
||||||
# Mock the comparisonResult signal
|
# Mock the comparisonResult signal
|
||||||
mock_slot = MagicMock()
|
mock_slot = MagicMock()
|
||||||
@@ -194,3 +240,75 @@ def test_export_segment_bad_index(qapp, controller, tmp_path):
|
|||||||
controller.segmentExported.connect(exported)
|
controller.segmentExported.connect(exported)
|
||||||
controller.exportSegment(99)
|
controller.exportSegment(99)
|
||||||
assert exported.call_args[0][0]["success"] is False
|
assert exported.call_args[0][0]["success"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_live_trend_sets_start_time_and_emits_reset(controller, monkeypatch):
|
||||||
|
monkeypatch.setattr(session_controller_module.time, "monotonic", lambda: 100.0)
|
||||||
|
|
||||||
|
reset_seen = MagicMock()
|
||||||
|
controller.trendReset.connect(reset_seen)
|
||||||
|
controller._reset_live_trend()
|
||||||
|
|
||||||
|
assert controller._stream_start_time == 100.0
|
||||||
|
assert reset_seen.called
|
||||||
|
|
||||||
|
|
||||||
|
def test_on_live_frame_emits_trend_delta_with_elapsed_seconds(controller, monkeypatch):
|
||||||
|
controller._stream_start_time = 100.0
|
||||||
|
monkeypatch.setattr(session_controller_module.time, "monotonic", lambda: 102.5)
|
||||||
|
|
||||||
|
delta_seen = MagicMock()
|
||||||
|
controller.trendDelta.connect(delta_seen)
|
||||||
|
|
||||||
|
frame = Frame(seq=1, time=102.5, values=[10.0, 20.0, 30.0])
|
||||||
|
controller._on_live_frame(frame)
|
||||||
|
|
||||||
|
payload = json.loads(delta_seen.call_args[0][0])
|
||||||
|
assert len(payload) == 1
|
||||||
|
elapsed, avg = payload[0]
|
||||||
|
assert elapsed == pytest.approx(2.5)
|
||||||
|
assert avg == pytest.approx(20.0)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- edge-center stats exposure (ADR-0002) ----
|
||||||
|
|
||||||
|
def _write_f_family_csv(path):
|
||||||
|
"""22-sensor F-family stream: pair (0,18) has delta 1.0, all others 0."""
|
||||||
|
n = 22
|
||||||
|
values = [149.0] * n
|
||||||
|
values[0] = 150.0
|
||||||
|
lines = [
|
||||||
|
"Wafer ID=F12",
|
||||||
|
"Acquisition Date=03/26/2025",
|
||||||
|
"Label," + ",".join(str(i + 1) for i in range(n)),
|
||||||
|
"X (mm)," + ",".join(str(i) for i in range(n)),
|
||||||
|
"Y (mm)," + ",".join(str(i) for i in range(n)),
|
||||||
|
"data",
|
||||||
|
"0.0," + ",".join(f"{v:.1f}" for v in values),
|
||||||
|
]
|
||||||
|
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_stats_include_edge_center_for_known_family(controller, tmp_path):
|
||||||
|
csv = tmp_path / "f_run.csv"
|
||||||
|
_write_f_family_csv(csv)
|
||||||
|
controller.loadFile(str(csv))
|
||||||
|
s = controller.stats
|
||||||
|
assert s["ecMaxDelta"] == pytest.approx(1.0)
|
||||||
|
assert s["ecMaxEdgeIndex"] == 1 and s["ecMaxCenterIndex"] == 19 # 1-origin
|
||||||
|
assert s["ecMaxEdgeValue"] == pytest.approx(150.0)
|
||||||
|
assert s["ecMaxCenterValue"] == pytest.approx(149.0)
|
||||||
|
assert s["ecMinDelta"] == pytest.approx(0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_stats_edge_center_skips_replaced_sensor(controller, tmp_path):
|
||||||
|
csv = tmp_path / "f_run.csv"
|
||||||
|
_write_f_family_csv(csv)
|
||||||
|
controller.loadFile(str(csv))
|
||||||
|
controller.replaceSensor(0, 149.0) # replaced: pair (0,18) must be skipped
|
||||||
|
assert controller.stats["ecMaxDelta"] == pytest.approx(0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_stats_edge_center_absent_for_unknown_or_short_family(controller):
|
||||||
|
controller.loadFile("tests/fixtures/sample_stream.csv") # A12, 3 sensors
|
||||||
|
assert "ecMaxDelta" not in controller.stats
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""Tests for src/pygui/backend/visualization/trend_chart_item.py."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Use the session-scoped qapp fixture from conftest.py so Qt Property/Signal
|
||||||
|
# descriptors work properly.
|
||||||
|
pytestmark = pytest.mark.usefixtures("qapp")
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_data_initially():
|
||||||
|
from pygui.backend.visualization.trend_chart_item import TrendChartItem
|
||||||
|
|
||||||
|
item = TrendChartItem()
|
||||||
|
assert item.hasData is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_append_delta_adds_point_and_sets_has_data():
|
||||||
|
from pygui.backend.visualization.trend_chart_item import TrendChartItem
|
||||||
|
|
||||||
|
item = TrendChartItem()
|
||||||
|
item.appendDelta(json.dumps([[1.5, 42.0]]))
|
||||||
|
|
||||||
|
assert item.hasData is True
|
||||||
|
assert item._points == [(1.5, 42.0)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_append_delta_accumulates_across_calls():
|
||||||
|
from pygui.backend.visualization.trend_chart_item import TrendChartItem
|
||||||
|
|
||||||
|
item = TrendChartItem()
|
||||||
|
item.appendDelta(json.dumps([[0.0, 10.0]]))
|
||||||
|
item.appendDelta(json.dumps([[1.0, 20.0]]))
|
||||||
|
|
||||||
|
assert item._points == [(0.0, 10.0), (1.0, 20.0)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_append_delta_prunes_points_older_than_60s_window():
|
||||||
|
from pygui.backend.visualization.trend_chart_item import TrendChartItem
|
||||||
|
|
||||||
|
item = TrendChartItem()
|
||||||
|
item.appendDelta(json.dumps([[0.0, 10.0]]))
|
||||||
|
item.appendDelta(json.dumps([[70.0, 20.0]]))
|
||||||
|
|
||||||
|
assert item._points == [(70.0, 20.0)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_append_delta_ignores_malformed_json():
|
||||||
|
from pygui.backend.visualization.trend_chart_item import TrendChartItem
|
||||||
|
|
||||||
|
item = TrendChartItem()
|
||||||
|
item.appendDelta("not json")
|
||||||
|
|
||||||
|
assert item._points == []
|
||||||
|
assert item.hasData is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_append_delta_skips_repaint_when_no_points_added():
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from pygui.backend.visualization.trend_chart_item import TrendChartItem
|
||||||
|
|
||||||
|
item = TrendChartItem()
|
||||||
|
item.appendDelta(json.dumps([[1.0, 10.0]]))
|
||||||
|
item.update = MagicMock()
|
||||||
|
|
||||||
|
item.appendDelta("not json")
|
||||||
|
|
||||||
|
assert item.update.called is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_trend_resets_points_and_has_data():
|
||||||
|
from pygui.backend.visualization.trend_chart_item import TrendChartItem
|
||||||
|
|
||||||
|
item = TrendChartItem()
|
||||||
|
item.appendDelta(json.dumps([[1.0, 10.0]]))
|
||||||
|
assert item.hasData is True
|
||||||
|
|
||||||
|
item.clearTrend()
|
||||||
|
|
||||||
|
assert item._points == []
|
||||||
|
assert item.hasData is False
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
"""Tests for src/pygui/backend/wafer_layouts.py."""
|
"""Tests for src/pygui/backend/wafer_layouts.py."""
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from pygui.backend.wafer.wafer_layouts import available_families, load_layout
|
from pygui.backend.wafer.wafer_layouts import (
|
||||||
|
available_families,
|
||||||
|
ec_pairs_for_wafer_id,
|
||||||
|
load_layout,
|
||||||
|
)
|
||||||
from pygui.backend.wafer.zwafer_models import Sensor
|
from pygui.backend.wafer.zwafer_models import Sensor
|
||||||
|
|
||||||
|
|
||||||
@@ -20,3 +24,27 @@ def test_loads_sensors_in_mm():
|
|||||||
def test_unknown_family_raises():
|
def test_unknown_family_raises():
|
||||||
with pytest.raises(KeyError):
|
with pytest.raises(KeyError):
|
||||||
load_layout("nope")
|
load_layout("nope")
|
||||||
|
|
||||||
|
|
||||||
|
# ---- edge-center pair tables (ADR-0002) ----
|
||||||
|
|
||||||
|
def test_ec_pairs_aep_match_csharp_table():
|
||||||
|
pairs = ec_pairs_for_wafer_id("A00123")
|
||||||
|
assert pairs[0] == (0, 40)
|
||||||
|
assert pairs[-1] == (15, 47)
|
||||||
|
assert len(pairs) == 16
|
||||||
|
assert ec_pairs_for_wafer_id("E1") == pairs
|
||||||
|
assert ec_pairs_for_wafer_id("P1") == pairs
|
||||||
|
|
||||||
|
|
||||||
|
def test_ec_pairs_per_family_classes():
|
||||||
|
assert len(ec_pairs_for_wafer_id("B00108")) == 12
|
||||||
|
assert ec_pairs_for_wafer_id("C1") == ec_pairs_for_wafer_id("D1")
|
||||||
|
assert ec_pairs_for_wafer_id("F1")[0] == (0, 18)
|
||||||
|
assert ec_pairs_for_wafer_id("X1")[0] == (5, 76)
|
||||||
|
assert ec_pairs_for_wafer_id("Z1")[0] == (49, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ec_pairs_unknown_family_empty():
|
||||||
|
assert ec_pairs_for_wafer_id("Q999") == ()
|
||||||
|
assert ec_pairs_for_wafer_id("") == ()
|
||||||
|
|||||||
+238
-31
@@ -8,20 +8,6 @@ import pytest
|
|||||||
pytestmark = pytest.mark.usefixtures("qapp")
|
pytestmark = pytest.mark.usefixtures("qapp")
|
||||||
|
|
||||||
|
|
||||||
def test_export_image_valid_path():
|
|
||||||
"""export_image saves a PNG when grabToImage succeeds."""
|
|
||||||
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
|
||||||
|
|
||||||
item = WaferMapItem()
|
|
||||||
|
|
||||||
mock_image = MagicMock()
|
|
||||||
mock_result = MagicMock()
|
|
||||||
mock_result.image.return_value = mock_image
|
|
||||||
item.grabToImage = MagicMock(return_value=mock_result)
|
|
||||||
|
|
||||||
assert item.export_image("/tmp/test_wafer.png") is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_export_image_empty_path():
|
def test_export_image_empty_path():
|
||||||
"""export_image returns False for empty/None path."""
|
"""export_image returns False for empty/None path."""
|
||||||
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||||||
@@ -31,29 +17,23 @@ def test_export_image_empty_path():
|
|||||||
assert item.export_image(None) is False
|
assert item.export_image(None) is False
|
||||||
|
|
||||||
|
|
||||||
def test_export_image_grab_fails():
|
def test_export_image_zero_size_item_fails():
|
||||||
"""export_image returns False when grabToImage raises."""
|
"""export_image returns False when the item has no geometry to render."""
|
||||||
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||||||
|
|
||||||
item = WaferMapItem()
|
item = WaferMapItem()
|
||||||
item.grabToImage = MagicMock(side_effect=RuntimeError("grab failed"))
|
|
||||||
|
|
||||||
assert item.export_image("/tmp/test_wafer.png") is False
|
assert item.export_image("/tmp/test_wafer.png") is False
|
||||||
|
|
||||||
|
|
||||||
def test_export_image_save_fails():
|
def test_export_image_unwritable_path_fails(tmp_path):
|
||||||
"""export_image returns False when image.save raises."""
|
"""export_image returns False when the target directory doesn't exist."""
|
||||||
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||||||
|
|
||||||
mock_image = MagicMock()
|
|
||||||
mock_image.save.side_effect = IOError("disk full")
|
|
||||||
mock_result = MagicMock()
|
|
||||||
mock_result.image.return_value = mock_image
|
|
||||||
|
|
||||||
item = WaferMapItem()
|
item = WaferMapItem()
|
||||||
item.grabToImage = MagicMock(return_value=mock_result)
|
item.setWidth(100)
|
||||||
|
item.setHeight(100)
|
||||||
assert item.export_image("/tmp/test_wafer.png") is False
|
item.values = [10.0, 20.0, 30.0]
|
||||||
|
assert item.export_image(str(tmp_path / "no_such_dir" / "wafer.png")) is False
|
||||||
|
|
||||||
|
|
||||||
def test_thickness_properties():
|
def test_thickness_properties():
|
||||||
@@ -67,16 +47,89 @@ def test_thickness_properties():
|
|||||||
|
|
||||||
|
|
||||||
def test_thickness_setter_updates_data():
|
def test_thickness_setter_updates_data():
|
||||||
"""Setting thicknessData updates _thickness_data."""
|
"""Setting thicknessData stores [x, y, t2] triples."""
|
||||||
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||||||
|
|
||||||
item = WaferMapItem()
|
item = WaferMapItem()
|
||||||
item._rebuild_thickness = MagicMock()
|
item._rebuild_thickness = MagicMock()
|
||||||
|
|
||||||
item.thicknessData = [1.0, 2.0, 3.0]
|
item.thicknessData = [[0.0, 10.0, 1.5], [5.0, -5.0, 2.0]]
|
||||||
|
|
||||||
assert item._thickness_data == [1.0, 2.0, 3.0]
|
assert item._thickness_data == [[0.0, 10.0, 1.5], [5.0, -5.0, 2.0]]
|
||||||
assert item._rebuild_thickness.called
|
assert item._rebuild_thickness.called
|
||||||
|
assert item.hasThickness is True
|
||||||
|
|
||||||
|
|
||||||
|
THICKNESS_HEADER = "Lot Start Time,RC,Site#,Slot#,T2,NGOF,Wafer X,Wafer Y\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _write_csv(tmp_path, body, header=THICKNESS_HEADER):
|
||||||
|
p = tmp_path / "thickness.csv"
|
||||||
|
p.write_text(header + body)
|
||||||
|
return str(p)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_thickness_csv_valid(tmp_path):
|
||||||
|
from pygui.backend.visualization.wafer_map_item import parse_thickness_csv
|
||||||
|
|
||||||
|
path = _write_csv(tmp_path,
|
||||||
|
"t,1,1,7,1.5,0,10.0,-20.0\n"
|
||||||
|
"t,1,2,7,2.5,0,-30.0,40.0\n")
|
||||||
|
points, err = parse_thickness_csv(path)
|
||||||
|
assert err == ""
|
||||||
|
assert points == [[10.0, -20.0, 1.5], [-30.0, 40.0, 2.5]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_thickness_csv_first_slot_only(tmp_path):
|
||||||
|
"""Rows after the Slot# changes belong to another wafer and are skipped."""
|
||||||
|
from pygui.backend.visualization.wafer_map_item import parse_thickness_csv
|
||||||
|
|
||||||
|
path = _write_csv(tmp_path,
|
||||||
|
"t,1,1,7,1.5,0,10.0,-20.0\n"
|
||||||
|
"t,1,1,8,9.9,0,0.0,0.0\n")
|
||||||
|
points, err = parse_thickness_csv(path)
|
||||||
|
assert err == ""
|
||||||
|
assert points == [[10.0, -20.0, 1.5]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_thickness_csv_bad_header(tmp_path):
|
||||||
|
from pygui.backend.visualization.wafer_map_item import parse_thickness_csv
|
||||||
|
|
||||||
|
path = _write_csv(tmp_path, "t,1,1,7,1.5,0,10.0,-20.0\n", header="A,B,C\n")
|
||||||
|
points, err = parse_thickness_csv(path)
|
||||||
|
assert points == []
|
||||||
|
assert "header" in err
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_thickness_csv_malformed_value(tmp_path):
|
||||||
|
from pygui.backend.visualization.wafer_map_item import parse_thickness_csv
|
||||||
|
|
||||||
|
path = _write_csv(tmp_path, "t,1,1,7,oops,0,10.0,-20.0\n")
|
||||||
|
points, err = parse_thickness_csv(path)
|
||||||
|
assert points == []
|
||||||
|
assert "Malformed" in err
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_thickness_csv_missing_file():
|
||||||
|
from pygui.backend.visualization.wafer_map_item import parse_thickness_csv
|
||||||
|
|
||||||
|
points, err = parse_thickness_csv("/no/such/file.csv")
|
||||||
|
assert points == []
|
||||||
|
assert "Unable to read" in err
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_thickness_slot(tmp_path):
|
||||||
|
"""loadThickness feeds parsed points into thicknessData."""
|
||||||
|
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||||||
|
|
||||||
|
item = WaferMapItem()
|
||||||
|
path = _write_csv(tmp_path, "t,1,1,7,1.5,0,10.0,-20.0\n")
|
||||||
|
assert item.loadThickness(path) == ""
|
||||||
|
assert item.hasThickness is True
|
||||||
|
|
||||||
|
assert item.loadThickness("/no/such/file.csv") != ""
|
||||||
|
# Failed load keeps the previously loaded data.
|
||||||
|
assert item.hasThickness is True
|
||||||
|
|
||||||
|
|
||||||
def test_show_thickness_setter():
|
def test_show_thickness_setter():
|
||||||
@@ -88,3 +141,157 @@ def test_show_thickness_setter():
|
|||||||
|
|
||||||
item.showThickness = True
|
item.showThickness = True
|
||||||
assert item._show_thickness is True
|
assert item._show_thickness is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_image_writes_real_png_headless(tmp_path):
|
||||||
|
"""export_image must produce an actual decodable image file without a
|
||||||
|
QQuickWindow — the button was silently failing because grabToImage()
|
||||||
|
resolves asynchronously and never completes headless/immediately."""
|
||||||
|
from PySide6.QtGui import QImage
|
||||||
|
|
||||||
|
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||||||
|
|
||||||
|
item = WaferMapItem()
|
||||||
|
item.setWidth(200)
|
||||||
|
item.setHeight(200)
|
||||||
|
item.values = [10.0, 20.0, 30.0]
|
||||||
|
out = tmp_path / "wafer.png"
|
||||||
|
|
||||||
|
assert item.export_image(str(out)) is True
|
||||||
|
assert out.exists()
|
||||||
|
loaded = QImage(str(out))
|
||||||
|
assert not loaded.isNull()
|
||||||
|
assert loaded.width() == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_image_includes_metrics_footer(tmp_path):
|
||||||
|
"""With sensor values loaded, the export gains a footer strip below the
|
||||||
|
map carrying the metric readouts (sensors / min / max / avg)."""
|
||||||
|
from PySide6.QtGui import QImage
|
||||||
|
|
||||||
|
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||||||
|
|
||||||
|
item = WaferMapItem()
|
||||||
|
item.setWidth(200)
|
||||||
|
item.setHeight(200)
|
||||||
|
item.values = [10.0, 20.0, 30.0]
|
||||||
|
out = tmp_path / "wafer.png"
|
||||||
|
|
||||||
|
assert item.export_image(str(out)) is True
|
||||||
|
loaded = QImage(str(out))
|
||||||
|
assert loaded.height() > 200 # footer strip appended
|
||||||
|
# footer is painted opaque (dark bg), not transparent
|
||||||
|
assert loaded.pixelColor(5, loaded.height() - 5).alpha() == 255
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_image_extra_line_grows_footer(tmp_path):
|
||||||
|
"""Live export passes an extra metrics line; the footer gains a second
|
||||||
|
row for it. Review export (no extra) keeps the single-row footer."""
|
||||||
|
from PySide6.QtGui import QImage
|
||||||
|
|
||||||
|
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||||||
|
|
||||||
|
item = WaferMapItem()
|
||||||
|
item.setWidth(200)
|
||||||
|
item.setHeight(200)
|
||||||
|
item.values = [10.0, 20.0, 30.0]
|
||||||
|
|
||||||
|
review = tmp_path / "review.png"
|
||||||
|
live = tmp_path / "live.png"
|
||||||
|
assert item.export_image(str(review)) is True
|
||||||
|
assert item.export_image(str(live), "Frames: 42 Errors: 0") is True
|
||||||
|
|
||||||
|
h_review = QImage(str(review)).height()
|
||||||
|
h_live = QImage(str(live)).height()
|
||||||
|
assert h_review > 200 # readout footer present
|
||||||
|
assert h_live > h_review # extra live row appended
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_image_highlights_hottest_and_coldest(tmp_path):
|
||||||
|
"""The exported map rings the hottest sensor in the high color and the
|
||||||
|
coldest in the low color. With no bands set every marker fill is the
|
||||||
|
in-range green, so any high/low-colored pixels must come from the rings."""
|
||||||
|
from PySide6.QtGui import QImage
|
||||||
|
|
||||||
|
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||||||
|
|
||||||
|
item = WaferMapItem()
|
||||||
|
item.setWidth(200)
|
||||||
|
item.setHeight(200)
|
||||||
|
item.sensors = [
|
||||||
|
{"label": "1", "x": -50.0, "y": 0.0},
|
||||||
|
{"label": "2", "x": 0.0, "y": 0.0},
|
||||||
|
{"label": "3", "x": 50.0, "y": 0.0},
|
||||||
|
]
|
||||||
|
item.values = [10.0, 20.0, 30.0]
|
||||||
|
out = tmp_path / "wafer.png"
|
||||||
|
assert item.export_image(str(out)) is True
|
||||||
|
|
||||||
|
img = QImage(str(out))
|
||||||
|
found = {img.pixelColor(x, y).name()
|
||||||
|
for y in range(img.height()) for x in range(img.width())}
|
||||||
|
assert "#ef4444" in found # hottest ring (sensorHigh)
|
||||||
|
assert "#5b9df5" in found # coldest ring (sensorLow)
|
||||||
|
|
||||||
|
|
||||||
|
def test_show_extremes_toggle_gates_peak_rings(tmp_path):
|
||||||
|
"""showExtremes (Display setting) hides the hot/cold rings when off.
|
||||||
|
Same pixel probe as the highlight test: with rings off, no high/low
|
||||||
|
colored pixels should appear anywhere."""
|
||||||
|
from PySide6.QtGui import QImage
|
||||||
|
|
||||||
|
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||||||
|
|
||||||
|
item = WaferMapItem()
|
||||||
|
assert item.showExtremes is True # rings on by default
|
||||||
|
|
||||||
|
item.setWidth(200)
|
||||||
|
item.setHeight(200)
|
||||||
|
item.sensors = [
|
||||||
|
{"label": "1", "x": -50.0, "y": 0.0},
|
||||||
|
{"label": "2", "x": 0.0, "y": 0.0},
|
||||||
|
{"label": "3", "x": 50.0, "y": 0.0},
|
||||||
|
]
|
||||||
|
item.values = [10.0, 20.0, 30.0]
|
||||||
|
item.showExtremes = False
|
||||||
|
out = tmp_path / "wafer.png"
|
||||||
|
assert item.export_image(str(out)) is True
|
||||||
|
|
||||||
|
img = QImage(str(out))
|
||||||
|
found = {img.pixelColor(x, y).name()
|
||||||
|
for y in range(img.height()) for x in range(img.width())}
|
||||||
|
assert "#ef4444" not in found
|
||||||
|
assert "#5b9df5" not in found
|
||||||
|
|
||||||
|
|
||||||
|
def test_readout_text_full_stats():
|
||||||
|
"""Export footer readout carries every READOUT stat with sensor indices.
|
||||||
|
|
||||||
|
Worked example: [10, 20, 30] → min 10 (#1), max 30 (#3), diff 20,
|
||||||
|
avg 20, σ = sqrt(200/3) ≈ 8.16, 3σ ≈ 24.49.
|
||||||
|
"""
|
||||||
|
from pygui.backend.visualization.wafer_map_item import readout_text
|
||||||
|
|
||||||
|
assert readout_text([10.0, 20.0, 30.0]) == (
|
||||||
|
"Sensors: 3 Min: 10.00°C (#1) Max: 30.00°C (#3) "
|
||||||
|
"Diff: 20.00 Avg: 20.00 σ: 8.16 3σ: 24.49"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_readout_text_empty():
|
||||||
|
from pygui.backend.visualization.wafer_map_item import readout_text
|
||||||
|
|
||||||
|
assert readout_text([]) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_image_no_data_fails(tmp_path):
|
||||||
|
"""No file loaded / no stream played -> nothing meaningful to export."""
|
||||||
|
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||||||
|
|
||||||
|
item = WaferMapItem()
|
||||||
|
item.setWidth(200)
|
||||||
|
item.setHeight(200)
|
||||||
|
out = tmp_path / "wafer.png"
|
||||||
|
|
||||||
|
assert item.export_image(str(out)) is False
|
||||||
|
assert not out.exists()
|
||||||
|
|||||||
Reference in New Issue
Block a user