Coverage for ebcpy/simulationapi/dymola_api.py: 64%
601 statements
« prev ^ index » next coverage.py v7.4.4, created at 2025-08-26 09:12 +0000
« prev ^ index » next coverage.py v7.4.4, created at 2025-08-26 09:12 +0000
1"""Module containing the DymolaAPI used for simulation
2of Modelica-Models."""
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
17from pydantic import Field, BaseModel
18import pandas as pd
20from ebcpy.modelica import manipulate_ds
21from ebcpy.simulationapi import SimulationSetup, SimulationAPI, \
22 SimulationSetupClass, Variable
23from ebcpy.utils.conversion import convert_tsd_to_modelica_txt
26class DymolaSimulationSetup(SimulationSetup):
27 """
28 Adds ``tolerance`` to the list of possible
29 setup fields.
30 """
31 tolerance: float = Field(
32 title="tolerance",
33 default=0.0001,
34 description="Tolerance of integration"
35 )
37 _default_solver = "Dassl"
38 _allowed_solvers = ["Dassl", "Euler", "Cerk23", "Cerk34", "Cerk45",
39 "Esdirk23a", "Esdirk34a", "Esdirk45a", "Cvode",
40 "Rkfix2", "Rkfix3", "Rkfix4", "Lsodar",
41 "Radau", "Dopri45", "Dopri853", "Sdirk34hw"]
44class ExperimentSetupOutput(BaseModel):
45 """
46 Experiment setup output data model with
47 defaults equal to those in Dymola
48 """
49 states: bool = True
50 derivatives: bool = True
51 inputs: bool = True
52 outputs: bool = True
53 auxiliaries: bool = True
54 equidistant: bool = False
55 events: bool = True
57 class Config:
58 """
59 Pydantic internal model settings
60 """
61 # pylint: disable=too-few-public-methods
62 extra = "forbid"
65class DymolaAPI(SimulationAPI):
66 """
67 API to a Dymola instance.
69 :param str,Path working_directory:
70 Dirpath for the current working directory of dymola
71 :param str model_name:
72 Name of the model to be simulated.
73 If None, it has to be provided prior to or when calling simulate().
74 :param list packages:
75 List with path's to the packages needed to simulate the model
76 :keyword Boolean show_window:
77 True to show the Dymola window. Default is False
78 :keyword Boolean modify_structural_parameters:
79 True to automatically set the structural parameters of the
80 simulation model via Modelica modifiers. Default is True.
81 See also the keyword ``structural_parameters``
82 of the ``simulate`` function.
83 :keyword Boolean equidistant_output:
84 If True (Default), Dymola stores variables in an
85 equisdistant output and does not store variables at events.
86 :keyword dict[str,bool] variables_to_save:
87 A dictionary to select which variables are going
88 to be stored if the simulation creates .mat files.
89 Options (with the default being all True):
90 - states=True
91 - derivatives=True
92 - inputs=True
93 - outputs=True
94 - auxiliaries=False
95 :keyword int n_restart:
96 Number of iterations after which Dymola should restart.
97 This is done to free memory. Default value -1. For values
98 below 1 Dymola does not restart.
99 :keyword bool extract_variables:
100 If True (the default), all variables of the model will be extracted
101 on init of this class.
102 This required translating the model.
103 :keyword bool debug:
104 If True (not the default), the dymola instance is not closed
105 on exit of the python script. This allows further debugging in
106 dymola itself if API-functions cause a python error.
107 :keyword str mos_script_pre:
108 Path to a valid mos-script for Modelica/Dymola.
109 If given, the script is executed **prior** to laoding any
110 package specified in this API.
111 May be relevant for handling version conflicts.
112 :keyword str mos_script_post:
113 Path to a valid mos-script for Modelica/Dymola.
114 If given, the script is executed before closing Dymola.
115 :keyword str dymola_version:
116 Version of Dymola to use.
117 If not given, newest version will be used.
118 If given, the Version needs to be equal to the folder name
119 of your installation.
121 **Example:** If you have two versions installed at
123 - ``C://Program Files//Dymola 2021`` and
124 - ``C://Program Files//Dymola 2020x``
126 and you want to use Dymola 2020x, specify
127 ``dymola_version='Dymola 2020x'``.
129 This parameter is overwritten if ``dymola_path`` is specified.
130 :keyword str dymola_path:
131 Path to the dymola installation on the device. Necessary
132 e.g. on linux, if we can't find the path automatically.
133 Example: ``dymola_path="C://Program Files//Dymola 2020x"``
134 :keyword str dymola_interface_path:
135 Direct path to the .egg-file of the dymola interface.
136 Only relevant when the dymola_path
137 differs from the interface path.
138 :keyword str dymola_exe_path:
139 Direct path to the dymola executable.
140 Only relevant if the dymola installation do not follow
141 the official guideline.
142 :keyword float time_delay_between_starts:
143 If starting multiple Dymola instances on multiple
144 cores, a time delay between each start avoids weird
145 behaviour, such as requiring to set the C-Compiler again
146 as Dymola overrides the default .dymx setup file.
147 If you start e.g. 20 instances and specify `time_delay_between_starts=5`,
148 each 5 seconds one instance will start, taking in total
149 100 seconds. Default is no delay.
151 Example:
153 >>> import os
154 >>> from ebcpy import DymolaAPI
155 >>> # Specify the model name
156 >>> model_name = "Modelica.Thermal.FluidHeatFlow.Examples.PumpAndValve"
157 >>> dym_api = DymolaAPI(working_directory=os.getcwd(),
158 >>> model_name=model_name,
159 >>> packages=[],
160 >>> show_window=True)
161 >>> dym_api.sim_setup = {"start_time": 100,
162 >>> "stop_time": 200}
163 >>> dym_api.simulate()
164 >>> dym_api.close()
166 """
167 _sim_setup_class: SimulationSetupClass = DymolaSimulationSetup
168 _items_to_drop = ["pool", "dymola", "_dummy_dymola_instance"]
169 dymola = None
170 # Default simulation setup
171 _supported_kwargs = [
172 "show_window",
173 "modify_structural_parameters",
174 "dymola_path",
175 "equidistant_output",
176 "variables_to_save",
177 "n_restart",
178 "debug",
179 "mos_script_pre",
180 "mos_script_post",
181 "dymola_version",
182 "dymola_interface_path",
183 "dymola_exe_path",
184 "time_delay_between_starts"
185 ]
187 def __init__(
188 self,
189 working_directory: Union[Path, str],
190 model_name: str = None,
191 packages: List[Union[Path, str]] = None,
192 **kwargs
193 ):
194 """Instantiate class objects."""
195 self.dymola = None # Avoid key-error in get-state. Instance attribute needs to be there.
196 # Update kwargs with regard to what kwargs are supported.
197 self.extract_variables = kwargs.pop("extract_variables", True)
198 self.fully_initialized = False
199 self.debug = kwargs.pop("debug", False)
200 self.show_window = kwargs.pop("show_window", False)
201 self.modify_structural_parameters = kwargs.pop("modify_structural_parameters", True)
202 self.equidistant_output = kwargs.pop("equidistant_output", True)
203 _variables_to_save = kwargs.pop("variables_to_save", {})
204 self.experiment_setup_output = ExperimentSetupOutput(**_variables_to_save)
206 self.mos_script_pre = kwargs.pop("mos_script_pre", None)
207 self.mos_script_post = kwargs.pop("mos_script_post", None)
208 self.dymola_version = kwargs.pop("dymola_version", None)
209 self.dymola_interface_path = kwargs.pop("dymola_interface_path", None)
210 self.dymola_exe_path = kwargs.pop("dymola_exe_path", None)
211 _time_delay_between_starts = kwargs.pop("time_delay_between_starts", 0)
212 for mos_script in [self.mos_script_pre, self.mos_script_post]:
213 if mos_script is not None:
214 if not os.path.isfile(mos_script):
215 raise FileNotFoundError(
216 f"Given mos_script '{mos_script}' does "
217 f"not exist."
218 )
219 if not str(mos_script).endswith(".mos"):
220 raise TypeError(
221 f"Given mos_script '{mos_script}' "
222 f"is not a valid .mos file."
223 )
225 # Convert to modelica path
226 if self.mos_script_pre is not None:
227 self.mos_script_pre = self._make_modelica_normpath(self.mos_script_pre)
228 if self.mos_script_post is not None:
229 self.mos_script_post = self._make_modelica_normpath(self.mos_script_post)
231 super().__init__(working_directory=working_directory,
232 model_name=model_name,
233 n_cpu=kwargs.pop("n_cpu", 1),
234 save_logs=kwargs.pop("save_logs", True))
236 # First import the dymola-interface
237 dymola_path = kwargs.pop("dymola_path", None)
238 if dymola_path is not None:
239 if not os.path.exists(dymola_path):
240 raise FileNotFoundError(f"Given path '{dymola_path}' can not be found on "
241 "your machine.")
242 else:
243 # Get the dymola-install-path:
244 _dym_installations = self.get_dymola_install_paths()
245 if _dym_installations:
246 if self.dymola_version:
247 dymola_path = _get_dymola_path_of_version(
248 dymola_installations=_dym_installations,
249 dymola_version=self.dymola_version
250 )
251 else:
252 dymola_path = _dym_installations[0] # 0 is the newest
253 self.logger.info("Using dymola installation at %s", dymola_path)
254 else:
255 if self.dymola_exe_path is None or self.dymola_interface_path is None:
256 raise FileNotFoundError(
257 "Could not find dymola on your machine. "
258 "Thus, not able to find the `dymola_exe_path` and `dymola_interface_path`. "
259 "Either specify both or pass an existing `dymola_path`."
260 )
261 self.dymola_path = dymola_path
262 if self.dymola_exe_path is None:
263 self.dymola_exe_path = self.get_dymola_exe_path(dymola_path)
264 self.logger.info("Using dymola.exe: %s", self.dymola_exe_path)
265 if self.dymola_interface_path is None:
266 self.dymola_interface_path = self.get_dymola_interface_path(dymola_path)
267 self.logger.info("Using dymola interface: %s", self.dymola_interface_path)
269 self.packages = []
270 if packages is not None:
271 for package in packages:
272 if isinstance(package, Path):
273 self.packages.append(str(package))
274 elif isinstance(package, str):
275 self.packages.append(package)
276 else:
277 raise TypeError(f"Given package is of type {type(package)}"
278 f" but should be any valid path.")
280 # Import n_restart
281 self.sim_counter = 0
282 self.n_restart = kwargs.pop("n_restart", -1)
283 if not isinstance(self.n_restart, int):
284 raise TypeError(f"n_restart has to be type int but "
285 f"is of type {type(self.n_restart)}")
287 self._dummy_dymola_instance = None # Ensure self._close_dummy gets the attribute.
288 if self.n_restart > 0:
289 self.logger.info("Open blank placeholder Dymola instance to ensure"
290 " a licence during Dymola restarts")
291 # Use standard port allocation, should always work
292 self._dummy_dymola_instance = self._open_dymola_interface(port=-1)
293 atexit.register(self._close_dummy)
295 # List storing structural parameters for later modifying the simulation-name.
296 # Parameter for raising a warning if to many dymola-instances are running
297 self._critical_number_instances = 10 + self.n_cpu
298 # Register the function now in case of an error.
299 if not self.debug:
300 atexit.register(self.close)
301 if self.use_mp:
302 ports = _get_n_available_ports(n_ports=self.n_cpu)
303 self.pool.map(
304 self._setup_dymola_interface,
305 [dict(use_mp=True, port=port, time_delay=i * _time_delay_between_starts)
306 for i, port in enumerate(ports)]
307 )
308 # For translation etc. always setup a default dymola instance
309 self.dymola = self._setup_dymola_interface(dict(use_mp=False))
310 if not self.license_is_available():
311 warnings.warn("You have no licence to use Dymola. "
312 "Hence you can only simulate models with 8 or less equations.")
313 # Update experiment setup output
314 self.update_experiment_setup_output(self.experiment_setup_output)
315 self.fully_initialized = True
316 # Trigger on init.
317 if model_name is not None:
318 self._update_model()
319 # Set result_names to output variables.
320 self.result_names = list(self.outputs.keys())
322 # Check if some kwargs are still present. If so, inform the user about
323 # false usage of kwargs:
324 if kwargs:
325 self.logger.error(
326 "You passed the following kwargs which "
327 "are not part of the supported kwargs and "
328 "have thus no effect: %s.", " ,".join(list(kwargs.keys())))
330 def _update_model(self):
331 # Translate the model and extract all variables,
332 # if the user wants to:
333 if self.extract_variables and self.fully_initialized:
334 self.extract_model_variables()
336 def simulate(self,
337 parameters: Union[dict, List[dict]] = None,
338 return_option: str = "time_series",
339 **kwargs):
340 """
341 Simulate the given parameters.
343 Additional settings:
345 :keyword List[str] model_names:
346 List of Dymola model-names to simulate. Should be either the size
347 of parameters or parameters needs to be sized 1.
348 Keep in mind that different models may use different parameters!
349 :keyword Boolean show_eventlog:
350 Default False. True to show evenlog of simulation (advanced)
351 :keyword Boolean squeeze:
352 Default True. If only one set of initialValues is provided,
353 a DataFrame is returned directly instead of a list.
354 :keyword str table_name:
355 If inputs are given, you have to specify the name of the table
356 in the instance of CombiTimeTable. In order for the inputs to
357 work the value should be equal to the value of 'tableName' in Modelica.
358 :keyword str file_name:
359 If inputs are given, you have to specify the file_name of the table
360 in the instance of CombiTimeTable. In order for the inputs to
361 work the value should be equal to the value of 'fileName' in Modelica.
362 :keyword callable postprocess_mat_result:
363 When choosing return_option savepath and no equidistant output, the mat files may take up
364 a lot of disk space while you are only interested in some variables or parts
365 of the simulation results. This features enables you to pass any function which
366 gets the mat-path as an input and returns some result you are interested in.
367 The function signature is `foo(mat_result_file, **kwargs_postprocessing) -> Any`.
368 Be sure to define the function in a global scope to allow multiprocessing.
369 :keyword dict kwargs_postprocessing:
370 Keyword arguments used in the function `postprocess_mat_result`.
371 :keyword List[str] structural_parameters:
372 A list containing all parameter names which are structural in Modelica.
373 This means a modifier has to be created in order to change
374 the value of this parameter. Internally, the given list
375 is added to the known states of the model. Hence, you only have to
376 specify this keyword argument if your structural parameter
377 does not appear in the dsin.txt file created during translation.
379 Example:
380 Changing a record in a model:
382 >>> sim_api.simulate(
383 >>> parameters={"parameterPipe": "AixLib.DataBase.Pipes.PE_X.DIN_16893_SDR11_d160()"},
384 >>> structural_parameters=["parameterPipe"])
386 """
387 # Handle special case for structural_parameters
388 if "structural_parameters" in kwargs:
389 _struc_params = kwargs["structural_parameters"]
390 # Check if input is 2-dimensional for multiprocessing.
391 # If not, make it 2-dimensional to avoid list flattening in
392 # the super method.
393 if not isinstance(_struc_params[0], list):
394 kwargs["structural_parameters"] = [_struc_params]
395 if "model_names" in kwargs:
396 model_names = kwargs["model_names"]
397 if not isinstance(model_names, list):
398 raise TypeError("model_names needs to be a list.")
399 if isinstance(parameters, dict):
400 # Make an array of parameters to enable correct use of super function.
401 parameters = [parameters] * len(model_names)
402 if parameters is None:
403 parameters = [{}] * len(model_names)
404 return super().simulate(parameters=parameters, return_option=return_option, **kwargs)
406 def _single_simulation(self, kwargs):
407 # Unpack kwargs
408 show_eventlog = kwargs.pop("show_eventlog", False)
409 squeeze = kwargs.pop("squeeze", True)
410 result_file_name = kwargs.pop("result_file_name", 'resultFile')
411 if not isinstance(result_file_name, str):
412 raise TypeError(f"result_file_name has to be type str but is of type {type(result_file_name)}")
413 parameters = kwargs.pop("parameters")
414 return_option = kwargs.pop("return_option")
415 model_names = kwargs.pop("model_names", None)
416 inputs = kwargs.pop("inputs", None)
417 fail_on_error = kwargs.pop("fail_on_error", True)
418 structural_parameters = kwargs.pop("structural_parameters", [])
419 table_name = kwargs.pop("table_name", None)
420 file_name = kwargs.pop("file_name", None)
421 savepath = kwargs.pop("savepath", None)
423 def empty_postprocessing(mat_result, **_kwargs):
424 return mat_result
426 postprocess_mat_result = kwargs.pop("postprocess_mat_result", empty_postprocessing)
427 kwargs_postprocessing = kwargs.pop("kwargs_postprocessing", {})
428 if kwargs:
429 self.logger.error(
430 "You passed the following kwargs which "
431 "are not part of the supported kwargs and "
432 "have thus no effect: %s.", " ,".join(list(kwargs.keys())))
434 # Handle multiprocessing
435 if self.use_mp:
436 if self.dymola is None:
437 # This should not affect #119, as this rarely happens. Thus, the
438 # method used in the DymolaInterface should work.
439 self._setup_dymola_interface(dict(use_mp=True))
441 # Re-set the dymola experiment output if API is newly started
442 self.dymola.experimentSetupOutput(**self.experiment_setup_output.model_dump())
444 # Handle eventlog
445 if show_eventlog:
446 if not self.experiment_setup_output.events:
447 raise ValueError("You can't log events and have an "
448 "equidistant output, set equidistant output=False")
449 self.dymola.ExecuteCommand("Advanced.Debug.LogEvents = true")
450 self.dymola.ExecuteCommand("Advanced.Debug.LogEventsInitialization = true")
452 # Restart Dymola after n_restart iterations
453 self._check_restart()
455 # Handle custom model_names
456 if model_names is not None:
457 # Custom model_name setting
458 _res_names = self.result_names.copy()
459 self._model_name = model_names
460 self._update_model_variables()
461 if _res_names != self.result_names:
462 self.logger.info(
463 "Result names changed due to setting the new model. "
464 "If you do not expect custom result names, ignore this warning."
465 "If you do expect them, please raise an issue to add the "
466 "option when using the model_names keyword.")
467 self.logger.info(
468 "Difference: %s",
469 " ,".join(list(set(_res_names).difference(self.result_names)))
470 )
472 if self.model_name is None:
473 raise ValueError(
474 "You neither passed a model_name when "
475 "starting DymolaAPI, nor when calling simulate. "
476 "Can't simulate no model."
477 )
479 # Handle parameters:
480 if parameters is None:
481 parameters = {}
482 unsupported_parameters = False
483 else:
484 unsupported_parameters = self.check_unsupported_variables(
485 variables=list(parameters.keys()),
486 type_of_var="parameters"
487 )
489 # Handle structural parameters
491 if (unsupported_parameters and
492 (self.modify_structural_parameters or
493 structural_parameters)):
494 # Alter the model_name for the next simulation
495 model_name, parameters_new = self._alter_model_name(
496 parameters=parameters,
497 model_name=self.model_name,
498 structural_params=list(self.states.keys()) + structural_parameters
499 )
500 # Trigger translation only if something changed
501 if model_name != self.model_name:
502 _res_names = self.result_names.copy()
503 self.model_name = model_name
504 self.result_names = _res_names # Restore previous result names
505 self.logger.warning(
506 "Warning: Currently, the model is re-translating "
507 "for each simulation. You should add to your Modelica "
508 "parameters \"annotation(Evaluate=false)\".\n "
509 "Check for these parameters: %s",
510 ', '.join(set(parameters.keys()).difference(parameters_new.keys()))
511 )
512 parameters = parameters_new
513 # Check again
514 unsupported_parameters = self.check_unsupported_variables(
515 variables=list(parameters.keys()),
516 type_of_var="parameters"
517 )
519 initial_names = list(parameters.keys())
520 initial_values = list(parameters.values())
521 # Convert to float for Boolean and integer types:
522 try:
523 initial_values = [float(v) for v in initial_values]
524 except (ValueError, TypeError) as err:
525 raise TypeError("Dymola only accepts float values. "
526 "Could bot automatically convert the given "
527 "parameter values to float.") from err
529 # Handle inputs
530 if inputs is not None:
531 # Unpack additional kwargs
532 if table_name is None or file_name is None:
533 raise KeyError("For inputs to be used by DymolaAPI.simulate, you "
534 "have to specify the 'table_name' and the 'file_name' "
535 "as keyword arguments of the function. These must match"
536 "the values 'tableName' and 'fileName' in the CombiTimeTable"
537 " model in your modelica code.") from err
538 # Generate the input in the correct format
539 offset = self.sim_setup.start_time - inputs.index[0]
540 filepath = convert_tsd_to_modelica_txt(
541 tsd=inputs,
542 table_name=table_name,
543 save_path_file=file_name,
544 offset=offset
545 )
546 self.logger.info("Successfully created Dymola input file at %s", filepath)
548 if return_option == "savepath":
549 if unsupported_parameters:
550 raise KeyError("Dymola does not accept invalid parameter "
551 "names for option return_type='savepath'. "
552 "To use this option, delete unsupported "
553 "parameters from your setup.")
554 res = self.dymola.simulateExtendedModel(
555 self.model_name,
556 startTime=self.sim_setup.start_time,
557 stopTime=self.sim_setup.stop_time,
558 numberOfIntervals=0,
559 outputInterval=self.sim_setup.output_interval,
560 method=self.sim_setup.solver,
561 tolerance=self.sim_setup.tolerance,
562 fixedstepsize=self.sim_setup.fixedstepsize,
563 resultFile=result_file_name,
564 initialNames=initial_names,
565 initialValues=initial_values)
566 else:
567 if not parameters and not self.parameters:
568 raise ValueError(
569 "Sadly, simulating a model in Dymola "
570 "with no parameters returns no result. "
571 "Call this function using return_option='savepath' to get the results."
572 )
573 if not parameters:
574 random_name = list(self.parameters.keys())[0]
575 initial_values = [self.parameters[random_name].value]
576 initial_names = [random_name]
578 # Handle 1 and 2 D initial names:
579 # Convert a 1D list to 2D list
580 if initial_values and isinstance(initial_values[0], (float, int)):
581 initial_values = [initial_values]
583 # Handle the time of the simulation:
584 res_names = self.result_names.copy()
585 if "Time" not in res_names:
586 res_names.append("Time")
588 # Internally convert output Interval to number of intervals
589 # (Required by function simulateMultiResultsModel
590 number_of_intervals = (self.sim_setup.stop_time - self.sim_setup.start_time) / \
591 self.sim_setup.output_interval
592 if int(number_of_intervals) != number_of_intervals:
593 raise ValueError(
594 "Given output_interval and time interval did not yield "
595 "an integer numberOfIntervals. To use this functions "
596 "without savepaths, you have to provide either a "
597 "numberOfIntervals or a value for output_interval "
598 "which can be converted to numberOfIntervals.")
600 res = self.dymola.simulateMultiResultsModel(
601 self.model_name,
602 startTime=self.sim_setup.start_time,
603 stopTime=self.sim_setup.stop_time,
604 numberOfIntervals=int(number_of_intervals),
605 method=self.sim_setup.solver,
606 tolerance=self.sim_setup.tolerance,
607 fixedstepsize=self.sim_setup.fixedstepsize,
608 resultFile=None,
609 initialNames=initial_names,
610 initialValues=initial_values,
611 resultNames=res_names)
613 if not res[0]:
614 self.logger.error("Simulation failed!")
615 self.logger.error("The last error log from Dymola:")
616 dslog_path = self._get_worker_directory(use_mp=self.use_mp).joinpath('dslog.txt')
617 try:
618 with open(dslog_path, "r") as dslog_file:
619 dslog_content = dslog_file.read()
620 self.logger.error(dslog_content)
621 except Exception:
622 log = self.dymola.getLastErrorLog()
623 # Only print last part as output is sometimes to verbose and the error is at the bottom
624 self.logger.error(log[-10000:])
625 dslog_content = "Not retreivable. Open it yourself."
626 msg = f"Simulation failed: Reason according " \
627 f"to dslog, located at '{dslog_path}': {dslog_content}"
628 if fail_on_error:
629 raise Exception(msg)
630 # Don't raise and return None
631 self.logger.error(msg)
632 return None
634 if return_option == "savepath":
635 _save_name_dsres = f"{result_file_name}.mat"
636 # Get the working_directory of the current dymola instance
637 self.dymola.cd()
638 # Get the value and convert it to a 100 % fitting str-path
639 dymola_working_directory = Path(self.dymola.getLastErrorLog().replace("\n", ""))
640 if dymola_working_directory != self._get_worker_directory(use_mp=self.use_mp):
641 self.logger.warning(
642 "The working directory set by ebcpy and the one with the result does not match: "
643 "%s (dymola) vs. %s (ebcpy). This will inhibit correct error "
644 "messages upon failed simulations.",
645 dymola_working_directory,
646 self._get_worker_directory(use_mp=self.use_mp)
647 )
649 mat_working_directory = dymola_working_directory.joinpath(_save_name_dsres).as_posix()
650 if savepath is None or str(savepath) == str(dymola_working_directory):
651 mat_result_file = mat_working_directory
652 else:
653 mat_save_path = os.path.join(savepath, _save_name_dsres)
654 os.makedirs(savepath, exist_ok=True)
655 # Copying dslogs and dsfinals can lead to errors,
656 # as the names are not unique
657 # for filename in [_save_name_dsres, "dslog.txt", "dsfinal.txt"]:
658 # Delete existing files
659 try:
660 os.remove(mat_save_path)
661 except OSError:
662 pass
663 # Move files
664 shutil.copy(mat_working_directory, mat_save_path)
665 os.remove(mat_working_directory)
666 mat_result_file = mat_save_path
667 result_file = postprocess_mat_result(mat_result_file, **kwargs_postprocessing)
668 return result_file
670 data = res[1] # Get data
672 # Sometimes, Dymola adds a last row with all 0 values, even the time
673 data_clean = []
674 for ini_val_set in data:
675 if all(res[-1] == 0 for res in ini_val_set):
676 data_clean.append([res[:-1] for res in ini_val_set])
677 else:
678 data_clean.append(ini_val_set)
680 if return_option == "last_point":
681 results = []
682 for ini_val_set in data_clean:
683 results.append({result_name: ini_val_set[idx][-1] for idx, result_name
684 in enumerate(res_names)})
685 if len(results) == 1 and squeeze:
686 return results[0]
687 return results
688 # Else return as dataframe.
689 dfs = []
690 for ini_val_set in data_clean:
691 df = pd.DataFrame({result_name: ini_val_set[idx] for idx, result_name
692 in enumerate(res_names)})
693 # Set time index
694 df = df.set_index("Time")
695 # Convert it to float
696 df.index = df.index.astype("float64")
698 dfs.append(df)
699 # Most of the cases, only one set is provided. In that case, avoid
700 if len(dfs) == 1 and squeeze:
701 return dfs[0]
702 return dfs
704 def translate(self):
705 """
706 Translates the current model using dymola.translateModel()
707 and checks if erros occur.
708 """
709 res = self.dymola.translateModel(self.model_name)
710 if not res:
711 self.logger.error("Translation failed!")
712 self.logger.error("The last error log from Dymola:")
713 self.logger.error(self.dymola.getLastErrorLog())
714 raise Exception("Translation failed - Aborting")
716 def set_compiler(self, name, path, dll=False, dde=False, opc=False):
717 """
718 Set up the compiler and compiler options on Windows.
719 Optional: Specify if you want to enable dll, dde or opc.
721 :param str name:
722 Name of the compiler, avaiable options:
723 - 'vs': Visual Studio
724 - 'gcc': GCC
725 :param str,os.path.normpath path:
726 Path to the compiler files.
727 Example for name='vs': path='C:/Program Files (x86)/Microsoft Visual Studio 10.0/Vc'
728 Example for name='gcc': path='C:/MinGW/bin/gcc'
729 :param Boolean dll:
730 Set option for dll support. Check Dymolas Manual on what this exactly does.
731 :param Boolean dde:
732 Set option for dde support. Check Dymolas Manual on what this exactly does.
733 :param Boolean opc:
734 Set option for opc support. Check Dymolas Manual on what this exactly does.
735 :return: True, on success.
736 """
737 # Lookup dict for internal name of CCompiler-Variable
738 _name_int = {"vs": "MSVC",
739 "gcc": "GCC"}
741 if "win" not in sys.platform:
742 raise OSError(f"set_compiler function only implemented "
743 f"for windows systems, you are using {sys.platform}")
744 # Manually check correct input as Dymola's error are not a help
745 name = name.lower()
746 if name not in ["vs", "gcc"]:
747 raise ValueError(f"Given compiler name {name} not supported.")
748 if not os.path.exists(path):
749 raise FileNotFoundError(f"Given compiler path {path} does not exist on your machine.")
750 # Convert path for correct input
751 path = self._make_modelica_normpath(path)
752 if self.use_mp:
753 raise ValueError("Given function is not yet supported for multiprocessing")
755 res = self.dymola.SetDymolaCompiler(name.lower(),
756 [f"CCompiler={_name_int[name]}",
757 f"{_name_int[name]}DIR={path}",
758 f"DLL={int(dll)}",
759 f"DDE={int(dde)}",
760 f"OPC={int(opc)}"])
762 return res
764 def import_initial(self, filepath):
765 """
766 Load given dsfinal.txt into dymola
768 :param str,os.path.normpath filepath:
769 Path to the dsfinal.txt to be loaded
770 """
771 if not os.path.isfile(filepath):
772 raise FileNotFoundError(f"Given filepath {filepath} does not exist")
773 if not os.path.splitext(filepath)[1] == ".txt":
774 raise TypeError('File is not of type .txt')
775 if self.use_mp:
776 raise ValueError("Given function is not yet supported for multiprocessing")
777 res = self.dymola.importInitial(dsName=filepath)
778 if res:
779 self.logger.info("Successfully loaded dsfinal.txt")
780 else:
781 raise Exception("Could not load dsfinal into Dymola.")
783 @SimulationAPI.working_directory.setter
784 def working_directory(self, working_directory: Union[Path, str]):
785 """Set the working directory to the given path"""
786 if isinstance(working_directory, str):
787 working_directory = Path(working_directory)
788 self._working_directory = working_directory
789 if self.dymola is None: # Not yet started
790 return
791 # Also set the working_directory in the dymola api
792 self.set_dymola_working_directory(dymola=self.dymola,
793 working_directory=working_directory)
794 if self.use_mp:
795 self.logger.warning("Won't set the working_directory for all workers, "
796 "not yet implemented.")
798 def set_dymola_working_directory(self, dymola, working_directory):
799 """
800 Set the working directory of the Dymola Instance.
801 Before calling the Function, create the path and
802 convert to a modelica-normpath.
803 """
804 os.makedirs(working_directory, exist_ok=True)
805 modelica_working_directory = self._make_modelica_normpath(path=working_directory)
806 res = dymola.cd(modelica_working_directory)
807 if not res:
808 raise OSError(f"Could not change working directory to {working_directory}")
810 def close(self):
811 """Closes dymola."""
812 # Close MP of super class
813 super().close()
814 # Always close main instance
815 self._single_close(dymola=self.dymola)
817 def _close_multiprocessing(self, _):
818 self._single_close()
819 DymolaAPI.dymola = None
821 def _single_close(self, **kwargs):
822 """Closes a single dymola instance"""
823 if self.dymola is None:
824 return # Already closed prior
825 # Execute the mos-script if given:
826 if self.mos_script_post is not None:
827 self.logger.info("Executing given mos_script_post "
828 "prior to closing.")
829 self.dymola.RunScript(self.mos_script_post)
830 self.logger.info("Output of mos_script_post: %s", self.dymola.getLastErrorLog())
831 self.logger.info('Closing Dymola')
832 self.dymola.close()
833 self.logger.info('Successfully closed Dymola')
834 self.dymola = None
836 def _close_dummy(self):
837 """
838 Closes dummy instance at the end of the execution
839 """
840 if self._dummy_dymola_instance is not None:
841 self.logger.info('Closing dummy Dymola instance')
842 self._dummy_dymola_instance.close()
843 self.logger.info('Successfully closed dummy Dymola instance')
845 def extract_model_variables(self):
846 """
847 Extract all variables of the model by
848 translating it and then processing the dsin
849 using the manipulate_ds module.
850 """
851 # Translate model
852 self.logger.info("Translating model '%s' to extract model variables ",
853 self.model_name)
854 self.translate()
855 # Get dsin:
856 df = manipulate_ds.convert_ds_file_to_dataframe(
857 self._get_worker_directory(use_mp=self.use_mp).joinpath("dsin.txt")
858 )
859 # Convert and return all parameters of dsin to initial values and names
860 for idx, row in df.iterrows():
861 _max = float(row["4"])
862 _min = float(row["3"])
863 if _min >= _max:
864 _var_ebcpy = Variable(value=float(row["2"]))
865 else:
866 _var_ebcpy = Variable(
867 min=_min,
868 max=_max,
869 value=float(row["2"])
870 )
871 if row["5"] == "1":
872 self.parameters[idx] = _var_ebcpy
873 elif row["5"] == "5":
874 self.inputs[idx] = _var_ebcpy
875 elif row["5"] == "4":
876 self.outputs[idx] = _var_ebcpy
877 else:
878 self.states[idx] = _var_ebcpy
880 def _setup_dymola_interface(self, kwargs: dict):
881 """Load all packages and change the current working directory"""
882 use_mp = kwargs["use_mp"]
883 port = kwargs.get("port", -1)
884 time_delay = kwargs.get("time_delay", 0)
885 time.sleep(time_delay)
886 dymola = self._open_dymola_interface(port=port)
887 self._check_dymola_instances()
889 # Execute the mos-script if given:
890 if self.mos_script_pre is not None:
891 self.logger.info("Executing given mos_script_pre "
892 "prior to loading packages.")
893 dymola.RunScript(self.mos_script_pre)
894 self.logger.info("Output of mos_script_pre: %s", dymola.getLastErrorLog())
896 # Set the cd in the dymola api
897 self.set_dymola_working_directory(dymola=dymola, working_directory=self._get_worker_directory(use_mp))
899 for package in self.packages:
900 self.logger.info("Loading Model %s", os.path.dirname(package).split("\\")[-1])
901 res = dymola.openModel(package, changeDirectory=False)
902 if not res:
903 raise ImportError(dymola.getLastErrorLog())
904 self.logger.info("Loaded modules")
906 dymola.experimentSetupOutput(**self.experiment_setup_output.dict())
907 if use_mp:
908 DymolaAPI.dymola = dymola
909 return None
910 return dymola
912 def update_experiment_setup_output(self, experiment_setup_output: Union[ExperimentSetupOutput, dict]):
913 """
914 Function to update the ExperimentSetupOutput in Dymola for selection
915 of which variables are going to be saved. The options
916 `events` and `equidistant` are overridden if equidistant output is required.
918 :param (ExperimentSetupOutput, dict) experiment_setup_output:
919 An instance of ExperimentSetupOutput or a dict with valid keys for it.
920 """
921 if isinstance(experiment_setup_output, dict):
922 self.experiment_setup_output = ExperimentSetupOutput(**experiment_setup_output)
923 else:
924 self.experiment_setup_output = experiment_setup_output
925 if self.equidistant_output:
926 # Change the Simulation Output, to ensure all
927 # simulation results have the same array shape.
928 # Events can also cause errors in the shape.
929 self.experiment_setup_output.equidistant = True
930 self.experiment_setup_output.events = False
931 if self.dymola is None:
932 return
933 self.dymola.experimentSetupOutput(**self.experiment_setup_output.model_dump())
935 def license_is_available(self, option: str = "Standard"):
936 """Check if license is available"""
937 if self.dymola is None:
938 warnings.warn("You want to check the license before starting dymola, this is not supported.")
939 return False
940 return self.dymola.RequestOption(option)
942 def _open_dymola_interface(self, port):
943 """Open an instance of dymola and return the API-Object"""
944 if self.dymola_interface_path not in sys.path:
945 sys.path.insert(0, self.dymola_interface_path)
946 try:
947 from dymola.dymola_interface import DymolaInterface
948 from dymola.dymola_exception import DymolaConnectionException
949 return DymolaInterface(showwindow=self.show_window,
950 dymolapath=self.dymola_exe_path,
951 port=port)
952 except ImportError as error:
953 raise ImportError("Given dymola-interface could not be "
954 "loaded:\n %s" % self.dymola_interface_path) from error
955 except DymolaConnectionException as error:
956 raise ConnectionError(error) from error
958 def to_dict(self):
959 """
960 Store the most relevant information of this class
961 into a dictionary. This may be used for future configuration.
963 :return: dict config:
964 Dictionary with keys to re-init this class.
965 """
966 # Convert Path to str to enable json-dumping
967 config = {"working_directory": str(self.working_directory),
968 "packages": [str(pack) for pack in self.packages],
969 "model_name": self.model_name,
970 "type": "DymolaAPI",
971 }
972 # Update kwargs
973 config.update({kwarg: self.__dict__.get(kwarg, None)
974 for kwarg in self._supported_kwargs})
976 return config
978 def get_packages(self):
979 """
980 Get the currently loaded packages of Dymola
981 """
982 packages = self.dymola.ExecuteCommand(
983 'ModelManagement.Structure.AST.Misc.ClassesInPackage("")'
984 )
985 if packages is None:
986 self.logger.error("Could not load packages from Dymola, using self.packages")
987 packages = []
988 for pack in self.packages:
989 pack = Path(pack)
990 if pack.name == "package.mo":
991 packages.append(pack.parent.name)
992 valid_packages = []
993 for pack in packages:
994 current_package = f"modelica://{pack}/package.order"
995 pack_path = self.dymola.ExecuteCommand(
996 f'Modelica.Utilities.Files.loadResource("{current_package}")'
997 )
998 if not isinstance(pack_path, str):
999 self.logger.error("Could not load model resource for package %s", pack)
1000 if os.path.isfile(pack_path):
1001 valid_packages.append(Path(pack_path).parent)
1002 return valid_packages
1004 def save_for_reproduction(
1005 self,
1006 title: str,
1007 path: Path = None,
1008 files: list = None,
1009 save_total_model: bool = True,
1010 export_fmu: bool = True,
1011 **kwargs
1012 ):
1013 """
1014 Additionally to the basic reproduction, add info
1015 for Dymola packages.
1017 Content which is saved:
1018 - DymolaAPI configuration
1019 - Information on Dymola: Version, flags
1020 - All loaded packages
1021 - Total model, if save_total_model = True
1022 - FMU, if export_fmu = True
1024 :param bool save_total_model:
1025 True to save the total model
1026 :param bool export_fmu:
1027 True to export the FMU of the current model.
1028 """
1029 # Local import to require git-package only when called
1030 from ebcpy.utils.reproduction import ReproductionFile, CopyFile, get_git_information
1032 if files is None:
1033 files = []
1034 # DymolaAPI Info:
1035 files.append(ReproductionFile(
1036 filename="Dymola/DymolaAPI_config.json",
1037 content=json.dumps(self.to_dict(), indent=2)
1038 ))
1039 # Dymola info:
1040 self.dymola.ExecuteCommand("list();")
1041 _flags = self.dymola.getLastErrorLog()
1042 dymola_info = [
1043 self.dymola.ExecuteCommand("DymolaVersion()"),
1044 str(self.dymola.ExecuteCommand("DymolaVersionNumber()")),
1045 "\n\n"
1046 ]
1047 files.append(ReproductionFile(
1048 filename="Dymola/DymolaInfo.txt",
1049 content="\n".join(dymola_info) + _flags
1050 ))
1052 # Packages
1053 packages = self.get_packages()
1054 package_infos = []
1055 for pack_path in packages:
1057 for pack_dir_parent in [pack_path] + list(pack_path.parents):
1058 repo_info = get_git_information(
1059 path=pack_dir_parent,
1060 zip_folder_path="Dymola"
1061 )
1062 if not repo_info:
1063 continue
1065 files.extend(repo_info.pop("difference_files"))
1066 pack_path = str(pack_path) + "; " + "; ".join([f"{key}: {value}" for key, value in repo_info.items()])
1067 break
1068 package_infos.append(str(pack_path))
1069 files.append(ReproductionFile(
1070 filename="Dymola/Modelica_packages.txt",
1071 content="\n".join(package_infos)
1072 ))
1073 # Total model
1074 if save_total_model and self.model_name is not None:
1075 # split ( catches model_names with modifiers. Dots are replaced as they indicate a file suffix.
1076 _total_model_name = f"Dymola/{self.model_name.split('(')[0].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 ))
1120 return super().save_for_reproduction(
1121 title=title,
1122 path=path,
1123 files=files,
1124 **kwargs
1125 )
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
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.
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)
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
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
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.")
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.
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.")
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 )
1230 return dym_file
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.
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 """
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.")
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
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)
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.
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
1345 def _check_restart(self):
1346 """Restart Dymola every n_restart iterations in order to free memory"""
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
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 )
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.
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.
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.
1389 Returns:
1390 - list of int:
1391 A list containing the available ports.
1392 The length of the list is equal to 'n_ports'.
1394 Raises:
1395 - ConnectionError:
1396 If the required number of open ports cannot
1397 be found within the specified range.
1399 Example:
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 )