Hooked up simulation to BEsim
This commit is contained in:
@@ -172,6 +172,7 @@ Text widgets use their native `copy()`, `cut()`, and `paste()` methods. Componen
|
|||||||
- `SimulationRoot` contains source-document identity, the selected component and copied settings, and eventually results. Compiled artifacts are transient application state and must not be serialized; reopen saved simulations by compiling them again. Never serialize live Python objects.
|
- `SimulationRoot` contains source-document identity, the selected component and copied settings, and eventually results. Compiled artifacts are transient application state and must not be serialized; reopen saved simulations by compiling them again. Never serialize live Python objects.
|
||||||
- BEdit launches the simulator as a separate process. Compile/Open Simulation integration may transfer a `.bes` file to that process.
|
- BEdit launches the simulator as a separate process. Compile/Open Simulation integration may transfer a `.bes` file to that process.
|
||||||
- Keep simulator file workflows in their own services/controllers rather than adding them to `MainWindow` or the editor document controller.
|
- Keep simulator file workflows in their own services/controllers rather than adding them to `MainWindow` or the editor document controller.
|
||||||
|
- Keep compiled-executable launching, time-window progression, result loading, and cancellation inside `bedit_simulation`. GUI controllers may schedule backend calls and present state, but must not execute or manage simulation binaries themselves.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PySide6.QtCore import QObject, Qt
|
from PySide6.QtCore import QObject, Signal, Qt
|
||||||
from PySide6.QtWidgets import QApplication, QDialog, QFileDialog, QInputDialog, QMessageBox
|
from PySide6.QtWidgets import QApplication, QDialog, QFileDialog, QInputDialog, QMessageBox
|
||||||
|
|
||||||
from bedit_gui.services import document_files, simulation_files
|
from bedit_gui.services import document_files, simulation_files
|
||||||
@@ -17,6 +17,8 @@ logger = get_logger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class SimulationFileController(QObject):
|
class SimulationFileController(QObject):
|
||||||
|
runtime_changed = Signal()
|
||||||
|
|
||||||
def __init__(self, window: SimulationWindow) -> None:
|
def __init__(self, window: SimulationWindow) -> None:
|
||||||
super().__init__(window)
|
super().__init__(window)
|
||||||
self.window = window
|
self.window = window
|
||||||
@@ -34,6 +36,7 @@ class SimulationFileController(QObject):
|
|||||||
self.compiled_model = None
|
self.compiled_model = None
|
||||||
self.path = None
|
self.path = None
|
||||||
self._update_window()
|
self._update_window()
|
||||||
|
self.runtime_changed.emit()
|
||||||
logger.info("Created new simulation")
|
logger.info("Created new simulation")
|
||||||
|
|
||||||
def open(self, path: str | Path, *, component: str | None = None, simulation: str | None = None, backed_by_file: bool = True, compiled_model: CompiledModel | None = None) -> None:
|
def open(self, path: str | Path, *, component: str | None = None, simulation: str | None = None, backed_by_file: bool = True, compiled_model: CompiledModel | None = None) -> None:
|
||||||
@@ -51,6 +54,7 @@ class SimulationFileController(QObject):
|
|||||||
raise
|
raise
|
||||||
self._log_compilation()
|
self._log_compilation()
|
||||||
self._update_window()
|
self._update_window()
|
||||||
|
self.runtime_changed.emit()
|
||||||
logger.info("Opened simulation: %s", file_path)
|
logger.info("Opened simulation: %s", file_path)
|
||||||
|
|
||||||
def open_dialog(self) -> None:
|
def open_dialog(self) -> None:
|
||||||
@@ -111,9 +115,12 @@ class SimulationFileController(QObject):
|
|||||||
self.root.component = updated.component
|
self.root.component = updated.component
|
||||||
self.root.component_path = next((path for component_id, _component, path in components if component_id == updated.component), self.root.component_path)
|
self.root.component_path = next((path for component_id, _component, path in components if component_id == updated.component), self.root.component_path)
|
||||||
self.root.settings_name = updated.name
|
self.root.settings_name = updated.name
|
||||||
|
self.root.current_end_time = updated.start_time
|
||||||
|
self.root.results.clear()
|
||||||
if component_changed:
|
if component_changed:
|
||||||
self.compiled_model = None
|
self.compiled_model = None
|
||||||
self._update_window()
|
self._update_window()
|
||||||
|
self.runtime_changed.emit()
|
||||||
logger.info("Updated simulation settings: %s", updated.name)
|
logger.info("Updated simulation settings: %s", updated.name)
|
||||||
|
|
||||||
def _source_components(self) -> list[tuple]:
|
def _source_components(self) -> list[tuple]:
|
||||||
|
|||||||
155
src/bedit_gui/controllers/simulation_run_controller.py
Normal file
155
src/bedit_gui/controllers/simulation_run_controller.py
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections.abc import Callable
|
||||||
|
from threading import Thread
|
||||||
|
|
||||||
|
from PySide6.QtCore import QObject, Signal
|
||||||
|
|
||||||
|
from bedit_gui.controllers.simulation_file_controller import SimulationFileController
|
||||||
|
from bedit_gui.services.application_logging import get_logger
|
||||||
|
from bedit_gui.simulation_models import CompiledModel
|
||||||
|
from bedit_gui.views.simulation_window import SimulationWindow
|
||||||
|
from bedit_simulation import SimulationCancelledError, SimulationResult, SimulationRunSettings, SimulationSession
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
SessionFactory = Callable[[CompiledModel, SimulationRunSettings, float | None, list[SimulationResult]], SimulationSession]
|
||||||
|
|
||||||
|
|
||||||
|
def _create_session(compiled: CompiledModel, settings: SimulationRunSettings, current_end_time: float | None, results: list[SimulationResult]) -> SimulationSession:
|
||||||
|
return SimulationSession(compiled.model_name, compiled.executable, settings, current_end_time=current_end_time, results=results)
|
||||||
|
|
||||||
|
|
||||||
|
class SimulationRunController(QObject):
|
||||||
|
run_completed = Signal(object)
|
||||||
|
run_cancelled = Signal()
|
||||||
|
run_failed = Signal(object)
|
||||||
|
|
||||||
|
def __init__(self, window: SimulationWindow, files: SimulationFileController, session_factory: SessionFactory = _create_session) -> None:
|
||||||
|
super().__init__(window)
|
||||||
|
self.window = window
|
||||||
|
self.files = files
|
||||||
|
self.session_factory = session_factory
|
||||||
|
self.session: SimulationSession | None = None
|
||||||
|
self._running = False
|
||||||
|
self._reset_pending = False
|
||||||
|
|
||||||
|
window.ui.actionRun_Simulation.triggered.connect(self.start)
|
||||||
|
window.ui.actionStop_Simulation.triggered.connect(self.stop)
|
||||||
|
window.ui.actionRestart_Simulation.triggered.connect(self.restart)
|
||||||
|
files.runtime_changed.connect(self._load_session)
|
||||||
|
self.run_completed.connect(self._completed)
|
||||||
|
self.run_cancelled.connect(self._cancelled)
|
||||||
|
self.run_failed.connect(self._failed)
|
||||||
|
self._load_session()
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
if self._running or self.session is None:
|
||||||
|
return
|
||||||
|
start_time = self.session.current_end_time
|
||||||
|
stop_time = start_time + self.session.settings.duration
|
||||||
|
self._running = True
|
||||||
|
self._update_actions()
|
||||||
|
logger.info("Starting simulation from %s to %s", start_time, stop_time)
|
||||||
|
Thread(target=self._run_worker, args=(self.session,), name="besim-run", daemon=True).start()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
if not self._running or self.session is None:
|
||||||
|
return
|
||||||
|
self.session.cancel()
|
||||||
|
logger.info("Stopping simulation")
|
||||||
|
|
||||||
|
def restart(self) -> None:
|
||||||
|
if self.session is None:
|
||||||
|
return
|
||||||
|
if self._running:
|
||||||
|
self._reset_pending = True
|
||||||
|
logger.info("Reset requested; stopping the active simulation")
|
||||||
|
self.stop()
|
||||||
|
return
|
||||||
|
self._reset()
|
||||||
|
|
||||||
|
def _reset(self) -> None:
|
||||||
|
if self.session is None:
|
||||||
|
return
|
||||||
|
self.session.reset()
|
||||||
|
self._store_session_state()
|
||||||
|
logger.info("Reset simulation to start time %s", self.session.current_end_time)
|
||||||
|
self._update_actions()
|
||||||
|
|
||||||
|
def _run_worker(self, session: SimulationSession) -> None:
|
||||||
|
try:
|
||||||
|
result = asyncio.run(session.run_next())
|
||||||
|
except SimulationCancelledError:
|
||||||
|
self.run_cancelled.emit()
|
||||||
|
except (OSError, RuntimeError, TypeError, ValueError) as exc:
|
||||||
|
self.run_failed.emit(exc)
|
||||||
|
else:
|
||||||
|
self.run_completed.emit(result)
|
||||||
|
|
||||||
|
def _completed(self, result: SimulationResult) -> None:
|
||||||
|
self._running = False
|
||||||
|
self._store_session_state()
|
||||||
|
logger.info("Simulation completed at time %s", self.session.current_end_time if self.session is not None else "unknown")
|
||||||
|
if result.process_output.strip():
|
||||||
|
logger.info("Simulation output:\n%s", result.process_output.strip())
|
||||||
|
if result.process_errors.strip():
|
||||||
|
logger.warning("Simulation errors:\n%s", result.process_errors.strip())
|
||||||
|
self._finish_run()
|
||||||
|
|
||||||
|
def _cancelled(self) -> None:
|
||||||
|
self._running = False
|
||||||
|
logger.info("Simulation stopped")
|
||||||
|
self._finish_run()
|
||||||
|
|
||||||
|
def _failed(self, error: Exception) -> None:
|
||||||
|
self._running = False
|
||||||
|
logger.error("Simulation failed: %s", error, exc_info=(type(error), error, error.__traceback__))
|
||||||
|
self._finish_run()
|
||||||
|
|
||||||
|
def _finish_run(self) -> None:
|
||||||
|
self._update_actions()
|
||||||
|
if self._reset_pending:
|
||||||
|
self._reset_pending = False
|
||||||
|
self._reset()
|
||||||
|
|
||||||
|
def _load_session(self) -> None:
|
||||||
|
root = self.files.root
|
||||||
|
compiled = self.files.compiled_model
|
||||||
|
self._reset_pending = False
|
||||||
|
if root is None or compiled is None:
|
||||||
|
self.session = None
|
||||||
|
else:
|
||||||
|
settings = root.settings
|
||||||
|
try:
|
||||||
|
run_settings = SimulationRunSettings(
|
||||||
|
start_time=settings.start_time,
|
||||||
|
duration=settings.duration,
|
||||||
|
use_timed_steps=settings.use_timed_steps,
|
||||||
|
number_of_steps=settings.number_of_steps,
|
||||||
|
step_size=settings.step_size,
|
||||||
|
tolerance=settings.dassl_tolerance,
|
||||||
|
method=settings.method.value,
|
||||||
|
)
|
||||||
|
self.session = self.session_factory(compiled, run_settings, root.current_end_time, root.results)
|
||||||
|
except (OSError, RuntimeError, ValueError):
|
||||||
|
self.session = None
|
||||||
|
logger.exception("Could not initialize the simulation runtime")
|
||||||
|
self._update_actions()
|
||||||
|
|
||||||
|
def _update_actions(self) -> None:
|
||||||
|
available = self.session is not None
|
||||||
|
self.window.ui.actionRun_Simulation.setEnabled(available and not self._running)
|
||||||
|
self.window.ui.actionStop_Simulation.setEnabled(available and self._running)
|
||||||
|
self.window.ui.actionRestart_Simulation.setEnabled(available)
|
||||||
|
for action in (self.window.ui.actionNew_Simulation_Run, self.window.ui.actionOpen_Simulation_Run, self.window.ui.actionSave_Simulation_Run, self.window.ui.actionSimulation_Options):
|
||||||
|
action.setEnabled(not self._running and (available or action is not self.window.ui.actionSimulation_Options))
|
||||||
|
if self.session is not None:
|
||||||
|
state = "running" if self._running else "ready"
|
||||||
|
self.window.statusBar().showMessage(f"Simulation {state} · current time {self.session.current_end_time}")
|
||||||
|
|
||||||
|
def _store_session_state(self) -> None:
|
||||||
|
if self.session is None or self.files.root is None:
|
||||||
|
return
|
||||||
|
self.files.root.current_end_time = self.session.current_end_time
|
||||||
|
self.files.root.results = list(self.session.results)
|
||||||
@@ -7,6 +7,7 @@ from PySide6.QtWidgets import QApplication, QMessageBox
|
|||||||
|
|
||||||
from bedit_gui.controllers.simulation_file_controller import SimulationFileController
|
from bedit_gui.controllers.simulation_file_controller import SimulationFileController
|
||||||
from bedit_gui.controllers.log_controller import LogController
|
from bedit_gui.controllers.log_controller import LogController
|
||||||
|
from bedit_gui.controllers.simulation_run_controller import SimulationRunController
|
||||||
from bedit_gui.simulation_models import CompiledModel
|
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
|
||||||
@@ -42,6 +43,7 @@ def main(arguments: list[str] | None = None) -> int:
|
|||||||
|
|
||||||
settings = SimulationApplicationSettings()
|
settings = SimulationApplicationSettings()
|
||||||
LogController(window, settings.log_level)
|
LogController(window, settings.log_level)
|
||||||
|
SimulationRunController(window, controller)
|
||||||
|
|
||||||
window.showMaximized()
|
window.showMaximized()
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from bedit_core.models import ComponentID
|
from bedit_core.models import ComponentID
|
||||||
from bedit_gui.models import Simulation
|
from bedit_gui.models import Simulation
|
||||||
|
from bedit_simulation import SimulationResult
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -33,6 +34,8 @@ class SimulationRoot:
|
|||||||
component_path: str
|
component_path: str
|
||||||
settings_name: str | None
|
settings_name: str | None
|
||||||
settings: Simulation
|
settings: Simulation
|
||||||
|
current_end_time: float | None = None
|
||||||
|
results: list[SimulationResult] = field(default_factory=list)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_data(cls, data: Mapping[str, Any]) -> SimulationRoot:
|
def from_data(cls, data: Mapping[str, Any]) -> SimulationRoot:
|
||||||
@@ -46,6 +49,8 @@ class SimulationRoot:
|
|||||||
component_path=str(data.get("component_path", "")),
|
component_path=str(data.get("component_path", "")),
|
||||||
settings_name=str(data["settings_name"]) if data.get("settings_name") is not None else None,
|
settings_name=str(data["settings_name"]) if data.get("settings_name") is not None else None,
|
||||||
settings=Simulation.from_data(data["settings"]),
|
settings=Simulation.from_data(data["settings"]),
|
||||||
|
current_end_time=float(data["current_end_time"]) if data.get("current_end_time") is not None else None,
|
||||||
|
results=[_result_from_data(result) for result in data.get("results", [])],
|
||||||
)
|
)
|
||||||
|
|
||||||
def to_data(self) -> dict[str, Any]:
|
def to_data(self) -> dict[str, Any]:
|
||||||
@@ -58,4 +63,18 @@ class SimulationRoot:
|
|||||||
"component_path": self.component_path,
|
"component_path": self.component_path,
|
||||||
"settings_name": self.settings_name,
|
"settings_name": self.settings_name,
|
||||||
"settings": self.settings.to_data(),
|
"settings": self.settings.to_data(),
|
||||||
|
"current_end_time": self.current_end_time,
|
||||||
|
"results": [_result_to_data(result) for result in self.results],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _result_from_data(data: Mapping[str, Any]) -> SimulationResult:
|
||||||
|
raw_columns = data.get("data", {})
|
||||||
|
if not isinstance(raw_columns, Mapping):
|
||||||
|
raise TypeError("simulation result data must be a mapping")
|
||||||
|
columns = {str(name): [float(value) for value in values] for name, values in raw_columns.items()}
|
||||||
|
return SimulationResult(model_name=str(data.get("model_name", "")), data=columns, process_output=str(data.get("process_output", "")), process_errors=str(data.get("process_errors", "")))
|
||||||
|
|
||||||
|
|
||||||
|
def _result_to_data(result: SimulationResult) -> dict[str, Any]:
|
||||||
|
return {"model_name": result.model_name, "data": result.data, "process_output": result.process_output, "process_errors": result.process_errors}
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ class SimulationSettingsDialog(QDialog):
|
|||||||
if simulation.dassl_tolerance <= 0:
|
if simulation.dassl_tolerance <= 0:
|
||||||
QMessageBox.warning(self, "Invalid simulation", "The DASSL tolerance must be greater than zero.")
|
QMessageBox.warning(self, "Invalid simulation", "The DASSL tolerance must be greater than zero.")
|
||||||
return
|
return
|
||||||
|
if simulation.duration <= 0:
|
||||||
|
QMessageBox.warning(self, "Invalid simulation", "The simulation length must be greater than zero.")
|
||||||
|
return
|
||||||
simulation.name = simulation.name.strip()
|
simulation.name = simulation.name.strip()
|
||||||
self._database.active_simulation = self._simulation_id()
|
self._database.active_simulation = self._simulation_id()
|
||||||
super().accept()
|
super().accept()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from .openmodelica import (
|
|||||||
OpenModelicaError,
|
OpenModelicaError,
|
||||||
OpenModelicaRunner,
|
OpenModelicaRunner,
|
||||||
ProcessResult,
|
ProcessResult,
|
||||||
|
SimulationCancelledError,
|
||||||
)
|
)
|
||||||
from .results import SimulationResult, load_openmodelica_csv
|
from .results import SimulationResult, load_openmodelica_csv
|
||||||
from .compile import compile_component, compile_component_sync
|
from .compile import compile_component, compile_component_sync
|
||||||
@@ -17,6 +18,7 @@ from .simulation import (
|
|||||||
SimulationStateError,
|
SimulationStateError,
|
||||||
simulate,
|
simulate,
|
||||||
)
|
)
|
||||||
|
from .runtime import SimulationRunSettings, SimulationSession
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ModelBuildResult",
|
"ModelBuildResult",
|
||||||
@@ -26,9 +28,12 @@ __all__ = [
|
|||||||
"OpenModelicaRunner",
|
"OpenModelicaRunner",
|
||||||
"ProcessResult",
|
"ProcessResult",
|
||||||
"Simulation",
|
"Simulation",
|
||||||
|
"SimulationCancelledError",
|
||||||
"SimulationOptions",
|
"SimulationOptions",
|
||||||
"SimulationProgress",
|
"SimulationProgress",
|
||||||
"SimulationResult",
|
"SimulationResult",
|
||||||
|
"SimulationRunSettings",
|
||||||
|
"SimulationSession",
|
||||||
"SimulationStateError",
|
"SimulationStateError",
|
||||||
"compile_component",
|
"compile_component",
|
||||||
"compile_component_sync",
|
"compile_component_sync",
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
|
import signal
|
||||||
import shlex
|
import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import time
|
||||||
from collections.abc import Callable, Mapping, Sequence
|
from collections.abc import Callable, Mapping, Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -27,6 +29,10 @@ class OpenModelicaError(RuntimeError):
|
|||||||
"""Raised when OMC cannot start or reports a failure."""
|
"""Raised when OMC cannot start or reports a failure."""
|
||||||
|
|
||||||
|
|
||||||
|
class SimulationCancelledError(RuntimeError):
|
||||||
|
"""Raised when a running compiled simulation is cancelled."""
|
||||||
|
|
||||||
|
|
||||||
ProcessExecutor = Callable[
|
ProcessExecutor = Callable[
|
||||||
[Sequence[str], Path, float | None, Mapping[str, str] | None],
|
[Sequence[str], Path, float | None, Mapping[str, str] | None],
|
||||||
ProcessResult,
|
ProcessResult,
|
||||||
@@ -89,6 +95,58 @@ class OpenModelicaRunner:
|
|||||||
"""Run OMC on an asyncio worker thread."""
|
"""Run OMC on an asyncio worker thread."""
|
||||||
return await _run_on_worker(self._run, script, working_directory)
|
return await _run_on_worker(self._run, script, working_directory)
|
||||||
|
|
||||||
|
def _run_cancellable(self, script: Path, working_directory: Path, cancel_event: Event) -> ProcessResult:
|
||||||
|
command = (*self.command, str(script.resolve()))
|
||||||
|
return self._run_cancellable_process(command, working_directory, cancel_event)
|
||||||
|
|
||||||
|
def _run_cancellable_process(self, command: Sequence[str], working_directory: Path, cancel_event: Event) -> ProcessResult:
|
||||||
|
process_environment = None
|
||||||
|
if self.environment is not None:
|
||||||
|
process_environment = {**os.environ, **self.environment}
|
||||||
|
try:
|
||||||
|
process = subprocess.Popen(list(command), cwd=working_directory, env=process_environment, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=os.name != "nt")
|
||||||
|
except OSError as exc:
|
||||||
|
raise OpenModelicaError(f"simulation executable could not start: {exc}") from exc
|
||||||
|
deadline = time.monotonic() + self.timeout if self.timeout is not None else None
|
||||||
|
while True:
|
||||||
|
if cancel_event.is_set():
|
||||||
|
_terminate_process(process)
|
||||||
|
try:
|
||||||
|
stdout, stderr = process.communicate(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
_kill_process(process)
|
||||||
|
stdout, stderr = process.communicate()
|
||||||
|
raise SimulationCancelledError("simulation was cancelled")
|
||||||
|
if deadline is not None and time.monotonic() >= deadline:
|
||||||
|
_kill_process(process)
|
||||||
|
process.communicate()
|
||||||
|
raise OpenModelicaError(f"simulation timed out after {self.timeout} seconds")
|
||||||
|
try:
|
||||||
|
stdout, stderr = process.communicate(timeout=0.05)
|
||||||
|
break
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
continue
|
||||||
|
result = ProcessResult(command=tuple(command), return_code=process.returncode, stdout=stdout, stderr=stderr)
|
||||||
|
if result.return_code != 0:
|
||||||
|
details = result.stderr.strip() or result.stdout.strip()
|
||||||
|
suffix = f": {details}" if details else ""
|
||||||
|
raise OpenModelicaError(f"simulation exited with status {result.return_code}{suffix}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _terminate_process(process: subprocess.Popen[str]) -> None:
|
||||||
|
if os.name == "nt":
|
||||||
|
process.terminate()
|
||||||
|
else:
|
||||||
|
os.killpg(process.pid, signal.SIGTERM)
|
||||||
|
|
||||||
|
|
||||||
|
def _kill_process(process: subprocess.Popen[str]) -> None:
|
||||||
|
if os.name == "nt":
|
||||||
|
process.kill()
|
||||||
|
else:
|
||||||
|
os.killpg(process.pid, signal.SIGKILL)
|
||||||
|
|
||||||
|
|
||||||
def _command_parts(command: str | Sequence[str]) -> tuple[str, ...]:
|
def _command_parts(command: str | Sequence[str]) -> tuple[str, ...]:
|
||||||
if isinstance(command, str):
|
if isinstance(command, str):
|
||||||
@@ -140,7 +198,7 @@ async def _run_on_worker(
|
|||||||
def invoke() -> None:
|
def invoke() -> None:
|
||||||
try:
|
try:
|
||||||
results.append(operation(*args, **kwargs))
|
results.append(operation(*args, **kwargs))
|
||||||
except BaseException as error:
|
except Exception as error: # noqa: BLE001 - worker must relay operation failures
|
||||||
errors.append(error)
|
errors.append(error)
|
||||||
finally:
|
finally:
|
||||||
finished.set()
|
finished.set()
|
||||||
|
|||||||
65
src/bedit_simulation/runtime.py
Normal file
65
src/bedit_simulation/runtime.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .openmodelica import OpenModelicaRunner
|
||||||
|
from .results import SimulationResult
|
||||||
|
from .simulation import Simulation, SimulationOptions, SimulationStateError
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SimulationRunSettings:
|
||||||
|
start_time: float = 0.0
|
||||||
|
duration: float = 1.0
|
||||||
|
use_timed_steps: bool = False
|
||||||
|
number_of_steps: int = 500
|
||||||
|
step_size: float = 0.002
|
||||||
|
tolerance: float = 1e-6
|
||||||
|
method: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.duration <= 0:
|
||||||
|
raise ValueError("simulation duration must be positive")
|
||||||
|
if self.number_of_steps <= 0:
|
||||||
|
raise ValueError("number of steps must be positive")
|
||||||
|
if self.step_size <= 0:
|
||||||
|
raise ValueError("step size must be positive")
|
||||||
|
if self.tolerance <= 0:
|
||||||
|
raise ValueError("simulation tolerance must be positive")
|
||||||
|
|
||||||
|
|
||||||
|
class SimulationSession:
|
||||||
|
"""Run consecutive time ranges for one compiled model."""
|
||||||
|
|
||||||
|
def __init__(self, model_name: str, executable: str | Path, settings: SimulationRunSettings, *, current_end_time: float | None = None, results: Sequence[SimulationResult] = (), runner: OpenModelicaRunner | None = None) -> None:
|
||||||
|
self.settings = settings
|
||||||
|
self.current_end_time = settings.start_time if current_end_time is None else current_end_time
|
||||||
|
self.results = list(results)
|
||||||
|
self._simulation = Simulation(runner)
|
||||||
|
self._simulation.load_compiled(model_name, executable)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_running(self) -> bool:
|
||||||
|
return self._simulation.is_running
|
||||||
|
|
||||||
|
async def run_next(self) -> SimulationResult:
|
||||||
|
start_time = self.current_end_time
|
||||||
|
stop_time = start_time + self.settings.duration
|
||||||
|
intervals = math.ceil(self.settings.duration / self.settings.step_size) if self.settings.use_timed_steps else self.settings.number_of_steps
|
||||||
|
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)
|
||||||
|
self.current_end_time = stop_time
|
||||||
|
self.results.append(result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def cancel(self) -> bool:
|
||||||
|
return self._simulation.cancel()
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
if self.is_running:
|
||||||
|
raise SimulationStateError("cannot reset while a simulation is running")
|
||||||
|
self.current_end_time = self.settings.start_time
|
||||||
|
self.results.clear()
|
||||||
@@ -107,6 +107,9 @@ class Simulation:
|
|||||||
self.last_result: SimulationResult | None = None
|
self.last_result: SimulationResult | None = None
|
||||||
self._progress = SimulationProgress()
|
self._progress = SimulationProgress()
|
||||||
self._progress_lock = Lock()
|
self._progress_lock = Lock()
|
||||||
|
self._cancel_event = Event()
|
||||||
|
self._running_lock = Lock()
|
||||||
|
self._running = False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _compose(component: Component) -> CompositionResult:
|
def _compose(component: Component) -> CompositionResult:
|
||||||
@@ -179,6 +182,25 @@ class Simulation:
|
|||||||
with self._progress_lock:
|
with self._progress_lock:
|
||||||
self._progress = progress
|
self._progress = progress
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_running(self) -> bool:
|
||||||
|
with self._running_lock:
|
||||||
|
return self._running
|
||||||
|
|
||||||
|
def cancel(self) -> bool:
|
||||||
|
"""Request cancellation of the active compiled simulation run."""
|
||||||
|
running = self.is_running
|
||||||
|
self._cancel_event.set()
|
||||||
|
return running
|
||||||
|
|
||||||
|
def load_compiled(self, model_name: str, executable: str | Path) -> None:
|
||||||
|
"""Load an existing compiled model without composing or compiling it."""
|
||||||
|
executable_path = Path(executable)
|
||||||
|
if not executable_path.is_file():
|
||||||
|
raise ValueError(f"compiled simulation executable does not exist: {executable_path}")
|
||||||
|
self._set_model("", model_name)
|
||||||
|
self.last_build = ModelBuildResult(model_name=model_name, executable=executable_path, output="", errors="")
|
||||||
|
|
||||||
def _active_model(self) -> tuple[str, str]:
|
def _active_model(self) -> tuple[str, str]:
|
||||||
if self._modelica is None or self._model_name is None:
|
if self._modelica is None or self._model_name is None:
|
||||||
raise SimulationStateError(
|
raise SimulationStateError(
|
||||||
@@ -411,14 +433,17 @@ class Simulation:
|
|||||||
raise SimulationStateError(
|
raise SimulationStateError(
|
||||||
"working_directory must be the directory used by build()"
|
"working_directory must be the directory used by build()"
|
||||||
)
|
)
|
||||||
|
with self._running_lock:
|
||||||
|
if self._running:
|
||||||
|
raise SimulationStateError("a simulation is already running")
|
||||||
|
self._running = True
|
||||||
self._set_progress(SimulationProgress())
|
self._set_progress(SimulationProgress())
|
||||||
result = await _run_on_worker(
|
try:
|
||||||
self._run_built_model,
|
result = await _run_on_worker(self._run_built_model, model_name, build.executable, options, directory)
|
||||||
model_name,
|
finally:
|
||||||
build.executable,
|
self._cancel_event.clear()
|
||||||
options,
|
with self._running_lock:
|
||||||
directory,
|
self._running = False
|
||||||
)
|
|
||||||
self.last_result = result
|
self.last_result = result
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -509,12 +534,9 @@ class Simulation:
|
|||||||
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
|
||||||
script_path.write_text(
|
script_path.write_text(_executable_script(arguments, directory), encoding="utf-8")
|
||||||
_executable_script(arguments, directory),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
process = self.runner._run(script_path, directory)
|
process = self.runner._run_cancellable(script_path, directory, self._cancel_event)
|
||||||
finally:
|
finally:
|
||||||
command_finished.set()
|
command_finished.set()
|
||||||
reader.join(timeout=20)
|
reader.join(timeout=20)
|
||||||
@@ -649,17 +671,13 @@ async def simulate(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _executable_script(
|
def _executable_script(arguments: Sequence[str], working_directory: Path) -> str:
|
||||||
arguments: Sequence[str],
|
|
||||||
working_directory: Path,
|
|
||||||
) -> str:
|
|
||||||
"""Create an OMC script that starts a compiled simulation binary."""
|
"""Create an OMC script that starts a compiled simulation binary."""
|
||||||
command = shlex.join(arguments)
|
command = shlex.join(arguments)
|
||||||
return "\n".join(
|
return "\n".join(
|
||||||
[
|
[
|
||||||
f"cd({json.dumps(str(working_directory.resolve()))});",
|
f"cd({json.dumps(str(working_directory.resolve()))});",
|
||||||
f"status := system({json.dumps(command)}, "
|
f"status := system({json.dumps(command)}, {json.dumps(_RUN_OUTPUT_FILE)});",
|
||||||
f"{json.dumps(_RUN_OUTPUT_FILE)});",
|
|
||||||
"if status <> 0 then",
|
"if status <> 0 then",
|
||||||
f" print(readFile({json.dumps(_RUN_OUTPUT_FILE)}));",
|
f" print(readFile({json.dumps(_RUN_OUTPUT_FILE)}));",
|
||||||
" exit(1);",
|
" exit(1);",
|
||||||
|
|||||||
7888
untitled.besim.json
7888
untitled.besim.json
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user