Single omc session

This commit is contained in:
Joppe Blondel
2026-08-19 11:19:36 +02:00
parent 16d9cc7651
commit 6ebeb2607b
8 changed files with 113 additions and 18 deletions
+1
View File
@@ -11,6 +11,7 @@ requires-python = ">=3.10"
dependencies = [ dependencies = [
"matplotlib>=3.8,<4", "matplotlib>=3.8,<4",
"msgpack>=1.0,<2", "msgpack>=1.0,<2",
"OMPython>=3.6,<5",
"PySide6>=6.7,<7", "PySide6>=6.7,<7",
] ]
+4 -1
View File
@@ -21,6 +21,7 @@ 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 from bedit_gui.versions import BEDIT_VERSION
from bedit_simulation import OpenModelicaRunner, compile_component_sync
def parse_arguments(): def parse_arguments():
parser = argparse.ArgumentParser(exit_on_error=False) parser = argparse.ArgumentParser(exit_on_error=False)
@@ -46,6 +47,8 @@ def main() -> int:
app.setApplicationVersion(BEDIT_VERSION) app.setApplicationVersion(BEDIT_VERSION)
settings = ApplicationSettings() settings = ApplicationSettings()
omc_runner = OpenModelicaRunner()
app.aboutToQuit.connect(omc_runner.close)
document = Document(app) document = Document(app)
window = MainWindow(settings.snap_to_grid_size) window = MainWindow(settings.snap_to_grid_size)
window.ui.actionAbout.triggered.connect(lambda: QMessageBox.about(window, "About BEdit", f"BEdit {BEDIT_VERSION}")) window.ui.actionAbout.triggered.connect(lambda: QMessageBox.about(window, "About BEdit", f"BEdit {BEDIT_VERSION}"))
@@ -55,7 +58,7 @@ def main() -> int:
DocumentController(document, window) DocumentController(document, window)
settings_controller = SettingsController(window, settings) settings_controller = SettingsController(window, settings)
SimulationSettingsController(document, window) SimulationSettingsController(document, window)
SimulationController(document, window) SimulationController(document, window, compiler=lambda component, directory: compile_component_sync(component, directory, runner=omc_runner))
UndoController(document, window) UndoController(document, window)
ViewMenuController(window) ViewMenuController(window)
document_tree_controller = DocumentTreeController(document, window) document_tree_controller = DocumentTreeController(document, window)
@@ -13,6 +13,7 @@ from bedit_gui.services.application_logging import get_logger
from bedit_gui.simulation_models import CompiledModel, SimulationRoot from bedit_gui.simulation_models import CompiledModel, SimulationRoot
from bedit_gui.views.dialogs.simulation_settings_dialog import SimulationSettingsDialog from bedit_gui.views.dialogs.simulation_settings_dialog import SimulationSettingsDialog
from bedit_gui.views.simulation_window import SimulationWindow from bedit_gui.views.simulation_window import SimulationWindow
from bedit_simulation import OpenModelicaRunner
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -20,9 +21,10 @@ logger = get_logger(__name__)
class SimulationFileController(QObject): class SimulationFileController(QObject):
runtime_changed = Signal() runtime_changed = Signal()
def __init__(self, window: SimulationWindow) -> None: def __init__(self, window: SimulationWindow, runner: OpenModelicaRunner | None = None) -> None:
super().__init__(window) super().__init__(window)
self.window = window self.window = window
self.runner = runner
self.root: SimulationRoot | None = None self.root: SimulationRoot | None = None
self.compiled_model: CompiledModel | None = None self.compiled_model: CompiledModel | None = None
self.path: Path | None = None self.path: Path | None = None
@@ -147,14 +149,14 @@ class SimulationFileController(QObject):
def _compile_with_wait_cursor(self, path: Path, *, component: str | None, simulation: str | None) -> tuple[SimulationRoot, CompiledModel]: def _compile_with_wait_cursor(self, path: Path, *, component: str | None, simulation: str | None) -> tuple[SimulationRoot, CompiledModel]:
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
try: try:
return load_and_compile_bedit(path, component_selector=component, simulation_selector=simulation) return load_and_compile_bedit(path, component_selector=component, simulation_selector=simulation, runner=self.runner)
finally: finally:
QApplication.restoreOverrideCursor() QApplication.restoreOverrideCursor()
def _recompile_with_wait_cursor(self, root: SimulationRoot) -> CompiledModel: def _recompile_with_wait_cursor(self, root: SimulationRoot) -> CompiledModel:
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
try: try:
return compile_simulation_root(root) return compile_simulation_root(root, runner=self.runner)
finally: finally:
QApplication.restoreOverrideCursor() QApplication.restoreOverrideCursor()
+7 -7
View File
@@ -7,7 +7,7 @@ from bedit_core.models import Component, ComponentID, Document, GraphImplementat
from bedit_gui.models import Simulation, SimulationMethod from bedit_gui.models import Simulation, SimulationMethod
from bedit_gui.services import document_files from bedit_gui.services import document_files
from bedit_gui.simulation_models import CompiledModel, SimulationRoot from bedit_gui.simulation_models import CompiledModel, SimulationRoot
from bedit_simulation import compile_component_sync from bedit_simulation import OpenModelicaRunner, compile_component_sync
def component_choices(document: Document) -> list[tuple[ComponentID, Component, str]]: def component_choices(document: Document) -> list[tuple[ComponentID, Component, str]]:
@@ -24,7 +24,7 @@ def component_choices(document: Document) -> list[tuple[ComponentID, Component,
return choices return choices
def load_and_compile_bedit(path: str | Path, *, component_selector: str | None = None, simulation_selector: str | None = None, working_directory: str | Path | None = None, omc_command: str = "omc") -> tuple[SimulationRoot, CompiledModel]: def load_and_compile_bedit(path: str | Path, *, component_selector: str | None = None, simulation_selector: str | None = None, working_directory: str | Path | None = None, omc_command: str = "omc", runner: OpenModelicaRunner | None = None) -> tuple[SimulationRoot, CompiledModel]:
"""Open a BEdit document, resolve one launch target, and compile it.""" """Open a BEdit document, resolve one launch target, and compile it."""
if bool(component_selector) == bool(simulation_selector): if bool(component_selector) == bool(simulation_selector):
raise ValueError("specify exactly one component or simulation settings block") raise ValueError("specify exactly one component or simulation settings block")
@@ -57,16 +57,16 @@ def load_and_compile_bedit(path: str | Path, *, component_selector: str | None =
settings_name=settings_name, settings_name=settings_name,
settings=settings, settings=settings,
) )
return root, _compile(component, build_directory, omc_command) return root, _compile(component, build_directory, omc_command, runner)
def compile_simulation_root(root: SimulationRoot, *, working_directory: str | Path | None = None, omc_command: str = "omc") -> CompiledModel: def compile_simulation_root(root: SimulationRoot, *, working_directory: str | Path | None = None, omc_command: str = "omc", runner: OpenModelicaRunner | None = None) -> CompiledModel:
if root.source_document is None: if root.source_document is None:
raise ValueError("the simulation does not reference a BEdit source document and cannot be recompiled") raise ValueError("the simulation does not reference a BEdit source document and cannot be recompiled")
document = document_files.load(root.source_document) document = document_files.load(root.source_document)
_component_id, component, _component_path = _find_component(component_choices(document), str(root.component)) _component_id, component, _component_path = _find_component(component_choices(document), str(root.component))
build_directory = Path(working_directory) if working_directory is not None else Path(tempfile.mkdtemp(prefix="bedit-compiled-")) build_directory = Path(working_directory) if working_directory is not None else Path(tempfile.mkdtemp(prefix="bedit-compiled-"))
return _compile(component, build_directory, omc_command) return _compile(component, build_directory, omc_command, runner)
def _find_component(choices: list[tuple[ComponentID, Component, str]], selector: str) -> tuple[ComponentID, Component, str]: def _find_component(choices: list[tuple[ComponentID, Component, str]], selector: str) -> tuple[ComponentID, Component, str]:
@@ -90,6 +90,6 @@ def _default_settings(component_id: ComponentID) -> Simulation:
) )
def _compile(component: Component, working_directory: Path, omc_command: str) -> CompiledModel: def _compile(component: Component, working_directory: Path, omc_command: str, runner: OpenModelicaRunner | None) -> CompiledModel:
build = compile_component_sync(component, working_directory, omc_command=omc_command) build = compile_component_sync(component, working_directory, omc_command=omc_command, runner=runner)
return CompiledModel(build.model_name, str(build.executable.resolve()), str(build.executable.parent.resolve()), build.output, build.errors) return CompiledModel(build.model_name, str(build.executable.resolve()), str(build.executable.parent.resolve()), build.output, build.errors)
+4 -1
View File
@@ -14,6 +14,7 @@ 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 from bedit_gui.versions import BESIM_VERSION
from bedit_simulation import OpenModelicaRunner
def parse_arguments(arguments: list[str] | None = None) -> argparse.Namespace: def parse_arguments(arguments: list[str] | None = None) -> argparse.Namespace:
@@ -46,7 +47,9 @@ def main(arguments: list[str] | None = None) -> int:
window = SimulationWindow() window = SimulationWindow()
window.ui.actionAbout.triggered.connect(lambda: QMessageBox.about(window, "About BEsim", f"BEsim {BESIM_VERSION}")) window.ui.actionAbout.triggered.connect(lambda: QMessageBox.about(window, "About BEsim", f"BEsim {BESIM_VERSION}"))
window.ui.actionAbout_QT.triggered.connect(app.aboutQt) window.ui.actionAbout_QT.triggered.connect(app.aboutQt)
controller = SimulationFileController(window) omc_runner = OpenModelicaRunner()
app.aboutToQuit.connect(omc_runner.close)
controller = SimulationFileController(window, omc_runner)
settings = SimulationApplicationSettings() settings = SimulationApplicationSettings()
LogController(window, settings.log_level) LogController(window, settings.log_level)
+4 -4
View File
@@ -9,13 +9,13 @@ from .openmodelica import OpenModelicaRunner
from .simulation import ModelBuildResult, Simulation from .simulation import ModelBuildResult, Simulation
async def compile_component(component: Component, working_directory: str | Path, *, omc_command: str = "omc", timeout: float | None = None) -> ModelBuildResult: async def compile_component(component: Component, working_directory: str | Path, *, omc_command: str = "omc", timeout: float | None = None, runner: OpenModelicaRunner | None = None) -> ModelBuildResult:
"""Compose and compile a component without creating a GUI application.""" """Compose and compile a component without creating a GUI application."""
simulation = Simulation(OpenModelicaRunner(omc_command, timeout=timeout)) simulation = Simulation(runner or OpenModelicaRunner(omc_command, timeout=timeout))
await simulation.load(component) await simulation.load(component)
return await simulation.compile(working_directory) return await simulation.compile(working_directory)
def compile_component_sync(component: Component, working_directory: str | Path, *, omc_command: str = "omc", timeout: float | None = None) -> ModelBuildResult: def compile_component_sync(component: Component, working_directory: str | Path, *, omc_command: str = "omc", timeout: float | None = None, runner: OpenModelicaRunner | None = None) -> ModelBuildResult:
"""Synchronous entry point for scripts and non-async suite applications.""" """Synchronous entry point for scripts and non-async suite applications."""
return asyncio.run(compile_component(component, working_directory, omc_command=omc_command, timeout=timeout)) return asyncio.run(compile_component(component, working_directory, omc_command=omc_command, timeout=timeout, runner=runner))
+87 -2
View File
@@ -6,13 +6,15 @@ import asyncio
import os import os
import signal import signal
import shlex import shlex
import shutil
import subprocess import subprocess
import time import time
from collections.abc import Callable, Mapping, Sequence from collections.abc import Callable, Mapping, Sequence
from contextlib import suppress
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from threading import Event, Thread from threading import Event, Lock, Thread
from typing import Any, TypeVar from typing import Any, Protocol, Self, TypeVar
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -48,6 +50,15 @@ ProcessExecutor = Callable[
_Result = TypeVar("_Result") _Result = TypeVar("_Result")
class InteractiveSession(Protocol):
"""Small part of the OMPython session API used by the runner."""
def sendExpression(self, command: str, parsed: bool = True) -> Any: ...
SessionFactory = Callable[[tuple[str, ...], float | None], InteractiveSession]
class OpenModelicaRunner: class OpenModelicaRunner:
"""Execute ``.mos`` scripts using an OMC command or wrapper script. """Execute ``.mos`` scripts using an OMC command or wrapper script.
@@ -63,14 +74,22 @@ class OpenModelicaRunner:
timeout: float | None = None, timeout: float | None = None,
environment: Mapping[str, str] | None = None, environment: Mapping[str, str] | None = None,
executor: ProcessExecutor | None = None, executor: ProcessExecutor | None = None,
session_factory: SessionFactory | None = None,
reuse_session: bool = True,
) -> None: ) -> None:
self.command = _command_parts(command) self.command = _command_parts(command)
self.timeout = timeout self.timeout = timeout
self.environment = environment self.environment = environment
self._executor = executor or _execute_process self._executor = executor or _execute_process
self._session_factory = session_factory or _create_interactive_session
self._reuse_session = reuse_session and executor is None and environment is None and len(self.command) == 1
self._session: InteractiveSession | None = None
self._session_lock = Lock()
def _run(self, script: Path, working_directory: Path) -> ProcessResult: def _run(self, script: Path, working_directory: Path) -> ProcessResult:
"""Run ``script`` in ``working_directory`` and return captured output.""" """Run ``script`` in ``working_directory`` and return captured output."""
if self._reuse_session:
return self._run_in_session(script, working_directory)
command = (*self.command, str(script.resolve())) command = (*self.command, str(script.resolve()))
try: try:
result = self._executor( result = self._executor(
@@ -95,6 +114,48 @@ class OpenModelicaRunner:
) )
return result return result
def _run_in_session(self, script: Path, working_directory: Path) -> ProcessResult:
script_path = script.resolve()
expression = f"runScript({_modelica_string(str(script_path))})"
with self._session_lock:
try:
session = self._session
if session is None:
session = self._session_factory(self.command, self.timeout)
self._session = session
output = session.sendExpression(expression, parsed=False)
except Exception as exc: # OMPython exceptions are optional at import time
self._close_session_locked()
if isinstance(exc, OpenModelicaError):
raise
raise OpenModelicaError(f"OpenModelica session failed: {exc}") from exc
return ProcessResult(command=(*self.command, str(script_path)), return_code=0, stdout=str(output), stderr="")
@property
def session_running(self) -> bool:
"""Return whether this runner has started its reusable OMC session."""
with self._session_lock:
return self._session is not None
def close(self) -> None:
"""Stop the reusable OMC session, if it has been started."""
with self._session_lock:
self._close_session_locked()
def _close_session_locked(self) -> None:
session = self._session
self._session = None
if session is None:
return
with suppress(Exception):
session.sendExpression("quit()", parsed=False)
def __enter__(self) -> Self:
return self
def __exit__(self, *_args: object) -> None:
self.close()
async def run( async def run(
self, self,
script: Path, script: Path,
@@ -167,6 +228,30 @@ def _command_parts(command: str | Sequence[str]) -> tuple[str, ...]:
return parts return parts
def _modelica_string(value: str) -> str:
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
def _create_interactive_session(command: tuple[str, ...], timeout: float | None) -> InteractiveSession:
if len(command) != 1:
raise OpenModelicaError("a reusable OpenModelica session requires a single OMC executable command")
executable = shutil.which(command[0])
if executable is None:
raise OpenModelicaError(f"OpenModelica command {command[0]!r} was not found")
try:
from OMPython import OMCSessionZMQ
except ImportError:
raise OpenModelicaError("reusable OpenModelica sessions require the OMPython package") from None
# Preserve wrapper symlinks: OMPython reconstructs ``<omhome>/bin/omc``.
omhome = str(Path(executable).parent.parent)
try:
if timeout is None:
return OMCSessionZMQ(omhome=omhome)
return OMCSessionZMQ(timeout=timeout, omhome=omhome)
except Exception as exc:
raise OpenModelicaError(f"OpenModelica session could not start: {exc}") from exc
def _execute_process( def _execute_process(
command: Sequence[str], command: Sequence[str],
working_directory: Path, working_directory: Path,
+1
View File
@@ -705,6 +705,7 @@ def _load_model_script(
]) ])
return "\n".join( return "\n".join(
[ [
"clear();",
f"cd({json.dumps(str(working_directory.resolve()))});", f"cd({json.dumps(str(working_directory.resolve()))});",
*load_libraries, *load_libraries,
f"loaded := loadFile({json.dumps(_MODEL_FILE)});", f"loaded := loadFile({json.dumps(_MODEL_FILE)});",