"""QQuickPaintedItem wafer map — ported from the replay app's ReplayWidget. Draws: • Radial ring template (concentric guides + crosshair axes + top notch) • RBF heatmap layer (blended under markers via `blend` 0→1) • Sensor marker circles colored by band (low/in_range/high) • Numbered labels (toggle via `showLabels`) All sensor coordinates are center-origin mm (from wafer_layouts or a loaded CSV). """ from __future__ import annotations import math import numpy as np from PySide6.QtCore import Property, QPoint, Signal, Slot, Qt from PySide6.QtGui import ( QBrush, QColor, QFont, QImage, QPainter, QPen, QPolygon, ) from PySide6.QtQml import QmlElement from PySide6.QtQuick import QQuickPaintedItem from pygui.backend.visualization.rbf_heatmap import interpolate_field from pygui.backend.wafer.zwafer_models import Sensor QML_IMPORT_NAME = "ISC.Wafer" QML_IMPORT_MAJOR_VERSION = 1 @QmlElement class WaferMapItem(QQuickPaintedItem): """Painted wafer map; driven by SessionController via QML property bindings.""" sensorsChanged = Signal() valuesChanged = Signal() bandsChanged = Signal() targetChanged = Signal() marginChanged = Signal() blendChanged = Signal() showLabelsChanged = Signal() colorsChanged = Signal() def __init__(self, parent=None): super().__init__(parent) self._sensors: list[Sensor] = [] self._values: list[float] = [] self._bands: list[str] = [] self._target: float = 149.0 self._margin: float = 1.0 self._blend: float = 0.0 self._show_labels: bool = True # Dark-theme color defaults (match Theme.qml tokens) self._ring_color = QColor("#2A3441") # waferRingColor (toneBorder) self._axis_color = QColor("#3A4D5C") # waferAxisColor (softBorder) self._low_color = QColor("#5B9DF5") # sensorLow self._in_range_color = QColor("#22C55E") # sensorInRange self._high_color = QColor("#EF4444") # sensorHigh self._text_color = QColor("#CBD5E1") # bodyColor # Internal draw state self._markers: dict[int, tuple[int, int]] = {} # sensor index → (px, py) self._marker_r: int = 4 self._heatmap: QImage | None = None self.widthChanged.connect(self._on_resize) self.heightChanged.connect(self._on_resize) # ── Qt properties ──────────────────────────────────────────────────── @Property("QVariantList", notify=sensorsChanged) def sensors(self) -> list: return [{"label": s.label, "x": s.x, "y": s.y} for s in self._sensors] @sensors.setter def sensors(self, val: list) -> None: self._sensors = [Sensor(label=d["label"], x=float(d["x"]), y=float(d["y"])) for d in (val or [])] self._rebuild() self.sensorsChanged.emit() @Property("QVariantList", notify=valuesChanged) def values(self) -> list: return self._values @values.setter def values(self, val: list) -> None: self._values = list(val or []) self._rebuild_heatmap() self.valuesChanged.emit() self.update() @Property("QVariantList", notify=bandsChanged) def bands(self) -> list: return self._bands @bands.setter def bands(self, val: list) -> None: self._bands = list(val or []) self.bandsChanged.emit() self.update() @Property(float, notify=targetChanged) def target(self) -> float: return self._target @target.setter def target(self, val: float) -> None: self._target = float(val) self._rebuild_heatmap() self.targetChanged.emit() self.update() @Property(float, notify=marginChanged) def margin(self) -> float: return self._margin @margin.setter def margin(self, val: float) -> None: self._margin = float(val) self._rebuild_heatmap() self.marginChanged.emit() self.update() @Property(float, notify=blendChanged) def blend(self) -> float: return self._blend @blend.setter def blend(self, val: float) -> None: self._blend = max(0.0, min(1.0, float(val))) if self._blend > 0 and self._heatmap is None: self._rebuild_heatmap() self.blendChanged.emit() self.update() @Property(bool, notify=showLabelsChanged) def showLabels(self) -> bool: return self._show_labels @showLabels.setter def showLabels(self, val: bool) -> None: self._show_labels = bool(val) self.showLabelsChanged.emit() self.update() # Colour properties — QML can bind these to Theme tokens @Property(QColor, notify=colorsChanged) def ringColor(self) -> QColor: return self._ring_color @ringColor.setter def ringColor(self, c: QColor) -> None: self._ring_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 lowColor(self) -> QColor: return self._low_color @lowColor.setter def lowColor(self, c: QColor) -> None: self._low_color = c; self.update() @Property(QColor, notify=colorsChanged) def inRangeColor(self) -> QColor: return self._in_range_color @inRangeColor.setter def inRangeColor(self, c: QColor) -> None: self._in_range_color = c; self.update() @Property(QColor, notify=colorsChanged) def highColor(self) -> QColor: return self._high_color @highColor.setter def highColor(self, c: QColor) -> None: self._high_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() # ── slots ───────────────────────────────────────────────────────────── @Slot(float, float, result=int) def which_marker(self, x: float, y: float) -> int: """Return the sensor index nearest to (x, y) within marker radius, else -1.""" r = max(self._marker_r, 6) for idx, (mx, my) in self._markers.items(): if abs(mx - x) <= r and abs(my - y) <= r: return idx return -1 # ── internal ───────────────────────────────────────────────────────── def _on_resize(self) -> None: self._rebuild() def _rebuild(self) -> None: self._compute_markers() self._rebuild_heatmap() self.update() def _draw_size(self) -> int: return max(1, int(min(self.width(), self.height()))) def _center(self) -> tuple[int, int]: return int(self.width() / 2), int(self.height() / 2) def _wafer_radius_mm(self) -> float: """Radius of the wafer bounding circle in mm (5% padding beyond outermost sensor).""" if not self._sensors: return 150.0 r = max(math.hypot(s.x, s.y) for s in self._sensors) return r * 1.05 def _sensor_ring_radii_mm(self) -> list[float]: """Distinct radial distances of sensor groups, sorted ascending, plus the outer boundary.""" if not self._sensors: r = self._wafer_radius_mm() return [r * f for f in (0.25, 0.50, 0.75, 1.0)] # Cluster radii that are within 2 mm of each other into one ring; skip center point. radii = sorted(r for r in {math.hypot(s.x, s.y) for s in self._sensors} if r > 1.0) groups: list[float] = [] for r in radii: if not groups or r - groups[-1] > 2.0: groups.append(r) else: groups[-1] = (groups[-1] + r) / 2 # merge close values # Always include the outer boundary ring so the wafer circle is drawn. outer = self._wafer_radius_mm() if not groups or outer - groups[-1] > 2.0: groups.append(outer) return groups def _scale(self, ds: int, r_mm: float) -> float: """Pixels per mm. The wafer radius maps to ds//2 - 24 px.""" return (ds / 2 - 24) / r_mm def _to_px(self, x_mm: float, y_mm: float, cx: int, cy: int, scale: float) -> tuple[int, int]: """Center-origin mm → pixel (top-left origin). Y is flipped.""" return cx + int(x_mm * scale), cy - int(y_mm * scale) def _compute_markers(self) -> None: ds = self._draw_size() r_mm = self._wafer_radius_mm() sc = self._scale(ds, r_mm) cx, cy = self._center() self._marker_r = max(3, ds // 70) self._markers = {i: self._to_px(s.x, s.y, cx, cy, sc) for i, s in enumerate(self._sensors)} def _rebuild_heatmap(self) -> None: if not self._sensors or not self._values or self._blend == 0.0: self._heatmap = None return ds = self._draw_size() r_mm = self._wafer_radius_mm() xs = np.array([s.x for s in self._sensors]) ys = np.array([s.y for s in self._sensors]) vs = np.array(self._values[:len(self._sensors)], dtype=float) if len(vs) < len(self._sensors): self._heatmap = None return try: field = interpolate_field( xs, ys, vs, width=ds, height=ds, extent=(-r_mm, r_mm, -r_mm, r_mm), round_clip=True, ) except Exception: self._heatmap = None return self._heatmap = self._field_to_qimage(field, ds) def _field_to_qimage(self, field: np.ndarray, ds: int) -> QImage: """Apply a band-aware tri-color gradient → RGBA QImage.""" lo_b = self._target - self._margin hi_b = self._target + self._margin span = hi_b - lo_b or 1.0 # t: 0 = lo_b, 1 = hi_b (clipped) t = np.clip((field - lo_b) / span, 0.0, 1.0) def c(q: QColor) -> np.ndarray: return np.array([q.redF(), q.greenF(), q.blueF()], dtype=np.float32) lo_c = c(self._low_color) mid_c = c(self._in_range_color) hi_c = c(self._high_color) t2 = t * 2 # 0→2 across full range lower = t <= 0.5 t_lo = np.clip(t2, 0.0, 1.0)[:, :, np.newaxis] # 0→1 in lower half t_hi = np.clip(t2 - 1.0, 0.0, 1.0)[:, :, np.newaxis] # 0→1 in upper half rgb = np.where( lower[:, :, np.newaxis], lo_c * (1 - t_lo) + mid_c * t_lo, mid_c * (1 - t_hi) + hi_c * t_hi, ) # Outside the wafer circle `field` is NaN → NaN propagates into rgb. Alpha # masks those pixels anyway, but zero them so the uint8 cast is well-defined. rgb = np.nan_to_num(rgb, nan=0.0) rgba = np.zeros((ds, ds, 4), dtype=np.uint8) rgba[:, :, :3] = (rgb * 255).astype(np.uint8) rgba[:, :, 3] = np.where(np.isfinite(field), 210, 0).astype(np.uint8) return QImage(rgba.tobytes(), ds, ds, QImage.Format.Format_RGBA8888).copy() # ── paint ───────────────────────────────────────────────────────────── def paint(self, painter: QPainter) -> None: ds = self._draw_size() r_px = int(ds / 2 - 4) cx, cy = self._center() painter.setRenderHint(QPainter.RenderHint.Antialiasing) self._paint_template(painter, cx, cy, r_px) if self._heatmap and self._blend > 0.0: painter.setOpacity(self._blend) painter.drawImage(cx - self._heatmap.width() // 2, cy - self._heatmap.height() // 2, self._heatmap) painter.setOpacity(1.0) self._paint_markers(painter) def _paint_template(self, painter: QPainter, cx: int, cy: int, r_px: int) -> None: ds = self._draw_size() r_mm = self._wafer_radius_mm() sc = self._scale(ds, r_mm) # Concentric rings at actual sensor group radii (falls back to 25/50/75/100% when no sensors). ring_pen = QPen(self._ring_color, 1, Qt.PenStyle.SolidLine) painter.setPen(ring_pen) for ring_r_mm in self._sensor_ring_radii_mm(): rr = max(1, int(ring_r_mm * sc)) painter.drawEllipse(cx - rr, cy - rr, 2 * rr, 2 * rr) # Crosshair axes axis_pen = QPen(self._axis_color, 1, Qt.PenStyle.DashLine) painter.setPen(axis_pen) painter.drawLine(cx, cy - r_px, cx, cy + r_px) painter.drawLine(cx - r_px, cy, cx + r_px, cy) # Top notch triangle (wafer orientation marker) nw = max(6, ds // 25) nh = max(4, ds // 35) notch = QPolygon([ QPoint(cx, cy - r_px), QPoint(cx - nw // 2, cy - r_px + nh), QPoint(cx + nw // 2, cy - r_px + nh), ]) painter.setPen(Qt.PenStyle.NoPen) painter.setBrush(QBrush(self._axis_color)) painter.drawPolygon(notch) def _paint_markers(self, painter: QPainter) -> None: r = self._marker_r id_font = QFont() id_font.setPointSize(max(5, r)) id_font.setBold(True) temp_font = QFont() temp_font.setPointSize(max(4, r - 1)) # Pre-compute ID font metrics for vertical centering painter.setFont(id_font) id_fm = painter.fontMetrics() id_line_h = id_fm.height() id_ascent = id_fm.ascent() band_color = { "in_range": self._in_range_color, "high": self._high_color, "low": self._low_color, } for i, s in enumerate(self._sensors): if i not in self._markers: continue px, py = self._markers[i] color = band_color.get( self._bands[i] if i < len(self._bands) else "in_range", self._in_range_color, ) # Filled circle with thin dark outline for contrast over heatmap painter.setPen(QPen(QColor(0, 0, 0, 100), 1)) painter.setBrush(QBrush(color)) painter.drawEllipse(px - r, py - r, 2 * r, 2 * r) if self._show_labels: has_temp = i < len(self._values) lx = px + r + 3 # Two-line block: split the gap at dot center; single-line: original position y1 = (py - id_line_h // 2) if has_temp else (py + id_ascent // 2) # Sensor ID — bold, muted text color painter.setFont(id_font) painter.setPen(QPen(self._text_color)) painter.drawText(lx, y1, s.label) # Temperature — band color, smaller font, below ID if has_temp: painter.setFont(temp_font) painter.setPen(QPen(color)) painter.drawText(lx, y1 + id_line_h, f"{self._values[i]:.2f}")