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:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user