Simulation progression

This commit is contained in:
Joppe Blondel
2026-08-19 11:33:40 +02:00
parent 6ebeb2607b
commit be9e05732f
7 changed files with 111 additions and 18 deletions
+2
View File
@@ -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]
@@ -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()
+2 -1
View File
@@ -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",
]
+22
View File
@@ -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
+57 -9
View File
@@ -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)
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)
# TODO: Continue from OpenModelica restart state instead of recomputing the full time range.
self.current_end_time = stop_time
self.results[:] = [result]
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
+16 -4
View File
@@ -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
+1 -1
View File
@@ -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",