feat: implement DTW comparison, add data segmentation and persistence, and update QML UI for file imports and graphing.
This commit is contained in:
@@ -80,6 +80,34 @@ Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 ======
|
||||||
RowLayout {
|
RowLayout {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
@@ -141,6 +169,20 @@ Rectangle {
|
|||||||
else if (index === 2) {
|
else if (index === 2) {
|
||||||
deviceController.openCsvFile()
|
deviceController.openCsvFile()
|
||||||
}
|
}
|
||||||
|
else if (index === 3) {
|
||||||
|
// ERASE MEMORY
|
||||||
|
streamController.setMode("review")
|
||||||
|
streamController.stopStream()
|
||||||
|
deviceController.eraseMemory(deviceController.selectedPort || "")
|
||||||
|
}
|
||||||
|
else if (index === 4) {
|
||||||
|
// IMPORT DATA
|
||||||
|
importFileDialog.open()
|
||||||
|
}
|
||||||
|
else if (index === 5) {
|
||||||
|
// STORED DATA
|
||||||
|
storedDataDialog.open()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
background: Rectangle {
|
background: Rectangle {
|
||||||
@@ -236,7 +278,7 @@ 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
|
||||||
@@ -247,6 +289,12 @@ Rectangle {
|
|||||||
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" : ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -313,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+12
-1
@@ -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.
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""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)
|
||||||
|
|
||||||
|
if x.size == 0 or y.size == 0:
|
||||||
|
return {"distance": float('inf'), "index_a": [],"index_b": [], "warping_path": []}
|
||||||
|
|
||||||
|
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()))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -35,12 +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
|
# trend: per-frame avg for live graph
|
||||||
trendData = Signal(str) # JSON array of floats
|
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()
|
||||||
@@ -50,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)
|
||||||
@@ -70,6 +80,26 @@ class SessionController(QObject):
|
|||||||
self._active_clusters: list[list[int]] = []
|
self._active_clusters: list[list[int]] = []
|
||||||
self._stats_tracker = ReplayStatsTracker()
|
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."""
|
||||||
|
last_file = settings.get("session_last_file", "")
|
||||||
|
if last_file:
|
||||||
|
try:
|
||||||
|
self.loadFile(last_file)
|
||||||
|
except Exception:
|
||||||
|
log.warning("Could not restore last session file: %s", last_file)
|
||||||
|
|
||||||
|
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)
|
||||||
def mode(self) -> str: return self._mode
|
def mode(self) -> str: return self._mode
|
||||||
@@ -257,6 +287,11 @@ class SessionController(QObject):
|
|||||||
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
|
@slot_error_boundary
|
||||||
@@ -383,6 +418,9 @@ 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._stats_tracker.reset()
|
||||||
self._last = None
|
self._last = None
|
||||||
self._last_raw_frame = None
|
self._last_raw_frame = None
|
||||||
@@ -410,6 +448,7 @@ class SessionController(QObject):
|
|||||||
@slot_error_boundary
|
@slot_error_boundary
|
||||||
def stopStream(self) -> None:
|
def stopStream(self) -> None:
|
||||||
self._repaint_timer.stop()
|
self._repaint_timer.stop()
|
||||||
|
self._trend_timer.stop()
|
||||||
if self._reader:
|
if self._reader:
|
||||||
transport = self._reader._transport
|
transport = self._reader._transport
|
||||||
self._reader.stop()
|
self._reader.stop()
|
||||||
@@ -442,6 +481,21 @@ class SessionController(QObject):
|
|||||||
self._dirty = True
|
self._dirty = True
|
||||||
self.trendData.emit(json.dumps(self._stats_tracker.get_trend()))
|
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:
|
||||||
return
|
return
|
||||||
@@ -491,4 +545,8 @@ class SessionController(QObject):
|
|||||||
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))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -32,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:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
# ===== Visualization Sub-package =====
|
# ===== Visualization Sub-package =====
|
||||||
|
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.wafer_map_item import WaferMapItem
|
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"WaferMapItem", "GraphView",
|
"GraphQuickItem", "GraphView", "WaferMapItem",
|
||||||
"interpolate_field",
|
"interpolate_field",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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",
|
||||||
|
)
|
||||||
@@ -11,7 +11,7 @@ from typing import Any, Optional
|
|||||||
|
|
||||||
import pyqtgraph as pg
|
import pyqtgraph as pg
|
||||||
from pyqtgraph import PlotWidget
|
from pyqtgraph import PlotWidget
|
||||||
from PySide6.QtCore import Property, QObject, Signal, Slot
|
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__)
|
||||||
@@ -191,3 +191,44 @@ class GraphView(QObject):
|
|||||||
y_min, y_max = min(avgs), max(avgs)
|
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
|
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)
|
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")
|
||||||
|
|||||||
@@ -47,6 +47,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 +62,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 +189,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,6 +252,25 @@ 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:
|
||||||
|
image = self.grabToImage()
|
||||||
|
image.save(file_path, "PNG")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
log.error("Export failed: %s", e)
|
||||||
|
return False
|
||||||
# ── internal ─────────────────────────────────────────────────────────
|
# ── internal ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _on_resize(self) -> None:
|
def _on_resize(self) -> None:
|
||||||
@@ -360,6 +406,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:
|
||||||
|
|||||||
@@ -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,2 @@
|
|||||||
|
{
|
||||||
|
"_mock_return_value":
|
||||||
Reference in New Issue
Block a user