Plotting signals in besim
This commit is contained in:
79
src/bedit_gui/commands/simulation_plot_commands.py
Normal file
79
src/bedit_gui/commands/simulation_plot_commands.py
Normal file
@@ -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)
|
||||
229
src/bedit_gui/controllers/simulation_plot_controller.py
Normal file
229
src/bedit_gui/controllers/simulation_plot_controller.py
Normal file
@@ -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")
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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],
|
||||
}
|
||||
|
||||
|
||||
|
||||
43
src/bedit_gui/views/simulation_plot_widget.py
Normal file
43
src/bedit_gui/views/simulation_plot_widget.py
Normal file
@@ -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()
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user