diff --git a/src/pygui/ISC/Tabs/components/ReadoutPanel.qml b/src/pygui/ISC/Tabs/components/ReadoutPanel.qml index 04df534..5101c0d 100644 --- a/src/pygui/ISC/Tabs/components/ReadoutPanel.qml +++ b/src/pygui/ISC/Tabs/components/ReadoutPanel.qml @@ -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 diff --git a/src/pygui/ISC/Tabs/components/ReplaceSensorDialog.qml b/src/pygui/ISC/Tabs/components/ReplaceSensorDialog.qml index 89bf7e5..8dd37e8 100644 --- a/src/pygui/ISC/Tabs/components/ReplaceSensorDialog.qml +++ b/src/pygui/ISC/Tabs/components/ReplaceSensorDialog.qml @@ -29,8 +29,9 @@ Dialog { background: Rectangle { color: Theme.cardBackground - radius: 8 + radius: Theme.radiusLg border.color: Theme.cardBorder + border.width: Theme.borderThin } ColumnLayout { diff --git a/src/pygui/backend/cluster_average.py b/src/pygui/backend/cluster_average.py index e69de29..507efb0 100644 --- a/src/pygui/backend/cluster_average.py +++ b/src/pygui/backend/cluster_average.py @@ -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 diff --git a/src/pygui/backend/controllers/session_controller.py b/src/pygui/backend/controllers/session_controller.py index 3463fea..14af1e8 100644 --- a/src/pygui/backend/controllers/session_controller.py +++ b/src/pygui/backend/controllers/session_controller.py @@ -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 diff --git a/src/pygui/backend/visualization/wafer_map_item.py b/src/pygui/backend/visualization/wafer_map_item.py index a76d960..666440e 100644 --- a/src/pygui/backend/visualization/wafer_map_item.py +++ b/src/pygui/backend/visualization/wafer_map_item.py @@ -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 diff --git a/tests/test_cluster_average.py b/tests/test_cluster_average.py new file mode 100644 index 0000000..5629b9a --- /dev/null +++ b/tests/test_cluster_average.py @@ -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