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