Compare commits
3
Commits
master
...
sim_backend
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eef77aed09 | ||
|
|
be9e05732f | ||
|
|
6ebeb2607b |
@@ -9,9 +9,12 @@ description = "A Bondgraph and block scheme simulator"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"DyMat>=0.7,<1",
|
||||
"matplotlib>=3.8,<4",
|
||||
"msgpack>=1.0,<2",
|
||||
"OMPython>=3.6,<5",
|
||||
"PySide6>=6.7,<7",
|
||||
"scipy>=1.12,<2",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -21,6 +21,7 @@ from bedit_gui.services.application_settings import ApplicationSettings
|
||||
from bedit_gui.services.clipboard import ClipboardService
|
||||
from bedit_gui.views.main_window import MainWindow
|
||||
from bedit_gui.versions import BEDIT_VERSION
|
||||
from bedit_simulation import OpenModelicaRunner, compile_component_sync
|
||||
|
||||
def parse_arguments():
|
||||
parser = argparse.ArgumentParser(exit_on_error=False)
|
||||
@@ -46,6 +47,8 @@ def main() -> int:
|
||||
app.setApplicationVersion(BEDIT_VERSION)
|
||||
|
||||
settings = ApplicationSettings()
|
||||
omc_runner = OpenModelicaRunner()
|
||||
app.aboutToQuit.connect(omc_runner.close)
|
||||
document = Document(app)
|
||||
window = MainWindow(settings.snap_to_grid_size)
|
||||
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)
|
||||
settings_controller = SettingsController(window, settings)
|
||||
SimulationSettingsController(document, window)
|
||||
SimulationController(document, window)
|
||||
SimulationController(document, window, compiler=lambda component, directory: compile_component_sync(component, directory, runner=omc_runner))
|
||||
UndoController(document, window)
|
||||
ViewMenuController(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.views.dialogs.simulation_settings_dialog import SimulationSettingsDialog
|
||||
from bedit_gui.views.simulation_window import SimulationWindow
|
||||
from bedit_simulation import OpenModelicaRunner
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -20,9 +21,10 @@ logger = get_logger(__name__)
|
||||
class SimulationFileController(QObject):
|
||||
runtime_changed = Signal()
|
||||
|
||||
def __init__(self, window: SimulationWindow) -> None:
|
||||
def __init__(self, window: SimulationWindow, runner: OpenModelicaRunner | None = None) -> None:
|
||||
super().__init__(window)
|
||||
self.window = window
|
||||
self.runner = runner
|
||||
self.root: SimulationRoot | None = None
|
||||
self.compiled_model: CompiledModel | 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]:
|
||||
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
|
||||
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:
|
||||
QApplication.restoreOverrideCursor()
|
||||
|
||||
def _recompile_with_wait_cursor(self, root: SimulationRoot) -> CompiledModel:
|
||||
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
|
||||
try:
|
||||
return compile_simulation_root(root)
|
||||
return compile_simulation_root(root, runner=self.runner)
|
||||
finally:
|
||||
QApplication.restoreOverrideCursor()
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ class SimulationRunController(QObject):
|
||||
run_cancelled = Signal()
|
||||
run_failed = Signal(object)
|
||||
run_progress = Signal(int)
|
||||
segment_completed = Signal()
|
||||
simulation_state_changed = Signal()
|
||||
|
||||
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_failed.connect(self._failed)
|
||||
self.run_progress.connect(self.window.ui.progressBar.setValue)
|
||||
self.segment_completed.connect(self._segment_completed)
|
||||
self._load_session()
|
||||
|
||||
@property
|
||||
@@ -98,7 +100,7 @@ class SimulationRunController(QObject):
|
||||
self.run_completed.emit(result)
|
||||
|
||||
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
|
||||
while not task.done():
|
||||
progress = min(session.get_progress(), 99)
|
||||
@@ -108,6 +110,12 @@ class SimulationRunController(QObject):
|
||||
await asyncio.sleep(0.1)
|
||||
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:
|
||||
self._running = False
|
||||
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.services import document_files
|
||||
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]]:
|
||||
@@ -24,7 +24,7 @@ def component_choices(document: Document) -> list[tuple[ComponentID, Component,
|
||||
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."""
|
||||
if bool(component_selector) == bool(simulation_selector):
|
||||
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=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:
|
||||
raise ValueError("the simulation does not reference a BEdit source document and cannot be recompiled")
|
||||
document = document_files.load(root.source_document)
|
||||
_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-"))
|
||||
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]:
|
||||
@@ -90,6 +90,6 @@ def _default_settings(component_id: ComponentID) -> Simulation:
|
||||
)
|
||||
|
||||
|
||||
def _compile(component: Component, working_directory: Path, omc_command: str) -> CompiledModel:
|
||||
build = compile_component_sync(component, working_directory, omc_command=omc_command)
|
||||
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, runner=runner)
|
||||
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.views.simulation_window import SimulationWindow
|
||||
from bedit_gui.versions import BESIM_VERSION
|
||||
from bedit_simulation import OpenModelicaRunner
|
||||
|
||||
|
||||
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.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)
|
||||
omc_runner = OpenModelicaRunner()
|
||||
app.aboutToQuit.connect(omc_runner.close)
|
||||
controller = SimulationFileController(window, omc_runner)
|
||||
|
||||
settings = SimulationApplicationSettings()
|
||||
LogController(window, settings.log_level)
|
||||
|
||||
@@ -6,7 +6,7 @@ from .openmodelica import (
|
||||
ProcessResult,
|
||||
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 .simulation import (
|
||||
ModelBuildResult,
|
||||
@@ -38,5 +38,7 @@ __all__ = [
|
||||
"compile_component",
|
||||
"compile_component_sync",
|
||||
"load_openmodelica_csv",
|
||||
"load_openmodelica_mat",
|
||||
"load_openmodelica_mat_snapshot",
|
||||
"simulate",
|
||||
]
|
||||
|
||||
@@ -9,13 +9,13 @@ from .openmodelica import OpenModelicaRunner
|
||||
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."""
|
||||
simulation = Simulation(OpenModelicaRunner(omc_command, timeout=timeout))
|
||||
simulation = Simulation(runner or OpenModelicaRunner(omc_command, timeout=timeout))
|
||||
await simulation.load(component)
|
||||
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."""
|
||||
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 signal
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from threading import Event, Thread
|
||||
from typing import Any, TypeVar
|
||||
from threading import Event, Lock, Thread
|
||||
from typing import Any, Protocol, Self, TypeVar
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -48,6 +50,15 @@ ProcessExecutor = Callable[
|
||||
_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:
|
||||
"""Execute ``.mos`` scripts using an OMC command or wrapper script.
|
||||
|
||||
@@ -63,14 +74,22 @@ class OpenModelicaRunner:
|
||||
timeout: float | None = None,
|
||||
environment: Mapping[str, str] | None = None,
|
||||
executor: ProcessExecutor | None = None,
|
||||
session_factory: SessionFactory | None = None,
|
||||
reuse_session: bool = True,
|
||||
) -> None:
|
||||
self.command = _command_parts(command)
|
||||
self.timeout = timeout
|
||||
self.environment = environment
|
||||
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:
|
||||
"""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()))
|
||||
try:
|
||||
result = self._executor(
|
||||
@@ -95,6 +114,48 @@ class OpenModelicaRunner:
|
||||
)
|
||||
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(
|
||||
self,
|
||||
script: Path,
|
||||
@@ -167,6 +228,30 @@ def _command_parts(command: str | Sequence[str]) -> tuple[str, ...]:
|
||||
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(
|
||||
command: Sequence[str],
|
||||
working_directory: Path,
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import struct
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
@@ -44,3 +46,76 @@ def load_openmodelica_csv(path: str | Path) -> dict[str, list[float]]:
|
||||
except StopIteration as error:
|
||||
raise ValueError("simulation result CSV is empty") from error
|
||||
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
|
||||
|
||||
import math
|
||||
from collections.abc import Sequence
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
@@ -9,7 +9,6 @@ from .openmodelica import OpenModelicaRunner
|
||||
from .results import SimulationResult
|
||||
from .simulation import Simulation, SimulationOptions, SimulationStateError
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SimulationRunSettings:
|
||||
start_time: float = 0.0
|
||||
@@ -49,17 +48,52 @@ class SimulationSession:
|
||||
return self._simulation.get_progress()
|
||||
|
||||
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)
|
||||
return await self.run_next_segmented()
|
||||
|
||||
async def run_next_segmented(self, segment_completed: Callable[[SimulationResult], None] | None = None) -> SimulationResult:
|
||||
"""Advance one configured duration in one process with live result snapshots."""
|
||||
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)
|
||||
# TODO: Continue from OpenModelica restart state instead of recomputing the full time range.
|
||||
self.current_end_time = stop_time
|
||||
self.results[:] = [result]
|
||||
self.results[:] = [*completed_results, 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:
|
||||
return self._simulation.cancel()
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import time
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from collections.abc import Sequence
|
||||
from collections.abc import Callable, Sequence
|
||||
from threading import Event, Lock, Thread
|
||||
|
||||
from bedit_core.modelica import CompositionResult, compose_modelica_model
|
||||
@@ -22,7 +22,7 @@ from .openmodelica import (
|
||||
ProcessResult,
|
||||
_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"
|
||||
_RUN_SCRIPT_FILE = "run.mos"
|
||||
@@ -39,6 +39,11 @@ class SimulationOptions:
|
||||
number_of_intervals: int = 500
|
||||
tolerance: float = 1e-6
|
||||
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:
|
||||
if self.stop_time <= self.start_time:
|
||||
@@ -473,7 +478,7 @@ class Simulation:
|
||||
options: SimulationOptions,
|
||||
directory: Path,
|
||||
) -> 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)
|
||||
if not result_path.is_file():
|
||||
details = process.stderr.strip() or process.stdout.strip()
|
||||
@@ -491,7 +496,7 @@ class Simulation:
|
||||
)
|
||||
return SimulationResult(
|
||||
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_errors=process.stderr,
|
||||
result_file=result_path,
|
||||
@@ -514,7 +519,7 @@ class Simulation:
|
||||
|
||||
reader = Thread(
|
||||
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",
|
||||
daemon=True,
|
||||
)
|
||||
@@ -529,10 +534,18 @@ class Simulation:
|
||||
f"-stopTime={options.stop_time}",
|
||||
f"-stepSize={step_size}",
|
||||
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}",
|
||||
"-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:
|
||||
arguments.append(f"-s={options.method}")
|
||||
script_path = directory / _RUN_SCRIPT_FILE
|
||||
@@ -556,6 +569,9 @@ class Simulation:
|
||||
server: socket.socket,
|
||||
command_finished: Event,
|
||||
errors: list[BaseException],
|
||||
result_file: str | Path | None,
|
||||
model_name: str | None,
|
||||
intermediate_result: Callable[[SimulationResult], None] | None,
|
||||
) -> None:
|
||||
try:
|
||||
connection: socket.socket | None = None
|
||||
@@ -574,6 +590,7 @@ class Simulation:
|
||||
raise OpenModelicaError(
|
||||
"simulation did not connect to the progress server"
|
||||
)
|
||||
next_snapshot = 10
|
||||
with connection, connection.makefile("r", encoding="utf-8") as stream:
|
||||
for line in stream:
|
||||
try:
|
||||
@@ -583,9 +600,25 @@ class Simulation:
|
||||
# messages. Keep consuming the stream so the simulator
|
||||
# does not receive SIGPIPE.
|
||||
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:
|
||||
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:
|
||||
element = ET.fromstring(line.strip())
|
||||
if element.tag != "status":
|
||||
@@ -705,6 +738,7 @@ def _load_model_script(
|
||||
])
|
||||
return "\n".join(
|
||||
[
|
||||
"clear();",
|
||||
f"cd({json.dumps(str(working_directory.resolve()))});",
|
||||
*load_libraries,
|
||||
f"loaded := loadFile({json.dumps(_MODEL_FILE)});",
|
||||
|
||||
+1
-1
@@ -2049,7 +2049,7 @@
|
||||
"name": "run bondgraph",
|
||||
"start_time": 0.0,
|
||||
"duration": 10.0,
|
||||
"use_timed_steps": true,
|
||||
"use_timed_steps": false,
|
||||
"number_of_steps": 500,
|
||||
"step_size": 0.001,
|
||||
"method": "dassl",
|
||||
|
||||
+45756
-7624
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user