diff --git a/energyplus_calibrator/calibration_class.py b/energyplus_calibrator/calibration_class.py index 033020abcda1a3815300728828b4e97a88d947bf..c70f77a8e707e469c88857d51fc42edb5c995371 100644 --- a/energyplus_calibrator/calibration_class.py +++ b/energyplus_calibrator/calibration_class.py @@ -37,9 +37,8 @@ class EnergyPlusCalibrator: goals: List[str], real_data_dataframe: pd.DataFrame, goal_var_convert_dict: Dict[str, str], - schedule_vars: Optional[Dict[str, Tuple[int, str]]] = None, calc_after_sim_function: Optional[callable] = None, - error_metric: str = 'NMBE', + error_metric: Union[str, List[str]] = 'NMBE', n_cpu: int = 1, force_same: Optional[Dict[str, Tuple[str, float]]] = None, envelope_calibration: bool = False, @@ -58,7 +57,6 @@ class EnergyPlusCalibrator: goals: List of output variables to calibrate against real_data_dataframe: Measured data for comparison goal_var_convert_dict: Mapping between simulation and measurement variables - schedule_vars: Dictionary of schedule variables to be calibrated calc_after_sim_function: Post-processing function for simulation results error_metric: Error metric for calibration ('NMBE', 'CVRMSE', or 'RMSE') n_cpu: Number of CPU cores to use @@ -70,10 +68,16 @@ class EnergyPlusCalibrator: NameError: If error_metric is invalid ValueError: If data consistency checks fail """ - - if error_metric not in ['NMBE', 'CVRMSE', 'RMSE']: - raise NameError( - 'Error metric must be either "NMBE", "CVRMSE" or "RMSE"') + if isinstance(error_metric, str): + if error_metric not in ['NMBE', 'CVRMSE', 'RMSE']: + raise NameError( + 'Error metric must be either "NMBE", "CVRMSE" or "RMSE"') + elif isinstance(error_metric, list): + for metric in error_metric: + if metric not in ['NMBE', 'CVRMSE', 'RMSE']: + raise NameError( + 'Error metric must be either "NMBE", "CVRMSE" or "RMSE"') + self.error_metric = error_metric self.energy_plus_api = EnergyPlusAPI( cd=cd, @@ -83,8 +87,7 @@ class EnergyPlusCalibrator: delete_temp_files=True, energyplus_version=energyplus_version, force_same=force_same, - schedule_vars=schedule_vars, - calc_after_sim_function=calc_after_sim_function, + after_sim_functions=[calc_after_sim_function], energyplus_install_dir=energyplus_install_dir, n_cpu=n_cpu, envelope_model=envelope_calibration) @@ -97,9 +100,6 @@ class EnergyPlusCalibrator: self.tuner_params = tuner_paras self.force_same = force_same - if schedule_vars is None: - schedule_vars = [] - self.schedule_vars = schedule_vars self._check_tuner_paras() @@ -151,14 +151,12 @@ class EnergyPlusCalibrator: ValueError: If parameter structure is invalid """ - def check_param(param: str, - is_schedule_var: bool = False): + def check_param(param: str): """ Check individual parameter validity. Args: param: Parameter path in model - is_schedule_var: Whether parameter is a schedule variable Raises: NameError: If parameter not in model @@ -168,28 +166,23 @@ class EnergyPlusCalibrator: keys = param.split('/') _model_dict = self.energy_plus_api.ep_json.copy() - if is_schedule_var: - keys = keys[:-1] for key in keys: + if '_list' in key: + key = int(key[5:]) + _model_dict = _model_dict[key] + continue if key in _model_dict: _model_dict = _model_dict[key] else: raise NameError( - f'{param} is not part of the model description') - - if is_schedule_var: - if 'data' not in _model_dict: - raise ValueError( - f'{param} is a schedule var, but there is no "data" in the resulting dict') - return + f'{param} is not part of the model description. Error with {key}') if isinstance(_model_dict, dict): raise ValueError(f'{param} seems to be missing a description which leads to' f'a value. It still ends in a dict') for param in self.tuner_params: - is_schedule_var = param in self.schedule_vars - check_param(param, is_schedule_var=is_schedule_var) + check_param(param) if self.force_same is not None: for force_to, force_from in self.force_same.items(): @@ -458,7 +451,7 @@ class EnergyPlusCalibrator: to_copy.append('calibration_kpis.json') infos_from_calibration_log(result_path=self.energy_plus_api.working_directory, - used_error_type=self.error_metric, + used_error_type=self.calibration_class.goals.statistical_measure, save_path=self.energy_plus_api.working_directory / 'convergence.png') to_copy.append('convergence.png') return to_copy @@ -482,9 +475,13 @@ class EnergyPlusCalibrator: self.energy_plus_api.convert_json_to_idf(calibrated_model_path) - def create_final_files(self) -> None: + def create_final_files(self, + calibration_results_folder: Path = None) -> None: """ Create and organize final calibration results. + + Args: + calibration_results_folder: Path to save calibration results Creates: 1. Calibrated parameter file @@ -500,26 +497,11 @@ class EnergyPlusCalibrator: calibrated_model = self.energy_plus_api.ep_json.copy() calibrated_model = self.energy_plus_api.change_model_dict_with_parameters( model_dict=calibrated_model, - parameters=parameters, - schedule_vars=self.schedule_vars + parameters=parameters ) parameters_use = parameters.copy() - for param in parameters: - if param in self.schedule_vars: - _, var = self.schedule_vars[param] - value = parameters[param] - if var == 'time' and not isinstance(value, str): - value = round(value * 2) / 2 - - hours = int(value) - minutes = int((value - hours) * 60) - - # Format the time string with leading zeros - time_string = f"{hours:02d}:{minutes:02d}" - parameters[param] = time_string - with open(Path(self.energy_plus_api.working_directory) / 'calibrated_parameters.json', 'w') as f: json.dump(parameters, f) @@ -541,10 +523,14 @@ class EnergyPlusCalibrator: f"_{self.res_name_add}" if len( self.res_name_add) > 0 else formatted_date - results_folder = Path( - __file__).parents[1] / 'calibration_results' / folder_name + if calibration_results_folder is None: + results_folder = Path( + __file__).parents[1] / 'calibration_results' / folder_name + else: + results_folder = calibration_results_folder / folder_name + results_folder.mkdir(parents=True) - + to_copy = ['calibrated_model.json', 'calibrated_model.idf', 'calibrated_parameters.json', @@ -552,8 +538,8 @@ class EnergyPlusCalibrator: try: to_copy2 = self.create_plots(parameters_opt=parameters_use) - except: - print('Error in creating plots. Do again manually') + except Exception as e: + print(f'Error in creating plots: {str(e)}. Do again manually') to_copy2 = [] to_copy += to_copy2 @@ -567,7 +553,8 @@ class EnergyPlusCalibrator: def calibrate(self, framework: str, method: str, - max_time: int = 120) -> None: + max_time: int = 120, + calibration_result_folder: Path = None) -> None: """ Run the calibration process. @@ -575,6 +562,7 @@ class EnergyPlusCalibrator: framework: Optimization framework to use method: Optimization method within the framework max_time: Maximum runtime in seconds + calibration_result_folder: Path to save calibration results Note: Saves calibration results and creates final files @@ -609,7 +597,7 @@ class EnergyPlusCalibrator: 'calibrated_parameters.json', 'w') as f: json.dump(result, f) - self.create_final_files() + self.create_final_files(calibration_results_folder=calibration_result_folder) def _get_kwargs_optimization(self, framework: str, diff --git a/energyplus_calibrator/energy_plus_api.py b/energyplus_calibrator/energy_plus_api.py index 4fc3efab9f011b2a89d91815e4c5420aedf6fbbd..741159346bd6b884d98dd6df09b720ebe627ecff 100644 --- a/energyplus_calibrator/energy_plus_api.py +++ b/energyplus_calibrator/energy_plus_api.py @@ -6,6 +6,7 @@ import subprocess import sys import tempfile import time +import numbers from datetime import datetime from datetime import timedelta from pathlib import Path @@ -173,7 +174,6 @@ class EnergyPlusAPI(SimulationAPI): energyplus_install_dir: Path, energyplus_version: str, force_same: Optional[Dict] = None, - schedule_vars: Optional[Dict] = None, after_sim_functions: Optional[List[Callable]] = None, **kwargs): """ @@ -187,7 +187,6 @@ class EnergyPlusAPI(SimulationAPI): energyplus_install_dir: EnergyPlus installation directory energyplus_version: EnergyPlus version string force_same: Dictionary of parameters to force equal values - schedule_vars: Dictionary of schedule variables after_sim_functions: List of post-processing functions **kwargs: Additional arguments passed to parent class @@ -217,7 +216,6 @@ class EnergyPlusAPI(SimulationAPI): self.force_same = force_same self.after_sim_functions = after_sim_functions self.energyplus_version = energyplus_version - self.schedule_vars = schedule_vars self.ep_json: Dict[str, Any] = {} n_cpu = kwargs.pop("n_cpu", 1) @@ -348,9 +346,9 @@ class EnergyPlusAPI(SimulationAPI): self._stop_date = stop if is_mp: - _df = self._single_simulation_solo_mp(kwargs) + _df = self._single_simulation_solo_mp(kwargs.copy()) else: - _df = self._single_simulation_solo(kwargs) + _df = self._single_simulation_solo(kwargs.copy()) if _df is None: if queue is not None: queue.put(None) @@ -389,15 +387,13 @@ class EnergyPlusAPI(SimulationAPI): def change_model_dict_with_parameters( self, model_dict: Dict[str, Any], - parameters: Dict[str, Any], - schedule_vars: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + parameters: Dict[str, Any]) -> Dict[str, Any]: """ Update model dictionary with new parameter values. Args: model_dict: Original model dictionary parameters: Dictionary of parameters to update - schedule_vars: Dictionary of schedule variables Returns: Updated model dictionary @@ -406,58 +402,39 @@ class EnergyPlusAPI(SimulationAPI): KeyError: If parameter path not found in model dictionary """ - if schedule_vars is None: - schedule_vars = {} - - def _nested_dict_alter(model_dict: Dict[str, Any], - param: str, - value: Any) -> None: + def set_value_in_nested_dict(dictionary: dict, + param: str, + value: numbers.Number): + """ + Set the value of a parameter in a nested dictionary. + + Args: + dictionary (dict): The nested dictionary. + param (str): The parameter to set. + value (Any): The value to set the parameter to. + """ + if not isinstance(value, (numbers.Number, str)): + raise ValueError(f"Value {value} is not a number or a string") + keys = param.split('/') - - schedule_var_flag = False - if param in schedule_vars: - schedule_var_flag = True - keys = keys[:-1] - ix, var = schedule_vars[param] - if var == 'time' and not isinstance(value, str): - value = round(value * 2) / 2 - - hours = int(value) - minutes = int((value - hours) * 60) - - # Format the time string with leading zeros - time_string = f"{hours:02d}:{minutes:02d}" - value = time_string - _model_dict = model_dict - for n, key in enumerate(keys): - if n != len(keys) - 1: - if key not in _model_dict: - raise KeyError( - f'{param} is not part of the model_dict') - _model_dict = _model_dict[key] - continue - - if key not in _model_dict: - # If the years are not specified in the energyplus model, the simulation will still run normally. I have no idea what happens when you simulate over multiple years. - if key == "begin_year": - if not self.use_for_calibration: - print( - "BEGIN YEAR NOT INCLUDED IN THE MODEL DESCRIPTION. STILL SIMULATING") - continue - elif key == "end_year": - if not self.use_for_calibration: - print( - "END YEAR NOT INCLUDED IN THE MODEL DESCRIPTION. STILL SIMULATING") - continue - raise KeyError(f'{param} is not part of the model_dict') - if schedule_var_flag: - _model_dict[key]['data'][ix][var] = value - else: - _model_dict[key] = value - + for key in keys[:-1]: + if "_list" in key: + key = int(key[5:]) + dictionary = dictionary[key] + + if keys[-1] in ['begin_year', 'end_year'] and keys[-1] not in dictionary: + if not self.use_for_calibration: + print( + f"{keys[-1].upper()} NOT INCLUDED IN THE MODEL DESCRIPTION. STILL SIMULATING") + return + current_value = dictionary[keys[-1]] + # check whether the current value is a number or a string + if not isinstance(current_value, (numbers.Number, str)): + raise ValueError(f"Current value you are trying to set: {current_value} is not a number or a string") + dictionary[keys[-1]] = value # Change parameter values inside dataframe according to parameters for name, value in parameters.items(): - _nested_dict_alter(model_dict, name, value) + set_value_in_nested_dict(model_dict, name, value) return model_dict @@ -504,8 +481,7 @@ class EnergyPlusAPI(SimulationAPI): ep_json_new = self.ep_json.copy() ep_json_new = self.change_model_dict_with_parameters(ep_json_new, - parameters, - schedule_vars=self.schedule_vars) + parameters) _temp_generated_inputs = Path(tempfile.mkdtemp(dir=self.cd)) generated_input_file = _temp_generated_inputs / 'input.epJSON' diff --git a/energyplus_calibrator/utils.py b/energyplus_calibrator/utils.py index eed057a899e9059c47f0ab9b292aad391df495c9..b400ae2d465ad3ad524747e5d9ee8d5d059b4345 100644 --- a/energyplus_calibrator/utils.py +++ b/energyplus_calibrator/utils.py @@ -9,8 +9,6 @@ def infos_from_calibration_log(result_path: Path, log_lines: str = None, save_path: Path = None): plot_strings = [] - if used_error_type not in ['RMSE', 'CVRMSE', 'NMBE']: - raise NameError('Error type wrong') if log_lines is not None: lines = log_lines