f4621f1faf
Introduce Radial Basis Function interpolation for fine wafer thermal gradients and GraphQuickItem for high-performance trend line rendering.
618 lines
23 KiB
Python
618 lines
23 KiB
Python
"""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 logging
|
|
import math
|
|
|
|
import numpy as np
|
|
from PySide6.QtCore import Property, QPoint, Qt, Signal, Slot
|
|
from PySide6.QtGui import (
|
|
QBrush,
|
|
QColor,
|
|
QFont,
|
|
QImage,
|
|
QPainter,
|
|
QPen,
|
|
QPolygon,
|
|
) # fmt: skip
|
|
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
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
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()
|
|
shapeChanged = Signal()
|
|
sizeChanged = Signal()
|
|
thicknessChanged = Signal()
|
|
showThicknessChanged = 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
|
|
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)
|
|
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"]),
|
|
side=d.get("side", "right"),
|
|
offset_x=float(d.get("offset_x", 0.0)),
|
|
offset_y=float(d.get("offset_y", 0.0))
|
|
)
|
|
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()
|
|
|
|
@Property(str, notify=shapeChanged)
|
|
def shape(self) -> str:
|
|
return self._shape
|
|
|
|
@shape.setter
|
|
def shape(self, val: str) -> None:
|
|
self._shape = str(val).lower()
|
|
self._rebuild()
|
|
self.shapeChanged.emit()
|
|
|
|
@Property(float, notify=sizeChanged)
|
|
def size(self) -> float:
|
|
return self._size
|
|
|
|
@size.setter
|
|
def size(self, val: float) -> None:
|
|
self._size = float(val)
|
|
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
|
|
@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
|
|
|
|
@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:
|
|
result = self.grabToImage()
|
|
img = result.image()
|
|
img.save(file_path, "PNG")
|
|
return True
|
|
except Exception as e:
|
|
log.error("Export failed: %s", e)
|
|
return False
|
|
|
|
# ── internal ─────────────────────────────────────────────────────────
|
|
|
|
def _rebuild_thickness(self) -> None:
|
|
"""Interpolate thickness data into a gray/orange heatmap QImage."""
|
|
if not self._sensors or not self._thickness_data:
|
|
self._thickness_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._thickness_data[:len(self._sensors)], dtype=float)
|
|
if len(vs) < len(self._sensors):
|
|
self._thickness_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=(self._shape == "round"),
|
|
)
|
|
except Exception:
|
|
self._thickness_heatmap = None
|
|
return
|
|
# Gray/orange colormap: map field range 0→1 to gray→orange
|
|
vmin, vmax = np.nanmin(field), np.nanmax(field)
|
|
span = vmax - vmin or 1.0
|
|
t = np.clip((field - vmin) / span, 0.0, 1.0)
|
|
|
|
# Gray (0.5, 0.5, 0.5) → orange (1.0, 0.65, 0.0)
|
|
rgb = np.zeros((ds, ds, 3), dtype=np.float32)
|
|
rgb[:, :, 0] = 0.5 + 0.5 * t
|
|
rgb[:, :, 1] = 0.5 + 0.15 * t
|
|
rgb[:, :, 2] = 0.5 - 0.5 * t
|
|
|
|
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), 180, 0).astype(np.uint8)
|
|
|
|
self._thickness_heatmap = (
|
|
QImage(rgba.tobytes(), ds, ds, QImage.Format.Format_RGBA8888).copy()
|
|
)
|
|
|
|
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=(self._shape == "round"),
|
|
)
|
|
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)
|
|
|
|
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:
|
|
ds = self._draw_size()
|
|
r_mm = self._wafer_radius_mm()
|
|
sc = self._scale(ds, r_mm)
|
|
|
|
if self._shape == "square":
|
|
# Draw square boundary (thick pen)
|
|
border_pen = QPen(self._ring_color, 2, Qt.PenStyle.SolidLine)
|
|
painter.setPen(border_pen)
|
|
half_size_px = int(self._size / 2 * sc)
|
|
painter.drawRect(cx - half_size_px, cy - half_size_px, 2 * half_size_px, 2 * half_size_px)
|
|
|
|
# Crosshair axes
|
|
axis_pen = QPen(self._axis_color, 1, Qt.PenStyle.DashLine)
|
|
painter.setPen(axis_pen)
|
|
painter.drawLine(cx, cy - half_size_px, cx, cy + half_size_px)
|
|
painter.drawLine(cx - half_size_px, cy, cx + half_size_px, cy)
|
|
|
|
# Draw concentric square guide lines at the distinct radii of sensor rings
|
|
grid_pen = QPen(self._ring_color, 1, Qt.PenStyle.SolidLine)
|
|
painter.setPen(grid_pen)
|
|
for ring_r_mm in self._sensor_ring_radii_mm()[:-1]: # exclude the outermost border
|
|
rr = max(1, int(ring_r_mm * sc))
|
|
painter.drawRect(cx - rr, cy - rr, 2 * rr, 2 * rr)
|
|
else:
|
|
# Concentric rings
|
|
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
|
|
|
|
# Scale font size based on the number of sensors to prevent overlap on dense wafers
|
|
num_sensors = len(self._sensors)
|
|
font_scale = 1.0
|
|
if num_sensors > 60:
|
|
font_scale = 0.7 # reduce font size by 30% for dense wafers
|
|
elif num_sensors > 40:
|
|
font_scale = 0.85
|
|
|
|
id_font = QFont()
|
|
id_font.setPointSize(max(4, int(r * font_scale)))
|
|
id_font.setBold(True)
|
|
|
|
temp_font = QFont()
|
|
temp_font.setPointSize(max(3, int((r - 1) * font_scale)))
|
|
|
|
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)
|
|
|
|
# Fetch text alignment side and offsets
|
|
side = getattr(s, "side", "right").lower()
|
|
ox = getattr(s, "offset_x", 0.0) * r
|
|
oy = getattr(s, "offset_y", 0.0) * r
|
|
|
|
# Pre-compute metrics using current scaled fonts
|
|
painter.setFont(id_font)
|
|
id_fm = painter.fontMetrics()
|
|
id_line_h = id_fm.height()
|
|
id_ascent = id_fm.ascent()
|
|
|
|
painter.setFont(temp_font)
|
|
temp_fm = painter.fontMetrics()
|
|
temp_line_h = temp_fm.height()
|
|
temp_ascent = temp_fm.ascent()
|
|
|
|
id_text = s.label
|
|
temp_text = f"{self._values[i]:.2f}" if has_temp else ""
|
|
|
|
id_w = id_fm.horizontalAdvance(id_text)
|
|
temp_w = temp_fm.horizontalAdvance(temp_text) if has_temp else 0
|
|
text_w = max(id_w, temp_w)
|
|
|
|
# Height of the 1 or 2-line text block
|
|
text_h = (id_line_h + temp_line_h) if has_temp else id_line_h
|
|
|
|
# Calculate box top-left (lx, ly) relative to dot center (px, py)
|
|
gap = 3
|
|
if side == "left":
|
|
lx = px - r - gap - text_w - ox
|
|
ly = py - text_h // 2 + oy
|
|
elif side == "top":
|
|
lx = px - text_w // 2 + ox
|
|
ly = py - r - gap - text_h - oy
|
|
elif side == "bottom":
|
|
lx = px - text_w // 2 + ox
|
|
ly = py + r + gap + oy
|
|
else: # "right" or default
|
|
lx = px + r + gap + ox
|
|
ly = py - text_h // 2 + oy
|
|
|
|
# Draw Sensor ID (first line)
|
|
painter.setFont(id_font)
|
|
painter.setPen(QPen(self._text_color))
|
|
y1 = ly + id_ascent
|
|
if side in ("top", "bottom"):
|
|
painter.drawText(lx + (text_w - id_w) // 2, y1, id_text)
|
|
elif side == "left":
|
|
painter.drawText(lx + (text_w - id_w), y1, id_text)
|
|
else:
|
|
painter.drawText(lx, y1, id_text)
|
|
|
|
# Draw Temperature (second line)
|
|
if has_temp:
|
|
painter.setFont(temp_font)
|
|
painter.setPen(QPen(color))
|
|
y2 = ly + id_line_h + temp_ascent
|
|
if side in ("top", "bottom"):
|
|
painter.drawText(lx + (text_w - temp_w) // 2, y2, temp_text)
|
|
elif side == "left":
|
|
painter.drawText(lx + (text_w - temp_w), y2, temp_text)
|
|
else:
|
|
painter.drawText(lx, y2, temp_text)
|