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.
This commit is contained in:
@@ -23,6 +23,9 @@ Rectangle {
|
|||||||
if (selectedTabIndex === 2) {
|
if (selectedTabIndex === 2) {
|
||||||
file_browser.refreshFiles();
|
file_browser.refreshFiles();
|
||||||
streamController.unloadFile()
|
streamController.unloadFile()
|
||||||
|
} else if (streamController.mode === "live") {
|
||||||
|
// Leaving the map tab mid-stream: stop and blank the live map.
|
||||||
|
streamController.setMode("review")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ Item {
|
|||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
spacing: 8
|
spacing: 16
|
||||||
|
|
||||||
WaferMapView {
|
WaferMapView {
|
||||||
id: waferView
|
id: waferView
|
||||||
@@ -154,14 +154,15 @@ Item {
|
|||||||
Layout.minimumHeight: 280
|
Layout.minimumHeight: 280
|
||||||
blend: readoutPanel.heatmapBlend
|
blend: readoutPanel.heatmapBlend
|
||||||
showLabels: readoutPanel.showLabels
|
showLabels: readoutPanel.showLabels
|
||||||
|
showExtremes: readoutPanel.showExtremes
|
||||||
showThickness: readoutPanel.showThickness
|
showThickness: readoutPanel.showThickness
|
||||||
}
|
}
|
||||||
Rectangle {
|
Rectangle {
|
||||||
id: trendPane
|
id: trendPane
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: false
|
||||||
Layout.preferredHeight: 220
|
Layout.preferredHeight: 220
|
||||||
Layout.minimumHeight: 160
|
Layout.minimumHeight: 120
|
||||||
visible: trendChart.hasData && streamController.mode === "live"
|
visible: trendChart.hasData && streamController.mode === "live"
|
||||||
color: Theme.cardBackground
|
color: Theme.cardBackground
|
||||||
border.color: Theme.cardBorder
|
border.color: Theme.cardBorder
|
||||||
@@ -183,9 +184,11 @@ Item {
|
|||||||
|
|
||||||
Item {
|
Item {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
|
visible: transportBar.hasContent
|
||||||
implicitHeight: trendPane.visible ? 52 : 16
|
implicitHeight: trendPane.visible ? 52 : 16
|
||||||
}
|
}
|
||||||
TransportBar {
|
TransportBar {
|
||||||
|
id: transportBar
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ ColumnLayout {
|
|||||||
spacing: 6
|
spacing: 6
|
||||||
property var s: streamController.stats
|
property var s: streamController.stats
|
||||||
property alias showLabels: labelsToggle.checked
|
property alias showLabels: labelsToggle.checked
|
||||||
|
property alias showExtremes: extremesToggle.checked
|
||||||
property alias heatmapBlend: heatmapSlider.value
|
property alias heatmapBlend: heatmapSlider.value
|
||||||
property alias showThickness: thicknessToggle.checked
|
property alias showThickness: thicknessToggle.checked
|
||||||
|
|
||||||
@@ -353,6 +354,13 @@ ColumnLayout {
|
|||||||
font.pixelSize: Theme.fontSm
|
font.pixelSize: Theme.fontSm
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PanelCheckBox {
|
||||||
|
id: extremesToggle
|
||||||
|
text: "Highlight Min/Max"
|
||||||
|
checked: true
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
}
|
||||||
|
|
||||||
PanelCheckBox {
|
PanelCheckBox {
|
||||||
id: clusterAverageToggle
|
id: clusterAverageToggle
|
||||||
text: "Average Clusters"
|
text: "Average Clusters"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ Item {
|
|||||||
id: root
|
id: root
|
||||||
property real blend: 0.0
|
property real blend: 0.0
|
||||||
property bool showLabels: true
|
property bool showLabels: true
|
||||||
|
property alias showExtremes: map.showExtremes
|
||||||
property alias showThickness: map.showThickness
|
property alias showThickness: map.showThickness
|
||||||
|
|
||||||
WaferMapItem {
|
WaferMapItem {
|
||||||
@@ -39,7 +40,9 @@ Item {
|
|||||||
HoverHandler {
|
HoverHandler {
|
||||||
id: hoverHandler
|
id: hoverHandler
|
||||||
onPointChanged: {
|
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
|
onHoveredChanged: if (!hoverHandler.hovered) map.hoveredIndex = -1
|
||||||
cursorShape: map.hoveredIndex >= 0 ? Qt.PointingHandCursor : Qt.ArrowCursor
|
cursorShape: map.hoveredIndex >= 0 ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||||
@@ -47,6 +50,8 @@ Item {
|
|||||||
|
|
||||||
TapHandler {
|
TapHandler {
|
||||||
onTapped: ev => {
|
onTapped: ev => {
|
||||||
|
if (streamController.mode === "live")
|
||||||
|
return;
|
||||||
var idx = map.which_marker(ev.position.x, ev.position.y);
|
var idx = map.which_marker(ev.position.x, ev.position.y);
|
||||||
if (idx >= 0)
|
if (idx >= 0)
|
||||||
replaceDialog.openFor(idx);
|
replaceDialog.openFor(idx);
|
||||||
|
|||||||
@@ -225,6 +225,12 @@ class SessionController(QObject):
|
|||||||
return
|
return
|
||||||
if mode != "live":
|
if mode != "live":
|
||||||
self.stopStream()
|
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._play_timer.stop()
|
||||||
self._mode = mode
|
self._mode = mode
|
||||||
self.modeChanged.emit()
|
self.modeChanged.emit()
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ class TrendChartItem(QQuickPaintedItem):
|
|||||||
# `_padding` alone — that mismatch is what clipped y-axis text before.
|
# `_padding` alone — that mismatch is what clipped y-axis text before.
|
||||||
self._margin_left: int = 48
|
self._margin_left: int = 48
|
||||||
self._margin_bottom: int = 30
|
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._line_color = QColor("#5B9DF5")
|
||||||
self._grid_color = QColor("#2A3441")
|
self._grid_color = QColor("#2A3441")
|
||||||
self._text_color = QColor("#CBD5E1")
|
self._text_color = QColor("#CBD5E1")
|
||||||
@@ -178,10 +181,11 @@ class TrendChartItem(QQuickPaintedItem):
|
|||||||
return
|
return
|
||||||
|
|
||||||
left = self._padding + self._margin_left
|
left = self._padding + self._margin_left
|
||||||
|
top = self._padding + self._margin_top
|
||||||
bottom_inset = self._padding + self._margin_bottom
|
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, w - left - self._padding),
|
||||||
max(1, h - self._padding - bottom_inset))
|
max(1, h - top - bottom_inset))
|
||||||
|
|
||||||
self._draw_grid(painter, plot_rect)
|
self._draw_grid(painter, plot_rect)
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
marginChanged = Signal()
|
marginChanged = Signal()
|
||||||
blendChanged = Signal()
|
blendChanged = Signal()
|
||||||
showLabelsChanged = Signal()
|
showLabelsChanged = Signal()
|
||||||
|
showExtremesChanged = Signal()
|
||||||
colorsChanged = Signal()
|
colorsChanged = Signal()
|
||||||
shapeChanged = Signal()
|
shapeChanged = Signal()
|
||||||
sizeChanged = Signal()
|
sizeChanged = Signal()
|
||||||
@@ -80,6 +81,7 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
self._margin: float = 1.0
|
self._margin: float = 1.0
|
||||||
self._blend: float = 0.0
|
self._blend: float = 0.0
|
||||||
self._show_labels: bool = True
|
self._show_labels: bool = True
|
||||||
|
self._show_extremes: bool = True
|
||||||
self._shape: str = "round"
|
self._shape: str = "round"
|
||||||
self._size: float = 300.0
|
self._size: float = 300.0
|
||||||
self._thickness_data: list[float] = []
|
self._thickness_data: list[float] = []
|
||||||
@@ -199,6 +201,16 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
self.showLabelsChanged.emit()
|
self.showLabelsChanged.emit()
|
||||||
self.update()
|
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)
|
@Property(str, notify=shapeChanged)
|
||||||
def shape(self) -> str:
|
def shape(self) -> str:
|
||||||
return self._shape
|
return self._shape
|
||||||
@@ -340,7 +352,6 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
img.fill(0) # transparent background
|
img.fill(0) # transparent background
|
||||||
painter = QPainter(img)
|
painter = QPainter(img)
|
||||||
self.paint(painter)
|
self.paint(painter)
|
||||||
self._paint_extremes(painter)
|
|
||||||
if lines:
|
if lines:
|
||||||
painter.fillRect(QRectF(0, h, w, footer_h), QColor("#101014"))
|
painter.fillRect(QRectF(0, h, w, footer_h), QColor("#101014"))
|
||||||
painter.setPen(QColor("#CBD5E1"))
|
painter.setPen(QColor("#CBD5E1"))
|
||||||
@@ -357,12 +368,8 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
# ── internal ─────────────────────────────────────────────────────────
|
# ── internal ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _paint_extremes(self, painter: QPainter) -> None:
|
def _paint_extremes(self, painter: QPainter) -> None:
|
||||||
"""Ring the hottest (high color) and coldest (low color) markers.
|
"""Ring the hottest (high color) and coldest (low color) markers."""
|
||||||
|
if not self._show_extremes or not self._values or not self._markers:
|
||||||
Export-only overlay — paint() itself stays untouched so the live
|
|
||||||
view is unchanged.
|
|
||||||
"""
|
|
||||||
if not self._values or not self._markers:
|
|
||||||
return
|
return
|
||||||
s = compute_stats(self._values)
|
s = compute_stats(self._values)
|
||||||
ring_r = self._marker_r + 5
|
ring_r = self._marker_r + 5
|
||||||
@@ -606,6 +613,7 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
painter.setOpacity(1.0)
|
painter.setOpacity(1.0)
|
||||||
|
|
||||||
self._paint_markers(painter)
|
self._paint_markers(painter)
|
||||||
|
self._paint_extremes(painter)
|
||||||
|
|
||||||
def _paint_template(self, painter: QPainter, cx: int, cy: int, r_px: int) -> None:
|
def _paint_template(self, painter: QPainter, cx: int, cy: int, r_px: int) -> None:
|
||||||
ds = self._draw_size()
|
ds = self._draw_size()
|
||||||
|
|||||||
@@ -53,6 +53,29 @@ def test_pause_stop_timer(controller):
|
|||||||
controller.stop()
|
controller.stop()
|
||||||
assert controller.frameIndex == 0
|
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):
|
def test_compare_files_integration(controller):
|
||||||
# Mock the comparisonResult signal
|
# Mock the comparisonResult signal
|
||||||
mock_slot = MagicMock()
|
mock_slot = MagicMock()
|
||||||
|
|||||||
@@ -161,6 +161,36 @@ def test_export_image_highlights_hottest_and_coldest(tmp_path):
|
|||||||
assert "#5b9df5" in found # coldest ring (sensorLow)
|
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():
|
def test_readout_text_full_stats():
|
||||||
"""Export footer readout carries every READOUT stat with sensor indices.
|
"""Export footer readout carries every READOUT stat with sensor indices.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user