Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eef77aed09 | ||
|
|
be9e05732f | ||
|
|
6ebeb2607b |
@@ -9,9 +9,12 @@ description = "A Bondgraph and block scheme simulator"
|
|||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"DyMat>=0.7,<1",
|
||||||
"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",
|
||||||
|
"scipy>=1.12,<2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class SimulationRunController(QObject):
|
|||||||
run_cancelled = Signal()
|
run_cancelled = Signal()
|
||||||
run_failed = Signal(object)
|
run_failed = Signal(object)
|
||||||
run_progress = Signal(int)
|
run_progress = Signal(int)
|
||||||
|
segment_completed = Signal()
|
||||||
simulation_state_changed = Signal()
|
simulation_state_changed = Signal()
|
||||||
|
|
||||||
def __init__(self, window: SimulationWindow, files: SimulationFileController, session_factory: SessionFactory = _create_session) -> None:
|
def __init__(self, window: SimulationWindow, files: SimulationFileController, session_factory: SessionFactory = _create_session) -> None:
|
||||||
@@ -45,6 +46,7 @@ class SimulationRunController(QObject):
|
|||||||
self.run_cancelled.connect(self._cancelled)
|
self.run_cancelled.connect(self._cancelled)
|
||||||
self.run_failed.connect(self._failed)
|
self.run_failed.connect(self._failed)
|
||||||
self.run_progress.connect(self.window.ui.progressBar.setValue)
|
self.run_progress.connect(self.window.ui.progressBar.setValue)
|
||||||
|
self.segment_completed.connect(self._segment_completed)
|
||||||
self._load_session()
|
self._load_session()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -98,7 +100,7 @@ class SimulationRunController(QObject):
|
|||||||
self.run_completed.emit(result)
|
self.run_completed.emit(result)
|
||||||
|
|
||||||
async def _run_with_progress(self, session: SimulationSession) -> SimulationResult:
|
async def _run_with_progress(self, session: SimulationSession) -> SimulationResult:
|
||||||
task = asyncio.create_task(session.run_next())
|
task = asyncio.create_task(session.run_next_segmented(lambda _result: self.segment_completed.emit()))
|
||||||
last_progress = -1
|
last_progress = -1
|
||||||
while not task.done():
|
while not task.done():
|
||||||
progress = min(session.get_progress(), 99)
|
progress = min(session.get_progress(), 99)
|
||||||
@@ -108,6 +110,12 @@ class SimulationRunController(QObject):
|
|||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
return await task
|
return await task
|
||||||
|
|
||||||
|
def _segment_completed(self) -> None:
|
||||||
|
self._store_session_state()
|
||||||
|
if self.session is not None:
|
||||||
|
self.window.statusBar().showMessage(f"Simulation running · current time {self.session.current_end_time}")
|
||||||
|
logger.debug("Simulation segment completed at time %s", self.session.current_end_time)
|
||||||
|
|
||||||
def _completed(self, result: SimulationResult) -> None:
|
def _completed(self, result: SimulationResult) -> None:
|
||||||
self._running = False
|
self._running = False
|
||||||
self._store_session_state()
|
self._store_session_state()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from .openmodelica import (
|
|||||||
ProcessResult,
|
ProcessResult,
|
||||||
SimulationCancelledError,
|
SimulationCancelledError,
|
||||||
)
|
)
|
||||||
from .results import SimulationResult, load_openmodelica_csv
|
from .results import SimulationResult, load_openmodelica_csv, load_openmodelica_mat, load_openmodelica_mat_snapshot
|
||||||
from .compile import compile_component, compile_component_sync
|
from .compile import compile_component, compile_component_sync
|
||||||
from .simulation import (
|
from .simulation import (
|
||||||
ModelBuildResult,
|
ModelBuildResult,
|
||||||
@@ -38,5 +38,7 @@ __all__ = [
|
|||||||
"compile_component",
|
"compile_component",
|
||||||
"compile_component_sync",
|
"compile_component_sync",
|
||||||
"load_openmodelica_csv",
|
"load_openmodelica_csv",
|
||||||
|
"load_openmodelica_mat",
|
||||||
|
"load_openmodelica_mat_snapshot",
|
||||||
"simulate",
|
"simulate",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import csv
|
import csv
|
||||||
|
import io
|
||||||
|
import struct
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -44,3 +46,76 @@ def load_openmodelica_csv(path: str | Path) -> dict[str, list[float]]:
|
|||||||
except StopIteration as error:
|
except StopIteration as error:
|
||||||
raise ValueError("simulation result CSV is empty") from error
|
raise ValueError("simulation result CSV is empty") from error
|
||||||
return columns
|
return columns
|
||||||
|
|
||||||
|
|
||||||
|
def load_openmodelica_mat(path: str | Path) -> dict[str, list[float]]:
|
||||||
|
"""Load the MATLAB v4 result layout written by OpenModelica."""
|
||||||
|
try:
|
||||||
|
from DyMat import DyMatFile
|
||||||
|
except ImportError:
|
||||||
|
raise ValueError("reading OpenModelica MAT results requires DyMat") from None
|
||||||
|
try:
|
||||||
|
result = DyMatFile(str(Path(path)))
|
||||||
|
columns = _dymat_columns(result)
|
||||||
|
except (IndexError, KeyError, OSError, TypeError, ValueError) as error:
|
||||||
|
raise ValueError(f"could not read OpenModelica MAT results: {error}") from error
|
||||||
|
return columns
|
||||||
|
|
||||||
|
|
||||||
|
def load_openmodelica_mat_snapshot(path: str | Path) -> dict[str, list[float]]:
|
||||||
|
"""Read the complete time-step columns currently available in a growing MAT file."""
|
||||||
|
try:
|
||||||
|
from DyMat import DyMatFile
|
||||||
|
except ImportError:
|
||||||
|
raise ValueError("reading OpenModelica MAT results requires DyMat") from None
|
||||||
|
try:
|
||||||
|
snapshot = bytearray(Path(path).read_bytes())
|
||||||
|
header_offset, byte_order, rows, columns, element_size, data_offset = _mat_v4_matrix(snapshot, "data_2")
|
||||||
|
available_columns = (len(snapshot) - data_offset) // (rows * element_size)
|
||||||
|
complete_columns = min(columns, available_columns) if columns > 0 else available_columns
|
||||||
|
if complete_columns < 2:
|
||||||
|
raise ValueError("the MAT result does not contain enough complete samples yet")
|
||||||
|
struct.pack_into(f"{byte_order}i", snapshot, header_offset + 8, complete_columns)
|
||||||
|
del snapshot[data_offset + complete_columns * rows * element_size:]
|
||||||
|
columns_data = _dymat_columns(DyMatFile(io.BytesIO(snapshot)))
|
||||||
|
except (IndexError, KeyError, OSError, TypeError, ValueError, struct.error) as error:
|
||||||
|
raise ValueError(f"could not read growing OpenModelica MAT results: {error}") from error
|
||||||
|
return columns_data
|
||||||
|
|
||||||
|
|
||||||
|
def _dymat_columns(result) -> dict[str, list[float]]:
|
||||||
|
blocks = result.blocks()
|
||||||
|
time_block = 2 if 2 in blocks else blocks[-1]
|
||||||
|
times = result.abscissa(time_block, valuesOnly=True).astype(float).tolist()
|
||||||
|
columns = {"time": times}
|
||||||
|
for name in result.names():
|
||||||
|
values = result.data(name).astype(float).tolist()
|
||||||
|
if len(values) != len(times):
|
||||||
|
values = [values[0]] * len(times)
|
||||||
|
columns[name] = values
|
||||||
|
return columns
|
||||||
|
|
||||||
|
|
||||||
|
def _mat_v4_matrix(content: bytearray, target: str) -> tuple[int, str, int, int, int, int]:
|
||||||
|
offset = 0
|
||||||
|
while offset + 20 <= len(content):
|
||||||
|
byte_order = _mat_v4_byte_order(content, offset)
|
||||||
|
matrix_type, rows, columns, imaginary, name_length = struct.unpack_from(f"{byte_order}5i", content, offset)
|
||||||
|
if rows <= 0 or columns < 0 or imaginary not in (0, 1) or name_length <= 0:
|
||||||
|
break
|
||||||
|
name_offset = offset + 20
|
||||||
|
data_offset = name_offset + name_length
|
||||||
|
name = bytes(content[name_offset:data_offset]).split(b"\x00", 1)[0].decode("ascii", errors="replace")
|
||||||
|
precision = (matrix_type // 10) % 10
|
||||||
|
element_size = {0: 8, 1: 4, 2: 4, 3: 2, 4: 2, 5: 1}.get(precision)
|
||||||
|
if element_size is None:
|
||||||
|
break
|
||||||
|
if name == target:
|
||||||
|
return offset, byte_order, rows, columns, element_size, data_offset
|
||||||
|
offset = data_offset + rows * columns * element_size * (2 if imaginary else 1)
|
||||||
|
raise ValueError(f"MAT v4 matrix {target!r} was not found")
|
||||||
|
|
||||||
|
|
||||||
|
def _mat_v4_byte_order(content: bytearray, offset: int) -> str:
|
||||||
|
little_type = struct.unpack_from("<i", content, offset)[0]
|
||||||
|
return "<" if little_type // 1000 in (0, 1) and (little_type // 10) % 10 <= 5 else ">"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import math
|
import math
|
||||||
from collections.abc import Sequence
|
from collections.abc import Callable, Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -9,7 +9,6 @@ from .openmodelica import OpenModelicaRunner
|
|||||||
from .results import SimulationResult
|
from .results import SimulationResult
|
||||||
from .simulation import Simulation, SimulationOptions, SimulationStateError
|
from .simulation import Simulation, SimulationOptions, SimulationStateError
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class SimulationRunSettings:
|
class SimulationRunSettings:
|
||||||
start_time: float = 0.0
|
start_time: float = 0.0
|
||||||
@@ -49,17 +48,52 @@ class SimulationSession:
|
|||||||
return self._simulation.get_progress()
|
return self._simulation.get_progress()
|
||||||
|
|
||||||
async def run_next(self) -> SimulationResult:
|
async def run_next(self) -> SimulationResult:
|
||||||
start_time = self.settings.start_time
|
return await self.run_next_segmented()
|
||||||
stop_time = self.current_end_time + self.settings.duration
|
|
||||||
run_duration = stop_time - start_time
|
async def run_next_segmented(self, segment_completed: Callable[[SimulationResult], None] | None = None) -> SimulationResult:
|
||||||
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)
|
"""Advance one configured duration in one process with live result snapshots."""
|
||||||
options = SimulationOptions(start_time=start_time, stop_time=stop_time, number_of_intervals=intervals, tolerance=self.settings.tolerance, method=self.settings.method)
|
previous_file = self._restart_file()
|
||||||
|
if previous_file is None and self.current_end_time != self.settings.start_time:
|
||||||
|
self.current_end_time = self.settings.start_time
|
||||||
|
self.results.clear()
|
||||||
|
start_time = self.current_end_time
|
||||||
|
stop_time = start_time + self.settings.duration
|
||||||
|
completed_results = list(self.results)
|
||||||
|
result_file = self._simulation.last_build.executable.parent / f"{self._simulation.last_build.executable.stem}_segment_{len(completed_results):06d}.mat"
|
||||||
|
|
||||||
|
def publish(result: SimulationResult) -> None:
|
||||||
|
self.results[:] = [*completed_results, result]
|
||||||
|
if segment_completed is not None:
|
||||||
|
segment_completed(result)
|
||||||
|
|
||||||
|
options = SimulationOptions(
|
||||||
|
start_time=start_time,
|
||||||
|
stop_time=stop_time,
|
||||||
|
number_of_intervals=self._run_intervals(),
|
||||||
|
tolerance=self.settings.tolerance,
|
||||||
|
method=self.settings.method,
|
||||||
|
result_file=result_file,
|
||||||
|
initialization_file=previous_file,
|
||||||
|
initialization_time=start_time if previous_file is not None else None,
|
||||||
|
skip_initialization=previous_file is not None,
|
||||||
|
intermediate_result=publish,
|
||||||
|
)
|
||||||
result = await self._simulation.run(options)
|
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.current_end_time = stop_time
|
||||||
self.results[:] = [result]
|
self.results[:] = [*completed_results, result]
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def _run_intervals(self) -> int:
|
||||||
|
if self.settings.use_timed_steps:
|
||||||
|
return max(1, math.ceil(self.settings.duration / self.settings.step_size))
|
||||||
|
return self.settings.number_of_steps
|
||||||
|
|
||||||
|
def _restart_file(self) -> Path | None:
|
||||||
|
for result in reversed(self.results):
|
||||||
|
if result.result_file is not None and result.result_file.is_file():
|
||||||
|
return result.result_file
|
||||||
|
return None
|
||||||
|
|
||||||
def cancel(self) -> bool:
|
def cancel(self) -> bool:
|
||||||
return self._simulation.cancel()
|
return self._simulation.cancel()
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import time
|
|||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from collections.abc import Sequence
|
from collections.abc import Callable, Sequence
|
||||||
from threading import Event, Lock, Thread
|
from threading import Event, Lock, Thread
|
||||||
|
|
||||||
from bedit_core.modelica import CompositionResult, compose_modelica_model
|
from bedit_core.modelica import CompositionResult, compose_modelica_model
|
||||||
@@ -22,7 +22,7 @@ from .openmodelica import (
|
|||||||
ProcessResult,
|
ProcessResult,
|
||||||
_run_on_worker,
|
_run_on_worker,
|
||||||
)
|
)
|
||||||
from .results import SimulationResult, load_openmodelica_csv
|
from .results import SimulationResult, load_openmodelica_csv, load_openmodelica_mat, load_openmodelica_mat_snapshot
|
||||||
|
|
||||||
_MODEL_FILE = "model.mo"
|
_MODEL_FILE = "model.mo"
|
||||||
_RUN_SCRIPT_FILE = "run.mos"
|
_RUN_SCRIPT_FILE = "run.mos"
|
||||||
@@ -39,6 +39,11 @@ class SimulationOptions:
|
|||||||
number_of_intervals: int = 500
|
number_of_intervals: int = 500
|
||||||
tolerance: float = 1e-6
|
tolerance: float = 1e-6
|
||||||
method: str | None = None
|
method: str | None = None
|
||||||
|
result_file: str | Path | None = None
|
||||||
|
initialization_file: str | Path | None = None
|
||||||
|
initialization_time: float | None = None
|
||||||
|
skip_initialization: bool = False
|
||||||
|
intermediate_result: Callable[[SimulationResult], None] | None = None
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
if self.stop_time <= self.start_time:
|
if self.stop_time <= self.start_time:
|
||||||
@@ -473,7 +478,7 @@ class Simulation:
|
|||||||
options: SimulationOptions,
|
options: SimulationOptions,
|
||||||
directory: Path,
|
directory: Path,
|
||||||
) -> SimulationResult:
|
) -> SimulationResult:
|
||||||
result_path = directory / f"{executable.stem}_res.csv"
|
result_path = Path(options.result_file) if options.result_file is not None else directory / f"{executable.stem}_res.csv"
|
||||||
process = self._run_executable(executable, options, directory)
|
process = self._run_executable(executable, options, directory)
|
||||||
if not result_path.is_file():
|
if not result_path.is_file():
|
||||||
details = process.stderr.strip() or process.stdout.strip()
|
details = process.stderr.strip() or process.stdout.strip()
|
||||||
@@ -491,7 +496,7 @@ class Simulation:
|
|||||||
)
|
)
|
||||||
return SimulationResult(
|
return SimulationResult(
|
||||||
model_name=model_name,
|
model_name=model_name,
|
||||||
data=load_openmodelica_csv(result_path),
|
data=load_openmodelica_mat(result_path) if result_path.suffix.lower() == ".mat" else load_openmodelica_csv(result_path),
|
||||||
process_output=process.stdout,
|
process_output=process.stdout,
|
||||||
process_errors=process.stderr,
|
process_errors=process.stderr,
|
||||||
result_file=result_path,
|
result_file=result_path,
|
||||||
@@ -514,7 +519,7 @@ class Simulation:
|
|||||||
|
|
||||||
reader = Thread(
|
reader = Thread(
|
||||||
target=self._read_progress,
|
target=self._read_progress,
|
||||||
args=(server, command_finished, reader_errors),
|
args=(server, command_finished, reader_errors, options.result_file, self._model_name, options.intermediate_result),
|
||||||
name="bedit-simulation-progress",
|
name="bedit-simulation-progress",
|
||||||
daemon=True,
|
daemon=True,
|
||||||
)
|
)
|
||||||
@@ -529,10 +534,18 @@ class Simulation:
|
|||||||
f"-stopTime={options.stop_time}",
|
f"-stopTime={options.stop_time}",
|
||||||
f"-stepSize={step_size}",
|
f"-stepSize={step_size}",
|
||||||
f"-tolerance={options.tolerance}",
|
f"-tolerance={options.tolerance}",
|
||||||
"-outputFormat=csv",
|
f"-outputFormat={'mat' if options.result_file is not None and Path(options.result_file).suffix.lower() == '.mat' else 'csv'}",
|
||||||
f"-port={port}",
|
f"-port={port}",
|
||||||
"-logFormat=xmltcp",
|
"-logFormat=xmltcp",
|
||||||
]
|
]
|
||||||
|
if options.result_file is not None:
|
||||||
|
arguments.extend([f"-r={Path(options.result_file).resolve()}", "-emit_protected", "-ignoreHideResult"])
|
||||||
|
if options.initialization_file is not None:
|
||||||
|
arguments.append(f"-iif={Path(options.initialization_file).resolve()}")
|
||||||
|
if options.initialization_time is not None:
|
||||||
|
arguments.append(f"-iit={options.initialization_time}")
|
||||||
|
if options.skip_initialization:
|
||||||
|
arguments.append("-iim=none")
|
||||||
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
|
||||||
@@ -556,6 +569,9 @@ class Simulation:
|
|||||||
server: socket.socket,
|
server: socket.socket,
|
||||||
command_finished: Event,
|
command_finished: Event,
|
||||||
errors: list[BaseException],
|
errors: list[BaseException],
|
||||||
|
result_file: str | Path | None,
|
||||||
|
model_name: str | None,
|
||||||
|
intermediate_result: Callable[[SimulationResult], None] | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
try:
|
try:
|
||||||
connection: socket.socket | None = None
|
connection: socket.socket | None = None
|
||||||
@@ -574,6 +590,7 @@ class Simulation:
|
|||||||
raise OpenModelicaError(
|
raise OpenModelicaError(
|
||||||
"simulation did not connect to the progress server"
|
"simulation did not connect to the progress server"
|
||||||
)
|
)
|
||||||
|
next_snapshot = 10
|
||||||
with connection, connection.makefile("r", encoding="utf-8") as stream:
|
with connection, connection.makefile("r", encoding="utf-8") as stream:
|
||||||
for line in stream:
|
for line in stream:
|
||||||
try:
|
try:
|
||||||
@@ -583,9 +600,25 @@ class Simulation:
|
|||||||
# messages. Keep consuming the stream so the simulator
|
# messages. Keep consuming the stream so the simulator
|
||||||
# does not receive SIGPIPE.
|
# does not receive SIGPIPE.
|
||||||
continue
|
continue
|
||||||
|
percentage = self.get_progress()
|
||||||
|
if intermediate_result is not None and percentage >= next_snapshot:
|
||||||
|
self._publish_intermediate_result(result_file, model_name, intermediate_result)
|
||||||
|
next_snapshot = (percentage // 10 + 1) * 10
|
||||||
except (OSError, OpenModelicaError) as error:
|
except (OSError, OpenModelicaError) as error:
|
||||||
errors.append(error)
|
errors.append(error)
|
||||||
|
|
||||||
|
def _publish_intermediate_result(self, result_file: str | Path | None, model_name: str | None, callback: Callable[[SimulationResult], None]) -> None:
|
||||||
|
if result_file is None or model_name is None:
|
||||||
|
return
|
||||||
|
path = Path(result_file)
|
||||||
|
if not path.is_file() or path.suffix.lower() != ".mat":
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
data = load_openmodelica_mat_snapshot(path)
|
||||||
|
except Exception: # noqa: BLE001 - the MAT file may be incomplete while the simulator writes it
|
||||||
|
return
|
||||||
|
callback(SimulationResult(model_name=model_name, data=data, result_file=path))
|
||||||
|
|
||||||
def _parse_progress_line(self, line: str) -> None:
|
def _parse_progress_line(self, line: str) -> None:
|
||||||
element = ET.fromstring(line.strip())
|
element = ET.fromstring(line.strip())
|
||||||
if element.tag != "status":
|
if element.tag != "status":
|
||||||
@@ -705,6 +738,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)});",
|
||||||
|
|||||||
+1
-1
@@ -2049,7 +2049,7 @@
|
|||||||
"name": "run bondgraph",
|
"name": "run bondgraph",
|
||||||
"start_time": 0.0,
|
"start_time": 0.0,
|
||||||
"duration": 10.0,
|
"duration": 10.0,
|
||||||
"use_timed_steps": true,
|
"use_timed_steps": false,
|
||||||
"number_of_steps": 500,
|
"number_of_steps": 500,
|
||||||
"step_size": 0.001,
|
"step_size": 0.001,
|
||||||
"method": "dassl",
|
"method": "dassl",
|
||||||
|
|||||||
+45756
-7624
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user