feat: implement DTW comparison, add data segmentation and persistence, and update QML UI for file imports and graphing.

This commit is contained in:
jack
2026-06-23 21:38:45 -07:00
parent 20a34e22ee
commit 62ce5a9c37
16 changed files with 1049 additions and 8 deletions
+50 -2
View File
@@ -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 ======
RowLayout {
anchors.fill: parent
@@ -141,6 +169,20 @@ Rectangle {
else if (index === 2) {
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 {
@@ -236,17 +278,23 @@ Rectangle {
Label {
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"
color: Theme.bodyColor
font.pixelSize: 20
}
Loader{
Loader {
anchors.fill: parent
active: parent.tabName === "Wafer Map"
source: parent.tabName === "Wafer Map" ? "Tabs/WaferMapTab.qml" : ""
}
Loader {
anchors.fill: parent
active: parent.tabName === "Graph"
source: parent.tabName === "Graph" ? "Tabs/GraphTab.qml" : ""
}
}
}
}
+219
View File
@@ -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()
}
}
}
+3 -2
View File
@@ -5,7 +5,8 @@ import QtQuick.Dialogs
import ISC
// ===== 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 {
id: root
anchors.fill: parent
@@ -313,7 +314,7 @@ ColumnLayout {
TextArea {
id: activityLog
width: parent.width
text: ""
text: qsTr("Welcome — use the side rail to detect a wafer, or import data to review.")
readOnly: true
font.family: "monospace"
font.pixelSize: 12
+1
View File
@@ -1,6 +1,7 @@
# ===== ISC Tabs Module =====
module ISC.Tabs
GraphTab 1.0 GraphTab.qml
WaferMapTab 1.0 WaferMapTab.qml
DataTab 1.0 DataTab.qml
SettingsTab 1.0 SettingsTab.qml
+12 -1
View File
@@ -44,9 +44,20 @@ def main() -> int:
engine.rootContext().setContextProperty("deviceController", device_controller)
# ===== 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)
# Persist session state back to settings when it changes
def _persist_session_settings():
session_state = stream_controller.collect_settings()
for k, v in session_state.items():
if hasattr(raw_settings, k):
setattr(raw_settings, k, v)
LocalSettings.save_settings(data_dir, raw_settings)
stream_controller.settingsChanged.connect(_persist_session_settings)
# ===== QML Startup =====
# The "ISC" QML module lives alongside this file (src/pygui/ISC), so the
# package directory is the import path the engine searches for qmldir.
+29
View File
@@ -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."""
from __future__ import annotations
import json
import logging
import time
from typing import Optional
@@ -35,12 +36,14 @@ class SessionController(QObject):
sensorsChanged = Signal()
loadedFileChanged = 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
_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)
self._mode = MODE_REVIEW
self._model = SessionModel()
@@ -50,6 +53,13 @@ class SessionController(QObject):
self._sensors: list[Sensor] = []
self._last = None # last SessionUpdate
self._elapsed = 0.0
self._trend_buffer: list[float] = [] # rolling window
self._trend_timestamps: list[float] = [] # monotonic timestamps
# Trend refresh timer - update every 1s
self._trend_timer = QTimer(self)
self._trend_timer.setInterval(1000)
self._trend_timer.timeout.connect(self._flush_trend)
self._play_timer = QTimer(self)
self._play_timer.timeout.connect(self._advance)
@@ -70,6 +80,26 @@ class SessionController(QObject):
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."""
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 ----
@Property(str, notify=modeChanged)
def mode(self) -> str: return self._mode
@@ -257,6 +287,11 @@ class SessionController(QObject):
self.loadedFileChanged.emit()
self.sensorsChanged.emit()
self._emit_current()
# Populate stats tracker from all loaded frames for trend chart (P3.1)
for frame in self._player._frames:
self._stats_tracker.track(frame.seq, frame.values)
self.trendData.emit(json.dumps(self._stats_tracker.get_trend()))
self.settingsChanged.emit()
@Slot()
@slot_error_boundary
@@ -383,6 +418,9 @@ class SessionController(QObject):
# Clear out any old data from the prev sessions
self._model.reset()
self._trend_buffer.clear()
self._trend_timestamps.clear()
self._trend_timer.start()
self._stats_tracker.reset()
self._last = None
self._last_raw_frame = None
@@ -410,6 +448,7 @@ class SessionController(QObject):
@slot_error_boundary
def stopStream(self) -> None:
self._repaint_timer.stop()
self._trend_timer.stop()
if self._reader:
transport = self._reader._transport
self._reader.stop()
@@ -442,6 +481,21 @@ class SessionController(QObject):
self._dirty = True
self.trendData.emit(json.dumps(self._stats_tracker.get_trend()))
if self._last and self._last.stats:
avg = self._last.stats.avg
now = time.monotonic()
self._trend_buffer.append(avg)
self._trend_timestamps.append(now)
# drop entries older than 60 secs
cutoff = now -60
while self._trend_timestamps and self._trend_timestamps[0] < cutoff:
self._trend_timestamps.pop(0)
self._trend_buffer.pop(0)
def _flush_repaint(self) -> None:
if not self._dirty:
return
@@ -491,4 +545,8 @@ class SessionController(QObject):
self._sensor_editor.clear()
self._reprocess_current()
def _flush_trend(self) -> None:
import json
self.trendData.emit(json.dumps(self._trend_buffer))
+20
View File
@@ -45,3 +45,23 @@ class CsvRecorder:
self._file_handle.close()
path, self._file_handle, self._path = self._path, None, None
return path
@staticmethod
def write_segment(file_path: str, source_path: str, start_frame: int, end_frame: int) ->bool:
"""Copy a range of frames from source CSV into a new file.
Args:
file_path: Output CSV path.
source_path: Source CSV path with full wafer data
start_frame: First frame index (inclusive).
end_frame: Last frame index (inclusive)
Returns:
True on success.
"""
import pandas as pd
df = pd.read_csv(source_path)
segment = df.iloc[start_frame:end_frame + 1]
segment.to_csv(file_path, index=False)
return True
+3
View File
@@ -32,6 +32,9 @@ class LocalSettings:
self.data_col_count = 0
self.last_csv_path = ""
# Session persistence
self.session_last_file = ""
# ===== File Path Helpers =====
@classmethod
def _settings_path(cls, directory: str) -> Path:
+48
View File
@@ -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
+2 -1
View File
@@ -1,9 +1,10 @@
# ===== Visualization Sub-package =====
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
from pygui.backend.visualization.graph_view import GraphView
from pygui.backend.visualization.rbf_heatmap import interpolate_field
from pygui.backend.visualization.wafer_map_item import WaferMapItem
__all__ = [
"WaferMapItem", "GraphView",
"GraphQuickItem", "GraphView", "WaferMapItem",
"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",
)
+42 -1
View File
@@ -11,7 +11,7 @@ from typing import Any, Optional
import pyqtgraph as pg
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
log = logging.getLogger(__name__)
@@ -191,3 +191,44 @@ class GraphView(QObject):
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")
@@ -47,6 +47,9 @@ class WaferMapItem(QQuickPaintedItem):
colorsChanged = Signal()
shapeChanged = Signal()
sizeChanged = Signal()
thicknessChanged = Signal()
showThicknessChanged = Signal()
def __init__(self, parent=None):
super().__init__(parent)
@@ -59,6 +62,9 @@ class WaferMapItem(QQuickPaintedItem):
self._show_labels: bool = True
self._shape: str = "round"
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)
self._ring_color = QColor("#2A3441") # waferRingColor (toneBorder)
@@ -183,6 +189,27 @@ class WaferMapItem(QQuickPaintedItem):
self._rebuild()
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
@Property(QColor, notify=colorsChanged)
def ringColor(self) -> QColor: return self._ring_color
@@ -225,6 +252,25 @@ class WaferMapItem(QQuickPaintedItem):
return idx
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 ─────────────────────────────────────────────────────────
def _on_resize(self) -> None:
@@ -360,6 +406,12 @@ class WaferMapItem(QQuickPaintedItem):
cy - self._heatmap.height() // 2, self._heatmap)
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)
def _paint_template(self, painter: QPainter, cx: int, cy: int, r_px: int) -> None: