Compare commits
65 Commits
SettingTab
...
d63332d619
| Author | SHA1 | Date | |
|---|---|---|---|
| d63332d619 | |||
| 88b0214582 | |||
| 0014ccc184 | |||
| 1989ba8e5a | |||
| 915720085b | |||
| e2d71662ff | |||
| 52cc747536 | |||
| 185e66ea3a | |||
| 3b919f576f | |||
| f604308466 | |||
| 0f813b2376 | |||
| 6996df73f8 | |||
| 3cc10ea7fe | |||
| ecab3d81b1 | |||
| 9e28a25695 | |||
| da43aeb1f6 | |||
| 0a2d6b78ba | |||
| 0d5cf9e4ff | |||
| 27292c3bdb | |||
| d7962f2850 | |||
| 5105ab1ab6 | |||
| db21f201f6 | |||
| c9cb7ab8d8 | |||
| 5d079174e0 | |||
| a54d536efe | |||
| c622a4cc23 | |||
| 3fef31efff | |||
| f9737259c1 | |||
| bf201d6afb | |||
| 831457941a | |||
| cfadbdf1c3 | |||
| f4621f1faf | |||
| 2cbc143c20 | |||
| 470f8acba3 | |||
| 62ce5a9c37 | |||
| 20a34e22ee | |||
| 7206334a27 | |||
| 5514653b94 | |||
| 93868bcde4 | |||
| b417211476 | |||
| 66942d250d | |||
| 8ad2a9b972 | |||
| 1119733929 | |||
| e545c245d7 | |||
| 7d32fb4b65 | |||
| 5b1c924d35 | |||
| b34b920759 | |||
| a70764e08c | |||
| 2e4763510d | |||
| 7e584e08e8 | |||
| cea4fb782e | |||
| 5b186df888 | |||
| f89a735d51 | |||
| 1cd54e81fc | |||
| 12bd778f13 | |||
| 72334795da | |||
| b9f8032203 | |||
| 97ca58bfc2 | |||
| b52b983bb2 | |||
| ca1a514f23 | |||
| 59528eb6c9 | |||
| 9cd3170e8a | |||
| 9779baa468 | |||
| af170666e8 | |||
| 16d8bf48af |
@@ -5,6 +5,9 @@ __pycache__/
|
|||||||
*.pyd
|
*.pyd
|
||||||
*.so
|
*.so
|
||||||
*.a
|
*.a
|
||||||
|
*.egg-info/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
# Virtual environment
|
# Virtual environment
|
||||||
.venv/
|
.venv/
|
||||||
@@ -14,9 +17,11 @@ env/
|
|||||||
# IDE
|
# IDE
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
.qtcreator/
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
|
docs
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type QmlProjectModel 1.0
|
||||||
|
|
||||||
|
QmlProject {
|
||||||
|
name: "ISC"
|
||||||
|
version: "1.0"
|
||||||
|
mainFile: "src/pygui/ISC/Main.qml"
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
module: "QtQuick"
|
||||||
|
minimumVersion: "6.0.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
importPaths: [
|
||||||
|
"src/pygui"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
import QtQuick
|
|
||||||
import QtQuick.Controls
|
|
||||||
import QtQuick.Layouts
|
|
||||||
import ISC
|
|
||||||
|
|
||||||
// ===== Home Workspace Shell =====
|
|
||||||
Rectangle {
|
|
||||||
id: root
|
|
||||||
anchors.fill: parent
|
|
||||||
color: Theme.pageBackground
|
|
||||||
clip: true
|
|
||||||
border.color: Theme.outerFrameBorder
|
|
||||||
border.width: Theme.borderStrong
|
|
||||||
|
|
||||||
// ===== Navigation Model =====
|
|
||||||
// Primary navigation shown in the left rail.
|
|
||||||
property var sideActions: ["DETECT WAFER", "READ MEMORY", "OPEN CSV IN EXCEL", "ERASE MEMORY", "IMPORT DATA", "STORED DATA"]
|
|
||||||
|
|
||||||
// ===== Footer Tab Model =====
|
|
||||||
// Footer tabs drive the active workspace section.
|
|
||||||
property var bottomTabs: ["Status", "Graph", "Data", "Wafer Map", "Compare", "Split", "Settings", "About"]
|
|
||||||
|
|
||||||
// ===== View State =====
|
|
||||||
property int selectedTabIndex: 0
|
|
||||||
property int selectedSideActionIndex: -1 // nothing active on startup
|
|
||||||
|
|
||||||
// ===== Main Two-Column Layout =====
|
|
||||||
RowLayout {
|
|
||||||
anchors.fill: parent
|
|
||||||
spacing: 0
|
|
||||||
|
|
||||||
// ===== Left Action Rail =====
|
|
||||||
// Left control rail.
|
|
||||||
Rectangle {
|
|
||||||
id: sideRail
|
|
||||||
Layout.preferredWidth: Theme.sideRailWidth
|
|
||||||
Layout.fillHeight: true
|
|
||||||
color: Theme.sideRailBackground
|
|
||||||
border.color: Theme.workspaceBorder
|
|
||||||
border.width: Theme.borderThin
|
|
||||||
|
|
||||||
readonly property int actionCount: root.sideActions.length
|
|
||||||
readonly property real computedButtonHeight: Math.min(Theme.sideButtonHeight, (height - (Theme.panelPadding * 2) - (Theme.sideRailSpacing * Math.max(0, actionCount - 1))) / Math.max(1, actionCount))
|
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.margins: Theme.panelPadding
|
|
||||||
spacing: Theme.sideRailSpacing
|
|
||||||
|
|
||||||
Repeater {
|
|
||||||
model: root.sideActions
|
|
||||||
|
|
||||||
Button {
|
|
||||||
id: control
|
|
||||||
text: modelData
|
|
||||||
property bool isActive: index === root.selectedSideActionIndex
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.preferredHeight: Math.max(Theme.sideButtonMinHeight, sideRail.computedButtonHeight)
|
|
||||||
hoverEnabled: true
|
|
||||||
onClicked: {
|
|
||||||
root.selectedSideActionIndex = index
|
|
||||||
root.selectedTabIndex = 0 // always jump to Status tab
|
|
||||||
if (index === 0) deviceController.detectWafer()
|
|
||||||
else if (index === 1) deviceController.readMemoryAsync()
|
|
||||||
else if (index === 2) deviceController.openCsvFile()
|
|
||||||
}
|
|
||||||
|
|
||||||
background: Rectangle {
|
|
||||||
color: {
|
|
||||||
if (control.down) {
|
|
||||||
return Theme.buttonPressed;
|
|
||||||
}
|
|
||||||
if (control.isActive) {
|
|
||||||
return Theme.sideActiveBackground;
|
|
||||||
}
|
|
||||||
return "transparent";
|
|
||||||
}
|
|
||||||
border.color: control.isActive ? Theme.cardBorder : "transparent"
|
|
||||||
border.width: 1
|
|
||||||
radius: Theme.radiusSm
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
anchors.left: parent.left
|
|
||||||
anchors.top: parent.top
|
|
||||||
anchors.bottom: parent.bottom
|
|
||||||
width: 3
|
|
||||||
visible: control.isActive
|
|
||||||
color: Theme.primaryAccent
|
|
||||||
radius: Theme.radiusXs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
contentItem: Text {
|
|
||||||
text: control.text
|
|
||||||
color: control.isActive ? Theme.headingColor : Theme.bodyColor
|
|
||||||
font.bold: control.isActive
|
|
||||||
font.pixelSize: 18
|
|
||||||
wrapMode: Text.WordWrap
|
|
||||||
horizontalAlignment: Text.AlignHCenter
|
|
||||||
verticalAlignment: Text.AlignVCenter
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Main workspace and footer navigation.
|
|
||||||
Rectangle {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.fillHeight: true
|
|
||||||
color: Theme.workspaceBackground
|
|
||||||
border.color: Theme.workspaceBorder
|
|
||||||
border.width: Theme.borderThin
|
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.margins: Theme.mainAreaPadding
|
|
||||||
spacing: Theme.rightPaneGap
|
|
||||||
|
|
||||||
// ===== Active Tab Content Area =====
|
|
||||||
Rectangle {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.fillHeight: true
|
|
||||||
Layout.minimumHeight: 220
|
|
||||||
color: Theme.responseBackground
|
|
||||||
border.color: Theme.responseBorder
|
|
||||||
border.width: 1
|
|
||||||
radius: Theme.radiusMd
|
|
||||||
|
|
||||||
StackLayout {
|
|
||||||
anchors.fill: parent
|
|
||||||
currentIndex: root.selectedTabIndex
|
|
||||||
|
|
||||||
// ===== Tab Content Routing =====
|
|
||||||
Repeater {
|
|
||||||
model: root.bottomTabs
|
|
||||||
|
|
||||||
Item {
|
|
||||||
property string tabName: modelData
|
|
||||||
|
|
||||||
Loader {
|
|
||||||
anchors.fill: parent
|
|
||||||
active: parent.tabName === "Settings"
|
|
||||||
source: parent.tabName === "Settings" ? "Tabs/SettingsTab.qml" : ""
|
|
||||||
}
|
|
||||||
|
|
||||||
Loader {
|
|
||||||
anchors.fill: parent
|
|
||||||
active: parent.tabName === "Status"
|
|
||||||
source: parent.tabName === "Status" ? "Tabs/StatusTab.qml" : ""
|
|
||||||
}
|
|
||||||
|
|
||||||
Loader {
|
|
||||||
anchors.fill: parent
|
|
||||||
active: parent.tabName === "Data"
|
|
||||||
source: parent.tabName === "Data" ? "Tabs/DataTab.qml" : ""
|
|
||||||
}
|
|
||||||
|
|
||||||
Label {
|
|
||||||
anchors.centerIn: parent
|
|
||||||
visible: parent.tabName !== "Settings" && parent.tabName !== "Status" && parent.tabName !== "Data"
|
|
||||||
text: parent.tabName + " content"
|
|
||||||
color: Theme.bodyColor
|
|
||||||
font.pixelSize: 20
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep the tab strip evenly distributed across the footer.
|
|
||||||
// ===== Footer Tab Strip =====
|
|
||||||
Rectangle {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.preferredHeight: Theme.tabBarHeight + (Theme.tabBarPadding * 2)
|
|
||||||
color: Theme.tabBarBackground
|
|
||||||
border.color: Theme.workspaceBorder
|
|
||||||
border.width: Theme.borderThin
|
|
||||||
|
|
||||||
RowLayout {
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.margins: Theme.tabBarPadding
|
|
||||||
spacing: Theme.tabSpacing
|
|
||||||
|
|
||||||
Repeater {
|
|
||||||
model: root.bottomTabs
|
|
||||||
|
|
||||||
Button {
|
|
||||||
id: botTabBtn
|
|
||||||
property bool isActive: index === root.selectedTabIndex
|
|
||||||
|
|
||||||
text: modelData
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.preferredHeight: Theme.tabBarHeight
|
|
||||||
Layout.minimumWidth: Theme.tabButtonMinWidth
|
|
||||||
onClicked: root.selectedTabIndex = index
|
|
||||||
hoverEnabled: true
|
|
||||||
|
|
||||||
background: Rectangle {
|
|
||||||
color: botTabBtn.isActive ? Theme.tabActiveBackground : (botTabBtn.hovered ? Theme.tabHoverBackground : Theme.tabBackground)
|
|
||||||
border.color: Theme.tabBorder
|
|
||||||
border.width: 1
|
|
||||||
radius: Theme.tabRadius
|
|
||||||
}
|
|
||||||
|
|
||||||
contentItem: Text {
|
|
||||||
text: botTabBtn.text
|
|
||||||
color: botTabBtn.isActive ? Theme.tabActiveText : Theme.tabText
|
|
||||||
font.pixelSize: Theme.tabFontSize
|
|
||||||
font.bold: botTabBtn.isActive
|
|
||||||
horizontalAlignment: Text.AlignHCenter
|
|
||||||
verticalAlignment: Text.AlignVCenter
|
|
||||||
elide: Text.ElideRight
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
import QtQuick
|
|
||||||
import QtQuick.Controls
|
|
||||||
import QtQuick.Layouts
|
|
||||||
import ISC
|
|
||||||
|
|
||||||
// ===== Data Tab =====
|
|
||||||
// Displays parsed temperature data in a scrollable table.
|
|
||||||
// Column 0 = Row index, remaining columns = Sensor1, Sensor2, ...
|
|
||||||
Item {
|
|
||||||
id: root
|
|
||||||
anchors.fill: parent
|
|
||||||
|
|
||||||
readonly property int rowCount: deviceController.dataRowCount
|
|
||||||
|
|
||||||
// Re-layout columns whenever the model is reset
|
|
||||||
Connections {
|
|
||||||
target: deviceController
|
|
||||||
function onParsedDataReady(result) {
|
|
||||||
if (result && result.success) {
|
|
||||||
dataTable.forceLayout()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Empty State =====
|
|
||||||
Column {
|
|
||||||
anchors.centerIn: parent
|
|
||||||
spacing: 16
|
|
||||||
visible: root.rowCount === 0
|
|
||||||
|
|
||||||
Label {
|
|
||||||
text: "No Data"
|
|
||||||
font.pixelSize: 24
|
|
||||||
font.bold: true
|
|
||||||
color: Theme.headingColor
|
|
||||||
anchors.horizontalCenter: parent.horizontalCenter
|
|
||||||
}
|
|
||||||
|
|
||||||
Label {
|
|
||||||
text: "Read and parse wafer data to see temperature readings here."
|
|
||||||
font.pixelSize: 14
|
|
||||||
color: Theme.bodyColor
|
|
||||||
anchors.horizontalCenter: parent.horizontalCenter
|
|
||||||
wrapMode: Text.WordWrap
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Data Table =====
|
|
||||||
ColumnLayout {
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.margins: Theme.panelPadding
|
|
||||||
spacing: Theme.rightPaneGap
|
|
||||||
visible: root.rowCount > 0
|
|
||||||
|
|
||||||
// --- Header row ---
|
|
||||||
RowLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
spacing: Theme.rightPaneGap
|
|
||||||
|
|
||||||
Label {
|
|
||||||
text: "Temperature Data"
|
|
||||||
font.pixelSize: 18
|
|
||||||
font.bold: true
|
|
||||||
color: Theme.headingColor
|
|
||||||
}
|
|
||||||
|
|
||||||
Item { Layout.fillWidth: true }
|
|
||||||
|
|
||||||
Label {
|
|
||||||
text: deviceController.dataRowCount + " rows × " +
|
|
||||||
deviceController.dataColCount + " sensors"
|
|
||||||
font.pixelSize: 13
|
|
||||||
color: Theme.bodyColor
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Table container ---
|
|
||||||
Rectangle {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.fillHeight: true
|
|
||||||
color: Theme.panelBackground
|
|
||||||
border.color: Theme.cardBorder
|
|
||||||
border.width: Theme.borderThin
|
|
||||||
radius: Theme.radiusSm
|
|
||||||
clip: true
|
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.margins: 1
|
|
||||||
spacing: 0
|
|
||||||
|
|
||||||
// Column header strip
|
|
||||||
HorizontalHeaderView {
|
|
||||||
id: headerView
|
|
||||||
Layout.fillWidth: true
|
|
||||||
syncView: dataTable
|
|
||||||
clip: true
|
|
||||||
|
|
||||||
delegate: Rectangle {
|
|
||||||
implicitHeight: 28
|
|
||||||
color: Theme.cardBackground
|
|
||||||
border.color: Theme.cardBorder
|
|
||||||
border.width: 1
|
|
||||||
|
|
||||||
Text {
|
|
||||||
anchors.centerIn: parent
|
|
||||||
// 'display' is the Qt.DisplayRole value from headerData()
|
|
||||||
text: display !== undefined ? display : ""
|
|
||||||
color: Theme.headingColor
|
|
||||||
font.pixelSize: 11
|
|
||||||
font.bold: true
|
|
||||||
elide: Text.ElideRight
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Data rows
|
|
||||||
TableView {
|
|
||||||
id: dataTable
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.fillHeight: true
|
|
||||||
model: deviceController.dataModel
|
|
||||||
clip: true
|
|
||||||
reuseItems: true
|
|
||||||
|
|
||||||
columnWidthProvider: function(col) {
|
|
||||||
if (col === 0) return 52 // row index column
|
|
||||||
const sensorCols = Math.max(1, dataTable.columns - 1)
|
|
||||||
const available = dataTable.width - 52
|
|
||||||
return Math.max(52, Math.min(90, available / sensorCols))
|
|
||||||
}
|
|
||||||
|
|
||||||
rowHeightProvider: function() { return 24 }
|
|
||||||
|
|
||||||
delegate: Rectangle {
|
|
||||||
color: row % 2 === 0 ? Theme.panelBackground : Theme.cardBackground
|
|
||||||
|
|
||||||
Text {
|
|
||||||
anchors.centerIn: parent
|
|
||||||
text: display !== undefined ? display : ""
|
|
||||||
color: Theme.bodyColor
|
|
||||||
font.pixelSize: 11
|
|
||||||
elide: Text.ElideRight
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ScrollBar.horizontal: ScrollBar { policy: ScrollBar.AsNeeded }
|
|
||||||
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,290 +0,0 @@
|
|||||||
import QtQuick
|
|
||||||
import QtQuick.Layouts
|
|
||||||
import QtQuick.Controls
|
|
||||||
import ISC
|
|
||||||
|
|
||||||
// ===== Status Tab =====
|
|
||||||
// Blank on startup. Fills in after Detect Wafer fires from the left rail.
|
|
||||||
ColumnLayout {
|
|
||||||
id: root
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.margins: Theme.panelPadding
|
|
||||||
spacing: Theme.rightPaneGap
|
|
||||||
|
|
||||||
property alias waferFamilyCode: waferInfoFamily.text
|
|
||||||
property alias waferSerialNumber: waferSerial.text
|
|
||||||
property alias waferSensorCount: waferSensors.text
|
|
||||||
property alias waferRuntime: waferRuntime.text
|
|
||||||
property alias waferCycles: waferCycles.text
|
|
||||||
property bool waferDetected: false
|
|
||||||
|
|
||||||
// Latches true once any operation starts (or a previous session is
|
|
||||||
// restored) and never resets, so the status panel stays visible even
|
|
||||||
// when a detect finds no wafer.
|
|
||||||
property bool statusActive: false
|
|
||||||
|
|
||||||
// Data summary after parse
|
|
||||||
property int dataRows: 0
|
|
||||||
property int dataCols: 0
|
|
||||||
property string csvPath: ""
|
|
||||||
property bool dataParsed: false
|
|
||||||
|
|
||||||
// ===== Empty state — nothing shown until detect fires =====
|
|
||||||
Item {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.fillHeight: true
|
|
||||||
visible: !root.statusActive
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Results area (shown once detect/operation runs) =====
|
|
||||||
ColumnLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.fillHeight: true
|
|
||||||
visible: root.statusActive
|
|
||||||
spacing: Theme.rightPaneGap
|
|
||||||
|
|
||||||
// --- Connection Status ---
|
|
||||||
Rectangle {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.preferredHeight: 86
|
|
||||||
color: {
|
|
||||||
if (deviceController.connectionStatus === "Connected") return Theme.statusSuccessColor + "22"
|
|
||||||
if (deviceController.connectionStatus === "Disconnected") return Theme.statusErrorColor + "22"
|
|
||||||
return Theme.cardBackground
|
|
||||||
}
|
|
||||||
border.color: {
|
|
||||||
if (deviceController.connectionStatus === "Connected") return Theme.statusSuccessColor
|
|
||||||
if (deviceController.connectionStatus === "Disconnected") return Theme.statusErrorColor
|
|
||||||
return Theme.cardBorder
|
|
||||||
}
|
|
||||||
radius: Theme.radiusSm
|
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.margins: Theme.panelPadding
|
|
||||||
spacing: 4
|
|
||||||
|
|
||||||
Text {
|
|
||||||
text: deviceController.connectionStatus
|
|
||||||
color: {
|
|
||||||
if (deviceController.connectionStatus === "Connected") return Theme.statusSuccessColor
|
|
||||||
if (deviceController.connectionStatus === "Disconnected") return Theme.statusErrorColor
|
|
||||||
return Theme.bodyColor
|
|
||||||
}
|
|
||||||
font.bold: true
|
|
||||||
font.pixelSize: 16
|
|
||||||
Layout.fillWidth: true
|
|
||||||
}
|
|
||||||
|
|
||||||
Text {
|
|
||||||
text: "Port: " + (deviceController.selectedPort || "None selected")
|
|
||||||
color: Theme.bodyColor
|
|
||||||
font.pixelSize: 14
|
|
||||||
Layout.fillWidth: true
|
|
||||||
}
|
|
||||||
|
|
||||||
Text {
|
|
||||||
text: "Save dir: " + deviceController.saveDataDir
|
|
||||||
color: Theme.bodyColor
|
|
||||||
font.pixelSize: 12
|
|
||||||
opacity: 0.7
|
|
||||||
elide: Text.ElideMiddle
|
|
||||||
Layout.fillWidth: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Wafer Info ---
|
|
||||||
GroupBox {
|
|
||||||
title: "Wafer Information"
|
|
||||||
visible: root.waferDetected
|
|
||||||
Layout.fillWidth: true
|
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.margins: Theme.panelPadding
|
|
||||||
spacing: 8
|
|
||||||
|
|
||||||
RowLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
spacing: Theme.rightPaneGap
|
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
spacing: 4
|
|
||||||
Text { text: "Family Code"; color: Theme.subheadingColor; font.pixelSize: 12 }
|
|
||||||
Text { id: waferInfoFamily; text: "—"; color: Theme.bodyColor; font.pixelSize: 16; font.bold: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
spacing: 4
|
|
||||||
Text { text: "Serial Number"; color: Theme.subheadingColor; font.pixelSize: 12 }
|
|
||||||
Text { id: waferSerial; text: "—"; color: Theme.bodyColor; font.pixelSize: 16; font.bold: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
spacing: 4
|
|
||||||
Text { text: "Sensor Count"; color: Theme.subheadingColor; font.pixelSize: 12 }
|
|
||||||
Text { id: waferSensors; text: "—"; color: Theme.bodyColor; font.pixelSize: 16; font.bold: true }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
RowLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
spacing: Theme.rightPaneGap
|
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
spacing: 4
|
|
||||||
Text { text: "Runtime"; color: Theme.subheadingColor; font.pixelSize: 12 }
|
|
||||||
Text { id: waferRuntime; text: "—"; color: Theme.bodyColor; font.pixelSize: 16; font.bold: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
spacing: 4
|
|
||||||
Text { text: "Cycles"; color: Theme.subheadingColor; font.pixelSize: 12 }
|
|
||||||
Text { id: waferCycles; text: "—"; color: Theme.bodyColor; font.pixelSize: 16; font.bold: true }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Data Summary (shown after parse) ---
|
|
||||||
GroupBox {
|
|
||||||
title: "Data Summary"
|
|
||||||
visible: root.dataParsed
|
|
||||||
Layout.fillWidth: true
|
|
||||||
|
|
||||||
RowLayout {
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.margins: Theme.panelPadding
|
|
||||||
spacing: Theme.rightPaneGap
|
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
spacing: 4
|
|
||||||
Text { text: "Rows"; color: Theme.subheadingColor; font.pixelSize: 12 }
|
|
||||||
Text { text: String(root.dataRows); color: Theme.bodyColor; font.pixelSize: 16; font.bold: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
spacing: 4
|
|
||||||
Text { text: "Sensors"; color: Theme.subheadingColor; font.pixelSize: 12 }
|
|
||||||
Text { text: String(root.dataCols); color: Theme.bodyColor; font.pixelSize: 16; font.bold: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
spacing: 4
|
|
||||||
Text { text: "CSV"; color: Theme.subheadingColor; font.pixelSize: 12 }
|
|
||||||
Text { text: root.csvPath; color: Theme.bodyColor; font.pixelSize: 12; elide: Text.ElideMiddle }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Activity Log ---
|
|
||||||
GroupBox {
|
|
||||||
title: "Activity Log"
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.fillHeight: true
|
|
||||||
|
|
||||||
ScrollView {
|
|
||||||
anchors.fill: parent
|
|
||||||
clip: true
|
|
||||||
|
|
||||||
TextArea {
|
|
||||||
id: activityLog
|
|
||||||
width: parent.width
|
|
||||||
text: ""
|
|
||||||
readOnly: true
|
|
||||||
font.family: "monospace"
|
|
||||||
font.pixelSize: 12
|
|
||||||
wrapMode: TextArea.WordWrap
|
|
||||||
leftPadding: 4
|
|
||||||
rightPadding: 4
|
|
||||||
color: Theme.bodyColor
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Connections {
|
|
||||||
target: deviceController
|
|
||||||
function onActivityLogUpdated(newLog) {
|
|
||||||
activityLog.text = newLog
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Signal Handlers ---
|
|
||||||
Connections {
|
|
||||||
target: deviceController
|
|
||||||
// Any operation start (detect/read/erase/debug) latches the panel on.
|
|
||||||
function onPortsUpdated() {
|
|
||||||
if (deviceController.operationInProgress)
|
|
||||||
root.statusActive = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDetectResult(result) {
|
|
||||||
root.statusActive = true
|
|
||||||
if (result && result.familyCode) {
|
|
||||||
root.waferDetected = true
|
|
||||||
waferInfoFamily.text = result.familyCode
|
|
||||||
waferSerial.text = result.serialNumber
|
|
||||||
waferSensors.text = String(result.sensorCount)
|
|
||||||
waferRuntime.text = result.runtime + "s"
|
|
||||||
waferCycles.text = String(result.cycleCount)
|
|
||||||
}
|
|
||||||
// On failure, keep the last displayed wafer info and do not change waferDetected.
|
|
||||||
}
|
|
||||||
|
|
||||||
function onStatusRestored() {
|
|
||||||
// Show restored wafer info if available
|
|
||||||
var info = deviceController.lastWaferInfo
|
|
||||||
if (info && info.length > 0) {
|
|
||||||
root.statusActive = true
|
|
||||||
root.waferDetected = true
|
|
||||||
waferInfoFamily.text = info[0] || "—"
|
|
||||||
waferSerial.text = info[1] || "—"
|
|
||||||
waferSensors.text = String(info[2] || 0)
|
|
||||||
waferRuntime.text = (info[3] || 0) + "s"
|
|
||||||
waferCycles.text = String(info[4] || 0)
|
|
||||||
}
|
|
||||||
// Show data summary if data was previously parsed
|
|
||||||
if (deviceController.dataRowCount > 0) {
|
|
||||||
root.statusActive = true
|
|
||||||
root.dataParsed = true
|
|
||||||
root.dataRows = deviceController.dataRowCount
|
|
||||||
root.dataCols = deviceController.dataColCount
|
|
||||||
root.csvPath = "" // CSV path not persisted as full path, just show it was parsed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Connections {
|
|
||||||
target: deviceController
|
|
||||||
function onReadResult(result) {
|
|
||||||
root.dataParsed = false
|
|
||||||
root.dataRows = 0
|
|
||||||
root.dataCols = 0
|
|
||||||
root.csvPath = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Connections {
|
|
||||||
target: deviceController
|
|
||||||
function onParsedDataReady(result) {
|
|
||||||
if (result && result.success) {
|
|
||||||
root.dataParsed = true
|
|
||||||
root.dataRows = result.rows
|
|
||||||
root.dataCols = result.cols
|
|
||||||
root.csvPath = result.csv_path || ""
|
|
||||||
} else {
|
|
||||||
root.dataParsed = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# ===== ISC Tabs Module =====
|
|
||||||
module ISC.Tabs
|
|
||||||
|
|
||||||
# ===== Tab Components =====
|
|
||||||
SettingsTab 1.0 SettingsTab.qml
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
pragma Singleton
|
|
||||||
|
|
||||||
import QtQuick
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Theme — single source of truth for colors and sizing
|
|
||||||
// =============================================================================
|
|
||||||
// Sections:
|
|
||||||
// 1. Mode flag
|
|
||||||
// 2. Tone palette (raw tones; everything else derives from these)
|
|
||||||
// 3. Surfaces (page / card / panel / workspace backgrounds)
|
|
||||||
// 4. Borders
|
|
||||||
// 5. Text (headings, body, panel titles)
|
|
||||||
// 6. Controls (fields, buttons, checkbox)
|
|
||||||
// 7. Tracks (pill toggle + scrollbar)
|
|
||||||
// 8. Tabs (footer tab bar)
|
|
||||||
// 9. Side rail
|
|
||||||
// 10. Status (success / warning / error)
|
|
||||||
// 11. Geometry (radius / border width / spacing / sizing)
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
QtObject {
|
|
||||||
// ── 1. Mode ──────────────────────────────────────────────────────────────
|
|
||||||
property bool isDarkMode: false
|
|
||||||
|
|
||||||
// ── 2. Tone palette (base values; consume via semantic tokens below) ─────
|
|
||||||
readonly property color tone100: isDarkMode ? "#111111" : "#FAFAFA"
|
|
||||||
readonly property color tone200: isDarkMode ? "#1A1A1A" : "#F2F2F2"
|
|
||||||
readonly property color tone300: isDarkMode ? "#242424" : "#E8E8E8"
|
|
||||||
readonly property color toneText: isDarkMode ? "#F2F2F2" : "#111111"
|
|
||||||
readonly property color toneMute: isDarkMode ? "#A8A8A8" : "#8A8A8A"
|
|
||||||
readonly property color toneBorder: isDarkMode ? "#2B2B2B" : "#D8D8D8"
|
|
||||||
|
|
||||||
// ── 3. Surfaces ──────────────────────────────────────────────────────────
|
|
||||||
readonly property color pageBackground: tone100
|
|
||||||
readonly property color cardBackground: tone200
|
|
||||||
readonly property color panelBackground: tone200
|
|
||||||
readonly property color workspaceBackground: tone200
|
|
||||||
readonly property color responseBackground: tone200
|
|
||||||
readonly property color subtleSectionBackground: tone300
|
|
||||||
|
|
||||||
// ── 4. Borders ───────────────────────────────────────────────────────────
|
|
||||||
readonly property color cardBorder: toneBorder
|
|
||||||
readonly property color responseBorder: toneBorder
|
|
||||||
readonly property color workspaceBorder: toneBorder
|
|
||||||
readonly property color innerFrameBorder: toneBorder
|
|
||||||
readonly property color outerFrameBorder: isDarkMode ? "#343434" : "#CECECE"
|
|
||||||
readonly property color softBorder: isDarkMode ? "#222222" : "#E2E2E2"
|
|
||||||
|
|
||||||
// ── 5. Text ──────────────────────────────────────────────────────────────
|
|
||||||
readonly property color headingColor: toneText
|
|
||||||
readonly property color bodyColor: toneMute
|
|
||||||
readonly property color subheadingColor: toneMute
|
|
||||||
readonly property color panelTitleText: toneMute
|
|
||||||
readonly property color disabledText: toneMute
|
|
||||||
|
|
||||||
// ── 6. Controls ──────────────────────────────────────────────────────────
|
|
||||||
// Fields
|
|
||||||
readonly property color fieldBackground: tone100
|
|
||||||
readonly property color fieldText: toneText
|
|
||||||
readonly property color fieldPlaceholder: toneMute
|
|
||||||
readonly property color fieldBorder: toneBorder
|
|
||||||
readonly property color fieldBorderFocus: toneText
|
|
||||||
// Neutral buttons
|
|
||||||
readonly property color buttonNeutralBackground: tone300
|
|
||||||
readonly property color buttonNeutralHover: isDarkMode ? "#303030" : "#DDDDDD"
|
|
||||||
readonly property color buttonNeutralPressed: tone100
|
|
||||||
readonly property color buttonNeutralText: toneText
|
|
||||||
readonly property color buttonPressed: tone300
|
|
||||||
readonly property color primaryAccent: toneText
|
|
||||||
// Checkbox
|
|
||||||
readonly property color checkboxText: toneText
|
|
||||||
|
|
||||||
// ── 7. Tracks (pill toggle, scrollbar thumb) ─────────────────────────────
|
|
||||||
readonly property color trackBackground: isDarkMode ? "#25252B" : "#D1D5DB"
|
|
||||||
|
|
||||||
// ── 8. Tabs (footer tab bar) ─────────────────────────────────────────────
|
|
||||||
readonly property color tabBarBackground: tone200
|
|
||||||
readonly property color tabBackground: "transparent"
|
|
||||||
readonly property color tabActiveBackground: "transparent"
|
|
||||||
readonly property color tabHoverBackground: tone300
|
|
||||||
readonly property color tabBorder: "transparent"
|
|
||||||
readonly property color tabText: toneMute
|
|
||||||
readonly property color tabActiveText: toneText
|
|
||||||
|
|
||||||
// ── 9. Side rail ─────────────────────────────────────────────────────────
|
|
||||||
readonly property color sideRailBackground: tone200
|
|
||||||
readonly property color sideActiveBackground: tone300
|
|
||||||
|
|
||||||
// ── 10. Status ───────────────────────────────────────────────────────────
|
|
||||||
readonly property color statusSuccessColor: isDarkMode ? "#63D471" : "#2E9E44"
|
|
||||||
readonly property color statusWarningColor: isDarkMode ? "#F5C15C" : "#C88A18"
|
|
||||||
readonly property color statusErrorColor: isDarkMode ? "#FF6B6B" : "#D64545"
|
|
||||||
|
|
||||||
// ── 11. Geometry ─────────────────────────────────────────────────────────
|
|
||||||
// Radius
|
|
||||||
readonly property int radiusXs: 4 // fields, tight elements
|
|
||||||
readonly property int radiusSm: 6 // buttons
|
|
||||||
readonly property int radiusMd: 10 // cards, group boxes
|
|
||||||
readonly property int radiusLg: 14 // large panels / dialogs
|
|
||||||
// Border width
|
|
||||||
readonly property int borderThin: 1
|
|
||||||
readonly property int borderStrong: 2
|
|
||||||
// Main panel layout
|
|
||||||
readonly property int mainAreaPadding: 10
|
|
||||||
readonly property int rightPaneGap: 6
|
|
||||||
readonly property int panelPadding: 12
|
|
||||||
// Side rail layout
|
|
||||||
readonly property int sideRailWidth: 190
|
|
||||||
readonly property int sideRailSpacing: 14
|
|
||||||
readonly property int sideButtonHeight: 146
|
|
||||||
readonly property int sideButtonMinHeight: 88
|
|
||||||
// Tab bar layout
|
|
||||||
readonly property int tabBarHeight: 34
|
|
||||||
readonly property int tabBarPadding: 6
|
|
||||||
readonly property int tabSpacing: 2
|
|
||||||
readonly property int tabRadius: 2
|
|
||||||
readonly property int tabFontSize: 12
|
|
||||||
readonly property int tabButtonMinWidth: 80
|
|
||||||
// Settings page layout
|
|
||||||
readonly property int settingsPanelMaxWidth: 760
|
|
||||||
readonly property int settingsOuterMargin: 40
|
|
||||||
readonly property int settingsSectionSpacing: 20
|
|
||||||
readonly property int settingsRowSpacing: 16
|
|
||||||
readonly property int settingsGridSpacing: 12
|
|
||||||
readonly property int settingsButtonHeight: 40
|
|
||||||
readonly property int settingsActionWidth: 200
|
|
||||||
readonly property int settingsFieldMinWidth: 160
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
.PHONY: install test lint fix typecheck run clean
|
||||||
|
|
||||||
|
install:
|
||||||
|
uv sync
|
||||||
|
|
||||||
|
test:
|
||||||
|
uv run pytest tests/
|
||||||
|
|
||||||
|
lint:
|
||||||
|
uv run ruff check src/
|
||||||
|
|
||||||
|
fix:
|
||||||
|
uv run ruff check src/ --fix
|
||||||
|
|
||||||
|
typecheck:
|
||||||
|
uv run mypy src/
|
||||||
|
|
||||||
|
run:
|
||||||
|
uv run python -m pygui
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf .pytest_cache .ruff_cache .mypy_cache
|
||||||
|
find . -type d -name "__pycache__" -exec rm -rf {} +
|
||||||
@@ -2,48 +2,156 @@
|
|||||||
|
|
||||||
Small PySide6 + Qt Quick application scaffold for the `ISenseCloud` desktop UI shell.
|
Small PySide6 + Qt Quick application scaffold for the `ISenseCloud` desktop UI shell.
|
||||||
|
|
||||||
|
## Key App Features
|
||||||
|
|
||||||
|
The `ISenseCloud` application provides a real-time monitor and analysis dashboard for silicon wafer temperature profiles:
|
||||||
|
|
||||||
|
- **Real-Time Data Streaming & Coalescing**: Decodes binary frame streams from thermal sensors over serial/COM connections at ~20 Hz with low CPU overhead.
|
||||||
|
- **RBF Heatmap Interpolation**: A Radial Basis Function (RBF) interpolation engine paints a smooth 2D temperature gradient across the wafer surface based on physical sensor locations.
|
||||||
|
- **Interactive Review & Playback**: A `FramePlayer` component supports variable-speed playback (play, pause, step, seek) of recorded runs.
|
||||||
|
- **Interactive Trend Charts**: Renders multi-series temperature trends using a custom QtQuick painted item (`GraphQuickItem`) with automatic scaling, custom legends, and grid layouts.
|
||||||
|
- **Tabular Data Inspection**: Direct inspection of tabular dataset grids for frame-by-frame diagnostic checkups.
|
||||||
|
- **Custom Safety Limits & Thresholds**: Configurable alarm thresholds and set-points with automatic alerts when temperatures diverge from normal boundaries.
|
||||||
|
|
||||||
|
### App Showcase
|
||||||
|

|
||||||
|
*Figure 1: Main Status Dashboard displaying successful connection, active serial communications, and automated logging terminal.*
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> **Take Screenshots for Visual Placeholders:**
|
||||||
|
> - **Wafer Heatmap Tab**: Take a screenshot of the Wafer Map tab showing the radial heatmap interpolation, save it to `docs/design/wafer_heatmap_tab.png` and add it here.
|
||||||
|
> - **Temperature Trend Graph**: Take a screenshot of the Graph tab plotting multi-sensor trend lines, save it to `docs/design/temp_trend_tab.png` and add it here.
|
||||||
|
|
||||||
|
## Key Architectural & Design Decisions
|
||||||
|
|
||||||
|
During the refactoring from the flat-layout prototype (`SettingTab` branch) to the unified package application (`main`), the following structural changes were made:
|
||||||
|
|
||||||
|
1. **Package Layout Restructuring**: Reorganized flat imports into a clean `src/` layout under `pygui.*` sub-packages (`controllers/`, `models/`, `data/`, `visualization/`, `wafer/`) for better module isolation.
|
||||||
|
2. **QML Component Decoupling**: Monolithic pages were refactored into reusable component blocks (e.g., `ReadoutPanel.qml`, `WaferMapView.qml`, `TransportBar.qml` under `src/pygui/ISC/Tabs/components/`), easing tab maintenance.
|
||||||
|
3. **Decoupled Controller Architecture**: Introduced QML-bound controllers (`DeviceController`, `SessionController`) acting as bridges between UI triggers and backend model states (`FramePlayer`, `SessionModel`), keeping UI files strictly visual.
|
||||||
|
4. **Robust Slot Error Boundaries**: Decorated QML-callable slots with a custom `@slot_error_boundary` wrapper to intercept runtime errors gracefully without terminating the Qt environment.
|
||||||
|
5. **Modernized Rendering Engine**: Replaced outdated contour algorithms with Radial Basis Function (RBF) interpolation (`rbf_heatmap.py`) to render high-fidelity, real-time thermal profiles.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Python 3.10+ (tested with Python 3.14 in this workspace)
|
- Python 3.11+ (tested with Python 3.14 in this workspace)
|
||||||
- macOS, Linux, or Windows with GUI support
|
- macOS, Linux, or Windows with GUI support
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
From the project root:
|
From the project root:
|
||||||
|
|
||||||
|
### Option A: Using `uv` (Recommended)
|
||||||
|
If you have [uv](https://docs.astral.sh/uv/) installed:
|
||||||
|
```bash
|
||||||
|
uv sync
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option B: Using `pip`
|
||||||
```bash
|
```bash
|
||||||
python3 -m venv .venv
|
python3 -m venv .venv
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate # or .venv\Scripts\activate on Windows
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
pip install -e . # install the `pygui` package (src/ layout) in editable mode
|
||||||
```
|
```
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
|
### Launch PySide6 QML App
|
||||||
```bash
|
```bash
|
||||||
source .venv/bin/activate
|
make run
|
||||||
python main.py
|
|
||||||
```
|
```
|
||||||
|
This launches the Qt window and loads the `ISC` QML module from `src/pygui/ISC/Main.qml`.
|
||||||
|
|
||||||
This launches the Qt window and loads the `ISC` QML module from `ISC/Main.qml`.
|
## Development
|
||||||
|
|
||||||
|
The project uses `uv` for dependency management and tooling, `Ruff` for linting and formatting, `Mypy` for static type checking, and `pytest` for unit tests.
|
||||||
|
|
||||||
|
A `Makefile` is provided to simplify common development and verification tasks:
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `make install` | Sync dependencies and set up the local `.venv` using `uv` |
|
||||||
|
| `make run` | Launch the ISenseCloud application |
|
||||||
|
| `make test` | Run the `pytest` test suite |
|
||||||
|
| `make lint` | Check code style and quality with the `Ruff` linter |
|
||||||
|
| `make fix` | Automatically fix fixable `Ruff` style violations |
|
||||||
|
| `make typecheck` | Run the `Mypy` static type checker |
|
||||||
|
| `make clean` | Clean build and tool caches (`.pytest_cache`, `.ruff_cache`, `.mypy_cache`, `__pycache__`) |
|
||||||
|
|
||||||
|
### Ruff Configuration
|
||||||
|
|
||||||
|
Ruff is configured in `pyproject.toml` to enforce:
|
||||||
|
- **E/W**: Pycodestyle errors and warnings
|
||||||
|
- **F**: Pyflakes linter rules
|
||||||
|
- **I**: Import sorting (isort parity)
|
||||||
|
- **N**: PEP 8 naming conventions
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
- `main.py`: PySide6 bootstrap; creates the Qt app and loads QML module `ISC/Main`.
|
The application lives under a `src/` layout as the `pygui` package:
|
||||||
- `ISC/Main.qml`: top-level window definition.
|
|
||||||
- `ISC/HomePage.qml`: main UI layout (left action rail, workspace panel, footer tabs).
|
- `src/pygui/__main__.py`: PySide6 bootstrap; creates the Qt app and loads QML module `ISC/Main`. Entry point for `python -m pygui`.
|
||||||
- `ISC/Theme.qml`: shared theme constants and dark/light mode tokens.
|
- `src/pygui/backend/`: Qt-facing models, controllers, and visualizers organized into subdirectories:
|
||||||
- `ISC/qmldir`: QML module registration.
|
- `controllers/`: QML-facing controllers (Session & Device controllers).
|
||||||
|
- `data/`: local settings, file browser, and CSV recorder utilities.
|
||||||
|
- `models/`: data models (Session, Thresholds, Frame Player, Frame representation).
|
||||||
|
- `visualization/`: QML wafer map item integration.
|
||||||
|
- `wafer/`: layout definitions, coordinate mappings, and files parser.
|
||||||
|
- `src/pygui/serialcomm/`: serial port transport, device service scanning, and protocol data-parser layer.
|
||||||
|
- `src/pygui/ISC/`: the `ISC` QML module (UI).
|
||||||
|
- `Main.qml`: top-level window definition.
|
||||||
|
- `HomePage.qml`: main UI layout (left action rail, workspace panel, footer tabs).
|
||||||
|
- `Theme.qml`: shared theme constants and dark/light mode tokens.
|
||||||
|
- `qmldir`: QML module registration.
|
||||||
|
- `tests/`: pytest suite.
|
||||||
|
- `packaging/`: PyInstaller spec (`isc.spec`) and app icons.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────────┐
|
||||||
|
│ QML Layer (src/pygui/ISC/) │
|
||||||
|
│ HomePage.qml ─ WaferMapTab.qml ─ DataTab.qml │
|
||||||
|
│ StatusTab.qml ─ SettingsTab.qml ─ components/* │
|
||||||
|
└─────────────────────────┬──────────────────────────────────┘
|
||||||
|
│ Q_PROPERTY / @Slot / Signal
|
||||||
|
┌─────────────────────────▼──────────────────────────────────┐
|
||||||
|
│ Controller Layer (backend/controllers/) │
|
||||||
|
│ DeviceController ─ SessionController │
|
||||||
|
│ (serial ops, status) (streaming, playback) │
|
||||||
|
└─────────────────────────┬──────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌─────────────────────────▼──────────────────────────────────┐
|
||||||
|
│ Model Layer (backend/models/) │
|
||||||
|
│ FramePlayer ─ SessionModel ─ ReplayStatsTracker │
|
||||||
|
│ ThresholdClassifier ─ SensorEditor ─ ClusterAverage │
|
||||||
|
│ TemperatureTableModel │
|
||||||
|
└─────────────────────────┬──────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌─────────────────────────▼──────────────────────────────────┐
|
||||||
|
│ Serial / Data Layer (serialcomm/ + data/) │
|
||||||
|
│ SerialPort ─ DeviceService ─ StreamReader ─ data_parser │
|
||||||
|
│ LocalSettings ─ FileBrowser ─ CsvRecorder │
|
||||||
|
└─────────────────────────┬──────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌─────────────────────────▼──────────────────────────────────┐
|
||||||
|
│ Visualization (backend/visualization/) │
|
||||||
|
│ WaferMapItem ─ GraphView ─ TrendChartItem │
|
||||||
|
│ RBFHeatmap ─ WaferMapView (QML wrapper) │
|
||||||
|
└────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
## Window Configuration
|
## Window Configuration
|
||||||
|
|
||||||
Window dimensions and constraints are defined in `ISC/Main.qml`:
|
Window dimensions and constraints are defined in `src/pygui/ISC/Main.qml`:
|
||||||
|
|
||||||
- **Default size**: 1400 × 820 pixels
|
- **Default size**: 1400 × 820 pixels
|
||||||
- **Minimum size**: 1100 × 700 pixels
|
- **Minimum size**: 1100 × 700 pixels
|
||||||
- **Title bar**: "ISenseCloud"
|
- **Title bar**: "ISenseCloud"
|
||||||
|
|
||||||
To adjust the window, edit the `Window` block in `ISC/Main.qml`:
|
To adjust the window, edit the `Window` block in `src/pygui/ISC/Main.qml`:
|
||||||
|
|
||||||
```qml
|
```qml
|
||||||
Window {
|
Window {
|
||||||
@@ -58,16 +166,46 @@ Window {
|
|||||||
|
|
||||||
## Customization
|
## Customization
|
||||||
|
|
||||||
- Toggle dark/light mode in `ISC/Theme.qml` via `isDarkMode`.
|
- Toggle dark/light mode in `src/pygui/ISC/Theme.qml` via `isDarkMode`.
|
||||||
- Update sidebar and footer labels in `ISC/HomePage.qml` through `sideActions` and `bottomTabs`.
|
- Update sidebar and footer labels in `src/pygui/ISC/HomePage.qml` through `sideActions` and `bottomTabs`.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
- If the app does not start, verify the venv is active and dependencies are installed:
|
- If the app does not start, verify the virtual environment is active and dependencies are installed:
|
||||||
|
|
||||||
|
*Using `uv`:*
|
||||||
```bash
|
```bash
|
||||||
source .venv/bin/activate
|
uv sync
|
||||||
|
```
|
||||||
|
|
||||||
|
*Using `pip`:*
|
||||||
|
```bash
|
||||||
|
source .venv/bin/activate # or .venv\Scripts\activate on Windows
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
- If no window appears, ensure you are running in a desktop session with GUI access.
|
- If no window appears, ensure you are running in a desktop session with GUI access.
|
||||||
|
|
||||||
|
- **Windows COM Port Connection Issues:**
|
||||||
|
* Standard Windows serial drivers don't support custom baud rates (e.g. `888888`), throwing an expected `OSError(22)` warning before falling back to `115200` baud.
|
||||||
|
|
||||||
|
### Step-by-Step Windows Simulator Connection Guide
|
||||||
|
|
||||||
|
#### Step 1: Create the Virtual Serial Port Bridge
|
||||||
|
Open **HHD Virtual Serial Port Tools**. Under the **Local Bridges** panel on the left, click the green **`+`** (Add) button to create a new port pair. Configure it to bridge **`COM5 ↔ COM6`**.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### Step 2: Configure and Start the Wafer Simulator
|
||||||
|
Launch the **Wafer Simulator Control Panel** (`wafer_sim_gui.py`):
|
||||||
|
1. Set the **Serial Port** to **`COM5`**.
|
||||||
|
2. **Uncheck** the `Auto-create Virtual Port (com0com)` checkbox.
|
||||||
|
3. Choose your desired **Wafer Type** (e.g., `aepwafer`) and **Family Code** (e.g., `A`).
|
||||||
|
4. Click **Start Simulator**. Once the client connects, you will see `Streaming (D2)` status and active data transfer logs.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### Step 3: Run the UI and Connect
|
||||||
|
Launch the `pygui` client application. On the left navigation rail, click **DETECT WAFER**. The application will scan all active COM ports, automatically detect the virtual wafer simulator on **`COM6`**, and update the status indicator to **Connected** (green).
|
||||||
|
|
||||||
|

|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# This will be the name of the output files
|
||||||
|
name: aepwafer
|
||||||
|
wafers: ["A", "E", "P"]
|
||||||
|
|
||||||
|
# round or square wafer
|
||||||
|
shape: round
|
||||||
|
|
||||||
|
# diameter/edge size of the wafer
|
||||||
|
size: 300
|
||||||
|
|
||||||
|
# large or small markers and labels
|
||||||
|
marker_size: large
|
||||||
|
font_size: large
|
||||||
|
|
||||||
|
# origin settings. x_origin can be left, right, or center. y_origin can
|
||||||
|
# be top, bottom, or center. Using right or top implies that the X/Y values
|
||||||
|
# for that axis are negative, or else we'll be drawing outside the image.
|
||||||
|
x_origin: left
|
||||||
|
y_origin: bottom
|
||||||
|
|
||||||
|
# sensor X and Y values, in mm relative to the origin
|
||||||
|
X: [150, 204, 249, 280, 290, 280, 249, 204, 150, 97, 51, 21, 10, 21, 51, 97,
|
||||||
|
150, 203, 241, 255, 241, 203, 150, 98, 59, 45, 59, 98, 171, 207, 228, 228,
|
||||||
|
207, 171, 130, 94, 73, 73, 94, 130, 150, 186, 200, 186, 150, 115, 100, 115]
|
||||||
|
Y: [290, 280, 249, 204, 150, 97, 51, 21, 10, 21, 51, 97, 150, 204, 249, 280,
|
||||||
|
255, 241, 203, 150, 98, 59, 45, 59, 98, 150, 203, 241, 228, 207, 171, 130,
|
||||||
|
94, 73, 73, 94, 130, 171, 207, 228, 200, 186, 150, 115, 100, 115, 150, 186]
|
||||||
|
|
||||||
|
# set this to either 0 or 1 for the initial sensor number (usually 1)
|
||||||
|
start_sn: 1
|
||||||
|
|
||||||
|
# Normally, positive X is to the right, and positive Y is up.
|
||||||
|
# These settings can be used to reverse one or both of these.
|
||||||
|
reverse_x: false
|
||||||
|
reverse_y: false
|
||||||
|
|
||||||
|
# these are sensors whose labels need to be repositioned. Sensor numbers
|
||||||
|
# start at start_sn. Directions are up, down, left, and right, and values
|
||||||
|
# are multiples of the marker size.
|
||||||
|
label_exceptions: {
|
||||||
|
4: { top: [0, 0] },
|
||||||
|
5: { top: [0, 0] },
|
||||||
|
6: { top: [0, 0] },
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# This will be the name of the output files
|
||||||
|
name: bcdwafer
|
||||||
|
wafers: ["B", "C", "D"]
|
||||||
|
|
||||||
|
# round or square wafer
|
||||||
|
shape: round
|
||||||
|
|
||||||
|
# diameter/edge size of the wafer
|
||||||
|
size: 300
|
||||||
|
|
||||||
|
# large or small markers and labels
|
||||||
|
marker_size: large
|
||||||
|
font_size: large
|
||||||
|
|
||||||
|
# origin settings. x_origin can be left, right, or center. y_origin can
|
||||||
|
# be top, bottom, or center. Using right or top implies that the X/Y values
|
||||||
|
# for that axis are negative, or else we'll be drawing outside the image.
|
||||||
|
x_origin: center
|
||||||
|
y_origin: center
|
||||||
|
|
||||||
|
# sensor X and Y values, in mm relative to the origin
|
||||||
|
X: [0, 72.50, 125.57, 145.00, 125.57, 72.50, 0, -72.50, -125.57, -145.00,
|
||||||
|
-125.57, -72.50, 0, 47.50, 82.27, 95.00, 82.27, 47.50, 0, -47.50, -82.27,
|
||||||
|
-95.00, -82.27, -47.50, 38.97, 22.50, -38.97, -22.50, 0]
|
||||||
|
Y: [145.00, 125.57, 72.50, 0, -72.50, -125.57, -145.00, -125.57, -72.50, 0,
|
||||||
|
72.50, 125.57, 95.00, 82.27, 47.50, 0, -47.50, -82.27, -95.00, -82.27,
|
||||||
|
-47.50, 0, 47.50, 82.27, 22.50, -38.97, -22.50, 38.97, 0]
|
||||||
|
|
||||||
|
# set this to either 0 or 1 for the initial sensor number (usually 1)
|
||||||
|
start_sn: 1
|
||||||
|
|
||||||
|
# Normally, positive X is to the right, and positive Y is up.
|
||||||
|
# These settings can be used to reverse one or both of these.
|
||||||
|
reverse_x: false
|
||||||
|
reverse_y: false
|
||||||
|
|
||||||
|
# these are sensors whose labels need to be repositioned. Sensor numbers
|
||||||
|
# start at start_sn. Directions are up, down, left, and right, and values
|
||||||
|
# are multiples of the marker size.
|
||||||
|
label_exceptions: {
|
||||||
|
2: { left: [1, 0] },
|
||||||
|
3: { left: [1, 0] },
|
||||||
|
4: { left: [1, 0] },
|
||||||
|
5: { left: [1, 0] },
|
||||||
|
6: { left: [1, 0] },
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# This will be the name of the output files
|
||||||
|
name: fwafer
|
||||||
|
wafers: ["F"]
|
||||||
|
|
||||||
|
# round or square wafer
|
||||||
|
shape: round
|
||||||
|
|
||||||
|
# diameter/edge size of the wafer
|
||||||
|
size: 200
|
||||||
|
|
||||||
|
# large or small markers and labels
|
||||||
|
marker_size: large
|
||||||
|
font_size: large
|
||||||
|
|
||||||
|
# origin settings. x_origin can be left, right, or center. y_origin can
|
||||||
|
# be top, bottom, or center. Using right or top implies that the X/Y values
|
||||||
|
# for that axis are negative, or else we'll be drawing outside the image.
|
||||||
|
x_origin: center
|
||||||
|
y_origin: center
|
||||||
|
|
||||||
|
# sensor X and Y values, in mm relative to the origin
|
||||||
|
X: [0.0, 47.5, 82.3, 95.0, 82.3, 47.5, 0.0, -47.5, -82.3, -95.0, -82.3, -47.5,
|
||||||
|
-55.0, -14.8, 39.7, 55.0, 14.8, -39.7, -14.7, 25.5, 14.7, -25.5]
|
||||||
|
Y: [95.0, 82.3, 47.5, 0.0, -47.5, -82.3, -95.0, -82.3, -47.5, 0.0, 47.5, 82.3,
|
||||||
|
13.3, 54.4, 40.0, -13.3, -54.4, -40.0, 25.5, 14.7, -25.5, -14.7]
|
||||||
|
|
||||||
|
# set this to either 0 or 1 for the initial sensor number (usually 1)
|
||||||
|
start_sn: 1
|
||||||
|
|
||||||
|
# Normally, positive X is to the right, and positive Y is up.
|
||||||
|
# These settings can be used to reverse one or both of these.
|
||||||
|
reverse_x: false
|
||||||
|
reverse_y: false
|
||||||
|
|
||||||
|
# these are sensors whose labels need to be repositioned. Sensor numbers
|
||||||
|
# start at start_sn. Directions are up, down, left, and right, and values
|
||||||
|
# are multiples of the marker size.
|
||||||
|
label_exceptions: {
|
||||||
|
2: { left: [1, 0] },
|
||||||
|
3: { left: [1, 0] },
|
||||||
|
4: { left: [1, 0] },
|
||||||
|
5: { left: [1, 0] },
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# This will be the name of the output files
|
||||||
|
name: xwafer
|
||||||
|
wafers: ["X"]
|
||||||
|
|
||||||
|
# round or square wafer
|
||||||
|
shape: square
|
||||||
|
|
||||||
|
# diameter/edge size of the wafer
|
||||||
|
size: 310
|
||||||
|
|
||||||
|
# large or small markers and labels
|
||||||
|
marker_size: small
|
||||||
|
font_size: small
|
||||||
|
|
||||||
|
# origin settings. x_origin can be left, right, or center. y_origin can
|
||||||
|
# be top, bottom, or center. Using right or top implies that the X/Y values
|
||||||
|
# for that axis are negative, or else we'll be drawing outside the image.
|
||||||
|
x_origin: left
|
||||||
|
y_origin: bottom
|
||||||
|
|
||||||
|
# sensor X and Y values, in mm relative to the origin
|
||||||
|
X: [ 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00,
|
||||||
|
13.00, 23.00, 46.43, 89.86, 133.29, 176.72, 220.15, 263.58, 287.01, 297.01,
|
||||||
|
307.01, 307.01, 307.01, 307.01, 307.01, 307.01, 307.01, 307.01, 307.01,
|
||||||
|
307.01, 307.01, 307.01, 297.01, 287.01, 263.58, 220.15, 176.72, 133.29,
|
||||||
|
89.86, 46.43, 23.00, 13.00, 46.43, 46.43, 46.43, 46.43, 46.43, 46.43,
|
||||||
|
89.86, 133.29, 176.72, 220.15, 263.58, 263.58, 263.58, 263.58, 263.58,
|
||||||
|
263.58, 220.15, 176.72, 133.29, 89.86, 89.86, 89.86, 89.86, 89.86,
|
||||||
|
133.29, 176.72, 220.15, 220.15, 220.15, 220.15, 176.72, 133.29, 133.29,
|
||||||
|
133.29, 176.72, 176.72]
|
||||||
|
Y: [ 3.00, 13.07, 23.07, 46.50, 89.93, 133.36, 176.79, 220.22, 263.65, 287.08,
|
||||||
|
297.08, 307.08, 307.08, 307.08, 307.08, 307.08, 307.08, 307.08, 307.08,
|
||||||
|
307.08, 307.08, 307.08, 307.08, 297.08, 287.08, 263.65, 220.22, 176.79,
|
||||||
|
133.36, 89.93, 46.50, 23.07, 13.07, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00,
|
||||||
|
3.00, 3.00, 3.00, 3.00, 3.00, 46.50, 89.93, 133.36, 176.79, 220.22, 263.65,
|
||||||
|
263.65, 263.65, 263.65, 263.65, 263.65, 220.22, 176.79, 133.36, 89.93,
|
||||||
|
46.50, 46.50, 46.50, 46.50, 46.50, 89.93, 133.36, 176.79, 220.22, 220.22,
|
||||||
|
220.22, 220.22, 176.79, 133.36, 89.93, 89.93, 89.93, 133.36, 176.79,
|
||||||
|
176.79, 133.36 ]
|
||||||
|
|
||||||
|
# set this to either 0 or 1 for the initial sensor number (usually 1)
|
||||||
|
start_sn: 1
|
||||||
|
|
||||||
|
# Normally, positive X is to the right, and positive Y is up.
|
||||||
|
# These settings can be used to reverse one or both of these.
|
||||||
|
reverse_x: false
|
||||||
|
reverse_y: false
|
||||||
|
|
||||||
|
# these are sensors whose labels need to be repositioned. Sensor numbers
|
||||||
|
# start at start_sn. Directions are up, down, left, and right, and values
|
||||||
|
# are multiples of the marker size.
|
||||||
|
label_exceptions: {
|
||||||
|
21: { left: [0, 0] },
|
||||||
|
22: { left: [0, 0] },
|
||||||
|
23: { left: [0, 0] },
|
||||||
|
24: { left: [0, 0] },
|
||||||
|
25: { left: [0, 0] },
|
||||||
|
26: { left: [0, 0] },
|
||||||
|
27: { left: [0, 0] },
|
||||||
|
28: { left: [0, 0] },
|
||||||
|
29: { left: [0, 0] },
|
||||||
|
30: { left: [0, 0] },
|
||||||
|
31: { left: [0, 0] },
|
||||||
|
32: { left: [0, 0] },
|
||||||
|
33: { left: [0, 0] },
|
||||||
|
34: { left: [0, 0] },
|
||||||
|
35: { left: [0, 0] },
|
||||||
|
36: { left: [0, 0] }
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# This will be the name of the output files
|
||||||
|
name: zwafer
|
||||||
|
wafers: ["Z"]
|
||||||
|
|
||||||
|
# round or square wafer
|
||||||
|
shape: round
|
||||||
|
|
||||||
|
# diameter/size of the wafer
|
||||||
|
size: 300
|
||||||
|
|
||||||
|
# large or small markers and labels
|
||||||
|
marker_size: small
|
||||||
|
font_size: small
|
||||||
|
|
||||||
|
# origin settings. x_origin can be left, right, or center. y_origin can
|
||||||
|
# be top, bottom, or center. Using right or top probably also needs
|
||||||
|
# reverse_x/reverse_y to be set as well.
|
||||||
|
x_origin: center
|
||||||
|
y_origin: center
|
||||||
|
|
||||||
|
# sensor X and Y values, in mm relative to the wafer center
|
||||||
|
X: [0.00, -1.17, 8.92, 1.17, -8.92, 22.52, 29.35, -22.52, -29.35, 45.05, 73.37,
|
||||||
|
58.71, 9.66, -45.05, -73.37, -58.71, -9.66, 79.85, 89.06, 46.10, -23.86,
|
||||||
|
-79.85, -89.06, -46.10, 23.86, 66.96, 109.06, 87.27, 14.36, -66.96,
|
||||||
|
-109.06, -87.27, -14.36, 121.24, 135.23, 70.00, -36.23, -121.24, -135.23,
|
||||||
|
-70.00, 36.23, 124.71, 139.09, 72.00, -37.27, -124.71, -139.09, -72.00,
|
||||||
|
37.27, 89.49, 127.31, 145.74, 141.99, 116.62, 73.50, 19.19, -38.05, -89.49,
|
||||||
|
-127.31, -145.74, -141.99, -116.62, -73.50, -19.19, 38.05]
|
||||||
|
Y: [0.00, -8.92, -1.17, 8.92, 1.17, -29.35, 22.52, 29.35, -22.52, -58.71,
|
||||||
|
-9.66, 45.05, 73.37, 58.71, 9.66, -45.05, -73.37, -46.10, 23.86, 79.85,
|
||||||
|
89.06, 46.10, -23.86, -79.85, -89.06, -87.27, -14.36, 66.96, 109.06, 87.27,
|
||||||
|
14.36, -66.96, -109.06, -70.00, 36.23, 121.24, 135.23, 70.00, -36.23,
|
||||||
|
-121.24, -135.23, -72.00, 37.27, 124.71, 139.09, 72.00, -37.27, -124.71,
|
||||||
|
-139.09, -116.62, -73.50, -19.19, 38.05, 89.49, 127.31, 145.74, 141.99,
|
||||||
|
116.62, 73.50, 19.19, -38.05, -89.49, -127.31, -145.74, -141.99]
|
||||||
|
|
||||||
|
# set this to either 0 or 1 for the initial sensor number (usually 1)
|
||||||
|
start_sn: 0
|
||||||
|
|
||||||
|
# If one or both axes are reversed, this can be used to fix it
|
||||||
|
# without having to edit all of the coordinates.
|
||||||
|
# These should be false for most normal wafers.
|
||||||
|
reverse_x: false
|
||||||
|
reverse_y: false
|
||||||
|
|
||||||
|
# these are sensors whose labels need to be repositioned. Sensor numbers
|
||||||
|
# start at start_sn. Directions are up, down, left, and right, and values
|
||||||
|
# are multiples of the marker size.
|
||||||
|
label_exceptions: {
|
||||||
|
33: { left: [0, 0] },
|
||||||
|
34: { left: [0, 0] },
|
||||||
|
41: { top: [0, 0] },
|
||||||
|
42: { bottom: [0, 0] },
|
||||||
|
45: { top: [1, 0] },
|
||||||
|
46: { bottom: [0.5, 0] },
|
||||||
|
49: { left: [0, 0] },
|
||||||
|
50: { bottom: [-1.75, -0.5] },
|
||||||
|
51: { top: [-1, 0] },
|
||||||
|
52: { top: [-1, 0] },
|
||||||
|
53: { bottom: [0, 0] },
|
||||||
|
54: { left: [0, -0.75] },
|
||||||
|
55: { right: [0, 0.5] },
|
||||||
|
58: { bottom: [-0.75, 0] },
|
||||||
|
60: { top: [0, 0] },
|
||||||
|
62: { right: [0, 1] },
|
||||||
|
63: { right: [0, 0] },
|
||||||
|
64: { left: [0, 0.25] }
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# This will be the name of the output files
|
||||||
|
name: zwafer_rev
|
||||||
|
wafers: [] # the reversed version doesn't represent any real wafer
|
||||||
|
|
||||||
|
# round or square wafer
|
||||||
|
shape: round
|
||||||
|
|
||||||
|
# diameter/size of the wafer
|
||||||
|
size: 300
|
||||||
|
|
||||||
|
# large or small markers and labels
|
||||||
|
marker_size: small
|
||||||
|
font_size: small
|
||||||
|
|
||||||
|
# origin settings. x_origin can be left, right, or center. y_origin can
|
||||||
|
# be top, bottom, or center. Using right or top probably also needs
|
||||||
|
# reverse_x/reverse_y to be set as well.
|
||||||
|
x_origin: center
|
||||||
|
y_origin: center
|
||||||
|
|
||||||
|
# sensor X and Y values, in mm relative to the wafer center
|
||||||
|
X: [0.00, -1.17, 8.92, 1.17, -8.92, 22.52, 29.35, -22.52, -29.35, 45.05, 73.37,
|
||||||
|
58.71, 9.66, -45.05, -73.37, -58.71, -9.66, 79.85, 89.06, 46.10, -23.86,
|
||||||
|
-79.85, -89.06, -46.10, 23.86, 66.96, 109.06, 87.27, 14.36, -66.96,
|
||||||
|
-109.06, -87.27, -14.36, 121.24, 135.23, 70.00, -36.23, -121.24, -135.23,
|
||||||
|
-70.00, 36.23, 124.71, 139.09, 72.00, -37.27, -124.71, -139.09, -72.00,
|
||||||
|
37.27, 89.49, 127.31, 145.74, 141.99, 116.62, 73.50, 19.19, -38.05, -89.49,
|
||||||
|
-127.31, -145.74, -141.99, -116.62, -73.50, -19.19, 38.05]
|
||||||
|
Y: [0.00, -8.92, -1.17, 8.92, 1.17, -29.35, 22.52, 29.35, -22.52, -58.71,
|
||||||
|
-9.66, 45.05, 73.37, 58.71, 9.66, -45.05, -73.37, -46.10, 23.86, 79.85,
|
||||||
|
89.06, 46.10, -23.86, -79.85, -89.06, -87.27, -14.36, 66.96, 109.06, 87.27,
|
||||||
|
14.36, -66.96, -109.06, -70.00, 36.23, 121.24, 135.23, 70.00, -36.23,
|
||||||
|
-121.24, -135.23, -72.00, 37.27, 124.71, 139.09, 72.00, -37.27, -124.71,
|
||||||
|
-139.09, -116.62, -73.50, -19.19, 38.05, 89.49, 127.31, 145.74, 141.99,
|
||||||
|
116.62, 73.50, 19.19, -38.05, -89.49, -127.31, -145.74, -141.99]
|
||||||
|
|
||||||
|
# set this to either 0 or 1 for the initial sensor number (usually 1)
|
||||||
|
start_sn: 0
|
||||||
|
|
||||||
|
# If one or both axes are reversed, this can be used to fix it
|
||||||
|
# without having to edit all of the coordinates.
|
||||||
|
# These should be false for most normal wafers.
|
||||||
|
reverse_x: true
|
||||||
|
reverse_y: true
|
||||||
|
|
||||||
|
# these are sensors whose labels need to be repositioned. Sensor numbers
|
||||||
|
# start at start_sn. Directions are up, down, left, and right, and values
|
||||||
|
# are multiples of the marker size.
|
||||||
|
label_exceptions: {
|
||||||
|
37: { left: [0, 0] },
|
||||||
|
38: { left: [0, 0] },
|
||||||
|
39: { left: [0, 0] },
|
||||||
|
41: { top: [1.5, 0] },
|
||||||
|
42: { bottom: [1, 0] },
|
||||||
|
43: { left: [0, -1] },
|
||||||
|
45: { bottom: [-1, -1] },
|
||||||
|
46: { bottom: [0, -1] },
|
||||||
|
47: { right: [0, 1] },
|
||||||
|
48: { left: [0, 0] },
|
||||||
|
50: { bottom: [-1, 0] },
|
||||||
|
52: { top: [0, 0] },
|
||||||
|
54: { right: [0.5, 1] },
|
||||||
|
56: { left: [0, 0.5] },
|
||||||
|
57: { left: [0, 0] },
|
||||||
|
58: { top: [0.5, 0] },
|
||||||
|
59: { left: [0, 0] },
|
||||||
|
60: { top: [-1, 0] },
|
||||||
|
61: { left: [0, 0] },
|
||||||
|
62: { left: [0, -1] },
|
||||||
|
63: { right: [0, 1] },
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
# ===== Backend Package Marker =====
|
|
||||||
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 62 KiB |
@@ -1,45 +0,0 @@
|
|||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from PySide6.QtQml import QQmlApplicationEngine
|
|
||||||
from PySide6.QtQuickControls2 import QQuickStyle
|
|
||||||
from PySide6.QtWidgets import QApplication
|
|
||||||
|
|
||||||
from backend.device_controller import DeviceController
|
|
||||||
from backend.local_settings import LocalSettings
|
|
||||||
from backend.local_settings_model import LocalSettingsModel
|
|
||||||
from backend.file_browser import FileBrowser
|
|
||||||
|
|
||||||
# ===== Application Entry Point =====
|
|
||||||
if __name__ == "__main__":
|
|
||||||
# ===== UI Style Setup =====
|
|
||||||
# Use a non-native controls style so our custom QML button backgrounds are supported.
|
|
||||||
QQuickStyle.setStyle("Basic")
|
|
||||||
|
|
||||||
# ===== Qt Bootstrap =====
|
|
||||||
# Minimal Qt bootstrap: load the ISC QML module and exit if it fails.
|
|
||||||
app = QApplication(sys.argv)
|
|
||||||
engine = QQmlApplicationEngine()
|
|
||||||
|
|
||||||
# ===== Shared Models =====
|
|
||||||
settings_model = LocalSettingsModel()
|
|
||||||
select_file_dialog_model = FileBrowser()
|
|
||||||
settings_model.loadSettings()
|
|
||||||
engine.rootContext().setContextProperty("settingsModel", settings_model)
|
|
||||||
engine.rootContext().setContextProperty("file_browser", select_file_dialog_model)
|
|
||||||
|
|
||||||
# ===== Device Controller (serial comm) =====
|
|
||||||
data_dir = str(settings_model._data_dir)
|
|
||||||
raw_settings = LocalSettings.read_settings(data_dir)
|
|
||||||
device_controller = DeviceController(raw_settings, data_dir)
|
|
||||||
engine.rootContext().setContextProperty("deviceController", device_controller)
|
|
||||||
|
|
||||||
# ===== QML Startup =====
|
|
||||||
engine.addImportPath(Path(__file__).parent)
|
|
||||||
engine.loadFromModule("ISC", "Main")
|
|
||||||
|
|
||||||
# ===== Exit Handling =====
|
|
||||||
if not engine.rootObjects():
|
|
||||||
sys.exit(-1)
|
|
||||||
|
|
||||||
sys.exit(app.exec())
|
|
||||||
@@ -1,16 +1,129 @@
|
|||||||
|
# ===== Build System =====
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=61"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
# ===== Project Metadata =====
|
# ===== Project Metadata =====
|
||||||
[project]
|
[project]
|
||||||
name = "pygui"
|
name = "pygui"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"PySide6>=6.6.0",
|
||||||
|
"pyqtgraph>=0.13.0",
|
||||||
|
"numpy>=1.24.0",
|
||||||
|
"pyserial>=3.5",
|
||||||
|
"pandas>=2.0.0",
|
||||||
|
"matplotlib>=3.7.0",
|
||||||
|
"scipy>=1.17.1",
|
||||||
|
"pyyaml>=6.0.3",
|
||||||
|
"dtw-python",
|
||||||
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = ["pytest"]
|
dev = ["pytest"]
|
||||||
|
|
||||||
|
# ===== Console Entry Point =====
|
||||||
|
[project.scripts]
|
||||||
|
isc = "pygui.__main__:main"
|
||||||
|
|
||||||
|
# ===== Package Discovery (src layout) =====
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
# Ship the QML module alongside the Python package.
|
||||||
|
pygui = ["ISC/**/*.qml", "ISC/**/qmldir"]
|
||||||
|
|
||||||
# ===== PySide Build Inputs =====
|
# ===== PySide Build Inputs =====
|
||||||
[tool.pyside6-project]
|
[tool.pyside6-project]
|
||||||
files = ["ISC/HomePage.qml", "ISC/Main.qml", "ISC/Tabs/DataTab.qml", "ISC/Tabs/GraphTab.qml", "ISC/Tabs/SelectFileDialog.qml", "ISC/Tabs/SettingsTab.qml", "ISC/Tabs/StatusTab.qml", "ISC/Tabs/qmldir", "ISC/Theme.qml", "ISC/qmldir", "backend/data_model.py", "backend/device_controller.py", "backend/graph_view.py", "backend/file_browser.py", "backend/local_settings.py", "backend/local_settings_model.py", "main.py", "serialcomm/__init__.py", "serialcomm/data_parser.py", "serialcomm/serial_port.py", "serialcomm/device_service.py"]
|
files = [
|
||||||
|
"src/pygui/ISC/HomePage.qml",
|
||||||
|
"src/pygui/ISC/Main.qml",
|
||||||
|
"src/pygui/ISC/Theme.qml",
|
||||||
|
"src/pygui/ISC/qmldir",
|
||||||
|
"src/pygui/ISC/Tabs/DataTab.qml",
|
||||||
|
"src/pygui/ISC/Tabs/SelectFileDialog.qml",
|
||||||
|
"src/pygui/ISC/Tabs/SettingsTab.qml",
|
||||||
|
"src/pygui/ISC/Tabs/StatusTab.qml",
|
||||||
|
"src/pygui/ISC/Tabs/qmldir",
|
||||||
|
"src/pygui/__main__.py",
|
||||||
|
"src/pygui/backend/contour_models.py",
|
||||||
|
"src/pygui/backend/crypto/crypto_helper.py",
|
||||||
|
"src/pygui/backend/csv_file_metadata.py",
|
||||||
|
"src/pygui/backend/data_model.py",
|
||||||
|
"src/pygui/backend/data_segment.py",
|
||||||
|
"src/pygui/backend/device_controller.py",
|
||||||
|
"src/pygui/backend/file_browser.py",
|
||||||
|
"src/pygui/backend/frame.py",
|
||||||
|
"src/pygui/backend/graph_view.py",
|
||||||
|
"src/pygui/backend/local_settings.py",
|
||||||
|
"src/pygui/backend/local_settings_model.py",
|
||||||
|
"src/pygui/backend/marching_squares.py",
|
||||||
|
"src/pygui/backend/zwafer_models.py",
|
||||||
|
"src/pygui/backend/zwafer_parser.py",
|
||||||
|
"src/pygui/backend/frame_stats.py",
|
||||||
|
"src/pygui/backend/threshold_classifier.py",
|
||||||
|
"src/pygui/backend/stability_detector.py",
|
||||||
|
"src/pygui/backend/csv_recorder.py",
|
||||||
|
"src/pygui/backend/frame_player.py",
|
||||||
|
"src/pygui/backend/stream_reader.py",
|
||||||
|
"src/pygui/backend/session_model.py",
|
||||||
|
"src/pygui/backend/session_controller.py",
|
||||||
|
"src/pygui/backend/wafer_layouts.py",
|
||||||
|
"src/pygui/backend/rbf_heatmap.py",
|
||||||
|
"src/pygui/backend/wafer_map_item.py",
|
||||||
|
"src/pygui/serialcomm/__init__.py",
|
||||||
|
"src/pygui/serialcomm/data_parser.py",
|
||||||
|
"src/pygui/serialcomm/device_service.py",
|
||||||
|
"src/pygui/serialcomm/serial_port.py",
|
||||||
|
"src/pygui/ISC/Tabs/WaferMapTab.qml",
|
||||||
|
"src/pygui/ISC/Tabs/components/WaferMapView.qml",
|
||||||
|
"src/pygui/ISC/Tabs/components/SourcePanel.qml",
|
||||||
|
"src/pygui/ISC/Tabs/components/ReadoutPanel.qml",
|
||||||
|
"src/pygui/ISC/Tabs/components/TransportBar.qml",
|
||||||
|
"src/pygui/ISC/Tabs/components/ReplaceSensorDialog.qml",
|
||||||
|
"src/pygui/ISC/Tabs/components/qmldir",
|
||||||
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=9.0.3",
|
"pytest>=9.0.3",
|
||||||
|
"ruff",
|
||||||
|
"mypy",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[tool.uv]
|
||||||
|
environments = [
|
||||||
|
"sys_platform == 'darwin'",
|
||||||
|
"sys_platform == 'win32'",
|
||||||
|
"sys_platform == 'linux'",
|
||||||
|
]
|
||||||
|
|
||||||
|
# ===== Ruff Linter and Formatter =====
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py311"
|
||||||
|
line-length = 125
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = [
|
||||||
|
"E", # pycodestyle errors
|
||||||
|
"W", # pycodestyle warnings
|
||||||
|
"F", # pyflakes
|
||||||
|
"I", # isort (import sorting)
|
||||||
|
"N", # pep8-naming
|
||||||
|
]
|
||||||
|
ignore = [
|
||||||
|
"N802", # Function name should be lowercase (standard for Qt Slots and Properties)
|
||||||
|
"N815", # Class attribute should not be mixedCase (standard for Qt Properties and Signals)
|
||||||
|
"E702", # Multiple statements on one line (semicolon, standard for inline Qt property setters with self.update())
|
||||||
|
]
|
||||||
|
|
||||||
|
# ===== Mypy Static Type Checker =====
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.11"
|
||||||
|
check_untyped_defs = true
|
||||||
|
warn_return_any = true
|
||||||
|
warn_unused_configs = true
|
||||||
|
ignore_missing_imports = true
|
||||||
|
|
||||||
|
|||||||
@@ -3,3 +3,7 @@ PySide6>=6.6.0
|
|||||||
pyqtgraph>=0.13.0
|
pyqtgraph>=0.13.0
|
||||||
numpy>=1.24.0
|
numpy>=1.24.0
|
||||||
pyserial>=3.5
|
pyserial>=3.5
|
||||||
|
pandas>=2.0.0
|
||||||
|
matplotlib>=3.7.0
|
||||||
|
scipy>=1.10.0
|
||||||
|
pyyaml>=6.0
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ISC
|
||||||
|
|
||||||
|
Popup {
|
||||||
|
id: root
|
||||||
|
modal: true
|
||||||
|
dim: true
|
||||||
|
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||||
|
width: 420
|
||||||
|
height: 340
|
||||||
|
anchors.centerIn: Overlay.overlay
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusMd
|
||||||
|
color: Theme.cardBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 24
|
||||||
|
spacing: 16
|
||||||
|
|
||||||
|
// ── Title ──
|
||||||
|
Label {
|
||||||
|
text: "About"
|
||||||
|
font.pixelSize: Theme.font2xl
|
||||||
|
font.bold: true
|
||||||
|
color: Theme.headingColor
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── App info ──
|
||||||
|
ColumnLayout {
|
||||||
|
spacing: 4
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "ISenseCloud (ISC)"
|
||||||
|
font.pixelSize: Theme.fontLg
|
||||||
|
color: Theme.headingColor
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "Version 0.1.0"
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
color: Theme.bodyColor
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "Temperature-sensing wafer monitoring"
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
color: Theme.bodyColor
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Separator ──
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
height: 1
|
||||||
|
color: Theme.cardBorder
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── License grid ──
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: 100
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: Theme.panelBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 10
|
||||||
|
spacing: 2
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "LICENSE"
|
||||||
|
color: Theme.panelTitleText
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
font.letterSpacing: 0.5
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grid header
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 4
|
||||||
|
Label { text: "Wafer SN"; font.pixelSize: Theme.fontXs; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 100 }
|
||||||
|
Label { text: "Mfg Date"; font.pixelSize: Theme.fontXs; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 80 }
|
||||||
|
Label { text: "Level"; font.pixelSize: Theme.fontXs; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 60 }
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
height: 1
|
||||||
|
color: Theme.softBorder
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "No license loaded"
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Close ──
|
||||||
|
Button {
|
||||||
|
text: "Close"
|
||||||
|
Layout.alignment: Qt.AlignRight
|
||||||
|
Layout.preferredWidth: 100
|
||||||
|
Layout.preferredHeight: 32
|
||||||
|
onClicked: root.close()
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: parent.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
|
||||||
|
border.color: Theme.fieldBorder
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
contentItem: Label {
|
||||||
|
text: parent.text
|
||||||
|
color: Theme.buttonNeutralText
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,621 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Controls.impl
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ISC
|
||||||
|
import ISC.Tabs.components
|
||||||
|
import QtQuick.Dialogs
|
||||||
|
|
||||||
|
// ===== Home Workspace Shell =====
|
||||||
|
// Unified left rail: pill tab bar → context panel → status footer → utility buttons.
|
||||||
|
// Workspace fills the remaining area with the active tab's content.
|
||||||
|
Rectangle {
|
||||||
|
id: root
|
||||||
|
anchors.fill: parent
|
||||||
|
color: Theme.pageBackground
|
||||||
|
clip: true
|
||||||
|
border.color: Theme.outerFrameBorder
|
||||||
|
border.width: Theme.borderStrong
|
||||||
|
|
||||||
|
// ===== View State =====
|
||||||
|
property int selectedTabIndex: 0
|
||||||
|
onSelectedTabIndexChanged: {
|
||||||
|
if (selectedTabIndex === 2) {
|
||||||
|
file_browser.refreshFiles();
|
||||||
|
streamController.unloadFile()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
property bool memoryRead: false
|
||||||
|
property bool waferDetected: false
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: deviceController
|
||||||
|
function onDetectResult(result){
|
||||||
|
root.waferDetected = (result != null && result != undefined)
|
||||||
|
}
|
||||||
|
function onParsedDataReady(result) {
|
||||||
|
if (result.success && result.csv_path) {
|
||||||
|
streamController.loadFile(result.csv_path)
|
||||||
|
if (root.selectedTabIndex === 0) {
|
||||||
|
root.selectedTabIndex = 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Connections {
|
||||||
|
target: deviceController
|
||||||
|
function onReadResult(result) {
|
||||||
|
root.memoryRead = (result !== null &&
|
||||||
|
result !== undefined &&
|
||||||
|
result.success === true)
|
||||||
|
if (root.memoryRead) {
|
||||||
|
console.log("[P1.1] Memory read complete:", result.bytes, "bytes")
|
||||||
|
} else {
|
||||||
|
console.log("[P1.1] Read failed:", result.error || "unknown")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanFolderUrl(url) {
|
||||||
|
return decodeURIComponent(String(url).replace(/^file:\/\//, ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
function _doDetect() {
|
||||||
|
root.memoryRead = false
|
||||||
|
streamController.setMode("review")
|
||||||
|
streamController.stopStream()
|
||||||
|
deviceController.detectWafer()
|
||||||
|
}
|
||||||
|
|
||||||
|
FolderDialog {
|
||||||
|
id: saveDirDialog
|
||||||
|
title: "Choose a folder to save Data"
|
||||||
|
onAccepted: {
|
||||||
|
deviceController.setSaveDataDir(root.cleanFolderUrl(selectedFolder))
|
||||||
|
root._doDetect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Split Dialog (Threshold Segmentation) =====
|
||||||
|
SplitDialog {
|
||||||
|
id: splitDialog
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Settings Popup =====
|
||||||
|
Popup {
|
||||||
|
id: settingsPopup
|
||||||
|
modal: true
|
||||||
|
dim: true
|
||||||
|
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||||
|
width: Math.min(root.width - 80, 800)
|
||||||
|
height: Math.min(root.height - 80, 600)
|
||||||
|
anchors.centerIn: Overlay.overlay
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusMd
|
||||||
|
color: Theme.cardBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
contentItem: Rectangle {
|
||||||
|
color: Theme.pageBackground
|
||||||
|
radius: Theme.radiusMd
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: 40
|
||||||
|
color: Theme.panelBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusMd
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.leftMargin: 14
|
||||||
|
anchors.rightMargin: 4
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "Settings"
|
||||||
|
font.pixelSize: Theme.fontLg
|
||||||
|
font.bold: true
|
||||||
|
color: Theme.headingColor
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: "\u2715"
|
||||||
|
flat: true
|
||||||
|
Layout.preferredWidth: 32
|
||||||
|
Layout.preferredHeight: 32
|
||||||
|
onClicked: settingsPopup.close()
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusXs
|
||||||
|
color: parent.hovered ? Theme.buttonNeutralHover : "transparent"
|
||||||
|
}
|
||||||
|
contentItem: Label {
|
||||||
|
text: parent.text
|
||||||
|
color: Theme.bodyColor
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
Layout.rightMargin: 6
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
Loader {
|
||||||
|
anchors.fill: parent
|
||||||
|
source: "Tabs/SettingsTab.qml"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== About Dialog =====
|
||||||
|
AboutDialog {
|
||||||
|
id: aboutDialog
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Main Layout: Rail + Workspace =====
|
||||||
|
RowLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
// ── LEFT RAIL ──────────────────────────────────────────────────────
|
||||||
|
Rectangle {
|
||||||
|
id: rail
|
||||||
|
Layout.preferredWidth: Theme.sideRailWidth
|
||||||
|
Layout.fillHeight: true
|
||||||
|
color: Theme.sideRailBackground
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Theme.sideRailMargin
|
||||||
|
spacing: Theme.sideRailSpacing
|
||||||
|
|
||||||
|
// ── BOX 1: Pill Tab Bar ─────────────────────────────────────
|
||||||
|
Rectangle {
|
||||||
|
id: pillBar
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: Theme.sidePillHeight
|
||||||
|
radius: 999
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
border.width: 1
|
||||||
|
color: "transparent"
|
||||||
|
|
||||||
|
ListModel {
|
||||||
|
id: pillModel
|
||||||
|
ListElement { label: "STATUS"; icon: "Tabs/icons/status.svg"; expandW: 86 }
|
||||||
|
ListElement { label: "DATA"; icon: "Tabs/icons/data.svg"; expandW: 66 }
|
||||||
|
ListElement { label: "MAP"; icon: "Tabs/icons/map.svg"; expandW: 58 }
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 3
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: pillModel
|
||||||
|
delegate: Button {
|
||||||
|
id: pillBtn
|
||||||
|
checked: root.selectedTabIndex === index
|
||||||
|
flat: true
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
Layout.preferredWidth: pillBtn.checked ? model.expandW : 44
|
||||||
|
Behavior on Layout.preferredWidth {
|
||||||
|
NumberAnimation { duration: 200; easing.type: Easing.OutCubic }
|
||||||
|
}
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: 50
|
||||||
|
color: pillBtn.checked ? Theme.sidePanelBackground : "transparent"
|
||||||
|
Behavior on color {
|
||||||
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contentItem: Row {
|
||||||
|
id: pillContent
|
||||||
|
spacing: pillBtn.checked ? 6 : 0
|
||||||
|
width: pillIcon.width + (pillBtn.checked ? pillContent.spacing + pillLabel.width : 0)
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
x: (parent.width - width) / 2
|
||||||
|
Behavior on x {
|
||||||
|
NumberAnimation { duration: 200; easing.type: Easing.OutCubic }
|
||||||
|
}
|
||||||
|
|
||||||
|
IconImage {
|
||||||
|
id: pillIcon
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: 16; height: 16
|
||||||
|
source: model.icon
|
||||||
|
sourceSize.width: 16; sourceSize.height: 16
|
||||||
|
color: pillBtn.checked ? Theme.headingColor
|
||||||
|
: pillBtn.hovered ? Qt.lighter(Theme.sideMutedText, 1.7)
|
||||||
|
: Theme.sideMutedText
|
||||||
|
opacity: pillBtn.checked ? 0.9 : pillBtn.hovered ? 0.7 : 0.4
|
||||||
|
Behavior on color {
|
||||||
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
|
Behavior on opacity {
|
||||||
|
NumberAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
id: pillLabel
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: pillBtn.checked ? implicitWidth : 0
|
||||||
|
text: model.label
|
||||||
|
color: pillBtn.checked ? Theme.headingColor : Theme.sideMutedText
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 1.0
|
||||||
|
opacity: pillBtn.checked ? 1.0 : 0.0
|
||||||
|
clip: true
|
||||||
|
Behavior on width {
|
||||||
|
NumberAnimation { duration: 200; easing.type: Easing.OutCubic }
|
||||||
|
}
|
||||||
|
Behavior on color {
|
||||||
|
ColorAnimation { duration: Theme.durationBase; easing.type: Theme.easeStandard }
|
||||||
|
}
|
||||||
|
Behavior on opacity {
|
||||||
|
NumberAnimation { duration: 200; easing.type: Easing.OutCubic }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onClicked: root.selectedTabIndex = index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── BOX 2: Context-Sensitive Rail Panel ────────────────────
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
Layout.minimumHeight: 120
|
||||||
|
radius: Theme.sidePanelRadius
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
border.width: 1
|
||||||
|
color: Theme.sidePanelBackground
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 14
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
StackLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
currentIndex: root.selectedTabIndex === 0 ? 0 : 1
|
||||||
|
|
||||||
|
// ── Status tab: Hardware Actions ────────────────
|
||||||
|
ColumnLayout {
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "HARDWARE ACTIONS"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
topPadding: 6
|
||||||
|
font.letterSpacing: 1.5
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
|
||||||
|
RailActionButton {
|
||||||
|
label: "DETECT WAFER"
|
||||||
|
iconSource: "../icons/detect.svg"
|
||||||
|
Layout.fillWidth: true
|
||||||
|
enabled: !deviceController.operationInProgress
|
||||||
|
onClicked: root._doDetect()
|
||||||
|
}
|
||||||
|
|
||||||
|
RailActionButton {
|
||||||
|
label: "READ MEMORY"
|
||||||
|
iconSource: "../icons/read.svg"
|
||||||
|
Layout.fillWidth: true
|
||||||
|
enabled: root.waferDetected
|
||||||
|
onClicked: deviceController.readMemoryAsync()
|
||||||
|
}
|
||||||
|
|
||||||
|
RailActionButton {
|
||||||
|
label: "ERASE MEMORY"
|
||||||
|
iconSource: "../icons/erase.svg"
|
||||||
|
Layout.fillWidth: true
|
||||||
|
destructive: true
|
||||||
|
enabled: root.waferDetected
|
||||||
|
onClicked: deviceController.eraseMemory(
|
||||||
|
deviceController.selectedPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { Layout.fillHeight: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Map tab: Source file browser ──────────────
|
||||||
|
SourcePanel {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── BOX 3: Hardware Status Footer ──────────────────────────
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: Theme.sideFooterConnection
|
||||||
|
radius: Theme.sidePanelRadius
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
border.width: 1
|
||||||
|
color: Theme.sidePanelBackground
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 14
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
// Row 1: Title + status badge (unchanged)
|
||||||
|
RowLayout {
|
||||||
|
spacing: 8
|
||||||
|
Layout.fillWidth: true
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "CONNECTION FLOW"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
font.letterSpacing: 1.2
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
height: 18
|
||||||
|
width: badgeLabel.implicitWidth + 14
|
||||||
|
radius: 9
|
||||||
|
color: {
|
||||||
|
var s = deviceController.connectionStatus
|
||||||
|
if (s === "Connected") return Theme.statusSuccessColor
|
||||||
|
if (s === "Disconnected") return Theme.statusErrorColor
|
||||||
|
return Theme.statusWarningColor
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
id: badgeLabel
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: deviceController.connectionStatus || "Disconnected"
|
||||||
|
color: Theme.statusBadgeText
|
||||||
|
font.pixelSize: Theme.font2xs
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { Layout.fillWidth: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row 2: Status dot + descriptive text (replaces dotted trail)
|
||||||
|
Row {
|
||||||
|
spacing: 8
|
||||||
|
Layout.fillWidth: true
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: statusDot
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: 8
|
||||||
|
height: 8
|
||||||
|
radius: 4
|
||||||
|
color: deviceController.connectionStatus === "Connected"
|
||||||
|
? Theme.statusSuccessColor : Theme.statusErrorColor
|
||||||
|
|
||||||
|
SequentialAnimation on opacity {
|
||||||
|
running: deviceController.connectionStatus === "Connected"
|
||||||
|
loops: Animation.Infinite
|
||||||
|
NumberAnimation { to: 0.4; duration: 1200 }
|
||||||
|
NumberAnimation { to: 1.0; duration: 1200 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: {
|
||||||
|
if (deviceController.connectionStatus === "Connected")
|
||||||
|
return deviceController.selectedPort || "Connected"
|
||||||
|
if (deviceController.selectedPort)
|
||||||
|
return deviceController.selectedPort
|
||||||
|
return "No port selected"
|
||||||
|
}
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row 3: Mode chip — neutral gray tag
|
||||||
|
Row {
|
||||||
|
spacing: 6
|
||||||
|
Layout.fillWidth: true
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: modeChipLabel.implicitWidth + 12
|
||||||
|
height: 18
|
||||||
|
radius: 4
|
||||||
|
color: Theme.fieldBackground
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
Label {
|
||||||
|
id: modeChipLabel
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: streamController.mode
|
||||||
|
? streamController.mode.toUpperCase() : "REVIEW"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.font2xs
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 0.8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── BOX 4: Utility Buttons ─────────────────────────────────
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: Theme.sideFooterUtility
|
||||||
|
radius: Theme.sidePanelRadius
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
border.width: 1
|
||||||
|
color: Theme.sidePanelBackground
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 4
|
||||||
|
spacing: 4
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: settingsBtn
|
||||||
|
flat: true
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
icon.source: "Tabs/icons/settings.svg"
|
||||||
|
onClicked: settingsPopup.open()
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: 8
|
||||||
|
color: settingsBtn.hovered
|
||||||
|
? Theme.sideActiveBackground : "transparent"
|
||||||
|
}
|
||||||
|
|
||||||
|
contentItem: Row {
|
||||||
|
spacing: 6
|
||||||
|
anchors.centerIn: parent
|
||||||
|
|
||||||
|
IconImage {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: 16
|
||||||
|
height: 16
|
||||||
|
source: "Tabs/icons/settings.svg"
|
||||||
|
sourceSize.width: 16
|
||||||
|
sourceSize.height: 16
|
||||||
|
color: Theme.headingColor
|
||||||
|
opacity: 0.75
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "SETTINGS"
|
||||||
|
color: Theme.headingColor
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 1.2
|
||||||
|
opacity: 0.8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: 1
|
||||||
|
height: 24
|
||||||
|
color: Theme.sideBorder
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: aboutBtn
|
||||||
|
flat: true
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
icon.source: "Tabs/icons/about.svg"
|
||||||
|
onClicked: aboutDialog.open()
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: 8
|
||||||
|
color: aboutBtn.hovered
|
||||||
|
? Theme.sideActiveBackground : "transparent"
|
||||||
|
}
|
||||||
|
|
||||||
|
contentItem: Row {
|
||||||
|
spacing: 6
|
||||||
|
anchors.centerIn: parent
|
||||||
|
|
||||||
|
IconImage {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: 16
|
||||||
|
height: 16
|
||||||
|
source: "Tabs/icons/about.svg"
|
||||||
|
sourceSize.width: 16
|
||||||
|
sourceSize.height: 16
|
||||||
|
color: Theme.headingColor
|
||||||
|
opacity: 0.75
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "ABOUT"
|
||||||
|
color: Theme.headingColor
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 1.2
|
||||||
|
opacity: 0.8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── WORKSPACE ─────────────────────────────────────────────────────
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
color: Theme.workspaceBackground
|
||||||
|
border.color: Theme.workspaceBorder
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
StackLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Theme.mainAreaPadding
|
||||||
|
currentIndex: root.selectedTabIndex
|
||||||
|
|
||||||
|
Loader {
|
||||||
|
source: "Tabs/StatusTab.qml"
|
||||||
|
active: StackLayout.index === root.selectedTabIndex
|
||||||
|
}
|
||||||
|
Loader {
|
||||||
|
id: dataTabLoader
|
||||||
|
source: "Tabs/DataTab.qml"
|
||||||
|
active: StackLayout.index === root.selectedTabIndex
|
||||||
|
}
|
||||||
|
Loader {
|
||||||
|
source: "Tabs/WaferMapTab.qml"
|
||||||
|
active: StackLayout.index === root.selectedTabIndex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,8 @@ import ISC
|
|||||||
// ===== App Window Shell =====
|
// ===== App Window Shell =====
|
||||||
Window {
|
Window {
|
||||||
// ===== Window Dimensions =====
|
// ===== Window Dimensions =====
|
||||||
width: 1400
|
width: 1920
|
||||||
height: 820
|
height: 1080
|
||||||
minimumWidth: 1100
|
minimumWidth: 1100
|
||||||
minimumHeight: 700
|
minimumHeight: 700
|
||||||
visible: true
|
visible: true
|
||||||
@@ -0,0 +1,910 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Controls.impl
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ISC
|
||||||
|
import ISC.Wafer
|
||||||
|
|
||||||
|
// ===== Data Tab (Compare Content Only) =====
|
||||||
|
// Per docs/design/navigation-mock.html: the Data tab shows only the Compare
|
||||||
|
// Runs content — the raw CSV table ("Inspect Table" mode in earlier drafts)
|
||||||
|
// is intentionally not displayed in the application.
|
||||||
|
// Layout: left column = aligned-curves chart with alignment scrubber + wafer
|
||||||
|
// overlap map; right side panel = run selection dropdowns, overlap settings,
|
||||||
|
// and DTW readout cards.
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
anchors.fill: parent
|
||||||
|
|
||||||
|
// Properties for DTW Comparison
|
||||||
|
property int activeBox: 1 // 1 for Reference (Run A), 2 for Session (Run B), 0 for inactive
|
||||||
|
property string compareFileA: ""
|
||||||
|
property string compareFileB: ""
|
||||||
|
property real warpingDistance: -1
|
||||||
|
property int frameOffset: 0
|
||||||
|
property real maxSensorDeviation: -1
|
||||||
|
property bool comparing: false
|
||||||
|
property var trendDataA: []
|
||||||
|
property var trendDataB: []
|
||||||
|
property string errorMessage: ""
|
||||||
|
|
||||||
|
// Wafer overlap properties
|
||||||
|
property var compareSensorLayout: []
|
||||||
|
property var compareSensorDiff: []
|
||||||
|
property string compareWaferShape: "round"
|
||||||
|
property real compareWaferSize: 300.0
|
||||||
|
|
||||||
|
readonly property bool hasSeries: trendDataA.length > 1 && trendDataB.length > 1
|
||||||
|
readonly property int seriesLen: Math.max(trendDataA.length, trendDataB.length)
|
||||||
|
|
||||||
|
function diffBands(diffs) {
|
||||||
|
return diffs.map(d => d > 1.0 ? "high" : d < -1.0 ? "low" : "in_range")
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortName(path) {
|
||||||
|
return path ? path.split("/").pop() : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearResults() {
|
||||||
|
root.warpingDistance = -1
|
||||||
|
root.frameOffset = 0
|
||||||
|
root.maxSensorDeviation = -1
|
||||||
|
root.trendDataA = []
|
||||||
|
root.trendDataB = []
|
||||||
|
root.compareSensorLayout = []
|
||||||
|
root.compareSensorDiff = []
|
||||||
|
root.errorMessage = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetComparison() {
|
||||||
|
root.compareFileA = ""
|
||||||
|
root.compareFileB = ""
|
||||||
|
root.comparing = false
|
||||||
|
root.activeBox = 1
|
||||||
|
clearResults()
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: file_browser.refreshFiles()
|
||||||
|
|
||||||
|
// Clicking a file in the left rail's browser populates the armed run slot
|
||||||
|
Connections {
|
||||||
|
target: streamController
|
||||||
|
function onLoadedFileChanged() {
|
||||||
|
if (streamController.loadedFile && root.activeBox > 0) {
|
||||||
|
if (root.activeBox === 1) {
|
||||||
|
root.compareFileA = streamController.loadedFile
|
||||||
|
root.activeBox = 2 // Auto-advance to box 2
|
||||||
|
} else if (root.activeBox === 2) {
|
||||||
|
root.compareFileB = streamController.loadedFile
|
||||||
|
root.activeBox = 0 // Finished selecting
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onComparisonResult(result) {
|
||||||
|
root.comparing = false
|
||||||
|
if (result && result.success) {
|
||||||
|
root.warpingDistance = result.distance
|
||||||
|
root.frameOffset = result.frame_offset !== undefined ? result.frame_offset : 0
|
||||||
|
root.maxSensorDeviation = result.max_sensor_deviation !== undefined ? result.max_sensor_deviation : -1
|
||||||
|
root.trendDataA = result.series_a || []
|
||||||
|
root.trendDataB = result.series_b || []
|
||||||
|
root.compareSensorLayout = result.sensor_layout || []
|
||||||
|
root.compareSensorDiff = result.sensor_diff || []
|
||||||
|
root.compareWaferShape = result.wafer_shape || "round"
|
||||||
|
root.compareWaferSize = result.wafer_size || 300.0
|
||||||
|
root.errorMessage = ""
|
||||||
|
scrubber.value = Math.floor(root.seriesLen / 2)
|
||||||
|
} else {
|
||||||
|
root.clearResults()
|
||||||
|
root.errorMessage = (result && result.error) ? result.error : "Comparison failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reusable pieces ────────────────────────────────────────────
|
||||||
|
component SectionTitle: Label {
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 1.2
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
}
|
||||||
|
|
||||||
|
component PanelBox: Rectangle {
|
||||||
|
color: Theme.cardBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusMd
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mini stat box under the alignment scrubber (mockup's subtle-box trio)
|
||||||
|
component ScrubStat: Rectangle {
|
||||||
|
id: scrubStat
|
||||||
|
property string label
|
||||||
|
property string value
|
||||||
|
property color valueColor: Theme.headingColor
|
||||||
|
Layout.fillWidth: true
|
||||||
|
implicitHeight: 38
|
||||||
|
radius: 4
|
||||||
|
color: Theme.cardWash
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.leftMargin: 6
|
||||||
|
anchors.rightMargin: 6
|
||||||
|
anchors.topMargin: 4
|
||||||
|
anchors.bottomMargin: 4
|
||||||
|
spacing: 0
|
||||||
|
Label {
|
||||||
|
text: scrubStat.label
|
||||||
|
font.pixelSize: 9
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
text: scrubStat.value
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.bold: true
|
||||||
|
font.family: Theme.codeFontFamily
|
||||||
|
color: scrubStat.valueColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Large labeled value card for the DTW readout panel
|
||||||
|
component ReadoutCard: Rectangle {
|
||||||
|
id: readoutCard
|
||||||
|
property string label
|
||||||
|
property string value
|
||||||
|
property color valueColor: Theme.headingColor
|
||||||
|
Layout.fillWidth: true
|
||||||
|
implicitHeight: 52
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: Theme.subtleSectionBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 8
|
||||||
|
spacing: 2
|
||||||
|
Label {
|
||||||
|
text: readoutCard.label
|
||||||
|
font.pixelSize: 9
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 0.2
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
text: readoutCard.value
|
||||||
|
font.pixelSize: Theme.fontLg
|
||||||
|
font.bold: true
|
||||||
|
font.family: Theme.codeFontFamily
|
||||||
|
color: readoutCard.valueColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run slot card: click to arm, then pick a file in the left rail's browser
|
||||||
|
component RunSlot: Rectangle {
|
||||||
|
id: slotBox
|
||||||
|
property int boxIndex: 1
|
||||||
|
property string title
|
||||||
|
property string filePath: ""
|
||||||
|
property color accent: Theme.primaryAccent
|
||||||
|
signal cleared()
|
||||||
|
|
||||||
|
Layout.fillWidth: true
|
||||||
|
implicitHeight: 58
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: Theme.fieldBackground
|
||||||
|
border.width: root.activeBox === boxIndex ? 2 : 1
|
||||||
|
border.color: root.activeBox === boxIndex ? accent : Theme.cardBorder
|
||||||
|
|
||||||
|
Behavior on border.color { ColorAnimation { duration: Theme.durationFast } }
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: root.activeBox = slotBox.boxIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 10
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: 8; height: 8; radius: 4
|
||||||
|
color: slotBox.accent
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 2
|
||||||
|
Label {
|
||||||
|
text: slotBox.title
|
||||||
|
font.pixelSize: 9
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 1.0
|
||||||
|
color: root.activeBox === slotBox.boxIndex ? slotBox.accent : Theme.sideMutedText
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
text: slotBox.filePath !== ""
|
||||||
|
? root.shortName(slotBox.filePath)
|
||||||
|
: (root.activeBox === slotBox.boxIndex ? "<- Select file from left panel..." : "Click to select")
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
color: slotBox.filePath !== "" ? Theme.headingColor : Theme.bodyColor
|
||||||
|
opacity: slotBox.filePath !== "" ? 1.0 : 0.6
|
||||||
|
elide: Text.ElideMiddle
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
visible: slotBox.filePath !== ""
|
||||||
|
flat: true
|
||||||
|
implicitWidth: 24; implicitHeight: 24
|
||||||
|
onClicked: slotBox.cleared()
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: parent.hovered ? Theme.flatButtonHover : "transparent"
|
||||||
|
}
|
||||||
|
contentItem: IconImage {
|
||||||
|
source: "icons/x.svg"
|
||||||
|
width: 14; height: 14
|
||||||
|
sourceSize.width: 14
|
||||||
|
sourceSize.height: 14
|
||||||
|
color: Theme.bodyColor
|
||||||
|
anchors.centerIn: parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main Layout ───────────────────────────────────────────────
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Theme.panelPadding
|
||||||
|
spacing: Theme.rightPaneGap
|
||||||
|
|
||||||
|
// ── Title / Header ─────────────────────────────────────────
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "Run Comparison (DTW)"
|
||||||
|
font.pixelSize: Theme.fontXl
|
||||||
|
font.bold: true
|
||||||
|
color: Theme.headingColor
|
||||||
|
Layout.fillWidth: true
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: resetBtn
|
||||||
|
flat: true
|
||||||
|
implicitWidth: 32
|
||||||
|
implicitHeight: 32
|
||||||
|
visible: root.compareFileA !== "" || root.compareFileB !== ""
|
||||||
|
onClicked: root.resetComparison()
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: resetBtn.hovered ? Theme.flatButtonHover : "transparent"
|
||||||
|
}
|
||||||
|
contentItem: IconImage {
|
||||||
|
source: "icons/rotate-ccw.svg"
|
||||||
|
width: 18; height: 18
|
||||||
|
sourceSize.width: 18
|
||||||
|
sourceSize.height: 18
|
||||||
|
color: Theme.bodyColor
|
||||||
|
anchors.centerIn: parent
|
||||||
|
}
|
||||||
|
ToolTip.visible: resetBtn.hovered
|
||||||
|
ToolTip.text: "Reset comparison"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Error Banner ───────────────────────────────────────────
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: errorRow.implicitHeight + 16
|
||||||
|
visible: root.errorMessage !== ""
|
||||||
|
color: Theme.errorSurface
|
||||||
|
border.color: Theme.statusErrorColor
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
id: errorRow
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 8
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
IconImage {
|
||||||
|
source: "icons/triangle-alert.svg"
|
||||||
|
width: 16; height: 16
|
||||||
|
sourceSize.width: 16
|
||||||
|
sourceSize.height: 16
|
||||||
|
color: Theme.errorTextSoft
|
||||||
|
Layout.alignment: Qt.AlignTop
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
id: errorLabel
|
||||||
|
text: root.errorMessage
|
||||||
|
color: Theme.errorTextSoft
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Body: main column | side panel ─────────────────────────
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
spacing: 16
|
||||||
|
|
||||||
|
// --- Left Column: chart + scrubber, wafer overlap map ---
|
||||||
|
ColumnLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
Layout.minimumWidth: 350
|
||||||
|
spacing: 12
|
||||||
|
|
||||||
|
// Chart card with alignment scrubber
|
||||||
|
PanelBox {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: chartCol.implicitHeight + 24
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: chartCol
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 12
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
SectionTitle {
|
||||||
|
text: "ALIGNED TEMP CURVE OVERLAY (DTW)"
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
Row {
|
||||||
|
spacing: 16
|
||||||
|
visible: root.hasSeries
|
||||||
|
Row {
|
||||||
|
spacing: 4
|
||||||
|
Rectangle { width: 12; height: 3; radius: 1.5; color: Theme.primaryAccent; anchors.verticalCenter: parent.verticalCenter }
|
||||||
|
Label { text: "Run A"; font.pixelSize: Theme.fontXs; color: Theme.bodyColor }
|
||||||
|
}
|
||||||
|
Row {
|
||||||
|
spacing: 4
|
||||||
|
Rectangle { width: 12; height: 3; radius: 1.5; color: Theme.themeSkill; anchors.verticalCenter: parent.verticalCenter }
|
||||||
|
Label { text: "Run B"; font.pixelSize: Theme.fontXs; color: Theme.bodyColor }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: 200
|
||||||
|
color: Theme.fieldBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
Canvas {
|
||||||
|
id: overlayCanvas
|
||||||
|
anchors.fill: parent
|
||||||
|
visible: root.hasSeries
|
||||||
|
|
||||||
|
readonly property real padLeft: 50
|
||||||
|
readonly property real padRight: 16
|
||||||
|
readonly property real padTop: 16
|
||||||
|
readonly property real padBottom: 28
|
||||||
|
|
||||||
|
function drawGrid(ctx, minV, maxV, dataLen) {
|
||||||
|
var plotW = width - padLeft - padRight
|
||||||
|
var plotH = height - padTop - padBottom
|
||||||
|
var range = (maxV - minV) || 1
|
||||||
|
|
||||||
|
ctx.strokeStyle = Theme.chartGridLine
|
||||||
|
ctx.lineWidth = 1
|
||||||
|
ctx.fillStyle = Theme.chartAxisText
|
||||||
|
ctx.font = "10px sans-serif"
|
||||||
|
ctx.textAlign = "right"
|
||||||
|
|
||||||
|
var ySteps = 5
|
||||||
|
for (var yi = 0; yi <= ySteps; yi++) {
|
||||||
|
var yFrac = yi / ySteps
|
||||||
|
var yPx = padTop + plotH * yFrac
|
||||||
|
var yVal = maxV - yFrac * range
|
||||||
|
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(padLeft, yPx)
|
||||||
|
ctx.lineTo(padLeft + plotW, yPx)
|
||||||
|
ctx.stroke()
|
||||||
|
|
||||||
|
ctx.fillText(yVal.toFixed(1), padLeft - 6, yPx + 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.textAlign = "center"
|
||||||
|
var xSteps = Math.min(5, dataLen - 1)
|
||||||
|
for (var xi = 0; xi <= xSteps; xi++) {
|
||||||
|
var xFrac = xi / xSteps
|
||||||
|
var xPx = padLeft + plotW * xFrac
|
||||||
|
var xIdx = Math.round(xFrac * (dataLen - 1))
|
||||||
|
ctx.fillText(xIdx.toString(), xPx, height - 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.save()
|
||||||
|
ctx.translate(12, padTop + plotH / 2)
|
||||||
|
ctx.rotate(-Math.PI / 2)
|
||||||
|
ctx.textAlign = "center"
|
||||||
|
ctx.fillText("°C", 0, 0)
|
||||||
|
ctx.restore()
|
||||||
|
|
||||||
|
ctx.textAlign = "center"
|
||||||
|
ctx.fillText("Frame", padLeft + plotW / 2, height - 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawSeries(ctx, series, minV, range, color) {
|
||||||
|
var plotW = width - padLeft - padRight
|
||||||
|
var plotH = height - padTop - padBottom
|
||||||
|
|
||||||
|
ctx.strokeStyle = color
|
||||||
|
ctx.lineWidth = 2
|
||||||
|
ctx.beginPath()
|
||||||
|
for (var i = 0; i < series.length; i++) {
|
||||||
|
var x = padLeft + (i / (series.length - 1)) * plotW
|
||||||
|
var y = padTop + plotH - ((series[i] - minV) / range) * plotH
|
||||||
|
if (i === 0) ctx.moveTo(x, y)
|
||||||
|
else ctx.lineTo(x, y)
|
||||||
|
}
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawScrubMarker(ctx) {
|
||||||
|
if (root.seriesLen < 2) return
|
||||||
|
var plotW = width - padLeft - padRight
|
||||||
|
var x = padLeft + (scrubber.value / (root.seriesLen - 1)) * plotW
|
||||||
|
ctx.strokeStyle = Theme.primaryAccent
|
||||||
|
ctx.lineWidth = 1.5
|
||||||
|
ctx.setLineDash([3, 3])
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(x, padTop)
|
||||||
|
ctx.lineTo(x, height - padBottom)
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.setLineDash([])
|
||||||
|
}
|
||||||
|
|
||||||
|
onPaint: {
|
||||||
|
var ctx = getContext("2d")
|
||||||
|
ctx.reset()
|
||||||
|
if (!root.hasSeries) return
|
||||||
|
var all = root.trendDataA.concat(root.trendDataB)
|
||||||
|
var minV = Math.min.apply(null, all)
|
||||||
|
var maxV = Math.max.apply(null, all)
|
||||||
|
var padding = (maxV - minV) * 0.05
|
||||||
|
minV -= padding
|
||||||
|
maxV += padding
|
||||||
|
var range = (maxV - minV) || 1
|
||||||
|
|
||||||
|
drawGrid(ctx, minV, maxV, root.seriesLen)
|
||||||
|
drawSeries(ctx, root.trendDataA, minV, range, Theme.primaryAccent)
|
||||||
|
drawSeries(ctx, root.trendDataB, minV, range, Theme.themeSkill)
|
||||||
|
drawScrubMarker(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: root
|
||||||
|
function onTrendDataAChanged() { overlayCanvas.requestPaint() }
|
||||||
|
function onTrendDataBChanged() { overlayCanvas.requestPaint() }
|
||||||
|
}
|
||||||
|
Connections {
|
||||||
|
target: scrubber
|
||||||
|
function onValueChanged() { overlayCanvas.requestPaint() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty state
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
visible: !root.hasSeries
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
IconImage {
|
||||||
|
Layout.alignment: Qt.AlignHCenter
|
||||||
|
visible: !root.comparing
|
||||||
|
source: "icons/bar-chart.svg"
|
||||||
|
width: 32; height: 32
|
||||||
|
sourceSize.width: 32
|
||||||
|
sourceSize.height: 32
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
Layout.alignment: Qt.AlignHCenter
|
||||||
|
text: root.comparing ? "Running DTW comparison…" : "Select two runs and run comparison"
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
Layout.alignment: Qt.AlignHCenter
|
||||||
|
visible: !root.comparing
|
||||||
|
text: "Click a run box on the right, then select a file from the left panel."
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Alignment Timeline Scrubber ────────────
|
||||||
|
ColumnLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 4
|
||||||
|
visible: root.hasSeries
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
height: 1
|
||||||
|
color: Theme.cardBorder
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
SectionTitle {
|
||||||
|
text: "ALIGNMENT TIMELINE SCRUBBER"
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
text: "Frame " + Math.round(scrubber.value) + " / " + Math.max(0, root.seriesLen - 1)
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
font.bold: true
|
||||||
|
font.family: Theme.codeFontFamily
|
||||||
|
color: Theme.headingColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Slider {
|
||||||
|
id: scrubber
|
||||||
|
Layout.fillWidth: true
|
||||||
|
from: 0
|
||||||
|
to: Math.max(1, root.seriesLen - 1)
|
||||||
|
stepSize: 1
|
||||||
|
value: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
id: scrubReadout
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
readonly property int scrubIdx: Math.round(scrubber.value)
|
||||||
|
readonly property real tempA: root.trendDataA.length > 0
|
||||||
|
? root.trendDataA[Math.min(scrubIdx, root.trendDataA.length - 1)] : 0
|
||||||
|
readonly property real tempB: root.trendDataB.length > 0
|
||||||
|
? root.trendDataB[Math.min(scrubIdx, root.trendDataB.length - 1)] : 0
|
||||||
|
|
||||||
|
ScrubStat {
|
||||||
|
label: "Temp (Run A)"
|
||||||
|
value: scrubReadout.tempA.toFixed(1) + "°C"
|
||||||
|
valueColor: Theme.primaryAccent
|
||||||
|
}
|
||||||
|
ScrubStat {
|
||||||
|
label: "Temp (Run B)"
|
||||||
|
value: scrubReadout.tempB.toFixed(1) + "°C"
|
||||||
|
valueColor: Theme.themeSkill
|
||||||
|
}
|
||||||
|
ScrubStat {
|
||||||
|
label: "Difference"
|
||||||
|
value: Math.abs(scrubReadout.tempA - scrubReadout.tempB).toFixed(1) + "°C"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wafer Map Overlap View card
|
||||||
|
PanelBox {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
Layout.minimumHeight: 220
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 12
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
SectionTitle {
|
||||||
|
text: "WAFER MAP OVERLAP VIEW"
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
visible: root.compareFileA !== "" && root.compareFileB !== ""
|
||||||
|
text: (root.shortName(root.compareFileA).replace(".csv", "") + " vs "
|
||||||
|
+ root.shortName(root.compareFileB).replace(".csv", "")).toUpperCase()
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
elide: Text.ElideMiddle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
color: Theme.fieldBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
WaferMapItem {
|
||||||
|
id: overlapMapItem
|
||||||
|
anchors.fill: parent
|
||||||
|
visible: root.compareSensorLayout.length > 0 && enableOverlapCheck.checked
|
||||||
|
sensors: root.compareSensorLayout
|
||||||
|
values: root.compareSensorDiff
|
||||||
|
bands: root.diffBands(root.compareSensorDiff)
|
||||||
|
shape: root.compareWaferShape
|
||||||
|
size: root.compareWaferSize
|
||||||
|
target: 0
|
||||||
|
margin: 1.0
|
||||||
|
blend: blendSlider.value / 100
|
||||||
|
showLabels: true
|
||||||
|
ringColor: Theme.waferRingColor
|
||||||
|
axisColor: Theme.waferAxisColor
|
||||||
|
lowColor: Theme.sensorLow
|
||||||
|
inRangeColor: Theme.sensorInRange
|
||||||
|
highColor: Theme.sensorHigh
|
||||||
|
textColor: Theme.headingColor
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
visible: root.compareSensorLayout.length === 0 || !enableOverlapCheck.checked
|
||||||
|
spacing: 4
|
||||||
|
IconImage {
|
||||||
|
Layout.alignment: Qt.AlignHCenter
|
||||||
|
source: "icons/map.svg"
|
||||||
|
width: 24; height: 24
|
||||||
|
sourceSize.width: 24
|
||||||
|
sourceSize.height: 24
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
Layout.alignment: Qt.AlignHCenter
|
||||||
|
text: !enableOverlapCheck.checked ? "Overlap map disabled"
|
||||||
|
: "No sensor map available"
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Right Column: run selection, overlap settings, readout ---
|
||||||
|
ColumnLayout {
|
||||||
|
Layout.fillWidth: false
|
||||||
|
Layout.preferredWidth: 280
|
||||||
|
Layout.minimumWidth: 280
|
||||||
|
Layout.maximumWidth: 280
|
||||||
|
Layout.fillHeight: true
|
||||||
|
spacing: 12
|
||||||
|
|
||||||
|
// Bento 1: Run Selection
|
||||||
|
PanelBox {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: runSelCol.implicitHeight + 24
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: runSelCol
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 12
|
||||||
|
spacing: 10
|
||||||
|
|
||||||
|
SectionTitle { text: "RUN SELECTION" }
|
||||||
|
|
||||||
|
RunSlot {
|
||||||
|
title: "REFERENCE (RUN A)"
|
||||||
|
boxIndex: 1
|
||||||
|
accent: Theme.primaryAccent
|
||||||
|
filePath: root.compareFileA
|
||||||
|
onCleared: {
|
||||||
|
root.compareFileA = ""
|
||||||
|
root.activeBox = 1
|
||||||
|
root.clearResults()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RunSlot {
|
||||||
|
title: "SESSION (RUN B)"
|
||||||
|
boxIndex: 2
|
||||||
|
accent: Theme.themeSkill
|
||||||
|
filePath: root.compareFileB
|
||||||
|
onCleared: {
|
||||||
|
root.compareFileB = ""
|
||||||
|
root.activeBox = 2
|
||||||
|
root.clearResults()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: compareBtn
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: 32
|
||||||
|
enabled: root.compareFileA !== "" && root.compareFileB !== "" && !root.comparing
|
||||||
|
onClicked: {
|
||||||
|
root.comparing = true
|
||||||
|
root.clearResults()
|
||||||
|
streamController.compareFiles(root.compareFileA, root.compareFileB)
|
||||||
|
}
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: compareBtn.enabled
|
||||||
|
? (compareBtn.hovered ? Qt.lighter(Theme.primaryAccent, 1.1) : Theme.primaryAccent)
|
||||||
|
: Theme.buttonNeutralBackground
|
||||||
|
Behavior on color { ColorAnimation { duration: Theme.durationFast } }
|
||||||
|
}
|
||||||
|
contentItem: Row {
|
||||||
|
spacing: 8
|
||||||
|
anchors.centerIn: parent
|
||||||
|
Label {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: compareBtn.enabled
|
||||||
|
? (root.comparing ? "Comparing…" : "Run DTW Comparison")
|
||||||
|
: (root.comparing ? "Comparing…" : "Select both runs")
|
||||||
|
color: compareBtn.enabled ? Theme.tone100 : Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
BusyIndicator {
|
||||||
|
running: root.comparing
|
||||||
|
visible: root.comparing
|
||||||
|
width: 14; height: 14
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bento 2: Overlap Map Settings
|
||||||
|
PanelBox {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: overlapCol.implicitHeight + 24
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: overlapCol
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 12
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
SectionTitle { text: "OVERLAP MAP SETTINGS" }
|
||||||
|
|
||||||
|
CheckBox {
|
||||||
|
id: enableOverlapCheck
|
||||||
|
Layout.fillWidth: true
|
||||||
|
checked: true
|
||||||
|
text: "Enable Overlap Map"
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
|
||||||
|
contentItem: Label {
|
||||||
|
leftPadding: enableOverlapCheck.indicator.width + 6
|
||||||
|
text: enableOverlapCheck.text
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
color: Theme.headingColor
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 6
|
||||||
|
visible: enableOverlapCheck.checked
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
height: 1
|
||||||
|
color: Theme.cardBorder
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 6
|
||||||
|
Label {
|
||||||
|
text: "Blend"
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
color: Theme.bodyColor
|
||||||
|
Layout.preferredWidth: 40
|
||||||
|
}
|
||||||
|
Slider {
|
||||||
|
id: blendSlider
|
||||||
|
Layout.fillWidth: true
|
||||||
|
from: 0; to: 100; value: 50
|
||||||
|
stepSize: 1
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
text: Math.round(blendSlider.value) + "%"
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
Layout.preferredWidth: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diff color legend
|
||||||
|
Row {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 10
|
||||||
|
Row {
|
||||||
|
spacing: 3
|
||||||
|
Rectangle { width: 8; height: 8; radius: 4; color: Theme.sensorLow; anchors.verticalCenter: parent.verticalCenter }
|
||||||
|
Label { text: "B cooler"; font.pixelSize: 9; color: Theme.sideMutedText }
|
||||||
|
}
|
||||||
|
Row {
|
||||||
|
spacing: 3
|
||||||
|
Rectangle { width: 8; height: 8; radius: 4; color: Theme.sensorInRange; anchors.verticalCenter: parent.verticalCenter }
|
||||||
|
Label { text: "Match"; font.pixelSize: 9; color: Theme.sideMutedText }
|
||||||
|
}
|
||||||
|
Row {
|
||||||
|
spacing: 3
|
||||||
|
Rectangle { width: 8; height: 8; radius: 4; color: Theme.sensorHigh; anchors.verticalCenter: parent.verticalCenter }
|
||||||
|
Label { text: "B hotter"; font.pixelSize: 9; color: Theme.sideMutedText }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bento 3: DTW Comparison Readout
|
||||||
|
PanelBox {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: readoutCol.implicitHeight + 24
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: readoutCol
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 12
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
SectionTitle { text: "DTW COMPARISON READOUT" }
|
||||||
|
|
||||||
|
ReadoutCard {
|
||||||
|
label: "DTW ALIGNMENT DISTANCE"
|
||||||
|
value: root.warpingDistance >= 0 ? root.warpingDistance.toFixed(2) : "--"
|
||||||
|
valueColor: root.warpingDistance >= 0
|
||||||
|
? (root.warpingDistance < 50 ? Theme.metricGood : root.warpingDistance < 100 ? Theme.metricWarn : Theme.metricBad)
|
||||||
|
: Theme.sideMutedText
|
||||||
|
}
|
||||||
|
ReadoutCard {
|
||||||
|
label: "TEMPORAL FRAME OFFSET"
|
||||||
|
value: root.warpingDistance >= 0
|
||||||
|
? ((root.frameOffset >= 0 ? "+" : "") + root.frameOffset + " frames")
|
||||||
|
: "--"
|
||||||
|
valueColor: root.warpingDistance >= 0 ? Theme.headingColor : Theme.sideMutedText
|
||||||
|
}
|
||||||
|
ReadoutCard {
|
||||||
|
label: "MAX SENSOR DEVIATION"
|
||||||
|
value: root.maxSensorDeviation >= 0 ? root.maxSensorDeviation.toFixed(2) + "°C" : "--"
|
||||||
|
valueColor: root.maxSensorDeviation >= 0
|
||||||
|
? (root.maxSensorDeviation < 1.0 ? Theme.metricGood : root.maxSensorDeviation < 3.0 ? Theme.metricWarn : Theme.metricBad)
|
||||||
|
: Theme.sideMutedText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { Layout.fillHeight: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import QtQuick.Controls
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Controls.impl
|
||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
import Qt.labs.qmlmodels
|
import Qt.labs.qmlmodels
|
||||||
import ISC
|
import ISC
|
||||||
@@ -24,9 +25,11 @@ Dialog {
|
|||||||
property int selectedRow: -1
|
property int selectedRow: -1
|
||||||
property int metadataEditRow: -1
|
property int metadataEditRow: -1
|
||||||
readonly property string masterTypeFieldText: {
|
readonly property string masterTypeFieldText: {
|
||||||
if (metadataEditRow < 0) return "";
|
if (metadataEditRow < 0)
|
||||||
|
return "";
|
||||||
const rec = tableModel[metadataEditRow];
|
const rec = tableModel[metadataEditRow];
|
||||||
if (!rec) return "";
|
if (!rec)
|
||||||
|
return "";
|
||||||
return rec.masterType ?? "";
|
return rec.masterType ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +116,7 @@ Dialog {
|
|||||||
verticalAlignment: Text.AlignVCenter
|
verticalAlignment: Text.AlignVCenter
|
||||||
elide: Text.ElideRight
|
elide: Text.ElideRight
|
||||||
font.bold: true
|
font.bold: true
|
||||||
font.pixelSize: 13
|
font.pixelSize: Theme.fontSm
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +132,12 @@ Dialog {
|
|||||||
color: Theme.fieldBackground
|
color: Theme.fieldBackground
|
||||||
border.width: dialogTextInput.activeFocus ? Theme.borderStrong : Theme.borderThin
|
border.width: dialogTextInput.activeFocus ? Theme.borderStrong : Theme.borderThin
|
||||||
border.color: dialogTextInput.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder
|
border.color: dialogTextInput.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder
|
||||||
|
Behavior on color {
|
||||||
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
|
Behavior on border.color {
|
||||||
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,7 +172,7 @@ Dialog {
|
|||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.leftMargin: 16
|
anchors.leftMargin: 16
|
||||||
text: root.title
|
text: root.title
|
||||||
font.pixelSize: 15
|
font.pixelSize: Theme.fontLg
|
||||||
font.bold: true
|
font.bold: true
|
||||||
color: Theme.headingColor
|
color: Theme.headingColor
|
||||||
}
|
}
|
||||||
@@ -173,10 +182,17 @@ Dialog {
|
|||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.rightMargin: 12
|
anchors.rightMargin: 12
|
||||||
text: "✕"
|
|
||||||
implicitWidth: 32
|
implicitWidth: 32
|
||||||
implicitHeight: 32
|
implicitHeight: 32
|
||||||
onClicked: root.close()
|
onClicked: root.close()
|
||||||
|
contentItem: IconImage {
|
||||||
|
source: "icons/x.svg"
|
||||||
|
width: 14; height: 14
|
||||||
|
sourceSize.width: 14
|
||||||
|
sourceSize.height: 14
|
||||||
|
color: Theme.buttonNeutralText
|
||||||
|
anchors.centerIn: parent
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,7 +237,7 @@ Dialog {
|
|||||||
text: file_browser.currentDirectory
|
text: file_browser.currentDirectory
|
||||||
color: Theme.fieldText
|
color: Theme.fieldText
|
||||||
elide: Text.ElideMiddle
|
elide: Text.ElideMiddle
|
||||||
font.pixelSize: 13
|
font.pixelSize: Theme.fontSm
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -242,7 +258,7 @@ Dialog {
|
|||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
text: root.headerTitles[index] ?? ""
|
text: root.headerTitles[index] ?? ""
|
||||||
font.bold: true
|
font.bold: true
|
||||||
font.pixelSize: 12
|
font.pixelSize: Theme.fontSm
|
||||||
color: Theme.headingColor
|
color: Theme.headingColor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -329,7 +345,7 @@ Dialog {
|
|||||||
const mt = root.normalizedRows[row]?.masterType ?? "";
|
const mt = root.normalizedRows[row]?.masterType ?? "";
|
||||||
return mt || "—";
|
return mt || "—";
|
||||||
}
|
}
|
||||||
font.pixelSize: 14
|
font.pixelSize: Theme.fontMd
|
||||||
font.bold: true
|
font.bold: true
|
||||||
color: {
|
color: {
|
||||||
const mt = root.normalizedRows[row]?.masterType ?? "";
|
const mt = root.normalizedRows[row]?.masterType ?? "";
|
||||||
@@ -346,7 +362,7 @@ Dialog {
|
|||||||
sourceComponent: Button {
|
sourceComponent: Button {
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
text: "Edit…"
|
text: "Edit…"
|
||||||
font.pixelSize: 12
|
font.pixelSize: Theme.fontSm
|
||||||
onClicked: root.openMetadataEditor(row)
|
onClicked: root.openMetadataEditor(row)
|
||||||
|
|
||||||
background: Rectangle {
|
background: Rectangle {
|
||||||
@@ -361,7 +377,7 @@ Dialog {
|
|||||||
color: Theme.buttonNeutralText
|
color: Theme.buttonNeutralText
|
||||||
horizontalAlignment: Text.AlignHCenter
|
horizontalAlignment: Text.AlignHCenter
|
||||||
verticalAlignment: Text.AlignVCenter
|
verticalAlignment: Text.AlignVCenter
|
||||||
font.pixelSize: 12
|
font.pixelSize: Theme.fontSm
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -388,7 +404,7 @@ Dialog {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
elide: Text.ElideRight
|
elide: Text.ElideRight
|
||||||
font.pixelSize: 13
|
font.pixelSize: Theme.fontSm
|
||||||
color: Theme.bodyColor
|
color: Theme.bodyColor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -409,7 +425,7 @@ Dialog {
|
|||||||
return record.notes ?? "";
|
return record.notes ?? "";
|
||||||
}
|
}
|
||||||
elide: Text.ElideRight
|
elide: Text.ElideRight
|
||||||
font.pixelSize: 13
|
font.pixelSize: Theme.fontSm
|
||||||
color: Theme.bodyColor
|
color: Theme.bodyColor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -456,7 +472,7 @@ Dialog {
|
|||||||
verticalAlignment: Text.AlignVCenter
|
verticalAlignment: Text.AlignVCenter
|
||||||
text: root.selectedPath
|
text: root.selectedPath
|
||||||
elide: Text.ElideLeft
|
elide: Text.ElideLeft
|
||||||
font.pixelSize: 13
|
font.pixelSize: Theme.fontSm
|
||||||
color: root.selectedPath ? Theme.fieldText : Theme.fieldPlaceholder
|
color: root.selectedPath ? Theme.fieldText : Theme.fieldPlaceholder
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -504,7 +520,7 @@ Dialog {
|
|||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.leftMargin: 16
|
anchors.leftMargin: 16
|
||||||
text: metadataEditDialog.title
|
text: metadataEditDialog.title
|
||||||
font.pixelSize: 15
|
font.pixelSize: Theme.fontLg
|
||||||
font.bold: true
|
font.bold: true
|
||||||
color: Theme.headingColor
|
color: Theme.headingColor
|
||||||
}
|
}
|
||||||
@@ -530,7 +546,7 @@ Dialog {
|
|||||||
maximumLineCount: 3
|
maximumLineCount: 3
|
||||||
elide: Text.ElideRight
|
elide: Text.ElideRight
|
||||||
color: Theme.bodyColor
|
color: Theme.bodyColor
|
||||||
font.pixelSize: 12
|
font.pixelSize: Theme.fontSm
|
||||||
}
|
}
|
||||||
|
|
||||||
Label {
|
Label {
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import QtQuick.Controls
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Controls.impl
|
||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
import QtQuick.Dialogs
|
import QtQuick.Dialogs
|
||||||
import ISC
|
import ISC
|
||||||
@@ -27,8 +28,7 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ===== Settings Data Helpers =====
|
// ===== Settings Data Helpers =====
|
||||||
readonly property var masterFamilies: ["A", "B", "C", "D", "E", "F", "P", "X", "Z"]
|
// ListModel for master families defined inline in the ColumnLayout below
|
||||||
|
|
||||||
function masterPath(family) {
|
function masterPath(family) {
|
||||||
const table = settingsModel.masters;
|
const table = settingsModel.masters;
|
||||||
return table && table[family] ? table[family] : "";
|
return table && table[family] ? table[family] : "";
|
||||||
@@ -58,7 +58,7 @@ Item {
|
|||||||
label: Label {
|
label: Label {
|
||||||
text: settingsGroup.title
|
text: settingsGroup.title
|
||||||
color: Theme.panelTitleText
|
color: Theme.panelTitleText
|
||||||
font.pixelSize: 16
|
font.pixelSize: Theme.fontLg
|
||||||
font.bold: true
|
font.bold: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -75,6 +75,12 @@ Item {
|
|||||||
color: Theme.fieldBackground
|
color: Theme.fieldBackground
|
||||||
border.width: textInput.activeFocus ? Theme.borderStrong : Theme.borderThin
|
border.width: textInput.activeFocus ? Theme.borderStrong : Theme.borderThin
|
||||||
border.color: textInput.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder
|
border.color: textInput.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder
|
||||||
|
Behavior on color {
|
||||||
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
|
Behavior on border.color {
|
||||||
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,25 +113,37 @@ Item {
|
|||||||
x: toggle.leftPadding
|
x: toggle.leftPadding
|
||||||
y: parent.height / 2 - height / 2
|
y: parent.height / 2 - height / 2
|
||||||
radius: Theme.radiusXs
|
radius: Theme.radiusXs
|
||||||
color: toggle.checked ? Theme.primaryAccent : Theme.fieldBackground
|
color: toggle.checked ? Theme.primaryAccent : "transparent"
|
||||||
border.width: Theme.borderThin
|
border.width: Theme.borderThin
|
||||||
border.color: toggle.checked ? Theme.primaryAccent : Theme.fieldBorder
|
border.color: toggle.checked ? Theme.primaryAccent : Theme.fieldBorder
|
||||||
|
|
||||||
Rectangle {
|
Behavior on color {
|
||||||
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
|
Behavior on border.color {
|
||||||
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
|
|
||||||
|
IconImage {
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
width: 9
|
source: "icons/check.svg"
|
||||||
height: 9
|
width: 12; height: 12
|
||||||
radius: Theme.radiusXs
|
sourceSize.width: 12
|
||||||
visible: toggle.checked
|
sourceSize.height: 12
|
||||||
color: Theme.fieldBackground
|
color: Theme.panelBackground
|
||||||
|
opacity: toggle.checked ? 1.0 : 0.0
|
||||||
|
Behavior on opacity {
|
||||||
|
NumberAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
contentItem: Text {
|
contentItem: Text {
|
||||||
text: toggle.text
|
text: toggle.text
|
||||||
color: Theme.checkboxText
|
color: Theme.headingColor
|
||||||
verticalAlignment: Text.AlignVCenter
|
verticalAlignment: Text.AlignVCenter
|
||||||
leftPadding: toggle.indicator.width + toggle.spacing
|
leftPadding: toggle.indicator.width + toggle.spacing
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +173,7 @@ Item {
|
|||||||
anchors.top: settingsScroll.top
|
anchors.top: settingsScroll.top
|
||||||
anchors.right: settingsScroll.right
|
anchors.right: settingsScroll.right
|
||||||
anchors.bottom: settingsScroll.bottom
|
anchors.bottom: settingsScroll.bottom
|
||||||
|
anchors.leftMargin: 6
|
||||||
policy: ScrollBar.AsNeeded
|
policy: ScrollBar.AsNeeded
|
||||||
|
|
||||||
contentItem: Rectangle {
|
contentItem: Rectangle {
|
||||||
@@ -170,16 +189,19 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Gap reserved between the centered content and the right-edge scrollbar.
|
||||||
|
readonly property int scrollGap: 100
|
||||||
|
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
id: container
|
id: container
|
||||||
width: Math.min(settingsScroll.availableWidth, Theme.settingsPanelMaxWidth)
|
width: Math.min(settingsScroll.availableWidth - settingsScroll.scrollGap, Theme.settingsPanelMaxWidth)
|
||||||
x: (settingsScroll.availableWidth - width) / 2
|
x: (settingsScroll.availableWidth - width) / 2
|
||||||
spacing: Theme.settingsSectionSpacing
|
spacing: Theme.settingsSectionSpacing
|
||||||
|
|
||||||
// ===== Page Title =====
|
// ===== Page Title =====
|
||||||
Label {
|
Label {
|
||||||
text: "Settings"
|
text: "Settings"
|
||||||
font.pixelSize: 30
|
font.pixelSize: Theme.font3xl
|
||||||
font.bold: true
|
font.bold: true
|
||||||
color: Theme.headingColor
|
color: Theme.headingColor
|
||||||
}
|
}
|
||||||
@@ -221,7 +243,7 @@ Item {
|
|||||||
Layout.preferredWidth: 90
|
Layout.preferredWidth: 90
|
||||||
Layout.preferredHeight: Theme.settingsButtonHeight
|
Layout.preferredHeight: Theme.settingsButtonHeight
|
||||||
onClicked: {
|
onClicked: {
|
||||||
settingsModel.setChamberId(chamberField.text);
|
settingsModel.ChamberId = chamberField.text;
|
||||||
settingsModel.saveSettings();
|
settingsModel.saveSettings();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -237,11 +259,24 @@ Item {
|
|||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
spacing: Theme.settingsGridSpacing
|
spacing: Theme.settingsGridSpacing
|
||||||
|
|
||||||
|
ListModel {
|
||||||
|
id: masterModel
|
||||||
|
ListElement { letter: "A" }
|
||||||
|
ListElement { letter: "B" }
|
||||||
|
ListElement { letter: "C" }
|
||||||
|
ListElement { letter: "D" }
|
||||||
|
ListElement { letter: "E" }
|
||||||
|
ListElement { letter: "F" }
|
||||||
|
ListElement { letter: "P" }
|
||||||
|
ListElement { letter: "X" }
|
||||||
|
ListElement { letter: "Z" }
|
||||||
|
}
|
||||||
|
|
||||||
Repeater {
|
Repeater {
|
||||||
model: root.masterFamilies
|
model: masterModel
|
||||||
|
|
||||||
RowLayout {
|
RowLayout {
|
||||||
property string family: modelData
|
property string family: model.letter
|
||||||
|
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
spacing: Theme.settingsRowSpacing
|
spacing: Theme.settingsRowSpacing
|
||||||
@@ -292,7 +327,7 @@ Item {
|
|||||||
SettingsToggle {
|
SettingsToggle {
|
||||||
text: "Reverse Z Wafer"
|
text: "Reverse Z Wafer"
|
||||||
checked: settingsModel.reverseZWafer
|
checked: settingsModel.reverseZWafer
|
||||||
onToggled: settingsModel.setReverseZWafer(checked)
|
onToggled: settingsModel.reverseZWafer = checked
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -330,7 +365,7 @@ Item {
|
|||||||
width: 22
|
width: 22
|
||||||
height: 22
|
height: 22
|
||||||
radius: height / 2
|
radius: height / 2
|
||||||
color: "white"
|
color: Theme.toggleThumb
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
x: Theme.isDarkMode ? parent.width - width - 3 : 3
|
x: Theme.isDarkMode ? parent.width - width - 3 : 3
|
||||||
|
|
||||||
@@ -357,7 +392,7 @@ Item {
|
|||||||
Label {
|
Label {
|
||||||
text: Theme.isDarkMode ? "Dark" : "Light"
|
text: Theme.isDarkMode ? "Dark" : "Light"
|
||||||
color: Theme.bodyColor
|
color: Theme.bodyColor
|
||||||
font.pixelSize: 13
|
font.pixelSize: Theme.fontSm
|
||||||
Layout.alignment: Qt.AlignVCenter
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
|
||||||
Behavior on color {
|
Behavior on color {
|
||||||
@@ -416,12 +451,9 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Label {
|
Row {
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
text: "↓ Scroll for more"
|
spacing: 4
|
||||||
font.pixelSize: 13
|
|
||||||
font.bold: true
|
|
||||||
color: Theme.bodyColor
|
|
||||||
opacity: (settingsScroll.ScrollBar.vertical.position <= 0 && settingsScroll.contentHeight > settingsScroll.height) ? (scrollHintTimer.running ? 1 : 0) : 0
|
opacity: (settingsScroll.ScrollBar.vertical.position <= 0 && settingsScroll.contentHeight > settingsScroll.height) ? (scrollHintTimer.running ? 1 : 0) : 0
|
||||||
|
|
||||||
Behavior on opacity {
|
Behavior on opacity {
|
||||||
@@ -429,6 +461,22 @@ Item {
|
|||||||
duration: 400
|
duration: 400
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
IconImage {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
source: "icons/chevron-down.svg"
|
||||||
|
width: 14; height: 14
|
||||||
|
sourceSize.width: 14
|
||||||
|
sourceSize.height: 14
|
||||||
|
color: Theme.bodyColor
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "Scroll for more"
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.bold: true
|
||||||
|
color: Theme.bodyColor
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,643 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Controls.impl
|
||||||
|
import QtQuick.Dialogs
|
||||||
|
import ISC
|
||||||
|
|
||||||
|
// ===== Status Tab — Bento Grid Dashboard =====
|
||||||
|
// ROW 1: Connection Status (65%) | Cycles Completed (35%)
|
||||||
|
// ROW 2: Wafer & Sensor + Directory (65%) | Total Runtime (35%)
|
||||||
|
// ROW 3: Activity Log (100%, fills remaining)
|
||||||
|
ColumnLayout {
|
||||||
|
id: root
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Theme.panelPadding
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
// ── Reusable Inline Components ──
|
||||||
|
component StatCard : Rectangle {
|
||||||
|
property alias value: valueText.text
|
||||||
|
property alias label: labelText.text
|
||||||
|
property bool active: root.waferDetected
|
||||||
|
property int valSize: active ? Theme.font3xl : Theme.font2xl
|
||||||
|
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
color: Theme.cardBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
spacing: 4
|
||||||
|
Text {
|
||||||
|
id: valueText
|
||||||
|
color: active ? Theme.headingColor : Theme.sideMutedText
|
||||||
|
font.pixelSize: valSize
|
||||||
|
font.weight: Font.Bold
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
Layout.alignment: Qt.AlignHCenter
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
id: labelText
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
font.weight: Font.Medium
|
||||||
|
font.letterSpacing: 1.2
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
Layout.alignment: Qt.AlignHCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
component GridCell : RowLayout {
|
||||||
|
property alias label: labelText.text
|
||||||
|
property alias value: valueText.text
|
||||||
|
property bool active: root.waferDetected
|
||||||
|
|
||||||
|
spacing: 8
|
||||||
|
Layout.fillWidth: true
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: labelText
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
Layout.fillWidth: true
|
||||||
|
elide: Text.ElideRight
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
id: valueText
|
||||||
|
color: active ? Theme.headingColor : Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.weight: Font.Bold
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
Layout.alignment: Qt.AlignRight
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Aliases for external wiring ──
|
||||||
|
property alias waferFamilyCode: waferInfoFamilyCell.value
|
||||||
|
property alias waferSerialNumber: waferSerialCell.value
|
||||||
|
property alias waferSensorCount: waferSensorsCell.value
|
||||||
|
property alias waferRuntime: runtimeCard.value
|
||||||
|
property alias waferCycles: waferCyclesCell.value
|
||||||
|
property bool waferDetected: {
|
||||||
|
var info = deviceController.lastWaferInfo;
|
||||||
|
return info && info.length > 0 && info[0] !== "";
|
||||||
|
}
|
||||||
|
|
||||||
|
property int dataRows: 0
|
||||||
|
property int dataCols: 0
|
||||||
|
property string csvPath: ""
|
||||||
|
property bool dataParsed: false
|
||||||
|
|
||||||
|
readonly property bool isConnected: deviceController.connectionStatus === "Connected"
|
||||||
|
readonly property bool isBusy: deviceController.connectionStatus.endsWith("...")
|
||||||
|
readonly property string portName: deviceController.selectedPort || "—"
|
||||||
|
|
||||||
|
function cleanFolderUrl(url) {
|
||||||
|
return decodeURIComponent(String(url).replace(/^file:\/\//, ""));
|
||||||
|
}
|
||||||
|
function currentFamilyCode() {
|
||||||
|
var info = deviceController.lastWaferInfo;
|
||||||
|
return info && info.length > 0 ? (info[0] || "") : "";
|
||||||
|
}
|
||||||
|
function parseAndSavePendingRead() {
|
||||||
|
deviceController.parseAndSaveData(root.currentFamilyCode(), deviceController.selectedPort || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
FolderDialog {
|
||||||
|
id: saveDirDialog
|
||||||
|
title: "Choose a folder to save."
|
||||||
|
onAccepted: {
|
||||||
|
deviceController.setSaveDataDir(root.cleanFolderUrl(selectedFolder));
|
||||||
|
root.parseAndSavePendingRead();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// ROW 1: Connection Status (100%)
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: 90
|
||||||
|
Layout.maximumHeight: 90
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
color: Theme.cardBackground
|
||||||
|
border.color: root.isConnected ? Theme.statusSuccessColor : (root.isBusy ? Theme.statusWarningColor : Theme.statusErrorColor)
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: connStatusCol
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
anchors.leftMargin: Theme.panelPadding
|
||||||
|
anchors.rightMargin: Theme.panelPadding
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "CONNECTION STATUS"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
font.weight: Font.Medium
|
||||||
|
font.letterSpacing: 1.2
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
spacing: 1
|
||||||
|
Text {
|
||||||
|
text: "HOST PC"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: "localhost"
|
||||||
|
color: Theme.headingColor
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.weight: Font.Bold
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
implicitHeight: 22
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
spacing: 5
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: 4
|
||||||
|
Rectangle {
|
||||||
|
width: 4; height: 4; radius: 2
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
color: root.isConnected ? Theme.statusSuccessColor : (root.isBusy ? Theme.statusWarningColor : Theme.sideMutedText)
|
||||||
|
opacity: (root.isConnected || root.isBusy) ? 1.0 : 0.3
|
||||||
|
|
||||||
|
SequentialAnimation on opacity {
|
||||||
|
running: root.isConnected || root.isBusy
|
||||||
|
loops: Animation.Infinite
|
||||||
|
NumberAnimation { to: 0.3; duration: 1400 }
|
||||||
|
NumberAnimation { to: 1.0; duration: 1400 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: statusPillText.implicitWidth + 16
|
||||||
|
height: 20
|
||||||
|
radius: 10
|
||||||
|
color: root.isConnected ? Theme.statusSuccessColor : (root.isBusy ? Theme.statusWarningColor : Theme.statusErrorColor)
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: statusPillText
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: root.isConnected ? "ACTIVE" : (root.isBusy ? deviceController.connectionStatus.replace("...", "").toUpperCase() : "INACTIVE")
|
||||||
|
color: Theme.statusBadgeText
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
font.weight: Font.Bold
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.letterSpacing: 0.6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: 4
|
||||||
|
Rectangle {
|
||||||
|
width: 4; height: 4; radius: 2
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
color: root.isConnected ? Theme.statusSuccessColor : (root.isBusy ? Theme.statusWarningColor : Theme.sideMutedText)
|
||||||
|
opacity: (root.isConnected || root.isBusy) ? 1.0 : 0.3
|
||||||
|
|
||||||
|
SequentialAnimation on opacity {
|
||||||
|
running: root.isConnected || root.isBusy
|
||||||
|
loops: Animation.Infinite
|
||||||
|
NumberAnimation { to: 0.3; duration: 1400 }
|
||||||
|
NumberAnimation { to: 1.0; duration: 1400 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
spacing: 1
|
||||||
|
Text {
|
||||||
|
text: "COM PORT"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
Layout.alignment: Qt.AlignRight
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: root.portName
|
||||||
|
color: root.isConnected ? Theme.statusSuccessColor : Theme.headingColor
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.weight: Font.Bold
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
Layout.alignment: Qt.AlignRight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// ROW 2: Wafer & Sensor + Directory (65%) | Total Runtime (35%)
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: 90
|
||||||
|
Layout.maximumHeight: 90
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.preferredWidth: 65
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
color: Theme.cardBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Theme.panelPadding
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 2
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "DIRECTORY"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.weight: Font.Medium
|
||||||
|
font.letterSpacing: 1.2
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: deviceController.saveDataDir || "Not configured"
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.italic: true
|
||||||
|
opacity: 0.7
|
||||||
|
elide: Text.ElideMiddle
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
visible: root.csvPath !== ""
|
||||||
|
width: sessionSavedLabel.implicitWidth + 12
|
||||||
|
height: 18
|
||||||
|
radius: 4
|
||||||
|
color: Theme.fieldBackground
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: sessionSavedLabel
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: "SESSION SAVED"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
font.weight: Font.Bold
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.letterSpacing: 0.6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { Layout.fillWidth: true }
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: browseBtn
|
||||||
|
text: "Save As"
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.weight: Font.Medium
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
implicitHeight: 24
|
||||||
|
implicitWidth: 70
|
||||||
|
hoverEnabled: true
|
||||||
|
background: Rectangle {
|
||||||
|
color: browseBtn.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
}
|
||||||
|
contentItem: Text {
|
||||||
|
text: browseBtn.text
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font: browseBtn.font
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
onClicked: saveDirDialog.open()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
color: Theme.cardBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 10
|
||||||
|
spacing: 4
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "WAFER & SENSOR DATA"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
font.weight: Font.Bold
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.letterSpacing: 0.6
|
||||||
|
Layout.bottomMargin: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
GridLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
columns: 2
|
||||||
|
rowSpacing: 4
|
||||||
|
columnSpacing: 16
|
||||||
|
|
||||||
|
GridCell {
|
||||||
|
id: waferInfoFamilyCell
|
||||||
|
label: "Family Code"
|
||||||
|
value: {
|
||||||
|
var info = deviceController.lastWaferInfo;
|
||||||
|
return (info && info.length > 0 && info[0]) ? info[0] : "—";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GridCell {
|
||||||
|
id: waferCyclesCell
|
||||||
|
label: "Cycles Completed"
|
||||||
|
value: {
|
||||||
|
var info = deviceController.lastWaferInfo;
|
||||||
|
return (info && info.length > 5 && info[5] !== undefined) ? String(info[5]) : "—";
|
||||||
|
}
|
||||||
|
active: root.waferDetected
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.columnSpan: 2
|
||||||
|
Layout.fillWidth: true
|
||||||
|
height: 1
|
||||||
|
color: Theme.cardBorder
|
||||||
|
opacity: 0.5
|
||||||
|
}
|
||||||
|
|
||||||
|
GridCell {
|
||||||
|
id: waferSerialCell
|
||||||
|
label: "Serial"
|
||||||
|
value: {
|
||||||
|
var info = deviceController.lastWaferInfo;
|
||||||
|
return (info && info.length > 1 && info[1]) ? info[1] : "—";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GridCell {
|
||||||
|
id: waferSensorsCell
|
||||||
|
label: "Sensors"
|
||||||
|
value: {
|
||||||
|
var info = deviceController.lastWaferInfo;
|
||||||
|
return (info && info.length > 2 && info[2]) ? String(info[2]) : "—";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StatCard {
|
||||||
|
id: runtimeCard
|
||||||
|
Layout.preferredWidth: 35
|
||||||
|
value: {
|
||||||
|
var info = deviceController.lastWaferInfo;
|
||||||
|
return (info && info.length > 4 && info[4] !== undefined) ? info[4] + "s" : "—";
|
||||||
|
}
|
||||||
|
label: "TOTAL RUNTIME"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// ROW 3: Activity Log — ALWAYS visible (fills remaining height)
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
color: Theme.cardBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusMd
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: 36
|
||||||
|
color: Theme.subtleSectionBackground
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.leftMargin: Theme.panelPadding
|
||||||
|
anchors.rightMargin: Theme.panelPadding
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
IconImage {
|
||||||
|
source: "icons/file-text.svg"
|
||||||
|
width: 14; height: 14
|
||||||
|
sourceSize.width: 14
|
||||||
|
sourceSize.height: 14
|
||||||
|
color: Theme.bodyColor
|
||||||
|
visible: root.csvPath !== ""
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: root.csvPath !== "" ? root.csvPath.split("/").pop() : "ACTIVITY LOG"
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: root.csvPath !== "" ? Theme.fontMd : Theme.fontSm
|
||||||
|
font.letterSpacing: root.csvPath !== "" ? 0 : 1.8
|
||||||
|
font.weight: Font.Medium
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
elide: Text.ElideMiddle
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: refreshSessionBtn
|
||||||
|
text: "Refresh"
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
implicitHeight: 24
|
||||||
|
hoverEnabled: true
|
||||||
|
background: Rectangle {
|
||||||
|
color: refreshSessionBtn.hovered ? Theme.buttonNeutralHover : "transparent"
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
}
|
||||||
|
contentItem: Text {
|
||||||
|
text: refreshSessionBtn.text
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font: refreshSessionBtn.font
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
onClicked: deviceController.clearSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: clearLogBtn
|
||||||
|
text: "Clear"
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
implicitHeight: 24
|
||||||
|
hoverEnabled: true
|
||||||
|
background: Rectangle {
|
||||||
|
color: clearLogBtn.hovered ? Theme.buttonNeutralHover : "transparent"
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
}
|
||||||
|
contentItem: Text {
|
||||||
|
text: clearLogBtn.text
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font: clearLogBtn.font
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
onClicked: deviceController.clearActivityLog()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
height: 1
|
||||||
|
color: Theme.cardBorder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
Layout.margins: Theme.panelPadding
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
TextArea {
|
||||||
|
id: activityLog
|
||||||
|
width: parent.width
|
||||||
|
text: qsTr("Welcome — use the side rail to detect a wafer, or import data to review.")
|
||||||
|
readOnly: true
|
||||||
|
font.family: "monospace"
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
wrapMode: TextArea.WordWrap
|
||||||
|
color: Theme.bodyColor
|
||||||
|
background: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: deviceController
|
||||||
|
function onActivityLogUpdated(newLog) {
|
||||||
|
activityLog.text = newLog ? newLog : qsTr("Welcome — use the side rail to detect a wafer, or import data to review.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// SIGNAL HANDLERS
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
Connections {
|
||||||
|
target: deviceController
|
||||||
|
function onLogMessage(message) {
|
||||||
|
if (message == "CHOOSE_SAVE_DIR") {
|
||||||
|
saveDirDialog.open();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onPortsUpdated() {}
|
||||||
|
function onDetectResult(result) {
|
||||||
|
if (!result) {
|
||||||
|
root.dataParsed = false;
|
||||||
|
root.dataRows = 0;
|
||||||
|
root.dataCols = 0;
|
||||||
|
root.csvPath = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onStatusRestored() {
|
||||||
|
if (deviceController.dataRowCount > 0) {
|
||||||
|
root.dataParsed = true;
|
||||||
|
root.dataRows = deviceController.dataRowCount;
|
||||||
|
root.dataCols = deviceController.dataColCount;
|
||||||
|
root.csvPath = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: deviceController
|
||||||
|
function onReadResult(result) {
|
||||||
|
root.dataParsed = false;
|
||||||
|
root.dataRows = 0;
|
||||||
|
root.dataCols = 0;
|
||||||
|
root.csvPath = "";
|
||||||
|
if (result && result.success === true)
|
||||||
|
saveDirDialog.open();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: deviceController
|
||||||
|
function onParsedDataReady(result) {
|
||||||
|
if (result && result.success) {
|
||||||
|
root.dataParsed = true;
|
||||||
|
root.dataRows = result.rows;
|
||||||
|
root.dataCols = result.cols;
|
||||||
|
root.csvPath = result.csv_path || "";
|
||||||
|
} else {
|
||||||
|
root.dataParsed = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Controls.impl
|
||||||
|
import QtQuick.Dialogs
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ISC
|
||||||
|
import ISC.Tabs.components
|
||||||
|
import ISC.Wafer
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
anchors.fill: parent
|
||||||
|
|
||||||
|
// Live elapsed-time counter
|
||||||
|
property int _liveSecs: 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;
|
||||||
|
}
|
||||||
|
// 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: ""
|
||||||
|
Connections {
|
||||||
|
target: streamController
|
||||||
|
function onTrendData(avgsJson) {
|
||||||
|
trendChart.setDataFromJson(avgsJson);
|
||||||
|
}
|
||||||
|
// 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() {
|
||||||
|
root.loadErrorMessage = "";
|
||||||
|
if (streamController.mode === "review")
|
||||||
|
modeBar.currentIndex = 0;
|
||||||
|
}
|
||||||
|
function onLoadFileError(message) {
|
||||||
|
root.loadErrorMessage = message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 16
|
||||||
|
spacing: 16
|
||||||
|
|
||||||
|
// ── Toolbar ───────────────────────────────────────────────────────
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
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: 64
|
||||||
|
implicitHeight: 28
|
||||||
|
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.fontSm
|
||||||
|
font.weight: parent.checked ? Font.Medium : Font.Normal
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TabButton {
|
||||||
|
text: "Live"
|
||||||
|
enabled: deviceController.connectionStatus === "Connected"
|
||||||
|
opacity: enabled ? 1.0 : 0.4
|
||||||
|
implicitWidth: 58
|
||||||
|
implicitHeight: 28
|
||||||
|
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.fontSm
|
||||||
|
font.weight: parent.checked ? Font.Medium : Font.Normal
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live source info: WiFi icon + port · serial
|
||||||
|
RowLayout {
|
||||||
|
visible: streamController.mode === "live"
|
||||||
|
spacing: 5
|
||||||
|
// Live status dot (matches the connection-flow indicator dot elsewhere)
|
||||||
|
Rectangle {
|
||||||
|
id: liveIndicator
|
||||||
|
width: 8; height: 8
|
||||||
|
radius: 4
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
color: (deviceController.connectionStatus === "Connected" && streamController.state !== "idle") ? Theme.liveColor : Theme.bodyColor
|
||||||
|
|
||||||
|
SequentialAnimation on opacity {
|
||||||
|
running: deviceController.connectionStatus === "Connected" && 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)
|
||||||
|
liveIndicator.opacity = 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
text: "Live stream"
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
|
||||||
|
// SET badge + avg temp
|
||||||
|
Rectangle {
|
||||||
|
visible: streamController.state === "set"
|
||||||
|
height: 22
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: "transparent"
|
||||||
|
border.color: Theme.liveColor
|
||||||
|
border.width: 1
|
||||||
|
implicitWidth: setLabel.implicitWidth + 16
|
||||||
|
Label {
|
||||||
|
id: setLabel
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: "SET " + (streamController.stats.avg !== undefined ? streamController.stats.avg + " avg" : "")
|
||||||
|
color: Theme.liveColor
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
font.weight: Font.Medium
|
||||||
|
font.letterSpacing: 0.8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record indicator
|
||||||
|
RowLayout {
|
||||||
|
spacing: 5
|
||||||
|
visible: streamController.recording
|
||||||
|
Rectangle {
|
||||||
|
width: 7
|
||||||
|
height: 7
|
||||||
|
radius: 4
|
||||||
|
color: Theme.recordColor
|
||||||
|
SequentialAnimation on opacity {
|
||||||
|
running: streamController.recording
|
||||||
|
loops: Animation.Infinite
|
||||||
|
NumberAnimation {
|
||||||
|
to: 0.2
|
||||||
|
duration: 600
|
||||||
|
}
|
||||||
|
NumberAnimation {
|
||||||
|
to: 1.0
|
||||||
|
duration: 600
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
text: "REC"
|
||||||
|
color: Theme.recordColor
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
font.weight: Font.Medium
|
||||||
|
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 ───────────────────────────────────────────
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: errorRow.implicitHeight + 16
|
||||||
|
visible: root.loadErrorMessage !== ""
|
||||||
|
color: Theme.errorSurface
|
||||||
|
border.color: Theme.statusErrorColor
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
id: errorRow
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 8
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
IconImage {
|
||||||
|
source: "icons/triangle-alert.svg"
|
||||||
|
width: 16; height: 16
|
||||||
|
sourceSize.width: 16
|
||||||
|
sourceSize.height: 16
|
||||||
|
color: Theme.errorTextSoft
|
||||||
|
Layout.alignment: Qt.AlignTop
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: root.loadErrorMessage
|
||||||
|
color: Theme.errorTextSoft
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Body: 2 columns (wafer + trend + transport | readout) ──────────
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
spacing: 16
|
||||||
|
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
WaferMapView {
|
||||||
|
id: waferView
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
blend: readoutPanel.heatmapBlend
|
||||||
|
showLabels: readoutPanel.showLabels
|
||||||
|
showThickness: readoutPanel.showThickness
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
id: trendPane
|
||||||
|
Layout.fillWidth: true
|
||||||
|
implicitHeight: 160
|
||||||
|
visible: trendChart.hasData && streamController.mode === "live"
|
||||||
|
color: Theme.cardBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusMd
|
||||||
|
|
||||||
|
TrendChartItem {
|
||||||
|
id: trendChart
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
implicitHeight: 16
|
||||||
|
}
|
||||||
|
TransportBar {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Readout panel — floating wrapper box
|
||||||
|
Rectangle {
|
||||||
|
Layout.preferredWidth: 220
|
||||||
|
Layout.fillHeight: true
|
||||||
|
color: "transparent"
|
||||||
|
border.color: "transparent"
|
||||||
|
border.width: 0
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
ReadoutPanel {
|
||||||
|
id: readoutPanel
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Controls.impl
|
||||||
|
import ISC
|
||||||
|
|
||||||
|
// ===== Rail Action Button =====
|
||||||
|
// Reusable pill-style button for rail action panels.
|
||||||
|
// Icon left, uppercase label, fixed height, hover/active backgrounds.
|
||||||
|
Rectangle {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property string label: ""
|
||||||
|
property string iconSource: ""
|
||||||
|
property bool destructive: false
|
||||||
|
property color _textColor: !root.enabled ? Theme.sideMutedText
|
||||||
|
: root.destructive ? Theme.statusErrorColor
|
||||||
|
: Theme.headingColor
|
||||||
|
|
||||||
|
signal clicked()
|
||||||
|
|
||||||
|
implicitHeight: Theme.sideButtonHeight
|
||||||
|
radius: 8
|
||||||
|
color: mouseArea.containsMouse || mouseArea.pressed
|
||||||
|
? Theme.sideActiveBackground : "transparent"
|
||||||
|
border.width: 0
|
||||||
|
|
||||||
|
Behavior on color {
|
||||||
|
ColorAnimation { duration: 100 }
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.leftMargin: 12
|
||||||
|
anchors.rightMargin: 12
|
||||||
|
spacing: 10
|
||||||
|
|
||||||
|
IconImage {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: 18
|
||||||
|
height: 18
|
||||||
|
source: root.iconSource
|
||||||
|
sourceSize.width: 18
|
||||||
|
sourceSize.height: 18
|
||||||
|
color: root._textColor
|
||||||
|
opacity: root.enabled ? 0.85 : 0.35
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: root.label
|
||||||
|
color: root._textColor
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 1.2
|
||||||
|
opacity: root.enabled ? 1.0 : 0.4
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: mouseArea
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: root.enabled
|
||||||
|
cursorShape: root.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||||
|
onClicked: {
|
||||||
|
if (root.enabled) root.clicked()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,510 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Controls.impl
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ISC
|
||||||
|
|
||||||
|
// ===== Readout Panel =====
|
||||||
|
// Right-rail panel with three bordered sections: READOUT, DISPLAY, THRESHOLDS.
|
||||||
|
// Cards match the left-rail SOURCE / CONNECTION FLOW style.
|
||||||
|
ColumnLayout {
|
||||||
|
spacing: 6
|
||||||
|
property var s: streamController.stats
|
||||||
|
property alias showLabels: labelsToggle.checked
|
||||||
|
property alias heatmapBlend: heatmapSlider.value
|
||||||
|
property alias showThickness: thicknessToggle.checked
|
||||||
|
|
||||||
|
component PanelCheckBox: CheckBox {
|
||||||
|
id: toggle
|
||||||
|
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 {
|
||||||
|
implicitWidth: badgeLabel.implicitWidth + 10
|
||||||
|
implicitHeight: 10
|
||||||
|
radius: 9
|
||||||
|
color: Theme.fieldBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
property alias text: badgeLabel.text
|
||||||
|
Label {
|
||||||
|
id: badgeLabel
|
||||||
|
anchors.centerIn: parent
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.font2xs
|
||||||
|
font.weight: Font.Medium
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
implicitHeight: readoutCard.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.fontXs
|
||||||
|
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.fontXs
|
||||||
|
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.fontMd
|
||||||
|
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.fontXs
|
||||||
|
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.fontMd
|
||||||
|
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.fontXs
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
Item { Layout.fillWidth: true }
|
||||||
|
Label {
|
||||||
|
text: s.diff !== undefined ? s.diff : "—"
|
||||||
|
color: Theme.diffAccent
|
||||||
|
font.family: Theme.codeFontFamily
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
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.fontXs
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
Item { Layout.fillWidth: true }
|
||||||
|
Label {
|
||||||
|
text: s.avg !== undefined ? s.avg : "—"
|
||||||
|
color: Theme.headingColor
|
||||||
|
font.family: Theme.codeFontFamily
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
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.fontXs
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
Item { Layout.fillWidth: true }
|
||||||
|
Label {
|
||||||
|
text: s.sigma !== undefined ? s.sigma : "—"
|
||||||
|
color: Theme.headingColor
|
||||||
|
font.family: Theme.codeFontFamily
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
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.fontXs
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
Item { Layout.fillWidth: true }
|
||||||
|
Label {
|
||||||
|
text: s.threeSigma !== undefined ? s.threeSigma : "—"
|
||||||
|
color: Theme.headingColor
|
||||||
|
font.family: Theme.codeFontFamily
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
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 {
|
||||||
|
id: displayCard
|
||||||
|
anchors {
|
||||||
|
left: parent.left
|
||||||
|
right: parent.right
|
||||||
|
top: parent.top
|
||||||
|
margins: 12
|
||||||
|
}
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "DISPLAY"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
font.letterSpacing: 1.5
|
||||||
|
font.bold: true
|
||||||
|
Layout.bottomMargin: 4
|
||||||
|
}
|
||||||
|
|
||||||
|
PanelCheckBox {
|
||||||
|
id: thicknessToggle
|
||||||
|
text: "Show Thickness"
|
||||||
|
checked: false
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
}
|
||||||
|
|
||||||
|
PanelCheckBox {
|
||||||
|
id: labelsToggle
|
||||||
|
text: "Labels"
|
||||||
|
checked: true
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
}
|
||||||
|
|
||||||
|
PanelCheckBox {
|
||||||
|
id: clusterAverageToggle
|
||||||
|
text: "Average Clusters"
|
||||||
|
checked: streamController.clusterAveragingEnabled
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
onCheckedChanged: streamController.clusterAveragingEnabled = checked
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
height: 1
|
||||||
|
color: Theme.cardBorder
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 6
|
||||||
|
Label {
|
||||||
|
text: "Heatmap"
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
Layout.preferredWidth: 52
|
||||||
|
}
|
||||||
|
Slider {
|
||||||
|
id: heatmapSlider
|
||||||
|
from: 0
|
||||||
|
to: 1
|
||||||
|
value: 0
|
||||||
|
Layout.fillWidth: true
|
||||||
|
ToolTip.visible: hovered
|
||||||
|
ToolTip.text: Math.round(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 {
|
||||||
|
text: Math.round(heatmapSlider.value * 100) + "%"
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
font.weight: Font.Medium
|
||||||
|
Layout.preferredWidth: 32
|
||||||
|
horizontalAlignment: Text.AlignRight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
implicitHeight: thresholdCard.implicitHeight + 24
|
||||||
|
color: Theme.sidePanelBackground
|
||||||
|
radius: Theme.sidePanelRadius
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: thresholdCard
|
||||||
|
anchors {
|
||||||
|
left: parent.left
|
||||||
|
right: parent.right
|
||||||
|
top: parent.top
|
||||||
|
margins: 12
|
||||||
|
}
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "THRESHOLDS"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
font.letterSpacing: 1.5
|
||||||
|
font.bold: true
|
||||||
|
Layout.bottomMargin: 4
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "Set Point (°C)"
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
}
|
||||||
|
TextField {
|
||||||
|
id: spField
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: "149.0"
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
inputMethodHints: Qt.ImhFormattedNumbersOnly
|
||||||
|
color: Theme.fieldText
|
||||||
|
placeholderTextColor: Theme.fieldPlaceholder
|
||||||
|
selectedTextColor: Theme.fieldBackground
|
||||||
|
selectionColor: Theme.fieldText
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusXs
|
||||||
|
color: Theme.fieldBackground
|
||||||
|
border.width: spField.activeFocus ? Theme.borderStrong : Theme.borderThin
|
||||||
|
border.color: spField.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder
|
||||||
|
Behavior on color {
|
||||||
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
|
Behavior on border.color {
|
||||||
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onEditingFinished: pushThresholds()
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "Margin (±°C)"
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
}
|
||||||
|
TextField {
|
||||||
|
id: mgField
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: "1.0"
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
inputMethodHints: Qt.ImhFormattedNumbersOnly
|
||||||
|
color: Theme.fieldText
|
||||||
|
placeholderTextColor: Theme.fieldPlaceholder
|
||||||
|
selectedTextColor: Theme.fieldBackground
|
||||||
|
selectionColor: Theme.fieldText
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusXs
|
||||||
|
color: Theme.fieldBackground
|
||||||
|
border.width: mgField.activeFocus ? Theme.borderStrong : Theme.borderThin
|
||||||
|
border.color: mgField.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder
|
||||||
|
Behavior on color {
|
||||||
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
|
Behavior on border.color {
|
||||||
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onEditingFinished: pushThresholds()
|
||||||
|
}
|
||||||
|
|
||||||
|
PanelCheckBox {
|
||||||
|
id: autoCheck
|
||||||
|
text: "Auto range (mean ± 1σ)"
|
||||||
|
checked: true
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
onCheckedChanged: pushThresholds()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
Layout.fillHeight: true
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushThresholds() {
|
||||||
|
var sp = parseFloat(spField.text) || 149.0;
|
||||||
|
var mg = parseFloat(mgField.text) || 1.0;
|
||||||
|
streamController.setThresholds(sp, mg, autoCheck.checked);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ISC
|
||||||
|
|
||||||
|
Dialog {
|
||||||
|
id: root
|
||||||
|
title: "Sensor Override"
|
||||||
|
modal: true
|
||||||
|
standardButtons: Dialog.NoButton
|
||||||
|
width: 300
|
||||||
|
|
||||||
|
// Anchor to the top-left of the window instead of the default centered position.
|
||||||
|
x: 24
|
||||||
|
y: 24
|
||||||
|
|
||||||
|
// called by WaferMapView's TapHandler
|
||||||
|
property int sensorIndex: -1
|
||||||
|
property string sensorLabel: ""
|
||||||
|
property real currentValue: 0.0
|
||||||
|
property bool hasOverride: streamController.overriddenSensors.indexOf(sensorIndex) >= 0
|
||||||
|
|
||||||
|
function openFor(index) {
|
||||||
|
sensorIndex = index;
|
||||||
|
var dot = streamController.sensorDots[index];
|
||||||
|
if (!dot)
|
||||||
|
return;
|
||||||
|
sensorLabel = dot.label !== undefined ? String(dot.label) : String(index + 1);
|
||||||
|
currentValue = dot.value;
|
||||||
|
valueField.text = "";
|
||||||
|
offsetField.text = "";
|
||||||
|
open();
|
||||||
|
}
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
color: Theme.cardBackground
|
||||||
|
radius: Theme.radiusLg
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: Theme.borderThin
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
spacing: Theme.rightPaneGap
|
||||||
|
|
||||||
|
// ── header ───────────────────────────────────────────────
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Label {
|
||||||
|
text: "Sensor #" + (root.sensorIndex + 1) + (root.sensorLabel ? " (" + root.sensorLabel + ")" : "")
|
||||||
|
color: Theme.headingColor
|
||||||
|
font.bold: true
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
visible: root.hasOverride
|
||||||
|
width: overrideTag.implicitWidth + 12
|
||||||
|
height: overrideTag.implicitHeight + 6
|
||||||
|
radius: 4
|
||||||
|
color: Theme.statusWarningColor
|
||||||
|
opacity: 0.85
|
||||||
|
Label {
|
||||||
|
id: overrideTag
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: "OVERRIDE"
|
||||||
|
color: Theme.statusBadgeText
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "Live: " + root.currentValue.toFixed(2)
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
height: 1
|
||||||
|
color: Theme.cardBorder
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── replace field ─────────────────────────────────────────
|
||||||
|
Label {
|
||||||
|
text: "Replace with value"
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
}
|
||||||
|
TextField {
|
||||||
|
id: valueField
|
||||||
|
Layout.fillWidth: true
|
||||||
|
placeholderText: "100"
|
||||||
|
inputMethodHints: Qt.ImhFormattedNumbersOnly
|
||||||
|
color: Theme.fieldText
|
||||||
|
placeholderTextColor: Theme.fieldPlaceholder
|
||||||
|
selectedTextColor: Theme.fieldBackground
|
||||||
|
selectionColor: Theme.fieldText
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusXs
|
||||||
|
color: Theme.fieldBackground
|
||||||
|
border.width: valueField.activeFocus ? Theme.borderStrong : Theme.borderThin
|
||||||
|
border.color: valueField.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder
|
||||||
|
Behavior on color {
|
||||||
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
|
Behavior on border.color {
|
||||||
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── offset field ──────────────────────────────────────────
|
||||||
|
Label {
|
||||||
|
text: "Or add offset (± delta)"
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
}
|
||||||
|
TextField {
|
||||||
|
id: offsetField
|
||||||
|
Layout.fillWidth: true
|
||||||
|
placeholderText: "e.g. +0.5 or -0.5 (blank = skip)"
|
||||||
|
inputMethodHints: Qt.ImhFormattedNumbersOnly
|
||||||
|
color: Theme.fieldText
|
||||||
|
placeholderTextColor: Theme.fieldPlaceholder
|
||||||
|
selectedTextColor: Theme.fieldBackground
|
||||||
|
selectionColor: Theme.fieldText
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusXs
|
||||||
|
color: Theme.fieldBackground
|
||||||
|
border.width: offsetField.activeFocus ? Theme.borderStrong : Theme.borderThin
|
||||||
|
border.color: offsetField.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder
|
||||||
|
Behavior on color {
|
||||||
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
|
Behavior on border.color {
|
||||||
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
height: 1
|
||||||
|
color: Theme.cardBorder
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── buttons ───────────────────────────────────────────────
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: applyBtn
|
||||||
|
text: "Apply"
|
||||||
|
Layout.fillWidth: true
|
||||||
|
hoverEnabled: true
|
||||||
|
enabled: valueField.text !== "" || offsetField.text !== ""
|
||||||
|
opacity: enabled ? 1.0 : 0.4
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: applyBtn.down ? Theme.buttonNeutralPressed : applyBtn.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
|
||||||
|
border.width: Theme.borderThin
|
||||||
|
border.color: Theme.fieldBorder
|
||||||
|
}
|
||||||
|
contentItem: Text {
|
||||||
|
text: applyBtn.text
|
||||||
|
color: Theme.buttonNeutralText
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
font.bold: true
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
}
|
||||||
|
onClicked: {
|
||||||
|
if (valueField.text !== "") {
|
||||||
|
var v = parseFloat(valueField.text);
|
||||||
|
if (!isNaN(v))
|
||||||
|
streamController.replaceSensor(root.sensorIndex, v);
|
||||||
|
}
|
||||||
|
if (offsetField.text !== "") {
|
||||||
|
var d = parseFloat(offsetField.text);
|
||||||
|
if (!isNaN(d))
|
||||||
|
streamController.offsetSensor(root.sensorIndex, d);
|
||||||
|
}
|
||||||
|
root.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
id: clearBtn
|
||||||
|
text: "Clear"
|
||||||
|
Layout.fillWidth: true
|
||||||
|
hoverEnabled: true
|
||||||
|
enabled: root.hasOverride
|
||||||
|
opacity: enabled ? 1.0 : 0.4
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: clearBtn.down ? Theme.buttonNeutralPressed : clearBtn.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
|
||||||
|
border.width: Theme.borderThin
|
||||||
|
border.color: Theme.fieldBorder
|
||||||
|
}
|
||||||
|
contentItem: Text {
|
||||||
|
text: clearBtn.text
|
||||||
|
color: Theme.buttonNeutralText
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
font.bold: true
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
}
|
||||||
|
onClicked: {
|
||||||
|
streamController.clearSensorEdit(root.sensorIndex);
|
||||||
|
root.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
id: cancelBtn
|
||||||
|
text: "Cancel"
|
||||||
|
hoverEnabled: true
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: cancelBtn.down ? Theme.buttonNeutralPressed : cancelBtn.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
|
||||||
|
border.width: Theme.borderThin
|
||||||
|
border.color: Theme.fieldBorder
|
||||||
|
}
|
||||||
|
contentItem: Text {
|
||||||
|
text: cancelBtn.text
|
||||||
|
color: Theme.buttonNeutralText
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
font.bold: true
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
}
|
||||||
|
onClicked: root.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import QtQuick.Controls.impl
|
||||||
|
import ISC
|
||||||
|
import ".."
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
// ── Section label & Refresh ───────────────────────────────────────────
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 4
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "SOURCE"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.letterSpacing: 1.5
|
||||||
|
font.bold: true
|
||||||
|
topPadding: 6
|
||||||
|
bottomPadding: 8
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: editBtn
|
||||||
|
implicitWidth: 28
|
||||||
|
implicitHeight: 28
|
||||||
|
hoverEnabled: true
|
||||||
|
background: Rectangle {
|
||||||
|
color: editBtn.pressed ? Theme.buttonPressed : editBtn.hovered ? Theme.buttonNeutralHover : "transparent"
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
}
|
||||||
|
contentItem: IconImage {
|
||||||
|
source: "../icons/edit.svg"
|
||||||
|
sourceSize.width: 16
|
||||||
|
sourceSize.height: 16
|
||||||
|
color: Theme.bodyColor
|
||||||
|
}
|
||||||
|
onClicked: {
|
||||||
|
file_browser.refreshFiles();
|
||||||
|
csvEditorDialog.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
ToolTip {
|
||||||
|
id: editTooltip
|
||||||
|
visible: editBtn.hovered
|
||||||
|
text: "Edit"
|
||||||
|
delay: 400
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
contentItem: Text {
|
||||||
|
text: editTooltip.text
|
||||||
|
color: Theme.headingColor
|
||||||
|
font: editTooltip.font
|
||||||
|
}
|
||||||
|
background: Rectangle {
|
||||||
|
color: Theme.cardBackground
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusXs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: refreshBtn
|
||||||
|
implicitWidth: 28
|
||||||
|
implicitHeight: 28
|
||||||
|
hoverEnabled: true
|
||||||
|
background: Rectangle {
|
||||||
|
color: refreshBtn.pressed ? Theme.buttonPressed : refreshBtn.hovered ? Theme.buttonNeutralHover : "transparent"
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
}
|
||||||
|
contentItem: IconImage {
|
||||||
|
source: "../icons/refresh.svg"
|
||||||
|
sourceSize.width: 16
|
||||||
|
sourceSize.height: 16
|
||||||
|
color: Theme.bodyColor
|
||||||
|
}
|
||||||
|
onClicked: file_browser.refreshFiles()
|
||||||
|
|
||||||
|
ToolTip {
|
||||||
|
id: refreshTooltip
|
||||||
|
visible: refreshBtn.hovered
|
||||||
|
text: "Refresh"
|
||||||
|
delay: 400
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
contentItem: Text {
|
||||||
|
text: refreshTooltip.text
|
||||||
|
color: Theme.headingColor
|
||||||
|
font: refreshTooltip.font
|
||||||
|
}
|
||||||
|
background: Rectangle {
|
||||||
|
color: Theme.cardBackground
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusXs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Directory button ──────────────────────────────────────────────────
|
||||||
|
Button {
|
||||||
|
id: dirBtn
|
||||||
|
Layout.fillWidth: true
|
||||||
|
hoverEnabled: true
|
||||||
|
implicitHeight: 36
|
||||||
|
leftPadding: 8
|
||||||
|
rightPadding: 8
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: dirBtn.pressed ? Theme.buttonNeutralPressed : dirBtn.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
|
||||||
|
border.width: Theme.borderThin
|
||||||
|
border.color: Theme.fieldBorder
|
||||||
|
}
|
||||||
|
contentItem: RowLayout {
|
||||||
|
spacing: 6
|
||||||
|
IconImage {
|
||||||
|
source: "../icons/folder.svg"
|
||||||
|
width: 14; height: 14
|
||||||
|
sourceSize.width: 14
|
||||||
|
sourceSize.height: 14
|
||||||
|
color: Theme.bodyColor
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
text: file_browser.currentDirectory.split("/").pop() || file_browser.currentDirectory
|
||||||
|
color: Theme.headingColor
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
elide: Text.ElideMiddle
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onClicked: file_browser.chooseDirectory()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Filter ────────────────────────────────────────────────────────────
|
||||||
|
TextField {
|
||||||
|
id: filter
|
||||||
|
Layout.fillWidth: true
|
||||||
|
placeholderText: "Filter…"
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
color: Theme.fieldText
|
||||||
|
placeholderTextColor: Theme.fieldPlaceholder
|
||||||
|
selectedTextColor: Theme.fieldBackground
|
||||||
|
selectionColor: Theme.fieldText
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusXs
|
||||||
|
color: Theme.fieldBackground
|
||||||
|
border.width: filter.activeFocus ? Theme.borderStrong : Theme.borderThin
|
||||||
|
border.color: filter.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder
|
||||||
|
Behavior on color {
|
||||||
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
|
Behavior on border.color {
|
||||||
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── File list ─────────────────────────────────────────────────────────
|
||||||
|
ListView {
|
||||||
|
id: fileList
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
clip: true
|
||||||
|
spacing: 2
|
||||||
|
highlight: null
|
||||||
|
highlightFollowsCurrentItem: false
|
||||||
|
|
||||||
|
model: file_browser.files
|
||||||
|
|
||||||
|
delegate: ItemDelegate {
|
||||||
|
id: fileItem
|
||||||
|
width: ListView.view.width
|
||||||
|
padding: 8
|
||||||
|
highlighted: false
|
||||||
|
focusPolicy: Qt.NoFocus
|
||||||
|
|
||||||
|
readonly property bool matchesFilter: {
|
||||||
|
if (filter.text === "")
|
||||||
|
return true;
|
||||||
|
var q = filter.text.toLowerCase();
|
||||||
|
return (modelData.baseName + modelData.waferType + modelData.date).toLowerCase().indexOf(q) >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active highlight logic: on Data tab, highlight compareFileA/B; otherwise highlight loadedFile (for Map tab).
|
||||||
|
readonly property bool isActive:{
|
||||||
|
try {
|
||||||
|
if (root.selectedTabIndex === 1 && dataTabLoader.item) {
|
||||||
|
return modelData.fileName === dataTabLoader.item.compareFileA || modelData.fileName === dataTabLoader.item.compareFileB;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
return streamController.mode === "review" && streamController.loadedFile !== "" && modelData.fileName === streamController.loadedFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
visible: matchesFilter
|
||||||
|
height: matchesFilter ? implicitHeight : 0
|
||||||
|
|
||||||
|
onClicked: streamController.loadFile(modelData.fileName)
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
color: fileItem.isActive ? Theme.listActiveBackground : fileItem.hovered ? Theme.listHoverBackground : "transparent"
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
Behavior on color {
|
||||||
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Left accent bar — shown only when active
|
||||||
|
Rectangle {
|
||||||
|
visible: fileItem.isActive
|
||||||
|
width: 3
|
||||||
|
radius: 1.5
|
||||||
|
anchors {
|
||||||
|
top: parent.top
|
||||||
|
bottom: parent.bottom
|
||||||
|
left: parent.left
|
||||||
|
topMargin: 5
|
||||||
|
bottomMargin: 5
|
||||||
|
}
|
||||||
|
color: {
|
||||||
|
var t = modelData.waferType;
|
||||||
|
if (t === "A" || t === "E" || t === "P")
|
||||||
|
return Theme.familyBlueAccent;
|
||||||
|
if (t === "B" || t === "C" || t === "D")
|
||||||
|
return Theme.familyGreenAccent;
|
||||||
|
if (t === "Z")
|
||||||
|
return Theme.familyVioletAccent;
|
||||||
|
return Theme.headingColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contentItem: RowLayout {
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
// Wafer-type avatar (circle)
|
||||||
|
Rectangle {
|
||||||
|
implicitWidth: 28
|
||||||
|
implicitHeight: 28
|
||||||
|
Layout.preferredWidth: 28
|
||||||
|
Layout.preferredHeight: 28
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
radius: 14
|
||||||
|
color: {
|
||||||
|
var t = modelData.waferType;
|
||||||
|
if (t === "A" || t === "E" || t === "P")
|
||||||
|
return Theme.familyBlueFill;
|
||||||
|
if (t === "B" || t === "C" || t === "D")
|
||||||
|
return Theme.familyGreenFill;
|
||||||
|
if (t === "Z")
|
||||||
|
return Theme.familyVioletFill;
|
||||||
|
return Theme.familyNeutralFill;
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: modelData.waferType || "?"
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.weight: Font.Bold
|
||||||
|
color: Theme.textOnColor
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recording indicator dot — distinguishes a live-recorded CSV
|
||||||
|
// (SessionController.startRecording) from a read-memory dump
|
||||||
|
// (DeviceController.parseAndSaveData).
|
||||||
|
Rectangle {
|
||||||
|
visible: modelData.isRecording === true
|
||||||
|
width: 10
|
||||||
|
height: 10
|
||||||
|
radius: 5
|
||||||
|
color: Theme.recordColor
|
||||||
|
border.color: Theme.subtleSectionBackground
|
||||||
|
border.width: 1.5
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.topMargin: -2
|
||||||
|
anchors.rightMargin: -2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typography hierarchy
|
||||||
|
ColumnLayout {
|
||||||
|
spacing: 1
|
||||||
|
Layout.fillWidth: true
|
||||||
|
|
||||||
|
// Primary: wafer type + serial number, plus a REC badge for recordings
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: modelData.serialNumber ? (modelData.waferType + modelData.serialNumber) : modelData.baseName
|
||||||
|
color: Theme.headingColor
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.weight: Font.Bold
|
||||||
|
elide: Text.ElideRight
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
visible: modelData.isRecording === true
|
||||||
|
radius: 4
|
||||||
|
color: Qt.alpha(Theme.recordColor, 0.18)
|
||||||
|
implicitWidth: recLabel.implicitWidth + 10
|
||||||
|
implicitHeight: recLabel.implicitHeight + 4
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
|
||||||
|
Label {
|
||||||
|
id: recLabel
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: "REC"
|
||||||
|
color: Theme.recordColor
|
||||||
|
font.pixelSize: Theme.font2xs
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 0.6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Secondary: date · time
|
||||||
|
RowLayout {
|
||||||
|
spacing: 3
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Label {
|
||||||
|
text: {
|
||||||
|
var d = modelData.date || "";
|
||||||
|
return d.length >= 10 ? d.substring(0, 10) : (d || "—");
|
||||||
|
}
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
visible: (modelData.timeStr || "") !== ""
|
||||||
|
text: "·"
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
opacity: 0.45
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
visible: (modelData.timeStr || "") !== ""
|
||||||
|
text: modelData.timeStr || ""
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
opacity: 0.65
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tertiary: edited metadata (chamber · notes) if any
|
||||||
|
Label {
|
||||||
|
visible: {
|
||||||
|
var c = modelData.chamber || "";
|
||||||
|
var n = modelData.notes || "";
|
||||||
|
return c !== "" || n !== "";
|
||||||
|
}
|
||||||
|
text: {
|
||||||
|
var parts = [];
|
||||||
|
if (modelData.chamber)
|
||||||
|
parts.push(modelData.chamber);
|
||||||
|
if (modelData.notes)
|
||||||
|
parts.push(modelData.notes);
|
||||||
|
return parts.join(" · ");
|
||||||
|
}
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
opacity: 0.7
|
||||||
|
elide: Text.ElideRight
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Footer ────────────────────────────────────────────────────────────
|
||||||
|
Label {
|
||||||
|
text: file_browser.files.length + " files · CSV only"
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
}
|
||||||
|
|
||||||
|
SelectFileDialog {
|
||||||
|
id: csvEditorDialog
|
||||||
|
tableModel: file_browser.files
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Dialogs
|
||||||
|
import ISC
|
||||||
|
|
||||||
|
// ===== Split Dialog (Threshold Segmentation) =====
|
||||||
|
// Modal popup for segmenting temperature profile into Ramp/Soak/Cool phases.
|
||||||
|
// Backend: splitter.segment_profile() returns list of DataSegment objects.
|
||||||
|
|
||||||
|
Popup {
|
||||||
|
id: root
|
||||||
|
modal: true
|
||||||
|
dim: true
|
||||||
|
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||||
|
width: Math.min(parent.width - 100, 800)
|
||||||
|
height: Math.min(parent.height - 100, 600)
|
||||||
|
anchors.centerIn: Overlay.overlay
|
||||||
|
|
||||||
|
property real threshold: 40.0
|
||||||
|
property bool segmenting: false
|
||||||
|
property var segments: []
|
||||||
|
property string currentFile: streamController.loadedFile || ""
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusMd
|
||||||
|
color: Theme.cardBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 20
|
||||||
|
spacing: 16
|
||||||
|
|
||||||
|
// ── Header ────────────────────────────────────────────────
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 10
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "SPLIT DATA"
|
||||||
|
font.pixelSize: Theme.fontLg
|
||||||
|
font.bold: true
|
||||||
|
color: Theme.headingColor
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: "\u2715"
|
||||||
|
flat: true
|
||||||
|
Layout.preferredWidth: 32
|
||||||
|
Layout.preferredHeight: 32
|
||||||
|
onClicked: root.close()
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusXs
|
||||||
|
color: parent.hovered ? Theme.buttonNeutralHover : "transparent"
|
||||||
|
}
|
||||||
|
contentItem: Label {
|
||||||
|
text: parent.text
|
||||||
|
color: Theme.bodyColor
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Threshold Input ───────────────────────────────────────
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 16
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 4
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "THRESHOLD TEMPERATURE (°C)"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
SpinBox {
|
||||||
|
id: thresholdSpin
|
||||||
|
from: 0
|
||||||
|
to: 200
|
||||||
|
value: root.threshold
|
||||||
|
stepSize: 1
|
||||||
|
editable: true
|
||||||
|
Layout.fillWidth: true
|
||||||
|
background: Rectangle {
|
||||||
|
color: Theme.fieldBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "°C"
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 4
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "SESSION CSV"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: root.currentFile !== ""
|
||||||
|
? root.currentFile.split("/").pop()
|
||||||
|
: "No file loaded"
|
||||||
|
color: root.currentFile !== "" ? Theme.bodyColor : Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
elide: Text.ElideMiddle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Split Button ──────────────────────────────────────────
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 10
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: splitBtn
|
||||||
|
text: "SPLIT"
|
||||||
|
Layout.fillWidth: true
|
||||||
|
enabled: !root.segmenting && root.currentFile !== ""
|
||||||
|
onClicked: {
|
||||||
|
root.threshold = thresholdSpin.value;
|
||||||
|
root.segmenting = true;
|
||||||
|
streamController.splitData(root.currentFile, root.threshold);
|
||||||
|
}
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: splitBtn.enabled ? Theme.primaryAccent : Theme.buttonNeutralBackground
|
||||||
|
border.color: splitBtn.enabled ? Theme.primaryAccent : "transparent"
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
contentItem: Row {
|
||||||
|
spacing: 8
|
||||||
|
anchors.centerIn: parent
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: splitBtn.enabled ? (root.segmenting ? "Segmenting..." : "SPLIT") : "LOAD DATA FIRST"
|
||||||
|
color: splitBtn.enabled ? Theme.tone100 : Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
BusyIndicator {
|
||||||
|
running: root.segmenting
|
||||||
|
visible: root.segmenting
|
||||||
|
width: 16
|
||||||
|
height: 16
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Segments Table ────────────────────────────────────────
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: Theme.panelBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 0
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
// Table header
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: 36
|
||||||
|
color: Theme.subtleSectionBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 12
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Label { text: "PHASE"; Layout.preferredWidth: 100; color: Theme.sideMutedText; font.bold: true; font.pixelSize: Theme.fontSm }
|
||||||
|
Label { text: "START (s)"; Layout.preferredWidth: 120; color: Theme.sideMutedText; font.bold: true; font.pixelSize: Theme.fontSm }
|
||||||
|
Label { text: "END (s)"; Layout.preferredWidth: 120; color: Theme.sideMutedText; font.bold: true; font.pixelSize: Theme.fontSm }
|
||||||
|
Label { text: "AVG TEMP (°C)"; Layout.fillWidth: true; color: Theme.sideMutedText; font.bold: true; font.pixelSize: Theme.fontSm }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Table body (scrollable)
|
||||||
|
ScrollView {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
width: parent.width - 32
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.segments.length > 0 ? root.segments : []
|
||||||
|
delegate: Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: 40
|
||||||
|
color: index % 2 === 0 ? Theme.cardBackground : "transparent"
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 12
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: modelData.label || ""
|
||||||
|
Layout.preferredWidth: 100
|
||||||
|
color: modelData.label === "Soak" ? Theme.statusSuccessColor :
|
||||||
|
modelData.label === "Ramp" ? Theme.statusWarningColor : Theme.bodyColor
|
||||||
|
font.bold: true
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: (modelData.start_frame || 0).toString()
|
||||||
|
Layout.preferredWidth: 120
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: (modelData.end_frame || 0).toString()
|
||||||
|
Layout.preferredWidth: 120
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: (modelData.avg_temp || 0).toFixed(2) + " °C"
|
||||||
|
Layout.fillWidth: true
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty state
|
||||||
|
Label {
|
||||||
|
visible: root.segments.length === 0
|
||||||
|
text: root.currentFile !== "" ? "Click SPLIT to segment data" : "Load a CSV file first"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: 100
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Summary line ──────────────────────────────────────────
|
||||||
|
Label {
|
||||||
|
visible: root.segments.length > 0
|
||||||
|
text: root.segments.length + " segment" + (root.segments.length === 1 ? "" : "s")
|
||||||
|
+ " found above " + root.threshold.toFixed(0) + "°C threshold."
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Backend Connection ────────────────────────────────────────
|
||||||
|
Connections {
|
||||||
|
target: streamController
|
||||||
|
function onSplitResult(result) {
|
||||||
|
root.segmenting = false;
|
||||||
|
if (result && result.success) {
|
||||||
|
root.segments = result.segments || [];
|
||||||
|
console.log("[SplitDialog] Segmented into", root.segments.length, "phases");
|
||||||
|
} else {
|
||||||
|
root.segments = [];
|
||||||
|
console.log("[SplitDialog] Split failed:", result.error || "unknown");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Controls.impl
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ISC
|
||||||
|
|
||||||
|
// ===== Control Bar =====
|
||||||
|
// Mode-aware footer: cross-fades between Live and Review layouts.
|
||||||
|
// Both variants render as a single centered pill — no full-width bar.
|
||||||
|
Item {
|
||||||
|
id: bar
|
||||||
|
Layout.fillWidth: true
|
||||||
|
implicitHeight: hasContent ? 80 : 0
|
||||||
|
Behavior on implicitHeight {
|
||||||
|
NumberAnimation { duration: Theme.durationBase; easing.type: Theme.easeStandard }
|
||||||
|
}
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
// Hide entirely when in review mode with no file loaded
|
||||||
|
readonly property bool hasContent: {
|
||||||
|
if (streamController.mode === "live") return true;
|
||||||
|
return streamController.loadedFile !== "";
|
||||||
|
}
|
||||||
|
readonly property bool isLive: streamController.mode === "live"
|
||||||
|
|
||||||
|
// ── LIVE MODE ─────────────────────────────────────────────────
|
||||||
|
Item {
|
||||||
|
id: liveContent
|
||||||
|
anchors.fill: parent
|
||||||
|
opacity: bar.isLive ? 1.0 : 0.0
|
||||||
|
visible: opacity > 0
|
||||||
|
Behavior on opacity {
|
||||||
|
NumberAnimation { duration: Theme.durationBase; easing.type: Theme.easeStandard }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single centered pill for Received + Errors + Stop
|
||||||
|
Rectangle {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
implicitWidth: liveRow.implicitWidth + 48
|
||||||
|
implicitHeight: 68
|
||||||
|
radius: Theme.radiusMd
|
||||||
|
color: Theme.sidePanelBackground
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: liveRow
|
||||||
|
anchors.centerIn: parent
|
||||||
|
spacing: 24
|
||||||
|
|
||||||
|
// Received counter
|
||||||
|
Text {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "Received: " + streamController.receivedCount
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.weight: Font.Medium
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separator
|
||||||
|
Rectangle {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: 1
|
||||||
|
height: 36
|
||||||
|
color: Theme.sideBorder
|
||||||
|
}
|
||||||
|
|
||||||
|
// Errors counter
|
||||||
|
Text {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "Errors: " + streamController.errorCount
|
||||||
|
color: streamController.errorCount > 0
|
||||||
|
? Theme.statusWarningColor : Theme.sideMutedText
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.weight: Font.Medium
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separator
|
||||||
|
Rectangle {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: 1
|
||||||
|
height: 36
|
||||||
|
color: Theme.sideBorder
|
||||||
|
}
|
||||||
|
|
||||||
|
// STOP button
|
||||||
|
Button {
|
||||||
|
id: stopBtn
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
implicitWidth: 112
|
||||||
|
implicitHeight: 48
|
||||||
|
hoverEnabled: true
|
||||||
|
text: "STOP"
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.weight: Font.Medium
|
||||||
|
font.letterSpacing: 1.0
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: 8
|
||||||
|
color: stopBtn.pressed ? Theme.transportButtonHover
|
||||||
|
: Theme.transportButtonBg
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
border.width: 1
|
||||||
|
Behavior on color {
|
||||||
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
contentItem: Text {
|
||||||
|
text: parent.text
|
||||||
|
color: Theme.headingColor
|
||||||
|
font: parent.font
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
onClicked: streamController.stopStream()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── REVIEW MODE ──────────────────────────────────────────────
|
||||||
|
Item {
|
||||||
|
id: reviewContent
|
||||||
|
anchors.fill: parent
|
||||||
|
opacity: !bar.isLive ? 1.0 : 0.0
|
||||||
|
visible: opacity > 0
|
||||||
|
Behavior on opacity {
|
||||||
|
NumberAnimation { duration: Theme.durationBase; easing.type: Theme.easeStandard }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single centered pill for frame counter + transport + speed
|
||||||
|
Rectangle {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
implicitWidth: reviewRow.implicitWidth + 48
|
||||||
|
implicitHeight: 68
|
||||||
|
radius: Theme.radiusMd
|
||||||
|
color: Theme.sidePanelBackground
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: reviewRow
|
||||||
|
anchors.centerIn: parent
|
||||||
|
spacing: 24
|
||||||
|
|
||||||
|
// Frame counter
|
||||||
|
Text {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "Frame " + (streamController.frameIndex + 1)
|
||||||
|
+ " / " + streamController.frameTotal
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.weight: Font.Medium
|
||||||
|
leftPadding: 8
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separator 1
|
||||||
|
Rectangle {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: 1
|
||||||
|
height: 36
|
||||||
|
color: Theme.sideBorder
|
||||||
|
}
|
||||||
|
|
||||||
|
// Media Buttons Row
|
||||||
|
Row {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 12
|
||||||
|
|
||||||
|
// Skip-start
|
||||||
|
Button {
|
||||||
|
id: skipStartBtn
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
implicitWidth: 56
|
||||||
|
implicitHeight: 56
|
||||||
|
hoverEnabled: true
|
||||||
|
background: Rectangle {
|
||||||
|
radius: 8
|
||||||
|
color: skipStartBtn.pressed ? Theme.transportButtonHover
|
||||||
|
: skipStartBtn.hovered ? Theme.transportButtonBg
|
||||||
|
: "transparent"
|
||||||
|
Behavior on color {
|
||||||
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
contentItem: IconImage {
|
||||||
|
source: "../icons/prev.svg"
|
||||||
|
width: 22; height: 22
|
||||||
|
sourceSize.width: 22
|
||||||
|
sourceSize.height: 22
|
||||||
|
color: Theme.headingColor
|
||||||
|
anchors.centerIn: parent
|
||||||
|
}
|
||||||
|
onClicked: streamController.step(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop
|
||||||
|
Button {
|
||||||
|
id: reviewStopBtn
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
implicitWidth: 56
|
||||||
|
implicitHeight: 56
|
||||||
|
hoverEnabled: true
|
||||||
|
background: Rectangle {
|
||||||
|
radius: 8
|
||||||
|
color: reviewStopBtn.pressed ? Theme.transportButtonHover
|
||||||
|
: reviewStopBtn.hovered ? Theme.transportButtonBg
|
||||||
|
: "transparent"
|
||||||
|
Behavior on color {
|
||||||
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
contentItem: IconImage {
|
||||||
|
source: "../icons/stop.svg"
|
||||||
|
width: 20; height: 20
|
||||||
|
sourceSize.width: 20
|
||||||
|
sourceSize.height: 20
|
||||||
|
color: Theme.headingColor
|
||||||
|
anchors.centerIn: parent
|
||||||
|
}
|
||||||
|
onClicked: streamController.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Play / Pause toggle
|
||||||
|
Button {
|
||||||
|
id: playPauseBtn
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
implicitWidth: 64
|
||||||
|
implicitHeight: 56
|
||||||
|
hoverEnabled: true
|
||||||
|
background: Rectangle {
|
||||||
|
radius: 8
|
||||||
|
color: playPauseBtn.pressed ? Theme.transportButtonHover
|
||||||
|
: playPauseBtn.hovered ? Theme.transportButtonBg
|
||||||
|
: "transparent"
|
||||||
|
Behavior on color {
|
||||||
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
contentItem: IconImage {
|
||||||
|
source: streamController.playing ? "../icons/pause.svg" : "../icons/play.svg"
|
||||||
|
width: 22; height: 22
|
||||||
|
sourceSize.width: 22
|
||||||
|
sourceSize.height: 22
|
||||||
|
color: Theme.headingColor
|
||||||
|
anchors.centerIn: parent
|
||||||
|
}
|
||||||
|
onClicked: {
|
||||||
|
if (streamController.playing) {
|
||||||
|
streamController.pause();
|
||||||
|
} else {
|
||||||
|
streamController.play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip-end
|
||||||
|
Button {
|
||||||
|
id: skipEndBtn
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
implicitWidth: 56
|
||||||
|
implicitHeight: 56
|
||||||
|
hoverEnabled: true
|
||||||
|
background: Rectangle {
|
||||||
|
radius: 8
|
||||||
|
color: skipEndBtn.pressed ? Theme.transportButtonHover
|
||||||
|
: skipEndBtn.hovered ? Theme.transportButtonBg
|
||||||
|
: "transparent"
|
||||||
|
Behavior on color {
|
||||||
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
contentItem: IconImage {
|
||||||
|
source: "../icons/next.svg"
|
||||||
|
width: 22; height: 22
|
||||||
|
sourceSize.width: 22
|
||||||
|
sourceSize.height: 22
|
||||||
|
color: Theme.headingColor
|
||||||
|
anchors.centerIn: parent
|
||||||
|
}
|
||||||
|
onClicked: streamController.step(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separator 2
|
||||||
|
Rectangle {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: 1
|
||||||
|
height: 36
|
||||||
|
color: Theme.sideBorder
|
||||||
|
}
|
||||||
|
|
||||||
|
// Speed capsule button
|
||||||
|
Button {
|
||||||
|
id: speedBtn
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
property var speeds: [1, 2, 4]
|
||||||
|
property int idx: 0
|
||||||
|
implicitWidth: 72
|
||||||
|
implicitHeight: 48
|
||||||
|
hoverEnabled: true
|
||||||
|
text: speeds[idx] + "x"
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.weight: Font.Medium
|
||||||
|
background: Rectangle {
|
||||||
|
radius: 8
|
||||||
|
color: speedBtn.pressed ? Theme.transportButtonHover
|
||||||
|
: Theme.transportButtonBg
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
border.width: 1
|
||||||
|
Behavior on color {
|
||||||
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
contentItem: Text {
|
||||||
|
text: parent.text
|
||||||
|
color: Theme.headingColor
|
||||||
|
font: parent.font
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
onClicked: {
|
||||||
|
idx = (idx + 1) % speeds.length;
|
||||||
|
streamController.setSpeed(speeds[idx]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import ISC
|
||||||
|
import ISC.Wafer
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
property real blend: 0.0
|
||||||
|
property bool showLabels: true
|
||||||
|
property alias showThickness: map.showThickness
|
||||||
|
|
||||||
|
WaferMapItem {
|
||||||
|
id: map
|
||||||
|
anchors.fill: parent
|
||||||
|
sensors: streamController.sensorLayout // [{label,x,y}]
|
||||||
|
values: streamController.sensorValues // [float]
|
||||||
|
bands: streamController.sensorBands // ["in_range"|"high"|"low"]
|
||||||
|
shape: streamController.waferShape
|
||||||
|
size: streamController.waferSize
|
||||||
|
target: streamController.target
|
||||||
|
margin: streamController.margin
|
||||||
|
blend: root.blend
|
||||||
|
showLabels: root.showLabels
|
||||||
|
// Bind to Theme so colors update on dark/light mode switch
|
||||||
|
ringColor: Theme.waferRingColor
|
||||||
|
axisColor: Theme.waferAxisColor
|
||||||
|
lowColor: Theme.sensorLow
|
||||||
|
inRangeColor: Theme.sensorInRange
|
||||||
|
highColor: Theme.sensorHigh
|
||||||
|
textColor: Theme.headingColor
|
||||||
|
|
||||||
|
// Grows the hovered marker smoothly; hoverScale setter triggers a repaint.
|
||||||
|
hoverScale: 1.0 + 0.35 * hoverPulse
|
||||||
|
property real hoverPulse: map.hoveredIndex >= 0 ? 1.0 : 0.0
|
||||||
|
Behavior on hoverPulse {
|
||||||
|
NumberAnimation { duration: 150; easing.type: Easing.OutQuad }
|
||||||
|
}
|
||||||
|
|
||||||
|
HoverHandler {
|
||||||
|
id: hoverHandler
|
||||||
|
onPointChanged: {
|
||||||
|
map.hoveredIndex = map.which_marker(hoverHandler.point.position.x, hoverHandler.point.position.y);
|
||||||
|
}
|
||||||
|
onHoveredChanged: if (!hoverHandler.hovered) map.hoveredIndex = -1
|
||||||
|
cursorShape: map.hoveredIndex >= 0 ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||||
|
}
|
||||||
|
|
||||||
|
TapHandler {
|
||||||
|
onTapped: ev => {
|
||||||
|
var idx = map.which_marker(ev.position.x, ev.position.y);
|
||||||
|
if (idx >= 0)
|
||||||
|
replaceDialog.openFor(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ReplaceSensorDialog {
|
||||||
|
id: replaceDialog
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportImage(filePath) {
|
||||||
|
return map.export_image(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# ===== ISC Tabs Components Module =====
|
||||||
|
module ISC.Tabs.components
|
||||||
|
SourcePanel 1.0 SourcePanel.qml
|
||||||
|
ReadoutPanel 1.0 ReadoutPanel.qml
|
||||||
|
TransportBar 1.0 TransportBar.qml
|
||||||
|
WaferMapView 1.0 WaferMapView.qml
|
||||||
|
ReplaceSensorDialog 1.0 ReplaceSensorDialog.qml
|
||||||
|
RailActionButton 1.0 RailActionButton.qml
|
||||||
|
SplitDialog 1.0 SplitDialog.qml
|
||||||
|
StreamStatsDialog 1.0 StreamStatsDialog.qml
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="M12 16v-4" />
|
||||||
|
<path d="M12 8h.01" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 294 B |
@@ -0,0 +1,14 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="m12 19-7-7 7-7" />
|
||||||
|
<path d="M19 12H5" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 262 B |
@@ -0,0 +1,15 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<line x1="18" x2="18" y1="20" y2="10" />
|
||||||
|
<line x1="12" x2="12" y1="20" y2="4" />
|
||||||
|
<line x1="6" x2="6" y1="20" y2="14" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 334 B |
@@ -0,0 +1,13 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M20 6 9 17l-5-5" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 239 B |
@@ -0,0 +1,13 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="m6 9 6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 236 B |
@@ -0,0 +1,16 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M8 3 4 7l4 4" />
|
||||||
|
<path d="M4 7h16" />
|
||||||
|
<path d="m16 21 4-4-4-4" />
|
||||||
|
<path d="M20 17H4" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 313 B |
@@ -0,0 +1,15 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<ellipse cx="12" cy="5" rx="9" ry="3" />
|
||||||
|
<path d="M3 5V19A9 3 0 0 0 21 19V5" />
|
||||||
|
<path d="M3 12A9 3 0 0 0 21 12" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 329 B |
@@ -0,0 +1,14 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="m21 21-4.34-4.34" />
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 275 B |
@@ -0,0 +1,14 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
|
||||||
|
<path d="m15 5 4 4" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 297 B |
@@ -0,0 +1,17 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M10 11v6" />
|
||||||
|
<path d="M14 11v6" />
|
||||||
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
|
||||||
|
<path d="M3 6h18" />
|
||||||
|
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 389 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g transform="translate(-1.5, 0)">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.29289 1.29289C9.48043 1.10536 9.73478 1 10 1H18C19.6569 1 21 2.34315 21 4V9C21 9.55228 20.5523 10 20 10C19.4477 10 19 9.55228 19 9V4C19 3.44772 18.5523 3 18 3H11V8C11 8.55228 10.5523 9 10 9H5V20C5 20.5523 5.44772 21 6 21H7C7.55228 21 8 21.4477 8 22C8 22.5523 7.55228 23 7 23H6C4.34315 23 3 21.6569 3 20V8C3 7.73478 3.10536 7.48043 3.29289 7.29289L9.29289 1.29289ZM6.41421 7H9V4.41421L6.41421 7ZM19 12C19.5523 12 20 12.4477 20 13V19H23C23.5523 19 24 19.4477 24 20C24 20.5523 23.5523 21 23 21H19C18.4477 21 18 20.5523 18 20V13C18 12.4477 18.4477 12 19 12ZM11.8137 12.4188C11.4927 11.9693 10.8682 11.8653 10.4188 12.1863C9.96935 12.5073 9.86526 13.1318 10.1863 13.5812L12.2711 16.5L10.1863 19.4188C9.86526 19.8682 9.96935 20.4927 10.4188 20.8137C10.8682 21.1347 11.4927 21.0307 11.8137 20.5812L13.5 18.2205L15.1863 20.5812C15.5073 21.0307 16.1318 21.1347 16.5812 20.8137C17.0307 20.4927 17.1347 19.8682 16.8137 19.4188L14.7289 16.5L16.8137 13.5812C17.1347 13.1318 17.0307 12.5073 16.5812 12.1863C16.1318 11.8653 15.5073 11.9693 15.1863 12.4188L13.5 14.7795L11.8137 12.4188Z" fill="currentColor"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,17 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
||||||
|
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||||
|
<path d="M10 9H8" />
|
||||||
|
<path d="M16 13H8" />
|
||||||
|
<path d="M16 17H8" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 392 B |
@@ -0,0 +1,13 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 342 B |
@@ -0,0 +1,17 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||||
|
<path d="M3 9h18" />
|
||||||
|
<path d="M3 15h18" />
|
||||||
|
<path d="M9 3v18" />
|
||||||
|
<path d="M15 3v18" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 355 B |
@@ -0,0 +1,13 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="m9 18 6-6-6-6" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 237 B |
@@ -0,0 +1,14 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<rect x="14" y="3" width="5" height="18" rx="1" />
|
||||||
|
<rect x="6" y="3" width="5" height="18" rx="1" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 313 B |
@@ -0,0 +1,13 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<polygon points="6 3 20 12 6 21 6 3" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 250 B |
@@ -0,0 +1,13 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="m15 18-6-6 6-6" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 238 B |
@@ -0,0 +1,14 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 360 B |
@@ -0,0 +1,16 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
|
||||||
|
<path d="M21 3v5h-5" />
|
||||||
|
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
|
||||||
|
<path d="M3 21v-5h5" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 393 B |
@@ -0,0 +1,14 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||||
|
<path d="M3 3v5h5" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 297 B |
@@ -0,0 +1,14 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 586 B |
@@ -0,0 +1,14 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||||
|
<path d="M12 3v18" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 285 B |
@@ -0,0 +1,14 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="M12 6v6l4 2" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 271 B |
@@ -0,0 +1,13 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 261 B |
@@ -0,0 +1,15 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="m21.73 18-8-14a2 2 0 0 0-3.46 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
|
||||||
|
<path d="M12 9v4" />
|
||||||
|
<path d="M12 17h.01" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 346 B |
@@ -0,0 +1,14 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M18 6 6 18" />
|
||||||
|
<path d="m6 6 12 12" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 260 B |
@@ -0,0 +1,8 @@
|
|||||||
|
# ===== ISC Tabs Module =====
|
||||||
|
module ISC.Tabs
|
||||||
|
|
||||||
|
WaferMapTab 1.0 WaferMapTab.qml
|
||||||
|
DataTab 1.0 DataTab.qml
|
||||||
|
SettingsTab 1.0 SettingsTab.qml
|
||||||
|
StatusTab 1.0 StatusTab.qml
|
||||||
|
SelectFileDialog 1.0 SelectFileDialog.qml
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Theme — single source of truth for colors and sizing
|
||||||
|
// =============================================================================
|
||||||
|
// Sections:
|
||||||
|
// 1. Mode flag
|
||||||
|
// 2. Typography (families, pixel sizes, weights)
|
||||||
|
// 3. Motion
|
||||||
|
// 4. Tone palette (raw tones; everything else derives from these)
|
||||||
|
// 5. Surfaces (page / card / panel / workspace backgrounds)
|
||||||
|
// 6. Borders
|
||||||
|
// 7. Text (headings, body, panel titles)
|
||||||
|
// 8. Controls (fields, buttons, checkbox, toggle)
|
||||||
|
// 9. Interaction overlays (hover / active washes — mode-aware)
|
||||||
|
// 10. Tracks (pill toggle + scrollbar)
|
||||||
|
// 11. Tabs (mode toggle pills)
|
||||||
|
// 12. Side rail
|
||||||
|
// 13. Transport / toolbar
|
||||||
|
// 14. Status & badges (success / warning / error, badge text)
|
||||||
|
// 15. Sensor bands (wafer map dots)
|
||||||
|
// 16. Wafer family badges (source list avatars + accent bars)
|
||||||
|
// 17. Comparison & charts (metric grading, diff accent, canvas grid)
|
||||||
|
// 18. Geometry (radius / border width / spacing / sizing)
|
||||||
|
// =============================================================================
|
||||||
|
// Based on Codex themes:
|
||||||
|
// Dark (oscurange): surface #0B0B0F, ink #E6E6E6
|
||||||
|
// Light (absolutely): surface #F9F9F7, ink #2D2D2B
|
||||||
|
// Accent (both): #F9B98C
|
||||||
|
|
||||||
|
QtObject {
|
||||||
|
// ── 1. Mode ──────────────────────────────────────────────────────────────
|
||||||
|
property bool isDarkMode: true
|
||||||
|
|
||||||
|
// ── 2. Typography ────────────────────────────────────────────────────────
|
||||||
|
readonly property string uiFontFamily: "Geist, Inter, system-ui, -apple-system, BlinkMacSystemFont, sans-serif"
|
||||||
|
readonly property string codeFontFamily: "\"Geist Mono\", ui-monospace, \"SFMono-Regular\", monospace"
|
||||||
|
|
||||||
|
// pixelSize scale — map to closest token, tune globally
|
||||||
|
readonly property int font2xs: 9 // tiny metadata
|
||||||
|
readonly property int fontXs: 11 // captions, micro labels
|
||||||
|
readonly property int fontSm: 13 // sidebar labels, secondary
|
||||||
|
readonly property int fontMd: 14 // body, field values
|
||||||
|
readonly property int fontLg: 16 // section headers
|
||||||
|
readonly property int fontXl: 20 // panel / dialog titles
|
||||||
|
readonly property int font2xl: 24 // hero titles
|
||||||
|
readonly property int font3xl: 30 // display
|
||||||
|
|
||||||
|
readonly property int fontWeightRegular: 400
|
||||||
|
readonly property int fontWeightMedium: 500
|
||||||
|
readonly property int fontWeightBold: 700
|
||||||
|
|
||||||
|
// ── 3. Motion ────────────────────────────────────────────────────────────
|
||||||
|
readonly property int durationFast: 120 // micro-interactions (focus ring, hover flash)
|
||||||
|
readonly property int durationBase: 180 // standard transitions (pill switch, row select)
|
||||||
|
readonly property int durationSlow: 260 // larger transitions (panel open/close)
|
||||||
|
readonly property var easeStandard: Easing.OutCubic
|
||||||
|
|
||||||
|
// ── 4. Tone palette (Codex theme v1 inspired) ────────────────────────────
|
||||||
|
readonly property color themeSurface: isDarkMode ? "#0B0B0F" : "#F9F9F7"
|
||||||
|
readonly property color themeInk: isDarkMode ? "#E6E6E6" : "#2D2D2B"
|
||||||
|
readonly property color themeAccent: isDarkMode ? "#F9B98C" : "#B87333"
|
||||||
|
readonly property color themeSkill: isDarkMode ? "#479FFA" : "#3B82F6" // blue family both modes
|
||||||
|
readonly property color themeAdded: isDarkMode ? "#40C977" : "#00C853"
|
||||||
|
readonly property color themeRemoved: isDarkMode ? "#FA423E" : "#FF5F38"
|
||||||
|
readonly property color themeViolet: isDarkMode ? "#A78BFA" : "#8B5CF6"
|
||||||
|
|
||||||
|
readonly property color tone100: themeSurface
|
||||||
|
readonly property color tone150: isDarkMode ? "#101014" : "#F3F3F1"
|
||||||
|
readonly property color tone200: isDarkMode ? "#151518" : "#EEEEEC"
|
||||||
|
readonly property color tone250: isDarkMode ? "#1B1B1F" : "#E8E8E6"
|
||||||
|
readonly property color tone300: isDarkMode ? "#222228" : "#E0E0DE"
|
||||||
|
readonly property color tone350: isDarkMode ? "#2B2B32" : "#D7D7D4"
|
||||||
|
readonly property color toneText: themeInk
|
||||||
|
readonly property color toneMute: isDarkMode ? "#A8A8A1" : "#85857E"
|
||||||
|
readonly property color toneBorder: isDarkMode ? "#2D2D34" : "#D2D2CE"
|
||||||
|
|
||||||
|
// ── 5. Surfaces ──────────────────────────────────────────────────────────
|
||||||
|
readonly property color pageBackground: tone100
|
||||||
|
readonly property color cardBackground: tone150
|
||||||
|
readonly property color panelBackground: tone150
|
||||||
|
readonly property color workspaceBackground: tone150
|
||||||
|
readonly property color responseBackground: tone150
|
||||||
|
readonly property color subtleSectionBackground: tone250
|
||||||
|
|
||||||
|
// ── 6. Borders ───────────────────────────────────────────────────────────
|
||||||
|
readonly property color cardBorder: toneBorder
|
||||||
|
readonly property color cardSurfaceBorder: isDarkMode ? "#3A3A42" : "#C7C7C2"
|
||||||
|
readonly property color responseBorder: toneBorder
|
||||||
|
readonly property color workspaceBorder: toneBorder
|
||||||
|
readonly property color innerFrameBorder: toneBorder
|
||||||
|
readonly property color outerFrameBorder: isDarkMode ? "#3A3A42" : "#C7C7C2"
|
||||||
|
readonly property color softBorder: isDarkMode ? "#24242A" : "#E4E4E1"
|
||||||
|
|
||||||
|
// ── 7. Text ──────────────────────────────────────────────────────────────
|
||||||
|
readonly property color headingColor: toneText
|
||||||
|
readonly property color bodyColor: toneMute
|
||||||
|
readonly property color subheadingColor: toneMute
|
||||||
|
readonly property color panelTitleText: toneMute
|
||||||
|
readonly property color disabledText: toneMute
|
||||||
|
|
||||||
|
// ── 8. Controls ──────────────────────────────────────────────────────────
|
||||||
|
// Fields
|
||||||
|
readonly property color fieldBackground: isDarkMode ? "#101014" : "#FFFFFF"
|
||||||
|
readonly property color fieldText: toneText
|
||||||
|
readonly property color fieldPlaceholder: toneMute
|
||||||
|
readonly property color fieldBorder: toneBorder
|
||||||
|
readonly property color fieldBorderFocus: themeAccent
|
||||||
|
// Neutral buttons
|
||||||
|
readonly property color buttonNeutralBackground: tone250
|
||||||
|
readonly property color buttonNeutralHover: tone300
|
||||||
|
readonly property color buttonNeutralPressed: isDarkMode ? "#0F0F13" : "#FFFFFF"
|
||||||
|
readonly property color buttonNeutralText: toneText
|
||||||
|
readonly property color buttonPressed: tone300
|
||||||
|
readonly property color primaryAccent: themeAccent
|
||||||
|
// Checkbox
|
||||||
|
readonly property color checkboxText: toneText
|
||||||
|
// Toggle switch thumb (white knob reads on both track colors)
|
||||||
|
readonly property color toggleThumb: "#FFFFFF"
|
||||||
|
|
||||||
|
// ── 9. Interaction overlays ──────────────────────────────────────────────
|
||||||
|
// Mode-aware washes. Never use raw Qt.rgba(1,1,1,x) in views: a white
|
||||||
|
// overlay is invisible on light-mode surfaces.
|
||||||
|
readonly property color listHoverBackground: isDarkMode ? Qt.rgba(1, 1, 1, 0.04) : Qt.rgba(0, 0, 0, 0.03)
|
||||||
|
readonly property color listActiveBackground: isDarkMode ? Qt.rgba(1, 1, 1, 0.06) : Qt.rgba(0, 0, 0, 0.05)
|
||||||
|
readonly property color flatButtonHover: isDarkMode ? Qt.rgba(1, 1, 1, 0.08) : Qt.rgba(0, 0, 0, 0.06)
|
||||||
|
readonly property color cardWash: isDarkMode ? Qt.rgba(1, 1, 1, 0.02) : Qt.rgba(0, 0, 0, 0.02)
|
||||||
|
|
||||||
|
// ── 10. Tracks (pill toggle, scrollbar thumb) ────────────────────────────
|
||||||
|
readonly property color trackBackground: isDarkMode ? "#25252B" : "#E2E2DE"
|
||||||
|
|
||||||
|
// ── 11. Tabs (mode toggle pills) ─────────────────────────────────────────
|
||||||
|
readonly property color tabActiveBackground: isDarkMode ? "#25252B" : "#FFFFFF"
|
||||||
|
|
||||||
|
// ── 12. Side rail ────────────────────────────────────────────────────────
|
||||||
|
readonly property color sideRailBackground: tone150
|
||||||
|
readonly property color sidePanelBackground: isDarkMode ? "#151518" : "#F3F3F1"
|
||||||
|
readonly property color sideActiveBackground: isDarkMode ? "#25252B" : "#FFFFFF"
|
||||||
|
readonly property color sideBorder: toneBorder
|
||||||
|
readonly property color sideMutedText: toneMute
|
||||||
|
|
||||||
|
// ── 13. Transport / toolbar surfaces ─────────────────────────────────────
|
||||||
|
readonly property color transportButtonBg: tone250
|
||||||
|
readonly property color transportButtonHover: tone300
|
||||||
|
readonly property color liveColor: themeAdded
|
||||||
|
readonly property color recordColor: themeRemoved
|
||||||
|
|
||||||
|
// ── 14. Status & badges ──────────────────────────────────────────────────
|
||||||
|
readonly property color statusSuccessColor: themeAdded
|
||||||
|
readonly property color statusWarningColor: isDarkMode ? "#E8A817" : "#B8860B" // amber/yellow, separate from accent
|
||||||
|
readonly property color statusErrorColor: themeRemoved
|
||||||
|
// Text placed ON a filled status pill / saturated chip:
|
||||||
|
readonly property color statusBadgeText: "#111111" // dark ink on bright status fills, both modes
|
||||||
|
readonly property color textOnColor: "#FFFFFF" // white text on deep saturated fills (avatars etc.)
|
||||||
|
// Error banner (soft red surface + readable red text)
|
||||||
|
readonly property color errorSurface: Qt.alpha(statusErrorColor, 0.15)
|
||||||
|
readonly property color errorTextSoft: isDarkMode ? "#FCA5A5" : "#B91C1C"
|
||||||
|
|
||||||
|
// ── 15. Sensor bands (wafer map dots) ────────────────────────────────────
|
||||||
|
readonly property color sensorInRange: statusSuccessColor
|
||||||
|
// ponytail: high-temp shares error red — semantically correct (dangerous value),
|
||||||
|
// re-hue if colorblind testing demands a different channel.
|
||||||
|
readonly property color sensorHigh: statusErrorColor
|
||||||
|
readonly property color sensorLow: themeSkill
|
||||||
|
readonly property color waferRingColor: toneBorder
|
||||||
|
readonly property color waferAxisColor: softBorder
|
||||||
|
|
||||||
|
// ── 16. Wafer family badges (SourcePanel list) ───────────────────────────
|
||||||
|
// Accent = left bar / outline shade; Fill = avatar circle behind textOnColor.
|
||||||
|
// Family grouping mirrors FileBrowser's waferType first-letter convention.
|
||||||
|
readonly property color familyBlueAccent: "#3B82F6" // A / E / P
|
||||||
|
readonly property color familyBlueFill: "#1D4ED8"
|
||||||
|
readonly property color familyGreenAccent: "#10B981" // B / C / D
|
||||||
|
readonly property color familyGreenFill: "#065F46"
|
||||||
|
readonly property color familyVioletAccent: "#8B5CF6" // Z
|
||||||
|
readonly property color familyVioletFill: "#7C3AED"
|
||||||
|
readonly property color familyNeutralFill: "#374151" // unknown family
|
||||||
|
|
||||||
|
// ── 17. Comparison & charts ──────────────────────────────────────────────
|
||||||
|
// Metric grading for DTW readout cards (good → caution → bad).
|
||||||
|
// Dark values are pastel (readable on tone150); light values are deepened
|
||||||
|
// so they still pass on near-white cards.
|
||||||
|
readonly property color metricGood: isDarkMode ? "#6EE7B7" : "#059669"
|
||||||
|
readonly property color metricWarn: isDarkMode ? "#FDE047" : "#B45309"
|
||||||
|
readonly property color metricBad: isDarkMode ? "#EF4444" : "#DC2626"
|
||||||
|
// DIFF column accent in ReadoutPanel
|
||||||
|
readonly property color diffAccent: themeViolet
|
||||||
|
// Canvas chart primitives (DTW plot grid + axis labels)
|
||||||
|
readonly property color chartGridLine: isDarkMode ? Qt.rgba(1, 1, 1, 0.08) : Qt.rgba(0, 0, 0, 0.10)
|
||||||
|
readonly property color chartAxisText: isDarkMode ? Qt.rgba(1, 1, 1, 0.35) : Qt.rgba(0, 0, 0, 0.45)
|
||||||
|
|
||||||
|
// ── 18. Geometry ─────────────────────────────────────────────────────────
|
||||||
|
// Radius
|
||||||
|
readonly property int radiusXs: 6 // fields, tight elements
|
||||||
|
readonly property int radiusSm: 8 // buttons
|
||||||
|
readonly property int radiusMd: 12 // cards, group boxes
|
||||||
|
readonly property int radiusLg: 18 // large panels / dialogs
|
||||||
|
// Border width
|
||||||
|
readonly property int borderThin: 1
|
||||||
|
readonly property int borderStrong: 2
|
||||||
|
// Main panel layout
|
||||||
|
readonly property int mainAreaPadding: 10
|
||||||
|
readonly property int rightPaneGap: 6
|
||||||
|
readonly property int panelPadding: 12
|
||||||
|
// Side rail layout
|
||||||
|
readonly property int sideRailWidth: 280
|
||||||
|
readonly property int sideRailMargin: 16
|
||||||
|
readonly property int sideRailSpacing: 16
|
||||||
|
readonly property int sideButtonHeight: 44
|
||||||
|
readonly property int sidePanelRadius: 10
|
||||||
|
readonly property int sideFooterConnection: 100
|
||||||
|
readonly property int sideFooterUtility: 46
|
||||||
|
readonly property int sidePillHeight: 54
|
||||||
|
// Settings page layout
|
||||||
|
readonly property int settingsPanelMaxWidth: 760
|
||||||
|
readonly property int settingsOuterMargin: 40
|
||||||
|
readonly property int settingsSectionSpacing: 20
|
||||||
|
readonly property int settingsRowSpacing: 16
|
||||||
|
readonly property int settingsGridSpacing: 12
|
||||||
|
readonly property int settingsButtonHeight: 40
|
||||||
|
readonly property int settingsActionWidth: 200
|
||||||
|
readonly property int settingsFieldMinWidth: 160
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ module ISC
|
|||||||
# ===== Root Components =====
|
# ===== Root Components =====
|
||||||
Main 1.0 Main.qml
|
Main 1.0 Main.qml
|
||||||
HomePage 1.0 HomePage.qml
|
HomePage 1.0 HomePage.qml
|
||||||
|
AboutDialog 1.0 AboutDialog.qml
|
||||||
|
|
||||||
# ===== Singleton Theme =====
|
# ===== Singleton Theme =====
|
||||||
singleton Theme 1.0 Theme.qml
|
singleton Theme 1.0 Theme.qml
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PySide6.QtQml import QQmlApplicationEngine
|
||||||
|
from PySide6.QtQuickControls2 import QQuickStyle
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from pygui.backend.controllers.device_controller import DeviceController
|
||||||
|
from pygui.backend.controllers.session_controller import SessionController
|
||||||
|
from pygui.backend.data.file_browser import FileBrowser
|
||||||
|
from pygui.backend.data.local_settings import LocalSettings
|
||||||
|
from pygui.backend.data.local_settings_model import LocalSettingsModel
|
||||||
|
|
||||||
|
# Importing wafer_map_item + trend_chart_item registers @QmlElement types (QML: import ISC.Wafer).
|
||||||
|
|
||||||
|
|
||||||
|
# ===== Application Entry Point =====
|
||||||
|
def main() -> int:
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
||||||
|
)
|
||||||
|
# ===== UI Style Setup =====
|
||||||
|
# Use a non-native controls style so our custom QML button backgrounds are supported.
|
||||||
|
QQuickStyle.setStyle("Basic")
|
||||||
|
|
||||||
|
# ===== Qt Bootstrap =====
|
||||||
|
# Minimal Qt bootstrap: load the ISC QML module and exit if it fails.
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
engine = QQmlApplicationEngine()
|
||||||
|
|
||||||
|
# ===== Shared Models =====
|
||||||
|
settings_model = LocalSettingsModel()
|
||||||
|
select_file_dialog_model = FileBrowser()
|
||||||
|
settings_model.loadSettings()
|
||||||
|
engine.rootContext().setContextProperty("settingsModel", settings_model)
|
||||||
|
engine.rootContext().setContextProperty("file_browser", select_file_dialog_model)
|
||||||
|
|
||||||
|
# ===== Device Controller (serial comm) =====
|
||||||
|
data_dir = str(settings_model._data_dir)
|
||||||
|
raw_settings = LocalSettings.read_settings(data_dir)
|
||||||
|
device_controller = DeviceController(raw_settings, data_dir)
|
||||||
|
engine.rootContext().setContextProperty("deviceController", device_controller)
|
||||||
|
|
||||||
|
# ===== Session Controller (live/review wafer dashboard) =====
|
||||||
|
raw_settings_dict = raw_settings.__dict__.copy() if hasattr(raw_settings, '__dict__') else {}
|
||||||
|
stream_controller = SessionController(settings=raw_settings_dict)
|
||||||
|
engine.rootContext().setContextProperty("streamController", stream_controller)
|
||||||
|
|
||||||
|
# Persist session state back to settings when it changes
|
||||||
|
def _persist_session_settings():
|
||||||
|
session_state = stream_controller.collect_settings()
|
||||||
|
for k, v in session_state.items():
|
||||||
|
if hasattr(raw_settings, k):
|
||||||
|
setattr(raw_settings, k, v)
|
||||||
|
LocalSettings.save_settings(data_dir, raw_settings)
|
||||||
|
|
||||||
|
stream_controller.settingsChanged.connect(_persist_session_settings)
|
||||||
|
|
||||||
|
# ===== QML Startup =====
|
||||||
|
# The "ISC" QML module lives alongside this file (src/pygui/ISC), so the
|
||||||
|
# package directory is the import path the engine searches for qmldir.
|
||||||
|
engine.addImportPath(Path(__file__).parent)
|
||||||
|
engine.loadFromModule("ISC", "Main")
|
||||||
|
|
||||||
|
# ===== Exit Handling =====
|
||||||
|
if not engine.rootObjects():
|
||||||
|
return -1
|
||||||
|
|
||||||
|
return app.exec()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# This will be the name of the output files
|
||||||
|
name: aepwafer
|
||||||
|
wafers: ["A", "E", "P"]
|
||||||
|
|
||||||
|
# round or square wafer
|
||||||
|
shape: round
|
||||||
|
|
||||||
|
# diameter/edge size of the wafer
|
||||||
|
size: 300
|
||||||
|
|
||||||
|
# large or small markers and labels
|
||||||
|
marker_size: large
|
||||||
|
font_size: large
|
||||||
|
|
||||||
|
# origin settings. x_origin can be left, right, or center. y_origin can
|
||||||
|
# be top, bottom, or center. Using right or top implies that the X/Y values
|
||||||
|
# for that axis are negative, or else we'll be drawing outside the image.
|
||||||
|
x_origin: left
|
||||||
|
y_origin: bottom
|
||||||
|
|
||||||
|
# sensor X and Y values, in mm relative to the origin
|
||||||
|
X: [150, 204, 249, 280, 290, 280, 249, 204, 150, 97, 51, 21, 10, 21, 51, 97,
|
||||||
|
150, 203, 241, 255, 241, 203, 150, 98, 59, 45, 59, 98, 171, 207, 228, 228,
|
||||||
|
207, 171, 130, 94, 73, 73, 94, 130, 150, 186, 200, 186, 150, 115, 100, 115]
|
||||||
|
Y: [290, 280, 249, 204, 150, 97, 51, 21, 10, 21, 51, 97, 150, 204, 249, 280,
|
||||||
|
255, 241, 203, 150, 98, 59, 45, 59, 98, 150, 203, 241, 228, 207, 171, 130,
|
||||||
|
94, 73, 73, 94, 130, 171, 207, 228, 200, 186, 150, 115, 100, 115, 150, 186]
|
||||||
|
|
||||||
|
# set this to either 0 or 1 for the initial sensor number (usually 1)
|
||||||
|
start_sn: 1
|
||||||
|
|
||||||
|
# Normally, positive X is to the right, and positive Y is up.
|
||||||
|
# These settings can be used to reverse one or both of these.
|
||||||
|
reverse_x: false
|
||||||
|
reverse_y: false
|
||||||
|
|
||||||
|
# these are sensors whose labels need to be repositioned. Sensor numbers
|
||||||
|
# start at start_sn. Directions are up, down, left, and right, and values
|
||||||
|
# are multiples of the marker size.
|
||||||
|
label_exceptions: {
|
||||||
|
4: { top: [0, 0] },
|
||||||
|
5: { top: [0, 0] },
|
||||||
|
6: { top: [0, 0] },
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# This will be the name of the output files
|
||||||
|
name: bcdwafer
|
||||||
|
wafers: ["B", "C", "D"]
|
||||||
|
|
||||||
|
# round or square wafer
|
||||||
|
shape: round
|
||||||
|
|
||||||
|
# diameter/edge size of the wafer
|
||||||
|
size: 300
|
||||||
|
|
||||||
|
# large or small markers and labels
|
||||||
|
marker_size: large
|
||||||
|
font_size: large
|
||||||
|
|
||||||
|
# origin settings. x_origin can be left, right, or center. y_origin can
|
||||||
|
# be top, bottom, or center. Using right or top implies that the X/Y values
|
||||||
|
# for that axis are negative, or else we'll be drawing outside the image.
|
||||||
|
x_origin: center
|
||||||
|
y_origin: center
|
||||||
|
|
||||||
|
# sensor X and Y values, in mm relative to the origin
|
||||||
|
X: [0, 72.50, 125.57, 145.00, 125.57, 72.50, 0, -72.50, -125.57, -145.00,
|
||||||
|
-125.57, -72.50, 0, 47.50, 82.27, 95.00, 82.27, 47.50, 0, -47.50, -82.27,
|
||||||
|
-95.00, -82.27, -47.50, 38.97, 22.50, -38.97, -22.50, 0]
|
||||||
|
Y: [145.00, 125.57, 72.50, 0, -72.50, -125.57, -145.00, -125.57, -72.50, 0,
|
||||||
|
72.50, 125.57, 95.00, 82.27, 47.50, 0, -47.50, -82.27, -95.00, -82.27,
|
||||||
|
-47.50, 0, 47.50, 82.27, 22.50, -38.97, -22.50, 38.97, 0]
|
||||||
|
|
||||||
|
# set this to either 0 or 1 for the initial sensor number (usually 1)
|
||||||
|
start_sn: 1
|
||||||
|
|
||||||
|
# Normally, positive X is to the right, and positive Y is up.
|
||||||
|
# These settings can be used to reverse one or both of these.
|
||||||
|
reverse_x: false
|
||||||
|
reverse_y: false
|
||||||
|
|
||||||
|
# these are sensors whose labels need to be repositioned. Sensor numbers
|
||||||
|
# start at start_sn. Directions are up, down, left, and right, and values
|
||||||
|
# are multiples of the marker size.
|
||||||
|
label_exceptions: {
|
||||||
|
2: { left: [1, 0] },
|
||||||
|
3: { left: [1, 0] },
|
||||||
|
4: { left: [1, 0] },
|
||||||
|
5: { left: [1, 0] },
|
||||||
|
6: { left: [1, 0] },
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# This will be the name of the output files
|
||||||
|
name: fwafer
|
||||||
|
wafers: ["F"]
|
||||||
|
|
||||||
|
# round or square wafer
|
||||||
|
shape: round
|
||||||
|
|
||||||
|
# diameter/edge size of the wafer
|
||||||
|
size: 200
|
||||||
|
|
||||||
|
# large or small markers and labels
|
||||||
|
marker_size: large
|
||||||
|
font_size: large
|
||||||
|
|
||||||
|
# origin settings. x_origin can be left, right, or center. y_origin can
|
||||||
|
# be top, bottom, or center. Using right or top implies that the X/Y values
|
||||||
|
# for that axis are negative, or else we'll be drawing outside the image.
|
||||||
|
x_origin: center
|
||||||
|
y_origin: center
|
||||||
|
|
||||||
|
# sensor X and Y values, in mm relative to the origin
|
||||||
|
X: [0.0, 47.5, 82.3, 95.0, 82.3, 47.5, 0.0, -47.5, -82.3, -95.0, -82.3, -47.5,
|
||||||
|
-55.0, -14.8, 39.7, 55.0, 14.8, -39.7, -14.7, 25.5, 14.7, -25.5]
|
||||||
|
Y: [95.0, 82.3, 47.5, 0.0, -47.5, -82.3, -95.0, -82.3, -47.5, 0.0, 47.5, 82.3,
|
||||||
|
13.3, 54.4, 40.0, -13.3, -54.4, -40.0, 25.5, 14.7, -25.5, -14.7]
|
||||||
|
|
||||||
|
# set this to either 0 or 1 for the initial sensor number (usually 1)
|
||||||
|
start_sn: 1
|
||||||
|
|
||||||
|
# Normally, positive X is to the right, and positive Y is up.
|
||||||
|
# These settings can be used to reverse one or both of these.
|
||||||
|
reverse_x: false
|
||||||
|
reverse_y: false
|
||||||
|
|
||||||
|
# these are sensors whose labels need to be repositioned. Sensor numbers
|
||||||
|
# start at start_sn. Directions are up, down, left, and right, and values
|
||||||
|
# are multiples of the marker size.
|
||||||
|
label_exceptions: {
|
||||||
|
2: { left: [1, 0] },
|
||||||
|
3: { left: [1, 0] },
|
||||||
|
4: { left: [1, 0] },
|
||||||
|
5: { left: [1, 0] },
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# This will be the name of the output files
|
||||||
|
name: xwafer
|
||||||
|
wafers: ["X"]
|
||||||
|
|
||||||
|
# round or square wafer
|
||||||
|
shape: square
|
||||||
|
|
||||||
|
# diameter/edge size of the wafer
|
||||||
|
size: 310
|
||||||
|
|
||||||
|
# large or small markers and labels
|
||||||
|
marker_size: small
|
||||||
|
font_size: small
|
||||||
|
|
||||||
|
# origin settings. x_origin can be left, right, or center. y_origin can
|
||||||
|
# be top, bottom, or center. Using right or top implies that the X/Y values
|
||||||
|
# for that axis are negative, or else we'll be drawing outside the image.
|
||||||
|
x_origin: left
|
||||||
|
y_origin: bottom
|
||||||
|
|
||||||
|
# sensor X and Y values, in mm relative to the origin
|
||||||
|
X: [ 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00,
|
||||||
|
13.00, 23.00, 46.43, 89.86, 133.29, 176.72, 220.15, 263.58, 287.01, 297.01,
|
||||||
|
307.01, 307.01, 307.01, 307.01, 307.01, 307.01, 307.01, 307.01, 307.01,
|
||||||
|
307.01, 307.01, 307.01, 297.01, 287.01, 263.58, 220.15, 176.72, 133.29,
|
||||||
|
89.86, 46.43, 23.00, 13.00, 46.43, 46.43, 46.43, 46.43, 46.43, 46.43,
|
||||||
|
89.86, 133.29, 176.72, 220.15, 263.58, 263.58, 263.58, 263.58, 263.58,
|
||||||
|
263.58, 220.15, 176.72, 133.29, 89.86, 89.86, 89.86, 89.86, 89.86,
|
||||||
|
133.29, 176.72, 220.15, 220.15, 220.15, 220.15, 176.72, 133.29, 133.29,
|
||||||
|
133.29, 176.72, 176.72]
|
||||||
|
Y: [ 3.00, 13.07, 23.07, 46.50, 89.93, 133.36, 176.79, 220.22, 263.65, 287.08,
|
||||||
|
297.08, 307.08, 307.08, 307.08, 307.08, 307.08, 307.08, 307.08, 307.08,
|
||||||
|
307.08, 307.08, 307.08, 307.08, 297.08, 287.08, 263.65, 220.22, 176.79,
|
||||||
|
133.36, 89.93, 46.50, 23.07, 13.07, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00,
|
||||||
|
3.00, 3.00, 3.00, 3.00, 3.00, 46.50, 89.93, 133.36, 176.79, 220.22, 263.65,
|
||||||
|
263.65, 263.65, 263.65, 263.65, 263.65, 220.22, 176.79, 133.36, 89.93,
|
||||||
|
46.50, 46.50, 46.50, 46.50, 46.50, 89.93, 133.36, 176.79, 220.22, 220.22,
|
||||||
|
220.22, 220.22, 176.79, 133.36, 89.93, 89.93, 89.93, 133.36, 176.79,
|
||||||
|
176.79, 133.36 ]
|
||||||
|
|
||||||
|
# set this to either 0 or 1 for the initial sensor number (usually 1)
|
||||||
|
start_sn: 1
|
||||||
|
|
||||||
|
# Normally, positive X is to the right, and positive Y is up.
|
||||||
|
# These settings can be used to reverse one or both of these.
|
||||||
|
reverse_x: false
|
||||||
|
reverse_y: false
|
||||||
|
|
||||||
|
# these are sensors whose labels need to be repositioned. Sensor numbers
|
||||||
|
# start at start_sn. Directions are up, down, left, and right, and values
|
||||||
|
# are multiples of the marker size.
|
||||||
|
label_exceptions: {
|
||||||
|
21: { left: [0, 0] },
|
||||||
|
22: { left: [0, 0] },
|
||||||
|
23: { left: [0, 0] },
|
||||||
|
24: { left: [0, 0] },
|
||||||
|
25: { left: [0, 0] },
|
||||||
|
26: { left: [0, 0] },
|
||||||
|
27: { left: [0, 0] },
|
||||||
|
28: { left: [0, 0] },
|
||||||
|
29: { left: [0, 0] },
|
||||||
|
30: { left: [0, 0] },
|
||||||
|
31: { left: [0, 0] },
|
||||||
|
32: { left: [0, 0] },
|
||||||
|
33: { left: [0, 0] },
|
||||||
|
34: { left: [0, 0] },
|
||||||
|
35: { left: [0, 0] },
|
||||||
|
36: { left: [0, 0] }
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# This will be the name of the output files
|
||||||
|
name: zwafer
|
||||||
|
wafers: ["Z"]
|
||||||
|
|
||||||
|
# round or square wafer
|
||||||
|
shape: round
|
||||||
|
|
||||||
|
# diameter/size of the wafer
|
||||||
|
size: 300
|
||||||
|
|
||||||
|
# large or small markers and labels
|
||||||
|
marker_size: small
|
||||||
|
font_size: small
|
||||||
|
|
||||||
|
# origin settings. x_origin can be left, right, or center. y_origin can
|
||||||
|
# be top, bottom, or center. Using right or top probably also needs
|
||||||
|
# reverse_x/reverse_y to be set as well.
|
||||||
|
x_origin: center
|
||||||
|
y_origin: center
|
||||||
|
|
||||||
|
# sensor X and Y values, in mm relative to the wafer center
|
||||||
|
X: [0.00, -1.17, 8.92, 1.17, -8.92, 22.52, 29.35, -22.52, -29.35, 45.05, 73.37,
|
||||||
|
58.71, 9.66, -45.05, -73.37, -58.71, -9.66, 79.85, 89.06, 46.10, -23.86,
|
||||||
|
-79.85, -89.06, -46.10, 23.86, 66.96, 109.06, 87.27, 14.36, -66.96,
|
||||||
|
-109.06, -87.27, -14.36, 121.24, 135.23, 70.00, -36.23, -121.24, -135.23,
|
||||||
|
-70.00, 36.23, 124.71, 139.09, 72.00, -37.27, -124.71, -139.09, -72.00,
|
||||||
|
37.27, 89.49, 127.31, 145.74, 141.99, 116.62, 73.50, 19.19, -38.05, -89.49,
|
||||||
|
-127.31, -145.74, -141.99, -116.62, -73.50, -19.19, 38.05]
|
||||||
|
Y: [0.00, -8.92, -1.17, 8.92, 1.17, -29.35, 22.52, 29.35, -22.52, -58.71,
|
||||||
|
-9.66, 45.05, 73.37, 58.71, 9.66, -45.05, -73.37, -46.10, 23.86, 79.85,
|
||||||
|
89.06, 46.10, -23.86, -79.85, -89.06, -87.27, -14.36, 66.96, 109.06, 87.27,
|
||||||
|
14.36, -66.96, -109.06, -70.00, 36.23, 121.24, 135.23, 70.00, -36.23,
|
||||||
|
-121.24, -135.23, -72.00, 37.27, 124.71, 139.09, 72.00, -37.27, -124.71,
|
||||||
|
-139.09, -116.62, -73.50, -19.19, 38.05, 89.49, 127.31, 145.74, 141.99,
|
||||||
|
116.62, 73.50, 19.19, -38.05, -89.49, -127.31, -145.74, -141.99]
|
||||||
|
|
||||||
|
# set this to either 0 or 1 for the initial sensor number (usually 1)
|
||||||
|
start_sn: 0
|
||||||
|
|
||||||
|
# If one or both axes are reversed, this can be used to fix it
|
||||||
|
# without having to edit all of the coordinates.
|
||||||
|
# These should be false for most normal wafers.
|
||||||
|
reverse_x: false
|
||||||
|
reverse_y: false
|
||||||
|
|
||||||
|
# these are sensors whose labels need to be repositioned. Sensor numbers
|
||||||
|
# start at start_sn. Directions are up, down, left, and right, and values
|
||||||
|
# are multiples of the marker size.
|
||||||
|
label_exceptions: {
|
||||||
|
33: { left: [0, 0] },
|
||||||
|
34: { left: [0, 0] },
|
||||||
|
41: { top: [0, 0] },
|
||||||
|
42: { bottom: [0, 0] },
|
||||||
|
45: { top: [1, 0] },
|
||||||
|
46: { bottom: [0.5, 0] },
|
||||||
|
49: { left: [0, 0] },
|
||||||
|
50: { bottom: [-1.75, -0.5] },
|
||||||
|
51: { top: [-1, 0] },
|
||||||
|
52: { top: [-1, 0] },
|
||||||
|
53: { bottom: [0, 0] },
|
||||||
|
54: { left: [0, -0.75] },
|
||||||
|
55: { right: [0, 0.5] },
|
||||||
|
58: { bottom: [-0.75, 0] },
|
||||||
|
60: { top: [0, 0] },
|
||||||
|
62: { right: [0, 1] },
|
||||||
|
63: { right: [0, 0] },
|
||||||
|
64: { left: [0, 0.25] }
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# This will be the name of the output files
|
||||||
|
name: zwafer_rev
|
||||||
|
wafers: [] # the reversed version doesn't represent any real wafer
|
||||||
|
|
||||||
|
# round or square wafer
|
||||||
|
shape: round
|
||||||
|
|
||||||
|
# diameter/size of the wafer
|
||||||
|
size: 300
|
||||||
|
|
||||||
|
# large or small markers and labels
|
||||||
|
marker_size: small
|
||||||
|
font_size: small
|
||||||
|
|
||||||
|
# origin settings. x_origin can be left, right, or center. y_origin can
|
||||||
|
# be top, bottom, or center. Using right or top probably also needs
|
||||||
|
# reverse_x/reverse_y to be set as well.
|
||||||
|
x_origin: center
|
||||||
|
y_origin: center
|
||||||
|
|
||||||
|
# sensor X and Y values, in mm relative to the wafer center
|
||||||
|
X: [0.00, -1.17, 8.92, 1.17, -8.92, 22.52, 29.35, -22.52, -29.35, 45.05, 73.37,
|
||||||
|
58.71, 9.66, -45.05, -73.37, -58.71, -9.66, 79.85, 89.06, 46.10, -23.86,
|
||||||
|
-79.85, -89.06, -46.10, 23.86, 66.96, 109.06, 87.27, 14.36, -66.96,
|
||||||
|
-109.06, -87.27, -14.36, 121.24, 135.23, 70.00, -36.23, -121.24, -135.23,
|
||||||
|
-70.00, 36.23, 124.71, 139.09, 72.00, -37.27, -124.71, -139.09, -72.00,
|
||||||
|
37.27, 89.49, 127.31, 145.74, 141.99, 116.62, 73.50, 19.19, -38.05, -89.49,
|
||||||
|
-127.31, -145.74, -141.99, -116.62, -73.50, -19.19, 38.05]
|
||||||
|
Y: [0.00, -8.92, -1.17, 8.92, 1.17, -29.35, 22.52, 29.35, -22.52, -58.71,
|
||||||
|
-9.66, 45.05, 73.37, 58.71, 9.66, -45.05, -73.37, -46.10, 23.86, 79.85,
|
||||||
|
89.06, 46.10, -23.86, -79.85, -89.06, -87.27, -14.36, 66.96, 109.06, 87.27,
|
||||||
|
14.36, -66.96, -109.06, -70.00, 36.23, 121.24, 135.23, 70.00, -36.23,
|
||||||
|
-121.24, -135.23, -72.00, 37.27, 124.71, 139.09, 72.00, -37.27, -124.71,
|
||||||
|
-139.09, -116.62, -73.50, -19.19, 38.05, 89.49, 127.31, 145.74, 141.99,
|
||||||
|
116.62, 73.50, 19.19, -38.05, -89.49, -127.31, -145.74, -141.99]
|
||||||
|
|
||||||
|
# set this to either 0 or 1 for the initial sensor number (usually 1)
|
||||||
|
start_sn: 0
|
||||||
|
|
||||||
|
# If one or both axes are reversed, this can be used to fix it
|
||||||
|
# without having to edit all of the coordinates.
|
||||||
|
# These should be false for most normal wafers.
|
||||||
|
reverse_x: true
|
||||||
|
reverse_y: true
|
||||||
|
|
||||||
|
# these are sensors whose labels need to be repositioned. Sensor numbers
|
||||||
|
# start at start_sn. Directions are up, down, left, and right, and values
|
||||||
|
# are multiples of the marker size.
|
||||||
|
label_exceptions: {
|
||||||
|
37: { left: [0, 0] },
|
||||||
|
38: { left: [0, 0] },
|
||||||
|
39: { left: [0, 0] },
|
||||||
|
41: { top: [1.5, 0] },
|
||||||
|
42: { bottom: [1, 0] },
|
||||||
|
43: { left: [0, -1] },
|
||||||
|
45: { bottom: [-1, -1] },
|
||||||
|
46: { bottom: [0, -1] },
|
||||||
|
47: { right: [0, 1] },
|
||||||
|
48: { left: [0, 0] },
|
||||||
|
50: { bottom: [-1, 0] },
|
||||||
|
52: { top: [0, 0] },
|
||||||
|
54: { right: [0.5, 1] },
|
||||||
|
56: { left: [0, 0.5] },
|
||||||
|
57: { left: [0, 0] },
|
||||||
|
58: { top: [0.5, 0] },
|
||||||
|
59: { left: [0, 0] },
|
||||||
|
60: { top: [-1, 0] },
|
||||||
|
61: { left: [0, 0] },
|
||||||
|
62: { left: [0, -1] },
|
||||||
|
63: { right: [0, 1] },
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# ===== Backend Package =====
|
||||||
|
# Backward-compatible re-exports so existing callers keep working
|
||||||
|
# while imports are migrated to the new sub-package paths.
|
||||||
|
|
||||||
|
from pygui.backend.controllers.device_controller import DeviceController # noqa: F401
|
||||||
|
from pygui.backend.controllers.session_controller import SessionController # noqa: F401
|
||||||
|
from pygui.backend.data.csv_file_metadata import CSVFileMetadata # noqa: F401
|
||||||
|
from pygui.backend.data.csv_recorder import CsvRecorder # noqa: F401
|
||||||
|
from pygui.backend.data.data_records import read_data_records # noqa: F401
|
||||||
|
from pygui.backend.data.file_browser import FileBrowser # noqa: F401
|
||||||
|
from pygui.backend.data.local_settings import LocalSettings # noqa: F401
|
||||||
|
from pygui.backend.data.local_settings_model import LocalSettingsModel # noqa: F401
|
||||||
|
from pygui.backend.models.data_model import TemperatureTableModel # noqa: F401
|
||||||
|
from pygui.backend.models.frame import Frame # noqa: F401
|
||||||
|
from pygui.backend.models.frame_player import FramePlayer, frames_from_wafer_data # noqa: F401
|
||||||
|
from pygui.backend.models.frame_stats import Stats, compute_stats # noqa: F401
|
||||||
|
from pygui.backend.models.sensor_editor import SensorEditor # noqa: F401
|
||||||
|
from pygui.backend.models.session_model import SessionModel # noqa: F401
|
||||||
|
from pygui.backend.models.stability_detector import StabilityDetector # noqa: F401
|
||||||
|
from pygui.backend.models.threshold_classifier import ThresholdConfig # noqa: F401
|
||||||
|
from pygui.backend.visualization.graph_view import GraphView # noqa: F401
|
||||||
|
from pygui.backend.visualization.rbf_heatmap import interpolate_field # noqa: F401
|
||||||
|
from pygui.backend.visualization.wafer_map_item import WaferMapItem # noqa: F401
|
||||||
|
from pygui.backend.wafer.wafer_layouts import available_families, load_layout # noqa: F401
|
||||||
|
from pygui.backend.wafer.zwafer_models import DataRecord, Sensor, ZWaferData # noqa: F401
|
||||||
|
from pygui.backend.wafer.zwafer_parser import ZWaferParser # noqa: F401
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
from typing import List, Tuple, Optional
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from backend.contour_models import ContourLine, ContourSegment
|
from pygui.backend.attic.contour_models import ContourLine, ContourSegment
|
||||||
|
|
||||||
|
|
||||||
# ===== Contour Generation =====
|
# ===== Contour Generation =====
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Cluster Average utility for wafer sensor values."""
|
||||||
|
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
from pygui.backend.wafer.zwafer_models import Sensor
|
||||||
|
|
||||||
|
|
||||||
|
def average_clusters(values: list[float], clusters: list[list[int]]) -> list[float]:
|
||||||
|
"""Return a new list of values where members of each cluster are replaced by their mean.
|
||||||
|
|
||||||
|
NaN values are filtered out before computing the mean.
|
||||||
|
If all values in a cluster are NaN, they remain NaN.
|
||||||
|
"""
|
||||||
|
result = list(values)
|
||||||
|
for cluster in clusters:
|
||||||
|
valid_vals = []
|
||||||
|
for idx in cluster:
|
||||||
|
if idx < len(values):
|
||||||
|
val = values[idx]
|
||||||
|
if not math.isnan(val):
|
||||||
|
valid_vals.append(val)
|
||||||
|
|
||||||
|
if valid_vals:
|
||||||
|
mean_val = sum(valid_vals) / len(valid_vals)
|
||||||
|
for idx in cluster:
|
||||||
|
if idx < len(result):
|
||||||
|
result[idx] = mean_val
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def group_sensors_by_radius(sensors: list[Sensor], tolerance: float = 2.0) -> list[list[int]]:
|
||||||
|
"""Group sensor indices into clusters if their radial distances are within tolerance."""
|
||||||
|
# Compute radii for all sensors
|
||||||
|
radii = [(i, math.hypot(s.x, s.y)) for i, s in enumerate(sensors)]
|
||||||
|
# Filter out center sensors (r < 1.0)
|
||||||
|
non_center = [item for item in radii if item[1] >= 1.0]
|
||||||
|
if not non_center:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Sort by radius
|
||||||
|
non_center.sort(key=lambda x: x[1])
|
||||||
|
|
||||||
|
clusters: list[list[int]] = []
|
||||||
|
current_cluster: list[int] = [non_center[0][0]]
|
||||||
|
last_r = non_center[0][1]
|
||||||
|
|
||||||
|
for idx, r in non_center[1:]:
|
||||||
|
if r - last_r <= tolerance:
|
||||||
|
current_cluster.append(idx)
|
||||||
|
else:
|
||||||
|
if len(current_cluster) > 1:
|
||||||
|
clusters.append(current_cluster)
|
||||||
|
current_cluster = [idx]
|
||||||
|
last_r = r
|
||||||
|
|
||||||
|
if len(current_cluster) > 1:
|
||||||
|
clusters.append(current_cluster)
|
||||||
|
|
||||||
|
return clusters
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Dynamic Time Warping comparision between two temperature runs."""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from dtw import dtw
|
||||||
|
|
||||||
|
|
||||||
|
def compare_runs(series_a: list[float], series_b: list[float]) -> dict:
|
||||||
|
"""Compute DTW alignment between 2 average-temperature series.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with keys:
|
||||||
|
- distance: float - warping distance
|
||||||
|
- index_a: list[int] - warping path indices into series_a
|
||||||
|
- index_b: list[int] - warping path indices into series_b
|
||||||
|
- warping_path: list[tuple[int,int]] - paired indices
|
||||||
|
"""
|
||||||
|
x = np.array(series_a, dtype=float)
|
||||||
|
y = np.array(series_b, dtype=float)
|
||||||
|
|
||||||
|
# Filter out NaN/Inf values so dtw doesn't choke on them.
|
||||||
|
mask_x = np.isfinite(x)
|
||||||
|
mask_y = np.isfinite(y)
|
||||||
|
if not mask_x.any() or not mask_y.any():
|
||||||
|
return {"distance": float('inf'), "index_a": [], "index_b": [], "warping_path": []}
|
||||||
|
|
||||||
|
x = x[mask_x]
|
||||||
|
y = y[mask_y]
|
||||||
|
|
||||||
|
alignment = dtw(x, y, keep_internals=True)
|
||||||
|
return {
|
||||||
|
"distance": float(alignment.distance),
|
||||||
|
"index_a": alignment.index1.tolist(),
|
||||||
|
"index_b": alignment.index2.tolist(),
|
||||||
|
"warping_path": list(zip(alignment.index1.tolist(), alignment.index2.tolist()))
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# ===== Controllers Sub-package =====
|
||||||
|
from pygui.backend.controllers.device_controller import DeviceController
|
||||||
|
from pygui.backend.controllers.session_controller import SessionController
|
||||||
|
|
||||||
|
__all__ = ["DeviceController", "SessionController"]
|
||||||
@@ -1,31 +1,35 @@
|
|||||||
"""QML-exposed controller for wafer device communication.
|
"""QML-exposed controller for wafer device communication."""
|
||||||
|
|
||||||
Bridges DeviceService (serialcomm) to QML via @Slot methods and signals.
|
|
||||||
All hardware operations run synchronously on the main thread (matching
|
|
||||||
C# Form1.cs per-operation open/close pattern). For long operations
|
|
||||||
(erase ~15s, read up to 120s) the caller should show a loading state.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
import threading
|
import threading
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from PySide6.QtCore import QObject, Property, Qt, Signal, Slot
|
from PySide6.QtCore import Property, QObject, Qt, Signal, Slot
|
||||||
|
|
||||||
from backend.data_model import TemperatureTableModel
|
from pygui.backend.controllers.session_controller import SessionController
|
||||||
from backend.graph_view import GraphView
|
from pygui.backend.data.local_settings import LocalSettings
|
||||||
from backend.local_settings import LocalSettings
|
from pygui.backend.models.data_model import TemperatureTableModel
|
||||||
from serialcomm.data_parser import (
|
from pygui.backend.utils import slot_error_boundary
|
||||||
|
from pygui.backend.visualization.graph_view import GraphView
|
||||||
|
from pygui.serialcomm.data_parser import (
|
||||||
convert_to_temperatures,
|
convert_to_temperatures,
|
||||||
parse_binary_data,
|
parse_binary_data,
|
||||||
remove_trailing_zeros,
|
remove_trailing_zeros,
|
||||||
save_to_csv,
|
save_to_csv,
|
||||||
)
|
)
|
||||||
from serialcomm.device_service import DeviceService
|
from pygui.serialcomm.serial_port import WaferInfo
|
||||||
from serialcomm.serial_port import WaferInfo
|
|
||||||
|
# import pygui.backend.wafer_map_item
|
||||||
|
|
||||||
|
stream_controller = SessionController()
|
||||||
|
# engine.rootContext().setContextProperty("streamController", stream_controller)
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -38,11 +42,14 @@ class DeviceController(QObject):
|
|||||||
|
|
||||||
# ---- public signals ----
|
# ---- public signals ----
|
||||||
portsUpdated = Signal(list)
|
portsUpdated = Signal(list)
|
||||||
|
connectionStatusChanged = Signal()
|
||||||
|
operationInProgressChanged = Signal()
|
||||||
|
selectedPortChanged = Signal()
|
||||||
detectResult = Signal(object) # WaferInfo dict or None
|
detectResult = Signal(object) # WaferInfo dict or None
|
||||||
readResult = Signal(object) # {"success": bool, "bytes": int} or {"error": str}
|
readResult = Signal(dict) # {"success": bool, "bytes": int} or {"error": str}
|
||||||
eraseResult = Signal(object) # {"success": bool}
|
eraseResult = Signal(dict) # {"success": bool}
|
||||||
debugResult = Signal(object) # {"success": bool, "sensor_bytes": int, "debug_bytes": int}
|
debugResult = Signal(dict) # {"success": bool, "sensor_bytes": int, "debug_bytes": int}
|
||||||
parsedDataReady = Signal(object) # {"success": bool, "rows": int, "cols": int, "data": ..., "csv_path": str}
|
parsedDataReady = Signal(dict) # {"success": bool, "rows": int, "cols": int, "data": ..., "csv_path": str}
|
||||||
statusRestored = Signal() # Emitted when status is restored from previous session
|
statusRestored = Signal() # Emitted when status is restored from previous session
|
||||||
logMessage = Signal(str)
|
logMessage = Signal(str)
|
||||||
activityLogUpdated = Signal(str)
|
activityLogUpdated = Signal(str)
|
||||||
@@ -50,46 +57,42 @@ class DeviceController(QObject):
|
|||||||
# ---- private signals (marshal worker-thread results back to main thread) ----
|
# ---- private signals (marshal worker-thread results back to main thread) ----
|
||||||
_detectFinished = Signal(object, object) # (port_or_None, WaferInfo_or_None)
|
_detectFinished = Signal(object, object) # (port_or_None, WaferInfo_or_None)
|
||||||
_readFinished = Signal(str, str, object) # (port, family_code, bytes_or_None)
|
_readFinished = Signal(str, str, object) # (port, family_code, bytes_or_None)
|
||||||
|
_eraseFinished = Signal(bool) # (success)
|
||||||
|
_debugFinished = Signal(dict) # (result_dict)
|
||||||
|
|
||||||
def __init__(self, settings: LocalSettings, data_dir: str, parent: QObject | None = None) -> None:
|
def __init__(self, settings: LocalSettings, data_dir: str, parent: QObject | None = None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._settings = settings
|
self._settings = settings
|
||||||
self._data_dir = data_dir
|
self._data_dir = data_dir
|
||||||
|
from pygui.serialcomm.device_service import DeviceService
|
||||||
self._service = DeviceService(settings)
|
self._service = DeviceService(settings)
|
||||||
|
|
||||||
# Initialize status from persisted settings (with defaults for new installations)
|
# Always start fresh — never restore connection status or logs from a prior session
|
||||||
self._connection_status = getattr(settings, 'connection_status', "Disconnected")
|
self._connection_status = "Disconnected"
|
||||||
self._operation_in_progress = False
|
self._operation_in_progress = False
|
||||||
self._activity_log: list[str] = getattr(settings, 'activity_log', [])
|
self._activity_log: list[str] = []
|
||||||
self._raw_bytes: Optional[bytes] = None
|
self._raw_bytes: Optional[bytes] = None
|
||||||
# Default save dir lives next to the settings file (~/Documents/isc_data/csv)
|
self._save_data_dir: str = getattr(settings, 'save_data_dir', "") or str(Path(self._data_dir) / "csv")
|
||||||
from pathlib import Path
|
self._last_wafer_info: dict[str, Any] = {}
|
||||||
self._save_data_dir: str = getattr(settings, 'save_data_dir', str(Path.home() / "Documents" / "isc_data" / "csv"))
|
self._last_csv_path: str = ""
|
||||||
self._last_wafer_info: dict[str, Any] = getattr(settings, 'last_wafer_info', {})
|
self._selected_port: str = ""
|
||||||
self._last_csv_path: str = getattr(settings, 'last_csv_path', "")
|
self._data_row_count: int = 0
|
||||||
self._selected_port: str = getattr(settings, 'selected_port', "")
|
self._data_col_count: int = 0
|
||||||
self._data_row_count: int = getattr(settings, 'data_row_count', 0)
|
|
||||||
self._data_col_count: int = getattr(settings, 'data_col_count', 0)
|
|
||||||
self._data_model = TemperatureTableModel(self)
|
self._data_model = TemperatureTableModel(self)
|
||||||
self._graph_view = GraphView(self)
|
self._graph_view = GraphView(self)
|
||||||
|
|
||||||
# If we have persisted activity log, emit it to QML
|
# Reset connection status on fresh start
|
||||||
if self._activity_log:
|
self._connection_status = "Disconnected"
|
||||||
self.activityLogUpdated.emit("\n".join(self._activity_log))
|
|
||||||
|
|
||||||
# Log status restoration and emit signal for UI updates
|
|
||||||
if self._connection_status != "Disconnected" or self._selected_port or self._last_wafer_info:
|
|
||||||
self._append_log("Restored previous session status")
|
|
||||||
if self._selected_port:
|
|
||||||
self._append_log(f"Last selected port: {self._selected_port}")
|
|
||||||
if self._last_wafer_info:
|
|
||||||
info = self._last_wafer_info
|
|
||||||
self._append_log(f"Last wafer: {info.get('serialNumber', 'Unknown')} ({info.get('familyCode', 'Unknown')})")
|
|
||||||
self.statusRestored.emit()
|
|
||||||
|
|
||||||
# Marshal worker-thread results onto the Qt main thread.
|
# Marshal worker-thread results onto the Qt main thread.
|
||||||
self._detectFinished.connect(self._handle_detect_finished, Qt.QueuedConnection)
|
self._detectFinished.connect(self._handle_detect_finished, Qt.ConnectionType.QueuedConnection)
|
||||||
self._readFinished.connect(self._handle_read_finished, Qt.QueuedConnection)
|
self._readFinished.connect(self._handle_read_finished, Qt.ConnectionType.QueuedConnection)
|
||||||
|
self._eraseFinished.connect(self._handle_erase_finished, Qt.ConnectionType.QueuedConnection)
|
||||||
|
self._debugFinished.connect(self._handle_debug_finished, Qt.ConnectionType.QueuedConnection)
|
||||||
|
|
||||||
|
root_logger = logging.getLogger()
|
||||||
|
root_logger.addHandler(QmlActivityLogHandler(self))
|
||||||
|
root_logger.setLevel(logging.INFO)
|
||||||
|
|
||||||
# ---- Properties ----
|
# ---- Properties ----
|
||||||
@Property(list, notify=portsUpdated)
|
@Property(list, notify=portsUpdated)
|
||||||
@@ -97,12 +100,18 @@ class DeviceController(QObject):
|
|||||||
"""Currently available serial port names."""
|
"""Currently available serial port names."""
|
||||||
return self._service.enumerate_ports()
|
return self._service.enumerate_ports()
|
||||||
|
|
||||||
@Property(str, notify=portsUpdated)
|
def _set_connection_status(self, status: str) -> None:
|
||||||
|
"""Update connection status and notify QML."""
|
||||||
|
if self._connection_status != status:
|
||||||
|
self._connection_status = status
|
||||||
|
self.connectionStatusChanged.emit()
|
||||||
|
|
||||||
|
@Property(str, notify=connectionStatusChanged)
|
||||||
def connectionStatus(self) -> str:
|
def connectionStatus(self) -> str:
|
||||||
"""Current connection status string for display."""
|
"""Current connection status string for display."""
|
||||||
return self._connection_status
|
return self._connection_status
|
||||||
|
|
||||||
@Property(bool, notify=portsUpdated)
|
@Property(bool, notify=operationInProgressChanged)
|
||||||
def operationInProgress(self) -> bool:
|
def operationInProgress(self) -> bool:
|
||||||
"""Whether a hardware operation is currently running."""
|
"""Whether a hardware operation is currently running."""
|
||||||
return self._operation_in_progress
|
return self._operation_in_progress
|
||||||
@@ -113,21 +122,53 @@ class DeviceController(QObject):
|
|||||||
return self._save_data_dir
|
return self._save_data_dir
|
||||||
|
|
||||||
@Slot(str)
|
@Slot(str)
|
||||||
|
@slot_error_boundary
|
||||||
def setSaveDataDir(self, path: str) -> None:
|
def setSaveDataDir(self, path: str) -> None:
|
||||||
"""Set the directory for saving parsed CSV data files."""
|
"""Set the directory for saving parsed CSV data files."""
|
||||||
self._save_data_dir = path
|
self._save_data_dir = path
|
||||||
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()
|
||||||
|
|
||||||
@Property(str, notify=portsUpdated)
|
@Slot(str)
|
||||||
|
@slot_error_boundary
|
||||||
|
def openCsvFile(self, file_path: str) -> None:
|
||||||
|
"""Open a CSV file in the system default spreadsheet application."""
|
||||||
|
|
||||||
|
if not file_path:
|
||||||
|
self._append_log("No file path provided to openCsvFile")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
path = Path(file_path)
|
||||||
|
if not path.exists():
|
||||||
|
self._append_log(f"File not found: {file_path}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Platform-appropriate file opener
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
subprocess.Popen(["open", str(path)])
|
||||||
|
elif sys.platform == "linux":
|
||||||
|
subprocess.Popen(["xdg-open", str(path)])
|
||||||
|
else: # Windows
|
||||||
|
os.startfile(str(path))
|
||||||
|
|
||||||
|
self._append_log(f"Opened file: {file_path}")
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Failed to open file %s: %s", file_path, exc)
|
||||||
|
self._append_log(f"Error opening file: {exc}")
|
||||||
|
|
||||||
|
@Property(str, notify=selectedPortChanged)
|
||||||
def selectedPort(self) -> str:
|
def selectedPort(self) -> str:
|
||||||
"""Currently selected serial port shared across the UI."""
|
"""Currently selected serial port shared across the UI."""
|
||||||
return self._selected_port
|
return self._selected_port
|
||||||
|
|
||||||
@Slot(str)
|
@Slot(str)
|
||||||
|
@slot_error_boundary
|
||||||
def setSelectedPort(self, port: str) -> None:
|
def setSelectedPort(self, port: str) -> None:
|
||||||
"""Set the active serial port from the port selector."""
|
"""Set the active serial port from the port selector."""
|
||||||
|
if self._selected_port != port:
|
||||||
self._selected_port = port
|
self._selected_port = port
|
||||||
|
self.selectedPortChanged.emit()
|
||||||
self._save_status()
|
self._save_status()
|
||||||
|
|
||||||
@Property(int, notify=parsedDataReady)
|
@Property(int, notify=parsedDataReady)
|
||||||
@@ -147,10 +188,17 @@ class DeviceController(QObject):
|
|||||||
Returns [familyCode, serialNumber, sensorCount, mfgDateHex, runtime, cycleCount].
|
Returns [familyCode, serialNumber, sensorCount, mfgDateHex, runtime, cycleCount].
|
||||||
"""
|
"""
|
||||||
info = self._last_wafer_info
|
info = self._last_wafer_info
|
||||||
|
family_code = info.get("familyCode", "")
|
||||||
|
sensor_count = info.get("sensorCount", 0)
|
||||||
|
from pygui.serialcomm.data_parser import csv_column_count
|
||||||
|
mapped_count = csv_column_count(family_code)
|
||||||
|
if mapped_count > 0:
|
||||||
|
sensor_count = mapped_count
|
||||||
|
|
||||||
return [
|
return [
|
||||||
info.get("familyCode", ""),
|
family_code,
|
||||||
info.get("serialNumber", ""),
|
info.get("serialNumber", ""),
|
||||||
info.get("sensorCount", 0),
|
sensor_count,
|
||||||
info.get("mfgDateHex", ""),
|
info.get("mfgDateHex", ""),
|
||||||
info.get("runtime", 0),
|
info.get("runtime", 0),
|
||||||
info.get("cycleCount", 0),
|
info.get("cycleCount", 0),
|
||||||
@@ -167,6 +215,7 @@ class DeviceController(QObject):
|
|||||||
return self._graph_view
|
return self._graph_view
|
||||||
|
|
||||||
@Slot(result=object)
|
@Slot(result=object)
|
||||||
|
@slot_error_boundary
|
||||||
def getChartData(self) -> dict[str, Any]:
|
def getChartData(self) -> dict[str, Any]:
|
||||||
"""Extract chart-ready data from the current model.
|
"""Extract chart-ready data from the current model.
|
||||||
|
|
||||||
@@ -204,28 +253,56 @@ class DeviceController(QObject):
|
|||||||
self._activity_log = self._activity_log[-200:]
|
self._activity_log = self._activity_log[-200:]
|
||||||
self.activityLogUpdated.emit("\n".join(self._activity_log))
|
self.activityLogUpdated.emit("\n".join(self._activity_log))
|
||||||
|
|
||||||
def _set_operation_progress(self, in_progress: bool) -> None:
|
@Slot()
|
||||||
"""Set operation progress state and notify QML.
|
@slot_error_boundary
|
||||||
|
def clearActivityLog(self) -> None:
|
||||||
|
"""Clear the activity log."""
|
||||||
|
self._activity_log.clear()
|
||||||
|
self.activityLogUpdated.emit("")
|
||||||
|
self._save_status()
|
||||||
|
|
||||||
portsUpdated is the shared notify signal for availablePorts,
|
@Slot()
|
||||||
connectionStatus, and operationInProgress — emitting it forces
|
@slot_error_boundary
|
||||||
QML to re-evaluate all three bindings.
|
def clearSession(self) -> None:
|
||||||
"""
|
"""Clear the current session state, allowing a clean detect."""
|
||||||
|
self._set_connection_status("Disconnected")
|
||||||
|
self._selected_port = ""
|
||||||
|
self._last_wafer_info = {}
|
||||||
|
self._data_row_count = 0
|
||||||
|
self._data_col_count = 0
|
||||||
|
self._raw_bytes = None
|
||||||
|
self._operation_in_progress = False
|
||||||
|
self._data_model.reset()
|
||||||
|
self._graph_view.resetChart()
|
||||||
|
self._activity_log.clear()
|
||||||
|
self.detectResult.emit(None)
|
||||||
|
self.parsedDataReady.emit({"success": False})
|
||||||
|
self.portsUpdated.emit(self.availablePorts)
|
||||||
|
self.activityLogUpdated.emit("")
|
||||||
|
self._save_status()
|
||||||
|
|
||||||
|
|
||||||
|
def _set_operation_progress(self, in_progress: bool) -> None:
|
||||||
|
"""Set operation progress state and notify QML."""
|
||||||
|
if self._operation_in_progress != in_progress:
|
||||||
self._operation_in_progress = in_progress
|
self._operation_in_progress = in_progress
|
||||||
self.portsUpdated.emit(self._service.enumerate_ports())
|
self.operationInProgressChanged.emit()
|
||||||
|
|
||||||
# ---- Public Slots ----
|
# ---- Public Slots ----
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
def refreshPorts(self) -> None:
|
def refreshPorts(self) -> None:
|
||||||
"""Scan for available serial ports and emit updated list."""
|
"""Scan for available serial ports and emit updated list."""
|
||||||
|
self._service.clear_port_scan_cache()
|
||||||
ports = self._service.enumerate_ports()
|
ports = self._service.enumerate_ports()
|
||||||
self._connection_status = "Disconnected"
|
self._set_connection_status("Disconnected")
|
||||||
self.portsUpdated.emit(ports)
|
self.portsUpdated.emit(ports)
|
||||||
self._append_log(f"Scanned {len(ports)} serial port(s)")
|
self._append_log(f"Scanned {len(ports)} serial port(s)")
|
||||||
self._save_status()
|
self._save_status()
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
def detectWafer(self) -> None:
|
def detectWafer(self) -> None:
|
||||||
"""Scan all serial ports for a wafer (runs in background thread).
|
"""Scan all serial ports for a wafer (runs in background thread).
|
||||||
|
|
||||||
@@ -237,8 +314,20 @@ class DeviceController(QObject):
|
|||||||
self._append_log("Already busy — ignoring detect request")
|
self._append_log("Already busy — ignoring detect request")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Clear previous session data & logs for a fresh session
|
||||||
|
self._selected_port = ""
|
||||||
|
self._last_wafer_info = {}
|
||||||
|
self._data_row_count = 0
|
||||||
|
self._data_col_count = 0
|
||||||
|
self._raw_bytes = None
|
||||||
|
self._data_model.reset()
|
||||||
|
self._graph_view.resetChart()
|
||||||
|
self._activity_log.clear()
|
||||||
|
self.detectResult.emit(None)
|
||||||
|
self.parsedDataReady.emit({"success": False})
|
||||||
|
|
||||||
self._set_operation_progress(True)
|
self._set_operation_progress(True)
|
||||||
self._connection_status = "Detecting..."
|
self._set_connection_status("Detecting...")
|
||||||
self.portsUpdated.emit(self._service.enumerate_ports())
|
self.portsUpdated.emit(self._service.enumerate_ports())
|
||||||
self._append_log("Scanning all ports for wafer ...")
|
self._append_log("Scanning all ports for wafer ...")
|
||||||
|
|
||||||
@@ -255,35 +344,34 @@ class DeviceController(QObject):
|
|||||||
self._detectFinished.emit(port, info)
|
self._detectFinished.emit(port, info)
|
||||||
|
|
||||||
@Slot(object, object)
|
@Slot(object, object)
|
||||||
|
@slot_error_boundary
|
||||||
def _handle_detect_finished(
|
def _handle_detect_finished(
|
||||||
self, port: Optional[str], info: Optional[WaferInfo]
|
self, port: Optional[str], info: Optional[WaferInfo]
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Main-thread completion handler for detectWafer."""
|
"""Main-thread completion handler for detectWafer."""
|
||||||
if info is not None:
|
if info is not None:
|
||||||
self._selected_port = port or ""
|
self._selected_port = port or ""
|
||||||
self._connection_status = "Connected"
|
self._set_connection_status("Connected")
|
||||||
self._append_log(
|
|
||||||
f"Detected: {info.serial_number} (family={info.family_code}, "
|
|
||||||
f"port={port}, runtime={info.runtime}s, cycles={info.cycle_count})"
|
|
||||||
)
|
|
||||||
wafer_dict = self._wafer_info_to_dict(info)
|
wafer_dict = self._wafer_info_to_dict(info)
|
||||||
self._last_wafer_info = wafer_dict
|
self._last_wafer_info = wafer_dict
|
||||||
self.detectResult.emit(wafer_dict)
|
self.detectResult.emit(wafer_dict)
|
||||||
self._save_status()
|
self._save_status()
|
||||||
else:
|
else:
|
||||||
self._selected_port = ""
|
self._selected_port = ""
|
||||||
self._connection_status = "Disconnected"
|
self._set_connection_status("Disconnected")
|
||||||
self._append_log("No wafer detected on any port")
|
self._append_log("No wafer detected on any port")
|
||||||
self._last_wafer_info = {}
|
self._last_wafer_info = {}
|
||||||
self.detectResult.emit(None)
|
self.detectResult.emit(None)
|
||||||
self._set_operation_progress(False)
|
self._set_operation_progress(False)
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
def readMemoryAsync(self) -> None:
|
def readMemoryAsync(self) -> None:
|
||||||
"""Read wafer memory in a background thread.
|
"""Read wafer memory in a background thread.
|
||||||
|
|
||||||
Uses the family code and port from the last detect.
|
Uses the family code and port from the last detect.
|
||||||
Emits readResult on completion, then auto-chains to parseAndSaveData.
|
Emits readResult on completion; QML prompts for the save directory
|
||||||
|
before calling parseAndSaveData.
|
||||||
"""
|
"""
|
||||||
if self._operation_in_progress:
|
if self._operation_in_progress:
|
||||||
self._append_log("Already busy — ignoring read request")
|
self._append_log("Already busy — ignoring read request")
|
||||||
@@ -298,7 +386,7 @@ class DeviceController(QObject):
|
|||||||
family_code = self._last_wafer_info.get("familyCode", "")
|
family_code = self._last_wafer_info.get("familyCode", "")
|
||||||
|
|
||||||
self._set_operation_progress(True)
|
self._set_operation_progress(True)
|
||||||
self._connection_status = "Reading..."
|
self._set_connection_status("Reading...")
|
||||||
self.portsUpdated.emit(self._service.enumerate_ports())
|
self.portsUpdated.emit(self._service.enumerate_ports())
|
||||||
self._append_log(
|
self._append_log(
|
||||||
f"Reading memory on {port} (family={family_code or 'auto'}) ..."
|
f"Reading memory on {port} (family={family_code or 'auto'}) ..."
|
||||||
@@ -320,22 +408,21 @@ class DeviceController(QObject):
|
|||||||
self._readFinished.emit(port, family_code, data)
|
self._readFinished.emit(port, family_code, data)
|
||||||
|
|
||||||
@Slot(str, str, object)
|
@Slot(str, str, object)
|
||||||
|
@slot_error_boundary
|
||||||
def _handle_read_finished(
|
def _handle_read_finished(
|
||||||
self, port: str, family_code: str, data: Optional[bytes]
|
self, port: str, family_code: str, data: Optional[bytes]
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Main-thread completion handler for readMemoryAsync."""
|
"""Main-thread completion handler for readMemoryAsync."""
|
||||||
if data is not None:
|
if data is not None:
|
||||||
self._connection_status = "Connected"
|
self._set_connection_status("Connected")
|
||||||
self._append_log(f"Read {len(data)} bytes")
|
self._append_log(f"Read {len(data)} bytes")
|
||||||
self._raw_bytes = data
|
self._raw_bytes = data
|
||||||
self.readResult.emit({"success": True, "bytes": len(data)})
|
self.readResult.emit({"success": True, "bytes": len(data)})
|
||||||
self._set_operation_progress(False)
|
self._set_operation_progress(False)
|
||||||
# Auto-chain: read → parse → save (matches C# behavior).
|
self._append_log("Memory read complete - choose save directory to save CSV")
|
||||||
# Parse runs on main thread — it's CPU-bound but bounded (~1s).
|
|
||||||
self.parseAndSaveData(family_code, port)
|
|
||||||
self._save_status()
|
self._save_status()
|
||||||
return
|
return
|
||||||
self._connection_status = "Disconnected"
|
self._set_connection_status("Disconnected")
|
||||||
self._append_log("Read returned no data")
|
self._append_log("Read returned no data")
|
||||||
self._raw_bytes = None
|
self._raw_bytes = None
|
||||||
self.readResult.emit({"error": "Read returned no data"})
|
self.readResult.emit({"error": "Read returned no data"})
|
||||||
@@ -343,67 +430,109 @@ class DeviceController(QObject):
|
|||||||
self._save_status()
|
self._save_status()
|
||||||
|
|
||||||
@Slot(str)
|
@Slot(str)
|
||||||
|
@slot_error_boundary
|
||||||
def eraseMemory(self, port: str) -> None:
|
def eraseMemory(self, port: str) -> None:
|
||||||
"""Send p1 erase command."""
|
"""Send p1 erase command."""
|
||||||
|
if self._operation_in_progress:
|
||||||
|
self._append_log("Already busy — ignoring erase request")
|
||||||
|
return
|
||||||
self._set_operation_progress(True)
|
self._set_operation_progress(True)
|
||||||
self._connection_status = "Erasing..."
|
self._set_connection_status("Erasing...")
|
||||||
self.portsUpdated.emit(self._service.enumerate_ports())
|
self.portsUpdated.emit(self._service.enumerate_ports())
|
||||||
self._append_log(f"Erase command sent to {port} (wafer takes ~15s) ...")
|
self._append_log(f"Erase command sent to {port} (wafer takes ~15s) ...")
|
||||||
|
|
||||||
|
threading.Thread(
|
||||||
|
target=self._erase_worker,
|
||||||
|
args=(port,),
|
||||||
|
daemon=True,
|
||||||
|
).start()
|
||||||
|
|
||||||
|
def _erase_worker(self, port: str) -> None:
|
||||||
|
"""Background thread worker for eraseMemory."""
|
||||||
|
try:
|
||||||
ok = self._service.erase_wafer(port)
|
ok = self._service.erase_wafer(port)
|
||||||
|
except Exception as exc:
|
||||||
|
log.exception("Erase worker crashed: %s", exc)
|
||||||
|
ok = False
|
||||||
|
self._eraseFinished.emit(ok)
|
||||||
|
|
||||||
|
@Slot(bool)
|
||||||
|
@slot_error_boundary
|
||||||
|
def _handle_erase_finished(self, ok: bool) -> None:
|
||||||
|
"""Main thread completion handler for eraseMemory."""
|
||||||
if ok:
|
if ok:
|
||||||
self._connection_status = "Connected"
|
self._set_connection_status("Connected")
|
||||||
self._append_log("Erase command accepted — wait ~15 seconds")
|
self._append_log("Erase command accepted — wait ~15 seconds")
|
||||||
else:
|
else:
|
||||||
self._connection_status = "Disconnected"
|
self._set_connection_status("Disconnected")
|
||||||
self._append_log("Erase command failed")
|
self._append_log("Erase command failed")
|
||||||
self.eraseResult.emit({"success": ok})
|
self.eraseResult.emit({"success": ok})
|
||||||
self._set_operation_progress(False)
|
self._set_operation_progress(False)
|
||||||
|
|
||||||
@Slot(str)
|
@Slot(str)
|
||||||
|
@slot_error_boundary
|
||||||
def readDebug(self, port: str) -> None:
|
def readDebug(self, port: str) -> None:
|
||||||
"""Read both D1 (sensor) and F1 (debug/cold-junction) data.
|
"""Read both D1 (sensor) and F1 (debug/cold-junction) data."""
|
||||||
|
if self._operation_in_progress:
|
||||||
Emits debugResult with counts on success.
|
self._append_log("Already busy — ignoring debug read request")
|
||||||
"""
|
return
|
||||||
self._set_operation_progress(True)
|
self._set_operation_progress(True)
|
||||||
self._connection_status = "Reading debug..."
|
self._set_connection_status("Reading debug...")
|
||||||
self.portsUpdated.emit(self._service.enumerate_ports())
|
self.portsUpdated.emit(self._service.enumerate_ports())
|
||||||
self._append_log(f"Reading debug data on {port} ...")
|
self._append_log(f"Reading debug data on {port} ...")
|
||||||
|
|
||||||
|
threading.Thread(
|
||||||
|
target=self._debug_worker,
|
||||||
|
args=(port,),
|
||||||
|
daemon=True,
|
||||||
|
).start()
|
||||||
|
|
||||||
|
def _debug_worker(self, port: str) -> None:
|
||||||
|
"""Background thread worker for readDebug."""
|
||||||
|
try:
|
||||||
# Step 1: D1 sensor data
|
# Step 1: D1 sensor data
|
||||||
sensor_data = self._service.read_wafer_data(port, family_code="")
|
sensor_data = self._service.read_wafer_data(port, family_code="")
|
||||||
if sensor_data is None:
|
if sensor_data is None:
|
||||||
self._connection_status = "Disconnected"
|
self._debugFinished.emit({"error": "D1 read failed"})
|
||||||
self._append_log("D1 read failed — aborting debug read")
|
|
||||||
self.debugResult.emit({"error": "D1 read failed"})
|
|
||||||
self._set_operation_progress(False)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
self._append_log(f"D1 read: {len(sensor_data)} bytes")
|
|
||||||
|
|
||||||
# Step 2: F1 debug data
|
# Step 2: F1 debug data
|
||||||
debug_data = self._service.read_wafer_data(port, family_code="", cmd="F1")
|
debug_data = self._service.read_wafer_data(port, family_code="", cmd="F1")
|
||||||
if debug_data is None:
|
if debug_data is None:
|
||||||
self._connection_status = "Disconnected"
|
self._debugFinished.emit({"error": "F1 read failed"})
|
||||||
self._append_log("F1 read failed")
|
|
||||||
self.debugResult.emit({"error": "F1 read failed"})
|
|
||||||
self._set_operation_progress(False)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
self._connection_status = "Connected"
|
self._debugFinished.emit({
|
||||||
self._append_log(
|
|
||||||
f"Debug read complete: {len(sensor_data)} sensor bytes, "
|
|
||||||
f"{len(debug_data)} debug bytes"
|
|
||||||
)
|
|
||||||
self.debugResult.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),
|
||||||
})
|
})
|
||||||
|
except Exception as exc:
|
||||||
|
log.exception("Debug worker crashed: %s", exc)
|
||||||
|
self._debugFinished.emit({"error": str(exc)})
|
||||||
|
|
||||||
|
@Slot(dict)
|
||||||
|
@slot_error_boundary
|
||||||
|
def _handle_debug_finished(self, result: dict) -> None:
|
||||||
|
"""Main thread completion handler for readDebug."""
|
||||||
|
if result.get("success"):
|
||||||
|
self._set_connection_status("Connected")
|
||||||
|
sensor_bytes = result.get("sensor_bytes", 0)
|
||||||
|
debug_bytes = result.get("debug_bytes", 0)
|
||||||
|
self._append_log(
|
||||||
|
f"Debug read complete: {sensor_bytes} sensor bytes, "
|
||||||
|
f"{debug_bytes} debug bytes"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._set_connection_status("Disconnected")
|
||||||
|
err_msg = result.get("error", "Debug read failed")
|
||||||
|
self._append_log(err_msg)
|
||||||
|
|
||||||
|
self.debugResult.emit(result)
|
||||||
self._set_operation_progress(False)
|
self._set_operation_progress(False)
|
||||||
|
|
||||||
@Slot(str, str)
|
@Slot(str, str)
|
||||||
|
@slot_error_boundary
|
||||||
def parseAndSaveData(self, family_code: str = "", port: str = "") -> None:
|
def parseAndSaveData(self, family_code: str = "", port: str = "") -> None:
|
||||||
"""Parse raw bytes into temperatures and save to CSV.
|
"""Parse raw bytes into temperatures and save to CSV.
|
||||||
|
|
||||||
@@ -419,12 +548,9 @@ class DeviceController(QObject):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if not self._save_data_dir:
|
if not self._save_data_dir:
|
||||||
self._append_log("No save data directory set")
|
self._save_data_dir = str(Path(self._data_dir) / "csv")
|
||||||
self.parsedDataReady.emit({
|
self._append_log(f"Auto-set save directory to: {self._save_data_dir}")
|
||||||
"success": False,
|
self._save_status()
|
||||||
"error": "No save data directory set",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
|
|
||||||
fc = family_code or (
|
fc = family_code or (
|
||||||
self._last_wafer_info.get("familyCode", "")
|
self._last_wafer_info.get("familyCode", "")
|
||||||
@@ -471,6 +597,7 @@ class DeviceController(QObject):
|
|||||||
return
|
return
|
||||||
|
|
||||||
self._append_log(f"Saved CSV: {csv_path}")
|
self._append_log(f"Saved CSV: {csv_path}")
|
||||||
|
self._last_csv_path = csv_path
|
||||||
|
|
||||||
# Load data into the QAbstractTableModel
|
# Load data into the QAbstractTableModel
|
||||||
self._data_model.load_data(temp_data, cols)
|
self._data_model.load_data(temp_data, cols)
|
||||||
@@ -504,18 +631,36 @@ class DeviceController(QObject):
|
|||||||
self._settings.last_csv_path = self._last_csv_path
|
self._settings.last_csv_path = self._last_csv_path
|
||||||
|
|
||||||
# Save to disk
|
# Save to disk
|
||||||
LocalSettings.save_settings(LocalSettings._settings_path(self._data_dir), self._settings)
|
LocalSettings.save_settings(self._data_dir, self._settings)
|
||||||
|
|
||||||
# ---- Helpers ----
|
# ---- Helpers ----
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _wafer_info_to_dict(info: WaferInfo) -> dict[str, Any]:
|
def _wafer_info_to_dict(info: WaferInfo) -> dict[str, Any]:
|
||||||
"""Convert WaferInfo dataclass to JSON-serialisable dict for QML."""
|
"""Convert WaferInfo dataclass to JSON-serialisable dict for QML."""
|
||||||
|
from pygui.serialcomm.data_parser import csv_column_count
|
||||||
|
sensor_count = info.sensor_count
|
||||||
|
mapped_count = csv_column_count(info.family_code)
|
||||||
|
if mapped_count > 0:
|
||||||
|
sensor_count = mapped_count
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"familyCode": info.family_code,
|
"familyCode": info.family_code,
|
||||||
"serialNumber": info.serial_number,
|
"serialNumber": info.serial_number,
|
||||||
"sensorCount": info.sensor_count,
|
"sensorCount": sensor_count,
|
||||||
"mfgDateHex": info.mfg_date_hex,
|
"mfgDateHex": info.mfg_date_hex,
|
||||||
"runtime": info.runtime,
|
"runtime": info.runtime,
|
||||||
"cycleCount": info.cycle_count,
|
"cycleCount": info.cycle_count,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class QmlActivityLogHandler(logging.Handler):
|
||||||
|
def __init__(self, controller: DeviceController) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._controller = controller
|
||||||
|
def emit(self, record: logging.LogRecord) -> None:
|
||||||
|
message = record.getMessage()
|
||||||
|
if "found wafer on" in message:
|
||||||
|
return
|
||||||
|
level = record.levelname
|
||||||
|
msg = f"{level}: {message}"
|
||||||
|
self._controller._append_log(msg)
|
||||||
@@ -0,0 +1,831 @@
|
|||||||
|
"""QML-facing controller for the live/review wafer dashboard."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
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.data.csv_recorder import CsvRecorder
|
||||||
|
from pygui.backend.models.frame import Frame
|
||||||
|
from pygui.backend.models.frame_player import FramePlayer, frames_from_wafer_data
|
||||||
|
from pygui.backend.models.replay_stats import ReplayStatsTracker
|
||||||
|
from pygui.backend.models.sensor_editor import SensorEditor
|
||||||
|
from pygui.backend.models.session_model import SessionModel
|
||||||
|
from pygui.backend.models.threshold_classifier import ThresholdConfig
|
||||||
|
from pygui.backend.utils import slot_error_boundary
|
||||||
|
from pygui.backend.wafer.zwafer_models import Sensor
|
||||||
|
from pygui.backend.wafer.zwafer_parser import ZWaferParser
|
||||||
|
from pygui.serialcomm.stream_reader import StreamReader
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
MODE_LIVE = "live"
|
||||||
|
MODE_REVIEW = "review"
|
||||||
|
|
||||||
|
|
||||||
|
class SessionController(QObject):
|
||||||
|
# public signals
|
||||||
|
frameUpdated = Signal()
|
||||||
|
modeChanged = Signal()
|
||||||
|
stateChanged = Signal()
|
||||||
|
recordingChanged = Signal()
|
||||||
|
sensorsChanged = Signal()
|
||||||
|
loadedFileChanged = Signal()
|
||||||
|
loadFileError = Signal(str) # emitted when loadFile() fails, so QML can show why
|
||||||
|
clusterAveragingEnabledChanged = Signal()
|
||||||
|
settingsChanged = Signal() # emitted when session state changes that should be persisted
|
||||||
|
liveStatsChanged = Signal() # emitted when receivedCount or errorCount change
|
||||||
|
# trend: per-frame avg for live graph
|
||||||
|
trendData = Signal(str) # JSON array of floats
|
||||||
|
# NOTE: dict (QVariantMap), not object — Signal(object) delivers Python
|
||||||
|
# dicts to QML as an opaque empty wrapper with no accessible fields.
|
||||||
|
comparisonResult = Signal(dict) # dict with success, distance, warping_path, max_sensor_deviation, series_a, series_b
|
||||||
|
splitResult = Signal(dict) # dict with success, segments
|
||||||
|
# private: marshal a worker-thread frame onto the main thread
|
||||||
|
_liveFrame = Signal(object) # Frame
|
||||||
|
_liveError = Signal() # worker-thread error ping
|
||||||
|
|
||||||
|
def __init__(self, parent: QObject | None = None,
|
||||||
|
settings: dict | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._mode = MODE_REVIEW
|
||||||
|
self._model = SessionModel()
|
||||||
|
self._player = FramePlayer()
|
||||||
|
self._reader: Optional[StreamReader] = None
|
||||||
|
self._recorder = CsvRecorder()
|
||||||
|
self._sensors: list[Sensor] = []
|
||||||
|
self._last = None # last SessionUpdate
|
||||||
|
self._elapsed = 0.0
|
||||||
|
self._trend_buffer: list[float] = [] # rolling window
|
||||||
|
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.timeout.connect(self._advance)
|
||||||
|
self._speed = 1.0
|
||||||
|
|
||||||
|
# Q1: coalesce live repaints to ~20 Hz; data is still processed every frame.
|
||||||
|
self._repaint_timer = QTimer(self)
|
||||||
|
self._repaint_timer.setInterval(50) # ~20 Hz
|
||||||
|
self._repaint_timer.timeout.connect(self._flush_repaint)
|
||||||
|
self._dirty = False
|
||||||
|
|
||||||
|
self._liveFrame.connect(self._on_live_frame, Qt.ConnectionType.QueuedConnection)
|
||||||
|
self._liveError.connect(self._on_live_error, Qt.ConnectionType.QueuedConnection)
|
||||||
|
|
||||||
|
self._sensor_editor = SensorEditor()
|
||||||
|
self._last_raw_frame: Frame | None = None
|
||||||
|
self._loaded_file: str = ""
|
||||||
|
self._cluster_averaging_enabled = False
|
||||||
|
self._active_clusters: list[list[int]] = []
|
||||||
|
self._stats_tracker = ReplayStatsTracker()
|
||||||
|
self._received_count: int = 0
|
||||||
|
self._error_count: int = 0
|
||||||
|
|
||||||
|
# Restore persisted state if available
|
||||||
|
self._load_settings(settings or {})
|
||||||
|
|
||||||
|
# ---- persisted settings ----
|
||||||
|
|
||||||
|
def _load_settings(self, settings: dict) -> None:
|
||||||
|
"""Restore session state from persisted settings dict."""
|
||||||
|
# Do NOT auto-restore the last session file — always start fresh.
|
||||||
|
|
||||||
|
def collect_settings(self) -> dict:
|
||||||
|
"""Return a dict of session state to persist."""
|
||||||
|
return {
|
||||||
|
"session_last_file": self._loaded_file,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- properties QML binds to ----
|
||||||
|
@Property(str, notify=modeChanged)
|
||||||
|
def mode(self) -> str: return self._mode
|
||||||
|
|
||||||
|
@Property(int, notify=frameUpdated)
|
||||||
|
def frameIndex(self) -> int: return self._player.index
|
||||||
|
|
||||||
|
@Property(int, notify=frameUpdated)
|
||||||
|
def frameTotal(self) -> int: return self._player.total
|
||||||
|
|
||||||
|
@Property(bool, notify=stateChanged)
|
||||||
|
def playing(self) -> bool: return self._play_timer.isActive()
|
||||||
|
|
||||||
|
@Property(str, notify=stateChanged)
|
||||||
|
def state(self) -> str:
|
||||||
|
if self._last:
|
||||||
|
return self._last.state
|
||||||
|
# IF we dont have data yet, but the reader is running -> Streaming
|
||||||
|
if self._mode == MODE_LIVE and self._reader is not None:
|
||||||
|
return "streaming"
|
||||||
|
|
||||||
|
return "idle"
|
||||||
|
|
||||||
|
@Property(bool, notify=recordingChanged)
|
||||||
|
def recording(self) -> bool: return self._recorder.is_recording
|
||||||
|
|
||||||
|
@Property(list, notify=frameUpdated)
|
||||||
|
def sensorDots(self) -> list[dict]:
|
||||||
|
"""Per-sensor render data for the radial map."""
|
||||||
|
if not self._last:
|
||||||
|
return []
|
||||||
|
out = []
|
||||||
|
for i, s in enumerate(self._sensors):
|
||||||
|
v = self._last.values[i] if i < len(self._last.values) else 0.0
|
||||||
|
band = self._last.bands[i] if i < len(self._last.bands) else "in_range"
|
||||||
|
out.append({"label": s.label, "x": s.x, "y": s.y,
|
||||||
|
"value": round(v, 2), "band": band, "index": i})
|
||||||
|
return out
|
||||||
|
|
||||||
|
@Property("QVariantList", notify=sensorsChanged)
|
||||||
|
def sensorLayout(self) -> list:
|
||||||
|
"""[{label, x, y, side, offset_x, offset_y}] for WaferMapItem.sensors."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"label": s.label,
|
||||||
|
"x": s.x,
|
||||||
|
"y": s.y,
|
||||||
|
"side": getattr(s, "side", "right"),
|
||||||
|
"offset_x": getattr(s, "offset_x", 0.0),
|
||||||
|
"offset_y": getattr(s, "offset_y", 0.0),
|
||||||
|
}
|
||||||
|
for s in self._sensors
|
||||||
|
]
|
||||||
|
|
||||||
|
@Property(str, notify=sensorsChanged)
|
||||||
|
def waferShape(self) -> str:
|
||||||
|
"""Wafer shape: 'round' or 'square'."""
|
||||||
|
return getattr(self._sensors, "shape", "round")
|
||||||
|
|
||||||
|
@Property(float, notify=sensorsChanged)
|
||||||
|
def waferSize(self) -> float:
|
||||||
|
"""Wafer size in mm."""
|
||||||
|
return getattr(self._sensors, "size", 300.0)
|
||||||
|
|
||||||
|
@Property("QVariantList", notify=frameUpdated)
|
||||||
|
def sensorValues(self) -> list:
|
||||||
|
"""[float] in sensor order."""
|
||||||
|
if not self._last:
|
||||||
|
return []
|
||||||
|
return [round(v, 3) for v in self._last.values]
|
||||||
|
|
||||||
|
@Property("QVariantList", notify=frameUpdated)
|
||||||
|
def sensorBands(self) -> list:
|
||||||
|
"""['in_range'|'high'|'low'] in sensor order."""
|
||||||
|
if not self._last:
|
||||||
|
return []
|
||||||
|
return list(self._last.bands)
|
||||||
|
|
||||||
|
@Property(float, notify=frameUpdated)
|
||||||
|
def target(self) -> float:
|
||||||
|
"""Resolved band center (frame mean in auto mode, else set_point)."""
|
||||||
|
return self._last.target if self._last else 149.0
|
||||||
|
|
||||||
|
@Property(float, notify=frameUpdated)
|
||||||
|
def margin(self) -> float:
|
||||||
|
"""Resolved band half-width (frame 1σ in auto mode, else margin)."""
|
||||||
|
return self._last.margin if self._last else 1.0
|
||||||
|
|
||||||
|
@Property(dict, notify=frameUpdated)
|
||||||
|
def stats(self) -> dict:
|
||||||
|
if not self._last:
|
||||||
|
return {}
|
||||||
|
s = self._last.stats
|
||||||
|
return {"min": round(s.min, 2), "minIndex": s.min_index + 1,
|
||||||
|
"max": round(s.max, 2), "maxIndex": s.max_index + 1,
|
||||||
|
"diff": round(s.diff, 2), "avg": round(s.avg, 2),
|
||||||
|
"sigma": round(s.sigma, 2), "threeSigma": round(s.three_sigma, 2)}
|
||||||
|
|
||||||
|
@Property(str, notify=loadedFileChanged)
|
||||||
|
def loadedFile(self) -> str: return self._loaded_file
|
||||||
|
|
||||||
|
@Property("QVariantList", notify=frameUpdated)
|
||||||
|
def overriddenSensors(self) -> list[int]:
|
||||||
|
"""Indices of sensors that currently have a replacement or offset."""
|
||||||
|
return self._sensor_editor.active_indices()
|
||||||
|
|
||||||
|
@Property(bool, notify=clusterAveragingEnabledChanged)
|
||||||
|
def clusterAveragingEnabled(self) -> bool:
|
||||||
|
return self._cluster_averaging_enabled
|
||||||
|
|
||||||
|
@clusterAveragingEnabled.setter
|
||||||
|
def clusterAveragingEnabled(self, value: bool) -> None:
|
||||||
|
if self._cluster_averaging_enabled == value:
|
||||||
|
return
|
||||||
|
self._cluster_averaging_enabled = value
|
||||||
|
self.clusterAveragingEnabledChanged.emit()
|
||||||
|
self._reprocess_current()
|
||||||
|
|
||||||
|
@Property(int, notify=liveStatsChanged)
|
||||||
|
def receivedCount(self) -> int:
|
||||||
|
return self._received_count
|
||||||
|
|
||||||
|
@Property(int, notify=liveStatsChanged)
|
||||||
|
def errorCount(self) -> int:
|
||||||
|
return self._error_count
|
||||||
|
|
||||||
|
@Property(int, notify=liveStatsChanged)
|
||||||
|
def resyncCount(self) -> int:
|
||||||
|
return self._reader.resync_count if self._reader else 0
|
||||||
|
|
||||||
|
# ---- mode + thresholds ----
|
||||||
|
@Slot(str)
|
||||||
|
@slot_error_boundary
|
||||||
|
def setMode(self, mode: str) -> None:
|
||||||
|
if mode == self._mode:
|
||||||
|
return
|
||||||
|
if mode != "live":
|
||||||
|
self.stopStream()
|
||||||
|
self._play_timer.stop()
|
||||||
|
self._mode = mode
|
||||||
|
self.modeChanged.emit()
|
||||||
|
|
||||||
|
@Slot(float, float, bool)
|
||||||
|
@slot_error_boundary
|
||||||
|
def setThresholds(self, set_point: float, margin: float, auto: bool) -> None:
|
||||||
|
self._model.set_thresholds(ThresholdConfig(set_point, margin, auto))
|
||||||
|
if self._last: # re-band current frame in place
|
||||||
|
self._reprocess_current()
|
||||||
|
|
||||||
|
# ---- review: file load + playback ----
|
||||||
|
@Slot(str)
|
||||||
|
@slot_error_boundary
|
||||||
|
def loadFile(self, file_path: str) -> None:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pygui.backend.data.data_records import (
|
||||||
|
is_official_csv,
|
||||||
|
read_data_records,
|
||||||
|
read_official_csv,
|
||||||
|
)
|
||||||
|
from pygui.backend.wafer.wafer_layouts import WaferLayout, resolve_shape_and_size
|
||||||
|
|
||||||
|
frames = []
|
||||||
|
|
||||||
|
if is_official_csv(file_path):
|
||||||
|
sensors, records = read_official_csv(file_path)
|
||||||
|
frames = frames_from_wafer_data(None, records)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
data, _ = ZWaferParser().parse(file_path)
|
||||||
|
except (ValueError, KeyError) as exc:
|
||||||
|
msg = f"Could not parse {Path(file_path).name}: {exc}"
|
||||||
|
log.warning("%s", msg)
|
||||||
|
self.loadFileError.emit(msg)
|
||||||
|
return
|
||||||
|
if data is None or not data.sensors:
|
||||||
|
msg = f"Could not parse {Path(file_path).name}"
|
||||||
|
log.warning("%s", msg)
|
||||||
|
self.loadFileError.emit(msg)
|
||||||
|
return
|
||||||
|
records = read_data_records(file_path)
|
||||||
|
sensors = data.sensors
|
||||||
|
frames = frames_from_wafer_data(data, records)
|
||||||
|
|
||||||
|
if not sensors:
|
||||||
|
msg = f"No sensor layout found in {Path(file_path).name}"
|
||||||
|
log.warning("%s", msg)
|
||||||
|
self.loadFileError.emit(msg)
|
||||||
|
return
|
||||||
|
if not frames:
|
||||||
|
# A well-formed header with zero data rows below it — most commonly
|
||||||
|
# a live recording that was stopped before any frames arrived.
|
||||||
|
msg = f"{Path(file_path).name} has no recorded frames"
|
||||||
|
log.warning("%s", msg)
|
||||||
|
self.loadFileError.emit(msg)
|
||||||
|
return
|
||||||
|
|
||||||
|
wafer_id = ""
|
||||||
|
if not is_official_csv(file_path):
|
||||||
|
wafer_id = data.serial if (data and data.serial) else ""
|
||||||
|
else:
|
||||||
|
stem = Path(file_path).stem
|
||||||
|
wafer_id = stem.split("-")[0] if "-" in stem else stem
|
||||||
|
|
||||||
|
shape, size = resolve_shape_and_size(sensors, wafer_id)
|
||||||
|
self._sensors = WaferLayout(sensors, shape=shape, size=size)
|
||||||
|
self._active_clusters = getattr(self._sensors, 'clusters', [])
|
||||||
|
if not self._active_clusters:
|
||||||
|
self._active_clusters = group_sensors_by_radius(self._sensors)
|
||||||
|
self._player.load(frames)
|
||||||
|
self._model.reset()
|
||||||
|
self._stats_tracker.reset()
|
||||||
|
self._loaded_file = file_path
|
||||||
|
# A file was explicitly picked for review — show it regardless of
|
||||||
|
# whichever mode (e.g. Live) was previously active, otherwise a
|
||||||
|
# successful load can be invisible to the user.
|
||||||
|
self.setMode(MODE_REVIEW)
|
||||||
|
self.loadedFileChanged.emit()
|
||||||
|
self.sensorsChanged.emit()
|
||||||
|
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()))
|
||||||
|
self.settingsChanged.emit()
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
|
def unloadFile(self) -> None:
|
||||||
|
"""Clear the loaded file, resetting the player and frame states."""
|
||||||
|
self._player.load([])
|
||||||
|
self._model.reset()
|
||||||
|
self._stats_tracker.reset()
|
||||||
|
self._loaded_file = ""
|
||||||
|
self.loadedFileChanged.emit()
|
||||||
|
self.sensorsChanged.emit()
|
||||||
|
self.settingsChanged.emit()
|
||||||
|
|
||||||
|
# ---- comparison: DTW between two CSV files ----
|
||||||
|
@Slot(str, str)
|
||||||
|
@slot_error_boundary
|
||||||
|
def compareFiles(self, file_a: str, file_b: str) -> None:
|
||||||
|
"""Run DTW comparison between two CSV files and emit result."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pygui.backend.comparison import compare_runs
|
||||||
|
from pygui.backend.data.data_records import is_official_csv, read_data_records, read_official_csv
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Recorded-live CSVs are named "live_<serial>_<timestamp>.csv" by
|
||||||
|
# startRecording (see FileBrowser.isRecording), distinct from the
|
||||||
|
# "<serial>-<timestamp>.csv" convention used for read-memory dumps.
|
||||||
|
# Comparing across those categories doesn't make sense (a live
|
||||||
|
# capture vs. a full memory readout aren't the same kind of run),
|
||||||
|
# so gate on that before even looking at wafer type.
|
||||||
|
stem_a, stem_b = Path(file_a).stem, Path(file_b).stem
|
||||||
|
is_recording_a = stem_a.lower().startswith("live_")
|
||||||
|
is_recording_b = stem_b.lower().startswith("live_")
|
||||||
|
if is_recording_a != is_recording_b:
|
||||||
|
self.comparisonResult.emit({
|
||||||
|
"success": False,
|
||||||
|
"error": "Cannot compare a recording file with a read-memory file"
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
|
# Gate on wafer type (family): comparing a P-wafer against an X-wafer
|
||||||
|
# (different sensor counts/layouts) would silently truncate the
|
||||||
|
# smaller sensor set and pair up unrelated physical sensors in the
|
||||||
|
# overlap view. Same convention as FileBrowser's waferType badge —
|
||||||
|
# first character of the filename stem, minus the "live_" prefix
|
||||||
|
# so the wafer letter is read from the serial, not the "L".
|
||||||
|
def _wafer_type(stem: str, is_recording: bool) -> str:
|
||||||
|
if is_recording:
|
||||||
|
stem = stem[len("live_"):]
|
||||||
|
return stem[0].upper() if stem else ""
|
||||||
|
|
||||||
|
type_a = _wafer_type(stem_a, is_recording_a)
|
||||||
|
type_b = _wafer_type(stem_b, is_recording_b)
|
||||||
|
if type_a and type_b and type_a != type_b:
|
||||||
|
self.comparisonResult.emit({
|
||||||
|
"success": False,
|
||||||
|
"error": f"Cannot compare different wafer types: {type_a} vs {type_b}"
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
|
if is_official_csv(file_a):
|
||||||
|
_, recs_a = read_official_csv(file_a)
|
||||||
|
else:
|
||||||
|
recs_a = read_data_records(file_a)
|
||||||
|
|
||||||
|
if is_official_csv(file_b):
|
||||||
|
_, recs_b = read_official_csv(file_b)
|
||||||
|
else:
|
||||||
|
recs_b = read_data_records(file_b)
|
||||||
|
if not recs_a or not recs_b:
|
||||||
|
self.comparisonResult.emit({
|
||||||
|
"success": False,
|
||||||
|
"error": "No data in one or both files"
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
|
# Compare the full overlapping length of both runs. DTW's O(n*m) cost
|
||||||
|
# matrix is cheap even at a few thousand frames (~26MB at 1800x1800),
|
||||||
|
# so only cap against pathologically long files, not typical runs.
|
||||||
|
max_frames = min(5000, len(recs_a), len(recs_b))
|
||||||
|
series_a = [recs_a[i].values[0] if recs_a[i].values else 0.0
|
||||||
|
for i in range(max_frames)]
|
||||||
|
series_b = [recs_b[i].values[0] if recs_b[i].values else 0.0
|
||||||
|
for i in range(max_frames)]
|
||||||
|
|
||||||
|
result = compare_runs(series_a, series_b)
|
||||||
|
|
||||||
|
# Mean temporal shift along the DTW path: positive means run B
|
||||||
|
# reaches the same profile features later than run A.
|
||||||
|
path = result["warping_path"]
|
||||||
|
frame_offset = round(sum(j - i for i, j in path) / len(path)) if path else 0
|
||||||
|
|
||||||
|
# Max sensor deviation: walk the DTW-aligned frame pairs and take
|
||||||
|
# the largest per-sensor abs difference across all sensors, not
|
||||||
|
# just the sensor[0] series used for alignment.
|
||||||
|
num_sensors = min(len(recs_a[0].values), len(recs_b[0].values)) if recs_a[0].values and recs_b[0].values else 0
|
||||||
|
max_sensor_deviation = 0.0
|
||||||
|
for i, j in zip(result["index_a"], result["index_b"]):
|
||||||
|
if i >= max_frames or j >= max_frames:
|
||||||
|
continue
|
||||||
|
values_a = recs_a[i].values
|
||||||
|
values_b = recs_b[j].values
|
||||||
|
for s in range(min(num_sensors, len(values_a), len(values_b))):
|
||||||
|
diff = abs(values_a[s] - values_b[s])
|
||||||
|
if diff > max_sensor_deviation:
|
||||||
|
max_sensor_deviation = diff
|
||||||
|
|
||||||
|
# Sensor layout + per-sensor diff for the wafer overlap view. Reuses the
|
||||||
|
# same sensor-parsing branch as loadFile(); file_a and file_b are now
|
||||||
|
# guaranteed to share a wafer type by the gate above.
|
||||||
|
from pygui.backend.data.data_records import is_official_csv, read_official_csv
|
||||||
|
from pygui.backend.wafer.wafer_layouts import resolve_shape_and_size
|
||||||
|
from pygui.backend.wafer.zwafer_parser import ZWaferParser
|
||||||
|
|
||||||
|
sensor_layout: list[dict] = []
|
||||||
|
sensor_diff: list[float] = []
|
||||||
|
wafer_shape = "round"
|
||||||
|
wafer_size = 300.0
|
||||||
|
try:
|
||||||
|
if is_official_csv(file_a):
|
||||||
|
sensors, _ = read_official_csv(file_a)
|
||||||
|
stem = Path(file_a).stem
|
||||||
|
wafer_id = stem.split("-")[0] if "-" in stem else stem
|
||||||
|
else:
|
||||||
|
parsed, _ = ZWaferParser().parse(file_a)
|
||||||
|
sensors = parsed.sensors if parsed else []
|
||||||
|
wafer_id = parsed.serial if (parsed and parsed.serial) else ""
|
||||||
|
|
||||||
|
if sensors:
|
||||||
|
wafer_shape, wafer_size = resolve_shape_and_size(sensors, wafer_id)
|
||||||
|
last_a = recs_a[max_frames - 1].values
|
||||||
|
last_b = recs_b[max_frames - 1].values
|
||||||
|
n = min(len(sensors), len(last_a), len(last_b))
|
||||||
|
sensor_layout = [
|
||||||
|
{
|
||||||
|
"label": s.label,
|
||||||
|
"x": s.x,
|
||||||
|
"y": s.y,
|
||||||
|
"side": getattr(s, "side", "right"),
|
||||||
|
"offset_x": getattr(s, "offset_x", 0.0),
|
||||||
|
"offset_y": getattr(s, "offset_y", 0.0),
|
||||||
|
}
|
||||||
|
for s in sensors[:n]
|
||||||
|
]
|
||||||
|
sensor_diff = [round(last_b[i] - last_a[i], 3) for i in range(n)]
|
||||||
|
except Exception as layout_exc:
|
||||||
|
log.warning("Could not resolve sensor layout for overlap view: %s", layout_exc)
|
||||||
|
|
||||||
|
self.comparisonResult.emit({
|
||||||
|
"success": True,
|
||||||
|
"distance": result["distance"],
|
||||||
|
# Lists, not tuples — tuples don't survive QVariant conversion
|
||||||
|
"warping_path": [list(p) for p in result["warping_path"][:50]],
|
||||||
|
"frame_offset": frame_offset,
|
||||||
|
"max_sensor_deviation": max_sensor_deviation,
|
||||||
|
"series_a": series_a,
|
||||||
|
"series_b": series_b,
|
||||||
|
"sensor_layout": sensor_layout,
|
||||||
|
"sensor_diff": sensor_diff,
|
||||||
|
"wafer_shape": wafer_shape,
|
||||||
|
"wafer_size": wafer_size,
|
||||||
|
})
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Comparison failed: %s", exc)
|
||||||
|
self.comparisonResult.emit({
|
||||||
|
"success": False,
|
||||||
|
"error": str(exc)
|
||||||
|
})
|
||||||
|
|
||||||
|
# ---- splitting: threshold-based segmentation ----
|
||||||
|
@Slot(str, float)
|
||||||
|
@slot_error_boundary
|
||||||
|
def splitData(self, file_path: str, threshold: float) -> None:
|
||||||
|
"""Segment temperature profile into Ramp/Soak/Cool phases."""
|
||||||
|
from pygui.backend.data.data_records import is_official_csv, read_data_records, read_official_csv
|
||||||
|
from pygui.backend.splitter import segment_profile
|
||||||
|
|
||||||
|
try:
|
||||||
|
if is_official_csv(file_path):
|
||||||
|
_, records = read_official_csv(file_path)
|
||||||
|
else:
|
||||||
|
records = read_data_records(file_path)
|
||||||
|
if not records:
|
||||||
|
self.splitResult.emit({
|
||||||
|
"success": False,
|
||||||
|
"error": "No data in file"
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
|
# Extract average temperatures (use first sensor as proxy)
|
||||||
|
avg_temps = [rec.values[0] if rec.values else 0.0 for rec in records]
|
||||||
|
segments = segment_profile(avg_temps, threshold)
|
||||||
|
|
||||||
|
self.splitResult.emit({
|
||||||
|
"success": True,
|
||||||
|
"segments": [{
|
||||||
|
"label": seg.label,
|
||||||
|
"start_frame": seg.start_frame,
|
||||||
|
"end_frame": seg.end_frame,
|
||||||
|
"avg_temp": seg.avg_temp
|
||||||
|
} for seg in segments]
|
||||||
|
})
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Split failed: %s", exc)
|
||||||
|
self.splitResult.emit({
|
||||||
|
"success": False,
|
||||||
|
"error": str(exc)
|
||||||
|
})
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
|
def play(self) -> None:
|
||||||
|
self._play_timer.start(self._next_interval_ms())
|
||||||
|
self.stateChanged.emit()
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
|
def pause(self) -> None:
|
||||||
|
self._play_timer.stop()
|
||||||
|
self.stateChanged.emit()
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._play_timer.stop()
|
||||||
|
self._player.seek(0)
|
||||||
|
self._emit_current()
|
||||||
|
self.stateChanged.emit()
|
||||||
|
|
||||||
|
@Slot(int)
|
||||||
|
@slot_error_boundary
|
||||||
|
def step(self, delta: int) -> None:
|
||||||
|
self._play_timer.stop()
|
||||||
|
self._player.step(delta)
|
||||||
|
self._emit_current()
|
||||||
|
self.stateChanged.emit()
|
||||||
|
|
||||||
|
@Slot(int)
|
||||||
|
@slot_error_boundary
|
||||||
|
def seek(self, i: int) -> None:
|
||||||
|
self._player.seek(i)
|
||||||
|
self._emit_current()
|
||||||
|
|
||||||
|
@Slot(float)
|
||||||
|
@slot_error_boundary
|
||||||
|
def setSpeed(self, x: float) -> None:
|
||||||
|
self._speed = max(0.1, x)
|
||||||
|
if self._play_timer.isActive():
|
||||||
|
self._play_timer.start(self._next_interval_ms())
|
||||||
|
|
||||||
|
def _next_interval_ms(self) -> int:
|
||||||
|
"""Timer interval in ms for the next frame, scaled by playback speed. Falls back to 500ms."""
|
||||||
|
ms = self._player.next_frame_ms()
|
||||||
|
if ms <= 0:
|
||||||
|
ms = 500.0
|
||||||
|
return max(1, int(ms / self._speed))
|
||||||
|
|
||||||
|
def _advance(self) -> None:
|
||||||
|
if self._player.at_end:
|
||||||
|
self._play_timer.stop()
|
||||||
|
self.stateChanged.emit()
|
||||||
|
return
|
||||||
|
self._player.step(1)
|
||||||
|
self._emit_current()
|
||||||
|
self._play_timer.start(self._next_interval_ms())
|
||||||
|
|
||||||
|
def _apply_pipeline(self, raw_values: list[float]) -> list[float]:
|
||||||
|
"""Apply override first, then optional cluster averaging."""
|
||||||
|
edited = self._sensor_editor.apply(raw_values)
|
||||||
|
if getattr(self, "_cluster_averaging_enabled", False):
|
||||||
|
return average_clusters(edited, getattr(self, "_active_clusters", []))
|
||||||
|
return edited
|
||||||
|
|
||||||
|
def _emit_current(self) -> None:
|
||||||
|
frame = self._player.current()
|
||||||
|
if frame is None:
|
||||||
|
return
|
||||||
|
self._last_raw_frame = frame
|
||||||
|
edited = Frame(seq=frame.seq, time=frame.time,
|
||||||
|
values=self._apply_pipeline(frame.values))
|
||||||
|
self._last = self._model.process(edited)
|
||||||
|
self.frameUpdated.emit()
|
||||||
|
self.stateChanged.emit()
|
||||||
|
|
||||||
|
def _reprocess_current(self) -> None:
|
||||||
|
"""Re-run the model on the last raw frame"""
|
||||||
|
frame = self._last_raw_frame
|
||||||
|
if frame is None:
|
||||||
|
return
|
||||||
|
edited = Frame(seq=frame.seq, time=frame.time,
|
||||||
|
values=self._apply_pipeline(frame.values))
|
||||||
|
self._last = self._model.process(edited)
|
||||||
|
self.frameUpdated.emit()
|
||||||
|
self.stateChanged.emit()
|
||||||
|
|
||||||
|
# ---- live: stream start/stop ----
|
||||||
|
@Slot(str, str)
|
||||||
|
@slot_error_boundary
|
||||||
|
def startStream(self, port: str, family_code: str = "") -> None:
|
||||||
|
if self._reader is not None:
|
||||||
|
log.warning("startStream: StreamReader is already running.")
|
||||||
|
return
|
||||||
|
|
||||||
|
from pygui.backend.wafer.wafer_layouts import load_layout_for_wafer_id
|
||||||
|
from pygui.serialcomm.serial_port import SerialPort # transport open
|
||||||
|
|
||||||
|
if family_code:
|
||||||
|
try:
|
||||||
|
self._sensors = load_layout_for_wafer_id(family_code)
|
||||||
|
self.sensorsChanged.emit()
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("Could not load layout for %s: %s", family_code, e)
|
||||||
|
|
||||||
|
self._active_clusters = getattr(self._sensors, 'clusters', [])
|
||||||
|
if not self._active_clusters:
|
||||||
|
self._active_clusters = group_sensors_by_radius(self._sensors)
|
||||||
|
|
||||||
|
# The new binary protocol sends payload of (sensorCount * 2) bytes
|
||||||
|
# Each sensor is a big-endian 16-bit value (Sign-Magnitude).
|
||||||
|
expected_sensors = 80 if (family_code or "") == "X" else 244
|
||||||
|
|
||||||
|
def parse_binary_frame(payload: bytes, seq: int) -> Frame:
|
||||||
|
values = []
|
||||||
|
num_sensors = min(expected_sensors, len(payload) // 2)
|
||||||
|
for i in range(num_sensors):
|
||||||
|
high_byte = payload[i * 2]
|
||||||
|
low_byte = payload[i * 2 + 1]
|
||||||
|
val16 = (high_byte << 8) | low_byte
|
||||||
|
|
||||||
|
is_negative = (val16 & 0x8000) != 0
|
||||||
|
integer_part = (val16 >> 7) & 0xFF
|
||||||
|
fractional_bits = val16 & 0x7F
|
||||||
|
fractional_part = fractional_bits / 128.0
|
||||||
|
result = integer_part + fractional_part
|
||||||
|
if is_negative:
|
||||||
|
result = -result
|
||||||
|
values.append(result)
|
||||||
|
|
||||||
|
return Frame(seq=seq, time=time.monotonic(), values=values)
|
||||||
|
|
||||||
|
# Clear out any old data from the prev sessions
|
||||||
|
self._model.reset()
|
||||||
|
self._trend_buffer.clear()
|
||||||
|
self._trend_timestamps.clear()
|
||||||
|
self._trend_timer.start()
|
||||||
|
self._stats_tracker.reset()
|
||||||
|
self._last = None
|
||||||
|
self._last_raw_frame = None
|
||||||
|
self._received_count = 0
|
||||||
|
self._error_count = 0
|
||||||
|
self.liveStatsChanged.emit()
|
||||||
|
|
||||||
|
transport = SerialPort.open_port(port, timeout=1)
|
||||||
|
# Send 'D2' command padded to 512 bytes to start the stream
|
||||||
|
cmd = b"D2" + SerialPort.COMMAND_PAD.encode()
|
||||||
|
transport.write(cmd)
|
||||||
|
|
||||||
|
import weakref
|
||||||
|
weak_self = weakref.ref(self)
|
||||||
|
|
||||||
|
def on_error(exc: Exception):
|
||||||
|
log.error("Live stream error: %s", exc)
|
||||||
|
ref = weak_self()
|
||||||
|
if ref is not None:
|
||||||
|
ref._liveError.emit()
|
||||||
|
ref.stopStream()
|
||||||
|
|
||||||
|
def on_frame(frame: Frame):
|
||||||
|
ref = weak_self()
|
||||||
|
if ref is not None:
|
||||||
|
ref._liveFrame.emit(frame)
|
||||||
|
|
||||||
|
self._reader = StreamReader(
|
||||||
|
transport, parse_binary_frame,
|
||||||
|
on_frame=on_frame,
|
||||||
|
on_error=on_error,
|
||||||
|
family_code=family_code or "A")
|
||||||
|
self._reader.start()
|
||||||
|
self._repaint_timer.start()
|
||||||
|
|
||||||
|
self.setMode("live")
|
||||||
|
self.stateChanged.emit()
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
|
def stopStream(self) -> None:
|
||||||
|
self._repaint_timer.stop()
|
||||||
|
self._trend_timer.stop()
|
||||||
|
self._received_count = 0
|
||||||
|
self._error_count = 0
|
||||||
|
self.liveStatsChanged.emit()
|
||||||
|
if self._reader:
|
||||||
|
transport = self._reader._transport
|
||||||
|
|
||||||
|
# Send 'D2S' command padded to 512 bytes to stop the stream
|
||||||
|
# Send BEFORE stopping the reader which will close the port on thread exit
|
||||||
|
if transport and transport.is_open:
|
||||||
|
try:
|
||||||
|
cmd = b"D2S" + (b"F" * 509)
|
||||||
|
transport.write(cmd)
|
||||||
|
transport.flush()
|
||||||
|
except Exception as exc:
|
||||||
|
log.error("Error sending stop command: %s", exc)
|
||||||
|
|
||||||
|
self._reader.stop()
|
||||||
|
self._reader = None
|
||||||
|
|
||||||
|
self.stateChanged.emit()
|
||||||
|
self.stopRecording()
|
||||||
|
|
||||||
|
@Slot(object)
|
||||||
|
@slot_error_boundary
|
||||||
|
def _on_live_frame(self, frame: Frame) -> None:
|
||||||
|
import json
|
||||||
|
self._received_count += 1
|
||||||
|
self.liveStatsChanged.emit()
|
||||||
|
self._stats_tracker.track(frame.seq, frame.values)
|
||||||
|
# Main thread (queued). Record raw, process edited; mark dirty for repaint.
|
||||||
|
self._last_raw_frame = frame
|
||||||
|
edited = Frame(seq=frame.seq, time=frame.time,
|
||||||
|
values=self._apply_pipeline(frame.values))
|
||||||
|
self._last = self._model.process(edited)
|
||||||
|
if self._recorder.is_recording:
|
||||||
|
self._recorder.write(frame) # always record raw values, never edited
|
||||||
|
self._dirty = True
|
||||||
|
self.trendData.emit(json.dumps(self._stats_tracker.get_trend()))
|
||||||
|
|
||||||
|
if self._last and self._last.stats:
|
||||||
|
avg = self._last.stats.avg
|
||||||
|
now = time.monotonic()
|
||||||
|
self._trend_buffer.append(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:
|
||||||
|
if not self._dirty:
|
||||||
|
return
|
||||||
|
self._dirty = False
|
||||||
|
self.frameUpdated.emit()
|
||||||
|
self.stateChanged.emit()
|
||||||
|
|
||||||
|
def _on_live_error(self) -> None:
|
||||||
|
"""Main-thread callback for worker-thread stream errors."""
|
||||||
|
self._error_count += 1
|
||||||
|
self.liveStatsChanged.emit()
|
||||||
|
|
||||||
|
# ---- recording ----
|
||||||
|
@Slot(str, str)
|
||||||
|
@slot_error_boundary
|
||||||
|
def startRecording(self, path: str, serial: str = "") -> None:
|
||||||
|
self._recorder.start(path, self._sensors, serial)
|
||||||
|
self.recordingChanged.emit()
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
|
def stopRecording(self) -> None:
|
||||||
|
if self._recorder.is_recording:
|
||||||
|
self._recorder.stop()
|
||||||
|
self.recordingChanged.emit()
|
||||||
|
|
||||||
|
@Slot(int, float)
|
||||||
|
@slot_error_boundary
|
||||||
|
def replaceSensor(self, index: int, value: float) -> None:
|
||||||
|
"""Override sensor `index` to display `value` every frame."""
|
||||||
|
self._sensor_editor.set_replacement(index, value)
|
||||||
|
self._reprocess_current()
|
||||||
|
|
||||||
|
@Slot(int, float)
|
||||||
|
@slot_error_boundary
|
||||||
|
def offsetSensor(self, index: int, delta: float) -> None:
|
||||||
|
"""Shift sensor `index` by `delta` every frame."""
|
||||||
|
self._sensor_editor.set_offset(index, delta)
|
||||||
|
self._reprocess_current()
|
||||||
|
|
||||||
|
@Slot(int)
|
||||||
|
@slot_error_boundary
|
||||||
|
def clearSensorEdit(self, index: int) -> None:
|
||||||
|
"""Remove all overrides for sensor `index`."""
|
||||||
|
self._sensor_editor.clear(index)
|
||||||
|
self._reprocess_current()
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
|
def clearSensorEdits(self) -> None:
|
||||||
|
"""Remove all sensor overrides."""
|
||||||
|
self._sensor_editor.clear()
|
||||||
|
self._reprocess_current()
|
||||||
|
|
||||||
|
def _flush_trend(self) -> None:
|
||||||
|
import json
|
||||||
|
self.trendData.emit(json.dumps(self._trend_buffer))
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# ===== Crypto Sub-package =====
|
||||||
|
from pygui.backend.crypto.crypto_helper import * # noqa: F403
|
||||||
@@ -2,8 +2,8 @@ import secrets
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Union
|
from typing import Union
|
||||||
|
|
||||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
||||||
from cryptography.hazmat.backends import default_backend
|
from cryptography.hazmat.backends import default_backend
|
||||||
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||||
|
|
||||||
|
|
||||||
# ===== Crypto Utilities =====
|
# ===== Crypto Utilities =====
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# ===== Data Sub-package =====
|
||||||
|
from pygui.backend.data.csv_file_metadata import CSVFileMetadata
|
||||||
|
from pygui.backend.data.csv_recorder import CsvRecorder
|
||||||
|
from pygui.backend.data.data_records import is_official_csv, read_data_records, read_official_csv
|
||||||
|
from pygui.backend.data.file_browser import FileBrowser
|
||||||
|
from pygui.backend.data.local_settings import LocalSettings
|
||||||
|
from pygui.backend.data.local_settings_model import LocalSettingsModel
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CsvRecorder", "CSVFileMetadata",
|
||||||
|
"read_data_records", "is_official_csv", "read_official_csv",
|
||||||
|
"FileBrowser",
|
||||||
|
"LocalSettings", "LocalSettingsModel",
|
||||||
|
]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# ===== Backend Data Constants =====
|
||||||
|
|
||||||
|
DEFAULT_DATA_DIR_NAME = "isc_data"
|
||||||
@@ -25,8 +25,8 @@ class CSVFileMetadata:
|
|||||||
try:
|
try:
|
||||||
return datetime.strptime(self.date, date_format)
|
return datetime.strptime(self.date, date_format)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
# If format is wrong or date is None, return current time
|
# If format is wrong or date is None, return None
|
||||||
return datetime.now()
|
return None
|
||||||
|
|
||||||
# ===== Formatting =====
|
# ===== Formatting =====
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -48,4 +48,5 @@ class CSVFileMetadata:
|
|||||||
|
|
||||||
def string_date_format(self) -> str:
|
def string_date_format(self) -> str:
|
||||||
"""Helper to ensure the date is always a string for JSON."""
|
"""Helper to ensure the date is always a string for JSON."""
|
||||||
return self.format_date(self.get_date())
|
dt = self.get_date()
|
||||||
|
return self.format_date(dt) if dt else self.date
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Append live frames"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, TextIO
|
||||||
|
|
||||||
|
from pygui.backend.models.frame import Frame
|
||||||
|
from pygui.backend.wafer.zwafer_models import Sensor
|
||||||
|
|
||||||
|
|
||||||
|
class CsvRecorder:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._file_handle: Optional[TextIO] = None
|
||||||
|
self._oath: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_recording(self) -> bool:
|
||||||
|
return self._file_handle is not None
|
||||||
|
|
||||||
|
|
||||||
|
def start(self, path: str, sensors:list[Sensor], serial: str = "") -> None:
|
||||||
|
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
file_handle = open(path, "w", encoding="utf-8", newline ="")
|
||||||
|
file_handle.write(f"Wafer ID={serial}\n")
|
||||||
|
file_handle.write(f"Acquisition Date={datetime.now():%m/%d/%Y}\n")
|
||||||
|
file_handle.write("Label," + ",".join(s.label for s in sensors) + "\n")
|
||||||
|
file_handle.write("X (mm)," + ",".join(str(s.x) for s in sensors) + "\n")
|
||||||
|
file_handle.write("Y (mm)," + ",".join(str(s.y) for s in sensors) + "\n")
|
||||||
|
file_handle.write("data\n")
|
||||||
|
file_handle.flush()
|
||||||
|
self._file_handle, self._path = file_handle, path
|
||||||
|
|
||||||
|
def write(self, frame: Frame) -> None:
|
||||||
|
if self._file_handle is None:
|
||||||
|
return
|
||||||
|
row = [f"{frame.time:.3f}"] + [f"{v:.3f}" for v in frame.values]
|
||||||
|
self._file_handle.write(",".join(row) + "\n")
|
||||||
|
self._file_handle.flush()
|
||||||
|
|
||||||
|
def stop(self) -> Optional[str]:
|
||||||
|
if self._file_handle is None:
|
||||||
|
return None
|
||||||
|
self._file_handle.close()
|
||||||
|
path, self._file_handle, self._path = self._path, None, None
|
||||||
|
return path
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def write_segment(file_path: str, source_path: str, start_frame: int, end_frame: int) ->bool:
|
||||||
|
"""Copy a range of frames from source CSV into a new file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Output CSV path.
|
||||||
|
source_path: Source CSV path with full wafer data
|
||||||
|
start_frame: First frame index (inclusive).
|
||||||
|
end_frame: Last frame index (inclusive)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True on success.
|
||||||
|
"""
|
||||||
|
import pandas as pd
|
||||||
|
df = pd.read_csv(source_path)
|
||||||
|
segment = df.iloc[start_frame:end_frame + 1]
|
||||||
|
segment.to_csv(file_path, index=False)
|
||||||
|
return True
|
||||||
|
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""Read the data section of a Z-wafer CSV into DataRecords, or the official headerless CSV."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pygui.backend.wafer.zwafer_models import DataRecord, Sensor
|
||||||
|
|
||||||
|
|
||||||
|
def read_data_records(file_path: str) -> list[DataRecord]:
|
||||||
|
records: list[DataRecord] = []
|
||||||
|
in_data = False
|
||||||
|
with Path(file_path).open("r", encoding="utf-8") as file_handle:
|
||||||
|
for line in file_handle:
|
||||||
|
line = line.rstrip().rstrip(",").strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
if not in_data:
|
||||||
|
if line.split(",")[0].lower() == "data":
|
||||||
|
in_data = True
|
||||||
|
continue
|
||||||
|
cols = [c.strip() for c in line.split(",") if c.strip() != ""]
|
||||||
|
try:
|
||||||
|
nums = [float(c) for c in cols]
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if not nums:
|
||||||
|
continue
|
||||||
|
records.append(DataRecord(time=nums[0], values=nums[1:]))
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def is_official_csv(file_path: str) -> bool:
|
||||||
|
"""Return True if the file uses the headerless official format (sensor-name header row, no 'data' sentinel)."""
|
||||||
|
with Path(file_path).open("r", encoding="utf-8") as f:
|
||||||
|
first = f.readline().rstrip().rstrip(",")
|
||||||
|
if not first:
|
||||||
|
return False
|
||||||
|
parts = [p.strip() for p in first.split(",") if p.strip()]
|
||||||
|
# Official format: first cell looks like "SensorN" (starts with letter, not key=value)
|
||||||
|
return bool(parts) and parts[0].lower().startswith("sensor") and "=" not in parts[0]
|
||||||
|
|
||||||
|
|
||||||
|
def read_official_csv(file_path: str) -> tuple[list[Sensor], list[DataRecord]]:
|
||||||
|
"""Parse the headerless official CSV: row 0 = sensor names, rows 1+ = values (no timestamp)."""
|
||||||
|
from pygui.backend.wafer.wafer_layouts import load_layout_for_wafer_id
|
||||||
|
|
||||||
|
stem = Path(file_path).stem
|
||||||
|
wafer_id = stem.split("-")[0] if "-" in stem else stem
|
||||||
|
try:
|
||||||
|
sensors = load_layout_for_wafer_id(wafer_id)
|
||||||
|
except KeyError:
|
||||||
|
return [], []
|
||||||
|
|
||||||
|
records: list[DataRecord] = []
|
||||||
|
with Path(file_path).open("r", encoding="utf-8") as f:
|
||||||
|
f.readline() # skip sensor-name header
|
||||||
|
for i, line in enumerate(f):
|
||||||
|
line = line.rstrip().rstrip(",")
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
cols = [c.strip() for c in line.split(",") if c.strip()]
|
||||||
|
try:
|
||||||
|
values = [float(c) for c in cols]
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if values:
|
||||||
|
records.append(DataRecord(time=float(i) * 0.5, values=values))
|
||||||
|
return sensors, records
|
||||||
@@ -5,11 +5,12 @@ import re
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PySide6.QtCore import QObject, Property, QStandardPaths, Signal, Slot
|
from PySide6.QtCore import Property, QObject, QStandardPaths, Signal, Slot
|
||||||
from PySide6.QtWidgets import QFileDialog, QMessageBox
|
from PySide6.QtWidgets import QFileDialog, QMessageBox
|
||||||
|
|
||||||
from backend.csv_file_metadata import CSVFileMetadata
|
from pygui.backend.data.constants import DEFAULT_DATA_DIR_NAME
|
||||||
from backend.zwafer_parser import ZWaferParser
|
from pygui.backend.data.csv_file_metadata import CSVFileMetadata
|
||||||
|
from pygui.backend.wafer.zwafer_parser import ZWaferParser
|
||||||
|
|
||||||
|
|
||||||
# ===== File Browser Model =====
|
# ===== File Browser Model =====
|
||||||
@@ -118,31 +119,63 @@ class FileBrowser(QObject):
|
|||||||
return
|
return
|
||||||
|
|
||||||
for csv_path in sorted(self._current_directory.glob("*.csv")):
|
for csv_path in sorted(self._current_directory.glob("*.csv")):
|
||||||
|
# Recorded-live CSVs are named "live_<serial>_<timestamp>.csv" by
|
||||||
|
# SessionController.startRecording (see WaferMapTab's Record button),
|
||||||
|
# distinct from the "<serial>-<timestamp>.csv" convention used by
|
||||||
|
# DeviceController.parseAndSaveData for read-memory dumps.
|
||||||
|
is_recording = csv_path.stem.lower().startswith("live_")
|
||||||
try:
|
try:
|
||||||
metadata = self._load_metadata(csv_path)
|
metadata = self._load_metadata(csv_path)
|
||||||
|
wafer_type = metadata.get_wafer_type().upper() or (csv_path.stem[0].upper() if csv_path.stem else "")
|
||||||
|
date_str = metadata.string_date_format()
|
||||||
|
serial = metadata.wafer[1:] if len(metadata.wafer) > 1 else ""
|
||||||
|
if not serial:
|
||||||
|
stem = csv_path.stem
|
||||||
|
serial = stem[1:] if len(stem) > 1 else ""
|
||||||
|
if "-" in serial:
|
||||||
|
serial = serial.split("-")[0]
|
||||||
|
if "_" in serial:
|
||||||
|
serial = serial.split("_")[0]
|
||||||
|
time_str = date_str[11:] if len(date_str) > 10 else ""
|
||||||
self._files.append(
|
self._files.append(
|
||||||
{
|
{
|
||||||
"selected": selected_state.get(str(csv_path), False),
|
"selected": selected_state.get(str(csv_path), False),
|
||||||
|
"baseName": csv_path.stem,
|
||||||
"wafer": metadata.wafer,
|
"wafer": metadata.wafer,
|
||||||
"date": metadata.string_date_format(),
|
"waferType": wafer_type,
|
||||||
|
"serialNumber": serial,
|
||||||
|
"date": date_str,
|
||||||
|
"timeStr": time_str,
|
||||||
"chamber": metadata.chamber,
|
"chamber": metadata.chamber,
|
||||||
"notes": metadata.notes,
|
"notes": metadata.notes,
|
||||||
"masterType": master_state.get(str(csv_path), "") or metadata.master_type,
|
"masterType": master_state.get(str(csv_path), "") or metadata.master_type,
|
||||||
"fileName": str(csv_path),
|
"fileName": str(csv_path),
|
||||||
"highlight": metadata.get_wafer_type() in {"A", "B", "C"},
|
"highlight": wafer_type in {"A", "B", "C"},
|
||||||
|
"isRecording": is_recording,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
stem = csv_path.stem
|
||||||
|
serial_fallback = stem[1:] if len(stem) > 1 else ""
|
||||||
|
if "-" in serial_fallback:
|
||||||
|
serial_fallback = serial_fallback.split("-")[0]
|
||||||
|
if "_" in serial_fallback:
|
||||||
|
serial_fallback = serial_fallback.split("_")[0]
|
||||||
self._files.append(
|
self._files.append(
|
||||||
{
|
{
|
||||||
"selected": selected_state.get(str(csv_path), False),
|
"selected": selected_state.get(str(csv_path), False),
|
||||||
"wafer": csv_path.stem,
|
"baseName": stem,
|
||||||
|
"wafer": stem,
|
||||||
|
"waferType": stem[0].upper() if stem else "",
|
||||||
|
"serialNumber": serial_fallback,
|
||||||
"date": "",
|
"date": "",
|
||||||
|
"timeStr": "",
|
||||||
"chamber": "",
|
"chamber": "",
|
||||||
"notes": "Unable to parse metadata",
|
"notes": "Unable to parse metadata",
|
||||||
"masterType": master_state.get(str(csv_path), ""),
|
"masterType": master_state.get(str(csv_path), ""),
|
||||||
"fileName": str(csv_path),
|
"fileName": str(csv_path),
|
||||||
"highlight": False,
|
"highlight": False,
|
||||||
|
"isRecording": is_recording,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -168,25 +201,19 @@ class FileBrowser(QObject):
|
|||||||
# Fall back to CSV/header parsing if sidecar is malformed.
|
# Fall back to CSV/header parsing if sidecar is malformed.
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
meta = self._metadata_from_filename(csv_path)
|
||||||
parser_data, _ = self._parser.parse(str(csv_path))
|
parser_data, _ = self._parser.parse(str(csv_path))
|
||||||
if parser_data is not None:
|
if parser_data is not None:
|
||||||
wafer = parser_data.serial or ""
|
if parser_data.serial:
|
||||||
date_text = ""
|
meta.wafer = parser_data.serial
|
||||||
if parser_data.date != datetime.min:
|
if parser_data.date != datetime.min:
|
||||||
date_text = CSVFileMetadata.format_date(parser_data.date)
|
meta.date = CSVFileMetadata.format_date(parser_data.date)
|
||||||
return CSVFileMetadata(
|
|
||||||
wafer=wafer,
|
|
||||||
date=date_text,
|
|
||||||
chamber="",
|
|
||||||
notes="",
|
|
||||||
filename=str(csv_path),
|
|
||||||
)
|
|
||||||
|
|
||||||
return self._metadata_from_filename(csv_path)
|
return meta
|
||||||
|
|
||||||
def _metadata_from_filename(self, csv_path: Path) -> CSVFileMetadata:
|
def _metadata_from_filename(self, csv_path: Path) -> CSVFileMetadata:
|
||||||
match = re.match(
|
match = re.match(
|
||||||
r"^(?P<wafer>[^-]+)-(?P<date>\d{8})(?:_(?P<run>\d+))?$",
|
r"^(?P<wafer>[^-]+)-(?P<date>\d{8})(?:_(?P<time>\d{6}))?$",
|
||||||
csv_path.stem,
|
csv_path.stem,
|
||||||
)
|
)
|
||||||
if not match:
|
if not match:
|
||||||
@@ -194,7 +221,9 @@ class FileBrowser(QObject):
|
|||||||
|
|
||||||
date_text = ""
|
date_text = ""
|
||||||
try:
|
try:
|
||||||
parsed = datetime.strptime(match.group("date"), "%Y%m%d")
|
date_part = match.group("date")
|
||||||
|
time_part = match.group("time") or "000000"
|
||||||
|
parsed = datetime.strptime(date_part + time_part, "%Y%m%d%H%M%S")
|
||||||
date_text = CSVFileMetadata.format_date(parsed)
|
date_text = CSVFileMetadata.format_date(parsed)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
date_text = ""
|
date_text = ""
|
||||||
@@ -211,7 +240,7 @@ class FileBrowser(QObject):
|
|||||||
QStandardPaths.DocumentsLocation
|
QStandardPaths.DocumentsLocation
|
||||||
)
|
)
|
||||||
base_dir = Path(documents_dir) if documents_dir else (Path.home() / "Documents")
|
base_dir = Path(documents_dir) if documents_dir else (Path.home() / "Documents")
|
||||||
return base_dir / "isc_data"
|
return base_dir / DEFAULT_DATA_DIR_NAME
|
||||||
|
|
||||||
def _set_current_directory(self, directory: Path) -> None:
|
def _set_current_directory(self, directory: Path) -> None:
|
||||||
normalized = Path(directory)
|
normalized = Path(directory)
|
||||||
@@ -236,7 +265,7 @@ class FileBrowser(QObject):
|
|||||||
def showInfo(self, message: str) -> None:
|
def showInfo(self, message: str) -> None:
|
||||||
self._show_info(message)
|
self._show_info(message)
|
||||||
|
|
||||||
@Slot(str, result="QVariantMap")
|
@Slot(str, result=dict)
|
||||||
def parseCsvMetadata(self, file_path: str) -> dict:
|
def parseCsvMetadata(self, file_path: str) -> dict:
|
||||||
from pathlib import Path as _Path
|
from pathlib import Path as _Path
|
||||||
|
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# ===== Settings Persistence Model =====
|
# ===== Settings Persistence Model =====
|
||||||
class LocalSettings:
|
class LocalSettings:
|
||||||
@@ -29,6 +32,9 @@ class LocalSettings:
|
|||||||
self.data_col_count = 0
|
self.data_col_count = 0
|
||||||
self.last_csv_path = ""
|
self.last_csv_path = ""
|
||||||
|
|
||||||
|
# Session persistence
|
||||||
|
self.session_last_file = ""
|
||||||
|
|
||||||
# ===== File Path Helpers =====
|
# ===== File Path Helpers =====
|
||||||
@classmethod
|
@classmethod
|
||||||
def _settings_path(cls, directory: str) -> Path:
|
def _settings_path(cls, directory: str) -> Path:
|
||||||
@@ -51,7 +57,8 @@ class LocalSettings:
|
|||||||
if hasattr(settings, key):
|
if hasattr(settings, key):
|
||||||
setattr(settings, key, value)
|
setattr(settings, key, value)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error reading setting file: {e}")
|
log = logging.getLogger(__name__)
|
||||||
|
log.warning("Error reading setting file: %s",e)
|
||||||
|
|
||||||
return settings
|
return settings
|
||||||
|
|
||||||
@@ -66,7 +73,7 @@ class LocalSettings:
|
|||||||
with open(path, "w", encoding="utf-8") as f:
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
json.dump(data, f, indent=4)
|
json.dump(data, f, indent=4)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error saving settings: {e}")
|
log.exception("Error saving settings: %s",e)
|
||||||
|
|
||||||
# ===== Master File Helpers =====
|
# ===== Master File Helpers =====
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -4,10 +4,10 @@ from copy import deepcopy
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from PySide6.QtCore import QObject, Property, QDateTime, QStandardPaths, Signal, Slot
|
from PySide6.QtCore import Property, QDateTime, QObject, QStandardPaths, Signal, Slot
|
||||||
|
|
||||||
from backend.local_settings import LocalSettings
|
|
||||||
|
|
||||||
|
from pygui.backend.data.constants import DEFAULT_DATA_DIR_NAME
|
||||||
|
from pygui.backend.data.local_settings import LocalSettings
|
||||||
|
|
||||||
MASTER_FAMILIES = ("A", "B", "C", "D", "E", "F", "P", "X", "Z")
|
MASTER_FAMILIES = ("A", "B", "C", "D", "E", "F", "P", "X", "Z")
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ class LocalSettingsModel(QObject):
|
|||||||
QStandardPaths.DocumentsLocation
|
QStandardPaths.DocumentsLocation
|
||||||
)
|
)
|
||||||
base_dir = Path(documents_dir) if documents_dir else (Path.home() / "Documents")
|
base_dir = Path(documents_dir) if documents_dir else (Path.home() / "Documents")
|
||||||
return base_dir / "isc_data"
|
return base_dir / DEFAULT_DATA_DIR_NAME
|
||||||
|
|
||||||
def _new_defaults(self) -> dict[str, Any]:
|
def _new_defaults(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
@@ -221,7 +221,7 @@ class LocalSettingsModel(QObject):
|
|||||||
self.waferRetriesChanged.emit()
|
self.waferRetriesChanged.emit()
|
||||||
self._recompute_derived()
|
self._recompute_derived()
|
||||||
|
|
||||||
@Property("QVariantMap", notify=mastersChanged)
|
@Property(dict, notify=mastersChanged)
|
||||||
def masters(self) -> dict[str, str]:
|
def masters(self) -> dict[str, str]:
|
||||||
return dict(self._masters)
|
return dict(self._masters)
|
||||||
|
|
||||||
@@ -334,27 +334,3 @@ class LocalSettingsModel(QObject):
|
|||||||
@Slot(str)
|
@Slot(str)
|
||||||
def clearMaster(self, family: str) -> None:
|
def clearMaster(self, family: str) -> None:
|
||||||
self.setMaster(family, "")
|
self.setMaster(family, "")
|
||||||
|
|
||||||
@Slot(str)
|
|
||||||
def setChamberId(self, value: str) -> None:
|
|
||||||
self.chamberId = value
|
|
||||||
|
|
||||||
@Slot(bool)
|
|
||||||
def setReverseZWafer(self, value: bool) -> None:
|
|
||||||
self.reverseZWafer = value
|
|
||||||
|
|
||||||
@Slot(bool)
|
|
||||||
def setDebugMode(self, value: bool) -> None:
|
|
||||||
self.debugMode = value
|
|
||||||
|
|
||||||
@Slot(int)
|
|
||||||
def setWaferReadTimeout(self, value: int) -> None:
|
|
||||||
self.waferReadTimeout = value
|
|
||||||
|
|
||||||
@Slot(int)
|
|
||||||
def setWaferDetectTimeout(self, value: int) -> None:
|
|
||||||
self.waferDetectTimeout = value
|
|
||||||
|
|
||||||
@Slot(int)
|
|
||||||
def setWaferRetries(self, value: int) -> None:
|
|
||||||
self.waferRetries = value
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# ===== Models Sub-package =====
|
||||||
|
from pygui.backend.models.data_model import TemperatureTableModel
|
||||||
|
from pygui.backend.models.frame import Frame
|
||||||
|
from pygui.backend.models.frame_player import FramePlayer, frames_from_wafer_data
|
||||||
|
from pygui.backend.models.frame_stats import Stats, compute_stats
|
||||||
|
from pygui.backend.models.sensor_editor import SensorEditor
|
||||||
|
from pygui.backend.models.session_model import SessionModel, SessionUpdate
|
||||||
|
from pygui.backend.models.stability_detector import StabilityDetector
|
||||||
|
from pygui.backend.models.threshold_classifier import ThresholdConfig, classify, resolve_bounds
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Frame", "FramePlayer", "frames_from_wafer_data",
|
||||||
|
"Stats", "compute_stats",
|
||||||
|
"SessionModel", "SessionUpdate",
|
||||||
|
"TemperatureTableModel",
|
||||||
|
"SensorEditor",
|
||||||
|
"ThresholdConfig", "classify", "resolve_bounds",
|
||||||
|
"StabilityDetector",
|
||||||
|
]
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Frame:
|
||||||
|
"""One sample across all sensors ata a point in time"""
|
||||||
|
|
||||||
|
seq: int # monotonically increasing
|
||||||
|
time: float # seconds (relative or epoch)
|
||||||
|
values: list[float] # one per sensor, in sensor-layout order
|
||||||