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
}
PanelCheckBox {
id: clusterAverageToggle
text: "Average Clusters"
checked: streamController.clusterAveragingEnabled
font.pixelSize: 11
onCheckedChanged: streamController.clusterAveragingEnabled = checked
}
RowLayout {
Layout.fillWidth: true
spacing: 6
@@ -29,8 +29,9 @@ Dialog {
background: Rectangle {
color: Theme.cardBackground
radius: 8
radius: Theme.radiusLg
border.color: Theme.cardBorder
border.width: Theme.borderThin
}
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.serialcomm.stream_reader import StreamReader
from pygui.backend.models.sensor_editor import SensorEditor
from pygui.backend.cluster_average import average_clusters, group_sensors_by_radius
log = logging.getLogger(__name__)
MODE_LIVE = "live"
@@ -24,12 +24,13 @@ MODE_REVIEW = "review"
class SessionController(QObject):
# public signals
frameUpdated = Signal() # any property below may have changed
frameUpdated = Signal()
modeChanged = Signal()
stateChanged = Signal()
recordingChanged = Signal()
sensorsChanged = Signal()
loadedFileChanged = Signal()
clusterAveragingEnabledChanged = Signal()
# private: marshal a worker-thread frame onto the main thread
_liveFrame = Signal(object) # Frame
@@ -59,6 +60,8 @@ class SessionController(QObject):
self._sensor_editor = SensorEditor()
self._last_raw_frame: Frame | None = None
self._loaded_file: str = ""
self._cluster_averaging_enabled = False
self._active_clusters: list[list[int]] = []
# ---- properties QML binds to ----
@Property(str, notify=modeChanged)
@@ -163,6 +166,18 @@ class SessionController(QObject):
"""Indices of sensors that currently have a replacement or offset."""
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 ----
@Slot(str)
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.wafer.wafer_layouts import WaferLayout, resolve_shape_and_size
sensors: list[Sensor] = []
frames = []
if is_official_csv(file_path):
@@ -218,6 +232,9 @@ class SessionController(QObject):
shape, size = resolve_shape_and_size(sensors, wafer_id)
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._model.reset()
self._loaded_file = file_path
@@ -270,13 +287,20 @@ class SessionController(QObject):
self._emit_current()
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:
frame = self._player.current()
if frame is None:
return
self._last_raw_frame = frame
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.frameUpdated.emit()
self.stateChanged.emit()
@@ -287,7 +311,7 @@ class SessionController(QObject):
if frame is None:
return
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.frameUpdated.emit()
self.stateChanged.emit()
@@ -307,6 +331,10 @@ class SessionController(QObject):
except Exception as 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
# Each sensor is a big-endian 16-bit value (Sign-Magnitude).
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.
self._last_raw_frame = frame
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)
if self._recorder.is_recording:
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)
gap = 3
if side == "left":
lx = px - r - gap - text_w + ox
lx = px - r - gap - text_w - ox
ly = py - text_h // 2 + oy
elif side == "top":
lx = px - text_w // 2 + ox
ly = py - r - gap - text_h + oy
ly = py - r - gap - text_h - oy
elif side == "bottom":
lx = px - text_w // 2 + ox
ly = py + r + gap + oy