diff --git a/src/pygui/assets/__init__.py b/src/pygui/assets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/pygui/assets/layouts/aepwafer.yaml b/src/pygui/assets/layouts/aepwafer.yaml new file mode 100644 index 0000000..61d4c29 --- /dev/null +++ b/src/pygui/assets/layouts/aepwafer.yaml @@ -0,0 +1,44 @@ +# This will be the name of the output files +name: aepwafer +wafers: ["A", "E", "P"] + +# round or square wafer +shape: round + +# diameter/edge size of the wafer +size: 300 + +# large or small markers and labels +marker_size: large +font_size: large + +# origin settings. x_origin can be left, right, or center. y_origin can +# be top, bottom, or center. Using right or top implies that the X/Y values +# for that axis are negative, or else we'll be drawing outside the image. +x_origin: left +y_origin: bottom + +# sensor X and Y values, in mm relative to the origin +X: [150, 204, 249, 280, 290, 280, 249, 204, 150, 97, 51, 21, 10, 21, 51, 97, + 150, 203, 241, 255, 241, 203, 150, 98, 59, 45, 59, 98, 171, 207, 228, 228, + 207, 171, 130, 94, 73, 73, 94, 130, 150, 186, 200, 186, 150, 115, 100, 115] +Y: [290, 280, 249, 204, 150, 97, 51, 21, 10, 21, 51, 97, 150, 204, 249, 280, + 255, 241, 203, 150, 98, 59, 45, 59, 98, 150, 203, 241, 228, 207, 171, 130, + 94, 73, 73, 94, 130, 171, 207, 228, 200, 186, 150, 115, 100, 115, 150, 186] + +# set this to either 0 or 1 for the initial sensor number (usually 1) +start_sn: 1 + +# Normally, positive X is to the right, and positive Y is up. +# These settings can be used to reverse one or both of these. +reverse_x: false +reverse_y: false + +# these are sensors whose labels need to be repositioned. Sensor numbers +# start at start_sn. Directions are up, down, left, and right, and values +# are multiples of the marker size. +label_exceptions: { + 4: { top: [0, 0] }, + 5: { top: [0, 0] }, + 6: { top: [0, 0] }, +} diff --git a/src/pygui/assets/layouts/bcdwafer.yaml b/src/pygui/assets/layouts/bcdwafer.yaml new file mode 100644 index 0000000..6874995 --- /dev/null +++ b/src/pygui/assets/layouts/bcdwafer.yaml @@ -0,0 +1,46 @@ +# This will be the name of the output files +name: bcdwafer +wafers: ["B", "C", "D"] + +# round or square wafer +shape: round + +# diameter/edge size of the wafer +size: 300 + +# large or small markers and labels +marker_size: large +font_size: large + +# origin settings. x_origin can be left, right, or center. y_origin can +# be top, bottom, or center. Using right or top implies that the X/Y values +# for that axis are negative, or else we'll be drawing outside the image. +x_origin: center +y_origin: center + +# sensor X and Y values, in mm relative to the origin +X: [0, 72.50, 125.57, 145.00, 125.57, 72.50, 0, -72.50, -125.57, -145.00, + -125.57, -72.50, 0, 47.50, 82.27, 95.00, 82.27, 47.50, 0, -47.50, -82.27, + -95.00, -82.27, -47.50, 38.97, 22.50, -38.97, -22.50, 0] +Y: [145.00, 125.57, 72.50, 0, -72.50, -125.57, -145.00, -125.57, -72.50, 0, + 72.50, 125.57, 95.00, 82.27, 47.50, 0, -47.50, -82.27, -95.00, -82.27, + -47.50, 0, 47.50, 82.27, 22.50, -38.97, -22.50, 38.97, 0] + +# set this to either 0 or 1 for the initial sensor number (usually 1) +start_sn: 1 + +# Normally, positive X is to the right, and positive Y is up. +# These settings can be used to reverse one or both of these. +reverse_x: false +reverse_y: false + +# these are sensors whose labels need to be repositioned. Sensor numbers +# start at start_sn. Directions are up, down, left, and right, and values +# are multiples of the marker size. +label_exceptions: { + 2: { left: [1, 0] }, + 3: { left: [1, 0] }, + 4: { left: [1, 0] }, + 5: { left: [1, 0] }, + 6: { left: [1, 0] }, +} diff --git a/src/pygui/assets/layouts/fwafer.yaml b/src/pygui/assets/layouts/fwafer.yaml new file mode 100644 index 0000000..c3770cc --- /dev/null +++ b/src/pygui/assets/layouts/fwafer.yaml @@ -0,0 +1,43 @@ +# This will be the name of the output files +name: fwafer +wafers: ["F"] + +# round or square wafer +shape: round + +# diameter/edge size of the wafer +size: 200 + +# large or small markers and labels +marker_size: large +font_size: large + +# origin settings. x_origin can be left, right, or center. y_origin can +# be top, bottom, or center. Using right or top implies that the X/Y values +# for that axis are negative, or else we'll be drawing outside the image. +x_origin: center +y_origin: center + +# sensor X and Y values, in mm relative to the origin +X: [0.0, 47.5, 82.3, 95.0, 82.3, 47.5, 0.0, -47.5, -82.3, -95.0, -82.3, -47.5, + -55.0, -14.8, 39.7, 55.0, 14.8, -39.7, -14.7, 25.5, 14.7, -25.5] +Y: [95.0, 82.3, 47.5, 0.0, -47.5, -82.3, -95.0, -82.3, -47.5, 0.0, 47.5, 82.3, + 13.3, 54.4, 40.0, -13.3, -54.4, -40.0, 25.5, 14.7, -25.5, -14.7] + +# set this to either 0 or 1 for the initial sensor number (usually 1) +start_sn: 1 + +# Normally, positive X is to the right, and positive Y is up. +# These settings can be used to reverse one or both of these. +reverse_x: false +reverse_y: false + +# these are sensors whose labels need to be repositioned. Sensor numbers +# start at start_sn. Directions are up, down, left, and right, and values +# are multiples of the marker size. +label_exceptions: { + 2: { left: [1, 0] }, + 3: { left: [1, 0] }, + 4: { left: [1, 0] }, + 5: { left: [1, 0] }, +} diff --git a/src/pygui/assets/layouts/xwafer.yaml b/src/pygui/assets/layouts/xwafer.yaml new file mode 100644 index 0000000..5097ee7 --- /dev/null +++ b/src/pygui/assets/layouts/xwafer.yaml @@ -0,0 +1,69 @@ +# This will be the name of the output files +name: xwafer +wafers: ["X"] + +# round or square wafer +shape: square + +# diameter/edge size of the wafer +size: 310 + +# large or small markers and labels +marker_size: small +font_size: small + +# origin settings. x_origin can be left, right, or center. y_origin can +# be top, bottom, or center. Using right or top implies that the X/Y values +# for that axis are negative, or else we'll be drawing outside the image. +x_origin: left +y_origin: bottom + +# sensor X and Y values, in mm relative to the origin +X: [ 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, + 13.00, 23.00, 46.43, 89.86, 133.29, 176.72, 220.15, 263.58, 287.01, 297.01, + 307.01, 307.01, 307.01, 307.01, 307.01, 307.01, 307.01, 307.01, 307.01, + 307.01, 307.01, 307.01, 297.01, 287.01, 263.58, 220.15, 176.72, 133.29, + 89.86, 46.43, 23.00, 13.00, 46.43, 46.43, 46.43, 46.43, 46.43, 46.43, + 89.86, 133.29, 176.72, 220.15, 263.58, 263.58, 263.58, 263.58, 263.58, + 263.58, 220.15, 176.72, 133.29, 89.86, 89.86, 89.86, 89.86, 89.86, + 133.29, 176.72, 220.15, 220.15, 220.15, 220.15, 176.72, 133.29, 133.29, + 133.29, 176.72, 176.72] +Y: [ 3.00, 13.07, 23.07, 46.50, 89.93, 133.36, 176.79, 220.22, 263.65, 287.08, + 297.08, 307.08, 307.08, 307.08, 307.08, 307.08, 307.08, 307.08, 307.08, + 307.08, 307.08, 307.08, 307.08, 297.08, 287.08, 263.65, 220.22, 176.79, + 133.36, 89.93, 46.50, 23.07, 13.07, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, + 3.00, 3.00, 3.00, 3.00, 3.00, 46.50, 89.93, 133.36, 176.79, 220.22, 263.65, + 263.65, 263.65, 263.65, 263.65, 263.65, 220.22, 176.79, 133.36, 89.93, + 46.50, 46.50, 46.50, 46.50, 46.50, 89.93, 133.36, 176.79, 220.22, 220.22, + 220.22, 220.22, 176.79, 133.36, 89.93, 89.93, 89.93, 133.36, 176.79, + 176.79, 133.36 ] + +# set this to either 0 or 1 for the initial sensor number (usually 1) +start_sn: 1 + +# Normally, positive X is to the right, and positive Y is up. +# These settings can be used to reverse one or both of these. +reverse_x: false +reverse_y: false + +# these are sensors whose labels need to be repositioned. Sensor numbers +# start at start_sn. Directions are up, down, left, and right, and values +# are multiples of the marker size. +label_exceptions: { + 21: { left: [0, 0] }, + 22: { left: [0, 0] }, + 23: { left: [0, 0] }, + 24: { left: [0, 0] }, + 25: { left: [0, 0] }, + 26: { left: [0, 0] }, + 27: { left: [0, 0] }, + 28: { left: [0, 0] }, + 29: { left: [0, 0] }, + 30: { left: [0, 0] }, + 31: { left: [0, 0] }, + 32: { left: [0, 0] }, + 33: { left: [0, 0] }, + 34: { left: [0, 0] }, + 35: { left: [0, 0] }, + 36: { left: [0, 0] } +} diff --git a/src/pygui/assets/layouts/zwafer.yaml b/src/pygui/assets/layouts/zwafer.yaml new file mode 100644 index 0000000..3814b74 --- /dev/null +++ b/src/pygui/assets/layouts/zwafer.yaml @@ -0,0 +1,68 @@ +# This will be the name of the output files +name: zwafer +wafers: ["Z"] + +# round or square wafer +shape: round + +# diameter/size of the wafer +size: 300 + +# large or small markers and labels +marker_size: small +font_size: small + +# origin settings. x_origin can be left, right, or center. y_origin can +# be top, bottom, or center. Using right or top probably also needs +# reverse_x/reverse_y to be set as well. +x_origin: center +y_origin: center + +# sensor X and Y values, in mm relative to the wafer center +X: [0.00, -1.17, 8.92, 1.17, -8.92, 22.52, 29.35, -22.52, -29.35, 45.05, 73.37, + 58.71, 9.66, -45.05, -73.37, -58.71, -9.66, 79.85, 89.06, 46.10, -23.86, + -79.85, -89.06, -46.10, 23.86, 66.96, 109.06, 87.27, 14.36, -66.96, + -109.06, -87.27, -14.36, 121.24, 135.23, 70.00, -36.23, -121.24, -135.23, + -70.00, 36.23, 124.71, 139.09, 72.00, -37.27, -124.71, -139.09, -72.00, + 37.27, 89.49, 127.31, 145.74, 141.99, 116.62, 73.50, 19.19, -38.05, -89.49, + -127.31, -145.74, -141.99, -116.62, -73.50, -19.19, 38.05] +Y: [0.00, -8.92, -1.17, 8.92, 1.17, -29.35, 22.52, 29.35, -22.52, -58.71, + -9.66, 45.05, 73.37, 58.71, 9.66, -45.05, -73.37, -46.10, 23.86, 79.85, + 89.06, 46.10, -23.86, -79.85, -89.06, -87.27, -14.36, 66.96, 109.06, 87.27, + 14.36, -66.96, -109.06, -70.00, 36.23, 121.24, 135.23, 70.00, -36.23, + -121.24, -135.23, -72.00, 37.27, 124.71, 139.09, 72.00, -37.27, -124.71, + -139.09, -116.62, -73.50, -19.19, 38.05, 89.49, 127.31, 145.74, 141.99, + 116.62, 73.50, 19.19, -38.05, -89.49, -127.31, -145.74, -141.99] + +# set this to either 0 or 1 for the initial sensor number (usually 1) +start_sn: 0 + +# If one or both axes are reversed, this can be used to fix it +# without having to edit all of the coordinates. +# These should be false for most normal wafers. +reverse_x: false +reverse_y: false + +# these are sensors whose labels need to be repositioned. Sensor numbers +# start at start_sn. Directions are up, down, left, and right, and values +# are multiples of the marker size. +label_exceptions: { + 33: { left: [0, 0] }, + 34: { left: [0, 0] }, + 41: { top: [0, 0] }, + 42: { bottom: [0, 0] }, + 45: { top: [1, 0] }, + 46: { bottom: [0.5, 0] }, + 49: { left: [0, 0] }, + 50: { bottom: [-1.75, -0.5] }, + 51: { top: [-1, 0] }, + 52: { top: [-1, 0] }, + 53: { bottom: [0, 0] }, + 54: { left: [0, -0.75] }, + 55: { right: [0, 0.5] }, + 58: { bottom: [-0.75, 0] }, + 60: { top: [0, 0] }, + 62: { right: [0, 1] }, + 63: { right: [0, 0] }, + 64: { left: [0, 0.25] } +} diff --git a/src/pygui/assets/layouts/zwafer_rev.yaml b/src/pygui/assets/layouts/zwafer_rev.yaml new file mode 100644 index 0000000..4fc2586 --- /dev/null +++ b/src/pygui/assets/layouts/zwafer_rev.yaml @@ -0,0 +1,71 @@ +# This will be the name of the output files +name: zwafer_rev +wafers: [] # the reversed version doesn't represent any real wafer + +# round or square wafer +shape: round + +# diameter/size of the wafer +size: 300 + +# large or small markers and labels +marker_size: small +font_size: small + +# origin settings. x_origin can be left, right, or center. y_origin can +# be top, bottom, or center. Using right or top probably also needs +# reverse_x/reverse_y to be set as well. +x_origin: center +y_origin: center + +# sensor X and Y values, in mm relative to the wafer center +X: [0.00, -1.17, 8.92, 1.17, -8.92, 22.52, 29.35, -22.52, -29.35, 45.05, 73.37, + 58.71, 9.66, -45.05, -73.37, -58.71, -9.66, 79.85, 89.06, 46.10, -23.86, + -79.85, -89.06, -46.10, 23.86, 66.96, 109.06, 87.27, 14.36, -66.96, + -109.06, -87.27, -14.36, 121.24, 135.23, 70.00, -36.23, -121.24, -135.23, + -70.00, 36.23, 124.71, 139.09, 72.00, -37.27, -124.71, -139.09, -72.00, + 37.27, 89.49, 127.31, 145.74, 141.99, 116.62, 73.50, 19.19, -38.05, -89.49, + -127.31, -145.74, -141.99, -116.62, -73.50, -19.19, 38.05] +Y: [0.00, -8.92, -1.17, 8.92, 1.17, -29.35, 22.52, 29.35, -22.52, -58.71, + -9.66, 45.05, 73.37, 58.71, 9.66, -45.05, -73.37, -46.10, 23.86, 79.85, + 89.06, 46.10, -23.86, -79.85, -89.06, -87.27, -14.36, 66.96, 109.06, 87.27, + 14.36, -66.96, -109.06, -70.00, 36.23, 121.24, 135.23, 70.00, -36.23, + -121.24, -135.23, -72.00, 37.27, 124.71, 139.09, 72.00, -37.27, -124.71, + -139.09, -116.62, -73.50, -19.19, 38.05, 89.49, 127.31, 145.74, 141.99, + 116.62, 73.50, 19.19, -38.05, -89.49, -127.31, -145.74, -141.99] + +# set this to either 0 or 1 for the initial sensor number (usually 1) +start_sn: 0 + +# If one or both axes are reversed, this can be used to fix it +# without having to edit all of the coordinates. +# These should be false for most normal wafers. +reverse_x: true +reverse_y: true + +# these are sensors whose labels need to be repositioned. Sensor numbers +# start at start_sn. Directions are up, down, left, and right, and values +# are multiples of the marker size. +label_exceptions: { + 37: { left: [0, 0] }, + 38: { left: [0, 0] }, + 39: { left: [0, 0] }, + 41: { top: [1.5, 0] }, + 42: { bottom: [1, 0] }, + 43: { left: [0, -1] }, + 45: { bottom: [-1, -1] }, + 46: { bottom: [0, -1] }, + 47: { right: [0, 1] }, + 48: { left: [0, 0] }, + 50: { bottom: [-1, 0] }, + 52: { top: [0, 0] }, + 54: { right: [0.5, 1] }, + 56: { left: [0, 0.5] }, + 57: { left: [0, 0] }, + 58: { top: [0.5, 0] }, + 59: { left: [0, 0] }, + 60: { top: [-1, 0] }, + 61: { left: [0, 0] }, + 62: { left: [0, -1] }, + 63: { right: [0, 1] }, +} \ No newline at end of file diff --git a/src/pygui/backend/rbf_heatmap.py b/src/pygui/backend/rbf_heatmap.py new file mode 100644 index 0000000..b74c056 --- /dev/null +++ b/src/pygui/backend/rbf_heatmap.py @@ -0,0 +1,61 @@ +"""RBF (thin-plate spline) heatmap field. + +Uses CuPy for GPU acceleration when available, falls back to NumPy + SciPy. +""" +from __future__ import annotations + +import numpy as np +from scipy.interpolate import RBFInterpolator + +try: + import cupy as _cupy # type: ignore + BACKEND = "cupy" +except Exception: + _cupy = None + BACKEND = "numpy" + +_KERNEL = "thin_plate_spline" +_SMOOTHING = 0.0 + + +def interpolate_field( + xs: np.ndarray, + ys: np.ndarray, + vs: np.ndarray, + *, + width: int, + height: int, + extent: tuple[float, float, float, float], # (xmin, xmax, ymin, ymax) in mm + round_clip: bool = False, +) -> np.ndarray: + """Return a (height, width) float64 array of interpolated values. + + Args: + xs, ys: sensor positions in mm (1-D arrays, length N) + vs: sensor values (length N) + width/height: output grid dimensions in pixels + extent: (xmin, xmax, ymin, ymax) in the same mm space as xs/ys + round_clip: if True, pixels outside the inscribed ellipse become NaN + """ + coords = np.column_stack([xs, ys]) + rbf = RBFInterpolator(coords, vs, kernel=_KERNEL, smoothing=_SMOOTHING) + + xmin, xmax, ymin, ymax = extent + gx = np.linspace(xmin, xmax, width) + gy = np.linspace(ymin, ymax, height) + grid_x, grid_y = np.meshgrid(gx, gy) + flat = np.column_stack([grid_x.ravel(), grid_y.ravel()]) + + # RBFInterpolator always runs on CPU; CuPy only accelerates other ops if added later + field = rbf(flat).reshape(height, width) + + if round_clip: + cx = (xmin + xmax) / 2 + cy = (ymin + ymax) / 2 + rx = (xmax - xmin) / 2 + ry = (ymax - ymin) / 2 + dist = ((grid_x - cx) / rx) ** 2 + ((grid_y - cy) / ry) ** 2 + field = np.where(dist <= 1.0, field, np.nan) + + return field.astype(np.float64) + diff --git a/src/pygui/backend/wafer_layouts.py b/src/pygui/backend/wafer_layouts.py new file mode 100644 index 0000000..046185c --- /dev/null +++ b/src/pygui/backend/wafer_layouts.py @@ -0,0 +1,79 @@ +"""Load wafer sensor layouts from bundled YAML files. + +YAML schema mirrors the replay app's wafer_desc.py format: + X/Y — sensor positions in mm relative to x_origin/y_origin + size — wafer diameter/edge in mm + shape — "round" or "square" + start_sn — first sensor number (usually 1) + x_origin — "left" | "right" | "center" + y_origin — "bottom" | "top" | "center" + +Returned Sensor coords are center-origin mm (negative values toward the edge). +""" +from __future__ import annotations + +from pathlib import Path + +import yaml + +from pygui.backend.zwafer_models import Sensor + +_LAYOUTS_DIR = Path(__file__).parent.parent / "assets" / "layouts" + + +def _family_name(raw_name: str) -> str: + return raw_name.replace("wafer", "").strip("_") + + +def _load_yaml(path: Path) -> dict: + with path.open(encoding="utf-8") as f: + return yaml.safe_load(f) + + +def available_families() -> list[str]: + return [_family_name(_load_yaml(p)["name"]) for p in _LAYOUTS_DIR.glob("*.yaml")] + + +def load_layout(family: str) -> list[Sensor]: + for path in _LAYOUTS_DIR.glob("*.yaml"): + data = _load_yaml(path) + if _family_name(data["name"]) == family: + return _to_sensors(data) + raise KeyError(f"Unknown wafer family: {family!r}") + + +def load_layout_for_wafer_id(wafer_id: str) -> list[Sensor]: + """Match 'B00108' → bcdwafer by looking up the first char in each YAML's 'wafers' list.""" + prefix = wafer_id[0].upper() if wafer_id else "" + for path in _LAYOUTS_DIR.glob("*.yaml"): + data = _load_yaml(path) + if prefix in data.get("wafers", []): + return _to_sensors(data) + raise KeyError(f"No layout found for wafer ID prefix {prefix!r}") + + +def _to_sensors(data: dict) -> list[Sensor]: + xs: list[float] = data["X"] + ys: list[float] = data["Y"] + size: float = float(data["size"]) + start_sn: int = data.get("start_sn", 1) + reverse_x: bool = data.get("reverse_x", False) + reverse_y: bool = data.get("reverse_y", False) + x_orig: str = data.get("x_origin", "left") + y_orig: str = data.get("y_origin", "bottom") + + x_shift = {"left": size / 2, "right": -(size / 2), "center": 0.0}.get(x_orig, 0.0) + y_shift = {"bottom": size / 2, "top": -(size / 2), "center": 0.0}.get(y_orig, 0.0) + + sensors: list[Sensor] = [] + for i, (x_mm, y_mm) in enumerate(zip(xs, ys)): + if reverse_x: + x_mm = -x_mm + if reverse_y: + y_mm = -y_mm + sensors.append(Sensor( + label=str(start_sn + i), + x=x_mm - x_shift, + y=y_mm - y_shift, + )) + return sensors diff --git a/src/pygui/backend/wafer_map_item.py b/src/pygui/backend/wafer_map_item.py new file mode 100644 index 0000000..2aa684c --- /dev/null +++ b/src/pygui/backend/wafer_map_item.py @@ -0,0 +1,406 @@ +"""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.rbf_heatmap import interpolate_field +from pygui.backend.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 - 4 px.""" + return (ds / 2 - 4) / 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}")