Skip to content

feat: configurable loadpoint priority strategy (soc/deficit) with hysteresis#31072

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

feat: configurable loadpoint priority strategy (soc/deficit) with hysteresis#31072
Alexxtheonly wants to merge 19 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 — happy to adjust the design/naming or move to a discussion before finalizing.

What

Adds a per-loadpoint priorityStrategy that sub-orders loadpoints within the same priority tier when distributing PV surplus, plus a priorityHysteresis deadband:

  • priorityStrategy: static (default) | soc (prefer lower vehicle soc) | deficit (prefer larger limitSoc − soc).
  • priorityHysteresis (soc-%, default 0 = 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() = integer EffectivePriority() (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.0 so it never weakens cross-tier ordering. Strategy term only applies when a positive vehicle soc is known.

Scope (full parity with the existing priority option)

  • Config (YAML mapstructure) + validation.
  • loadpoint.API getters/setters, settings-DB persistence + boot restore, published keys.
  • HTTP API endpoints + MQTT setters (mirror priority/mode); regenerated mock + OpenAPI/MCP spec.
  • DynamicConfig round-trip → exposed in the config modal, and in the runtime settings modal.
  • Unit tests (score math, prioritizer ordering, hysteresis no-leapfrog, setters validation/persistence). go test ./core/... ./server/..., vue-tsc, eslint, npm run build all green.

Notes

  • Docs PR: evcc-io/docs (linked separately).
  • i18n: English source only; other locales via the translation workflow.

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
Title string `json:"title"`
DefaultMode string `json:"defaultMode"`
Priority int `json:"priority"`
PriorityStrategy api.PriorityStrategy `json:"priorityStrategy"`

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.

Move to own block to limit the diff and make it readable

@Alexxtheonly Alexxtheonly Jun 21, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done. Moved the three priority fields into their own block

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.

No need to comment- GH will resolve addressed issues automatically ;)

Comment thread api/prioritystrategy.go Outdated
Comment thread core/loadpoint_api.go
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
// Priority strategies
const (
// PriorityStatic ranks loadpoints by their configured priority only (default).
PriorityStatic PriorityStrategy = ""

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.

Naming: what makes this "static"? Isn't it really first come, first serve? Or maybe "none"?

Comment thread api/prioritystrategy.go Outdated
)

// PriorityStrategyString converts a string to PriorityStrategy
func PriorityStrategyString(s string) (PriorityStrategy, error) {

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 pattern here is to use enumer and textmarshaler instead of handrolling these.

Comment thread api/prioritystrategy.go Outdated
)

// PriorityBasisString converts a string to PriorityBasis
func PriorityBasisString(s string) (PriorityBasis, error) {

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.

Dito- pls keep consistency with rest of the codebase

@ScumbagSteve

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 would you be so kind and quickly give your 5 cents?
I never understood the worth of "priority". Without adjusting the lp delay it's not enough right?

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.

@Alexxtheonly

Copy link
Copy Markdown
Contributor Author

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?

AI did write that i'm mostly on the phone giving instructions. That was me with fingers on my keyboard :D

@andig

andig commented Jun 21, 2026

Copy link
Copy Markdown
Member

We appreciate human interaction. That „one caveat“ is the telltale sign, plus the general wording…

Alexander Herold and others added 4 commits June 21, 2026 14:23
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.
@ScumbagSteve

Copy link
Copy Markdown

Can you publish the EffectivePriorityScore via API, so we can see the results?
Then I can make the load point delay consistent to the calculated priority (via ioBroker).

@Alexxtheonly

Copy link
Copy Markdown
Contributor Author

hey steve, i've implemented what you wished for ;) effectivePriorityScore is now in the loadpoint API

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>
@Alexxtheonly Alexxtheonly force-pushed the feat/loadpoint-priority-strategy branch from 91b3251 to 4fe8836 Compare June 23, 2026 16:50
@ScumbagSteve

Copy link
Copy Markdown

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.

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

Labels

backlog Things to do later 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)

3 participants