From 25fa7507ce070fb7bb67a0ee74d00c1073bc3f76 Mon Sep 17 00:00:00 2001 From: jack Date: Fri, 10 Jul 2026 16:28:55 -0700 Subject: [PATCH] feat(map): add min/max highlighting toggle and live safeguards - Add "Highlight Min/Max" option to ReadoutPanel showing peak rings. - Reset to review mode and blank wafer map when switching away from map tab. - Disable hover highlights and sensor dialog triggers during live stream. - Lower trendPane height and add top margin headroom for tick labels. - Add test coverage for blanking states, toggle overrides, and interactions. --- src/pygui/ISC/HomePage.qml | 3 ++ src/pygui/ISC/Tabs/WaferMapTab.qml | 9 ++++-- .../ISC/Tabs/components/ReadoutPanel.qml | 8 +++++ .../ISC/Tabs/components/WaferMapView.qml | 7 ++++- .../backend/controllers/session_controller.py | 6 ++++ .../backend/visualization/trend_chart_item.py | 8 +++-- .../backend/visualization/wafer_map_item.py | 22 +++++++++----- tests/test_session_controller.py | 23 ++++++++++++++ tests/test_wafer_map_item.py | 30 +++++++++++++++++++ 9 files changed, 103 insertions(+), 13 deletions(-) diff --git a/src/pygui/ISC/HomePage.qml b/src/pygui/ISC/HomePage.qml index a207d29..94fce12 100644 --- a/src/pygui/ISC/HomePage.qml +++ b/src/pygui/ISC/HomePage.qml @@ -23,6 +23,9 @@ Rectangle { if (selectedTabIndex === 2) { file_browser.refreshFiles(); streamController.unloadFile() + } else if (streamController.mode === "live") { + // Leaving the map tab mid-stream: stop and blank the live map. + streamController.setMode("review") } } diff --git a/src/pygui/ISC/Tabs/WaferMapTab.qml b/src/pygui/ISC/Tabs/WaferMapTab.qml index 2655368..3bfc32d 100644 --- a/src/pygui/ISC/Tabs/WaferMapTab.qml +++ b/src/pygui/ISC/Tabs/WaferMapTab.qml @@ -144,7 +144,7 @@ Item { ColumnLayout { Layout.fillWidth: true Layout.fillHeight: true - spacing: 8 + spacing: 16 WaferMapView { id: waferView @@ -154,14 +154,15 @@ Item { Layout.minimumHeight: 280 blend: readoutPanel.heatmapBlend showLabels: readoutPanel.showLabels + showExtremes: readoutPanel.showExtremes showThickness: readoutPanel.showThickness } Rectangle { id: trendPane Layout.fillWidth: true - Layout.fillHeight: true + Layout.fillHeight: false Layout.preferredHeight: 220 - Layout.minimumHeight: 160 + Layout.minimumHeight: 120 visible: trendChart.hasData && streamController.mode === "live" color: Theme.cardBackground border.color: Theme.cardBorder @@ -183,9 +184,11 @@ Item { Item { Layout.fillWidth: true + visible: transportBar.hasContent implicitHeight: trendPane.visible ? 52 : 16 } TransportBar { + id: transportBar Layout.fillWidth: true } } diff --git a/src/pygui/ISC/Tabs/components/ReadoutPanel.qml b/src/pygui/ISC/Tabs/components/ReadoutPanel.qml index 8abcbd3..da34d9e 100644 --- a/src/pygui/ISC/Tabs/components/ReadoutPanel.qml +++ b/src/pygui/ISC/Tabs/components/ReadoutPanel.qml @@ -12,6 +12,7 @@ ColumnLayout { spacing: 6 property var s: streamController.stats property alias showLabels: labelsToggle.checked + property alias showExtremes: extremesToggle.checked property alias heatmapBlend: heatmapSlider.value property alias showThickness: thicknessToggle.checked @@ -353,6 +354,13 @@ ColumnLayout { font.pixelSize: Theme.fontSm } + PanelCheckBox { + id: extremesToggle + text: "Highlight Min/Max" + checked: true + font.pixelSize: Theme.fontSm + } + PanelCheckBox { id: clusterAverageToggle text: "Average Clusters" diff --git a/src/pygui/ISC/Tabs/components/WaferMapView.qml b/src/pygui/ISC/Tabs/components/WaferMapView.qml index 75eb6b2..59068d9 100644 --- a/src/pygui/ISC/Tabs/components/WaferMapView.qml +++ b/src/pygui/ISC/Tabs/components/WaferMapView.qml @@ -7,6 +7,7 @@ Item { id: root property real blend: 0.0 property bool showLabels: true + property alias showExtremes: map.showExtremes property alias showThickness: map.showThickness WaferMapItem { @@ -39,7 +40,9 @@ Item { HoverHandler { id: hoverHandler onPointChanged: { - map.hoveredIndex = map.which_marker(hoverHandler.point.position.x, hoverHandler.point.position.y); + map.hoveredIndex = streamController.mode === "live" + ? -1 + : map.which_marker(hoverHandler.point.position.x, hoverHandler.point.position.y); } onHoveredChanged: if (!hoverHandler.hovered) map.hoveredIndex = -1 cursorShape: map.hoveredIndex >= 0 ? Qt.PointingHandCursor : Qt.ArrowCursor @@ -47,6 +50,8 @@ Item { TapHandler { onTapped: ev => { + if (streamController.mode === "live") + return; var idx = map.which_marker(ev.position.x, ev.position.y); if (idx >= 0) replaceDialog.openFor(idx); diff --git a/src/pygui/backend/controllers/session_controller.py b/src/pygui/backend/controllers/session_controller.py index 896b090..a3ce425 100644 --- a/src/pygui/backend/controllers/session_controller.py +++ b/src/pygui/backend/controllers/session_controller.py @@ -225,6 +225,12 @@ class SessionController(QObject): return if mode != "live": self.stopStream() + if self._mode == "live": + # Leaving a live session: blank the wafer map instead of + # freezing the last streamed frame on screen. + self._last = None + self._last_raw_frame = None + self.frameUpdated.emit() self._play_timer.stop() self._mode = mode self.modeChanged.emit() diff --git a/src/pygui/backend/visualization/trend_chart_item.py b/src/pygui/backend/visualization/trend_chart_item.py index 2b9643f..eabed7a 100644 --- a/src/pygui/backend/visualization/trend_chart_item.py +++ b/src/pygui/backend/visualization/trend_chart_item.py @@ -58,6 +58,9 @@ class TrendChartItem(QQuickPaintedItem): # `_padding` alone — that mismatch is what clipped y-axis text before. self._margin_left: int = 48 self._margin_bottom: int = 30 + # Top tick label is drawn centered on the plot's top edge (y_px - 7), + # so the plot needs headroom or the label kisses the card border. + self._margin_top: int = 10 self._line_color = QColor("#5B9DF5") self._grid_color = QColor("#2A3441") self._text_color = QColor("#CBD5E1") @@ -178,10 +181,11 @@ class TrendChartItem(QQuickPaintedItem): return left = self._padding + self._margin_left + top = self._padding + self._margin_top bottom_inset = self._padding + self._margin_bottom - plot_rect = QRectF(left, self._padding, + plot_rect = QRectF(left, top, max(1, w - left - self._padding), - max(1, h - self._padding - bottom_inset)) + max(1, h - top - bottom_inset)) self._draw_grid(painter, plot_rect) diff --git a/src/pygui/backend/visualization/wafer_map_item.py b/src/pygui/backend/visualization/wafer_map_item.py index 46e5146..4d0d05d 100644 --- a/src/pygui/backend/visualization/wafer_map_item.py +++ b/src/pygui/backend/visualization/wafer_map_item.py @@ -62,6 +62,7 @@ class WaferMapItem(QQuickPaintedItem): marginChanged = Signal() blendChanged = Signal() showLabelsChanged = Signal() + showExtremesChanged = Signal() colorsChanged = Signal() shapeChanged = Signal() sizeChanged = Signal() @@ -80,6 +81,7 @@ class WaferMapItem(QQuickPaintedItem): self._margin: float = 1.0 self._blend: float = 0.0 self._show_labels: bool = True + self._show_extremes: bool = True self._shape: str = "round" self._size: float = 300.0 self._thickness_data: list[float] = [] @@ -199,6 +201,16 @@ class WaferMapItem(QQuickPaintedItem): self.showLabelsChanged.emit() self.update() + @Property(bool, notify=showExtremesChanged) + def showExtremes(self) -> bool: + return self._show_extremes + + @showExtremes.setter # type: ignore[no-redef] + def showExtremes(self, val: bool) -> None: + self._show_extremes = bool(val) + self.showExtremesChanged.emit() + self.update() + @Property(str, notify=shapeChanged) def shape(self) -> str: return self._shape @@ -340,7 +352,6 @@ class WaferMapItem(QQuickPaintedItem): img.fill(0) # transparent background painter = QPainter(img) self.paint(painter) - self._paint_extremes(painter) if lines: painter.fillRect(QRectF(0, h, w, footer_h), QColor("#101014")) painter.setPen(QColor("#CBD5E1")) @@ -357,12 +368,8 @@ class WaferMapItem(QQuickPaintedItem): # ── internal ───────────────────────────────────────────────────────── def _paint_extremes(self, painter: QPainter) -> None: - """Ring the hottest (high color) and coldest (low color) markers. - - Export-only overlay — paint() itself stays untouched so the live - view is unchanged. - """ - if not self._values or not self._markers: + """Ring the hottest (high color) and coldest (low color) markers.""" + if not self._show_extremes or not self._values or not self._markers: return s = compute_stats(self._values) ring_r = self._marker_r + 5 @@ -606,6 +613,7 @@ class WaferMapItem(QQuickPaintedItem): painter.setOpacity(1.0) self._paint_markers(painter) + self._paint_extremes(painter) def _paint_template(self, painter: QPainter, cx: int, cy: int, r_px: int) -> None: ds = self._draw_size() diff --git a/tests/test_session_controller.py b/tests/test_session_controller.py index 663d10f..360e11b 100644 --- a/tests/test_session_controller.py +++ b/tests/test_session_controller.py @@ -53,6 +53,29 @@ def test_pause_stop_timer(controller): controller.stop() assert controller.frameIndex == 0 +def test_leaving_live_mode_blanks_wafer_map(controller): + """Stopping a live session (tab switch → setMode) must not leave stale + sensor values on the wafer map — it should read as blank.""" + frame_signal = MagicMock() + controller.frameUpdated.connect(frame_signal) + + controller.setMode("live") + controller._on_live_frame(Frame(seq=1, time=0.0, values=[100.0] * 22)) + assert controller.sensorValues, "sanity: live frame should populate the map" + + controller.setMode("review") + assert controller.sensorValues == [] + assert controller.stats == {} + assert frame_signal.called, "QML needs frameUpdated to repaint blank" + + +def test_leaving_live_mode_stops_stream(controller): + controller.setMode("live") + controller.setMode("review") + assert controller._reader is None + assert not controller._repaint_timer.isActive() + + def test_compare_files_integration(controller): # Mock the comparisonResult signal mock_slot = MagicMock() diff --git a/tests/test_wafer_map_item.py b/tests/test_wafer_map_item.py index 2fce50a..ec60a2b 100644 --- a/tests/test_wafer_map_item.py +++ b/tests/test_wafer_map_item.py @@ -161,6 +161,36 @@ def test_export_image_highlights_hottest_and_coldest(tmp_path): assert "#5b9df5" in found # coldest ring (sensorLow) +def test_show_extremes_toggle_gates_peak_rings(tmp_path): + """showExtremes (Display setting) hides the hot/cold rings when off. + Same pixel probe as the highlight test: with rings off, no high/low + colored pixels should appear anywhere.""" + from PySide6.QtGui import QImage + + from pygui.backend.visualization.wafer_map_item import WaferMapItem + + item = WaferMapItem() + assert item.showExtremes is True # rings on by default + + item.setWidth(200) + item.setHeight(200) + item.sensors = [ + {"label": "1", "x": -50.0, "y": 0.0}, + {"label": "2", "x": 0.0, "y": 0.0}, + {"label": "3", "x": 50.0, "y": 0.0}, + ] + item.values = [10.0, 20.0, 30.0] + item.showExtremes = False + out = tmp_path / "wafer.png" + assert item.export_image(str(out)) is True + + img = QImage(str(out)) + found = {img.pixelColor(x, y).name() + for y in range(img.height()) for x in range(img.width())} + assert "#ef4444" not in found + assert "#5b9df5" not in found + + def test_readout_text_full_stats(): """Export footer readout carries every READOUT stat with sensor indices.