Coverage for ebcpy/simulationapi/dymola_api.py: 64%

604 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2025-09-30 11:13 +0000

1"""Module containing the DymolaAPI used for simulation 

2of Modelica-Models.""" 

3 

4import sys 

5import os 

6import shutil 

7import uuid 

8import warnings 

9import atexit 

10import json 

11import time 

12import socket 

13from pathlib import Path 

14from contextlib import closing 

15from typing import Union, List 

16 

17from pydantic import Field, BaseModel 

18import pandas as pd 

19 

20from ebcpy.modelica import manipulate_ds 

21from ebcpy.simulationapi import SimulationSetup, SimulationAPI, \ 

22 SimulationSetupClass, Variable 

23from ebcpy.utils.conversion import convert_tsd_to_modelica_txt 

24 

25 

26class DymolaSimulationSetup(SimulationSetup): 

27 """ 

28 Adds ``tolerance`` to the list of possible 

29 setup fields. 

30 """ 

31 tolerance: float = Field( 

32 title="tolerance", 

33 default=0.0001, 

34 description="Tolerance of integration" 

35 ) 

36 

37 _default_solver = "Dassl" 

38 _allowed_solvers = ["Dassl", "Euler", "Cerk23", "Cerk34", "Cerk45", 

39 "Esdirk23a", "Esdirk34a", "Esdirk45a", "Cvode", 

40 "Rkfix2", "Rkfix3", "Rkfix4", "Lsodar", 

41 "Radau", "Dopri45", "Dopri853", "Sdirk34hw"] 

42 

43 

44class ExperimentSetupOutput(BaseModel): 

45 """ 

46 Experiment setup output data model with 

47 defaults equal to those in Dymola 

48 """ 

49 states: bool = True 

50 derivatives: bool = True 

51 inputs: bool = True 

52 outputs: bool = True 

53 auxiliaries: bool = True 

54 equidistant: bool = False 

55 events: bool = True 

56 

57 class Config: 

58 """ 

59 Pydantic internal model settings 

60 """ 

61 # pylint: disable=too-few-public-methods 

62 extra = "forbid" 

63 

64 

65class DymolaAPI(SimulationAPI): 

66 """ 

67 API to a Dymola instance. 

68 

69 :param str,Path working_directory: 

70 Dirpath for the current working directory of dymola 

71 :param str model_name: 

72 Name of the model to be simulated. 

73 If None, it has to be provided prior to or when calling simulate(). 

74 :param list packages: 

75 List with path's to the packages needed to simulate the model 

76 :keyword Boolean show_window: 

77 True to show the Dymola window. Default is False 

78 :keyword Boolean modify_structural_parameters: 

79 True to automatically set the structural parameters of the 

80 simulation model via Modelica modifiers. Default is True. 

81 See also the keyword ``structural_parameters`` 

82 of the ``simulate`` function. 

83 :keyword Boolean equidistant_output: 

84 If True (Default), Dymola stores variables in an 

85 equisdistant output and does not store variables at events. 

86 :keyword dict[str,bool] variables_to_save: 

87 A dictionary to select which variables are going 

88 to be stored if the simulation creates .mat files. 

89 Options (with the default being all True): 

90 - states=True 

91 - derivatives=True 

92 - inputs=True 

93 - outputs=True 

94 - auxiliaries=False 

95 :keyword int n_restart: 

96 Number of iterations after which Dymola should restart. 

97 This is done to free memory. Default value -1. For values 

98 below 1 Dymola does not restart. 

99 :keyword bool extract_variables: 

100 If True (the default), all variables of the model will be extracted 

101 on init of this class. 

102 This required translating the model. 

103 :keyword bool debug: 

104 If True (not the default), the dymola instance is not closed 

105 on exit of the python script. This allows further debugging in 

106 dymola itself if API-functions cause a python error. 

107 :keyword str mos_script_pre: 

108 Path to a valid mos-script for Modelica/Dymola. 

109 If given, the script is executed **prior** to laoding any 

110 package specified in this API. 

111 May be relevant for handling version conflicts. 

112 :keyword str mos_script_post: 

113 Path to a valid mos-script for Modelica/Dymola. 

114 If given, the script is executed before closing Dymola. 

115 :keyword str dymola_version: 

116 Version of Dymola to use. 

117 If not given, newest version will be used. 

118 If given, the Version needs to be equal to the folder name 

119 of your installation. 

120 

121 **Example:** If you have two versions installed at 

122 

123 - ``C://Program Files//Dymola 2021`` and 

124 - ``C://Program Files//Dymola 2020x`` 

125 

126 and you want to use Dymola 2020x, specify 

127 ``dymola_version='Dymola 2020x'``. 

128 

129 This parameter is overwritten if ``dymola_path`` is specified. 

130 :keyword str dymola_path: 

131 Path to the dymola installation on the device. Necessary 

132 e.g. on linux, if we can't find the path automatically. 

133 Example: ``dymola_path="C://Program Files//Dymola 2020x"`` 

134 :keyword str dymola_interface_path: 

135 Direct path to the .egg-file of the dymola interface. 

136 Only relevant when the dymola_path 

137 differs from the interface path. 

138 :keyword str dymola_exe_path: 

139 Direct path to the dymola executable. 

140 Only relevant if the dymola installation do not follow 

141 the official guideline. 

142 :keyword float time_delay_between_starts: 

143 If starting multiple Dymola instances on multiple 

144 cores, a time delay between each start avoids weird 

145 behaviour, such as requiring to set the C-Compiler again 

146 as Dymola overrides the default .dymx setup file. 

147 If you start e.g. 20 instances and specify `time_delay_between_starts=5`, 

148 each 5 seconds one instance will start, taking in total 

149 100 seconds. Default is no delay. 

150 

151 Example: 

152 

153 >>> import os 

154 >>> from ebcpy import DymolaAPI 

155 >>> # Specify the model name 

156 >>> model_name = "Modelica.Thermal.FluidHeatFlow.Examples.PumpAndValve" 

157 >>> dym_api = DymolaAPI(working_directory=os.getcwd(), 

158 >>> model_name=model_name, 

159 >>> packages=[], 

160 >>> show_window=True) 

161 >>> dym_api.sim_setup = {"start_time": 100, 

162 >>> "stop_time": 200} 

163 >>> dym_api.simulate() 

164 >>> dym_api.close() 

165 

166 """ 

167 _sim_setup_class: SimulationSetupClass = DymolaSimulationSetup 

168 _items_to_drop = ["pool", "dymola", "_dummy_dymola_instance"] 

169 dymola = None 

170 # Default simulation setup 

171 _supported_kwargs = [ 

172 "show_window", 

173 "modify_structural_parameters", 

174 "dymola_path", 

175 "equidistant_output", 

176 "variables_to_save", 

177 "n_restart", 

178 "debug", 

179 "mos_script_pre", 

180 "mos_script_post", 

181 "dymola_version", 

182 "dymola_interface_path", 

183 "dymola_exe_path", 

184 "time_delay_between_starts" 

185 ] 

186 

187 def __init__( 

188 self, 

189 working_directory: Union[Path, str], 

190 model_name: str = None, 

191 packages: List[Union[Path, str]] = None, 

192 **kwargs 

193 ): 

194 """Instantiate class objects.""" 

195 self.dymola = None # Avoid key-error in get-state. Instance attribute needs to be there. 

196 # Update kwargs with regard to what kwargs are supported. 

197 self.extract_variables = kwargs.pop("extract_variables", True) 

198 self.fully_initialized = False 

199 self.debug = kwargs.pop("debug", False) 

200 self.show_window = kwargs.pop("show_window", False) 

201 self.modify_structural_parameters = kwargs.pop("modify_structural_parameters", True) 

202 self.equidistant_output = kwargs.pop("equidistant_output", True) 

203 _variables_to_save = kwargs.pop("variables_to_save", {}) 

204 self.experiment_setup_output = ExperimentSetupOutput(**_variables_to_save) 

205 

206 self.mos_script_pre = kwargs.pop("mos_script_pre", None) 

207 self.mos_script_post = kwargs.pop("mos_script_post", None) 

208 self.dymola_version = kwargs.pop("dymola_version", None) 

209 self.dymola_interface_path = kwargs.pop("dymola_interface_path", None) 

210 self.dymola_exe_path = kwargs.pop("dymola_exe_path", None) 

211 _time_delay_between_starts = kwargs.pop("time_delay_between_starts", 0) 

212 for mos_script in [self.mos_script_pre, self.mos_script_post]: 

213 if mos_script is not None: 

214 if not os.path.isfile(mos_script): 

215 raise FileNotFoundError( 

216 f"Given mos_script '{mos_script}' does " 

217 f"not exist." 

218 ) 

219 if not str(mos_script).endswith(".mos"): 

220 raise TypeError( 

221 f"Given mos_script '{mos_script}' " 

222 f"is not a valid .mos file." 

223 ) 

224 

225 # Convert to modelica path 

226 if self.mos_script_pre is not None: 

227 self.mos_script_pre = self._make_modelica_normpath(self.mos_script_pre) 

228 if self.mos_script_post is not None: 

229 self.mos_script_post = self._make_modelica_normpath(self.mos_script_post) 

230 

231 super().__init__(working_directory=working_directory, 

232 model_name=model_name, 

233 n_cpu=kwargs.pop("n_cpu", 1), 

234 save_logs=kwargs.pop("save_logs", True)) 

235 

236 # First import the dymola-interface 

237 dymola_path = kwargs.pop("dymola_path", None) 

238 if dymola_path is not None: 

239 if not os.path.exists(dymola_path): 

240 raise FileNotFoundError(f"Given path '{dymola_path}' can not be found on " 

241 "your machine.") 

242 else: 

243 # Get the dymola-install-path: 

244 _dym_installations = self.get_dymola_install_paths() 

245 if _dym_installations: 

246 if self.dymola_version: 

247 dymola_path = _get_dymola_path_of_version( 

248 dymola_installations=_dym_installations, 

249 dymola_version=self.dymola_version 

250 ) 

251 else: 

252 dymola_path = _dym_installations[0] # 0 is the newest 

253 self.logger.info("Using dymola installation at %s", dymola_path) 

254 else: 

255 if self.dymola_exe_path is None or self.dymola_interface_path is None: 

256 raise FileNotFoundError( 

257 "Could not find dymola on your machine. " 

258 "Thus, not able to find the `dymola_exe_path` and `dymola_interface_path`. " 

259 "Either specify both or pass an existing `dymola_path`." 

260 ) 

261 self.dymola_path = dymola_path 

262 if self.dymola_exe_path is None: 

263 self.dymola_exe_path = self.get_dymola_exe_path(dymola_path) 

264 self.logger.info("Using dymola.exe: %s", self.dymola_exe_path) 

265 if self.dymola_interface_path is None: 

266 self.dymola_interface_path = self.get_dymola_interface_path(dymola_path) 

267 self.logger.info("Using dymola interface: %s", self.dymola_interface_path) 

268 

269 self.packages = [] 

270 if packages is not None: 

271 for package in packages: 

272 if isinstance(package, Path): 

273 self.packages.append(str(package)) 

274 elif isinstance(package, str): 

275 self.packages.append(package) 

276 else: 

277 raise TypeError(f"Given package is of type {type(package)}" 

278 f" but should be any valid path.") 

279 

280 # Import n_restart 

281 self.sim_counter = 0 

282 self.n_restart = kwargs.pop("n_restart", -1) 

283 if not isinstance(self.n_restart, int): 

284 raise TypeError(f"n_restart has to be type int but " 

285 f"is of type {type(self.n_restart)}") 

286 

287 self._dummy_dymola_instance = None # Ensure self._close_dummy gets the attribute. 

288 if self.n_restart > 0: 

289 self.logger.info("Open blank placeholder Dymola instance to ensure" 

290 " a licence during Dymola restarts") 

291 # Use standard port allocation, should always work 

292 self._dummy_dymola_instance = self._open_dymola_interface(port=-1) 

293 atexit.register(self._close_dummy) 

294 

295 # List storing structural parameters for later modifying the simulation-name. 

296 # Parameter for raising a warning if to many dymola-instances are running 

297 self._critical_number_instances = 10 + self.n_cpu 

298 # Register the function now in case of an error. 

299 if not self.debug: 

300 atexit.register(self.close) 

301 if self.use_mp: 

302 ports = _get_n_available_ports(n_ports=self.n_cpu) 

303 self.pool.map( 

304 self._setup_dymola_interface, 

305 [dict(use_mp=True, port=port, time_delay=i * _time_delay_between_starts) 

306 for i, port in enumerate(ports)] 

307 ) 

308 # For translation etc. always setup a default dymola instance 

309 self.dymola = self._setup_dymola_interface(dict(use_mp=False)) 

310 if not self.license_is_available(): 

311 warnings.warn("You have no licence to use Dymola. " 

312 "Hence you can only simulate models with 8 or less equations.") 

313 # Update experiment setup output 

314 self.update_experiment_setup_output(self.experiment_setup_output) 

315 self.fully_initialized = True 

316 # Trigger on init. 

317 if model_name is not None: 

318 self._update_model() 

319 # Set result_names to output variables. 

320 self.result_names = list(self.outputs.keys()) 

321 

322 # Check if some kwargs are still present. If so, inform the user about 

323 # false usage of kwargs: 

324 if kwargs: 

325 self.logger.error( 

326 "You passed the following kwargs which " 

327 "are not part of the supported kwargs and " 

328 "have thus no effect: %s.", " ,".join(list(kwargs.keys()))) 

329 

330 def _update_model(self): 

331 # Translate the model and extract all variables, 

332 # if the user wants to: 

333 if self.extract_variables and self.fully_initialized: 

334 self.extract_model_variables() 

335 

336 def simulate(self, 

337 parameters: Union[dict, List[dict]] = None, 

338 return_option: str = "time_series", 

339 **kwargs): 

340 """ 

341 Simulate the given parameters. 

342 

343 Additional settings: 

344 

345 :keyword List[str] model_names: 

346 List of Dymola model-names to simulate. Should be either the size 

347 of parameters or parameters needs to be sized 1. 

348 Keep in mind that different models may use different parameters! 

349 :keyword Boolean show_eventlog: 

350 Default False. True to show evenlog of simulation (advanced) 

351 :keyword Boolean squeeze: 

352 Default True. If only one set of initialValues is provided, 

353 a DataFrame is returned directly instead of a list. 

354 :keyword str table_name: 

355 If inputs are given, you have to specify the name of the table 

356 in the instance of CombiTimeTable. In order for the inputs to 

357 work the value should be equal to the value of 'tableName' in Modelica. 

358 :keyword str file_name: 

359 If inputs are given, you have to specify the file_name of the table 

360 in the instance of CombiTimeTable. In order for the inputs to 

361 work the value should be equal to the value of 'fileName' in Modelica. 

362 :keyword callable postprocess_mat_result: 

363 When choosing return_option savepath and no equidistant output, the mat files may take up 

364 a lot of disk space while you are only interested in some variables or parts 

365 of the simulation results. This features enables you to pass any function which 

366 gets the mat-path as an input and returns some result you are interested in. 

367 The function signature is `foo(mat_result_file, **kwargs_postprocessing) -> Any`. 

368 Be sure to define the function in a global scope to allow multiprocessing. 

369 :keyword dict kwargs_postprocessing: 

370 Keyword arguments used in the function `postprocess_mat_result`. 

371 :keyword List[str] structural_parameters: 

372 A list containing all parameter names which are structural in Modelica. 

373 This means a modifier has to be created in order to change 

374 the value of this parameter. Internally, the given list 

375 is added to the known states of the model. Hence, you only have to 

376 specify this keyword argument if your structural parameter 

377 does not appear in the dsin.txt file created during translation. 

378 

379 Example: 

380 Changing a record in a model: 

381 

382 >>> sim_api.simulate( 

383 >>> parameters={"parameterPipe": "AixLib.DataBase.Pipes.PE_X.DIN_16893_SDR11_d160()"}, 

384 >>> structural_parameters=["parameterPipe"]) 

385 

386 """ 

387 # Handle special case for structural_parameters 

388 if "structural_parameters" in kwargs: 

389 _struc_params = kwargs["structural_parameters"] 

390 # Check if input is 2-dimensional for multiprocessing. 

391 # If not, make it 2-dimensional to avoid list flattening in 

392 # the super method. 

393 if not isinstance(_struc_params[0], list): 

394 kwargs["structural_parameters"] = [_struc_params] 

395 if "model_names" in kwargs: 

396 model_names = kwargs["model_names"] 

397 if not isinstance(model_names, list): 

398 raise TypeError("model_names needs to be a list.") 

399 if isinstance(parameters, dict): 

400 # Make an array of parameters to enable correct use of super function. 

401 parameters = [parameters] * len(model_names) 

402 if parameters is None: 

403 parameters = [{}] * len(model_names) 

404 return super().simulate(parameters=parameters, return_option=return_option, **kwargs) 

405 

406 def _single_simulation(self, kwargs): 

407 # Unpack kwargs 

408 show_eventlog = kwargs.pop("show_eventlog", False) 

409 squeeze = kwargs.pop("squeeze", True) 

410 result_file_name = kwargs.pop("result_file_name", 'resultFile') 

411 if not isinstance(result_file_name, str): 

412 raise TypeError(f"result_file_name has to be type str but is of type {type(result_file_name)}") 

413 parameters = kwargs.pop("parameters") 

414 return_option = kwargs.pop("return_option") 

415 model_names = kwargs.pop("model_names", None) 

416 inputs = kwargs.pop("inputs", None) 

417 fail_on_error = kwargs.pop("fail_on_error", True) 

418 structural_parameters = kwargs.pop("structural_parameters", []) 

419 table_name = kwargs.pop("table_name", None) 

420 file_name = kwargs.pop("file_name", None) 

421 savepath = kwargs.pop("savepath", None) 

422 

423 def empty_postprocessing(mat_result, **_kwargs): 

424 return mat_result 

425 

426 postprocess_mat_result = kwargs.pop("postprocess_mat_result", empty_postprocessing) 

427 kwargs_postprocessing = kwargs.pop("kwargs_postprocessing", {}) 

428 if kwargs: 

429 self.logger.error( 

430 "You passed the following kwargs which " 

431 "are not part of the supported kwargs and " 

432 "have thus no effect: %s.", " ,".join(list(kwargs.keys()))) 

433 

434 # Handle multiprocessing 

435 if self.use_mp: 

436 if self.dymola is None: 

437 # This should not affect #119, as this rarely happens. Thus, the 

438 # method used in the DymolaInterface should work. 

439 self._setup_dymola_interface(dict(use_mp=True)) 

440 

441 # Re-set the dymola experiment output if API is newly started 

442 self.dymola.experimentSetupOutput(**self.experiment_setup_output.model_dump()) 

443 

444 # Handle eventlog 

445 if show_eventlog: 

446 if not self.experiment_setup_output.events: 

447 raise ValueError("You can't log events and have an " 

448 "equidistant output, set equidistant output=False") 

449 self.dymola.ExecuteCommand("Advanced.Debug.LogEvents = true") 

450 self.dymola.ExecuteCommand("Advanced.Debug.LogEventsInitialization = true") 

451 

452 # Restart Dymola after n_restart iterations 

453 self._check_restart() 

454 

455 # Handle custom model_names 

456 if model_names is not None: 

457 # Custom model_name setting 

458 _res_names = self.result_names.copy() 

459 self._model_name = model_names 

460 self._update_model_variables() 

461 if _res_names != self.result_names: 

462 self.logger.info( 

463 "Result names changed due to setting the new model. " 

464 "If you do not expect custom result names, ignore this warning." 

465 "If you do expect them, please raise an issue to add the " 

466 "option when using the model_names keyword.") 

467 self.logger.info( 

468 "Difference: %s", 

469 " ,".join(list(set(_res_names).difference(self.result_names))) 

470 ) 

471 

472 if self.model_name is None: 

473 raise ValueError( 

474 "You neither passed a model_name when " 

475 "starting DymolaAPI, nor when calling simulate. " 

476 "Can't simulate no model." 

477 ) 

478 

479 # Handle parameters: 

480 if parameters is None: 

481 parameters = {} 

482 unsupported_parameters = False 

483 else: 

484 unsupported_parameters = self.check_unsupported_variables( 

485 variables=list(parameters.keys()), 

486 type_of_var="parameters" 

487 ) 

488 

489 # Handle structural parameters 

490 

491 if (unsupported_parameters and 

492 (self.modify_structural_parameters or 

493 structural_parameters)): 

494 # Alter the model_name for the next simulation 

495 model_name, parameters_new = self._alter_model_name( 

496 parameters=parameters, 

497 model_name=self.model_name, 

498 structural_params=list(self.states.keys()) + structural_parameters 

499 ) 

500 # Trigger translation only if something changed 

501 if model_name != self.model_name: 

502 _res_names = self.result_names.copy() 

503 self.model_name = model_name 

504 self.result_names = _res_names # Restore previous result names 

505 self.logger.warning( 

506 "Warning: Currently, the model is re-translating " 

507 "for each simulation. You should add to your Modelica " 

508 "parameters \"annotation(Evaluate=false)\".\n " 

509 "Check for these parameters: %s", 

510 ', '.join(set(parameters.keys()).difference(parameters_new.keys())) 

511 ) 

512 parameters = parameters_new 

513 # Check again 

514 unsupported_parameters = self.check_unsupported_variables( 

515 variables=list(parameters.keys()), 

516 type_of_var="parameters" 

517 ) 

518 

519 initial_names = list(parameters.keys()) 

520 initial_values = list(parameters.values()) 

521 # Convert to float for Boolean and integer types: 

522 try: 

523 initial_values = [float(v) for v in initial_values] 

524 except (ValueError, TypeError) as err: 

525 raise TypeError("Dymola only accepts float values. " 

526 "Could bot automatically convert the given " 

527 "parameter values to float.") from err 

528 

529 # Handle inputs 

530 if inputs is not None: 

531 # Unpack additional kwargs 

532 if table_name is None or file_name is None: 

533 raise KeyError("For inputs to be used by DymolaAPI.simulate, you " 

534 "have to specify the 'table_name' and the 'file_name' " 

535 "as keyword arguments of the function. These must match" 

536 "the values 'tableName' and 'fileName' in the CombiTimeTable" 

537 " model in your modelica code.") from err 

538 # Generate the input in the correct format 

539 offset = self.sim_setup.start_time - inputs.index[0] 

540 filepath = convert_tsd_to_modelica_txt( 

541 tsd=inputs, 

542 table_name=table_name, 

543 save_path_file=file_name, 

544 offset=offset 

545 ) 

546 self.logger.info("Successfully created Dymola input file at %s", filepath) 

547 

548 if return_option == "savepath": 

549 if unsupported_parameters: 

550 raise KeyError("Dymola does not accept invalid parameter " 

551 "names for option return_type='savepath'. " 

552 "To use this option, delete unsupported " 

553 "parameters from your setup.") 

554 res = self.dymola.simulateExtendedModel( 

555 self.model_name, 

556 startTime=self.sim_setup.start_time, 

557 stopTime=self.sim_setup.stop_time, 

558 numberOfIntervals=0, 

559 outputInterval=self.sim_setup.output_interval, 

560 method=self.sim_setup.solver, 

561 tolerance=self.sim_setup.tolerance, 

562 fixedstepsize=self.sim_setup.fixedstepsize, 

563 resultFile=result_file_name, 

564 initialNames=initial_names, 

565 initialValues=initial_values) 

566 else: 

567 if not parameters and not self.parameters: 

568 raise ValueError( 

569 "Sadly, simulating a model in Dymola " 

570 "with no parameters returns no result. " 

571 "Call this function using return_option='savepath' to get the results." 

572 ) 

573 if not parameters: 

574 random_name = list(self.parameters.keys())[0] 

575 initial_values = [self.parameters[random_name].value] 

576 initial_names = [random_name] 

577 

578 # Handle 1 and 2 D initial names: 

579 # Convert a 1D list to 2D list 

580 if initial_values and isinstance(initial_values[0], (float, int)): 

581 initial_values = [initial_values] 

582 

583 # Handle the time of the simulation: 

584 res_names = self.result_names.copy() 

585 if "Time" not in res_names: 

586 res_names.append("Time") 

587 

588 # Internally convert output Interval to number of intervals 

589 # (Required by function simulateMultiResultsModel 

590 number_of_intervals = (self.sim_setup.stop_time - self.sim_setup.start_time) / \ 

591 self.sim_setup.output_interval 

592 if int(number_of_intervals) != number_of_intervals: 

593 raise ValueError( 

594 "Given output_interval and time interval did not yield " 

595 "an integer numberOfIntervals. To use this functions " 

596 "without savepaths, you have to provide either a " 

597 "numberOfIntervals or a value for output_interval " 

598 "which can be converted to numberOfIntervals.") 

599 

600 res = self.dymola.simulateMultiResultsModel( 

601 self.model_name, 

602 startTime=self.sim_setup.start_time, 

603 stopTime=self.sim_setup.stop_time, 

604 numberOfIntervals=int(number_of_intervals), 

605 method=self.sim_setup.solver, 

606 tolerance=self.sim_setup.tolerance, 

607 fixedstepsize=self.sim_setup.fixedstepsize, 

608 resultFile=None, 

609 initialNames=initial_names, 

610 initialValues=initial_values, 

611 resultNames=res_names) 

612 

613 if not res[0]: 

614 self.logger.error("Simulation failed!") 

615 self.logger.error("The last error log from Dymola:") 

616 dslog_path = self._get_worker_directory(use_mp=self.use_mp).joinpath('dslog.txt') 

617 try: 

618 with open(dslog_path, "r") as dslog_file: 

619 dslog_content = dslog_file.read() 

620 self.logger.error(dslog_content) 

621 except Exception: 

622 log = self.dymola.getLastErrorLog() 

623 # Only print last part as output is sometimes to verbose and the error is at the bottom 

624 self.logger.error(log[-10000:]) 

625 dslog_content = "Not retreivable. Open it yourself." 

626 msg = f"Simulation failed: Reason according " \ 

627 f"to dslog, located at '{dslog_path}': {dslog_content}" 

628 if fail_on_error: 

629 raise Exception(msg) 

630 # Don't raise and return None 

631 self.logger.error(msg) 

632 return None 

633 

634 if return_option == "savepath": 

635 _save_name_dsres = f"{result_file_name}.mat" 

636 # Get the working_directory of the current dymola instance 

637 self.dymola.cd() 

638 # Get the value and convert it to a 100 % fitting str-path 

639 cd_log = self.dymola.getLastErrorLog() 

640 if cd_log.endswith("\n"): # Typically ends with \n 

641 cd_log = cd_log[:-1] 

642 # Sometimes, the other logs are also included. Only use the last line. 

643 dymola_working_directory = Path(cd_log.split("\n")[-1]) 

644 if dymola_working_directory != self._get_worker_directory(use_mp=self.use_mp): 

645 self.logger.warning( 

646 "The working directory set by ebcpy and the one with the result does not match: " 

647 "%s (dymola) vs. %s (ebcpy). This will inhibit correct error " 

648 "messages upon failed simulations.", 

649 dymola_working_directory, 

650 self._get_worker_directory(use_mp=self.use_mp) 

651 ) 

652 

653 mat_working_directory = dymola_working_directory.joinpath(_save_name_dsres).as_posix() 

654 if savepath is None or str(savepath) == str(dymola_working_directory): 

655 mat_result_file = mat_working_directory 

656 else: 

657 mat_save_path = os.path.join(savepath, _save_name_dsres) 

658 os.makedirs(savepath, exist_ok=True) 

659 # Copying dslogs and dsfinals can lead to errors, 

660 # as the names are not unique 

661 # for filename in [_save_name_dsres, "dslog.txt", "dsfinal.txt"]: 

662 # Delete existing files 

663 try: 

664 os.remove(mat_save_path) 

665 except OSError: 

666 pass 

667 # Move files 

668 shutil.copy(mat_working_directory, mat_save_path) 

669 os.remove(mat_working_directory) 

670 mat_result_file = mat_save_path 

671 result_file = postprocess_mat_result(mat_result_file, **kwargs_postprocessing) 

672 return result_file 

673 

674 data = res[1] # Get data 

675 

676 # Sometimes, Dymola adds a last row with all 0 values, even the time 

677 data_clean = [] 

678 for ini_val_set in data: 

679 if all(res[-1] == 0 for res in ini_val_set): 

680 data_clean.append([res[:-1] for res in ini_val_set]) 

681 else: 

682 data_clean.append(ini_val_set) 

683 

684 if return_option == "last_point": 

685 results = [] 

686 for ini_val_set in data_clean: 

687 results.append({result_name: ini_val_set[idx][-1] for idx, result_name 

688 in enumerate(res_names)}) 

689 if len(results) == 1 and squeeze: 

690 return results[0] 

691 return results 

692 # Else return as dataframe. 

693 dfs = [] 

694 for ini_val_set in data_clean: 

695 df = pd.DataFrame({result_name: ini_val_set[idx] for idx, result_name 

696 in enumerate(res_names)}) 

697 # Set time index 

698 df = df.set_index("Time") 

699 # Convert it to float 

700 df.index = df.index.astype("float64") 

701 

702 dfs.append(df) 

703 # Most of the cases, only one set is provided. In that case, avoid 

704 if len(dfs) == 1 and squeeze: 

705 return dfs[0] 

706 return dfs 

707 

708 def translate(self): 

709 """ 

710 Translates the current model using dymola.translateModel() 

711 and checks if erros occur. 

712 """ 

713 res = self.dymola.translateModel(self.model_name) 

714 if not res: 

715 self.logger.error("Translation failed!") 

716 self.logger.error("The last error log from Dymola:") 

717 self.logger.error(self.dymola.getLastErrorLog()) 

718 raise Exception("Translation failed - Aborting") 

719 

720 def set_compiler(self, name, path, dll=False, dde=False, opc=False): 

721 """ 

722 Set up the compiler and compiler options on Windows. 

723 Optional: Specify if you want to enable dll, dde or opc. 

724 

725 :param str name: 

726 Name of the compiler, avaiable options: 

727 - 'vs': Visual Studio 

728 - 'gcc': GCC 

729 :param str,os.path.normpath path: 

730 Path to the compiler files. 

731 Example for name='vs': path='C:/Program Files (x86)/Microsoft Visual Studio 10.0/Vc' 

732 Example for name='gcc': path='C:/MinGW/bin/gcc' 

733 :param Boolean dll: 

734 Set option for dll support. Check Dymolas Manual on what this exactly does. 

735 :param Boolean dde: 

736 Set option for dde support. Check Dymolas Manual on what this exactly does. 

737 :param Boolean opc: 

738 Set option for opc support. Check Dymolas Manual on what this exactly does. 

739 :return: True, on success. 

740 """ 

741 # Lookup dict for internal name of CCompiler-Variable 

742 _name_int = {"vs": "MSVC", 

743 "gcc": "GCC"} 

744 

745 if "win" not in sys.platform: 

746 raise OSError(f"set_compiler function only implemented " 

747 f"for windows systems, you are using {sys.platform}") 

748 # Manually check correct input as Dymola's error are not a help 

749 name = name.lower() 

750 if name not in ["vs", "gcc"]: 

751 raise ValueError(f"Given compiler name {name} not supported.") 

752 if not os.path.exists(path): 

753 raise FileNotFoundError(f"Given compiler path {path} does not exist on your machine.") 

754 # Convert path for correct input 

755 path = self._make_modelica_normpath(path) 

756 if self.use_mp: 

757 raise ValueError("Given function is not yet supported for multiprocessing") 

758 

759 res = self.dymola.SetDymolaCompiler(name.lower(), 

760 [f"CCompiler={_name_int[name]}", 

761 f"{_name_int[name]}DIR={path}", 

762 f"DLL={int(dll)}", 

763 f"DDE={int(dde)}", 

764 f"OPC={int(opc)}"]) 

765 

766 return res 

767 

768 def import_initial(self, filepath): 

769 """ 

770 Load given dsfinal.txt into dymola 

771 

772 :param str,os.path.normpath filepath: 

773 Path to the dsfinal.txt to be loaded 

774 """ 

775 if not os.path.isfile(filepath): 

776 raise FileNotFoundError(f"Given filepath {filepath} does not exist") 

777 if not os.path.splitext(filepath)[1] == ".txt": 

778 raise TypeError('File is not of type .txt') 

779 if self.use_mp: 

780 raise ValueError("Given function is not yet supported for multiprocessing") 

781 res = self.dymola.importInitial(dsName=filepath) 

782 if res: 

783 self.logger.info("Successfully loaded dsfinal.txt") 

784 else: 

785 raise Exception("Could not load dsfinal into Dymola.") 

786 

787 @SimulationAPI.working_directory.setter 

788 def working_directory(self, working_directory: Union[Path, str]): 

789 """Set the working directory to the given path""" 

790 if isinstance(working_directory, str): 

791 working_directory = Path(working_directory) 

792 self._working_directory = working_directory 

793 if self.dymola is None: # Not yet started 

794 return 

795 # Also set the working_directory in the dymola api 

796 self.set_dymola_working_directory(dymola=self.dymola, 

797 working_directory=working_directory) 

798 if self.use_mp: 

799 self.logger.warning("Won't set the working_directory for all workers, " 

800 "not yet implemented.") 

801 

802 def set_dymola_working_directory(self, dymola, working_directory): 

803 """ 

804 Set the working directory of the Dymola Instance. 

805 Before calling the Function, create the path and 

806 convert to a modelica-normpath. 

807 """ 

808 os.makedirs(working_directory, exist_ok=True) 

809 modelica_working_directory = self._make_modelica_normpath(path=working_directory) 

810 res = dymola.cd(modelica_working_directory) 

811 if not res: 

812 raise OSError(f"Could not change working directory to {working_directory}") 

813 

814 def close(self): 

815 """Closes dymola.""" 

816 # Close MP of super class 

817 super().close() 

818 # Always close main instance 

819 self._single_close(dymola=self.dymola) 

820 

821 def _close_multiprocessing(self, _): 

822 self._single_close() 

823 DymolaAPI.dymola = None 

824 

825 def _single_close(self, **kwargs): 

826 """Closes a single dymola instance""" 

827 if self.dymola is None: 

828 return # Already closed prior 

829 # Execute the mos-script if given: 

830 if self.mos_script_post is not None: 

831 self.logger.info("Executing given mos_script_post " 

832 "prior to closing.") 

833 self.dymola.RunScript(self.mos_script_post) 

834 self.logger.info("Output of mos_script_post: %s", self.dymola.getLastErrorLog()) 

835 self.logger.info('Closing Dymola') 

836 self.dymola.close() 

837 self.logger.info('Successfully closed Dymola') 

838 self.dymola = None 

839 

840 def _close_dummy(self): 

841 """ 

842 Closes dummy instance at the end of the execution 

843 """ 

844 if self._dummy_dymola_instance is not None: 

845 self.logger.info('Closing dummy Dymola instance') 

846 self._dummy_dymola_instance.close() 

847 self.logger.info('Successfully closed dummy Dymola instance') 

848 

849 def extract_model_variables(self): 

850 """ 

851 Extract all variables of the model by 

852 translating it and then processing the dsin 

853 using the manipulate_ds module. 

854 """ 

855 # Translate model 

856 self.logger.info("Translating model '%s' to extract model variables ", 

857 self.model_name) 

858 self.translate() 

859 # Get dsin: 

860 df = manipulate_ds.convert_ds_file_to_dataframe( 

861 self._get_worker_directory(use_mp=self.use_mp).joinpath("dsin.txt") 

862 ) 

863 # Convert and return all parameters of dsin to initial values and names 

864 for idx, row in df.iterrows(): 

865 _max = float(row["4"]) 

866 _min = float(row["3"]) 

867 if _min >= _max: 

868 _var_ebcpy = Variable(value=float(row["2"])) 

869 else: 

870 _var_ebcpy = Variable( 

871 min=_min, 

872 max=_max, 

873 value=float(row["2"]) 

874 ) 

875 if row["5"] == "1": 

876 self.parameters[idx] = _var_ebcpy 

877 elif row["5"] == "5": 

878 self.inputs[idx] = _var_ebcpy 

879 elif row["5"] == "4": 

880 self.outputs[idx] = _var_ebcpy 

881 else: 

882 self.states[idx] = _var_ebcpy 

883 

884 def _setup_dymola_interface(self, kwargs: dict): 

885 """Load all packages and change the current working directory""" 

886 use_mp = kwargs["use_mp"] 

887 port = kwargs.get("port", -1) 

888 time_delay = kwargs.get("time_delay", 0) 

889 time.sleep(time_delay) 

890 dymola = self._open_dymola_interface(port=port) 

891 self._check_dymola_instances() 

892 

893 # Execute the mos-script if given: 

894 if self.mos_script_pre is not None: 

895 self.logger.info("Executing given mos_script_pre " 

896 "prior to loading packages.") 

897 dymola.RunScript(self.mos_script_pre) 

898 self.logger.info("Output of mos_script_pre: %s", dymola.getLastErrorLog()) 

899 

900 # Set the cd in the dymola api 

901 self.set_dymola_working_directory(dymola=dymola, working_directory=self._get_worker_directory(use_mp)) 

902 

903 for package in self.packages: 

904 self.logger.info("Loading Model %s", os.path.dirname(package).split("\\")[-1]) 

905 res = dymola.openModel(package, changeDirectory=False) 

906 if not res: 

907 raise ImportError(dymola.getLastErrorLog()) 

908 self.logger.info("Loaded modules") 

909 

910 dymola.experimentSetupOutput(**self.experiment_setup_output.dict()) 

911 if use_mp: 

912 DymolaAPI.dymola = dymola 

913 return None 

914 return dymola 

915 

916 def update_experiment_setup_output(self, experiment_setup_output: Union[ExperimentSetupOutput, dict]): 

917 """ 

918 Function to update the ExperimentSetupOutput in Dymola for selection 

919 of which variables are going to be saved. The options 

920 `events` and `equidistant` are overridden if equidistant output is required. 

921 

922 :param (ExperimentSetupOutput, dict) experiment_setup_output: 

923 An instance of ExperimentSetupOutput or a dict with valid keys for it. 

924 """ 

925 if isinstance(experiment_setup_output, dict): 

926 self.experiment_setup_output = ExperimentSetupOutput(**experiment_setup_output) 

927 else: 

928 self.experiment_setup_output = experiment_setup_output 

929 if self.equidistant_output: 

930 # Change the Simulation Output, to ensure all 

931 # simulation results have the same array shape. 

932 # Events can also cause errors in the shape. 

933 self.experiment_setup_output.equidistant = True 

934 self.experiment_setup_output.events = False 

935 if self.dymola is None: 

936 return 

937 self.dymola.experimentSetupOutput(**self.experiment_setup_output.model_dump()) 

938 

939 def license_is_available(self, option: str = "Standard"): 

940 """Check if license is available""" 

941 if self.dymola is None: 

942 warnings.warn("You want to check the license before starting dymola, this is not supported.") 

943 return False 

944 return self.dymola.RequestOption(option) 

945 

946 def _open_dymola_interface(self, port): 

947 """Open an instance of dymola and return the API-Object""" 

948 if self.dymola_interface_path not in sys.path: 

949 sys.path.insert(0, self.dymola_interface_path) 

950 try: 

951 from dymola.dymola_interface import DymolaInterface 

952 from dymola.dymola_exception import DymolaConnectionException 

953 return DymolaInterface(showwindow=self.show_window, 

954 dymolapath=self.dymola_exe_path, 

955 port=port) 

956 except ImportError as error: 

957 raise ImportError("Given dymola-interface could not be " 

958 "loaded:\n %s" % self.dymola_interface_path) from error 

959 except DymolaConnectionException as error: 

960 raise ConnectionError(error) from error 

961 

962 def to_dict(self): 

963 """ 

964 Store the most relevant information of this class 

965 into a dictionary. This may be used for future configuration. 

966 

967 :return: dict config: 

968 Dictionary with keys to re-init this class. 

969 """ 

970 # Convert Path to str to enable json-dumping 

971 config = {"working_directory": str(self.working_directory), 

972 "packages": [str(pack) for pack in self.packages], 

973 "model_name": self.model_name, 

974 "type": "DymolaAPI", 

975 } 

976 # Update kwargs 

977 config.update({kwarg: self.__dict__.get(kwarg, None) 

978 for kwarg in self._supported_kwargs}) 

979 

980 return config 

981 

982 def get_packages(self): 

983 """ 

984 Get the currently loaded packages of Dymola 

985 """ 

986 packages = self.dymola.ExecuteCommand( 

987 'ModelManagement.Structure.AST.Misc.ClassesInPackage("")' 

988 ) 

989 if packages is None: 

990 self.logger.error("Could not load packages from Dymola, using self.packages") 

991 packages = [] 

992 for pack in self.packages: 

993 pack = Path(pack) 

994 if pack.name == "package.mo": 

995 packages.append(pack.parent.name) 

996 valid_packages = [] 

997 for pack in packages: 

998 current_package = f"modelica://{pack}/package.order" 

999 pack_path = self.dymola.ExecuteCommand( 

1000 f'Modelica.Utilities.Files.loadResource("{current_package}")' 

1001 ) 

1002 if not isinstance(pack_path, str): 

1003 self.logger.error("Could not load model resource for package %s", pack) 

1004 if os.path.isfile(pack_path): 

1005 valid_packages.append(Path(pack_path).parent) 

1006 return valid_packages 

1007 

1008 def save_for_reproduction( 

1009 self, 

1010 title: str, 

1011 path: Path = None, 

1012 files: list = None, 

1013 save_total_model: bool = True, 

1014 export_fmu: bool = True, 

1015 **kwargs 

1016 ): 

1017 """ 

1018 Additionally to the basic reproduction, add info 

1019 for Dymola packages. 

1020 

1021 Content which is saved: 

1022 - DymolaAPI configuration 

1023 - Information on Dymola: Version, flags 

1024 - All loaded packages 

1025 - Total model, if save_total_model = True 

1026 - FMU, if export_fmu = True 

1027 

1028 :param bool save_total_model: 

1029 True to save the total model 

1030 :param bool export_fmu: 

1031 True to export the FMU of the current model. 

1032 """ 

1033 # Local import to require git-package only when called 

1034 from ebcpy.utils.reproduction import ReproductionFile, CopyFile, get_git_information 

1035 

1036 if files is None: 

1037 files = [] 

1038 # DymolaAPI Info: 

1039 files.append(ReproductionFile( 

1040 filename="Dymola/DymolaAPI_config.json", 

1041 content=json.dumps(self.to_dict(), indent=2) 

1042 )) 

1043 # Dymola info: 

1044 self.dymola.ExecuteCommand("list();") 

1045 _flags = self.dymola.getLastErrorLog() 

1046 dymola_info = [ 

1047 self.dymola.ExecuteCommand("DymolaVersion()"), 

1048 str(self.dymola.ExecuteCommand("DymolaVersionNumber()")), 

1049 "\n\n" 

1050 ] 

1051 files.append(ReproductionFile( 

1052 filename="Dymola/DymolaInfo.txt", 

1053 content="\n".join(dymola_info) + _flags 

1054 )) 

1055 

1056 # Packages 

1057 packages = self.get_packages() 

1058 package_infos = [] 

1059 for pack_path in packages: 

1060 

1061 for pack_dir_parent in [pack_path] + list(pack_path.parents): 

1062 repo_info = get_git_information( 

1063 path=pack_dir_parent, 

1064 zip_folder_path="Dymola" 

1065 ) 

1066 if not repo_info: 

1067 continue 

1068 

1069 files.extend(repo_info.pop("difference_files")) 

1070 pack_path = str(pack_path) + "; " + "; ".join([f"{key}: {value}" for key, value in repo_info.items()]) 

1071 break 

1072 package_infos.append(str(pack_path)) 

1073 files.append(ReproductionFile( 

1074 filename="Dymola/Modelica_packages.txt", 

1075 content="\n".join(package_infos) 

1076 )) 

1077 # Total model 

1078 if save_total_model and self.model_name is not None: 

1079 # split ( catches model_names with modifiers. Dots are replaced as they indicate a file suffix. 

1080 _total_model_name = f"Dymola/{self.model_name.split('(')[0].replace('.', '_')}_total.mo" 

1081 _total_model = Path(self.working_directory).joinpath(_total_model_name) 

1082 os.makedirs(_total_model.parent, exist_ok=True) # Create to ensure model can be saved. 

1083 if "(" in self.model_name: 

1084 # Create temporary model: 

1085 temp_model_file = Path(self.working_directory).joinpath(f"temp_total_model_{uuid.uuid4()}.mo") 

1086 temp_mode_name = f"{self.model_name.split('(')[0].split('.')[-1]}WithModifier" 

1087 with open(temp_model_file, "w") as file: 

1088 file.write(f"model {temp_mode_name}\n extends {self.model_name};\nend {temp_mode_name};") 

1089 res = self.dymola.openModel(str(temp_model_file), changeDirectory=False) 

1090 if not res: 

1091 self.logger.error( 

1092 "Could not create separate model for model with modifiers: %s", 

1093 self.model_name 

1094 ) 

1095 model_name_to_save = self.model_name 

1096 else: 

1097 model_name_to_save = temp_mode_name 

1098 os.remove(temp_model_file) 

1099 else: 

1100 model_name_to_save = self.model_name 

1101 res = self.dymola.saveTotalModel( 

1102 fileName=str(_total_model), 

1103 modelName=model_name_to_save 

1104 ) 

1105 if res: 

1106 files.append(ReproductionFile( 

1107 filename=_total_model_name, 

1108 content=_total_model.read_text() 

1109 )) 

1110 os.remove(_total_model) 

1111 else: 

1112 self.logger.error("Could not save total model: %s", 

1113 self.dymola.getLastErrorLog()) 

1114 # FMU 

1115 if export_fmu: 

1116 _fmu_path = self._save_to_fmu(fail_on_error=False) 

1117 if _fmu_path is not None: 

1118 files.append(CopyFile( 

1119 sourcepath=_fmu_path, 

1120 filename="Dymola/" + _fmu_path.name, 

1121 remove=True 

1122 )) 

1123 

1124 return super().save_for_reproduction( 

1125 title=title, 

1126 path=path, 

1127 files=files, 

1128 **kwargs 

1129 ) 

1130 

1131 def _save_to_fmu(self, fail_on_error): 

1132 """Save model as an FMU""" 

1133 res = self.dymola.translateModelFMU( 

1134 modelToOpen=self.model_name, 

1135 storeResult=False, 

1136 modelName='', 

1137 fmiVersion='2', 

1138 fmiType='all', 

1139 includeSource=False, 

1140 includeImage=0 

1141 ) 

1142 if not res: 

1143 msg = "Could not export fmu: %s" % self.dymola.getLastErrorLog() 

1144 self.logger.error(msg) 

1145 if fail_on_error: 

1146 raise Exception(msg) 

1147 else: 

1148 path = Path(self.working_directory).joinpath(res + ".fmu") 

1149 return path 

1150 

1151 @staticmethod 

1152 def _make_modelica_normpath(path): 

1153 """ 

1154 Convert given path to a path readable in dymola. 

1155 If the base path does not exist, create it. 

1156 

1157 :param str,os.path.normpath path: 

1158 Either a file or a folder path. The base to this 

1159 path is created in non existent. 

1160 :return: str 

1161 Path readable in dymola 

1162 """ 

1163 if isinstance(path, Path): 

1164 path = str(path) 

1165 

1166 path = path.replace("\\", "/") 

1167 # Search for e.g. "D:testzone" and replace it with D:/testzone 

1168 loc = path.find(":") 

1169 if path[loc + 1] != "/" and loc != -1: 

1170 path = path.replace(":", ":/") 

1171 return path 

1172 

1173 @staticmethod 

1174 def get_dymola_interface_path(dymola_install_dir): 

1175 """ 

1176 Function to get the path of the newest dymola interface 

1177 installment on the used machine 

1178 

1179 :param str dymola_install_dir: 

1180 The dymola installation folder. Example: 

1181 "C://Program Files//Dymola 2020" 

1182 :return: str 

1183 Path to the dymola.egg-file or .whl file (for 2024 refresh 1 or newer versions) 

1184 """ 

1185 path_to_interface = os.path.join(dymola_install_dir, "Modelica", "Library", "python_interface") 

1186 path_to_egg_file = os.path.join(path_to_interface, "dymola.egg") 

1187 if os.path.isfile(path_to_egg_file): 

1188 return path_to_egg_file 

1189 # Try to find .whl file: 

1190 for file in os.listdir(path_to_interface): 

1191 if file.endswith(".whl"): 

1192 return os.path.join(path_to_interface, file) 

1193 # If still here, no .egg or .whl was found 

1194 raise FileNotFoundError(f"The given dymola installation directory " 

1195 f"'{dymola_install_dir}' has no " 

1196 f"dymola-interface .egg or .whl-file.") 

1197 

1198 @staticmethod 

1199 def get_dymola_exe_path(dymola_install_dir, dymola_name=None): 

1200 """ 

1201 Function to get the path of the dymola exe-file 

1202 on the current used machine. 

1203 

1204 :param str dymola_install_dir: 

1205 The dymola installation folder. Example: 

1206 "C://Program Files//Dymola 2020" 

1207 :param str dymola_name: 

1208 Name of the executable. On Windows it is always Dymola.exe, on 

1209 linux just dymola. 

1210 :return: str 

1211 Path to the dymola-exe-file. 

1212 """ 

1213 if dymola_name is None: 

1214 if "linux" in sys.platform: 

1215 dymola_name = "dymola" 

1216 elif "win" in sys.platform: 

1217 dymola_name = "Dymola.exe" 

1218 else: 

1219 raise OSError(f"Your operating system {sys.platform} has no default dymola-name." 

1220 f"Please provide one.") 

1221 

1222 bin_64 = os.path.join(dymola_install_dir, "bin64", dymola_name) 

1223 bin_32 = os.path.join(dymola_install_dir, "bin", dymola_name) 

1224 if os.path.isfile(bin_64): # First check for 64bit installation 

1225 dym_file = bin_64 

1226 elif os.path.isfile(bin_32): # Else use the 32bit version 

1227 dym_file = bin_32 

1228 else: 

1229 raise FileNotFoundError( 

1230 f"The given dymola installation has not executable at '{bin_32}'. " 

1231 f"If your dymola_path exists, please raise an issue." 

1232 ) 

1233 

1234 return dym_file 

1235 

1236 @staticmethod 

1237 def get_dymola_install_paths(basedir=None): 

1238 """ 

1239 Function to get all paths of dymola installations 

1240 on the used machine. Supported platforms are: 

1241 * Windows 

1242 * Linux 

1243 * Mac OS X 

1244 If multiple installation of Dymola are found, the newest version will be returned. 

1245 This assumes the names are sortable, e.g. Dymola 2020, Dymola 2019 etc. 

1246 

1247 :param str basedir: 

1248 The base-directory to search for the dymola-installation. 

1249 The default value depends on the platform one is using. 

1250 On Windows it is "C://Program Files" or "C://Program Files (x86)" (for 64 bit) 

1251 On Linux it is "/opt" (based on our ci-Docker configuration 

1252 On Mac OS X "/Application" (based on the default) 

1253 :return: str 

1254 Path to the dymola-installation 

1255 """ 

1256 

1257 if basedir is None: 

1258 if "linux" in sys.platform: 

1259 basedir = os.path.normpath("/opt") 

1260 elif "win" in sys.platform: 

1261 basedir = os.path.normpath("C:/Program Files") 

1262 elif "darwin" in sys.platform: 

1263 basedir = os.path.normpath("/Applications") 

1264 else: 

1265 raise OSError(f"Your operating system ({sys.platform})does not support " 

1266 f"a default basedir. Please provide one.") 

1267 

1268 syspaths = [basedir] 

1269 # Check if 64bit is installed (Windows only) 

1270 systempath_64 = os.path.normpath("C://Program Files (x86)") 

1271 if os.path.exists(systempath_64): 

1272 syspaths.append(systempath_64) 

1273 # Get all folders in both path's 

1274 temp_list = [] 

1275 for systempath in syspaths: 

1276 temp_list += os.listdir(systempath) 

1277 # Filter programs that are not Dymola 

1278 dym_versions = [] 

1279 for folder_name in temp_list: 

1280 # Catch both Dymola and dymola folder-names 

1281 if "dymola" in folder_name.lower(): 

1282 dym_versions.append(folder_name) 

1283 del temp_list 

1284 # Find the newest version and return the egg-file 

1285 # This sorting only works with a good Folder structure, eg. Dymola 2020, Dymola 2019 etc. 

1286 dym_versions.sort() 

1287 valid_paths = [] 

1288 for dym_version in reversed(dym_versions): 

1289 for system_path in syspaths: 

1290 full_path = os.path.join(system_path, dym_version) 

1291 if os.path.isdir(full_path): 

1292 valid_paths.append(full_path) 

1293 return valid_paths 

1294 

1295 def _check_dymola_instances(self): 

1296 """ 

1297 Check how many dymola instances are running on the machine. 

1298 Raise a warning if the number exceeds a certain amount. 

1299 """ 

1300 # The option may be useful. However the explicit requirement leads to 

1301 # Problems on linux, therefore the feature is not worth the trouble. 

1302 # pylint: disable=import-outside-toplevel 

1303 try: 

1304 import psutil 

1305 except ImportError: 

1306 return 

1307 counter = 0 

1308 for proc in psutil.process_iter(): 

1309 try: 

1310 if "Dymola" in proc.name(): 

1311 counter += 1 

1312 except (psutil.AccessDenied, psutil.NoSuchProcess): 

1313 continue 

1314 if counter >= self._critical_number_instances: 

1315 warnings.warn("There are currently %s Dymola-Instances " 

1316 "running on your machine!" % counter) 

1317 

1318 @staticmethod 

1319 def _alter_model_name(parameters, model_name, structural_params): 

1320 """ 

1321 Creates a modifier for all structural parameters, 

1322 based on the modelname and the initalNames and values. 

1323 

1324 :param dict parameters: 

1325 Parameters of the simulation 

1326 :param str model_name: 

1327 Name of the model to be modified 

1328 :param list structural_params: 

1329 List of strings with structural parameters 

1330 :return: str altered_modelName: 

1331 modified model name 

1332 """ 

1333 # the structural parameter needs to be removed from paramters dict 

1334 new_parameters = parameters.copy() 

1335 model_name = model_name.split("(")[0] # Trim old modifier 

1336 if parameters == {}: 

1337 return model_name 

1338 all_modifiers = [] 

1339 for var_name, value in parameters.items(): 

1340 # Check if the variable is in the 

1341 # given list of structural parameters 

1342 if var_name in structural_params: 

1343 all_modifiers.append(f"{var_name}={value}") 

1344 # removal of the structural parameter 

1345 new_parameters.pop(var_name) 

1346 altered_model_name = f"{model_name}({','.join(all_modifiers)})" 

1347 return altered_model_name, new_parameters 

1348 

1349 def _check_restart(self): 

1350 """Restart Dymola every n_restart iterations in order to free memory""" 

1351 

1352 if self.sim_counter == self.n_restart: 

1353 self.logger.info("Closing and restarting Dymola to free memory") 

1354 self.close() 

1355 self._dummy_dymola_instance = self._setup_dymola_interface(dict(use_mp=False)) 

1356 self.sim_counter = 1 

1357 else: 

1358 self.sim_counter += 1 

1359 

1360 

1361def _get_dymola_path_of_version(dymola_installations: list, dymola_version: str): 

1362 """ 

1363 Helper function to get the path associated to the dymola_version 

1364 from the list of all installations 

1365 """ 

1366 for dymola_path in dymola_installations: 

1367 if dymola_path.endswith(dymola_version): 

1368 return dymola_path 

1369 # If still here, version was not found 

1370 raise ValueError( 

1371 f"Given dymola_version '{dymola_version}' not found in " 

1372 f"the list of dymola installations {dymola_installations}" 

1373 ) 

1374 

1375 

1376def _get_n_available_ports(n_ports: int, start_range: int = 44000, end_range: int = 44400): 

1377 """ 

1378 Get a specified number of available network ports within a given range. 

1379 

1380 This function uses socket connections to check the availability of ports within the specified range. 

1381 If the required number of open ports is found, it returns a list of those ports. If not, it raises 

1382 a ConnectionError with a descriptive message indicating the failure to find the necessary ports. 

1383 

1384 Parameters: 

1385 - n_ports (int): The number of open ports to find. 

1386 - start_range (int, optional): 

1387 The starting port of the range to check (inclusive). 

1388 Default is 44000. 

1389 - end_range (int, optional): 

1390 The ending port of the range to check (exclusive). 

1391 Default is 44400. 

1392 

1393 Returns: 

1394 - list of int: 

1395 A list containing the available ports. 

1396 The length of the list is equal to 'n_ports'. 

1397 

1398 Raises: 

1399 - ConnectionError: 

1400 If the required number of open ports cannot 

1401 be found within the specified range. 

1402 

1403 Example: 

1404 

1405 ``` 

1406 try: 

1407 open_ports = _get_n_available_ports(3, start_range=50000, end_range=50500) 

1408 print(f"Found open ports: {open_ports}") 

1409 except ConnectionError as e: 

1410 print(f"Error: {e}") 

1411 ``` 

1412 """ 

1413 ports = [] 

1414 for port in range(start_range, end_range): 

1415 try: 

1416 with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock: 

1417 sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 

1418 sock.bind(("127.0.0.1", port)) 

1419 ports.append(port) 

1420 except OSError: 

1421 pass 

1422 if len(ports) == n_ports: 

1423 return ports 

1424 raise ConnectionError( 

1425 f"Could not find {n_ports} open ports in range {start_range}-{end_range}." 

1426 f"Can't open {n_ports} Dymola instances" 

1427 )