Compare commits

..

7 Commits

27 changed files with 9289 additions and 26 deletions

View File

@@ -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. - `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. - 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 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 ## Verification

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import sys import sys
import argparse 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.clipboard_controller import ClipboardController, DocumentTreeClipboardHandler, TextClipboardHandler
from bedit_gui.controllers.document_controller import DocumentController 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.application_settings import ApplicationSettings
from bedit_gui.services.clipboard import ClipboardService from bedit_gui.services.clipboard import ClipboardService
from bedit_gui.views.main_window import MainWindow from bedit_gui.views.main_window import MainWindow
from bedit_gui.versions import BEDIT_VERSION
def parse_arguments(): def parse_arguments():
parser = argparse.ArgumentParser(exit_on_error=False) parser = argparse.ArgumentParser(exit_on_error=False)
@@ -26,6 +27,7 @@ def parse_arguments():
parser.add_argument( parser.add_argument(
"-f", "--file", type=str, help="Path to a file to open", default=None, required=False "-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() return parser.parse_args()
@@ -40,10 +42,13 @@ def main() -> int:
app.setOrganizationName("BEdit") app.setOrganizationName("BEdit")
app.setApplicationName("BEdit") app.setApplicationName("BEdit")
app.setApplicationVersion(BEDIT_VERSION)
settings = ApplicationSettings() settings = ApplicationSettings()
document = Document(app) document = Document(app)
window = MainWindow() 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) LogController(window, settings.log_level)
DocumentController(document, window) DocumentController(document, window)

View 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)

View File

@@ -13,6 +13,7 @@ from bedit_gui.documents import Document
from bedit_gui.services import simulation_files from bedit_gui.services import simulation_files
from bedit_gui.services.application_logging import get_logger from bedit_gui.services.application_logging import get_logger
from bedit_gui.services.simulation_loader import component_choices 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.simulation_models import CompiledModel, SimulationRoot
from bedit_gui.views.main_window import MainWindow from bedit_gui.views.main_window import MainWindow
from bedit_simulation import ModelBuildResult, compile_component_sync 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: 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)] 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) launched = QProcess.startDetached(sys.executable, arguments)
return launched[0] if isinstance(launched, tuple) else bool(launched) return launched[0] if isinstance(launched, tuple) else bool(launched)

View File

@@ -1,8 +1,9 @@
from __future__ import annotations from __future__ import annotations
from copy import deepcopy
from pathlib import Path 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 PySide6.QtWidgets import QApplication, QDialog, QFileDialog, QInputDialog, QMessageBox
from bedit_gui.services import document_files, simulation_files from bedit_gui.services import document_files, simulation_files
@@ -17,6 +18,8 @@ logger = get_logger(__name__)
class SimulationFileController(QObject): class SimulationFileController(QObject):
runtime_changed = Signal()
def __init__(self, window: SimulationWindow) -> None: def __init__(self, window: SimulationWindow) -> None:
super().__init__(window) super().__init__(window)
self.window = window self.window = window
@@ -34,10 +37,12 @@ class SimulationFileController(QObject):
self.compiled_model = None self.compiled_model = None
self.path = None self.path = None
self._update_window() self._update_window()
self.runtime_changed.emit()
logger.info("Created new simulation") 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) file_path = Path(path)
plot_tabs = deepcopy(self.root.plot_tabs) if preserve_plots and self.root is not None else None
try: try:
if file_path.suffix.lower() == ".bes" or simulation_files.is_simulation_json(file_path): if file_path.suffix.lower() == ".bes" or simulation_files.is_simulation_json(file_path):
self.root = simulation_files.load(file_path) self.root = simulation_files.load(file_path)
@@ -49,10 +54,20 @@ class SimulationFileController(QObject):
except (OSError, RuntimeError, TypeError, ValueError): except (OSError, RuntimeError, TypeError, ValueError):
logger.exception("Could not open simulation %s", file_path) logger.exception("Could not open simulation %s", file_path)
raise 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._log_compilation()
self._update_window() self._update_window()
self.runtime_changed.emit()
logger.info("Opened simulation: %s", file_path) 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: def open_dialog(self) -> None:
filename, _ = QFileDialog.getOpenFileName(self.window, "Open Simulation", "", "Simulation and BEdit files (*.bes *.beb *.json)") filename, _ = QFileDialog.getOpenFileName(self.window, "Open Simulation", "", "Simulation and BEdit files (*.bes *.beb *.json)")
if not filename: if not filename:
@@ -111,9 +126,12 @@ class SimulationFileController(QObject):
self.root.component = updated.component 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.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.settings_name = updated.name
self.root.current_end_time = updated.start_time
self.root.results.clear()
if component_changed: if component_changed:
self.compiled_model = None self.compiled_model = None
self._update_window() self._update_window()
self.runtime_changed.emit()
logger.info("Updated simulation settings: %s", updated.name) logger.info("Updated simulation settings: %s", updated.name)
def _source_components(self) -> list[tuple]: def _source_components(self) -> list[tuple]:

View 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()

View 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")

View 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()

View 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)

View File

@@ -7,13 +7,18 @@ from PySide6.QtWidgets import QApplication, QMessageBox
from bedit_gui.controllers.simulation_file_controller import SimulationFileController from bedit_gui.controllers.simulation_file_controller import SimulationFileController
from bedit_gui.controllers.log_controller import LogController 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.simulation_models import CompiledModel
from bedit_gui.services.application_settings import SimulationApplicationSettings from bedit_gui.services.application_settings import SimulationApplicationSettings
from bedit_gui.views.simulation_window import SimulationWindow 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: def parse_arguments(arguments: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Open and run BEdit simulations") 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("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("-f", "--file", dest="file_option", help=argparse.SUPPRESS)
parser.add_argument("--handoff", action="store_true", 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.setOrganizationName("BEsim")
app.setApplicationName("BEsim") app.setApplicationName("BEsim")
app.setApplicationVersion(BESIM_VERSION)
window = SimulationWindow() 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) controller = SimulationFileController(window)
settings = SimulationApplicationSettings() settings = SimulationApplicationSettings()
LogController(window, settings.log_level) 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() window.showMaximized()

View File

@@ -1,11 +1,12 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping
from dataclasses import dataclass from dataclasses import dataclass, field
from typing import Any from typing import Any
from bedit_core.models import ComponentID from bedit_core.models import ComponentID
from bedit_gui.models import Simulation from bedit_gui.models import Simulation
from bedit_simulation import SimulationResult
@dataclass @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} 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 @dataclass
class SimulationRoot: class SimulationRoot:
format_version: int format_version: int
@@ -33,6 +119,9 @@ class SimulationRoot:
component_path: str component_path: str
settings_name: str | None settings_name: str | None
settings: Simulation 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 @classmethod
def from_data(cls, data: Mapping[str, Any]) -> SimulationRoot: def from_data(cls, data: Mapping[str, Any]) -> SimulationRoot:
@@ -46,6 +135,9 @@ class SimulationRoot:
component_path=str(data.get("component_path", "")), component_path=str(data.get("component_path", "")),
settings_name=str(data["settings_name"]) if data.get("settings_name") is not None else None, settings_name=str(data["settings_name"]) if data.get("settings_name") is not None else None,
settings=Simulation.from_data(data["settings"]), 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]: def to_data(self) -> dict[str, Any]:
@@ -58,4 +150,19 @@ class SimulationRoot:
"component_path": self.component_path, "component_path": self.component_path,
"settings_name": self.settings_name, "settings_name": self.settings_name,
"settings": self.settings.to_data(), "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}

View 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 &amp; 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>

View File

@@ -0,0 +1,4 @@
"""Independent versions for the BEdit desktop applications."""
BEDIT_VERSION = "0.2.0"
BESIM_VERSION = "0.1.0"

View 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 "")

View File

@@ -70,6 +70,9 @@ class SimulationSettingsDialog(QDialog):
if simulation.dassl_tolerance <= 0: if simulation.dassl_tolerance <= 0:
QMessageBox.warning(self, "Invalid simulation", "The DASSL tolerance must be greater than zero.") QMessageBox.warning(self, "Invalid simulation", "The DASSL tolerance must be greater than zero.")
return return
if simulation.duration <= 0:
QMessageBox.warning(self, "Invalid simulation", "The simulation length must be greater than zero.")
return
simulation.name = simulation.name.strip() simulation.name = simulation.name.strip()
self._database.active_simulation = self._simulation_id() self._database.active_simulation = self._simulation_id()
super().accept() super().accept()

View 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()

View File

@@ -4,6 +4,7 @@ from .openmodelica import (
OpenModelicaError, OpenModelicaError,
OpenModelicaRunner, OpenModelicaRunner,
ProcessResult, ProcessResult,
SimulationCancelledError,
) )
from .results import SimulationResult, load_openmodelica_csv from .results import SimulationResult, load_openmodelica_csv
from .compile import compile_component, compile_component_sync from .compile import compile_component, compile_component_sync
@@ -17,6 +18,7 @@ from .simulation import (
SimulationStateError, SimulationStateError,
simulate, simulate,
) )
from .runtime import SimulationRunSettings, SimulationSession
__all__ = [ __all__ = [
"ModelBuildResult", "ModelBuildResult",
@@ -26,9 +28,12 @@ __all__ = [
"OpenModelicaRunner", "OpenModelicaRunner",
"ProcessResult", "ProcessResult",
"Simulation", "Simulation",
"SimulationCancelledError",
"SimulationOptions", "SimulationOptions",
"SimulationProgress", "SimulationProgress",
"SimulationResult", "SimulationResult",
"SimulationRunSettings",
"SimulationSession",
"SimulationStateError", "SimulationStateError",
"compile_component", "compile_component",
"compile_component_sync", "compile_component_sync",

View File

@@ -4,8 +4,10 @@ from __future__ import annotations
import asyncio import asyncio
import os import os
import signal
import shlex import shlex
import subprocess import subprocess
import time
from collections.abc import Callable, Mapping, Sequence from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@@ -27,6 +29,10 @@ class OpenModelicaError(RuntimeError):
"""Raised when OMC cannot start or reports a failure.""" """Raised when OMC cannot start or reports a failure."""
class SimulationCancelledError(RuntimeError):
"""Raised when a running compiled simulation is cancelled."""
ProcessExecutor = Callable[ ProcessExecutor = Callable[
[Sequence[str], Path, float | None, Mapping[str, str] | None], [Sequence[str], Path, float | None, Mapping[str, str] | None],
ProcessResult, ProcessResult,
@@ -89,6 +95,58 @@ class OpenModelicaRunner:
"""Run OMC on an asyncio worker thread.""" """Run OMC on an asyncio worker thread."""
return await _run_on_worker(self._run, script, working_directory) 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, ...]: def _command_parts(command: str | Sequence[str]) -> tuple[str, ...]:
if isinstance(command, str): if isinstance(command, str):
@@ -140,7 +198,7 @@ async def _run_on_worker(
def invoke() -> None: def invoke() -> None:
try: try:
results.append(operation(*args, **kwargs)) results.append(operation(*args, **kwargs))
except BaseException as error: except Exception as error: # noqa: BLE001 - worker must relay operation failures
errors.append(error) errors.append(error)
finally: finally:
finished.set() finished.set()

View 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()

View File

@@ -107,6 +107,9 @@ class Simulation:
self.last_result: SimulationResult | None = None self.last_result: SimulationResult | None = None
self._progress = SimulationProgress() self._progress = SimulationProgress()
self._progress_lock = Lock() self._progress_lock = Lock()
self._cancel_event = Event()
self._running_lock = Lock()
self._running = False
@staticmethod @staticmethod
def _compose(component: Component) -> CompositionResult: def _compose(component: Component) -> CompositionResult:
@@ -179,6 +182,25 @@ class Simulation:
with self._progress_lock: with self._progress_lock:
self._progress = progress 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]: def _active_model(self) -> tuple[str, str]:
if self._modelica is None or self._model_name is None: if self._modelica is None or self._model_name is None:
raise SimulationStateError( raise SimulationStateError(
@@ -411,14 +433,17 @@ class Simulation:
raise SimulationStateError( raise SimulationStateError(
"working_directory must be the directory used by build()" "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()) self._set_progress(SimulationProgress())
result = await _run_on_worker( try:
self._run_built_model, result = await _run_on_worker(self._run_built_model, model_name, build.executable, options, directory)
model_name, finally:
build.executable, self._cancel_event.clear()
options, with self._running_lock:
directory, self._running = False
)
self.last_result = result self.last_result = result
return result return result
@@ -509,12 +534,9 @@ class Simulation:
if options.method: if options.method:
arguments.append(f"-s={options.method}") arguments.append(f"-s={options.method}")
script_path = directory / _RUN_SCRIPT_FILE script_path = directory / _RUN_SCRIPT_FILE
script_path.write_text( script_path.write_text(_executable_script(arguments, directory), encoding="utf-8")
_executable_script(arguments, directory),
encoding="utf-8",
)
try: try:
process = self.runner._run(script_path, directory) process = self.runner._run_cancellable(script_path, directory, self._cancel_event)
finally: finally:
command_finished.set() command_finished.set()
reader.join(timeout=20) reader.join(timeout=20)
@@ -649,17 +671,13 @@ async def simulate(
) )
def _executable_script( def _executable_script(arguments: Sequence[str], working_directory: Path) -> str:
arguments: Sequence[str],
working_directory: Path,
) -> str:
"""Create an OMC script that starts a compiled simulation binary.""" """Create an OMC script that starts a compiled simulation binary."""
command = shlex.join(arguments) command = shlex.join(arguments)
return "\n".join( return "\n".join(
[ [
f"cd({json.dumps(str(working_directory.resolve()))});", f"cd({json.dumps(str(working_directory.resolve()))});",
f"status := system({json.dumps(command)}, " f"status := system({json.dumps(command)}, {json.dumps(_RUN_OUTPUT_FILE)});",
f"{json.dumps(_RUN_OUTPUT_FILE)});",
"if status <> 0 then", "if status <> 0 then",
f" print(readFile({json.dumps(_RUN_OUTPUT_FILE)}));", f" print(readFile({json.dumps(_RUN_OUTPUT_FILE)}));",
" exit(1);", " exit(1);",

File diff suppressed because it is too large Load Diff