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" 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", "OMPython>=3.6,<5",
"PySide6>=6.7,<7", "PySide6>=6.7,<7",
"scipy>=1.12,<2",
] ]
[project.scripts] [project.scripts]
@@ -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()
+2 -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
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,6 @@ __all__ = [
"compile_component", "compile_component",
"compile_component_sync", "compile_component_sync",
"load_openmodelica_csv", "load_openmodelica_csv",
"load_openmodelica_mat",
"simulate", "simulate",
] ]
+22
View File
@@ -44,3 +44,25 @@ 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)))
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
+59 -11
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,6 +9,8 @@ from .openmodelica import OpenModelicaRunner
from .results import SimulationResult from .results import SimulationResult
from .simulation import Simulation, SimulationOptions, SimulationStateError from .simulation import Simulation, SimulationOptions, SimulationStateError
_SEGMENTS_PER_RUN = 10
@dataclass(frozen=True) @dataclass(frozen=True)
class SimulationRunSettings: class SimulationRunSettings:
@@ -40,26 +42,70 @@ class SimulationSession:
self.results = list(results) self.results = list(results)
self._simulation = Simulation(runner) self._simulation = Simulation(runner)
self._simulation.load_compiled(model_name, executable) self._simulation.load_compiled(model_name, executable)
self._segments_completed = 0
self._segments_total = 1
@property @property
def is_running(self) -> bool: def is_running(self) -> bool:
return self._simulation.is_running return self._simulation.is_running
def get_progress(self) -> int: 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: 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 restartable, plot-ready segments."""
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()
result = await self._simulation.run(options) if previous_file is None and self.current_end_time != self.settings.start_time:
# TODO: Continue from OpenModelica restart state instead of recomputing the full time range. self.current_end_time = self.settings.start_time
self.current_end_time = stop_time self.results.clear()
self.results[:] = [result] 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 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: def cancel(self) -> bool:
return self._simulation.cancel() return self._simulation.cancel()
@@ -68,3 +114,5 @@ class SimulationSession:
raise SimulationStateError("cannot reset while a simulation is running") raise SimulationStateError("cannot reset while a simulation is running")
self.current_end_time = self.settings.start_time self.current_end_time = self.settings.start_time
self.results.clear() self.results.clear()
self._segments_completed = 0
self._segments_total = 1
+16 -4
View File
@@ -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
_MODEL_FILE = "model.mo" _MODEL_FILE = "model.mo"
_RUN_SCRIPT_FILE = "run.mos" _RUN_SCRIPT_FILE = "run.mos"
@@ -39,6 +39,10 @@ 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
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 +477,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 +495,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,
@@ -529,10 +533,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
+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",