feat(graph): add graph tab for whole-run data

Provides a static line chart plotting multi-sensor temperature values
over time when a session file is loaded.
This commit is contained in:
jack
2026-07-10 20:06:48 -07:00
parent 94f917b116
commit 4bb855a940
8 changed files with 242 additions and 2 deletions
+10
View File
@@ -238,6 +238,12 @@ Rectangle {
expandW: 58
desc: "Visualize spatial sensor readings on a 2D thin-plate spline RBF interpolated heatmap."
}
ListElement {
label: "GRAPH"
icon: "Tabs/icons/bar-chart.svg"
expandW: 70
desc: "Whole-run multi-sensor temperature chart for the currently loaded file."
}
}
RowLayout {
@@ -648,6 +654,10 @@ Rectangle {
// so the tab never instantiates while locked. See plan §3.1.
active: StackLayout.index === root.selectedTabIndex
}
Loader {
source: "Tabs/GraphTab.qml"
active: StackLayout.index === root.selectedTabIndex
}
}
}
}
+50
View File
@@ -0,0 +1,50 @@
import QtQuick
import QtQuick.Layouts
import ISC
import ISC.Wafer
// ===== Graph Tab =====
// Whole-run multi-sensor line chart (C# parity: tabGraph/chartData). Static
// snapshot recomputed whenever a file is loaded — not live-updating during
// a stream. See specs/plans/2026-07-10-graph-tab.md and
// docs/adr/0003-pyqtgraph-for-multi-sensor-charts.md.
Item {
id: root
anchors.fill: parent
function refresh() {
if (!streamController.loadedFile) {
chart.seriesData = []
chart.sensorNames = []
return
}
chart.sensorNames = streamController.graphSensorNames
? streamController.graphSensorNames.split(",") : []
chart.seriesData = JSON.parse(streamController.graphSeriesJson || "[]")
}
Connections {
target: streamController
function onLoadedFileChanged() { root.refresh() }
}
Component.onCompleted: root.refresh()
Rectangle {
anchors.fill: parent
anchors.margins: Theme.panelPadding
color: Theme.cardBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusMd
GraphQuickItem {
id: chart
anchors.fill: parent
anchors.margins: 8
title: "Sensor Temperature Over Time"
xLabel: "Measurement Interval"
yLabel: "Temperature (°C)"
}
}
}
@@ -11,7 +11,11 @@ from PySide6.QtCore import Property, QObject, Qt, QTimer, Signal, Slot
from pygui.backend.cluster_average import average_clusters, group_sensors_by_radius
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.frame_player import (
FramePlayer,
all_sensor_series,
frames_from_wafer_data,
)
from pygui.backend.models.frame_stats import compute_edge_center
from pygui.backend.models.sensor_editor import SensorEditor
from pygui.backend.models.session_model import SessionModel, SessionUpdate
@@ -207,6 +211,18 @@ class SessionController(QObject):
@Property(str, notify=loadedFileChanged)
def loadedFile(self) -> str: return self._loaded_file
@Property(str, notify=loadedFileChanged)
def graphSensorNames(self) -> str:
"""Comma-joined sensor names for the whole-run Graph tab."""
names, _ = all_sensor_series(list(self._player.frames))
return ",".join(names)
@Property(str, notify=loadedFileChanged)
def graphSeriesJson(self) -> str:
"""JSON per-sensor value series (one list per sensor) for the Graph tab."""
_, series = all_sensor_series(list(self._player.frames))
return json.dumps(series)
@Property("QVariantList", notify=frameUpdated) # type: ignore[arg-type]
def overriddenSensors(self) -> list[int]:
"""Indices of sensors that currently have a replacement or offset."""
+17
View File
@@ -8,6 +8,19 @@ def frames_from_wafer_data(data: ZWaferData | None, records) -> list[Frame]:
"""Build Frames from parsed DataRecords (records = list[DataRecord].)"""
return [Frame(seq=i, time=r.time, values=list(r.values)) for i, r in enumerate(records)]
def all_sensor_series(frames: list[Frame]) -> tuple[list[str], list[list[float]]]:
"""Per-sensor value series across every frame, for the whole-run Graph tab.
Sensor names are 1-based ("Sensor 1", ...), matching the C# original.
"""
if not frames:
return [], []
n_sensors = len(frames[0].values)
names = [f"Sensor {i + 1}" for i in range(n_sensors)]
series = [[frame.values[i] for frame in frames] for i in range(n_sensors)]
return names, series
class FramePlayer:
def __init__(self) -> None:
self._frames: list[Frame] =[]
@@ -22,6 +35,10 @@ class FramePlayer:
def total(self) -> int:
return len(self._frames)
@property
def frames(self) -> tuple[Frame, ...]:
return tuple(self._frames)
@property
def index(self) -> int:
return self._i