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