f4621f1faf
Introduce Radial Basis Function interpolation for fine wafer thermal gradients and GraphQuickItem for high-performance trend line rendering.
263 lines
8.8 KiB
Python
263 lines
8.8 KiB
Python
"""QQuickPaintedItem trend chart — running-average temperature over time.
|
|
|
|
Renders a single polyline series (per-frame average temperatures) using QPainter.
|
|
Mirrors the @QmlElement registration pattern used by `WaferMapItem` so it can
|
|
be embedded directly in QML via `import ISC.Wafer` and used as `TrendChartItem { }`.
|
|
|
|
The series is driven by the existing `streamController.trendData` signal, which
|
|
emits a JSON-encoded list of floats from both review-mode replay and live-mode
|
|
streaming.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from typing import Optional
|
|
|
|
from PySide6.QtCore import Property, QPoint, QRectF, Qt, Signal, Slot
|
|
from PySide6.QtGui import QColor, QFont, QPainter, QPen, QPolygon
|
|
from PySide6.QtQml import QmlElement
|
|
from PySide6.QtQuick import QQuickPaintedItem
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
QML_IMPORT_NAME = "ISC.Wafer"
|
|
QML_IMPORT_MAJOR_VERSION = 1
|
|
|
|
|
|
@QmlElement
|
|
class TrendChartItem(QQuickPaintedItem):
|
|
"""Painted trend chart; driven by a list of floats via the `data` property."""
|
|
|
|
dataChanged = Signal()
|
|
hasDataChanged = Signal()
|
|
lineColorChanged = Signal()
|
|
gridColorChanged = Signal()
|
|
textColorChanged = Signal()
|
|
paddingChanged = Signal()
|
|
|
|
def __init__(self, parent: Optional[QQuickPaintedItem] = None) -> None:
|
|
super().__init__(parent)
|
|
self.setRenderTarget(QQuickPaintedItem.RenderTarget.FramebufferObject)
|
|
self.setAntialiasing(True)
|
|
self.setFillColor(QColor("transparent"))
|
|
|
|
self._data: list[float] = []
|
|
self._padding: int = 8
|
|
self._line_color = QColor("#5B9DF5")
|
|
self._grid_color = QColor("#2A3441")
|
|
self._text_color = QColor("#CBD5E1")
|
|
|
|
# ── Qt properties ─────────────────────────────────────────────────────
|
|
|
|
@Property("QVariantList", notify=dataChanged)
|
|
def data(self) -> list[float]:
|
|
return self._data
|
|
|
|
@data.setter
|
|
def data(self, val) -> None:
|
|
coerced = self._coerce_floats(val)
|
|
if coerced == self._data:
|
|
return
|
|
self._data = coerced
|
|
self.dataChanged.emit()
|
|
self.hasDataChanged.emit()
|
|
self.update()
|
|
|
|
@Property(bool, notify=hasDataChanged)
|
|
def hasData(self) -> bool:
|
|
"""True when the data list has at least one point.
|
|
|
|
QML can bind to this safely (QVariantList `.length` is not bindable).
|
|
"""
|
|
return len(self._data) > 0
|
|
|
|
@Property(QColor, notify=lineColorChanged)
|
|
def lineColor(self) -> QColor:
|
|
return self._line_color
|
|
|
|
@lineColor.setter
|
|
def lineColor(self, c: QColor) -> None:
|
|
if c == self._line_color:
|
|
return
|
|
self._line_color = QColor(c)
|
|
self.lineColorChanged.emit()
|
|
self.update()
|
|
|
|
@Property(QColor, notify=gridColorChanged)
|
|
def gridColor(self) -> QColor:
|
|
return self._grid_color
|
|
|
|
@gridColor.setter
|
|
def gridColor(self, c: QColor) -> None:
|
|
if c == self._grid_color:
|
|
return
|
|
self._grid_color = QColor(c)
|
|
self.gridColorChanged.emit()
|
|
self.update()
|
|
|
|
@Property(QColor, notify=textColorChanged)
|
|
def textColor(self) -> QColor:
|
|
return self._text_color
|
|
|
|
@textColor.setter
|
|
def textColor(self, c: QColor) -> None:
|
|
if c == self._text_color:
|
|
return
|
|
self._text_color = QColor(c)
|
|
self.textColorChanged.emit()
|
|
self.update()
|
|
|
|
@Property(int, notify=paddingChanged)
|
|
def padding(self) -> int:
|
|
return self._padding
|
|
|
|
@padding.setter
|
|
def padding(self, val: int) -> None:
|
|
v = max(0, int(val))
|
|
if v == self._padding:
|
|
return
|
|
self._padding = v
|
|
self.paddingChanged.emit()
|
|
self.update()
|
|
|
|
# ── Convenience slot for QML: accept a JSON string from a Signal(str) ──
|
|
|
|
@Slot(str)
|
|
def setDataFromJson(self, avgs_json: str) -> None:
|
|
"""Slot for QML Connections handler: parse JSON array and update data.
|
|
|
|
Mirrors `streamController.trendData` (which emits JSON-encoded floats).
|
|
"""
|
|
try:
|
|
parsed = json.loads(avgs_json) if avgs_json else []
|
|
except (json.JSONDecodeError, TypeError) as exc:
|
|
log.error("TrendChartItem: failed to parse trend JSON: %s", exc)
|
|
return
|
|
coerced = self._coerce_floats(parsed)
|
|
if coerced == self._data:
|
|
return
|
|
self._data = coerced
|
|
self.dataChanged.emit()
|
|
self.hasDataChanged.emit()
|
|
self.update()
|
|
|
|
# ── paint ─────────────────────────────────────────────────────────────
|
|
|
|
def paint(self, painter: QPainter) -> None: # type: ignore[override]
|
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
|
painter.setRenderHint(QPainter.RenderHint.TextAntialiasing, True)
|
|
|
|
w = self.width()
|
|
h = self.height()
|
|
if w <= 0 or h <= 0:
|
|
return
|
|
|
|
plot_rect = QRectF(self._padding, self._padding,
|
|
max(1, w - 2 * self._padding),
|
|
max(1, h - 2 * self._padding))
|
|
|
|
self._draw_grid(painter, plot_rect)
|
|
|
|
if len(self._data) < 2:
|
|
return
|
|
|
|
y_min, y_max = self._y_range()
|
|
self._draw_axes_labels(painter, plot_rect, y_min, y_max)
|
|
self._draw_line(painter, plot_rect, y_min, y_max)
|
|
|
|
# ── internals ─────────────────────────────────────────────────────────
|
|
|
|
def _coerce_floats(self, val) -> list[float]:
|
|
if not val:
|
|
return []
|
|
out: list[float] = []
|
|
for v in val:
|
|
try:
|
|
out.append(float(v))
|
|
except (TypeError, ValueError):
|
|
continue
|
|
return out
|
|
|
|
def _y_range(self) -> tuple[float, float]:
|
|
lo = min(self._data)
|
|
hi = max(self._data)
|
|
if hi - lo < 1e-9:
|
|
return lo - 5.0, hi + 5.0
|
|
buf = (hi - lo) * 0.1
|
|
return lo - buf, hi + buf
|
|
|
|
def _x_to_px(self, i: int, n: int, plot_rect: QRectF) -> float:
|
|
if n <= 1:
|
|
return plot_rect.left()
|
|
return plot_rect.left() + (i / (n - 1)) * plot_rect.width()
|
|
|
|
def _y_to_px(self, v: float, y_min: float, y_max: float, plot_rect: QRectF) -> float:
|
|
if y_max - y_min < 1e-9:
|
|
return plot_rect.center().y()
|
|
t = (v - y_min) / (y_max - y_min)
|
|
return plot_rect.bottom() - t * plot_rect.height()
|
|
|
|
def _draw_grid(self, painter: QPainter, plot_rect: QRectF) -> None:
|
|
pen = QPen(self._grid_color)
|
|
pen.setWidthF(1.0)
|
|
pen.setCosmetic(True)
|
|
painter.setPen(pen)
|
|
rows = 4
|
|
for i in range(rows + 1):
|
|
y = plot_rect.top() + (i / rows) * plot_rect.height()
|
|
painter.drawLine(int(plot_rect.left()), int(y),
|
|
int(plot_rect.right()), int(y))
|
|
|
|
def _draw_axes_labels(
|
|
self,
|
|
painter: QPainter,
|
|
plot_rect: QRectF,
|
|
y_min: float,
|
|
y_max: float,
|
|
) -> None:
|
|
painter.setPen(self._text_color)
|
|
font = QFont(painter.font())
|
|
font.setPointSizeF(max(7.0, font.pointSizeF() * 0.85))
|
|
painter.setFont(font)
|
|
rows = 4
|
|
for i in range(rows + 1):
|
|
t = i / rows
|
|
y_val = y_max - t * (y_max - y_min)
|
|
y_px = plot_rect.top() + t * plot_rect.height()
|
|
label = f"{y_val:.1f}"
|
|
painter.drawText(QRectF(0, y_px - 7, self._padding, 14),
|
|
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
|
|
label)
|
|
|
|
def _draw_line(
|
|
self,
|
|
painter: QPainter,
|
|
plot_rect: QRectF,
|
|
y_min: float,
|
|
y_max: float,
|
|
) -> None:
|
|
n = len(self._data)
|
|
pen = QPen(self._line_color)
|
|
pen.setWidthF(2.0)
|
|
pen.setCosmetic(True)
|
|
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
|
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
|
|
painter.setPen(pen)
|
|
|
|
poly = QPolygon()
|
|
for i, v in enumerate(self._data):
|
|
px = self._x_to_px(i, n, plot_rect)
|
|
py = self._y_to_px(v, y_min, y_max, plot_rect)
|
|
poly.append(QPoint(int(px), int(py)))
|
|
painter.drawPolyline(poly)
|
|
|
|
# Trailing dot at the last data point
|
|
last_x = int(self._x_to_px(n - 1, n, plot_rect))
|
|
last_y = int(self._y_to_px(self._data[-1], y_min, y_max, plot_rect))
|
|
painter.setBrush(self._line_color)
|
|
painter.setPen(Qt.PenStyle.NoPen)
|
|
radius = 3
|
|
painter.drawEllipse(last_x - radius, last_y - radius,
|
|
radius * 2, radius * 2)
|