diff --git a/CHANGELOG.md b/CHANGELOG.md index dbf31d4c..56709b27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Unreleased +### Improvement +- Adding new constraints to limit the dynamics (kW/sec) of deferrable loads and battery power. The LP formulation works correctly and a work should be done on integrating the user input parameters to control this functionality. +- Added new constraint to avoid battery discharging to the grid +### Fix +- Bumped version of skforecast from 0.6.0 to 0.8.0. Doing this mainly implies changing how the exogenous data is passed to fit and predict methods. +- Fixed wrong path for csv files when using load cost and prod price forecasts. + ## [0.4.10] - 2023-05-21 ### Fix - Fixed wrong name of new cost sensor. diff --git a/config_emhass.yaml b/config_emhass.yaml index b666a2cb..b965f15f 100644 --- a/config_emhass.yaml +++ b/config_emhass.yaml @@ -48,6 +48,10 @@ optim_conf: - lp_solver: 'PULP_CBC_CMD' # set the name of the linear programming solver that will be used - lp_solver_path: 'empty' # set the path to the LP solver - set_nocharge_from_grid: False # avoid battery charging from the grid + - set_nodischarge_to_grid: True # avoid battery discharging to the grid + - set_battery_dynamic: False # add a constraint to limit the dynamic of the battery power in power per time unit + - battery_dynamic_max: 0.9 # maximum dynamic positive power variation in percentage of battery maximum power + - battery_dynamic_min: -0.9 # minimum dynamic negative power variation in percentage of battery maximum power plant_conf: - P_grid_max: 9000 # The maximum power that can be supplied by the utility grid in Watts diff --git a/docs/conf.py b/docs/conf.py index 1cb9dd18..4e274160 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -22,7 +22,7 @@ author = 'David HERNANDEZ' # The full version, including alpha/beta/rc tags -release = '0.4.10' +release = '0.4.11' # -- General configuration --------------------------------------------------- diff --git a/docs/requirements.txt b/docs/requirements.txt index d99661a4..298ef4b3 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -12,7 +12,7 @@ pulp>=2.4 pyyaml>=5.4.1 netcdf4>=1.5.3 tables==3.7.0 -skforecast==0.6.0 +skforecast==0.8.0 markupsafe==2.1.2 Jinja2<3.2 sphinx==5.3.0 diff --git a/requirements.txt b/requirements.txt index 72ec6da9..d5be2574 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,4 +12,4 @@ pulp>=2.4 pyyaml>=5.4.1 netcdf4>=1.5.3 tables==3.7.0 -skforecast==0.6.0 \ No newline at end of file +skforecast==0.8.0 \ No newline at end of file diff --git a/requirements_webserver.txt b/requirements_webserver.txt index e6c2c8da..a9a50be6 100644 --- a/requirements_webserver.txt +++ b/requirements_webserver.txt @@ -12,7 +12,7 @@ pulp>=2.4 pyyaml>=5.4.1 netcdf4>=1.5.3 tables==3.7.0 -skforecast==0.6.0 +skforecast==0.8.0 flask>=2.0.3 waitress>=2.1.1 plotly>=5.6.0 diff --git a/scripts/use_cases_analysis.py b/scripts/use_cases_analysis.py index aeea6b0d..d24d01da 100644 --- a/scripts/use_cases_analysis.py +++ b/scripts/use_cases_analysis.py @@ -43,6 +43,7 @@ def get_forecast_optim_objects(retrieve_hass_conf, optim_conf, plant_conf, if __name__ == '__main__': get_data_from_file = False params = None + save_figures = False retrieve_hass_conf, optim_conf, plant_conf = get_yaml_parse(pathlib.Path(root+'/config_emhass.yaml'), use_secrets=True) rh = retrieve_hass(retrieve_hass_conf['hass_url'], retrieve_hass_conf['long_lived_token'], retrieve_hass_conf['freq'], retrieve_hass_conf['time_zone'], @@ -71,8 +72,9 @@ def get_forecast_optim_objects(retrieve_hass_conf, optim_conf, plant_conf, fig_inputs1.update_yaxes(title_text = "Powers (W)") fig_inputs1.update_xaxes(title_text = "Time") fig_inputs1.show() - fig_inputs1.write_image(root + "/docs/images/inputs_power.svg", - width=1080, height=0.8*1080) + if save_figures: + fig_inputs1.write_image(root + "/docs/images/inputs_power.svg", + width=1080, height=0.8*1080) fig_inputs2 = df_input_data[['unit_load_cost', 'unit_prod_price']].plot() @@ -80,16 +82,18 @@ def get_forecast_optim_objects(retrieve_hass_conf, optim_conf, plant_conf, fig_inputs2.update_yaxes(title_text = "Load cost and production sell price (EUR)") fig_inputs2.update_xaxes(title_text = "Time") fig_inputs2.show() - fig_inputs2.write_image(root + "/docs/images/inputs_cost_price.svg", - width=1080, height=0.8*1080) + if save_figures: + fig_inputs2.write_image(root + "/docs/images/inputs_cost_price.svg", + width=1080, height=0.8*1080) fig_inputs_dah = df_input_data_dayahead.plot() fig_inputs_dah.layout.template = template fig_inputs_dah.update_yaxes(title_text = "Powers (W)") fig_inputs_dah.update_xaxes(title_text = "Time") fig_inputs_dah.show() - fig_inputs_dah.write_image(root + "/docs/images/inputs_dayahead.svg", - width=1080, height=0.8*1080) + if save_figures: + fig_inputs_dah.write_image(root + "/docs/images/inputs_dayahead.svg", + width=1080, height=0.8*1080) # Let's first perform a perfect optimization opt_res = opt.perform_perfect_forecast_optim(df_input_data, days_list) @@ -98,8 +102,9 @@ def get_forecast_optim_objects(retrieve_hass_conf, optim_conf, plant_conf, fig_res.update_yaxes(title_text = "Powers (W)") fig_res.update_xaxes(title_text = "Time") fig_res.show() - fig_res.write_image(root + "/docs/images/optim_results_PV_defLoads_perfectOptim.svg", - width=1080, height=0.8*1080) + if save_figures: + fig_res.write_image(root + "/docs/images/optim_results_PV_defLoads_perfectOptim.svg", + width=1080, height=0.8*1080) print("System with: PV, two deferrable loads, perfect optimization, profit >> total cost function sum: "+\ str(opt_res['cost_profit'].sum())) @@ -113,8 +118,9 @@ def get_forecast_optim_objects(retrieve_hass_conf, optim_conf, plant_conf, fig_res_dah.update_yaxes(title_text = "Powers (W)") fig_res_dah.update_xaxes(title_text = "Time") fig_res_dah.show() - fig_res_dah.write_image(root + "/docs/images/optim_results_PV_defLoads_dayaheadOptim.svg", - width=1080, height=0.8*1080) + if save_figures: + fig_res_dah.write_image(root + "/docs/images/optim_results_PV_defLoads_dayaheadOptim.svg", + width=1080, height=0.8*1080) print("System with: PV, two deferrable loads, dayahead optimization, profit >> total cost function sum: "+\ str(opt_res_dah['cost_profit'].sum())) @@ -132,8 +138,9 @@ def get_forecast_optim_objects(retrieve_hass_conf, optim_conf, plant_conf, fig_res_dah.update_yaxes(title_text = "Powers (W)") fig_res_dah.update_xaxes(title_text = "Time") fig_res_dah.show() - fig_res_dah.write_image(root + "/docs/images/optim_results_defLoads_dayaheadOptim.svg", - width=1080, height=0.8*1080) + if save_figures: + fig_res_dah.write_image(root + "/docs/images/optim_results_defLoads_dayaheadOptim.svg", + width=1080, height=0.8*1080) print("System with: two deferrable loads, dayahead optimization, profit >> total cost function sum: "+\ str(opt_res_dah['cost_profit'].sum())) @@ -152,15 +159,17 @@ def get_forecast_optim_objects(retrieve_hass_conf, optim_conf, plant_conf, fig_res_dah.update_yaxes(title_text = "Powers (W)") fig_res_dah.update_xaxes(title_text = "Time") fig_res_dah.show() - fig_res_dah.write_image(root + "/docs/images/optim_results_PV_Batt_defLoads_dayaheadOptim.svg", - width=1080, height=0.8*1080) + if save_figures: + fig_res_dah.write_image(root + "/docs/images/optim_results_PV_Batt_defLoads_dayaheadOptim.svg", + width=1080, height=0.8*1080) fig_res_dah = opt_res_dah[['SOC_opt']].plot() fig_res_dah.layout.template = template fig_res_dah.update_yaxes(title_text = "Battery State of Charge (%)") fig_res_dah.update_xaxes(title_text = "Time") fig_res_dah.show() - fig_res_dah.write_image(root + "/docs/images/optim_results_PV_Batt_defLoads_dayaheadOptim_SOC.svg", - width=1080, height=0.8*1080) + if save_figures: + fig_res_dah.write_image(root + "/docs/images/optim_results_PV_Batt_defLoads_dayaheadOptim_SOC.svg", + width=1080, height=0.8*1080) print("System with: PV, Battery, two deferrable loads, dayahead optimization, profit >> total cost function sum: "+\ str(opt_res_dah['cost_profit'].sum())) \ No newline at end of file diff --git a/setup.py b/setup.py index 6cc76fda..9a69be18 100644 --- a/setup.py +++ b/setup.py @@ -19,7 +19,7 @@ setup( name='emhass', # Required - version='0.4.10', # Required + version='0.4.11', # Required description='An Energy Management System for Home Assistant', # Optional long_description=long_description, # Optional long_description_content_type='text/markdown', # Optional (see note above) @@ -57,7 +57,7 @@ 'pyyaml>=5.4.1', 'netcdf4>=1.5.3', 'tables==3.7.0', - 'skforecast==0.6.0', + 'skforecast==0.8.0', ], # Optional entry_points={ # Optional 'console_scripts': [ diff --git a/src/emhass/command_line.py b/src/emhass/command_line.py index 6314adfc..9a0888e8 100644 --- a/src/emhass/command_line.py +++ b/src/emhass/command_line.py @@ -229,7 +229,8 @@ def dayahead_forecast_optim(input_data_dict: dict, logger: logging.Logger, input_data_dict['df_input_data_dayahead'], method=input_data_dict['fcst'].optim_conf['load_cost_forecast_method']) df_input_data_dayahead = input_data_dict['fcst'].get_prod_price_forecast( - df_input_data_dayahead, method=input_data_dict['fcst'].optim_conf['prod_price_forecast_method']) + df_input_data_dayahead, + method=input_data_dict['fcst'].optim_conf['prod_price_forecast_method']) opt_res_dayahead = input_data_dict['opt'].perform_dayahead_forecast_optim( df_input_data_dayahead, input_data_dict['P_PV_forecast'], input_data_dict['P_load_forecast']) # Save CSV file for publish_data diff --git a/src/emhass/forecast.py b/src/emhass/forecast.py index 509200e3..28d1a314 100644 --- a/src/emhass/forecast.py +++ b/src/emhass/forecast.py @@ -658,7 +658,7 @@ def get_load_forecast(self, days_min_load_forecast: Optional[int] = 3, method: O return P_Load_forecast def get_load_cost_forecast(self, df_final: pd.DataFrame, method: Optional[str] = 'hp_hc_periods', - csv_path: Optional[str] = "/data/data_load_cost_forecast.csv") -> pd.DataFrame: + csv_path: Optional[str] = "data_load_cost_forecast.csv") -> pd.DataFrame: r""" Get the unit cost for the load consumption based on multiple tariff \ periods. This is the cost of the energy from the utility in a vector \ @@ -671,7 +671,7 @@ def get_load_cost_forecast(self, df_final: pd.DataFrame, method: Optional[str] = and 'csv' to load a CSV file, defaults to 'hp_hc_periods' :type method: str, optional :param csv_path: The path to the CSV file used when method = 'csv', \ - defaults to "/data/data_load_cost_forecast.csv" + defaults to "data_load_cost_forecast.csv" :type csv_path: str, optional :return: The input DataFrame with one additionnal column appended containing the load cost for each time observation. diff --git a/src/emhass/machine_learning_forecaster.py b/src/emhass/machine_learning_forecaster.py index 6ed8385c..42e6d5f8 100644 --- a/src/emhass/machine_learning_forecaster.py +++ b/src/emhass/machine_learning_forecaster.py @@ -97,6 +97,17 @@ def neg_r2_score(y_true, y_pred): """The negative of the r2 score.""" return -r2_score(y_true, y_pred) + @staticmethod + def generate_exog(data_last_window, periods, var_name): + """Generate the exogenous data for future timestamps.""" + forecast_dates = pd.date_range(start=data_last_window.index[-1]+data_last_window.index.freq, + periods=periods, + freq=data_last_window.index.freq) + exog = pd.DataFrame({var_name:[np.nan]*periods}, + index=forecast_dates) + exog = mlforecaster.add_date_features(exog) + return exog + def fit(self, split_date_delta: Optional[str] = '48h', perform_backtest: Optional[bool] = False ) -> Tuple[pd.DataFrame, pd.DataFrame]: r"""The fit method to train the ML model. @@ -119,7 +130,7 @@ def fit(self, split_date_delta: Optional[str] = '48h', perform_backtest: Optiona # train/test split self.date_train = self.data_exo.index[-1]-pd.Timedelta('5days')+self.data_exo.index.freq # The last 5 days self.date_split = self.data_exo.index[-1]-pd.Timedelta(split_date_delta)+self.data_exo.index.freq # The last 48h - self.data_train = self.data_exo.loc[:self.date_split,:] + self.data_train = self.data_exo.loc[:self.date_split-self.data_exo.index.freq,:] self.data_test = self.data_exo.loc[self.date_split:,:] self.steps = len(self.data_test) # Pick correct sklearn model @@ -143,7 +154,7 @@ def fit(self, split_date_delta: Optional[str] = '48h', perform_backtest: Optiona exog=self.data_train.drop(self.var_model, axis=1)) self.logger.info(f"Elapsed time for model fit: {time.time() - start_time}") # Make a prediction to print metrics - predictions = self.forecaster.predict(steps=self.steps, exog=self.data_train.drop(self.var_model, axis=1)) + predictions = self.forecaster.predict(steps=self.steps, exog=self.data_test.drop(self.var_model, axis=1)) pred_metric = r2_score(self.data_test[self.var_model],predictions) self.logger.info(f"Prediction R2 score of fitted model on test data: {pred_metric}") # Packing results in a DataFrame @@ -187,18 +198,18 @@ def predict(self, data_last_window: Optional[pd.DataFrame] = None :rtype: pd.Series """ if data_last_window is None: - predictions = self.forecaster.predict(steps=self.num_lags, exog=self.data_train.drop(self.var_model, axis=1)) + predictions = self.forecaster.predict(steps=self.num_lags, exog=self.data_test.drop(self.var_model, axis=1)) else: - data_last_window = mlforecaster.add_date_features(data_last_window) - data_last_window = data_last_window.interpolate(method='linear', axis=0, limit=None) if self.is_tuned: + exog = mlforecaster.generate_exog(data_last_window, self.lags_opt, self.var_model) predictions = self.forecaster.predict(steps=self.lags_opt, last_window=data_last_window[self.var_model], - exog=data_last_window.drop(self.var_model, axis=1)) + exog=exog.drop(self.var_model, axis=1)) else: + exog = mlforecaster.generate_exog(data_last_window, self.num_lags, self.var_model) predictions = self.forecaster.predict(steps=self.num_lags, last_window=data_last_window[self.var_model], - exog=data_last_window.drop(self.var_model, axis=1)) + exog=exog.drop(self.var_model, axis=1)) return predictions def tune(self, debug: Optional[bool] = False) -> pd.DataFrame: @@ -223,11 +234,11 @@ def tune(self, debug: Optional[bool] = False) -> pd.DataFrame: if self.sklearn_model == 'LinearRegression': if debug: def search_space(trial): - search_space = {'fit_intercept': trial.suggest_categorical('fit_intercept', ['True'])} + search_space = {'fit_intercept': trial.suggest_categorical('fit_intercept', [True])} return search_space else: def search_space(trial): - search_space = {'fit_intercept': trial.suggest_categorical('fit_intercept', ['True', 'False'])} + search_space = {'fit_intercept': trial.suggest_categorical('fit_intercept', [True, False])} return search_space elif self.sklearn_model == 'ElasticNet': if debug: @@ -276,7 +287,7 @@ def search_space(trial): ) self.logger.info(f"Elapsed time: {time.time() - start_time}") self.is_tuned = True - predictions_opt = self.forecaster.predict(steps=self.num_lags, exog=self.data_train.drop(self.var_model, axis=1)) + predictions_opt = self.forecaster.predict(steps=self.num_lags, exog=self.data_test.drop(self.var_model, axis=1)) freq_hours = self.data_exo.index.freq.delta.seconds/3600 self.lags_opt = int(np.round(len(self.optimize_results.iloc[0]['lags']))) self.days_needed = int(np.round(self.lags_opt*freq_hours/24)) diff --git a/src/emhass/optimization.py b/src/emhass/optimization.py index 10848203..dc95cb92 100644 --- a/src/emhass/optimization.py +++ b/src/emhass/optimization.py @@ -225,137 +225,163 @@ def perform_optimization(self, data_opt: pd.DataFrame, P_PV: np.array, P_load: n ## Setting constraints # The main constraint: power balance constraints = {"constraint_main1_{}".format(i) : - plp.LpConstraint( - e = P_PV[i] - P_def_sum[i] - P_load[i] + P_grid_neg[i] + P_grid_pos[i] + P_sto_pos[i] + P_sto_neg[i], - sense = plp.LpConstraintEQ, - rhs = 0) - for i in set_I} - - # An optional constraint to avoid charging the battery from the grid - if self.optim_conf['set_use_battery']: - if self.optim_conf['set_nocharge_from_grid']: - constraints.update({"constraint_nocharge_from_grid_{}".format(i) : - plp.LpConstraint( - e = P_sto_neg[i] + P_PV[i], - sense = plp.LpConstraintGE, - rhs = 0) - for i in set_I}) + plp.LpConstraint( + e = P_PV[i] - P_def_sum[i] - P_load[i] + P_grid_neg[i] + P_grid_pos[i] + P_sto_pos[i] + P_sto_neg[i], + sense = plp.LpConstraintEQ, + rhs = 0) + for i in set_I} # Two special constraints just for a self-consumption cost function if self.costfun == 'self-consumption': if type_self_conso == 'maxmin': # maxmin linear problem constraints.update({"constraint_selfcons_PV_{}".format(i) : - plp.LpConstraint( - e = SC[i] - P_PV[i], - sense = plp.LpConstraintLE, - rhs = 0) - for i in set_I}) + plp.LpConstraint( + e = SC[i] - P_PV[i], + sense = plp.LpConstraintLE, + rhs = 0) + for i in set_I}) constraints.update({"constraint_selfcons_PV_{}".format(i) : - plp.LpConstraint( - e = SC[i] - P_load[i] - P_def_sum[i], - sense = plp.LpConstraintLE, - rhs = 0) - for i in set_I}) + plp.LpConstraint( + e = SC[i] - P_load[i] - P_def_sum[i], + sense = plp.LpConstraintLE, + rhs = 0) + for i in set_I}) # Avoid injecting and consuming from grid at the same time constraints.update({"constraint_pgridpos_{}".format(i) : - plp.LpConstraint( - e = P_grid_pos[i] - self.plant_conf['P_grid_max']*D[i], - sense = plp.LpConstraintLE, - rhs = 0) - for i in set_I}) + plp.LpConstraint( + e = P_grid_pos[i] - self.plant_conf['P_grid_max']*D[i], + sense = plp.LpConstraintLE, + rhs = 0) + for i in set_I}) constraints.update({"constraint_pgridneg_{}".format(i) : - plp.LpConstraint( - e = -P_grid_neg[i] - self.plant_conf['P_grid_max']*(1-D[i]), - sense = plp.LpConstraintLE, - rhs = 0) - for i in set_I}) + plp.LpConstraint( + e = -P_grid_neg[i] - self.plant_conf['P_grid_max']*(1-D[i]), + sense = plp.LpConstraintLE, + rhs = 0) + for i in set_I}) - # Total time of deferrable load + # Treat deferrable loads constraints for k in range(self.optim_conf['num_def_loads']): + # Total time of deferrable load constraints.update({"constraint_defload{}_energy".format(k) : - plp.LpConstraint( - e = plp.lpSum(P_deferrable[k][i]*self.timeStep for i in set_I), - sense = plp.LpConstraintEQ, - rhs = def_total_hours[k]*self.optim_conf['P_deferrable_nom'][k]) - }) - - # Treat deferrable load as a semi-continuous variable - for k in range(self.optim_conf['num_def_loads']): + plp.LpConstraint( + e = plp.lpSum(P_deferrable[k][i]*self.timeStep for i in set_I), + sense = plp.LpConstraintEQ, + rhs = def_total_hours[k]*self.optim_conf['P_deferrable_nom'][k]) + }) + # Treat deferrable load as a semi-continuous variable if self.optim_conf['treat_def_as_semi_cont'][k]: constraints.update({"constraint_pdef{}_semicont1_{}".format(k, i) : - plp.LpConstraint( - e=P_deferrable[k][i] - self.optim_conf['P_deferrable_nom'][k]*P_def_bin1[k][i], - sense=plp.LpConstraintGE, - rhs=0) - for i in set_I}) + plp.LpConstraint( + e=P_deferrable[k][i] - self.optim_conf['P_deferrable_nom'][k]*P_def_bin1[k][i], + sense=plp.LpConstraintGE, + rhs=0) + for i in set_I}) constraints.update({"constraint_pdef{}_semicont2_{}".format(k, i) : - plp.LpConstraint( - e=P_deferrable[k][i] - self.optim_conf['P_deferrable_nom'][k]*P_def_bin1[k][i], - sense=plp.LpConstraintLE, - rhs=0) - for i in set_I}) - - # Treat the number of starts for a deferrable load - for k in range(self.optim_conf['num_def_loads']): + plp.LpConstraint( + e=P_deferrable[k][i] - self.optim_conf['P_deferrable_nom'][k]*P_def_bin1[k][i], + sense=plp.LpConstraintLE, + rhs=0) + for i in set_I}) + # Treat the number of starts for a deferrable load if self.optim_conf['set_def_constant'][k]: constraints.update({"constraint_pdef{}_start1".format(k) : - plp.LpConstraint( - e=P_def_start[k][0], - sense=plp.LpConstraintEQ, - rhs=0) - }) + plp.LpConstraint( + e=P_def_start[k][0], + sense=plp.LpConstraintEQ, + rhs=0) + }) constraints.update({"constraint_pdef{}_start2_{}".format(k, i) : - plp.LpConstraint( - e=P_def_start[k][i] - P_def_bin2[k][i] + P_def_bin2[k][i-1], - sense=plp.LpConstraintEQ, - rhs=0) - for i in set_I[1:]}) + plp.LpConstraint( + e=P_def_start[k][i] - P_def_bin2[k][i] + P_def_bin2[k][i-1], + sense=plp.LpConstraintEQ, + rhs=0) + for i in set_I[1:]}) constraints.update({"constraint_pdef{}_start4_{}".format(k, i) : - plp.LpConstraint( - e=P_deferrable[k][i] - P_def_bin2[k][i]*M, - sense=plp.LpConstraintLE, - rhs=0) - for i in set_I}) + plp.LpConstraint( + e=P_deferrable[k][i] - P_def_bin2[k][i]*M, + sense=plp.LpConstraintLE, + rhs=0) + for i in set_I}) constraints.update({"constraint_pdef{}_start5_{}".format(k, i) : - plp.LpConstraint( - e=-P_deferrable[k][i] + M*(P_def_bin2[k][i]-1) + 1, - sense=plp.LpConstraintLE, - rhs=0) - for i in set_I}) + plp.LpConstraint( + e=-P_deferrable[k][i] + M*(P_def_bin2[k][i]-1) + 1, + sense=plp.LpConstraintLE, + rhs=0) + for i in set_I}) # The battery constraints if self.optim_conf['set_use_battery']: + # Optional constraints to avoid charging the battery from the grid + if self.optim_conf['set_nocharge_from_grid']: + constraints.update({"constraint_nocharge_from_grid_{}".format(i) : + plp.LpConstraint( + e = P_sto_neg[i] + P_PV[i], + sense = plp.LpConstraintGE, + rhs = 0) + for i in set_I}) + # Optional constraints to avoid discharging the battery to the grid + if self.optim_conf['set_nodischarge_to_grid']: + constraints.update({"constraint_nodischarge_to_grid_{}".format(i) : + plp.LpConstraint( + e = P_grid_neg[i] + P_PV[i], + sense = plp.LpConstraintGE, + rhs = 0) + for i in set_I}) + # Limitation of power dynamics in power per unit of time + if self.optim_conf['set_battery_dynamic']: + constraints.update({"constraint_pos_batt_dynamic_max_{}".format(i) : + plp.LpConstraint(e = P_sto_pos[i+1] - P_sto_pos[i], + sense = plp.LpConstraintLE, + rhs = self.timeStep*self.optim_conf['battery_dynamic_max']*self.plant_conf['Pd_max']) + for i in range(n-1)}) + constraints.update({"constraint_pos_batt_dynamic_min_{}".format(i) : + plp.LpConstraint(e = P_sto_pos[i+1] - P_sto_pos[i], + sense = plp.LpConstraintGE, + rhs = self.timeStep*self.optim_conf['battery_dynamic_min']*self.plant_conf['Pd_max']) + for i in range(n-1)}) + constraints.update({"constraint_neg_batt_dynamic_max_{}".format(i) : + plp.LpConstraint(e = P_sto_neg[i+1] - P_sto_neg[i], + sense = plp.LpConstraintLE, + rhs = self.timeStep*self.optim_conf['battery_dynamic_max']*self.plant_conf['Pc_max']) + for i in range(n-1)}) + constraints.update({"constraint_neg_batt_dynamic_min_{}".format(i) : + plp.LpConstraint(e = P_sto_neg[i+1] - P_sto_neg[i], + sense = plp.LpConstraintGE, + rhs = self.timeStep*self.optim_conf['battery_dynamic_min']*self.plant_conf['Pc_max']) + for i in range(n-1)}) + # Then the classic battery constraints constraints.update({"constraint_pstopos_{}".format(i) : - plp.LpConstraint( - e=P_sto_pos[i] - self.plant_conf['eta_disch']*self.plant_conf['Pd_max']*E[i], - sense=plp.LpConstraintLE, - rhs=0) - for i in set_I}) + plp.LpConstraint( + e=P_sto_pos[i] - self.plant_conf['eta_disch']*self.plant_conf['Pd_max']*E[i], + sense=plp.LpConstraintLE, + rhs=0) + for i in set_I}) constraints.update({"constraint_pstoneg_{}".format(i) : - plp.LpConstraint( - e=-P_sto_neg[i] - (1/self.plant_conf['eta_ch'])*self.plant_conf['Pc_max']*(1-E[i]), - sense=plp.LpConstraintLE, - rhs=0) - for i in set_I}) + plp.LpConstraint( + e=-P_sto_neg[i] - (1/self.plant_conf['eta_ch'])*self.plant_conf['Pc_max']*(1-E[i]), + sense=plp.LpConstraintLE, + rhs=0) + for i in set_I}) constraints.update({"constraint_socmax_{}".format(i) : - plp.LpConstraint( - e=-plp.lpSum(P_sto_pos[j]*(1/self.plant_conf['eta_disch']) + self.plant_conf['eta_ch']*P_sto_neg[j] for j in range(i)), - sense=plp.LpConstraintLE, - rhs=(self.plant_conf['Enom']/self.timeStep)*(self.plant_conf['SOCmax'] - soc_init)) - for i in set_I}) + plp.LpConstraint( + e=-plp.lpSum(P_sto_pos[j]*(1/self.plant_conf['eta_disch']) + self.plant_conf['eta_ch']*P_sto_neg[j] for j in range(i)), + sense=plp.LpConstraintLE, + rhs=(self.plant_conf['Enom']/self.timeStep)*(self.plant_conf['SOCmax'] - soc_init)) + for i in set_I}) constraints.update({"constraint_socmin_{}".format(i) : - plp.LpConstraint( - e=plp.lpSum(P_sto_pos[j]*(1/self.plant_conf['eta_disch']) + self.plant_conf['eta_ch']*P_sto_neg[j] for j in range(i)), - sense=plp.LpConstraintLE, - rhs=(self.plant_conf['Enom']/self.timeStep)*(soc_init - self.plant_conf['SOCmin'])) - for i in set_I}) + plp.LpConstraint( + e=plp.lpSum(P_sto_pos[j]*(1/self.plant_conf['eta_disch']) + self.plant_conf['eta_ch']*P_sto_neg[j] for j in range(i)), + sense=plp.LpConstraintLE, + rhs=(self.plant_conf['Enom']/self.timeStep)*(soc_init - self.plant_conf['SOCmin'])) + for i in set_I}) constraints.update({"constraint_socfinal_{}".format(0) : - plp.LpConstraint( - e=plp.lpSum(P_sto_pos[i]*(1/self.plant_conf['eta_disch']) + self.plant_conf['eta_ch']*P_sto_neg[i] for i in set_I), - sense=plp.LpConstraintEQ, - rhs=(soc_init - soc_final)*self.plant_conf['Enom']/self.timeStep) - }) + plp.LpConstraint( + e=plp.lpSum(P_sto_pos[i]*(1/self.plant_conf['eta_disch']) + self.plant_conf['eta_ch']*P_sto_neg[i] for i in set_I), + sense=plp.LpConstraintEQ, + rhs=(soc_init - soc_final)*self.plant_conf['Enom']/self.timeStep) + }) opt_model.constraints = constraints ## Finally, we call the solver to solve our optimization model: diff --git a/src/emhass/templates/index.html b/src/emhass/templates/index.html index c53da466..03587c94 100644 --- a/src/emhass/templates/index.html +++ b/src/emhass/templates/index.html @@ -22,7 +22,7 @@

Use the buttons below to fit - < diff --git a/src/emhass/web_server.py b/src/emhass/web_server.py index 4a038ea4..4f282637 100644 --- a/src/emhass/web_server.py +++ b/src/emhass/web_server.py @@ -17,15 +17,8 @@ from emhass.command_line import forecast_model_fit, forecast_model_predict, forecast_model_tune from emhass.command_line import publish_data - # Define the Flask instance app = Flask(__name__) -app.logger.setLevel(logging.DEBUG) -app.logger.propagate = False -ch = logging.StreamHandler() -formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') -ch.setFormatter(formatter) -app.logger.addHandler(ch) def get_injection_dict(df, plot_size = 1366): # Create plots @@ -129,6 +122,10 @@ def build_params(params, options, addon): params['optim_conf'][16]['lp_solver'] = options['lp_solver'] params['optim_conf'][17]['lp_solver_path'] = options['lp_solver_path'] params['optim_conf'][18]['set_nocharge_from_grid'] = options['set_nocharge_from_grid'] + params['optim_conf'][19]['set_nodischarge_to_grid'] = options['set_nodischarge_to_grid'] + params['optim_conf'][20]['set_battery_dynamic'] = options['set_battery_dynamic'] + params['optim_conf'][21]['battery_dynamic_max'] = options['battery_dynamic_max'] + params['optim_conf'][22]['battery_dynamic_min'] = options['battery_dynamic_min'] # Updating variables in plant_conf params['plant_conf'][0]['P_grid_max'] = options['maximum_power_from_grid'] params['plant_conf'][1]['module_model'] = [i['pv_module_model'] for i in options['list_pv_module_model']] @@ -297,6 +294,7 @@ def action_call(action_name): # The cost function costfun = options['costfun'] # Some data from options + logging_level = options['logging_level'] url_from_options = options['hass_url'] if url_from_options == 'empty': url = hass_url+"/config" @@ -324,6 +322,7 @@ def action_call(action_name): } else: costfun = os.getenv('LOCAL_COSTFUN', default='profit') + logging_level = os.getenv('LOGGING_LEVEL', default='INFO') with open('/app/secrets_emhass.yaml', 'r') as file: params_secrets = yaml.load(file, Loader=yaml.FullLoader) hass_url = params_secrets['hass_url'] @@ -333,6 +332,28 @@ def action_call(action_name): with open(str(data_path / 'params.pkl'), "wb") as fid: pickle.dump((config_path, params), fid) + # Define logger + ch = logging.StreamHandler() + formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') + ch.setFormatter(formatter) + if logging_level == "DEBUG": + app.logger.setLevel(logging.DEBUG) + ch.setLevel(logging.DEBUG) + elif logging_level == "INFO": + app.logger.setLevel(logging.INFO) + ch.setLevel(logging.INFO) + elif logging_level == "WARNING": + app.logger.setLevel(logging.WARNING) + ch.setLevel(logging.WARNING) + elif logging_level == "ERROR": + app.logger.setLevel(logging.ERROR) + ch.setLevel(logging.ERROR) + else: + app.logger.setLevel(logging.DEBUG) + ch.setLevel(logging.DEBUG) + app.logger.propagate = False + app.logger.addHandler(ch) + # Launch server port = int(os.environ.get('PORT', 5000)) app.logger.info("Launching the emhass webserver at: http://"+web_ui_url+":"+str(port)) diff --git a/tests/test_forecast.py b/tests/test_forecast.py index 4f03171c..cb9611f8 100644 --- a/tests/test_forecast.py +++ b/tests/test_forecast.py @@ -406,13 +406,15 @@ def test_get_load_forecast_mlforecaster(self): self.assertIsInstance(P_load_forecast.index, pd.core.indexes.datetimes.DatetimeIndex) self.assertIsInstance(P_load_forecast.index.dtype, pd.core.dtypes.dtypes.DatetimeTZDtype) self.assertEqual(P_load_forecast.index.tz, self.fcst.time_zone) + self.assertTrue((P_load_forecast.index == self.fcst.forecast_dates).all()) self.assertEqual(len(self.P_PV_forecast), len(P_load_forecast)) def test_get_load_cost_forecast(self): df_input_data = self.fcst.get_load_cost_forecast(self.df_input_data) self.assertTrue(self.fcst.var_load_cost in df_input_data.columns) self.assertTrue(df_input_data.isnull().sum().sum()==0) - df_input_data = self.fcst.get_load_cost_forecast(self.df_input_data, method='csv') + df_input_data = self.fcst.get_load_cost_forecast(self.df_input_data, method='csv', + csv_path='/data/data_load_cost_forecast.csv') self.assertTrue(self.fcst.var_load_cost in df_input_data.columns) self.assertTrue(df_input_data.isnull().sum().sum()==0) @@ -420,7 +422,8 @@ def test_get_prod_price_forecast(self): df_input_data = self.fcst.get_prod_price_forecast(self.df_input_data) self.assertTrue(self.fcst.var_prod_price in df_input_data.columns) self.assertTrue(df_input_data.isnull().sum().sum()==0) - df_input_data = self.fcst.get_prod_price_forecast(self.df_input_data, method='csv') + df_input_data = self.fcst.get_prod_price_forecast(self.df_input_data, method='csv', + csv_path='/data/data_load_cost_forecast.csv') self.assertTrue(self.fcst.var_prod_price in df_input_data.columns) self.assertTrue(df_input_data.isnull().sum().sum()==0) diff --git a/tests/test_optimization.py b/tests/test_optimization.py index 4ed60010..50ca4452 100644 --- a/tests/test_optimization.py +++ b/tests/test_optimization.py @@ -83,9 +83,11 @@ def test_perform_dayahead_forecast_optim(self): now_precise = datetime.now(self.input_data_dict['retrieve_hass_conf']['time_zone']).replace(second=0, microsecond=0) idx_closest = self.opt_res_dayahead.index.get_indexer([now_precise], method='ffill')[0] idx_closest = self.opt_res_dayahead.index.get_indexer([now_precise], method='nearest')[0] - # Test the battery + # Test the battery, dynamics and grid exchange contraints self.optim_conf.update({'set_use_battery': True}) self.optim_conf.update({'set_nocharge_from_grid': True}) + self.optim_conf.update({'set_battery_dynamic': True}) + self.optim_conf.update({'set_nodischarge_to_grid': True}) self.opt = optimization(self.retrieve_hass_conf, self.optim_conf, self.plant_conf, self.fcst.var_load_cost, self.fcst.var_prod_price, self.costfun, root, logger)