refactor: optimize RBF heatmap performance by decoupling grid resolution from viewport size and update UI file handling.
This commit is contained in:
@@ -200,8 +200,19 @@ ColumnLayout {
|
|||||||
return streamController.mode === "review" && streamController.loadedFile !== "" && modelData.fileName === streamController.loadedFile;
|
return streamController.mode === "review" && streamController.loadedFile !== "" && modelData.fileName === streamController.loadedFile;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "live_<serial>_<timestamp>.csv" is SessionController's recording
|
||||||
|
// naming convention (see FileBrowser._refresh_files) — grey these out
|
||||||
|
// unconditionally rather than only while actively being written, since
|
||||||
|
// a stale directory listing can't tell "mid-write" from "just finished".
|
||||||
|
readonly property bool isLiveFile: (modelData.baseName || "").toLowerCase().indexOf("live") >= 0
|
||||||
|
|
||||||
visible: matchesFilter
|
visible: matchesFilter
|
||||||
height: matchesFilter ? implicitHeight : 0
|
height: matchesFilter ? implicitHeight : 0
|
||||||
|
enabled: !isLiveFile
|
||||||
|
opacity: isLiveFile ? 0.45 : 1.0
|
||||||
|
Behavior on opacity {
|
||||||
|
NumberAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
|
|
||||||
onClicked: streamController.loadFile(modelData.fileName)
|
onClicked: streamController.loadFile(modelData.fileName)
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from PySide6.QtCore import Property, QObject, Qt, QTimer, Signal, Slot
|
from PySide6.QtCore import Property, QObject, Qt, QTimer, Signal, Slot
|
||||||
@@ -258,8 +259,6 @@ class SessionController(QObject):
|
|||||||
@Slot(str)
|
@Slot(str)
|
||||||
@slot_error_boundary
|
@slot_error_boundary
|
||||||
def loadFile(self, file_path: str) -> None:
|
def loadFile(self, file_path: str) -> None:
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from pygui.backend.data.data_records import (
|
from pygui.backend.data.data_records import (
|
||||||
is_official_csv,
|
is_official_csv,
|
||||||
read_data_records,
|
read_data_records,
|
||||||
@@ -348,8 +347,6 @@ class SessionController(QObject):
|
|||||||
@slot_error_boundary
|
@slot_error_boundary
|
||||||
def compareFiles(self, file_a: str, file_b: str) -> None:
|
def compareFiles(self, file_a: str, file_b: str) -> None:
|
||||||
"""Run DTW comparison between two CSV files and emit result."""
|
"""Run DTW comparison between two CSV files and emit result."""
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from pygui.backend.comparison import compare_runs
|
from pygui.backend.comparison import compare_runs
|
||||||
from pygui.backend.data.data_records import is_official_csv, read_data_records, read_official_csv
|
from pygui.backend.data.data_records import is_official_csv, read_data_records, read_official_csv
|
||||||
|
|
||||||
@@ -603,14 +600,6 @@ class SessionController(QObject):
|
|||||||
return average_clusters(edited, getattr(self, "_active_clusters", []))
|
return average_clusters(edited, getattr(self, "_active_clusters", []))
|
||||||
return edited
|
return edited
|
||||||
|
|
||||||
# TODO P2.4: throttle live-frame UI updates to ~30Hz
|
|
||||||
# THINKING: every decoded serial frame triggers frameUpdated/stateChanged,
|
|
||||||
# which repaints WaferMapItem + trend chart; at high stream rates the GUI
|
|
||||||
# thread saturates on paint, not decode. Buffer the latest frame and flush
|
|
||||||
# via a 33ms QTimer (latest-wins, no queue growth). Recording must keep
|
|
||||||
# writing every frame — only the UI emit is throttled, so place the timer
|
|
||||||
# here rather than in StreamReader. Depends on nothing; verify with P4.3.
|
|
||||||
# See docs/pending/alpha-release-polish-plan.md §2.4.
|
|
||||||
def _emit_current(self) -> None:
|
def _emit_current(self) -> None:
|
||||||
frame = self._player.current()
|
frame = self._player.current()
|
||||||
if frame is None:
|
if frame is None:
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from pygui.backend.wafer.zwafer_models import Sensor
|
|||||||
class CsvRecorder:
|
class CsvRecorder:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._file_handle: Optional[TextIO] = None
|
self._file_handle: Optional[TextIO] = None
|
||||||
self._oath: Optional[str] = None
|
self._path: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from scipy.interpolate import RBFInterpolator
|
from scipy.interpolate import RBFInterpolator
|
||||||
|
from scipy.ndimage import zoom as _ndi_zoom
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import cupy as _cupy # type: ignore
|
import cupy as _cupy # type: ignore
|
||||||
@@ -17,14 +18,18 @@ except Exception:
|
|||||||
_KERNEL = "thin_plate_spline"
|
_KERNEL = "thin_plate_spline"
|
||||||
_SMOOTHING = 0.0
|
_SMOOTHING = 0.0
|
||||||
|
|
||||||
|
# Sensor density (≤244 points) carries no more spatial information than this;
|
||||||
|
# evaluating on a bigger grid than the widget's pixel size just burns CPU that
|
||||||
|
# a GPU-bilinear QImage upscale reproduces visually. See alpha-release-polish-plan.md §2.2.
|
||||||
|
GRID_RES = 100
|
||||||
|
|
||||||
|
# Bilinear-scaling the raw 100x100 field straight to widget size shows visible
|
||||||
|
# facets on real (higher-contrast) sensor data. A cheap cubic pre-smooth of the
|
||||||
|
# field (RBF grid is the expensive part; resampling a small array is not) removes
|
||||||
|
# the facets before the final QImage upscale does the rest.
|
||||||
|
REFINE_FACTOR = 2
|
||||||
|
|
||||||
|
|
||||||
# TODO P2.2: decouple grid resolution from output size
|
|
||||||
# THINKING: callers pass widget pixel size as width/height, so RBF evaluation
|
|
||||||
# count scales quadratically with viewport size (600px → 360k evaluations).
|
|
||||||
# Interpolate on a fixed ~100×100 grid and let the caller scale the QImage;
|
|
||||||
# sensor density (≤244 points) carries no more spatial information than that.
|
|
||||||
# Called from WaferMapItem._rebuild_heatmap (see its P2.2/P2.3 TODO).
|
|
||||||
# See docs/pending/alpha-release-polish-plan.md §2.2.
|
|
||||||
def interpolate_field(
|
def interpolate_field(
|
||||||
xs: np.ndarray,
|
xs: np.ndarray,
|
||||||
ys: np.ndarray,
|
ys: np.ndarray,
|
||||||
@@ -35,26 +40,30 @@ def interpolate_field(
|
|||||||
extent: tuple[float, float, float, float], # (xmin, xmax, ymin, ymax) in mm
|
extent: tuple[float, float, float, float], # (xmin, xmax, ymin, ymax) in mm
|
||||||
round_clip: bool = False,
|
round_clip: bool = False,
|
||||||
) -> np.ndarray:
|
) -> np.ndarray:
|
||||||
"""Return a (height, width) float64 array of interpolated values.
|
"""Return a float64 array of interpolated values, capped at GRID_RES per side.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
xs, ys: sensor positions in mm (1-D arrays, length N)
|
xs, ys: sensor positions in mm (1-D arrays, length N)
|
||||||
vs: sensor values (length N)
|
vs: sensor values (length N)
|
||||||
width/height: output grid dimensions in pixels
|
width/height: desired output dimensions in pixels; the grid is solved at
|
||||||
|
min(dim, GRID_RES) and the caller upscales the resulting image
|
||||||
extent: (xmin, xmax, ymin, ymax) in the same mm space as xs/ys
|
extent: (xmin, xmax, ymin, ymax) in the same mm space as xs/ys
|
||||||
round_clip: if True, pixels outside the inscribed ellipse become NaN
|
round_clip: if True, pixels outside the inscribed ellipse become NaN
|
||||||
"""
|
"""
|
||||||
coords = np.column_stack([xs, ys])
|
coords = np.column_stack([xs, ys])
|
||||||
rbf = RBFInterpolator(coords, vs, kernel=_KERNEL, smoothing=_SMOOTHING)
|
rbf = RBFInterpolator(coords, vs, kernel=_KERNEL, smoothing=_SMOOTHING)
|
||||||
|
|
||||||
|
grid_w = min(width, GRID_RES)
|
||||||
|
grid_h = min(height, GRID_RES)
|
||||||
|
|
||||||
xmin, xmax, ymin, ymax = extent
|
xmin, xmax, ymin, ymax = extent
|
||||||
gx = np.linspace(xmin, xmax, width)
|
gx = np.linspace(xmin, xmax, grid_w)
|
||||||
gy = np.linspace(ymin, ymax, height)
|
gy = np.linspace(ymin, ymax, grid_h)
|
||||||
grid_x, grid_y = np.meshgrid(gx, gy)
|
grid_x, grid_y = np.meshgrid(gx, gy)
|
||||||
flat = np.column_stack([grid_x.ravel(), grid_y.ravel()])
|
flat = np.column_stack([grid_x.ravel(), grid_y.ravel()])
|
||||||
|
|
||||||
# RBFInterpolator always runs on CPU; CuPy only accelerates other ops if added later
|
# RBFInterpolator always runs on CPU; CuPy only accelerates other ops if added later
|
||||||
field = rbf(flat).reshape(height, width)
|
field = rbf(flat).reshape(grid_h, grid_w)
|
||||||
|
|
||||||
if round_clip:
|
if round_clip:
|
||||||
cx = (xmin + xmax) / 2
|
cx = (xmin + xmax) / 2
|
||||||
@@ -66,3 +75,29 @@ def interpolate_field(
|
|||||||
|
|
||||||
return field.astype(np.float64)
|
return field.astype(np.float64)
|
||||||
|
|
||||||
|
|
||||||
|
def refine_field(field: np.ndarray, factor: int = REFINE_FACTOR) -> np.ndarray:
|
||||||
|
"""Cubic-upsample a coarse (NaN-free) field by `factor`.
|
||||||
|
|
||||||
|
Callers that need a circular boundary should pass an *unclipped* field
|
||||||
|
(round_clip=False) and mask with ellipse_alpha() afterwards — cubic
|
||||||
|
resampling across a NaN/zero-filled hole rings at the boundary, and any
|
||||||
|
mask derived from the coarse grid keeps its staircase when upscaled.
|
||||||
|
"""
|
||||||
|
return _ndi_zoom(field, factor, order=3)
|
||||||
|
|
||||||
|
|
||||||
|
def ellipse_alpha(height: int, width: int, feather_px: float = 1.2) -> np.ndarray:
|
||||||
|
"""Anti-aliased coverage mask (0..1) of the ellipse inscribed in the grid.
|
||||||
|
|
||||||
|
Computed analytically at the target resolution — unlike upsampling a
|
||||||
|
binary clip mask from the coarse RBF grid, this cannot show grid steps.
|
||||||
|
`feather_px` is the half-width of the linear edge ramp, in grid pixels.
|
||||||
|
"""
|
||||||
|
y = (np.arange(height) + 0.5) / height * 2.0 - 1.0
|
||||||
|
x = (np.arange(width) + 0.5) / width * 2.0 - 1.0
|
||||||
|
yy, xx = np.meshgrid(y, x, indexing="ij")
|
||||||
|
r = np.sqrt(xx * xx + yy * yy)
|
||||||
|
f = feather_px * 2.0 / min(width, height)
|
||||||
|
return np.clip((1.0 - r) / f + 0.5, 0.0, 1.0)
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,11 @@ from PySide6.QtGui import (
|
|||||||
from PySide6.QtQml import QmlElement
|
from PySide6.QtQml import QmlElement
|
||||||
from PySide6.QtQuick import QQuickPaintedItem
|
from PySide6.QtQuick import QQuickPaintedItem
|
||||||
|
|
||||||
from pygui.backend.visualization.rbf_heatmap import interpolate_field
|
from pygui.backend.visualization.rbf_heatmap import (
|
||||||
|
ellipse_alpha,
|
||||||
|
interpolate_field,
|
||||||
|
refine_field,
|
||||||
|
)
|
||||||
from pygui.backend.wafer.zwafer_models import Sensor
|
from pygui.backend.wafer.zwafer_models import Sensor
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@@ -333,33 +337,46 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
self._thickness_heatmap = None
|
self._thickness_heatmap = None
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
|
# No round_clip — see _rebuild_heatmap for why the mask is analytic.
|
||||||
field = interpolate_field(
|
field = interpolate_field(
|
||||||
xs, ys, vs,
|
xs, ys, vs,
|
||||||
width=ds, height=ds,
|
width=ds, height=ds,
|
||||||
extent=(-r_mm, r_mm, -r_mm, r_mm),
|
extent=(-r_mm, r_mm, -r_mm, r_mm),
|
||||||
round_clip=(self._shape == "round"),
|
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
self._thickness_heatmap = None
|
self._thickness_heatmap = None
|
||||||
return
|
return
|
||||||
# Gray/orange colormap: map field range 0→1 to gray→orange
|
field = refine_field(field)
|
||||||
vmin, vmax = np.nanmin(field), np.nanmax(field)
|
if self._shape == "round":
|
||||||
|
alpha = ellipse_alpha(*field.shape)
|
||||||
|
else:
|
||||||
|
alpha = np.ones(field.shape)
|
||||||
|
# Color-range bounds from inside the wafer only — the unclipped field
|
||||||
|
# extrapolates wildly toward the square corners, which alpha hides but
|
||||||
|
# a corner-driven vmin/vmax would flatten the visible gradient.
|
||||||
|
inside = field[alpha >= 0.5]
|
||||||
|
vmin, vmax = inside.min(), inside.max()
|
||||||
span = vmax - vmin or 1.0
|
span = vmax - vmin or 1.0
|
||||||
|
# Gray/orange colormap: map field range 0→1 to gray→orange
|
||||||
t = np.clip((field - vmin) / span, 0.0, 1.0)
|
t = np.clip((field - vmin) / span, 0.0, 1.0)
|
||||||
|
|
||||||
|
gh, gw = field.shape
|
||||||
# Gray (0.5, 0.5, 0.5) → orange (1.0, 0.65, 0.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 = np.zeros((gh, gw, 3), dtype=np.float32)
|
||||||
rgb[:, :, 0] = 0.5 + 0.5 * t
|
rgb[:, :, 0] = 0.5 + 0.5 * t
|
||||||
rgb[:, :, 1] = 0.5 + 0.15 * t
|
rgb[:, :, 1] = 0.5 + 0.15 * t
|
||||||
rgb[:, :, 2] = 0.5 - 0.5 * t
|
rgb[:, :, 2] = 0.5 - 0.5 * t
|
||||||
|
|
||||||
rgb = np.nan_to_num(rgb, nan=0.0)
|
rgb = np.nan_to_num(rgb, nan=0.0)
|
||||||
rgba = np.zeros((ds, ds, 4), dtype=np.uint8)
|
rgba = np.zeros((gh, gw, 4), dtype=np.uint8)
|
||||||
rgba[:, :, :3] = (rgb * 255).astype(np.uint8)
|
rgba[:, :, :3] = (rgb * 255).astype(np.uint8)
|
||||||
rgba[:, :, 3] = np.where(np.isfinite(field), 180, 0).astype(np.uint8)
|
rgba[:, :, 3] = (alpha * 180).astype(np.uint8)
|
||||||
|
|
||||||
self._thickness_heatmap = (
|
grid_img = QImage(rgba.tobytes(), gw, gh, QImage.Format.Format_RGBA8888).copy()
|
||||||
QImage(rgba.tobytes(), ds, ds, QImage.Format.Format_RGBA8888).copy()
|
self._thickness_heatmap = grid_img.scaled(
|
||||||
|
ds, ds,
|
||||||
|
Qt.AspectRatioMode.IgnoreAspectRatio,
|
||||||
|
Qt.TransformationMode.SmoothTransformation,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _on_resize(self) -> None:
|
def _on_resize(self) -> None:
|
||||||
@@ -426,15 +443,6 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
self._markers = {i: self._to_px(s.x, s.y, cx, cy, sc)
|
self._markers = {i: self._to_px(s.x, s.y, cx, cy, sc)
|
||||||
for i, s in enumerate(self._sensors)}
|
for i, s in enumerate(self._sensors)}
|
||||||
|
|
||||||
# TODO P2.2 + P2.3: fixed-resolution RBF grid, then offload to QThreadPool
|
|
||||||
# THINKING: width=height=ds couples RBF cost to widget pixel size — a 600px
|
|
||||||
# viewport solves a 360k-point system per frame where a fixed 100×100 grid
|
|
||||||
# (10k points, GPU bilinear-scales the QImage up) is visually identical
|
|
||||||
# through the 0.4-opacity blend. Do P2.2 first: it may make P2.3 (QRunnable
|
|
||||||
# worker emitting heatmapReady(QImage), guarding against stale results when
|
|
||||||
# sensors change mid-flight) unnecessary for alpha. Grid width param lands
|
|
||||||
# in interpolate_field (see matching TODO in rbf_heatmap.py).
|
|
||||||
# See docs/pending/alpha-release-polish-plan.md §2.2/§2.3.
|
|
||||||
def _rebuild_heatmap(self) -> None:
|
def _rebuild_heatmap(self) -> None:
|
||||||
if not self._sensors or not self._values or self._blend == 0.0:
|
if not self._sensors or not self._values or self._blend == 0.0:
|
||||||
self._heatmap = None
|
self._heatmap = None
|
||||||
@@ -448,19 +456,30 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
self._heatmap = None
|
self._heatmap = None
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
|
# No round_clip: refine the smooth unclipped field, then mask with an
|
||||||
|
# analytic anti-aliased ellipse (a coarse-grid clip mask upscales jagged).
|
||||||
field = interpolate_field(
|
field = interpolate_field(
|
||||||
xs, ys, vs,
|
xs, ys, vs,
|
||||||
width=ds, height=ds,
|
width=ds, height=ds,
|
||||||
extent=(-r_mm, r_mm, -r_mm, r_mm),
|
extent=(-r_mm, r_mm, -r_mm, r_mm),
|
||||||
round_clip=(self._shape == "round"),
|
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
self._heatmap = None
|
self._heatmap = None
|
||||||
return
|
return
|
||||||
self._heatmap = self._field_to_qimage(field, ds)
|
field = refine_field(field)
|
||||||
|
if self._shape == "round":
|
||||||
|
alpha = ellipse_alpha(*field.shape)
|
||||||
|
else:
|
||||||
|
alpha = np.ones(field.shape)
|
||||||
|
grid_img = self._field_to_qimage(field, alpha)
|
||||||
|
self._heatmap = grid_img.scaled(
|
||||||
|
ds, ds,
|
||||||
|
Qt.AspectRatioMode.IgnoreAspectRatio,
|
||||||
|
Qt.TransformationMode.SmoothTransformation,
|
||||||
|
)
|
||||||
|
|
||||||
def _field_to_qimage(self, field: np.ndarray, ds: int) -> QImage:
|
def _field_to_qimage(self, field: np.ndarray, alpha: np.ndarray) -> QImage:
|
||||||
"""Apply a band-aware tri-color gradient → RGBA QImage."""
|
"""Apply a band-aware tri-color gradient → RGBA QImage at the field's own resolution."""
|
||||||
lo_b = self._target - self._margin
|
lo_b = self._target - self._margin
|
||||||
hi_b = self._target + self._margin
|
hi_b = self._target + self._margin
|
||||||
span = hi_b - lo_b or 1.0
|
span = hi_b - lo_b or 1.0
|
||||||
@@ -485,14 +504,15 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
lo_c * (1 - t_lo) + mid_c * t_lo,
|
lo_c * (1 - t_lo) + mid_c * t_lo,
|
||||||
mid_c * (1 - t_hi) + hi_c * t_hi,
|
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)
|
rgb = np.nan_to_num(rgb, nan=0.0)
|
||||||
rgba = np.zeros((ds, ds, 4), dtype=np.uint8)
|
gh, gw = field.shape
|
||||||
|
rgba = np.zeros((gh, gw, 4), dtype=np.uint8)
|
||||||
rgba[:, :, :3] = (rgb * 255).astype(np.uint8)
|
rgba[:, :, :3] = (rgb * 255).astype(np.uint8)
|
||||||
rgba[:, :, 3] = np.where(np.isfinite(field), 210, 0).astype(np.uint8)
|
# alpha is a continuous 0..1 coverage mask (not a hard isfinite cutoff) so the
|
||||||
|
# round-clip boundary anti-aliases instead of showing coarse-grid steps.
|
||||||
|
rgba[:, :, 3] = (alpha * 210).astype(np.uint8)
|
||||||
|
|
||||||
return QImage(rgba.tobytes(), ds, ds, QImage.Format.Format_RGBA8888).copy()
|
return QImage(rgba.tobytes(), gw, gh, QImage.Format.Format_RGBA8888).copy()
|
||||||
|
|
||||||
# ── paint ─────────────────────────────────────────────────────────────
|
# ── paint ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -601,6 +621,16 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
"low": self._low_color,
|
"low": self._low_color,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
for i, s in enumerate(self._sensors):
|
for i, s in enumerate(self._sensors):
|
||||||
if i not in self._markers:
|
if i not in self._markers:
|
||||||
continue
|
continue
|
||||||
@@ -634,23 +664,6 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
ox = getattr(s, "offset_x", 0.0) * r
|
ox = getattr(s, "offset_x", 0.0) * r
|
||||||
oy = getattr(s, "offset_y", 0.0) * r
|
oy = getattr(s, "offset_y", 0.0) * r
|
||||||
|
|
||||||
# TODO P2.1: hoist fontMetrics() out of the sensor loop
|
|
||||||
# THINKING: id_font/temp_font are fixed for the whole paint pass,
|
|
||||||
# but fontMetrics() is re-queried per sensor — 65+ OS font-engine
|
|
||||||
# round-trips per frame on a dense wafer, ~30/s in Live mode.
|
|
||||||
# Compute id_fm/temp_fm once before `for i, s in ...` (they only
|
|
||||||
# change when r/font_scale change). Verified by P4.3 benchmark.
|
|
||||||
# See docs/pending/alpha-release-polish-plan.md §2.1.
|
|
||||||
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
|
id_text = s.label
|
||||||
temp_text = f"{self._values[i]:.2f}" if has_temp else ""
|
temp_text = f"{self._values[i]:.2f}" if has_temp else ""
|
||||||
|
|
||||||
|
|||||||
@@ -235,6 +235,7 @@ def test_recording_toggle(controller, tmp_path):
|
|||||||
content = open(csv_path).read()
|
content = open(csv_path).read()
|
||||||
assert "Wafer ID=SN123" in content
|
assert "Wafer ID=SN123" in content
|
||||||
|
|
||||||
|
|
||||||
def test_resync_count_default(controller):
|
def test_resync_count_default(controller):
|
||||||
# No active reader -> zero
|
# No active reader -> zero
|
||||||
assert controller.resyncCount == 0
|
assert controller.resyncCount == 0
|
||||||
|
|||||||
Reference in New Issue
Block a user