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

571 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2024-09-19 12:21 +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 

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 DymolaAPI(SimulationAPI): 

46 """ 

47 API to a Dymola instance. 

48 

49 :param str,Path working_directory: 

50 Dirpath for the current working directory of dymola 

51 :param str model_name: 

52 Name of the model to be simulated. 

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

54 :param list packages: 

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

56 :keyword Boolean show_window: 

57 True to show the Dymola window. Default is False 

58 :keyword Boolean modify_structural_parameters: 

59 True to automatically set the structural parameters of the 

60 simulation model via Modelica modifiers. Default is True. 

61 See also the keyword ``structural_parameters`` 

62 of the ``simulate`` function. 

63 :keyword Boolean equidistant_output: 

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

65 equisdistant output and does not store variables at events. 

66 :keyword int n_restart: 

67 Number of iterations after which Dymola should restart. 

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

69 below 1 Dymola does not restart. 

70 :keyword bool extract_variables: 

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

72 on init of this class. 

73 This required translating the model. 

74 :keyword bool debug: 

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

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

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

78 :keyword str mos_script_pre: 

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

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

81 package specified in this API. 

82 May be relevant for handling version conflicts. 

83 :keyword str mos_script_post: 

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

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

86 :keyword str dymola_version: 

87 Version of Dymola to use. 

88 If not given, newest version will be used. 

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

90 of your installation. 

91 

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

93 

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

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

96 

97 and you want to use Dymola 2020x, specify 

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

99 

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

101 :keyword str dymola_path: 

102 Path to the dymola installation on the device. Necessary 

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

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

105 :keyword str dymola_interface_path: 

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

107 Only relevant when the dymola_path 

108 differs from the interface path. 

109 :keyword str dymola_exe_path: 

110 Direct path to the dymola executable. 

111 Only relevant if the dymola installation do not follow 

112 the official guideline. 

113 :keyword float time_delay_between_starts: 

114 If starting multiple Dymola instances on multiple 

115 cores, a time delay between each start avoids weird 

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

117 as Dymola overrides the default .dymx setup file. 

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

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

120 100 seconds. Default is no delay. 

121 

122 Example: 

123 

124 >>> import os 

125 >>> from ebcpy import DymolaAPI 

126 >>> # Specify the model name 

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

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

129 >>> model_name=model_name, 

130 >>> packages=[], 

131 >>> show_window=True) 

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

133 >>> "stop_time": 200} 

134 >>> dym_api.simulate() 

135 >>> dym_api.close() 

136 

137 """ 

138 _sim_setup_class: SimulationSetupClass = DymolaSimulationSetup 

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

140 dymola = None 

141 # Default simulation setup 

142 _supported_kwargs = [ 

143 "show_window", 

144 "modify_structural_parameters", 

145 "dymola_path", 

146 "equidistant_output", 

147 "n_restart", 

148 "debug", 

149 "mos_script_pre", 

150 "mos_script_post", 

151 "dymola_version", 

152 "dymola_interface_path", 

153 "dymola_exe_path", 

154 "time_delay_between_starts" 

155 ] 

156 

157 def __init__( 

158 self, 

159 working_directory: Union[Path, str], 

160 model_name: str = None, 

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

162 **kwargs 

163 ): 

164 """Instantiate class objects.""" 

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

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

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

168 self.fully_initialized = False 

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

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

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

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

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

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

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

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

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

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

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

180 if mos_script is not None: 

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

182 raise FileNotFoundError( 

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

184 f"not exist." 

185 ) 

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

187 raise TypeError( 

188 f"Given mos_script '{mos_script}' " 

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

190 ) 

191 

192 # Convert to modelica path 

193 if self.mos_script_pre is not None: 

194 self.mos_script_pre = self._make_modelica_normpath(self.mos_script_pre) 

195 if self.mos_script_post is not None: 

196 self.mos_script_post = self._make_modelica_normpath(self.mos_script_post) 

197 

198 super().__init__(working_directory=working_directory, 

199 model_name=model_name, 

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

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

202 

203 # First import the dymola-interface 

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

205 if dymola_path is not None: 

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

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

208 "your machine.") 

209 else: 

210 # Get the dymola-install-path: 

211 _dym_installations = self.get_dymola_install_paths() 

212 if _dym_installations: 

213 if self.dymola_version: 

214 dymola_path = _get_dymola_path_of_version( 

215 dymola_installations=_dym_installations, 

216 dymola_version=self.dymola_version 

217 ) 

218 else: 

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

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

221 else: 

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

223 raise FileNotFoundError( 

224 "Could not find dymola on your machine. " 

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

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

227 ) 

228 self.dymola_path = dymola_path 

229 if self.dymola_exe_path is None: 

230 self.dymola_exe_path = self.get_dymola_exe_path(dymola_path) 

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

232 if self.dymola_interface_path is None: 

233 self.dymola_interface_path = self.get_dymola_interface_path(dymola_path) 

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

235 

236 self.packages = [] 

237 if packages is not None: 

238 for package in packages: 

239 if isinstance(package, Path): 

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

241 elif isinstance(package, str): 

242 self.packages.append(package) 

243 else: 

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

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

246 

247 # Import n_restart 

248 self.sim_counter = 0 

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

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

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

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

253 

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

255 if self.n_restart > 0: 

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

257 " a licence during Dymola restarts") 

258 # Use standard port allocation, should always work 

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

260 atexit.register(self._close_dummy) 

261 

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

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

264 self._critical_number_instances = 10 + self.n_cpu 

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

266 if not self.debug: 

267 atexit.register(self.close) 

268 if self.use_mp: 

269 ports = _get_n_available_ports(n_ports=self.n_cpu) 

270 self.pool.map( 

271 self._setup_dymola_interface, 

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

273 for i, port in enumerate(ports)] 

274 ) 

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

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

277 if not self.license_is_available(): 

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

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

280 

281 self.fully_initialized = True 

282 # Trigger on init. 

283 if model_name is not None: 

284 self._update_model() 

285 # Set result_names to output variables. 

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

287 

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

289 # false usage of kwargs: 

290 if kwargs: 

291 self.logger.error( 

292 "You passed the following kwargs which " 

293 "are not part of the supported kwargs and " 

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

295 

296 def _update_model(self): 

297 # Translate the model and extract all variables, 

298 # if the user wants to: 

299 if self.extract_variables and self.fully_initialized: 

300 self.extract_model_variables() 

301 

302 def simulate(self, 

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

304 return_option: str = "time_series", 

305 **kwargs): 

306 """ 

307 Simulate the given parameters. 

308 

309 Additional settings: 

310 

311 :keyword List[str] model_names: 

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

313 of parameters or parameters needs to be sized 1. 

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

315 :keyword Boolean show_eventlog: 

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

317 :keyword Boolean squeeze: 

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

319 a DataFrame is returned directly instead of a list. 

320 :keyword str table_name: 

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

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

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

324 :keyword str file_name: 

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

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

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

328 :keyword List[str] structural_parameters: 

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

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

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

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

333 specify this keyword argument if your structural parameter 

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

335 

336 Example: 

337 Changing a record in a model: 

338 

339 >>> sim_api.simulate( 

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

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

342 

343 """ 

344 # Handle special case for structural_parameters 

345 if "structural_parameters" in kwargs: 

346 _struc_params = kwargs["structural_parameters"] 

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

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

349 # the super method. 

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

351 kwargs["structural_parameters"] = [_struc_params] 

352 if "model_names" in kwargs: 

353 model_names = kwargs["model_names"] 

354 if not isinstance(model_names, list): 

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

356 if isinstance(parameters, dict): 

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

358 parameters = [parameters] * len(model_names) 

359 if parameters is None: 

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

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

362 

363 def _single_simulation(self, kwargs): 

364 # Unpack kwargs 

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

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

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

368 parameters = kwargs.pop("parameters") 

369 return_option = kwargs.pop("return_option") 

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

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

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

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

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

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

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

377 if kwargs: 

378 self.logger.error( 

379 "You passed the following kwargs which " 

380 "are not part of the supported kwargs and " 

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

382 

383 # Handle multiprocessing 

384 if self.use_mp: 

385 idx_worker = self.worker_idx 

386 if self.dymola is None: 

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

388 # method used in the DymolaInterface should work. 

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

390 

391 # Handle eventlog 

392 if show_eventlog: 

393 self.dymola.experimentSetupOutput(events=True) 

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

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

396 

397 # Restart Dymola after n_restart iterations 

398 self._check_restart() 

399 

400 # Handle custom model_names 

401 if model_names is not None: 

402 # Custom model_name setting 

403 _res_names = self.result_names.copy() 

404 self._model_name = model_names 

405 self._update_model_variables() 

406 if _res_names != self.result_names: 

407 self.logger.info( 

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

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

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

411 "option when using the model_names keyword.") 

412 self.logger.info( 

413 "Difference: %s", 

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

415 ) 

416 

417 if self.model_name is None: 

418 raise ValueError( 

419 "You neither passed a model_name when " 

420 "starting DymolaAPI, nor when calling simulate. " 

421 "Can't simulate no model." 

422 ) 

423 

424 # Handle parameters: 

425 if parameters is None: 

426 parameters = {} 

427 unsupported_parameters = False 

428 else: 

429 unsupported_parameters = self.check_unsupported_variables( 

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

431 type_of_var="parameters" 

432 ) 

433 

434 # Handle structural parameters 

435 

436 if (unsupported_parameters and 

437 (self.modify_structural_parameters or 

438 structural_parameters)): 

439 # Alter the model_name for the next simulation 

440 model_name, parameters_new = self._alter_model_name( 

441 parameters=parameters, 

442 model_name=self.model_name, 

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

444 ) 

445 # Trigger translation only if something changed 

446 if model_name != self.model_name: 

447 _res_names = self.result_names.copy() 

448 self.model_name = model_name 

449 self.result_names = _res_names # Restore previous result names 

450 self.logger.warning( 

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

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

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

454 "Check for these parameters: %s", 

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

456 ) 

457 parameters = parameters_new 

458 # Check again 

459 unsupported_parameters = self.check_unsupported_variables( 

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

461 type_of_var="parameters" 

462 ) 

463 

464 initial_names = list(parameters.keys()) 

465 initial_values = list(parameters.values()) 

466 # Convert to float for Boolean and integer types: 

467 try: 

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

469 except (ValueError, TypeError) as err: 

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

471 "Could bot automatically convert the given " 

472 "parameter values to float.") from err 

473 

474 # Handle inputs 

475 if inputs is not None: 

476 # Unpack additional kwargs 

477 if table_name is None or file_name is None: 

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

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

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

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

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

483 # Generate the input in the correct format 

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

485 filepath = convert_tsd_to_modelica_txt( 

486 tsd=inputs, 

487 table_name=table_name, 

488 save_path_file=file_name, 

489 offset=offset 

490 ) 

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

492 

493 if return_option == "savepath": 

494 if unsupported_parameters: 

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

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

497 "To use this option, delete unsupported " 

498 "parameters from your setup.") 

499 res = self.dymola.simulateExtendedModel( 

500 self.model_name, 

501 startTime=self.sim_setup.start_time, 

502 stopTime=self.sim_setup.stop_time, 

503 numberOfIntervals=0, 

504 outputInterval=self.sim_setup.output_interval, 

505 method=self.sim_setup.solver, 

506 tolerance=self.sim_setup.tolerance, 

507 fixedstepsize=self.sim_setup.fixedstepsize, 

508 resultFile=result_file_name, 

509 initialNames=initial_names, 

510 initialValues=initial_values) 

511 else: 

512 if not parameters and not self.parameters: 

513 raise ValueError( 

514 "Sadly, simulating a model in Dymola " 

515 "with no parameters returns no result. " 

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

517 ) 

518 if not parameters: 

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

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

521 initial_names = [random_name] 

522 

523 # Handle 1 and 2 D initial names: 

524 # Convert a 1D list to 2D list 

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

526 initial_values = [initial_values] 

527 

528 # Handle the time of the simulation: 

529 res_names = self.result_names.copy() 

530 if "Time" not in res_names: 

531 res_names.append("Time") 

532 

533 # Internally convert output Interval to number of intervals 

534 # (Required by function simulateMultiResultsModel 

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

536 self.sim_setup.output_interval 

537 if int(number_of_intervals) != number_of_intervals: 

538 raise ValueError( 

539 "Given output_interval and time interval did not yield " 

540 "an integer numberOfIntervals. To use this functions " 

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

542 "numberOfIntervals or a value for output_interval " 

543 "which can be converted to numberOfIntervals.") 

544 

545 res = self.dymola.simulateMultiResultsModel( 

546 self.model_name, 

547 startTime=self.sim_setup.start_time, 

548 stopTime=self.sim_setup.stop_time, 

549 numberOfIntervals=int(number_of_intervals), 

550 method=self.sim_setup.solver, 

551 tolerance=self.sim_setup.tolerance, 

552 fixedstepsize=self.sim_setup.fixedstepsize, 

553 resultFile=None, 

554 initialNames=initial_names, 

555 initialValues=initial_values, 

556 resultNames=res_names) 

557 

558 if not res[0]: 

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

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

561 log = self.dymola.getLastErrorLog() 

562 # Only print first part as output is sometimes to verbose. 

563 self.logger.error(log[:10000]) 

564 dslog_path = self.working_directory.joinpath('dslog.txt') 

565 try: 

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

567 dslog_content = dslog_file.read() 

568 self.logger.error(dslog_content) 

569 except Exception: 

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

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

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

573 if fail_on_error: 

574 raise Exception(msg) 

575 # Don't raise and return None 

576 self.logger.error(msg) 

577 return None 

578 

579 if return_option == "savepath": 

580 _save_name_dsres = f"{result_file_name}.mat" 

581 # Get the working_directory of the current dymola instance 

582 self.dymola.cd() 

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

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

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

586 return os.path.join(dymola_working_directory, _save_name_dsres) 

587 os.makedirs(savepath, exist_ok=True) 

588 for filename in [_save_name_dsres]: 

589 # Copying dslogs and dsfinals can lead to errors, 

590 # as the names are not unique 

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

592 # Delete existing files 

593 try: 

594 os.remove(os.path.join(savepath, filename)) 

595 except OSError: 

596 pass 

597 # Move files 

598 shutil.copy(os.path.join(dymola_working_directory, filename), 

599 os.path.join(savepath, filename)) 

600 os.remove(os.path.join(dymola_working_directory, filename)) 

601 return os.path.join(savepath, _save_name_dsres) 

602 

603 data = res[1] # Get data 

604 if return_option == "last_point": 

605 results = [] 

606 for ini_val_set in data: 

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

608 in enumerate(res_names)}) 

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

610 return results[0] 

611 return results 

612 # Else return as dataframe. 

613 dfs = [] 

614 for ini_val_set in data: 

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

616 in enumerate(res_names)}) 

617 # Set time index 

618 df = df.set_index("Time") 

619 # Convert it to float 

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

621 dfs.append(df) 

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

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

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

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

626 

627 def translate(self): 

628 """ 

629 Translates the current model using dymola.translateModel() 

630 and checks if erros occur. 

631 """ 

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

633 if not res: 

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

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

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

637 raise Exception("Translation failed - Aborting") 

638 

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

640 """ 

641 Set up the compiler and compiler options on Windows. 

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

643 

644 :param str name: 

645 Name of the compiler, avaiable options: 

646 - 'vs': Visual Studio 

647 - 'gcc': GCC 

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

649 Path to the compiler files. 

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

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

652 :param Boolean dll: 

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

654 :param Boolean dde: 

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

656 :param Boolean opc: 

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

658 :return: True, on success. 

659 """ 

660 # Lookup dict for internal name of CCompiler-Variable 

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

662 "gcc": "GCC"} 

663 

664 if "win" not in sys.platform: 

665 raise OSError(f"set_compiler function only implemented " 

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

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

668 name = name.lower() 

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

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

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

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

673 # Convert path for correct input 

674 path = self._make_modelica_normpath(path) 

675 if self.use_mp: 

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

677 

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

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

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

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

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

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

684 

685 return res 

686 

687 def import_initial(self, filepath): 

688 """ 

689 Load given dsfinal.txt into dymola 

690 

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

692 Path to the dsfinal.txt to be loaded 

693 """ 

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

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

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

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

698 if self.use_mp: 

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

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

701 if res: 

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

703 else: 

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

705 

706 @SimulationAPI.working_directory.setter 

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

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

709 if isinstance(working_directory, str): 

710 working_directory = Path(working_directory) 

711 self._working_directory = working_directory 

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

713 return 

714 # Also set the working_directory in the dymola api 

715 self.set_dymola_cd(dymola=self.dymola, 

716 cd=working_directory) 

717 if self.use_mp: 

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

719 "not yet implemented.") 

720 

721 @SimulationAPI.cd.setter 

722 def cd(self, cd): 

723 warnings.warn("cd was renamed to working_directory in all classes. Use working_directory instead.", category=DeprecationWarning) 

724 self.working_directory = cd 

725 

726 def set_dymola_cd(self, dymola, cd): 

727 """ 

728 Set the cd of the Dymola Instance. 

729 Before calling the Function, create the path and 

730 convert to a modelica-normpath. 

731 """ 

732 os.makedirs(cd, exist_ok=True) 

733 cd_modelica = self._make_modelica_normpath(path=cd) 

734 res = dymola.cd(cd_modelica) 

735 if not res: 

736 raise OSError(f"Could not change working directory to {cd}") 

737 

738 def close(self): 

739 """Closes dymola.""" 

740 # Close MP of super class 

741 super().close() 

742 # Always close main instance 

743 self._single_close(dymola=self.dymola) 

744 

745 def _close_multiprocessing(self, _): 

746 self._single_close() 

747 DymolaAPI.dymola = None 

748 

749 def _single_close(self, **kwargs): 

750 """Closes a single dymola instance""" 

751 if self.dymola is None: 

752 return # Already closed prior 

753 # Execute the mos-script if given: 

754 if self.mos_script_post is not None: 

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

756 "prior to closing.") 

757 self.dymola.RunScript(self.mos_script_post) 

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

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

760 self.dymola.close() 

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

762 self.dymola = None 

763 

764 def _close_dummy(self): 

765 """ 

766 Closes dummy instance at the end of the execution 

767 """ 

768 if self._dummy_dymola_instance is not None: 

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

770 self._dummy_dymola_instance.close() 

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

772 

773 def extract_model_variables(self): 

774 """ 

775 Extract all variables of the model by 

776 translating it and then processing the dsin 

777 using the manipulate_ds module. 

778 """ 

779 # Translate model 

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

781 self.model_name) 

782 self.translate() 

783 # Get path to dsin: 

784 dsin_path = os.path.join(self.cd, "dsin.txt") 

785 df = manipulate_ds.convert_ds_file_to_dataframe(dsin_path) 

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

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

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

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

790 if _min >= _max: 

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

792 else: 

793 _var_ebcpy = Variable( 

794 min=_min, 

795 max=_max, 

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

797 ) 

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

799 self.parameters[idx] = _var_ebcpy 

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

801 self.inputs[idx] = _var_ebcpy 

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

803 self.outputs[idx] = _var_ebcpy 

804 else: 

805 self.states[idx] = _var_ebcpy 

806 

807 def _setup_dymola_interface(self, kwargs: dict): 

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

809 use_mp = kwargs["use_mp"] 

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

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

812 time.sleep(time_delay) 

813 dymola = self._open_dymola_interface(port=port) 

814 self._check_dymola_instances() 

815 if use_mp: 

816 cd = os.path.join(self.cd, f"worker_{self.worker_idx}") 

817 else: 

818 cd = self.cd 

819 # Execute the mos-script if given: 

820 if self.mos_script_pre is not None: 

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

822 "prior to loading packages.") 

823 dymola.RunScript(self.mos_script_pre) 

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

825 

826 # Set the cd in the dymola api 

827 self.set_dymola_cd(dymola=dymola, cd=cd) 

828 

829 for package in self.packages: 

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

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

832 if not res: 

833 raise ImportError(dymola.getLastErrorLog()) 

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

835 if self.equidistant_output: 

836 # Change the Simulation Output, to ensure all 

837 # simulation results have the same array shape. 

838 # Events can also cause errors in the shape. 

839 dymola.experimentSetupOutput(equidistant=True, 

840 events=False) 

841 if use_mp: 

842 DymolaAPI.dymola = dymola 

843 return None 

844 return dymola 

845 

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

847 """Check if license is available""" 

848 if self.dymola is None: 

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

850 return False 

851 return self.dymola.RequestOption(option) 

852 

853 def _open_dymola_interface(self, port): 

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

855 if self.dymola_interface_path not in sys.path: 

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

857 try: 

858 from dymola.dymola_interface import DymolaInterface 

859 from dymola.dymola_exception import DymolaConnectionException 

860 return DymolaInterface(showwindow=self.show_window, 

861 dymolapath=self.dymola_exe_path, 

862 port=port) 

863 except ImportError as error: 

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

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

866 except DymolaConnectionException as error: 

867 raise ConnectionError(error) from error 

868 

869 def to_dict(self): 

870 """ 

871 Store the most relevant information of this class 

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

873 

874 :return: dict config: 

875 Dictionary with keys to re-init this class. 

876 """ 

877 # Convert Path to str to enable json-dumping 

878 config = {"cd": str(self.cd), 

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

880 "model_name": self.model_name, 

881 "type": "DymolaAPI", 

882 } 

883 # Update kwargs 

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

885 for kwarg in self._supported_kwargs}) 

886 

887 return config 

888 

889 def get_packages(self): 

890 """ 

891 Get the currently loaded packages of Dymola 

892 """ 

893 packages = self.dymola.ExecuteCommand( 

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

895 ) 

896 if packages is None: 

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

898 packages = [] 

899 for pack in self.packages: 

900 pack = Path(pack) 

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

902 packages.append(pack.parent.name) 

903 valid_packages = [] 

904 for pack in packages: 

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

906 pack_path = self.dymola.ExecuteCommand( 

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

908 ) 

909 if not isinstance(pack_path, str): 

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

911 if os.path.isfile(pack_path): 

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

913 return valid_packages 

914 

915 def save_for_reproduction( 

916 self, 

917 title: str, 

918 path: Path = None, 

919 files: list = None, 

920 save_total_model: bool = True, 

921 export_fmu: bool = True, 

922 **kwargs 

923 ): 

924 """ 

925 Additionally to the basic reproduction, add info 

926 for Dymola packages. 

927 

928 Content which is saved: 

929 - DymolaAPI configuration 

930 - Information on Dymola: Version, flags 

931 - All loaded packages 

932 - Total model, if save_total_model = True 

933 - FMU, if export_fmu = True 

934 

935 :param bool save_total_model: 

936 True to save the total model 

937 :param bool export_fmu: 

938 True to export the FMU of the current model. 

939 """ 

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

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

942 

943 if files is None: 

944 files = [] 

945 # DymolaAPI Info: 

946 files.append(ReproductionFile( 

947 filename="Dymola/DymolaAPI_config.json", 

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

949 )) 

950 # Dymola info: 

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

952 _flags = self.dymola.getLastErrorLog() 

953 dymola_info = [ 

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

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

956 "\n\n" 

957 ] 

958 files.append(ReproductionFile( 

959 filename="Dymola/DymolaInfo.txt", 

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

961 )) 

962 

963 # Packages 

964 packages = self.get_packages() 

965 package_infos = [] 

966 for pack_path in packages: 

967 

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

969 repo_info = get_git_information( 

970 path=pack_dir_parent, 

971 zip_folder_path="Dymola" 

972 ) 

973 if not repo_info: 

974 continue 

975 

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

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

978 break 

979 package_infos.append(str(pack_path)) 

980 files.append(ReproductionFile( 

981 filename="Dymola/Modelica_packages.txt", 

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

983 )) 

984 # Total model 

985 if save_total_model: 

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

987 _total_model = Path(self.cd).joinpath(_total_model_name) 

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

989 if "(" in self.model_name: 

990 # Create temporary model: 

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

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

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

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

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

996 if not res: 

997 self.logger.error( 

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

999 self.model_name 

1000 ) 

1001 model_name_to_save = self.model_name 

1002 else: 

1003 model_name_to_save = temp_mode_name 

1004 os.remove(temp_model_file) 

1005 else: 

1006 model_name_to_save = self.model_name 

1007 res = self.dymola.saveTotalModel( 

1008 fileName=str(_total_model), 

1009 modelName=model_name_to_save 

1010 ) 

1011 if res: 

1012 files.append(ReproductionFile( 

1013 filename=_total_model_name, 

1014 content=_total_model.read_text() 

1015 )) 

1016 os.remove(_total_model) 

1017 else: 

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

1019 self.dymola.getLastErrorLog()) 

1020 # FMU 

1021 if export_fmu: 

1022 _fmu_path = self._save_to_fmu(fail_on_error=False) 

1023 if _fmu_path is not None: 

1024 files.append(CopyFile( 

1025 sourcepath=_fmu_path, 

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

1027 remove=True 

1028 )) 

1029 

1030 return super().save_for_reproduction( 

1031 title=title, 

1032 path=path, 

1033 files=files, 

1034 **kwargs 

1035 ) 

1036 

1037 def _save_to_fmu(self, fail_on_error): 

1038 """Save model as an FMU""" 

1039 res = self.dymola.translateModelFMU( 

1040 modelToOpen=self.model_name, 

1041 storeResult=False, 

1042 modelName='', 

1043 fmiVersion='2', 

1044 fmiType='all', 

1045 includeSource=False, 

1046 includeImage=0 

1047 ) 

1048 if not res: 

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

1050 self.logger.error(msg) 

1051 if fail_on_error: 

1052 raise Exception(msg) 

1053 else: 

1054 path = Path(self.cd).joinpath(res + ".fmu") 

1055 return path 

1056 

1057 @staticmethod 

1058 def _make_modelica_normpath(path): 

1059 """ 

1060 Convert given path to a path readable in dymola. 

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

1062 

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

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

1065 path is created in non existent. 

1066 :return: str 

1067 Path readable in dymola 

1068 """ 

1069 if isinstance(path, Path): 

1070 path = str(path) 

1071 

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

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

1074 loc = path.find(":") 

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

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

1077 return path 

1078 

1079 @staticmethod 

1080 def get_dymola_interface_path(dymola_install_dir): 

1081 """ 

1082 Function to get the path of the newest dymola interface 

1083 installment on the used machine 

1084 

1085 :param str dymola_install_dir: 

1086 The dymola installation folder. Example: 

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

1088 :return: str 

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

1090 """ 

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

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

1093 if os.path.isfile(path_to_egg_file): 

1094 return path_to_egg_file 

1095 # Try to find .whl file: 

1096 for file in os.listdir(path_to_interface): 

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

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

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

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

1101 f"'{dymola_install_dir}' has no " 

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

1103 

1104 @staticmethod 

1105 def get_dymola_exe_path(dymola_install_dir, dymola_name=None): 

1106 """ 

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

1108 on the current used machine. 

1109 

1110 :param str dymola_install_dir: 

1111 The dymola installation folder. Example: 

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

1113 :param str dymola_name: 

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

1115 linux just dymola. 

1116 :return: str 

1117 Path to the dymola-exe-file. 

1118 """ 

1119 if dymola_name is None: 

1120 if "linux" in sys.platform: 

1121 dymola_name = "dymola" 

1122 elif "win" in sys.platform: 

1123 dymola_name = "Dymola.exe" 

1124 else: 

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

1126 f"Please provide one.") 

1127 

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

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

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

1131 dym_file = bin_64 

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

1133 dym_file = bin_32 

1134 else: 

1135 raise FileNotFoundError( 

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

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

1138 ) 

1139 

1140 return dym_file 

1141 

1142 @staticmethod 

1143 def get_dymola_install_paths(basedir=None): 

1144 """ 

1145 Function to get all paths of dymola installations 

1146 on the used machine. Supported platforms are: 

1147 * Windows 

1148 * Linux 

1149 * Mac OS X 

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

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

1152 

1153 :param str basedir: 

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

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

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

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

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

1159 :return: str 

1160 Path to the dymola-installation 

1161 """ 

1162 

1163 if basedir is None: 

1164 if "linux" in sys.platform: 

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

1166 elif "win" in sys.platform: 

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

1168 elif "darwin" in sys.platform: 

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

1170 else: 

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

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

1173 

1174 syspaths = [basedir] 

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

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

1177 if os.path.exists(systempath_64): 

1178 syspaths.append(systempath_64) 

1179 # Get all folders in both path's 

1180 temp_list = [] 

1181 for systempath in syspaths: 

1182 temp_list += os.listdir(systempath) 

1183 # Filter programs that are not Dymola 

1184 dym_versions = [] 

1185 for folder_name in temp_list: 

1186 # Catch both Dymola and dymola folder-names 

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

1188 dym_versions.append(folder_name) 

1189 del temp_list 

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

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

1192 dym_versions.sort() 

1193 valid_paths = [] 

1194 for dym_version in reversed(dym_versions): 

1195 for system_path in syspaths: 

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

1197 if os.path.isdir(full_path): 

1198 valid_paths.append(full_path) 

1199 return valid_paths 

1200 

1201 def _check_dymola_instances(self): 

1202 """ 

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

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

1205 """ 

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

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

1208 # pylint: disable=import-outside-toplevel 

1209 try: 

1210 import psutil 

1211 except ImportError: 

1212 return 

1213 counter = 0 

1214 for proc in psutil.process_iter(): 

1215 try: 

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

1217 counter += 1 

1218 except psutil.AccessDenied: 

1219 continue 

1220 if counter >= self._critical_number_instances: 

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

1222 "running on your machine!" % counter) 

1223 

1224 @staticmethod 

1225 def _alter_model_name(parameters, model_name, structural_params): 

1226 """ 

1227 Creates a modifier for all structural parameters, 

1228 based on the modelname and the initalNames and values. 

1229 

1230 :param dict parameters: 

1231 Parameters of the simulation 

1232 :param str model_name: 

1233 Name of the model to be modified 

1234 :param list structural_params: 

1235 List of strings with structural parameters 

1236 :return: str altered_modelName: 

1237 modified model name 

1238 """ 

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

1240 new_parameters = parameters.copy() 

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

1242 if parameters == {}: 

1243 return model_name 

1244 all_modifiers = [] 

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

1246 # Check if the variable is in the 

1247 # given list of structural parameters 

1248 if var_name in structural_params: 

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

1250 # removal of the structural parameter 

1251 new_parameters.pop(var_name) 

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

1253 return altered_model_name, new_parameters 

1254 

1255 def _check_restart(self): 

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

1257 

1258 if self.sim_counter == self.n_restart: 

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

1260 self.close() 

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

1262 self.sim_counter = 1 

1263 else: 

1264 self.sim_counter += 1 

1265 

1266 

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

1268 """ 

1269 Helper function to get the path associated to the dymola_version 

1270 from the list of all installations 

1271 """ 

1272 for dymola_path in dymola_installations: 

1273 if dymola_path.endswith(dymola_version): 

1274 return dymola_path 

1275 # If still here, version was not found 

1276 raise ValueError( 

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

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

1279 ) 

1280 

1281 

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

1283 """ 

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

1285 

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

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

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

1289 

1290 Parameters: 

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

1292 - start_range (int, optional): 

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

1294 Default is 44000. 

1295 - end_range (int, optional): 

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

1297 Default is 44400. 

1298 

1299 Returns: 

1300 - list of int: 

1301 A list containing the available ports. 

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

1303 

1304 Raises: 

1305 - ConnectionError: 

1306 If the required number of open ports cannot 

1307 be found within the specified range. 

1308 

1309 Example: 

1310 

1311 ``` 

1312 try: 

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

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

1315 except ConnectionError as e: 

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

1317 ``` 

1318 """ 

1319 ports = [] 

1320 for port in range(start_range, end_range): 

1321 try: 

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

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

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

1325 ports.append(port) 

1326 except OSError: 

1327 pass 

1328 if len(ports) == n_ports: 

1329 return ports 

1330 raise ConnectionError( 

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

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

1333 )