Start of a sim window
This commit is contained in:
@@ -1,12 +1,16 @@
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import socket
|
||||
import tempfile
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
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 threading import Event, Lock, Thread, current_thread
|
||||
from typing import Any
|
||||
|
||||
from bedit.core.application_log import get_logger
|
||||
@@ -19,17 +23,32 @@ ErrorCallback = Callable[[Exception], None]
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Request:
|
||||
expression: str
|
||||
parsed: bool
|
||||
description: str
|
||||
operation: Callable[[Any, Path], Any]
|
||||
callback: ResultCallback | None
|
||||
error_callback: ErrorCallback | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SimulationProgress:
|
||||
phase: str
|
||||
current_step_size: float
|
||||
time: float
|
||||
progress: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SimulationMessage:
|
||||
stream: str
|
||||
type: str
|
||||
text: str
|
||||
|
||||
|
||||
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.
|
||||
The worker and OMC session are created lazily for the first request. Callbacks
|
||||
execute on background threads and must not manipulate Qt widgets directly.
|
||||
"""
|
||||
|
||||
def __init__(self, executable_path: str = "") -> None:
|
||||
@@ -59,32 +78,45 @@ class OpenModelicaInterface:
|
||||
"""Request the OpenModelica version without blocking the caller."""
|
||||
self.send_expression("getVersion()", callback, error_callback)
|
||||
|
||||
def load_model(
|
||||
def build_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(
|
||||
"""Load and build one composed model as an ordered worker operation."""
|
||||
|
||||
def operation(omc, _temp_dir: Path):
|
||||
loaded = omc.sendExpression(f"loadString({json.dumps(model)})")
|
||||
if loaded is not True:
|
||||
raise RuntimeError("OpenModelica could not load the composed model")
|
||||
return omc.sendExpression(f"buildModel({model_name})")
|
||||
|
||||
self._submit("build model", operation, callback, error_callback)
|
||||
|
||||
def run_model(
|
||||
self,
|
||||
cmd: str,
|
||||
executable: str,
|
||||
arguments: list[str],
|
||||
progress_callback: Callable[[SimulationProgress], None] | None = None,
|
||||
message_callback: Callable[[SimulationMessage], None] | None = None,
|
||||
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)
|
||||
"""Run a built model and consume its XML/TCP status stream."""
|
||||
|
||||
def operation(omc, temp_dir: Path):
|
||||
return _run_model_with_tcp(
|
||||
omc,
|
||||
executable,
|
||||
arguments,
|
||||
temp_dir,
|
||||
progress_callback,
|
||||
message_callback,
|
||||
)
|
||||
|
||||
self._submit("run simulation", operation, callback, error_callback)
|
||||
|
||||
def send_expression(
|
||||
self,
|
||||
@@ -96,7 +128,19 @@ class OpenModelicaInterface:
|
||||
) -> None:
|
||||
"""Queue an OMC expression for ordered execution on the worker thread."""
|
||||
|
||||
request = _Request(expression, parsed, callback, error_callback)
|
||||
def operation(omc, _temp_dir: Path):
|
||||
return omc.sendExpression(expression, parsed=parsed)
|
||||
|
||||
self._submit(expression, operation, callback, error_callback)
|
||||
|
||||
def _submit(
|
||||
self,
|
||||
description: str,
|
||||
operation: Callable[[Any, Path], Any],
|
||||
callback: ResultCallback | None,
|
||||
error_callback: ErrorCallback | None,
|
||||
) -> None:
|
||||
request = _Request(description, operation, callback, error_callback)
|
||||
self._ensure_worker().put(request)
|
||||
|
||||
def shutdown(self, *, wait: bool = True) -> None:
|
||||
@@ -158,13 +202,11 @@ class OpenModelicaInterface:
|
||||
raise RuntimeError(
|
||||
f"OpenModelica could not use {str(temp_dir)!r}"
|
||||
)
|
||||
result = omc.sendExpression(
|
||||
request.expression, parsed=request.parsed
|
||||
)
|
||||
result = request.operation(omc, temp_dir)
|
||||
except Exception as error:
|
||||
if request.error_callback is None:
|
||||
log.exception(
|
||||
"OpenModelica request failed: %s", request.expression
|
||||
"OpenModelica request failed: %s", request.description
|
||||
)
|
||||
else:
|
||||
_deliver_callback(request.error_callback, error)
|
||||
@@ -214,6 +256,127 @@ def _create_session(executable_path: str):
|
||||
return OMCSessionZMQ(omhome=_openmodelica_home(executable_path))
|
||||
|
||||
|
||||
def _run_model_with_tcp(
|
||||
omc,
|
||||
executable: str,
|
||||
arguments: list[str],
|
||||
temp_dir: Path,
|
||||
progress_callback: Callable[[SimulationProgress], None] | None,
|
||||
message_callback: Callable[[SimulationMessage], None] | None,
|
||||
) -> int:
|
||||
executable_path = Path(executable)
|
||||
executable_command = executable
|
||||
if not executable_path.is_absolute() and executable_path.parent == Path("."):
|
||||
executable_command = f"./{executable}"
|
||||
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
|
||||
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
server.bind(("127.0.0.1", 0))
|
||||
server.listen(1)
|
||||
server.settimeout(0.25)
|
||||
port = server.getsockname()[1]
|
||||
command = shlex.join([
|
||||
executable_command,
|
||||
*arguments,
|
||||
f"-port={port}",
|
||||
"-logFormat=xmltcp",
|
||||
])
|
||||
output_path = temp_dir / "simulation-output.txt"
|
||||
command_finished = Event()
|
||||
reader_finished = Event()
|
||||
reader_errors: list[Exception] = []
|
||||
|
||||
def read_progress() -> None:
|
||||
try:
|
||||
connection = _accept_simulation_connection(server, command_finished)
|
||||
with connection, connection.makefile(
|
||||
"r", encoding="utf-8"
|
||||
) as stream:
|
||||
for line in stream:
|
||||
_handle_simulation_xml(
|
||||
line, progress_callback, message_callback
|
||||
)
|
||||
except Exception as error:
|
||||
reader_errors.append(error)
|
||||
finally:
|
||||
reader_finished.set()
|
||||
|
||||
reader = Thread(
|
||||
target=read_progress,
|
||||
name="bedit-simulation-progress",
|
||||
daemon=True,
|
||||
)
|
||||
reader.start()
|
||||
log.info("Starting simulation through OpenModelica: %s", command)
|
||||
try:
|
||||
return_code = omc.sendExpression(
|
||||
f"system({json.dumps(command)}, {json.dumps(str(output_path))})"
|
||||
)
|
||||
finally:
|
||||
command_finished.set()
|
||||
if not reader_finished.wait(20.0):
|
||||
raise TimeoutError("Simulation progress connection did not close")
|
||||
if reader_errors:
|
||||
raise reader_errors[0]
|
||||
if return_code != 0:
|
||||
output = output_path.read_text(errors="replace") if output_path.exists() else ""
|
||||
detail = f": {output.strip()}" if output.strip() else ""
|
||||
raise RuntimeError(
|
||||
f"Simulation process exited with status {return_code}{detail}"
|
||||
)
|
||||
return int(return_code)
|
||||
|
||||
|
||||
def _accept_simulation_connection(
|
||||
server: socket.socket, command_finished: Event
|
||||
) -> socket.socket:
|
||||
deadline = time.monotonic() + 15.0
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
connection, _address = server.accept()
|
||||
return connection
|
||||
except socket.timeout:
|
||||
if command_finished.is_set():
|
||||
raise RuntimeError(
|
||||
"Simulation command finished before opening its progress connection"
|
||||
)
|
||||
raise TimeoutError("Simulation did not connect to the progress server")
|
||||
|
||||
|
||||
def _handle_simulation_xml(
|
||||
line: str,
|
||||
progress_callback: Callable[[SimulationProgress], None] | None,
|
||||
message_callback: Callable[[SimulationMessage], None] | None,
|
||||
) -> None:
|
||||
text = line.strip()
|
||||
if not text:
|
||||
return
|
||||
try:
|
||||
element = ET.fromstring(text)
|
||||
except ET.ParseError:
|
||||
log.warning("Invalid simulation status XML: %s", text)
|
||||
return
|
||||
if element.tag == "status" and progress_callback is not None:
|
||||
_deliver_callback(
|
||||
progress_callback,
|
||||
SimulationProgress(
|
||||
phase=element.get("phase", ""),
|
||||
current_step_size=float(element.get("currentStepSize", 0)),
|
||||
time=float(element.get("time", 0)),
|
||||
progress=int(float(element.get("progress", 0))),
|
||||
),
|
||||
)
|
||||
elif element.tag == "message":
|
||||
message = SimulationMessage(
|
||||
stream=element.get("stream", ""),
|
||||
type=element.get("type", ""),
|
||||
text=element.get("text", ""),
|
||||
)
|
||||
log.info("OpenModelica %s: %s", message.stream, message.text)
|
||||
if message_callback is not None:
|
||||
_deliver_callback(message_callback, message)
|
||||
|
||||
|
||||
def _ompython_transport_files(omc) -> set[Path]:
|
||||
"""Return only the log and port files owned by this OMPython session."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user