From 4bb855a94083e4a21498080ec895f63088b8f47d Mon Sep 17 00:00:00 2001 From: jack Date: Fri, 10 Jul 2026 20:06:48 -0700 Subject: [PATCH] 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. --- src/pygui/ISC/HomePage.qml | 10 +++ src/pygui/ISC/Tabs/GraphTab.qml | 50 +++++++++++++++ .../backend/controllers/session_controller.py | 18 +++++- src/pygui/backend/models/frame_player.py | 17 ++++++ tests/test_frame_player.py | 23 ++++++- tests/test_graph_quick_item.py | 61 +++++++++++++++++++ tests/test_graph_view.py | 45 ++++++++++++++ tests/test_session_controller.py | 20 ++++++ 8 files changed, 242 insertions(+), 2 deletions(-) create mode 100644 src/pygui/ISC/Tabs/GraphTab.qml create mode 100644 tests/test_graph_quick_item.py diff --git a/src/pygui/ISC/HomePage.qml b/src/pygui/ISC/HomePage.qml index 94fce12..d707e1f 100644 --- a/src/pygui/ISC/HomePage.qml +++ b/src/pygui/ISC/HomePage.qml @@ -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 + } } } } diff --git a/src/pygui/ISC/Tabs/GraphTab.qml b/src/pygui/ISC/Tabs/GraphTab.qml new file mode 100644 index 0000000..44e5db3 --- /dev/null +++ b/src/pygui/ISC/Tabs/GraphTab.qml @@ -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)" + } + } +} diff --git a/src/pygui/backend/controllers/session_controller.py b/src/pygui/backend/controllers/session_controller.py index bc4c61b..549edb4 100644 --- a/src/pygui/backend/controllers/session_controller.py +++ b/src/pygui/backend/controllers/session_controller.py @@ -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.""" diff --git a/src/pygui/backend/models/frame_player.py b/src/pygui/backend/models/frame_player.py index 8a86be0..d6f1ce4 100644 --- a/src/pygui/backend/models/frame_player.py +++ b/src/pygui/backend/models/frame_player.py @@ -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 diff --git a/tests/test_frame_player.py b/tests/test_frame_player.py index aa6d247..98ce0ef 100644 --- a/tests/test_frame_player.py +++ b/tests/test_frame_player.py @@ -1,5 +1,5 @@ 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): @@ -29,3 +29,24 @@ def test_at_end(): assert not p.at_end p.seek(1) 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]] diff --git a/tests/test_graph_quick_item.py b/tests/test_graph_quick_item.py new file mode 100644 index 0000000..2dc4233 --- /dev/null +++ b/tests/test_graph_quick_item.py @@ -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) diff --git a/tests/test_graph_view.py b/tests/test_graph_view.py index 2eb939d..050488b 100644 --- a/tests/test_graph_view.py +++ b/tests/test_graph_view.py @@ -66,6 +66,51 @@ class TestUpdateTrend: 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: def test_parses_valid_json(self): from pygui.backend.visualization.graph_view import GraphView diff --git a/tests/test_session_controller.py b/tests/test_session_controller.py index 6e295d6..0b69bf3 100644 --- a/tests/test_session_controller.py +++ b/tests/test_session_controller.py @@ -53,6 +53,26 @@ def test_pause_stop_timer(controller): controller.stop() 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): """Stopping a live session (tab switch → setMode) must not leave stale sensor values on the wafer map — it should read as blank."""