diff --git a/src/bedit_gui/commands/simulation_plot_commands.py b/src/bedit_gui/commands/simulation_plot_commands.py new file mode 100644 index 0000000..333cc0e --- /dev/null +++ b/src/bedit_gui/commands/simulation_plot_commands.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from collections.abc import Callable + +from PySide6.QtGui import QUndoCommand + +from bedit_gui.simulation_models import SimulationPlotTab, SimulationRoot + +ChangedCallback = Callable[[int], None] + + +class AddSimulationPlotTabCommand(QUndoCommand): + def __init__(self, root: SimulationRoot, tab: SimulationPlotTab, index: int, changed: ChangedCallback) -> None: + super().__init__("Add plot") + self.root = root + self.tab = tab + self.index = index + self.changed = changed + + def redo(self) -> None: + self.root.plot_tabs.insert(self.index, self.tab) + self.changed(self.index) + + def undo(self) -> None: + self.root.plot_tabs.pop(self.index) + self.changed(max(0, self.index - 1)) + + +class RemoveSimulationPlotTabCommand(QUndoCommand): + def __init__(self, root: SimulationRoot, index: int, changed: ChangedCallback) -> None: + super().__init__("Remove plot") + self.root = root + self.index = index + self.tab = root.plot_tabs[index] + self.changed = changed + + def redo(self) -> None: + self.root.plot_tabs.pop(self.index) + self.changed(min(self.index, len(self.root.plot_tabs) - 1)) + + def undo(self) -> None: + self.root.plot_tabs.insert(self.index, self.tab) + self.changed(self.index) + + +class RenameSimulationPlotTabCommand(QUndoCommand): + def __init__(self, root: SimulationRoot, index: int, name: str, changed: ChangedCallback) -> None: + super().__init__("Rename plot") + self.root = root + self.index = index + self.old_name = root.plot_tabs[index].name + self.name = name + self.changed = changed + + def redo(self) -> None: + self.root.plot_tabs[self.index].name = self.name + self.changed(self.index) + + def undo(self) -> None: + self.root.plot_tabs[self.index].name = self.old_name + self.changed(self.index) + + +class ChangeSimulationPlotSignalsCommand(QUndoCommand): + def __init__(self, root: SimulationRoot, index: int, signals: list[str], changed: ChangedCallback) -> None: + super().__init__("Change plotted signals") + self.root = root + self.index = index + self.old_signals = list(root.plot_tabs[index].signals) + self.signals = list(signals) + self.changed = changed + + def redo(self) -> None: + self.root.plot_tabs[self.index].signals = list(self.signals) + self.changed(self.index) + + def undo(self) -> None: + self.root.plot_tabs[self.index].signals = list(self.old_signals) + self.changed(self.index) diff --git a/src/bedit_gui/controllers/simulation_plot_controller.py b/src/bedit_gui/controllers/simulation_plot_controller.py new file mode 100644 index 0000000..1346861 --- /dev/null +++ b/src/bedit_gui/controllers/simulation_plot_controller.py @@ -0,0 +1,229 @@ +from __future__ import annotations + +from PySide6.QtCore import QObject, QPoint, Qt +from PySide6.QtGui import QUndoStack +from PySide6.QtWidgets import QInputDialog, QMenu, QTreeWidgetItem, QWidget + +from bedit_gui.commands.simulation_plot_commands import AddSimulationPlotTabCommand, ChangeSimulationPlotSignalsCommand, RemoveSimulationPlotTabCommand, RenameSimulationPlotTabCommand +from bedit_gui.controllers.simulation_file_controller import SimulationFileController +from bedit_gui.services.application_logging import get_logger +from bedit_gui.simulation_models import SimulationPlotTab +from bedit_gui.views.simulation_plot_widget import SimulationPlotWidget +from bedit_gui.views.simulation_window import SimulationWindow + +logger = get_logger(__name__) + + +class SimulationPlotController(QObject): + def __init__(self, window: SimulationWindow, files: SimulationFileController) -> None: + super().__init__(window) + self.window = window + self.files = files + self.undo_stack = QUndoStack(self) + self._updating = False + self._active_tab = -1 + + tabs = window.ui.resultsTabWidget + tree = window.ui.simulationTree + tabs.currentChanged.connect(self._tab_changed) + tabs.tabBar().setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + tabs.tabBar().customContextMenuRequested.connect(self._show_tab_menu) + tree.itemChanged.connect(self._signal_changed) + files.runtime_changed.connect(self.load_root) + + window.ui.actionUndo.triggered.connect(self.undo) + window.ui.actionRedo.triggered.connect(self.redo) + self.undo_stack.canUndoChanged.connect(window.ui.actionUndo.setEnabled) + self.undo_stack.canRedoChanged.connect(window.ui.actionRedo.setEnabled) + self.undo_stack.undoTextChanged.connect(self._set_undo_text) + self.undo_stack.redoTextChanged.connect(self._set_redo_text) + window.ui.actionUndo.setEnabled(False) + window.ui.actionRedo.setEnabled(False) + self.load_root() + + def load_root(self) -> None: + self.undo_stack.clear() + self._active_tab = 0 if self.files.root is not None and self.files.root.plot_tabs else -1 + self._rebuild_tabs(self._active_tab) + self._rebuild_tree() + + def refresh_results(self) -> None: + self._rebuild_tree() + self._refresh_plots() + + def add_tab(self) -> None: + root = self.files.root + if root is None: + return + used_names = {tab.name for tab in root.plot_tabs} + number = 1 + while f"Plot {number}" in used_names: + number += 1 + index = len(root.plot_tabs) + self.undo_stack.push(AddSimulationPlotTabCommand(root, SimulationPlotTab(f"Plot {number}"), index, self._tabs_changed)) + + def rename_tab(self, index: int, name: str) -> None: + root = self.files.root + name = name.strip() + if root is None or not 0 <= index < len(root.plot_tabs) or not name or root.plot_tabs[index].name == name: + return + self.undo_stack.push(RenameSimulationPlotTabCommand(root, index, name, self._tabs_changed)) + + def remove_tab(self, index: int) -> None: + root = self.files.root + if root is None or not 0 <= index < len(root.plot_tabs): + return + self.undo_stack.push(RemoveSimulationPlotTabCommand(root, index, self._tabs_changed)) + + def undo(self) -> None: + command = self.undo_stack.undoText() + self.undo_stack.undo() + logger.info("Undo: %s", command) + + def redo(self) -> None: + command = self.undo_stack.redoText() + self.undo_stack.redo() + logger.info("Redo: %s", command) + + def _tabs_changed(self, selected_index: int) -> None: + root = self.files.root + if root is None or not root.plot_tabs: + self._active_tab = -1 + else: + self._active_tab = max(0, min(selected_index, len(root.plot_tabs) - 1)) + self._rebuild_tabs(self._active_tab) + self._rebuild_tree() + + def _selection_changed(self, index: int) -> None: + self._active_tab = index + self._sync_tree_checks() + self._refresh_plot(index) + + def _rebuild_tabs(self, selected_index: int) -> None: + tabs = self.window.ui.resultsTabWidget + root = self.files.root + self._updating = True + try: + while tabs.count(): + tabs.removeTab(0) + if root is not None: + for tab in root.plot_tabs: + tabs.addTab(SimulationPlotWidget(tabs), tab.name) + tabs.addTab(QWidget(tabs), "+") + tabs.setCurrentIndex(selected_index if selected_index >= 0 else tabs.count() - 1) + finally: + self._updating = False + self._refresh_plots() + + def _rebuild_tree(self) -> None: + tree = self.window.ui.simulationTree + signals = self._available_signals() + self._updating = True + try: + tree.clear() + nodes: dict[tuple[str, ...], QTreeWidgetItem] = {} + for signal in signals: + parent = tree.invisibleRootItem() + parts = signal.split(".") + for depth, part in enumerate(parts, start=1): + path = tuple(parts[:depth]) + item = nodes.get(path) + if item is None: + item = QTreeWidgetItem(parent, [part]) + nodes[path] = item + parent = item + item.setData(0, Qt.ItemDataRole.UserRole, signal) + item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable) + self._sync_tree_checks() + finally: + self._updating = False + + def _sync_tree_checks(self) -> None: + tree = self.window.ui.simulationTree + root = self.files.root + selected = set(root.plot_tabs[self._active_tab].signals) if root is not None and 0 <= self._active_tab < len(root.plot_tabs) else set() + previous = self._updating + self._updating = True + try: + iterator = tree.invisibleRootItem() + pending = [iterator.child(index) for index in range(iterator.childCount())] + while pending: + item = pending.pop() + signal = item.data(0, Qt.ItemDataRole.UserRole) + if signal is not None: + item.setCheckState(0, Qt.CheckState.Checked if signal in selected else Qt.CheckState.Unchecked) + pending.extend(item.child(index) for index in range(item.childCount())) + finally: + self._updating = previous + + def _signal_changed(self, item: QTreeWidgetItem, _column: int) -> None: + if self._updating: + return + root = self.files.root + signal = item.data(0, Qt.ItemDataRole.UserRole) + if root is None or signal is None or not 0 <= self._active_tab < len(root.plot_tabs): + return + selected = set(root.plot_tabs[self._active_tab].signals) + if item.checkState(0) == Qt.CheckState.Checked: + selected.add(signal) + else: + selected.discard(signal) + ordered = [available for available in self._available_signals() if available in selected] + if ordered != root.plot_tabs[self._active_tab].signals: + self.undo_stack.push(ChangeSimulationPlotSignalsCommand(root, self._active_tab, ordered, self._selection_changed)) + + def _tab_changed(self, index: int) -> None: + if self._updating: + return + root = self.files.root + real_tab_count = len(root.plot_tabs) if root is not None else 0 + if index == real_tab_count: + self.add_tab() + return + if 0 <= index < real_tab_count: + self._active_tab = index + self._sync_tree_checks() + + def _show_tab_menu(self, position: QPoint) -> None: + root = self.files.root + tab_bar = self.window.ui.resultsTabWidget.tabBar() + index = tab_bar.tabAt(position) + if root is None or not 0 <= index < len(root.plot_tabs): + return + menu = QMenu(tab_bar) + rename_action = menu.addAction("Rename") + remove_action = menu.addAction("Remove") + selected = menu.exec(tab_bar.mapToGlobal(position)) + if selected is rename_action: + name, accepted = QInputDialog.getText(self.window, "Rename Plot", "Name:", text=root.plot_tabs[index].name) + if accepted: + self.rename_tab(index, name) + elif selected is remove_action: + self.remove_tab(index) + + def _available_signals(self) -> list[str]: + root = self.files.root + if root is None: + return [] + return sorted({signal for result in root.results for signal in result.data}) + + def _refresh_plots(self) -> None: + root = self.files.root + if root is None: + return + for index in range(len(root.plot_tabs)): + self._refresh_plot(index) + + def _refresh_plot(self, index: int) -> None: + root = self.files.root + if root is None or not 0 <= index < len(root.plot_tabs): + return + widget = self.window.ui.resultsTabWidget.widget(index) + if isinstance(widget, SimulationPlotWidget): + widget.set_plot(root.results, root.plot_tabs[index].signals) + + def _set_undo_text(self, command: str) -> None: + self.window.ui.actionUndo.setText(f"Undo {command}" if command else "Undo") + + def _set_redo_text(self, command: str) -> None: + self.window.ui.actionRedo.setText(f"Redo {command}" if command else "Redo") diff --git a/src/bedit_gui/controllers/simulation_run_controller.py b/src/bedit_gui/controllers/simulation_run_controller.py index 81453dd..ddc949a 100644 --- a/src/bedit_gui/controllers/simulation_run_controller.py +++ b/src/bedit_gui/controllers/simulation_run_controller.py @@ -24,6 +24,7 @@ class SimulationRunController(QObject): run_completed = Signal(object) run_cancelled = Signal() run_failed = Signal(object) + simulation_state_changed = Signal() def __init__(self, window: SimulationWindow, files: SimulationFileController, session_factory: SessionFactory = _create_session) -> None: super().__init__(window) @@ -153,3 +154,4 @@ class SimulationRunController(QObject): return self.files.root.current_end_time = self.session.current_end_time self.files.root.results = list(self.session.results) + self.simulation_state_changed.emit() diff --git a/src/bedit_gui/simulation_application.py b/src/bedit_gui/simulation_application.py index ed21b08..527f80f 100644 --- a/src/bedit_gui/simulation_application.py +++ b/src/bedit_gui/simulation_application.py @@ -7,6 +7,7 @@ from PySide6.QtWidgets import QApplication, QMessageBox from bedit_gui.controllers.simulation_file_controller import SimulationFileController from bedit_gui.controllers.log_controller import LogController +from bedit_gui.controllers.simulation_plot_controller import SimulationPlotController from bedit_gui.controllers.simulation_run_controller import SimulationRunController from bedit_gui.simulation_models import CompiledModel from bedit_gui.services.application_settings import SimulationApplicationSettings @@ -43,7 +44,9 @@ def main(arguments: list[str] | None = None) -> int: settings = SimulationApplicationSettings() LogController(window, settings.log_level) - SimulationRunController(window, controller) + run_controller = SimulationRunController(window, controller) + plot_controller = SimulationPlotController(window, controller) + run_controller.simulation_state_changed.connect(plot_controller.refresh_results) window.showMaximized() diff --git a/src/bedit_gui/simulation_models.py b/src/bedit_gui/simulation_models.py index 9a95144..57f19b6 100644 --- a/src/bedit_gui/simulation_models.py +++ b/src/bedit_gui/simulation_models.py @@ -25,6 +25,22 @@ class CompiledModel: return {"model_name": self.model_name, "executable": self.executable, "working_directory": self.working_directory, "output": self.output, "errors": self.errors} +@dataclass +class SimulationPlotTab: + name: str + signals: list[str] = field(default_factory=list) + + @classmethod + def from_data(cls, data: Mapping[str, Any]) -> SimulationPlotTab: + raw_signals = data.get("signals", []) + if not isinstance(raw_signals, list): + raise TypeError("plot tab signals must be a list") + return cls(name=str(data.get("name", "Plot")), signals=[str(signal) for signal in raw_signals]) + + def to_data(self) -> dict[str, Any]: + return {"name": self.name, "signals": self.signals} + + @dataclass class SimulationRoot: format_version: int @@ -36,6 +52,7 @@ class SimulationRoot: settings: Simulation current_end_time: float | None = None results: list[SimulationResult] = field(default_factory=list) + plot_tabs: list[SimulationPlotTab] = field(default_factory=lambda: [SimulationPlotTab("Plot 1")]) @classmethod def from_data(cls, data: Mapping[str, Any]) -> SimulationRoot: @@ -51,6 +68,7 @@ class SimulationRoot: settings=Simulation.from_data(data["settings"]), current_end_time=float(data["current_end_time"]) if data.get("current_end_time") is not None else None, results=[_result_from_data(result) for result in data.get("results", [])], + plot_tabs=[SimulationPlotTab.from_data(tab) for tab in data["plot_tabs"]] if "plot_tabs" in data else [SimulationPlotTab("Plot 1")], ) def to_data(self) -> dict[str, Any]: @@ -65,6 +83,7 @@ class SimulationRoot: "settings": self.settings.to_data(), "current_end_time": self.current_end_time, "results": [_result_to_data(result) for result in self.results], + "plot_tabs": [tab.to_data() for tab in self.plot_tabs], } diff --git a/src/bedit_gui/views/simulation_plot_widget.py b/src/bedit_gui/views/simulation_plot_widget.py new file mode 100644 index 0000000..57a054f --- /dev/null +++ b/src/bedit_gui/views/simulation_plot_widget.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg +from matplotlib.figure import Figure +from PySide6.QtWidgets import QVBoxLayout, QWidget + +from bedit_simulation import SimulationResult + + +class SimulationPlotWidget(QWidget): + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.figure = Figure(layout="constrained") + self.canvas = FigureCanvasQTAgg(self.figure) + self.axes = self.figure.add_subplot() + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.canvas) + + def set_plot(self, results: list[SimulationResult], signals: list[str]) -> None: + self.axes.clear() + for signal in signals: + times: list[float] = [] + values: list[float] = [] + sample_offset = 0 + for result in results: + result_values = result.data.get(signal) + if result_values is None: + continue + result_times = result.data.get("time") + if result_times is not None and len(result_times) == len(result_values): + times.extend(result_times) + else: + times.extend(range(sample_offset, sample_offset + len(result_values))) + values.extend(result_values) + sample_offset += len(result_values) + if values: + self.axes.plot(times, values, label=signal) + self.axes.set_xlabel("Time") + self.axes.grid(True) + if self.axes.lines: + self.axes.legend() + self.canvas.draw_idle() diff --git a/untitled.besim.json b/untitled.besim.json index 690cdc9..46bab51 100644 --- a/untitled.besim.json +++ b/untitled.besim.json @@ -7901,5 +7901,20 @@ "process_output": "localuser:joppe being added to access control list\n\"/tmp/bedit-compiled-_ebkgdzs\"\n0\n\n", "process_errors": "" } + ], + "plot_tabs": [ + { + "name": "0 and 1", + "signals": [ + "j0.e", + "j1.f" + ] + }, + { + "name": "R power", + "signals": [ + "R.power" + ] + } ] }