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