Skip to content

Commit

Permalink
Move Modelica methods over from GMT (#68)
Browse files Browse the repository at this point in the history
* remove tox and add poetry

* run pre-commit

* add benchmark package

* add building of doc in test

* update dependendencies

* precommit

* move modelica methods from gmt

---------

Co-authored-by: Nathan Moore <nathan.moore@nrel.gov>
  • Loading branch information
nllong and vtnate authored Oct 5, 2023
1 parent a568163 commit a6c06b3
Show file tree
Hide file tree
Showing 38 changed files with 65,149 additions and 2 deletions.
3 changes: 3 additions & 0 deletions .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
"GDHC",
"IBPSA",
"Jing",
"jinga",
"klass",
"levelname",
"libfortran",
"linecount",
Expand All @@ -28,6 +30,7 @@
"pygments",
"redeclarations",
"Reparse",
"searchpath",
"setpoint",
"tanushree",
"Templatized",
Expand Down
5 changes: 4 additions & 1 deletion modelica_builder/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,7 @@
__all__ = ['Transformer',
'Transformation',
'Edit',
'Selector']
'Selector',
'ModelicaProject',
'PackageParser',
'ModelicaMOS',]
99 changes: 99 additions & 0 deletions modelica_builder/modelica_mos_file.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# :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 re
from pathlib import Path
from typing import Any, Union


class ModelicaMOS(object):
def __init__(self, filename: str, data: str = None):
"""Read in a .mos file if it exists into a data object
The format is CSV with additional header info, so for
now read the data as a string.
Args:
filename (str): Name of the file to import, typically a full path
Raises:
FileNotFoundError: File does not exist
Exception: The file extension needs to be MOS
"""
# allow reading in the data directly for testing purposes, for the
# most part.
if not data:
if Path(filename).exists():
self.filename = Path(filename)
else:
raise FileNotFoundError(f"{filename} does not exist")

if self.filename.suffix.lower() != ".mos":
raise Exception(f"{filename} is not a .mos file")

self.data = self.filename.read_text()
else:
self.data = data

def retrieve_header_variable_value(self, key: str, cast_type: type = str) -> Any:
"""Retrieve a value from a variable in the header
Args:
key (str): Key to retrieve
cast_type (type, optional): Type to cast the value to. Defaults to str.
Returns:
str: Value of the key
"""
# check if the peak water heating load is zero, otherwise just skip
key_re = rf'#(\s?{key}\s?)=\s?(-?\b\d[\d,.]*\b)(.*\s)'
match = re.search(key_re, self.data)
if match:
# the first group is the variable name, the second is the value
value = match.group(2).strip()
if value and cast_type:
try:
if cast_type != str:
value = value.replace(',', '')
return cast_type(value)
else:
return value
except ValueError:
raise Exception(f"Unable to cast {value} to {cast_type}")
else:
return value
else:
return None

def replace_header_variable_value(self, key: str, new_value: Any) -> bool:
"""Replace the variable value in the header vars
Args:
key (str): Key of the variable value to replace
new_value (Any): new value
Returns:
bool: Always True (for now)
"""
key_re = rf'#(\s?{key}\s?)=\s?(-?\b\d[\d,.]*\b)(.*\s)'

# verify that group3 exists, otherwise the key doesn't exist
match = re.search(key_re, self.data)
if match:
if match.group(3):
# if there are units, then put the unit back in
self.data = re.sub(key_re, '#' + r'\g<1>' + f"= {new_value}" + r'\g<3>', self.data)
else:
# no g3, therefore, just replace the var and value.
self.data = re.sub(key_re, '#' + r'\g<1>' + f"= {new_value}", self.data)

# replace the value, this requires recreating the variable and value
self.data = re.sub(key_re, '#' + r'\g<1>' + f"= {new_value}" + r'\g<3>', self.data)
return True

def save(self):
"""Save the file back to disk"""
self.filename.write_text(self.data)

def save_as(self, filename: Union[str, Path]):
"""Save the file to a new filename"""
Path(filename).write_text(self.data)
262 changes: 262 additions & 0 deletions modelica_builder/modelica_project.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
"""
****************************************************************************************************
:copyright (c) 2020-2023, Alliance for Sustainable Energy, LLC.
All rights reserved.
****************************************************************************************************
"""
import logging
import os
import time
from pathlib import Path
from typing import Union

from modelica_builder.model import Model
from modelica_builder.package_parser import PackageParser

_log = logging.getLogger(__name__)


class ModelicaFileObject:
"""Class for storing a Modelica file object. Example is a '.mo' file that is
lazily parsed into the AST using Modelica-Builder or a '.mos' file that reads in the
header's values using the ModelicaMOS class."""

# enumerations for different file types
FILE_TYPE_PACKAGE = 0
FILE_TYPE_MODEL = 1
FILE_TYPE_SCRIPT = 2
FILE_TYPE_TEXT = 3

def __init__(self, file_path):
self.file_path = Path(file_path)
self.object = None
self.file_contents = None
self.file_type = None

# depending on the file type, parse the object when it is first accessed.
if self.file_path.is_dir():
self.file_contents = None
elif self.file_path.name == 'package.mo':
# this parses both the .mo and .order files, so we
# need to skip over the .order file. The PackageParser is
# a directory, not the file itself.
self.object = PackageParser(self.file_path.parent)
self.file_type = self.FILE_TYPE_PACKAGE
elif self.file_path.name == 'package.order':
pass
elif self.file_path.suffix == '.mo':
self.file_type = self.FILE_TYPE_MODEL
self._parse_mo_file()
elif self.file_path.suffix == '.mos':
self.file_type = self.FILE_TYPE_SCRIPT
self.file_contents = self.file_path.read_text()
elif self.file_path.suffix == '.txt':
self.file_type = self.FILE_TYPE_TEXT
self.file_contents = self.file_path.read_text()
else:
# not sure what to do with this
_log.warning(f"Unknown file type {self.file_path}")

def exists(self):
self.file_path.exists()

def _parse_mo_file(self):
"""method to parse the mo file into a Modelica AST"""
# time the loading of the file
start = time.time()
self.object = Model(self.file_path)
end = time.time()

_log.debug(f"Took {end - start} seconds to load {self.file_path.name}")

@property
def name(self):
"""method to get the name of the file"""
return self.file_path.name


class ModelicaProject:
"""Class for storing all the files in a Modelica project. This class should organically
grow as more requirements are needed.
The current functionality includes:
* Load in a package.mo file and store all the related files in memory space."""

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', ]
# skip some specific files that software may create that are not needed to be
# sent around with the modelica project.
self.file_names_to_skip = ['analysis_variables.csv', 'analysis_name.txt', '.DS_Store', ]
self.file_data = {}

self._load_data()

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.name in self.file_names_to_skip and file_path.is_file():
# skip files that have the file_names_to_skip name
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, 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:
print(f"Unknown file {file_path}")

# now sort the file_data by the keys
self.file_data = {key: self.file_data[key] for key in sorted(self.file_data)}

# validate the data, extend as needed.
if self.file_data.get('package.mo', None) is None:
raise Exception('ModelicaPackage does not contain a /package.mo file')

def pretty_print_tree(self) -> None:
"""Pretty print all the items in the directory structure
"""
# Print a couple lines, just because
print()
for key, obj in self.file_data.items():
# find how many indents we need based on the number of path separators
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
Args:
new_package_name (str): Name of the new package, which will also be the directory name
output_dir (Path, optional): Where to persist the new directory and package. Defaults to existing.
"""
if output_dir is None:
output_dir = self.root_directory
output_dir = output_dir / new_package_name

# in the root package, rename the modelica package (there is not within statement)
self.file_data['package.mo'].object.rename_package(new_package_name)

# go through each of the package.mo files first and update the within statements
for path, file in self.file_data.items():
if path == 'package.mo':
# this file is handled above, so just skip
continue

if file.file_type == ModelicaFileObject.FILE_TYPE_PACKAGE:
# this is a package, so update the within statement
file.object.update_within_statement(new_package_name, element_index=0)

elif file.file_type == ModelicaFileObject.FILE_TYPE_MODEL:
new_within_statement = f"{new_package_name}.{str(Path(path).parent).replace(os.path.sep, '.')}"
file.object.set_within_statement(new_within_statement)

# there are a few very specific methods that exist when reading in weather files or
# load files. I am not sure how to abstract out this logic at the moment.

# IDF names - find the existing value and replace if found
idf_name = file.object.get_parameter_value('String', 'idfName')
if idf_name:
# replace the previous model name with the new name
idf_name = idf_name.replace(self.root_directory.name, new_package_name)
file.object.update_parameter('String', 'idfName', idf_name)

epw_name = file.object.get_parameter_value('String', 'epwName')
if epw_name:
# replace the previous model name with the new name
epw_name = epw_name.replace(self.root_directory.name, new_package_name)
file.object.update_parameter('String', 'epwName', epw_name)

weather_filename = file.object.get_parameter_value('String', 'weaName')
if weather_filename:
# replace the previous model name with the new name
weather_filename = weather_filename.replace(self.root_directory.name, new_package_name)
file.object.update_parameter('String', 'weaName', weather_filename)

filename = file.object.get_parameter_value('String', 'filNam')
if filename:
# replace the previous model name with the new name
filename = filename.replace(self.root_directory.name, new_package_name)
file.object.update_parameter('String', 'filNam', filename)

# now persist all the files to the new location
if not output_dir.exists():
output_dir.mkdir(parents=True, exist_ok=True)

for path, file in self.file_data.items():
# create the new path
new_path = output_dir / path
if file.file_path.is_dir():
# this is a directory, so just create it
new_path.mkdir(parents=True, exist_ok=True)

elif file.file_type == ModelicaFileObject.FILE_TYPE_PACKAGE:
file.object.save_as(new_path.parent)
elif file.file_type == ModelicaFileObject.FILE_TYPE_MODEL:
file.object.save_as(new_path)
elif file.file_type == ModelicaFileObject.FILE_TYPE_SCRIPT:
# just save the file as it is text (mos-based file)
open(new_path, 'w').write(file.file_contents)
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(f"Unknown file type, not including in .save_as, {file.file_path}")
Loading

0 comments on commit a6c06b3

Please sign in to comment.