From be9e05732f3552d3d7045c4701e97a7d68efe3e7 Mon Sep 17 00:00:00 2001 From: Joppe Blondel Date: Wed, 19 Aug 2026 11:33:40 +0200 Subject: [PATCH] Simulation progression --- pyproject.toml | 2 + .../controllers/simulation_run_controller.py | 10 ++- src/bedit_simulation/__init__.py | 3 +- src/bedit_simulation/results.py | 22 ++++++ src/bedit_simulation/runtime.py | 70 ++++++++++++++++--- src/bedit_simulation/simulation.py | 20 ++++-- untitled.bedit.json | 2 +- 7 files changed, 111 insertions(+), 18 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 59b5eaa..e466e1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,10 +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] diff --git a/src/bedit_gui/controllers/simulation_run_controller.py b/src/bedit_gui/controllers/simulation_run_controller.py index 27e5f47..40f3622 100644 --- a/src/bedit_gui/controllers/simulation_run_controller.py +++ b/src/bedit_gui/controllers/simulation_run_controller.py @@ -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() diff --git a/src/bedit_simulation/__init__.py b/src/bedit_simulation/__init__.py index be5a68e..527ca67 100644 --- a/src/bedit_simulation/__init__.py +++ b/src/bedit_simulation/__init__.py @@ -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 from .compile import compile_component, compile_component_sync from .simulation import ( ModelBuildResult, @@ -38,5 +38,6 @@ __all__ = [ "compile_component", "compile_component_sync", "load_openmodelica_csv", + "load_openmodelica_mat", "simulate", ] diff --git a/src/bedit_simulation/results.py b/src/bedit_simulation/results.py index b599f0e..bd9d5ee 100644 --- a/src/bedit_simulation/results.py +++ b/src/bedit_simulation/results.py @@ -44,3 +44,25 @@ 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))) + 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 + except (IndexError, KeyError, OSError, TypeError, ValueError) as error: + raise ValueError(f"could not read OpenModelica MAT results: {error}") from error + return columns diff --git a/src/bedit_simulation/runtime.py b/src/bedit_simulation/runtime.py index 22addf4..8a9438c 100644 --- a/src/bedit_simulation/runtime.py +++ b/src/bedit_simulation/runtime.py @@ -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,6 +9,8 @@ from .openmodelica import OpenModelicaRunner from .results import SimulationResult from .simulation import Simulation, SimulationOptions, SimulationStateError +_SEGMENTS_PER_RUN = 10 + @dataclass(frozen=True) class SimulationRunSettings: @@ -40,26 +42,70 @@ class SimulationSession: self.results = list(results) self._simulation = Simulation(runner) self._simulation.load_compiled(model_name, executable) + self._segments_completed = 0 + self._segments_total = 1 @property def is_running(self) -> bool: return self._simulation.is_running def get_progress(self) -> int: - return self._simulation.get_progress() + return min(100, int((self._segments_completed + self._simulation.get_progress() / 100) * 100 / self._segments_total)) async def run_next(self) -> SimulationResult: - start_time = self.settings.start_time - stop_time = self.current_end_time + self.settings.duration - run_duration = stop_time - start_time - intervals = math.ceil(run_duration / self.settings.step_size) if self.settings.use_timed_steps else math.ceil(self.settings.number_of_steps * run_duration / self.settings.duration) - options = SimulationOptions(start_time=start_time, stop_time=stop_time, number_of_intervals=intervals, tolerance=self.settings.tolerance, method=self.settings.method) - result = await self._simulation.run(options) - # TODO: Continue from OpenModelica restart state instead of recomputing the full time range. - self.current_end_time = stop_time - self.results[:] = [result] + return await self.run_next_segmented() + + async def run_next_segmented(self, segment_completed: Callable[[SimulationResult], None] | None = None) -> SimulationResult: + """Advance one configured duration in restartable, plot-ready segments.""" + 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() + run_start_time = self.current_end_time + run_stop_time = run_start_time + self.settings.duration + segment_duration = self.settings.duration / _SEGMENTS_PER_RUN + intervals = self._segment_intervals(segment_duration) + self._segments_completed = 0 + self._segments_total = _SEGMENTS_PER_RUN + result: SimulationResult | None = None + for segment in range(_SEGMENTS_PER_RUN): + start_time = self.current_end_time + stop_time = run_stop_time if segment == _SEGMENTS_PER_RUN - 1 else run_start_time + (segment + 1) * segment_duration + result_file = self._simulation.last_build.executable.parent / f"{self._simulation.last_build.executable.stem}_segment_{len(self.results):06d}.mat" + options = SimulationOptions( + start_time=start_time, + stop_time=stop_time, + number_of_intervals=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, + ) + result = await self._simulation.run(options) + self.current_end_time = stop_time + self.results.append(result) + previous_file = result.result_file + self._segments_completed += 1 + if segment_completed is not None: + segment_completed(result) + assert result is not None + self._segments_total = 1 + self._segments_completed = 1 return result + def _segment_intervals(self, segment_duration: float) -> int: + if self.settings.use_timed_steps: + return max(1, math.ceil(segment_duration / self.settings.step_size)) + return max(1, math.ceil(self.settings.number_of_steps / _SEGMENTS_PER_RUN)) + + 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() @@ -68,3 +114,5 @@ class SimulationSession: raise SimulationStateError("cannot reset while a simulation is running") self.current_end_time = self.settings.start_time self.results.clear() + self._segments_completed = 0 + self._segments_total = 1 diff --git a/src/bedit_simulation/simulation.py b/src/bedit_simulation/simulation.py index fc57b25..2ef6c17 100644 --- a/src/bedit_simulation/simulation.py +++ b/src/bedit_simulation/simulation.py @@ -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 _MODEL_FILE = "model.mo" _RUN_SCRIPT_FILE = "run.mos" @@ -39,6 +39,10 @@ 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 def __post_init__(self) -> None: if self.stop_time <= self.start_time: @@ -473,7 +477,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 +495,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, @@ -529,10 +533,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 diff --git a/untitled.bedit.json b/untitled.bedit.json index 6e8be13..811e9f9 100644 --- a/untitled.bedit.json +++ b/untitled.bedit.json @@ -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",