basic runner setup changed
This commit is contained in:
@@ -25,7 +25,7 @@ src/bedit/
|
|||||||
│ ├── port_types.py # Port type definitions and compatibility
|
│ ├── port_types.py # Port type definitions and compatibility
|
||||||
│ ├── serializer.py # JSON persistence
|
│ ├── serializer.py # JSON persistence
|
||||||
│ ├── libraries.py # Library file discovery and parsing
|
│ ├── libraries.py # Library file discovery and parsing
|
||||||
│ └── simulation/ # Qt-free simulation service and compiler/runtime code
|
│ └── simulation/ # Qt-free composition and OpenModelica interface code
|
||||||
└── gui/ # All Qt-dependent code
|
└── gui/ # All Qt-dependent code
|
||||||
├── app.py # QApplication startup and palette
|
├── app.py # QApplication startup and palette
|
||||||
├── main_window.py # Top-level UI orchestration
|
├── main_window.py # Top-level UI orchestration
|
||||||
@@ -105,16 +105,19 @@ Keep the root of `src/bedit` minimal. New UI modules should go into the relevant
|
|||||||
- File → Reload Simulation Code (`Ctrl+F5`) reloads modules under
|
- File → Reload Simulation Code (`Ctrl+F5`) reloads modules under
|
||||||
`bedit.core.simulation`, replaces the shared application/controller service,
|
`bedit.core.simulation`, replaces the shared application/controller service,
|
||||||
and preserves the previous instance attributes where possible.
|
and preserves the previous instance attributes where possible.
|
||||||
- Simulation compilation lives in `core/simulation/compiler.py`; the simulation
|
- Modelica composition lives in `core/simulation/composer.py`; the simulation
|
||||||
service only owns application state and delegates compilation. Ports with
|
service only owns application state and delegates composition. Ports with
|
||||||
`multipleConnections` are emitted as Modelica arrays. Their size is inferred
|
`multipleConnections` are emitted as Modelica arrays. Their size is inferred
|
||||||
per component instance from graph connections and exposed while compiling as
|
per component instance from graph connections and exposed while compiling as
|
||||||
`$portname_N$`; array connection endpoints receive stable one-based indices in
|
`$portname_N$`; array connection endpoints receive stable one-based indices in
|
||||||
graph connection order.
|
graph connection order.
|
||||||
- Blocking simulator integration belongs in `core/simulation/runner.py` and runs
|
- OpenModelica integration belongs in `core/simulation/openmodelica.py`. Its
|
||||||
on its daemon worker thread. Never perform OMPython startup or simulation work
|
persistent worker and OMC session start lazily on the first queued request.
|
||||||
directly on the Qt GUI thread; background failures must go to the application
|
Never perform OMPython work directly on the Qt GUI thread. Result and error
|
||||||
log.
|
callbacks run on the worker thread and must use a Qt signal before touching UI.
|
||||||
|
One lazy temporary working directory is shared by all requests in the session.
|
||||||
|
Explicit application shutdown closes OMC and removes that directory plus the
|
||||||
|
current session's OMPython log and port files; `__del__` is only a fallback.
|
||||||
- The optional OpenModelica executable is persisted as
|
- The optional OpenModelica executable is persisted as
|
||||||
`simulation/openModelicaPath`. The GUI passes it into `Simulation`; core must
|
`simulation/openModelicaPath`. The GUI passes it into `Simulation`; core must
|
||||||
not read `QSettings`. An empty path uses OMPython/PATH discovery, while an
|
not read `QSettings`. An empty path uses OMPython/PATH discovery, while an
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
from bedit.core.simulation.service import Simulation
|
from bedit.core.simulation.service import Simulation
|
||||||
|
from bedit.core.simulation.openmodelica import OpenModelicaInterface
|
||||||
|
|
||||||
__all__ = ["Simulation"]
|
__all__ = ["OpenModelicaInterface", "Simulation"]
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ _BEVALUE_PATTERN = re.compile(r"\$([A-Za-z_][A-Za-z0-9_]*)\$")
|
|||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class CompilationResult:
|
class CompositionResult:
|
||||||
"""The intermediate data and generated source produced by compilation."""
|
"""The intermediate data and generated source produced by composition."""
|
||||||
|
|
||||||
graph: dict[str, Any]
|
graph: dict[str, Any]
|
||||||
objects_by_id: dict[str, Any]
|
objects_by_id: dict[str, Any]
|
||||||
@@ -23,16 +23,16 @@ class CompilationResult:
|
|||||||
model_name: str
|
model_name: str
|
||||||
|
|
||||||
|
|
||||||
def compile_graph(graph: dict[str, Any]) -> CompilationResult:
|
def compose_graph(graph: dict[str, Any]) -> CompositionResult:
|
||||||
"""Clean, index, and emit a serialized component tree."""
|
"""Clean, index, and compose a serialized component tree."""
|
||||||
|
|
||||||
cleaned_graph = cleanup_graph(deepcopy(graph))
|
cleaned_graph = cleanup_graph(deepcopy(graph))
|
||||||
objects_by_id = build_id_list(cleaned_graph)
|
objects_by_id = build_id_list(cleaned_graph)
|
||||||
return CompilationResult(
|
return CompositionResult(
|
||||||
graph=cleaned_graph,
|
graph=cleaned_graph,
|
||||||
objects_by_id=objects_by_id,
|
objects_by_id=objects_by_id,
|
||||||
modelica=emit_model(cleaned_graph, objects_by_id),
|
modelica=emit_model(cleaned_graph, objects_by_id),
|
||||||
model_name=model_name_for(cleaned_graph)
|
model_name=model_name_for(cleaned_graph),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ def emit_model(
|
|||||||
) -> str:
|
) -> str:
|
||||||
"""Emit a component and its nested definitions as Modelica source."""
|
"""Emit a component and its nested definitions as Modelica source."""
|
||||||
|
|
||||||
del id_list # Kept in the public API for compiler extensions and inspection.
|
del id_list # Kept in the public API for composer extensions and inspection.
|
||||||
indentation = "\t" * indent
|
indentation = "\t" * indent
|
||||||
body_indent = "\t" * (indent + 1)
|
body_indent = "\t" * (indent + 1)
|
||||||
model_name = model_name_for(graph)
|
model_name = model_name_for(graph)
|
||||||
@@ -283,7 +283,7 @@ def _port_count_macros(
|
|||||||
|
|
||||||
|
|
||||||
def expand_bevalues(text: str, values: dict[str, str]) -> str:
|
def expand_bevalues(text: str, values: dict[str, str]) -> str:
|
||||||
"""Replace BEdit ``$name$`` macros and reject unresolved compiler values."""
|
"""Replace BEdit ``$name$`` macros and reject unresolved composer values."""
|
||||||
|
|
||||||
def replace(match: re.Match[str]) -> str:
|
def replace(match: re.Match[str]) -> str:
|
||||||
name = match.group(1)
|
name = match.group(1)
|
||||||
247
BEdit/src/bedit/core/simulation/openmodelica.py
Normal file
247
BEdit/src/bedit/core/simulation/openmodelica.py
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from queue import Queue
|
||||||
|
from threading import Lock, Thread, current_thread
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from bedit.core.application_log import get_logger
|
||||||
|
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
ResultCallback = Callable[[Any], None]
|
||||||
|
ErrorCallback = Callable[[Exception], None]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _Request:
|
||||||
|
expression: str
|
||||||
|
parsed: bool
|
||||||
|
callback: ResultCallback | None
|
||||||
|
error_callback: ErrorCallback | None
|
||||||
|
|
||||||
|
|
||||||
|
class OpenModelicaInterface:
|
||||||
|
"""Asynchronous, persistent interface to one OpenModelica session.
|
||||||
|
|
||||||
|
The worker and OMC session are created lazily for the first request. Callback
|
||||||
|
functions execute on the worker thread and must not manipulate Qt widgets.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, executable_path: str = "") -> None:
|
||||||
|
self._executable_path = executable_path
|
||||||
|
self._lifecycle_lock = Lock()
|
||||||
|
self._queue: Queue[_Request | None] | None = None
|
||||||
|
self._worker: Thread | None = None
|
||||||
|
self._temp_dir: Path | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def executable_path(self) -> str:
|
||||||
|
return self._executable_path
|
||||||
|
|
||||||
|
def configure(self, executable_path: str) -> None:
|
||||||
|
"""Use a new executable path for subsequent requests."""
|
||||||
|
|
||||||
|
if executable_path == self._executable_path:
|
||||||
|
return
|
||||||
|
self.shutdown(wait=True)
|
||||||
|
self._executable_path = executable_path
|
||||||
|
|
||||||
|
def get_version(
|
||||||
|
self,
|
||||||
|
callback: ResultCallback | None = None,
|
||||||
|
error_callback: ErrorCallback | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Request the OpenModelica version without blocking the caller."""
|
||||||
|
self.send_expression("getVersion()", callback, error_callback)
|
||||||
|
|
||||||
|
def load_model(
|
||||||
|
self,
|
||||||
|
model: str,
|
||||||
|
callback: ResultCallback | None = None,
|
||||||
|
error_callback: ErrorCallback | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Load a composed model into OpenModelica"""
|
||||||
|
self.send_expression(f"loadString({json.dumps(model)})", callback, error_callback)
|
||||||
|
|
||||||
|
def compile_model(
|
||||||
|
self,
|
||||||
|
model_name: str,
|
||||||
|
callback: ResultCallback | None = None,
|
||||||
|
error_callback: ErrorCallback | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Compile a loaded model in OpenModelica"""
|
||||||
|
self.send_expression(f"buildModel({model_name})", callback, error_callback)
|
||||||
|
|
||||||
|
def exec_system(
|
||||||
|
self,
|
||||||
|
cmd: str,
|
||||||
|
callback: ResultCallback | None = None,
|
||||||
|
error_callback: ErrorCallback | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Execute system command from OpenModelica"""
|
||||||
|
self.send_expression(f"system(\"{cmd}\", \"system_out.txt\")", callback, error_callback)
|
||||||
|
|
||||||
|
def send_expression(
|
||||||
|
self,
|
||||||
|
expression: str,
|
||||||
|
callback: ResultCallback | None = None,
|
||||||
|
error_callback: ErrorCallback | None = None,
|
||||||
|
*,
|
||||||
|
parsed: bool = True,
|
||||||
|
) -> None:
|
||||||
|
"""Queue an OMC expression for ordered execution on the worker thread."""
|
||||||
|
|
||||||
|
request = _Request(expression, parsed, callback, error_callback)
|
||||||
|
self._ensure_worker().put(request)
|
||||||
|
|
||||||
|
def shutdown(self, *, wait: bool = True) -> None:
|
||||||
|
"""Ask the worker to close its OMC session after queued requests."""
|
||||||
|
|
||||||
|
with self._lifecycle_lock:
|
||||||
|
worker = self._worker
|
||||||
|
queue = self._queue
|
||||||
|
self._worker = None
|
||||||
|
self._queue = None
|
||||||
|
if queue is not None:
|
||||||
|
queue.put(None)
|
||||||
|
if wait and worker is not None and worker is not current_thread():
|
||||||
|
worker.join()
|
||||||
|
if worker is None:
|
||||||
|
self._cleanup_temp_dir()
|
||||||
|
|
||||||
|
def __del__(self) -> None:
|
||||||
|
"""Best-effort fallback; normal application shutdown is explicit."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.shutdown(wait=False)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _ensure_worker(self) -> Queue[_Request | None]:
|
||||||
|
with self._lifecycle_lock:
|
||||||
|
if self._worker is not None and self._worker.is_alive():
|
||||||
|
return self._queue
|
||||||
|
temp_dir = self._ensure_temp_dir_locked()
|
||||||
|
queue: Queue[_Request | None] = Queue()
|
||||||
|
worker = Thread(
|
||||||
|
target=self._worker_main,
|
||||||
|
args=(queue, self._executable_path, temp_dir),
|
||||||
|
name="bedit-openmodelica",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
self._queue = queue
|
||||||
|
self._worker = worker
|
||||||
|
worker.start()
|
||||||
|
return queue
|
||||||
|
|
||||||
|
def _worker_main(
|
||||||
|
self,
|
||||||
|
queue: Queue[_Request | None],
|
||||||
|
executable_path: str,
|
||||||
|
temp_dir: Path,
|
||||||
|
) -> None:
|
||||||
|
omc = None
|
||||||
|
try:
|
||||||
|
while (request := queue.get()) is not None:
|
||||||
|
try:
|
||||||
|
if omc is None:
|
||||||
|
omc = _create_session(executable_path)
|
||||||
|
changed_directory = omc.sendExpression(
|
||||||
|
f"cd({json.dumps(str(temp_dir))})"
|
||||||
|
)
|
||||||
|
if not changed_directory:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"OpenModelica could not use {str(temp_dir)!r}"
|
||||||
|
)
|
||||||
|
result = omc.sendExpression(
|
||||||
|
request.expression, parsed=request.parsed
|
||||||
|
)
|
||||||
|
except Exception as error:
|
||||||
|
if request.error_callback is None:
|
||||||
|
log.exception(
|
||||||
|
"OpenModelica request failed: %s", request.expression
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_deliver_callback(request.error_callback, error)
|
||||||
|
else:
|
||||||
|
if request.callback is not None:
|
||||||
|
_deliver_callback(request.callback, result)
|
||||||
|
finally:
|
||||||
|
transport_files = _ompython_transport_files(omc)
|
||||||
|
if omc is not None:
|
||||||
|
try:
|
||||||
|
omc.sendExpression("quit()")
|
||||||
|
except Exception:
|
||||||
|
log.debug("Could not close OpenModelica session", exc_info=True)
|
||||||
|
for path in transport_files:
|
||||||
|
try:
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
log.debug("Could not remove OMPython file %s", path, exc_info=True)
|
||||||
|
self._cleanup_temp_dir(temp_dir)
|
||||||
|
|
||||||
|
def _ensure_temp_dir_locked(self) -> Path:
|
||||||
|
if self._temp_dir is None:
|
||||||
|
self._temp_dir = Path(tempfile.mkdtemp(prefix="bedit-openmodelica-"))
|
||||||
|
return self._temp_dir
|
||||||
|
|
||||||
|
def _cleanup_temp_dir(self, expected: Path | None = None) -> None:
|
||||||
|
with self._lifecycle_lock:
|
||||||
|
if expected is not None and self._temp_dir != expected:
|
||||||
|
temp_dir = expected
|
||||||
|
else:
|
||||||
|
temp_dir = self._temp_dir
|
||||||
|
self._temp_dir = None
|
||||||
|
if temp_dir is not None:
|
||||||
|
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _deliver_callback(callback: Callable[[Any], None], value: Any) -> None:
|
||||||
|
try:
|
||||||
|
callback(value)
|
||||||
|
except Exception:
|
||||||
|
log.exception("OpenModelica callback failed")
|
||||||
|
|
||||||
|
|
||||||
|
def _create_session(executable_path: str):
|
||||||
|
from OMPython import OMCSessionZMQ
|
||||||
|
|
||||||
|
return OMCSessionZMQ(omhome=_openmodelica_home(executable_path))
|
||||||
|
|
||||||
|
|
||||||
|
def _ompython_transport_files(omc) -> set[Path]:
|
||||||
|
"""Return only the log and port files owned by this OMPython session."""
|
||||||
|
|
||||||
|
if omc is None:
|
||||||
|
return set()
|
||||||
|
process = getattr(omc, "omc_process", None)
|
||||||
|
if process is None:
|
||||||
|
return set()
|
||||||
|
files: set[Path] = set()
|
||||||
|
temp_dir = getattr(process, "_temp_dir", None)
|
||||||
|
file_base = getattr(process, "_omc_filebase", None)
|
||||||
|
if temp_dir is not None and file_base:
|
||||||
|
files.add(Path(temp_dir) / f"{file_base}.log")
|
||||||
|
try:
|
||||||
|
port_file = process._get_portfile_path()
|
||||||
|
except Exception:
|
||||||
|
port_file = None
|
||||||
|
if port_file is not None:
|
||||||
|
files.add(Path(port_file))
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
def _openmodelica_home(executable_path: str) -> str | None:
|
||||||
|
"""Convert an optional omc executable path to the home expected by OMPython."""
|
||||||
|
|
||||||
|
if not executable_path.strip():
|
||||||
|
return None
|
||||||
|
path = Path(os.path.expandvars(executable_path)).expanduser()
|
||||||
|
if path.name.lower() in {"omc", "omc.exe"}:
|
||||||
|
return str(path.parent.parent)
|
||||||
|
return str(path)
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
import json
|
|
||||||
import os
|
|
||||||
from collections.abc import Callable
|
|
||||||
from pathlib import Path
|
|
||||||
from threading import Lock, Thread
|
|
||||||
|
|
||||||
from bedit.core.application_log import get_logger
|
|
||||||
|
|
||||||
|
|
||||||
log = get_logger(__name__)
|
|
||||||
_worker_lock = Lock()
|
|
||||||
_worker: Thread | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def run_validation_async(
|
|
||||||
openmodelica_path: str = "",
|
|
||||||
model: str = "",
|
|
||||||
model_name: str = "",
|
|
||||||
task: Callable[[], None] | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Start one validation task in a background thread and return immediately."""
|
|
||||||
|
|
||||||
global _worker
|
|
||||||
with _worker_lock:
|
|
||||||
if _worker is not None and _worker.is_alive():
|
|
||||||
raise RuntimeError("An OpenModelica task is already running")
|
|
||||||
_worker = Thread(
|
|
||||||
target=_run_safely,
|
|
||||||
args=(
|
|
||||||
task
|
|
||||||
or (lambda: _run_validation(openmodelica_path, model, model_name)),
|
|
||||||
),
|
|
||||||
name="bedit-simulation",
|
|
||||||
daemon=True,
|
|
||||||
)
|
|
||||||
_worker.start()
|
|
||||||
|
|
||||||
|
|
||||||
def run_simulation_async(
|
|
||||||
openmodelica_path: str = "",
|
|
||||||
model: str = "",
|
|
||||||
model_name: str = "",
|
|
||||||
task: Callable[[], None] | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Start one validation-and-simulation task and return immediately."""
|
|
||||||
|
|
||||||
global _worker
|
|
||||||
with _worker_lock:
|
|
||||||
if _worker is not None and _worker.is_alive():
|
|
||||||
raise RuntimeError("An OpenModelica task is already running")
|
|
||||||
_worker = Thread(
|
|
||||||
target=_run_safely,
|
|
||||||
args=(
|
|
||||||
task
|
|
||||||
or (lambda: _run_openmodelica(openmodelica_path, model, model_name)),
|
|
||||||
),
|
|
||||||
name="bedit-simulation",
|
|
||||||
daemon=True,
|
|
||||||
)
|
|
||||||
_worker.start()
|
|
||||||
|
|
||||||
|
|
||||||
def simulation_is_running() -> bool:
|
|
||||||
"""Return whether the background simulation worker is active."""
|
|
||||||
|
|
||||||
with _worker_lock:
|
|
||||||
return _worker is not None and _worker.is_alive()
|
|
||||||
|
|
||||||
|
|
||||||
def _run_safely(task: Callable[[], None]) -> None:
|
|
||||||
global _worker
|
|
||||||
try:
|
|
||||||
task()
|
|
||||||
except Exception:
|
|
||||||
log.exception("Simulation run failed")
|
|
||||||
finally:
|
|
||||||
with _worker_lock:
|
|
||||||
_worker = None
|
|
||||||
|
|
||||||
|
|
||||||
def _run_validation(
|
|
||||||
openmodelica_path: str = "", model: str = "", model_name: str = ""
|
|
||||||
) -> None:
|
|
||||||
"""Create the OpenModelica session and perform a validation"""
|
|
||||||
|
|
||||||
from OMPython import OMCSessionZMQ
|
|
||||||
|
|
||||||
omhome = _openmodelica_home(openmodelica_path)
|
|
||||||
omc = OMCSessionZMQ(omhome=omhome)
|
|
||||||
_validate_model(omc, model, model_name)
|
|
||||||
|
|
||||||
|
|
||||||
def _run_openmodelica(
|
|
||||||
openmodelica_path: str = "", model: str = "", model_name: str = ""
|
|
||||||
) -> None:
|
|
||||||
"""Validate and run the current simulation stub in one OMC session."""
|
|
||||||
|
|
||||||
from OMPython import OMCSessionZMQ
|
|
||||||
|
|
||||||
omhome = _openmodelica_home(openmodelica_path)
|
|
||||||
omc = OMCSessionZMQ(omhome=omhome)
|
|
||||||
_validate_model(omc, model, model_name)
|
|
||||||
|
|
||||||
log.info("OpenModelica Version: %s", omc.sendExpression("getVersion()"))
|
|
||||||
|
|
||||||
loaded = omc.sendExpression(f"loadString({json.dumps(model)})")
|
|
||||||
if loaded is not True:
|
|
||||||
details = _get_error_string(omc)
|
|
||||||
raise RuntimeError(f"OpenModelica could not load the model: {details}")
|
|
||||||
|
|
||||||
result = omc.sendExpression(f"checkModel({model_name})")
|
|
||||||
details = _get_error_string(omc)
|
|
||||||
if not isinstance(result, str) or "completed successfully" not in result:
|
|
||||||
message = details or result or "Unknown OpenModelica validation error"
|
|
||||||
raise RuntimeError(f"OpenModelica model validation failed: {message}")
|
|
||||||
|
|
||||||
def _validate_model(omc, model: str, model_name: str) -> None:
|
|
||||||
if not model:
|
|
||||||
raise ValueError("There is no compiled Modelica source to validate")
|
|
||||||
if not model_name:
|
|
||||||
raise ValueError("The compiled model has no Modelica name")
|
|
||||||
|
|
||||||
loaded = omc.sendExpression(f"loadString({json.dumps(model)})")
|
|
||||||
if loaded is not True:
|
|
||||||
details = _get_error_string(omc)
|
|
||||||
raise RuntimeError(f"OpenModelica could not load the model: {details}")
|
|
||||||
|
|
||||||
result = omc.sendExpression(f"checkModel({model_name})")
|
|
||||||
details = _get_error_string(omc)
|
|
||||||
if not isinstance(result, str) or "completed successfully" not in result:
|
|
||||||
message = details or result or "Unknown OpenModelica validation error"
|
|
||||||
raise RuntimeError(f"OpenModelica model validation failed: {message}")
|
|
||||||
log.info("%s", result)
|
|
||||||
if details:
|
|
||||||
log.warning("OpenModelica validation messages: %s", details)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_error_string(omc) -> str:
|
|
||||||
"""Read OMC's deliberately unparsed error response as plain text."""
|
|
||||||
|
|
||||||
raw = omc.sendExpression("getErrorString()", parsed=False)
|
|
||||||
if not isinstance(raw, str):
|
|
||||||
return str(raw or "")
|
|
||||||
raw = raw.strip()
|
|
||||||
if not raw:
|
|
||||||
return ""
|
|
||||||
try:
|
|
||||||
decoded = json.loads(raw)
|
|
||||||
except (TypeError, json.JSONDecodeError):
|
|
||||||
return raw
|
|
||||||
return decoded if isinstance(decoded, str) else str(decoded)
|
|
||||||
|
|
||||||
|
|
||||||
def _openmodelica_home(openmodelica_path: str) -> str | None:
|
|
||||||
"""Convert an optional omc executable path to the home expected by OMPython."""
|
|
||||||
|
|
||||||
if not openmodelica_path.strip():
|
|
||||||
return None
|
|
||||||
path = Path(os.path.expandvars(openmodelica_path)).expanduser()
|
|
||||||
if path.name.lower() in {"omc", "omc.exe"}:
|
|
||||||
return str(path.parent.parent)
|
|
||||||
return str(path)
|
|
||||||
@@ -1,50 +1,102 @@
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from bedit.core.application_log import get_logger
|
from bedit.core.application_log import get_logger
|
||||||
from bedit.core.simulation.compiler import compile_graph
|
from bedit.core.simulation.composer import compose_graph
|
||||||
from bedit.core.simulation.runner import run_simulation_async, run_validation_async
|
from bedit.core.simulation.openmodelica import (
|
||||||
|
ErrorCallback,
|
||||||
|
OpenModelicaInterface,
|
||||||
|
ResultCallback,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class Simulation:
|
class Simulation:
|
||||||
"""Application-owned simulation state and compiler facade."""
|
"""Application-owned composition state and OpenModelica interface."""
|
||||||
|
|
||||||
def __init__(self, *, openmodelica_path: str = "") -> None:
|
def __init__(self, *, openmodelica_path: str = "") -> None:
|
||||||
self.state: dict[str, Any] = {}
|
self.state: dict[str, Any] = {}
|
||||||
self.last_compilation_input: dict[str, Any] | None = None
|
self.last_composition_input: dict[str, Any] | None = None
|
||||||
self.last_compilation_output: str | None = None
|
self.last_composition_output: str | None = None
|
||||||
self.id_list: dict[str, Any] = {}
|
self.id_list: dict[str, Any] = {}
|
||||||
self.openmodelica_path = openmodelica_path
|
self.model_name: str | None = None
|
||||||
|
self._openmodelica_path = openmodelica_path
|
||||||
|
self.openmodelica = OpenModelicaInterface(openmodelica_path)
|
||||||
|
self.model_path : str | None = None
|
||||||
|
|
||||||
def compile(self, graph: dict[str, Any]) -> None:
|
@property
|
||||||
"""Compile a serialized component tree and retain the result."""
|
def openmodelica_path(self) -> str:
|
||||||
|
return self._openmodelica_path
|
||||||
|
|
||||||
self._prepare_compilation(graph)
|
@openmodelica_path.setter
|
||||||
run_validation_async(
|
def openmodelica_path(self, value: str) -> None:
|
||||||
self.openmodelica_path,
|
self._openmodelica_path = value
|
||||||
self.last_compilation_output,
|
self.openmodelica.configure(value)
|
||||||
self.model_name,
|
|
||||||
|
def compose(self, graph: dict[str, Any]) -> None:
|
||||||
|
"""Compose and retain the active graph's Modelica representation."""
|
||||||
|
|
||||||
|
self.model_path = None
|
||||||
|
|
||||||
|
# Create openmodelica model
|
||||||
|
result = compose_graph(graph)
|
||||||
|
self.last_composition_input = result.graph
|
||||||
|
self.id_list = result.objects_by_id
|
||||||
|
self.last_composition_output = result.modelica
|
||||||
|
self.model_name = result.model_name
|
||||||
|
log.info("Composed OpenModelica model:\n%s", result.modelica)
|
||||||
|
|
||||||
|
# Compile openmodelica model
|
||||||
|
self.openmodelica.load_model(
|
||||||
|
self.last_composition_output,
|
||||||
|
lambda result: log.info("Loading OK: %s", result),
|
||||||
|
lambda error: log.info("Loading ERROR: %s", repr(error)),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _prepare_compilation(self, graph: dict[str, Any]) -> None:
|
def _model_compiled(result):
|
||||||
"""Generate and retain Modelica without starting a background operation."""
|
log.info("Compiling OK: %s", result)
|
||||||
|
self.model_path = result[0]
|
||||||
|
|
||||||
result = compile_graph(graph)
|
self.openmodelica.compile_model(
|
||||||
self.last_compilation_input = result.graph
|
self.model_name,
|
||||||
self.id_list = result.objects_by_id
|
_model_compiled,
|
||||||
self.last_compilation_output = result.modelica
|
lambda error: log.info("Compiling ERROR: %s", repr(error)),
|
||||||
self.model_name = result.model_name
|
)
|
||||||
|
|
||||||
def run_simulation(self, graph: dict[str, Any]) -> None:
|
def run_simulation(self, graph: dict[str, Any]) -> None:
|
||||||
"""Start the simulation without blocking the calling UI thread."""
|
"""Reserved for a future simulation request."""
|
||||||
|
|
||||||
# Always regenerate before running, then validate and simulate as one
|
if self.model_path is None:
|
||||||
# background operation so the two phases cannot compete for the worker.
|
self.compose(graph)
|
||||||
self._prepare_compilation(graph)
|
|
||||||
run_simulation_async(
|
self.openmodelica.get_version(lambda val: log.info(val))
|
||||||
self.openmodelica_path,
|
log.info(f"Running model binary {self.model_path}")
|
||||||
self.last_compilation_output,
|
|
||||||
self.model_name,
|
self.openmodelica.exec_system(
|
||||||
|
f'./{self.model_name} {self.build_simulation_arguments()}',
|
||||||
|
lambda result: log.info("Running OK: %s", result),
|
||||||
|
lambda error: log.info("Running ERROR: %s", repr(error)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def shutdown(self, *, wait: bool = True) -> None:
|
||||||
|
"""Close OpenModelica and clean its generated working directory."""
|
||||||
|
self.openmodelica.shutdown(wait=wait)
|
||||||
|
self.model_path = None
|
||||||
|
|
||||||
|
def build_simulation_arguments(self) -> str:
|
||||||
|
opts = self.last_composition_input['implementation']['graph']['simulation']
|
||||||
|
|
||||||
|
|
||||||
|
args = "-port=40696 -outputFormat=csv -logFormat=xmltcp "
|
||||||
|
|
||||||
|
args += f"-startTime={opts['startTime']} -stopTime={opts['stopTime']} "
|
||||||
|
|
||||||
|
step_size = opts['intervalTime']
|
||||||
|
if opts['intervalMode'] == 'numberOfIntervals':
|
||||||
|
step_size = (opts['stopTime']-opts['startTime'])/opts['numberOfIntervals']
|
||||||
|
args += f"-stepSize={step_size}"
|
||||||
|
|
||||||
|
log.info("Running model with: %s", args)
|
||||||
|
|
||||||
|
return args
|
||||||
|
|||||||
@@ -421,11 +421,11 @@ class DocumentController(QObject):
|
|||||||
if old != new:
|
if old != new:
|
||||||
self.undo_stack.push(EditGraphParametersCommand(self, old, new))
|
self.undo_stack.push(EditGraphParametersCommand(self, old, new))
|
||||||
|
|
||||||
def compile_active_graph(self) -> None:
|
def compose_active_graph(self) -> None:
|
||||||
component = self.active_component
|
component = self.active_component
|
||||||
if component is None or component.implementation_kind != "graph":
|
if component is None or component.implementation_kind != "graph":
|
||||||
raise ValueError("Open a graph component before compiling")
|
raise ValueError("Open a graph component before composing")
|
||||||
self.simulation.compile(component.to_dict())
|
self.simulation.compose(component.to_dict())
|
||||||
|
|
||||||
def run_simulation(self) -> None:
|
def run_simulation(self) -> None:
|
||||||
component = self.active_component
|
component = self.active_component
|
||||||
|
|||||||
@@ -41,11 +41,11 @@ class Ui_MainWindow(object):
|
|||||||
icon1 = QIcon()
|
icon1 = QIcon()
|
||||||
icon1.addFile(u":/icons/icons/configure.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
|
icon1.addFile(u":/icons/icons/configure.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
|
||||||
self.actionGraphParameters.setIcon(icon1)
|
self.actionGraphParameters.setIcon(icon1)
|
||||||
self.actionCompile = QAction(MainWindow)
|
self.actionCompose = QAction(MainWindow)
|
||||||
self.actionCompile.setObjectName(u"actionCompile")
|
self.actionCompose.setObjectName(u"actionCompose")
|
||||||
icon2 = QIcon()
|
icon2 = QIcon()
|
||||||
icon2.addFile(u":/icons/icons/run-build.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
|
icon2.addFile(u":/icons/icons/run-build.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
|
||||||
self.actionCompile.setIcon(icon2)
|
self.actionCompose.setIcon(icon2)
|
||||||
self.actionRunSimulation = QAction(MainWindow)
|
self.actionRunSimulation = QAction(MainWindow)
|
||||||
self.actionRunSimulation.setObjectName(u"actionRunSimulation")
|
self.actionRunSimulation.setObjectName(u"actionRunSimulation")
|
||||||
icon3 = QIcon()
|
icon3 = QIcon()
|
||||||
@@ -435,7 +435,7 @@ class Ui_MainWindow(object):
|
|||||||
self.menuHelp.addAction(self.actionAboutQt)
|
self.menuHelp.addAction(self.actionAboutQt)
|
||||||
self.menuSimulation.addAction(self.actionSimulationSettings)
|
self.menuSimulation.addAction(self.actionSimulationSettings)
|
||||||
self.menuSimulation.addAction(self.actionGraphParameters)
|
self.menuSimulation.addAction(self.actionGraphParameters)
|
||||||
self.menuSimulation.addAction(self.actionCompile)
|
self.menuSimulation.addAction(self.actionCompose)
|
||||||
self.menuSimulation.addAction(self.actionRunSimulation)
|
self.menuSimulation.addAction(self.actionRunSimulation)
|
||||||
self.fileToolbar.addAction(self.actionNew)
|
self.fileToolbar.addAction(self.actionNew)
|
||||||
self.fileToolbar.addAction(self.actionOpen)
|
self.fileToolbar.addAction(self.actionOpen)
|
||||||
@@ -451,7 +451,7 @@ class Ui_MainWindow(object):
|
|||||||
self.cameraToolbar.addAction(self.actionCenterView)
|
self.cameraToolbar.addAction(self.actionCenterView)
|
||||||
self.simulationToolbar.addAction(self.actionSimulationSettings)
|
self.simulationToolbar.addAction(self.actionSimulationSettings)
|
||||||
self.simulationToolbar.addAction(self.actionGraphParameters)
|
self.simulationToolbar.addAction(self.actionGraphParameters)
|
||||||
self.simulationToolbar.addAction(self.actionCompile)
|
self.simulationToolbar.addAction(self.actionCompose)
|
||||||
self.simulationToolbar.addAction(self.actionRunSimulation)
|
self.simulationToolbar.addAction(self.actionRunSimulation)
|
||||||
|
|
||||||
self.retranslateUi(MainWindow)
|
self.retranslateUi(MainWindow)
|
||||||
@@ -472,12 +472,12 @@ class Ui_MainWindow(object):
|
|||||||
#if QT_CONFIG(statustip)
|
#if QT_CONFIG(statustip)
|
||||||
self.actionGraphParameters.setStatusTip(QCoreApplication.translate("MainWindow", u"Edit parameters throughout the active graph", None))
|
self.actionGraphParameters.setStatusTip(QCoreApplication.translate("MainWindow", u"Edit parameters throughout the active graph", None))
|
||||||
#endif // QT_CONFIG(statustip)
|
#endif // QT_CONFIG(statustip)
|
||||||
self.actionCompile.setText(QCoreApplication.translate("MainWindow", u"Compile", None))
|
self.actionCompose.setText(QCoreApplication.translate("MainWindow", u"Compose", None))
|
||||||
#if QT_CONFIG(statustip)
|
#if QT_CONFIG(statustip)
|
||||||
self.actionCompile.setStatusTip(QCoreApplication.translate("MainWindow", u"Compile the active graph for simulation", None))
|
self.actionCompose.setStatusTip(QCoreApplication.translate("MainWindow", u"Compose the active graph as an OpenModelica model", None))
|
||||||
#endif // QT_CONFIG(statustip)
|
#endif // QT_CONFIG(statustip)
|
||||||
#if QT_CONFIG(shortcut)
|
#if QT_CONFIG(shortcut)
|
||||||
self.actionCompile.setShortcut(QCoreApplication.translate("MainWindow", u"F5", None))
|
self.actionCompose.setShortcut(QCoreApplication.translate("MainWindow", u"F5", None))
|
||||||
#endif // QT_CONFIG(shortcut)
|
#endif // QT_CONFIG(shortcut)
|
||||||
self.actionRunSimulation.setText(QCoreApplication.translate("MainWindow", u"Run", None))
|
self.actionRunSimulation.setText(QCoreApplication.translate("MainWindow", u"Run", None))
|
||||||
#if QT_CONFIG(statustip)
|
#if QT_CONFIG(statustip)
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ class MainWindow(QMainWindow):
|
|||||||
self.show_simulation_settings
|
self.show_simulation_settings
|
||||||
)
|
)
|
||||||
self.ui.actionGraphParameters.triggered.connect(self.show_graph_parameters)
|
self.ui.actionGraphParameters.triggered.connect(self.show_graph_parameters)
|
||||||
self.ui.actionCompile.triggered.connect(self.compile_active_graph)
|
self.ui.actionCompose.triggered.connect(self.compose_active_graph)
|
||||||
self.ui.actionRunSimulation.triggered.connect(self.run_simulation)
|
self.ui.actionRunSimulation.triggered.connect(self.run_simulation)
|
||||||
self.document_controller.undo_stack.canUndoChanged.connect(self.ui.actionUndo.setEnabled)
|
self.document_controller.undo_stack.canUndoChanged.connect(self.ui.actionUndo.setEnabled)
|
||||||
self.document_controller.undo_stack.canRedoChanged.connect(self.ui.actionRedo.setEnabled)
|
self.document_controller.undo_stack.canRedoChanged.connect(self.ui.actionRedo.setEnabled)
|
||||||
@@ -239,12 +239,12 @@ class MainWindow(QMainWindow):
|
|||||||
QMessageBox.warning(self, "Cannot change graph parameters", str(error))
|
QMessageBox.warning(self, "Cannot change graph parameters", str(error))
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
def compile_active_graph(self) -> None:
|
def compose_active_graph(self) -> None:
|
||||||
try:
|
try:
|
||||||
self.document_controller.compile_active_graph()
|
self.document_controller.compose_active_graph()
|
||||||
except ValueError as error:
|
except ValueError as error:
|
||||||
self.log.error("Compile failed: %s", error)
|
self.log.error("Composition failed: %s", error)
|
||||||
QMessageBox.warning(self, "Cannot compile", str(error))
|
QMessageBox.warning(self, "Cannot compose", str(error))
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
def run_simulation(self) -> None:
|
def run_simulation(self) -> None:
|
||||||
@@ -281,7 +281,7 @@ class MainWindow(QMainWindow):
|
|||||||
self._set_graph_controls_visible(False)
|
self._set_graph_controls_visible(False)
|
||||||
self.ui.actionSimulationSettings.setEnabled(False)
|
self.ui.actionSimulationSettings.setEnabled(False)
|
||||||
self.ui.actionGraphParameters.setEnabled(False)
|
self.ui.actionGraphParameters.setEnabled(False)
|
||||||
self.ui.actionCompile.setEnabled(False)
|
self.ui.actionCompose.setEnabled(False)
|
||||||
self.ui.actionRunSimulation.setEnabled(False)
|
self.ui.actionRunSimulation.setEnabled(False)
|
||||||
self._update_edit_actions()
|
self._update_edit_actions()
|
||||||
return
|
return
|
||||||
@@ -292,7 +292,7 @@ class MainWindow(QMainWindow):
|
|||||||
is_graph = component.implementation_kind == "graph"
|
is_graph = component.implementation_kind == "graph"
|
||||||
self.ui.actionSimulationSettings.setEnabled(is_graph)
|
self.ui.actionSimulationSettings.setEnabled(is_graph)
|
||||||
self.ui.actionGraphParameters.setEnabled(is_graph)
|
self.ui.actionGraphParameters.setEnabled(is_graph)
|
||||||
self.ui.actionCompile.setEnabled(is_graph)
|
self.ui.actionCompose.setEnabled(is_graph)
|
||||||
self.ui.actionRunSimulation.setEnabled(is_graph)
|
self.ui.actionRunSimulation.setEnabled(is_graph)
|
||||||
self.ui.workspaceModeLabel.setText("Graph" if is_graph else "Text")
|
self.ui.workspaceModeLabel.setText("Graph" if is_graph else "Text")
|
||||||
self.ui.workspaceStack.setCurrentWidget(
|
self.ui.workspaceStack.setCurrentWidget(
|
||||||
@@ -760,6 +760,7 @@ class MainWindow(QMainWindow):
|
|||||||
event.ignore()
|
event.ignore()
|
||||||
return
|
return
|
||||||
self.settings.setValue("window/geometry", self.saveGeometry())
|
self.settings.setValue("window/geometry", self.saveGeometry())
|
||||||
|
self.simulation.shutdown()
|
||||||
self.log.info("BEdit closed")
|
self.log.info("BEdit closed")
|
||||||
self.application_logger.removeHandler(self.log_handler)
|
self.application_logger.removeHandler(self.log_handler)
|
||||||
event.accept()
|
event.accept()
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ def _saved_instance_state(instance) -> dict:
|
|||||||
def reload_simulation(current):
|
def reload_simulation(current):
|
||||||
"""Reload the simulation package and return a fresh state-preserving instance."""
|
"""Reload the simulation package and return a fresh state-preserving instance."""
|
||||||
saved_state = _saved_instance_state(current)
|
saved_state = _saved_instance_state(current)
|
||||||
|
saved_state.pop("openmodelica", None)
|
||||||
|
current.shutdown(wait=True)
|
||||||
importlib.invalidate_caches()
|
importlib.invalidate_caches()
|
||||||
package = importlib.import_module(SIMULATION_PACKAGE)
|
package = importlib.import_module(SIMULATION_PACKAGE)
|
||||||
discovered: list[ModuleType] = []
|
discovered: list[ModuleType] = []
|
||||||
@@ -30,4 +32,5 @@ def reload_simulation(current):
|
|||||||
package = importlib.reload(package)
|
package = importlib.reload(package)
|
||||||
replacement = package.Simulation()
|
replacement = package.Simulation()
|
||||||
vars(replacement).update(saved_state)
|
vars(replacement).update(saved_state)
|
||||||
|
replacement.openmodelica.configure(replacement.openmodelica_path)
|
||||||
return replacement
|
return replacement
|
||||||
|
|||||||
@@ -551,7 +551,7 @@
|
|||||||
</property>
|
</property>
|
||||||
<addaction name="actionSimulationSettings"/>
|
<addaction name="actionSimulationSettings"/>
|
||||||
<addaction name="actionGraphParameters"/>
|
<addaction name="actionGraphParameters"/>
|
||||||
<addaction name="actionCompile"/>
|
<addaction name="actionCompose"/>
|
||||||
<addaction name="actionRunSimulation"/>
|
<addaction name="actionRunSimulation"/>
|
||||||
</widget>
|
</widget>
|
||||||
<addaction name="menuFile"/>
|
<addaction name="menuFile"/>
|
||||||
@@ -644,7 +644,7 @@
|
|||||||
</attribute>
|
</attribute>
|
||||||
<addaction name="actionSimulationSettings"/>
|
<addaction name="actionSimulationSettings"/>
|
||||||
<addaction name="actionGraphParameters"/>
|
<addaction name="actionGraphParameters"/>
|
||||||
<addaction name="actionCompile"/>
|
<addaction name="actionCompose"/>
|
||||||
<addaction name="actionRunSimulation"/>
|
<addaction name="actionRunSimulation"/>
|
||||||
</widget>
|
</widget>
|
||||||
<action name="actionSimulationSettings">
|
<action name="actionSimulationSettings">
|
||||||
@@ -671,16 +671,16 @@
|
|||||||
<string>Edit parameters throughout the active graph</string>
|
<string>Edit parameters throughout the active graph</string>
|
||||||
</property>
|
</property>
|
||||||
</action>
|
</action>
|
||||||
<action name="actionCompile">
|
<action name="actionCompose">
|
||||||
<property name="icon">
|
<property name="icon">
|
||||||
<iconset resource="../resources/resources.qrc">
|
<iconset resource="../resources/resources.qrc">
|
||||||
<normaloff>:/icons/icons/run-build.png</normaloff>:/icons/icons/run-build.png</iconset>
|
<normaloff>:/icons/icons/run-build.png</normaloff>:/icons/icons/run-build.png</iconset>
|
||||||
</property>
|
</property>
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string>Compile</string>
|
<string>Compose</string>
|
||||||
</property>
|
</property>
|
||||||
<property name="statusTip">
|
<property name="statusTip">
|
||||||
<string>Compile the active graph for simulation</string>
|
<string>Compose the active graph as an OpenModelica model</string>
|
||||||
</property>
|
</property>
|
||||||
<property name="shortcut">
|
<property name="shortcut">
|
||||||
<string>F5</string>
|
<string>F5</string>
|
||||||
|
|||||||
@@ -653,7 +653,7 @@
|
|||||||
"startTime": 0.0,
|
"startTime": 0.0,
|
||||||
"stopTime": 10.0,
|
"stopTime": 10.0,
|
||||||
"intervalMode": "numberOfIntervals",
|
"intervalMode": "numberOfIntervals",
|
||||||
"numberOfIntervals": 5000,
|
"numberOfIntervals": 50,
|
||||||
"intervalTime": 0.002
|
"intervalTime": 0.002
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user