feat(viz): implement RBF heatmap and custom QPainter trend chart item

Introduce Radial Basis Function interpolation for fine wafer thermal gradients and GraphQuickItem for high-performance trend line rendering.
This commit is contained in:
jack
2026-06-25 13:47:48 -07:00
parent 2cbc143c20
commit f4621f1faf
3 changed files with 317 additions and 6 deletions
@@ -0,0 +1,262 @@
"""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)