Loadpoint: add configurable priority strategy (soc/deficit) with hysteresis - #31072
Loadpoint: add configurable priority strategy (soc/deficit) with hysteresis#31072Alexxtheonly wants to merge 33 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.
|
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? |
|
Heating loadpoints are now excluded from the priority sub-ordering (60314ef):
Cross-tier behavior is unchanged: integer priorities still apply to heaters in both directions. |
I've added this in #32310. Together they form quite a good overview of everything priority related (except vehicle-lp-prio-overwrite). |
|
@premultiply @andig whats you're opinion on this? From a users perspective this is a nice feature which makes priority behavior easier to understand. Especially when paired with #32310. |
…rity-strategy # Conflicts: # assets/js/types/evcc.ts # assets/js/views/Config.vue # core/keys/site.go # core/prioritizer/prioritizer.go # core/site.go # core/site_api.go # server/mcp/openapi.json # server/mcp/openapi.md # server/openapi.yaml
|
changed buttons to work with the new mobile layout #32810
|
mockSite embeds a nil site.API, so GetPriorityStrategy/GetPriorityBasis segfaulted once PublishEffectiveValues started calling them.
|
@Alexxtheonly can you go throught the open comments above? |
Addresses the open review threads on evcc-io#31072. - the hysteresis band applies within a priority tier only, so an explicit priority always wins; same-tier pairs involving a heating loadpoint are left untouched instead of always losing - basis and reference are resolved once site-wide in Site.EffectivePriorityScoring, so the prioritizer and PublishEffectiveValues always score on the same scale - the strategy gap is normalised against the largest capacity in scope, so energy-basis gaps above the old 0.99 clamp no longer collapse into a tie - loadpoints without a comparable soc no longer veto the energy basis - soc 0 stays the sentinel for unknown, now documented Also fixes a nil dereference: site.Loadpoints() returns nil entries for unconfigured slots, so iterating it and calling IsHeating() panics. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019rAwXiLYPZPpBF2a341n87
|
went through the open threads, should all be covered now the prioritizer loop is your suggestion as written. band only applies within a tier so an explicit priority always wins, and a same-tier pair involving a heater gets skipped instead of always losing. dropped the 0.99 cap and the the energy veto moved to the site as the clamp was the interesting one. the fraction is normalised against the largest capacity in scope now, 100 on percent and max capacity on energy, so your 200 kWh example at soc 5 vs soc 45 comes out 0.95 vs 0.55 instead of both landing on 0.99. i kept a cap at 0.999, but only as a tier guard: soc 0 i left alone with a comment saying it reads as unknown. moving for the published score, basis and reference are resolved once site-wide and both the prioritizer and two things i'd rather have your call on than guess: hysteresis units. the band is the also fixed a nil deref i ran into on the way: |
andig
left a comment
There was a problem hiding this comment.
Two things I'd fix before merge — details inline.
🤖 Generated with Claude Code
| // hysteresis deadband in gap units (soc-% or kWh), normalised against the same reference | ||
| // as the score fraction so near-equal loadpoints tie and converge instead of leapfrogging | ||
| band := float64(p.settings.GetPriorityHysteresis()) / ref |
There was a problem hiding this comment.
The hysteresis value silently changes unit when the energy basis gets vetoed.
Site.EffectivePriorityScoring() falls back to (PriorityBasisPercent, 100) as soon as any active loadpoint has soc > 0 but no vehicle capacity. That isn't only the no-vehicle case — a charger that reports SoC itself hits it too: socAndLimit("charger", lp.charger) (core/loadpoint.go:2007) sets vehicleSoc with lp.vehicle == nil, which is the normal situation for integrated devices.
So: user selects basis energy and hysteresis 10, which the modal labels kWh. One SoC-reporting charger anywhere on the site forces ref = 100, and band becomes 10 percentage points instead of 10 kWh — roughly a 4× wider deadband on a 40 kWh pack. Nothing surfaces the fallback: priorityBasis still reports energy and the UI still shows kWh.
At minimum I'd log the veto and publish the effective basis so the UI can label the unit correctly.
🤖 Generated with Claude Code
There was a problem hiding this comment.
Seems this is the same as the question you've had identified.
|
Can we show the resulting priority of each loadpoint as an easily understandable value (1/2/3) in the UI? It would help understanding how the system will react - even if there is no surplus at the moment of connecting. That is not only very helpful for the first time (testing the feature) but also helpful in general for the users to understand what the system will do if there will be surplus. |
We already had attempts to add priority(configuration) to the loadpoint card directly. However, with this addition we have to rethink what info is helpful and most clear for the user. We have loadpoints and vehicle configured priority levels plus this new sub priority. Might be a good idea to not show any of these raw values at all (at least not as primary) but offer some kind of derived/effective priority ranking of all relevant loadpoints. I dont have a clear picture for an elegant solution for this right now. Need to give this some more thought, but this would be a followup PR and not block this one. There already is a related issue #19650 |
What about sorting the loadpoint cards by priority. Usually, I want to see the loadpoint with the highest priority, or the one that is currently charging, first. |
|
I'll create a separate issue later, then we can discuss UI adjustments there |
You mean sorting the cards in the charging UI? Would not do that, especially since we a) allow users to sort manually (user interface settings) and b) automatically resorting while users look at it does not feel nice. |
Yes, i know that this would currently be in conflict with the manual sorting. The ranking shouldn't actually change in real-time, it depends solely on the defined priority. Loadpoints with the same piority shouldnt change. Just wanted to give an idea. |



Closes #31071.
What
Adds a site-level
priorityStrategythat sub-orders loadpoints within the sameprioritytier when distributing PV surplus:priorityStrategy:none(default) |soc(prefer lower vehicle soc) |deficit(prefer largerlimitSoc - soc)priorityBasis:percent(default) |energy. Measures the gap in soc-% or kWh. kWh keeps a small second battery from outranking a big one; falls back to percent when a vehicle capacity is unknown.priorityHysteresis(default0= off): a loadpoint only outranks a same-tier peer when ahead by more than the band (soc-% or kWh depending on basis), so near-equal vehicles converge instead of leapfrogging.All three are global site settings (per review: mixed per-loadpoint strategies within a tier would compare incomparable quantities). Backward compatible:
none+0= current behavior.How
EffectivePriorityScore(strategy, basis)= integerEffectivePriority()(tier) + fractional[0,1)strategy term. Sub-ordering never crosses a priority tier. The prioritizer ranks by this score; hysteresis applies at the comparison (> band). Strategy term only applies when a positive vehicle soc is known.effectivePriorityScore.Scope
prioritySoc), regenerated mock + OpenAPI/MCP specsgo test ./core/... ./server/...,vue-tsc, eslint green.Todos
Video Screenshots
priority.webm