feat: implement DTW comparison, add data segmentation and persistence, and update QML UI for file imports and graphing.
This commit is contained in:
@@ -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",
|
||||
)
|
||||
Reference in New Issue
Block a user