Added signal graphing
This commit is contained in:
@@ -1,13 +1,26 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg, NavigationToolbar2QT
|
||||
from matplotlib.figure import Figure
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import QFileDialog, QMainWindow, QMessageBox
|
||||
from PySide6.QtWidgets import (
|
||||
QFileDialog,
|
||||
QInputDialog,
|
||||
QMainWindow,
|
||||
QMenu,
|
||||
QMessageBox,
|
||||
QTreeWidgetItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bedit.core.simulation.openmodelica import SimulationMessage, SimulationProgress
|
||||
from bedit.core.simulation.results import (
|
||||
SimulationExecutionResult,
|
||||
SimulationGraph,
|
||||
SimulationResults,
|
||||
SimulationTrace,
|
||||
load_simulation_results,
|
||||
save_simulation_results,
|
||||
)
|
||||
@@ -29,6 +42,8 @@ class SimulationWindow(QMainWindow):
|
||||
self._running = False
|
||||
self._run_generation = 0
|
||||
self._file_path: Path | None = None
|
||||
self._rebuilding_graph_tabs = False
|
||||
self._updating_signal_checks = False
|
||||
self.results = SimulationResults()
|
||||
self._connect_actions()
|
||||
self._populate_view_menu()
|
||||
@@ -52,12 +67,22 @@ class SimulationWindow(QMainWindow):
|
||||
lambda: QMessageBox.aboutQt(self, "About Qt Framework")
|
||||
)
|
||||
self.ui.actionToggleResults.toggled.connect(self.ui.centralWidget.setVisible)
|
||||
self.ui.actionAddGraph.triggered.connect(self.add_graph_tab)
|
||||
self.ui.actionRemoveGraph.triggered.connect(self.remove_current_graph_tab)
|
||||
self.ui.graphTabs.tabBarDoubleClicked.connect(self.rename_graph_tab)
|
||||
self.ui.graphTabs.tabBar().tabMoved.connect(self._move_graph_tab)
|
||||
self.ui.graphTabs.currentChanged.connect(self._current_graph_changed)
|
||||
self.ui.signalsTree.itemChanged.connect(self._signal_check_changed)
|
||||
self.ui.signalsTree.customContextMenuRequested.connect(
|
||||
self.show_signal_context_menu
|
||||
)
|
||||
|
||||
def _populate_view_menu(self) -> None:
|
||||
self.ui.menuPanels.addAction(self.ui.actionToggleResults)
|
||||
for panel in (self.ui.statusDock, self.ui.logDock):
|
||||
for panel in (self.ui.statusDock, self.ui.logDock, self.ui.signalsDock):
|
||||
self.ui.menuPanels.addAction(panel.toggleViewAction())
|
||||
self.ui.menuToolbars.addAction(self.ui.fileToolbar.toggleViewAction())
|
||||
self.ui.menuToolbars.addAction(self.ui.workspaceToolbar.toggleViewAction())
|
||||
|
||||
def begin_run(self, model_name: str = "") -> tuple:
|
||||
"""Reset the viewer and return callbacks bound to this run."""
|
||||
@@ -91,6 +116,7 @@ class SimulationWindow(QMainWindow):
|
||||
self.ui.timeLabel.setText("Time: 0 s")
|
||||
self.ui.messageList.clear()
|
||||
self.clear_result_views()
|
||||
self.load_result_views()
|
||||
self._update_title()
|
||||
|
||||
def clear_result_views(self) -> None:
|
||||
@@ -100,9 +126,10 @@ class SimulationWindow(QMainWindow):
|
||||
``resultsLayout`` and reset here.
|
||||
"""
|
||||
|
||||
self.ui.resultsPlaceholder.setText(
|
||||
"Simulation graphs and result controls can be added here."
|
||||
)
|
||||
self._rebuilding_graph_tabs = True
|
||||
self._clear_graph_tab_widgets()
|
||||
self._rebuilding_graph_tabs = False
|
||||
self.ui.signalsTree.clear()
|
||||
|
||||
def load_result_views(self) -> None:
|
||||
"""Populate custom plots from ``self.results.data`` and traces.
|
||||
@@ -110,14 +137,195 @@ class SimulationWindow(QMainWindow):
|
||||
This is the intended integration point for a future plotting widget.
|
||||
"""
|
||||
|
||||
data_count = len(self.results.data)
|
||||
trace_count = len(self.results.traces)
|
||||
if data_count or trace_count:
|
||||
sample_count = len(next(iter(self.results.data.values()), []))
|
||||
self.ui.resultsPlaceholder.setText(
|
||||
f"{data_count} data column(s), {sample_count} sample(s), and "
|
||||
f"{trace_count} configured trace(s) loaded; add graph rendering here."
|
||||
self._rebuild_graph_tabs()
|
||||
self._rebuild_signal_tree()
|
||||
|
||||
def _rebuild_graph_tabs(self) -> None:
|
||||
self._rebuilding_graph_tabs = True
|
||||
self._clear_graph_tab_widgets()
|
||||
for graph in self.results.graphs:
|
||||
self.ui.graphTabs.addTab(
|
||||
GraphWorkspacePage(graph, self.results), graph.title
|
||||
)
|
||||
self._rebuilding_graph_tabs = False
|
||||
self._update_graph_actions()
|
||||
self._sync_signal_checks()
|
||||
|
||||
def _clear_graph_tab_widgets(self) -> None:
|
||||
while self.ui.graphTabs.count():
|
||||
page = self.ui.graphTabs.widget(0)
|
||||
self.ui.graphTabs.removeTab(0)
|
||||
page.deleteLater()
|
||||
|
||||
def add_graph_tab(self) -> None:
|
||||
used_titles = {graph.title for graph in self.results.graphs}
|
||||
number = 1
|
||||
while f"Graph {number}" in used_titles:
|
||||
number += 1
|
||||
graph = SimulationGraph(title=f"Graph {number}")
|
||||
self.results.graphs.append(graph)
|
||||
index = self.ui.graphTabs.addTab(
|
||||
GraphWorkspacePage(graph, self.results), graph.title
|
||||
)
|
||||
self.ui.graphTabs.setCurrentIndex(index)
|
||||
self._update_graph_actions()
|
||||
|
||||
def remove_current_graph_tab(self) -> None:
|
||||
index = self.ui.graphTabs.currentIndex()
|
||||
if index < 0 or index >= len(self.results.graphs):
|
||||
return
|
||||
self.results.graphs.pop(index)
|
||||
page = self.ui.graphTabs.widget(index)
|
||||
self.ui.graphTabs.removeTab(index)
|
||||
page.deleteLater()
|
||||
self._update_graph_actions()
|
||||
self._sync_signal_checks()
|
||||
|
||||
def rename_graph_tab(self, index: int) -> None:
|
||||
if index < 0 or index >= len(self.results.graphs):
|
||||
return
|
||||
graph = self.results.graphs[index]
|
||||
title, accepted = QInputDialog.getText(
|
||||
self, "Rename Graph", "Title:", text=graph.title
|
||||
)
|
||||
title = title.strip()
|
||||
if accepted and title:
|
||||
graph.title = title
|
||||
self.ui.graphTabs.setTabText(index, title)
|
||||
page = self.ui.graphTabs.widget(index)
|
||||
if isinstance(page, GraphWorkspacePage):
|
||||
page.refresh_chart()
|
||||
|
||||
def _move_graph_tab(self, old_index: int, new_index: int) -> None:
|
||||
if self._rebuilding_graph_tabs or old_index == new_index:
|
||||
return
|
||||
graph = self.results.graphs.pop(old_index)
|
||||
self.results.graphs.insert(new_index, graph)
|
||||
self._sync_signal_checks()
|
||||
|
||||
def _update_graph_actions(self) -> None:
|
||||
self.ui.actionRemoveGraph.setEnabled(bool(self.results.graphs))
|
||||
|
||||
def _rebuild_signal_tree(self) -> None:
|
||||
"""Build a hierarchy while retaining each leaf's complete signal name."""
|
||||
|
||||
tree = self.ui.signalsTree
|
||||
self._updating_signal_checks = True
|
||||
try:
|
||||
tree.clear()
|
||||
items: dict[tuple[str, ...], QTreeWidgetItem] = {}
|
||||
for signal_name in sorted(self.results.data, key=str.casefold):
|
||||
parts = _signal_tree_parts(signal_name)
|
||||
if not parts:
|
||||
continue
|
||||
parent = tree.invisibleRootItem()
|
||||
for depth, part in enumerate(parts, start=1):
|
||||
path = parts[:depth]
|
||||
item = items.get(path)
|
||||
if item is None:
|
||||
item = QTreeWidgetItem(parent, [part])
|
||||
items[path] = item
|
||||
parent = item
|
||||
parent.setData(0, Qt.ItemDataRole.UserRole, signal_name)
|
||||
parent.setToolTip(0, signal_name)
|
||||
parent.setFlags(parent.flags() | Qt.ItemFlag.ItemIsUserCheckable)
|
||||
parent.setCheckState(0, Qt.CheckState.Unchecked)
|
||||
tree.expandToDepth(0)
|
||||
finally:
|
||||
self._updating_signal_checks = False
|
||||
self._sync_signal_checks()
|
||||
|
||||
def _current_graph_changed(self, _index: int) -> None:
|
||||
if not self._rebuilding_graph_tabs:
|
||||
self._sync_signal_checks()
|
||||
|
||||
def _current_graph(self) -> SimulationGraph | None:
|
||||
index = self.ui.graphTabs.currentIndex()
|
||||
if 0 <= index < len(self.results.graphs):
|
||||
return self.results.graphs[index]
|
||||
return None
|
||||
|
||||
def _sync_signal_checks(self) -> None:
|
||||
graph = self._current_graph()
|
||||
enabled = {trace.name for trace in graph.traces} if graph else set()
|
||||
self._updating_signal_checks = True
|
||||
try:
|
||||
root = self.ui.signalsTree.invisibleRootItem()
|
||||
pending = [root.child(index) for index in range(root.childCount())]
|
||||
while pending:
|
||||
item = pending.pop()
|
||||
pending.extend(
|
||||
item.child(index) for index in range(item.childCount())
|
||||
)
|
||||
signal_name = item.data(0, Qt.ItemDataRole.UserRole)
|
||||
if isinstance(signal_name, str):
|
||||
item.setCheckState(
|
||||
0,
|
||||
Qt.CheckState.Checked
|
||||
if signal_name in enabled
|
||||
else Qt.CheckState.Unchecked,
|
||||
)
|
||||
finally:
|
||||
self._updating_signal_checks = False
|
||||
self.ui.signalsTree.setEnabled(graph is not None)
|
||||
|
||||
def _signal_check_changed(self, item: QTreeWidgetItem, _column: int) -> None:
|
||||
if self._updating_signal_checks:
|
||||
return
|
||||
signal_name = item.data(0, Qt.ItemDataRole.UserRole)
|
||||
graph = self._current_graph()
|
||||
if not isinstance(signal_name, str) or graph is None:
|
||||
return
|
||||
enabled = item.checkState(0) == Qt.CheckState.Checked
|
||||
existing = next(
|
||||
(trace for trace in graph.traces if trace.name == signal_name), None
|
||||
)
|
||||
if enabled and existing is None:
|
||||
graph.traces.append(SimulationTrace(name=signal_name))
|
||||
elif not enabled and existing is not None:
|
||||
graph.traces.remove(existing)
|
||||
page = self.ui.graphTabs.currentWidget()
|
||||
if isinstance(page, GraphWorkspacePage):
|
||||
page.refresh_chart()
|
||||
|
||||
def selected_signal_names(self) -> list[str]:
|
||||
"""Return full column names selected for future plotting actions."""
|
||||
|
||||
names = []
|
||||
for item in self.ui.signalsTree.selectedItems():
|
||||
name = item.data(0, Qt.ItemDataRole.UserRole)
|
||||
if isinstance(name, str):
|
||||
names.append(name)
|
||||
return names
|
||||
|
||||
def show_signal_context_menu(self, position) -> None:
|
||||
item = self.ui.signalsTree.itemAt(position)
|
||||
signal_name = (
|
||||
item.data(0, Qt.ItemDataRole.UserRole) if item is not None else None
|
||||
)
|
||||
graph = self._current_graph()
|
||||
if not isinstance(signal_name, str) or graph is None:
|
||||
return
|
||||
menu = QMenu(self)
|
||||
use_as_x_action = menu.addAction("Use as X Axis")
|
||||
use_as_x_action.setCheckable(True)
|
||||
use_as_x_action.setChecked(graph.x_axis == signal_name)
|
||||
selected = menu.exec(
|
||||
self.ui.signalsTree.viewport().mapToGlobal(position)
|
||||
)
|
||||
if selected is use_as_x_action:
|
||||
self.set_x_axis_signal(signal_name)
|
||||
|
||||
def set_x_axis_signal(self, signal_name: str) -> None:
|
||||
"""Set the current graph's persisted horizontal data column."""
|
||||
|
||||
graph = self._current_graph()
|
||||
if graph is None or signal_name not in self.results.data:
|
||||
return
|
||||
graph.x_axis = signal_name
|
||||
page = self.ui.graphTabs.currentWidget()
|
||||
if isinstance(page, GraphWorkspacePage):
|
||||
page.refresh_chart()
|
||||
|
||||
def open_results(self) -> None:
|
||||
file_name, _selected_filter = QFileDialog.getOpenFileName(
|
||||
@@ -261,6 +469,65 @@ class SimulationWindow(QMainWindow):
|
||||
return self._running
|
||||
|
||||
|
||||
class GraphWorkspacePage(QWidget):
|
||||
"""Matplotlib view of one persisted simulation graph definition."""
|
||||
|
||||
def __init__(
|
||||
self, graph: SimulationGraph, results: SimulationResults, parent=None
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.graph_id = graph.id
|
||||
self.graph = graph
|
||||
self.results = results
|
||||
self.plot_layout = QVBoxLayout(self)
|
||||
self.figure = Figure(layout="constrained")
|
||||
self.canvas = FigureCanvasQTAgg(self.figure)
|
||||
self.axes = self.figure.add_subplot(111)
|
||||
self.navigation_toolbar = NavigationToolbar2QT(self.canvas, self)
|
||||
self.plot_layout.addWidget(self.navigation_toolbar)
|
||||
self.plot_layout.addWidget(self.canvas)
|
||||
self.refresh_chart()
|
||||
|
||||
def refresh_chart(self) -> None:
|
||||
self.axes.clear()
|
||||
x_values = self.results.data.get(self.graph.x_axis)
|
||||
for trace in self.graph.traces:
|
||||
y_values = self.results.data.get(trace.name)
|
||||
if y_values is None:
|
||||
continue
|
||||
horizontal = x_values if x_values is not None else range(len(y_values))
|
||||
sample_count = min(len(horizontal), len(y_values))
|
||||
color = trace.properties.get("color")
|
||||
self.axes.plot(
|
||||
list(horizontal)[:sample_count],
|
||||
y_values[:sample_count],
|
||||
label=trace.name,
|
||||
color=color if isinstance(color, str) and color else None,
|
||||
)
|
||||
self.axes.set_title(self.graph.title)
|
||||
self.axes.set_xlabel(self.graph.x_axis if x_values is not None else "sample")
|
||||
self.axes.grid(True, alpha=0.25)
|
||||
if self.axes.lines:
|
||||
self.axes.legend()
|
||||
self.canvas.draw_idle()
|
||||
|
||||
|
||||
def _safe_file_stem(model_name: str) -> str:
|
||||
stem = re.sub(r"[^A-Za-z0-9_.-]+", "_", model_name).strip("._")
|
||||
return stem or "simulation"
|
||||
|
||||
|
||||
def _signal_tree_parts(signal_name: str) -> tuple[str, ...]:
|
||||
"""Split dots and array indices into display hierarchy segments."""
|
||||
|
||||
parts: list[str] = []
|
||||
for segment in signal_name.split("."):
|
||||
if not segment:
|
||||
continue
|
||||
match = re.fullmatch(r"([^\[]+)((?:\[[^\]]+\])+)", segment)
|
||||
if match is None:
|
||||
parts.append(segment)
|
||||
continue
|
||||
parts.append(match.group(1))
|
||||
parts.extend(re.findall(r"\[([^\]]+)\]", match.group(2)))
|
||||
return tuple(parts)
|
||||
|
||||
Reference in New Issue
Block a user