diff --git a/CHANGELOG.md b/CHANGELOG.md index 97cf25b0..733006c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.10.0 - Unreleased +### Improvement +- Added support for hybrid inverters +- Implemented a new `continual_publish` service that avoid the need of setting a special automation for data publish. Thanks to @GeoDerp +- Implement a deferrable load start penalty functionality. Thanks to @werdnum + - This feature also implement a `def_current_state` that can be passed at runtime to let the optimization consider that a deferrable load is currently scheduled or under operation when launching the optimization task +### Fix +- Fixed forecast methods to treat delta_forecast higher than 1 day +- Fixed solar.forecast wrong interpolation of nan values + ## 0.9.1 - 2024-05-13 ### Fix - Fix patch for issue with paths to modules and inverters database diff --git a/config_emhass.yaml b/config_emhass.yaml index 6d306e51..bc2ac8d9 100644 --- a/config_emhass.yaml +++ b/config_emhass.yaml @@ -77,6 +77,7 @@ plant_conf: - 16 strings_per_inverter: # The number of used strings per inverter - 1 + inverter_is_hybrid: False # Set if it is a hybrid inverter (PV+batteries) or not Pd_max: 1000 # If your system has a battery (set_use_battery=True), the maximum discharge power in Watts Pc_max: 1000 # If your system has a battery (set_use_battery=True), the maximum charge power in Watts eta_disch: 0.95 # If your system has a battery (set_use_battery=True), the discharge efficiency diff --git a/options.json b/options.json index 0b154ed1..7e6aa849 100644 --- a/options.json +++ b/options.json @@ -134,6 +134,7 @@ "strings_per_inverter": 1 } ], + "inverter_is_hybrid": false, "set_use_battery": false, "battery_discharge_power_max": 1000, "battery_charge_power_max": 1000, diff --git a/scripts/script_debug_optim.py b/scripts/script_debug_optim.py new file mode 100644 index 00000000..57383c5b --- /dev/null +++ b/scripts/script_debug_optim.py @@ -0,0 +1,118 @@ +# -*- coding: utf-8 -*- +import pickle +import numpy as np +import pandas as pd +import pathlib +import plotly.express as px +import plotly.subplots as sp +import plotly.io as pio +pio.renderers.default = 'browser' +pd.options.plotting.backend = "plotly" + +from emhass.retrieve_hass import RetrieveHass +from emhass.optimization import Optimization +from emhass.forecast import Forecast +from emhass.utils import get_root, get_yaml_parse, get_days_list, get_logger + +# the root folder +root = str(get_root(__file__, num_parent=2)) +emhass_conf = {} +emhass_conf['config_path'] = pathlib.Path(root) / 'config_emhass.yaml' +emhass_conf['data_path'] = pathlib.Path(root) / 'data/' +emhass_conf['root_path'] = pathlib.Path(root) + +# create logger +logger, ch = get_logger(__name__, emhass_conf, save_to_file=False) + +if __name__ == '__main__': + get_data_from_file = True + params = None + show_figures = True + template = 'presentation' + + retrieve_hass_conf, optim_conf, plant_conf = get_yaml_parse(emhass_conf, use_secrets=False) + retrieve_hass_conf, optim_conf, plant_conf = \ + retrieve_hass_conf, optim_conf, plant_conf + rh = RetrieveHass(retrieve_hass_conf['hass_url'], retrieve_hass_conf['long_lived_token'], + retrieve_hass_conf['freq'], retrieve_hass_conf['time_zone'], + params, emhass_conf, logger) + if get_data_from_file: + with open(emhass_conf['data_path'] / 'test_df_final.pkl', 'rb') as inp: + rh.df_final, days_list, var_list = pickle.load(inp) + retrieve_hass_conf['var_load'] = str(var_list[0]) + retrieve_hass_conf['var_PV'] = str(var_list[1]) + retrieve_hass_conf['var_interp'] = [retrieve_hass_conf['var_PV'], retrieve_hass_conf['var_load']] + retrieve_hass_conf['var_replace_zero'] = [retrieve_hass_conf['var_PV']] + else: + days_list = get_days_list(retrieve_hass_conf['days_to_retrieve']) + var_list = [retrieve_hass_conf['var_load'], retrieve_hass_conf['var_PV']] + rh.get_data(days_list, var_list, + minimal_response=False, significant_changes_only=False) + rh.prepare_data(retrieve_hass_conf['var_load'], load_negative = retrieve_hass_conf['load_negative'], + set_zero_min = retrieve_hass_conf['set_zero_min'], + var_replace_zero = retrieve_hass_conf['var_replace_zero'], + var_interp = retrieve_hass_conf['var_interp']) + df_input_data = rh.df_final.copy() + + fcst = Forecast(retrieve_hass_conf, optim_conf, plant_conf, + params, emhass_conf, logger, get_data_from_file=get_data_from_file) + df_weather = fcst.get_weather_forecast(method='csv') + P_PV_forecast = fcst.get_power_from_weather(df_weather) + P_load_forecast = fcst.get_load_forecast(method=optim_conf['load_forecast_method']) + df_input_data = pd.concat([P_PV_forecast, P_load_forecast], axis=1) + df_input_data.columns = ['P_PV_forecast', 'P_load_forecast'] + + df_input_data = fcst.get_load_cost_forecast(df_input_data) + df_input_data = fcst.get_prod_price_forecast(df_input_data) + input_data_dict = { + 'retrieve_hass_conf': retrieve_hass_conf, + } + + # Set special debug cases + optim_conf.update({'lp_solver': 'PULP_CBC_CMD'}) # set the name of the linear programming solver that will be used. Options are 'PULP_CBC_CMD', 'GLPK_CMD' and 'COIN_CMD'. + optim_conf.update({'lp_solver_path': 'empty'}) # set the path to the LP solver, COIN_CMD default is /usr/bin/cbc + optim_conf.update({'treat_def_as_semi_cont': [True, True]}) + optim_conf.update({'set_def_constant': [False, False]}) + # optim_conf.update({'P_deferrable_nom': [[500.0, 100.0, 100.0, 500.0], 750.0]}) + + optim_conf.update({'set_use_battery': False}) + optim_conf.update({'set_nocharge_from_grid': False}) + optim_conf.update({'set_battery_dynamic': True}) + optim_conf.update({'set_nodischarge_to_grid': True}) + + plant_conf.update({'inverter_is_hybrid': True}) + + df_input_data.loc[df_input_data.index[25:30],'unit_prod_price'] = -0.07 + df_input_data['P_PV_forecast'] = df_input_data['P_PV_forecast']*2 + P_PV_forecast = P_PV_forecast*2 + + costfun = 'profit' + opt = Optimization(retrieve_hass_conf, optim_conf, plant_conf, + fcst.var_load_cost, fcst.var_prod_price, + costfun, emhass_conf, logger) + opt_res_dayahead = opt.perform_dayahead_forecast_optim( + df_input_data, P_PV_forecast, P_load_forecast) + + # Let's plot the input data + fig_inputs_dah = df_input_data.plot() + fig_inputs_dah.layout.template = template + fig_inputs_dah.update_yaxes(title_text = "Powers (W) and Costs(EUR)") + fig_inputs_dah.update_xaxes(title_text = "Time") + if show_figures: + fig_inputs_dah.show() + + vars_to_plot = ['P_deferrable0', 'P_deferrable1','P_grid', 'P_PV', 'P_PV_curtailment'] + if plant_conf['inverter_is_hybrid']: + vars_to_plot = vars_to_plot + ['P_hybrid_inverter'] + if optim_conf['set_use_battery']: + vars_to_plot = vars_to_plot + ['P_batt'] + fig_res_dah = opt_res_dayahead[vars_to_plot].plot() # 'P_def_start_0', 'P_def_start_1', 'P_def_bin2_0', 'P_def_bin2_1' + fig_res_dah.layout.template = template + fig_res_dah.update_yaxes(title_text = "Powers (W)") + fig_res_dah.update_xaxes(title_text = "Time") + if show_figures: + fig_res_dah.show() + + print("System with: PV, two deferrable loads, dayahead optimization, profit >> total cost function sum: "+\ + str(opt_res_dayahead['cost_profit'].sum())+", Status: "+opt_res_dayahead['optim_status'].unique().item()) + \ No newline at end of file diff --git a/src/emhass/optimization.py b/src/emhass/optimization.py index a7f97d2e..1ffdd5a6 100644 --- a/src/emhass/optimization.py +++ b/src/emhass/optimization.py @@ -3,6 +3,9 @@ import logging import copy +import pathlib +import bz2 +import pickle as cPickle from typing import Optional, Tuple import pandas as pd import numpy as np @@ -171,12 +174,16 @@ def perform_optimization(self, data_opt: pd.DataFrame, P_PV: np.array, P_load: n P_deferrable = [] P_def_bin1 = [] for k in range(self.optim_conf['num_def_loads']): + if type(self.optim_conf['P_deferrable_nom'][k]) == list: + upBound = np.max(self.optim_conf['P_deferrable_nom'][k]) + else: + upBound = self.optim_conf['P_deferrable_nom'][k] if self.optim_conf['treat_def_as_semi_cont'][k]: P_deferrable.append({(i):plp.LpVariable(cat='Continuous', name="P_deferrable{}_{}".format(k, i)) for i in set_I}) else: P_deferrable.append({(i):plp.LpVariable(cat='Continuous', - lowBound=0, upBound=self.optim_conf['P_deferrable_nom'][k], + lowBound=0, upBound=upBound, name="P_deferrable{}_{}".format(k, i)) for i in set_I}) P_def_bin1.append({(i):plp.LpVariable(cat='Binary', name="P_def{}_bin1_{}".format(k, i)) for i in set_I}) @@ -205,7 +212,12 @@ def perform_optimization(self, data_opt: pd.DataFrame, P_PV: np.array, P_load: n if self.costfun == 'self-consumption': SC = {(i):plp.LpVariable(cat='Continuous', name="SC_{}".format(i)) for i in set_I} - + if self.plant_conf['inverter_is_hybrid']: + P_hybrid_inverter = {(i):plp.LpVariable(cat='Continuous', + name="P_hybrid_inverter{}".format(i)) for i in set_I} + P_PV_curtailment = {(i):plp.LpVariable(cat='Continuous', lowBound=0, + name="P_PV_curtailment{}".format(i)) for i in set_I} + ## Define objective P_def_sum= [] for i in set_I: @@ -240,35 +252,113 @@ def perform_optimization(self, data_opt: pd.DataFrame, P_PV: np.array, P_load: n self.optim_conf['weight_battery_charge']*P_sto_neg[i]) for i in set_I) # Add term penalizing each startup where configured - if ( - "def_start_penalty" in self.optim_conf - and self.optim_conf["def_start_penalty"] - ): + if ("def_start_penalty" in self.optim_conf and self.optim_conf["def_start_penalty"]): for k in range(self.optim_conf["num_def_loads"]): - if ( - len(self.optim_conf["def_start_penalty"]) > k - and self.optim_conf["def_start_penalty"][k] - ): + if (len(self.optim_conf["def_start_penalty"]) > k and self.optim_conf["def_start_penalty"][k]): objective = objective + plp.lpSum( - -0.001 - * self.timeStep - * self.optim_conf["def_start_penalty"][k] - * P_def_start[k][i] - * unit_load_cost[i] - * self.optim_conf['P_deferrable_nom'][k] - for i in set_I - ) + -0.001 * self.timeStep * self.optim_conf["def_start_penalty"][k] * P_def_start[k][i] *\ + unit_load_cost[i] * self.optim_conf['P_deferrable_nom'][k] + for i in set_I) opt_model.setObjective(objective) ## 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} + if self.plant_conf['inverter_is_hybrid']: + constraints = {"constraint_main1_{}".format(i) : + plp.LpConstraint( + e = P_hybrid_inverter[i] - P_def_sum[i] - P_load[i] + P_grid_neg[i] + P_grid_pos[i] , + sense = plp.LpConstraintEQ, + rhs = 0) + for i in set_I} + else: + constraints = {"constraint_main1_{}".format(i) : + plp.LpConstraint( + e = P_PV[i] - P_PV_curtailment[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} + + # Constraint for hybrid inverter and curtailment cases + if type(self.plant_conf['module_model']) == list: + P_nom_inverter = 0.0 + for i in range(len(self.plant_conf['inverter_model'])): + if type(self.plant_conf['inverter_model'][i]) == str: + cec_inverters = bz2.BZ2File(pathlib.Path(__file__).parent / 'data/cec_inverters.pbz2', "rb") + cec_inverters = cPickle.load(cec_inverters) + inverter = cec_inverters[self.plant_conf['inverter_model'][i]] + P_nom_inverter += inverter.Paco + else: + P_nom_inverter += self.plant_conf['inverter_model'][i] + else: + if type(self.plant_conf['inverter_model'][i]) == str: + cec_inverters = bz2.BZ2File(pathlib.Path(__file__).parent / 'data/cec_inverters.pbz2', "rb") + cec_inverters = cPickle.load(cec_inverters) + inverter = cec_inverters[self.plant_conf['inverter_model']] + P_nom_inverter = inverter.Paco + else: + P_nom_inverter = self.plant_conf['inverter_model'] + if self.plant_conf['inverter_is_hybrid']: + constraints.update({"constraint_hybrid_inverter1_{}".format(i) : + plp.LpConstraint( + e = P_PV[i] - P_PV_curtailment[i] + P_sto_pos[i] + P_sto_neg[i] - P_nom_inverter, + sense = plp.LpConstraintLE, + rhs = 0) + for i in set_I}) + constraints.update({"constraint_hybrid_inverter2_{}".format(i) : + plp.LpConstraint( + e = P_PV[i] - P_PV_curtailment[i] + P_sto_pos[i] + P_sto_neg[i] - P_hybrid_inverter[i], + sense = plp.LpConstraintEQ, + rhs = 0) + for i in set_I}) + else: + constraints.update({"constraint_curtailment_{}".format(i) : + plp.LpConstraint( + e = P_PV[i] - P_PV_curtailment[i] - P_nom_inverter, + sense = plp.LpConstraintLE, + rhs = 0) + for i in set_I}) + + # Constraint for sequence of deferrable + # WARNING: This is experimental, formulation seems correct but feasibility problems. + # Probably uncomptabile with other constraints + for k in range(self.optim_conf['num_def_loads']): + if type(self.optim_conf['P_deferrable_nom'][k]) == list: + power_sequence = self.optim_conf['P_deferrable_nom'][k] + sequence_length = len(power_sequence) + def create_matrix(input_list, n): + matrix = [] + for i in range(n + 1): + row = [0] * i + input_list + [0] * (n - i) + matrix.append(row[:n*2]) + return matrix + matrix = create_matrix(power_sequence, n-sequence_length) + y = plp.LpVariable.dicts(f"y{k}", (i for i in range(len(matrix))), cat='Binary') + constraints.update({f"single_value_constraint_{k}" : + plp.LpConstraint( + e = plp.lpSum(y[i] for i in range(len(matrix))) - 1, + sense = plp.LpConstraintEQ, + rhs = 0) + }) + constraints.update({f"pdef{k}_sumconstraint_{i}" : + plp.LpConstraint( + e = plp.lpSum(P_deferrable[k][i] for i in set_I) - np.sum(power_sequence), + sense = plp.LpConstraintEQ, + rhs = 0) + }) + constraints.update({f"pdef{k}_positive_constraint_{i}" : + plp.LpConstraint( + e = P_deferrable[k][i], + sense = plp.LpConstraintGE, + rhs = 0) + for i in set_I}) + for num, mat in enumerate(matrix): + constraints.update({f"pdef{k}_value_constraint_{num}_{i}" : + plp.LpConstraint( + e = P_deferrable[k][i] - mat[i]*y[num], + 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': @@ -302,118 +392,141 @@ def perform_optimization(self, data_opt: pd.DataFrame, P_PV: np.array, P_load: n # 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]) - }) - # Ensure deferrable loads consume energy between def_start_timestep & def_end_timestep - self.logger.debug("Deferrable load {}: Proposed optimization window: {} --> {}".format(k, def_start_timestep[k], def_end_timestep[k])) - def_start, def_end, warning = Optimization.validate_def_timewindow(def_start_timestep[k], def_end_timestep[k], ceil(def_total_hours[k]/self.timeStep), n) - if warning is not None: - self.logger.warning("Deferrable load {} : {}".format(k, warning)) - self.logger.debug("Deferrable load {}: Validated optimization window: {} --> {}".format(k, def_start, def_end)) - if def_start > 0: - constraints.update({"constraint_defload{}_start_timestep".format(k) : + if type(self.optim_conf['P_deferrable_nom'][k]) == list: + continue + else: + # 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 range(0, def_start)), + e = plp.lpSum(P_deferrable[k][i]*self.timeStep for i in set_I), sense = plp.LpConstraintEQ, - rhs = 0) + rhs = def_total_hours[k]*self.optim_conf['P_deferrable_nom'][k]) }) - if def_end > 0: - constraints.update({"constraint_defload{}_end_timestep".format(k) : + # Ensure deferrable loads consume energy between def_start_timestep & def_end_timestep + self.logger.debug("Deferrable load {}: Proposed optimization window: {} --> {}".format( + k, def_start_timestep[k], def_end_timestep[k])) + def_start, def_end, warning = Optimization.validate_def_timewindow( + def_start_timestep[k], def_end_timestep[k], ceil(def_total_hours[k]/self.timeStep), n) + if warning is not None: + self.logger.warning("Deferrable load {} : {}".format(k, warning)) + self.logger.debug("Deferrable load {}: Validated optimization window: {} --> {}".format( + k, def_start, def_end)) + if def_start > 0: + constraints.update({"constraint_defload{}_start_timestep".format(k) : + plp.LpConstraint( + e = plp.lpSum(P_deferrable[k][i]*self.timeStep for i in range(0, def_start)), + sense = plp.LpConstraintEQ, + rhs = 0) + }) + if def_end > 0: + constraints.update({"constraint_defload{}_end_timestep".format(k) : + plp.LpConstraint( + e = plp.lpSum(P_deferrable[k][i]*self.timeStep for i in range(def_end, n)), + sense = plp.LpConstraintEQ, + rhs = 0) + }) + # 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}) + 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 + if self.optim_conf['set_def_constant'][k]: + constraints.update({"constraint_pdef{}_start1_{}".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}) + 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] if i-1 >= 0 else 0), + sense=plp.LpConstraintGE, + rhs=0) + for i in set_I}) + constraints.update({"constraint_pdef{}_start3".format(k) : plp.LpConstraint( - e = plp.lpSum(P_deferrable[k][i]*self.timeStep for i in range(def_end, n)), + e = plp.lpSum(P_def_start[k][i] for i in set_I), sense = plp.LpConstraintEQ, - rhs = 0) + rhs = 1) }) - - # 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) : + # 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}) + 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 + current_state = 0 + if ("def_current_state" in self.optim_conf and len(self.optim_conf["def_current_state"]) > k): + current_state = 1 if self.optim_conf["def_current_state"][k] else 0 + # P_deferrable < P_def_bin2 * 1 million + # P_deferrable must be zero if P_def_bin2 is zero + constraints.update({"constraint_pdef{}_start1_{}".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, + 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{}_semicont2_{}".format(k, i) : + # P_deferrable - P_def_bin2 <= 0 + # P_def_bin2 must be zero if P_deferrable is zero + constraints.update({"constraint_pdef{}_start1a_{}".format(k, i): plp.LpConstraint( - e=P_deferrable[k][i] - self.optim_conf['P_deferrable_nom'][k]*P_def_bin1[k][i], + e=P_def_bin2[k][i] - P_deferrable[k][i], sense=plp.LpConstraintLE, rhs=0) for i in set_I}) - - # Treat the number of starts for a deferrable load - current_state = 0 - if ( - "def_current_state" in self.optim_conf - and len(self.optim_conf["def_current_state"]) > k - ): - current_state = 1 if self.optim_conf["def_current_state"][k] else 0 - # P_deferrable < P_def_bin2 * 1 million - # P_deferrable must be zero if P_def_bin2 is zero - constraints.update( - { - "constraint_pdef{}_start1_{}".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 - } - ) - # P_deferrable - P_def_bin2 <= 0 - # P_def_bin2 must be zero if P_deferrable is zero - constraints.update( - { - "constraint_pdef{}_start1a_{}".format(k, i): plp.LpConstraint( - e=P_def_bin2[k][i] - P_deferrable[k][i], - sense=plp.LpConstraintLE, - rhs=0, - ) - for i in set_I - } - ) - # P_def_start + P_def_bin2[i-1] >= P_def_bin2[i] - # If load is on this cycle (P_def_bin2[i] is 1) then P_def_start must be 1 OR P_def_bin2[i-1] must be 1 - # For first timestep, use current state if provided by caller. - constraints.update( - { - "constraint_pdef{}_start2_{}".format(k, i): plp.LpConstraint( + # P_def_start + P_def_bin2[i-1] >= P_def_bin2[i] + # If load is on this cycle (P_def_bin2[i] is 1) then P_def_start must be 1 OR P_def_bin2[i-1] must be 1 + # For first timestep, use current state if provided by caller. + 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] if i - 1 >= 0 else current_state), sense=plp.LpConstraintGE, - rhs=0, - ) - for i in set_I - } - ) - # P_def_bin2[i-1] + P_def_start <= 1 - # If load started this cycle (P_def_start[i] is 1) then P_def_bin2[i-1] must be 0 - constraints.update({"constraint_pdef{}_start3_{}".format(k, i): - plp.LpConstraint( - e=(P_def_bin2[k][i-1] if i-1 >= 0 else 0) + P_def_start[k][i], - sense=plp.LpConstraintLE, - rhs=1) - for i in set_I}) - if self.optim_conf['set_def_constant'][k]: - # P_def_start[i] must be 1 for exactly 1 value of i - constraints.update({"constraint_pdef{}_start4".format(k) : - plp.LpConstraint( - e = plp.lpSum(P_def_start[k][i] for i in set_I), - sense = plp.LpConstraintEQ, - rhs = 1) - }) - # P_def_bin2 must be 1 for exactly the correct number of timesteps. - constraints.update({"constraint_pdef{}_start5".format(k) : - plp.LpConstraint( - e = plp.lpSum(P_def_bin2[k][i] for i in set_I), - sense = plp.LpConstraintEQ, - rhs = def_total_hours[k]/self.timeStep) - }) + rhs=0) + for i in set_I}) + # P_def_bin2[i-1] + P_def_start <= 1 + # If load started this cycle (P_def_start[i] is 1) then P_def_bin2[i-1] must be 0 + constraints.update({"constraint_pdef{}_start3_{}".format(k, i): + plp.LpConstraint( + e=(P_def_bin2[k][i-1] if i-1 >= 0 else 0) + P_def_start[k][i], + sense=plp.LpConstraintLE, + rhs=1) + for i in set_I}) + if self.optim_conf['set_def_constant'][k]: + # P_def_start[i] must be 1 for exactly 1 value of i + constraints.update({"constraint_pdef{}_start4".format(k) : + plp.LpConstraint( + e = plp.lpSum(P_def_start[k][i] for i in set_I), + sense = plp.LpConstraintEQ, + rhs = 1) + }) + # P_def_bin2 must be 1 for exactly the correct number of timesteps. + constraints.update({"constraint_pdef{}_start5".format(k) : + plp.LpConstraint( + e = plp.lpSum(P_def_bin2[k][i] for i in set_I), + sense = plp.LpConstraintEQ, + rhs = def_total_hours[k]/self.timeStep) + }) # The battery constraints if self.optim_conf['set_use_battery']: @@ -529,6 +642,9 @@ def perform_optimization(self, data_opt: pd.DataFrame, P_PV: np.array, P_load: n SOC_opt.append(SOCinit - SOC_opt_delta[i]) SOCinit = SOC_opt[i] opt_tp["SOC_opt"] = SOC_opt + if self.plant_conf['inverter_is_hybrid']: + opt_tp["P_hybrid_inverter"] = [P_hybrid_inverter[i].varValue for i in set_I] + opt_tp["P_PV_curtailment"] = [P_PV_curtailment[i].varValue for i in set_I] opt_tp.index = data_opt.index # Lets compute the optimal cost function diff --git a/src/emhass/utils.py b/src/emhass/utils.py index edfcf04c..3dcedbf7 100644 --- a/src/emhass/utils.py +++ b/src/emhass/utils.py @@ -739,63 +739,30 @@ def build_params(params: dict, params_secrets: dict, options: dict, addon: int, """ if addon == 1: # Updating variables in retrieve_hass_conf - params["retrieve_hass_conf"]["freq"] = options.get( - "optimization_time_step", params["retrieve_hass_conf"]["freq"] - ) - params["retrieve_hass_conf"]["days_to_retrieve"] = options.get( - "historic_days_to_retrieve", - params["retrieve_hass_conf"]["days_to_retrieve"], - ) - params["retrieve_hass_conf"]["var_PV"] = options.get( - "sensor_power_photovoltaics", params["retrieve_hass_conf"]["var_PV"] - ) - params["retrieve_hass_conf"]["var_load"] = options.get( - "sensor_power_load_no_var_loads", params["retrieve_hass_conf"]["var_load"] - ) - params["retrieve_hass_conf"]["load_negative"] = options.get( - "load_negative", params["retrieve_hass_conf"]["load_negative"] - ) - params["retrieve_hass_conf"]["set_zero_min"] = options.get( - "set_zero_min", params["retrieve_hass_conf"]["set_zero_min"] - ) + params["retrieve_hass_conf"]["freq"] = options.get("optimization_time_step", params["retrieve_hass_conf"]["freq"]) + params["retrieve_hass_conf"]["days_to_retrieve"] = options.get("historic_days_to_retrieve", params["retrieve_hass_conf"]["days_to_retrieve"]) + params["retrieve_hass_conf"]["var_PV"] = options.get("sensor_power_photovoltaics", params["retrieve_hass_conf"]["var_PV"]) + params["retrieve_hass_conf"]["var_load"] = options.get("sensor_power_load_no_var_loads", params["retrieve_hass_conf"]["var_load"]) + params["retrieve_hass_conf"]["load_negative"] = options.get("load_negative", params["retrieve_hass_conf"]["load_negative"]) + params["retrieve_hass_conf"]["set_zero_min"] = options.get("set_zero_min", params["retrieve_hass_conf"]["set_zero_min"]) params["retrieve_hass_conf"]["var_replace_zero"] = [ - options.get( - "sensor_power_photovoltaics", - params["retrieve_hass_conf"]["var_replace_zero"], - ) + options.get("sensor_power_photovoltaics", params["retrieve_hass_conf"]["var_replace_zero"]) ] params["retrieve_hass_conf"]["var_interp"] = [ - options.get( - "sensor_power_photovoltaics", params["retrieve_hass_conf"]["var_PV"] - ), - options.get( - "sensor_power_load_no_var_loads", - params["retrieve_hass_conf"]["var_load"], - ), + options.get("sensor_power_photovoltaics", params["retrieve_hass_conf"]["var_PV"]), + options.get("sensor_power_load_no_var_loads", params["retrieve_hass_conf"]["var_load"]) ] - params["retrieve_hass_conf"]["method_ts_round"] = options.get( - "method_ts_round", params["retrieve_hass_conf"]["method_ts_round"] - ) - params["retrieve_hass_conf"]["continual_publish"] = options.get( - "continual_publish", params["retrieve_hass_conf"]["continual_publish"] - ) + params["retrieve_hass_conf"]["method_ts_round"] = options.get("method_ts_round", params["retrieve_hass_conf"]["method_ts_round"]) + params["retrieve_hass_conf"]["continual_publish"] = options.get("continual_publish", params["retrieve_hass_conf"]["continual_publish"]) # Update params Secrets if specified params["params_secrets"] = params_secrets - params["params_secrets"]["time_zone"] = options.get( - "time_zone", params_secrets["time_zone"] - ) + params["params_secrets"]["time_zone"] = options.get("time_zone", params_secrets["time_zone"]) params["params_secrets"]["lat"] = options.get("Latitude", params_secrets["lat"]) - params["params_secrets"]["lon"] = options.get( - "Longitude", params_secrets["lon"] - ) + params["params_secrets"]["lon"] = options.get("Longitude", params_secrets["lon"]) params["params_secrets"]["alt"] = options.get("Altitude", params_secrets["alt"]) # Updating variables in optim_conf - params["optim_conf"]["set_use_battery"] = options.get( - "set_use_battery", params["optim_conf"]["set_use_battery"] - ) - params["optim_conf"]["num_def_loads"] = options.get( - "number_of_deferrable_loads", params["optim_conf"]["num_def_loads"] - ) + params["optim_conf"]["set_use_battery"] = options.get("set_use_battery", params["optim_conf"]["set_use_battery"]) + params["optim_conf"]["num_def_loads"] = options.get("number_of_deferrable_loads", params["optim_conf"]["num_def_loads"]) if options.get("list_nominal_power_of_deferrable_loads", None) != None: params["optim_conf"]["P_deferrable_nom"] = [ i["nominal_power_of_deferrable_loads"] @@ -811,43 +778,22 @@ def build_params(params: dict, params_secrets: dict, options: dict, addon: int, i["treat_deferrable_load_as_semi_cont"] for i in options.get("list_treat_deferrable_load_as_semi_cont") ] - params["optim_conf"]["weather_forecast_method"] = options.get( - "weather_forecast_method", params["optim_conf"]["weather_forecast_method"] - ) + params["optim_conf"]["weather_forecast_method"] = options.get("weather_forecast_method", params["optim_conf"]["weather_forecast_method"]) # Update optional param secrets if params["optim_conf"]["weather_forecast_method"] == "solcast": - params["params_secrets"]["solcast_api_key"] = options.get( - "optional_solcast_api_key", - params_secrets.get("solcast_api_key", "123456"), - ) - params["params_secrets"]["solcast_rooftop_id"] = options.get( - "optional_solcast_rooftop_id", - params_secrets.get("solcast_rooftop_id", "123456"), - ) + params["params_secrets"]["solcast_api_key"] = options.get("optional_solcast_api_key", params_secrets.get("solcast_api_key", "123456")) + params["params_secrets"]["solcast_rooftop_id"] = options.get("optional_solcast_rooftop_id", params_secrets.get("solcast_rooftop_id", "123456")) elif params["optim_conf"]["weather_forecast_method"] == "solar.forecast": - params["params_secrets"]["solar_forecast_kwp"] = options.get( - "optional_solar_forecast_kwp", - params_secrets.get("solar_forecast_kwp", 5), - ) - params["optim_conf"]["load_forecast_method"] = options.get( - "load_forecast_method", params["optim_conf"]["load_forecast_method"] - ) - params["optim_conf"]["delta_forecast"] = options.get( - "delta_forecast_daily", params["optim_conf"]["delta_forecast"] - ) - params["optim_conf"]["load_cost_forecast_method"] = options.get( - "load_cost_forecast_method", - params["optim_conf"]["load_cost_forecast_method"], - ) + params["params_secrets"]["solar_forecast_kwp"] = options.get("optional_solar_forecast_kwp", params_secrets.get("solar_forecast_kwp", 5)) + params["optim_conf"]["load_forecast_method"] = options.get("load_forecast_method", params["optim_conf"]["load_forecast_method"]) + params["optim_conf"]["delta_forecast"] = options.get("delta_forecast_daily", params["optim_conf"]["delta_forecast"]) + params["optim_conf"]["load_cost_forecast_method"] = options.get("load_cost_forecast_method", params["optim_conf"]["load_cost_forecast_method"]) if options.get("list_set_deferrable_load_single_constant", None) != None: params["optim_conf"]["set_def_constant"] = [ i["set_deferrable_load_single_constant"] for i in options.get("list_set_deferrable_load_single_constant") ] - if ( - options.get("list_peak_hours_periods_start_hours", None) != None - and options.get("list_peak_hours_periods_end_hours", None) != None - ): + if (options.get("list_peak_hours_periods_start_hours", None) != None and options.get("list_peak_hours_periods_end_hours", None) != None): start_hours_list = [ i["peak_hours_periods_start_hours"] for i in options["list_peak_hours_periods_start_hours"] @@ -859,27 +805,27 @@ def build_params(params: dict, params_secrets: dict, options: dict, addon: int, num_peak_hours = len(start_hours_list) list_hp_periods_list = [{'period_hp_'+str(i+1):[{'start':start_hours_list[i]},{'end':end_hours_list[i]}]} for i in range(num_peak_hours)] params['optim_conf']['list_hp_periods'] = list_hp_periods_list - params['optim_conf']['load_cost_hp'] = options.get('load_peak_hours_cost',params['optim_conf']['load_cost_hp']) + params['optim_conf']['load_cost_hp'] = options.get('load_peak_hours_cost', params['optim_conf']['load_cost_hp']) params['optim_conf']['load_cost_hc'] = options.get('load_offpeak_hours_cost', params['optim_conf']['load_cost_hc']) params['optim_conf']['prod_price_forecast_method'] = options.get('production_price_forecast_method', params['optim_conf']['prod_price_forecast_method']) - params['optim_conf']['prod_sell_price'] = options.get('photovoltaic_production_sell_price',params['optim_conf']['prod_sell_price']) - params['optim_conf']['set_total_pv_sell'] = options.get('set_total_pv_sell',params['optim_conf']['set_total_pv_sell']) - params['optim_conf']['lp_solver'] = options.get('lp_solver',params['optim_conf']['lp_solver']) - params['optim_conf']['lp_solver_path'] = options.get('lp_solver_path',params['optim_conf']['lp_solver_path']) - params['optim_conf']['set_nocharge_from_grid'] = options.get('set_nocharge_from_grid',params['optim_conf']['set_nocharge_from_grid']) - params['optim_conf']['set_nodischarge_to_grid'] = options.get('set_nodischarge_to_grid',params['optim_conf']['set_nodischarge_to_grid']) - params['optim_conf']['set_battery_dynamic'] = options.get('set_battery_dynamic',params['optim_conf']['set_battery_dynamic']) - params['optim_conf']['battery_dynamic_max'] = options.get('battery_dynamic_max',params['optim_conf']['battery_dynamic_max']) - params['optim_conf']['battery_dynamic_min'] = options.get('battery_dynamic_min',params['optim_conf']['battery_dynamic_min']) - params['optim_conf']['weight_battery_discharge'] = options.get('weight_battery_discharge',params['optim_conf']['weight_battery_discharge']) - params['optim_conf']['weight_battery_charge'] = options.get('weight_battery_charge',params['optim_conf']['weight_battery_charge']) + params['optim_conf']['prod_sell_price'] = options.get('photovoltaic_production_sell_price', params['optim_conf']['prod_sell_price']) + params['optim_conf']['set_total_pv_sell'] = options.get('set_total_pv_sell', params['optim_conf']['set_total_pv_sell']) + params['optim_conf']['lp_solver'] = options.get('lp_solver', params['optim_conf']['lp_solver']) + params['optim_conf']['lp_solver_path'] = options.get('lp_solver_path', params['optim_conf']['lp_solver_path']) + params['optim_conf']['set_nocharge_from_grid'] = options.get('set_nocharge_from_grid', params['optim_conf']['set_nocharge_from_grid']) + params['optim_conf']['set_nodischarge_to_grid'] = options.get('set_nodischarge_to_grid', params['optim_conf']['set_nodischarge_to_grid']) + params['optim_conf']['set_battery_dynamic'] = options.get('set_battery_dynamic', params['optim_conf']['set_battery_dynamic']) + params['optim_conf']['battery_dynamic_max'] = options.get('battery_dynamic_max', params['optim_conf']['battery_dynamic_max']) + params['optim_conf']['battery_dynamic_min'] = options.get('battery_dynamic_min', params['optim_conf']['battery_dynamic_min']) + params['optim_conf']['weight_battery_discharge'] = options.get('weight_battery_discharge', params['optim_conf']['weight_battery_discharge']) + params['optim_conf']['weight_battery_charge'] = options.get('weight_battery_charge', params['optim_conf']['weight_battery_charge']) if options.get('list_start_timesteps_of_each_deferrable_load',None) != None: params['optim_conf']['def_start_timestep'] = [i['start_timesteps_of_each_deferrable_load'] for i in options.get('list_start_timesteps_of_each_deferrable_load')] if options.get('list_end_timesteps_of_each_deferrable_load',None) != None: params['optim_conf']['def_end_timestep'] = [i['end_timesteps_of_each_deferrable_load'] for i in options.get('list_end_timesteps_of_each_deferrable_load')] # Updating variables in plant_conf - params['plant_conf']['P_from_grid_max'] = options.get('maximum_power_from_grid',params['plant_conf']['P_from_grid_max']) - params['plant_conf']['P_to_grid_max'] = options.get('maximum_power_to_grid',params['plant_conf']['P_to_grid_max']) + params['plant_conf']['P_from_grid_max'] = options.get('maximum_power_from_grid', params['plant_conf']['P_from_grid_max']) + params['plant_conf']['P_to_grid_max'] = options.get('maximum_power_to_grid', params['plant_conf']['P_to_grid_max']) if options.get('list_pv_module_model',None) != None: params['plant_conf']['module_model'] = [i['pv_module_model'] for i in options.get('list_pv_module_model')] if options.get('list_pv_inverter_model',None) != None: @@ -892,14 +838,15 @@ def build_params(params: dict, params_secrets: dict, options: dict, addon: int, params['plant_conf']['modules_per_string'] = [i['modules_per_string'] for i in options.get('list_modules_per_string')] if options.get('list_strings_per_inverter',None) != None: params['plant_conf']['strings_per_inverter'] = [i['strings_per_inverter'] for i in options.get('list_strings_per_inverter')] - params['plant_conf']['Pd_max'] = options.get('battery_discharge_power_max',params['plant_conf']['Pd_max']) - params['plant_conf']['Pc_max'] = options.get('battery_charge_power_max',params['plant_conf']['Pc_max']) - params['plant_conf']['eta_disch'] = options.get('battery_discharge_efficiency',params['plant_conf']['eta_disch']) - params['plant_conf']['eta_ch'] = options.get('battery_charge_efficiency',params['plant_conf']['eta_ch']) - params['plant_conf']['Enom'] = options.get('battery_nominal_energy_capacity',params['plant_conf']['Enom']) - params['plant_conf']['SOCmin'] = options.get('battery_minimum_state_of_charge',params['plant_conf']['SOCmin']) - params['plant_conf']['SOCmax'] = options.get('battery_maximum_state_of_charge',params['plant_conf']['SOCmax']) - params['plant_conf']['SOCtarget'] = options.get('battery_target_state_of_charge',params['plant_conf']['SOCtarget']) + params["plant_conf"]["inverter_is_hybrid"] = options.get("inverter_is_hybrid", params["plant_conf"]["inverter_is_hybrid"]) + params['plant_conf']['Pd_max'] = options.get('battery_discharge_power_max', params['plant_conf']['Pd_max']) + params['plant_conf']['Pc_max'] = options.get('battery_charge_power_max', params['plant_conf']['Pc_max']) + params['plant_conf']['eta_disch'] = options.get('battery_discharge_efficiency', params['plant_conf']['eta_disch']) + params['plant_conf']['eta_ch'] = options.get('battery_charge_efficiency', params['plant_conf']['eta_ch']) + params['plant_conf']['Enom'] = options.get('battery_nominal_energy_capacity', params['plant_conf']['Enom']) + params['plant_conf']['SOCmin'] = options.get('battery_minimum_state_of_charge', params['plant_conf']['SOCmin']) + params['plant_conf']['SOCmax'] = options.get('battery_maximum_state_of_charge', params['plant_conf']['SOCmax']) + params['plant_conf']['SOCtarget'] = options.get('battery_target_state_of_charge', params['plant_conf']['SOCtarget']) # Check parameter lists have the same amounts as deferrable loads # If not, set defaults it fill in gaps if params['optim_conf']['num_def_loads'] is not len(params['optim_conf']['def_start_timestep']): @@ -929,9 +876,7 @@ def build_params(params: dict, params_secrets: dict, options: dict, addon: int, # days_to_retrieve should be no less then 2 if params["retrieve_hass_conf"]["days_to_retrieve"] < 2: params["retrieve_hass_conf"]["days_to_retrieve"] = 2 - logger.warning( - "days_to_retrieve should not be lower then 2, setting days_to_retrieve to 2. Make sure your sensors also have at least 2 days of history" - ) + logger.warning("days_to_retrieve should not be lower then 2, setting days_to_retrieve to 2. Make sure your sensors also have at least 2 days of history") else: params["params_secrets"] = params_secrets # The params dict