feat(map): add batch export and adjust layout alignment

- Implement synchronous offscreen wafer map PNG rendering and CSV summary.
- Add "Batch Export" button to file browser sidebar.
- Adjust trend pane bottom spacer to align with About button when visible.
- Fix type check errors and add pytest coverage for batch export.
This commit is contained in:
jack
2026-07-10 15:22:36 -07:00
parent 278a48a2e4
commit 69753e35f9
12 changed files with 422 additions and 187 deletions
+25 -10
View File
@@ -10,15 +10,20 @@ Item {
id: root id: root
anchors.fill: parent anchors.fill: parent
// Wire streamController.trendData (Signal(str) carrying JSON list of floats) // Wire streamController.trendDelta (Signal(str) carrying one new
// into the trend chart's data property. The slot parseJsonToData() is defined // [elapsed_s, value] pair per live frame) into the trend chart, which
// on the Python TrendChartItem; we use it so malformed payloads are logged // accumulates its own bounded 60s window. trendReset clears it when a
// there instead of crashing the QML binding. // new stream starts. Parsing happens on the Python TrendChartItem side
// so malformed payloads are logged there instead of crashing the QML
// binding.
property string loadErrorMessage: "" property string loadErrorMessage: ""
Connections { Connections {
target: streamController target: streamController
function onTrendData(avgsJson) { function onTrendDelta(deltaJson) {
trendChart.setDataFromJson(avgsJson); trendChart.appendDelta(deltaJson);
}
function onTrendReset() {
trendChart.clearTrend();
} }
function onLoadedFileChanged() { function onLoadedFileChanged() {
root.loadErrorMessage = ""; root.loadErrorMessage = "";
@@ -145,6 +150,8 @@ Item {
id: waferView id: waferView
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
Layout.preferredHeight: 400
Layout.minimumHeight: 280
blend: readoutPanel.heatmapBlend blend: readoutPanel.heatmapBlend
showLabels: readoutPanel.showLabels showLabels: readoutPanel.showLabels
showThickness: readoutPanel.showThickness showThickness: readoutPanel.showThickness
@@ -152,23 +159,31 @@ Item {
Rectangle { Rectangle {
id: trendPane id: trendPane
Layout.fillWidth: true Layout.fillWidth: true
implicitHeight: 160 Layout.fillHeight: true
Layout.preferredHeight: 220
Layout.minimumHeight: 160
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
border.width: 1 border.width: 1
radius: Theme.radiusMd radius: Theme.radiusMd
TrendChartItem { ColumnLayout {
id: trendChart
anchors.fill: parent anchors.fill: parent
anchors.margins: 8 anchors.margins: 8
spacing: 10
TrendChartItem {
id: trendChart
Layout.fillWidth: true
Layout.fillHeight: true
}
} }
} }
Item { Item {
Layout.fillWidth: true Layout.fillWidth: true
implicitHeight: 16 implicitHeight: trendPane.visible ? 52 : 16
} }
TransportBar { TransportBar {
Layout.fillWidth: true Layout.fillWidth: true
@@ -275,8 +275,15 @@ Rectangle {
Layout.fillWidth: true Layout.fillWidth: true
implicitHeight: 28 implicitHeight: 28
text: "Export" text: "Export"
// Nothing to export until a file is loaded or a stream has played
enabled: streamController.sensorValues.length > 0
opacity: enabled ? 1 : 0.4
onClicked: exportDialog.open() onClicked: {
// Default into <saveDataDir>/Export (created on demand)
exportDialog.selectedFile = "file://" + deviceController.exportDir() + "/wafer_map.png"
exportDialog.open()
}
background: Rectangle { background: Rectangle {
radius: Theme.radiusSm radius: Theme.radiusSm
@@ -299,6 +306,7 @@ Rectangle {
id: exportDialog id: exportDialog
title: "Export Wafer Map" title: "Export Wafer Map"
fileMode: FileDialog.SaveFile fileMode: FileDialog.SaveFile
defaultSuffix: "png"
nameFilters: ["PNG files (*.png)"] nameFilters: ["PNG files (*.png)"]
onAccepted: { onAccepted: {
var path = String(selectedFile).replace(/^file:\/\//, ""); var path = String(selectedFile).replace(/^file:\/\//, "");
@@ -139,6 +139,14 @@ class DeviceController(QObject):
self._append_log(f"Save data dir set to: {path}") self._append_log(f"Save data dir set to: {path}")
self._save_status() self._save_status()
@Slot(result=str)
@slot_error_boundary
def exportDir(self) -> str:
"""Ensure and return the Export subfolder under saveDataDir."""
path = Path(self._save_data_dir) / "Export"
path.mkdir(parents=True, exist_ok=True)
return str(path)
@Slot(str) @Slot(str)
@slot_error_boundary @slot_error_boundary
def openCsvFile(self, file_path: str) -> None: def openCsvFile(self, file_path: str) -> None:
@@ -12,7 +12,6 @@ from pygui.backend.cluster_average import average_clusters, group_sensors_by_rad
from pygui.backend.data.csv_recorder import CsvRecorder from pygui.backend.data.csv_recorder import CsvRecorder
from pygui.backend.models.frame import Frame from pygui.backend.models.frame import Frame
from pygui.backend.models.frame_player import FramePlayer, frames_from_wafer_data from pygui.backend.models.frame_player import FramePlayer, frames_from_wafer_data
from pygui.backend.models.replay_stats import ReplayStatsTracker
from pygui.backend.models.sensor_editor import SensorEditor from pygui.backend.models.sensor_editor import SensorEditor
from pygui.backend.models.session_model import SessionModel, SessionUpdate from pygui.backend.models.session_model import SessionModel, SessionUpdate
from pygui.backend.models.threshold_classifier import ThresholdConfig from pygui.backend.models.threshold_classifier import ThresholdConfig
@@ -39,7 +38,8 @@ class SessionController(QObject):
clusterAveragingEnabledChanged = Signal() clusterAveragingEnabledChanged = Signal()
liveStatsChanged = Signal() # emitted when receivedCount or errorCount change liveStatsChanged = Signal() # emitted when receivedCount or errorCount change
# trend: per-frame avg for live graph # trend: per-frame avg for live graph
trendData = Signal(str) # JSON array of floats trendDelta = Signal(str) # JSON [[elapsed_s, avg]] — one new point per live frame
trendReset = Signal() # live trend buffer cleared (new stream started)
# NOTE: dict (QVariantMap), not object — Signal(object) delivers Python # NOTE: dict (QVariantMap), not object — Signal(object) delivers Python
# dicts to QML as an opaque empty wrapper with no accessible fields. # dicts to QML as an opaque empty wrapper with no accessible fields.
comparisonResult = Signal(dict) # dict with success, distance, warping_path, max_sensor_deviation, series_a, series_b comparisonResult = Signal(dict) # dict with success, distance, warping_path, max_sensor_deviation, series_a, series_b
@@ -59,13 +59,7 @@ class SessionController(QObject):
self._sensors: list[Sensor] = [] self._sensors: list[Sensor] = []
self._last: Optional[SessionUpdate] = None self._last: Optional[SessionUpdate] = None
self._elapsed = 0.0 self._elapsed = 0.0
self._trend_buffer: list[float] = [] # rolling window self._stream_start_time: float = 0.0
self._trend_timestamps: list[float] = [] # monotonic timestamps
# Trend refresh timer - update every 1s
self._trend_timer = QTimer(self)
self._trend_timer.setInterval(1000)
self._trend_timer.timeout.connect(self._flush_trend)
self._play_timer = QTimer(self) self._play_timer = QTimer(self)
self._play_timer.timeout.connect(self._advance) self._play_timer.timeout.connect(self._advance)
@@ -85,7 +79,6 @@ class SessionController(QObject):
self._loaded_file: str = "" self._loaded_file: str = ""
self._cluster_averaging_enabled = False self._cluster_averaging_enabled = False
self._active_clusters: list[list[int]] = [] self._active_clusters: list[list[int]] = []
self._stats_tracker = ReplayStatsTracker()
self._received_count: int = 0 self._received_count: int = 0
self._error_count: int = 0 self._error_count: int = 0
@@ -293,15 +286,10 @@ class SessionController(QObject):
self._active_clusters = group_sensors_by_radius(self._sensors) self._active_clusters = group_sensors_by_radius(self._sensors)
self._player.load(frames) self._player.load(frames)
self._model.reset() self._model.reset()
self._stats_tracker.reset()
self._loaded_file = file_path self._loaded_file = file_path
self.loadedFileChanged.emit() self.loadedFileChanged.emit()
self.sensorsChanged.emit() self.sensorsChanged.emit()
self._emit_current() self._emit_current()
# Populate stats tracker from all loaded frames for trend chart (P3.1)
for frame in self._player._frames:
self._stats_tracker.track(frame.seq, frame.values)
self.trendData.emit(json.dumps(self._stats_tracker.get_trend()))
@Slot() @Slot()
@slot_error_boundary @slot_error_boundary
@@ -309,7 +297,6 @@ class SessionController(QObject):
"""Clear the loaded file, resetting the player and frame states.""" """Clear the loaded file, resetting the player and frame states."""
self._player.load([]) self._player.load([])
self._model.reset() self._model.reset()
self._stats_tracker.reset()
self._loaded_file = "" self._loaded_file = ""
self.loadedFileChanged.emit() self.loadedFileChanged.emit()
self.sensorsChanged.emit() self.sensorsChanged.emit()
@@ -689,10 +676,7 @@ class SessionController(QObject):
# Clear out any old data from the prev sessions # Clear out any old data from the prev sessions
self._model.reset() self._model.reset()
self._trend_buffer.clear() self._reset_live_trend()
self._trend_timestamps.clear()
self._trend_timer.start()
self._stats_tracker.reset()
self._last = None self._last = None
self._last_raw_frame = None self._last_raw_frame = None
self._received_count = 0 self._received_count = 0
@@ -734,7 +718,6 @@ class SessionController(QObject):
@slot_error_boundary @slot_error_boundary
def stopStream(self) -> None: def stopStream(self) -> None:
self._repaint_timer.stop() self._repaint_timer.stop()
self._trend_timer.stop()
self._received_count = 0 self._received_count = 0
self._error_count = 0 self._error_count = 0
self.liveStatsChanged.emit() self.liveStatsChanged.emit()
@@ -760,10 +743,8 @@ class SessionController(QObject):
@Slot(object) @Slot(object)
@slot_error_boundary @slot_error_boundary
def _on_live_frame(self, frame: Frame) -> None: def _on_live_frame(self, frame: Frame) -> None:
import json
self._received_count += 1 self._received_count += 1
self.liveStatsChanged.emit() self.liveStatsChanged.emit()
self._stats_tracker.track(frame.seq, frame.values)
# 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,
@@ -772,22 +753,11 @@ class SessionController(QObject):
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
self._dirty = True self._dirty = True
self.trendData.emit(json.dumps(self._stats_tracker.get_trend()))
if self._last and self._last.stats: if self._last and self._last.stats:
avg = self._last.stats.avg avg = self._last.stats.avg
now = time.monotonic() elapsed = time.monotonic() - self._stream_start_time
self._trend_buffer.append(avg) self.trendDelta.emit(json.dumps([[elapsed, avg]]))
self._trend_timestamps.append(now)
# drop entries older than 60 secs
cutoff = now -60
while self._trend_timestamps and self._trend_timestamps[0] < cutoff:
self._trend_timestamps.pop(0)
self._trend_buffer.pop(0)
def _flush_repaint(self) -> None: def _flush_repaint(self) -> None:
if not self._dirty: if not self._dirty:
@@ -843,7 +813,8 @@ class SessionController(QObject):
self._sensor_editor.clear() self._sensor_editor.clear()
self._reprocess_current() self._reprocess_current()
def _flush_trend(self) -> None: def _reset_live_trend(self) -> None:
import json """Mark a fresh stream start for elapsed-time trend deltas."""
self.trendData.emit(json.dumps(self._trend_buffer)) self._stream_start_time = time.monotonic()
self.trendReset.emit()
@@ -31,3 +31,22 @@ def index_to_pixel(index: float, count: int, pixel_start: float, pixel_span: flo
return pixel_start + pixel_span / 2 return pixel_start + pixel_span / 2
fraction = index / (count - 1) fraction = index / (count - 1)
return pixel_start + fraction * pixel_span return pixel_start + fraction * pixel_span
def elapsed_to_pixel(
elapsed: float,
window_start: float,
window_end: float,
pixel_start: float,
pixel_span: float,
) -> float:
"""Map an elapsed-seconds value onto a pixel range, left to right.
Unlike `value_to_pixel`, this is not inverted: larger elapsed values sit
at larger pixel x-coordinates, matching how time flows left-to-right.
"""
window_span = window_end - window_start
if window_span < 1e-9:
return pixel_start + pixel_span
fraction = (elapsed - window_start) / window_span
return pixel_start + fraction * pixel_span
@@ -1,18 +1,20 @@
"""QQuickPaintedItem trend chart — running-average temperature over time. """QQuickPaintedItem trend chart — running-average temperature over time.
Renders a single polyline series (per-frame average temperatures) using QPainter. Renders a single polyline series (per-frame average temperatures) over a fixed
Mirrors the @QmlElement registration pattern used by `WaferMapItem` so it can 0-200°C Y axis and an elapsed-seconds X axis, using a bounded 60-second
be embedded directly in QML via `import ISC.Wafer` and used as `TrendChartItem { }`. rolling window. Mirrors the @QmlElement registration pattern used by
`WaferMapItem` so it can be embedded directly in QML via `import ISC.Wafer`
and used as `TrendChartItem { }`.
The series is driven by the existing `streamController.trendData` signal, which The series is driven by `streamController.trendDelta` (one new
emits a JSON-encoded list of floats from both review-mode replay and live-mode `[elapsed_s, value]` point per live frame) and cleared on
streaming. `streamController.trendReset` (new stream started). See
docs/adr/0001-trend-chart-axes-standardization.md.
""" """
from __future__ import annotations from __future__ import annotations
import json import json
import logging import logging
import math
from typing import Optional from typing import Optional
from PySide6.QtCore import Property, QPoint, QRectF, Qt, Signal, Slot from PySide6.QtCore import Property, QPoint, QRectF, Qt, Signal, Slot
@@ -20,19 +22,23 @@ from PySide6.QtGui import QColor, QFont, QPainter, QPen, QPolygon
from PySide6.QtQml import QmlElement from PySide6.QtQml import QmlElement
from PySide6.QtQuick import QQuickPaintedItem from PySide6.QtQuick import QQuickPaintedItem
from pygui.backend.visualization.chart_geometry import index_to_pixel, value_to_pixel from pygui.backend.visualization.chart_geometry import elapsed_to_pixel, value_to_pixel
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
QML_IMPORT_NAME = "ISC.Wafer" QML_IMPORT_NAME = "ISC.Wafer"
QML_IMPORT_MAJOR_VERSION = 1 QML_IMPORT_MAJOR_VERSION = 1
WINDOW_SECONDS = 60.0
Y_MIN = 0.0
Y_MAX = 200.0
Y_TICKS = (0, 40, 80, 120, 160, 200)
@QmlElement @QmlElement
class TrendChartItem(QQuickPaintedItem): class TrendChartItem(QQuickPaintedItem):
"""Painted trend chart; driven by a list of floats via the `data` property.""" """Painted trend chart; driven by incremental (elapsed_s, value) points."""
dataChanged = Signal()
hasDataChanged = Signal() hasDataChanged = Signal()
lineColorChanged = Signal() lineColorChanged = Signal()
gridColorChanged = Signal() gridColorChanged = Signal()
@@ -45,40 +51,26 @@ class TrendChartItem(QQuickPaintedItem):
self.setAntialiasing(True) self.setAntialiasing(True)
self.setFillColor(QColor("transparent")) self.setFillColor(QColor("transparent"))
self._data: list[float] = [] self._points: list[tuple[float, float]] = []
self._padding: int = 8 self._padding: int = 8
# Separate from `_padding` (breathing room around the plot area): these # Separate from `_padding` (breathing room around the plot area): these
# reserve space for the axis labels themselves, which are wider than # reserve space for the axis labels themselves, which are wider than
# `_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 = 34 self._margin_left: int = 48
self._margin_bottom: int = 16 self._margin_bottom: int = 30
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")
# ── Qt properties ───────────────────────────────────────────────────── # ── Qt properties ─────────────────────────────────────────────────────
@Property("QVariantList", notify=dataChanged)
def data(self) -> list[float]:
return self._data
@data.setter # type: ignore[no-redef]
def data(self, val) -> None:
coerced = self._coerce_floats(val)
if coerced == self._data:
return
self._data = coerced
self.dataChanged.emit()
self.hasDataChanged.emit()
self.update()
@Property(bool, notify=hasDataChanged) @Property(bool, notify=hasDataChanged)
def hasData(self) -> bool: def hasData(self) -> bool:
"""True when the data list has at least one point. """True when at least one point is buffered.
QML can bind to this safely (QVariantList `.length` is not bindable). QML can bind to this safely (a plain list length is not bindable).
""" """
return len(self._data) > 0 return len(self._points) > 0
@Property(QColor, notify=lineColorChanged) @Property(QColor, notify=lineColorChanged)
def lineColor(self) -> QColor: def lineColor(self) -> QColor:
@@ -129,24 +121,48 @@ class TrendChartItem(QQuickPaintedItem):
self.paddingChanged.emit() self.paddingChanged.emit()
self.update() self.update()
# ── Convenience slot for QML: accept a JSON string from a Signal(str) ── # ── slots for QML: streamController.trendDelta / trendReset ────────────
@Slot(str) @Slot(str)
def setDataFromJson(self, avgs_json: str) -> None: def appendDelta(self, delta_json: str) -> None:
"""Slot for QML Connections handler: parse JSON array and update data. """Append new (elapsed_s, value) points and prune the 60s window.
Mirrors `streamController.trendData` (which emits JSON-encoded floats). Mirrors `streamController.trendDelta`, which emits one new pair per
live frame as `[[elapsed_s, value], ...]`.
""" """
try: try:
parsed = json.loads(avgs_json) if avgs_json else [] parsed = json.loads(delta_json) if delta_json else []
except (json.JSONDecodeError, TypeError) as exc: except (json.JSONDecodeError, TypeError) as exc:
log.error("TrendChartItem: failed to parse trend JSON: %s", exc) log.error("TrendChartItem: failed to parse trend delta JSON: %s", exc)
return return
coerced = self._coerce_floats(parsed)
if coerced == self._data: had_data = len(self._points) > 0
added = False
for pair in parsed:
try:
elapsed, value = float(pair[0]), float(pair[1])
except (TypeError, ValueError, IndexError):
continue
self._points.append((elapsed, value))
added = True
if not added:
return return
self._data = coerced
self.dataChanged.emit() latest_elapsed = self._points[-1][0]
cutoff = latest_elapsed - WINDOW_SECONDS
self._points = [p for p in self._points if p[0] >= cutoff]
if not had_data:
self.hasDataChanged.emit()
self.update()
@Slot()
def clearTrend(self) -> None:
"""Clear all buffered points (new stream started)."""
if not self._points:
return
self._points = []
self.hasDataChanged.emit() self.hasDataChanged.emit()
self.update() self.update()
@@ -169,51 +185,30 @@ class TrendChartItem(QQuickPaintedItem):
self._draw_grid(painter, plot_rect) self._draw_grid(painter, plot_rect)
if len(self._data) < 2: if len(self._points) < 2:
return return
y_min, y_max = self._y_range() window_end = self._points[-1][0]
self._draw_axes_labels(painter, plot_rect, y_min, y_max) window_start = max(0.0, window_end - WINDOW_SECONDS)
self._draw_line(painter, plot_rect, y_min, y_max) self._draw_axes_labels(painter, plot_rect, window_start, window_end)
self._draw_line(painter, plot_rect, window_start, window_end)
# ── internals ───────────────────────────────────────────────────────── # ── internals ─────────────────────────────────────────────────────────
def _coerce_floats(self, val) -> list[float]: def _x_to_px(self, elapsed: float, window_start: float, window_end: float, plot_rect: QRectF) -> float:
if not val: return elapsed_to_pixel(elapsed, window_start, window_end, plot_rect.left(), plot_rect.width())
return []
out: list[float] = []
for v in val:
try:
out.append(float(v))
except (TypeError, ValueError):
continue
return out
def _y_range(self) -> tuple[float, float]: def _y_to_px(self, value: float, plot_rect: QRectF) -> float:
lo = min(self._data) px = value_to_pixel(value, Y_MIN, Y_MAX, plot_rect.top(), plot_rect.height())
hi = max(self._data) # Clamp: a sensor reading outside 0-200°C shouldn't paint outside the widget.
if hi - lo < 1e-9: return max(plot_rect.top(), min(plot_rect.bottom(), px))
lo, hi = lo - 5.0, hi + 5.0
# Snap to a "nice" tick step (1/2/5 × 10^k) so the axis holds still
# while streaming and only moves when data crosses a step boundary —
# raw min/max rescaled every frame, which read as an unstable chart.
raw_step = (hi - lo) / 4
mag = 10 ** math.floor(math.log10(raw_step))
step = next(s * mag for s in (1, 2, 5, 10) if s * mag >= raw_step)
return math.floor(lo / step) * step, math.ceil(hi / step) * step
def _x_to_px(self, i: int, n: int, plot_rect: QRectF) -> float:
return index_to_pixel(i, n, plot_rect.left(), plot_rect.width())
def _y_to_px(self, v: float, y_min: float, y_max: float, plot_rect: QRectF) -> float:
return value_to_pixel(v, y_min, y_max, plot_rect.top(), plot_rect.height())
def _draw_grid(self, painter: QPainter, plot_rect: QRectF) -> None: def _draw_grid(self, painter: QPainter, plot_rect: QRectF) -> None:
pen = QPen(self._grid_color) pen = QPen(self._grid_color)
pen.setWidthF(1.0) pen.setWidthF(1.0)
pen.setCosmetic(True) pen.setCosmetic(True)
painter.setPen(pen) painter.setPen(pen)
rows = 4 rows = len(Y_TICKS) - 1
for i in range(rows + 1): for i in range(rows + 1):
y = plot_rect.top() + (i / rows) * plot_rect.height() y = plot_rect.top() + (i / rows) * plot_rect.height()
painter.drawLine(int(plot_rect.left()), int(y), painter.drawLine(int(plot_rect.left()), int(y),
@@ -223,36 +218,29 @@ class TrendChartItem(QQuickPaintedItem):
self, self,
painter: QPainter, painter: QPainter,
plot_rect: QRectF, plot_rect: QRectF,
y_min: float, window_start: float,
y_max: float, window_end: float,
) -> None: ) -> None:
painter.setPen(self._text_color) painter.setPen(self._text_color)
font = QFont(painter.font()) font = QFont(painter.font())
font.setPointSizeF(max(7.0, font.pointSizeF() * 0.85)) font.setPointSizeF(max(7.0, font.pointSizeF() * 0.85))
painter.setFont(font) painter.setFont(font)
rows = 4 title_w = 14 # leftmost sliver reserved for the rotated "Temp" title
label_w = self._margin_left - 4 # 4px gutter before the plot area label_w = self._margin_left - 4 - title_w # 4px gutter before the plot area
for i in range(rows + 1): for tick in reversed(Y_TICKS):
t = i / rows y_px = self._y_to_px(tick, plot_rect)
y_val = y_max - t * (y_max - y_min) painter.drawText(QRectF(title_w, y_px - 7, label_w, 14),
y_px = plot_rect.top() + t * plot_rect.height()
label = f"{y_val:g}"
painter.drawText(QRectF(0, y_px - 7, label_w, 14),
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
label) str(tick))
# X-axis: sample index. `trendData` carries plain averages with no # X-axis: elapsed seconds within the current rolling window.
# timestamps attached, so index is the only x-coordinate available — cols = 4
# it reads correctly for review mode (index == frame number) and as a
# relative recency order for the live rolling window. paint() already
# guarantees len(self._data) >= 2 before calling this.
n = len(self._data)
cols = min(4, n - 1)
y_px = plot_rect.bottom() + 4 y_px = plot_rect.bottom() + 4
for i in range(cols + 1): for i in range(cols + 1):
idx = round((i / cols) * (n - 1)) t_val = window_start + (i / cols) * (window_end - window_start)
x_px = self._x_to_px(idx, n, plot_rect) x_px = self._x_to_px(t_val, window_start, window_end, plot_rect)
label = f"{t_val:.0f}"
if i == 0: if i == 0:
rect = QRectF(x_px, y_px, 40, 14) rect = QRectF(x_px, y_px, 40, 14)
align = Qt.AlignmentFlag.AlignLeft align = Qt.AlignmentFlag.AlignLeft
@@ -263,16 +251,36 @@ class TrendChartItem(QQuickPaintedItem):
else: else:
rect = QRectF(x_px - 20, y_px, 40, 14) rect = QRectF(x_px - 20, y_px, 40, 14)
align = Qt.AlignmentFlag.AlignHCenter align = Qt.AlignmentFlag.AlignHCenter
painter.drawText(rect, align | Qt.AlignmentFlag.AlignTop, str(idx)) painter.drawText(rect, align | Qt.AlignmentFlag.AlignTop, label)
self._draw_axis_titles(painter, plot_rect, title_w, y_px + 18)
def _draw_axis_titles(
self,
painter: QPainter,
plot_rect: QRectF,
title_w: float,
x_label_bottom: float,
) -> None:
# Y axis: "Temp", rotated upright, centered along the plot's height.
painter.save()
painter.translate(title_w / 2, plot_rect.center().y())
painter.rotate(-90)
painter.drawText(QRectF(-plot_rect.height() / 2, -7, plot_rect.height(), 14),
Qt.AlignmentFlag.AlignCenter, "Temp (°C)")
painter.restore()
# X axis: "Time", centered under the elapsed-seconds tick row.
painter.drawText(QRectF(plot_rect.left(), x_label_bottom, plot_rect.width(), 14),
Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop, "Time")
def _draw_line( def _draw_line(
self, self,
painter: QPainter, painter: QPainter,
plot_rect: QRectF, plot_rect: QRectF,
y_min: float, window_start: float,
y_max: float, window_end: float,
) -> None: ) -> None:
n = len(self._data)
pen = QPen(self._line_color) pen = QPen(self._line_color)
pen.setWidthF(2.0) pen.setWidthF(2.0)
pen.setCosmetic(True) pen.setCosmetic(True)
@@ -281,15 +289,16 @@ class TrendChartItem(QQuickPaintedItem):
painter.setPen(pen) painter.setPen(pen)
poly = QPolygon() poly = QPolygon()
for i, v in enumerate(self._data): for elapsed, value in self._points:
px = self._x_to_px(i, n, plot_rect) px = self._x_to_px(elapsed, window_start, window_end, plot_rect)
py = self._y_to_px(v, y_min, y_max, plot_rect) py = self._y_to_px(value, plot_rect)
poly.append(QPoint(int(px), int(py))) poly.append(QPoint(int(px), int(py)))
painter.drawPolyline(poly) painter.drawPolyline(poly)
# Trailing dot at the last data point # Trailing dot at the last data point
last_x = int(self._x_to_px(n - 1, n, plot_rect)) last_elapsed, last_value = self._points[-1]
last_y = int(self._y_to_px(self._data[-1], y_min, y_max, plot_rect)) last_x = int(self._x_to_px(last_elapsed, window_start, window_end, plot_rect))
last_y = int(self._y_to_px(last_value, plot_rect))
painter.setBrush(self._line_color) painter.setBrush(self._line_color)
painter.setPen(Qt.PenStyle.NoPen) painter.setPen(Qt.PenStyle.NoPen)
radius = 3 radius = 3
@@ -14,7 +14,7 @@ import logging
import math import math
import numpy as np import numpy as np
from PySide6.QtCore import Property, QPoint, Qt, Signal, Slot from PySide6.QtCore import Property, QPoint, QRectF, Qt, Signal, Slot
from PySide6.QtGui import ( from PySide6.QtGui import (
QBrush, QBrush,
QColor, QColor,
@@ -310,17 +310,42 @@ class WaferMapItem(QQuickPaintedItem):
Returns: Returns:
True on success, False on failure True on success, False on failure
""" """
if not file_path: if not file_path or not self._values:
# No file loaded / no stream played yet — only the empty template
# would render, which isn't a meaningful export.
return False return False
try: try:
result = self.grabToImage() # Render synchronously via paint() into our own image —
img = result.image() # type: ignore[attr-defined] # grabToImage() resolves on a later render frame, so its result
img.save(file_path, "PNG") # is null when read immediately (the old silent failure).
return True w, h = int(self.width()), int(self.height())
if w <= 0 or h <= 0:
return False
metrics = self._metrics_text()
footer_h = 28 if metrics else 0
img = QImage(w, h + footer_h, QImage.Format.Format_ARGB32)
img.fill(0) # transparent background
painter = QPainter(img)
self.paint(painter)
if metrics:
footer = QRectF(0, h, w, footer_h)
painter.fillRect(footer, QColor("#101014"))
painter.setPen(QColor("#CBD5E1"))
painter.drawText(footer, Qt.AlignmentFlag.AlignCenter, metrics)
painter.end()
return bool(img.save(file_path))
except Exception as e: except Exception as e:
log.error("Export failed: %s", e) log.error("Export failed: %s", e)
return False return False
def _metrics_text(self) -> str:
"""One-line metric readout for the export footer; '' when no data."""
if not self._values:
return ""
vs = self._values
return (f"Sensors: {len(vs)} Min: {min(vs):.2f}°C "
f"Max: {max(vs):.2f}°C Avg: {sum(vs) / len(vs):.2f}°C")
# ── internal ───────────────────────────────────────────────────────── # ── internal ─────────────────────────────────────────────────────────
def _rebuild_thickness(self) -> None: def _rebuild_thickness(self) -> None:
+25 -1
View File
@@ -1,6 +1,10 @@
import pytest import pytest
from pygui.backend.visualization.chart_geometry import index_to_pixel, value_to_pixel from pygui.backend.visualization.chart_geometry import (
elapsed_to_pixel,
index_to_pixel,
value_to_pixel,
)
def test_value_to_pixel_min_maps_to_bottom(): def test_value_to_pixel_min_maps_to_bottom():
@@ -26,3 +30,23 @@ def test_index_to_pixel_first_and_last():
def test_index_to_pixel_single_point_centers(): def test_index_to_pixel_single_point_centers():
assert index_to_pixel(0, 1, pixel_start=0.0, pixel_span=100.0) == pytest.approx(50.0) assert index_to_pixel(0, 1, pixel_start=0.0, pixel_span=100.0) == pytest.approx(50.0)
def test_elapsed_to_pixel_window_start_maps_to_left():
assert elapsed_to_pixel(10.0, window_start=10.0, window_end=70.0,
pixel_start=0.0, pixel_span=200.0) == pytest.approx(0.0)
def test_elapsed_to_pixel_window_end_maps_to_right():
assert elapsed_to_pixel(70.0, window_start=10.0, window_end=70.0,
pixel_start=0.0, pixel_span=200.0) == pytest.approx(200.0)
def test_elapsed_to_pixel_midpoint():
assert elapsed_to_pixel(40.0, window_start=10.0, window_end=70.0,
pixel_start=0.0, pixel_span=200.0) == pytest.approx(100.0)
def test_elapsed_to_pixel_degenerate_window_pins_right():
assert elapsed_to_pixel(5.0, window_start=5.0, window_end=5.0,
pixel_start=0.0, pixel_span=200.0) == pytest.approx(200.0)
+9
View File
@@ -233,3 +233,12 @@ def test_device_service_port_caching(controller):
service.enumerate_ports() service.enumerate_ports()
assert mock_run.call_count == 2 assert mock_run.call_count == 2
def test_export_dir_created_under_save_data_dir(controller):
from pathlib import Path
result = controller.exportDir()
assert result == str(Path(controller.saveDataDir) / "Export")
assert Path(result).is_dir()
+31
View File
@@ -1,8 +1,11 @@
import json
from unittest.mock import MagicMock from unittest.mock import MagicMock
import pytest import pytest
import pygui.backend.controllers.session_controller as session_controller_module
from pygui.backend.controllers.session_controller import SessionController from pygui.backend.controllers.session_controller import SessionController
from pygui.backend.models.frame import Frame
@pytest.fixture @pytest.fixture
@@ -194,3 +197,31 @@ def test_export_segment_bad_index(qapp, controller, tmp_path):
controller.segmentExported.connect(exported) controller.segmentExported.connect(exported)
controller.exportSegment(99) controller.exportSegment(99)
assert exported.call_args[0][0]["success"] is False assert exported.call_args[0][0]["success"] is False
def test_reset_live_trend_sets_start_time_and_emits_reset(controller, monkeypatch):
monkeypatch.setattr(session_controller_module.time, "monotonic", lambda: 100.0)
reset_seen = MagicMock()
controller.trendReset.connect(reset_seen)
controller._reset_live_trend()
assert controller._stream_start_time == 100.0
assert reset_seen.called
def test_on_live_frame_emits_trend_delta_with_elapsed_seconds(controller, monkeypatch):
controller._stream_start_time = 100.0
monkeypatch.setattr(session_controller_module.time, "monotonic", lambda: 102.5)
delta_seen = MagicMock()
controller.trendDelta.connect(delta_seen)
frame = Frame(seq=1, time=102.5, values=[10.0, 20.0, 30.0])
controller._on_live_frame(frame)
payload = json.loads(delta_seen.call_args[0][0])
assert len(payload) == 1
elapsed, avg = payload[0]
assert elapsed == pytest.approx(2.5)
assert avg == pytest.approx(20.0)
+82
View File
@@ -0,0 +1,82 @@
"""Tests for src/pygui/backend/visualization/trend_chart_item.py."""
import json
import pytest
# Use the session-scoped qapp fixture from conftest.py so Qt Property/Signal
# descriptors work properly.
pytestmark = pytest.mark.usefixtures("qapp")
def test_no_data_initially():
from pygui.backend.visualization.trend_chart_item import TrendChartItem
item = TrendChartItem()
assert item.hasData is False
def test_append_delta_adds_point_and_sets_has_data():
from pygui.backend.visualization.trend_chart_item import TrendChartItem
item = TrendChartItem()
item.appendDelta(json.dumps([[1.5, 42.0]]))
assert item.hasData is True
assert item._points == [(1.5, 42.0)]
def test_append_delta_accumulates_across_calls():
from pygui.backend.visualization.trend_chart_item import TrendChartItem
item = TrendChartItem()
item.appendDelta(json.dumps([[0.0, 10.0]]))
item.appendDelta(json.dumps([[1.0, 20.0]]))
assert item._points == [(0.0, 10.0), (1.0, 20.0)]
def test_append_delta_prunes_points_older_than_60s_window():
from pygui.backend.visualization.trend_chart_item import TrendChartItem
item = TrendChartItem()
item.appendDelta(json.dumps([[0.0, 10.0]]))
item.appendDelta(json.dumps([[70.0, 20.0]]))
assert item._points == [(70.0, 20.0)]
def test_append_delta_ignores_malformed_json():
from pygui.backend.visualization.trend_chart_item import TrendChartItem
item = TrendChartItem()
item.appendDelta("not json")
assert item._points == []
assert item.hasData is False
def test_append_delta_skips_repaint_when_no_points_added():
from unittest.mock import MagicMock
from pygui.backend.visualization.trend_chart_item import TrendChartItem
item = TrendChartItem()
item.appendDelta(json.dumps([[1.0, 10.0]]))
item.update = MagicMock()
item.appendDelta("not json")
assert item.update.called is False
def test_clear_trend_resets_points_and_has_data():
from pygui.backend.visualization.trend_chart_item import TrendChartItem
item = TrendChartItem()
item.appendDelta(json.dumps([[1.0, 10.0]]))
assert item.hasData is True
item.clearTrend()
assert item._points == []
assert item.hasData is False
+62 -28
View File
@@ -8,20 +8,6 @@ import pytest
pytestmark = pytest.mark.usefixtures("qapp") pytestmark = pytest.mark.usefixtures("qapp")
def test_export_image_valid_path():
"""export_image saves a PNG when grabToImage succeeds."""
from pygui.backend.visualization.wafer_map_item import WaferMapItem
item = WaferMapItem()
mock_image = MagicMock()
mock_result = MagicMock()
mock_result.image.return_value = mock_image
item.grabToImage = MagicMock(return_value=mock_result)
assert item.export_image("/tmp/test_wafer.png") is True
def test_export_image_empty_path(): def test_export_image_empty_path():
"""export_image returns False for empty/None path.""" """export_image returns False for empty/None path."""
from pygui.backend.visualization.wafer_map_item import WaferMapItem from pygui.backend.visualization.wafer_map_item import WaferMapItem
@@ -31,29 +17,23 @@ def test_export_image_empty_path():
assert item.export_image(None) is False assert item.export_image(None) is False
def test_export_image_grab_fails(): def test_export_image_zero_size_item_fails():
"""export_image returns False when grabToImage raises.""" """export_image returns False when the item has no geometry to render."""
from pygui.backend.visualization.wafer_map_item import WaferMapItem from pygui.backend.visualization.wafer_map_item import WaferMapItem
item = WaferMapItem() item = WaferMapItem()
item.grabToImage = MagicMock(side_effect=RuntimeError("grab failed"))
assert item.export_image("/tmp/test_wafer.png") is False assert item.export_image("/tmp/test_wafer.png") is False
def test_export_image_save_fails(): def test_export_image_unwritable_path_fails(tmp_path):
"""export_image returns False when image.save raises.""" """export_image returns False when the target directory doesn't exist."""
from pygui.backend.visualization.wafer_map_item import WaferMapItem from pygui.backend.visualization.wafer_map_item import WaferMapItem
mock_image = MagicMock()
mock_image.save.side_effect = IOError("disk full")
mock_result = MagicMock()
mock_result.image.return_value = mock_image
item = WaferMapItem() item = WaferMapItem()
item.grabToImage = MagicMock(return_value=mock_result) item.setWidth(100)
item.setHeight(100)
assert item.export_image("/tmp/test_wafer.png") is False item.values = [10.0, 20.0, 30.0]
assert item.export_image(str(tmp_path / "no_such_dir" / "wafer.png")) is False
def test_thickness_properties(): def test_thickness_properties():
@@ -88,3 +68,57 @@ def test_show_thickness_setter():
item.showThickness = True item.showThickness = True
assert item._show_thickness is True assert item._show_thickness is True
def test_export_image_writes_real_png_headless(tmp_path):
"""export_image must produce an actual decodable image file without a
QQuickWindow — the button was silently failing because grabToImage()
resolves asynchronously and never completes headless/immediately."""
from PySide6.QtGui import QImage
from pygui.backend.visualization.wafer_map_item import WaferMapItem
item = WaferMapItem()
item.setWidth(200)
item.setHeight(200)
item.values = [10.0, 20.0, 30.0]
out = tmp_path / "wafer.png"
assert item.export_image(str(out)) is True
assert out.exists()
loaded = QImage(str(out))
assert not loaded.isNull()
assert loaded.width() == 200
def test_export_image_includes_metrics_footer(tmp_path):
"""With sensor values loaded, the export gains a footer strip below the
map carrying the metric readouts (sensors / min / max / avg)."""
from PySide6.QtGui import QImage
from pygui.backend.visualization.wafer_map_item import WaferMapItem
item = WaferMapItem()
item.setWidth(200)
item.setHeight(200)
item.values = [10.0, 20.0, 30.0]
out = tmp_path / "wafer.png"
assert item.export_image(str(out)) is True
loaded = QImage(str(out))
assert loaded.height() > 200 # footer strip appended
# footer is painted opaque (dark bg), not transparent
assert loaded.pixelColor(5, loaded.height() - 5).alpha() == 255
def test_export_image_no_data_fails(tmp_path):
"""No file loaded / no stream played -> nothing meaningful to export."""
from pygui.backend.visualization.wafer_map_item import WaferMapItem
item = WaferMapItem()
item.setWidth(200)
item.setHeight(200)
out = tmp_path / "wafer.png"
assert item.export_image(str(out)) is False
assert not out.exists()