Skip to content

Commit cd95599

Browse files
authored
Merge pull request #99 from RWTH-EBC/98-add-save-options-for-dymolaapi [PYPI-RELEASE]
feat: Add experiment setup output option
2 parents bf5cc43 + 5e1735f commit cd95599

File tree

7 files changed

+208
-37
lines changed

7 files changed

+208
-37
lines changed

.gitlab-ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ variables:
1212
PYTHON_VERSION: "registry.git.rwth-aachen.de/ebc/ebc_all/gitlab_ci/templates:python_3.9"
1313
TEST_ENGINE: "unittest"
1414
GIT_REPO: "RWTH-EBC/ebcpy"
15+
EXCLUDE_PYTHON: 37
1516

1617
include:
1718
- project: 'EBC/EBC_all/gitlab_ci/templates'

CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,4 +112,7 @@
112112
- Added bayesian-optimization to the possible optimizers #129
113113
- v0.5.1
114114
- Add save-logs option for SimulatioAPI #141
115-
- Minor bug fixes #143
115+
- Minor bug fixes #143
116+
- v0.5.2
117+
- Add save options in Dymola #98
118+
- Add feature to postprocess mat results within the simulate function to avoid memory errors in large studies

ebcpy/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,4 @@
88
from .optimization import Optimizer
99

1010

11-
__version__ = '0.5.1'
11+
__version__ = '0.5.2'

ebcpy/simulationapi/__init__.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -393,7 +393,7 @@ def _remaining_time(self, t1):
393393
Start time after n_cpu simulations.
394394
"""
395395
t_remaining = (time.time() - t1) / (self._n_sim_counter - self.n_cpu) * (
396-
self._n_sim_total - self._n_sim_counter)
396+
self._n_sim_total - self._n_sim_counter)
397397
p_finished = self._n_sim_counter / self._n_sim_total * 100
398398
sys.stderr.write(f"\rFinished {np.round(p_finished, 1)} %. "
399399
f"Approximately remaining time: {timedelta(seconds=int(t_remaining))} ")
@@ -414,6 +414,13 @@ def convert_bytes(size):
414414
size = size / 1024.0
415415
return f'{str(np.round(size, 2))} {suffixes[suffix_idx]}'
416416

417+
if not isinstance(filepath, (Path, str)) or not os.path.exists(filepath):
418+
self.logger.info(
419+
"Can't check disk usage as you probably used postprocessing on simulation "
420+
"results but did not return a file-path in the post-processing function"
421+
)
422+
return
423+
417424
sim_file_size = os.stat(filepath).st_size
418425
sim_files_size = sim_file_size * self._n_sim_total
419426
self.logger.info(f"Simulations files need approximately {convert_bytes(sim_files_size)} of disk space")
@@ -444,7 +451,7 @@ def set_sim_setup(self, sim_setup):
444451
"""
445452
Replaced in v0.1.7 by property function
446453
"""
447-
new_setup = self._sim_setup.dict()
454+
new_setup = self._sim_setup.model_dump()
448455
new_setup.update(sim_setup)
449456
self._sim_setup = self._sim_setup_class(**new_setup)
450457

ebcpy/simulationapi/dymola_api.py

Lines changed: 95 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from contextlib import closing
1515
from typing import Union, List
1616

17-
from pydantic import Field
17+
from pydantic import Field, BaseModel
1818
import pandas as pd
1919

2020
from ebcpy import TimeSeriesData
@@ -42,6 +42,27 @@ class DymolaSimulationSetup(SimulationSetup):
4242
"Radau", "Dopri45", "Dopri853", "Sdirk34hw"]
4343

4444

45+
class ExperimentSetupOutput(BaseModel):
46+
"""
47+
Experiment setup output data model with
48+
defaults equal to those in Dymola
49+
"""
50+
states: bool = True
51+
derivatives: bool = True
52+
inputs: bool = True
53+
outputs: bool = True
54+
auxiliaries: bool = True
55+
equidistant: bool = False
56+
events: bool = True
57+
58+
class Config:
59+
"""
60+
Pydantic internal model settings
61+
"""
62+
# pylint: disable=too-few-public-methods
63+
extra = "forbid"
64+
65+
4566
class DymolaAPI(SimulationAPI):
4667
"""
4768
API to a Dymola instance.
@@ -63,6 +84,15 @@ class DymolaAPI(SimulationAPI):
6384
:keyword Boolean equidistant_output:
6485
If True (Default), Dymola stores variables in an
6586
equisdistant output and does not store variables at events.
87+
:keyword dict[str,bool] variables_to_save:
88+
A dictionary to select which variables are going
89+
to be stored if the simulation creates .mat files.
90+
Options (with the default being all True):
91+
- states=True
92+
- derivatives=True
93+
- inputs=True
94+
- outputs=True
95+
- auxiliaries=False
6696
:keyword int n_restart:
6797
Number of iterations after which Dymola should restart.
6898
This is done to free memory. Default value -1. For values
@@ -144,6 +174,7 @@ class DymolaAPI(SimulationAPI):
144174
"modify_structural_parameters",
145175
"dymola_path",
146176
"equidistant_output",
177+
"variables_to_save",
147178
"n_restart",
148179
"debug",
149180
"mos_script_pre",
@@ -170,6 +201,9 @@ def __init__(
170201
self.show_window = kwargs.pop("show_window", False)
171202
self.modify_structural_parameters = kwargs.pop("modify_structural_parameters", True)
172203
self.equidistant_output = kwargs.pop("equidistant_output", True)
204+
_variables_to_save = kwargs.pop("variables_to_save", {})
205+
self.experiment_setup_output = ExperimentSetupOutput(**_variables_to_save)
206+
173207
self.mos_script_pre = kwargs.pop("mos_script_pre", None)
174208
self.mos_script_post = kwargs.pop("mos_script_post", None)
175209
self.dymola_version = kwargs.pop("dymola_version", None)
@@ -277,7 +311,8 @@ def __init__(
277311
if not self.license_is_available():
278312
warnings.warn("You have no licence to use Dymola. "
279313
"Hence you can only simulate models with 8 or less equations.")
280-
314+
# Update experiment setup output
315+
self.update_experiment_setup_output(self.experiment_setup_output)
281316
self.fully_initialized = True
282317
# Trigger on init.
283318
if model_name is not None:
@@ -325,6 +360,15 @@ def simulate(self,
325360
If inputs are given, you have to specify the file_name of the table
326361
in the instance of CombiTimeTable. In order for the inputs to
327362
work the value should be equal to the value of 'fileName' in Modelica.
363+
:keyword callable postprocess_mat_result:
364+
When choosing return_option savepath and no equidistant output, the mat files may take up
365+
a lot of disk space while you are only interested in some variables or parts
366+
of the simulation results. This features enables you to pass any function which
367+
gets the mat-path as an input and returns some result you are interested in.
368+
The function signature is `foo(mat_result_file, **kwargs_postprocessing) -> Any`.
369+
Be sure to define the function in a global scope to allow multiprocessing.
370+
:keyword dict kwargs_postprocessing:
371+
Keyword arguments used in the function `postprocess_mat_result`.
328372
:keyword List[str] structural_parameters:
329373
A list containing all parameter names which are structural in Modelica.
330374
This means a modifier has to be created in order to change
@@ -374,6 +418,12 @@ def _single_simulation(self, kwargs):
374418
table_name = kwargs.pop("table_name", None)
375419
file_name = kwargs.pop("file_name", None)
376420
savepath = kwargs.pop("savepath", None)
421+
422+
def empty_postprocessing(mat_result, **_kwargs):
423+
return mat_result
424+
425+
postprocess_mat_result = kwargs.pop("postprocess_mat_result", empty_postprocessing)
426+
kwargs_postprocessing = kwargs.pop("kwargs_postprocessing", {})
377427
if kwargs:
378428
self.logger.error(
379429
"You passed the following kwargs which "
@@ -388,9 +438,14 @@ def _single_simulation(self, kwargs):
388438
# method used in the DymolaInterface should work.
389439
self._setup_dymola_interface(dict(use_mp=True))
390440

441+
# Re-set the dymola experiment output if API is newly started
442+
self.dymola.experimentSetupOutput(**self.experiment_setup_output.model_dump())
443+
391444
# Handle eventlog
392445
if show_eventlog:
393-
self.dymola.experimentSetupOutput(events=True)
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")
394449
self.dymola.ExecuteCommand("Advanced.Debug.LogEvents = true")
395450
self.dymola.ExecuteCommand("Advanced.Debug.LogEventsInitialization = true")
396451

@@ -582,23 +637,26 @@ def _single_simulation(self, kwargs):
582637
self.dymola.cd()
583638
# Get the value and convert it to a 100 % fitting str-path
584639
dymola_working_directory = str(Path(self.dymola.getLastErrorLog().replace("\n", "")))
640+
mat_working_directory = os.path.join(dymola_working_directory, _save_name_dsres)
585641
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]:
642+
mat_result_file = mat_working_directory
643+
else:
644+
mat_save_path = os.path.join(savepath, _save_name_dsres)
645+
os.makedirs(savepath, exist_ok=True)
589646
# Copying dslogs and dsfinals can lead to errors,
590647
# as the names are not unique
591648
# for filename in [_save_name_dsres, "dslog.txt", "dsfinal.txt"]:
592649
# Delete existing files
593650
try:
594-
os.remove(os.path.join(savepath, filename))
651+
os.remove(mat_save_path)
595652
except OSError:
596653
pass
597654
# 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)
655+
shutil.copy(mat_working_directory, mat_save_path)
656+
os.remove(mat_working_directory)
657+
mat_result_file = mat_save_path
658+
result_file = postprocess_mat_result(mat_result_file, **kwargs_postprocessing)
659+
return result_file
602660

603661
data = res[1] # Get data
604662
if return_option == "last_point":
@@ -832,17 +890,36 @@ def _setup_dymola_interface(self, kwargs: dict):
832890
if not res:
833891
raise ImportError(dymola.getLastErrorLog())
834892
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)
893+
894+
dymola.experimentSetupOutput(**self.experiment_setup_output.dict())
841895
if use_mp:
842896
DymolaAPI.dymola = dymola
843897
return None
844898
return dymola
845899

900+
def update_experiment_setup_output(self, experiment_setup_output: Union[ExperimentSetupOutput, dict]):
901+
"""
902+
Function to update the ExperimentSetupOutput in Dymola for selection
903+
of which variables are going to be saved. The options
904+
`events` and `equidistant` are overridden if equidistant output is required.
905+
906+
:param (ExperimentSetupOutput, dict) experiment_setup_output:
907+
An instance of ExperimentSetupOutput or a dict with valid keys for it.
908+
"""
909+
if isinstance(experiment_setup_output, dict):
910+
self.experiment_setup_output = ExperimentSetupOutput(**experiment_setup_output)
911+
else:
912+
self.experiment_setup_output = experiment_setup_output
913+
if self.equidistant_output:
914+
# Change the Simulation Output, to ensure all
915+
# simulation results have the same array shape.
916+
# Events can also cause errors in the shape.
917+
self.experiment_setup_output.equidistant = True
918+
self.experiment_setup_output.events = False
919+
if self.dymola is None:
920+
return
921+
self.dymola.experimentSetupOutput(**self.experiment_setup_output.model_dump())
922+
846923
def license_is_available(self, option: str = "Standard"):
847924
"""Check if license is available"""
848925
if self.dymola is None:
@@ -1215,7 +1292,7 @@ def _check_dymola_instances(self):
12151292
try:
12161293
if "Dymola" in proc.name():
12171294
counter += 1
1218-
except psutil.AccessDenied:
1295+
except (psutil.AccessDenied, psutil.NoSuchProcess):
12191296
continue
12201297
if counter >= self._critical_number_instances:
12211298
warnings.warn("There are currently %s Dymola-Instances "

ebcpy/utils/reproduction.py

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@
99
import platform
1010
import os
1111
import logging
12-
from typing import List, Union
12+
from typing import List, Tuple, Union
1313
import zipfile
1414
from datetime import datetime
1515
from dataclasses import dataclass
16+
from importlib.metadata import distributions
1617

1718
logger = logging.getLogger(__name__)
1819

@@ -322,44 +323,58 @@ def _get_general_information(title: str, log_message: str, current_time: str):
322323
return "\n".join(_content_lines)
323324

324325

326+
def get_installed_packages() -> List[dict]:
327+
"""
328+
Returns a list of tuples containing (package_name, version, location)
329+
for all installed Python packages.
330+
"""
331+
packages = []
332+
for dist in distributions():
333+
packages.append(dict(
334+
name=dist.metadata['Name'],
335+
version=dist.version,
336+
location=os.path.normpath(str(dist.locate_file('')))
337+
))
338+
return packages
339+
340+
325341
def _get_python_package_information(search_on_pypi: bool):
326342
"""
327343
Function to get the content of python packages installed
328344
as a requirement.txt format content.
329345
"""
330-
import pkg_resources
331-
installed_packages = [pack for pack in pkg_resources.working_set]
346+
installed_packages = get_installed_packages()
332347
diff_paths = []
333348
requirement_txt_content = []
334349
pip_version = ""
335350
for package in installed_packages:
336351
repo_info = get_git_information(
337-
path=package.location,
338-
name=package.key,
352+
path=package["location"],
353+
name=package["name"],
339354
zip_folder_path="python"
340355
)
341356
if repo_info is None:
342357
# Check if in python path:
343-
if package.key == "pip": # exclude pip in requirements and give info to _get_python_reproduction
344-
pip_version = f"=={package.version}"
358+
if package["name"] == "pip": # exclude pip in requirements and give info to _get_python_reproduction
359+
pip_version = f'=={package["version"]}'
345360
else:
346361
requirement_txt_content.append(
347-
f"{package.key}=={package.version}"
362+
f'{package["name"]}=={package["version"]}'
348363
)
349364
if search_on_pypi:
350365
from pypisearch.search import Search
351-
res = Search(package.key).result
366+
res = Search(package["name"]).result
352367
if not res:
353368
raise ModuleNotFoundError(
354369
"Package '%s' is neither a git "
355370
"repo nor a package on pypi. "
356371
"Won't be able to reproduce it!",
357-
package.key
372+
package["name"]
358373
)
359374
else:
360375
cmt_sha = repo_info["commit"]
361376
requirement_txt_content.append(
362-
f"git+{repo_info['url']}.git@{cmt_sha}#egg={package.key}"
377+
f"git+{repo_info['url']}.git@{cmt_sha}#egg={package['name']}"
363378
)
364379
diff_paths.extend(repo_info["difference_files"])
365380
return "\n".join(requirement_txt_content), diff_paths, pip_version

0 commit comments

Comments
 (0)