Skip to content

Commit 70cabfe

Browse files
authored
Merge pull request #1 from syntron/syntron_RFC
Syntron rfc
2 parents 76b73e5 + d81e33b commit 70cabfe

13 files changed

+2577
-2216
lines changed

OMPython/ModelExecution.py

Lines changed: 351 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,351 @@
1+
# -*- coding: utf-8 -*-
2+
"""
3+
Definition of all data needed to run a simulation based on a compiled model executable.
4+
"""
5+
6+
from __future__ import annotations
7+
8+
import ast
9+
import dataclasses
10+
import logging
11+
import numbers
12+
import os
13+
import pathlib
14+
import re
15+
import subprocess
16+
from typing import Any, Optional
17+
import warnings
18+
19+
# define logger using the current module name as ID
20+
logger = logging.getLogger(__name__)
21+
22+
23+
class ModelExecutionException(Exception):
24+
"""
25+
Exception which is raised by ModelException* classes.
26+
"""
27+
28+
29+
class ModelExecutionCmd:
30+
"""
31+
All information about a compiled model executable. This should include data about all structured parameters, i.e.
32+
parameters which need a recompilation of the model. All non-structured parameters can be easily changed without
33+
the need for recompilation.
34+
"""
35+
36+
def __init__(
37+
self,
38+
runpath: os.PathLike,
39+
cmd_prefix: list[str],
40+
cmd_local: bool = False,
41+
cmd_windows: bool = False,
42+
timeout: float = 10.0,
43+
model_name: Optional[str] = None,
44+
) -> None:
45+
if model_name is None:
46+
raise ModelExecutionException("Missing model name!")
47+
48+
self._cmd_local = cmd_local
49+
self._cmd_windows = cmd_windows
50+
self._cmd_prefix = cmd_prefix
51+
self._runpath = pathlib.PurePosixPath(runpath)
52+
self._model_name = model_name
53+
self._timeout = timeout
54+
55+
# dictionaries of command line arguments for the model executable
56+
self._args: dict[str, str | None] = {}
57+
# 'override' argument needs special handling, as it is a dict on its own saved as dict elements following the
58+
# structure: 'key' => 'key=value'
59+
self._arg_override: dict[str, str] = {}
60+
61+
def arg_set(
62+
self,
63+
key: str,
64+
val: Optional[str | dict[str, Any] | numbers.Number] = None,
65+
) -> None:
66+
"""
67+
Set one argument for the executable model.
68+
69+
Args:
70+
key: identifier / argument name to be used for the call of the model executable.
71+
val: value for the given key; None for no value and for key == 'override' a dictionary can be used which
72+
indicates variables to override
73+
"""
74+
75+
def override2str(
76+
okey: str,
77+
oval: str | bool | numbers.Number,
78+
) -> str:
79+
"""
80+
Convert a value for 'override' to a string taking into account differences between Modelica and Python.
81+
"""
82+
# check oval for any string representations of numbers (or bool) and convert these to Python representations
83+
if isinstance(oval, str):
84+
try:
85+
oval_evaluated = ast.literal_eval(oval)
86+
if isinstance(oval_evaluated, (numbers.Number, bool)):
87+
oval = oval_evaluated
88+
except (ValueError, SyntaxError):
89+
pass
90+
91+
if isinstance(oval, str):
92+
oval_str = oval.strip()
93+
elif isinstance(oval, bool):
94+
oval_str = 'true' if oval else 'false'
95+
elif isinstance(oval, numbers.Number):
96+
oval_str = str(oval)
97+
else:
98+
raise ModelExecutionException(f"Invalid value for override key {okey}: {type(oval)}")
99+
100+
return f"{okey}={oval_str}"
101+
102+
if not isinstance(key, str):
103+
raise ModelExecutionException(f"Invalid argument key: {repr(key)} (type: {type(key)})")
104+
key = key.strip()
105+
106+
if isinstance(val, dict):
107+
if key != 'override':
108+
raise ModelExecutionException("Dictionary input only possible for key 'override'!")
109+
110+
for okey, oval in val.items():
111+
if not isinstance(okey, str):
112+
raise ModelExecutionException("Invalid key for argument 'override': "
113+
f"{repr(okey)} (type: {type(okey)})")
114+
115+
if not isinstance(oval, (str, bool, numbers.Number, type(None))):
116+
raise ModelExecutionException(f"Invalid input for 'override'.{repr(okey)}: "
117+
f"{repr(oval)} (type: {type(oval)})")
118+
119+
if okey in self._arg_override:
120+
if oval is None:
121+
logger.info(f"Remove model executable override argument: {repr(self._arg_override[okey])}")
122+
del self._arg_override[okey]
123+
continue
124+
125+
logger.info(f"Update model executable override argument: {repr(okey)} = {repr(oval)} "
126+
f"(was: {repr(self._arg_override[okey])})")
127+
128+
if oval is not None:
129+
self._arg_override[okey] = override2str(okey=okey, oval=oval)
130+
131+
argval = ','.join(sorted(self._arg_override.values()))
132+
elif val is None:
133+
argval = None
134+
elif isinstance(val, str):
135+
argval = val.strip()
136+
elif isinstance(val, numbers.Number):
137+
argval = str(val)
138+
else:
139+
raise ModelExecutionException(f"Invalid argument value for {repr(key)}: {repr(val)} (type: {type(val)})")
140+
141+
if key in self._args:
142+
logger.warning(f"Override model executable argument: {repr(key)} = {repr(argval)} "
143+
f"(was: {repr(self._args[key])})")
144+
self._args[key] = argval
145+
146+
def arg_get(self, key: str) -> Optional[str | dict[str, str | bool | numbers.Number]]:
147+
"""
148+
Return the value for the given key
149+
"""
150+
if key in self._args:
151+
return self._args[key]
152+
153+
return None
154+
155+
def args_set(
156+
self,
157+
args: dict[str, Optional[str | dict[str, Any] | numbers.Number]],
158+
) -> None:
159+
"""
160+
Define arguments for the model executable.
161+
"""
162+
for arg in args:
163+
self.arg_set(key=arg, val=args[arg])
164+
165+
def get_cmd_args(self) -> list[str]:
166+
"""
167+
Get a list with the command arguments for the model executable.
168+
"""
169+
170+
cmdl = []
171+
for key in sorted(self._args):
172+
if self._args[key] is None:
173+
cmdl.append(f"-{key}")
174+
else:
175+
cmdl.append(f"-{key}={self._args[key]}")
176+
177+
return cmdl
178+
179+
def definition(self) -> ModelExecutionData:
180+
"""
181+
Define all needed data to run the model executable. The data is stored in an OMCSessionRunData object.
182+
"""
183+
# ensure that a result filename is provided
184+
result_file = self.arg_get('r')
185+
if not isinstance(result_file, str):
186+
result_file = (self._runpath / f"{self._model_name}.mat").as_posix()
187+
188+
# as this is the local implementation, pathlib.Path can be used
189+
cmd_path = self._runpath
190+
191+
cmd_library_path = None
192+
if self._cmd_local and self._cmd_windows:
193+
cmd_library_path = ""
194+
195+
# set the process environment from the generated .bat file in windows which should have all the dependencies
196+
# for this pathlib.PurePosixPath() must be converted to a pathlib.Path() object, i.e. WindowsPath
197+
path_bat = pathlib.Path(cmd_path) / f"{self._model_name}.bat"
198+
if not path_bat.is_file():
199+
raise ModelExecutionException("Batch file (*.bat) does not exist " + str(path_bat))
200+
201+
content = path_bat.read_text(encoding='utf-8')
202+
for line in content.splitlines():
203+
match = re.match(pattern=r"^SET PATH=([^%]*)", string=line, flags=re.IGNORECASE)
204+
if match:
205+
cmd_library_path = match.group(1).strip(';') # Remove any trailing semicolons
206+
my_env = os.environ.copy()
207+
my_env["PATH"] = cmd_library_path + os.pathsep + my_env["PATH"]
208+
209+
cmd_model_executable = cmd_path / f"{self._model_name}.exe"
210+
else:
211+
# for Linux the paths to the needed libraries should be included in the executable (using rpath)
212+
cmd_model_executable = cmd_path / self._model_name
213+
214+
# define local(!) working directory
215+
cmd_cwd_local = None
216+
if self._cmd_local:
217+
cmd_cwd_local = cmd_path.as_posix()
218+
219+
omc_run_data = ModelExecutionData(
220+
cmd_path=cmd_path.as_posix(),
221+
cmd_model_name=self._model_name,
222+
cmd_args=self.get_cmd_args(),
223+
cmd_result_file=result_file,
224+
cmd_prefix=self._cmd_prefix,
225+
cmd_library_path=cmd_library_path,
226+
cmd_model_executable=cmd_model_executable.as_posix(),
227+
cmd_cwd_local=cmd_cwd_local,
228+
cmd_timeout=self._timeout,
229+
)
230+
231+
return omc_run_data
232+
233+
@staticmethod
234+
def parse_simflags(simflags: str) -> dict[str, Optional[str | dict[str, Any] | numbers.Number]]:
235+
"""
236+
Parse a simflag definition; this is deprecated!
237+
238+
The return data can be used as input for self.args_set().
239+
"""
240+
warnings.warn(
241+
message="The argument 'simflags' is depreciated and will be removed in future versions; "
242+
"please use 'simargs' instead",
243+
category=DeprecationWarning,
244+
stacklevel=2,
245+
)
246+
247+
simargs: dict[str, Optional[str | dict[str, Any] | numbers.Number]] = {}
248+
249+
args = [s for s in simflags.split(' ') if s]
250+
for arg in args:
251+
if arg[0] != '-':
252+
raise ModelExecutionException(f"Invalid simulation flag: {arg}")
253+
arg = arg[1:]
254+
parts = arg.split('=')
255+
if len(parts) == 1:
256+
simargs[parts[0]] = None
257+
elif parts[0] == 'override':
258+
override = '='.join(parts[1:])
259+
260+
override_dict = {}
261+
for item in override.split(','):
262+
kv = item.split('=')
263+
if not 0 < len(kv) < 3:
264+
raise ModelExecutionException(f"Invalid value for '-override': {override}")
265+
if kv[0]:
266+
try:
267+
override_dict[kv[0]] = kv[1]
268+
except (KeyError, IndexError) as ex:
269+
raise ModelExecutionException(f"Invalid value for '-override': {override}") from ex
270+
271+
simargs[parts[0]] = override_dict
272+
273+
return simargs
274+
275+
276+
@dataclasses.dataclass
277+
class ModelExecutionData:
278+
"""
279+
Data class to store the command line data for running a model executable in the OMC environment.
280+
281+
All data should be defined for the environment, where OMC is running (local, docker or WSL)
282+
283+
To use this as a definition of an OMC simulation run, it has to be processed within
284+
OMCProcess*.self_update(). This defines the attribute cmd_model_executable.
285+
"""
286+
# cmd_path is the expected working directory
287+
cmd_path: str
288+
cmd_model_name: str
289+
# command prefix data (as list of strings); needed for docker or WSL
290+
cmd_prefix: list[str]
291+
# cmd_model_executable is build out of cmd_path and cmd_model_name; this is mainly needed on Windows (add *.exe)
292+
cmd_model_executable: str
293+
# command line arguments for the model executable
294+
cmd_args: list[str]
295+
# result file with the simulation output
296+
cmd_result_file: str
297+
# command timeout
298+
cmd_timeout: float
299+
300+
# additional library search path; this is mainly needed if OMCProcessLocal is run on Windows
301+
cmd_library_path: Optional[str] = None
302+
# working directory to be used on the *local* system
303+
cmd_cwd_local: Optional[str] = None
304+
305+
def get_cmd(self) -> list[str]:
306+
"""
307+
Get the command line to run the model executable in the environment defined by the OMCProcess definition.
308+
"""
309+
310+
cmdl = self.cmd_prefix
311+
cmdl += [self.cmd_model_executable]
312+
cmdl += self.cmd_args
313+
314+
return cmdl
315+
316+
def run(self) -> int:
317+
"""
318+
Run the model execution defined in this class.
319+
"""
320+
321+
my_env = os.environ.copy()
322+
if isinstance(self.cmd_library_path, str):
323+
my_env["PATH"] = self.cmd_library_path + os.pathsep + my_env["PATH"]
324+
325+
cmdl = self.get_cmd()
326+
327+
logger.debug("Run OM command %s in %s", repr(cmdl), self.cmd_path)
328+
try:
329+
cmdres = subprocess.run(
330+
cmdl,
331+
capture_output=True,
332+
text=True,
333+
env=my_env,
334+
cwd=self.cmd_cwd_local,
335+
timeout=self.cmd_timeout,
336+
check=True,
337+
)
338+
stdout = cmdres.stdout.strip()
339+
stderr = cmdres.stderr.strip()
340+
returncode = cmdres.returncode
341+
342+
logger.debug("OM output for command %s:\n%s", repr(cmdl), stdout)
343+
344+
if stderr:
345+
raise ModelExecutionException(f"Error running model executable {repr(cmdl)}: {stderr}")
346+
except subprocess.TimeoutExpired as ex:
347+
raise ModelExecutionException(f"Timeout running model executable {repr(cmdl)}: {ex}") from ex
348+
except subprocess.CalledProcessError as ex:
349+
raise ModelExecutionException(f"Error running model executable {repr(cmdl)}: {ex}") from ex
350+
351+
return returncode

0 commit comments

Comments
 (0)