Skip to content

Loadpoint: add configurable priority strategy (soc/deficit) with hysteresis - #31072

Open
Alexxtheonly wants to merge 33 commits into
evcc-io:masterfrom
Alexxtheonly:feat/loadpoint-priority-strategy
Open

Loadpoint: add configurable priority strategy (soc/deficit) with hysteresis#31072
Alexxtheonly wants to merge 33 commits into
evcc-io:masterfrom
Alexxtheonly:feat/loadpoint-priority-strategy

Conversation

@Alexxtheonly

@Alexxtheonly Alexxtheonly commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Closes #31071.

Draft, opened for maintainer feedback per the prior-consensus policy.

What

Adds a site-level priorityStrategy that sub-orders loadpoints within the same priority tier when distributing PV surplus:

  • priorityStrategy: none (default) | soc (prefer lower vehicle soc) | deficit (prefer larger limitSoc - 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 (default 0 = 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) = integer EffectivePriority() (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.
  • Loadpoints publish a read-only effectivePriorityScore.
  • pv: reserve surplus for higher-priority loadpoints starting up (#31194) #31684's startup reservation keeps working on integer tiers, unchanged. Making it score-aware (startup precedence within a tier) is a possible follow-up PR.

Scope

  • Site config (YAML) + validation, settings persistence + boot restore, published keys
  • Site HTTP API + MQTT setters (next to prioritySoc), regenerated mock + OpenAPI/MCP specs
  • Config UI (ControlModal)
  • Tests: score math, prioritizer ordering, hysteresis, energy-basis fallback, setters. go test ./core/... ./server/..., vue-tsc, eslint green.

Todos

Video Screenshots

priority.webm
loadpoint settings priority modal

Alexander Herold and others added 7 commits June 20, 2026 16:09
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>
@ScumbagSteve

Copy link
Copy Markdown

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.

Alexander Herold and others added 2 commits June 21, 2026 06:35
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>
@Alexxtheonly

Alexxtheonly commented Jun 21, 2026

Copy link
Copy Markdown
Contributor Author

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 priorityBasis that composes with both the soc and deficit strategies:

  • percent (default): rank by the soc-% gap, unchanged behavior
  • energy: scale the soc-% gap by the vehicle's capacity, so loadpoints rank by absolute energy (kWh) instead of percentage

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 capacity isn't set. The hysteresis deadband follows the basis (soc-% in percent mode, kWh in energy mode).

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.

Alexxtheonly added 3 commits June 21, 2026 07:00
- 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.
Comment thread core/loadpoint/config.go Outdated
Comment thread api/prioritystrategy.go Outdated
Comment thread core/loadpoint_api.go Outdated
Comment thread core/loadpoint_effective.go Outdated
@andig

andig commented Jun 21, 2026

Copy link
Copy Markdown
Member

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
@Alexxtheonly

Alexxtheonly commented Jun 21, 2026

Copy link
Copy Markdown
Contributor Author

@andig on the hysteresis question: priorityHysteresis covers this. The prioritizer only lets one loadpoint outrank another when its score is ahead by more than the band, so loadpoints inside the band tie rather than leapfrog (prioritizer.go, GetChargePowerFlexibility).

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 TestPrioritizerHysteresis.

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).

@ScumbagSteve

ScumbagSteve commented Jun 21, 2026

Copy link
Copy Markdown

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?
In my local implementation, the loadpoint of the prioritized car always gets the smaller delay, so I make sure this is actually the one that starts charging first.

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.

@andig andig added the backlog Things to do later label Jun 21, 2026
@andig

andig commented Jun 21, 2026

Copy link
Copy Markdown
Member

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).

Out of curiosity: did you write that or AI?

@andig
andig requested a review from naltatis June 21, 2026 09:49
@andig andig added the ux User experience/ interface label Jun 21, 2026
Comment thread api/prioritystrategy.go Outdated
Comment thread api/prioritystrategy.go Outdated
@naltatis

naltatis commented Jul 30, 2026

Copy link
Copy Markdown
Member

Heating loadpoints are now excluded from the priority sub-ordering (60314ef):

  • EffectivePriorityScore returns the plain priority tier for heating loadpoints. Their soc value holds a temperature, not a charge level, so ranking them by "emptiest first" (distance to 100) or converting the gap to kWh makes no sense. Within a tier they now lose against ranked vehicles, which is a reasonable default behavior.
  • effectiveBasis no longer lets a heater veto the energy basis. Previously a single heating loadpoint (no vehicle capacity) silently forced percent ranking for all vehicles.
  • New IsHeating() accessor on loadpoint.API, mock regenerated.

Cross-tier behavior is unchanged: integer priorities still apply to heaters in both directions.

@naltatis

Copy link
Copy Markdown
Member

The new modal is a good place to introduce a visual priority selection UI (drag&drop ranking instead of 1..10 select per lp) in a further iteration.

I've added this in #32310. Together they form quite a good overview of everything priority related (except vehicle-lp-prio-overwrite).

@github-actions github-actions Bot added the stale Outdated and ready to close label Aug 6, 2026
@naltatis naltatis removed the stale Outdated and ready to close label Aug 13, 2026
@naltatis

Copy link
Copy Markdown
Member

@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.

@ScumbagSteve

Copy link
Copy Markdown

I have already implemented dynamic priority via ioBroker with the same strategy as you have implemented here with "biggest need first". It is a very useful addition to core evcc.
A friend of mine Is waiting for exactly this, he does not want to solve it via third party services. So I can say there definitely is a demand for this feature in the user base.

What would be a great feature to come along with, is showing the "effective" priority next to the loadpoint, as shown in the mockup, as with the dynamic priority it's not clear which car will be charged first at a time.

Bildschirmfoto 2026-08-13 um 12 23 08

But for sure this is also something we could add later on and start with what we have.

…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
@naltatis

naltatis commented Aug 13, 2026

Copy link
Copy Markdown
Member

changed buttons to work with the new mobile layout #32810

  • changed to plural "Priorities/Prioritäten"
  • only show if >1 loadpoints exist

mobile
Bildschirmfoto 2026-08-13 um 18 41 11

desktop
desktop

mockSite embeds a nil site.API, so GetPriorityStrategy/GetPriorityBasis
segfaulted once PublishEffectiveValues started calling them.
andig

This comment was marked as resolved.

@naltatis

Copy link
Copy Markdown
Member

@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
@Alexxtheonly

Copy link
Copy Markdown
Contributor Author

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 math import with it.

the energy veto moved to the site as EffectivePriorityScoring, and loadpoints with no comparable soc are skipped rather than vetoing, also as suggested.

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: limitSoc has no upper bound anywhere, so a deficit gap can exceed the reference and push the score into the next tier.

soc 0 i left alone with a comment saying it reads as unknown. moving socUpdated under vmu seemed like more than this PR should carry, but happy to do it if you'd rather have the real signal.

for the published score, basis and reference are resolved once site-wide and both the prioritizer and PublishEffectiveValues read the same values, so it can't sit on a different scale than the one that decided the ranking.

two things i'd rather have your call on than guess:

hysteresis units. the band is hysteresis / reference now, so a value configured in kWh really is kWh. but when the veto flips the basis to percent the reference goes back to 100 and the same number quietly means something else, mid-operation, with nothing in the log or the UI to say so. consistent with how the veto works, just invisible. worth a debug line, or publishing the effective basis?

the 0..99 bound is percent shaped. on a fleet of 40 kWh cars a hysteresis of 99 kWh gives a band above 1, so nothing can ever outrank anything and the feature is silently off. should the max scale with the basis?

also fixed a nil deref i ran into on the way: site.Loadpoints() returns nil entries for unconfigured slots, so iterating it and calling IsHeating() panics.

@andig andig left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two things I'd fix before merge — details inline.

🤖 Generated with Claude Code

Comment thread server/openapi.yaml Outdated
Comment on lines +53 to +55
// 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems this is the same as the question you've had identified.

@ScumbagSteve

Copy link
Copy Markdown

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.

@naltatis

Copy link
Copy Markdown
Member

Can we show the resulting priority of each loadpoint as an easily understandable value (1/2/3) in the UI?

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

@florian240483

Copy link
Copy Markdown

Can we show the resulting priority of each loadpoint as an easily understandable value (1/2/3) in the UI?

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.

@ScumbagSteve

Copy link
Copy Markdown

I'll create a separate issue later, then we can discuss UI adjustments there ☺️

@naltatis

Copy link
Copy Markdown
Member

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.

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.

@florian240483

Copy link
Copy Markdown

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.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs decision Unsure if we should really do this needs documentation Triggers issue creation in evcc-io/docs ux User experience/ interface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Loadpoint priority strategy: charge the lower-SoC vehicle first (configurable, with hysteresis)

5 participants