feat: implement cluster averaging pipeline, UI controls

This commit is contained in:
jack
2026-06-15 15:02:55 -07:00
parent 7d32fb4b65
commit e545c245d7
6 changed files with 158 additions and 9 deletions
@@ -182,6 +182,14 @@ ColumnLayout {
font.pixelSize: 11 font.pixelSize: 11
} }
PanelCheckBox {
id: clusterAverageToggle
text: "Average Clusters"
checked: streamController.clusterAveragingEnabled
font.pixelSize: 11
onCheckedChanged: streamController.clusterAveragingEnabled = checked
}
RowLayout { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
spacing: 6 spacing: 6
@@ -29,8 +29,9 @@ Dialog {
background: Rectangle { background: Rectangle {
color: Theme.cardBackground color: Theme.cardBackground
radius: 8 radius: Theme.radiusLg
border.color: Theme.cardBorder border.color: Theme.cardBorder
border.width: Theme.borderThin
} }
ColumnLayout { ColumnLayout {
+60
View File
@@ -0,0 +1,60 @@
"""Cluster Average utility for wafer sensor values."""
from __future__ import annotations
import math
from pygui.backend.wafer.zwafer_models import Sensor
def average_clusters(values: list[float], clusters: list[list[int]]) -> list[float]:
"""Return a new list of values where members of each cluster are replaced by their mean.
NaN values are filtered out before computing the mean.
If all values in a cluster are NaN, they remain NaN.
"""
result = list(values)
for cluster in clusters:
valid_vals = []
for idx in cluster:
if idx < len(values):
val = values[idx]
if not math.isnan(val):
valid_vals.append(val)
if valid_vals:
mean_val = sum(valid_vals) / len(valid_vals)
for idx in cluster:
if idx < len(result):
result[idx] = mean_val
return result
def group_sensors_by_radius(sensors: list[Sensor], tolerance: float = 2.0) -> list[list[int]]:
"""Group sensor indices into clusters if their radial distances are within tolerance."""
# Compute radii for all sensors
radii = [(i, math.hypot(s.x, s.y)) for i, s in enumerate(sensors)]
# Filter out center sensors (r < 1.0)
non_center = [item for item in radii if item[1] >= 1.0]
if not non_center:
return []
# Sort by radius
non_center.sort(key=lambda x: x[1])
clusters: list[list[int]] = []
current_cluster: list[int] = [non_center[0][0]]
last_r = non_center[0][1]
for idx, r in non_center[1:]:
if r - last_r <= tolerance:
current_cluster.append(idx)
else:
if len(current_cluster) > 1:
clusters.append(current_cluster)
current_cluster = [idx]
last_r = r
if len(current_cluster) > 1:
clusters.append(current_cluster)
return clusters
@@ -15,7 +15,7 @@ from pygui.backend.wafer.zwafer_models import Sensor
from pygui.backend.wafer.zwafer_parser import ZWaferParser from pygui.backend.wafer.zwafer_parser import ZWaferParser
from pygui.serialcomm.stream_reader import StreamReader from pygui.serialcomm.stream_reader import StreamReader
from pygui.backend.models.sensor_editor import SensorEditor from pygui.backend.models.sensor_editor import SensorEditor
from pygui.backend.cluster_average import average_clusters, group_sensors_by_radius
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
MODE_LIVE = "live" MODE_LIVE = "live"
@@ -24,12 +24,13 @@ MODE_REVIEW = "review"
class SessionController(QObject): class SessionController(QObject):
# public signals # public signals
frameUpdated = Signal() # any property below may have changed frameUpdated = Signal()
modeChanged = Signal() modeChanged = Signal()
stateChanged = Signal() stateChanged = Signal()
recordingChanged = Signal() recordingChanged = Signal()
sensorsChanged = Signal() sensorsChanged = Signal()
loadedFileChanged = Signal() loadedFileChanged = Signal()
clusterAveragingEnabledChanged = Signal()
# private: marshal a worker-thread frame onto the main thread # private: marshal a worker-thread frame onto the main thread
_liveFrame = Signal(object) # Frame _liveFrame = Signal(object) # Frame
@@ -59,6 +60,8 @@ class SessionController(QObject):
self._sensor_editor = SensorEditor() self._sensor_editor = SensorEditor()
self._last_raw_frame: Frame | None = None self._last_raw_frame: Frame | None = None
self._loaded_file: str = "" self._loaded_file: str = ""
self._cluster_averaging_enabled = False
self._active_clusters: list[list[int]] = []
# ---- properties QML binds to ---- # ---- properties QML binds to ----
@Property(str, notify=modeChanged) @Property(str, notify=modeChanged)
@@ -163,6 +166,18 @@ class SessionController(QObject):
"""Indices of sensors that currently have a replacement or offset.""" """Indices of sensors that currently have a replacement or offset."""
return self._sensor_editor.active_indices() return self._sensor_editor.active_indices()
@Property(bool, notify=clusterAveragingEnabledChanged)
def clusterAveragingEnabled(self) -> bool:
return self._cluster_averaging_enabled
@clusterAveragingEnabled.setter
def clusterAveragingEnabled(self, value: bool) -> None:
if self._cluster_averaging_enabled == value:
return
self._cluster_averaging_enabled = value
self.clusterAveragingEnabledChanged.emit()
self._reprocess_current()
# ---- mode + thresholds ---- # ---- mode + thresholds ----
@Slot(str) @Slot(str)
def setMode(self, mode: str) -> None: def setMode(self, mode: str) -> None:
@@ -186,7 +201,6 @@ class SessionController(QObject):
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
from pygui.backend.wafer.wafer_layouts import WaferLayout, resolve_shape_and_size from pygui.backend.wafer.wafer_layouts import WaferLayout, resolve_shape_and_size
sensors: list[Sensor] = []
frames = [] frames = []
if is_official_csv(file_path): if is_official_csv(file_path):
@@ -218,6 +232,9 @@ class SessionController(QObject):
shape, size = resolve_shape_and_size(sensors, wafer_id) shape, size = resolve_shape_and_size(sensors, wafer_id)
self._sensors = WaferLayout(sensors, shape=shape, size=size) self._sensors = WaferLayout(sensors, shape=shape, size=size)
self._active_clusters = getattr(self._sensors, 'clusters', [])
if not self._active_clusters:
self._active_clusters = group_sensors_by_radius(self._sensors)
self._player.load(frames) self._player.load(frames)
self._model.reset() self._model.reset()
self._loaded_file = file_path self._loaded_file = file_path
@@ -270,13 +287,20 @@ class SessionController(QObject):
self._emit_current() self._emit_current()
self._play_timer.start(self._next_interval_ms()) self._play_timer.start(self._next_interval_ms())
def _apply_pipeline(self, raw_values: list[float]) -> list[float]:
"""Apply override first, then optional cluster averaging."""
edited = self._sensor_editor.apply(raw_values)
if getattr(self, "_cluster_averaging_enabled", False):
return average_clusters(edited, getattr(self, "_active_clusters", []))
return edited
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:
return return
self._last_raw_frame = frame self._last_raw_frame = frame
edited = Frame(seq=frame.seq, time=frame.time, edited = Frame(seq=frame.seq, time=frame.time,
values=self._sensor_editor.apply(frame.values)) values=self._apply_pipeline(frame.values))
self._last = self._model.process(edited) self._last = self._model.process(edited)
self.frameUpdated.emit() self.frameUpdated.emit()
self.stateChanged.emit() self.stateChanged.emit()
@@ -287,7 +311,7 @@ class SessionController(QObject):
if frame is None: if frame is None:
return return
edited = Frame(seq=frame.seq, time=frame.time, edited = Frame(seq=frame.seq, time=frame.time,
values=self._sensor_editor.apply(frame.values)) values=self._apply_pipeline(frame.values))
self._last = self._model.process(edited) self._last = self._model.process(edited)
self.frameUpdated.emit() self.frameUpdated.emit()
self.stateChanged.emit() self.stateChanged.emit()
@@ -307,6 +331,10 @@ class SessionController(QObject):
except Exception as e: except Exception as e:
log.warning("Could not load layout for %s: %s", family_code, e) log.warning("Could not load layout for %s: %s", family_code, e)
self._active_clusters = getattr(self._sensors, 'clusters', [])
if not self._active_clusters:
self._active_clusters = group_sensors_by_radius(self._sensors)
# The new binary protocol sends payload of (sensorCount * 2) bytes # The new binary protocol sends payload of (sensorCount * 2) bytes
# Each sensor is a big-endian 16-bit value (Sign-Magnitude). # Each sensor is a big-endian 16-bit value (Sign-Magnitude).
expected_sensors = 80 if (family_code or "") == "X" else 244 expected_sensors = 80 if (family_code or "") == "X" else 244
@@ -379,7 +407,7 @@ class SessionController(QObject):
# Main thread (queued). Record raw, process edited; mark dirty for repaint. # Main thread (queued). Record raw, process edited; mark dirty for repaint.
self._last_raw_frame = frame self._last_raw_frame = frame
edited = Frame(seq=frame.seq, time=frame.time, edited = Frame(seq=frame.seq, time=frame.time,
values=self._sensor_editor.apply(frame.values)) values=self._apply_pipeline(frame.values))
self._last = self._model.process(edited) self._last = self._model.process(edited)
if self._recorder.is_recording: if self._recorder.is_recording:
self._recorder.write(frame) # always record raw values, never edited self._recorder.write(frame) # always record raw values, never edited
@@ -475,11 +475,11 @@ class WaferMapItem(QQuickPaintedItem):
# Calculate box top-left (lx, ly) relative to dot center (px, py) # Calculate box top-left (lx, ly) relative to dot center (px, py)
gap = 3 gap = 3
if side == "left": if side == "left":
lx = px - r - gap - text_w + ox lx = px - r - gap - text_w - ox
ly = py - text_h // 2 + oy ly = py - text_h // 2 + oy
elif side == "top": elif side == "top":
lx = px - text_w // 2 + ox lx = px - text_w // 2 + ox
ly = py - r - gap - text_h + oy ly = py - r - gap - text_h - oy
elif side == "bottom": elif side == "bottom":
lx = px - text_w // 2 + ox lx = px - text_w // 2 + ox
ly = py + r + gap + oy ly = py + r + gap + oy
+52
View File
@@ -0,0 +1,52 @@
"""Tests for src/pygui/backend/cluster_average.py."""
import math
import pytest
from pygui.backend.cluster_average import average_clusters, group_sensors_by_radius
from pygui.backend.wafer.zwafer_models import Sensor
def test_members_take_cluster_mean():
vals = [10.0, 20.0, 100.0, 0.0]
clusters = [[0, 1], [2, 3]]
assert average_clusters(vals, clusters) == [15.0, 15.0, 50.0, 50.0]
def test_unclustered_sensors_unchanged():
vals = [10.0, 20.0, 30.0]
assert average_clusters(vals, [[0, 1]]) == [15.0, 15.0, 30.0]
def test_empty_clusters_is_identity():
assert average_clusters([1.0, 2.0], []) == [1.0, 2.0]
def test_nan_handling_in_averaging():
vals = [10.0, float('nan'), 100.0, float('nan')]
clusters = [[0, 1], [2, 3]]
res = average_clusters(vals, clusters)
assert res[0] == 10.0
assert res[1] == 10.0
assert res[2] == 100.0
assert res[3] == 100.0
def test_all_nan_cluster():
vals = [float('nan'), float('nan'), 5.0]
clusters = [[0, 1]]
res = average_clusters(vals, clusters)
assert math.isnan(res[0])
assert math.isnan(res[1])
assert res[2] == 5.0
def test_group_by_radius():
sensors = [
Sensor(label="1", x=0.0, y=0.0), # center (r=0)
Sensor(label="2", x=10.0, y=0.0), # r=10
Sensor(label="3", x=0.0, y=10.5), # r=10.5 (within 2mm)
Sensor(label="4", x=20.0, y=0.0), # r=20
]
# Center should be excluded or not grouped; other sensors grouped
clusters = group_sensors_by_radius(sensors, tolerance=2.0)
# Expected: 2 and 3 are in the same cluster because 10.5 - 10.0 <= 2.0.
assert [1, 2] in clusters