feat: configurable loadpoint priority strategy (soc/deficit) with hysteresis#31072
feat: configurable loadpoint priority strategy (soc/deficit) with hysteresis#31072Alexxtheonly wants to merge 19 commits into
Conversation
Loadpoint priority is currently a static integer: among loadpoints competing for surplus power, the higher number always wins, regardless of how full each vehicle is. There is no way to say "charge whichever car is emptier first". This adds an optional per-loadpoint `priorityStrategy`: - static (default) — existing behaviour, rank by priority only - soc — within the same priority, prefer the lower vehicle soc - deficit — within the same priority, prefer the larger gap to limitSoc Implementation keeps the existing integer priority as the dominant tier and introduces EffectivePriorityScore() = priority + a fractional [0,1) sub-ordering derived from the strategy, so the sub-ordering can never cross a priority boundary. The prioritizer ranks by this score instead of the bare integer; EffectivePriority() (published to the UI) is unchanged. The sub-ordering only applies when a positive vehicle soc is known, otherwise the score falls back to the plain priority. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The soc/deficit priority strategies rank loadpoints by a fractional score, so two near-equal-soc loadpoints competing for surplus would leapfrog each other as their soc crosses. priorityHysteresis (soc-%, default 0 = off) makes a loadpoint outrank another only when ahead by more than the band, so near-equal loadpoints tie and converge instead of swapping. The band is capped below 1.0 so it never weakens cross-tier (integer priority) ordering. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nt setters Promote the two config-only options to full runtime citizens mirroring the existing priority option: - add keys.PriorityStrategy / keys.PriorityHysteresis - add SetPriorityStrategy / SetPriorityHysteresis to the loadpoint.API interface with locked public setters + no-mutex helpers that publish the key and persist to the settings DB - validate inputs (PriorityStrategyString; hysteresis 0..99) - publish both keys with the initial loadpoint values and restore them from the settings DB on startup - HTTP routes: string handler for strategy (mode pattern), int handler for hysteresis (priority pattern) - MQTT setters for both - regenerate loadpoint API mock - unit tests for both setters (validation + persistence) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…endpoints Document the two new runtime setters in the hand-maintained openapi.yaml and regenerate mcp/openapi.json + mcp/openapi.md via go generate ./server/. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…cConfig Add priorityStrategy (api.PriorityStrategy) and priorityHysteresis (int) to the loadpoint DynamicConfig struct so the static config-editor path (config GET/POST) round-trips them, mirroring the existing priority field. SplitConfig consumes them via mapstructure squash; Apply wires them through the existing SetPriorityStrategy/SetPriorityHysteresis setters (which validate/normalize). The getLoadpointDynamicConfig handler now returns them on config GET. Extend SplitConfig test to cover the new fields. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…fig modal Add a strategy select (static/soc/deficit) and a hysteresis number input (0..99 %, shown only for soc/deficit) to the static-config LoadpointModal, mirroring the existing priority control. The strategy computed maps the backend's empty-string static value to/from the explicit 'static' choice so the config GET/POST round-trips. Add ConfigLoadpoint type fields and English config-modal i18n keys. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- core/loadpoint_priority_test.go: sort stdlib imports (gci)
- tests/config-loadpoint.spec.ts: use exact-match Priority label;
the new 'Priority strategy' select made getByLabel('Priority')
resolve to 2 elements (strict mode violation)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Is it possible you consider actual energy stored, instead of only percentage for the prioritization? I think directionally secondary cars with smaller battery become more and more popular. Our second car has a 25 kWh battery, which is less then half of the other. |
The soc and deficit priority strategies ranked loadpoints in soc-% only, which over-prioritizes a smaller battery whose percentage is lower even though it needs less energy (raised by @ScumbagSteve on the strategy PR: a 25 kWh second car vs a >50 kWh primary). Add an orthogonal priorityBasis that composes with both strategies: - percent (default) - rank by the soc-% gap (unchanged behavior) - energy - scale the soc-% gap by the vehicle capacity (kWh), so loadpoints are ranked by absolute energy need When the vehicle capacity is unknown the energy basis falls back to the percentage gap per loadpoint, so a missing capacity degrades gracefully. The hysteresis deadband follows the basis (soc-% or kWh). Fully backward compatible: percent basis is exactly the previous behavior. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a percent/energy basis selector to the config LoadpointModal and the runtime SettingsModal, shown alongside the priority strategy. The hysteresis unit label switches between % and kWh to match the basis. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Good call. Ranking by percentage alone over-prioritizes a smaller battery: your 25 kWh second car at 40% needs less energy than the big one at 50%, yet the lower % would win. Added it. There's now a
So you can keep "lower charge level first" but have it mean less energy stored / more energy needed in kWh. In your example the primary car (needs ~37.5 kWh) now outranks the 25 kWh car (needs ~15 kWh) instead of the other way around. When a vehicle's capacity is unknown the energy basis falls back to the percentage gap for that loadpoint, so nothing breaks if It's exposed in both the config and runtime loadpoint modals (a percent/energy selector next to the strategy) and wired into the API/MQTT/OpenAPI surface, with tests covering the inversion case. Pushed to this branch. |
- core/loadpoint/mock.go: reorder GetPriorityBasis/SetPriorityBasis to mockgen's alphabetical position (fixes Clean porcelain check) - core/loadpoint_effective_test.go: gofmt comment alignment (fixes Lint gci/gofmt)
The 0.80 row has a single-digit capacity (0) so it is one column shorter than the 50/25 rows; gofmt aligns its comment with two spaces.
With priorityBasis=energy the soc-% gap is scaled by vehicle capacity to a kWh fraction. When a vehicle's capacity is unknown the score silently fell back to the percentage gap, so a configured vehicle (ranked in kWh) and an unconfigured one (ranked in %) were compared on different scales — the unconfigured car was systematically over-prioritized. EffectivePriorityScore now takes an explicit basis. The prioritizer resolves one basis per priority tier (effectiveBasis): if any energy-basis loadpoint in the tier lacks a known capacity, the whole tier is ranked by percent, so kWh and percentage fractions are never mixed. Adds a regression test covering the mixed-capacity case.
| Title string `json:"title"` | ||
| DefaultMode string `json:"defaultMode"` | ||
| Priority int `json:"priority"` | ||
| PriorityStrategy api.PriorityStrategy `json:"priorityStrategy"` |
There was a problem hiding this comment.
Move to own block to limit the diff and make it readable
There was a problem hiding this comment.
Done. Moved the three priority fields into their own block
There was a problem hiding this comment.
No need to comment- GH will resolve addressed issues automatically ;)
|
Sounds like this PR fulfills a long-standing need for bigger installations. Nice. I‘ve wondered how hysteresis is handeled in case of >1 vehicle with closely matching socs? If they have streaming api, prioritizer decision may change at each cycle. Does it need hysteresis to guard against that happening? |
…inger, own config block - api: drop the PriorityStrategy/PriorityBasis Stringer, add UnmarshalText (encoding.TextUnmarshaler) like ChargeMode, so invalid values are rejected at decode for both config (mapstructure hook) and API (json) paths - config: move the priority sub-ordering fields into their own block, keeping the surrounding DynamicConfig diff/alignment minimal - loadpoint: trim the EffectivePriorityScore doc comment to ~2 lines
|
@andig on the hysteresis question: It converges instead of oscillating. Charging the emptier car raises its soc and lowers its own score, so a within-band pair both keep charging and stay tied. A handover happens once the gap actually exceeds the band. Default is 0, which reproduces the current behavior; set it a bit wider than the per-cycle soc movement to absorb streaming-soc jitter. Covered by One caveat: it's a stateless deadband, not a latch, so there can still be one-step chatter right at the band edge. That only shifts flexible-power allocation, not charge/stop. If you'd prefer flap protection out of the box, I can ship a small non-zero default, or make it a proper latch (keep the previous winner until the other exceeds it by the band). |
|
I have implemented dynamic prioritization via ioBroker currently and I remember it was pretty counter-intuitive and the documentation did not cover the topic to its full complexity. When I remember right, the priority was not the only relevant component. I had to adjust the loadpoint delay (the surplus time after which the loadpoints starts charging). I think this is not covered by this commit, right? One has to consider the actual time until the car starts charging. When the car needs a minute to start charging (after power is supplied) the second loadpoint must have at least 1 minute plus the evcc control interval delay more than the first. I set the delay to 3 minutes for primary LP and 6 minutes for secondary LP. |
Out of curiosity: did you write that or AI? |
| // Priority strategies | ||
| const ( | ||
| // PriorityStatic ranks loadpoints by their configured priority only (default). | ||
| PriorityStatic PriorityStrategy = "" |
There was a problem hiding this comment.
Naming: what makes this "static"? Isn't it really first come, first serve? Or maybe "none"?
| ) | ||
|
|
||
| // PriorityStrategyString converts a string to PriorityStrategy | ||
| func PriorityStrategyString(s string) (PriorityStrategy, error) { |
There was a problem hiding this comment.
The pattern here is to use enumer and textmarshaler instead of handrolling these.
| ) | ||
|
|
||
| // PriorityBasisString converts a string to PriorityBasis | ||
| func PriorityBasisString(s string) (PriorityBasis, error) { |
There was a problem hiding this comment.
Dito- pls keep consistency with rest of the codebase
@andig would you be so kind and quickly give your 5 cents? When there is only enough energy to charge one car with surplus, both cars would initiate the charge and then the car with less priority would stop. |
AI did write that i'm mostly on the phone giving instructions. That was me with fingers on my keyboard :D |
|
We appreciate human interaction. That „one caveat“ is the telltale sign, plus the general wording… |
Address review: - api: convert PriorityStrategy/PriorityBasis from hand-rolled string enums to int/iota with //go:generate enumer -text, matching BatteryMode et al. Generated String/XString/MarshalText/UnmarshalText replace the hand-written ones; invalid values are guarded at decode via the mapstructure TextUnmarshaller hook - rename the default strategy from "static" to "none" - loadpoint: unexport EffectivePriority; the prioritizer derives the tier from the integer part of EffectivePriorityScore (basis-independent), shrinking the API - config decode drops the manual re-parse now that decode validates - frontend (both modals, evcc.ts, i18n) + OpenAPI updated to none
The enumer decoder rejects "" (only none/soc/deficit, percent/energy are valid), so creating a loadpoint via the config UI without touching the dropdowns POSTed priorityStrategy:"" and 400'd. Default to the concrete enum values and drop the now-impossible | "" from the config types so the compiler catches this class.
|
Can you publish the EffectivePriorityScore via API, so we can see the results? |
|
hey steve, i've implemented what you wished for ;) |
Expose the computed priority score (effective tier + strategy sub-ordering fraction) as a published loadpoint value so external tools can read the ranking the prioritizer uses and align their own logic (e.g. loadpoint start delays) with it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
91b3251 to
4fe8836
Compare
|
Hi Alex, our of curiosity: how did you set the loadpoint delays? My observation: When both are set to the same delay, both cars initiate a charge when surplus is sufficient and then the car/lp with the lower priority stops charging. |
Closes #31071.
What
Adds a per-loadpoint
priorityStrategythat sub-orders loadpoints within the sameprioritytier when distributing PV surplus, plus apriorityHysteresisdeadband:priorityStrategy:static(default) |soc(prefer lower vehicle soc) |deficit(prefer largerlimitSoc − soc).priorityHysteresis(soc-%, default0= off): only outrank another same-priority loadpoint when ahead by more than the band, so near-equal-soc loadpoints tie/converge instead of leapfrogging.Fully backward compatible:
static+0== current behavior.How
EffectivePriorityScore()= integerEffectivePriority()(tier) + a fractional[0,1)strategy term, so sub-ordering never crosses a priority tier. The prioritizer ranks by this score; the hysteresis is applied at the comparison (> band), capped< 1.0so it never weakens cross-tier ordering. Strategy term only applies when a positive vehicle soc is known.Scope (full parity with the existing
priorityoption)mapstructure) + validation.loadpoint.APIgetters/setters, settings-DB persistence + boot restore, publishedkeys.priority/mode); regenerated mock + OpenAPI/MCP spec.DynamicConfiground-trip → exposed in the config modal, and in the runtime settings modal.go test ./core/... ./server/...,vue-tsc, eslint,npm run buildall green.Notes