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
« 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."""
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
18import pandas as pd
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
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 )
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"]
45class DymolaAPI(SimulationAPI):
46 """
47 API to a Dymola instance.
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.
92 **Example:** If you have two versions installed at
94 - ``C://Program Files//Dymola 2021`` and
95 - ``C://Program Files//Dymola 2020x``
97 and you want to use Dymola 2020x, specify
98 ``dymola_version='Dymola 2020x'``.
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.
122 Example:
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()
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 ]
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 )
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)
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))
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)
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.")
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)}")
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)
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.")
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())
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())))
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()
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.
309 Additional settings:
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.
336 Example:
337 Changing a record in a model:
339 >>> sim_api.simulate(
340 >>> parameters={"parameterPipe": "AixLib.DataBase.Pipes.PE_X.DIN_16893_SDR11_d160()"},
341 >>> structural_parameters=["parameterPipe"])
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)
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())))
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))
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")
397 # Restart Dymola after n_restart iterations
398 self._check_restart()
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 )
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 )
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 )
434 # Handle structural parameters
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 )
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
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)
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]
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]
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")
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.")
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)
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
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)
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]
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")
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.
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"}
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")
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)}"])
685 return res
687 def import_initial(self, filepath):
688 """
689 Load given dsfinal.txt into dymola
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.")
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.")
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
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}")
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)
745 def _close_multiprocessing(self, _):
746 self._single_close()
747 DymolaAPI.dymola = None
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
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')
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
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())
826 # Set the cd in the dymola api
827 self.set_dymola_cd(dymola=dymola, cd=cd)
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
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)
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
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.
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})
887 return config
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
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.
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
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
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 ))
963 # Packages
964 packages = self.get_packages()
965 package_infos = []
966 for pack_path in packages:
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
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 ))
1030 return super().save_for_reproduction(
1031 title=title,
1032 path=path,
1033 files=files,
1034 **kwargs
1035 )
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
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.
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)
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
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
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.")
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.
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.")
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 )
1140 return dym_file
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.
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 """
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.")
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
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)
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.
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
1255 def _check_restart(self):
1256 """Restart Dymola every n_restart iterations in order to free memory"""
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
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 )
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.
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.
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.
1299 Returns:
1300 - list of int:
1301 A list containing the available ports.
1302 The length of the list is equal to 'n_ports'.
1304 Raises:
1305 - ConnectionError:
1306 If the required number of open ports cannot
1307 be found within the specified range.
1309 Example:
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 )