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:
@@ -238,6 +238,12 @@ Rectangle {
|
|||||||
expandW: 58
|
expandW: 58
|
||||||
desc: "Visualize spatial sensor readings on a 2D thin-plate spline RBF interpolated heatmap."
|
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 {
|
RowLayout {
|
||||||
@@ -648,6 +654,10 @@ Rectangle {
|
|||||||
// so the tab never instantiates while locked. See plan §3.1.
|
// so the tab never instantiates while locked. See plan §3.1.
|
||||||
active: StackLayout.index === root.selectedTabIndex
|
active: StackLayout.index === root.selectedTabIndex
|
||||||
}
|
}
|
||||||
|
Loader {
|
||||||
|
source: "Tabs/GraphTab.qml"
|
||||||
|
active: StackLayout.index === root.selectedTabIndex
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.cluster_average import average_clusters, group_sensors_by_radius
|
||||||
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,
|
||||||
|
all_sensor_series,
|
||||||
|
frames_from_wafer_data,
|
||||||
|
)
|
||||||
from pygui.backend.models.frame_stats import compute_edge_center
|
from pygui.backend.models.frame_stats import compute_edge_center
|
||||||
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
|
||||||
@@ -207,6 +211,18 @@ class SessionController(QObject):
|
|||||||
@Property(str, notify=loadedFileChanged)
|
@Property(str, notify=loadedFileChanged)
|
||||||
def loadedFile(self) -> str: return self._loaded_file
|
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]
|
@Property("QVariantList", notify=frameUpdated) # type: ignore[arg-type]
|
||||||
def overriddenSensors(self) -> list[int]:
|
def overriddenSensors(self) -> list[int]:
|
||||||
"""Indices of sensors that currently have a replacement or offset."""
|
"""Indices of sensors that currently have a replacement or offset."""
|
||||||
|
|||||||
@@ -8,6 +8,19 @@ def frames_from_wafer_data(data: ZWaferData | None, records) -> list[Frame]:
|
|||||||
"""Build Frames from parsed DataRecords (records = list[DataRecord].)"""
|
"""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)]
|
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:
|
class FramePlayer:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._frames: list[Frame] =[]
|
self._frames: list[Frame] =[]
|
||||||
@@ -22,6 +35,10 @@ class FramePlayer:
|
|||||||
def total(self) -> int:
|
def total(self) -> int:
|
||||||
return len(self._frames)
|
return len(self._frames)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def frames(self) -> tuple[Frame, ...]:
|
||||||
|
return tuple(self._frames)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def index(self) -> int:
|
def index(self) -> int:
|
||||||
return self._i
|
return self._i
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from pygui.backend.models.frame import Frame
|
from pygui.backend.models.frame import Frame
|
||||||
from pygui.backend.models.frame_player import FramePlayer
|
from pygui.backend.models.frame_player import FramePlayer, all_sensor_series
|
||||||
|
|
||||||
|
|
||||||
def frames(n):
|
def frames(n):
|
||||||
@@ -29,3 +29,24 @@ def test_at_end():
|
|||||||
assert not p.at_end
|
assert not p.at_end
|
||||||
p.seek(1)
|
p.seek(1)
|
||||||
assert p.at_end
|
assert p.at_end
|
||||||
|
|
||||||
|
def test_frames_property_exposes_loaded_frames():
|
||||||
|
p = FramePlayer(); p.load(frames(3))
|
||||||
|
assert p.frames == tuple(frames(3))
|
||||||
|
|
||||||
|
|
||||||
|
# ---- all_sensor_series (Graph tab whole-run assembly) ----
|
||||||
|
|
||||||
|
def test_all_sensor_series_empty_frames():
|
||||||
|
assert all_sensor_series([]) == ([], [])
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_sensor_series_names_and_columns():
|
||||||
|
fs = [
|
||||||
|
Frame(seq=0, time=0.0, values=[150.0, 148.0]),
|
||||||
|
Frame(seq=1, time=1.0, values=[151.0, 147.5]),
|
||||||
|
Frame(seq=2, time=2.0, values=[152.0, 147.0]),
|
||||||
|
]
|
||||||
|
names, series = all_sensor_series(fs)
|
||||||
|
assert names == ["Sensor 1", "Sensor 2"]
|
||||||
|
assert series == [[150.0, 151.0, 152.0], [148.0, 147.5, 147.0]]
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""Tests for src/pygui/backend/visualization/graph_quick_item.py."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.usefixtures("qapp")
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_data_initially():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||||
|
|
||||||
|
item = GraphQuickItem()
|
||||||
|
assert item.seriesData == []
|
||||||
|
assert item.sensorNames == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_series_data_setter_stores_series():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||||
|
|
||||||
|
item = GraphQuickItem()
|
||||||
|
item.seriesData = [[150.0, 151.0], [148.0, 147.5]]
|
||||||
|
|
||||||
|
assert item.seriesData == [[150.0, 151.0], [148.0, 147.5]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_series_data_auto_ranges_y_with_padding():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||||
|
|
||||||
|
item = GraphQuickItem()
|
||||||
|
item.seriesData = [[150.0, 100.0], [200.0, 120.0]]
|
||||||
|
|
||||||
|
# min=100, max=200, range=100, 10% pad => [90, 210]
|
||||||
|
assert item._min_y == pytest.approx(90.0)
|
||||||
|
assert item._max_y == pytest.approx(210.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_series_data_empty_keeps_default_range():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||||
|
|
||||||
|
item = GraphQuickItem()
|
||||||
|
item.seriesData = []
|
||||||
|
|
||||||
|
assert item._min_y == 0.0
|
||||||
|
assert item._max_y == 150.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_sensor_names_setter_stores_names():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||||
|
|
||||||
|
item = GraphQuickItem()
|
||||||
|
item.sensorNames = ["Sensor 1", "Sensor 2"]
|
||||||
|
|
||||||
|
assert item.sensorNames == ["Sensor 1", "Sensor 2"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_numeric_values_are_skipped_not_crashing():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||||
|
|
||||||
|
item = GraphQuickItem()
|
||||||
|
item.seriesData = [[150.0, "bad", 151.0]]
|
||||||
|
|
||||||
|
assert item._min_y == pytest.approx(149.9)
|
||||||
|
assert item._max_y == pytest.approx(151.1)
|
||||||
@@ -66,6 +66,51 @@ class TestUpdateTrend:
|
|||||||
gv.updateTrend("[1, 2, 3]")
|
gv.updateTrend("[1, 2, 3]")
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpdateChart:
|
||||||
|
def test_plots_one_series_per_sensor(self):
|
||||||
|
from pygui.backend.visualization.graph_view import GraphView
|
||||||
|
|
||||||
|
gv = GraphView()
|
||||||
|
gv._plot_widget = _make_plot_widget()
|
||||||
|
|
||||||
|
series = [[150.0, 151.0, 152.0], [148.5, 148.4, 148.6]]
|
||||||
|
gv.updateChart("Sensor 1,Sensor 2", json.dumps(series))
|
||||||
|
|
||||||
|
assert gv._plot_widget.clear.called
|
||||||
|
assert gv._plot_widget.plot.call_count == 2
|
||||||
|
assert gv._sensor_names == ["Sensor 1", "Sensor 2"]
|
||||||
|
|
||||||
|
def test_rejects_invalid_json(self):
|
||||||
|
from pygui.backend.visualization.graph_view import GraphView
|
||||||
|
|
||||||
|
gv = GraphView()
|
||||||
|
gv._plot_widget = _make_plot_widget()
|
||||||
|
|
||||||
|
gv.updateChart("Sensor 1", "not json")
|
||||||
|
|
||||||
|
assert not gv._plot_widget.plot.called
|
||||||
|
|
||||||
|
def test_empty_series_clears_without_plotting(self):
|
||||||
|
from pygui.backend.visualization.graph_view import GraphView
|
||||||
|
|
||||||
|
gv = GraphView()
|
||||||
|
gv._plot_widget = _make_plot_widget()
|
||||||
|
|
||||||
|
gv.updateChart("", "[]")
|
||||||
|
|
||||||
|
assert gv._plot_widget.clear.called
|
||||||
|
assert not gv._plot_widget.plot.called
|
||||||
|
|
||||||
|
def test_no_plot_widget(self):
|
||||||
|
from pygui.backend.visualization.graph_view import GraphView
|
||||||
|
|
||||||
|
gv = GraphView()
|
||||||
|
assert gv._plot_widget is None
|
||||||
|
|
||||||
|
# Should not crash
|
||||||
|
gv.updateChart("Sensor 1", json.dumps([[1.0, 2.0]]))
|
||||||
|
|
||||||
|
|
||||||
class TestUpdateComparison:
|
class TestUpdateComparison:
|
||||||
def test_parses_valid_json(self):
|
def test_parses_valid_json(self):
|
||||||
from pygui.backend.visualization.graph_view import GraphView
|
from pygui.backend.visualization.graph_view import GraphView
|
||||||
|
|||||||
@@ -53,6 +53,26 @@ def test_pause_stop_timer(controller):
|
|||||||
controller.stop()
|
controller.stop()
|
||||||
assert controller.frameIndex == 0
|
assert controller.frameIndex == 0
|
||||||
|
|
||||||
|
def test_graph_data_empty_before_load(controller):
|
||||||
|
assert controller.graphSensorNames == ""
|
||||||
|
assert controller.graphSeriesJson == "[]"
|
||||||
|
|
||||||
|
def test_graph_data_after_load(controller):
|
||||||
|
controller.loadFile("tests/fixtures/sample_stream.csv")
|
||||||
|
assert controller.graphSensorNames == "Sensor 1,Sensor 2,Sensor 3"
|
||||||
|
series = json.loads(controller.graphSeriesJson)
|
||||||
|
assert series == [
|
||||||
|
[149.0, 149.2, 149.1],
|
||||||
|
[148.5, 148.4, 148.6],
|
||||||
|
[150.6, 150.7, 150.5],
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_graph_data_cleared_after_unload(controller):
|
||||||
|
controller.loadFile("tests/fixtures/sample_stream.csv")
|
||||||
|
controller.unloadFile()
|
||||||
|
assert controller.graphSensorNames == ""
|
||||||
|
assert controller.graphSeriesJson == "[]"
|
||||||
|
|
||||||
def test_leaving_live_mode_blanks_wafer_map(controller):
|
def test_leaving_live_mode_blanks_wafer_map(controller):
|
||||||
"""Stopping a live session (tab switch → setMode) must not leave stale
|
"""Stopping a live session (tab switch → setMode) must not leave stale
|
||||||
sensor values on the wafer map — it should read as blank."""
|
sensor values on the wafer map — it should read as blank."""
|
||||||
|
|||||||
Reference in New Issue
Block a user