Adds support for deferrable load groups#784
Conversation
Reviewer's GuideAdds configuration-driven deferrable load grouping, enforcing shared power budgets and optional mutual exclusion in the optimization, with validation, cache-key integration, documentation, and tests. Updated class diagram for optimization and cache key deferrable load groupingclassDiagram
class Optimization {
+dict optim_conf
+dict vars
+logger logger
+_add_deferrable_load_constraints(constraints)
+_add_deferrable_group_constraints(constraints)
+_build_results_dataframe(data_opt, ...)
+_build_objective_function(batt_stress_conf, inv_stress_conf)
}
class OptimizationCacheKey {
+tuple set_deferrable_load_as_timeseries
+tuple nominal_power_of_deferrable_loads
+tuple def_load_config_structure
+tuple deferrable_load_groups
+bool inverter_is_hybrid
+bool compute_curtailment
+float optimization_time_step_s
}
class CommandLineConfig {
+dict optim_conf
+dict plant_conf
+dict retrieve_hass_conf
}
class ConfigHashBuilder {
+config_hash(cfg, exclude_keys) str
}
OptimizationCacheKey <.. ConfigHashBuilder : builds
CommandLineConfig <.. ConfigHashBuilder : reads
class DeferrableLoadGroupConfig {
+tuple names
+float max_power
+bool mutual_exclusion
}
Optimization "1" --> "*" DeferrableLoadGroupConfig : uses
OptimizationCacheKey "1" --> "*" DeferrableLoadGroupConfig : encoded_as_tuple_from
Flow diagram for deferrable_load_groups validation in build_paramsflowchart TD
Start["Start build_params validation for deferrable_load_groups"] --> GetGroups["Read optim_conf.deferrable_load_groups as groups"]
GetGroups --> HasGroups{groups is empty?}
HasGroups -- Yes --> End["Skip group validation"]
HasGroups -- No --> InitSeen["Initialize seen_indices as empty set"]
InitSeen --> LoopGroups["For each group gi, group in groups"]
LoopGroups --> GetNames["names = group.names or []"]
GetNames --> NamesEmpty{names empty?}
NamesEmpty -- Yes --> ErrNames["Raise ValueError: names must contain at least 1 deferrable load reference"]
NamesEmpty -- No --> InitIndices["indices = empty list"]
InitIndices --> LoopNames["For each name in names"]
LoopNames --> ParseIdx["Parse idx from name by stripping prefix 'deferrable'"]
ParseIdx --> ParseOk{parse succeeded?}
ParseOk -- No --> ErrParse["Raise ValueError: could not parse index from name"]
ParseOk -- Yes --> CheckRange{idx >= num_def_loads?}
CheckRange -- Yes --> ErrRange["Raise ValueError: index out of range for configured deferrable loads"]
CheckRange -- No --> CheckSeen{idx in seen_indices?}
CheckSeen -- Yes --> ErrSeen["Raise ValueError: load already in another group"]
CheckSeen -- No --> AddIdx["Append idx to indices"]
AddIdx --> MoreNames{more names?}
MoreNames -- Yes --> LoopNames
MoreNames -- No --> UpdateSeen["seen_indices.update(indices)"]
UpdateSeen --> ReadPower["max_power = group.max_power"]
ReadPower --> ReadMutual["mutual_exclusion = bool(group.mutual_exclusion, default False)"]
ReadMutual --> CheckMaxVal{max_power is not None and max_power <= 0?}
CheckMaxVal -- Yes --> ErrMaxVal["Raise ValueError: max_power must be positive"]
CheckMaxVal -- No --> CheckMutualType{mutual_exclusion is bool?}
CheckMutualType -- No --> ErrMutualType["Raise ValueError: mutual_exclusion must be boolean"]
CheckMutualType -- Yes --> CheckMaxRequired{max_power is None and mutual_exclusion is False?}
CheckMaxRequired -- Yes --> ErrMaxReq["Raise ValueError: max_power required when mutual_exclusion is false"]
CheckMaxRequired -- No --> CheckMutualEnabled{mutual_exclusion is True?}
CheckMutualEnabled -- No --> NextGroup{more groups?}
CheckMutualEnabled -- Yes --> LoadSemiCont["semi_cont = optim_conf.treat_deferrable_load_as_semi_cont or []"]
LoadSemiCont --> LoopIdx["For each idx in indices"]
LoopIdx --> SemiCheck{"idx < len(semi_cont) and semi_cont[idx] is False?"}
SemiCheck -- Yes --> ErrSemi["Raise ValueError: mutual_exclusion requires treat_deferrable_load_as_semi_cont=true"]
SemiCheck -- No --> MoreIdx{more indices?}
MoreIdx -- Yes --> LoopIdx
MoreIdx -- No --> NextGroup
NextGroup -- Yes --> LoopGroups
NextGroup -- No --> End["Validation complete"]
File-Level Changes
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
9d6db45 to
f62a4c9
Compare
There was a problem hiding this comment.
Hey - I've found 3 issues, and left some high level feedback:
- In
_add_deferrable_group_constraintsyou always add both shared-power and mutual-exclusion constraints, but in the relaxed optimization path the comment says "shared power budget only in relaxed mode"; consider either splitting the helper or passing a flag so that mutual exclusion is not enforced in the relaxed model (and avoid depending onp_def_bin2there). - The deferrable group validation in
build_paramscould be tightened: currently duplicate names within a single group and loads with indices beyond the length oftreat_deferrable_load_as_semi_contsilently pass some checks; consider explicitly rejecting duplicates within the same group, validating thatsemi_contis defined for all referenced indices, and using a stricter name parser (e.g. enforcing thedeferrableprefix and non-negative integer suffix).
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- In `_add_deferrable_group_constraints` you always add both shared-power and mutual-exclusion constraints, but in the relaxed optimization path the comment says "shared power budget only in relaxed mode"; consider either splitting the helper or passing a flag so that mutual exclusion is not enforced in the relaxed model (and avoid depending on `p_def_bin2` there).
- The deferrable group validation in `build_params` could be tightened: currently duplicate names within a single group and loads with indices beyond the length of `treat_deferrable_load_as_semi_cont` silently pass some checks; consider explicitly rejecting duplicates within the same group, validating that `semi_cont` is defined for all referenced indices, and using a stricter name parser (e.g. enforcing the `deferrable` prefix and non-negative integer suffix).
## Individual Comments
### Comment 1
<location path="src/emhass/utils.py" line_range="2204" />
<code_context>
+ raise ValueError(
+ f"deferrable_load_groups[{gi}]: could not parse index from name '{name}'"
+ )
+ if idx >= num_def_loads:
+ raise ValueError(
+ f"deferrable_load_groups[{gi}]: '{name}' references index {idx}, "
</code_context>
<issue_to_address>
**issue (bug_risk):** Also validate that parsed indices are non-negative to avoid unintended negative indexing
Currently only `idx >= num_def_loads` is checked. A name like `deferrable-1` would parse to `-1`, pass this check, and then be used as a valid negative index (e.g., into `nominal_power_of_deferrable_loads`), silently referencing the last element instead of failing. Please also reject negative indices, e.g. `if idx < 0 or idx >= num_def_loads:`.
</issue_to_address>
### Comment 2
<location path="src/emhass/optimization.py" line_range="2007-2016" />
<code_context>
+ def _add_deferrable_group_constraints(self, constraints):
</code_context>
<issue_to_address>
**issue (bug_risk):** Mutual exclusion logic here is also applied in relaxed mode, which conflicts with the intended "shared power budget only" behavior
This helper always enforces both shared power (`max_power`) and mutual exclusion:
- It unconditionally reads `p_def_bin2` and adds `bin_sum <= 1` when `mutual_exclusion` is true.
- The same helper is then used for `constraints_relaxed`, which is documented as "shared power budget only".
If the relaxed formulation omits `p_def_bin2`, this will either break (missing var) or implicitly introduce binary-style constraints where only the shared power budget is intended.
Consider either:
- Adding a flag (e.g. `enforce_mutual_exclusion`) to `_add_deferrable_group_constraints` and disabling it for the relaxed call, or
- Splitting into two helpers (shared power vs. mutual exclusion) and using only the shared-power helper in relaxed mode.
This would make the behavior match the comment and avoid relying on `p_def_bin2` in the relaxed problem.
</issue_to_address>
### Comment 3
<location path="src/emhass/utils.py" line_range="2232-2193" />
<code_context>
+ constraints.append(group_power_sum <= max_power)
+
+ # Mutual exclusion: at most one load active per timestep
+ if mutual_exclusion:
+ bin_sum = sum(p_def_bin2[i] for i in indices)
+ constraints.append(bin_sum <= 1)
</code_context>
<issue_to_address>
**suggestion:** Mutual exclusion relies on `treat_deferrable_load_as_semi_cont` length, which might silently bypass the check for some indices
When `mutual_exclusion` is enabled, some indices may be assumed semi-continuous without ever being checked if `treat_deferrable_load_as_semi_cont` is shorter than `num_def_loads`. That makes misconfiguration easy to miss. Consider either enforcing `len(treat_deferrable_load_as_semi_cont) >= num_def_loads` when mutual exclusion is used, or treating missing entries as `False` and raising explicitly, so incomplete configs don’t slip through silently.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
f62a4c9 to
a432ad9
Compare
|
@davidusb-geek i hit the issue described in #686, so i thought i'd attempt to solve it i'm not familiar with the project / codebase, so this is all from claude, but it appears to work correctly in my setup:
(deferrable 0 and 1 being a heat pump central heating / hot water modelled as thermal loads, they're not scheduled at the same time) give a shout if there's anything you'd like to see changed / added etc. thanks! |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #784 +/- ##
==========================================
+ Coverage 81.82% 81.88% +0.05%
==========================================
Files 10 10
Lines 5739 5796 +57
==========================================
+ Hits 4696 4746 +50
- Misses 1043 1050 +7 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
@JosephSalisbury, thanks for contributing and directly to solve an open feature request! |

Towards #686
Add support for deferrable load groups, using the configuration suggested in #686 (comment).
Summary by Sourcery
Add configuration-driven support for grouping deferrable loads with shared constraints in the optimization model.
New Features:
Enhancements:
Documentation:
Tests: