Skip to content

Commit 9d6db45

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

6 files changed

Lines changed: 218 additions & 0 deletions

File tree

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: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2004,6 +2004,32 @@ 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):
2008+
"""Add shared power budget and mutual exclusion constraints for deferrable load groups."""
2009+
groups = self.optim_conf.get("deferrable_load_groups", [])
2010+
if not groups:
2011+
return
2012+
2013+
p_deferrable = self.vars["p_deferrable"]
2014+
p_def_bin2 = self.vars["p_def_bin2"]
2015+
2016+
for gi, group in enumerate(groups):
2017+
indices = [int(name.replace("deferrable", "")) for name in group["names"]]
2018+
max_power = group.get("max_power")
2019+
mutual_exclusion = group.get("mutual_exclusion", False)
2020+
2021+
self.logger.debug(f"Adding group {gi} constraints for deferrable loads {indices}")
2022+
2023+
# Shared power budget: sum of group members <= max_power at each timestep
2024+
if max_power is not None:
2025+
group_power_sum = sum(p_deferrable[i] for i in indices)
2026+
constraints.append(group_power_sum <= max_power)
2027+
2028+
# Mutual exclusion: at most one load active per timestep
2029+
if mutual_exclusion:
2030+
bin_sum = sum(p_def_bin2[i] for i in indices)
2031+
constraints.append(bin_sum <= 1)
2032+
20072033
def _build_results_dataframe(
20082034
self,
20092035
data_opt,
@@ -2457,6 +2483,9 @@ def pad_list(input_list, target_len, fill=0):
24572483
)
24582484
)
24592485

2486+
# Deferrable Load Group Constraints (shared power budget, mutual exclusion)
2487+
self._add_deferrable_group_constraints(constraints)
2488+
24602489
# Build Objective
24612490
objective_expr = self._build_objective_function(
24622491
batt_stress_conf,
@@ -2599,6 +2628,9 @@ def pad_list(input_list, target_len, fill=0):
25992628
)
26002629
)
26012630

2631+
# Deferrable Load Group Constraints (shared power budget only in relaxed mode)
2632+
self._add_deferrable_group_constraints(constraints_relaxed)
2633+
26022634
# Re-build Objective
26032635
objective_expr = self._build_objective_function(batt_stress_conf, inv_stress_conf)
26042636
if not isinstance(penalty_terms_total, int) or penalty_terms_total != 0:

src/emhass/utils.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2182,6 +2182,61 @@ 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+
for name in names:
2198+
try:
2199+
idx = int(name.replace("deferrable", ""))
2200+
except ValueError:
2201+
raise ValueError(
2202+
f"deferrable_load_groups[{gi}]: could not parse index from name '{name}'"
2203+
)
2204+
if idx >= num_def_loads:
2205+
raise ValueError(
2206+
f"deferrable_load_groups[{gi}]: '{name}' references index {idx}, "
2207+
f"but only {num_def_loads} deferrable loads are configured"
2208+
)
2209+
if idx in seen_indices:
2210+
raise ValueError(
2211+
f"deferrable_load_groups[{gi}]: '{name}' is already in another group. "
2212+
f"A deferrable load cannot belong to multiple groups"
2213+
)
2214+
indices.append(idx)
2215+
seen_indices.update(indices)
2216+
2217+
# Validate max_power (optional when mutual_exclusion is true)
2218+
max_power = group.get("max_power")
2219+
mutual_exclusion = group.get("mutual_exclusion", False)
2220+
if max_power is not None and max_power <= 0:
2221+
raise ValueError(
2222+
f"deferrable_load_groups[{gi}]: 'max_power' must be a positive number"
2223+
)
2224+
if not isinstance(mutual_exclusion, bool):
2225+
raise ValueError(
2226+
f"deferrable_load_groups[{gi}]: 'mutual_exclusion' must be a boolean"
2227+
)
2228+
if max_power is None and not mutual_exclusion:
2229+
raise ValueError(
2230+
f"deferrable_load_groups[{gi}]: 'max_power' is required when 'mutual_exclusion' is false"
2231+
)
2232+
if mutual_exclusion:
2233+
semi_cont = params["optim_conf"].get("treat_deferrable_load_as_semi_cont", [])
2234+
for idx in indices:
2235+
if idx < len(semi_cont) and not semi_cont[idx]:
2236+
raise ValueError(
2237+
f"deferrable_load_groups[{gi}]: mutual_exclusion requires "
2238+
f"'deferrable{idx}' to have treat_deferrable_load_as_semi_cont=true"
2239+
)
21852240
else:
21862241
logger.warning("unable to obtain parameter: number_of_deferrable_loads")
21872242
# 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)