From 62ce5a9c37216787b61a7b68c38c0d5a0f5bd1b3 Mon Sep 17 00:00:00 2001 From: jack Date: Tue, 23 Jun 2026 21:38:45 -0700 Subject: [PATCH] feat: implement DTW comparison, add data segmentation and persistence, and update QML UI for file imports and graphing. --- src/pygui/ISC/HomePage.qml | 52 ++- src/pygui/ISC/Tabs/GraphTab.qml | 219 +++++++++ src/pygui/ISC/Tabs/StatusTab.qml | 5 +- src/pygui/ISC/Tabs/qmldir | 1 + src/pygui/__main__.py | 13 +- src/pygui/backend/comparison.py | 29 ++ .../backend/controllers/session_controller.py | 60 ++- src/pygui/backend/data/csv_recorder.py | 20 + src/pygui/backend/data/local_settings.py | 3 + src/pygui/backend/splitter.py | 48 ++ src/pygui/backend/visualization/__init__.py | 3 +- .../backend/visualization/graph_quick_item.py | 417 ++++++++++++++++++ src/pygui/backend/visualization/graph_view.py | 43 +- .../backend/visualization/wafer_map_item.py | 52 +++ tests/test_splitter.py | 90 ++++ tmp/settings.json | 2 + 16 files changed, 1049 insertions(+), 8 deletions(-) create mode 100644 src/pygui/ISC/Tabs/GraphTab.qml create mode 100644 src/pygui/backend/comparison.py create mode 100644 src/pygui/backend/splitter.py create mode 100644 src/pygui/backend/visualization/graph_quick_item.py create mode 100644 tests/test_splitter.py create mode 100644 tmp/settings.json diff --git a/src/pygui/ISC/HomePage.qml b/src/pygui/ISC/HomePage.qml index 0a93416..194eb32 100644 --- a/src/pygui/ISC/HomePage.qml +++ b/src/pygui/ISC/HomePage.qml @@ -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" : "" + } } } } diff --git a/src/pygui/ISC/Tabs/GraphTab.qml b/src/pygui/ISC/Tabs/GraphTab.qml new file mode 100644 index 0000000..c88c69b --- /dev/null +++ b/src/pygui/ISC/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() + } + } +} diff --git a/src/pygui/ISC/Tabs/StatusTab.qml b/src/pygui/ISC/Tabs/StatusTab.qml index e280ff3..88f7d7c 100644 --- a/src/pygui/ISC/Tabs/StatusTab.qml +++ b/src/pygui/ISC/Tabs/StatusTab.qml @@ -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 diff --git a/src/pygui/ISC/Tabs/qmldir b/src/pygui/ISC/Tabs/qmldir index 4e00d78..2efa59d 100644 --- a/src/pygui/ISC/Tabs/qmldir +++ b/src/pygui/ISC/Tabs/qmldir @@ -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 diff --git a/src/pygui/__main__.py b/src/pygui/__main__.py index 0a840cb..0e3efd5 100644 --- a/src/pygui/__main__.py +++ b/src/pygui/__main__.py @@ -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. diff --git a/src/pygui/backend/comparison.py b/src/pygui/backend/comparison.py new file mode 100644 index 0000000..ea441f9 --- /dev/null +++ b/src/pygui/backend/comparison.py @@ -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())) + } diff --git a/src/pygui/backend/controllers/session_controller.py b/src/pygui/backend/controllers/session_controller.py index a327c44..4191ce7 100644 --- a/src/pygui/backend/controllers/session_controller.py +++ b/src/pygui/backend/controllers/session_controller.py @@ -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)) + diff --git a/src/pygui/backend/data/csv_recorder.py b/src/pygui/backend/data/csv_recorder.py index 620b563..693970f 100644 --- a/src/pygui/backend/data/csv_recorder.py +++ b/src/pygui/backend/data/csv_recorder.py @@ -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 + diff --git a/src/pygui/backend/data/local_settings.py b/src/pygui/backend/data/local_settings.py index c771122..b7e35ec 100644 --- a/src/pygui/backend/data/local_settings.py +++ b/src/pygui/backend/data/local_settings.py @@ -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: diff --git a/src/pygui/backend/splitter.py b/src/pygui/backend/splitter.py new file mode 100644 index 0000000..bcb55c1 --- /dev/null +++ b/src/pygui/backend/splitter.py @@ -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 diff --git a/src/pygui/backend/visualization/__init__.py b/src/pygui/backend/visualization/__init__.py index cccec23..b31b753 100644 --- a/src/pygui/backend/visualization/__init__.py +++ b/src/pygui/backend/visualization/__init__.py @@ -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", ] diff --git a/src/pygui/backend/visualization/graph_quick_item.py b/src/pygui/backend/visualization/graph_quick_item.py new file mode 100644 index 0000000..993286b --- /dev/null +++ b/src/pygui/backend/visualization/graph_quick_item.py @@ -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", + ) diff --git a/src/pygui/backend/visualization/graph_view.py b/src/pygui/backend/visualization/graph_view.py index 874fe38..a09aa30 100644 --- a/src/pygui/backend/visualization/graph_view.py +++ b/src/pygui/backend/visualization/graph_view.py @@ -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") diff --git a/src/pygui/backend/visualization/wafer_map_item.py b/src/pygui/backend/visualization/wafer_map_item.py index 29da81e..68cddbc 100644 --- a/src/pygui/backend/visualization/wafer_map_item.py +++ b/src/pygui/backend/visualization/wafer_map_item.py @@ -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: diff --git a/tests/test_splitter.py b/tests/test_splitter.py new file mode 100644 index 0000000..d222209 --- /dev/null +++ b/tests/test_splitter.py @@ -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), + ] diff --git a/tmp/settings.json b/tmp/settings.json new file mode 100644 index 0000000..9012666 --- /dev/null +++ b/tmp/settings.json @@ -0,0 +1,2 @@ +{ + "_mock_return_value": \ No newline at end of file