diff --git a/src/pygui/backend/visualization/radial_metrics.py b/src/pygui/backend/visualization/radial_metrics.py new file mode 100644 index 0000000..cd64d78 --- /dev/null +++ b/src/pygui/backend/visualization/radial_metrics.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import math + +from pygui.backend.wafer.zwafer_models import Sensor + + +def ring_boundaries(sensors: list[Sensor]) -> list[float]: + """Distinct radial distances of sensor groups, sorted ascending, plus the outer boundary.""" + if not sensors: + r = 150.0 + 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 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 = max(math.hypot(s.x, s.y) for s in sensors) * 1.05 + if not groups or outer - groups[-1] > 2.0: + groups.append(outer) + return groups + + +def bucket_by_ring(sensors: list[Sensor], values: list[float]) -> dict[int, list[float]]: + """Group each sensor's value under the index of its nearest ring boundary.""" + boundaries = ring_boundaries(sensors) + result: dict[int, list[float]] = {i: [] for i in range(len(boundaries))} + for i, s in enumerate(sensors): + if i >= len(values): + continue + r = math.hypot(s.x, s.y) + idx = min(range(len(boundaries)), key=lambda j: abs(boundaries[j] - r)) + result[idx].append(values[i]) + return result diff --git a/src/pygui/backend/visualization/wafer_map_item.py b/src/pygui/backend/visualization/wafer_map_item.py index a1295ff..4c44a2c 100644 --- a/src/pygui/backend/visualization/wafer_map_item.py +++ b/src/pygui/backend/visualization/wafer_map_item.py @@ -28,6 +28,7 @@ from PySide6.QtQml import QmlElement from PySide6.QtQuick import QQuickPaintedItem from pygui.backend.models.frame_stats import compute_stats +from pygui.backend.visualization.radial_metrics import ring_boundaries from pygui.backend.visualization.rbf_heatmap import ( ellipse_alpha, interpolate_field, @@ -517,22 +518,7 @@ class WaferMapItem(QQuickPaintedItem): 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 + return ring_boundaries(self._sensors) def _scale(self, ds: int, r_mm: float) -> float: """Pixels per mm. The wafer radius maps to ds//2 - 24 px.""" diff --git a/tests/test_radial_metrics.py b/tests/test_radial_metrics.py new file mode 100644 index 0000000..969572d --- /dev/null +++ b/tests/test_radial_metrics.py @@ -0,0 +1,89 @@ +import math + +import pytest + +from pygui.backend.visualization.radial_metrics import bucket_by_ring, ring_boundaries +from pygui.backend.wafer.zwafer_models import Sensor + + +def test_ring_boundaries_empty_sensors_returns_default_quartiles(): + assert ring_boundaries([]) == [37.5, 75.0, 112.5, 150.0] + + +def test_ring_boundaries_single_ring_plus_outer_boundary(): + sensors = [ + Sensor(label="1", x=100.0, y=0.0), + Sensor(label="2", x=0.0, y=100.0), + Sensor(label="3", x=-100.0, y=0.0), + Sensor(label="4", x=0.0, y=-100.0), + ] + assert ring_boundaries(sensors) == [100.0, 105.0] + + +def test_ring_boundaries_excludes_center_point(): + sensors = [ + Sensor(label="center", x=0.0, y=0.0), + Sensor(label="1", x=50.0, y=0.0), + Sensor(label="2", x=0.0, y=50.0), + Sensor(label="3", x=-50.0, y=0.0), + Sensor(label="4", x=0.0, y=-50.0), + ] + assert ring_boundaries(sensors) == [50.0, 52.5] + + +def test_ring_boundaries_merges_radii_within_2mm_and_averages(): + sensors = [ + Sensor(label="1", x=30.0, y=0.0), + Sensor(label="2", x=0.0, y=31.5), + Sensor(label="3", x=80.0, y=0.0), + ] + assert ring_boundaries(sensors) == [30.75, 80.0, 84.0] + + +def test_ring_boundaries_square_family_grid_buckets_by_distance_not_axis(): + # A square-family sensor grid (per wafer_layouts.py, e.g. the 80-sensor + # square layout): axis points and corner points sit at different radial + # distances from center despite forming a visually square pattern, so + # ring clustering must key on hypot() distance, not grid position. + sensors = [ + Sensor(label="axis1", x=40.0, y=0.0), + Sensor(label="axis2", x=-40.0, y=0.0), + Sensor(label="axis3", x=0.0, y=40.0), + Sensor(label="axis4", x=0.0, y=-40.0), + Sensor(label="corner1", x=40.0, y=40.0), + Sensor(label="corner2", x=-40.0, y=-40.0), + ] + axis_r = 40.0 + corner_r = math.hypot(40.0, 40.0) + outer = corner_r * 1.05 + assert ring_boundaries(sensors) == pytest.approx([axis_r, corner_r, outer]) + + +def test_bucket_by_ring_groups_single_ring_sensors_together(): + sensors = [ + Sensor(label="1", x=100.0, y=0.0), + Sensor(label="2", x=0.0, y=100.0), + Sensor(label="3", x=-100.0, y=0.0), + Sensor(label="4", x=0.0, y=-100.0), + ] + values = [10.0, 20.0, 30.0, 40.0] + assert bucket_by_ring(sensors, values) == {0: [10.0, 20.0, 30.0, 40.0], 1: []} + + +def test_bucket_by_ring_assigns_each_sensor_to_its_nearest_boundary(): + sensors = [ + Sensor(label="1", x=30.0, y=0.0), + Sensor(label="2", x=0.0, y=31.5), + Sensor(label="3", x=80.0, y=0.0), + ] + values = [1.0, 2.0, 3.0] + assert bucket_by_ring(sensors, values) == {0: [1.0, 2.0], 1: [3.0], 2: []} + + +def test_bucket_by_ring_ignores_sensors_beyond_a_short_values_list(): + sensors = [ + Sensor(label="1", x=100.0, y=0.0), + Sensor(label="2", x=0.0, y=100.0), + ] + values = [10.0] # frame shorter than sensor count + assert bucket_by_ring(sensors, values) == {0: [10.0], 1: []}