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

601 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2025-08-20 12:54 +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 import TimeSeriesData 

21from ebcpy.modelica import manipulate_ds 

22from ebcpy.simulationapi import SimulationSetup, SimulationAPI, \ 

23 SimulationSetupClass, Variable 

24from ebcpy.utils.conversion import convert_tsd_to_modelica_txt 

25 

26 

27class DymolaSimulationSetup(SimulationSetup): 

28 """ 

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

30 setup fields. 

31 """ 

32 tolerance: float = Field( 

33 title="tolerance", 

34 default=0.0001, 

35 description="Tolerance of integration" 

36 ) 

37 

38 _default_solver = "Dassl" 

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

40 "Esdirk23a", "Esdirk34a", "Esdirk45a", "Cvode", 

41 "Rkfix2", "Rkfix3", "Rkfix4", "Lsodar", 

42 "Radau", "Dopri45", "Dopri853", "Sdirk34hw"] 

43 

44 

45class ExperimentSetupOutput(BaseModel): 

46 """ 

47 Experiment setup output data model with 

48 defaults equal to those in Dymola 

49 """ 

50 states: bool = True 

51 derivatives: bool = True 

52 inputs: bool = True 

53 outputs: bool = True 

54 auxiliaries: bool = True 

55 equidistant: bool = False 

56 events: bool = True 

57 

58 class Config: 

59 """ 

60 Pydantic internal model settings 

61 """ 

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

63 extra = "forbid" 

64 

65 

66class DymolaAPI(SimulationAPI): 

67 """ 

68 API to a Dymola instance. 

69 

70 :param str,Path working_directory: 

71 Dirpath for the current working directory of dymola 

72 :param str model_name: 

73 Name of the model to be simulated. 

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

75 :param list packages: 

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

77 :keyword Boolean show_window: 

78 True to show the Dymola window. Default is False 

79 :keyword Boolean modify_structural_parameters: 

80 True to automatically set the structural parameters of the 

81 simulation model via Modelica modifiers. Default is True. 

82 See also the keyword ``structural_parameters`` 

83 of the ``simulate`` function. 

84 :keyword Boolean equidistant_output: 

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

86 equisdistant output and does not store variables at events. 

87 :keyword dict[str,bool] variables_to_save: 

88 A dictionary to select which variables are going 

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

90 Options (with the default being all True): 

91 - states=True 

92 - derivatives=True 

93 - inputs=True 

94 - outputs=True 

95 - auxiliaries=False 

96 :keyword int n_restart: 

97 Number of iterations after which Dymola should restart. 

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

99 below 1 Dymola does not restart. 

100 :keyword bool extract_variables: 

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

102 on init of this class. 

103 This required translating the model. 

104 :keyword bool debug: 

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

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

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

108 :keyword str mos_script_pre: 

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

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

111 package specified in this API. 

112 May be relevant for handling version conflicts. 

113 :keyword str mos_script_post: 

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

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

116 :keyword str dymola_version: 

117 Version of Dymola to use. 

118 If not given, newest version will be used. 

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

120 of your installation. 

121 

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

123 

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

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

126 

127 and you want to use Dymola 2020x, specify 

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

129 

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

131 :keyword str dymola_path: 

132 Path to the dymola installation on the device. Necessary 

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

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

135 :keyword str dymola_interface_path: 

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

137 Only relevant when the dymola_path 

138 differs from the interface path. 

139 :keyword str dymola_exe_path: 

140 Direct path to the dymola executable. 

141 Only relevant if the dymola installation do not follow 

142 the official guideline. 

143 :keyword float time_delay_between_starts: 

144 If starting multiple Dymola instances on multiple 

145 cores, a time delay between each start avoids weird 

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

147 as Dymola overrides the default .dymx setup file. 

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

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

150 100 seconds. Default is no delay. 

151 

152 Example: 

153 

154 >>> import os 

155 >>> from ebcpy import DymolaAPI 

156 >>> # Specify the model name 

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

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

159 >>> model_name=model_name, 

160 >>> packages=[], 

161 >>> show_window=True) 

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

163 >>> "stop_time": 200} 

164 >>> dym_api.simulate() 

165 >>> dym_api.close() 

166 

167 """ 

168 _sim_setup_class: SimulationSetupClass = DymolaSimulationSetup 

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

170 dymola = None 

171 # Default simulation setup 

172 _supported_kwargs = [ 

173 "show_window", 

174 "modify_structural_parameters", 

175 "dymola_path", 

176 "equidistant_output", 

177 "variables_to_save", 

178 "n_restart", 

179 "debug", 

180 "mos_script_pre", 

181 "mos_script_post", 

182 "dymola_version", 

183 "dymola_interface_path", 

184 "dymola_exe_path", 

185 "time_delay_between_starts" 

186 ] 

187 

188 def __init__( 

189 self, 

190 working_directory: Union[Path, str], 

191 model_name: str = None, 

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

193 **kwargs 

194 ): 

195 """Instantiate class objects.""" 

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

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

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

199 self.fully_initialized = False 

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

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

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

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

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

205 self.experiment_setup_output = ExperimentSetupOutput(**_variables_to_save) 

206 

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

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

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

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

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

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

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

214 if mos_script is not None: 

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

216 raise FileNotFoundError( 

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

218 f"not exist." 

219 ) 

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

221 raise TypeError( 

222 f"Given mos_script '{mos_script}' " 

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

224 ) 

225 

226 # Convert to modelica path 

227 if self.mos_script_pre is not None: 

228 self.mos_script_pre = self._make_modelica_normpath(self.mos_script_pre) 

229 if self.mos_script_post is not None: 

230 self.mos_script_post = self._make_modelica_normpath(self.mos_script_post) 

231 

232 super().__init__(working_directory=working_directory, 

233 model_name=model_name, 

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

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

236 

237 # First import the dymola-interface 

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

239 if dymola_path is not None: 

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

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

242 "your machine.") 

243 else: 

244 # Get the dymola-install-path: 

245 _dym_installations = self.get_dymola_install_paths() 

246 if _dym_installations: 

247 if self.dymola_version: 

248 dymola_path = _get_dymola_path_of_version( 

249 dymola_installations=_dym_installations, 

250 dymola_version=self.dymola_version 

251 ) 

252 else: 

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

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

255 else: 

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

257 raise FileNotFoundError( 

258 "Could not find dymola on your machine. " 

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

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

261 ) 

262 self.dymola_path = dymola_path 

263 if self.dymola_exe_path is None: 

264 self.dymola_exe_path = self.get_dymola_exe_path(dymola_path) 

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

266 if self.dymola_interface_path is None: 

267 self.dymola_interface_path = self.get_dymola_interface_path(dymola_path) 

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

269 

270 self.packages = [] 

271 if packages is not None: 

272 for package in packages: 

273 if isinstance(package, Path): 

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

275 elif isinstance(package, str): 

276 self.packages.append(package) 

277 else: 

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

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

280 

281 # Import n_restart 

282 self.sim_counter = 0 

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

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

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

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

287 

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

289 if self.n_restart > 0: 

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

291 " a licence during Dymola restarts") 

292 # Use standard port allocation, should always work 

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

294 atexit.register(self._close_dummy) 

295 

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

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

298 self._critical_number_instances = 10 + self.n_cpu 

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

300 if not self.debug: 

301 atexit.register(self.close) 

302 if self.use_mp: 

303 ports = _get_n_available_ports(n_ports=self.n_cpu) 

304 self.pool.map( 

305 self._setup_dymola_interface, 

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

307 for i, port in enumerate(ports)] 

308 ) 

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

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

311 if not self.license_is_available(): 

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

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

314 # Update experiment setup output 

315 self.update_experiment_setup_output(self.experiment_setup_output) 

316 self.fully_initialized = True 

317 # Trigger on init. 

318 if model_name is not None: 

319 self._update_model() 

320 # Set result_names to output variables. 

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

322 

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

324 # false usage of kwargs: 

325 if kwargs: 

326 self.logger.error( 

327 "You passed the following kwargs which " 

328 "are not part of the supported kwargs and " 

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

330 

331 def _update_model(self): 

332 # Translate the model and extract all variables, 

333 # if the user wants to: 

334 if self.extract_variables and self.fully_initialized: 

335 self.extract_model_variables() 

336 

337 def simulate(self, 

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

339 return_option: str = "time_series", 

340 **kwargs): 

341 """ 

342 Simulate the given parameters. 

343 

344 Additional settings: 

345 

346 :keyword List[str] model_names: 

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

348 of parameters or parameters needs to be sized 1. 

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

350 :keyword Boolean show_eventlog: 

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

352 :keyword Boolean squeeze: 

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

354 a DataFrame is returned directly instead of a list. 

355 :keyword str table_name: 

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

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

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

359 :keyword str file_name: 

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

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

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

363 :keyword callable postprocess_mat_result: 

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

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

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

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

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

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

370 :keyword dict kwargs_postprocessing: 

371 Keyword arguments used in the function `postprocess_mat_result`. 

372 :keyword List[str] structural_parameters: 

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

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

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

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

377 specify this keyword argument if your structural parameter 

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

379 

380 Example: 

381 Changing a record in a model: 

382 

383 >>> sim_api.simulate( 

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

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

386 

387 """ 

388 # Handle special case for structural_parameters 

389 if "structural_parameters" in kwargs: 

390 _struc_params = kwargs["structural_parameters"] 

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

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

393 # the super method. 

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

395 kwargs["structural_parameters"] = [_struc_params] 

396 if "model_names" in kwargs: 

397 model_names = kwargs["model_names"] 

398 if not isinstance(model_names, list): 

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

400 if isinstance(parameters, dict): 

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

402 parameters = [parameters] * len(model_names) 

403 if parameters is None: 

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

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

406 

407 def _single_simulation(self, kwargs): 

408 # Unpack kwargs 

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

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

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

412 if not isinstance(result_file_name, str): 

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

414 parameters = kwargs.pop("parameters") 

415 return_option = kwargs.pop("return_option") 

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

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

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

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

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

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

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

423 

424 def empty_postprocessing(mat_result, **_kwargs): 

425 return mat_result 

426 

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

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

429 if kwargs: 

430 self.logger.error( 

431 "You passed the following kwargs which " 

432 "are not part of the supported kwargs and " 

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

434 

435 # Handle multiprocessing 

436 if self.use_mp: 

437 if self.dymola is None: 

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

439 # method used in the DymolaInterface should work. 

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

441 

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

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

444 

445 # Handle eventlog 

446 if show_eventlog: 

447 if not self.experiment_setup_output.events: 

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

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

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

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

452 

453 # Restart Dymola after n_restart iterations 

454 self._check_restart() 

455 

456 # Handle custom model_names 

457 if model_names is not None: 

458 # Custom model_name setting 

459 _res_names = self.result_names.copy() 

460 self._model_name = model_names 

461 self._update_model_variables() 

462 if _res_names != self.result_names: 

463 self.logger.info( 

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

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

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

467 "option when using the model_names keyword.") 

468 self.logger.info( 

469 "Difference: %s", 

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

471 ) 

472 

473 if self.model_name is None: 

474 raise ValueError( 

475 "You neither passed a model_name when " 

476 "starting DymolaAPI, nor when calling simulate. " 

477 "Can't simulate no model." 

478 ) 

479 

480 # Handle parameters: 

481 if parameters is None: 

482 parameters = {} 

483 unsupported_parameters = False 

484 else: 

485 unsupported_parameters = self.check_unsupported_variables( 

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

487 type_of_var="parameters" 

488 ) 

489 

490 # Handle structural parameters 

491 

492 if (unsupported_parameters and 

493 (self.modify_structural_parameters or 

494 structural_parameters)): 

495 # Alter the model_name for the next simulation 

496 model_name, parameters_new = self._alter_model_name( 

497 parameters=parameters, 

498 model_name=self.model_name, 

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

500 ) 

501 # Trigger translation only if something changed 

502 if model_name != self.model_name: 

503 _res_names = self.result_names.copy() 

504 self.model_name = model_name 

505 self.result_names = _res_names # Restore previous result names 

506 self.logger.warning( 

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

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

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

510 "Check for these parameters: %s", 

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

512 ) 

513 parameters = parameters_new 

514 # Check again 

515 unsupported_parameters = self.check_unsupported_variables( 

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

517 type_of_var="parameters" 

518 ) 

519 

520 initial_names = list(parameters.keys()) 

521 initial_values = list(parameters.values()) 

522 # Convert to float for Boolean and integer types: 

523 try: 

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

525 except (ValueError, TypeError) as err: 

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

527 "Could bot automatically convert the given " 

528 "parameter values to float.") from err 

529 

530 # Handle inputs 

531 if inputs is not None: 

532 # Unpack additional kwargs 

533 if table_name is None or file_name is None: 

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

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

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

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

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

539 # Generate the input in the correct format 

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

541 filepath = convert_tsd_to_modelica_txt( 

542 tsd=inputs, 

543 table_name=table_name, 

544 save_path_file=file_name, 

545 offset=offset 

546 ) 

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

548 

549 if return_option == "savepath": 

550 if unsupported_parameters: 

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

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

553 "To use this option, delete unsupported " 

554 "parameters from your setup.") 

555 res = self.dymola.simulateExtendedModel( 

556 self.model_name, 

557 startTime=self.sim_setup.start_time, 

558 stopTime=self.sim_setup.stop_time, 

559 numberOfIntervals=0, 

560 outputInterval=self.sim_setup.output_interval, 

561 method=self.sim_setup.solver, 

562 tolerance=self.sim_setup.tolerance, 

563 fixedstepsize=self.sim_setup.fixedstepsize, 

564 resultFile=result_file_name, 

565 initialNames=initial_names, 

566 initialValues=initial_values) 

567 else: 

568 if not parameters and not self.parameters: 

569 raise ValueError( 

570 "Sadly, simulating a model in Dymola " 

571 "with no parameters returns no result. " 

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

573 ) 

574 if not parameters: 

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

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

577 initial_names = [random_name] 

578 

579 # Handle 1 and 2 D initial names: 

580 # Convert a 1D list to 2D list 

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

582 initial_values = [initial_values] 

583 

584 # Handle the time of the simulation: 

585 res_names = self.result_names.copy() 

586 if "Time" not in res_names: 

587 res_names.append("Time") 

588 

589 # Internally convert output Interval to number of intervals 

590 # (Required by function simulateMultiResultsModel 

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

592 self.sim_setup.output_interval 

593 if int(number_of_intervals) != number_of_intervals: 

594 raise ValueError( 

595 "Given output_interval and time interval did not yield " 

596 "an integer numberOfIntervals. To use this functions " 

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

598 "numberOfIntervals or a value for output_interval " 

599 "which can be converted to numberOfIntervals.") 

600 

601 res = self.dymola.simulateMultiResultsModel( 

602 self.model_name, 

603 startTime=self.sim_setup.start_time, 

604 stopTime=self.sim_setup.stop_time, 

605 numberOfIntervals=int(number_of_intervals), 

606 method=self.sim_setup.solver, 

607 tolerance=self.sim_setup.tolerance, 

608 fixedstepsize=self.sim_setup.fixedstepsize, 

609 resultFile=None, 

610 initialNames=initial_names, 

611 initialValues=initial_values, 

612 resultNames=res_names) 

613 

614 if not res[0]: 

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

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

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

618 try: 

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

620 dslog_content = dslog_file.read() 

621 self.logger.error(dslog_content) 

622 except Exception: 

623 log = self.dymola.getLastErrorLog() 

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

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

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

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

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

629 if fail_on_error: 

630 raise Exception(msg) 

631 # Don't raise and return None 

632 self.logger.error(msg) 

633 return None 

634 

635 if return_option == "savepath": 

636 _save_name_dsres = f"{result_file_name}.mat" 

637 # Get the working_directory of the current dymola instance 

638 self.dymola.cd() 

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

640 dymola_working_directory = Path(self.dymola.getLastErrorLog().replace("\n", "")) 

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

642 self.logger.warning( 

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

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

645 "messages upon failed simulations.", 

646 dymola_working_directory, 

647 self._get_worker_directory(use_mp=self.use_mp) 

648 ) 

649 

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

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

652 mat_result_file = mat_working_directory 

653 else: 

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

655 os.makedirs(savepath, exist_ok=True) 

656 # Copying dslogs and dsfinals can lead to errors, 

657 # as the names are not unique 

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

659 # Delete existing files 

660 try: 

661 os.remove(mat_save_path) 

662 except OSError: 

663 pass 

664 # Move files 

665 shutil.copy(mat_working_directory, mat_save_path) 

666 os.remove(mat_working_directory) 

667 mat_result_file = mat_save_path 

668 result_file = postprocess_mat_result(mat_result_file, **kwargs_postprocessing) 

669 return result_file 

670 

671 data = res[1] # Get data 

672 if return_option == "last_point": 

673 results = [] 

674 for ini_val_set in data: 

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

676 in enumerate(res_names)}) 

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

678 return results[0] 

679 return results 

680 # Else return as dataframe. 

681 dfs = [] 

682 for ini_val_set in data: 

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

684 in enumerate(res_names)}) 

685 # Set time index 

686 df = df.set_index("Time") 

687 # Convert it to float 

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

689 dfs.append(df) 

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

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

692 return TimeSeriesData(dfs[0], default_tag="sim") 

693 return [TimeSeriesData(df, default_tag="sim") for df in dfs] 

694 

695 def translate(self): 

696 """ 

697 Translates the current model using dymola.translateModel() 

698 and checks if erros occur. 

699 """ 

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

701 if not res: 

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

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

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

705 raise Exception("Translation failed - Aborting") 

706 

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

708 """ 

709 Set up the compiler and compiler options on Windows. 

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

711 

712 :param str name: 

713 Name of the compiler, avaiable options: 

714 - 'vs': Visual Studio 

715 - 'gcc': GCC 

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

717 Path to the compiler files. 

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

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

720 :param Boolean dll: 

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

722 :param Boolean dde: 

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

724 :param Boolean opc: 

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

726 :return: True, on success. 

727 """ 

728 # Lookup dict for internal name of CCompiler-Variable 

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

730 "gcc": "GCC"} 

731 

732 if "win" not in sys.platform: 

733 raise OSError(f"set_compiler function only implemented " 

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

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

736 name = name.lower() 

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

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

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

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

741 # Convert path for correct input 

742 path = self._make_modelica_normpath(path) 

743 if self.use_mp: 

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

745 

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

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

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

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

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

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

752 

753 return res 

754 

755 def import_initial(self, filepath): 

756 """ 

757 Load given dsfinal.txt into dymola 

758 

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

760 Path to the dsfinal.txt to be loaded 

761 """ 

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

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

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

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

766 if self.use_mp: 

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

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

769 if res: 

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

771 else: 

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

773 

774 @SimulationAPI.working_directory.setter 

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

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

777 if isinstance(working_directory, str): 

778 working_directory = Path(working_directory) 

779 self._working_directory = working_directory 

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

781 return 

782 # Also set the working_directory in the dymola api 

783 self.set_dymola_working_directory(dymola=self.dymola, 

784 working_directory=working_directory) 

785 if self.use_mp: 

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

787 "not yet implemented.") 

788 

789 def set_dymola_working_directory(self, dymola, working_directory): 

790 """ 

791 Set the working directory of the Dymola Instance. 

792 Before calling the Function, create the path and 

793 convert to a modelica-normpath. 

794 """ 

795 os.makedirs(working_directory, exist_ok=True) 

796 modelica_working_directory = self._make_modelica_normpath(path=working_directory) 

797 res = dymola.cd(modelica_working_directory) 

798 if not res: 

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

800 

801 def close(self): 

802 """Closes dymola.""" 

803 # Close MP of super class 

804 super().close() 

805 # Always close main instance 

806 self._single_close(dymola=self.dymola) 

807 

808 def _close_multiprocessing(self, _): 

809 self._single_close() 

810 DymolaAPI.dymola = None 

811 

812 def _single_close(self, **kwargs): 

813 """Closes a single dymola instance""" 

814 if self.dymola is None: 

815 return # Already closed prior 

816 # Execute the mos-script if given: 

817 if self.mos_script_post is not None: 

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

819 "prior to closing.") 

820 self.dymola.RunScript(self.mos_script_post) 

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

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

823 self.dymola.close() 

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

825 self.dymola = None 

826 

827 def _close_dummy(self): 

828 """ 

829 Closes dummy instance at the end of the execution 

830 """ 

831 if self._dummy_dymola_instance is not None: 

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

833 self._dummy_dymola_instance.close() 

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

835 

836 def extract_model_variables(self): 

837 """ 

838 Extract all variables of the model by 

839 translating it and then processing the dsin 

840 using the manipulate_ds module. 

841 """ 

842 # Translate model 

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

844 self.model_name) 

845 self.translate() 

846 # Get dsin: 

847 df = manipulate_ds.convert_ds_file_to_dataframe( 

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

849 ) 

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

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

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

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

854 if _min >= _max: 

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

856 else: 

857 _var_ebcpy = Variable( 

858 min=_min, 

859 max=_max, 

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

861 ) 

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

863 self.parameters[idx] = _var_ebcpy 

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

865 self.inputs[idx] = _var_ebcpy 

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

867 self.outputs[idx] = _var_ebcpy 

868 else: 

869 self.states[idx] = _var_ebcpy 

870 

871 def _setup_dymola_interface(self, kwargs: dict): 

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

873 use_mp = kwargs["use_mp"] 

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

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

876 time.sleep(time_delay) 

877 dymola = self._open_dymola_interface(port=port) 

878 self._check_dymola_instances() 

879 

880 # Execute the mos-script if given: 

881 if self.mos_script_pre is not None: 

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

883 "prior to loading packages.") 

884 dymola.RunScript(self.mos_script_pre) 

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

886 

887 # Set the cd in the dymola api 

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

889 

890 for package in self.packages: 

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

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

893 if not res: 

894 raise ImportError(dymola.getLastErrorLog()) 

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

896 

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

898 if use_mp: 

899 DymolaAPI.dymola = dymola 

900 return None 

901 return dymola 

902 

903 def _get_worker_directory(self, use_mp: bool): 

904 """ 

905 Returns the current working directory for the process / worker. 

906 

907 :param bool use_mp: Indicates if the central working directory is needed or the worker one. 

908 """ 

909 if use_mp: 

910 return self.working_directory.joinpath(f"worker_{self.worker_idx}") 

911 return self.working_directory 

912 

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

914 """ 

915 Function to update the ExperimentSetupOutput in Dymola for selection 

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

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

918 

919 :param (ExperimentSetupOutput, dict) experiment_setup_output: 

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

921 """ 

922 if isinstance(experiment_setup_output, dict): 

923 self.experiment_setup_output = ExperimentSetupOutput(**experiment_setup_output) 

924 else: 

925 self.experiment_setup_output = experiment_setup_output 

926 if self.equidistant_output: 

927 # Change the Simulation Output, to ensure all 

928 # simulation results have the same array shape. 

929 # Events can also cause errors in the shape. 

930 self.experiment_setup_output.equidistant = True 

931 self.experiment_setup_output.events = False 

932 if self.dymola is None: 

933 return 

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

935 

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

937 """Check if license is available""" 

938 if self.dymola is None: 

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

940 return False 

941 return self.dymola.RequestOption(option) 

942 

943 def _open_dymola_interface(self, port): 

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

945 if self.dymola_interface_path not in sys.path: 

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

947 try: 

948 from dymola.dymola_interface import DymolaInterface 

949 from dymola.dymola_exception import DymolaConnectionException 

950 return DymolaInterface(showwindow=self.show_window, 

951 dymolapath=self.dymola_exe_path, 

952 port=port) 

953 except ImportError as error: 

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

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

956 except DymolaConnectionException as error: 

957 raise ConnectionError(error) from error 

958 

959 def to_dict(self): 

960 """ 

961 Store the most relevant information of this class 

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

963 

964 :return: dict config: 

965 Dictionary with keys to re-init this class. 

966 """ 

967 # Convert Path to str to enable json-dumping 

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

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

970 "model_name": self.model_name, 

971 "type": "DymolaAPI", 

972 } 

973 # Update kwargs 

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

975 for kwarg in self._supported_kwargs}) 

976 

977 return config 

978 

979 def get_packages(self): 

980 """ 

981 Get the currently loaded packages of Dymola 

982 """ 

983 packages = self.dymola.ExecuteCommand( 

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

985 ) 

986 if packages is None: 

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

988 packages = [] 

989 for pack in self.packages: 

990 pack = Path(pack) 

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

992 packages.append(pack.parent.name) 

993 valid_packages = [] 

994 for pack in packages: 

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

996 pack_path = self.dymola.ExecuteCommand( 

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

998 ) 

999 if not isinstance(pack_path, str): 

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

1001 if os.path.isfile(pack_path): 

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

1003 return valid_packages 

1004 

1005 def save_for_reproduction( 

1006 self, 

1007 title: str, 

1008 path: Path = None, 

1009 files: list = None, 

1010 save_total_model: bool = True, 

1011 export_fmu: bool = True, 

1012 **kwargs 

1013 ): 

1014 """ 

1015 Additionally to the basic reproduction, add info 

1016 for Dymola packages. 

1017 

1018 Content which is saved: 

1019 - DymolaAPI configuration 

1020 - Information on Dymola: Version, flags 

1021 - All loaded packages 

1022 - Total model, if save_total_model = True 

1023 - FMU, if export_fmu = True 

1024 

1025 :param bool save_total_model: 

1026 True to save the total model 

1027 :param bool export_fmu: 

1028 True to export the FMU of the current model. 

1029 """ 

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

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

1032 

1033 if files is None: 

1034 files = [] 

1035 # DymolaAPI Info: 

1036 files.append(ReproductionFile( 

1037 filename="Dymola/DymolaAPI_config.json", 

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

1039 )) 

1040 # Dymola info: 

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

1042 _flags = self.dymola.getLastErrorLog() 

1043 dymola_info = [ 

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

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

1046 "\n\n" 

1047 ] 

1048 files.append(ReproductionFile( 

1049 filename="Dymola/DymolaInfo.txt", 

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

1051 )) 

1052 

1053 # Packages 

1054 packages = self.get_packages() 

1055 package_infos = [] 

1056 for pack_path in packages: 

1057 

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

1059 repo_info = get_git_information( 

1060 path=pack_dir_parent, 

1061 zip_folder_path="Dymola" 

1062 ) 

1063 if not repo_info: 

1064 continue 

1065 

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

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

1068 break 

1069 package_infos.append(str(pack_path)) 

1070 files.append(ReproductionFile( 

1071 filename="Dymola/Modelica_packages.txt", 

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

1073 )) 

1074 # Total model 

1075 if save_total_model: 

1076 _total_model_name = f"Dymola/{self.model_name.replace('.', '_')}_total.mo" 

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

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

1079 if "(" in self.model_name: 

1080 # Create temporary model: 

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

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

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

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

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

1086 if not res: 

1087 self.logger.error( 

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

1089 self.model_name 

1090 ) 

1091 model_name_to_save = self.model_name 

1092 else: 

1093 model_name_to_save = temp_mode_name 

1094 os.remove(temp_model_file) 

1095 else: 

1096 model_name_to_save = self.model_name 

1097 res = self.dymola.saveTotalModel( 

1098 fileName=str(_total_model), 

1099 modelName=model_name_to_save 

1100 ) 

1101 if res: 

1102 files.append(ReproductionFile( 

1103 filename=_total_model_name, 

1104 content=_total_model.read_text() 

1105 )) 

1106 os.remove(_total_model) 

1107 else: 

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

1109 self.dymola.getLastErrorLog()) 

1110 # FMU 

1111 if export_fmu: 

1112 _fmu_path = self._save_to_fmu(fail_on_error=False) 

1113 if _fmu_path is not None: 

1114 files.append(CopyFile( 

1115 sourcepath=_fmu_path, 

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

1117 remove=True 

1118 )) 

1119 

1120 return super().save_for_reproduction( 

1121 title=title, 

1122 path=path, 

1123 files=files, 

1124 **kwargs 

1125 ) 

1126 

1127 def _save_to_fmu(self, fail_on_error): 

1128 """Save model as an FMU""" 

1129 res = self.dymola.translateModelFMU( 

1130 modelToOpen=self.model_name, 

1131 storeResult=False, 

1132 modelName='', 

1133 fmiVersion='2', 

1134 fmiType='all', 

1135 includeSource=False, 

1136 includeImage=0 

1137 ) 

1138 if not res: 

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

1140 self.logger.error(msg) 

1141 if fail_on_error: 

1142 raise Exception(msg) 

1143 else: 

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

1145 return path 

1146 

1147 @staticmethod 

1148 def _make_modelica_normpath(path): 

1149 """ 

1150 Convert given path to a path readable in dymola. 

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

1152 

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

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

1155 path is created in non existent. 

1156 :return: str 

1157 Path readable in dymola 

1158 """ 

1159 if isinstance(path, Path): 

1160 path = str(path) 

1161 

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

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

1164 loc = path.find(":") 

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

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

1167 return path 

1168 

1169 @staticmethod 

1170 def get_dymola_interface_path(dymola_install_dir): 

1171 """ 

1172 Function to get the path of the newest dymola interface 

1173 installment on the used machine 

1174 

1175 :param str dymola_install_dir: 

1176 The dymola installation folder. Example: 

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

1178 :return: str 

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

1180 """ 

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

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

1183 if os.path.isfile(path_to_egg_file): 

1184 return path_to_egg_file 

1185 # Try to find .whl file: 

1186 for file in os.listdir(path_to_interface): 

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

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

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

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

1191 f"'{dymola_install_dir}' has no " 

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

1193 

1194 @staticmethod 

1195 def get_dymola_exe_path(dymola_install_dir, dymola_name=None): 

1196 """ 

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

1198 on the current used machine. 

1199 

1200 :param str dymola_install_dir: 

1201 The dymola installation folder. Example: 

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

1203 :param str dymola_name: 

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

1205 linux just dymola. 

1206 :return: str 

1207 Path to the dymola-exe-file. 

1208 """ 

1209 if dymola_name is None: 

1210 if "linux" in sys.platform: 

1211 dymola_name = "dymola" 

1212 elif "win" in sys.platform: 

1213 dymola_name = "Dymola.exe" 

1214 else: 

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

1216 f"Please provide one.") 

1217 

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

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

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

1221 dym_file = bin_64 

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

1223 dym_file = bin_32 

1224 else: 

1225 raise FileNotFoundError( 

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

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

1228 ) 

1229 

1230 return dym_file 

1231 

1232 @staticmethod 

1233 def get_dymola_install_paths(basedir=None): 

1234 """ 

1235 Function to get all paths of dymola installations 

1236 on the used machine. Supported platforms are: 

1237 * Windows 

1238 * Linux 

1239 * Mac OS X 

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

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

1242 

1243 :param str basedir: 

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

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

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

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

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

1249 :return: str 

1250 Path to the dymola-installation 

1251 """ 

1252 

1253 if basedir is None: 

1254 if "linux" in sys.platform: 

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

1256 elif "win" in sys.platform: 

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

1258 elif "darwin" in sys.platform: 

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

1260 else: 

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

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

1263 

1264 syspaths = [basedir] 

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

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

1267 if os.path.exists(systempath_64): 

1268 syspaths.append(systempath_64) 

1269 # Get all folders in both path's 

1270 temp_list = [] 

1271 for systempath in syspaths: 

1272 temp_list += os.listdir(systempath) 

1273 # Filter programs that are not Dymola 

1274 dym_versions = [] 

1275 for folder_name in temp_list: 

1276 # Catch both Dymola and dymola folder-names 

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

1278 dym_versions.append(folder_name) 

1279 del temp_list 

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

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

1282 dym_versions.sort() 

1283 valid_paths = [] 

1284 for dym_version in reversed(dym_versions): 

1285 for system_path in syspaths: 

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

1287 if os.path.isdir(full_path): 

1288 valid_paths.append(full_path) 

1289 return valid_paths 

1290 

1291 def _check_dymola_instances(self): 

1292 """ 

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

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

1295 """ 

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

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

1298 # pylint: disable=import-outside-toplevel 

1299 try: 

1300 import psutil 

1301 except ImportError: 

1302 return 

1303 counter = 0 

1304 for proc in psutil.process_iter(): 

1305 try: 

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

1307 counter += 1 

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

1309 continue 

1310 if counter >= self._critical_number_instances: 

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

1312 "running on your machine!" % counter) 

1313 

1314 @staticmethod 

1315 def _alter_model_name(parameters, model_name, structural_params): 

1316 """ 

1317 Creates a modifier for all structural parameters, 

1318 based on the modelname and the initalNames and values. 

1319 

1320 :param dict parameters: 

1321 Parameters of the simulation 

1322 :param str model_name: 

1323 Name of the model to be modified 

1324 :param list structural_params: 

1325 List of strings with structural parameters 

1326 :return: str altered_modelName: 

1327 modified model name 

1328 """ 

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

1330 new_parameters = parameters.copy() 

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

1332 if parameters == {}: 

1333 return model_name 

1334 all_modifiers = [] 

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

1336 # Check if the variable is in the 

1337 # given list of structural parameters 

1338 if var_name in structural_params: 

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

1340 # removal of the structural parameter 

1341 new_parameters.pop(var_name) 

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

1343 return altered_model_name, new_parameters 

1344 

1345 def _check_restart(self): 

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

1347 

1348 if self.sim_counter == self.n_restart: 

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

1350 self.close() 

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

1352 self.sim_counter = 1 

1353 else: 

1354 self.sim_counter += 1 

1355 

1356 

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

1358 """ 

1359 Helper function to get the path associated to the dymola_version 

1360 from the list of all installations 

1361 """ 

1362 for dymola_path in dymola_installations: 

1363 if dymola_path.endswith(dymola_version): 

1364 return dymola_path 

1365 # If still here, version was not found 

1366 raise ValueError( 

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

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

1369 ) 

1370 

1371 

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

1373 """ 

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

1375 

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

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

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

1379 

1380 Parameters: 

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

1382 - start_range (int, optional): 

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

1384 Default is 44000. 

1385 - end_range (int, optional): 

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

1387 Default is 44400. 

1388 

1389 Returns: 

1390 - list of int: 

1391 A list containing the available ports. 

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

1393 

1394 Raises: 

1395 - ConnectionError: 

1396 If the required number of open ports cannot 

1397 be found within the specified range. 

1398 

1399 Example: 

1400 

1401 ``` 

1402 try: 

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

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

1405 except ConnectionError as e: 

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

1407 ``` 

1408 """ 

1409 ports = [] 

1410 for port in range(start_range, end_range): 

1411 try: 

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

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

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

1415 ports.append(port) 

1416 except OSError: 

1417 pass 

1418 if len(ports) == n_ports: 

1419 return ports 

1420 raise ConnectionError( 

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

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

1423 )