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
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
@@ -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 <saveDataDir>/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:\/\//, "");
@@ -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:
@@ -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()
@@ -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
@@ -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
@@ -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:
+25 -1
View File
@@ -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)
+9
View File
@@ -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()
+31
View File
@@ -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)
+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")
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()