Compare commits

3 Commits
Author SHA1 Message Date
Joppe Blondel eef77aed09 Implemented live updates using the growing MAT file. 2026-08-19 11:50:21 +02:00
Joppe Blondel be9e05732f Simulation progression 2026-08-19 11:33:40 +02:00
Joppe Blondel 6ebeb2607b Single omc session 2026-08-19 11:19:36 +02:00
14 changed files with 46041 additions and 7660 deletions
+3
View File
@@ -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]
+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()
@@ -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
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)
+3 -1
View File
@@ -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",
] ]
+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,
+75
View File
@@ -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 ">"
+43 -9
View File
@@ -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()
+40 -6
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff