From 43977dfa866cb244cff7a666c5795a7836270f00 Mon Sep 17 00:00:00 2001 From: Nicholas Long <1907354+nllong@users.noreply.github.com> Date: Thu, 17 Aug 2023 21:24:55 -0600 Subject: [PATCH] Add Dymola runner (#577) * use filNam for teaser loads * fix types * add modelica project class with save_as method * fix types * add method to run modelica models in dymola * rename cspell dictionary * move modelica tests to class * Update tests/base_test_case.py Co-authored-by: Nathan Moore * skip tmp folder in ModelicaProject * fix windows-based test * add get_model method to modelica project * update docstring * relative path option * add relative path setting for system parameter generation * precommit * do not dymola in tests * Update geojson_modelica_translator/modelica/modelica_project.py Co-authored-by: Nathan Moore * Update geojson_modelica_translator/modelica/modelica_project.py Co-authored-by: Nathan Moore * Update geojson_modelica_translator/modelica/modelica_project.py Co-authored-by: Nathan Moore * Update geojson_modelica_translator/system_parameters/system_parameters.py Co-authored-by: Nathan Moore * pre-commit pep8 fix --------- Co-authored-by: Nathan Moore --- .cspell.json | 30 +++++ .cspell/custom-dictionary-workspace.txt | 12 -- .github/workflows/ci.yml | 4 +- README.rst | 4 +- .../GMT_Lib/DHC/DHC_5G_waste_heat_GHX.py | 3 +- .../modelica/modelica_project.py | 60 +++++++++- .../modelica/modelica_runner.py | 96 +++++++++++++++- .../modelica/package_parser.py | 3 + .../system_parameters/system_parameters.py | 42 ++++--- poetry.lock | 1 + pyproject.toml | 3 +- tests/GMT_Lib/test_gmt_lib_des.py | 99 +++++++++++------ tests/base_test_case.py | 21 ++++ tests/model_connectors/test_time_series_5g.py | 11 ++ tests/modelica/test_csv_modelica.py | 2 +- tests/modelica/test_modelica_project.py | 25 ++++- tests/modelica/test_modelica_runner.py | 105 +++++++++++++----- 17 files changed, 416 insertions(+), 105 deletions(-) create mode 100644 .cspell.json delete mode 100644 .cspell/custom-dictionary-workspace.txt diff --git a/.cspell.json b/.cspell.json new file mode 100644 index 000000000..4ddfd4188 --- /dev/null +++ b/.cspell.json @@ -0,0 +1,30 @@ +{ + "version": "0.2", + "language": "en", + "words": [ + "autoload", + "buildingspy", + "Combi", + "cvrmsd", + "dassl", + "dymola", + "GDHC", + "levelname", + "libfortran", + "linecount", + "mfrt", + "MODELICAPATH", + "mofile", + "openstudio", + "oversizing", + "redeclarations", + "Reparse", + "setpoint", + "timeseries", + "timestep", + "urbanopt" + ], + "flagWords": [ + "hte" + ] +} diff --git a/.cspell/custom-dictionary-workspace.txt b/.cspell/custom-dictionary-workspace.txt deleted file mode 100644 index 19f0ff24c..000000000 --- a/.cspell/custom-dictionary-workspace.txt +++ /dev/null @@ -1,12 +0,0 @@ -# Custom Dictionary Words -autoload -Combi -Dymola -linecount -mfrt -mofile -Reparse -setpoint -timeseries -timestep -urbanopt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e3e93ef5..559629172 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,9 +86,9 @@ jobs: run: | if [ '${{ matrix.test_env }}' == 'python' ]; then if [ '${{ matrix.os }}' == 'windows-latest' ]; then - poetry run pytest --doctest-modules -v -m 'not simulation and not compilation' ./tests + poetry run pytest --doctest-modules -v -m 'not simulation and not compilation and not dymola' ./tests else - poetry run pytest --doctest-modules -v --cov-report term-missing --cov . ./tests + poetry run pytest --doctest-modules -v -m 'not dymola' --cov-report term-missing --cov . ./tests fi fi - name: Run pre-commit diff --git a/README.rst b/README.rst index 7db8e5f16..4a1b6ff94 100644 --- a/README.rst +++ b/README.rst @@ -22,7 +22,7 @@ The project is motivated by the need to easily evaluate district energy systems. Getting Started --------------- -It is possible to test the GeoJSON to Modelica Translator (GMT) by simpling installing the Python package and running the +It is possible to test the GeoJSON to Modelica Translator (GMT) by simply installing the Python package and running the command line interface (CLI) with results from and URBANopt SDK set of results. However, to fully leverage the functionality of this package (e.g., running simulations), then you must also install the Modelica Buildings library (MBL) and Docker. Instructions for installing and configuring the MBL and Docker are available @@ -55,7 +55,7 @@ More example projects are available in an accompanying Architecture Overview --------------------- -The GMT is designed to enable "easy" swapping of building loads, district systems, and newtork topologies. Some +The GMT is designed to enable "easy" swapping of building loads, district systems, and network topologies. Some of these functionalities are more developed than others, for instance swapping building loads between Spawn and RC models (using TEASER) is fleshed out; however, swapping between a first and fifth generation heating system has yet to be fully implemented. diff --git a/geojson_modelica_translator/modelica/GMT_Lib/DHC/DHC_5G_waste_heat_GHX.py b/geojson_modelica_translator/modelica/GMT_Lib/DHC/DHC_5G_waste_heat_GHX.py index a8d9a9964..38a2d325c 100644 --- a/geojson_modelica_translator/modelica/GMT_Lib/DHC/DHC_5G_waste_heat_GHX.py +++ b/geojson_modelica_translator/modelica/GMT_Lib/DHC/DHC_5G_waste_heat_GHX.py @@ -43,7 +43,6 @@ def build_from_template(self, output_dir: Path, project_name: str) -> None: # 1: grab all of the time series files and place them in the proper location for building in self.system_parameters.get_param("$.buildings[?load_model=time_series]"): building_load_file = Path(building['load_model_parameters']['time_series']['filepath']) - files_to_copy.append({ "orig_file": building_load_file, "geojson_id": building['geojson_id'], @@ -52,7 +51,7 @@ def build_from_template(self, output_dir: Path, project_name: str) -> None: }) # 2: Copy the files to the appropriate location and ensure uniqueness by putting into a unique directory - # (since openstudio creates all files with modelica.mos) + # (since OpenStudio creates all files with modelica.mos) total_heating_load = 0 total_cooling_load = 0 total_swh_load = 0 diff --git a/geojson_modelica_translator/modelica/modelica_project.py b/geojson_modelica_translator/modelica/modelica_project.py index f4b916654..33d2eb079 100644 --- a/geojson_modelica_translator/modelica/modelica_project.py +++ b/geojson_modelica_translator/modelica/modelica_project.py @@ -4,6 +4,7 @@ import os import time from pathlib import Path +from typing import Union from modelica_builder.model import Model @@ -81,6 +82,7 @@ class ModelicaProject: def __init__(self, package_file): self.root_directory = Path(package_file).parent self.file_types = ['.mo', '.txt', '.mos', '.order'] + self.file_types_to_skip = ['.log', '.mat'] self.file_data = {} self._load_data() @@ -89,13 +91,24 @@ def _load_data(self) -> None: """method to load all of the files into a data structure for processing""" # walk the tree and add in all the files for file_path in self.root_directory.rglob('*'): + if file_path.suffix in self.file_types_to_skip and file_path.is_file(): + # skip files that have the file_types_to_skip suffix + continue + if file_path.suffix in self.file_types and file_path.is_file(): # only store the relative path that is in the package rel_path = file_path.relative_to(self.root_directory) self.file_data[str(rel_path)] = ModelicaFileObject(file_path) elif file_path.is_dir(): - # this is a directory, just add in - # a temp object for now to keep the path known + # this is a directory, add in an empty ModelicaFileObject + # to keep track of the directory. + # + # however, we ignore if there is a tmp directory or the parent dir is + # tmp. Maybe we need to support more than 2 levels here. + if 'tmp' in file_path.parts: + _log.warning(f"Found a tmp directory, skipping {file_path}") + continue + rel_path = file_path.relative_to(self.root_directory) self.file_data[str(rel_path)] = ModelicaFileObject(file_path) else: @@ -108,8 +121,6 @@ def _load_data(self) -> None: if self.file_data.get('package.mo', None) is None: raise Exception('ModelicaPackage does not contain a /package.mo file') - self.pretty_print_tree() - def pretty_print_tree(self) -> None: """Pretty print all the items in the directory structure """ @@ -120,6 +131,42 @@ def pretty_print_tree(self) -> None: indent = key.count(os.path.sep) print(" " * indent + f"{os.path.sep} {key.replace(os.path.sep, f' {os.path.sep} ')}") + def get_model(self, model_name: Union[Path, str]) -> Model: + """Return the model object based on the based string name. The model + name should be in the format that Modelica prefers which is period(.) + delimited. + + Args: + model_name (str): Name of the model to return, in the form of . delimited + + Raises: + Exception: Various exceptions if the model is not found or the file type is incorrect + + Returns: + Model: The Modelica Builder model object + """ + # check if the last 3 characters are .mo. The path should originally be + # a period delimited path. + model_name = str(model_name) + if model_name.endswith('.mo'): + raise Exception(f"Model name should not have the .mo extension: {model_name} ") + + # convert the model_name to the path format + model_name = Path(model_name.replace('.', os.path.sep)) + + # now add on the extension + model_name = model_name.with_suffix('.mo') + + if self.file_data.get(str(model_name)) is None: + raise Exception(f"ModelicaPackage does not contain a {model_name} model") + else: + # verify that the type of file is model + model = self.file_data[str(model_name)] + if model.file_type != ModelicaFileObject.FILE_TYPE_MODEL: + raise Exception(f"Model is a package file, not a model: {model_name}") + + return self.file_data[str(model_name)].object + def save_as(self, new_package_name: str, output_dir: Path = None) -> None: """method to save the ModelicaProject to a new location which requires a new path name and updating all of the "within" statements @@ -198,5 +245,8 @@ def save_as(self, new_package_name: str, output_dir: Path = None) -> None: elif file.file_type == ModelicaFileObject.FILE_TYPE_TEXT: # just save the file as it is text (all other files) open(new_path, 'w').write(file.file_contents) + elif file.file_path.name == 'package.order': + # this is included in the FILE_TYPE_PACKAGE, so just skip + continue else: - _log.warn("Unknown file type, not saving") + _log.warn(f"Unknown file type, not including in .save_as, {file.file_path}") diff --git a/geojson_modelica_translator/modelica/modelica_runner.py b/geojson_modelica_translator/modelica/modelica_runner.py index 6128da91d..f425b0503 100644 --- a/geojson_modelica_translator/modelica/modelica_runner.py +++ b/geojson_modelica_translator/modelica/modelica_runner.py @@ -8,6 +8,7 @@ from pathlib import Path from typing import Union +from buildingspy.simulate.Dymola import Simulator from jinja2 import Environment, FileSystemLoader, StrictUndefined from geojson_modelica_translator.jinja_filters import ALL_CUSTOM_FILTERS @@ -46,7 +47,7 @@ def __init__(self, modelica_lib_path=None): local_path = Path(__file__).parent.resolve() self.om_docker_path = local_path / 'lib' / 'runner' / 'om_docker.sh' - # Verify that docker is up and running + # Verify that docker is up and running, if needed. r = subprocess.call(['docker', 'ps'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) self.docker_configured = r == 0 @@ -181,7 +182,7 @@ def _subprocess_call_to_docker(self, run_path: Union[str, Path], action: str) -> cwd=run_path ) # Uncomment this section and rebuild the container in order to pause the container - # to inpsect the container and test commands. + # to inspect the container and test commands. # import time # time.sleep(10000) # wait for the subprocess to start logger.debug(f"Subprocess command executed, waiting for completion... \nArgs used: {p.args}") @@ -221,11 +222,12 @@ def run_in_docker(self, action: str, model_name: str, file_to_load: Union[str, P model_name (str): The name of the model to be simulated (this is the name within Modelica) file_to_load (str, Path): The file path or a modelica path to be simulated run_path (str, optional): location where the Modelica simulation will start. Defaults to None. - kwargs: additional arugments to pass to the runner which can include + kwargs: additional arguments to pass to the runner which can include project_in_library (bool): whether the project is in a library or not start_time (float): start time of the simulation stop_time (float): stop time of the simulation step_size (float): step size of the simulation + number_of_intervals (int): number of intervals to run the simulation debug (bool): whether to run in debug mode or not, prevents files from being deleted. Returns: @@ -271,6 +273,88 @@ def run_in_docker(self, action: str, model_name: str, file_to_load: Union[str, P self.move_results(verified_run_path, results_path, model_name) return (exitcode == 0, results_path) + def run_in_dymola(self, action: str, model_name: str, file_to_load: Union[str, Path], run_path: Union[str, Path], **kwargs) -> tuple[bool, Union[str, Path]]: + """If running on Windows or Linux, you can run Dymola (assuming you have a license), + using the BuildingsPy library. This is not supported on Mac. + + For using Dymola with the GMT, you need to ensure that MSL v4.0 are loaded correctly and that the + Buildings library is in the MODELICAPATH. I added the MSL openModel via appending it to the Dymola's + /opt//install/dymola.mos file on Linux. + + Args: + action (str): compile (translate) or simulate + model_name (str): Name of the model to translate or simulate + package_path (Union[str, Path]): Name of the package to also load + kwargs: additional arguments to pass to the runner which can include + start_time (float): start time of the simulation + stop_time (float): stop time of the simulation, in seconds + step_size (float): step size of the simulation, in seconds + debug (bool): whether to run in debug mode or not, prevents files from being deleted. + + Returns: + tuple[bool, Union[str, Path]]: success status and path to the results directory + """ + run_path = str(run_path) + current_dir = Path.cwd() + try: + os.chdir(run_path) + print(run_path) + if file_to_load is None: + # This occurs when the model is already in a library that is loaded (e.g., MSL, Buildings) + # Dymola does check the MODELICAPATH for any libraries that need to be loaded automatically. + dymola_simulator = Simulator( + modelName=model_name, + outputDirectory=run_path, + ) + else: + file_to_load = str(file_to_load) + dymola_simulator = Simulator( + modelName=model_name, + outputDirectory=run_path, + packagePath=file_to_load, + ) + + # TODO: add in passing of parameters + # dymola_simulator.addParameters({'PI.k': 10.0, 'PI.Ti': 0.1}) + dymola_simulator.setSolver("dassl") + + start_time = kwargs.get('start_time', 0) + stop_time = kwargs.get('stop_time', 300) + step_size = kwargs.get('step_size', 5) + + # calculate the number of intervals based on the step size + number_of_intervals = int((stop_time - start_time) / step_size) + + dymola_simulator.setStartTime(start_time) + dymola_simulator.setStopTime(stop_time) + dymola_simulator.setNumberOfIntervals(number_of_intervals) + + # Do not show progressbar! -- It will cause an "elapsed time used before assigned" error. + # dymola_simulator.showProgressBar(show=True) + + if kwargs.get('debug', False): + dymola_simulator.showGUI(show=True) + dymola_simulator.exitSimulator(False) + + # BuildingPy throws an exception if the model errs + try: + if action == 'compile': + dymola_simulator.translate() + # the results of this does not create an FMU, just + # the status of the translation/compilation. + elif action == 'simulate': + dymola_simulator.simulate() + except Exception as e: + logger.error(f"Exception running Dymola: {e}") + return False, run_path + + # remove some of the unneeded results + self.cleanup_path(Path(run_path), model_name, debug=kwargs.get('debug', False)) + finally: + os.chdir(current_dir) + + return True, run_path + def move_results(self, from_path: Path, to_path: Path, model_name: Union[str, None] = None) -> None: """This method moves the results of the simulation that are known for now. This method moves only specific files (stdout.log for now), plus all files and folders beginning @@ -313,6 +397,10 @@ def cleanup_path(self, path: Path, model_name: str, **kwargs: dict) -> None: """ # list of files to always remove files_to_remove = [ + 'dsin.txt', + 'dsmodel.c', + 'dymosim', + 'request.', f'{model_name}', f'{model_name}.makefile', f'{model_name}.libs', @@ -325,6 +413,8 @@ def cleanup_path(self, path: Path, model_name: str, **kwargs: dict) -> None: 'om_docker.sh', 'compile_fmu.mos', 'simulate.mos', + 'run.mos', + 'run_translate.mos', ] if not kwargs.get('debug', False): diff --git a/geojson_modelica_translator/modelica/package_parser.py b/geojson_modelica_translator/modelica/package_parser.py index 7601f183a..867991d75 100644 --- a/geojson_modelica_translator/modelica/package_parser.py +++ b/geojson_modelica_translator/modelica/package_parser.py @@ -136,6 +136,9 @@ def save_as(self, new_path: Union[str, Path]) -> None: f.write(self.order_data) f.write("\n") + # update link to saved path + self.path = new_path + @property def order(self) -> list[str]: """Return the order of the packages from the package.order file diff --git a/geojson_modelica_translator/system_parameters/system_parameters.py b/geojson_modelica_translator/system_parameters/system_parameters.py index b48fafd19..9b9f6ca21 100644 --- a/geojson_modelica_translator/system_parameters/system_parameters.py +++ b/geojson_modelica_translator/system_parameters/system_parameters.py @@ -694,7 +694,8 @@ def csv_to_sys_param(self, sys_param_filename: Path, ghe=False, overwrite=True, - microgrid=False) -> None: + microgrid=False, + **kwargs) -> None: """ Create a system parameters file using output from URBANopt SDK @@ -705,12 +706,18 @@ def csv_to_sys_param(self, :param overwrite: Boolean, whether to overwrite existing sys-param file :param ghe: Boolean, flag to add Ground Heat Exchanger properties to System Parameter File :param microgrid: Boolean, Optional. If set to true, also process microgrid fields + + :kwargs (optional): + - relative_path: Path, set the paths (time series files, weather file, etc) relate to `relative_path` :return None, file created and saved to user-specified location + + """ self.sys_param_filename = sys_param_filename + self.rel_path = kwargs.get('relative_path', None) if model_type == 'time_series': - # TODO: delineate between time_series and time_series_mft + # TODO: delineate between time_series and time_series_massflow_rate if microgrid: param_template_path = Path(__file__).parent / 'time_series_microgrid_template.json' else: @@ -773,9 +780,9 @@ def csv_to_sys_param(self, building_list.append(feature_info) # Grab the modelica file for the each Feature, and add it to the appropriate building dict - district_nominal_mfrt = 0 + district_nominal_massflow_rate = 0 for building in building_list: - building_nominal_mfrt = 0 + building_nominal_massflow_rate = 0 for measure_file_path in measure_list: # Grab the relevant 2 components of the path: feature name and measure folder name, items -3 & -2 respectively feature_name = Path(measure_file_path).parts[-3] @@ -783,23 +790,28 @@ def csv_to_sys_param(self, if feature_name != building['geojson_id']: continue if (measure_file_path.suffix == '.mos'): - building['load_model_parameters']['time_series']['filepath'] = str(measure_file_path.resolve()) + # if there is a relative path, then set the path relative + to_file_path = measure_file_path.resolve() + if self.rel_path: + to_file_path = to_file_path.relative_to(self.rel_path) + + building['load_model_parameters']['time_series']['filepath'] = str(to_file_path) if (measure_file_path.suffix == '.csv') and ('_export_time_series_modelica' in str(measure_folder_name)): - mfrt_df = pd.read_csv(measure_file_path) + massflow_rate_df = pd.read_csv(measure_file_path) try: - building_nominal_mfrt = round(mfrt_df['massFlowRateHeating'].max(), 3) # round max to 3 decimal places - # Force casting to float even if building_nominal_mfrt == 0 + building_nominal_massflow_rate = round(massflow_rate_df['massFlowRateHeating'].max(), 3) # round max to 3 decimal places + # Force casting to float even if building_nominal_massflow_rate == 0 # FIXME: This might be related to building_type == `lodging` for non-zero building percentages - building['ets_indirect_parameters']['nominal_mass_flow_building'] = float(building_nominal_mfrt) + building['ets_indirect_parameters']['nominal_mass_flow_building'] = float(building_nominal_massflow_rate) except KeyError: # If massFlowRateHeating is not in the export_time_series_modelica output, just skip this step. # It probably won't be in the export for hpxml residential buildings, at least as of 2022-06-29 logger.info("mass-flow-rate heating is not present. It is not expected in residential buildings. Skipping.") continue - district_nominal_mfrt += building_nominal_mfrt + district_nominal_massflow_rate += building_nominal_massflow_rate if measure_file_path.suffix == '.csv' and measure_folder_name.endswith('_export_modelica_loads'): try: - building_loads = pd.read_csv(measure_file_path, usecols=['ElectricityFacility']) # usecols to make the df small + building_loads = pd.read_csv(measure_file_path, usecols=['ElectricityFacility']) # only use the one column to make the df small except ValueError: # hack to handle the case where there is no ElectricityFacility column in the csv continue max_electricity_load = int(building_loads['ElectricityFacility'].max()) @@ -817,7 +829,7 @@ def csv_to_sys_param(self, # Update specific sys-param settings for each building for building in building_list: - building['ets_indirect_parameters']['nominal_mass_flow_district'] = district_nominal_mfrt + building['ets_indirect_parameters']['nominal_mass_flow_district'] = district_nominal_massflow_rate feature_opt_file = scenario_dir / building['geojson_id'] / 'feature_reports' / 'feature_optimization.json' if microgrid and not feature_opt_file.exists(): logger.debug(f"No feature optimization file found for {building['geojson_id']}. Skipping REopt for this building") @@ -829,7 +841,10 @@ def csv_to_sys_param(self, # Update district sys-param settings # Parens are to allow the line break - self.param_template['weather'] = str(mos_weather_path) + to_file_path = mos_weather_path + if self.rel_path: + to_file_path = to_file_path.relative_to(self.rel_path) + self.param_template['weather'] = str(to_file_path) if microgrid and not feature_opt_file.exists(): logger.warn("Microgrid requires OpenDSS and REopt feature optimization for full functionality.\n" "Run opendss and reopt-feature post-processing in the UO SDK for a full-featured microgrid.") @@ -841,7 +856,6 @@ def csv_to_sys_param(self, # Update ground heat exchanger properties if true if ghe: - ghe_ids = [] # add properties from the feature file with open(feature_file) as json_file: diff --git a/poetry.lock b/poetry.lock index e87bd20d4..27fc03ada 100644 --- a/poetry.lock +++ b/poetry.lock @@ -688,6 +688,7 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, !=3.6.*" files = [ {file = "jsonpointer-2.4-py2.py3-none-any.whl", hash = "sha256:15d51bba20eea3165644553647711d150376234112651b4f1811022aecad7d7a"}, + {file = "jsonpointer-2.4.tar.gz", hash = "sha256:585cee82b70211fa9e6043b7bb89db6e1aa49524340dde8ad6b63206ea689d88"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index 2fe0291f7..bc3a4b3fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -79,5 +79,6 @@ log_cli = true log_cli_level = "DEBUG" markers = [ "simulation: marks tests that run a simulation with docker/optimica (deselect with '-m \"not simulation\"'). All simulations now require MSL v4.", - "compilation: marks tests that are for compiling a simulation with docker/optimica (deselect with '-m \"not compilation\"'). All simulations now require MSL v4." + "compilation: marks tests that are for compiling a simulation with docker/optimica (deselect with '-m \"not compilation\"'). All simulations now require MSL v4.", + "dymola: mark tests that are for running only in Dymola, which requires a local install and license (deselect with '-m \"not dymola\"').", ] diff --git a/tests/GMT_Lib/test_gmt_lib_des.py b/tests/GMT_Lib/test_gmt_lib_des.py index 4d9004f7f..0f17fd1a1 100644 --- a/tests/GMT_Lib/test_gmt_lib_des.py +++ b/tests/GMT_Lib/test_gmt_lib_des.py @@ -1,6 +1,7 @@ # :copyright (c) URBANopt, Alliance for Sustainable Energy, LLC, and other contributors. # See also https://github.com/urbanopt/geojson-modelica-translator/blob/develop/LICENSE.md +import unittest from pathlib import Path from shutil import rmtree @@ -28,34 +29,70 @@ ) -@pytest.mark.simulation -def test_5G_des_waste_heat_and_ghx(): - # -- Setup - package_output_dir = PARENT_DIR / 'output' - package_name = 'DES_5G' - if (package_output_dir / package_name).exists(): - rmtree(package_output_dir / package_name) - sys_params = SystemParameters(DES_PARAMS) - - # -- Act - cpv = DHC5GWasteHeatAndGHX(sys_params) - cpv.build_from_template(package_output_dir, 'DES_5G') - - # -- Assert - # Did the mofile get created? - assert linecount(package_output_dir / package_name / 'Districts' / 'district.mo') > 20 - - # Test to make sure that a zero SWH peak is set to a minimum value. - # Otherwise, Modelica will error out. - with open(package_output_dir / package_name / 'Resources' / 'Data' / 'Districts' / '8' / 'B11.mos', 'r') as f: - assert '#Peak water heating load = 7714.5 Watts' in f.read() - - # -- Act - with simulation - runner = ModelicaRunner() - success, _ = runner.run_in_docker( - 'compile_and_run', 'DES_5G.Districts.district', - file_to_load=package_output_dir / 'DES_5G' / 'package.mo', - run_path=package_output_dir / 'DES_5G', - start_time=0, stop_time=86400) - - assert success is True +class GmtLibDesTest(unittest.TestCase): + + @pytest.mark.simulation + def test_5G_des_waste_heat_and_ghx(self): + # -- Setup + package_output_dir = PARENT_DIR / 'output' + package_name = 'DES_5G' + if (package_output_dir / package_name).exists(): + rmtree(package_output_dir / package_name) + sys_params = SystemParameters(DES_PARAMS) + + # -- Act + cpv = DHC5GWasteHeatAndGHX(sys_params) + cpv.build_from_template(package_output_dir, package_name) + + # -- Assert + # Did the mofile get created? + assert linecount(package_output_dir / package_name / 'Districts' / 'district.mo') > 20 + + # Test to make sure that a zero SWH peak is set to a minimum value. + # Otherwise, Modelica will error out. + with open(package_output_dir / package_name / 'Resources' / 'Data' / 'Districts' / '8' / 'B11.mos', 'r') as f: + assert '#Peak water heating load = 7714.5 Watts' in f.read() + + # -- Act - with simulation + runner = ModelicaRunner() + success, _ = runner.run_in_docker( + 'compile_and_run', f"{package_name}.Districts.district", + file_to_load=package_output_dir / package_name / 'package.mo', + run_path=package_output_dir / package_name, + start_time=0, stop_time=86400) + + assert success is True + + @pytest.mark.dymola + def test_5G_des_waste_heat_and_ghx_dymola(self): + # -- Setup + package_output_dir = PARENT_DIR / 'output' + package_name = 'DES_5G_Dymola' + if (package_output_dir / package_name).exists(): + rmtree(package_output_dir / package_name) + sys_params = SystemParameters(DES_PARAMS) + + # -- Act + cpv = DHC5GWasteHeatAndGHX(sys_params) + cpv.build_from_template(package_output_dir, package_name) + + # -- Assert + # Did the mofile get created? + assert linecount(package_output_dir / package_name / 'Districts' / 'district.mo') > 20 + + # Test to make sure that a zero SWH peak is set to a minimum value. + # Otherwise, Modelica will error out. + with open(package_output_dir / package_name / 'Resources' / 'Data' / 'Districts' / '8' / 'B11.mos', 'r') as f: + assert '#Peak water heating load = 7714.5 Watts' in f.read() + + # -- Act - with simulation + runner = ModelicaRunner() + success, _ = runner.run_in_dymola( + 'simulate', f"{package_name}.Districts.district", + file_to_load=package_output_dir / package_name, + run_path=package_output_dir / package_name, + start_time=0, stop_time=86400, step_size=300, + debug=True + ) + + assert success is True diff --git a/tests/base_test_case.py b/tests/base_test_case.py index be5736ed8..8fb401232 100644 --- a/tests/base_test_case.py +++ b/tests/base_test_case.py @@ -6,6 +6,7 @@ from unittest import TestCase import numpy as np +import pytest from geojson_modelica_translator.modelica.modelica_runner import ModelicaRunner @@ -80,6 +81,26 @@ def run_and_assert_in_docker(self, model_name: str, file_to_load: str, run_path: # make sure that the results log exist self.assertTrue((Path(results_path) / 'stdout.log').exists()) + @pytest.mark.dymola + def run_and_assert_in_dymola(self, model_name: str, file_to_load: str, run_path: Path, **kwargs): + """Wrapper for running and asserting that the simulation completed successfully in dymola + + Args: + model_name (str): Name of the model to run, this is the name in the Modelica file + file_to_load (str): Name of the file to load, which may or may not contain the model_name + run_path (Path): Path to the simulation directory where all the files will be copied + """ + mr = ModelicaRunner() + success, results_path = mr.run_in_dymola( + 'simulate', model_name, file_to_load=file_to_load, run_path=run_path, **kwargs + ) + # on the exit of the docker command it should return a zero exit code, otherwise there was an issue. + # Look at the stdout.log if this is non-zero. + self.assertTrue(success) + + # make sure that the results log exist + self.assertTrue((Path(results_path) / 'stdout.log').exists()) + def cvrmsd(self, measured, simulated): """Return CVRMSD between arrays. Implementation of ASHRAE Guideline 14 (4-4) diff --git a/tests/model_connectors/test_time_series_5g.py b/tests/model_connectors/test_time_series_5g.py index f11979a05..421141868 100644 --- a/tests/model_connectors/test_time_series_5g.py +++ b/tests/model_connectors/test_time_series_5g.py @@ -80,3 +80,14 @@ def test_simulate_district_system(self): file_to_load=self.district._scaffold.package_path, run_path=self.district._scaffold.project_path ) + + @pytest.mark.dymola + @pytest.mark.skip(reason="Structurally singular error in Dymola.") + def test_simulate_district_system_in_dymola(self): + # need to just pass the dir, dymola runner looks for package.mo + self.run_and_assert_in_dymola( + f'{self.district._scaffold.project_name}.Districts.DistrictEnergySystem', + file_to_load=self.district._scaffold.project_path, + run_path=self.district._scaffold.project_path, + # debug=True + ) diff --git a/tests/modelica/test_csv_modelica.py b/tests/modelica/test_csv_modelica.py index c75bf20e6..09243baa5 100644 --- a/tests/modelica/test_csv_modelica.py +++ b/tests/modelica/test_csv_modelica.py @@ -59,7 +59,7 @@ def test_csv_modelica_at_60_min_timestep(self): self.assertTrue('111600,57.2,82.2,5.784,16.2,6.7,3.062' in data) self.assertFalse('9900,40.1,82.2,0.879,14.9,6.7,1.512' in data) - def test_csv_modelica_at_60_min_timestep_with_Eplus_file(self): + def test_csv_modelica_at_60_min_timestep_with_energyplus_file(self): input_file = Path(self.data_dir) / 'mfrt.csv' output_modelica_file_name = Path(self.output_dir) / 'mfrt_output.csv' diff --git a/tests/modelica/test_modelica_project.py b/tests/modelica/test_modelica_project.py index 13cdac95a..959ca252e 100644 --- a/tests/modelica/test_modelica_project.py +++ b/tests/modelica/test_modelica_project.py @@ -31,7 +31,28 @@ def test_load_package_files(self): # check the data in the DistrictEnergySystem.mo file mofile = project.file_data[district_mo_file].object self.assertIsInstance(mofile, Model) - + + def test_get_model_from_package(self): + """Return a model object from a string path""" + package_file = self.data_dir / 'teaser_single' / 'package.mo' + project = ModelicaProject(package_file) + + model = project.get_model('Districts.DistrictEnergySystem') + self.assertIsInstance(model, Model) + + with self.assertRaises(Exception) as ctx: + model = project.get_model('Districts.DistrictEnergySystem.mo') + self.assertIn("Model name should not have the .mo extension", str(ctx.exception)) + + with self.assertRaises(Exception) as ctx: + model = project.get_model('not.a.path.to.a.model') + self.assertIn("ModelicaPackage does not contain a", str(ctx.exception)) + + # verify that searching for a package.mo will raise an exception + with self.assertRaises(Exception) as ctx: + model = project.get_model('package') + self.assertIn("Model is a package file, not a model", str(ctx.exception)) + def test_project_save_as(self): """Saving a package will require renaming the within statements in all the files""" package_file = self.data_dir / 'teaser_single' / 'package.mo' @@ -42,5 +63,3 @@ def test_project_save_as(self): # verify that the package.mo file was saved self.assertTrue((self.output_dir / 'test_package_1' / 'package.mo').exists()) - # TODO: how to test the files are correct, really it is just running in Dymola - diff --git a/tests/modelica/test_modelica_runner.py b/tests/modelica/test_modelica_runner.py index a43a0cfe6..02b9c1b0d 100644 --- a/tests/modelica/test_modelica_runner.py +++ b/tests/modelica/test_modelica_runner.py @@ -5,10 +5,11 @@ import shutil import unittest import logging - +import inspect import pytest from geojson_modelica_translator.modelica.modelica_runner import ModelicaRunner +from pathlib import Path logger = logging.getLogger(__name__) logging.basicConfig( @@ -73,22 +74,8 @@ def test_run_in_docker_errors(self): mr.run_in_docker('compile', 'no_file', file_to_load=file_to_run) self.assertEqual(f'File not found to run {file_to_run}', str(exc.exception)) - @pytest.mark.simulation - def test_simulate_in_docker(self): - mr = ModelicaRunner() - success, _ = mr.run_in_docker('compile_and_run', 'BouncingBall', - file_to_load = os.path.join(self.run_path, 'BouncingBall.mo'), - run_path=self.run_path) - - self.assertTrue(success) - - results_path = os.path.join(self.run_path, 'BouncingBall_results') - self.assertTrue(os.path.exists(os.path.join(results_path, 'stdout.log'))) - self.assertTrue(os.path.exists(os.path.join(results_path, 'BouncingBall_res.mat'))) - self.assertFalse(os.path.exists(os.path.join(results_path, 'om_docker.sh'))) - @pytest.mark.compilation - def test_compile_in_docker(self): + def test_compile_bouncing_ball_in_docker(self): # cleanup output path results_path = os.path.join(self.run_path, 'BouncingBall_results') shutil.rmtree(results_path, ignore_errors=True) @@ -109,6 +96,20 @@ def test_compile_in_docker(self): self.assertFalse(os.path.exists(os.path.join(results_path, 'compile_fmu.mos'))) self.assertFalse(os.path.exists(os.path.join(results_path, 'simulate.mos'))) + @pytest.mark.simulation + def test_simulate_bouncing_ball_in_docker(self): + mr = ModelicaRunner() + success, _ = mr.run_in_docker('compile_and_run', 'BouncingBall', + file_to_load = os.path.join(self.run_path, 'BouncingBall.mo'), + run_path=self.run_path) + + self.assertTrue(success) + + results_path = os.path.join(self.run_path, 'BouncingBall_results') + self.assertTrue(os.path.exists(os.path.join(results_path, 'stdout.log'))) + self.assertTrue(os.path.exists(os.path.join(results_path, 'BouncingBall_res.mat'))) + self.assertFalse(os.path.exists(os.path.join(results_path, 'om_docker.sh'))) + @pytest.mark.simulation @pytest.mark.skip(reason='Need to install libfortran.so.4 in docker image') def test_simulate_fmu_in_docker(self): @@ -127,17 +128,6 @@ def test_simulate_fmu_in_docker(self): self.assertTrue(os.path.exists(os.path.join(results_path, 'BouncingBall_result.mat'))) self.assertFalse(os.path.exists(os.path.join(results_path, 'om_docker.sh'))) - @pytest.mark.simulation - def test_simulate_mbl_in_docker(self): - model_name = 'Buildings.Controls.OBC.CDL.Continuous.Validation.PID' - - mr = ModelicaRunner() - success, _ = mr.run_in_docker( - 'compile_and_run', model_name, run_path=self.mbl_run_path, project_in_library=True - ) - self.assertTrue(success) - - @pytest.mark.compilation def test_compile_msl_in_docker(self): model_name = 'Modelica.Blocks.Examples.PID_Controller' @@ -165,7 +155,7 @@ def test_simulate_msl_in_docker(self): self.assertTrue(os.path.exists(os.path.join(results_path, f'{model_name}_res.mat'))) @pytest.mark.simulation - def test_simulate_msl_with_starttimes_in_docker(self): + def test_simulate_msl_with_start_times_in_docker(self): model_name = 'Modelica.Blocks.Examples.PID_Controller' results_path = os.path.join(self.msl_run_path, f"{model_name}_results") shutil.rmtree(results_path, ignore_errors=True) @@ -179,4 +169,61 @@ def test_simulate_msl_with_starttimes_in_docker(self): self.assertTrue(os.path.exists(os.path.join(results_path, 'stdout.log'))) self.assertTrue(os.path.exists(os.path.join(results_path, f'{model_name}_res.mat'))) - \ No newline at end of file + @pytest.mark.simulation + def test_simulate_mbl_pid_in_docker(self): + model_name = 'Buildings.Controls.OBC.CDL.Continuous.Validation.PID' + + mr = ModelicaRunner() + success, _ = mr.run_in_docker( + 'compile_and_run', model_name, run_path=self.mbl_run_path, project_in_library=True + ) + self.assertTrue(success) + + @pytest.mark.dymola + def test_simulate_msl_in_dymola(self): + model_name = 'Modelica.Blocks.Examples.PID_Controller' + results_path = Path(self.msl_run_path) / f"{inspect.currentframe().f_code.co_name}_results" + if results_path.exists(): + shutil.rmtree(results_path, ignore_errors=True) + results_path.mkdir(parents=True) + + # shutil.rmtree(results_path, ignore_errors=True) + + mr = ModelicaRunner() + success, _ = mr.run_in_dymola( + 'simulate', model_name, run_path=results_path, file_to_load=None + ) + + self.assertTrue(success) + # self.assertTrue(os.path.exists(os.path.join(results_path, 'stdout.log'))) + # self.assertTrue(os.path.exists(os.path.join(results_path, f'{model_name}.fmu'))) + + @pytest.mark.dymola + def test_simulate_mbl_pid_in_dymola(self): + results_path = Path(self.mbl_run_path) / f"{inspect.currentframe().f_code.co_name}_results" + if results_path.exists(): + shutil.rmtree(results_path, ignore_errors=True) + results_path.mkdir(parents=True) + + model_name = 'Buildings.Controls.OBC.CDL.Continuous.Validation.PID' + + mr = ModelicaRunner() + success, _ = mr.run_in_dymola( + 'simulate', model_name, run_path=results_path, file_to_load=None #, debug=True + ) + self.assertTrue(success) + + @pytest.mark.dymola + def test_compile_mbl_pid_in_dymola(self): + results_path = Path(self.mbl_run_path) / f"{inspect.currentframe().f_code.co_name}_results" + if results_path.exists(): + shutil.rmtree(results_path, ignore_errors=True) + results_path.mkdir(parents=True) + + model_name = 'Buildings.Controls.OBC.CDL.Continuous.Validation.PID' + + mr = ModelicaRunner() + success, _ = mr.run_in_dymola( + 'compile', model_name, run_path=results_path, file_to_load=None, debug=True + ) + self.assertTrue(success) \ No newline at end of file