Simulation progression
This commit is contained in:
@@ -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()
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
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)
|
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.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
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user