feat: implement cluster averaging pipeline, UI controls
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user