feat(visualization): extract ring_boundaries to radial_metrics
- add radial_metrics: ring_boundaries + bucket_by_ring - replace WaferMapItem inline logic with ring_boundaries() - fix outer boundary: sensor max-hypot*1.05 (was wafer_radius_mm) - add LicenseModel.reviewAccessBlocked() (ADR-0005 deferred) - add appendActivityLog test - add test_radial_metrics.py (89 lines, full edge-case coverage) - update README: licmgr --level flag + date format
This commit is contained in:
@@ -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
|
||||||
@@ -28,6 +28,7 @@ from PySide6.QtQml import QmlElement
|
|||||||
from PySide6.QtQuick import QQuickPaintedItem
|
from PySide6.QtQuick import QQuickPaintedItem
|
||||||
|
|
||||||
from pygui.backend.models.frame_stats import compute_stats
|
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 (
|
from pygui.backend.visualization.rbf_heatmap import (
|
||||||
ellipse_alpha,
|
ellipse_alpha,
|
||||||
interpolate_field,
|
interpolate_field,
|
||||||
@@ -517,22 +518,7 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
|
|
||||||
def _sensor_ring_radii_mm(self) -> list[float]:
|
def _sensor_ring_radii_mm(self) -> list[float]:
|
||||||
"""Distinct radial distances of sensor groups, sorted ascending, plus the outer boundary."""
|
"""Distinct radial distances of sensor groups, sorted ascending, plus the outer boundary."""
|
||||||
if not self._sensors:
|
return ring_boundaries(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:
|
def _scale(self, ds: int, r_mm: float) -> float:
|
||||||
"""Pixels per mm. The wafer radius maps to ds//2 - 24 px."""
|
"""Pixels per mm. The wafer radius maps to ds//2 - 24 px."""
|
||||||
|
|||||||
@@ -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: []}
|
||||||
Reference in New Issue
Block a user