Compare commits
10 Commits
b417211476
...
831457941a
| Author | SHA1 | Date | |
|---|---|---|---|
| 831457941a | |||
| cfadbdf1c3 | |||
| f4621f1faf | |||
| 2cbc143c20 | |||
| 470f8acba3 | |||
| 62ce5a9c37 | |||
| 20a34e22ee | |||
| 7206334a27 | |||
| 5514653b94 | |||
| 93868bcde4 |
+1
-2
@@ -22,6 +22,7 @@ env/
|
|||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
docs
|
docs
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
@@ -39,5 +40,3 @@ dist/
|
|||||||
*.spec
|
*.spec
|
||||||
*.icns
|
*.icns
|
||||||
*.ico
|
*.ico
|
||||||
# Superpowers brainstorming visual companion
|
|
||||||
.superpowers/
|
|
||||||
|
|||||||
+7
-3
@@ -49,7 +49,7 @@ files = [
|
|||||||
"src/pygui/ISC/Tabs/qmldir",
|
"src/pygui/ISC/Tabs/qmldir",
|
||||||
"src/pygui/__main__.py",
|
"src/pygui/__main__.py",
|
||||||
"src/pygui/backend/contour_models.py",
|
"src/pygui/backend/contour_models.py",
|
||||||
"src/pygui/backend/crypto_helper.py",
|
"src/pygui/backend/crypto/crypto_helper.py",
|
||||||
"src/pygui/backend/csv_file_metadata.py",
|
"src/pygui/backend/csv_file_metadata.py",
|
||||||
"src/pygui/backend/data_model.py",
|
"src/pygui/backend/data_model.py",
|
||||||
"src/pygui/backend/data_segment.py",
|
"src/pygui/backend/data_segment.py",
|
||||||
@@ -103,7 +103,7 @@ environments = [
|
|||||||
# ===== Ruff Linter and Formatter =====
|
# ===== Ruff Linter and Formatter =====
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
target-version = "py311"
|
target-version = "py311"
|
||||||
line-length = 100
|
line-length = 125
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = [
|
select = [
|
||||||
@@ -113,7 +113,11 @@ select = [
|
|||||||
"I", # isort (import sorting)
|
"I", # isort (import sorting)
|
||||||
"N", # pep8-naming
|
"N", # pep8-naming
|
||||||
]
|
]
|
||||||
ignore = []
|
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 =====
|
# ===== Mypy Static Type Checker =====
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
|
|||||||
+120
-16
@@ -2,6 +2,7 @@ import QtQuick
|
|||||||
import QtQuick.Controls
|
import QtQuick.Controls
|
||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
import ISC
|
import ISC
|
||||||
|
import QtQuick.Dialogs
|
||||||
|
|
||||||
// ===== Home Workspace Shell =====
|
// ===== Home Workspace Shell =====
|
||||||
Rectangle {
|
Rectangle {
|
||||||
@@ -13,16 +14,26 @@ Rectangle {
|
|||||||
border.width: Theme.borderStrong
|
border.width: Theme.borderStrong
|
||||||
|
|
||||||
// ===== Navigation Model =====
|
// ===== 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"]
|
// TODO P1.1: Delete these 3 property blocks — superseded by mockup rail
|
||||||
|
//
|
||||||
|
// THINKING:
|
||||||
|
// The new rail uses a pill-tab-bar for navigation (Status/Data/Map) instead
|
||||||
|
// of sideActions[] + bottomTabs[]. The Repeaters below that consume these
|
||||||
|
// arrays are also deleted with the old layout (P1.1).
|
||||||
|
// selectedSideActionIndex is dead — the new rail has no concept of a
|
||||||
|
// globally-selected side action. selectedTabIndex stays, redefined below.
|
||||||
|
//
|
||||||
|
// Cross-ref: P1.2 (pill tabs), P1.3 (context panels), P1.4 (workspace)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
property var sideActions: ["DETECT WAFER", "READ MEMORY", "ERASE MEMORY"]
|
||||||
|
|
||||||
// ===== Footer Tab Model =====
|
// ===== Footer Tab Model =====
|
||||||
// Footer tabs drive the active workspace section.
|
property var bottomTabs: ["Status", "Data", "Wafer Map", "Settings"]
|
||||||
property var bottomTabs: ["Status", "Graph", "Data", "Wafer Map", "Compare", "Split", "Settings", "About"]
|
|
||||||
|
|
||||||
// ===== View State =====
|
// ===== View State =====
|
||||||
property int selectedTabIndex: 0
|
property int selectedTabIndex: 0
|
||||||
property int selectedSideActionIndex: -1 // nothing active on startup
|
property int selectedSideActionIndex: -1
|
||||||
|
|
||||||
property bool memoryRead: false
|
property bool memoryRead: false
|
||||||
property bool waferDetected: false
|
property bool waferDetected: false
|
||||||
@@ -59,7 +70,92 @@ Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TODO P1.1: Delete importFileDialog + storedDataDialog (and the
|
||||||
|
// `selectedTabIndex = 3` lines inside them)
|
||||||
|
//
|
||||||
|
// THINKING:
|
||||||
|
// These dialogs were only opened from the old side-rail buttons
|
||||||
|
// "IMPORT DATA" (index 4) and "STORED DATA" (index 5). With the
|
||||||
|
// mockup rail, the Map tab's file list (P1.3) replaces both — it
|
||||||
|
// drives `streamController.loadFile()` directly from a file_browser
|
||||||
|
// Repeater. Keeping these dead dialogs would bloat the QML and
|
||||||
|
// confuse future contributors.
|
||||||
|
//
|
||||||
|
// `selectedTabIndex = 3` is also stale (Map tab is index 2 now).
|
||||||
|
// The replacement flow: file_row.onClicked → loadFile() →
|
||||||
|
// selectedTabIndex = 2.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
FileDialog {
|
||||||
|
id: importFileDialog
|
||||||
|
title: "Import CSV / ZWafer file"
|
||||||
|
nameFilters: ["CSV files (*.csv)", "ZWafer files (*.zwafer)", "All files (*)"]
|
||||||
|
onAccepted: {
|
||||||
|
var path = root.cleanFolderUrl(selectedFile)
|
||||||
|
streamController.setMode("review")
|
||||||
|
streamController.stopStream()
|
||||||
|
streamController.loadFile(path)
|
||||||
|
root.selectedTabIndex = 3 // Switch to Wafer Map tab
|
||||||
|
root.selectedSideActionIndex = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FileDialog {
|
||||||
|
id: storedDataDialog
|
||||||
|
title: "Open Stored CSV File"
|
||||||
|
nameFilters: ["CSV files (*.csv)", "All files (*)"]
|
||||||
|
onAccepted: {
|
||||||
|
var path = root.cleanFolderUrl(selectedFile)
|
||||||
|
streamController.setMode("review")
|
||||||
|
streamController.stopStream()
|
||||||
|
streamController.loadFile(path)
|
||||||
|
root.selectedTabIndex = 3 // Switch to Wafer Map tab
|
||||||
|
root.selectedSideActionIndex = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ===== Main Two-Column Layout ======
|
// ===== Main Two-Column Layout ======
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TODO P1.1: Delete the ENTIRE RowLayout below (lines ~112-341) — old
|
||||||
|
// side-rail + workspace + footer-tab-strip.
|
||||||
|
//
|
||||||
|
// THINKING:
|
||||||
|
// This ~230-line RowLayout is being replaced by:
|
||||||
|
// P1.2 — Pill tab bar (BOX 1, top of rail)
|
||||||
|
// P1.3 — Context-sensitive panel (BOX 2, mid rail)
|
||||||
|
// P1.5 — Hardware status footer (BOX 3, below BOX 2)
|
||||||
|
// P1.6 — Settings/About utility buttons (BOX 4, rail bottom)
|
||||||
|
// P1.4 — Workspace StackLayout (fills right side)
|
||||||
|
//
|
||||||
|
// None of the old structure (Repeater for sideActions[], Repeater for
|
||||||
|
// bottomTabs[], footer tab strip) survives. Keeping it would cause
|
||||||
|
// double-rendering or visual conflicts.
|
||||||
|
//
|
||||||
|
// Keep: saveDirDialog (FolderDialog), cleanFolderUrl(), _doDetect(),
|
||||||
|
// and all Connections blocks (lines 31-61).
|
||||||
|
//
|
||||||
|
// Cross-ref: P1.2, P1.3, P1.4, P1.5, P1.6
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
RowLayout {
|
RowLayout {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
spacing: 0
|
spacing: 0
|
||||||
@@ -92,10 +188,8 @@ Rectangle {
|
|||||||
switch (index) {
|
switch (index) {
|
||||||
case 0: return true // DETECT WAFER
|
case 0: return true // DETECT WAFER
|
||||||
case 1: return root.waferDetected // READ MEMORY
|
case 1: return root.waferDetected // READ MEMORY
|
||||||
case 2: return root.memoryRead // OPEN CSV IN EXCEL
|
case 2: return root.waferDetected // ERASE MEMORY
|
||||||
case 3: return root.waferDetected // ERASE MEMORY
|
default: return true
|
||||||
case 4: return root.memoryRead // IMPORT DATA
|
|
||||||
default: return true // STORED DATA
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
property bool isActive: index === root.selectedSideActionIndex
|
property bool isActive: index === root.selectedSideActionIndex
|
||||||
@@ -106,10 +200,11 @@ Rectangle {
|
|||||||
root.selectedSideActionIndex = index
|
root.selectedSideActionIndex = index
|
||||||
root.selectedTabIndex = 0 // always jump to Status tab
|
root.selectedTabIndex = 0 // always jump to Status tab
|
||||||
if (index === 0) {
|
if (index === 0) {
|
||||||
root.memoryRead = false
|
if (!deviceController.saveDataDir) {
|
||||||
streamController.setMode("review")
|
saveDirDialog.open()
|
||||||
streamController.stopStream()
|
return
|
||||||
deviceController.detectWafer()
|
}
|
||||||
|
root._doDetect()
|
||||||
}
|
}
|
||||||
else if (index === 1) {
|
else if (index === 1) {
|
||||||
streamController.setMode("review")
|
streamController.setMode("review")
|
||||||
@@ -117,7 +212,10 @@ Rectangle {
|
|||||||
deviceController.readMemoryAsync()
|
deviceController.readMemoryAsync()
|
||||||
}
|
}
|
||||||
else if (index === 2) {
|
else if (index === 2) {
|
||||||
deviceController.openCsvFile()
|
// ERASE MEMORY
|
||||||
|
streamController.setMode("review")
|
||||||
|
streamController.stopStream()
|
||||||
|
deviceController.eraseMemory(deviceController.selectedPort || "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,17 +312,23 @@ Rectangle {
|
|||||||
|
|
||||||
Label {
|
Label {
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
visible: parent.tabName !== "Settings" && parent.tabName !== "Status" && parent.tabName !== "Data" && parent.tabName !== "Wafer Map"
|
visible: parent.tabName !== "Settings" && parent.tabName !== "Status" && parent.tabName !== "Data" && parent.tabName !== "Wafer Map" && parent.tabName !== "Graph"
|
||||||
text: parent.tabName + " content"
|
text: parent.tabName + " content"
|
||||||
color: Theme.bodyColor
|
color: Theme.bodyColor
|
||||||
font.pixelSize: 20
|
font.pixelSize: 20
|
||||||
}
|
}
|
||||||
|
|
||||||
Loader{
|
Loader {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
active: parent.tabName === "Wafer Map"
|
active: parent.tabName === "Wafer Map"
|
||||||
source: parent.tabName === "Wafer Map" ? "Tabs/WaferMapTab.qml" : ""
|
source: parent.tabName === "Wafer Map" ? "Tabs/WaferMapTab.qml" : ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loader {
|
||||||
|
anchors.fill: parent
|
||||||
|
active: parent.tabName === "Graph"
|
||||||
|
source: parent.tabName === "Graph" ? "Tabs/GraphTab.qml" : ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import QtQuick.Controls
|
import QtQuick.Controls
|
||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
|
import QtQuick.TableView
|
||||||
import ISC
|
import ISC
|
||||||
|
|
||||||
// ===== Data Tab =====
|
// ===== Data Tab =====
|
||||||
@@ -74,6 +75,29 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Toolbar ---
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: "Open in Excel"
|
||||||
|
icon.source: "icon/excel.svg"
|
||||||
|
onClicked: deviceController.openCsvFile()
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: "Compare"
|
||||||
|
onClicked: compareDialog.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: "Split"
|
||||||
|
onClicked: splitDialog.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { Layout.fillWidth: true}
|
||||||
|
}
|
||||||
// --- Table container ---
|
// --- Table container ---
|
||||||
Rectangle {
|
Rectangle {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ISC
|
||||||
|
import ISC.Wafer
|
||||||
|
|
||||||
|
// ===== Graph Tab =====
|
||||||
|
// Displays sensor temperature line charts using GraphQuickItem.
|
||||||
|
// Gets data from deviceController.getChartData() (parsed CSV, batch read)
|
||||||
|
// or from streamController.trendData (live streaming trend).
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
anchors.fill: parent
|
||||||
|
|
||||||
|
property var _chartData: null // cached result from getChartData()
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Theme.panelPadding
|
||||||
|
spacing: Theme.rightPaneGap
|
||||||
|
|
||||||
|
// ── Toolbar ───────────────────────────────────────────────────────────
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 10
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "Line Chart"
|
||||||
|
color: Theme.headingColor
|
||||||
|
font.pixelSize: 13
|
||||||
|
font.bold: true
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { Layout.fillWidth: true }
|
||||||
|
|
||||||
|
// Refresh button — pulls data from deviceController
|
||||||
|
Button {
|
||||||
|
id: refreshBtn
|
||||||
|
text: "REFRESH"
|
||||||
|
font.pixelSize: 10
|
||||||
|
font.weight: Font.Medium
|
||||||
|
implicitHeight: 26
|
||||||
|
implicitWidth: 72
|
||||||
|
hoverEnabled: true
|
||||||
|
background: Rectangle {
|
||||||
|
color: parent.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
}
|
||||||
|
contentItem: Text {
|
||||||
|
text: parent.text
|
||||||
|
color: Theme.headingColor
|
||||||
|
font: parent.font
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
onClicked: reloadData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mode selector (Trend / Full chart) ─────────────────────────────
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "Source:"
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: 10
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
ComboBox {
|
||||||
|
id: sourceSelector
|
||||||
|
model: ["Parsed Data", "Live Trend"]
|
||||||
|
currentIndex: 0
|
||||||
|
implicitHeight: 26
|
||||||
|
font.pixelSize: 11
|
||||||
|
background: Rectangle {
|
||||||
|
color: Theme.fieldBackground
|
||||||
|
border.color: Theme.fieldBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
}
|
||||||
|
contentItem: Text {
|
||||||
|
text: sourceSelector.displayText
|
||||||
|
color: Theme.fieldText
|
||||||
|
font: sourceSelector.font
|
||||||
|
horizontalAlignment: Text.AlignLeft
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
leftPadding: 8
|
||||||
|
}
|
||||||
|
onCurrentIndexChanged: reloadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { Layout.fillWidth: true }
|
||||||
|
|
||||||
|
Label {
|
||||||
|
id: statusLabel
|
||||||
|
text: "Ready"
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: 10
|
||||||
|
font.italic: true
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Chart Area ─────────────────────────────────────────────────────────
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
color: Theme.cardBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusMd
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
GraphQuickItem {
|
||||||
|
id: graph
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 2
|
||||||
|
backgroundColor: Theme.cardBackground
|
||||||
|
gridColor: Theme.softBorder
|
||||||
|
axisColor: Theme.bodyColor
|
||||||
|
textColor: Theme.headingColor
|
||||||
|
seriesColors: [Theme.sensorLow, Theme.sensorHigh, Theme.sensorInRange,
|
||||||
|
"#FFA726", "#AB47BC", "#00BCD4"]
|
||||||
|
showLegend: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Data Loading ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function reloadData() {
|
||||||
|
if (sourceSelector.currentIndex === 0) {
|
||||||
|
// Parsed data from DeviceController
|
||||||
|
loadParsedData()
|
||||||
|
} else {
|
||||||
|
// Live trend from SessionController
|
||||||
|
loadLiveTrend()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadParsedData() {
|
||||||
|
statusLabel.text = "Loading..."
|
||||||
|
var result = deviceController.getChartData()
|
||||||
|
if (!result || !result.success) {
|
||||||
|
graph.seriesData = []
|
||||||
|
graph.sensorNames = []
|
||||||
|
graph.title = "Sensor Temperature Over Time"
|
||||||
|
statusLabel.text = "No data — read a wafer first"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
graph.seriesData = result.series || []
|
||||||
|
graph.sensorNames = result.sensor_names || []
|
||||||
|
graph.title = "Sensor Temperature Over Time"
|
||||||
|
statusLabel.text = (result.series ? result.series.length : 0) + " sensors loaded"
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadLiveTrend() {
|
||||||
|
// Live trend: connects to streamController.trendData
|
||||||
|
// The trend data comes as a JSON array of per-frame averages
|
||||||
|
statusLabel.text = "Connecting to live trend..."
|
||||||
|
// We need at least one data point
|
||||||
|
if (streamController.stats && streamController.stats.avg !== undefined) {
|
||||||
|
// Build a single-series graph from the trend buffer
|
||||||
|
// For now show a placeholder — the trendData signal handles updates
|
||||||
|
graph.title = "Live Average Temperature"
|
||||||
|
graph.yLabel = "Avg Temperature (°C)"
|
||||||
|
graph.xLabel = "Time (s)"
|
||||||
|
statusLabel.text = "Live trend — waiting for data..."
|
||||||
|
} else {
|
||||||
|
statusLabel.text = "No live data — start a stream on the Wafer Map tab"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Connections ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: deviceController
|
||||||
|
function onParsedDataReady(result) {
|
||||||
|
if (sourceSelector.currentIndex === 0) {
|
||||||
|
root.reloadData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: streamController
|
||||||
|
function onTrendData(avgsJson) {
|
||||||
|
if (sourceSelector.currentIndex === 1) {
|
||||||
|
try {
|
||||||
|
var avgs = JSON.parse(avgsJson)
|
||||||
|
if (avgs && avgs.length > 0) {
|
||||||
|
graph.seriesData = [avgs]
|
||||||
|
graph.sensorNames = ["Average"]
|
||||||
|
graph.title = "Live Average Temperature"
|
||||||
|
graph.yLabel = "Avg Temperature (°C)"
|
||||||
|
graph.xLabel = "Frame"
|
||||||
|
statusLabel.text = "Live: " + avgs.length + " data points"
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore parse errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// On startup, try to load parsed data if available
|
||||||
|
Component.onCompleted: {
|
||||||
|
if (deviceController.dataRowCount > 0) {
|
||||||
|
reloadData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -225,7 +225,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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -296,7 +296,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import QtQuick.Dialogs
|
|||||||
import ISC
|
import ISC
|
||||||
|
|
||||||
// ===== Status Tab =====
|
// ===== Status Tab =====
|
||||||
// Blank on startup. Fills in after Detect Wafer fires from the left rail.
|
// Shows connection status, wafer info, and activity log.
|
||||||
|
// Initially visible — displays connection status and activity log from start.
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
id: root
|
id: root
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
@@ -19,11 +20,6 @@ ColumnLayout {
|
|||||||
property alias waferCycles: waferCycles.text
|
property alias waferCycles: waferCycles.text
|
||||||
property bool waferDetected: false
|
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
|
// Data summary after parse
|
||||||
property int dataRows: 0
|
property int dataRows: 0
|
||||||
property int dataCols: 0
|
property int dataCols: 0
|
||||||
@@ -45,31 +41,22 @@ ColumnLayout {
|
|||||||
|
|
||||||
FolderDialog {
|
FolderDialog {
|
||||||
id: saveDirDialog
|
id: saveDirDialog
|
||||||
title: "Choose CSV Save Folder"
|
title: "Choose a folder to save."
|
||||||
onAccepted: {
|
onAccepted: {
|
||||||
deviceController.setSaveDataDir(root.cleanFolderUrl(selectedFolder))
|
deviceController.setSaveDataDir(root.cleanFolderUrl(selectedFolder))
|
||||||
root.parseAndSavePendingRead()
|
root.parseAndSavePendingRead()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 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 {
|
ColumnLayout {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
visible: root.statusActive
|
|
||||||
spacing: Theme.rightPaneGap
|
spacing: Theme.rightPaneGap
|
||||||
|
|
||||||
// --- Connection Status ---
|
// --- Connection Status ---
|
||||||
Rectangle {
|
Rectangle {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.preferredHeight: 86
|
Layout.preferredHeight: 92
|
||||||
color: Theme.cardBackground
|
color: Theme.cardBackground
|
||||||
border.color: {
|
border.color: {
|
||||||
if (deviceController.connectionStatus === "Connected") return Theme.statusSuccessColor
|
if (deviceController.connectionStatus === "Connected") return Theme.statusSuccessColor
|
||||||
@@ -102,13 +89,44 @@ ColumnLayout {
|
|||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
}
|
}
|
||||||
|
|
||||||
Text {
|
RowLayout {
|
||||||
text: "Save dir: " + deviceController.saveDataDir
|
|
||||||
color: Theme.bodyColor
|
|
||||||
font.pixelSize: 12
|
|
||||||
opacity: 0.7
|
|
||||||
elide: Text.ElideMiddle
|
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
|
spacing: 8
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "Save dir: " + (deviceController.saveDataDir || "None selected")
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: 12
|
||||||
|
opacity: 0.7
|
||||||
|
elide: Text.ElideMiddle
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: browseBtn
|
||||||
|
text: "BROWSE..."
|
||||||
|
font.pixelSize: 9
|
||||||
|
font.weight: Font.Medium
|
||||||
|
implicitHeight: 20
|
||||||
|
implicitWidth: 65
|
||||||
|
hoverEnabled: true
|
||||||
|
background: Rectangle {
|
||||||
|
color: parent.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
}
|
||||||
|
contentItem: Text {
|
||||||
|
text: parent.text
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font: parent.font
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
onClicked: saveDirDialog.open()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -296,7 +314,7 @@ ColumnLayout {
|
|||||||
TextArea {
|
TextArea {
|
||||||
id: activityLog
|
id: activityLog
|
||||||
width: parent.width
|
width: parent.width
|
||||||
text: ""
|
text: qsTr("Welcome — use the side rail to detect a wafer, or import data to review.")
|
||||||
readOnly: true
|
readOnly: true
|
||||||
font.family: "monospace"
|
font.family: "monospace"
|
||||||
font.pixelSize: 12
|
font.pixelSize: 12
|
||||||
@@ -318,15 +336,21 @@ ColumnLayout {
|
|||||||
|
|
||||||
// --- Signal Handlers ---
|
// --- Signal Handlers ---
|
||||||
Connections {
|
Connections {
|
||||||
target: deviceController
|
target: deviceController
|
||||||
// Any operation start (detect/read/erase/debug) latches the panel on.
|
|
||||||
function onPortsUpdated() {
|
|
||||||
if (deviceController.operationInProgress)
|
|
||||||
root.statusActive = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDetectResult(result) {
|
function onLogMessage(message) {
|
||||||
root.statusActive = true
|
if (message == "CHOOSE_SAVE_DIR"){
|
||||||
|
saveDirDialog.open()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any operation start (detect/read/erase/debug) latches the panel on.
|
||||||
|
function onPortsUpdated() {
|
||||||
|
// Status tab always visible now
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function onDetectResult(result) {
|
||||||
if (result && result.familyCode) {
|
if (result && result.familyCode) {
|
||||||
root.waferDetected = true
|
root.waferDetected = true
|
||||||
waferInfoFamily.text = result.familyCode
|
waferInfoFamily.text = result.familyCode
|
||||||
@@ -342,7 +366,6 @@ ColumnLayout {
|
|||||||
// Show restored wafer info if available
|
// Show restored wafer info if available
|
||||||
var info = deviceController.lastWaferInfo
|
var info = deviceController.lastWaferInfo
|
||||||
if (info && info.length > 0) {
|
if (info && info.length > 0) {
|
||||||
root.statusActive = true
|
|
||||||
root.waferDetected = true
|
root.waferDetected = true
|
||||||
waferInfoFamily.text = info[0] || "—"
|
waferInfoFamily.text = info[0] || "—"
|
||||||
waferSerial.text = info[1] || "—"
|
waferSerial.text = info[1] || "—"
|
||||||
@@ -352,7 +375,6 @@ ColumnLayout {
|
|||||||
}
|
}
|
||||||
// Show data summary if data was previously parsed
|
// Show data summary if data was previously parsed
|
||||||
if (deviceController.dataRowCount > 0) {
|
if (deviceController.dataRowCount > 0) {
|
||||||
root.statusActive = true
|
|
||||||
root.dataParsed = true
|
root.dataParsed = true
|
||||||
root.dataRows = deviceController.dataRowCount
|
root.dataRows = deviceController.dataRowCount
|
||||||
root.dataCols = deviceController.dataColCount
|
root.dataCols = deviceController.dataColCount
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import QtQuick.Controls
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Dialogs
|
||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
import ISC
|
import ISC
|
||||||
import ISC.Tabs.components
|
import ISC.Tabs.components
|
||||||
|
import ISC.Wafer
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
@@ -23,6 +25,16 @@ Item {
|
|||||||
var ss = s % 60
|
var ss = s % 60
|
||||||
return (m < 10 ? "0" : "") + m + ":" + (ss < 10 ? "0" : "") + ss
|
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.
|
||||||
|
Connections {
|
||||||
|
target: streamController
|
||||||
|
function onTrendData(avgsJson) {
|
||||||
|
trendChart.setDataFromJson(avgsJson)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
@@ -37,7 +49,7 @@ Item {
|
|||||||
// Mode toggle
|
// Mode toggle
|
||||||
TabBar {
|
TabBar {
|
||||||
id: modeBar
|
id: modeBar
|
||||||
currentIndex: 1 // Default to "Review"
|
currentIndex: 0 // Default to "Review"
|
||||||
spacing: 2
|
spacing: 2
|
||||||
padding: 2
|
padding: 2
|
||||||
background: Rectangle {
|
background: Rectangle {
|
||||||
@@ -46,6 +58,22 @@ Item {
|
|||||||
border.color: Theme.cardBorder
|
border.color: Theme.cardBorder
|
||||||
border.width: Theme.borderThin
|
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: 11
|
||||||
|
font.weight: parent.checked ? Font.Medium : Font.Normal
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
TabButton {
|
TabButton {
|
||||||
text: "Live"
|
text: "Live"
|
||||||
enabled: deviceController.connectionStatus === "Connected"
|
enabled: deviceController.connectionStatus === "Connected"
|
||||||
@@ -64,33 +92,20 @@ Item {
|
|||||||
verticalAlignment: Text.AlignVCenter
|
verticalAlignment: Text.AlignVCenter
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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: 11
|
|
||||||
font.weight: parent.checked ? Font.Medium : Font.Normal
|
|
||||||
horizontalAlignment: Text.AlignHCenter
|
|
||||||
verticalAlignment: Text.AlignVCenter
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onCurrentIndexChanged:
|
onCurrentIndexChanged:
|
||||||
if (currentIndex === 0){
|
if (currentIndex === 1){
|
||||||
// Entering Live mode, only work when wafer detected/connected
|
// Guard: revert to Review if not connected
|
||||||
streamController.setMode("live")
|
if (deviceController.connectionStatus !== "Connected") {
|
||||||
if (deviceController.connectionStatus === "Connected") {
|
currentIndex = 0
|
||||||
var fc = ""
|
return
|
||||||
if (deviceController.lastWaferInfo && deviceController.lastWaferInfo.length > 0) {
|
|
||||||
fc = deviceController.lastWaferInfo[0]
|
|
||||||
}
|
|
||||||
streamController.startStream(deviceController.selectedPort, fc)
|
|
||||||
}
|
}
|
||||||
|
// 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 {
|
} else {
|
||||||
// Entering Review Mode (automatically calls stopStream in backend)
|
// Entering Review Mode (automatically calls stopStream in backend)
|
||||||
streamController.setMode("review")
|
streamController.setMode("review")
|
||||||
@@ -172,34 +187,62 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// LIVE timer
|
// LIVE timer
|
||||||
RowLayout {
|
// RowLayout {
|
||||||
spacing: 5
|
// spacing: 5
|
||||||
visible: streamController.mode === "live" && streamController.state !== "idle"
|
// visible: streamController.mode === "live" && streamController.state !== "idle"
|
||||||
Rectangle {
|
// Rectangle {
|
||||||
width: 7; height: 7; radius: 4
|
// width: 7; height: 7; radius: 4
|
||||||
color: Theme.liveColor
|
// color: Theme.liveColor
|
||||||
}
|
// }
|
||||||
Label {
|
// Label {
|
||||||
text: "LIVE " + root.fmtTime(root._liveSecs)
|
// text: "LIVE " + root.fmtTime(root._liveSecs)
|
||||||
color: Theme.liveColor
|
// color: Theme.liveColor
|
||||||
font.pixelSize: 10
|
// font.pixelSize: 10
|
||||||
font.weight: Font.Medium
|
// font.weight: Font.Medium
|
||||||
font.letterSpacing: 0.8
|
// font.letterSpacing: 0.8
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
// IDLE label (review / not connected)
|
// IDLE label (review / not connected)
|
||||||
Label {
|
// Label {
|
||||||
visible: streamController.mode === "review" || streamController.state === "idle"
|
// visible: streamController.mode === "review" || streamController.state === "idle"
|
||||||
text: streamController.state.toUpperCase()
|
// text: streamController.state.toUpperCas`e`()
|
||||||
color: Theme.bodyColor
|
// color: Theme.bodyColor
|
||||||
font.pixelSize: 11
|
// font.pixelSize: 11
|
||||||
font.weight: Font.Medium
|
// font.weight: Font.Medium
|
||||||
font.letterSpacing: 1.2
|
// font.letterSpacing: 1.2
|
||||||
|
// }
|
||||||
|
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:\/\//, ""))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Body: 3 zones ─────────────────────────────────────────────────
|
// ── Body: 3 zones (TODO P2.1: becomes 2 zones) ──────────────────────
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// TODO P2.1: Delete the SourcePanel Rectangle (lines ~219-233) —
|
||||||
|
// the file list moved to the rail (P1.3 Map context panel).
|
||||||
|
//
|
||||||
|
// THINKING:
|
||||||
|
// SourcePanel shows a file list driven by file_browser — now that
|
||||||
|
// the Map tab's context panel in the rail (P1.3) provides the same
|
||||||
|
// file list with search/filter, SourcePanel in the wafer body is
|
||||||
|
// redundant. The wafer map gets more horizontal space.
|
||||||
|
//
|
||||||
|
// After deletion: RowLayout has 2 children instead of 3.
|
||||||
|
// Add Layout.fillWidth: true to the center ColumnLayout so it
|
||||||
|
// fills the vacated width.
|
||||||
|
//
|
||||||
|
// Cross-ref: P1.3 (Map context panel), P2.1 (this task)
|
||||||
|
// -------------------------------------------------------------------
|
||||||
RowLayout {
|
RowLayout {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
@@ -233,20 +276,29 @@ Item {
|
|||||||
blend: readoutPanel.heatmapBlend
|
blend: readoutPanel.heatmapBlend
|
||||||
showLabels: readoutPanel.showLabels
|
showLabels: readoutPanel.showLabels
|
||||||
}
|
}
|
||||||
|
Rectangle {
|
||||||
// Horizontal separation line with vertical padding
|
id: trendPane
|
||||||
Item {
|
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
implicitHeight: 32
|
implicitHeight: 160
|
||||||
Rectangle {
|
visible: trendChart.hasData
|
||||||
anchors.centerIn: parent
|
color: Theme.cardBackground
|
||||||
width: parent.width
|
border.color: Theme.cardBorder
|
||||||
height: 1
|
border.width: 1
|
||||||
color: Theme.cardBorder
|
radius: Theme.radiusMd
|
||||||
|
|
||||||
|
TrendChartItem {
|
||||||
|
id: trendChart
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 8
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
TransportBar { Layout.fillWidth: true }
|
Item { Layout.fillWidth: true; implicitHeight: 16 }
|
||||||
|
TransportBar {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
visible: streamController.mode !== "live" || trendChart.hasData
|
||||||
|
height: visible ? implicitHeight : 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Readout panel — bordered card
|
// Readout panel — bordered card
|
||||||
|
|||||||
@@ -175,6 +175,14 @@ ColumnLayout {
|
|||||||
bottomPadding: 8
|
bottomPadding: 8
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PanelCheckBox {
|
||||||
|
id: thicknessToggle
|
||||||
|
text: "Show Thickness"
|
||||||
|
checked: false
|
||||||
|
font.pixelSize: 11
|
||||||
|
onCheckedChanged: waferMapItem.showThickness = checked
|
||||||
|
}
|
||||||
|
|
||||||
PanelCheckBox {
|
PanelCheckBox {
|
||||||
id: labelsToggle
|
id: labelsToggle
|
||||||
text: "Labels"
|
text: "Labels"
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ Item {
|
|||||||
|
|
||||||
ReplaceSensorDialog { id: replaceDialog}
|
ReplaceSensorDialog { id: replaceDialog}
|
||||||
|
|
||||||
|
function exportImage(filePath) {
|
||||||
|
return map.export_image(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<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="#000000"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -1,6 +1,7 @@
|
|||||||
# ===== ISC Tabs Module =====
|
# ===== ISC Tabs Module =====
|
||||||
module ISC.Tabs
|
module ISC.Tabs
|
||||||
|
|
||||||
|
GraphTab 1.0 GraphTab.qml
|
||||||
WaferMapTab 1.0 WaferMapTab.qml
|
WaferMapTab 1.0 WaferMapTab.qml
|
||||||
DataTab 1.0 DataTab.qml
|
DataTab 1.0 DataTab.qml
|
||||||
SettingsTab 1.0 SettingsTab.qml
|
SettingsTab 1.0 SettingsTab.qml
|
||||||
|
|||||||
+13
-2
@@ -12,7 +12,7 @@ from pygui.backend.data.file_browser import FileBrowser
|
|||||||
from pygui.backend.data.local_settings import LocalSettings
|
from pygui.backend.data.local_settings import LocalSettings
|
||||||
from pygui.backend.data.local_settings_model import LocalSettingsModel
|
from pygui.backend.data.local_settings_model import LocalSettingsModel
|
||||||
|
|
||||||
# Importing wafer_map_item registers the @QmlElement WaferMapItem (QML: import ISC.Wafer).
|
# Importing wafer_map_item + trend_chart_item registers @QmlElement types (QML: import ISC.Wafer).
|
||||||
|
|
||||||
|
|
||||||
# ===== Application Entry Point =====
|
# ===== Application Entry Point =====
|
||||||
@@ -44,9 +44,20 @@ def main() -> int:
|
|||||||
engine.rootContext().setContextProperty("deviceController", device_controller)
|
engine.rootContext().setContextProperty("deviceController", device_controller)
|
||||||
|
|
||||||
# ===== Session Controller (live/review wafer dashboard) =====
|
# ===== Session Controller (live/review wafer dashboard) =====
|
||||||
stream_controller = SessionController()
|
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)
|
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 =====
|
# ===== QML Startup =====
|
||||||
# The "ISC" QML module lives alongside this file (src/pygui/ISC), so the
|
# The "ISC" QML module lives alongside this file (src/pygui/ISC), so the
|
||||||
# package directory is the import path the engine searches for qmldir.
|
# package directory is the import path the engine searches for qmldir.
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ from pygui.backend.controllers.session_controller import SessionController # no
|
|||||||
from pygui.backend.data.csv_file_metadata import CSVFileMetadata # 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.csv_recorder import CsvRecorder # noqa: F401
|
||||||
from pygui.backend.data.data_records import read_data_records # noqa: F401
|
from pygui.backend.data.data_records import read_data_records # noqa: F401
|
||||||
from pygui.backend.data.data_segment import DataSegment # noqa: F401
|
|
||||||
from pygui.backend.data.file_browser import FileBrowser # 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 import LocalSettings # noqa: F401
|
||||||
from pygui.backend.data.local_settings_model import LocalSettingsModel # noqa: F401
|
from pygui.backend.data.local_settings_model import LocalSettingsModel # noqa: F401
|
||||||
@@ -19,7 +18,6 @@ from pygui.backend.models.sensor_editor import SensorEditor # noqa: F401
|
|||||||
from pygui.backend.models.session_model import SessionModel # 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.stability_detector import StabilityDetector # noqa: F401
|
||||||
from pygui.backend.models.threshold_classifier import ThresholdConfig # noqa: F401
|
from pygui.backend.models.threshold_classifier import ThresholdConfig # noqa: F401
|
||||||
from pygui.backend.visualization.contour_models import ContourLine, ContourSegment # noqa: F401
|
|
||||||
from pygui.backend.visualization.graph_view import GraphView # 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.rbf_heatmap import interpolate_field # noqa: F401
|
||||||
from pygui.backend.visualization.wafer_map_item import WaferMapItem # noqa: F401
|
from pygui.backend.visualization.wafer_map_item import WaferMapItem # noqa: F401
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ from typing import List, Optional, Tuple
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from pygui.backend.visualization.contour_models import ContourLine, ContourSegment
|
from pygui.backend.attic.contour_models import ContourLine, ContourSegment
|
||||||
|
|
||||||
|
|
||||||
# ===== Contour Generation =====
|
# ===== Contour Generation =====
|
||||||
@@ -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()))
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ from PySide6.QtCore import Property, QObject, Qt, Signal, Slot
|
|||||||
from pygui.backend.controllers.session_controller import SessionController
|
from pygui.backend.controllers.session_controller import SessionController
|
||||||
from pygui.backend.data.local_settings import LocalSettings
|
from pygui.backend.data.local_settings import LocalSettings
|
||||||
from pygui.backend.models.data_model import TemperatureTableModel
|
from pygui.backend.models.data_model import TemperatureTableModel
|
||||||
|
from pygui.backend.utils import slot_error_boundary
|
||||||
from pygui.backend.visualization.graph_view import GraphView
|
from pygui.backend.visualization.graph_view import GraphView
|
||||||
from pygui.serialcomm.data_parser import (
|
from pygui.serialcomm.data_parser import (
|
||||||
convert_to_temperatures,
|
convert_to_temperatures,
|
||||||
@@ -57,8 +58,8 @@ class DeviceController(QObject):
|
|||||||
self._data_dir = data_dir
|
self._data_dir = data_dir
|
||||||
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 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] = getattr(settings, 'activity_log', [])
|
||||||
self._raw_bytes: Optional[bytes] = None
|
self._raw_bytes: Optional[bytes] = None
|
||||||
@@ -86,16 +87,29 @@ class DeviceController(QObject):
|
|||||||
self._append_log(f"Last wafer: {info.get('serialNumber', 'Unknown')} ({info.get('familyCode', 'Unknown')})")
|
self._append_log(f"Last wafer: {info.get('serialNumber', 'Unknown')} ({info.get('familyCode', 'Unknown')})")
|
||||||
self.statusRestored.emit()
|
self.statusRestored.emit()
|
||||||
|
|
||||||
|
# Reset connection status on fresh start
|
||||||
|
self._connection_status = "Disconnected"
|
||||||
|
|
||||||
# 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.ConnectionType.QueuedConnection)
|
self._detectFinished.connect(self._handle_detect_finished, Qt.ConnectionType.QueuedConnection)
|
||||||
self._readFinished.connect(self._handle_read_finished, Qt.ConnectionType.QueuedConnection)
|
self._readFinished.connect(self._handle_read_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)
|
||||||
def availablePorts(self) -> list[str]:
|
def availablePorts(self) -> list[str]:
|
||||||
"""Currently available serial port names."""
|
"""Currently available serial port names."""
|
||||||
return self._service.enumerate_ports()
|
return self._service.enumerate_ports()
|
||||||
|
|
||||||
|
def _set_connection_status(self, status: str) -> None:
|
||||||
|
"""Update connection status and notify QML."""
|
||||||
|
if self._connection_status != status:
|
||||||
|
self._connection_status = status
|
||||||
|
self.portsUpdated.emit(self.availablePorts)
|
||||||
|
|
||||||
@Property(str, notify=portsUpdated)
|
@Property(str, notify=portsUpdated)
|
||||||
def connectionStatus(self) -> str:
|
def connectionStatus(self) -> str:
|
||||||
"""Current connection status string for display."""
|
"""Current connection status string for display."""
|
||||||
@@ -112,6 +126,7 @@ 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
|
||||||
@@ -124,6 +139,7 @@ class DeviceController(QObject):
|
|||||||
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."""
|
||||||
self._selected_port = port
|
self._selected_port = port
|
||||||
@@ -166,6 +182,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,6 +221,7 @@ class DeviceController(QObject):
|
|||||||
self.activityLogUpdated.emit("\n".join(self._activity_log))
|
self.activityLogUpdated.emit("\n".join(self._activity_log))
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
def clearActivityLog(self) -> None:
|
def clearActivityLog(self) -> None:
|
||||||
"""Clear the activity log."""
|
"""Clear the activity log."""
|
||||||
self._activity_log.clear()
|
self._activity_log.clear()
|
||||||
@@ -211,9 +229,10 @@ class DeviceController(QObject):
|
|||||||
self._save_status()
|
self._save_status()
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
def clearSession(self) -> None:
|
def clearSession(self) -> None:
|
||||||
"""Clear the current session state, allowing a clean detect."""
|
"""Clear the current session state, allowing a clean detect."""
|
||||||
self._connection_status = "Disconnected"
|
self._set_connection_status("Disconnected")
|
||||||
self._selected_port = ""
|
self._selected_port = ""
|
||||||
self._last_wafer_info = {}
|
self._last_wafer_info = {}
|
||||||
self._data_row_count = 0
|
self._data_row_count = 0
|
||||||
@@ -225,6 +244,7 @@ class DeviceController(QObject):
|
|||||||
self._append_log("Session refreshed/cleared")
|
self._append_log("Session refreshed/cleared")
|
||||||
self._save_status()
|
self._save_status()
|
||||||
|
|
||||||
|
|
||||||
def _set_operation_progress(self, in_progress: bool) -> None:
|
def _set_operation_progress(self, in_progress: bool) -> None:
|
||||||
"""Set operation progress state and notify QML.
|
"""Set operation progress state and notify QML.
|
||||||
|
|
||||||
@@ -238,15 +258,17 @@ class DeviceController(QObject):
|
|||||||
# ---- 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."""
|
||||||
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).
|
||||||
|
|
||||||
@@ -259,7 +281,7 @@ class DeviceController(QObject):
|
|||||||
return
|
return
|
||||||
|
|
||||||
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 ...")
|
||||||
|
|
||||||
@@ -276,13 +298,14 @@ 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(
|
self._append_log(
|
||||||
f"Detected: {info.serial_number} (family={info.family_code}, "
|
f"Detected: {info.serial_number} (family={info.family_code}, "
|
||||||
f"port={port}, runtime={info.runtime}s, cycles={info.cycle_count})"
|
f"port={port}, runtime={info.runtime}s, cycles={info.cycle_count})"
|
||||||
@@ -293,13 +316,14 @@ class DeviceController(QObject):
|
|||||||
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.
|
||||||
|
|
||||||
@@ -320,7 +344,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'}) ..."
|
||||||
@@ -342,12 +366,13 @@ 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)})
|
||||||
@@ -355,7 +380,7 @@ class DeviceController(QObject):
|
|||||||
self._append_log("Memory read complete - choose save directory to save CSV")
|
self._append_log("Memory read complete - choose save directory to save CSV")
|
||||||
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"})
|
||||||
@@ -363,38 +388,40 @@ 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."""
|
||||||
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) ...")
|
||||||
|
|
||||||
ok = self._service.erase_wafer(port)
|
ok = self._service.erase_wafer(port)
|
||||||
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.
|
||||||
|
|
||||||
Emits debugResult with counts on success.
|
Emits debugResult with counts on success.
|
||||||
"""
|
"""
|
||||||
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} ...")
|
||||||
|
|
||||||
# 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._set_connection_status("Disconnected")
|
||||||
self._append_log("D1 read failed — aborting debug read")
|
self._append_log("D1 read failed — aborting debug read")
|
||||||
self.debugResult.emit({"error": "D1 read failed"})
|
self.debugResult.emit({"error": "D1 read failed"})
|
||||||
self._set_operation_progress(False)
|
self._set_operation_progress(False)
|
||||||
@@ -405,13 +432,13 @@ class DeviceController(QObject):
|
|||||||
# 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._set_connection_status("Disconnected")
|
||||||
self._append_log("F1 read failed")
|
self._append_log("F1 read failed")
|
||||||
self.debugResult.emit({"error": "F1 read failed"})
|
self.debugResult.emit({"error": "F1 read failed"})
|
||||||
self._set_operation_progress(False)
|
self._set_operation_progress(False)
|
||||||
return
|
return
|
||||||
|
|
||||||
self._connection_status = "Connected"
|
self._set_connection_status("Connected")
|
||||||
self._append_log(
|
self._append_log(
|
||||||
f"Debug read complete: {len(sensor_data)} sensor bytes, "
|
f"Debug read complete: {len(sensor_data)} sensor bytes, "
|
||||||
f"{len(debug_data)} debug bytes"
|
f"{len(debug_data)} debug bytes"
|
||||||
@@ -424,6 +451,7 @@ class DeviceController(QObject):
|
|||||||
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.
|
||||||
|
|
||||||
@@ -538,3 +566,14 @@ class DeviceController(QObject):
|
|||||||
"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:
|
||||||
|
timestamp = datetime.now().strftime("%H:%M:%S")
|
||||||
|
level = record.levelname
|
||||||
|
message = record.getMessage()
|
||||||
|
msg = f"[{timestamp}] {level}: {message}"
|
||||||
|
self._controller._append_log(msg)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""QML-facing controller for the live/review wafer dashboard."""
|
"""QML-facing controller for the live/review wafer dashboard."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -11,9 +12,11 @@ from pygui.backend.cluster_average import average_clusters, group_sensors_by_rad
|
|||||||
from pygui.backend.data.csv_recorder import CsvRecorder
|
from pygui.backend.data.csv_recorder import CsvRecorder
|
||||||
from pygui.backend.models.frame import Frame
|
from pygui.backend.models.frame import Frame
|
||||||
from pygui.backend.models.frame_player import FramePlayer, frames_from_wafer_data
|
from pygui.backend.models.frame_player import 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.sensor_editor import SensorEditor
|
||||||
from pygui.backend.models.session_model import SessionModel
|
from pygui.backend.models.session_model import SessionModel
|
||||||
from pygui.backend.models.threshold_classifier import ThresholdConfig
|
from pygui.backend.models.threshold_classifier import ThresholdConfig
|
||||||
|
from pygui.backend.utils import slot_error_boundary
|
||||||
from pygui.backend.wafer.zwafer_models import Sensor
|
from pygui.backend.wafer.zwafer_models import Sensor
|
||||||
from pygui.backend.wafer.zwafer_parser import ZWaferParser
|
from pygui.backend.wafer.zwafer_parser import ZWaferParser
|
||||||
from pygui.serialcomm.stream_reader import StreamReader
|
from pygui.serialcomm.stream_reader import StreamReader
|
||||||
@@ -33,10 +36,14 @@ class SessionController(QObject):
|
|||||||
sensorsChanged = Signal()
|
sensorsChanged = Signal()
|
||||||
loadedFileChanged = Signal()
|
loadedFileChanged = Signal()
|
||||||
clusterAveragingEnabledChanged = Signal()
|
clusterAveragingEnabledChanged = Signal()
|
||||||
|
settingsChanged = Signal() # emitted when session state changes that should be persisted
|
||||||
|
# trend: per-frame avg for live graph
|
||||||
|
trendData = Signal(str) # JSON array of floats
|
||||||
# private: marshal a worker-thread frame onto the main thread
|
# private: marshal a worker-thread frame onto the main thread
|
||||||
_liveFrame = Signal(object) # Frame
|
_liveFrame = Signal(object) # Frame
|
||||||
|
|
||||||
def __init__(self, parent: QObject | None = None) -> None:
|
def __init__(self, parent: QObject | None = None,
|
||||||
|
settings: dict | None = None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._mode = MODE_REVIEW
|
self._mode = MODE_REVIEW
|
||||||
self._model = SessionModel()
|
self._model = SessionModel()
|
||||||
@@ -46,6 +53,13 @@ class SessionController(QObject):
|
|||||||
self._sensors: list[Sensor] = []
|
self._sensors: list[Sensor] = []
|
||||||
self._last = None # last SessionUpdate
|
self._last = None # last SessionUpdate
|
||||||
self._elapsed = 0.0
|
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 = QTimer(self)
|
||||||
self._play_timer.timeout.connect(self._advance)
|
self._play_timer.timeout.connect(self._advance)
|
||||||
@@ -64,6 +78,22 @@ class SessionController(QObject):
|
|||||||
self._loaded_file: str = ""
|
self._loaded_file: str = ""
|
||||||
self._cluster_averaging_enabled = False
|
self._cluster_averaging_enabled = False
|
||||||
self._active_clusters: list[list[int]] = []
|
self._active_clusters: list[list[int]] = []
|
||||||
|
self._stats_tracker = ReplayStatsTracker()
|
||||||
|
|
||||||
|
# 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 ----
|
# ---- properties QML binds to ----
|
||||||
@Property(str, notify=modeChanged)
|
@Property(str, notify=modeChanged)
|
||||||
@@ -182,6 +212,7 @@ class SessionController(QObject):
|
|||||||
|
|
||||||
# ---- mode + thresholds ----
|
# ---- mode + thresholds ----
|
||||||
@Slot(str)
|
@Slot(str)
|
||||||
|
@slot_error_boundary
|
||||||
def setMode(self, mode: str) -> None:
|
def setMode(self, mode: str) -> None:
|
||||||
if mode == self._mode:
|
if mode == self._mode:
|
||||||
return
|
return
|
||||||
@@ -191,6 +222,7 @@ class SessionController(QObject):
|
|||||||
self.modeChanged.emit()
|
self.modeChanged.emit()
|
||||||
|
|
||||||
@Slot(float, float, bool)
|
@Slot(float, float, bool)
|
||||||
|
@slot_error_boundary
|
||||||
def setThresholds(self, set_point: float, margin: float, auto: bool) -> None:
|
def setThresholds(self, set_point: float, margin: float, auto: bool) -> None:
|
||||||
self._model.set_thresholds(ThresholdConfig(set_point, margin, auto))
|
self._model.set_thresholds(ThresholdConfig(set_point, margin, auto))
|
||||||
if self._last: # re-band current frame in place
|
if self._last: # re-band current frame in place
|
||||||
@@ -198,6 +230,7 @@ class SessionController(QObject):
|
|||||||
|
|
||||||
# ---- review: file load + playback ----
|
# ---- review: file load + playback ----
|
||||||
@Slot(str)
|
@Slot(str)
|
||||||
|
@slot_error_boundary
|
||||||
def loadFile(self, file_path: str) -> None:
|
def loadFile(self, file_path: str) -> None:
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -244,36 +277,49 @@ class SessionController(QObject):
|
|||||||
self._active_clusters = group_sensors_by_radius(self._sensors)
|
self._active_clusters = group_sensors_by_radius(self._sensors)
|
||||||
self._player.load(frames)
|
self._player.load(frames)
|
||||||
self._model.reset()
|
self._model.reset()
|
||||||
|
self._stats_tracker.reset()
|
||||||
self._loaded_file = file_path
|
self._loaded_file = file_path
|
||||||
self.loadedFileChanged.emit()
|
self.loadedFileChanged.emit()
|
||||||
self.sensorsChanged.emit()
|
self.sensorsChanged.emit()
|
||||||
self._emit_current()
|
self._emit_current()
|
||||||
|
# Populate stats tracker from all loaded frames for trend chart (P3.1)
|
||||||
|
for frame in self._player._frames:
|
||||||
|
self._stats_tracker.track(frame.seq, frame.values)
|
||||||
|
self.trendData.emit(json.dumps(self._stats_tracker.get_trend()))
|
||||||
|
self.settingsChanged.emit()
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
def play(self) -> None:
|
def play(self) -> None:
|
||||||
self._play_timer.start(self._next_interval_ms())
|
self._play_timer.start(self._next_interval_ms())
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
def pause(self) -> None: self._play_timer.stop()
|
@slot_error_boundary
|
||||||
|
def pause(self) -> None:
|
||||||
|
self._play_timer.stop()
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
self._play_timer.stop()
|
self._play_timer.stop()
|
||||||
self._player.seek(0)
|
self._player.seek(0)
|
||||||
self._emit_current()
|
self._emit_current()
|
||||||
|
|
||||||
@Slot(int)
|
@Slot(int)
|
||||||
|
@slot_error_boundary
|
||||||
def step(self, delta: int) -> None:
|
def step(self, delta: int) -> None:
|
||||||
self._play_timer.stop()
|
self._play_timer.stop()
|
||||||
self._player.step(delta)
|
self._player.step(delta)
|
||||||
self._emit_current()
|
self._emit_current()
|
||||||
|
|
||||||
@Slot(int)
|
@Slot(int)
|
||||||
|
@slot_error_boundary
|
||||||
def seek(self, i: int) -> None:
|
def seek(self, i: int) -> None:
|
||||||
self._player.seek(i)
|
self._player.seek(i)
|
||||||
self._emit_current()
|
self._emit_current()
|
||||||
|
|
||||||
@Slot(float)
|
@Slot(float)
|
||||||
|
@slot_error_boundary
|
||||||
def setSpeed(self, x: float) -> None:
|
def setSpeed(self, x: float) -> None:
|
||||||
self._speed = max(0.1, x)
|
self._speed = max(0.1, x)
|
||||||
if self._play_timer.isActive():
|
if self._play_timer.isActive():
|
||||||
@@ -325,6 +371,7 @@ class SessionController(QObject):
|
|||||||
|
|
||||||
# ---- live: stream start/stop ----
|
# ---- live: stream start/stop ----
|
||||||
@Slot(str, str)
|
@Slot(str, str)
|
||||||
|
@slot_error_boundary
|
||||||
def startStream(self, port: str, family_code: str = "") -> None:
|
def startStream(self, port: str, family_code: str = "") -> None:
|
||||||
|
|
||||||
from pygui.backend.wafer.wafer_layouts import load_layout_for_wafer_id
|
from pygui.backend.wafer.wafer_layouts import load_layout_for_wafer_id
|
||||||
@@ -366,6 +413,10 @@ class SessionController(QObject):
|
|||||||
|
|
||||||
# Clear out any old data from the prev sessions
|
# Clear out any old data from the prev sessions
|
||||||
self._model.reset()
|
self._model.reset()
|
||||||
|
self._trend_buffer.clear()
|
||||||
|
self._trend_timestamps.clear()
|
||||||
|
self._trend_timer.start()
|
||||||
|
self._stats_tracker.reset()
|
||||||
self._last = None
|
self._last = None
|
||||||
self._last_raw_frame = None
|
self._last_raw_frame = None
|
||||||
|
|
||||||
@@ -389,8 +440,10 @@ class SessionController(QObject):
|
|||||||
self.stateChanged.emit()
|
self.stateChanged.emit()
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
def stopStream(self) -> None:
|
def stopStream(self) -> None:
|
||||||
self._repaint_timer.stop()
|
self._repaint_timer.stop()
|
||||||
|
self._trend_timer.stop()
|
||||||
if self._reader:
|
if self._reader:
|
||||||
transport = self._reader._transport
|
transport = self._reader._transport
|
||||||
self._reader.stop()
|
self._reader.stop()
|
||||||
@@ -409,7 +462,10 @@ class SessionController(QObject):
|
|||||||
self.stopRecording()
|
self.stopRecording()
|
||||||
|
|
||||||
@Slot(object)
|
@Slot(object)
|
||||||
|
@slot_error_boundary
|
||||||
def _on_live_frame(self, frame: Frame) -> None:
|
def _on_live_frame(self, frame: Frame) -> None:
|
||||||
|
import json
|
||||||
|
self._stats_tracker.track(frame.seq, frame.values)
|
||||||
# Main thread (queued). Record raw, process edited; mark dirty for repaint.
|
# Main thread (queued). Record raw, process edited; mark dirty for repaint.
|
||||||
self._last_raw_frame = frame
|
self._last_raw_frame = frame
|
||||||
edited = Frame(seq=frame.seq, time=frame.time,
|
edited = Frame(seq=frame.seq, time=frame.time,
|
||||||
@@ -418,6 +474,22 @@ class SessionController(QObject):
|
|||||||
if self._recorder.is_recording:
|
if self._recorder.is_recording:
|
||||||
self._recorder.write(frame) # always record raw values, never edited
|
self._recorder.write(frame) # always record raw values, never edited
|
||||||
self._dirty = True
|
self._dirty = True
|
||||||
|
self.trendData.emit(json.dumps(self._stats_tracker.get_trend()))
|
||||||
|
|
||||||
|
if self._last and self._last.stats:
|
||||||
|
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:
|
def _flush_repaint(self) -> None:
|
||||||
if not self._dirty:
|
if not self._dirty:
|
||||||
@@ -428,38 +500,48 @@ class SessionController(QObject):
|
|||||||
|
|
||||||
# ---- recording ----
|
# ---- recording ----
|
||||||
@Slot(str, str)
|
@Slot(str, str)
|
||||||
|
@slot_error_boundary
|
||||||
def startRecording(self, path: str, serial: str = "") -> None:
|
def startRecording(self, path: str, serial: str = "") -> None:
|
||||||
self._recorder.start(path, self._sensors, serial)
|
self._recorder.start(path, self._sensors, serial)
|
||||||
self.recordingChanged.emit()
|
self.recordingChanged.emit()
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
def stopRecording(self) -> None:
|
def stopRecording(self) -> None:
|
||||||
if self._recorder.is_recording:
|
if self._recorder.is_recording:
|
||||||
self._recorder.stop()
|
self._recorder.stop()
|
||||||
self.recordingChanged.emit()
|
self.recordingChanged.emit()
|
||||||
|
|
||||||
@Slot(int, float)
|
@Slot(int, float)
|
||||||
|
@slot_error_boundary
|
||||||
def replaceSensor(self, index: int, value: float) -> None:
|
def replaceSensor(self, index: int, value: float) -> None:
|
||||||
"""Override sensor `index` to display `value` every frame."""
|
"""Override sensor `index` to display `value` every frame."""
|
||||||
self._sensor_editor.set_replacement(index, value)
|
self._sensor_editor.set_replacement(index, value)
|
||||||
self._reprocess_current()
|
self._reprocess_current()
|
||||||
|
|
||||||
@Slot(int, float)
|
@Slot(int, float)
|
||||||
|
@slot_error_boundary
|
||||||
def offsetSensor(self, index: int, delta: float) -> None:
|
def offsetSensor(self, index: int, delta: float) -> None:
|
||||||
"""Shift sensor `index` by `delta` every frame."""
|
"""Shift sensor `index` by `delta` every frame."""
|
||||||
self._sensor_editor.set_offset(index, delta)
|
self._sensor_editor.set_offset(index, delta)
|
||||||
self._reprocess_current()
|
self._reprocess_current()
|
||||||
|
|
||||||
@Slot(int)
|
@Slot(int)
|
||||||
|
@slot_error_boundary
|
||||||
def clearSensorEdit(self, index: int) -> None:
|
def clearSensorEdit(self, index: int) -> None:
|
||||||
"""Remove all overrides for sensor `index`."""
|
"""Remove all overrides for sensor `index`."""
|
||||||
self._sensor_editor.clear(index)
|
self._sensor_editor.clear(index)
|
||||||
self._reprocess_current()
|
self._reprocess_current()
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
def clearSensorEdits(self) -> None:
|
def clearSensorEdits(self) -> None:
|
||||||
"""Remove all sensor overrides."""
|
"""Remove all sensor overrides."""
|
||||||
self._sensor_editor.clear()
|
self._sensor_editor.clear()
|
||||||
self._reprocess_current()
|
self._reprocess_current()
|
||||||
|
|
||||||
|
def _flush_trend(self) -> None:
|
||||||
|
import json
|
||||||
|
self.trendData.emit(json.dumps(self._trend_buffer))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
from pygui.backend.data.csv_file_metadata import CSVFileMetadata
|
from pygui.backend.data.csv_file_metadata import CSVFileMetadata
|
||||||
from pygui.backend.data.csv_recorder import CsvRecorder
|
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.data_records import is_official_csv, read_data_records, read_official_csv
|
||||||
from pygui.backend.data.data_segment import DataSegment
|
|
||||||
from pygui.backend.data.file_browser import FileBrowser
|
from pygui.backend.data.file_browser import FileBrowser
|
||||||
from pygui.backend.data.local_settings import LocalSettings
|
from pygui.backend.data.local_settings import LocalSettings
|
||||||
from pygui.backend.data.local_settings_model import LocalSettingsModel
|
from pygui.backend.data.local_settings_model import LocalSettingsModel
|
||||||
@@ -10,7 +9,6 @@ from pygui.backend.data.local_settings_model import LocalSettingsModel
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"CsvRecorder", "CSVFileMetadata",
|
"CsvRecorder", "CSVFileMetadata",
|
||||||
"read_data_records", "is_official_csv", "read_official_csv",
|
"read_data_records", "is_official_csv", "read_official_csv",
|
||||||
"DataSegment",
|
|
||||||
"FileBrowser",
|
"FileBrowser",
|
||||||
"LocalSettings", "LocalSettingsModel",
|
"LocalSettings", "LocalSettingsModel",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -45,3 +45,23 @@ class CsvRecorder:
|
|||||||
self._file_handle.close()
|
self._file_handle.close()
|
||||||
path, self._file_handle, self._path = self._path, None, None
|
path, self._file_handle, self._path = self._path, None, None
|
||||||
return path
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,36 @@
|
|||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
class ReplayStatsTracker:
|
||||||
|
"""Accumulates per-frame statistics across a replay session."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.history: list[dict] = []
|
||||||
|
self.running_avg: float = 0.0
|
||||||
|
|
||||||
|
def track(self, frame_index: int, temperatures: list[float]) -> dict:
|
||||||
|
arr = np.array(temperatures, dtype=np.float32)
|
||||||
|
clean_arr = arr[np.isfinite(arr)]
|
||||||
|
|
||||||
|
if clean_arr.size == 0:
|
||||||
|
stats = {"frame": frame_index, "min":0.0, "max":0.0, "avg":0.0, "std":0.0}
|
||||||
|
else:
|
||||||
|
stats = {
|
||||||
|
"frame": frame_index,
|
||||||
|
"min": float(np.min(clean_arr)),
|
||||||
|
"max": float(np.max(clean_arr)),
|
||||||
|
"avg": float(np.mean(clean_arr)),
|
||||||
|
"std": float(np.std(clean_arr)),
|
||||||
|
}
|
||||||
|
|
||||||
|
self.history.append(stats)
|
||||||
|
self.running_avg = float(np.mean([s["avg"] for s in self.history]))
|
||||||
|
return stats
|
||||||
|
|
||||||
|
def get_trend(self) -> list[float]:
|
||||||
|
"""Return list of per-frame averages for the trend chart."""
|
||||||
|
return [s["avg"] for s in self.history]
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
self.history.clear()
|
||||||
|
self.running_avg = 0.0
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Threshold-based temperature profile segmentation into Ramp/Soak/Cool phases."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DataSegment:
|
||||||
|
"""A contiguous segment of the temperature profile."""
|
||||||
|
label: str
|
||||||
|
start_frame: int
|
||||||
|
end_frame: int
|
||||||
|
avg_temp: float
|
||||||
|
|
||||||
|
|
||||||
|
def segment_profile(avg_temps: list[float], threshold: float = 40.0) -> list[DataSegment]:
|
||||||
|
"""Scan average temperatures and identify Ramp/Soak/Cool phases.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
avg_temps: Per-frame average temperatures.
|
||||||
|
threshold: Temperature threshold (°C) defining Soak entry/exit.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of DataSegment objects covering the entire profile.
|
||||||
|
"""
|
||||||
|
if not avg_temps:
|
||||||
|
return []
|
||||||
|
|
||||||
|
segments: list[DataSegment] = []
|
||||||
|
n = len(avg_temps)
|
||||||
|
i = 0
|
||||||
|
|
||||||
|
while i < n and avg_temps[i] < threshold:
|
||||||
|
i += 1
|
||||||
|
if i > 0:
|
||||||
|
avg = sum(avg_temps[:i]) / i
|
||||||
|
segments.append(DataSegment("Ramp", 0, i - 1, round(avg, 2)))
|
||||||
|
|
||||||
|
while i < n:
|
||||||
|
start = i
|
||||||
|
above = avg_temps[i] >= threshold
|
||||||
|
while i < n and (avg_temps[i] >= threshold) == above:
|
||||||
|
i += 1
|
||||||
|
label = "Soak" if above else "Cool"
|
||||||
|
count = i - start
|
||||||
|
avg = sum(avg_temps[start:i]) / count
|
||||||
|
segments.append(DataSegment(label, start, i - 1, round(avg, 2)))
|
||||||
|
|
||||||
|
return segments
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import logging
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
|
||||||
|
def slot_error_boundary(func):
|
||||||
|
"""Wrap a @Slot method so exceptions are logged + signalled to QML.
|
||||||
|
|
||||||
|
Usage (stack under @Slot):
|
||||||
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
|
def myMethod(self) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
On exception:
|
||||||
|
- Logs full traceback via logger.exception()
|
||||||
|
- Emits logMessage signal with human-readable error (if available)
|
||||||
|
- Returns None so QML never sees a crash
|
||||||
|
"""
|
||||||
|
@wraps(func)
|
||||||
|
def wrapper(self, *args, **kwargs):
|
||||||
|
try:
|
||||||
|
return func(self, *args, **kwargs)
|
||||||
|
except Exception as e:
|
||||||
|
logger = logging.getLogger(self.__class__.__module__)
|
||||||
|
logger.exception("Slot '%s' raised: %s", func.__name__, e)
|
||||||
|
if hasattr(self, "logMessage"):
|
||||||
|
self.logMessage.emit(f"Error in {func.__name__}: {str(e)}")
|
||||||
|
return None
|
||||||
|
return wrapper
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
# ===== Visualization Sub-package =====
|
# ===== Visualization Sub-package =====
|
||||||
from pygui.backend.visualization.contour_models import ContourLine, ContourSegment
|
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||||
from pygui.backend.visualization.graph_view import GraphView
|
from pygui.backend.visualization.graph_view import GraphView
|
||||||
from pygui.backend.visualization.rbf_heatmap import interpolate_field
|
from pygui.backend.visualization.rbf_heatmap import interpolate_field
|
||||||
|
from pygui.backend.visualization.trend_chart_item import TrendChartItem
|
||||||
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"WaferMapItem", "GraphView",
|
"GraphQuickItem", "GraphView", "TrendChartItem", "WaferMapItem",
|
||||||
"interpolate_field",
|
"interpolate_field",
|
||||||
"ContourLine", "ContourSegment",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,417 @@
|
|||||||
|
"""QQuickPaintedItem line chart for embedding in QML.
|
||||||
|
|
||||||
|
Draws multiple sensor-data line series with axis labels, grid lines,
|
||||||
|
auto-scaled Y range, legend, and a dark-theme default palette that
|
||||||
|
complements Theme.qml.
|
||||||
|
|
||||||
|
Usage from QML::
|
||||||
|
|
||||||
|
import ISC.Wafer
|
||||||
|
|
||||||
|
GraphQuickItem {
|
||||||
|
id: graph
|
||||||
|
anchors.fill: parent
|
||||||
|
seriesData: [...]
|
||||||
|
sensorNames: ["Sensor1", "Sensor2"]
|
||||||
|
title: "Sensor Temperature Over Time"
|
||||||
|
yLabel: "Temperature (°C)"
|
||||||
|
xLabel: "Measurement"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PySide6.QtCore import Property, QRectF, Qt, Signal
|
||||||
|
from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPen
|
||||||
|
from PySide6.QtQml import QmlElement
|
||||||
|
from PySide6.QtQuick import QQuickPaintedItem
|
||||||
|
|
||||||
|
QML_IMPORT_NAME = "ISC.Wafer"
|
||||||
|
QML_IMPORT_MAJOR_VERSION = 1
|
||||||
|
|
||||||
|
|
||||||
|
@QmlElement
|
||||||
|
class GraphQuickItem(QQuickPaintedItem):
|
||||||
|
"""Painted line chart; driven by series data passed via QML property bindings."""
|
||||||
|
|
||||||
|
seriesDataChanged = Signal()
|
||||||
|
sensorNamesChanged = Signal()
|
||||||
|
titleChanged = Signal()
|
||||||
|
xLabelChanged = Signal()
|
||||||
|
yLabelChanged = Signal()
|
||||||
|
colorsChanged = Signal()
|
||||||
|
|
||||||
|
def __init__(self, parent=None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._series_data: list[list[float]] = [] # [[sensor1_vals...], [sensor2_vals...]]
|
||||||
|
self._sensor_names: list[str] = [] # parallel to series_data
|
||||||
|
self._title: str = ""
|
||||||
|
self._x_label: str = "Measurement"
|
||||||
|
self._y_label: str = "Temperature (°C)"
|
||||||
|
self._show_legend: bool = True
|
||||||
|
|
||||||
|
# Dark-theme colour defaults (match Theme.qml where possible)
|
||||||
|
self._bg_color = QColor("#1A1A1A") # tone200
|
||||||
|
self._grid_color = QColor("#2A2A2A") # toneBorder
|
||||||
|
self._axis_color = QColor("#A8A8A8") # toneMute
|
||||||
|
self._text_color = QColor("#F2F2F2") # toneText
|
||||||
|
self._series_colors: list[QColor] = [
|
||||||
|
QColor("#FF5757"), # Red
|
||||||
|
QColor("#42A5F5"), # Blue
|
||||||
|
QColor("#66BB6A"), # Green
|
||||||
|
QColor("#FFA726"), # Orange
|
||||||
|
QColor("#AB47BC"), # Purple
|
||||||
|
QColor("#00BCD4"), # Cyan
|
||||||
|
QColor("#FF7043"), # Deep Orange
|
||||||
|
QColor("#795548"), # Brown
|
||||||
|
QColor("#5C6BC0"), # Indigo
|
||||||
|
QColor("#307D75"), # Teal
|
||||||
|
]
|
||||||
|
|
||||||
|
self._min_y: float = 0.0
|
||||||
|
self._max_y: float = 150.0
|
||||||
|
self._padding: dict[str, int] = {"left": 60, "right": 20, "top": 30, "bottom": 50}
|
||||||
|
|
||||||
|
# ── Qt properties (QML-bindable) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
@Property("QVariantList", notify=seriesDataChanged)
|
||||||
|
def seriesData(self) -> list:
|
||||||
|
return self._series_data
|
||||||
|
|
||||||
|
@seriesData.setter
|
||||||
|
def seriesData(self, val: list) -> None:
|
||||||
|
self._series_data = [list(s) for s in (val or [])]
|
||||||
|
self._auto_range()
|
||||||
|
self.seriesDataChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property("QStringList", notify=sensorNamesChanged)
|
||||||
|
def sensorNames(self) -> list:
|
||||||
|
return self._sensor_names
|
||||||
|
|
||||||
|
@sensorNames.setter
|
||||||
|
def sensorNames(self, val: list) -> None:
|
||||||
|
self._sensor_names = list(val or [])
|
||||||
|
self.sensorNamesChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property(str, notify=titleChanged)
|
||||||
|
def title(self) -> str:
|
||||||
|
return self._title
|
||||||
|
|
||||||
|
@title.setter
|
||||||
|
def title(self, val: str) -> None:
|
||||||
|
self._title = str(val or "")
|
||||||
|
self.titleChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property(str, notify=xLabelChanged)
|
||||||
|
def xLabel(self) -> str:
|
||||||
|
return self._x_label
|
||||||
|
|
||||||
|
@xLabel.setter
|
||||||
|
def xLabel(self, val: str) -> None:
|
||||||
|
self._x_label = str(val or "")
|
||||||
|
self.xLabelChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property(str, notify=yLabelChanged)
|
||||||
|
def yLabel(self) -> str:
|
||||||
|
return self._y_label
|
||||||
|
|
||||||
|
@yLabel.setter
|
||||||
|
def yLabel(self, val: str) -> None:
|
||||||
|
self._y_label = str(val or "")
|
||||||
|
self.yLabelChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property(bool, notify=colorsChanged)
|
||||||
|
def showLegend(self) -> bool:
|
||||||
|
return self._show_legend
|
||||||
|
|
||||||
|
@showLegend.setter
|
||||||
|
def showLegend(self, val: bool) -> None:
|
||||||
|
self._show_legend = bool(val)
|
||||||
|
self.colorsChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
# ── Colour properties (QML can bind Theme tokens here) ────────────────────
|
||||||
|
|
||||||
|
@Property(QColor, notify=colorsChanged)
|
||||||
|
def backgroundColor(self) -> QColor:
|
||||||
|
return self._bg_color
|
||||||
|
|
||||||
|
@backgroundColor.setter
|
||||||
|
def backgroundColor(self, c: QColor) -> None:
|
||||||
|
self._bg_color = c
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property(QColor, notify=colorsChanged)
|
||||||
|
def gridColor(self) -> QColor:
|
||||||
|
return self._grid_color
|
||||||
|
|
||||||
|
@gridColor.setter
|
||||||
|
def gridColor(self, c: QColor) -> None:
|
||||||
|
self._grid_color = c
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property(QColor, notify=colorsChanged)
|
||||||
|
def axisColor(self) -> QColor:
|
||||||
|
return self._axis_color
|
||||||
|
|
||||||
|
@axisColor.setter
|
||||||
|
def axisColor(self, c: QColor) -> None:
|
||||||
|
self._axis_color = c
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property(QColor, notify=colorsChanged)
|
||||||
|
def textColor(self) -> QColor:
|
||||||
|
return self._text_color
|
||||||
|
|
||||||
|
@textColor.setter
|
||||||
|
def textColor(self, c: QColor) -> None:
|
||||||
|
self._text_color = c
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property("QVariantList", notify=colorsChanged)
|
||||||
|
def seriesColors(self) -> list:
|
||||||
|
"""Return hex strings of the current series color palette."""
|
||||||
|
return [c.name() for c in self._series_colors]
|
||||||
|
|
||||||
|
@seriesColors.setter
|
||||||
|
def seriesColors(self, hex_list: list) -> None:
|
||||||
|
colors = []
|
||||||
|
for h in (hex_list or []):
|
||||||
|
try:
|
||||||
|
colors.append(QColor(str(h)))
|
||||||
|
except Exception:
|
||||||
|
colors.append(QColor("#FFFFFF"))
|
||||||
|
if colors:
|
||||||
|
self._series_colors = colors
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
# ── internal ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _auto_range(self) -> None:
|
||||||
|
"""Set Y range to cover all data with 10% padding."""
|
||||||
|
all_vals: list[float] = []
|
||||||
|
for series in self._series_data:
|
||||||
|
for v in series:
|
||||||
|
try:
|
||||||
|
all_vals.append(float(v))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
if not all_vals:
|
||||||
|
return
|
||||||
|
mn = min(all_vals)
|
||||||
|
mx = max(all_vals)
|
||||||
|
if mx <= mn:
|
||||||
|
mn -= 5.0
|
||||||
|
mx += 5.0
|
||||||
|
pad = (mx - mn) * 0.1
|
||||||
|
self._min_y = mn - pad
|
||||||
|
self._max_y = mx + pad
|
||||||
|
|
||||||
|
def _plot_rect(self) -> QRectF:
|
||||||
|
w = self.width()
|
||||||
|
h = self.height()
|
||||||
|
pl = self._padding.get("left", 60)
|
||||||
|
pr = self._padding.get("right", 20)
|
||||||
|
pt = self._padding.get("top", 30)
|
||||||
|
pb = self._padding.get("bottom", 50)
|
||||||
|
return QRectF(pl, pt, max(10, w - pl - pr), max(10, h - pt - pb))
|
||||||
|
|
||||||
|
# ── paint ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def paint(self, painter: QPainter) -> None:
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
|
|
||||||
|
w = self.width()
|
||||||
|
h = self.height()
|
||||||
|
if w < 50 or h < 50:
|
||||||
|
return
|
||||||
|
|
||||||
|
pr = self._plot_rect()
|
||||||
|
plot_left = pr.left()
|
||||||
|
plot_top = pr.top()
|
||||||
|
plot_w = pr.width()
|
||||||
|
plot_h = pr.height()
|
||||||
|
|
||||||
|
# --- Background fill ---
|
||||||
|
painter.fillRect(0, 0, int(w), int(h), self._bg_color)
|
||||||
|
|
||||||
|
# --- Title ---
|
||||||
|
if self._title:
|
||||||
|
title_font = QFont()
|
||||||
|
title_font.setPixelSize(14)
|
||||||
|
title_font.setBold(True)
|
||||||
|
painter.setFont(title_font)
|
||||||
|
painter.setPen(QPen(self._text_color))
|
||||||
|
painter.drawText(
|
||||||
|
QRectF(0, 4, w, 24),
|
||||||
|
Qt.AlignmentFlag.AlignHCenter,
|
||||||
|
self._title,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self._series_data:
|
||||||
|
self._paint_empty(painter, pr)
|
||||||
|
return
|
||||||
|
|
||||||
|
# --- Determine Y tick count and values ---
|
||||||
|
n_y_ticks = max(2, min(8, int(plot_h // 40)))
|
||||||
|
y_range = self._max_y - self._min_y
|
||||||
|
if y_range <= 0:
|
||||||
|
y_range = 10.0
|
||||||
|
y_step = y_range / (n_y_ticks - 1) if n_y_ticks > 1 else y_range
|
||||||
|
|
||||||
|
# --- Grid & Y-axis labels ---
|
||||||
|
grid_pen = QPen(self._grid_color, 1)
|
||||||
|
axis_pen = QPen(self._axis_color, 1)
|
||||||
|
label_font = QFont()
|
||||||
|
label_font.setPixelSize(10)
|
||||||
|
|
||||||
|
painter.setFont(label_font)
|
||||||
|
fm = QFontMetrics(label_font)
|
||||||
|
|
||||||
|
for i in range(n_y_ticks):
|
||||||
|
y_val = self._min_y + i * y_step
|
||||||
|
y_frac = 1.0 - (i / (n_y_ticks - 1)) if n_y_ticks > 1 else 0.5
|
||||||
|
y_px = plot_top + y_frac * plot_h
|
||||||
|
|
||||||
|
# Grid line
|
||||||
|
painter.setPen(grid_pen)
|
||||||
|
painter.drawLine(
|
||||||
|
int(plot_left), int(y_px),
|
||||||
|
int(plot_left + plot_w), int(y_px),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Label
|
||||||
|
label_str = f"{y_val:.1f}"
|
||||||
|
tw = fm.horizontalAdvance(label_str)
|
||||||
|
painter.setPen(axis_pen)
|
||||||
|
painter.drawText(
|
||||||
|
int(plot_left - tw - 6), int(y_px + fm.height() // 2 - 3),
|
||||||
|
label_str,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Y axis label (rotated) ---
|
||||||
|
if self._y_label:
|
||||||
|
painter.save()
|
||||||
|
painter.setPen(QPen(self._axis_color))
|
||||||
|
label_font_b = QFont()
|
||||||
|
label_font_b.setPixelSize(11)
|
||||||
|
painter.setFont(label_font_b)
|
||||||
|
painter.translate(14, plot_top + plot_h / 2)
|
||||||
|
painter.rotate(-90)
|
||||||
|
painter.drawText(0, 0, self._y_label)
|
||||||
|
painter.restore()
|
||||||
|
|
||||||
|
# --- X axis label ---
|
||||||
|
if self._x_label:
|
||||||
|
painter.setPen(QPen(self._axis_color))
|
||||||
|
label_font_b = QFont()
|
||||||
|
label_font_b.setPixelSize(11)
|
||||||
|
painter.setFont(label_font_b)
|
||||||
|
painter.drawText(
|
||||||
|
QRectF(plot_left, h - 18, plot_w, 16),
|
||||||
|
Qt.AlignmentFlag.AlignHCenter,
|
||||||
|
self._x_label,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- X-axis ticks ---
|
||||||
|
num_points = max(len(s) for s in self._series_data) if self._series_data else 0
|
||||||
|
if num_points > 1:
|
||||||
|
n_x_ticks = min(num_points, max(2, int(plot_w // 80)))
|
||||||
|
x_tick_step = (num_points - 1) / max(1, n_x_ticks - 1)
|
||||||
|
for i in range(n_x_ticks):
|
||||||
|
idx = int(round(i * x_tick_step))
|
||||||
|
x_frac = idx / (num_points - 1)
|
||||||
|
x_px = plot_left + x_frac * plot_w
|
||||||
|
painter.setPen(axis_pen)
|
||||||
|
painter.drawText(
|
||||||
|
int(x_px - 12), int(plot_top + plot_h + 14),
|
||||||
|
str(idx + 1),
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Axes boundary ---
|
||||||
|
border_pen = QPen(self._axis_color, 1)
|
||||||
|
painter.setPen(border_pen)
|
||||||
|
painter.drawLine(int(plot_left), int(plot_top), int(plot_left), int(plot_top + plot_h))
|
||||||
|
painter.drawLine(int(plot_left), int(plot_top + plot_h), int(plot_left + plot_w), int(plot_top + plot_h))
|
||||||
|
|
||||||
|
# --- Figure out max series length ---
|
||||||
|
max_len = max(len(s) for s in self._series_data) if self._series_data else 0
|
||||||
|
if max_len < 1:
|
||||||
|
return
|
||||||
|
|
||||||
|
# --- Draw each series ---
|
||||||
|
for si, series in enumerate(self._series_data):
|
||||||
|
if len(series) < 1:
|
||||||
|
continue
|
||||||
|
color = self._series_colors[si % len(self._series_colors)]
|
||||||
|
pen = QPen(color, 2)
|
||||||
|
painter.setPen(pen)
|
||||||
|
|
||||||
|
pts: list[tuple[float, float]] = []
|
||||||
|
for i, val in enumerate(series):
|
||||||
|
try:
|
||||||
|
v = float(val)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
x_frac = i / (max_len - 1) if max_len > 1 else 0.5
|
||||||
|
y_frac = 1.0 - ((v - self._min_y) / y_range) if y_range > 0 else 0.5
|
||||||
|
x_px = plot_left + x_frac * plot_w
|
||||||
|
y_px = plot_top + y_frac * plot_h
|
||||||
|
pts.append((x_px, y_px))
|
||||||
|
|
||||||
|
if len(pts) < 2:
|
||||||
|
if len(pts) == 1:
|
||||||
|
painter.drawPoint(int(pts[0][0]), int(pts[0][1]))
|
||||||
|
continue
|
||||||
|
|
||||||
|
for i in range(len(pts) - 1):
|
||||||
|
painter.drawLine(
|
||||||
|
int(pts[i][0]), int(pts[i][1]),
|
||||||
|
int(pts[i + 1][0]), int(pts[i + 1][1]),
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Legend ---
|
||||||
|
if self._show_legend and self._sensor_names:
|
||||||
|
legend_font = QFont()
|
||||||
|
legend_font.setPixelSize(10)
|
||||||
|
painter.setFont(legend_font)
|
||||||
|
lfm = QFontMetrics(legend_font)
|
||||||
|
|
||||||
|
# Compute legend dimensions
|
||||||
|
lh = 16
|
||||||
|
max_name_w = max(lfm.horizontalAdvance(n) for n in self._sensor_names)
|
||||||
|
lw = max_name_w + 24
|
||||||
|
legend_count = min(len(self._sensor_names), len(self._series_data))
|
||||||
|
lh_total = legend_count * lh + 4
|
||||||
|
|
||||||
|
legend_x = int(plot_left + plot_w - lw - 8)
|
||||||
|
legend_y = int(plot_top + 8)
|
||||||
|
|
||||||
|
# Background
|
||||||
|
painter.fillRect(legend_x, legend_y, lw, lh_total, QColor(0, 0, 0, 140))
|
||||||
|
|
||||||
|
for i in range(legend_count):
|
||||||
|
y_pos = legend_y + 4 + i * lh
|
||||||
|
color = self._series_colors[i % len(self._series_colors)]
|
||||||
|
# Color swatch
|
||||||
|
painter.fillRect(legend_x + 4, y_pos + 2, 12, 10, color)
|
||||||
|
# Label
|
||||||
|
painter.setPen(QPen(self._text_color))
|
||||||
|
painter.drawText(legend_x + 20, y_pos + 11, self._sensor_names[i])
|
||||||
|
|
||||||
|
def _paint_empty(self, painter: QPainter, pr: QRectF) -> None:
|
||||||
|
"""Draw placeholder when no data is available."""
|
||||||
|
painter.setPen(QPen(self._axis_color))
|
||||||
|
empty_font = QFont()
|
||||||
|
empty_font.setPixelSize(14)
|
||||||
|
painter.setFont(empty_font)
|
||||||
|
painter.drawText(
|
||||||
|
pr,
|
||||||
|
Qt.AlignmentFlag.AlignCenter,
|
||||||
|
"No data — read a wafer or open a CSV file",
|
||||||
|
)
|
||||||
@@ -9,16 +9,13 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from PySide6.QtCore import Property, QObject, Signal, Slot
|
import pyqtgraph as pg
|
||||||
|
from pyqtgraph import PlotWidget
|
||||||
|
from PySide6.QtCore import Property, QObject, Qt, Signal, Slot
|
||||||
from PySide6.QtWidgets import QWidget
|
from PySide6.QtWidgets import QWidget
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Import pyqtgraph after Qt is initialized
|
|
||||||
import pyqtgraph as pg
|
|
||||||
from pyqtgraph import PlotWidget
|
|
||||||
|
|
||||||
|
|
||||||
class GraphView(QObject):
|
class GraphView(QObject):
|
||||||
"""QML-exposed controller for a pyqtgraph line chart.
|
"""QML-exposed controller for a pyqtgraph line chart.
|
||||||
|
|
||||||
@@ -167,3 +164,71 @@ class GraphView(QObject):
|
|||||||
self._plot_window = None
|
self._plot_window = None
|
||||||
self._plot_widget = None
|
self._plot_widget = None
|
||||||
self._series = []
|
self._series = []
|
||||||
|
|
||||||
|
@Slot(str)
|
||||||
|
def updateTrend(self, avgs_json: str) -> None:
|
||||||
|
"""Plot the running avg temp series."""
|
||||||
|
import json
|
||||||
|
if not self._plot_widget:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
avgs = json.loads(avgs_json)
|
||||||
|
except (json.JSONDecodeError, TypeError) as exc:
|
||||||
|
log.error("Failed to parse trend data: %s", exc)
|
||||||
|
return
|
||||||
|
|
||||||
|
if hasattr(self,'_trend_line') and self._trend_line is not None:
|
||||||
|
self._plot_widget.removeItem(self._trend_line)
|
||||||
|
|
||||||
|
if not avgs:
|
||||||
|
return
|
||||||
|
|
||||||
|
x = list(range(len(avgs)))
|
||||||
|
pen = pg.mkPen("#5B9DF5", width=2)
|
||||||
|
self._trend_line = self._plot_widget.plot(x, avgs, name="Average", pen=pen)
|
||||||
|
|
||||||
|
# Y-axis range with buffer
|
||||||
|
y_min, y_max = min(avgs), max(avgs)
|
||||||
|
buf = max((y_max - y_min) * 0.1, 1.0) if y_max > y_min else 5.0
|
||||||
|
self._plot_widget.setYRange(y_min - buf, y_max + buf)
|
||||||
|
|
||||||
|
@Slot(str,str,str)
|
||||||
|
def updateComparison(self, series_a_json: str, series_b_json: str,path_json: str) -> None:
|
||||||
|
"""Overlay two runs with DTW warping path connections.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
series_a_json: JSON list of floats -- Run Aaverage temps.
|
||||||
|
series_b_json: JSON list of floats -- Run B average temps.
|
||||||
|
path_json: JSON list of [index_a, index_b] paris.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
if not self._plot_widget:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._plot_widget.clear()
|
||||||
|
|
||||||
|
try:
|
||||||
|
a = json.loads(series_a_json)
|
||||||
|
b = json.loads(series_b_json)
|
||||||
|
path = json.loads(path_json)
|
||||||
|
except (json.JSONDecodeError, TypeError) as exc:
|
||||||
|
log.error("Failed to parse comparison data: %s", exc)
|
||||||
|
return
|
||||||
|
|
||||||
|
pen_a = pg.mkPen("#5B9DF5", width=2) #blue
|
||||||
|
pen_b = pg.mkPen("#22C55E", width=2) #green
|
||||||
|
|
||||||
|
self._plot_widget.plot(list(range(len(a))), a, name="Run A", pen=pen_a)
|
||||||
|
self._plot_widget.plot(list(range(len(b))), b, name="Run B", pen=pen_b)
|
||||||
|
|
||||||
|
# Draw every 20th warping link as vertical dotted lines
|
||||||
|
step = max(1, len(path)//20)
|
||||||
|
for i in range (0,len(path),step):
|
||||||
|
index_a, index_b = path[i]
|
||||||
|
if index_a < len(a) and index_b < len(b):
|
||||||
|
line = pg.InfiniteLine(pos=index_a, angle=90, pen=pg.mkPen("#666666", width=1,
|
||||||
|
style=Qt.DashLine))
|
||||||
|
self._plot_widget.addItem(line)
|
||||||
|
|
||||||
|
self._plot_widget.setTitle("DTW Comparison")
|
||||||
|
self._plot_widget.setLabel("left", "Temperature", units="°C")
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
"""QQuickPaintedItem trend chart — running-average temperature over time.
|
||||||
|
|
||||||
|
Renders a single polyline series (per-frame average temperatures) using QPainter.
|
||||||
|
Mirrors the @QmlElement registration pattern used by `WaferMapItem` so it can
|
||||||
|
be embedded directly in QML via `import ISC.Wafer` and used as `TrendChartItem { }`.
|
||||||
|
|
||||||
|
The series is driven by the existing `streamController.trendData` signal, which
|
||||||
|
emits a JSON-encoded list of floats from both review-mode replay and live-mode
|
||||||
|
streaming.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from PySide6.QtCore import Property, QPoint, QRectF, Qt, Signal, Slot
|
||||||
|
from PySide6.QtGui import QColor, QFont, QPainter, QPen, QPolygon
|
||||||
|
from PySide6.QtQml import QmlElement
|
||||||
|
from PySide6.QtQuick import QQuickPaintedItem
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
QML_IMPORT_NAME = "ISC.Wafer"
|
||||||
|
QML_IMPORT_MAJOR_VERSION = 1
|
||||||
|
|
||||||
|
|
||||||
|
@QmlElement
|
||||||
|
class TrendChartItem(QQuickPaintedItem):
|
||||||
|
"""Painted trend chart; driven by a list of floats via the `data` property."""
|
||||||
|
|
||||||
|
dataChanged = Signal()
|
||||||
|
hasDataChanged = Signal()
|
||||||
|
lineColorChanged = Signal()
|
||||||
|
gridColorChanged = Signal()
|
||||||
|
textColorChanged = Signal()
|
||||||
|
paddingChanged = Signal()
|
||||||
|
|
||||||
|
def __init__(self, parent: Optional[QQuickPaintedItem] = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setRenderTarget(QQuickPaintedItem.RenderTarget.FramebufferObject)
|
||||||
|
self.setAntialiasing(True)
|
||||||
|
self.setFillColor(QColor("transparent"))
|
||||||
|
|
||||||
|
self._data: list[float] = []
|
||||||
|
self._padding: int = 8
|
||||||
|
self._line_color = QColor("#5B9DF5")
|
||||||
|
self._grid_color = QColor("#2A3441")
|
||||||
|
self._text_color = QColor("#CBD5E1")
|
||||||
|
|
||||||
|
# ── Qt properties ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Property("QVariantList", notify=dataChanged)
|
||||||
|
def data(self) -> list[float]:
|
||||||
|
return self._data
|
||||||
|
|
||||||
|
@data.setter
|
||||||
|
def data(self, val) -> None:
|
||||||
|
coerced = self._coerce_floats(val)
|
||||||
|
if coerced == self._data:
|
||||||
|
return
|
||||||
|
self._data = coerced
|
||||||
|
self.dataChanged.emit()
|
||||||
|
self.hasDataChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property(bool, notify=hasDataChanged)
|
||||||
|
def hasData(self) -> bool:
|
||||||
|
"""True when the data list has at least one point.
|
||||||
|
|
||||||
|
QML can bind to this safely (QVariantList `.length` is not bindable).
|
||||||
|
"""
|
||||||
|
return len(self._data) > 0
|
||||||
|
|
||||||
|
@Property(QColor, notify=lineColorChanged)
|
||||||
|
def lineColor(self) -> QColor:
|
||||||
|
return self._line_color
|
||||||
|
|
||||||
|
@lineColor.setter
|
||||||
|
def lineColor(self, c: QColor) -> None:
|
||||||
|
if c == self._line_color:
|
||||||
|
return
|
||||||
|
self._line_color = QColor(c)
|
||||||
|
self.lineColorChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property(QColor, notify=gridColorChanged)
|
||||||
|
def gridColor(self) -> QColor:
|
||||||
|
return self._grid_color
|
||||||
|
|
||||||
|
@gridColor.setter
|
||||||
|
def gridColor(self, c: QColor) -> None:
|
||||||
|
if c == self._grid_color:
|
||||||
|
return
|
||||||
|
self._grid_color = QColor(c)
|
||||||
|
self.gridColorChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property(QColor, notify=textColorChanged)
|
||||||
|
def textColor(self) -> QColor:
|
||||||
|
return self._text_color
|
||||||
|
|
||||||
|
@textColor.setter
|
||||||
|
def textColor(self, c: QColor) -> None:
|
||||||
|
if c == self._text_color:
|
||||||
|
return
|
||||||
|
self._text_color = QColor(c)
|
||||||
|
self.textColorChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property(int, notify=paddingChanged)
|
||||||
|
def padding(self) -> int:
|
||||||
|
return self._padding
|
||||||
|
|
||||||
|
@padding.setter
|
||||||
|
def padding(self, val: int) -> None:
|
||||||
|
v = max(0, int(val))
|
||||||
|
if v == self._padding:
|
||||||
|
return
|
||||||
|
self._padding = v
|
||||||
|
self.paddingChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
# ── Convenience slot for QML: accept a JSON string from a Signal(str) ──
|
||||||
|
|
||||||
|
@Slot(str)
|
||||||
|
def setDataFromJson(self, avgs_json: str) -> None:
|
||||||
|
"""Slot for QML Connections handler: parse JSON array and update data.
|
||||||
|
|
||||||
|
Mirrors `streamController.trendData` (which emits JSON-encoded floats).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
parsed = json.loads(avgs_json) if avgs_json else []
|
||||||
|
except (json.JSONDecodeError, TypeError) as exc:
|
||||||
|
log.error("TrendChartItem: failed to parse trend JSON: %s", exc)
|
||||||
|
return
|
||||||
|
coerced = self._coerce_floats(parsed)
|
||||||
|
if coerced == self._data:
|
||||||
|
return
|
||||||
|
self._data = coerced
|
||||||
|
self.dataChanged.emit()
|
||||||
|
self.hasDataChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
# ── paint ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def paint(self, painter: QPainter) -> None: # type: ignore[override]
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.TextAntialiasing, True)
|
||||||
|
|
||||||
|
w = self.width()
|
||||||
|
h = self.height()
|
||||||
|
if w <= 0 or h <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
plot_rect = QRectF(self._padding, self._padding,
|
||||||
|
max(1, w - 2 * self._padding),
|
||||||
|
max(1, h - 2 * self._padding))
|
||||||
|
|
||||||
|
self._draw_grid(painter, plot_rect)
|
||||||
|
|
||||||
|
if len(self._data) < 2:
|
||||||
|
return
|
||||||
|
|
||||||
|
y_min, y_max = self._y_range()
|
||||||
|
self._draw_axes_labels(painter, plot_rect, y_min, y_max)
|
||||||
|
self._draw_line(painter, plot_rect, y_min, y_max)
|
||||||
|
|
||||||
|
# ── internals ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _coerce_floats(self, val) -> list[float]:
|
||||||
|
if not val:
|
||||||
|
return []
|
||||||
|
out: list[float] = []
|
||||||
|
for v in val:
|
||||||
|
try:
|
||||||
|
out.append(float(v))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _y_range(self) -> tuple[float, float]:
|
||||||
|
lo = min(self._data)
|
||||||
|
hi = max(self._data)
|
||||||
|
if hi - lo < 1e-9:
|
||||||
|
return lo - 5.0, hi + 5.0
|
||||||
|
buf = (hi - lo) * 0.1
|
||||||
|
return lo - buf, hi + buf
|
||||||
|
|
||||||
|
def _x_to_px(self, i: int, n: int, plot_rect: QRectF) -> float:
|
||||||
|
if n <= 1:
|
||||||
|
return plot_rect.left()
|
||||||
|
return plot_rect.left() + (i / (n - 1)) * plot_rect.width()
|
||||||
|
|
||||||
|
def _y_to_px(self, v: float, y_min: float, y_max: float, plot_rect: QRectF) -> float:
|
||||||
|
if y_max - y_min < 1e-9:
|
||||||
|
return plot_rect.center().y()
|
||||||
|
t = (v - y_min) / (y_max - y_min)
|
||||||
|
return plot_rect.bottom() - t * plot_rect.height()
|
||||||
|
|
||||||
|
def _draw_grid(self, painter: QPainter, plot_rect: QRectF) -> None:
|
||||||
|
pen = QPen(self._grid_color)
|
||||||
|
pen.setWidthF(1.0)
|
||||||
|
pen.setCosmetic(True)
|
||||||
|
painter.setPen(pen)
|
||||||
|
rows = 4
|
||||||
|
for i in range(rows + 1):
|
||||||
|
y = plot_rect.top() + (i / rows) * plot_rect.height()
|
||||||
|
painter.drawLine(int(plot_rect.left()), int(y),
|
||||||
|
int(plot_rect.right()), int(y))
|
||||||
|
|
||||||
|
def _draw_axes_labels(
|
||||||
|
self,
|
||||||
|
painter: QPainter,
|
||||||
|
plot_rect: QRectF,
|
||||||
|
y_min: float,
|
||||||
|
y_max: float,
|
||||||
|
) -> None:
|
||||||
|
painter.setPen(self._text_color)
|
||||||
|
font = QFont(painter.font())
|
||||||
|
font.setPointSizeF(max(7.0, font.pointSizeF() * 0.85))
|
||||||
|
painter.setFont(font)
|
||||||
|
rows = 4
|
||||||
|
for i in range(rows + 1):
|
||||||
|
t = i / rows
|
||||||
|
y_val = y_max - t * (y_max - y_min)
|
||||||
|
y_px = plot_rect.top() + t * plot_rect.height()
|
||||||
|
label = f"{y_val:.1f}"
|
||||||
|
painter.drawText(QRectF(0, y_px - 7, self._padding, 14),
|
||||||
|
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
|
||||||
|
label)
|
||||||
|
|
||||||
|
def _draw_line(
|
||||||
|
self,
|
||||||
|
painter: QPainter,
|
||||||
|
plot_rect: QRectF,
|
||||||
|
y_min: float,
|
||||||
|
y_max: float,
|
||||||
|
) -> None:
|
||||||
|
n = len(self._data)
|
||||||
|
pen = QPen(self._line_color)
|
||||||
|
pen.setWidthF(2.0)
|
||||||
|
pen.setCosmetic(True)
|
||||||
|
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||||
|
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
|
||||||
|
painter.setPen(pen)
|
||||||
|
|
||||||
|
poly = QPolygon()
|
||||||
|
for i, v in enumerate(self._data):
|
||||||
|
px = self._x_to_px(i, n, plot_rect)
|
||||||
|
py = self._y_to_px(v, y_min, y_max, plot_rect)
|
||||||
|
poly.append(QPoint(int(px), int(py)))
|
||||||
|
painter.drawPolyline(poly)
|
||||||
|
|
||||||
|
# Trailing dot at the last data point
|
||||||
|
last_x = int(self._x_to_px(n - 1, n, plot_rect))
|
||||||
|
last_y = int(self._y_to_px(self._data[-1], y_min, y_max, plot_rect))
|
||||||
|
painter.setBrush(self._line_color)
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
radius = 3
|
||||||
|
painter.drawEllipse(last_x - radius, last_y - radius,
|
||||||
|
radius * 2, radius * 2)
|
||||||
@@ -10,6 +10,7 @@ All sensor coordinates are center-origin mm (from wafer_layouts or a loaded CSV)
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import math
|
import math
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -22,13 +23,15 @@ from PySide6.QtGui import (
|
|||||||
QPainter,
|
QPainter,
|
||||||
QPen,
|
QPen,
|
||||||
QPolygon,
|
QPolygon,
|
||||||
)
|
) # fmt: skip
|
||||||
from PySide6.QtQml import QmlElement
|
from PySide6.QtQml import QmlElement
|
||||||
from PySide6.QtQuick import QQuickPaintedItem
|
from PySide6.QtQuick import QQuickPaintedItem
|
||||||
|
|
||||||
from pygui.backend.visualization.rbf_heatmap import interpolate_field
|
from pygui.backend.visualization.rbf_heatmap import interpolate_field
|
||||||
from pygui.backend.wafer.zwafer_models import Sensor
|
from pygui.backend.wafer.zwafer_models import Sensor
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
QML_IMPORT_NAME = "ISC.Wafer"
|
QML_IMPORT_NAME = "ISC.Wafer"
|
||||||
QML_IMPORT_MAJOR_VERSION = 1
|
QML_IMPORT_MAJOR_VERSION = 1
|
||||||
|
|
||||||
@@ -47,6 +50,9 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
colorsChanged = Signal()
|
colorsChanged = Signal()
|
||||||
shapeChanged = Signal()
|
shapeChanged = Signal()
|
||||||
sizeChanged = Signal()
|
sizeChanged = Signal()
|
||||||
|
thicknessChanged = Signal()
|
||||||
|
showThicknessChanged = Signal()
|
||||||
|
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -59,6 +65,9 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
self._show_labels: bool = True
|
self._show_labels: bool = True
|
||||||
self._shape: str = "round"
|
self._shape: str = "round"
|
||||||
self._size: float = 300.0
|
self._size: float = 300.0
|
||||||
|
self._thickness_data: list[float] = []
|
||||||
|
self._show_thickness: bool = False
|
||||||
|
self._thickness_heatmap:QImage | None = None
|
||||||
|
|
||||||
# Dark-theme color defaults (match Theme.qml tokens)
|
# Dark-theme color defaults (match Theme.qml tokens)
|
||||||
self._ring_color = QColor("#2A3441") # waferRingColor (toneBorder)
|
self._ring_color = QColor("#2A3441") # waferRingColor (toneBorder)
|
||||||
@@ -183,6 +192,27 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
self._rebuild()
|
self._rebuild()
|
||||||
self.sizeChanged.emit()
|
self.sizeChanged.emit()
|
||||||
|
|
||||||
|
@Property("QVariantList", notify=thicknessChanged)
|
||||||
|
def thicknessData(self) -> list:
|
||||||
|
return self._thickness_data
|
||||||
|
|
||||||
|
@thicknessData.setter
|
||||||
|
def thicknessData(self, val:list) -> None:
|
||||||
|
self._thickness_data = list(val or [])
|
||||||
|
self._rebuild_thickness()
|
||||||
|
self.thicknessChanged.emit()
|
||||||
|
self.update
|
||||||
|
|
||||||
|
@Property(bool, notify=showThicknessChanged)
|
||||||
|
def showThickness(self) -> bool:
|
||||||
|
return self._show_thickness
|
||||||
|
|
||||||
|
@showThickness.setter
|
||||||
|
def showThickness(self, val: bool) -> None:
|
||||||
|
self._show_thickness = bool(val)
|
||||||
|
self.showThicknessChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
# Colour properties — QML can bind these to Theme tokens
|
# Colour properties — QML can bind these to Theme tokens
|
||||||
@Property(QColor, notify=colorsChanged)
|
@Property(QColor, notify=colorsChanged)
|
||||||
def ringColor(self) -> QColor: return self._ring_color
|
def ringColor(self) -> QColor: return self._ring_color
|
||||||
@@ -225,8 +255,72 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
return idx
|
return idx
|
||||||
return -1
|
return -1
|
||||||
|
|
||||||
|
@Slot(str, result=bool)
|
||||||
|
def export_image(self, file_path: str) -> bool:
|
||||||
|
"""Export the current wafer map rendering to a PNG file
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_pat: Absolute path for the output PNG.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True on success, False on failure
|
||||||
|
"""
|
||||||
|
if not file_path:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
result = self.grabToImage()
|
||||||
|
img = result.image()
|
||||||
|
img.save(file_path, "PNG")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
log.error("Export failed: %s", e)
|
||||||
|
return False
|
||||||
|
|
||||||
# ── internal ─────────────────────────────────────────────────────────
|
# ── internal ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _rebuild_thickness(self) -> None:
|
||||||
|
"""Interpolate thickness data into a gray/orange heatmap QImage."""
|
||||||
|
if not self._sensors or not self._thickness_data:
|
||||||
|
self._thickness_heatmap = None
|
||||||
|
return
|
||||||
|
ds = self._draw_size()
|
||||||
|
r_mm = self._wafer_radius_mm()
|
||||||
|
xs = np.array([s.x for s in self._sensors])
|
||||||
|
ys = np.array([s.y for s in self._sensors])
|
||||||
|
vs = np.array(self._thickness_data[:len(self._sensors)], dtype=float)
|
||||||
|
if len(vs) < len(self._sensors):
|
||||||
|
self._thickness_heatmap = None
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
field = interpolate_field(
|
||||||
|
xs, ys, vs,
|
||||||
|
width=ds, height=ds,
|
||||||
|
extent=(-r_mm, r_mm, -r_mm, r_mm),
|
||||||
|
round_clip=(self._shape == "round"),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
self._thickness_heatmap = None
|
||||||
|
return
|
||||||
|
# Gray/orange colormap: map field range 0→1 to gray→orange
|
||||||
|
vmin, vmax = np.nanmin(field), np.nanmax(field)
|
||||||
|
span = vmax - vmin or 1.0
|
||||||
|
t = np.clip((field - vmin) / span, 0.0, 1.0)
|
||||||
|
|
||||||
|
# Gray (0.5, 0.5, 0.5) → orange (1.0, 0.65, 0.0)
|
||||||
|
rgb = np.zeros((ds, ds, 3), dtype=np.float32)
|
||||||
|
rgb[:, :, 0] = 0.5 + 0.5 * t
|
||||||
|
rgb[:, :, 1] = 0.5 + 0.15 * t
|
||||||
|
rgb[:, :, 2] = 0.5 - 0.5 * t
|
||||||
|
|
||||||
|
rgb = np.nan_to_num(rgb, nan=0.0)
|
||||||
|
rgba = np.zeros((ds, ds, 4), dtype=np.uint8)
|
||||||
|
rgba[:, :, :3] = (rgb * 255).astype(np.uint8)
|
||||||
|
rgba[:, :, 3] = np.where(np.isfinite(field), 180, 0).astype(np.uint8)
|
||||||
|
|
||||||
|
self._thickness_heatmap = (
|
||||||
|
QImage(rgba.tobytes(), ds, ds, QImage.Format.Format_RGBA8888).copy()
|
||||||
|
)
|
||||||
|
|
||||||
def _on_resize(self) -> None:
|
def _on_resize(self) -> None:
|
||||||
self._rebuild()
|
self._rebuild()
|
||||||
|
|
||||||
@@ -360,6 +454,12 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
cy - self._heatmap.height() // 2, self._heatmap)
|
cy - self._heatmap.height() // 2, self._heatmap)
|
||||||
painter.setOpacity(1.0)
|
painter.setOpacity(1.0)
|
||||||
|
|
||||||
|
if self._show_thickness and self._thickness_heatmap:
|
||||||
|
painter.setOpacity(0.4)
|
||||||
|
painter.drawImage(cx - self._thickness_heatmap.width() // 2,
|
||||||
|
cy - self._thickness_heatmap.height()//2, self._thickness_heatmap)
|
||||||
|
painter.setOpacity(1.0)
|
||||||
|
|
||||||
self._paint_markers(painter)
|
self._paint_markers(painter)
|
||||||
|
|
||||||
def _paint_template(self, painter: QPainter, cx: int, cy: int, r_px: int) -> None:
|
def _paint_template(self, painter: QPainter, cx: int, cy: int, r_px: int) -> None:
|
||||||
|
|||||||
@@ -33,10 +33,9 @@ class ZWaferParser:
|
|||||||
# ===== Header Parsing =====
|
# ===== Header Parsing =====
|
||||||
def _process_header(self, file_obj) -> ZWaferData:
|
def _process_header(self, file_obj) -> ZWaferData:
|
||||||
"""Parse header and sensor layout from open file object.
|
"""Parse header and sensor layout from open file object.
|
||||||
|
|
||||||
Handles two CSV row orderings:
|
Handles two CSV row orderings:
|
||||||
1. Label/X/Y rows BEFORE 'data' (fixture test format)
|
1. Label/X/Y rows BEFORE 'data' (fixture test format)
|
||||||
2. 'data' row BEFORE Label/X/Y (some real CSV files)
|
2. 'data' row BEFORE Label/X/Y (some real CSV files)
|
||||||
The C# parser handles both by collecting sensor layout rows
|
The C# parser handles both by collecting sensor layout rows
|
||||||
regardless of whether 'data' has been seen.
|
regardless of whether 'data' has been seen.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -196,7 +196,6 @@ class SerialPort:
|
|||||||
bytes 1016-1019 : Runtime (2 bytes hex → decimal seconds)
|
bytes 1016-1019 : Runtime (2 bytes hex → decimal seconds)
|
||||||
bytes 1020-1023 : Cycle time (2 bytes hex → decimal cycles)
|
bytes 1020-1023 : Cycle time (2 bytes hex → decimal cycles)
|
||||||
"""
|
"""
|
||||||
raw = bytes.fromhex(hex_response)
|
|
||||||
|
|
||||||
family_code = self._hex_to_ascii(hex_response[0:2])
|
family_code = self._hex_to_ascii(hex_response[0:2])
|
||||||
serial_num = f"{family_code}{int(hex_response[2:6], 16):05d}"
|
serial_num = f"{family_code}{int(hex_response[2:6], 16):05d}"
|
||||||
|
|||||||
@@ -184,9 +184,8 @@ class StreamReader:
|
|||||||
# Sync marker appeared – hand off to binary parser.
|
# Sync marker appeared – hand off to binary parser.
|
||||||
hex_chars.append(byte)
|
hex_chars.append(byte)
|
||||||
break
|
break
|
||||||
c = chr(byte)
|
if chr(byte) in "0123456789ABCDEFabcdef":
|
||||||
if c in "0123456789ABCDEFabcdef":
|
hex_chars.append(byte)
|
||||||
hex_chars.append(c)
|
|
||||||
else:
|
else:
|
||||||
continue
|
continue
|
||||||
break # broke out of inner loop due to 0xAA
|
break # broke out of inner loop due to 0xAA
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
"""Shared pytest fixtures."""
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def qapp():
|
||||||
|
"""Provide a QApplication instance for the test session."""
|
||||||
|
app = QApplication.instance()
|
||||||
|
if app is None:
|
||||||
|
app = QApplication([])
|
||||||
|
yield app
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Tests for src/pygui/backend/comparison.py."""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from pygui.backend.comparison import compare_runs
|
||||||
|
|
||||||
|
|
||||||
|
def test_identical_series():
|
||||||
|
a = [20.0, 30.0, 40.0, 50.0, 60.0]
|
||||||
|
result = compare_runs(a, a)
|
||||||
|
assert result["distance"] == 0.0
|
||||||
|
assert len(result["index_a"]) == len(a)
|
||||||
|
assert result["index_a"] == result["index_b"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_series():
|
||||||
|
result = compare_runs([], [])
|
||||||
|
assert result["distance"] == float("inf")
|
||||||
|
assert result["index_a"] == []
|
||||||
|
assert result["index_b"] == []
|
||||||
|
assert result["warping_path"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_empty():
|
||||||
|
result = compare_runs([10.0, 20.0], [])
|
||||||
|
assert result["distance"] == float("inf")
|
||||||
|
|
||||||
|
|
||||||
|
def test_aligned_series():
|
||||||
|
a = [10.0, 20.0, 30.0]
|
||||||
|
b = [15.0, 25.0, 35.0]
|
||||||
|
result = compare_runs(a, b)
|
||||||
|
assert result["distance"] >= 0.0
|
||||||
|
assert len(result["index_a"]) > 0
|
||||||
|
assert len(result["index_b"]) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_element():
|
||||||
|
result = compare_runs([42.0], [42.0])
|
||||||
|
assert result["distance"] == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_different_lengths():
|
||||||
|
short = [10.0, 20.0]
|
||||||
|
long_ = [10.0, 15.0, 20.0, 25.0, 30.0]
|
||||||
|
result = compare_runs(short, long_)
|
||||||
|
assert result["distance"] >= 0.0
|
||||||
|
assert len(result["index_a"]) > 0
|
||||||
|
assert len(result["index_b"]) > 0
|
||||||
|
assert len(result["index_a"]) == len(result["index_b"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_return_structure():
|
||||||
|
a = [1.0, 2.0, 3.0, 4.0, 5.0]
|
||||||
|
b = [2.0, 3.0, 4.0, 5.0, 6.0]
|
||||||
|
result = compare_runs(a, b)
|
||||||
|
assert "distance" in result
|
||||||
|
assert "index_a" in result
|
||||||
|
assert "index_b" in result
|
||||||
|
assert "warping_path" in result
|
||||||
|
assert isinstance(result["distance"], float)
|
||||||
|
assert isinstance(result["index_a"], list)
|
||||||
|
assert isinstance(result["index_b"], list)
|
||||||
|
assert isinstance(result["warping_path"], list)
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import pytest
|
||||||
|
import logging
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
from pygui.backend.controllers.device_controller import DeviceController
|
||||||
|
from pygui.backend.data.local_settings import LocalSettings
|
||||||
|
from pygui.serialcomm.serial_port import WaferInfo
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_settings():
|
||||||
|
s = LocalSettings()
|
||||||
|
s.save_data_dir = ""
|
||||||
|
s.connection_status = "Disconnected"
|
||||||
|
s.activity_log = []
|
||||||
|
s.last_wafer_info = {}
|
||||||
|
return s
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def controller(mock_settings):
|
||||||
|
import tempfile
|
||||||
|
import shutil
|
||||||
|
temp_dir = tempfile.mkdtemp()
|
||||||
|
yield DeviceController(mock_settings, temp_dir)
|
||||||
|
shutil.rmtree(temp_dir)
|
||||||
|
|
||||||
|
def test_refresh_ports(controller):
|
||||||
|
controller._service.enumerate_ports = MagicMock(return_value=["COM1", "COM2"])
|
||||||
|
controller.refreshPorts()
|
||||||
|
assert "COM1" in controller.availablePorts
|
||||||
|
|
||||||
|
def test_detect_wafer_spawns_thread(controller):
|
||||||
|
with patch("threading.Thread") as mock_thread:
|
||||||
|
info = WaferInfo(
|
||||||
|
family_code="P",
|
||||||
|
serial_number="P00001",
|
||||||
|
sensor_count=244,
|
||||||
|
mfg_date_hex="12345678",
|
||||||
|
sensor_assigned_hex="01",
|
||||||
|
locked_hex="00",
|
||||||
|
runtime=100,
|
||||||
|
cycle_count=10
|
||||||
|
)
|
||||||
|
controller._service.detect_all_ports = MagicMock(
|
||||||
|
return_value=("COM1", info)
|
||||||
|
)
|
||||||
|
controller.detectWafer()
|
||||||
|
mock_thread.assert_called_once()
|
||||||
|
assert controller.operationInProgress
|
||||||
|
|
||||||
|
def test_read_memory_without_detect_return_error(controller):
|
||||||
|
emitted_result = None
|
||||||
|
def on_read_result(result):
|
||||||
|
nonlocal emitted_result
|
||||||
|
emitted_result = result
|
||||||
|
|
||||||
|
controller.readResult.connect(on_read_result)
|
||||||
|
controller.readMemoryAsync()
|
||||||
|
assert emitted_result is not None
|
||||||
|
assert "error" in emitted_result
|
||||||
|
|
||||||
|
def test_setters_and_properties(controller):
|
||||||
|
controller.setSelectedPort("COM3")
|
||||||
|
assert controller.selectedPort == "COM3"
|
||||||
|
|
||||||
|
controller.setSaveDataDir("some/custom/dir")
|
||||||
|
assert controller.saveDataDir == "some/custom/dir"
|
||||||
|
|
||||||
|
assert controller.connectionStatus == "Disconnected"
|
||||||
|
assert controller.dataRowCount == 0
|
||||||
|
assert controller.dataColCount == 0
|
||||||
|
assert isinstance(controller.lastWaferInfo, list)
|
||||||
|
assert controller.dataModel is not None
|
||||||
|
assert controller.graphView is not None
|
||||||
|
|
||||||
|
def test_clear_activity_log(controller):
|
||||||
|
controller._activity_log.append("[12:00:00] Some message")
|
||||||
|
assert len(controller._activity_log) > 0
|
||||||
|
controller.clearActivityLog()
|
||||||
|
assert len(controller._activity_log) == 0
|
||||||
|
|
||||||
|
def test_clear_session(controller):
|
||||||
|
controller._connection_status = "Connected"
|
||||||
|
controller._selected_port = "COM1"
|
||||||
|
controller._last_wafer_info = {"serialNumber": "P00001"}
|
||||||
|
|
||||||
|
controller.clearSession()
|
||||||
|
assert controller.connectionStatus == "Disconnected"
|
||||||
|
assert controller.selectedPort == ""
|
||||||
|
assert controller.lastWaferInfo == ["", "", 0, "", 0, 0]
|
||||||
|
|
||||||
|
def test_erase_memory(controller):
|
||||||
|
controller._service.erase_wafer = MagicMock(return_value=True)
|
||||||
|
emitted_result = None
|
||||||
|
def on_erase_result(result):
|
||||||
|
nonlocal emitted_result
|
||||||
|
emitted_result = result
|
||||||
|
|
||||||
|
controller.eraseResult.connect(on_erase_result)
|
||||||
|
controller.eraseMemory("COM1")
|
||||||
|
assert emitted_result == {"success": True}
|
||||||
|
assert controller.connectionStatus == "Connected"
|
||||||
|
|
||||||
|
def test_read_debug(controller):
|
||||||
|
controller._service.read_wafer_data = MagicMock(return_value=bytes([12] * 512))
|
||||||
|
emitted_result = None
|
||||||
|
def on_debug_result(result):
|
||||||
|
nonlocal emitted_result
|
||||||
|
emitted_result = result
|
||||||
|
|
||||||
|
controller.debugResult.connect(on_debug_result)
|
||||||
|
controller.readDebug("COM1")
|
||||||
|
assert emitted_result is not None
|
||||||
|
assert emitted_result.get("success") is True
|
||||||
|
assert emitted_result.get("sensor_bytes") == 512
|
||||||
|
assert emitted_result.get("debug_bytes") == 512
|
||||||
|
|
||||||
|
def test_parse_and_save_data_and_get_chart_data(controller):
|
||||||
|
res = controller.getChartData()
|
||||||
|
assert res == {"success": False}
|
||||||
|
|
||||||
|
controller._raw_bytes = bytes([12] * 512)
|
||||||
|
emitted_result = None
|
||||||
|
def on_parsed(result):
|
||||||
|
nonlocal emitted_result
|
||||||
|
emitted_result = result
|
||||||
|
|
||||||
|
controller.parsedDataReady.connect(on_parsed)
|
||||||
|
controller.parseAndSaveData("P", "COM1")
|
||||||
|
|
||||||
|
assert emitted_result is not None
|
||||||
|
assert emitted_result.get("success") is True
|
||||||
|
assert controller.dataRowCount == 1
|
||||||
|
assert controller.dataColCount == 244
|
||||||
|
|
||||||
|
chart_res = controller.getChartData()
|
||||||
|
assert chart_res.get("success") is True
|
||||||
|
assert "Sensor1" in chart_res.get("sensor_names")
|
||||||
|
assert len(chart_res.get("series")) == 244
|
||||||
|
|
||||||
|
def test_logging_handler(controller):
|
||||||
|
logger = logging.getLogger("test_logger")
|
||||||
|
logger.info("Hello QML Activity Log")
|
||||||
|
assert any("Hello QML Activity Log" in line for line in controller._activity_log)
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""Tests for src/pygui/backend/visualization/graph_view.py."""
|
||||||
|
import json
|
||||||
|
from unittest.mock import MagicMock, PropertyMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def _make_plot_widget():
|
||||||
|
"""Create a mock PlotWidget with the interface GraphView expects."""
|
||||||
|
widget = MagicMock()
|
||||||
|
widget.removeItem = MagicMock()
|
||||||
|
widget.plot = MagicMock(return_value=MagicMock())
|
||||||
|
widget.setYRange = MagicMock()
|
||||||
|
widget.clear = MagicMock()
|
||||||
|
return widget
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpdateTrend:
|
||||||
|
def test_parses_valid_json(self):
|
||||||
|
from pygui.backend.visualization.graph_view import GraphView
|
||||||
|
|
||||||
|
gv = GraphView()
|
||||||
|
gv._plot_widget = _make_plot_widget()
|
||||||
|
|
||||||
|
gv.updateTrend(json.dumps([20.0, 25.0, 30.0]))
|
||||||
|
|
||||||
|
assert gv._plot_widget.plot.called
|
||||||
|
gv._plot_widget.setYRange.assert_called_once()
|
||||||
|
|
||||||
|
def test_rejects_invalid_json(self):
|
||||||
|
from pygui.backend.visualization.graph_view import GraphView
|
||||||
|
|
||||||
|
gv = GraphView()
|
||||||
|
gv._plot_widget = _make_plot_widget()
|
||||||
|
|
||||||
|
gv.updateTrend("not json")
|
||||||
|
|
||||||
|
assert not gv._plot_widget.plot.called
|
||||||
|
|
||||||
|
def test_rejects_none(self):
|
||||||
|
from pygui.backend.visualization.graph_view import GraphView
|
||||||
|
|
||||||
|
gv = GraphView()
|
||||||
|
gv._plot_widget = _make_plot_widget()
|
||||||
|
|
||||||
|
gv.updateTrend(None)
|
||||||
|
|
||||||
|
assert not gv._plot_widget.plot.called
|
||||||
|
|
||||||
|
def test_empty_list(self):
|
||||||
|
from pygui.backend.visualization.graph_view import GraphView
|
||||||
|
|
||||||
|
gv = GraphView()
|
||||||
|
gv._plot_widget = _make_plot_widget()
|
||||||
|
|
||||||
|
gv.updateTrend("[]")
|
||||||
|
|
||||||
|
# Empty list means nothing to plot, but clear IS called
|
||||||
|
assert gv._plot_widget.plot.called is False
|
||||||
|
|
||||||
|
def test_no_plot_widget(self):
|
||||||
|
from pygui.backend.visualization.graph_view import GraphView
|
||||||
|
|
||||||
|
gv = GraphView()
|
||||||
|
assert gv._plot_widget is None
|
||||||
|
|
||||||
|
# Should not crash
|
||||||
|
gv.updateTrend("[1, 2, 3]")
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpdateComparison:
|
||||||
|
def test_parses_valid_json(self):
|
||||||
|
from pygui.backend.visualization.graph_view import GraphView
|
||||||
|
|
||||||
|
gv = GraphView()
|
||||||
|
gv._plot_widget = _make_plot_widget()
|
||||||
|
|
||||||
|
a = [10.0, 20.0, 30.0]
|
||||||
|
b = [12.0, 22.0, 32.0]
|
||||||
|
path = [[0, 0], [1, 1], [2, 2]]
|
||||||
|
|
||||||
|
gv.updateComparison(json.dumps(a), json.dumps(b), json.dumps(path))
|
||||||
|
|
||||||
|
assert gv._plot_widget.clear.called
|
||||||
|
assert gv._plot_widget.plot.call_count == 2
|
||||||
|
|
||||||
|
def test_rejects_invalid_json(self):
|
||||||
|
"""clear() is called before parsing, so it always fires on valid widget."""
|
||||||
|
from pygui.backend.visualization.graph_view import GraphView
|
||||||
|
|
||||||
|
gv = GraphView()
|
||||||
|
gv._plot_widget = _make_plot_widget()
|
||||||
|
|
||||||
|
gv.updateComparison("bad", json.dumps([1]), json.dumps([]))
|
||||||
|
|
||||||
|
# clear() is called before parsing — it always fires
|
||||||
|
assert gv._plot_widget.clear.called
|
||||||
|
# But plot() should NOT be called since parsing fails
|
||||||
|
assert not gv._plot_widget.plot.called
|
||||||
|
|
||||||
|
def test_no_plot_widget(self):
|
||||||
|
from pygui.backend.visualization.graph_view import GraphView
|
||||||
|
|
||||||
|
gv = GraphView()
|
||||||
|
assert gv._plot_widget is None
|
||||||
|
|
||||||
|
# Should not crash
|
||||||
|
gv.updateComparison("[1,2]", "[3,4]", "[[0,0]]")
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import pytest
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
from pygui.backend.controllers.session_controller import SessionController
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def controller():
|
||||||
|
return SessionController()
|
||||||
|
|
||||||
|
def test_initial_default(controller):
|
||||||
|
assert controller.mode == "review"
|
||||||
|
assert controller.state == "idle"
|
||||||
|
assert controller.recording == False
|
||||||
|
|
||||||
|
def test_playback_flow(controller):
|
||||||
|
controller.loadFile("tests/fixtures/sample_stream.csv")
|
||||||
|
controller.play()
|
||||||
|
initial_index = controller.frameIndex
|
||||||
|
controller._advance()
|
||||||
|
assert controller.frameIndex == initial_index + 1
|
||||||
|
|
||||||
|
def test_speed_control(controller):
|
||||||
|
controller.setSpeed(2.0)
|
||||||
|
interval = controller._next_interval_ms()
|
||||||
|
controller.setSpeed(0.5)
|
||||||
|
assert controller._next_interval_ms() > interval
|
||||||
|
|
||||||
|
def test_seek(controller):
|
||||||
|
controller.loadFile("tests/fixtures/sample_stream.csv")
|
||||||
|
controller.seek(1)
|
||||||
|
assert controller.frameIndex == 1
|
||||||
|
|
||||||
|
def test_step_forward(controller):
|
||||||
|
controller.loadFile("tests/fixtures/sample_stream.csv")
|
||||||
|
initial = controller.frameIndex
|
||||||
|
controller.step(1)
|
||||||
|
assert controller.frameIndex == initial + 1
|
||||||
|
|
||||||
|
def test_step_backward(controller):
|
||||||
|
controller.loadFile("tests/fixtures/sample_stream.csv")
|
||||||
|
controller.seek(2)
|
||||||
|
controller.step(-1)
|
||||||
|
assert controller.frameIndex == 1
|
||||||
|
|
||||||
|
def test_pause_stop_timer(controller):
|
||||||
|
controller.loadFile("tests/fixtures/sample_stream.csv")
|
||||||
|
controller.seek(2)
|
||||||
|
controller.stop()
|
||||||
|
assert controller.frameIndex == 0
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""Tests for slot_error_boundary decorator."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from pygui.backend.utils import slot_error_boundary
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeSlot:
|
||||||
|
"""Minimal QObject-like object to test the decorator against."""
|
||||||
|
|
||||||
|
def __init__(self, has_log_message=True):
|
||||||
|
self.logMessage = MagicMock() if has_log_message else None
|
||||||
|
self.__class__.__module__ = "test_module"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSlotErrorBoundary:
|
||||||
|
|
||||||
|
def test_passes_through_on_success(self):
|
||||||
|
@slot_error_boundary
|
||||||
|
def good(self) -> str:
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
result = good(_FakeSlot())
|
||||||
|
assert result == "ok"
|
||||||
|
|
||||||
|
def test_catches_and_logs_exception(self, caplog):
|
||||||
|
@slot_error_boundary
|
||||||
|
def bad(self) -> None:
|
||||||
|
raise ValueError("boom")
|
||||||
|
|
||||||
|
with caplog.at_level(logging.ERROR):
|
||||||
|
bad(_FakeSlot())
|
||||||
|
|
||||||
|
assert "ValueError" in caplog.text
|
||||||
|
assert "bad" in caplog.text
|
||||||
|
|
||||||
|
def test_emits_log_message_on_error(self):
|
||||||
|
obj = _FakeSlot(has_log_message=True)
|
||||||
|
|
||||||
|
@slot_error_boundary
|
||||||
|
def bad(self) -> None:
|
||||||
|
raise RuntimeError("fail")
|
||||||
|
|
||||||
|
bad(obj)
|
||||||
|
|
||||||
|
obj.logMessage.emit.assert_called_once()
|
||||||
|
emitted_msg = obj.logMessage.emit.call_args[0][0]
|
||||||
|
assert "bad" in emitted_msg
|
||||||
|
assert "fail" in emitted_msg
|
||||||
|
|
||||||
|
def test_returns_none_on_error(self):
|
||||||
|
@slot_error_boundary
|
||||||
|
def bad(self) -> None:
|
||||||
|
raise KeyError("missing")
|
||||||
|
|
||||||
|
result = bad(_FakeSlot())
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_no_log_message_signal_is_ok(self):
|
||||||
|
"""Controller without logMessage signal should not crash."""
|
||||||
|
|
||||||
|
class _NoLogMsg:
|
||||||
|
__module__ = "test_module"
|
||||||
|
|
||||||
|
@slot_error_boundary
|
||||||
|
def bad(self) -> None:
|
||||||
|
raise ValueError("boom")
|
||||||
|
|
||||||
|
# Should not raise — hasattr check guards the emit
|
||||||
|
result = bad(_NoLogMsg())
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_preserves_function_name(self):
|
||||||
|
@slot_error_boundary
|
||||||
|
def named_method(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert named_method.__name__ == "named_method"
|
||||||
|
|
||||||
|
def test_passes_args_through(self):
|
||||||
|
@slot_error_boundary
|
||||||
|
def with_args(self, a: int, b: str) -> tuple[int, str]:
|
||||||
|
return (a, b)
|
||||||
|
|
||||||
|
result = with_args(_FakeSlot(), 42, "hello")
|
||||||
|
assert result == (42, "hello")
|
||||||
|
|
||||||
|
def test_exception_in_wrapper_does_not_leak(self):
|
||||||
|
"""The decorator must never re-raise — QML would crash."""
|
||||||
|
|
||||||
|
@slot_error_boundary
|
||||||
|
def crashing(self) -> None:
|
||||||
|
raise TypeError("uncaught type error")
|
||||||
|
|
||||||
|
# Should return None, not raise
|
||||||
|
result = crashing(_FakeSlot())
|
||||||
|
assert result is None
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""Tests for src/pygui/backend/splitter.py."""
|
||||||
|
from pygui.backend.splitter import DataSegment, segment_profile
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_below_threshold():
|
||||||
|
temps = [10.0, 20.0, 30.0]
|
||||||
|
result = segment_profile(temps, threshold=40.0)
|
||||||
|
assert result == [
|
||||||
|
DataSegment(label="Ramp", start_frame=0, end_frame=2, avg_temp=20.0)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_above_threshold():
|
||||||
|
temps = [50.0, 60.0, 70.0]
|
||||||
|
result = segment_profile(temps, threshold=40.0)
|
||||||
|
assert result == [
|
||||||
|
DataSegment(label="Soak", start_frame=0, end_frame=2, avg_temp=60.0)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ramp_soak_cool_full_cycle():
|
||||||
|
temps = [20.0, 30.0, 50.0, 60.0, 45.0, 35.0]
|
||||||
|
result = segment_profile(temps, threshold=40.0)
|
||||||
|
assert result == [
|
||||||
|
DataSegment(label="Ramp", start_frame=0, end_frame=1, avg_temp=25.0),
|
||||||
|
DataSegment(label="Soak", start_frame=2, end_frame=4, avg_temp=51.67),
|
||||||
|
DataSegment(label="Cool", start_frame=5, end_frame=5, avg_temp=35.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiple_oscillations():
|
||||||
|
temps = [20.0, 50.0, 30.0, 60.0, 25.0]
|
||||||
|
result = segment_profile(temps, threshold=40.0)
|
||||||
|
assert result == [
|
||||||
|
DataSegment(label="Ramp", start_frame=0, end_frame=0, avg_temp=20.0),
|
||||||
|
DataSegment(label="Soak", start_frame=1, end_frame=1, avg_temp=50.0),
|
||||||
|
DataSegment(label="Cool", start_frame=2, end_frame=2, avg_temp=30.0),
|
||||||
|
DataSegment(label="Soak", start_frame=3, end_frame=3, avg_temp=60.0),
|
||||||
|
DataSegment(label="Cool", start_frame=4, end_frame=4, avg_temp=25.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_cool_phase():
|
||||||
|
temps = [20.0, 30.0, 50.0, 60.0]
|
||||||
|
result = segment_profile(temps, threshold=40.0)
|
||||||
|
assert result == [
|
||||||
|
DataSegment(label="Ramp", start_frame=0, end_frame=1, avg_temp=25.0),
|
||||||
|
DataSegment(label="Soak", start_frame=2, end_frame=3, avg_temp=55.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_threshold():
|
||||||
|
temps = [20.0, 50.0, 30.0]
|
||||||
|
result = segment_profile(temps)
|
||||||
|
assert result == [
|
||||||
|
DataSegment(label="Ramp", start_frame=0, end_frame=0, avg_temp=20.0),
|
||||||
|
DataSegment(label="Soak", start_frame=1, end_frame=1, avg_temp=50.0),
|
||||||
|
DataSegment(label="Cool", start_frame=2, end_frame=2, avg_temp=30.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_frame_below():
|
||||||
|
temps = [25.0]
|
||||||
|
result = segment_profile(temps, threshold=40.0)
|
||||||
|
assert result == [
|
||||||
|
DataSegment(label="Ramp", start_frame=0, end_frame=0, avg_temp=25.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_frame_above():
|
||||||
|
temps = [55.0]
|
||||||
|
result = segment_profile(temps, threshold=40.0)
|
||||||
|
assert result == [
|
||||||
|
DataSegment(label="Soak", start_frame=0, end_frame=0, avg_temp=55.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_input():
|
||||||
|
temps: list[float] = []
|
||||||
|
result = segment_profile(temps)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_precision_rounding():
|
||||||
|
temps = [10.0, 20.0, 30.0]
|
||||||
|
result = segment_profile(temps, threshold=15.0)
|
||||||
|
assert result == [
|
||||||
|
DataSegment(label="Ramp", start_frame=0, end_frame=0, avg_temp=10.0),
|
||||||
|
DataSegment(label="Soak", start_frame=1, end_frame=2, avg_temp=25.0),
|
||||||
|
]
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"""Tests for src/pygui/backend/visualization/wafer_map_item.py."""
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
# Use the session-scoped qapp fixture from conftest.py so Qt Property/Signal
|
||||||
|
# descriptors work properly.
|
||||||
|
pytestmark = pytest.mark.usefixtures("qapp")
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_image_valid_path():
|
||||||
|
"""export_image saves a PNG when grabToImage succeeds."""
|
||||||
|
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||||||
|
|
||||||
|
item = WaferMapItem()
|
||||||
|
|
||||||
|
mock_image = MagicMock()
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.image.return_value = mock_image
|
||||||
|
item.grabToImage = MagicMock(return_value=mock_result)
|
||||||
|
|
||||||
|
assert item.export_image("/tmp/test_wafer.png") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_image_empty_path():
|
||||||
|
"""export_image returns False for empty/None path."""
|
||||||
|
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||||||
|
|
||||||
|
item = WaferMapItem()
|
||||||
|
assert item.export_image("") is False
|
||||||
|
assert item.export_image(None) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_image_grab_fails():
|
||||||
|
"""export_image returns False when grabToImage raises."""
|
||||||
|
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||||||
|
|
||||||
|
item = WaferMapItem()
|
||||||
|
item.grabToImage = MagicMock(side_effect=RuntimeError("grab failed"))
|
||||||
|
|
||||||
|
assert item.export_image("/tmp/test_wafer.png") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_image_save_fails():
|
||||||
|
"""export_image returns False when image.save raises."""
|
||||||
|
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||||||
|
|
||||||
|
mock_image = MagicMock()
|
||||||
|
mock_image.save.side_effect = IOError("disk full")
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.image.return_value = mock_image
|
||||||
|
|
||||||
|
item = WaferMapItem()
|
||||||
|
item.grabToImage = MagicMock(return_value=mock_result)
|
||||||
|
|
||||||
|
assert item.export_image("/tmp/test_wafer.png") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_thickness_properties():
|
||||||
|
"""Thickness data properties exist with correct defaults."""
|
||||||
|
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||||||
|
|
||||||
|
item = WaferMapItem()
|
||||||
|
|
||||||
|
assert item._thickness_data == []
|
||||||
|
assert item._show_thickness is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_thickness_setter_updates_data():
|
||||||
|
"""Setting thicknessData updates _thickness_data."""
|
||||||
|
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||||||
|
|
||||||
|
item = WaferMapItem()
|
||||||
|
item._rebuild_thickness = MagicMock()
|
||||||
|
|
||||||
|
item.thicknessData = [1.0, 2.0, 3.0]
|
||||||
|
|
||||||
|
assert item._thickness_data == [1.0, 2.0, 3.0]
|
||||||
|
assert item._rebuild_thickness.called
|
||||||
|
|
||||||
|
|
||||||
|
def test_show_thickness_setter():
|
||||||
|
"""Setting showThickness updates _show_thickness."""
|
||||||
|
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||||||
|
|
||||||
|
item = WaferMapItem()
|
||||||
|
assert item._show_thickness is False
|
||||||
|
|
||||||
|
item.showThickness = True
|
||||||
|
assert item._show_thickness is True
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
{
|
||||||
|
"_mock_return_value":
|
||||||
Reference in New Issue
Block a user