Skip to content

Commit

Permalink
Finished implementing thermal model
Browse files Browse the repository at this point in the history
  • Loading branch information
davidusb-geek committed Jul 9, 2024
1 parent a362b5a commit 8d4d32f
Show file tree
Hide file tree
Showing 8 changed files with 900 additions and 62 deletions.
10 changes: 2 additions & 8 deletions config_emhass.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ optim_conf:
num_def_loads: 2
P_deferrable_nom: # Watts
- 3000.0
- 2000.0
- 750.0
def_total_hours: # hours
- 5
- 8
Expand All @@ -39,13 +39,7 @@ optim_conf:
- False
def_start_penalty: # Set a penalty for each start up of a deferrable load
- 0.0
- 10.0
def_load_config:
- {}
- thermal_config:
heating_rate: 5.0
cooling_constant: 0.1
overshoot_temperature: 24.0
- 0.0
weather_forecast_method: 'scrapper' # options are 'scrapper', 'csv', 'list', 'solcast' and 'solar.forecast'
load_forecast_method: 'naive' # options are 'csv' to load a custom load forecast from a CSV file or 'naive' for a persistance model
load_cost_forecast_method: 'hp_hc_periods' # options are 'hp_hc_periods' for peak and non-peak hours contracts and 'csv' to load custom cost from CSV file
Expand Down
733 changes: 733 additions & 0 deletions docs/images/thermal_load_diagram.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ lpems.md
forecasts.md
mlforecaster.md
mlregressor.md
thermal_model.md
study_case.md
config.md
emhass.md
Expand Down
118 changes: 118 additions & 0 deletions docs/thermal_model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# Deferrable load thermal model

EMHASS supports defining a deferrable load as a thermal model.
This is useful to control thermal equipement as: heaters, air conditioners, etc.
The advantage of using this approach is that you will be able to define your desired room temperature jsut as you will do with your real equipmenet thermostat.
Then EMHASS will deliver the operating schedule to maintain that desired temperature while minimizing the energy bill and taking into account the forecasted outdoor temperature.

A big thanks to @werdnum for proposing this model and the initial code for implementing this.

## The thermal model

The thermal model implemented in EMHASS is a linear model represented by the following equation:

$$
T_{in}^{pred}[k+1] = T_{in}^{pred}[k] + P_{def}[k]\frac{\alpha_h\Delta t}{P_{def}^{nom}}-(\gamma_c(T_{in}^{pred}[k] - T_{out}^{fcst}[k]))
$$

where $k$ is each time instant, $T_{in}^{pred}$ is the indoor predicted temperature, $T_{out}^{fcst}$ is the outdoor forecasted temperature and $P_{def}$ is the deferrable load power.

In this model we can see two main configuration parameters:
- The heating rate $\alpha_h$ in degrees per hour.
- The cooling constant $\gamma_c$ in degrees per hour per degree of cooling.

These parameters are defined according to the thermal characteristics of the building/house.
It was reported by @werdnum, that values of $\alpha_h=5.0$ and $\gamma_c=0.1$ were reasonable in his case.
Of course these parameters should be adapted to each use case. This can be done with with history values of the deferrable load operation and the differents temperatures (indoor/outdoor).

The following diagram tries to represent an example behavior of this model:

![](./images/thermal_load_diagram.svg)

## Implementing the model

To implement this model we need to provide a configuration for the discussed parameters and the input temperatures. You need to pass in the start temperature, the desired room temperature per timestep, and the forecasted outdoor temperature per timestep.

We will control this by using data passed at runtime.
The first step will be to define a new entry `def_load_config`, this will be used as a dictionary to store any needed special configuration for each deferrable load.

For example if we have just **two** deferrable loads and the **second** load is a **thermal load** then we will define `def_load_config` as for example:
```
'def_load_config': {
{},
{'thermal_config': {
'heating_rate': 5.0,
'cooling_constant': 0.1,
'overshoot_temperature': 24.0,
'start_temperature': 20,
'desired_temperatures': [...]
}}
}
```

Here the `desired_temperatures` is a list of float values for each time step.

Now we also need to define the other needed input, the `outdoor_temperature_forecast`, which is a list of float values. The list of floats for `desired_temperatures` and the list in `outdoor_temperature_forecast` should have proper lengths, if using MPC the length should be at least equal to the prediction horizon.

Here is an example modified from a working example provided by @werdnum to pass all the needed data at runtime.
This example is given for the following configuration: just one deferrable load (a thermal load), no PV, no battery, an MPC application, pre-defined heating intervals times.

```
rest_command:
emhass_forecast:
url: http://localhost:5000/action/naive-mpc-optim
method: post
timeout: 300
payload: >
{% macro time_to_timestep(time) -%}
{{ (((today_at(time) - now()) / timedelta(minutes=30)) | round(0, 'ceiling')) % 48 }}
{%- endmacro %}
{%- set horizon = (state_attr('sensor.electricity_price_forecast', 'forecasts')|length) -%}
{%- set heated_intervals = [[time_to_timestep("06:30")|int, time_to_timestep("07:30")|int], [time_to_timestep("17:30")|int, time_to_timestep("23:00")|int]] -%}
{
"prediction_horizon": {{ horizon }},
"load_cost_forecast": {{
(
[states('sensor.general_price')|float(0)]
+ state_attr('sensor.electricity_price_forecast', 'forecasts')
|map(attribute='per_kwh')
|list
)[:horizon]
}},
"pv_power_forecast": [
{% set comma = joiner(", ") -%}
{%- for _ in range(horizon) %}{{ comma() }}0{% endfor %}
],
"def_load_config": {
"thermal_config": {
"heating_rate": 5.0,
"cooling_constant": 0.1,
"overshoot_temperature": 24.0,
"start_temperature": {{ state_attr("climate.living", "current_temperature") }},
"heater_desired_temperatures": [
{%- set comma = joiner(", ") -%}
{%- for i in range(horizon) -%}
{%- set timestep = i -%}
{{ comma() }}
{% for interval in heated_intervals if timestep >= interval[0] and timestep <= interval[1] %}
21
{%- else -%}
0
{%- endfor %}
{%- endfor %}
]
}
},
"outdoor_temperature_forecast": [
{%- set comma = joiner(", ") -%}
{%- for fc in state_attr('weather.openweathermap', 'forecast') if (fc.datetime|as_datetime) > now() and (fc.datetime|as_datetime) - now() < timedelta(hours=24) -%}
{%- if loop.index0 * 2 < horizon -%}
{{ comma() }}{{ fc.temperature }}
{%- if loop.index0 * 2 + 1 < horizon -%}
{{ comma() }}{{ fc.temperature }}
{%- endif -%}
{%- endif -%}
{%- endfor %}
]
}
```
24 changes: 15 additions & 9 deletions scripts/script_thermal_model_optim.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,15 +80,21 @@
# Thermal modeling
df_input_data['outdoor_temperature_forecast'] = [random.normalvariate(10.0, 3.0) for _ in range(48)]

runtimeparams = {'heater_start_temperatures': [0, 20],
'heater_desired_temperatures': [[], [21]*48]}
if 'def_load_config' in optim_conf:
for k in range(len(optim_conf['def_load_config'])):
if 'thermal_config' in optim_conf['def_load_config'][k]:
if 'heater_desired_temperatures' in runtimeparams and len(runtimeparams['heater_desired_temperatures']) > k:
optim_conf["def_load_config"][k]['thermal_config']["desired_temperature"] = runtimeparams["heater_desired_temperatures"][k]
if 'heater_start_temperatures' in runtimeparams and len(runtimeparams['heater_start_temperatures']) > k:
optim_conf["def_load_config"][k]['thermal_config']["start_temperature"] = runtimeparams["heater_start_temperatures"][k]
runtimeparams = {
'def_load_config': [
{},
{'thermal_config': {
'heating_rate': 5.0,
'cooling_constant': 0.1,
'overshoot_temperature': 24.0,
'start_temperature': 20,
'desired_temperatures': [21]*48,
}
}
]
}
if 'def_load_config' in runtimeparams:
optim_conf["def_load_config"] = runtimeparams['def_load_config']

costfun = 'profit'
opt = Optimization(retrieve_hass_conf, optim_conf, plant_conf,
Expand Down
17 changes: 6 additions & 11 deletions src/emhass/command_line.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,6 @@ def weather_forecast_cache(emhass_conf: dict, params: str,

return True


def perfect_forecast_optim(input_data_dict: dict, logger: logging.Logger,
save_data_to_file: Optional[bool] = True,
debug: Optional[bool] = False) -> pd.DataFrame:
Expand Down Expand Up @@ -334,8 +333,6 @@ def perfect_forecast_optim(input_data_dict: dict, logger: logging.Logger,
if not debug:
opt_res.to_csv(
input_data_dict['emhass_conf']['data_path'] / filename, index_label='timestamp')


if not isinstance(input_data_dict["params"],dict):
params = json.loads(input_data_dict["params"])
else:
Expand All @@ -348,7 +345,6 @@ def perfect_forecast_optim(input_data_dict: dict, logger: logging.Logger,

return opt_res


def dayahead_forecast_optim(input_data_dict: dict, logger: logging.Logger,
save_data_to_file: Optional[bool] = False,
debug: Optional[bool] = False) -> pd.DataFrame:
Expand Down Expand Up @@ -379,6 +375,9 @@ def dayahead_forecast_optim(input_data_dict: dict, logger: logging.Logger,
method=input_data_dict['fcst'].optim_conf['prod_price_forecast_method'])
if isinstance(df_input_data_dayahead, bool) and not df_input_data_dayahead:
return False
if "outdoor_temperature_forecast" in input_data_dict["params"]["passed_data"]:
df_input_data_dayahead["outdoor_temperature_forecast"] = \
input_data_dict["params"]["passed_data"]["outdoor_temperature_forecast"]
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
Expand All @@ -397,7 +396,6 @@ def dayahead_forecast_optim(input_data_dict: dict, logger: logging.Logger,
params = json.loads(input_data_dict["params"])
else:
params = input_data_dict["params"]


# if continual_publish, save day_ahead results to data_path/entities json
if input_data_dict["retrieve_hass_conf"].get("continual_publish",False) or params["passed_data"].get("entity_save",False):
Expand All @@ -406,7 +404,6 @@ def dayahead_forecast_optim(input_data_dict: dict, logger: logging.Logger,

return opt_res_dayahead


def naive_mpc_optim(input_data_dict: dict, logger: logging.Logger,
save_data_to_file: Optional[bool] = False,
debug: Optional[bool] = False) -> pd.DataFrame:
Expand Down Expand Up @@ -436,6 +433,9 @@ def naive_mpc_optim(input_data_dict: dict, logger: logging.Logger,
df_input_data_dayahead, method=input_data_dict['fcst'].optim_conf['prod_price_forecast_method'])
if isinstance(df_input_data_dayahead, bool) and not df_input_data_dayahead:
return False
if "outdoor_temperature_forecast" in input_data_dict["params"]["passed_data"]:
df_input_data_dayahead["outdoor_temperature_forecast"] = \
input_data_dict["params"]["passed_data"]["outdoor_temperature_forecast"]
# The specifics params for the MPC at runtime
prediction_horizon = input_data_dict["params"]["passed_data"]["prediction_horizon"]
soc_init = input_data_dict["params"]["passed_data"]["soc_init"]
Expand Down Expand Up @@ -471,7 +471,6 @@ def naive_mpc_optim(input_data_dict: dict, logger: logging.Logger,

return opt_res_naive_mpc


def forecast_model_fit(input_data_dict: dict, logger: logging.Logger,
debug: Optional[bool] = False) -> Tuple[pd.DataFrame, pd.DataFrame, MLForecaster]:
"""Perform a forecast model fit from training data retrieved from Home Assistant.
Expand Down Expand Up @@ -507,7 +506,6 @@ def forecast_model_fit(input_data_dict: dict, logger: logging.Logger,
pickle.dump(mlf, outp, pickle.HIGHEST_PROTOCOL)
return df_pred, df_pred_backtest, mlf


def forecast_model_predict(input_data_dict: dict, logger: logging.Logger,
use_last_window: Optional[bool] = True,
debug: Optional[bool] = False, mlf: Optional[MLForecaster] = None
Expand Down Expand Up @@ -585,7 +583,6 @@ def forecast_model_predict(input_data_dict: dict, logger: logging.Logger,
type_var="mlforecaster", publish_prefix=publish_prefix)
return predictions


def forecast_model_tune(input_data_dict: dict, logger: logging.Logger,
debug: Optional[bool] = False, mlf: Optional[MLForecaster] = None
) -> Tuple[pd.DataFrame, MLForecaster]:
Expand Down Expand Up @@ -626,7 +623,6 @@ def forecast_model_tune(input_data_dict: dict, logger: logging.Logger,
pickle.dump(mlf, outp, pickle.HIGHEST_PROTOCOL)
return df_pred_optim, mlf


def regressor_model_fit(input_data_dict: dict, logger: logging.Logger,
debug: Optional[bool] = False) -> MLRegressor:
"""Perform a forecast model fit from training data retrieved from Home Assistant.
Expand Down Expand Up @@ -681,7 +677,6 @@ def regressor_model_fit(input_data_dict: dict, logger: logging.Logger,
pickle.dump(mlr, outp, pickle.HIGHEST_PROTOCOL)
return mlr


def regressor_model_predict(input_data_dict: dict, logger: logging.Logger,
debug: Optional[bool] = False, mlr: Optional[MLRegressor] = None
) -> np.ndarray:
Expand Down
8 changes: 4 additions & 4 deletions src/emhass/optimization.py
Original file line number Diff line number Diff line change
Expand Up @@ -412,7 +412,7 @@ def create_matrix(input_list, n):
heating_rate = hc["heating_rate"]
overshoot_temperature = hc["overshoot_temperature"]
outdoor_temperature_forecast = data_opt['outdoor_temperature_forecast']
desired_temperature = hc["desired_temperature"]
desired_temperatures = hc["desired_temperatures"]
sense = hc.get('sense', 'heat')
predicted_temp = [start_temperature]
for I in set_I:
Expand All @@ -422,12 +422,12 @@ def create_matrix(input_list, n):
predicted_temp[I-1]
+ (P_deferrable[k][I-1] * (heating_rate * self.timeStep / self.optim_conf['P_deferrable_nom'][k]))
- (cooling_constant * (predicted_temp[I-1] - outdoor_temperature_forecast[I-1])))
if len(desired_temperature) > I and desired_temperature[I]:
if len(desired_temperatures) > I and desired_temperatures[I]:
constraints.update({"constraint_defload{}_temperature_{}".format(k, I):
plp.LpConstraint(
e = predicted_temp[I],
sense = plp.LpConstraintGE if sense == 'heat' else plp.LpConstraintLE,
rhs = desired_temperature[I],
rhs = desired_temperatures[I],
)
})
constraints.update({"constraint_defload{}_overshoot_temp_{}".format(k, I):
Expand Down Expand Up @@ -732,7 +732,7 @@ def create_matrix(input_list, n):
opt_tp[f"P_def_bin2_{k}"] = [P_def_bin2[k][i].varValue for i in set_I]
for i, predicted_temp in predicted_temps.items():
opt_tp[f"predicted_temp_heater{i}"] = pd.Series([round(pt.value(), 2) if isinstance(pt, plp.LpAffineExpression) else pt for pt in predicted_temp], index=opt_tp.index)
opt_tp[f"target_temp_heater{i}"] = pd.Series(self.optim_conf["def_load_config"][i]['thermal_config']["desired_temperature"], index=opt_tp.index)
opt_tp[f"target_temp_heater{i}"] = pd.Series(self.optim_conf["def_load_config"][i]['thermal_config']["desired_temperatures"], index=opt_tp.index)

return opt_tp

Expand Down
Loading

0 comments on commit 8d4d32f

Please sign in to comment.