Hooked up simulation to BEsim

This commit is contained in:
2026-08-01 12:25:11 +02:00
parent a38d461a36
commit 2128fb00b4
11 changed files with 8241 additions and 24 deletions

View File

@@ -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

View File

@@ -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]:

View 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)

View File

@@ -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()

View File

@@ -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}

View File

@@ -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()

View File

@@ -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",

View File

@@ -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()

View 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()

View File

@@ -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);",

File diff suppressed because it is too large Load Diff