Compare commits
7 Commits
a38d461a36
...
c8373c3423
| Author | SHA1 | Date | |
|---|---|---|---|
| c8373c3423 | |||
| 4b565edb5c | |||
| feba2c9798 | |||
| 51d01ad208 | |||
| e85c507d7f | |||
| 756a621ab1 | |||
| 2128fb00b4 |
@@ -172,6 +172,7 @@ Text widgets use their native `copy()`, `cut()`, and `paste()` methods. Componen
|
||||
- `SimulationRoot` contains source-document identity, the selected component and copied settings, and eventually results. Compiled artifacts are transient application state and must not be serialized; reopen saved simulations by compiling them again. Never serialize live Python objects.
|
||||
- BEdit launches the simulator as a separate process. Compile/Open Simulation integration may transfer a `.bes` file to that process.
|
||||
- Keep simulator file workflows in their own services/controllers rather than adding them to `MainWindow` or the editor document controller.
|
||||
- Keep compiled-executable launching, time-window progression, result loading, and cancellation inside `bedit_simulation`. GUI controllers may schedule backend calls and present state, but must not execute or manage simulation binaries themselves.
|
||||
|
||||
## Verification
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import sys
|
||||
import argparse
|
||||
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from PySide6.QtWidgets import QApplication, QMessageBox
|
||||
|
||||
from bedit_gui.controllers.clipboard_controller import ClipboardController, DocumentTreeClipboardHandler, TextClipboardHandler
|
||||
from bedit_gui.controllers.document_controller import DocumentController
|
||||
@@ -19,6 +19,7 @@ from bedit_gui.documents import Document
|
||||
from bedit_gui.services.application_settings import ApplicationSettings
|
||||
from bedit_gui.services.clipboard import ClipboardService
|
||||
from bedit_gui.views.main_window import MainWindow
|
||||
from bedit_gui.versions import BEDIT_VERSION
|
||||
|
||||
def parse_arguments():
|
||||
parser = argparse.ArgumentParser(exit_on_error=False)
|
||||
@@ -26,6 +27,7 @@ def parse_arguments():
|
||||
parser.add_argument(
|
||||
"-f", "--file", type=str, help="Path to a file to open", default=None, required=False
|
||||
)
|
||||
parser.add_argument("--version", action="version", version=f"BEdit {BEDIT_VERSION}")
|
||||
|
||||
return parser.parse_args()
|
||||
|
||||
@@ -40,10 +42,13 @@ def main() -> int:
|
||||
|
||||
app.setOrganizationName("BEdit")
|
||||
app.setApplicationName("BEdit")
|
||||
app.setApplicationVersion(BEDIT_VERSION)
|
||||
|
||||
settings = ApplicationSettings()
|
||||
document = Document(app)
|
||||
window = MainWindow()
|
||||
window.ui.actionAbout.triggered.connect(lambda: QMessageBox.about(window, "About BEdit", f"BEdit {BEDIT_VERSION}"))
|
||||
window.ui.actionAbout_QT.triggered.connect(app.aboutQt)
|
||||
|
||||
LogController(window, settings.log_level)
|
||||
DocumentController(document, window)
|
||||
|
||||
116
src/bedit_gui/commands/simulation_plot_commands.py
Normal file
116
src/bedit_gui/commands/simulation_plot_commands.py
Normal file
@@ -0,0 +1,116 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from copy import deepcopy
|
||||
|
||||
from PySide6.QtGui import QUndoCommand
|
||||
|
||||
from bedit_gui.simulation_models import SimulationPlotSettings, 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)
|
||||
|
||||
|
||||
class ChangeSimulationPlotXAxisCommand(QUndoCommand):
|
||||
def __init__(self, root: SimulationRoot, index: int, x_axis: str | None, changed: ChangedCallback) -> None:
|
||||
super().__init__("Change plot x axis")
|
||||
self.root = root
|
||||
self.index = index
|
||||
self.old_x_axis = root.plot_tabs[index].x_axis
|
||||
self.x_axis = x_axis
|
||||
self.changed = changed
|
||||
|
||||
def redo(self) -> None:
|
||||
self.root.plot_tabs[self.index].x_axis = self.x_axis
|
||||
self.changed(self.index)
|
||||
|
||||
def undo(self) -> None:
|
||||
self.root.plot_tabs[self.index].x_axis = self.old_x_axis
|
||||
self.changed(self.index)
|
||||
|
||||
|
||||
class ChangeSimulationPlotSettingsCommand(QUndoCommand):
|
||||
def __init__(self, root: SimulationRoot, index: int, settings: SimulationPlotSettings, changed: ChangedCallback) -> None:
|
||||
super().__init__("Change plot settings")
|
||||
self.root = root
|
||||
self.index = index
|
||||
self.old_settings = deepcopy(root.plot_tabs[index].settings)
|
||||
self.settings = deepcopy(settings)
|
||||
self.changed = changed
|
||||
|
||||
def redo(self) -> None:
|
||||
self.root.plot_tabs[self.index].settings = deepcopy(self.settings)
|
||||
self.changed(self.index)
|
||||
|
||||
def undo(self) -> None:
|
||||
self.root.plot_tabs[self.index].settings = deepcopy(self.old_settings)
|
||||
self.changed(self.index)
|
||||
@@ -13,6 +13,7 @@ from bedit_gui.documents import Document
|
||||
from bedit_gui.services import simulation_files
|
||||
from bedit_gui.services.application_logging import get_logger
|
||||
from bedit_gui.services.simulation_loader import component_choices
|
||||
from bedit_gui.services.simulation_handoff import send_simulation_handoff
|
||||
from bedit_gui.simulation_models import CompiledModel, SimulationRoot
|
||||
from bedit_gui.views.main_window import MainWindow
|
||||
from bedit_simulation import ModelBuildResult, compile_component_sync
|
||||
@@ -24,6 +25,8 @@ Launcher = Callable[[Path, CompiledModel], bool]
|
||||
|
||||
|
||||
def _launch_simulator(path: Path, compiled_model: CompiledModel) -> bool:
|
||||
if send_simulation_handoff(path, compiled_model):
|
||||
return True
|
||||
arguments = ["-m", "bedit_gui.simulation_application", "--handoff", "--model-name", compiled_model.model_name, "--executable", compiled_model.executable, "--working-directory", compiled_model.working_directory, str(path)]
|
||||
launched = QProcess.startDetached(sys.executable, arguments)
|
||||
return launched[0] if isinstance(launched, tuple) else bool(launched)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QObject, Qt
|
||||
from PySide6.QtCore import QObject, Signal, Qt
|
||||
from PySide6.QtWidgets import QApplication, QDialog, QFileDialog, QInputDialog, QMessageBox
|
||||
|
||||
from bedit_gui.services import document_files, simulation_files
|
||||
@@ -17,6 +18,8 @@ logger = get_logger(__name__)
|
||||
|
||||
|
||||
class SimulationFileController(QObject):
|
||||
runtime_changed = Signal()
|
||||
|
||||
def __init__(self, window: SimulationWindow) -> None:
|
||||
super().__init__(window)
|
||||
self.window = window
|
||||
@@ -34,10 +37,12 @@ class SimulationFileController(QObject):
|
||||
self.compiled_model = None
|
||||
self.path = None
|
||||
self._update_window()
|
||||
self.runtime_changed.emit()
|
||||
logger.info("Created new simulation")
|
||||
|
||||
def open(self, path: str | Path, *, component: str | None = None, simulation: str | None = None, backed_by_file: bool = True, compiled_model: CompiledModel | None = None) -> None:
|
||||
def open(self, path: str | Path, *, component: str | None = None, simulation: str | None = None, backed_by_file: bool = True, compiled_model: CompiledModel | None = None, preserve_plots: bool = False) -> None:
|
||||
file_path = Path(path)
|
||||
plot_tabs = deepcopy(self.root.plot_tabs) if preserve_plots and self.root is not None else None
|
||||
try:
|
||||
if file_path.suffix.lower() == ".bes" or simulation_files.is_simulation_json(file_path):
|
||||
self.root = simulation_files.load(file_path)
|
||||
@@ -49,10 +54,20 @@ class SimulationFileController(QObject):
|
||||
except (OSError, RuntimeError, TypeError, ValueError):
|
||||
logger.exception("Could not open simulation %s", file_path)
|
||||
raise
|
||||
if preserve_plots and self.root is not None:
|
||||
if plot_tabs is not None:
|
||||
self.root.plot_tabs = plot_tabs
|
||||
self.root.current_end_time = self.root.settings.start_time
|
||||
self.root.results.clear()
|
||||
self._log_compilation()
|
||||
self._update_window()
|
||||
self.runtime_changed.emit()
|
||||
logger.info("Opened simulation: %s", file_path)
|
||||
|
||||
def open_handoff(self, path: str | Path, compiled_model: CompiledModel) -> None:
|
||||
self.open(path, backed_by_file=False, compiled_model=compiled_model, preserve_plots=True)
|
||||
logger.info("Accepted simulation handoff and reset to time %s", self.root.current_end_time if self.root is not None else "unknown")
|
||||
|
||||
def open_dialog(self) -> None:
|
||||
filename, _ = QFileDialog.getOpenFileName(self.window, "Open Simulation", "", "Simulation and BEdit files (*.bes *.beb *.json)")
|
||||
if not filename:
|
||||
@@ -111,9 +126,12 @@ class SimulationFileController(QObject):
|
||||
self.root.component = updated.component
|
||||
self.root.component_path = next((path for component_id, _component, path in components if component_id == updated.component), self.root.component_path)
|
||||
self.root.settings_name = updated.name
|
||||
self.root.current_end_time = updated.start_time
|
||||
self.root.results.clear()
|
||||
if component_changed:
|
||||
self.compiled_model = None
|
||||
self._update_window()
|
||||
self.runtime_changed.emit()
|
||||
logger.info("Updated simulation settings: %s", updated.name)
|
||||
|
||||
def _source_components(self) -> list[tuple]:
|
||||
|
||||
57
src/bedit_gui/controllers/simulation_handoff_controller.py
Normal file
57
src/bedit_gui/controllers/simulation_handoff_controller.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QObject
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
from bedit_gui.controllers.simulation_file_controller import SimulationFileController
|
||||
from bedit_gui.controllers.simulation_run_controller import SimulationRunController
|
||||
from bedit_gui.services.application_logging import get_logger
|
||||
from bedit_gui.services.simulation_handoff import SimulationHandoffServer
|
||||
from bedit_gui.simulation_models import CompiledModel
|
||||
from bedit_gui.views.simulation_window import SimulationWindow
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class SimulationHandoffController(QObject):
|
||||
def __init__(self, window: SimulationWindow, files: SimulationFileController, runs: SimulationRunController) -> None:
|
||||
super().__init__(window)
|
||||
self.window = window
|
||||
self.files = files
|
||||
self.runs = runs
|
||||
self._pending: tuple[Path, CompiledModel] | None = None
|
||||
self.server = SimulationHandoffServer(self)
|
||||
self.server.handoff_received.connect(self.receive)
|
||||
runs.run_completed.connect(self._run_finished)
|
||||
runs.run_cancelled.connect(self._run_finished)
|
||||
runs.run_failed.connect(self._run_finished)
|
||||
|
||||
def receive(self, path: Path, compiled_model: CompiledModel) -> None:
|
||||
self._pending = (path, compiled_model)
|
||||
if self.runs.is_running:
|
||||
logger.info("Received a new model; stopping the active simulation before handoff")
|
||||
self.runs.stop()
|
||||
return
|
||||
self._apply_pending()
|
||||
|
||||
def _run_finished(self, *_args: object) -> None:
|
||||
if self._pending is not None:
|
||||
self._apply_pending()
|
||||
|
||||
def _apply_pending(self) -> None:
|
||||
pending = self._pending
|
||||
self._pending = None
|
||||
if pending is None:
|
||||
return
|
||||
path, compiled_model = pending
|
||||
try:
|
||||
self.files.open_handoff(path, compiled_model)
|
||||
except (OSError, RuntimeError, TypeError, ValueError) as exc:
|
||||
logger.exception("Could not accept simulation handoff from %s", path)
|
||||
QMessageBox.critical(self.window, "Could not open simulation", str(exc))
|
||||
return
|
||||
self.window.show()
|
||||
self.window.raise_()
|
||||
self.window.activateWindow()
|
||||
264
src/bedit_gui/controllers/simulation_plot_controller.py
Normal file
264
src/bedit_gui/controllers/simulation_plot_controller.py
Normal file
@@ -0,0 +1,264 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import QObject, QPoint, Qt
|
||||
from PySide6.QtGui import QUndoStack
|
||||
from PySide6.QtWidgets import QDialog, QInputDialog, QMenu, QTreeWidgetItem, QWidget
|
||||
|
||||
from bedit_gui.commands.simulation_plot_commands import AddSimulationPlotTabCommand, ChangeSimulationPlotSettingsCommand, ChangeSimulationPlotSignalsCommand, ChangeSimulationPlotXAxisCommand, 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.dialogs.plot_settings_dialog import PlotSettingsDialog
|
||||
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)
|
||||
tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
tree.customContextMenuRequested.connect(self._show_signal_menu)
|
||||
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 index, tab in enumerate(root.plot_tabs):
|
||||
widget = SimulationPlotWidget(tabs)
|
||||
widget.settings_requested.connect(lambda checked=False, tab_index=index: self._open_plot_settings(tab_index))
|
||||
tabs.addTab(widget, 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 _show_signal_menu(self, position: QPoint) -> None:
|
||||
root = self.files.root
|
||||
tree = self.window.ui.simulationTree
|
||||
item = tree.itemAt(position)
|
||||
signal = item.data(0, Qt.ItemDataRole.UserRole) if item is not None else None
|
||||
if root is None or signal is None or not 0 <= self._active_tab < len(root.plot_tabs):
|
||||
return
|
||||
tab = root.plot_tabs[self._active_tab]
|
||||
menu = QMenu(tree)
|
||||
use_signal = menu.addAction("Use as x axis")
|
||||
use_signal.setEnabled(tab.x_axis != signal)
|
||||
use_time = menu.addAction("Use time as x axis")
|
||||
use_time.setEnabled(tab.x_axis is not None)
|
||||
selected = menu.exec(tree.viewport().mapToGlobal(position))
|
||||
if selected is use_signal:
|
||||
self.undo_stack.push(ChangeSimulationPlotXAxisCommand(root, self._active_tab, signal, self._selection_changed))
|
||||
elif selected is use_time:
|
||||
self.undo_stack.push(ChangeSimulationPlotXAxisCommand(root, self._active_tab, None, self._selection_changed))
|
||||
|
||||
def _open_plot_settings(self, index: int) -> None:
|
||||
root = self.files.root
|
||||
if root is None or not 0 <= index < len(root.plot_tabs):
|
||||
return
|
||||
dialog = PlotSettingsDialog(root.plot_tabs[index].settings, root.plot_tabs[index].signals, self.window)
|
||||
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||||
settings = dialog.settings()
|
||||
if settings != root.plot_tabs[index].settings:
|
||||
self.undo_stack.push(ChangeSimulationPlotSettingsCommand(root, index, settings, self._selection_changed))
|
||||
|
||||
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):
|
||||
tab = root.plot_tabs[index]
|
||||
widget.set_plot(root.results, tab.signals, tab.x_axis, tab.settings)
|
||||
|
||||
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")
|
||||
161
src/bedit_gui/controllers/simulation_run_controller.py
Normal file
161
src/bedit_gui/controllers/simulation_run_controller.py
Normal file
@@ -0,0 +1,161 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from threading import Thread
|
||||
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
|
||||
from bedit_gui.controllers.simulation_file_controller import SimulationFileController
|
||||
from bedit_gui.services.application_logging import get_logger
|
||||
from bedit_gui.simulation_models import CompiledModel
|
||||
from bedit_gui.views.simulation_window import SimulationWindow
|
||||
from bedit_simulation import SimulationCancelledError, SimulationResult, SimulationRunSettings, SimulationSession
|
||||
|
||||
logger = get_logger(__name__)
|
||||
SessionFactory = Callable[[CompiledModel, SimulationRunSettings, float | None, list[SimulationResult]], SimulationSession]
|
||||
|
||||
|
||||
def _create_session(compiled: CompiledModel, settings: SimulationRunSettings, current_end_time: float | None, results: list[SimulationResult]) -> SimulationSession:
|
||||
return SimulationSession(compiled.model_name, compiled.executable, settings, current_end_time=current_end_time, results=results)
|
||||
|
||||
|
||||
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)
|
||||
self.window = window
|
||||
self.files = files
|
||||
self.session_factory = session_factory
|
||||
self.session: SimulationSession | None = None
|
||||
self._running = False
|
||||
self._reset_pending = False
|
||||
|
||||
window.ui.actionRun_Simulation.triggered.connect(self.start)
|
||||
window.ui.actionStop_Simulation.triggered.connect(self.stop)
|
||||
window.ui.actionRestart_Simulation.triggered.connect(self.restart)
|
||||
files.runtime_changed.connect(self._load_session)
|
||||
self.run_completed.connect(self._completed)
|
||||
self.run_cancelled.connect(self._cancelled)
|
||||
self.run_failed.connect(self._failed)
|
||||
self._load_session()
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
return self._running
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running or self.session is None:
|
||||
return
|
||||
start_time = self.session.settings.start_time
|
||||
stop_time = self.session.current_end_time + self.session.settings.duration
|
||||
self._running = True
|
||||
self._update_actions()
|
||||
logger.info("Starting simulation from %s to %s", start_time, stop_time)
|
||||
Thread(target=self._run_worker, args=(self.session,), name="besim-run", daemon=True).start()
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running or self.session is None:
|
||||
return
|
||||
self.session.cancel()
|
||||
logger.info("Stopping simulation")
|
||||
|
||||
def restart(self) -> None:
|
||||
if self.session is None:
|
||||
return
|
||||
if self._running:
|
||||
self._reset_pending = True
|
||||
logger.info("Reset requested; stopping the active simulation")
|
||||
self.stop()
|
||||
return
|
||||
self._reset()
|
||||
|
||||
def _reset(self) -> None:
|
||||
if self.session is None:
|
||||
return
|
||||
self.session.reset()
|
||||
self._store_session_state()
|
||||
logger.info("Reset simulation to start time %s", self.session.current_end_time)
|
||||
self._update_actions()
|
||||
|
||||
def _run_worker(self, session: SimulationSession) -> None:
|
||||
try:
|
||||
result = asyncio.run(session.run_next())
|
||||
except SimulationCancelledError:
|
||||
self.run_cancelled.emit()
|
||||
except (OSError, RuntimeError, TypeError, ValueError) as exc:
|
||||
self.run_failed.emit(exc)
|
||||
else:
|
||||
self.run_completed.emit(result)
|
||||
|
||||
def _completed(self, result: SimulationResult) -> None:
|
||||
self._running = False
|
||||
self._store_session_state()
|
||||
logger.info("Simulation completed at time %s", self.session.current_end_time if self.session is not None else "unknown")
|
||||
if result.process_output.strip():
|
||||
logger.info("Simulation output:\n%s", result.process_output.strip())
|
||||
if result.process_errors.strip():
|
||||
logger.warning("Simulation errors:\n%s", result.process_errors.strip())
|
||||
self._finish_run()
|
||||
|
||||
def _cancelled(self) -> None:
|
||||
self._running = False
|
||||
logger.info("Simulation stopped")
|
||||
self._finish_run()
|
||||
|
||||
def _failed(self, error: Exception) -> None:
|
||||
self._running = False
|
||||
logger.error("Simulation failed: %s", error, exc_info=(type(error), error, error.__traceback__))
|
||||
self._finish_run()
|
||||
|
||||
def _finish_run(self) -> None:
|
||||
self._update_actions()
|
||||
if self._reset_pending:
|
||||
self._reset_pending = False
|
||||
self._reset()
|
||||
|
||||
def _load_session(self) -> None:
|
||||
root = self.files.root
|
||||
compiled = self.files.compiled_model
|
||||
self._reset_pending = False
|
||||
if root is None or compiled is None:
|
||||
self.session = None
|
||||
else:
|
||||
settings = root.settings
|
||||
try:
|
||||
run_settings = SimulationRunSettings(
|
||||
start_time=settings.start_time,
|
||||
duration=settings.duration,
|
||||
use_timed_steps=settings.use_timed_steps,
|
||||
number_of_steps=settings.number_of_steps,
|
||||
step_size=settings.step_size,
|
||||
tolerance=settings.dassl_tolerance,
|
||||
method=settings.method.value,
|
||||
)
|
||||
self.session = self.session_factory(compiled, run_settings, root.current_end_time, root.results)
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
self.session = None
|
||||
logger.exception("Could not initialize the simulation runtime")
|
||||
self._update_actions()
|
||||
|
||||
def _update_actions(self) -> None:
|
||||
available = self.session is not None
|
||||
self.window.ui.actionRun_Simulation.setEnabled(available and not self._running)
|
||||
self.window.ui.actionStop_Simulation.setEnabled(available and self._running)
|
||||
self.window.ui.actionRestart_Simulation.setEnabled(available)
|
||||
for action in (self.window.ui.actionNew_Simulation_Run, self.window.ui.actionOpen_Simulation_Run, self.window.ui.actionSave_Simulation_Run, self.window.ui.actionSimulation_Options):
|
||||
action.setEnabled(not self._running and (available or action is not self.window.ui.actionSimulation_Options))
|
||||
if self.session is not None:
|
||||
state = "running" if self._running else "ready"
|
||||
self.window.statusBar().showMessage(f"Simulation {state} · current time {self.session.current_end_time}")
|
||||
|
||||
def _store_session_state(self) -> None:
|
||||
if self.session is None or self.files.root is None:
|
||||
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()
|
||||
72
src/bedit_gui/services/simulation_handoff.py
Normal file
72
src/bedit_gui/services/simulation_handoff.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
from PySide6.QtNetwork import QLocalServer, QLocalSocket
|
||||
|
||||
from bedit_gui.simulation_models import CompiledModel
|
||||
|
||||
SERVER_NAME = "bedit-besim-handoff-v1"
|
||||
|
||||
|
||||
def send_simulation_handoff(path: str | Path, compiled_model: CompiledModel, *, server_name: str = SERVER_NAME, timeout_ms: int = 500) -> bool:
|
||||
socket = QLocalSocket()
|
||||
socket.connectToServer(server_name)
|
||||
if not socket.waitForConnected(timeout_ms):
|
||||
return False
|
||||
payload = json.dumps({"path": str(Path(path).resolve()), "compiled_model": compiled_model.to_data()}, separators=(",", ":")).encode("utf-8") + b"\n"
|
||||
if socket.write(payload) != len(payload) or not socket.waitForBytesWritten(timeout_ms):
|
||||
socket.abort()
|
||||
return False
|
||||
socket.disconnectFromServer()
|
||||
return True
|
||||
|
||||
|
||||
class SimulationHandoffServer(QObject):
|
||||
handoff_received = Signal(object, object)
|
||||
|
||||
def __init__(self, parent: QObject | None = None, *, server_name: str = SERVER_NAME) -> None:
|
||||
super().__init__(parent)
|
||||
self.server = QLocalServer(self)
|
||||
self._buffers: dict[QLocalSocket, bytearray] = {}
|
||||
self.server.newConnection.connect(self._accept_connections)
|
||||
if not self.server.listen(server_name):
|
||||
probe = QLocalSocket()
|
||||
probe.connectToServer(server_name)
|
||||
if probe.waitForConnected(200):
|
||||
probe.disconnectFromServer()
|
||||
return
|
||||
QLocalServer.removeServer(server_name)
|
||||
if not self.server.listen(server_name):
|
||||
raise RuntimeError(f"could not listen for BEsim handoffs: {self.server.errorString()}")
|
||||
|
||||
def _accept_connections(self) -> None:
|
||||
while self.server.hasPendingConnections():
|
||||
socket = self.server.nextPendingConnection()
|
||||
if socket is None:
|
||||
continue
|
||||
self._buffers[socket] = bytearray()
|
||||
socket.readyRead.connect(lambda active=socket: self._read(active))
|
||||
socket.disconnected.connect(lambda active=socket: self._discard(active))
|
||||
self._read(socket)
|
||||
|
||||
def _read(self, socket: QLocalSocket) -> None:
|
||||
buffer = self._buffers.get(socket)
|
||||
if buffer is None:
|
||||
return
|
||||
buffer.extend(socket.readAll().data())
|
||||
while b"\n" in buffer:
|
||||
raw_message, _, remaining = buffer.partition(b"\n")
|
||||
buffer[:] = remaining
|
||||
try:
|
||||
message = json.loads(raw_message.decode("utf-8"))
|
||||
path = Path(str(message["path"]))
|
||||
compiled_model = CompiledModel.from_data(message["compiled_model"])
|
||||
except (KeyError, TypeError, ValueError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
continue
|
||||
self.handoff_received.emit(path, compiled_model)
|
||||
|
||||
def _discard(self, socket: QLocalSocket) -> None:
|
||||
self._buffers.pop(socket, None)
|
||||
@@ -7,13 +7,18 @@ 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.controllers.simulation_handoff_controller import SimulationHandoffController
|
||||
from bedit_gui.simulation_models import CompiledModel
|
||||
from bedit_gui.services.application_settings import SimulationApplicationSettings
|
||||
from bedit_gui.views.simulation_window import SimulationWindow
|
||||
from bedit_gui.versions import BESIM_VERSION
|
||||
|
||||
|
||||
def parse_arguments(arguments: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Open and run BEdit simulations")
|
||||
parser.add_argument("--version", action="version", version=f"BEsim {BESIM_VERSION}")
|
||||
parser.add_argument("file", nargs="?", help="BEdit (.beb/.json) or simulation (.bes/.json) file")
|
||||
parser.add_argument("-f", "--file", dest="file_option", help=argparse.SUPPRESS)
|
||||
parser.add_argument("--handoff", action="store_true", help=argparse.SUPPRESS)
|
||||
@@ -36,12 +41,19 @@ def main(arguments: list[str] | None = None) -> int:
|
||||
|
||||
app.setOrganizationName("BEsim")
|
||||
app.setApplicationName("BEsim")
|
||||
app.setApplicationVersion(BESIM_VERSION)
|
||||
|
||||
window = SimulationWindow()
|
||||
window.ui.actionAbout.triggered.connect(lambda: QMessageBox.about(window, "About BEsim", f"BEsim {BESIM_VERSION}"))
|
||||
window.ui.actionAbout_QT.triggered.connect(app.aboutQt)
|
||||
controller = SimulationFileController(window)
|
||||
|
||||
settings = SimulationApplicationSettings()
|
||||
LogController(window, settings.log_level)
|
||||
run_controller = SimulationRunController(window, controller)
|
||||
plot_controller = SimulationPlotController(window, controller)
|
||||
SimulationHandoffController(window, controller, run_controller)
|
||||
run_controller.simulation_state_changed.connect(plot_controller.refresh_results)
|
||||
|
||||
window.showMaximized()
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from bedit_core.models import ComponentID
|
||||
from bedit_gui.models import Simulation
|
||||
from bedit_simulation import SimulationResult
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -24,6 +25,91 @@ class CompiledModel:
|
||||
return {"model_name": self.model_name, "executable": self.executable, "working_directory": self.working_directory, "output": self.output, "errors": self.errors}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SimulationTraceSettings:
|
||||
visible: bool = True
|
||||
label: str = ""
|
||||
color: str = ""
|
||||
line_style: str = "-"
|
||||
line_width: float = 1.5
|
||||
marker: str = ""
|
||||
marker_size: float = 6.0
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data: Mapping[str, Any]) -> SimulationTraceSettings:
|
||||
return cls(visible=bool(data.get("visible", True)), label=str(data.get("label", "")), color=str(data.get("color", "")), line_style=str(data.get("line_style", "-")), line_width=float(data.get("line_width", 1.5)), marker=str(data.get("marker", "")), marker_size=float(data.get("marker_size", 6.0)))
|
||||
|
||||
def to_data(self) -> dict[str, Any]:
|
||||
return {"visible": self.visible, "label": self.label, "color": self.color, "line_style": self.line_style, "line_width": self.line_width, "marker": self.marker, "marker_size": self.marker_size}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SimulationPlotSettings:
|
||||
title: str = ""
|
||||
x_label: str = ""
|
||||
y_label: str = ""
|
||||
x_scale: str = "linear"
|
||||
y_scale: str = "linear"
|
||||
x_auto: bool = True
|
||||
y_auto: bool = True
|
||||
x_min: float = 0.0
|
||||
x_max: float = 1.0
|
||||
y_min: float = 0.0
|
||||
y_max: float = 1.0
|
||||
grid_visible: bool = True
|
||||
grid_axis: str = "both"
|
||||
grid_style: str = "-"
|
||||
grid_alpha: float = 0.5
|
||||
legend_visible: bool = True
|
||||
legend_location: str = "best"
|
||||
traces: dict[str, SimulationTraceSettings] = field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data: Mapping[str, Any]) -> SimulationPlotSettings:
|
||||
raw_traces = data.get("traces", {})
|
||||
if not isinstance(raw_traces, Mapping):
|
||||
raise TypeError("plot trace settings must be a mapping")
|
||||
return cls(
|
||||
title=str(data.get("title", "")), x_label=str(data.get("x_label", "")), y_label=str(data.get("y_label", "")),
|
||||
x_scale=str(data.get("x_scale", "linear")), y_scale=str(data.get("y_scale", "linear")),
|
||||
x_auto=bool(data.get("x_auto", True)), y_auto=bool(data.get("y_auto", True)),
|
||||
x_min=float(data.get("x_min", 0.0)), x_max=float(data.get("x_max", 1.0)), y_min=float(data.get("y_min", 0.0)), y_max=float(data.get("y_max", 1.0)),
|
||||
grid_visible=bool(data.get("grid_visible", True)), grid_axis=str(data.get("grid_axis", "both")), grid_style=str(data.get("grid_style", "-")), grid_alpha=float(data.get("grid_alpha", 0.5)),
|
||||
legend_visible=bool(data.get("legend_visible", True)), legend_location=str(data.get("legend_location", "best")),
|
||||
traces={str(signal): SimulationTraceSettings.from_data(trace) for signal, trace in raw_traces.items() if isinstance(trace, Mapping)},
|
||||
)
|
||||
|
||||
def to_data(self) -> dict[str, Any]:
|
||||
return {
|
||||
"title": self.title, "x_label": self.x_label, "y_label": self.y_label, "x_scale": self.x_scale, "y_scale": self.y_scale,
|
||||
"x_auto": self.x_auto, "y_auto": self.y_auto, "x_min": self.x_min, "x_max": self.x_max, "y_min": self.y_min, "y_max": self.y_max,
|
||||
"grid_visible": self.grid_visible, "grid_axis": self.grid_axis, "grid_style": self.grid_style, "grid_alpha": self.grid_alpha,
|
||||
"legend_visible": self.legend_visible, "legend_location": self.legend_location,
|
||||
"traces": {signal: trace.to_data() for signal, trace in self.traces.items()},
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SimulationPlotTab:
|
||||
name: str
|
||||
signals: list[str] = field(default_factory=list)
|
||||
x_axis: str | None = None
|
||||
settings: SimulationPlotSettings = field(default_factory=SimulationPlotSettings)
|
||||
|
||||
@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")
|
||||
raw_settings = data.get("settings", {})
|
||||
if not isinstance(raw_settings, Mapping):
|
||||
raise TypeError("plot tab settings must be a mapping")
|
||||
return cls(name=str(data.get("name", "Plot")), signals=[str(signal) for signal in raw_signals], x_axis=str(data["x_axis"]) if data.get("x_axis") is not None else None, settings=SimulationPlotSettings.from_data(raw_settings))
|
||||
|
||||
def to_data(self) -> dict[str, Any]:
|
||||
return {"name": self.name, "signals": self.signals, "x_axis": self.x_axis, "settings": self.settings.to_data()}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SimulationRoot:
|
||||
format_version: int
|
||||
@@ -33,6 +119,9 @@ class SimulationRoot:
|
||||
component_path: str
|
||||
settings_name: str | None
|
||||
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:
|
||||
@@ -46,6 +135,9 @@ class SimulationRoot:
|
||||
component_path=str(data.get("component_path", "")),
|
||||
settings_name=str(data["settings_name"]) if data.get("settings_name") is not None else None,
|
||||
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]:
|
||||
@@ -58,4 +150,19 @@ class SimulationRoot:
|
||||
"component_path": self.component_path,
|
||||
"settings_name": self.settings_name,
|
||||
"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],
|
||||
}
|
||||
|
||||
|
||||
def _result_from_data(data: Mapping[str, Any]) -> SimulationResult:
|
||||
raw_columns = data.get("data", {})
|
||||
if not isinstance(raw_columns, Mapping):
|
||||
raise TypeError("simulation result data must be a mapping")
|
||||
columns = {str(name): [float(value) for value in values] for name, values in raw_columns.items()}
|
||||
return SimulationResult(model_name=str(data.get("model_name", "")), data=columns, process_output=str(data.get("process_output", "")), process_errors=str(data.get("process_errors", "")))
|
||||
|
||||
|
||||
def _result_to_data(result: SimulationResult) -> dict[str, Any]:
|
||||
return {"model_name": result.model_name, "data": result.data, "process_output": result.process_output, "process_errors": result.process_errors}
|
||||
|
||||
102
src/bedit_gui/ui/forms/plot_settings.ui
Normal file
102
src/bedit_gui/ui/forms/plot_settings.ui
Normal file
@@ -0,0 +1,102 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>PlotSettingsDialog</class>
|
||||
<widget class="QDialog" name="PlotSettingsDialog">
|
||||
<property name="geometry"><rect><x>0</x><y>0</y><width>520</width><height>430</height></rect></property>
|
||||
<property name="windowTitle"><string>Plot Settings</string></property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QTabWidget" name="tabWidget">
|
||||
<property name="currentIndex"><number>0</number></property>
|
||||
<widget class="QWidget" name="generalTab">
|
||||
<attribute name="title"><string>General</string></attribute>
|
||||
<layout class="QVBoxLayout" name="generalLayout">
|
||||
<item><widget class="QGroupBox" name="labelsGroup"><property name="title"><string>Labels</string></property><layout class="QFormLayout" name="labelsForm">
|
||||
<item row="0" column="0"><widget class="QLabel" name="titleLabel"><property name="text"><string>Title:</string></property></widget></item>
|
||||
<item row="0" column="1"><widget class="QLineEdit" name="titleEdit"><property name="placeholderText"><string>Optional plot title</string></property></widget></item>
|
||||
<item row="1" column="0"><widget class="QLabel" name="xLabel"><property name="text"><string>X-axis label:</string></property></widget></item>
|
||||
<item row="1" column="1"><widget class="QLineEdit" name="xLabelEdit"><property name="placeholderText"><string>Automatic</string></property></widget></item>
|
||||
<item row="2" column="0"><widget class="QLabel" name="yLabel"><property name="text"><string>Y-axis label:</string></property></widget></item>
|
||||
<item row="2" column="1"><widget class="QLineEdit" name="yLabelEdit"><property name="placeholderText"><string>Optional</string></property></widget></item>
|
||||
</layout></widget></item>
|
||||
<item><spacer name="generalSpacer"><property name="orientation"><enum>Qt::Orientation::Vertical</enum></property><property name="sizeHint" stdset="0"><size><width>20</width><height>40</height></size></property></spacer></item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QWidget" name="axesTab">
|
||||
<attribute name="title"><string>Axes</string></attribute>
|
||||
<layout class="QVBoxLayout" name="axesLayout">
|
||||
<item><widget class="QGroupBox" name="xAxisGroup"><property name="title"><string>X axis</string></property><layout class="QGridLayout" name="xAxisLayout">
|
||||
<item row="0" column="0"><widget class="QLabel" name="xScaleLabel"><property name="text"><string>Scale:</string></property></widget></item>
|
||||
<item row="0" column="1"><widget class="QComboBox" name="xScaleCombo"><item><property name="text"><string>Linear</string></property></item><item><property name="text"><string>Logarithmic</string></property></item></widget></item>
|
||||
<item row="1" column="0" colspan="2"><widget class="QCheckBox" name="xAutoCheck"><property name="text"><string>Automatic limits</string></property><property name="checked"><bool>true</bool></property></widget></item>
|
||||
<item row="2" column="0"><widget class="QLabel" name="xMinLabel"><property name="text"><string>Minimum:</string></property></widget></item>
|
||||
<item row="2" column="1"><widget class="QDoubleSpinBox" name="xMinSpin"><property name="decimals"><number>8</number></property></widget></item>
|
||||
<item row="2" column="2"><widget class="QLabel" name="xMaxLabel"><property name="text"><string>Maximum:</string></property></widget></item>
|
||||
<item row="2" column="3"><widget class="QDoubleSpinBox" name="xMaxSpin"><property name="decimals"><number>8</number></property><property name="value"><double>1.000000000000000</double></property></widget></item>
|
||||
</layout></widget></item>
|
||||
<item><widget class="QGroupBox" name="yAxisGroup"><property name="title"><string>Y axis</string></property><layout class="QGridLayout" name="yAxisLayout">
|
||||
<item row="0" column="0"><widget class="QLabel" name="yScaleLabel"><property name="text"><string>Scale:</string></property></widget></item>
|
||||
<item row="0" column="1"><widget class="QComboBox" name="yScaleCombo"><item><property name="text"><string>Linear</string></property></item><item><property name="text"><string>Logarithmic</string></property></item></widget></item>
|
||||
<item row="1" column="0" colspan="2"><widget class="QCheckBox" name="yAutoCheck"><property name="text"><string>Automatic limits</string></property><property name="checked"><bool>true</bool></property></widget></item>
|
||||
<item row="2" column="0"><widget class="QLabel" name="yMinLabel"><property name="text"><string>Minimum:</string></property></widget></item>
|
||||
<item row="2" column="1"><widget class="QDoubleSpinBox" name="yMinSpin"><property name="decimals"><number>8</number></property></widget></item>
|
||||
<item row="2" column="2"><widget class="QLabel" name="yMaxLabel"><property name="text"><string>Maximum:</string></property></widget></item>
|
||||
<item row="2" column="3"><widget class="QDoubleSpinBox" name="yMaxSpin"><property name="decimals"><number>8</number></property><property name="value"><double>1.000000000000000</double></property></widget></item>
|
||||
</layout></widget></item>
|
||||
<item><spacer name="axesSpacer"><property name="orientation"><enum>Qt::Orientation::Vertical</enum></property><property name="sizeHint" stdset="0"><size><width>20</width><height>20</height></size></property></spacer></item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QWidget" name="tracesTab">
|
||||
<attribute name="title"><string>Traces</string></attribute>
|
||||
<layout class="QVBoxLayout" name="tracesLayout">
|
||||
<item><layout class="QFormLayout" name="traceSelectionForm">
|
||||
<item row="0" column="0"><widget class="QLabel" name="traceLabel"><property name="text"><string>Trace:</string></property></widget></item>
|
||||
<item row="0" column="1"><widget class="QComboBox" name="traceCombo"/></item>
|
||||
</layout></item>
|
||||
<item><widget class="QGroupBox" name="traceGroup"><property name="title"><string>Appearance</string></property><layout class="QFormLayout" name="traceForm">
|
||||
<item row="0" column="0" colspan="2"><widget class="QCheckBox" name="traceVisibleCheck"><property name="text"><string>Visible</string></property><property name="checked"><bool>true</bool></property></widget></item>
|
||||
<item row="1" column="0"><widget class="QLabel" name="traceLegendLabel"><property name="text"><string>Legend label:</string></property></widget></item>
|
||||
<item row="1" column="1"><widget class="QLineEdit" name="traceLegendEdit"><property name="placeholderText"><string>Signal name</string></property></widget></item>
|
||||
<item row="2" column="0"><widget class="QLabel" name="traceColorLabel"><property name="text"><string>Line color:</string></property></widget></item>
|
||||
<item row="2" column="1"><layout class="QHBoxLayout" name="traceColorLayout"><item><widget class="QPushButton" name="traceColorButton"><property name="text"><string>Automatic</string></property></widget></item><item><widget class="QPushButton" name="traceColorResetButton"><property name="text"><string>Reset</string></property></widget></item></layout></item>
|
||||
<item row="3" column="0"><widget class="QLabel" name="traceLineStyleLabel"><property name="text"><string>Line style:</string></property></widget></item>
|
||||
<item row="3" column="1"><widget class="QComboBox" name="traceLineStyleCombo"><item><property name="text"><string>Solid</string></property></item><item><property name="text"><string>Dashed</string></property></item><item><property name="text"><string>Dotted</string></property></item><item><property name="text"><string>Dash-dot</string></property></item><item><property name="text"><string>No line</string></property></item></widget></item>
|
||||
<item row="4" column="0"><widget class="QLabel" name="traceLineWidthLabel"><property name="text"><string>Line width:</string></property></widget></item>
|
||||
<item row="4" column="1"><widget class="QDoubleSpinBox" name="traceLineWidthSpin"><property name="minimum"><double>0.100000000000000</double></property><property name="maximum"><double>20.000000000000000</double></property><property name="singleStep"><double>0.250000000000000</double></property><property name="value"><double>1.500000000000000</double></property></widget></item>
|
||||
<item row="5" column="0"><widget class="QLabel" name="traceMarkerLabel"><property name="text"><string>Marker:</string></property></widget></item>
|
||||
<item row="5" column="1"><widget class="QComboBox" name="traceMarkerCombo"/></item>
|
||||
<item row="6" column="0"><widget class="QLabel" name="traceMarkerSizeLabel"><property name="text"><string>Marker size:</string></property></widget></item>
|
||||
<item row="6" column="1"><widget class="QDoubleSpinBox" name="traceMarkerSizeSpin"><property name="minimum"><double>0.100000000000000</double></property><property name="maximum"><double>50.000000000000000</double></property><property name="singleStep"><double>0.500000000000000</double></property><property name="value"><double>6.000000000000000</double></property></widget></item>
|
||||
</layout></widget></item>
|
||||
<item><spacer name="tracesSpacer"><property name="orientation"><enum>Qt::Orientation::Vertical</enum></property><property name="sizeHint" stdset="0"><size><width>20</width><height>20</height></size></property></spacer></item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QWidget" name="appearanceTab">
|
||||
<attribute name="title"><string>Grid & Legend</string></attribute>
|
||||
<layout class="QVBoxLayout" name="appearanceLayout">
|
||||
<item><widget class="QGroupBox" name="gridGroup"><property name="title"><string>Grid</string></property><property name="checkable"><bool>true</bool></property><property name="checked"><bool>true</bool></property><layout class="QFormLayout" name="gridForm">
|
||||
<item row="0" column="0"><widget class="QLabel" name="gridAxisLabel"><property name="text"><string>Grid lines:</string></property></widget></item>
|
||||
<item row="0" column="1"><widget class="QComboBox" name="gridAxisCombo"><item><property name="text"><string>Both axes</string></property></item><item><property name="text"><string>X axis only</string></property></item><item><property name="text"><string>Y axis only</string></property></item></widget></item>
|
||||
<item row="1" column="0"><widget class="QLabel" name="gridStyleLabel"><property name="text"><string>Line style:</string></property></widget></item>
|
||||
<item row="1" column="1"><widget class="QComboBox" name="gridStyleCombo"><item><property name="text"><string>Solid</string></property></item><item><property name="text"><string>Dashed</string></property></item><item><property name="text"><string>Dotted</string></property></item><item><property name="text"><string>Dash-dot</string></property></item></widget></item>
|
||||
<item row="2" column="0"><widget class="QLabel" name="gridOpacityLabel"><property name="text"><string>Opacity:</string></property></widget></item>
|
||||
<item row="2" column="1"><widget class="QSlider" name="gridOpacitySlider"><property name="orientation"><enum>Qt::Orientation::Horizontal</enum></property><property name="maximum"><number>100</number></property><property name="value"><number>50</number></property></widget></item>
|
||||
</layout></widget></item>
|
||||
<item><widget class="QGroupBox" name="legendGroup"><property name="title"><string>Legend</string></property><property name="checkable"><bool>true</bool></property><property name="checked"><bool>true</bool></property><layout class="QFormLayout" name="legendForm">
|
||||
<item row="0" column="0"><widget class="QLabel" name="legendLocationLabel"><property name="text"><string>Position:</string></property></widget></item>
|
||||
<item row="0" column="1"><widget class="QComboBox" name="legendLocationCombo"/></item>
|
||||
</layout></widget></item>
|
||||
<item><spacer name="appearanceSpacer"><property name="orientation"><enum>Qt::Orientation::Vertical</enum></property><property name="sizeHint" stdset="0"><size><width>20</width><height>30</height></size></property></spacer></item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
</item>
|
||||
<item><widget class="QDialogButtonBox" name="buttonBox"><property name="orientation"><enum>Qt::Orientation::Horizontal</enum></property><property name="standardButtons"><set>QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok</set></property></widget></item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections>
|
||||
<connection><sender>buttonBox</sender><signal>accepted()</signal><receiver>PlotSettingsDialog</receiver><slot>accept()</slot><hints/></connection>
|
||||
<connection><sender>buttonBox</sender><signal>rejected()</signal><receiver>PlotSettingsDialog</receiver><slot>reject()</slot><hints/></connection>
|
||||
</connections>
|
||||
</ui>
|
||||
4
src/bedit_gui/versions.py
Normal file
4
src/bedit_gui/versions.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""Independent versions for the BEdit desktop applications."""
|
||||
|
||||
BEDIT_VERSION = "0.2.0"
|
||||
BESIM_VERSION = "0.1.0"
|
||||
139
src/bedit_gui/views/dialogs/plot_settings_dialog.py
Normal file
139
src/bedit_gui/views/dialogs/plot_settings_dialog.py
Normal file
@@ -0,0 +1,139 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
|
||||
from PySide6.QtGui import QColor
|
||||
from PySide6.QtWidgets import QColorDialog, QDialog, QMessageBox, QWidget
|
||||
|
||||
from bedit_gui.simulation_models import SimulationPlotSettings, SimulationTraceSettings
|
||||
from bedit_gui.ui.generated.ui_plot_settings import Ui_PlotSettingsDialog
|
||||
|
||||
|
||||
class PlotSettingsDialog(QDialog):
|
||||
def __init__(self, settings: SimulationPlotSettings, signals: list[str], parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.ui = Ui_PlotSettingsDialog()
|
||||
self.ui.setupUi(self)
|
||||
self._trace_settings = deepcopy(settings.traces)
|
||||
self._current_trace: str | None = None
|
||||
self._trace_color = ""
|
||||
self._configure_controls()
|
||||
self._load(settings)
|
||||
self.ui.traceCombo.addItems(signals)
|
||||
self.ui.traceCombo.currentIndexChanged.connect(self._trace_changed)
|
||||
self.ui.traceColorButton.clicked.connect(self._choose_trace_color)
|
||||
self.ui.traceColorResetButton.clicked.connect(self._reset_trace_color)
|
||||
self.ui.xAutoCheck.toggled.connect(self._update_limit_controls)
|
||||
self.ui.yAutoCheck.toggled.connect(self._update_limit_controls)
|
||||
self._update_limit_controls()
|
||||
self._trace_changed(self.ui.traceCombo.currentIndex())
|
||||
|
||||
def settings(self) -> SimulationPlotSettings:
|
||||
self._store_trace()
|
||||
return SimulationPlotSettings(
|
||||
title=self.ui.titleEdit.text().strip(), x_label=self.ui.xLabelEdit.text().strip(), y_label=self.ui.yLabelEdit.text().strip(),
|
||||
x_scale=self.ui.xScaleCombo.currentData(), y_scale=self.ui.yScaleCombo.currentData(),
|
||||
x_auto=self.ui.xAutoCheck.isChecked(), y_auto=self.ui.yAutoCheck.isChecked(),
|
||||
x_min=self.ui.xMinSpin.value(), x_max=self.ui.xMaxSpin.value(), y_min=self.ui.yMinSpin.value(), y_max=self.ui.yMaxSpin.value(),
|
||||
grid_visible=self.ui.gridGroup.isChecked(), grid_axis=self.ui.gridAxisCombo.currentData(), grid_style=self.ui.gridStyleCombo.currentData(), grid_alpha=self.ui.gridOpacitySlider.value() / 100.0,
|
||||
legend_visible=self.ui.legendGroup.isChecked(), legend_location=self.ui.legendLocationCombo.currentData(),
|
||||
traces=deepcopy(self._trace_settings),
|
||||
)
|
||||
|
||||
def accept(self) -> None:
|
||||
settings = self.settings()
|
||||
if not settings.x_auto and settings.x_min >= settings.x_max:
|
||||
QMessageBox.warning(self, "Invalid x-axis limits", "The x-axis maximum must be greater than its minimum.")
|
||||
return
|
||||
if not settings.y_auto and settings.y_min >= settings.y_max:
|
||||
QMessageBox.warning(self, "Invalid y-axis limits", "The y-axis maximum must be greater than its minimum.")
|
||||
return
|
||||
if settings.x_scale == "log" and not settings.x_auto and settings.x_min <= 0:
|
||||
QMessageBox.warning(self, "Invalid x-axis limits", "A logarithmic x axis requires a positive minimum.")
|
||||
return
|
||||
if settings.y_scale == "log" and not settings.y_auto and settings.y_min <= 0:
|
||||
QMessageBox.warning(self, "Invalid y-axis limits", "A logarithmic y axis requires a positive minimum.")
|
||||
return
|
||||
super().accept()
|
||||
|
||||
def _configure_controls(self) -> None:
|
||||
for combo in (self.ui.xScaleCombo, self.ui.yScaleCombo):
|
||||
combo.setItemData(0, "linear")
|
||||
combo.setItemData(1, "log")
|
||||
for label, value in (("Both axes", "both"), ("X axis only", "x"), ("Y axis only", "y")):
|
||||
self.ui.gridAxisCombo.setItemData(self.ui.gridAxisCombo.findText(label), value)
|
||||
for index, value in enumerate(("-", "--", ":", "-.")):
|
||||
self.ui.gridStyleCombo.setItemData(index, value)
|
||||
self.ui.traceLineStyleCombo.setItemData(index, value)
|
||||
self.ui.traceLineStyleCombo.setItemData(4, "None")
|
||||
for label, value in (("None", ""), ("Point", "."), ("Circle", "o"), ("Square", "s"), ("Triangle up", "^"), ("Triangle down", "v"), ("Diamond", "D"), ("Plus", "+"), ("Cross", "x"), ("Star", "*")):
|
||||
self.ui.traceMarkerCombo.addItem(label, value)
|
||||
for label, value in (("Automatic", "best"), ("Upper right", "upper right"), ("Upper left", "upper left"), ("Lower right", "lower right"), ("Lower left", "lower left"), ("Center right", "center right"), ("Center left", "center left"), ("Upper center", "upper center"), ("Lower center", "lower center"), ("Center", "center")):
|
||||
self.ui.legendLocationCombo.addItem(label, value)
|
||||
for spin in (self.ui.xMinSpin, self.ui.xMaxSpin, self.ui.yMinSpin, self.ui.yMaxSpin):
|
||||
spin.setRange(-1e100, 1e100)
|
||||
|
||||
def _load(self, settings: SimulationPlotSettings) -> None:
|
||||
self.ui.titleEdit.setText(settings.title)
|
||||
self.ui.xLabelEdit.setText(settings.x_label)
|
||||
self.ui.yLabelEdit.setText(settings.y_label)
|
||||
self.ui.xScaleCombo.setCurrentIndex(self.ui.xScaleCombo.findData(settings.x_scale))
|
||||
self.ui.yScaleCombo.setCurrentIndex(self.ui.yScaleCombo.findData(settings.y_scale))
|
||||
self.ui.xAutoCheck.setChecked(settings.x_auto)
|
||||
self.ui.yAutoCheck.setChecked(settings.y_auto)
|
||||
self.ui.xMinSpin.setValue(settings.x_min)
|
||||
self.ui.xMaxSpin.setValue(settings.x_max)
|
||||
self.ui.yMinSpin.setValue(settings.y_min)
|
||||
self.ui.yMaxSpin.setValue(settings.y_max)
|
||||
self.ui.gridGroup.setChecked(settings.grid_visible)
|
||||
self.ui.gridAxisCombo.setCurrentIndex(self.ui.gridAxisCombo.findData(settings.grid_axis))
|
||||
self.ui.gridStyleCombo.setCurrentIndex(self.ui.gridStyleCombo.findData(settings.grid_style))
|
||||
self.ui.gridOpacitySlider.setValue(round(settings.grid_alpha * 100))
|
||||
self.ui.legendGroup.setChecked(settings.legend_visible)
|
||||
self.ui.legendLocationCombo.setCurrentIndex(self.ui.legendLocationCombo.findData(settings.legend_location))
|
||||
|
||||
def _update_limit_controls(self) -> None:
|
||||
for widget in (self.ui.xMinLabel, self.ui.xMinSpin, self.ui.xMaxLabel, self.ui.xMaxSpin):
|
||||
widget.setEnabled(not self.ui.xAutoCheck.isChecked())
|
||||
for widget in (self.ui.yMinLabel, self.ui.yMinSpin, self.ui.yMaxLabel, self.ui.yMaxSpin):
|
||||
widget.setEnabled(not self.ui.yAutoCheck.isChecked())
|
||||
|
||||
def _trace_changed(self, index: int) -> None:
|
||||
self._store_trace()
|
||||
self._current_trace = self.ui.traceCombo.itemText(index) if index >= 0 else None
|
||||
self.ui.traceGroup.setEnabled(self._current_trace is not None)
|
||||
trace = self._trace_settings.get(self._current_trace, SimulationTraceSettings())
|
||||
self.ui.traceVisibleCheck.setChecked(trace.visible)
|
||||
self.ui.traceLegendEdit.setText(trace.label)
|
||||
self.ui.traceLineStyleCombo.setCurrentIndex(self.ui.traceLineStyleCombo.findData(trace.line_style))
|
||||
self.ui.traceLineWidthSpin.setValue(trace.line_width)
|
||||
self.ui.traceMarkerCombo.setCurrentIndex(self.ui.traceMarkerCombo.findData(trace.marker))
|
||||
self.ui.traceMarkerSizeSpin.setValue(trace.marker_size)
|
||||
self._set_trace_color(trace.color)
|
||||
|
||||
def _store_trace(self) -> None:
|
||||
if self._current_trace is None:
|
||||
return
|
||||
trace = SimulationTraceSettings(
|
||||
visible=self.ui.traceVisibleCheck.isChecked(), label=self.ui.traceLegendEdit.text().strip(), color=self._trace_color,
|
||||
line_style=self.ui.traceLineStyleCombo.currentData(), line_width=self.ui.traceLineWidthSpin.value(),
|
||||
marker=self.ui.traceMarkerCombo.currentData(), marker_size=self.ui.traceMarkerSizeSpin.value(),
|
||||
)
|
||||
if trace == SimulationTraceSettings():
|
||||
self._trace_settings.pop(self._current_trace, None)
|
||||
else:
|
||||
self._trace_settings[self._current_trace] = trace
|
||||
|
||||
def _choose_trace_color(self) -> None:
|
||||
initial = QColor(self._trace_color) if self._trace_color else QColor("black")
|
||||
color = QColorDialog.getColor(initial, self, "Select trace color")
|
||||
if color.isValid():
|
||||
self._set_trace_color(color.name(QColor.NameFormat.HexRgb))
|
||||
|
||||
def _reset_trace_color(self) -> None:
|
||||
self._set_trace_color("")
|
||||
|
||||
def _set_trace_color(self, color: str) -> None:
|
||||
self._trace_color = color
|
||||
self.ui.traceColorButton.setText(color or "Automatic")
|
||||
self.ui.traceColorButton.setStyleSheet(f"background-color: {color};" if color else "")
|
||||
@@ -70,6 +70,9 @@ class SimulationSettingsDialog(QDialog):
|
||||
if simulation.dassl_tolerance <= 0:
|
||||
QMessageBox.warning(self, "Invalid simulation", "The DASSL tolerance must be greater than zero.")
|
||||
return
|
||||
if simulation.duration <= 0:
|
||||
QMessageBox.warning(self, "Invalid simulation", "The simulation length must be greater than zero.")
|
||||
return
|
||||
simulation.name = simulation.name.strip()
|
||||
self._database.active_simulation = self._simulation_id()
|
||||
super().accept()
|
||||
|
||||
73
src/bedit_gui/views/simulation_plot_widget.py
Normal file
73
src/bedit_gui/views/simulation_plot_widget.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg, NavigationToolbar2QT
|
||||
from matplotlib.figure import Figure
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import QVBoxLayout, QWidget
|
||||
|
||||
from bedit_gui.simulation_models import SimulationPlotSettings
|
||||
from bedit_simulation import SimulationResult
|
||||
|
||||
|
||||
class SimulationPlotWidget(QWidget):
|
||||
settings_requested = Signal()
|
||||
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.figure = Figure(layout="constrained")
|
||||
self.canvas = FigureCanvasQTAgg(self.figure)
|
||||
self.toolbar = NavigationToolbar2QT(self.canvas, self)
|
||||
for action in self.toolbar.actions():
|
||||
if action.text() in ("Customize", "Subplots"):
|
||||
self.toolbar.removeAction(action)
|
||||
self.toolbar.addSeparator()
|
||||
self.settings_action = self.toolbar.addAction("Plot settings…")
|
||||
self.settings_action.setToolTip("Edit plot title, axes, traces, grid, and legend")
|
||||
self.settings_action.triggered.connect(self.settings_requested)
|
||||
self.axes = self.figure.add_subplot()
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.addWidget(self.toolbar)
|
||||
layout.addWidget(self.canvas)
|
||||
|
||||
def set_plot(self, results: list[SimulationResult], signals: list[str], x_axis: str | None = None, settings: SimulationPlotSettings | None = None) -> None:
|
||||
settings = settings or SimulationPlotSettings()
|
||||
self.axes.clear()
|
||||
for signal in signals:
|
||||
trace = settings.traces.get(signal)
|
||||
if trace is not None and not trace.visible:
|
||||
continue
|
||||
x_values: 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_x = result.data.get(x_axis or "time")
|
||||
if result_x is not None and len(result_x) == len(result_values):
|
||||
x_values.extend(result_x)
|
||||
elif x_axis is not None:
|
||||
continue
|
||||
else:
|
||||
x_values.extend(range(sample_offset, sample_offset + len(result_values)))
|
||||
values.extend(result_values)
|
||||
sample_offset += len(result_values)
|
||||
if values:
|
||||
options = {"label": trace.label or signal, "linestyle": trace.line_style, "linewidth": trace.line_width, "marker": trace.marker or None, "markersize": trace.marker_size} if trace is not None else {"label": signal}
|
||||
if trace is not None and trace.color:
|
||||
options["color"] = trace.color
|
||||
self.axes.plot(x_values, values, **options)
|
||||
self.axes.set_title(settings.title)
|
||||
self.axes.set_xlabel(settings.x_label or x_axis or "Time")
|
||||
self.axes.set_ylabel(settings.y_label)
|
||||
self.axes.set_xscale(settings.x_scale)
|
||||
self.axes.set_yscale(settings.y_scale)
|
||||
if not settings.x_auto:
|
||||
self.axes.set_xlim(settings.x_min, settings.x_max)
|
||||
if not settings.y_auto:
|
||||
self.axes.set_ylim(settings.y_min, settings.y_max)
|
||||
self.axes.grid(settings.grid_visible, axis=settings.grid_axis, linestyle=settings.grid_style, alpha=settings.grid_alpha)
|
||||
if self.axes.lines and settings.legend_visible:
|
||||
self.axes.legend(loc=settings.legend_location)
|
||||
self.canvas.draw_idle()
|
||||
@@ -4,6 +4,7 @@ from .openmodelica import (
|
||||
OpenModelicaError,
|
||||
OpenModelicaRunner,
|
||||
ProcessResult,
|
||||
SimulationCancelledError,
|
||||
)
|
||||
from .results import SimulationResult, load_openmodelica_csv
|
||||
from .compile import compile_component, compile_component_sync
|
||||
@@ -17,6 +18,7 @@ from .simulation import (
|
||||
SimulationStateError,
|
||||
simulate,
|
||||
)
|
||||
from .runtime import SimulationRunSettings, SimulationSession
|
||||
|
||||
__all__ = [
|
||||
"ModelBuildResult",
|
||||
@@ -26,9 +28,12 @@ __all__ = [
|
||||
"OpenModelicaRunner",
|
||||
"ProcessResult",
|
||||
"Simulation",
|
||||
"SimulationCancelledError",
|
||||
"SimulationOptions",
|
||||
"SimulationProgress",
|
||||
"SimulationResult",
|
||||
"SimulationRunSettings",
|
||||
"SimulationSession",
|
||||
"SimulationStateError",
|
||||
"compile_component",
|
||||
"compile_component_sync",
|
||||
|
||||
@@ -4,8 +4,10 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import signal
|
||||
import shlex
|
||||
import subprocess
|
||||
import time
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
@@ -27,6 +29,10 @@ class OpenModelicaError(RuntimeError):
|
||||
"""Raised when OMC cannot start or reports a failure."""
|
||||
|
||||
|
||||
class SimulationCancelledError(RuntimeError):
|
||||
"""Raised when a running compiled simulation is cancelled."""
|
||||
|
||||
|
||||
ProcessExecutor = Callable[
|
||||
[Sequence[str], Path, float | None, Mapping[str, str] | None],
|
||||
ProcessResult,
|
||||
@@ -89,6 +95,58 @@ class OpenModelicaRunner:
|
||||
"""Run OMC on an asyncio worker thread."""
|
||||
return await _run_on_worker(self._run, script, working_directory)
|
||||
|
||||
def _run_cancellable(self, script: Path, working_directory: Path, cancel_event: Event) -> ProcessResult:
|
||||
command = (*self.command, str(script.resolve()))
|
||||
return self._run_cancellable_process(command, working_directory, cancel_event)
|
||||
|
||||
def _run_cancellable_process(self, command: Sequence[str], working_directory: Path, cancel_event: Event) -> ProcessResult:
|
||||
process_environment = None
|
||||
if self.environment is not None:
|
||||
process_environment = {**os.environ, **self.environment}
|
||||
try:
|
||||
process = subprocess.Popen(list(command), cwd=working_directory, env=process_environment, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=os.name != "nt")
|
||||
except OSError as exc:
|
||||
raise OpenModelicaError(f"simulation executable could not start: {exc}") from exc
|
||||
deadline = time.monotonic() + self.timeout if self.timeout is not None else None
|
||||
while True:
|
||||
if cancel_event.is_set():
|
||||
_terminate_process(process)
|
||||
try:
|
||||
stdout, stderr = process.communicate(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
_kill_process(process)
|
||||
stdout, stderr = process.communicate()
|
||||
raise SimulationCancelledError("simulation was cancelled")
|
||||
if deadline is not None and time.monotonic() >= deadline:
|
||||
_kill_process(process)
|
||||
process.communicate()
|
||||
raise OpenModelicaError(f"simulation timed out after {self.timeout} seconds")
|
||||
try:
|
||||
stdout, stderr = process.communicate(timeout=0.05)
|
||||
break
|
||||
except subprocess.TimeoutExpired:
|
||||
continue
|
||||
result = ProcessResult(command=tuple(command), return_code=process.returncode, stdout=stdout, stderr=stderr)
|
||||
if result.return_code != 0:
|
||||
details = result.stderr.strip() or result.stdout.strip()
|
||||
suffix = f": {details}" if details else ""
|
||||
raise OpenModelicaError(f"simulation exited with status {result.return_code}{suffix}")
|
||||
return result
|
||||
|
||||
|
||||
def _terminate_process(process: subprocess.Popen[str]) -> None:
|
||||
if os.name == "nt":
|
||||
process.terminate()
|
||||
else:
|
||||
os.killpg(process.pid, signal.SIGTERM)
|
||||
|
||||
|
||||
def _kill_process(process: subprocess.Popen[str]) -> None:
|
||||
if os.name == "nt":
|
||||
process.kill()
|
||||
else:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
|
||||
|
||||
def _command_parts(command: str | Sequence[str]) -> tuple[str, ...]:
|
||||
if isinstance(command, str):
|
||||
@@ -140,7 +198,7 @@ async def _run_on_worker(
|
||||
def invoke() -> None:
|
||||
try:
|
||||
results.append(operation(*args, **kwargs))
|
||||
except BaseException as error:
|
||||
except Exception as error: # noqa: BLE001 - worker must relay operation failures
|
||||
errors.append(error)
|
||||
finally:
|
||||
finished.set()
|
||||
|
||||
67
src/bedit_simulation/runtime.py
Normal file
67
src/bedit_simulation/runtime.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .openmodelica import OpenModelicaRunner
|
||||
from .results import SimulationResult
|
||||
from .simulation import Simulation, SimulationOptions, SimulationStateError
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SimulationRunSettings:
|
||||
start_time: float = 0.0
|
||||
duration: float = 1.0
|
||||
use_timed_steps: bool = False
|
||||
number_of_steps: int = 500
|
||||
step_size: float = 0.002
|
||||
tolerance: float = 1e-6
|
||||
method: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.duration <= 0:
|
||||
raise ValueError("simulation duration must be positive")
|
||||
if self.number_of_steps <= 0:
|
||||
raise ValueError("number of steps must be positive")
|
||||
if self.step_size <= 0:
|
||||
raise ValueError("step size must be positive")
|
||||
if self.tolerance <= 0:
|
||||
raise ValueError("simulation tolerance must be positive")
|
||||
|
||||
|
||||
class SimulationSession:
|
||||
"""Run consecutive time ranges for one compiled model."""
|
||||
|
||||
def __init__(self, model_name: str, executable: str | Path, settings: SimulationRunSettings, *, current_end_time: float | None = None, results: Sequence[SimulationResult] = (), runner: OpenModelicaRunner | None = None) -> None:
|
||||
self.settings = settings
|
||||
self.current_end_time = settings.start_time if current_end_time is None else current_end_time
|
||||
self.results = list(results)
|
||||
self._simulation = Simulation(runner)
|
||||
self._simulation.load_compiled(model_name, executable)
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
return self._simulation.is_running
|
||||
|
||||
async def run_next(self) -> SimulationResult:
|
||||
start_time = self.settings.start_time
|
||||
stop_time = self.current_end_time + self.settings.duration
|
||||
run_duration = stop_time - start_time
|
||||
intervals = math.ceil(run_duration / self.settings.step_size) if self.settings.use_timed_steps else math.ceil(self.settings.number_of_steps * run_duration / self.settings.duration)
|
||||
options = SimulationOptions(start_time=start_time, stop_time=stop_time, number_of_intervals=intervals, tolerance=self.settings.tolerance, method=self.settings.method)
|
||||
result = await self._simulation.run(options)
|
||||
# TODO: Continue from OpenModelica restart state instead of recomputing the full time range.
|
||||
self.current_end_time = stop_time
|
||||
self.results[:] = [result]
|
||||
return result
|
||||
|
||||
def cancel(self) -> bool:
|
||||
return self._simulation.cancel()
|
||||
|
||||
def reset(self) -> None:
|
||||
if self.is_running:
|
||||
raise SimulationStateError("cannot reset while a simulation is running")
|
||||
self.current_end_time = self.settings.start_time
|
||||
self.results.clear()
|
||||
@@ -107,6 +107,9 @@ class Simulation:
|
||||
self.last_result: SimulationResult | None = None
|
||||
self._progress = SimulationProgress()
|
||||
self._progress_lock = Lock()
|
||||
self._cancel_event = Event()
|
||||
self._running_lock = Lock()
|
||||
self._running = False
|
||||
|
||||
@staticmethod
|
||||
def _compose(component: Component) -> CompositionResult:
|
||||
@@ -179,6 +182,25 @@ class Simulation:
|
||||
with self._progress_lock:
|
||||
self._progress = progress
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
with self._running_lock:
|
||||
return self._running
|
||||
|
||||
def cancel(self) -> bool:
|
||||
"""Request cancellation of the active compiled simulation run."""
|
||||
running = self.is_running
|
||||
self._cancel_event.set()
|
||||
return running
|
||||
|
||||
def load_compiled(self, model_name: str, executable: str | Path) -> None:
|
||||
"""Load an existing compiled model without composing or compiling it."""
|
||||
executable_path = Path(executable)
|
||||
if not executable_path.is_file():
|
||||
raise ValueError(f"compiled simulation executable does not exist: {executable_path}")
|
||||
self._set_model("", model_name)
|
||||
self.last_build = ModelBuildResult(model_name=model_name, executable=executable_path, output="", errors="")
|
||||
|
||||
def _active_model(self) -> tuple[str, str]:
|
||||
if self._modelica is None or self._model_name is None:
|
||||
raise SimulationStateError(
|
||||
@@ -411,14 +433,17 @@ class Simulation:
|
||||
raise SimulationStateError(
|
||||
"working_directory must be the directory used by build()"
|
||||
)
|
||||
with self._running_lock:
|
||||
if self._running:
|
||||
raise SimulationStateError("a simulation is already running")
|
||||
self._running = True
|
||||
self._set_progress(SimulationProgress())
|
||||
result = await _run_on_worker(
|
||||
self._run_built_model,
|
||||
model_name,
|
||||
build.executable,
|
||||
options,
|
||||
directory,
|
||||
)
|
||||
try:
|
||||
result = await _run_on_worker(self._run_built_model, model_name, build.executable, options, directory)
|
||||
finally:
|
||||
self._cancel_event.clear()
|
||||
with self._running_lock:
|
||||
self._running = False
|
||||
self.last_result = result
|
||||
return result
|
||||
|
||||
@@ -509,12 +534,9 @@ class Simulation:
|
||||
if options.method:
|
||||
arguments.append(f"-s={options.method}")
|
||||
script_path = directory / _RUN_SCRIPT_FILE
|
||||
script_path.write_text(
|
||||
_executable_script(arguments, directory),
|
||||
encoding="utf-8",
|
||||
)
|
||||
script_path.write_text(_executable_script(arguments, directory), encoding="utf-8")
|
||||
try:
|
||||
process = self.runner._run(script_path, directory)
|
||||
process = self.runner._run_cancellable(script_path, directory, self._cancel_event)
|
||||
finally:
|
||||
command_finished.set()
|
||||
reader.join(timeout=20)
|
||||
@@ -649,17 +671,13 @@ async def simulate(
|
||||
)
|
||||
|
||||
|
||||
def _executable_script(
|
||||
arguments: Sequence[str],
|
||||
working_directory: Path,
|
||||
) -> str:
|
||||
def _executable_script(arguments: Sequence[str], working_directory: Path) -> str:
|
||||
"""Create an OMC script that starts a compiled simulation binary."""
|
||||
command = shlex.join(arguments)
|
||||
return "\n".join(
|
||||
[
|
||||
f"cd({json.dumps(str(working_directory.resolve()))});",
|
||||
f"status := system({json.dumps(command)}, "
|
||||
f"{json.dumps(_RUN_OUTPUT_FILE)});",
|
||||
f"status := system({json.dumps(command)}, {json.dumps(_RUN_OUTPUT_FILE)});",
|
||||
"if status <> 0 then",
|
||||
f" print(readFile({json.dumps(_RUN_OUTPUT_FILE)}));",
|
||||
" exit(1);",
|
||||
|
||||
7982
untitled.besim.json
7982
untitled.besim.json
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user