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 ======
|
||||
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" : ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
// ===== 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,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
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 =====
|
||||
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",
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -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