Skip to content

Commit a432ad9

Browse files
Adds support for deferrable load groups
1 parent 6c6faf3 commit a432ad9

7 files changed

Lines changed: 255 additions & 1 deletion

File tree

docs/config.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,29 @@ Example:
103103
- `set_deferrable_load_single_constant`: Define if we should set each deferrable load as a constant fixed value variable with just one startup for each optimization task. For example:
104104
- False
105105
- False
106-
- `set_deferrable_startup_penalty`: Set to a list of floats. For each deferrable load with a penalty `P`, each time the deferrable load turns on will incur an additional cost of `P * nominal_power_of_deferrable_loads * cost_of_electricity` at that time.
106+
- `set_deferrable_startup_penalty`: Set to a list of floats. For each deferrable load with a penalty `P`, each time the deferrable load turns on will incur an additional cost of `P * nominal_power_of_deferrable_loads * cost_of_electricity` at that time.
107+
- `deferrable_load_groups`: Define groups of deferrable loads that share a physical actuator (e.g. a heat pump serving both hot water and underfloor heating). Each group can enforce a shared power budget, mutual exclusion, or both. This is a list of group objects, each with the following fields:
108+
- `names`: List of deferrable load names in the group (e.g. `["deferrable0", "deferrable1"]`).
109+
- `max_power` *(optional when `mutual_exclusion` is `true`)*: Maximum combined power in Watts for all loads in the group at any timestep. Required when `mutual_exclusion` is `false`.
110+
- `mutual_exclusion` *(optional, defaults to `false`)*: When `true`, only one load in the group may be active at any timestep. Requires all loads in the group to have `treat_deferrable_load_as_semi_cont` set to `true`.
111+
112+
A load cannot belong to multiple groups. Examples:
113+
```json
114+
"deferrable_load_groups": [
115+
{"names": ["deferrable0", "deferrable1"], "max_power": 2500}
116+
]
117+
```
118+
```json
119+
"deferrable_load_groups": [
120+
{"names": ["deferrable0", "deferrable1"], "mutual_exclusion": true}
121+
]
122+
```
123+
```json
124+
"deferrable_load_groups": [
125+
{"names": ["deferrable0", "deferrable1"], "max_power": 2500, "mutual_exclusion": true}
126+
]
127+
```
128+
Defaults to an empty list (no groups).
107129
- `weather_forecast_method`: This will define the weather forecast method that will be used. The options are `open-meteo` to use the weather forecast API proposed by [Open-Meteo](https://open-meteo.com/), `solcast` to use the [Solcast](https://solcast.com/) solar forecast service, `solar.forecast` to use the free public [Solar.Forecast](https://forecast.solar/) account and finally the `csv` to load a CSV file. When loading a CSV file this will be directly considered as the PV power forecast in Watts. The default CSV file path that will be used is `/data/data_weather_forecast.csv`. This method is useful to load and use other external forecasting service data in EMHASS. Defaults to `open-meteo` method.
108130
- `load_forecast_method`: The load forecast method that will be used. The options are `typical` which uses basic statistics and a year long load power data grouped by the current day-of-the-week of the current month, `naive` also called persistence that assumes that the forecast for a future period will be equal to the observed values in a past period, `mlforecaster` that uses regression models considering auto-regression lags as features and finally the `csv` to load a CSV file. When loading a CSV file this will be directly considered as the PV power forecast in Watts. The default CSV file path that will be used is `/data/data_weather_forecast.csv`. This method is useful to load and use other external forecasting service data in EMHASS. Defaults to `typical`.
109131
```{note}

src/emhass/command_line.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ class OptimizationCacheKey:
122122
set_deferrable_load_as_timeseries: tuple
123123
nominal_power_of_deferrable_loads: tuple
124124
def_load_config_structure: tuple # (index, type) tuples for each load
125+
deferrable_load_groups: tuple
125126
inverter_is_hybrid: bool
126127
compute_curtailment: bool
127128
optimization_time_step_s: float | None
@@ -284,6 +285,10 @@ def config_hash(cfg: dict, exclude_keys: set | None = None) -> str:
284285
optim_conf.get("nominal_power_of_deferrable_loads", [])
285286
),
286287
def_load_config_structure=tuple(def_structure),
288+
deferrable_load_groups=tuple(
289+
(tuple(g.get("names", [])), g.get("max_power"), g.get("mutual_exclusion", False))
290+
for g in optim_conf.get("deferrable_load_groups", [])
291+
),
287292
inverter_is_hybrid=plant_conf.get("inverter_is_hybrid", False),
288293
compute_curtailment=plant_conf.get("compute_curtailment", False),
289294
optimization_time_step_s=to_seconds(retrieve_hass_conf.get("optimization_time_step")),

src/emhass/data/associations.csv

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ optim_conf,open_meteo_cache_max_age,open_meteo_cache_max_age
6969
optim_conf,def_start_timestep,start_timesteps_of_each_deferrable_load,list_start_timesteps_of_each_deferrable_load
7070
optim_conf,def_end_timestep,end_timesteps_of_each_deferrable_load,list_end_timesteps_of_each_deferrable_load
7171
optim_conf,set_deferrable_max_startups,set_deferrable_max_startups
72+
optim_conf,deferrable_load_groups,deferrable_load_groups
7273
optim_conf,list_hp_periods,load_peak_hour_periods
7374
optim_conf,model_type,model_type
7475
optim_conf,var_model,var_model

src/emhass/data/config_defaults.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@
7676
"set_deferrable_load_single_constant": [false, false],
7777
"set_deferrable_startup_penalty": [0.0, 0.0],
7878
"set_deferrable_max_startups": [0, 0],
79+
"deferrable_load_groups": [],
7980
"load_peak_hours_cost": 0.1907,
8081
"load_offpeak_hours_cost": 0.1419,
8182
"production_price_forecast_method": "constant",

src/emhass/optimization.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2004,6 +2004,39 @@ def _add_deferrable_load_constraints(
20042004

20052005
return predicted_temps, heating_demands, penalty_terms_total, q_inputs
20062006

2007+
def _add_deferrable_group_constraints(self, constraints, relaxed=False):
2008+
"""Add shared power budget and mutual exclusion constraints for deferrable load groups.
2009+
2010+
Args:
2011+
constraints: List of CVXPY constraints to append to.
2012+
relaxed: If True, only add shared power budget constraints (skip mutual
2013+
exclusion, which requires binary variables not available in the relaxed LP).
2014+
"""
2015+
groups = self.optim_conf.get("deferrable_load_groups", [])
2016+
if not groups:
2017+
return
2018+
2019+
p_deferrable = self.vars["p_deferrable"]
2020+
2021+
for gi, group in enumerate(groups):
2022+
indices = [int(name.replace("deferrable", "")) for name in group["names"]]
2023+
max_power = group.get("max_power")
2024+
mutual_exclusion = group.get("mutual_exclusion", False)
2025+
2026+
self.logger.debug(f"Adding group {gi} constraints for deferrable loads {indices}")
2027+
2028+
# Shared power budget: sum of group members <= max_power at each timestep
2029+
if max_power is not None:
2030+
group_power_sum = sum(p_deferrable[i] for i in indices)
2031+
constraints.append(group_power_sum <= max_power)
2032+
2033+
# Mutual exclusion: at most one load active per timestep
2034+
# Skipped in relaxed mode as binary variables are not available
2035+
if mutual_exclusion and not relaxed:
2036+
p_def_bin2 = self.vars["p_def_bin2"]
2037+
bin_sum = sum(p_def_bin2[i] for i in indices)
2038+
constraints.append(bin_sum <= 1)
2039+
20072040
def _build_results_dataframe(
20082041
self,
20092042
data_opt,
@@ -2457,6 +2490,9 @@ def pad_list(input_list, target_len, fill=0):
24572490
)
24582491
)
24592492

2493+
# Deferrable Load Group Constraints (shared power budget, mutual exclusion)
2494+
self._add_deferrable_group_constraints(constraints)
2495+
24602496
# Build Objective
24612497
objective_expr = self._build_objective_function(
24622498
batt_stress_conf,
@@ -2599,6 +2635,9 @@ def pad_list(input_list, target_len, fill=0):
25992635
)
26002636
)
26012637

2638+
# Deferrable Load Group Constraints (shared power budget only in relaxed mode)
2639+
self._add_deferrable_group_constraints(constraints_relaxed, relaxed=True)
2640+
26022641
# Re-build Objective
26032642
objective_expr = self._build_objective_function(batt_stress_conf, inv_stress_conf)
26042643
if not isinstance(penalty_terms_total, int) or penalty_terms_total != 0:

src/emhass/utils.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2182,6 +2182,68 @@ async def build_params(
21822182
"nominal_power_of_deferrable_loads",
21832183
logger,
21842184
)
2185+
# Validate deferrable_load_groups
2186+
groups = params["optim_conf"].get("deferrable_load_groups", [])
2187+
if groups:
2188+
seen_indices = set()
2189+
for gi, group in enumerate(groups):
2190+
# Validate names
2191+
names = group.get("names", [])
2192+
if not names:
2193+
raise ValueError(
2194+
f"deferrable_load_groups[{gi}]: 'names' must contain at least 1 deferrable load reference"
2195+
)
2196+
indices = []
2197+
group_indices = set()
2198+
for name in names:
2199+
try:
2200+
idx = int(name.replace("deferrable", ""))
2201+
except ValueError:
2202+
raise ValueError(
2203+
f"deferrable_load_groups[{gi}]: could not parse index from name '{name}'"
2204+
)
2205+
if idx < 0 or idx >= num_def_loads:
2206+
raise ValueError(
2207+
f"deferrable_load_groups[{gi}]: '{name}' references index {idx}, "
2208+
f"but only {num_def_loads} deferrable loads are configured"
2209+
)
2210+
if idx in group_indices:
2211+
raise ValueError(
2212+
f"deferrable_load_groups[{gi}]: '{name}' is duplicated within the group"
2213+
)
2214+
if idx in seen_indices:
2215+
raise ValueError(
2216+
f"deferrable_load_groups[{gi}]: '{name}' is already in another group. "
2217+
f"A deferrable load cannot belong to multiple groups"
2218+
)
2219+
indices.append(idx)
2220+
group_indices.add(idx)
2221+
seen_indices.update(indices)
2222+
2223+
# Validate max_power (optional when mutual_exclusion is true)
2224+
max_power = group.get("max_power")
2225+
mutual_exclusion = group.get("mutual_exclusion", False)
2226+
if max_power is not None and max_power <= 0:
2227+
raise ValueError(
2228+
f"deferrable_load_groups[{gi}]: 'max_power' must be a positive number"
2229+
)
2230+
if not isinstance(mutual_exclusion, bool):
2231+
raise ValueError(
2232+
f"deferrable_load_groups[{gi}]: 'mutual_exclusion' must be a boolean"
2233+
)
2234+
if max_power is None and not mutual_exclusion:
2235+
raise ValueError(
2236+
f"deferrable_load_groups[{gi}]: 'max_power' is required when 'mutual_exclusion' is false"
2237+
)
2238+
if mutual_exclusion:
2239+
semi_cont = params["optim_conf"].get("treat_deferrable_load_as_semi_cont", [])
2240+
for idx in indices:
2241+
is_semi_cont = semi_cont[idx] if idx < len(semi_cont) else False
2242+
if not is_semi_cont:
2243+
raise ValueError(
2244+
f"deferrable_load_groups[{gi}]: mutual_exclusion requires "
2245+
f"'deferrable{idx}' to have treat_deferrable_load_as_semi_cont=true"
2246+
)
21852247
else:
21862248
logger.warning("unable to obtain parameter: number_of_deferrable_loads")
21872249
# historic_days_to_retrieve should be no less then 2

tests/test_optimization.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3889,6 +3889,130 @@ def test_load_deactivation_with_def_total_hours(self):
38893889
)
38903890

38913891

3892+
def test_deferrable_load_group_shared_power(self):
3893+
"""Test that shared power budget constraint limits combined power of grouped loads."""
3894+
self.optim_conf.update(
3895+
{
3896+
"treat_deferrable_load_as_semi_cont": [True, True],
3897+
"set_deferrable_load_single_constant": [False, False],
3898+
"nominal_power_of_deferrable_loads": [2000.0, 2000.0],
3899+
"operating_hours_of_each_deferrable_load": [4, 4],
3900+
"deferrable_load_groups": [
3901+
{
3902+
"names": ["deferrable0", "deferrable1"],
3903+
"max_power": 2500,
3904+
"mutual_exclusion": False,
3905+
}
3906+
],
3907+
}
3908+
)
3909+
self.opt = self.create_optimization()
3910+
self.df_input_data_dayahead = self.prepare_forecast_data()
3911+
opt_res = self.opt.perform_dayahead_forecast_optim(
3912+
self.df_input_data_dayahead, self.p_pv_forecast, self.p_load_forecast
3913+
)
3914+
self.assertIn(self.opt.optim_status, VALID_OPTIMAL_STATUSES)
3915+
# Verify combined power never exceeds group max_power (with small tolerance)
3916+
combined = opt_res["P_deferrable0"] + opt_res["P_deferrable1"]
3917+
self.assertTrue(
3918+
(combined <= 2500 + 1.0).all(),
3919+
f"Combined power exceeded group max_power: max={combined.max():.1f}",
3920+
)
3921+
3922+
def test_deferrable_load_group_mutual_exclusion(self):
3923+
"""Test that mutual exclusion prevents simultaneous operation of grouped loads."""
3924+
self.optim_conf.update(
3925+
{
3926+
"treat_deferrable_load_as_semi_cont": [True, True],
3927+
"set_deferrable_load_single_constant": [False, False],
3928+
"nominal_power_of_deferrable_loads": [2000.0, 1500.0],
3929+
"operating_hours_of_each_deferrable_load": [4, 4],
3930+
"deferrable_load_groups": [
3931+
{
3932+
"names": ["deferrable0", "deferrable1"],
3933+
"max_power": 2500,
3934+
"mutual_exclusion": True,
3935+
}
3936+
],
3937+
}
3938+
)
3939+
self.opt = self.create_optimization()
3940+
self.df_input_data_dayahead = self.prepare_forecast_data()
3941+
opt_res = self.opt.perform_dayahead_forecast_optim(
3942+
self.df_input_data_dayahead, self.p_pv_forecast, self.p_load_forecast
3943+
)
3944+
self.assertIn(self.opt.optim_status, VALID_OPTIMAL_STATUSES)
3945+
# Verify at most one load is active at any timestep
3946+
both_active = (opt_res["P_deferrable0"] > 1.0) & (opt_res["P_deferrable1"] > 1.0)
3947+
self.assertFalse(
3948+
both_active.any(),
3949+
"Mutual exclusion violated: both loads active simultaneously",
3950+
)
3951+
3952+
def test_deferrable_load_group_no_groups(self):
3953+
"""Test that empty deferrable_load_groups works (backward compatibility)."""
3954+
self.optim_conf["deferrable_load_groups"] = []
3955+
self.opt = self.create_optimization()
3956+
self.df_input_data_dayahead = self.prepare_forecast_data()
3957+
opt_res = self.opt.perform_dayahead_forecast_optim(
3958+
self.df_input_data_dayahead, self.p_pv_forecast, self.p_load_forecast
3959+
)
3960+
self.assertIn(self.opt.optim_status, VALID_OPTIMAL_STATUSES)
3961+
3962+
async def _build_params_with_groups(self, groups, **config_overrides):
3963+
"""Helper to build params with deferrable_load_groups set in config."""
3964+
config = await build_config(emhass_conf, logger, emhass_conf["defaults_path"])
3965+
config["deferrable_load_groups"] = groups
3966+
for key, value in config_overrides.items():
3967+
config[key] = value
3968+
_, secrets = await build_secrets(emhass_conf, logger, no_response=True)
3969+
return await build_params(emhass_conf, secrets, config, logger)
3970+
3971+
async def test_deferrable_load_group_validation_invalid_name(self):
3972+
"""Test that invalid deferrable names in groups raise errors."""
3973+
with self.assertRaises(ValueError):
3974+
await self._build_params_with_groups([
3975+
{
3976+
"names": ["deferrable0", "deferrable99"],
3977+
"max_power": 2500,
3978+
"mutual_exclusion": False,
3979+
}
3980+
])
3981+
3982+
async def test_deferrable_load_group_validation_mutual_exclusion_not_semi_cont(self):
3983+
"""Test that mutual exclusion with non-semi-continuous loads raises error."""
3984+
with self.assertRaises(ValueError):
3985+
await self._build_params_with_groups(
3986+
[
3987+
{
3988+
"names": ["deferrable0", "deferrable1"],
3989+
"max_power": 2500,
3990+
"mutual_exclusion": True,
3991+
}
3992+
],
3993+
treat_deferrable_load_as_semi_cont=[False, False],
3994+
)
3995+
3996+
async def test_deferrable_load_group_validation_overlapping_groups(self):
3997+
"""Test that a load in multiple groups raises error."""
3998+
with self.assertRaises(ValueError):
3999+
await self._build_params_with_groups(
4000+
[
4001+
{
4002+
"names": ["deferrable0", "deferrable1"],
4003+
"max_power": 2500,
4004+
"mutual_exclusion": False,
4005+
},
4006+
{
4007+
"names": ["deferrable1", "deferrable2"],
4008+
"max_power": 2000,
4009+
"mutual_exclusion": False,
4010+
},
4011+
],
4012+
number_of_deferrable_loads=3,
4013+
)
4014+
4015+
38924016
if __name__ == "__main__":
38934017
unittest.main()
38944018
ch.close()

0 commit comments

Comments
 (0)