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