From a299f71c8b270c02583cc122e80f5b2d10eabc8e Mon Sep 17 00:00:00 2001 From: Alexander Herold Date: Sat, 20 Jun 2026 16:09:34 +0200 Subject: [PATCH 01/18] feat: configurable loadpoint priority strategy (soc, deficit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- api/prioritystrategy.go | 45 ++++++++++++++++++++++++++++ core/loadpoint.go | 36 +++++++++++++--------- core/loadpoint/api.go | 4 +++ core/loadpoint/mock.go | 28 +++++++++++++++++ core/loadpoint_api.go | 7 +++++ core/loadpoint_effective.go | 41 +++++++++++++++++++++++++ core/loadpoint_effective_test.go | 36 ++++++++++++++++++++++ core/prioritizer/prioritizer.go | 8 ++--- core/prioritizer/prioritizer_test.go | 33 +++++++++++++++++--- 9 files changed, 216 insertions(+), 22 deletions(-) create mode 100644 api/prioritystrategy.go diff --git a/api/prioritystrategy.go b/api/prioritystrategy.go new file mode 100644 index 00000000000..087456ce7d3 --- /dev/null +++ b/api/prioritystrategy.go @@ -0,0 +1,45 @@ +package api + +import ( + "fmt" + "strings" +) + +// PriorityStrategy determines how a loadpoint is ranked against other loadpoints +// of the same priority when distributing surplus power. Valid values are static, +// soc and deficit. +type PriorityStrategy string + +// Priority strategies +const ( + // PriorityStatic ranks loadpoints by their configured priority only (default). + PriorityStatic PriorityStrategy = "" + // PrioritySoc additionally prefers the loadpoint with the lower vehicle soc + // among loadpoints of the same priority. + PrioritySoc PriorityStrategy = "soc" + // PriorityDeficit additionally prefers the loadpoint with the larger gap + // between vehicle soc and its limit soc among loadpoints of the same priority. + PriorityDeficit PriorityStrategy = "deficit" +) + +// String implements Stringer +func (s PriorityStrategy) String() string { + if s == PriorityStatic { + return "static" + } + return string(s) +} + +// PriorityStrategyString converts a string to PriorityStrategy +func PriorityStrategyString(s string) (PriorityStrategy, error) { + switch strings.ToLower(strings.TrimSpace(s)) { + case "", "static": + return PriorityStatic, nil + case string(PrioritySoc): + return PrioritySoc, nil + case string(PriorityDeficit): + return PriorityDeficit, nil + default: + return PriorityStatic, fmt.Errorf("invalid priority strategy: %s", s) + } +} diff --git a/core/loadpoint.go b/core/loadpoint.go index 92bd04778ec..307f6a2d211 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -101,9 +101,10 @@ type Loadpoint struct { ui loadpoint.UIConfig // display-only, not used in control logic // from yaml - DefaultMode api.ChargeMode `mapstructure:"mode"` // Default charge mode, used for disconnect - Title string `mapstructure:"title"` // UI title - Priority int `mapstructure:"priority"` // Priority + DefaultMode api.ChargeMode `mapstructure:"mode"` // Default charge mode, used for disconnect + Title string `mapstructure:"title"` // UI title + Priority int `mapstructure:"priority"` // Priority + PriorityStrategy api.PriorityStrategy `mapstructure:"priorityStrategy"` // Priority strategy (static, soc, deficit) // from yaml, deprecated GuardDuration_ time.Duration `mapstructure:"guardduration"` // ignored, present for compatibility @@ -111,17 +112,18 @@ type Loadpoint struct { MinCurrent_ float64 `mapstructure:"minCurrent"` // ignored, present for compatibility MaxCurrent_ float64 `mapstructure:"maxCurrent"` // ignored, present for compatibility - title string // UI title - priority int // Priority - minCurrent float64 // PV mode: start current Min+PV mode: min current - maxCurrent float64 // Max allowed current. Physically ensured by the charger - phasesConfigured int // Charger configured phase mode 0/1/3 - limitSoc int // Session limit for soc - limitEnergy float64 // Session limit for energy - smartCostLimit *float64 // always charge if consumption cost is below this value - smartFeedInPriorityLimit *float64 // prevent charging if feed-in cost is above this value - batteryBoost int // battery boost state - batteryBoostLimit int // battery boost soc limit (0-100, 100=disabled) + title string // UI title + priority int // Priority + priorityStrategy api.PriorityStrategy // Priority strategy (static, soc, deficit) + minCurrent float64 // PV mode: start current Min+PV mode: min current + maxCurrent float64 // Max allowed current. Physically ensured by the charger + phasesConfigured int // Charger configured phase mode 0/1/3 + limitSoc int // Session limit for soc + limitEnergy float64 // Session limit for energy + smartCostLimit *float64 // always charge if consumption cost is below this value + smartFeedInPriorityLimit *float64 // prevent charging if feed-in cost is above this value + batteryBoost int // battery boost state + batteryBoostLimit int // battery boost soc limit (0-100, 100=disabled) mode api.ChargeMode enabled bool // Charger enabled state @@ -219,6 +221,12 @@ func NewLoadpointFromConfig(log *util.Logger, settings settings.Settings, collec lp.setPriority(lp.Priority) } + if ps, err := api.PriorityStrategyString(string(lp.PriorityStrategy)); err != nil { + return lp, err + } else { + lp.priorityStrategy = ps + } + if lp.CircuitRef != "" { dev, err := config.Circuits().ByName(lp.CircuitRef) if err != nil { diff --git a/core/loadpoint/api.go b/core/loadpoint/api.go index 71051f4db39..f05dd1f9c76 100644 --- a/core/loadpoint/api.go +++ b/core/loadpoint/api.go @@ -57,6 +57,8 @@ type API interface { GetPriority() int // SetPriority sets the priority SetPriority(int) + // GetPriorityStrategy returns the priority strategy + GetPriorityStrategy() api.PriorityStrategy // GetMinCurrent returns the min charging current GetMinCurrent() float64 // SetMinCurrent sets the min charging current @@ -98,6 +100,8 @@ type API interface { // EffectivePriority returns the effective priority EffectivePriority() int + // EffectivePriorityScore returns the sortable priority score (tier + strategy sub-ordering) + EffectivePriorityScore() float64 // EffectiveLimitSoc returns the effective session limit soc EffectiveLimitSoc() int // EffectivePlanId returns the effective plan id diff --git a/core/loadpoint/mock.go b/core/loadpoint/mock.go index 3f43e60a4aa..430cc8d0d98 100644 --- a/core/loadpoint/mock.go +++ b/core/loadpoint/mock.go @@ -165,6 +165,20 @@ func (mr *MockAPIMockRecorder) EffectivePriority() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EffectivePriority", reflect.TypeOf((*MockAPI)(nil).EffectivePriority)) } +// EffectivePriorityScore mocks base method. +func (m *MockAPI) EffectivePriorityScore() float64 { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "EffectivePriorityScore") + ret0, _ := ret[0].(float64) + return ret0 +} + +// EffectivePriorityScore indicates an expected call of EffectivePriorityScore. +func (mr *MockAPIMockRecorder) EffectivePriorityScore() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EffectivePriorityScore", reflect.TypeOf((*MockAPI)(nil).EffectivePriorityScore)) +} + // GetBatteryBoost mocks base method. func (m *MockAPI) GetBatteryBoost() int { m.ctrl.T.Helper() @@ -559,6 +573,20 @@ func (mr *MockAPIMockRecorder) GetPriority() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPriority", reflect.TypeOf((*MockAPI)(nil).GetPriority)) } +// GetPriorityStrategy mocks base method. +func (m *MockAPI) GetPriorityStrategy() api.PriorityStrategy { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPriorityStrategy") + ret0, _ := ret[0].(api.PriorityStrategy) + return ret0 +} + +// GetPriorityStrategy indicates an expected call of GetPriorityStrategy. +func (mr *MockAPIMockRecorder) GetPriorityStrategy() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPriorityStrategy", reflect.TypeOf((*MockAPI)(nil).GetPriorityStrategy)) +} + // GetRemainingDuration mocks base method. func (m *MockAPI) GetRemainingDuration() time.Duration { m.ctrl.T.Helper() diff --git a/core/loadpoint_api.go b/core/loadpoint_api.go index 04a88a26b96..6b45a9a4a71 100644 --- a/core/loadpoint_api.go +++ b/core/loadpoint_api.go @@ -228,6 +228,13 @@ func (lp *Loadpoint) GetPriority() int { return lp.priority } +// GetPriorityStrategy returns the loadpoint priority strategy +func (lp *Loadpoint) GetPriorityStrategy() api.PriorityStrategy { + lp.RLock() + defer lp.RUnlock() + return lp.priorityStrategy +} + // setPriority sets the loadpoint priority (no mutex) func (lp *Loadpoint) setPriority(prio int) { lp.priority = prio diff --git a/core/loadpoint_effective.go b/core/loadpoint_effective.go index 786d8be000d..788f1a0d420 100644 --- a/core/loadpoint_effective.go +++ b/core/loadpoint_effective.go @@ -32,6 +32,47 @@ func (lp *Loadpoint) EffectivePriority() int { return lp.GetPriority() } +// EffectivePriorityScore returns a sortable priority score used to rank loadpoints +// when distributing surplus power. The integer part is the effective priority (the +// tier); the fractional part in [0,1) orders loadpoints within the same tier +// according to the configured priority strategy, so the sub-ordering never crosses +// a priority boundary. +// +// A higher score wins. With the soc strategy a lower vehicle soc scores higher; +// with the deficit strategy a larger gap to the limit soc scores higher. The +// strategy sub-ordering only applies when a positive vehicle soc is known, +// otherwise the score equals the plain effective priority. +func (lp *Loadpoint) EffectivePriorityScore() float64 { + score := float64(lp.EffectivePriority()) + + soc := lp.GetSoc() + if soc <= 0 { + return score + } + + switch lp.GetPriorityStrategy() { + case api.PrioritySoc: + score += priorityFraction(100 - soc) + case api.PriorityDeficit: + score += priorityFraction(float64(lp.EffectiveLimitSoc()) - soc) + } + + return score +} + +// priorityFraction maps a soc-based value to a [0,1) sub-ordering offset, kept +// strictly below 1 so it can never bump a loadpoint into the next priority tier. +func priorityFraction(v float64) float64 { + switch { + case v <= 0: + return 0 + case v > 99: + return 0.99 + default: + return v / 100 + } +} + type plan struct { Id int Start time.Time // last possible start time diff --git a/core/loadpoint_effective_test.go b/core/loadpoint_effective_test.go index 2b6dc2dd4e5..490921d8572 100644 --- a/core/loadpoint_effective_test.go +++ b/core/loadpoint_effective_test.go @@ -17,6 +17,42 @@ func TestEffectiveLimitSoc(t *testing.T) { assert.Equal(t, 100, lp.effectiveLimitSoc()) } +func TestEffectivePriorityScore(t *testing.T) { + tc := []struct { + strategy api.PriorityStrategy + priority int + soc, limitSoc float64 + expected float64 + }{ + // static: fractional part is always zero + {api.PriorityStatic, 0, 50, 0, 0}, + {api.PriorityStatic, 2, 50, 0, 2}, + // soc: lower soc scores higher within the tier + {api.PrioritySoc, 0, 20, 0, 0.80}, + {api.PrioritySoc, 0, 80, 0, 0.20}, + {api.PrioritySoc, 1, 20, 0, 1.80}, + {api.PrioritySoc, 0, 100, 0, 0}, // full vehicle: no boost + {api.PrioritySoc, 0, 0, 0, 0}, // unknown soc: falls back to plain priority + // deficit: larger gap to the limit soc scores higher within the tier + {api.PriorityDeficit, 0, 50, 80, 0.30}, + {api.PriorityDeficit, 0, 50, 0, 0.50}, // no limit set -> default 100 + {api.PriorityDeficit, 0, 90, 80, 0}, // soc above limit: no boost + {api.PriorityDeficit, 0, 0, 80, 0}, // unknown soc: falls back to plain priority + } + + for _, tc := range tc { + t.Logf("%+v", tc) + + lp := NewLoadpoint(util.NewLogger("foo"), nil) + lp.priority = tc.priority + lp.priorityStrategy = tc.strategy + lp.vehicleSoc = tc.soc + lp.limitSoc = int(tc.limitSoc) + + assert.InDelta(t, tc.expected, lp.EffectivePriorityScore(), 1e-9) + } +} + func TestEffectiveMinMaxCurrent(t *testing.T) { tc := []struct { chargerMin, chargerMax float64 diff --git a/core/prioritizer/prioritizer.go b/core/prioritizer/prioritizer.go index 6cdb88866a1..4244c7cd927 100644 --- a/core/prioritizer/prioritizer.go +++ b/core/prioritizer/prioritizer.go @@ -31,7 +31,7 @@ func (p *Prioritizer) UpdateChargePowerFlexibility(lp loadpoint.API, rates api.R } func (p *Prioritizer) GetChargePowerFlexibility(lp loadpoint.API) float64 { - prio := lp.EffectivePriority() + score := lp.EffectivePriorityScore() var ( reduceBy float64 @@ -39,14 +39,14 @@ func (p *Prioritizer) GetChargePowerFlexibility(lp loadpoint.API) float64 { ) for lp, power := range p.demand { - if lp.EffectivePriority() < prio && power > 0 { + if lp.EffectivePriorityScore() < score && power > 0 { reduceBy += power - msg += fmt.Sprintf("%.0fW from %s at prio %d, ", power, lp.GetTitle(), lp.EffectivePriority()) + msg += fmt.Sprintf("%.0fW from %s at prio %.2f, ", power, lp.GetTitle(), lp.EffectivePriorityScore()) } } if p.log != nil && reduceBy > 0 { - p.log.DEBUG.Printf("lp %s at prio %d gets additional %stotal %.0fW\n", lp.GetTitle(), lp.EffectivePriority(), msg, reduceBy) + p.log.DEBUG.Printf("lp %s at prio %.2f gets additional %stotal %.0fW\n", lp.GetTitle(), score, msg, reduceBy) } return reduceBy diff --git a/core/prioritizer/prioritizer_test.go b/core/prioritizer/prioritizer_test.go index 0d65c87a8dc..7fb7c632fc7 100644 --- a/core/prioritizer/prioritizer_test.go +++ b/core/prioritizer/prioritizer_test.go @@ -15,13 +15,11 @@ func TestPrioritzer(t *testing.T) { lo := loadpoint.NewMockAPI(ctrl) lo.EXPECT().GetTitle().AnyTimes() - lo.EXPECT().GetPriority().Return(0).AnyTimes() // prio 0 - lo.EXPECT().EffectivePriority().Return(0).AnyTimes() // prio 0 + lo.EXPECT().EffectivePriorityScore().Return(0.0).AnyTimes() // prio 0 hi := loadpoint.NewMockAPI(ctrl) hi.EXPECT().GetTitle().AnyTimes() - hi.EXPECT().GetPriority().Return(1).AnyTimes() // prio 1 - hi.EXPECT().EffectivePriority().Return(1).AnyTimes() // prio 1 + hi.EXPECT().EffectivePriorityScore().Return(1.0).AnyTimes() // prio 1 // no additional power available lo.EXPECT().GetChargePowerFlexibility(nil).Return(300.0) @@ -38,3 +36,30 @@ func TestPrioritzer(t *testing.T) { p.UpdateChargePowerFlexibility(lo, nil) assert.Equal(t, 0.0, p.GetChargePowerFlexibility(hi)) } + +// TestPrioritizerWithinTier verifies that loadpoints sharing the same priority +// tier are ranked by their fractional score (e.g. soc/deficit strategy), so the +// emptier vehicle takes surplus from the fuller one. +func TestPrioritizerWithinTier(t *testing.T) { + ctrl := gomock.NewController(t) + + p := New(nil) + + full := loadpoint.NewMockAPI(ctrl) // prio 0, soc 80 -> score 0.20 + full.EXPECT().GetTitle().AnyTimes() + full.EXPECT().EffectivePriorityScore().Return(0.20).AnyTimes() + + empty := loadpoint.NewMockAPI(ctrl) // prio 0, soc 20 -> score 0.80 + empty.EXPECT().GetTitle().AnyTimes() + empty.EXPECT().EffectivePriorityScore().Return(0.80).AnyTimes() + + // fuller vehicle has nothing below it -> no extra power + full.EXPECT().GetChargePowerFlexibility(nil).Return(500.0) + p.UpdateChargePowerFlexibility(full, nil) + assert.Equal(t, 0.0, p.GetChargePowerFlexibility(full)) + + // emptier vehicle (higher score in the same tier) takes the fuller one's flexible power + empty.EXPECT().GetChargePowerFlexibility(nil).Return(1e3) + p.UpdateChargePowerFlexibility(empty, nil) + assert.Equal(t, 500.0, p.GetChargePowerFlexibility(empty)) +} From 8b2f970d2b59eb0a17726dd4388b90d6a00b70a0 Mon Sep 17 00:00:00 2001 From: Alexander Herold Date: Sat, 20 Jun 2026 18:28:07 +0200 Subject: [PATCH 02/18] feat: add priorityHysteresis deadband to loadpoint priority sub-ordering 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 --- core/loadpoint.go | 15 ++++++++--- core/loadpoint/api.go | 2 ++ core/loadpoint/mock.go | 14 ++++++++++ core/loadpoint_api.go | 7 +++++ core/prioritizer/prioritizer.go | 9 ++++++- core/prioritizer/prioritizer_test.go | 40 ++++++++++++++++++++++++++++ 6 files changed, 82 insertions(+), 5 deletions(-) diff --git a/core/loadpoint.go b/core/loadpoint.go index 307f6a2d211..093200706f5 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -101,10 +101,11 @@ type Loadpoint struct { ui loadpoint.UIConfig // display-only, not used in control logic // from yaml - DefaultMode api.ChargeMode `mapstructure:"mode"` // Default charge mode, used for disconnect - Title string `mapstructure:"title"` // UI title - Priority int `mapstructure:"priority"` // Priority - PriorityStrategy api.PriorityStrategy `mapstructure:"priorityStrategy"` // Priority strategy (static, soc, deficit) + DefaultMode api.ChargeMode `mapstructure:"mode"` // Default charge mode, used for disconnect + Title string `mapstructure:"title"` // UI title + Priority int `mapstructure:"priority"` // Priority + PriorityStrategy api.PriorityStrategy `mapstructure:"priorityStrategy"` // Priority strategy (static, soc, deficit) + PriorityHysteresis int `mapstructure:"priorityHysteresis"` // Priority sub-ordering deadband in soc-% (0 = off) // from yaml, deprecated GuardDuration_ time.Duration `mapstructure:"guardduration"` // ignored, present for compatibility @@ -115,6 +116,7 @@ type Loadpoint struct { title string // UI title priority int // Priority priorityStrategy api.PriorityStrategy // Priority strategy (static, soc, deficit) + priorityHysteresis int // Priority sub-ordering deadband in soc-% (0 = off) minCurrent float64 // PV mode: start current Min+PV mode: min current maxCurrent float64 // Max allowed current. Physically ensured by the charger phasesConfigured int // Charger configured phase mode 0/1/3 @@ -227,6 +229,11 @@ func NewLoadpointFromConfig(log *util.Logger, settings settings.Settings, collec lp.priorityStrategy = ps } + if lp.PriorityHysteresis < 0 || lp.PriorityHysteresis > 99 { + return lp, fmt.Errorf("invalid priority hysteresis: %d (must be 0..99)", lp.PriorityHysteresis) + } + lp.priorityHysteresis = lp.PriorityHysteresis + if lp.CircuitRef != "" { dev, err := config.Circuits().ByName(lp.CircuitRef) if err != nil { diff --git a/core/loadpoint/api.go b/core/loadpoint/api.go index f05dd1f9c76..e73e5c83d82 100644 --- a/core/loadpoint/api.go +++ b/core/loadpoint/api.go @@ -59,6 +59,8 @@ type API interface { SetPriority(int) // GetPriorityStrategy returns the priority strategy GetPriorityStrategy() api.PriorityStrategy + // GetPriorityHysteresis returns the priority sub-ordering deadband in soc-% + GetPriorityHysteresis() int // GetMinCurrent returns the min charging current GetMinCurrent() float64 // SetMinCurrent sets the min charging current diff --git a/core/loadpoint/mock.go b/core/loadpoint/mock.go index 430cc8d0d98..095e1ac2433 100644 --- a/core/loadpoint/mock.go +++ b/core/loadpoint/mock.go @@ -573,6 +573,20 @@ func (mr *MockAPIMockRecorder) GetPriority() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPriority", reflect.TypeOf((*MockAPI)(nil).GetPriority)) } +// GetPriorityHysteresis mocks base method. +func (m *MockAPI) GetPriorityHysteresis() int { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPriorityHysteresis") + ret0, _ := ret[0].(int) + return ret0 +} + +// GetPriorityHysteresis indicates an expected call of GetPriorityHysteresis. +func (mr *MockAPIMockRecorder) GetPriorityHysteresis() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPriorityHysteresis", reflect.TypeOf((*MockAPI)(nil).GetPriorityHysteresis)) +} + // GetPriorityStrategy mocks base method. func (m *MockAPI) GetPriorityStrategy() api.PriorityStrategy { m.ctrl.T.Helper() diff --git a/core/loadpoint_api.go b/core/loadpoint_api.go index 6b45a9a4a71..ba7db79b41a 100644 --- a/core/loadpoint_api.go +++ b/core/loadpoint_api.go @@ -235,6 +235,13 @@ func (lp *Loadpoint) GetPriorityStrategy() api.PriorityStrategy { return lp.priorityStrategy } +// GetPriorityHysteresis returns the priority sub-ordering deadband in soc-% +func (lp *Loadpoint) GetPriorityHysteresis() int { + lp.RLock() + defer lp.RUnlock() + return lp.priorityHysteresis +} + // setPriority sets the loadpoint priority (no mutex) func (lp *Loadpoint) setPriority(prio int) { lp.priority = prio diff --git a/core/prioritizer/prioritizer.go b/core/prioritizer/prioritizer.go index 4244c7cd927..7847da8472a 100644 --- a/core/prioritizer/prioritizer.go +++ b/core/prioritizer/prioritizer.go @@ -2,6 +2,7 @@ package prioritizer import ( "fmt" + "math" "sync" "github.com/evcc-io/evcc/api" @@ -33,13 +34,19 @@ func (p *Prioritizer) UpdateChargePowerFlexibility(lp loadpoint.API, rates api.R func (p *Prioritizer) GetChargePowerFlexibility(lp loadpoint.API) float64 { score := lp.EffectivePriorityScore() + // hysteresis deadband (soc-% -> score fraction): only outrank another loadpoint + // when ahead by more than the band, so near-equal soc loadpoints tie and converge + // instead of leapfrogging each other as their soc crosses. capped below 1.0 so it + // never weakens cross-tier (integer priority) ordering. + band := math.Min(float64(lp.GetPriorityHysteresis())/100, 0.99) + var ( reduceBy float64 msg string ) for lp, power := range p.demand { - if lp.EffectivePriorityScore() < score && power > 0 { + if score-lp.EffectivePriorityScore() > band && power > 0 { reduceBy += power msg += fmt.Sprintf("%.0fW from %s at prio %.2f, ", power, lp.GetTitle(), lp.EffectivePriorityScore()) } diff --git a/core/prioritizer/prioritizer_test.go b/core/prioritizer/prioritizer_test.go index 7fb7c632fc7..8f01852626e 100644 --- a/core/prioritizer/prioritizer_test.go +++ b/core/prioritizer/prioritizer_test.go @@ -16,10 +16,12 @@ func TestPrioritzer(t *testing.T) { lo := loadpoint.NewMockAPI(ctrl) lo.EXPECT().GetTitle().AnyTimes() lo.EXPECT().EffectivePriorityScore().Return(0.0).AnyTimes() // prio 0 + lo.EXPECT().GetPriorityHysteresis().Return(0).AnyTimes() hi := loadpoint.NewMockAPI(ctrl) hi.EXPECT().GetTitle().AnyTimes() hi.EXPECT().EffectivePriorityScore().Return(1.0).AnyTimes() // prio 1 + hi.EXPECT().GetPriorityHysteresis().Return(0).AnyTimes() // no additional power available lo.EXPECT().GetChargePowerFlexibility(nil).Return(300.0) @@ -48,10 +50,12 @@ func TestPrioritizerWithinTier(t *testing.T) { full := loadpoint.NewMockAPI(ctrl) // prio 0, soc 80 -> score 0.20 full.EXPECT().GetTitle().AnyTimes() full.EXPECT().EffectivePriorityScore().Return(0.20).AnyTimes() + full.EXPECT().GetPriorityHysteresis().Return(0).AnyTimes() empty := loadpoint.NewMockAPI(ctrl) // prio 0, soc 20 -> score 0.80 empty.EXPECT().GetTitle().AnyTimes() empty.EXPECT().EffectivePriorityScore().Return(0.80).AnyTimes() + empty.EXPECT().GetPriorityHysteresis().Return(0).AnyTimes() // fuller vehicle has nothing below it -> no extra power full.EXPECT().GetChargePowerFlexibility(nil).Return(500.0) @@ -63,3 +67,39 @@ func TestPrioritizerWithinTier(t *testing.T) { p.UpdateChargePowerFlexibility(empty, nil) assert.Equal(t, 500.0, p.GetChargePowerFlexibility(empty)) } + +// TestPrioritizerHysteresis verifies the priority deadband: within the same tier, +// a loadpoint only outranks another when ahead by more than the configured band, so +// near-equal soc loadpoints tie (no stealing, no leapfrog) while clearly-emptier ones +// still take priority. +func TestPrioritizerHysteresis(t *testing.T) { + ctrl := gomock.NewController(t) + + p := New(nil) + + // soc 50 -> 0.50, soc 51 -> 0.49, 5% deadband (0.05) + a := loadpoint.NewMockAPI(ctrl) + a.EXPECT().GetTitle().AnyTimes() + a.EXPECT().EffectivePriorityScore().Return(0.50).AnyTimes() + a.EXPECT().GetPriorityHysteresis().Return(5).AnyTimes() + + b := loadpoint.NewMockAPI(ctrl) + b.EXPECT().GetTitle().AnyTimes() + b.EXPECT().EffectivePriorityScore().Return(0.49).AnyTimes() + b.EXPECT().GetPriorityHysteresis().Return(5).AnyTimes() + + // clearly emptier (soc 40 -> 0.60), same 5% band + c := loadpoint.NewMockAPI(ctrl) + c.EXPECT().GetTitle().AnyTimes() + c.EXPECT().EffectivePriorityScore().Return(0.60).AnyTimes() + c.EXPECT().GetPriorityHysteresis().Return(5).AnyTimes() + + b.EXPECT().GetChargePowerFlexibility(nil).Return(400.0) + p.UpdateChargePowerFlexibility(b, nil) + + // a is only 0.01 ahead of b -> within the 0.05 band -> no steal (no leapfrog) + assert.Equal(t, 0.0, p.GetChargePowerFlexibility(a)) + + // c is 0.11 ahead of b -> beyond the band -> takes b's flexible power + assert.Equal(t, 400.0, p.GetChargePowerFlexibility(c)) +} From c156d76aca3879d29f2715b101f178fd434af0c1 Mon Sep 17 00:00:00 2001 From: Alexander Herold Date: Sat, 20 Jun 2026 18:42:35 +0200 Subject: [PATCH 03/18] feat: wire priorityStrategy and priorityHysteresis as runtime loadpoint 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 --- core/keys/loadpoint.go | 50 ++++++------ core/loadpoint.go | 10 +++ core/loadpoint/api.go | 4 + core/loadpoint/mock.go | 24 ++++++ core/loadpoint_api.go | 47 ++++++++++++ core/loadpoint_priority_test.go | 131 ++++++++++++++++++++++++++++++++ server/http.go | 2 + server/mqtt.go | 2 + 8 files changed, 246 insertions(+), 24 deletions(-) create mode 100644 core/loadpoint_priority_test.go diff --git a/core/keys/loadpoint.go b/core/keys/loadpoint.go index 3384ed4fcc2..05508f2c6d4 100644 --- a/core/keys/loadpoint.go +++ b/core/keys/loadpoint.go @@ -2,30 +2,32 @@ package keys const ( // loadpoint settings - Name = "name" // loadpoint name (config identifier) - Title = "title" // loadpoint title - Mode = "mode" // charge mode - DefaultMode = "defaultMode" // default charge mode - Charger = "charger" // charger ref - Meter = "meter" // meter ref - Circuit = "circuit" // circuit ref - DefaultVehicle = "vehicle" // default vehicle ref - Priority = "priority" // priority - MinCurrent = "minCurrent" // min current - MaxCurrent = "maxCurrent" // max current - MinSoc = "minSoc" // min soc - MinSocNotReached = "minSocNotReached" // min soc not reached - LimitSoc = "limitSoc" // limit soc - LimitEnergy = "limitEnergy" // limit energy - Soc = "soc" - Thresholds = "thresholds" - UI = "ui" // display-only ui settings (json) - EnableThreshold = "enableThreshold" - DisableThreshold = "disableThreshold" - EnableDelay = "enableDelay" - DisableDelay = "disableDelay" - BatteryBoost = "batteryBoost" - BatteryBoostLimit = "batteryBoostLimit" + Name = "name" // loadpoint name (config identifier) + Title = "title" // loadpoint title + Mode = "mode" // charge mode + DefaultMode = "defaultMode" // default charge mode + Charger = "charger" // charger ref + Meter = "meter" // meter ref + Circuit = "circuit" // circuit ref + DefaultVehicle = "vehicle" // default vehicle ref + Priority = "priority" // priority + PriorityStrategy = "priorityStrategy" // priority strategy (static, soc, deficit) + PriorityHysteresis = "priorityHysteresis" // priority sub-ordering deadband in soc-% (0 = off) + MinCurrent = "minCurrent" // min current + MaxCurrent = "maxCurrent" // max current + MinSoc = "minSoc" // min soc + MinSocNotReached = "minSocNotReached" // min soc not reached + LimitSoc = "limitSoc" // limit soc + LimitEnergy = "limitEnergy" // limit energy + Soc = "soc" + Thresholds = "thresholds" + UI = "ui" // display-only ui settings (json) + EnableThreshold = "enableThreshold" + DisableThreshold = "disableThreshold" + EnableDelay = "enableDelay" + DisableDelay = "disableDelay" + BatteryBoost = "batteryBoost" + BatteryBoostLimit = "batteryBoostLimit" PhasesConfigured = "phasesConfigured" // desired phase mode (0/1/3, 0 = automatic), user selection PhasesActive = "phasesActive" // expectedly active phases, taking vehicle into account (1/2/3) diff --git a/core/loadpoint.go b/core/loadpoint.go index 093200706f5..85c297bd28c 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -364,6 +364,14 @@ func (lp *Loadpoint) restoreSettings() { if v, err := lp.settings.Int(keys.Priority); err == nil { lp.setPriority(int(v)) } + if v, err := lp.settings.String(keys.PriorityStrategy); err == nil { + if strategy, err := api.PriorityStrategyString(v); err == nil { + lp.setPriorityStrategy(strategy) + } + } + if v, err := lp.settings.Int(keys.PriorityHysteresis); err == nil && v >= 0 && v <= 99 { + lp.setPriorityHysteresis(int(v)) + } if v, err := lp.settings.Int(keys.PhasesConfigured); err == nil && (v > 0 || lp.hasPhaseSwitching()) { lp.setPhasesConfigured(int(v)) } @@ -709,6 +717,8 @@ func (lp *Loadpoint) Prepare(site site.API, uiChan chan<- util.Param, pushChan c lp.publish(keys.Title, lp.GetTitle()) lp.publish(keys.Mode, lp.GetMode()) lp.publish(keys.Priority, lp.GetPriority()) + lp.publish(keys.PriorityStrategy, lp.GetPriorityStrategy()) + lp.publish(keys.PriorityHysteresis, lp.GetPriorityHysteresis()) lp.publish(keys.MinCurrent, lp.GetMinCurrent()) lp.publish(keys.MaxCurrent, lp.GetMaxCurrent()) diff --git a/core/loadpoint/api.go b/core/loadpoint/api.go index e73e5c83d82..db0f6660fc3 100644 --- a/core/loadpoint/api.go +++ b/core/loadpoint/api.go @@ -59,8 +59,12 @@ type API interface { SetPriority(int) // GetPriorityStrategy returns the priority strategy GetPriorityStrategy() api.PriorityStrategy + // SetPriorityStrategy sets the priority strategy + SetPriorityStrategy(api.PriorityStrategy) // GetPriorityHysteresis returns the priority sub-ordering deadband in soc-% GetPriorityHysteresis() int + // SetPriorityHysteresis sets the priority sub-ordering deadband in soc-% + SetPriorityHysteresis(int) // GetMinCurrent returns the min charging current GetMinCurrent() float64 // SetMinCurrent sets the min charging current diff --git a/core/loadpoint/mock.go b/core/loadpoint/mock.go index 095e1ac2433..edab641c060 100644 --- a/core/loadpoint/mock.go +++ b/core/loadpoint/mock.go @@ -1047,6 +1047,30 @@ func (mr *MockAPIMockRecorder) SetPriority(arg0 any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPriority", reflect.TypeOf((*MockAPI)(nil).SetPriority), arg0) } +// SetPriorityHysteresis mocks base method. +func (m *MockAPI) SetPriorityHysteresis(arg0 int) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SetPriorityHysteresis", arg0) +} + +// SetPriorityHysteresis indicates an expected call of SetPriorityHysteresis. +func (mr *MockAPIMockRecorder) SetPriorityHysteresis(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPriorityHysteresis", reflect.TypeOf((*MockAPI)(nil).SetPriorityHysteresis), arg0) +} + +// SetPriorityStrategy mocks base method. +func (m *MockAPI) SetPriorityStrategy(arg0 api.PriorityStrategy) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SetPriorityStrategy", arg0) +} + +// SetPriorityStrategy indicates an expected call of SetPriorityStrategy. +func (mr *MockAPIMockRecorder) SetPriorityStrategy(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPriorityStrategy", reflect.TypeOf((*MockAPI)(nil).SetPriorityStrategy), arg0) +} + // SetSmartCostLimit mocks base method. func (m *MockAPI) SetSmartCostLimit(limit *float64) { m.ctrl.T.Helper() diff --git a/core/loadpoint_api.go b/core/loadpoint_api.go index ba7db79b41a..818964ea52e 100644 --- a/core/loadpoint_api.go +++ b/core/loadpoint_api.go @@ -260,6 +260,53 @@ func (lp *Loadpoint) SetPriority(prio int) { } } +// setPriorityStrategy sets the loadpoint priority strategy (no mutex) +func (lp *Loadpoint) setPriorityStrategy(strategy api.PriorityStrategy) { + lp.priorityStrategy = strategy + lp.publish(keys.PriorityStrategy, lp.priorityStrategy) + lp.settings.SetString(keys.PriorityStrategy, string(lp.priorityStrategy)) +} + +// SetPriorityStrategy sets the loadpoint priority strategy +func (lp *Loadpoint) SetPriorityStrategy(strategy api.PriorityStrategy) { + lp.Lock() + defer lp.Unlock() + + normalized, err := api.PriorityStrategyString(string(strategy)) + if err != nil { + lp.log.ERROR.Printf("invalid priority strategy: %s", string(strategy)) + return + } + + lp.log.DEBUG.Printf("set priority strategy: %s", normalized) + if lp.priorityStrategy != normalized { + lp.setPriorityStrategy(normalized) + } +} + +// setPriorityHysteresis sets the loadpoint priority sub-ordering deadband (no mutex) +func (lp *Loadpoint) setPriorityHysteresis(hysteresis int) { + lp.priorityHysteresis = hysteresis + lp.publish(keys.PriorityHysteresis, lp.priorityHysteresis) + lp.settings.SetInt(keys.PriorityHysteresis, int64(lp.priorityHysteresis)) +} + +// SetPriorityHysteresis sets the loadpoint priority sub-ordering deadband in soc-% +func (lp *Loadpoint) SetPriorityHysteresis(hysteresis int) { + lp.Lock() + defer lp.Unlock() + + if hysteresis < 0 || hysteresis > 99 { + lp.log.ERROR.Printf("invalid priority hysteresis: %d (must be 0..99)", hysteresis) + return + } + + lp.log.DEBUG.Println("set priority hysteresis:", hysteresis) + if lp.priorityHysteresis != hysteresis { + lp.setPriorityHysteresis(hysteresis) + } +} + // GetPhases returns the enabled phases func (lp *Loadpoint) GetPhases() int { lp.RLock() diff --git a/core/loadpoint_priority_test.go b/core/loadpoint_priority_test.go new file mode 100644 index 00000000000..6887e190476 --- /dev/null +++ b/core/loadpoint_priority_test.go @@ -0,0 +1,131 @@ +package core + +import ( + "errors" + "time" + + "testing" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/core/keys" + "github.com/evcc-io/evcc/util" + "github.com/stretchr/testify/assert" +) + +// memSettings is a minimal in-memory settings.Settings implementation for tests. +type memSettings struct { + data map[string]any +} + +func newMemSettings() *memSettings { + return &memSettings{data: make(map[string]any)} +} + +func (s *memSettings) SetString(key string, val string) { s.data[key] = val } +func (s *memSettings) SetInt(key string, val int64) { s.data[key] = val } +func (s *memSettings) SetFloat(key string, val float64) { s.data[key] = val } +func (s *memSettings) SetFloatPtr(key string, val *float64) { s.data[key] = val } +func (s *memSettings) SetTime(key string, val time.Time) { s.data[key] = val } +func (s *memSettings) SetBool(key string, val bool) { s.data[key] = val } +func (s *memSettings) SetJson(key string, val any) error { s.data[key] = val; return nil } + +func (s *memSettings) String(key string) (string, error) { + if v, ok := s.data[key].(string); ok { + return v, nil + } + return "", errors.New("not found") +} + +func (s *memSettings) Int(key string) (int64, error) { + if v, ok := s.data[key].(int64); ok { + return v, nil + } + return 0, errors.New("not found") +} + +func (s *memSettings) Float(key string) (float64, error) { + if v, ok := s.data[key].(float64); ok { + return v, nil + } + return 0, errors.New("not found") +} + +func (s *memSettings) Time(key string) (time.Time, error) { + if v, ok := s.data[key].(time.Time); ok { + return v, nil + } + return time.Time{}, errors.New("not found") +} + +func (s *memSettings) Bool(key string) (bool, error) { + if v, ok := s.data[key].(bool); ok { + return v, nil + } + return false, errors.New("not found") +} + +func (s *memSettings) Json(key string, res any) error { return errors.New("not implemented") } + +func newPriorityTestLoadpoint() (*Loadpoint, *memSettings) { + s := newMemSettings() + lp := NewLoadpoint(util.NewLogger("foo"), s) + return lp, s +} + +func TestSetPriorityStrategy(t *testing.T) { + lp, s := newPriorityTestLoadpoint() + + // valid: soc + lp.SetPriorityStrategy(api.PrioritySoc) + assert.Equal(t, api.PrioritySoc, lp.GetPriorityStrategy()) + assert.Equal(t, string(api.PrioritySoc), s.data[keys.PriorityStrategy], "soc must be persisted") + + // valid: deficit + lp.SetPriorityStrategy(api.PriorityDeficit) + assert.Equal(t, api.PriorityDeficit, lp.GetPriorityStrategy()) + assert.Equal(t, string(api.PriorityDeficit), s.data[keys.PriorityStrategy]) + + // "static" normalizes to the empty PriorityStatic value + lp.SetPriorityStrategy(api.PriorityStrategy("static")) + assert.Equal(t, api.PriorityStatic, lp.GetPriorityStrategy()) + assert.Equal(t, string(api.PriorityStatic), s.data[keys.PriorityStrategy]) + + // invalid: rejected, state unchanged + lp.SetPriorityStrategy(api.PrioritySoc) + delete(s.data, keys.PriorityStrategy) + lp.SetPriorityStrategy(api.PriorityStrategy("bogus")) + assert.Equal(t, api.PrioritySoc, lp.GetPriorityStrategy(), "invalid strategy must not change state") + _, persisted := s.data[keys.PriorityStrategy] + assert.False(t, persisted, "invalid strategy must not be persisted") +} + +func TestSetPriorityHysteresis(t *testing.T) { + lp, s := newPriorityTestLoadpoint() + + // valid + lp.SetPriorityHysteresis(5) + assert.Equal(t, 5, lp.GetPriorityHysteresis()) + assert.Equal(t, int64(5), s.data[keys.PriorityHysteresis], "valid hysteresis must be persisted") + + // boundary: 99 ok + lp.SetPriorityHysteresis(99) + assert.Equal(t, 99, lp.GetPriorityHysteresis()) + assert.Equal(t, int64(99), s.data[keys.PriorityHysteresis]) + + // boundary: 0 ok (off) + lp.SetPriorityHysteresis(0) + assert.Equal(t, 0, lp.GetPriorityHysteresis()) + assert.Equal(t, int64(0), s.data[keys.PriorityHysteresis]) + + // invalid: > 99 rejected, state unchanged + lp.SetPriorityHysteresis(7) + delete(s.data, keys.PriorityHysteresis) + lp.SetPriorityHysteresis(100) + assert.Equal(t, 7, lp.GetPriorityHysteresis(), "out-of-range hysteresis must not change state") + _, persisted := s.data[keys.PriorityHysteresis] + assert.False(t, persisted, "out-of-range hysteresis must not be persisted") + + // invalid: negative rejected + lp.SetPriorityHysteresis(-1) + assert.Equal(t, 7, lp.GetPriorityHysteresis(), "negative hysteresis must not change state") +} diff --git a/server/http.go b/server/http.go index 2956eecccde..0c6cbc05f33 100644 --- a/server/http.go +++ b/server/http.go @@ -220,6 +220,8 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API) { "smartFeedInPriority": {"POST", "/smartfeedinprioritylimit/{value:-?[0-9.]+}", floatPtrHandler(pass(lp.SetSmartFeedInPriorityLimit), lp.GetSmartFeedInPriorityLimit)}, "smartFeedInPriorityDelete": {"DELETE", "/smartfeedinprioritylimit", floatPtrHandler(pass(lp.SetSmartFeedInPriorityLimit), lp.GetSmartFeedInPriorityLimit)}, "priority": {"POST", "/priority/{value:[0-9]+}", intHandler(pass(lp.SetPriority), lp.GetPriority)}, + "priorityStrategy": {"POST", "/prioritystrategy/{value:[a-z]+}", handler(eapi.PriorityStrategyString, pass(lp.SetPriorityStrategy), lp.GetPriorityStrategy)}, + "priorityHysteresis": {"POST", "/priorityhysteresis/{value:[0-9]+}", intHandler(pass(lp.SetPriorityHysteresis), lp.GetPriorityHysteresis)}, "batteryBoost": {"POST", "/batteryboost/{value:[01truefalse]+}", boolHandler(lp.SetBatteryBoost, func() bool { return lp.GetBatteryBoost() > 0 })}, "batteryBoostLimit": {"POST", "/batteryboostlimit/{value:[0-9]+}", intHandler(pass(lp.SetBatteryBoostLimit), lp.GetBatteryBoostLimit)}, } diff --git a/server/mqtt.go b/server/mqtt.go index c3f94298fdf..ab16d04a9cb 100644 --- a/server/mqtt.go +++ b/server/mqtt.go @@ -249,6 +249,8 @@ func (m *MQTT) listenLoadpointSetters(topic string, site site.API, lp loadpoint. {"phasesConfigured", intSetter(lp.SetPhasesConfigured)}, {"limitSoc", intSetter(pass(lp.SetLimitSoc))}, {"priority", intSetter(pass(lp.SetPriority))}, + {"priorityStrategy", setterFunc(api.PriorityStrategyString, pass(lp.SetPriorityStrategy))}, + {"priorityHysteresis", intSetter(pass(lp.SetPriorityHysteresis))}, {"minCurrent", floatSetter(lp.SetMinCurrent)}, {"maxCurrent", floatSetter(lp.SetMaxCurrent)}, {"limitEnergy", floatSetter(pass(lp.SetLimitEnergy))}, From 4d8d964f63683500668d73472fe6f50cfb447c13 Mon Sep 17 00:00:00 2001 From: Alexander Herold Date: Sat, 20 Jun 2026 18:42:40 +0200 Subject: [PATCH 04/18] feat(openapi): add priorityStrategy and priorityHysteresis loadpoint 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 --- server/mcp/openapi.json | 90 +++++++++++++++++++++++++++++++++++++++++ server/mcp/openapi.md | 44 ++++++++++++++++++++ server/openapi.yaml | 58 ++++++++++++++++++++++++++ 3 files changed, 192 insertions(+) diff --git a/server/mcp/openapi.json b/server/mcp/openapi.json index 5aa3652ab4e..25e6eaa397c 100644 --- a/server/mcp/openapi.json +++ b/server/mcp/openapi.json @@ -131,6 +131,14 @@ "$ref": "#/components/schemas/Power" } }, + "priorityStrategy": { + "in": "path", + "name": "strategy", + "required": true, + "schema": { + "$ref": "#/components/schemas/PriorityStrategy" + } + }, "soc": { "description": "SOC in %", "in": "path", @@ -633,6 +641,15 @@ "minimum": 0, "type": "integer" }, + "PriorityStrategy": { + "description": "Priority strategy used to sub-order loadpoints of the same priority.", + "enum": [ + "static", + "soc", + "deficit" + ], + "type": "string" + }, "Rate": { "description": "A charging interval", "properties": { @@ -2034,6 +2051,79 @@ ] } }, + "/loadpoints/{id}/priorityhysteresis/{hysteresis}": { + "post": { + "description": "Set the soc-% deadband used when sub-ordering loadpoints of the same priority (0 = off).", + "externalDocs": { + "url": "https://docs.evcc.io/en/docs/reference/configuration/loadpoints#priority" + }, + "operationId": "setLoadpointPriorityHysteresis", + "parameters": [ + { + "$ref": "#/components/parameters/id" + }, + { + "description": "Deadband in soc-% (0..99, 0 = off).", + "example": 5, + "in": "path", + "name": "hysteresis", + "required": true, + "schema": { + "maximum": 99, + "minimum": 0, + "type": "integer" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/IntegerResult" + } + }, + "summary": "Set priority hysteresis", + "tags": [ + "loadpoints" + ] + } + }, + "/loadpoints/{id}/prioritystrategy/{strategy}": { + "post": { + "description": "Set the strategy used to sub-order loadpoints of the same priority.", + "externalDocs": { + "url": "https://docs.evcc.io/en/docs/reference/configuration/loadpoints#priority" + }, + "operationId": "setLoadpointPriorityStrategy", + "parameters": [ + { + "$ref": "#/components/parameters/id" + }, + { + "$ref": "#/components/parameters/priorityStrategy" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "result": { + "$ref": "#/components/schemas/PriorityStrategy" + } + }, + "type": "object" + } + } + }, + "description": "Success" + } + }, + "summary": "Set priority strategy", + "tags": [ + "loadpoints" + ] + } + }, "/loadpoints/{id}/smartcostlimit": { "delete": { "description": "Delete cost or emission limit for fast-charging with grid energy.", diff --git a/server/mcp/openapi.md b/server/mcp/openapi.md index d576736ff7f..03fea995036 100644 --- a/server/mcp/openapi.md +++ b/server/mcp/openapi.md @@ -918,6 +918,50 @@ call setLoadpointPriority { } ``` +## setLoadpointPriorityHysteresis + +Set the soc-% deadband used when sub-ordering loadpoints of the same priority (0 = off). + +**Tags:** loadpoints + +**Arguments:** + +| Name | Type | Description | +|------|------|-------------| +| hysteresis | integer | Deadband in soc-% (0..99, 0 = off). | +| id | integer | Loadpoint index starting at 1 | + +**Example call:** + +```json +call setLoadpointPriorityHysteresis { + "hysteresis": 123, + "id": 123 +} +``` + +## setLoadpointPriorityStrategy + +Set the strategy used to sub-order loadpoints of the same priority. + +**Tags:** loadpoints + +**Arguments:** + +| Name | Type | Description | +|------|------|-------------| +| id | integer | Loadpoint index starting at 1 | +| strategy | string | Priority strategy used to sub-order loadpoints of the same priority. | + +**Example call:** + +```json +call setLoadpointPriorityStrategy { + "id": 123, + "strategy": "example" +} +``` + ## setLoadpointSmartCostLimit Set cost or emission limit for fast-charging with grid energy. diff --git a/server/openapi.yaml b/server/openapi.yaml index e6dadb881f5..cd6a21c18c0 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -603,6 +603,51 @@ paths: responses: "200": $ref: "#/components/responses/IntegerResult" + /loadpoints/{id}/prioritystrategy/{strategy}: + post: + operationId: setLoadpointPriorityStrategy + summary: Set priority strategy + description: "Set the strategy used to sub-order loadpoints of the same priority." + externalDocs: + url: https://docs.evcc.io/en/docs/reference/configuration/loadpoints#priority + tags: + - loadpoints + parameters: + - $ref: "#/components/parameters/id" + - $ref: "#/components/parameters/priorityStrategy" + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + result: + $ref: "#/components/schemas/PriorityStrategy" + /loadpoints/{id}/priorityhysteresis/{hysteresis}: + post: + operationId: setLoadpointPriorityHysteresis + summary: Set priority hysteresis + description: "Set the soc-% deadband used when sub-ordering loadpoints of the same priority (0 = off)." + externalDocs: + url: https://docs.evcc.io/en/docs/reference/configuration/loadpoints#priority + tags: + - loadpoints + parameters: + - $ref: "#/components/parameters/id" + - name: hysteresis + in: path + description: Deadband in soc-% (0..99, 0 = off). + required: true + example: 5 + schema: + type: integer + minimum: 0 + maximum: 99 + responses: + "200": + $ref: "#/components/responses/IntegerResult" /loadpoints/{id}/smartcostlimit: delete: operationId: deleteLoadpointSmartCostLimit @@ -1520,6 +1565,13 @@ components: - "now" - "minpv" - "pv" + PriorityStrategy: + description: "Priority strategy used to sub-order loadpoints of the same priority." + type: string + enum: + - "static" + - "soc" + - "deficit" Odometer: nullable: true type: "number" @@ -1727,6 +1779,12 @@ components: required: true schema: $ref: "#/components/schemas/Mode" + priorityStrategy: + name: strategy + in: path + required: true + schema: + $ref: "#/components/schemas/PriorityStrategy" soc: name: soc description: SOC in % From ba2ee388bff0fb25fa1d9c7236c81163d1a46703 Mon Sep 17 00:00:00 2001 From: Alexander Herold Date: Sat, 20 Jun 2026 18:49:10 +0200 Subject: [PATCH 05/18] feat(ui): add priorityStrategy and priorityHysteresis loadpoint controls Co-Authored-By: Claude Opus 4.8 --- .../components/Loadpoints/SettingsModal.vue | 116 +++++++++++++++++- assets/js/types/evcc.ts | 8 ++ i18n/en.json | 14 +++ 3 files changed, 137 insertions(+), 1 deletion(-) diff --git a/assets/js/components/Loadpoints/SettingsModal.vue b/assets/js/components/Loadpoints/SettingsModal.vue index cc441bd50ad..b9435dea51a 100644 --- a/assets/js/components/Loadpoints/SettingsModal.vue +++ b/assets/js/components/Loadpoints/SettingsModal.vue @@ -124,6 +124,65 @@ + +
+ {{ $t("main.loadpointSettings.priority.heading") }} +
+
+ +
+ +
+
+ + {{ $t("main.loadpointSettings.priorityStrategy.help") }} + +
+
+
+ +
+ + % +
+
+ + {{ $t("main.loadpointSettings.priorityHysteresis.help") }} + +
+
@@ -136,7 +195,14 @@ import SmartCostLimit from "../Tariff/SmartCostLimit.vue"; import SmartFeedInPriority from "../Tariff/SmartFeedInPriority.vue"; import SettingsBatteryBoost from "./SettingsBatteryBoost.vue"; import { defineComponent, type PropType } from "vue"; -import { PHASES, CURRENCY, SMART_COST_TYPE, type Forecast, type UiLoadpoint } from "@/types/evcc"; +import { + PHASES, + CURRENCY, + SMART_COST_TYPE, + PRIORITY_STRATEGY, + type Forecast, + type UiLoadpoint, +} from "@/types/evcc"; import api from "@/api"; const V = 230; @@ -181,6 +247,8 @@ export default defineComponent({ selectedMaxCurrent: undefined as number | undefined, selectedMinCurrent: undefined as number | undefined, selectedPhases: undefined as number | undefined, + selectedPriorityStrategy: PRIORITY_STRATEGY.STATIC as PRIORITY_STRATEGY, + selectedPriorityHysteresis: 0 as number, isModalVisible: false, }; }, @@ -244,6 +312,33 @@ export default defineComponent({ batteryBoostAvailable() { return this.batteryConfigured; }, + priorityStrategy(): PRIORITY_STRATEGY { + // published state sends "" for the static (default) strategy + return this.loadpoint?.priorityStrategy || PRIORITY_STRATEGY.STATIC; + }, + priorityHysteresis(): number { + return this.loadpoint?.priorityHysteresis ?? 0; + }, + priorityStrategyOptions(): { value: PRIORITY_STRATEGY; name: string }[] { + return [ + { + value: PRIORITY_STRATEGY.STATIC, + name: this.$t("main.loadpointSettings.priorityStrategy.static"), + }, + { + value: PRIORITY_STRATEGY.SOC, + name: this.$t("main.loadpointSettings.priorityStrategy.soc"), + }, + { + value: PRIORITY_STRATEGY.DEFICIT, + name: this.$t("main.loadpointSettings.priorityStrategy.deficit"), + }, + ]; + }, + priorityHysteresisAvailable(): boolean { + // hysteresis only affects soc/deficit sub-ordering, not static priority + return this.selectedPriorityStrategy !== PRIORITY_STRATEGY.STATIC; + }, }, watch: { maxCurrent(value) { @@ -255,6 +350,12 @@ export default defineComponent({ phasesConfigured(value) { this.selectedPhases = value; }, + priorityStrategy(value) { + this.selectedPriorityStrategy = value; + }, + priorityHysteresis(value) { + this.selectedPriorityHysteresis = value; + }, }, methods: { open(loadpointId: string) { @@ -262,6 +363,8 @@ export default defineComponent({ this.selectedPhases = this.phasesConfigured; this.selectedMaxCurrent = this.maxCurrent; this.selectedMinCurrent = this.minCurrent; + this.selectedPriorityStrategy = this.priorityStrategy; + this.selectedPriorityHysteresis = this.priorityHysteresis; const modalRef = this.$refs["modal"] as InstanceType | undefined; modalRef?.open(); }, @@ -286,6 +389,17 @@ export default defineComponent({ setBatteryBoostLimit(limit: number) { api.post(this.apiPath("batteryboostlimit") + "/" + limit); }, + setPriorityStrategy() { + api.post(this.apiPath("prioritystrategy") + "/" + this.selectedPriorityStrategy); + }, + setPriorityHysteresis() { + const value = Math.min( + 99, + Math.max(0, Math.round(this.selectedPriorityHysteresis || 0)) + ); + this.selectedPriorityHysteresis = value; + api.post(this.apiPath("priorityhysteresis") + "/" + value); + }, currentOption(current: number, isDefault: boolean, phases?: number) { const kw = this.fmtPhasePower(current, phases); let name = `${this.fmtNumber(current, undefined)} A (${kw})`; diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts index bb8305bf59a..917516edef4 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -343,6 +343,8 @@ export interface Loadpoint { planProjectedStart: string | null; planTime: string | null; priority: number; + priorityStrategy: PRIORITY_STRATEGY | ""; + priorityHysteresis: number; pvAction: PV_ACTION; pvRemaining: number; sessionCo2PerKWh: number | null; @@ -466,6 +468,12 @@ export enum PV_ACTION { DISABLE = "disable", } +export enum PRIORITY_STRATEGY { + STATIC = "static", + SOC = "soc", + DEFICIT = "deficit", +} + export enum CHARGER_STATUS_REASON { UNKNOWN = "unknown", WAITING_FOR_AUTHORIZATION = "waitingforauthorization", diff --git a/i18n/en.json b/i18n/en.json index 2bd7b8b15e3..98095b24c2f 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1328,6 +1328,20 @@ "phases_3": "3 phase", "phases_3_hint": "({min} to {max})" }, + "priority": { + "heading": "Priority" + }, + "priorityHysteresis": { + "help": "Don't reorder loadpoints until the charge level gap exceeds this many percent. Prevents constant leapfrogging between cars. Set to 0 to disable.", + "label": "Hysteresis" + }, + "priorityStrategy": { + "deficit": "Largest deficit", + "help": "How to order loadpoints of equal priority when distributing solar surplus.", + "label": "Strategy", + "soc": "Lower charge level first", + "static": "Static" + }, "smartCostCheap": "Cheap Grid Charging", "smartCostClean": "Clean Grid Charging", "title": "Settings {0}", From 55a0572b29394b0d82269776ddf2bee548327ae1 Mon Sep 17 00:00:00 2001 From: Alexander Herold Date: Sat, 20 Jun 2026 18:56:59 +0200 Subject: [PATCH 06/18] feat: include priorityStrategy/priorityHysteresis in loadpoint DynamicConfig 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 --- core/loadpoint/config.go | 32 ++++++++++++++----------- core/loadpoint/config_test.go | 19 +++++++++++++++ server/http_config_loadpoint_handler.go | 2 ++ 3 files changed, 39 insertions(+), 14 deletions(-) diff --git a/core/loadpoint/config.go b/core/loadpoint/config.go index 9268d5cdd52..8484787b8f6 100644 --- a/core/loadpoint/config.go +++ b/core/loadpoint/config.go @@ -17,20 +17,22 @@ type StaticConfig struct { type DynamicConfig struct { // dynamic config - Title string `json:"title"` - DefaultMode string `json:"defaultMode"` - Priority int `json:"priority"` - PhasesConfigured int `json:"phasesConfigured"` - MinCurrent float64 `json:"minCurrent"` - MaxCurrent float64 `json:"maxCurrent"` - SmartCostLimit *float64 `json:"smartCostLimit"` - SmartFeedInPriorityLimit *float64 `json:"smartFeedInPriorityLimit"` - PlanEnergy float64 `json:"planEnergy"` - PlanTime time.Time `json:"planTime"` - PlanPrecondition_ int64 `json:"planPrecondition" mapstructure:"planPrecondition"` // TODO deprecated, keep for compatibility - BatteryBoostLimit int `json:"batteryBoostLimit"` - LimitEnergy float64 `json:"limitEnergy"` - LimitSoc int `json:"limitSoc"` + Title string `json:"title"` + DefaultMode string `json:"defaultMode"` + Priority int `json:"priority"` + PriorityStrategy api.PriorityStrategy `json:"priorityStrategy"` + PriorityHysteresis int `json:"priorityHysteresis"` + PhasesConfigured int `json:"phasesConfigured"` + MinCurrent float64 `json:"minCurrent"` + MaxCurrent float64 `json:"maxCurrent"` + SmartCostLimit *float64 `json:"smartCostLimit"` + SmartFeedInPriorityLimit *float64 `json:"smartFeedInPriorityLimit"` + PlanEnergy float64 `json:"planEnergy"` + PlanTime time.Time `json:"planTime"` + PlanPrecondition_ int64 `json:"planPrecondition" mapstructure:"planPrecondition"` // TODO deprecated, keep for compatibility + BatteryBoostLimit int `json:"batteryBoostLimit"` + LimitEnergy float64 `json:"limitEnergy"` + LimitSoc int `json:"limitSoc"` PlanStrategy api.PlanStrategy `json:"planStrategy"` @@ -67,6 +69,8 @@ func SplitConfig(payload map[string]any) (DynamicConfig, map[string]any, error) func (payload DynamicConfig) Apply(lp API) error { lp.SetTitle(payload.Title) lp.SetPriority(payload.Priority) + lp.SetPriorityStrategy(payload.PriorityStrategy) + lp.SetPriorityHysteresis(payload.PriorityHysteresis) lp.SetSmartCostLimit(payload.SmartCostLimit) lp.SetSmartFeedInPriorityLimit(payload.SmartFeedInPriorityLimit) lp.SetThresholds(payload.Thresholds) diff --git a/core/loadpoint/config_test.go b/core/loadpoint/config_test.go index 72c5b816674..4afc0e521e4 100644 --- a/core/loadpoint/config_test.go +++ b/core/loadpoint/config_test.go @@ -3,6 +3,7 @@ package loadpoint import ( "testing" + "github.com/evcc-io/evcc/api" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -23,3 +24,21 @@ func TestSplitConfigUI(t *testing.T) { assert.Equal(t, 45.0, dynamic.UI.MaxTemp) assert.NotContains(t, other, "ui") } + +func TestSplitConfigPriority(t *testing.T) { + payload := map[string]any{ + "priority": 3, + "priorityStrategy": "soc", + "priorityHysteresis": 5, + } + + dynamic, other, err := SplitConfig(payload) + require.NoError(t, err) + + assert.Equal(t, 3, dynamic.Priority) + assert.Equal(t, api.PrioritySoc, dynamic.PriorityStrategy) + assert.Equal(t, 5, dynamic.PriorityHysteresis) + assert.NotContains(t, other, "priority") + assert.NotContains(t, other, "priorityStrategy") + assert.NotContains(t, other, "priorityHysteresis") +} diff --git a/server/http_config_loadpoint_handler.go b/server/http_config_loadpoint_handler.go index 87f71fcb97c..4b0a7c0f9de 100644 --- a/server/http_config_loadpoint_handler.go +++ b/server/http_config_loadpoint_handler.go @@ -32,6 +32,8 @@ func getLoadpointDynamicConfig(lp loadpoint.API) loadpoint.DynamicConfig { Title: lp.GetTitle(), DefaultMode: string(lp.GetDefaultMode()), Priority: lp.GetPriority(), + PriorityStrategy: lp.GetPriorityStrategy(), + PriorityHysteresis: lp.GetPriorityHysteresis(), PhasesConfigured: lp.GetPhasesConfigured(), MinCurrent: lp.GetMinCurrent(), MaxCurrent: lp.GetMaxCurrent(), From 2ce70520871437554b0a961c743fc314cff942d6 Mon Sep 17 00:00:00 2001 From: Alexander Herold Date: Sat, 20 Jun 2026 18:57:08 +0200 Subject: [PATCH 07/18] feat(ui): expose priorityStrategy/priorityHysteresis in loadpoint config 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 --- .../js/components/Config/LoadpointModal.vue | 65 +++++++++++++++++++ assets/js/types/evcc.ts | 2 + i18n/en.json | 7 ++ 3 files changed, 74 insertions(+) diff --git a/assets/js/components/Config/LoadpointModal.vue b/assets/js/components/Config/LoadpointModal.vue index 96bd3af05c2..34590f2aa58 100644 --- a/assets/js/components/Config/LoadpointModal.vue +++ b/assets/js/components/Config/LoadpointModal.vue @@ -300,6 +300,40 @@ /> + + + + + + + +
{{ $t("config.loadpoint.electricalTitle") }} {{ @@ -634,6 +668,7 @@ import { getModal, openModal, replaceModal, closeModal } from "@/configModal"; import { CHARGE_MODE, LOADPOINT_TYPE, + PRIORITY_STRATEGY, type DeviceType, type LoadpointType, type ConfigCharger, @@ -654,6 +689,8 @@ const defaultValues = { minCurrent: 6, maxCurrent: 16, priority: 0, + priorityStrategy: "", + priorityHysteresis: 0, defaultMode: "", thresholds: { enable: { delay: 1 * nsPerMin, threshold: 0 }, @@ -795,6 +832,34 @@ export default { result[10]!.name = "10 (highest)"; return result; }, + priorityStrategy: { + // backend returns "" for the static strategy; map to/from the explicit "static" choice + get(): PRIORITY_STRATEGY { + return this.values.priorityStrategy || PRIORITY_STRATEGY.STATIC; + }, + set(value: PRIORITY_STRATEGY) { + this.values.priorityStrategy = value; + }, + }, + priorityStrategyOptions(): { key: PRIORITY_STRATEGY; name: string }[] { + return [ + { + key: PRIORITY_STRATEGY.STATIC, + name: this.$t("config.loadpoint.priorityStrategyStatic"), + }, + { + key: PRIORITY_STRATEGY.SOC, + name: this.$t("config.loadpoint.priorityStrategySoc"), + }, + { + key: PRIORITY_STRATEGY.DEFICIT, + name: this.$t("config.loadpoint.priorityStrategyDeficit"), + }, + ]; + }, + priorityHysteresisAvailable(): boolean { + return this.priorityStrategy !== PRIORITY_STRATEGY.STATIC; + }, phasesOptions() { return [ { value: 1, name: this.$t("config.loadpoint.phases1p") }, diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts index 917516edef4..8e80ec130fb 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -249,6 +249,8 @@ export interface ConfigLoadpoint { title: string; defaultMode: string; priority: number; + priorityStrategy: PRIORITY_STRATEGY | ""; + priorityHysteresis: number; phasesConfigured: number; minCurrent: number; maxCurrent: number; diff --git a/i18n/en.json b/i18n/en.json index 98095b24c2f..4e98f0842c3 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -405,7 +405,14 @@ "pollModeConnectedHelp": "Update vehicle status in regular intervals when connected.", "pollModeLabel": "Update behaviour", "priorityHelp": "Higher priority get preferred access to solar surplus.", + "priorityHysteresisHelp": "Don't reorder loadpoints of equal priority until the charge level gap exceeds this many percent. Prevents constant leapfrogging between cars. Set to 0 to disable.", + "priorityHysteresisLabel": "Priority hysteresis", "priorityLabel": "Priority", + "priorityStrategyDeficit": "Largest deficit", + "priorityStrategyHelp": "How to order loadpoints of equal priority when distributing solar surplus.", + "priorityStrategyLabel": "Priority strategy", + "priorityStrategySoc": "Lower charge level first", + "priorityStrategyStatic": "Static", "save": "Save", "solarBehaviorCustomHelp": "Define your own enable and disable thresholds and delays.", "solarBehaviorDefaultHelp": "Start after {enableDelay} of sufficient surplus. Stop when there is not enough surplus for {disableDelay}.", From 4e9f3643a7f95613a4247661335dee7979c6d472 Mon Sep 17 00:00:00 2001 From: Alexander Herold Date: Sat, 20 Jun 2026 20:11:29 +0200 Subject: [PATCH 08/18] fix(test): gci import order + disambiguate Priority locator - 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 --- core/loadpoint_priority_test.go | 3 +-- tests/config-loadpoint.spec.ts | 10 +++++----- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/core/loadpoint_priority_test.go b/core/loadpoint_priority_test.go index 6887e190476..4567890697f 100644 --- a/core/loadpoint_priority_test.go +++ b/core/loadpoint_priority_test.go @@ -2,9 +2,8 @@ package core import ( "errors" - "time" - "testing" + "time" "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/core/keys" diff --git a/tests/config-loadpoint.spec.ts b/tests/config-loadpoint.spec.ts index 3bf55a0af26..5ed36c1b2a8 100644 --- a/tests/config-loadpoint.spec.ts +++ b/tests/config-loadpoint.spec.ts @@ -157,8 +157,8 @@ test.describe("charging loadpoint", async () => { // second loadpoint: increase priority await page.getByTestId("loadpoint").nth(1).getByRole("button", { name: "edit" }).click(); await expectModalVisible(lpModal); - await expect(lpModal.getByLabel("Priority")).toHaveValue("0"); - await lpModal.getByLabel("Priority").selectOption("1"); + await expect(lpModal.getByLabel("Priority", { exact: true })).toHaveValue("0"); + await lpModal.getByLabel("Priority", { exact: true }).selectOption("1"); await lpModal.getByRole("button", { name: "Save" }).click(); await expectModalHidden(lpModal); @@ -169,10 +169,10 @@ test.describe("charging loadpoint", async () => { // check priorities await page.getByTestId("loadpoint").nth(1).getByRole("button", { name: "edit" }).click(); await expectModalVisible(lpModal); - await expect(lpModal.getByLabel("Priority")).toHaveValue("1"); + await expect(lpModal.getByLabel("Priority", { exact: true })).toHaveValue("1"); // change back to 0 - await lpModal.getByLabel("Priority").selectOption("0 (default)"); + await lpModal.getByLabel("Priority", { exact: true }).selectOption("0 (default)"); await lpModal.getByRole("button", { name: "Save" }).click(); await expectModalHidden(lpModal); @@ -183,7 +183,7 @@ test.describe("charging loadpoint", async () => { // check priorities await page.getByTestId("loadpoint").nth(1).getByRole("button", { name: "edit" }).click(); await expectModalVisible(lpModal); - await expect(lpModal.getByLabel("Priority")).toHaveValue("0"); + await expect(lpModal.getByLabel("Priority", { exact: true })).toHaveValue("0"); }); test("vehicle", async ({ page }) => { From 89e09d72630b5f3e4cf18fd70b05bdc847284ccd Mon Sep 17 00:00:00 2001 From: Alexander Herold Date: Sun, 21 Jun 2026 06:35:01 +0200 Subject: [PATCH 09/18] feat: add priorityBasis (percent/energy) to loadpoint priority strategy 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 --- api/prioritystrategy.go | 36 +++++++++++++++ core/keys/loadpoint.go | 3 +- core/loadpoint.go | 18 +++++++- core/loadpoint/api.go | 8 +++- core/loadpoint/config.go | 2 + core/loadpoint/config_test.go | 3 ++ core/loadpoint/mock.go | 26 +++++++++++ core/loadpoint_api.go | 31 +++++++++++++ core/loadpoint_effective.go | 34 +++++++++++++-- core/loadpoint_effective_test.go | 51 ++++++++++++++++------ server/http.go | 1 + server/http_config_loadpoint_handler.go | 1 + server/mcp/openapi.json | 58 ++++++++++++++++++++++++- server/mcp/openapi.md | 26 ++++++++++- server/mqtt.go | 1 + server/openapi.yaml | 38 +++++++++++++++- 16 files changed, 310 insertions(+), 27 deletions(-) diff --git a/api/prioritystrategy.go b/api/prioritystrategy.go index 087456ce7d3..2172204faa8 100644 --- a/api/prioritystrategy.go +++ b/api/prioritystrategy.go @@ -43,3 +43,39 @@ func PriorityStrategyString(s string) (PriorityStrategy, error) { return PriorityStatic, fmt.Errorf("invalid priority strategy: %s", s) } } + +// PriorityBasis determines whether a priority strategy ranks loadpoints by soc +// percentage or by absolute energy (kWh). Valid values are percent and energy. +type PriorityBasis string + +// Priority bases +const ( + // PriorityBasisPercent ranks the priority strategy in soc-% (default), so a + // percentage gap is compared regardless of battery capacity. + PriorityBasisPercent PriorityBasis = "" + // PriorityBasisEnergy ranks the priority strategy by absolute energy (kWh) by + // scaling the soc-% gap with the vehicle capacity, so a smaller battery is not + // over-prioritized just because its percentage is lower. Falls back to percent + // when the vehicle capacity is unknown. + PriorityBasisEnergy PriorityBasis = "energy" +) + +// String implements Stringer +func (b PriorityBasis) String() string { + if b == PriorityBasisPercent { + return "percent" + } + return string(b) +} + +// PriorityBasisString converts a string to PriorityBasis +func PriorityBasisString(s string) (PriorityBasis, error) { + switch strings.ToLower(strings.TrimSpace(s)) { + case "", "percent", "percentage", "soc": + return PriorityBasisPercent, nil + case string(PriorityBasisEnergy), "absolute", "kwh": + return PriorityBasisEnergy, nil + default: + return PriorityBasisPercent, fmt.Errorf("invalid priority basis: %s", s) + } +} diff --git a/core/keys/loadpoint.go b/core/keys/loadpoint.go index 05508f2c6d4..b847e36193d 100644 --- a/core/keys/loadpoint.go +++ b/core/keys/loadpoint.go @@ -12,7 +12,8 @@ const ( DefaultVehicle = "vehicle" // default vehicle ref Priority = "priority" // priority PriorityStrategy = "priorityStrategy" // priority strategy (static, soc, deficit) - PriorityHysteresis = "priorityHysteresis" // priority sub-ordering deadband in soc-% (0 = off) + PriorityBasis = "priorityBasis" // priority strategy basis (percent, energy) + PriorityHysteresis = "priorityHysteresis" // priority sub-ordering deadband (soc-% or kWh per basis, 0 = off) MinCurrent = "minCurrent" // min current MaxCurrent = "maxCurrent" // max current MinSoc = "minSoc" // min soc diff --git a/core/loadpoint.go b/core/loadpoint.go index 85c297bd28c..f0775c380ec 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -105,7 +105,8 @@ type Loadpoint struct { Title string `mapstructure:"title"` // UI title Priority int `mapstructure:"priority"` // Priority PriorityStrategy api.PriorityStrategy `mapstructure:"priorityStrategy"` // Priority strategy (static, soc, deficit) - PriorityHysteresis int `mapstructure:"priorityHysteresis"` // Priority sub-ordering deadband in soc-% (0 = off) + PriorityBasis api.PriorityBasis `mapstructure:"priorityBasis"` // Priority strategy basis (percent, energy) + PriorityHysteresis int `mapstructure:"priorityHysteresis"` // Priority sub-ordering deadband (soc-% or kWh per basis, 0 = off) // from yaml, deprecated GuardDuration_ time.Duration `mapstructure:"guardduration"` // ignored, present for compatibility @@ -116,7 +117,8 @@ type Loadpoint struct { title string // UI title priority int // Priority priorityStrategy api.PriorityStrategy // Priority strategy (static, soc, deficit) - priorityHysteresis int // Priority sub-ordering deadband in soc-% (0 = off) + priorityBasis api.PriorityBasis // Priority strategy basis (percent, energy) + priorityHysteresis int // Priority sub-ordering deadband (soc-% or kWh per basis, 0 = off) minCurrent float64 // PV mode: start current Min+PV mode: min current maxCurrent float64 // Max allowed current. Physically ensured by the charger phasesConfigured int // Charger configured phase mode 0/1/3 @@ -229,6 +231,12 @@ func NewLoadpointFromConfig(log *util.Logger, settings settings.Settings, collec lp.priorityStrategy = ps } + if pb, err := api.PriorityBasisString(string(lp.PriorityBasis)); err != nil { + return lp, err + } else { + lp.priorityBasis = pb + } + if lp.PriorityHysteresis < 0 || lp.PriorityHysteresis > 99 { return lp, fmt.Errorf("invalid priority hysteresis: %d (must be 0..99)", lp.PriorityHysteresis) } @@ -369,6 +377,11 @@ func (lp *Loadpoint) restoreSettings() { lp.setPriorityStrategy(strategy) } } + if v, err := lp.settings.String(keys.PriorityBasis); err == nil { + if basis, err := api.PriorityBasisString(v); err == nil { + lp.setPriorityBasis(basis) + } + } if v, err := lp.settings.Int(keys.PriorityHysteresis); err == nil && v >= 0 && v <= 99 { lp.setPriorityHysteresis(int(v)) } @@ -718,6 +731,7 @@ func (lp *Loadpoint) Prepare(site site.API, uiChan chan<- util.Param, pushChan c lp.publish(keys.Mode, lp.GetMode()) lp.publish(keys.Priority, lp.GetPriority()) lp.publish(keys.PriorityStrategy, lp.GetPriorityStrategy()) + lp.publish(keys.PriorityBasis, lp.GetPriorityBasis()) lp.publish(keys.PriorityHysteresis, lp.GetPriorityHysteresis()) lp.publish(keys.MinCurrent, lp.GetMinCurrent()) lp.publish(keys.MaxCurrent, lp.GetMaxCurrent()) diff --git a/core/loadpoint/api.go b/core/loadpoint/api.go index db0f6660fc3..9f7ced9511f 100644 --- a/core/loadpoint/api.go +++ b/core/loadpoint/api.go @@ -61,9 +61,13 @@ type API interface { GetPriorityStrategy() api.PriorityStrategy // SetPriorityStrategy sets the priority strategy SetPriorityStrategy(api.PriorityStrategy) - // GetPriorityHysteresis returns the priority sub-ordering deadband in soc-% + // GetPriorityBasis returns the priority strategy basis (percent, energy) + GetPriorityBasis() api.PriorityBasis + // SetPriorityBasis sets the priority strategy basis (percent, energy) + SetPriorityBasis(api.PriorityBasis) + // GetPriorityHysteresis returns the priority sub-ordering deadband (soc-% or kWh per basis) GetPriorityHysteresis() int - // SetPriorityHysteresis sets the priority sub-ordering deadband in soc-% + // SetPriorityHysteresis sets the priority sub-ordering deadband (soc-% or kWh per basis) SetPriorityHysteresis(int) // GetMinCurrent returns the min charging current GetMinCurrent() float64 diff --git a/core/loadpoint/config.go b/core/loadpoint/config.go index 8484787b8f6..8bfaac1e963 100644 --- a/core/loadpoint/config.go +++ b/core/loadpoint/config.go @@ -21,6 +21,7 @@ type DynamicConfig struct { DefaultMode string `json:"defaultMode"` Priority int `json:"priority"` PriorityStrategy api.PriorityStrategy `json:"priorityStrategy"` + PriorityBasis api.PriorityBasis `json:"priorityBasis"` PriorityHysteresis int `json:"priorityHysteresis"` PhasesConfigured int `json:"phasesConfigured"` MinCurrent float64 `json:"minCurrent"` @@ -70,6 +71,7 @@ func (payload DynamicConfig) Apply(lp API) error { lp.SetTitle(payload.Title) lp.SetPriority(payload.Priority) lp.SetPriorityStrategy(payload.PriorityStrategy) + lp.SetPriorityBasis(payload.PriorityBasis) lp.SetPriorityHysteresis(payload.PriorityHysteresis) lp.SetSmartCostLimit(payload.SmartCostLimit) lp.SetSmartFeedInPriorityLimit(payload.SmartFeedInPriorityLimit) diff --git a/core/loadpoint/config_test.go b/core/loadpoint/config_test.go index 4afc0e521e4..d0be8ecf90c 100644 --- a/core/loadpoint/config_test.go +++ b/core/loadpoint/config_test.go @@ -29,6 +29,7 @@ func TestSplitConfigPriority(t *testing.T) { payload := map[string]any{ "priority": 3, "priorityStrategy": "soc", + "priorityBasis": "energy", "priorityHysteresis": 5, } @@ -37,8 +38,10 @@ func TestSplitConfigPriority(t *testing.T) { assert.Equal(t, 3, dynamic.Priority) assert.Equal(t, api.PrioritySoc, dynamic.PriorityStrategy) + assert.Equal(t, api.PriorityBasisEnergy, dynamic.PriorityBasis) assert.Equal(t, 5, dynamic.PriorityHysteresis) assert.NotContains(t, other, "priority") assert.NotContains(t, other, "priorityStrategy") + assert.NotContains(t, other, "priorityBasis") assert.NotContains(t, other, "priorityHysteresis") } diff --git a/core/loadpoint/mock.go b/core/loadpoint/mock.go index edab641c060..8b830475762 100644 --- a/core/loadpoint/mock.go +++ b/core/loadpoint/mock.go @@ -601,6 +601,20 @@ func (mr *MockAPIMockRecorder) GetPriorityStrategy() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPriorityStrategy", reflect.TypeOf((*MockAPI)(nil).GetPriorityStrategy)) } +// GetPriorityBasis mocks base method. +func (m *MockAPI) GetPriorityBasis() api.PriorityBasis { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPriorityBasis") + ret0, _ := ret[0].(api.PriorityBasis) + return ret0 +} + +// GetPriorityBasis indicates an expected call of GetPriorityBasis. +func (mr *MockAPIMockRecorder) GetPriorityBasis() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPriorityBasis", reflect.TypeOf((*MockAPI)(nil).GetPriorityBasis)) +} + // GetRemainingDuration mocks base method. func (m *MockAPI) GetRemainingDuration() time.Duration { m.ctrl.T.Helper() @@ -1071,6 +1085,18 @@ func (mr *MockAPIMockRecorder) SetPriorityStrategy(arg0 any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPriorityStrategy", reflect.TypeOf((*MockAPI)(nil).SetPriorityStrategy), arg0) } +// SetPriorityBasis mocks base method. +func (m *MockAPI) SetPriorityBasis(arg0 api.PriorityBasis) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SetPriorityBasis", arg0) +} + +// SetPriorityBasis indicates an expected call of SetPriorityBasis. +func (mr *MockAPIMockRecorder) SetPriorityBasis(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPriorityBasis", reflect.TypeOf((*MockAPI)(nil).SetPriorityBasis), arg0) +} + // SetSmartCostLimit mocks base method. func (m *MockAPI) SetSmartCostLimit(limit *float64) { m.ctrl.T.Helper() diff --git a/core/loadpoint_api.go b/core/loadpoint_api.go index 818964ea52e..365a3fa78c5 100644 --- a/core/loadpoint_api.go +++ b/core/loadpoint_api.go @@ -235,6 +235,13 @@ func (lp *Loadpoint) GetPriorityStrategy() api.PriorityStrategy { return lp.priorityStrategy } +// GetPriorityBasis returns the loadpoint priority strategy basis +func (lp *Loadpoint) GetPriorityBasis() api.PriorityBasis { + lp.RLock() + defer lp.RUnlock() + return lp.priorityBasis +} + // GetPriorityHysteresis returns the priority sub-ordering deadband in soc-% func (lp *Loadpoint) GetPriorityHysteresis() int { lp.RLock() @@ -284,6 +291,30 @@ func (lp *Loadpoint) SetPriorityStrategy(strategy api.PriorityStrategy) { } } +// setPriorityBasis sets the loadpoint priority strategy basis (no mutex) +func (lp *Loadpoint) setPriorityBasis(basis api.PriorityBasis) { + lp.priorityBasis = basis + lp.publish(keys.PriorityBasis, lp.priorityBasis) + lp.settings.SetString(keys.PriorityBasis, string(lp.priorityBasis)) +} + +// SetPriorityBasis sets the loadpoint priority strategy basis +func (lp *Loadpoint) SetPriorityBasis(basis api.PriorityBasis) { + lp.Lock() + defer lp.Unlock() + + normalized, err := api.PriorityBasisString(string(basis)) + if err != nil { + lp.log.ERROR.Printf("invalid priority basis: %s", string(basis)) + return + } + + lp.log.DEBUG.Printf("set priority basis: %s", normalized) + if lp.priorityBasis != normalized { + lp.setPriorityBasis(normalized) + } +} + // setPriorityHysteresis sets the loadpoint priority sub-ordering deadband (no mutex) func (lp *Loadpoint) setPriorityHysteresis(hysteresis int) { lp.priorityHysteresis = hysteresis diff --git a/core/loadpoint_effective.go b/core/loadpoint_effective.go index 788f1a0d420..81d712a6db8 100644 --- a/core/loadpoint_effective.go +++ b/core/loadpoint_effective.go @@ -42,6 +42,11 @@ func (lp *Loadpoint) EffectivePriority() int { // with the deficit strategy a larger gap to the limit soc scores higher. The // strategy sub-ordering only applies when a positive vehicle soc is known, // otherwise the score equals the plain effective priority. +// +// With the energy basis the soc-% gap is scaled by the vehicle capacity, so +// loadpoints are ranked by absolute energy (kWh) rather than percentage and a +// smaller battery is not over-prioritized just because its percentage is lower. +// When the vehicle capacity is unknown the score falls back to the percentage gap. func (lp *Loadpoint) EffectivePriorityScore() float64 { score := float64(lp.EffectivePriority()) @@ -50,14 +55,37 @@ func (lp *Loadpoint) EffectivePriorityScore() float64 { return score } + // gap is the soc-% quantity the strategy ranks by (a larger gap scores higher) + var gap float64 switch lp.GetPriorityStrategy() { case api.PrioritySoc: - score += priorityFraction(100 - soc) + gap = 100 - soc case api.PriorityDeficit: - score += priorityFraction(float64(lp.EffectiveLimitSoc()) - soc) + gap = float64(lp.EffectiveLimitSoc()) - soc + default: + return score + } + + // energy basis: convert the soc-% gap into absolute kWh using the vehicle + // capacity, falling back to the percentage gap when capacity is unknown + if lp.GetPriorityBasis() == api.PriorityBasisEnergy { + if capacity := lp.vehicleCapacity(); capacity > 0 { + gap = gap / 100 * capacity + } else { + lp.log.DEBUG.Println("priority basis energy: unknown vehicle capacity, ranking by soc percentage") + } } - return score + return score + priorityFraction(gap) +} + +// vehicleCapacity returns the active vehicle's usable capacity in kWh, or 0 if +// no vehicle is active or its capacity is unknown. +func (lp *Loadpoint) vehicleCapacity() float64 { + if v := lp.GetVehicle(); v != nil { + return v.Capacity() + } + return 0 } // priorityFraction maps a soc-based value to a [0,1) sub-ordering offset, kept diff --git a/core/loadpoint_effective_test.go b/core/loadpoint_effective_test.go index 490921d8572..f309c297b38 100644 --- a/core/loadpoint_effective_test.go +++ b/core/loadpoint_effective_test.go @@ -20,24 +20,40 @@ func TestEffectiveLimitSoc(t *testing.T) { func TestEffectivePriorityScore(t *testing.T) { tc := []struct { strategy api.PriorityStrategy + basis api.PriorityBasis priority int soc, limitSoc float64 + capacity float64 // vehicle capacity in kWh, 0 = no/unknown vehicle expected float64 }{ // static: fractional part is always zero - {api.PriorityStatic, 0, 50, 0, 0}, - {api.PriorityStatic, 2, 50, 0, 2}, - // soc: lower soc scores higher within the tier - {api.PrioritySoc, 0, 20, 0, 0.80}, - {api.PrioritySoc, 0, 80, 0, 0.20}, - {api.PrioritySoc, 1, 20, 0, 1.80}, - {api.PrioritySoc, 0, 100, 0, 0}, // full vehicle: no boost - {api.PrioritySoc, 0, 0, 0, 0}, // unknown soc: falls back to plain priority - // deficit: larger gap to the limit soc scores higher within the tier - {api.PriorityDeficit, 0, 50, 80, 0.30}, - {api.PriorityDeficit, 0, 50, 0, 0.50}, // no limit set -> default 100 - {api.PriorityDeficit, 0, 90, 80, 0}, // soc above limit: no boost - {api.PriorityDeficit, 0, 0, 80, 0}, // unknown soc: falls back to plain priority + {api.PriorityStatic, api.PriorityBasisPercent, 0, 50, 0, 0, 0}, + {api.PriorityStatic, api.PriorityBasisPercent, 2, 50, 0, 0, 2}, + // soc (percent): lower soc scores higher within the tier + {api.PrioritySoc, api.PriorityBasisPercent, 0, 20, 0, 0, 0.80}, + {api.PrioritySoc, api.PriorityBasisPercent, 0, 80, 0, 0, 0.20}, + {api.PrioritySoc, api.PriorityBasisPercent, 1, 20, 0, 0, 1.80}, + {api.PrioritySoc, api.PriorityBasisPercent, 0, 100, 0, 0, 0}, // full vehicle: no boost + {api.PrioritySoc, api.PriorityBasisPercent, 0, 0, 0, 0, 0}, // unknown soc: falls back to plain priority + // deficit (percent): larger gap to the limit soc scores higher within the tier + {api.PriorityDeficit, api.PriorityBasisPercent, 0, 50, 80, 0, 0.30}, + {api.PriorityDeficit, api.PriorityBasisPercent, 0, 50, 0, 0, 0.50}, // no limit set -> default 100 + {api.PriorityDeficit, api.PriorityBasisPercent, 0, 90, 80, 0, 0}, // soc above limit: no boost + {api.PriorityDeficit, api.PriorityBasisPercent, 0, 0, 80, 0, 0}, // unknown soc: falls back to plain priority + // soc (energy): gap is scaled by capacity -> (100-soc)/100*capacity, /100 for the fraction + {api.PrioritySoc, api.PriorityBasisEnergy, 0, 20, 0, 50, 0.40}, // 80% * 50kWh = 40kWh + {api.PrioritySoc, api.PriorityBasisEnergy, 0, 80, 0, 50, 0.10}, // 20% * 50kWh = 10kWh + {api.PrioritySoc, api.PriorityBasisEnergy, 0, 20, 0, 25, 0.20}, // 80% * 25kWh = 20kWh + {api.PrioritySoc, api.PriorityBasisEnergy, 0, 20, 0, 0, 0.80}, // capacity unknown: falls back to percent + // deficit (energy): (limitSoc-soc)/100*capacity, /100 for the fraction + {api.PriorityDeficit, api.PriorityBasisEnergy, 0, 50, 80, 50, 0.15}, // 30% * 50kWh = 15kWh + {api.PriorityDeficit, api.PriorityBasisEnergy, 0, 50, 80, 0, 0.30}, // capacity unknown: falls back to percent + // Steve's case: a small second car is NOT over-prioritized under the energy basis. + // Percent basis would rank B (40%) above A (50%); energy basis ranks A (needs 37.5kWh) above B (15kWh). + {api.PrioritySoc, api.PriorityBasisPercent, 0, 50, 0, 75, 0.50}, // car A, percent + {api.PrioritySoc, api.PriorityBasisPercent, 0, 40, 0, 25, 0.60}, // car B, percent -> B wins + {api.PrioritySoc, api.PriorityBasisEnergy, 0, 50, 0, 75, 0.375}, // car A, energy -> A wins + {api.PrioritySoc, api.PriorityBasisEnergy, 0, 40, 0, 25, 0.15}, // car B, energy } for _, tc := range tc { @@ -46,9 +62,18 @@ func TestEffectivePriorityScore(t *testing.T) { lp := NewLoadpoint(util.NewLogger("foo"), nil) lp.priority = tc.priority lp.priorityStrategy = tc.strategy + lp.priorityBasis = tc.basis lp.vehicleSoc = tc.soc lp.limitSoc = int(tc.limitSoc) + if tc.capacity > 0 { + ctrl := gomock.NewController(t) + vehicle := api.NewMockVehicle(ctrl) + vehicle.EXPECT().Capacity().Return(tc.capacity).AnyTimes() + vehicle.EXPECT().OnIdentified().Return(api.ActionConfig{}).AnyTimes() + lp.vehicle = vehicle + } + assert.InDelta(t, tc.expected, lp.EffectivePriorityScore(), 1e-9) } } diff --git a/server/http.go b/server/http.go index 0c6cbc05f33..503fb2f0f55 100644 --- a/server/http.go +++ b/server/http.go @@ -221,6 +221,7 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API) { "smartFeedInPriorityDelete": {"DELETE", "/smartfeedinprioritylimit", floatPtrHandler(pass(lp.SetSmartFeedInPriorityLimit), lp.GetSmartFeedInPriorityLimit)}, "priority": {"POST", "/priority/{value:[0-9]+}", intHandler(pass(lp.SetPriority), lp.GetPriority)}, "priorityStrategy": {"POST", "/prioritystrategy/{value:[a-z]+}", handler(eapi.PriorityStrategyString, pass(lp.SetPriorityStrategy), lp.GetPriorityStrategy)}, + "priorityBasis": {"POST", "/prioritybasis/{value:[a-z]+}", handler(eapi.PriorityBasisString, pass(lp.SetPriorityBasis), lp.GetPriorityBasis)}, "priorityHysteresis": {"POST", "/priorityhysteresis/{value:[0-9]+}", intHandler(pass(lp.SetPriorityHysteresis), lp.GetPriorityHysteresis)}, "batteryBoost": {"POST", "/batteryboost/{value:[01truefalse]+}", boolHandler(lp.SetBatteryBoost, func() bool { return lp.GetBatteryBoost() > 0 })}, "batteryBoostLimit": {"POST", "/batteryboostlimit/{value:[0-9]+}", intHandler(pass(lp.SetBatteryBoostLimit), lp.GetBatteryBoostLimit)}, diff --git a/server/http_config_loadpoint_handler.go b/server/http_config_loadpoint_handler.go index 4b0a7c0f9de..2bae92e2285 100644 --- a/server/http_config_loadpoint_handler.go +++ b/server/http_config_loadpoint_handler.go @@ -33,6 +33,7 @@ func getLoadpointDynamicConfig(lp loadpoint.API) loadpoint.DynamicConfig { DefaultMode: string(lp.GetDefaultMode()), Priority: lp.GetPriority(), PriorityStrategy: lp.GetPriorityStrategy(), + PriorityBasis: lp.GetPriorityBasis(), PriorityHysteresis: lp.GetPriorityHysteresis(), PhasesConfigured: lp.GetPhasesConfigured(), MinCurrent: lp.GetMinCurrent(), diff --git a/server/mcp/openapi.json b/server/mcp/openapi.json index 25e6eaa397c..574e5daf548 100644 --- a/server/mcp/openapi.json +++ b/server/mcp/openapi.json @@ -131,6 +131,14 @@ "$ref": "#/components/schemas/Power" } }, + "priorityBasis": { + "in": "path", + "name": "basis", + "required": true, + "schema": { + "$ref": "#/components/schemas/PriorityBasis" + } + }, "priorityStrategy": { "in": "path", "name": "strategy", @@ -641,6 +649,14 @@ "minimum": 0, "type": "integer" }, + "PriorityBasis": { + "description": "Whether the priority strategy ranks by soc percentage or by absolute energy (kWh).", + "enum": [ + "percent", + "energy" + ], + "type": "string" + }, "PriorityStrategy": { "description": "Priority strategy used to sub-order loadpoints of the same priority.", "enum": [ @@ -2051,9 +2067,47 @@ ] } }, + "/loadpoints/{id}/prioritybasis/{basis}": { + "post": { + "description": "Set whether the priority strategy ranks by soc percentage or by absolute energy (kWh).", + "externalDocs": { + "url": "https://docs.evcc.io/en/docs/reference/configuration/loadpoints#priority" + }, + "operationId": "setLoadpointPriorityBasis", + "parameters": [ + { + "$ref": "#/components/parameters/id" + }, + { + "$ref": "#/components/parameters/priorityBasis" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "result": { + "$ref": "#/components/schemas/PriorityBasis" + } + }, + "type": "object" + } + } + }, + "description": "Success" + } + }, + "summary": "Set priority basis", + "tags": [ + "loadpoints" + ] + } + }, "/loadpoints/{id}/priorityhysteresis/{hysteresis}": { "post": { - "description": "Set the soc-% deadband used when sub-ordering loadpoints of the same priority (0 = off).", + "description": "Set the deadband used when sub-ordering loadpoints of the same priority (soc-%, or kWh with the energy basis; 0 = off).", "externalDocs": { "url": "https://docs.evcc.io/en/docs/reference/configuration/loadpoints#priority" }, @@ -2063,7 +2117,7 @@ "$ref": "#/components/parameters/id" }, { - "description": "Deadband in soc-% (0..99, 0 = off).", + "description": "Deadband in soc-% or kWh per basis (0..99, 0 = off).", "example": 5, "in": "path", "name": "hysteresis", diff --git a/server/mcp/openapi.md b/server/mcp/openapi.md index 03fea995036..0749297572d 100644 --- a/server/mcp/openapi.md +++ b/server/mcp/openapi.md @@ -918,9 +918,31 @@ call setLoadpointPriority { } ``` +## setLoadpointPriorityBasis + +Set whether the priority strategy ranks by soc percentage or by absolute energy (kWh). + +**Tags:** loadpoints + +**Arguments:** + +| Name | Type | Description | +|------|------|-------------| +| basis | string | Whether the priority strategy ranks by soc percentage or by absolute energy (kWh). | +| id | integer | Loadpoint index starting at 1 | + +**Example call:** + +```json +call setLoadpointPriorityBasis { + "basis": "example", + "id": 123 +} +``` + ## setLoadpointPriorityHysteresis -Set the soc-% deadband used when sub-ordering loadpoints of the same priority (0 = off). +Set the deadband used when sub-ordering loadpoints of the same priority (soc-%, or kWh with the energy basis; 0 = off). **Tags:** loadpoints @@ -928,7 +950,7 @@ Set the soc-% deadband used when sub-ordering loadpoints of the same priority (0 | Name | Type | Description | |------|------|-------------| -| hysteresis | integer | Deadband in soc-% (0..99, 0 = off). | +| hysteresis | integer | Deadband in soc-% or kWh per basis (0..99, 0 = off). | | id | integer | Loadpoint index starting at 1 | **Example call:** diff --git a/server/mqtt.go b/server/mqtt.go index ab16d04a9cb..7d494efe4ba 100644 --- a/server/mqtt.go +++ b/server/mqtt.go @@ -250,6 +250,7 @@ func (m *MQTT) listenLoadpointSetters(topic string, site site.API, lp loadpoint. {"limitSoc", intSetter(pass(lp.SetLimitSoc))}, {"priority", intSetter(pass(lp.SetPriority))}, {"priorityStrategy", setterFunc(api.PriorityStrategyString, pass(lp.SetPriorityStrategy))}, + {"priorityBasis", setterFunc(api.PriorityBasisString, pass(lp.SetPriorityBasis))}, {"priorityHysteresis", intSetter(pass(lp.SetPriorityHysteresis))}, {"minCurrent", floatSetter(lp.SetMinCurrent)}, {"maxCurrent", floatSetter(lp.SetMaxCurrent)}, diff --git a/server/openapi.yaml b/server/openapi.yaml index cd6a21c18c0..6781d77f8cf 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -625,11 +625,33 @@ paths: properties: result: $ref: "#/components/schemas/PriorityStrategy" + /loadpoints/{id}/prioritybasis/{basis}: + post: + operationId: setLoadpointPriorityBasis + summary: Set priority basis + description: "Set whether the priority strategy ranks by soc percentage or by absolute energy (kWh)." + externalDocs: + url: https://docs.evcc.io/en/docs/reference/configuration/loadpoints#priority + tags: + - loadpoints + parameters: + - $ref: "#/components/parameters/id" + - $ref: "#/components/parameters/priorityBasis" + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + result: + $ref: "#/components/schemas/PriorityBasis" /loadpoints/{id}/priorityhysteresis/{hysteresis}: post: operationId: setLoadpointPriorityHysteresis summary: Set priority hysteresis - description: "Set the soc-% deadband used when sub-ordering loadpoints of the same priority (0 = off)." + description: "Set the deadband used when sub-ordering loadpoints of the same priority (soc-%, or kWh with the energy basis; 0 = off)." externalDocs: url: https://docs.evcc.io/en/docs/reference/configuration/loadpoints#priority tags: @@ -638,7 +660,7 @@ paths: - $ref: "#/components/parameters/id" - name: hysteresis in: path - description: Deadband in soc-% (0..99, 0 = off). + description: Deadband in soc-% or kWh per basis (0..99, 0 = off). required: true example: 5 schema: @@ -1572,6 +1594,12 @@ components: - "static" - "soc" - "deficit" + PriorityBasis: + description: "Whether the priority strategy ranks by soc percentage or by absolute energy (kWh)." + type: string + enum: + - "percent" + - "energy" Odometer: nullable: true type: "number" @@ -1785,6 +1813,12 @@ components: required: true schema: $ref: "#/components/schemas/PriorityStrategy" + priorityBasis: + name: basis + in: path + required: true + schema: + $ref: "#/components/schemas/PriorityBasis" soc: name: soc description: SOC in % From df69798101bb70eba87f9d47442b73fb79fe65bb Mon Sep 17 00:00:00 2001 From: Alexander Herold Date: Sun, 21 Jun 2026 06:35:01 +0200 Subject: [PATCH 10/18] feat(ui): expose priorityBasis in both loadpoint modals 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 --- .../js/components/Config/LoadpointModal.vue | 45 ++++++++++++++- .../components/Loadpoints/SettingsModal.vue | 56 ++++++++++++++++++- assets/js/types/evcc.ts | 7 +++ 3 files changed, 106 insertions(+), 2 deletions(-) diff --git a/assets/js/components/Config/LoadpointModal.vue b/assets/js/components/Config/LoadpointModal.vue index 34590f2aa58..c8f3e21c104 100644 --- a/assets/js/components/Config/LoadpointModal.vue +++ b/assets/js/components/Config/LoadpointModal.vue @@ -317,6 +317,23 @@ /> + + + + +
+ +
+ +
+
+ + {{ $t("main.loadpointSettings.priorityBasis.help") }} + +
+
@@ -200,6 +226,7 @@ import { CURRENCY, SMART_COST_TYPE, PRIORITY_STRATEGY, + PRIORITY_BASIS, type Forecast, type UiLoadpoint, } from "@/types/evcc"; @@ -248,6 +275,7 @@ export default defineComponent({ selectedMinCurrent: undefined as number | undefined, selectedPhases: undefined as number | undefined, selectedPriorityStrategy: PRIORITY_STRATEGY.STATIC as PRIORITY_STRATEGY, + selectedPriorityBasis: PRIORITY_BASIS.PERCENT as PRIORITY_BASIS, selectedPriorityHysteresis: 0 as number, isModalVisible: false, }; @@ -316,6 +344,10 @@ export default defineComponent({ // published state sends "" for the static (default) strategy return this.loadpoint?.priorityStrategy || PRIORITY_STRATEGY.STATIC; }, + priorityBasis(): PRIORITY_BASIS { + // published state sends "" for the percent (default) basis + return this.loadpoint?.priorityBasis || PRIORITY_BASIS.PERCENT; + }, priorityHysteresis(): number { return this.loadpoint?.priorityHysteresis ?? 0; }, @@ -335,10 +367,25 @@ export default defineComponent({ }, ]; }, + priorityBasisOptions(): { value: PRIORITY_BASIS; name: string }[] { + return [ + { + value: PRIORITY_BASIS.PERCENT, + name: this.$t("main.loadpointSettings.priorityBasis.percent"), + }, + { + value: PRIORITY_BASIS.ENERGY, + name: this.$t("main.loadpointSettings.priorityBasis.energy"), + }, + ]; + }, priorityHysteresisAvailable(): boolean { // hysteresis only affects soc/deficit sub-ordering, not static priority return this.selectedPriorityStrategy !== PRIORITY_STRATEGY.STATIC; }, + priorityHysteresisUnit(): string { + return this.selectedPriorityBasis === PRIORITY_BASIS.ENERGY ? "kWh" : "%"; + }, }, watch: { maxCurrent(value) { @@ -353,6 +400,9 @@ export default defineComponent({ priorityStrategy(value) { this.selectedPriorityStrategy = value; }, + priorityBasis(value) { + this.selectedPriorityBasis = value; + }, priorityHysteresis(value) { this.selectedPriorityHysteresis = value; }, @@ -364,6 +414,7 @@ export default defineComponent({ this.selectedMaxCurrent = this.maxCurrent; this.selectedMinCurrent = this.minCurrent; this.selectedPriorityStrategy = this.priorityStrategy; + this.selectedPriorityBasis = this.priorityBasis; this.selectedPriorityHysteresis = this.priorityHysteresis; const modalRef = this.$refs["modal"] as InstanceType | undefined; modalRef?.open(); @@ -392,6 +443,9 @@ export default defineComponent({ setPriorityStrategy() { api.post(this.apiPath("prioritystrategy") + "/" + this.selectedPriorityStrategy); }, + setPriorityBasis() { + api.post(this.apiPath("prioritybasis") + "/" + this.selectedPriorityBasis); + }, setPriorityHysteresis() { const value = Math.min( 99, diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts index 8e80ec130fb..323ed8316d2 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -250,6 +250,7 @@ export interface ConfigLoadpoint { defaultMode: string; priority: number; priorityStrategy: PRIORITY_STRATEGY | ""; + priorityBasis: PRIORITY_BASIS | ""; priorityHysteresis: number; phasesConfigured: number; minCurrent: number; @@ -346,6 +347,7 @@ export interface Loadpoint { planTime: string | null; priority: number; priorityStrategy: PRIORITY_STRATEGY | ""; + priorityBasis: PRIORITY_BASIS | ""; priorityHysteresis: number; pvAction: PV_ACTION; pvRemaining: number; @@ -476,6 +478,11 @@ export enum PRIORITY_STRATEGY { DEFICIT = "deficit", } +export enum PRIORITY_BASIS { + PERCENT = "percent", + ENERGY = "energy", +} + export enum CHARGER_STATUS_REASON { UNKNOWN = "unknown", WAITING_FOR_AUTHORIZATION = "waitingforauthorization", From 6f17f60522b7288fe3d8bd1eb5afed067b46adc7 Mon Sep 17 00:00:00 2001 From: Alexxtheonly Date: Sun, 21 Jun 2026 07:00:24 +0200 Subject: [PATCH 11/18] chore: regenerate mock + gofmt for priority basis - 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) --- core/loadpoint/mock.go | 52 ++++++++++++++++---------------- core/loadpoint_effective_test.go | 8 ++--- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/core/loadpoint/mock.go b/core/loadpoint/mock.go index 8b830475762..ba6c294c766 100644 --- a/core/loadpoint/mock.go +++ b/core/loadpoint/mock.go @@ -573,6 +573,20 @@ func (mr *MockAPIMockRecorder) GetPriority() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPriority", reflect.TypeOf((*MockAPI)(nil).GetPriority)) } +// GetPriorityBasis mocks base method. +func (m *MockAPI) GetPriorityBasis() api.PriorityBasis { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPriorityBasis") + ret0, _ := ret[0].(api.PriorityBasis) + return ret0 +} + +// GetPriorityBasis indicates an expected call of GetPriorityBasis. +func (mr *MockAPIMockRecorder) GetPriorityBasis() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPriorityBasis", reflect.TypeOf((*MockAPI)(nil).GetPriorityBasis)) +} + // GetPriorityHysteresis mocks base method. func (m *MockAPI) GetPriorityHysteresis() int { m.ctrl.T.Helper() @@ -601,20 +615,6 @@ func (mr *MockAPIMockRecorder) GetPriorityStrategy() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPriorityStrategy", reflect.TypeOf((*MockAPI)(nil).GetPriorityStrategy)) } -// GetPriorityBasis mocks base method. -func (m *MockAPI) GetPriorityBasis() api.PriorityBasis { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetPriorityBasis") - ret0, _ := ret[0].(api.PriorityBasis) - return ret0 -} - -// GetPriorityBasis indicates an expected call of GetPriorityBasis. -func (mr *MockAPIMockRecorder) GetPriorityBasis() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPriorityBasis", reflect.TypeOf((*MockAPI)(nil).GetPriorityBasis)) -} - // GetRemainingDuration mocks base method. func (m *MockAPI) GetRemainingDuration() time.Duration { m.ctrl.T.Helper() @@ -1061,6 +1061,18 @@ func (mr *MockAPIMockRecorder) SetPriority(arg0 any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPriority", reflect.TypeOf((*MockAPI)(nil).SetPriority), arg0) } +// SetPriorityBasis mocks base method. +func (m *MockAPI) SetPriorityBasis(arg0 api.PriorityBasis) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SetPriorityBasis", arg0) +} + +// SetPriorityBasis indicates an expected call of SetPriorityBasis. +func (mr *MockAPIMockRecorder) SetPriorityBasis(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPriorityBasis", reflect.TypeOf((*MockAPI)(nil).SetPriorityBasis), arg0) +} + // SetPriorityHysteresis mocks base method. func (m *MockAPI) SetPriorityHysteresis(arg0 int) { m.ctrl.T.Helper() @@ -1085,18 +1097,6 @@ func (mr *MockAPIMockRecorder) SetPriorityStrategy(arg0 any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPriorityStrategy", reflect.TypeOf((*MockAPI)(nil).SetPriorityStrategy), arg0) } -// SetPriorityBasis mocks base method. -func (m *MockAPI) SetPriorityBasis(arg0 api.PriorityBasis) { - m.ctrl.T.Helper() - m.ctrl.Call(m, "SetPriorityBasis", arg0) -} - -// SetPriorityBasis indicates an expected call of SetPriorityBasis. -func (mr *MockAPIMockRecorder) SetPriorityBasis(arg0 any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPriorityBasis", reflect.TypeOf((*MockAPI)(nil).SetPriorityBasis), arg0) -} - // SetSmartCostLimit mocks base method. func (m *MockAPI) SetSmartCostLimit(limit *float64) { m.ctrl.T.Helper() diff --git a/core/loadpoint_effective_test.go b/core/loadpoint_effective_test.go index f309c297b38..6cf7992703e 100644 --- a/core/loadpoint_effective_test.go +++ b/core/loadpoint_effective_test.go @@ -41,10 +41,10 @@ func TestEffectivePriorityScore(t *testing.T) { {api.PriorityDeficit, api.PriorityBasisPercent, 0, 90, 80, 0, 0}, // soc above limit: no boost {api.PriorityDeficit, api.PriorityBasisPercent, 0, 0, 80, 0, 0}, // unknown soc: falls back to plain priority // soc (energy): gap is scaled by capacity -> (100-soc)/100*capacity, /100 for the fraction - {api.PrioritySoc, api.PriorityBasisEnergy, 0, 20, 0, 50, 0.40}, // 80% * 50kWh = 40kWh - {api.PrioritySoc, api.PriorityBasisEnergy, 0, 80, 0, 50, 0.10}, // 20% * 50kWh = 10kWh - {api.PrioritySoc, api.PriorityBasisEnergy, 0, 20, 0, 25, 0.20}, // 80% * 25kWh = 20kWh - {api.PrioritySoc, api.PriorityBasisEnergy, 0, 20, 0, 0, 0.80}, // capacity unknown: falls back to percent + {api.PrioritySoc, api.PriorityBasisEnergy, 0, 20, 0, 50, 0.40}, // 80% * 50kWh = 40kWh + {api.PrioritySoc, api.PriorityBasisEnergy, 0, 80, 0, 50, 0.10}, // 20% * 50kWh = 10kWh + {api.PrioritySoc, api.PriorityBasisEnergy, 0, 20, 0, 25, 0.20}, // 80% * 25kWh = 20kWh + {api.PrioritySoc, api.PriorityBasisEnergy, 0, 20, 0, 0, 0.80}, // capacity unknown: falls back to percent // deficit (energy): (limitSoc-soc)/100*capacity, /100 for the fraction {api.PriorityDeficit, api.PriorityBasisEnergy, 0, 50, 80, 50, 0.15}, // 30% * 50kWh = 15kWh {api.PriorityDeficit, api.PriorityBasisEnergy, 0, 50, 80, 0, 0.30}, // capacity unknown: falls back to percent From 697d443c45b362218a5415f62dd4cbac1568768b Mon Sep 17 00:00:00 2001 From: Alexxtheonly Date: Sun, 21 Jun 2026 07:04:58 +0200 Subject: [PATCH 12/18] chore: fix gofmt comment alignment on capacity-unknown row 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. --- core/loadpoint_effective_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/loadpoint_effective_test.go b/core/loadpoint_effective_test.go index 6cf7992703e..3e1dc73f0c0 100644 --- a/core/loadpoint_effective_test.go +++ b/core/loadpoint_effective_test.go @@ -44,7 +44,7 @@ func TestEffectivePriorityScore(t *testing.T) { {api.PrioritySoc, api.PriorityBasisEnergy, 0, 20, 0, 50, 0.40}, // 80% * 50kWh = 40kWh {api.PrioritySoc, api.PriorityBasisEnergy, 0, 80, 0, 50, 0.10}, // 20% * 50kWh = 10kWh {api.PrioritySoc, api.PriorityBasisEnergy, 0, 20, 0, 25, 0.20}, // 80% * 25kWh = 20kWh - {api.PrioritySoc, api.PriorityBasisEnergy, 0, 20, 0, 0, 0.80}, // capacity unknown: falls back to percent + {api.PrioritySoc, api.PriorityBasisEnergy, 0, 20, 0, 0, 0.80}, // capacity unknown: falls back to percent // deficit (energy): (limitSoc-soc)/100*capacity, /100 for the fraction {api.PriorityDeficit, api.PriorityBasisEnergy, 0, 50, 80, 50, 0.15}, // 30% * 50kWh = 15kWh {api.PriorityDeficit, api.PriorityBasisEnergy, 0, 50, 80, 0, 0.30}, // capacity unknown: falls back to percent From 88a866258338e72a0d0dae0c39f0011d0b605937 Mon Sep 17 00:00:00 2001 From: Alexxtheonly Date: Sun, 21 Jun 2026 07:14:21 +0200 Subject: [PATCH 13/18] fix: rank a priority tier on one basis to avoid mixing kWh and percent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- core/loadpoint/api.go | 4 +- core/loadpoint/mock.go | 8 ++-- core/loadpoint_effective.go | 9 +++- core/loadpoint_effective_test.go | 2 +- core/prioritizer/prioritizer.go | 48 +++++++++++++++++-- core/prioritizer/prioritizer_test.go | 70 +++++++++++++++++++++++++--- 6 files changed, 121 insertions(+), 20 deletions(-) diff --git a/core/loadpoint/api.go b/core/loadpoint/api.go index 9f7ced9511f..ef991b4940c 100644 --- a/core/loadpoint/api.go +++ b/core/loadpoint/api.go @@ -110,8 +110,8 @@ type API interface { // EffectivePriority returns the effective priority EffectivePriority() int - // EffectivePriorityScore returns the sortable priority score (tier + strategy sub-ordering) - EffectivePriorityScore() float64 + // EffectivePriorityScore returns the sortable priority score (tier + strategy sub-ordering) for the given basis + EffectivePriorityScore(basis api.PriorityBasis) float64 // EffectiveLimitSoc returns the effective session limit soc EffectiveLimitSoc() int // EffectivePlanId returns the effective plan id diff --git a/core/loadpoint/mock.go b/core/loadpoint/mock.go index ba6c294c766..b70bf5cd637 100644 --- a/core/loadpoint/mock.go +++ b/core/loadpoint/mock.go @@ -166,17 +166,17 @@ func (mr *MockAPIMockRecorder) EffectivePriority() *gomock.Call { } // EffectivePriorityScore mocks base method. -func (m *MockAPI) EffectivePriorityScore() float64 { +func (m *MockAPI) EffectivePriorityScore(basis api.PriorityBasis) float64 { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "EffectivePriorityScore") + ret := m.ctrl.Call(m, "EffectivePriorityScore", basis) ret0, _ := ret[0].(float64) return ret0 } // EffectivePriorityScore indicates an expected call of EffectivePriorityScore. -func (mr *MockAPIMockRecorder) EffectivePriorityScore() *gomock.Call { +func (mr *MockAPIMockRecorder) EffectivePriorityScore(basis any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EffectivePriorityScore", reflect.TypeOf((*MockAPI)(nil).EffectivePriorityScore)) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EffectivePriorityScore", reflect.TypeOf((*MockAPI)(nil).EffectivePriorityScore), basis) } // GetBatteryBoost mocks base method. diff --git a/core/loadpoint_effective.go b/core/loadpoint_effective.go index 81d712a6db8..ea5f13e3333 100644 --- a/core/loadpoint_effective.go +++ b/core/loadpoint_effective.go @@ -47,7 +47,12 @@ func (lp *Loadpoint) EffectivePriority() int { // loadpoints are ranked by absolute energy (kWh) rather than percentage and a // smaller battery is not over-prioritized just because its percentage is lower. // When the vehicle capacity is unknown the score falls back to the percentage gap. -func (lp *Loadpoint) EffectivePriorityScore() float64 { +// +// The basis is passed in rather than read from the loadpoint so the prioritizer +// can rank a whole priority tier on a single, consistent basis: a kWh fraction and +// a percentage fraction live on different scales and must not be compared directly +// (see Prioritizer.effectiveBasis). +func (lp *Loadpoint) EffectivePriorityScore(basis api.PriorityBasis) float64 { score := float64(lp.EffectivePriority()) soc := lp.GetSoc() @@ -68,7 +73,7 @@ func (lp *Loadpoint) EffectivePriorityScore() float64 { // energy basis: convert the soc-% gap into absolute kWh using the vehicle // capacity, falling back to the percentage gap when capacity is unknown - if lp.GetPriorityBasis() == api.PriorityBasisEnergy { + if basis == api.PriorityBasisEnergy { if capacity := lp.vehicleCapacity(); capacity > 0 { gap = gap / 100 * capacity } else { diff --git a/core/loadpoint_effective_test.go b/core/loadpoint_effective_test.go index 3e1dc73f0c0..c5586657f25 100644 --- a/core/loadpoint_effective_test.go +++ b/core/loadpoint_effective_test.go @@ -74,7 +74,7 @@ func TestEffectivePriorityScore(t *testing.T) { lp.vehicle = vehicle } - assert.InDelta(t, tc.expected, lp.EffectivePriorityScore(), 1e-9) + assert.InDelta(t, tc.expected, lp.EffectivePriorityScore(tc.basis), 1e-9) } } diff --git a/core/prioritizer/prioritizer.go b/core/prioritizer/prioritizer.go index 7847da8472a..173d3db8c79 100644 --- a/core/prioritizer/prioritizer.go +++ b/core/prioritizer/prioritizer.go @@ -32,7 +32,10 @@ func (p *Prioritizer) UpdateChargePowerFlexibility(lp loadpoint.API, rates api.R } func (p *Prioritizer) GetChargePowerFlexibility(lp loadpoint.API) float64 { - score := lp.EffectivePriorityScore() + // rank every candidate on a basis resolved per priority tier so the score + // fractions compared below share one scale (see effectiveBasis) + candidates := p.candidates(lp) + score := lp.EffectivePriorityScore(p.effectiveBasis(lp, candidates)) // hysteresis deadband (soc-% -> score fraction): only outrank another loadpoint // when ahead by more than the band, so near-equal soc loadpoints tie and converge @@ -45,10 +48,11 @@ func (p *Prioritizer) GetChargePowerFlexibility(lp loadpoint.API) float64 { msg string ) - for lp, power := range p.demand { - if score-lp.EffectivePriorityScore() > band && power > 0 { + for other, power := range p.demand { + otherScore := other.EffectivePriorityScore(p.effectiveBasis(other, candidates)) + if score-otherScore > band && power > 0 { reduceBy += power - msg += fmt.Sprintf("%.0fW from %s at prio %.2f, ", power, lp.GetTitle(), lp.EffectivePriorityScore()) + msg += fmt.Sprintf("%.0fW from %s at prio %.2f, ", power, other.GetTitle(), otherScore) } } @@ -58,3 +62,39 @@ func (p *Prioritizer) GetChargePowerFlexibility(lp loadpoint.API) float64 { return reduceBy } + +// candidates returns the loadpoints that participate in ranking: the target plus +// every loadpoint that has registered demand. +func (p *Prioritizer) candidates(lp loadpoint.API) []loadpoint.API { + res := []loadpoint.API{lp} + for other := range p.demand { + if other != lp { + res = append(res, other) + } + } + return res +} + +// effectiveBasis returns the priority basis to score lp with. The energy basis +// ranks by absolute kWh while the percent basis ranks by soc-%; their fractions +// are not comparable, so a whole priority tier must use a single basis. When any +// energy-basis loadpoint in lp's tier has no known vehicle capacity (its energy +// score would silently fall back to a percentage), the entire tier is ranked by +// percent so configured and unconfigured vehicles are never mixed across scales. +func (p *Prioritizer) effectiveBasis(lp loadpoint.API, candidates []loadpoint.API) api.PriorityBasis { + if lp.GetPriorityBasis() != api.PriorityBasisEnergy { + return lp.GetPriorityBasis() + } + + tier := lp.EffectivePriority() + for _, other := range candidates { + if other.EffectivePriority() != tier || other.GetPriorityBasis() != api.PriorityBasisEnergy { + continue + } + if v := other.GetVehicle(); v == nil || v.Capacity() <= 0 { + return api.PriorityBasisPercent + } + } + + return api.PriorityBasisEnergy +} diff --git a/core/prioritizer/prioritizer_test.go b/core/prioritizer/prioritizer_test.go index 8f01852626e..e0a68886377 100644 --- a/core/prioritizer/prioritizer_test.go +++ b/core/prioritizer/prioritizer_test.go @@ -3,6 +3,7 @@ package prioritizer import ( "testing" + "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/core/loadpoint" "github.com/stretchr/testify/assert" "go.uber.org/mock/gomock" @@ -15,12 +16,14 @@ func TestPrioritzer(t *testing.T) { lo := loadpoint.NewMockAPI(ctrl) lo.EXPECT().GetTitle().AnyTimes() - lo.EXPECT().EffectivePriorityScore().Return(0.0).AnyTimes() // prio 0 + lo.EXPECT().GetPriorityBasis().Return(api.PriorityBasisPercent).AnyTimes() + lo.EXPECT().EffectivePriorityScore(gomock.Any()).Return(0.0).AnyTimes() // prio 0 lo.EXPECT().GetPriorityHysteresis().Return(0).AnyTimes() hi := loadpoint.NewMockAPI(ctrl) hi.EXPECT().GetTitle().AnyTimes() - hi.EXPECT().EffectivePriorityScore().Return(1.0).AnyTimes() // prio 1 + hi.EXPECT().GetPriorityBasis().Return(api.PriorityBasisPercent).AnyTimes() + hi.EXPECT().EffectivePriorityScore(gomock.Any()).Return(1.0).AnyTimes() // prio 1 hi.EXPECT().GetPriorityHysteresis().Return(0).AnyTimes() // no additional power available @@ -49,12 +52,14 @@ func TestPrioritizerWithinTier(t *testing.T) { full := loadpoint.NewMockAPI(ctrl) // prio 0, soc 80 -> score 0.20 full.EXPECT().GetTitle().AnyTimes() - full.EXPECT().EffectivePriorityScore().Return(0.20).AnyTimes() + full.EXPECT().GetPriorityBasis().Return(api.PriorityBasisPercent).AnyTimes() + full.EXPECT().EffectivePriorityScore(gomock.Any()).Return(0.20).AnyTimes() full.EXPECT().GetPriorityHysteresis().Return(0).AnyTimes() empty := loadpoint.NewMockAPI(ctrl) // prio 0, soc 20 -> score 0.80 empty.EXPECT().GetTitle().AnyTimes() - empty.EXPECT().EffectivePriorityScore().Return(0.80).AnyTimes() + empty.EXPECT().GetPriorityBasis().Return(api.PriorityBasisPercent).AnyTimes() + empty.EXPECT().EffectivePriorityScore(gomock.Any()).Return(0.80).AnyTimes() empty.EXPECT().GetPriorityHysteresis().Return(0).AnyTimes() // fuller vehicle has nothing below it -> no extra power @@ -80,18 +85,21 @@ func TestPrioritizerHysteresis(t *testing.T) { // soc 50 -> 0.50, soc 51 -> 0.49, 5% deadband (0.05) a := loadpoint.NewMockAPI(ctrl) a.EXPECT().GetTitle().AnyTimes() - a.EXPECT().EffectivePriorityScore().Return(0.50).AnyTimes() + a.EXPECT().GetPriorityBasis().Return(api.PriorityBasisPercent).AnyTimes() + a.EXPECT().EffectivePriorityScore(gomock.Any()).Return(0.50).AnyTimes() a.EXPECT().GetPriorityHysteresis().Return(5).AnyTimes() b := loadpoint.NewMockAPI(ctrl) b.EXPECT().GetTitle().AnyTimes() - b.EXPECT().EffectivePriorityScore().Return(0.49).AnyTimes() + b.EXPECT().GetPriorityBasis().Return(api.PriorityBasisPercent).AnyTimes() + b.EXPECT().EffectivePriorityScore(gomock.Any()).Return(0.49).AnyTimes() b.EXPECT().GetPriorityHysteresis().Return(5).AnyTimes() // clearly emptier (soc 40 -> 0.60), same 5% band c := loadpoint.NewMockAPI(ctrl) c.EXPECT().GetTitle().AnyTimes() - c.EXPECT().EffectivePriorityScore().Return(0.60).AnyTimes() + c.EXPECT().GetPriorityBasis().Return(api.PriorityBasisPercent).AnyTimes() + c.EXPECT().EffectivePriorityScore(gomock.Any()).Return(0.60).AnyTimes() c.EXPECT().GetPriorityHysteresis().Return(5).AnyTimes() b.EXPECT().GetChargePowerFlexibility(nil).Return(400.0) @@ -103,3 +111,51 @@ func TestPrioritizerHysteresis(t *testing.T) { // c is 0.11 ahead of b -> beyond the band -> takes b's flexible power assert.Equal(t, 400.0, p.GetChargePowerFlexibility(c)) } + +// TestPrioritizerEnergyBasisMixedCapacity verifies that when one loadpoint in a +// tier has a known vehicle capacity and another (also energy basis) does not, the +// tier is ranked by percent rather than mixing a kWh fraction against a percentage +// fraction. Without the fallback the unconfigured vehicle's percentage gap would +// out-score the configured vehicle's (smaller) kWh gap and wrongly steal surplus. +// +// known: soc 20%, 50 kWh -> energy 0.40, percent 0.80 +// unknown: soc 50%, no cap -> energy would fall back to percent 0.50 +// +// With percent ranking the genuinely emptier "known" car (0.80) outranks "unknown" +// (0.50); the buggy mixed ranking would have unknown (0.50) beat known (0.40). +func TestPrioritizerEnergyBasisMixedCapacity(t *testing.T) { + ctrl := gomock.NewController(t) + + p := New(nil) + + vehicle := api.NewMockVehicle(ctrl) + vehicle.EXPECT().Capacity().Return(50.0).AnyTimes() + + // known capacity, lower soc -> percent score 0.80 (energy 0.40) + known := loadpoint.NewMockAPI(ctrl) + known.EXPECT().GetTitle().AnyTimes() + known.EXPECT().EffectivePriority().Return(0).AnyTimes() + known.EXPECT().GetPriorityBasis().Return(api.PriorityBasisEnergy).AnyTimes() + known.EXPECT().GetVehicle().Return(vehicle).AnyTimes() + known.EXPECT().GetPriorityHysteresis().Return(0).AnyTimes() + known.EXPECT().EffectivePriorityScore(api.PriorityBasisPercent).Return(0.80).AnyTimes() + + // unknown capacity, higher soc -> percent score 0.50 + unknown := loadpoint.NewMockAPI(ctrl) + unknown.EXPECT().GetTitle().AnyTimes() + unknown.EXPECT().EffectivePriority().Return(0).AnyTimes() + unknown.EXPECT().GetPriorityBasis().Return(api.PriorityBasisEnergy).AnyTimes() + unknown.EXPECT().GetVehicle().Return(nil).AnyTimes() + unknown.EXPECT().GetPriorityHysteresis().Return(0).AnyTimes() + unknown.EXPECT().EffectivePriorityScore(api.PriorityBasisPercent).Return(0.50).AnyTimes() + + // unknown (fuller, 0.50) has nothing emptier below it -> no extra power + unknown.EXPECT().GetChargePowerFlexibility(nil).Return(700.0) + p.UpdateChargePowerFlexibility(unknown, nil) + assert.Equal(t, 0.0, p.GetChargePowerFlexibility(unknown)) + + // known (emptier, 0.80) outranks unknown and takes its flexible power + known.EXPECT().GetChargePowerFlexibility(nil).Return(1e3) + p.UpdateChargePowerFlexibility(known, nil) + assert.Equal(t, 700.0, p.GetChargePowerFlexibility(known)) +} From 46179069cca0a104c2b6f2d51008f5aea4ff8b20 Mon Sep 17 00:00:00 2001 From: Alexander Herold Date: Sun, 21 Jun 2026 10:33:43 +0200 Subject: [PATCH 14/18] =?UTF-8?q?refactor(loadpoint):=20address=20review?= =?UTF-8?q?=20=E2=80=94=20TextUnmarshaler=20guard,=20drop=20Stringer,=20ow?= =?UTF-8?q?n=20config=20block?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- api/prioritystrategy.go | 33 +++++++++++++++++---------------- core/loadpoint/config.go | 36 +++++++++++++++++++----------------- core/loadpoint_effective.go | 24 ++++-------------------- 3 files changed, 40 insertions(+), 53 deletions(-) diff --git a/api/prioritystrategy.go b/api/prioritystrategy.go index 2172204faa8..0e08d9a82f5 100644 --- a/api/prioritystrategy.go +++ b/api/prioritystrategy.go @@ -1,6 +1,7 @@ package api import ( + "encoding" "fmt" "strings" ) @@ -22,14 +23,6 @@ const ( PriorityDeficit PriorityStrategy = "deficit" ) -// String implements Stringer -func (s PriorityStrategy) String() string { - if s == PriorityStatic { - return "static" - } - return string(s) -} - // PriorityStrategyString converts a string to PriorityStrategy func PriorityStrategyString(s string) (PriorityStrategy, error) { switch strings.ToLower(strings.TrimSpace(s)) { @@ -44,6 +37,14 @@ func PriorityStrategyString(s string) (PriorityStrategy, error) { } } +var _ encoding.TextUnmarshaler = (*PriorityStrategy)(nil) + +func (s *PriorityStrategy) UnmarshalText(text []byte) error { + var err error + *s, err = PriorityStrategyString(string(text)) + return err +} + // PriorityBasis determines whether a priority strategy ranks loadpoints by soc // percentage or by absolute energy (kWh). Valid values are percent and energy. type PriorityBasis string @@ -60,14 +61,6 @@ const ( PriorityBasisEnergy PriorityBasis = "energy" ) -// String implements Stringer -func (b PriorityBasis) String() string { - if b == PriorityBasisPercent { - return "percent" - } - return string(b) -} - // PriorityBasisString converts a string to PriorityBasis func PriorityBasisString(s string) (PriorityBasis, error) { switch strings.ToLower(strings.TrimSpace(s)) { @@ -79,3 +72,11 @@ func PriorityBasisString(s string) (PriorityBasis, error) { return PriorityBasisPercent, fmt.Errorf("invalid priority basis: %s", s) } } + +var _ encoding.TextUnmarshaler = (*PriorityBasis)(nil) + +func (b *PriorityBasis) UnmarshalText(text []byte) error { + var err error + *b, err = PriorityBasisString(string(text)) + return err +} diff --git a/core/loadpoint/config.go b/core/loadpoint/config.go index 8bfaac1e963..8649c806f59 100644 --- a/core/loadpoint/config.go +++ b/core/loadpoint/config.go @@ -17,26 +17,28 @@ type StaticConfig struct { type DynamicConfig struct { // dynamic config - Title string `json:"title"` - DefaultMode string `json:"defaultMode"` - Priority int `json:"priority"` - PriorityStrategy api.PriorityStrategy `json:"priorityStrategy"` - PriorityBasis api.PriorityBasis `json:"priorityBasis"` - PriorityHysteresis int `json:"priorityHysteresis"` - PhasesConfigured int `json:"phasesConfigured"` - MinCurrent float64 `json:"minCurrent"` - MaxCurrent float64 `json:"maxCurrent"` - SmartCostLimit *float64 `json:"smartCostLimit"` - SmartFeedInPriorityLimit *float64 `json:"smartFeedInPriorityLimit"` - PlanEnergy float64 `json:"planEnergy"` - PlanTime time.Time `json:"planTime"` - PlanPrecondition_ int64 `json:"planPrecondition" mapstructure:"planPrecondition"` // TODO deprecated, keep for compatibility - BatteryBoostLimit int `json:"batteryBoostLimit"` - LimitEnergy float64 `json:"limitEnergy"` - LimitSoc int `json:"limitSoc"` + Title string `json:"title"` + DefaultMode string `json:"defaultMode"` + Priority int `json:"priority"` + PhasesConfigured int `json:"phasesConfigured"` + MinCurrent float64 `json:"minCurrent"` + MaxCurrent float64 `json:"maxCurrent"` + SmartCostLimit *float64 `json:"smartCostLimit"` + SmartFeedInPriorityLimit *float64 `json:"smartFeedInPriorityLimit"` + PlanEnergy float64 `json:"planEnergy"` + PlanTime time.Time `json:"planTime"` + PlanPrecondition_ int64 `json:"planPrecondition" mapstructure:"planPrecondition"` // TODO deprecated, keep for compatibility + BatteryBoostLimit int `json:"batteryBoostLimit"` + LimitEnergy float64 `json:"limitEnergy"` + LimitSoc int `json:"limitSoc"` PlanStrategy api.PlanStrategy `json:"planStrategy"` + // priority sub-ordering within a tier (see api.PriorityStrategy) + PriorityStrategy api.PriorityStrategy `json:"priorityStrategy"` + PriorityBasis api.PriorityBasis `json:"priorityBasis"` + PriorityHysteresis int `json:"priorityHysteresis"` + Thresholds ThresholdsConfig `json:"thresholds"` Soc SocConfig `json:"soc"` UI UIConfig `json:"ui"` diff --git a/core/loadpoint_effective.go b/core/loadpoint_effective.go index ea5f13e3333..e7b6304eabb 100644 --- a/core/loadpoint_effective.go +++ b/core/loadpoint_effective.go @@ -32,26 +32,10 @@ func (lp *Loadpoint) EffectivePriority() int { return lp.GetPriority() } -// EffectivePriorityScore returns a sortable priority score used to rank loadpoints -// when distributing surplus power. The integer part is the effective priority (the -// tier); the fractional part in [0,1) orders loadpoints within the same tier -// according to the configured priority strategy, so the sub-ordering never crosses -// a priority boundary. -// -// A higher score wins. With the soc strategy a lower vehicle soc scores higher; -// with the deficit strategy a larger gap to the limit soc scores higher. The -// strategy sub-ordering only applies when a positive vehicle soc is known, -// otherwise the score equals the plain effective priority. -// -// With the energy basis the soc-% gap is scaled by the vehicle capacity, so -// loadpoints are ranked by absolute energy (kWh) rather than percentage and a -// smaller battery is not over-prioritized just because its percentage is lower. -// When the vehicle capacity is unknown the score falls back to the percentage gap. -// -// The basis is passed in rather than read from the loadpoint so the prioritizer -// can rank a whole priority tier on a single, consistent basis: a kWh fraction and -// a percentage fraction live on different scales and must not be compared directly -// (see Prioritizer.effectiveBasis). +// EffectivePriorityScore ranks loadpoints for surplus distribution: the integer part is +// the effective priority tier, the fractional part [0,1) sub-orders within the tier by the +// priority strategy/basis (higher wins). Basis is passed in so the prioritizer can score a +// whole tier on one scale, see Prioritizer.effectiveBasis. func (lp *Loadpoint) EffectivePriorityScore(basis api.PriorityBasis) float64 { score := float64(lp.EffectivePriority()) From 16abf9ffb50d2611f0657563e33bbb623af0c713 Mon Sep 17 00:00:00 2001 From: Alexander Herold Date: Sun, 21 Jun 2026 14:23:44 +0200 Subject: [PATCH 15/18] refactor(loadpoint): enumer-based priority enums, rename static->none 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 --- api/prioritybasis_enumer.go | 90 ++++++++++++++++++ api/prioritystrategy.go | 82 +++------------- api/prioritystrategy_enumer.go | 94 +++++++++++++++++++ .../js/components/Config/LoadpointModal.vue | 10 +- .../components/Loadpoints/SettingsModal.vue | 14 +-- assets/js/types/evcc.ts | 2 +- core/keys/loadpoint.go | 2 +- core/loadpoint.go | 18 +--- core/loadpoint/api.go | 2 - core/loadpoint/mock.go | 14 --- core/loadpoint_api.go | 26 +++-- core/loadpoint_effective.go | 9 +- core/loadpoint_effective_test.go | 6 +- core/loadpoint_priority_test.go | 14 +-- core/prioritizer/prioritizer.go | 11 ++- core/prioritizer/prioritizer_test.go | 2 - i18n/en.json | 6 +- server/openapi.yaml | 2 +- 18 files changed, 254 insertions(+), 150 deletions(-) create mode 100644 api/prioritybasis_enumer.go create mode 100644 api/prioritystrategy_enumer.go diff --git a/api/prioritybasis_enumer.go b/api/prioritybasis_enumer.go new file mode 100644 index 00000000000..d943afdcd1b --- /dev/null +++ b/api/prioritybasis_enumer.go @@ -0,0 +1,90 @@ +// Code generated by "enumer -type PriorityBasis -trimprefix PriorityBasis -transform=lower -text"; DO NOT EDIT. + +package api + +import ( + "fmt" + "strings" +) + +const _PriorityBasisName = "percentenergy" + +var _PriorityBasisIndex = [...]uint8{0, 7, 13} + +const _PriorityBasisLowerName = "percentenergy" + +func (i PriorityBasis) String() string { + if i < 0 || i >= PriorityBasis(len(_PriorityBasisIndex)-1) { + return fmt.Sprintf("PriorityBasis(%d)", i) + } + return _PriorityBasisName[_PriorityBasisIndex[i]:_PriorityBasisIndex[i+1]] +} + +// An "invalid array index" compiler error signifies that the constant values have changed. +// Re-run the stringer command to generate them again. +func _PriorityBasisNoOp() { + var x [1]struct{} + _ = x[PriorityBasisPercent-(0)] + _ = x[PriorityBasisEnergy-(1)] +} + +var _PriorityBasisValues = []PriorityBasis{PriorityBasisPercent, PriorityBasisEnergy} + +var _PriorityBasisNameToValueMap = map[string]PriorityBasis{ + _PriorityBasisName[0:7]: PriorityBasisPercent, + _PriorityBasisLowerName[0:7]: PriorityBasisPercent, + _PriorityBasisName[7:13]: PriorityBasisEnergy, + _PriorityBasisLowerName[7:13]: PriorityBasisEnergy, +} + +var _PriorityBasisNames = []string{ + _PriorityBasisName[0:7], + _PriorityBasisName[7:13], +} + +// PriorityBasisString retrieves an enum value from the enum constants string name. +// Throws an error if the param is not part of the enum. +func PriorityBasisString(s string) (PriorityBasis, error) { + if val, ok := _PriorityBasisNameToValueMap[s]; ok { + return val, nil + } + + if val, ok := _PriorityBasisNameToValueMap[strings.ToLower(s)]; ok { + return val, nil + } + return 0, fmt.Errorf("%s does not belong to PriorityBasis values", s) +} + +// PriorityBasisValues returns all values of the enum +func PriorityBasisValues() []PriorityBasis { + return _PriorityBasisValues +} + +// PriorityBasisStrings returns a slice of all String values of the enum +func PriorityBasisStrings() []string { + strs := make([]string, len(_PriorityBasisNames)) + copy(strs, _PriorityBasisNames) + return strs +} + +// IsAPriorityBasis returns "true" if the value is listed in the enum definition. "false" otherwise +func (i PriorityBasis) IsAPriorityBasis() bool { + for _, v := range _PriorityBasisValues { + if i == v { + return true + } + } + return false +} + +// MarshalText implements the encoding.TextMarshaler interface for PriorityBasis +func (i PriorityBasis) MarshalText() ([]byte, error) { + return []byte(i.String()), nil +} + +// UnmarshalText implements the encoding.TextUnmarshaler interface for PriorityBasis +func (i *PriorityBasis) UnmarshalText(text []byte) error { + var err error + *i, err = PriorityBasisString(string(text)) + return err +} diff --git a/api/prioritystrategy.go b/api/prioritystrategy.go index 0e08d9a82f5..927bec34c32 100644 --- a/api/prioritystrategy.go +++ b/api/prioritystrategy.go @@ -1,82 +1,22 @@ package api -import ( - "encoding" - "fmt" - "strings" -) - // PriorityStrategy determines how a loadpoint is ranked against other loadpoints -// of the same priority when distributing surplus power. Valid values are static, -// soc and deficit. -type PriorityStrategy string +// of the same priority when distributing surplus power. +type PriorityStrategy int -// Priority strategies +//go:generate go tool enumer -type PriorityStrategy -trimprefix Priority -transform=lower -text const ( - // PriorityStatic ranks loadpoints by their configured priority only (default). - PriorityStatic PriorityStrategy = "" - // PrioritySoc additionally prefers the loadpoint with the lower vehicle soc - // among loadpoints of the same priority. - PrioritySoc PriorityStrategy = "soc" - // PriorityDeficit additionally prefers the loadpoint with the larger gap - // between vehicle soc and its limit soc among loadpoints of the same priority. - PriorityDeficit PriorityStrategy = "deficit" + PriorityNone PriorityStrategy = iota // no sub-ordering (default) + PrioritySoc // prefer the lower vehicle soc + PriorityDeficit // prefer the larger gap to limit soc ) -// PriorityStrategyString converts a string to PriorityStrategy -func PriorityStrategyString(s string) (PriorityStrategy, error) { - switch strings.ToLower(strings.TrimSpace(s)) { - case "", "static": - return PriorityStatic, nil - case string(PrioritySoc): - return PrioritySoc, nil - case string(PriorityDeficit): - return PriorityDeficit, nil - default: - return PriorityStatic, fmt.Errorf("invalid priority strategy: %s", s) - } -} - -var _ encoding.TextUnmarshaler = (*PriorityStrategy)(nil) - -func (s *PriorityStrategy) UnmarshalText(text []byte) error { - var err error - *s, err = PriorityStrategyString(string(text)) - return err -} - // PriorityBasis determines whether a priority strategy ranks loadpoints by soc -// percentage or by absolute energy (kWh). Valid values are percent and energy. -type PriorityBasis string +// percentage or by absolute energy (kWh). +type PriorityBasis int -// Priority bases +//go:generate go tool enumer -type PriorityBasis -trimprefix PriorityBasis -transform=lower -text const ( - // PriorityBasisPercent ranks the priority strategy in soc-% (default), so a - // percentage gap is compared regardless of battery capacity. - PriorityBasisPercent PriorityBasis = "" - // PriorityBasisEnergy ranks the priority strategy by absolute energy (kWh) by - // scaling the soc-% gap with the vehicle capacity, so a smaller battery is not - // over-prioritized just because its percentage is lower. Falls back to percent - // when the vehicle capacity is unknown. - PriorityBasisEnergy PriorityBasis = "energy" + PriorityBasisPercent PriorityBasis = iota // rank by soc-% (default) + PriorityBasisEnergy // rank by absolute energy (kWh) ) - -// PriorityBasisString converts a string to PriorityBasis -func PriorityBasisString(s string) (PriorityBasis, error) { - switch strings.ToLower(strings.TrimSpace(s)) { - case "", "percent", "percentage", "soc": - return PriorityBasisPercent, nil - case string(PriorityBasisEnergy), "absolute", "kwh": - return PriorityBasisEnergy, nil - default: - return PriorityBasisPercent, fmt.Errorf("invalid priority basis: %s", s) - } -} - -var _ encoding.TextUnmarshaler = (*PriorityBasis)(nil) - -func (b *PriorityBasis) UnmarshalText(text []byte) error { - var err error - *b, err = PriorityBasisString(string(text)) - return err -} diff --git a/api/prioritystrategy_enumer.go b/api/prioritystrategy_enumer.go new file mode 100644 index 00000000000..8b9b2c92b42 --- /dev/null +++ b/api/prioritystrategy_enumer.go @@ -0,0 +1,94 @@ +// Code generated by "enumer -type PriorityStrategy -trimprefix Priority -transform=lower -text"; DO NOT EDIT. + +package api + +import ( + "fmt" + "strings" +) + +const _PriorityStrategyName = "nonesocdeficit" + +var _PriorityStrategyIndex = [...]uint8{0, 4, 7, 14} + +const _PriorityStrategyLowerName = "nonesocdeficit" + +func (i PriorityStrategy) String() string { + if i < 0 || i >= PriorityStrategy(len(_PriorityStrategyIndex)-1) { + return fmt.Sprintf("PriorityStrategy(%d)", i) + } + return _PriorityStrategyName[_PriorityStrategyIndex[i]:_PriorityStrategyIndex[i+1]] +} + +// An "invalid array index" compiler error signifies that the constant values have changed. +// Re-run the stringer command to generate them again. +func _PriorityStrategyNoOp() { + var x [1]struct{} + _ = x[PriorityNone-(0)] + _ = x[PrioritySoc-(1)] + _ = x[PriorityDeficit-(2)] +} + +var _PriorityStrategyValues = []PriorityStrategy{PriorityNone, PrioritySoc, PriorityDeficit} + +var _PriorityStrategyNameToValueMap = map[string]PriorityStrategy{ + _PriorityStrategyName[0:4]: PriorityNone, + _PriorityStrategyLowerName[0:4]: PriorityNone, + _PriorityStrategyName[4:7]: PrioritySoc, + _PriorityStrategyLowerName[4:7]: PrioritySoc, + _PriorityStrategyName[7:14]: PriorityDeficit, + _PriorityStrategyLowerName[7:14]: PriorityDeficit, +} + +var _PriorityStrategyNames = []string{ + _PriorityStrategyName[0:4], + _PriorityStrategyName[4:7], + _PriorityStrategyName[7:14], +} + +// PriorityStrategyString retrieves an enum value from the enum constants string name. +// Throws an error if the param is not part of the enum. +func PriorityStrategyString(s string) (PriorityStrategy, error) { + if val, ok := _PriorityStrategyNameToValueMap[s]; ok { + return val, nil + } + + if val, ok := _PriorityStrategyNameToValueMap[strings.ToLower(s)]; ok { + return val, nil + } + return 0, fmt.Errorf("%s does not belong to PriorityStrategy values", s) +} + +// PriorityStrategyValues returns all values of the enum +func PriorityStrategyValues() []PriorityStrategy { + return _PriorityStrategyValues +} + +// PriorityStrategyStrings returns a slice of all String values of the enum +func PriorityStrategyStrings() []string { + strs := make([]string, len(_PriorityStrategyNames)) + copy(strs, _PriorityStrategyNames) + return strs +} + +// IsAPriorityStrategy returns "true" if the value is listed in the enum definition. "false" otherwise +func (i PriorityStrategy) IsAPriorityStrategy() bool { + for _, v := range _PriorityStrategyValues { + if i == v { + return true + } + } + return false +} + +// MarshalText implements the encoding.TextMarshaler interface for PriorityStrategy +func (i PriorityStrategy) MarshalText() ([]byte, error) { + return []byte(i.String()), nil +} + +// UnmarshalText implements the encoding.TextUnmarshaler interface for PriorityStrategy +func (i *PriorityStrategy) UnmarshalText(text []byte) error { + var err error + *i, err = PriorityStrategyString(string(text)) + return err +} diff --git a/assets/js/components/Config/LoadpointModal.vue b/assets/js/components/Config/LoadpointModal.vue index c8f3e21c104..e5e387ed21e 100644 --- a/assets/js/components/Config/LoadpointModal.vue +++ b/assets/js/components/Config/LoadpointModal.vue @@ -852,9 +852,9 @@ export default { return result; }, priorityStrategy: { - // backend returns "" for the static strategy; map to/from the explicit "static" choice + // fall back to the none (default) strategy get(): PRIORITY_STRATEGY { - return this.values.priorityStrategy || PRIORITY_STRATEGY.STATIC; + return this.values.priorityStrategy || PRIORITY_STRATEGY.NONE; }, set(value: PRIORITY_STRATEGY) { this.values.priorityStrategy = value; @@ -863,8 +863,8 @@ export default { priorityStrategyOptions(): { key: PRIORITY_STRATEGY; name: string }[] { return [ { - key: PRIORITY_STRATEGY.STATIC, - name: this.$t("config.loadpoint.priorityStrategyStatic"), + key: PRIORITY_STRATEGY.NONE, + name: this.$t("config.loadpoint.priorityStrategyNone"), }, { key: PRIORITY_STRATEGY.SOC, @@ -898,7 +898,7 @@ export default { ]; }, priorityHysteresisAvailable(): boolean { - return this.priorityStrategy !== PRIORITY_STRATEGY.STATIC; + return this.priorityStrategy !== PRIORITY_STRATEGY.NONE; }, priorityHysteresisUnit(): string { return this.priorityBasis === PRIORITY_BASIS.ENERGY ? "kWh" : "%"; diff --git a/assets/js/components/Loadpoints/SettingsModal.vue b/assets/js/components/Loadpoints/SettingsModal.vue index eeac9ea7944..4dd2bd2d01f 100644 --- a/assets/js/components/Loadpoints/SettingsModal.vue +++ b/assets/js/components/Loadpoints/SettingsModal.vue @@ -274,7 +274,7 @@ export default defineComponent({ selectedMaxCurrent: undefined as number | undefined, selectedMinCurrent: undefined as number | undefined, selectedPhases: undefined as number | undefined, - selectedPriorityStrategy: PRIORITY_STRATEGY.STATIC as PRIORITY_STRATEGY, + selectedPriorityStrategy: PRIORITY_STRATEGY.NONE as PRIORITY_STRATEGY, selectedPriorityBasis: PRIORITY_BASIS.PERCENT as PRIORITY_BASIS, selectedPriorityHysteresis: 0 as number, isModalVisible: false, @@ -341,8 +341,8 @@ export default defineComponent({ return this.batteryConfigured; }, priorityStrategy(): PRIORITY_STRATEGY { - // published state sends "" for the static (default) strategy - return this.loadpoint?.priorityStrategy || PRIORITY_STRATEGY.STATIC; + // fall back to the none (default) strategy + return this.loadpoint?.priorityStrategy || PRIORITY_STRATEGY.NONE; }, priorityBasis(): PRIORITY_BASIS { // published state sends "" for the percent (default) basis @@ -354,8 +354,8 @@ export default defineComponent({ priorityStrategyOptions(): { value: PRIORITY_STRATEGY; name: string }[] { return [ { - value: PRIORITY_STRATEGY.STATIC, - name: this.$t("main.loadpointSettings.priorityStrategy.static"), + value: PRIORITY_STRATEGY.NONE, + name: this.$t("main.loadpointSettings.priorityStrategy.none"), }, { value: PRIORITY_STRATEGY.SOC, @@ -380,8 +380,8 @@ export default defineComponent({ ]; }, priorityHysteresisAvailable(): boolean { - // hysteresis only affects soc/deficit sub-ordering, not static priority - return this.selectedPriorityStrategy !== PRIORITY_STRATEGY.STATIC; + // hysteresis only affects soc/deficit sub-ordering, not the none strategy + return this.selectedPriorityStrategy !== PRIORITY_STRATEGY.NONE; }, priorityHysteresisUnit(): string { return this.selectedPriorityBasis === PRIORITY_BASIS.ENERGY ? "kWh" : "%"; diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts index 323ed8316d2..40a65e9a80b 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -473,7 +473,7 @@ export enum PV_ACTION { } export enum PRIORITY_STRATEGY { - STATIC = "static", + NONE = "none", SOC = "soc", DEFICIT = "deficit", } diff --git a/core/keys/loadpoint.go b/core/keys/loadpoint.go index b847e36193d..5bc82bb1d7c 100644 --- a/core/keys/loadpoint.go +++ b/core/keys/loadpoint.go @@ -11,7 +11,7 @@ const ( Circuit = "circuit" // circuit ref DefaultVehicle = "vehicle" // default vehicle ref Priority = "priority" // priority - PriorityStrategy = "priorityStrategy" // priority strategy (static, soc, deficit) + PriorityStrategy = "priorityStrategy" // priority strategy (none, soc, deficit) PriorityBasis = "priorityBasis" // priority strategy basis (percent, energy) PriorityHysteresis = "priorityHysteresis" // priority sub-ordering deadband (soc-% or kWh per basis, 0 = off) MinCurrent = "minCurrent" // min current diff --git a/core/loadpoint.go b/core/loadpoint.go index f0775c380ec..6ba7a94549a 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -104,7 +104,7 @@ type Loadpoint struct { DefaultMode api.ChargeMode `mapstructure:"mode"` // Default charge mode, used for disconnect Title string `mapstructure:"title"` // UI title Priority int `mapstructure:"priority"` // Priority - PriorityStrategy api.PriorityStrategy `mapstructure:"priorityStrategy"` // Priority strategy (static, soc, deficit) + PriorityStrategy api.PriorityStrategy `mapstructure:"priorityStrategy"` // Priority strategy (none, soc, deficit) PriorityBasis api.PriorityBasis `mapstructure:"priorityBasis"` // Priority strategy basis (percent, energy) PriorityHysteresis int `mapstructure:"priorityHysteresis"` // Priority sub-ordering deadband (soc-% or kWh per basis, 0 = off) @@ -116,7 +116,7 @@ type Loadpoint struct { title string // UI title priority int // Priority - priorityStrategy api.PriorityStrategy // Priority strategy (static, soc, deficit) + priorityStrategy api.PriorityStrategy // Priority strategy (none, soc, deficit) priorityBasis api.PriorityBasis // Priority strategy basis (percent, energy) priorityHysteresis int // Priority sub-ordering deadband (soc-% or kWh per basis, 0 = off) minCurrent float64 // PV mode: start current Min+PV mode: min current @@ -225,17 +225,9 @@ func NewLoadpointFromConfig(log *util.Logger, settings settings.Settings, collec lp.setPriority(lp.Priority) } - if ps, err := api.PriorityStrategyString(string(lp.PriorityStrategy)); err != nil { - return lp, err - } else { - lp.priorityStrategy = ps - } - - if pb, err := api.PriorityBasisString(string(lp.PriorityBasis)); err != nil { - return lp, err - } else { - lp.priorityBasis = pb - } + // PriorityStrategy/PriorityBasis are validated at decode via their TextUnmarshaler + lp.priorityStrategy = lp.PriorityStrategy + lp.priorityBasis = lp.PriorityBasis if lp.PriorityHysteresis < 0 || lp.PriorityHysteresis > 99 { return lp, fmt.Errorf("invalid priority hysteresis: %d (must be 0..99)", lp.PriorityHysteresis) diff --git a/core/loadpoint/api.go b/core/loadpoint/api.go index ef991b4940c..85fd9ed0311 100644 --- a/core/loadpoint/api.go +++ b/core/loadpoint/api.go @@ -108,8 +108,6 @@ type API interface { // effective values // - // EffectivePriority returns the effective priority - EffectivePriority() int // EffectivePriorityScore returns the sortable priority score (tier + strategy sub-ordering) for the given basis EffectivePriorityScore(basis api.PriorityBasis) float64 // EffectiveLimitSoc returns the effective session limit soc diff --git a/core/loadpoint/mock.go b/core/loadpoint/mock.go index b70bf5cd637..7cf9889631c 100644 --- a/core/loadpoint/mock.go +++ b/core/loadpoint/mock.go @@ -151,20 +151,6 @@ func (mr *MockAPIMockRecorder) EffectivePlanTime() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EffectivePlanTime", reflect.TypeOf((*MockAPI)(nil).EffectivePlanTime)) } -// EffectivePriority mocks base method. -func (m *MockAPI) EffectivePriority() int { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "EffectivePriority") - ret0, _ := ret[0].(int) - return ret0 -} - -// EffectivePriority indicates an expected call of EffectivePriority. -func (mr *MockAPIMockRecorder) EffectivePriority() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EffectivePriority", reflect.TypeOf((*MockAPI)(nil).EffectivePriority)) -} - // EffectivePriorityScore mocks base method. func (m *MockAPI) EffectivePriorityScore(basis api.PriorityBasis) float64 { m.ctrl.T.Helper() diff --git a/core/loadpoint_api.go b/core/loadpoint_api.go index 365a3fa78c5..5bccf9748a7 100644 --- a/core/loadpoint_api.go +++ b/core/loadpoint_api.go @@ -271,7 +271,7 @@ func (lp *Loadpoint) SetPriority(prio int) { func (lp *Loadpoint) setPriorityStrategy(strategy api.PriorityStrategy) { lp.priorityStrategy = strategy lp.publish(keys.PriorityStrategy, lp.priorityStrategy) - lp.settings.SetString(keys.PriorityStrategy, string(lp.priorityStrategy)) + lp.settings.SetString(keys.PriorityStrategy, strategy.String()) } // SetPriorityStrategy sets the loadpoint priority strategy @@ -279,15 +279,14 @@ func (lp *Loadpoint) SetPriorityStrategy(strategy api.PriorityStrategy) { lp.Lock() defer lp.Unlock() - normalized, err := api.PriorityStrategyString(string(strategy)) - if err != nil { - lp.log.ERROR.Printf("invalid priority strategy: %s", string(strategy)) + if !strategy.IsAPriorityStrategy() { + lp.log.ERROR.Printf("invalid priority strategy: %d", strategy) return } - lp.log.DEBUG.Printf("set priority strategy: %s", normalized) - if lp.priorityStrategy != normalized { - lp.setPriorityStrategy(normalized) + lp.log.DEBUG.Printf("set priority strategy: %s", strategy) + if lp.priorityStrategy != strategy { + lp.setPriorityStrategy(strategy) } } @@ -295,7 +294,7 @@ func (lp *Loadpoint) SetPriorityStrategy(strategy api.PriorityStrategy) { func (lp *Loadpoint) setPriorityBasis(basis api.PriorityBasis) { lp.priorityBasis = basis lp.publish(keys.PriorityBasis, lp.priorityBasis) - lp.settings.SetString(keys.PriorityBasis, string(lp.priorityBasis)) + lp.settings.SetString(keys.PriorityBasis, basis.String()) } // SetPriorityBasis sets the loadpoint priority strategy basis @@ -303,15 +302,14 @@ func (lp *Loadpoint) SetPriorityBasis(basis api.PriorityBasis) { lp.Lock() defer lp.Unlock() - normalized, err := api.PriorityBasisString(string(basis)) - if err != nil { - lp.log.ERROR.Printf("invalid priority basis: %s", string(basis)) + if !basis.IsAPriorityBasis() { + lp.log.ERROR.Printf("invalid priority basis: %d", basis) return } - lp.log.DEBUG.Printf("set priority basis: %s", normalized) - if lp.priorityBasis != normalized { - lp.setPriorityBasis(normalized) + lp.log.DEBUG.Printf("set priority basis: %s", basis) + if lp.priorityBasis != basis { + lp.setPriorityBasis(basis) } } diff --git a/core/loadpoint_effective.go b/core/loadpoint_effective.go index e7b6304eabb..8408d6b73fb 100644 --- a/core/loadpoint_effective.go +++ b/core/loadpoint_effective.go @@ -12,7 +12,7 @@ import ( // PublishEffectiveValues publishes all effective values func (lp *Loadpoint) PublishEffectiveValues() { - lp.publish(keys.EffectivePriority, lp.EffectivePriority()) + lp.publish(keys.EffectivePriority, lp.effectivePriority()) lp.publish(keys.EffectivePlanId, lp.EffectivePlanId()) lp.publish(keys.EffectivePlanTime, lp.EffectivePlanTime()) lp.publish(keys.EffectivePlanSoc, lp.EffectivePlanSoc()) @@ -22,8 +22,9 @@ func (lp *Loadpoint) PublishEffectiveValues() { lp.publish(keys.EffectiveLimitSoc, lp.EffectiveLimitSoc()) } -// EffectivePriority returns the effective priority -func (lp *Loadpoint) EffectivePriority() int { +// effectivePriority returns the effective priority tier. It is the integer part +// of EffectivePriorityScore; the prioritizer derives the tier from the score. +func (lp *Loadpoint) effectivePriority() int { if v := lp.GetVehicle(); v != nil { if res, ok := v.OnIdentified().GetPriority(); ok { return res @@ -37,7 +38,7 @@ func (lp *Loadpoint) EffectivePriority() int { // priority strategy/basis (higher wins). Basis is passed in so the prioritizer can score a // whole tier on one scale, see Prioritizer.effectiveBasis. func (lp *Loadpoint) EffectivePriorityScore(basis api.PriorityBasis) float64 { - score := float64(lp.EffectivePriority()) + score := float64(lp.effectivePriority()) soc := lp.GetSoc() if soc <= 0 { diff --git a/core/loadpoint_effective_test.go b/core/loadpoint_effective_test.go index c5586657f25..eb36c7e7523 100644 --- a/core/loadpoint_effective_test.go +++ b/core/loadpoint_effective_test.go @@ -26,9 +26,9 @@ func TestEffectivePriorityScore(t *testing.T) { capacity float64 // vehicle capacity in kWh, 0 = no/unknown vehicle expected float64 }{ - // static: fractional part is always zero - {api.PriorityStatic, api.PriorityBasisPercent, 0, 50, 0, 0, 0}, - {api.PriorityStatic, api.PriorityBasisPercent, 2, 50, 0, 0, 2}, + // none: fractional part is always zero + {api.PriorityNone, api.PriorityBasisPercent, 0, 50, 0, 0, 0}, + {api.PriorityNone, api.PriorityBasisPercent, 2, 50, 0, 0, 2}, // soc (percent): lower soc scores higher within the tier {api.PrioritySoc, api.PriorityBasisPercent, 0, 20, 0, 0, 0.80}, {api.PrioritySoc, api.PriorityBasisPercent, 0, 80, 0, 0, 0.20}, diff --git a/core/loadpoint_priority_test.go b/core/loadpoint_priority_test.go index 4567890697f..94bfee552f0 100644 --- a/core/loadpoint_priority_test.go +++ b/core/loadpoint_priority_test.go @@ -77,22 +77,22 @@ func TestSetPriorityStrategy(t *testing.T) { // valid: soc lp.SetPriorityStrategy(api.PrioritySoc) assert.Equal(t, api.PrioritySoc, lp.GetPriorityStrategy()) - assert.Equal(t, string(api.PrioritySoc), s.data[keys.PriorityStrategy], "soc must be persisted") + assert.Equal(t, api.PrioritySoc.String(), s.data[keys.PriorityStrategy], "soc must be persisted") // valid: deficit lp.SetPriorityStrategy(api.PriorityDeficit) assert.Equal(t, api.PriorityDeficit, lp.GetPriorityStrategy()) - assert.Equal(t, string(api.PriorityDeficit), s.data[keys.PriorityStrategy]) + assert.Equal(t, api.PriorityDeficit.String(), s.data[keys.PriorityStrategy]) - // "static" normalizes to the empty PriorityStatic value - lp.SetPriorityStrategy(api.PriorityStrategy("static")) - assert.Equal(t, api.PriorityStatic, lp.GetPriorityStrategy()) - assert.Equal(t, string(api.PriorityStatic), s.data[keys.PriorityStrategy]) + // valid: none (default) + lp.SetPriorityStrategy(api.PriorityNone) + assert.Equal(t, api.PriorityNone, lp.GetPriorityStrategy()) + assert.Equal(t, api.PriorityNone.String(), s.data[keys.PriorityStrategy]) // invalid: rejected, state unchanged lp.SetPriorityStrategy(api.PrioritySoc) delete(s.data, keys.PriorityStrategy) - lp.SetPriorityStrategy(api.PriorityStrategy("bogus")) + lp.SetPriorityStrategy(api.PriorityStrategy(99)) assert.Equal(t, api.PrioritySoc, lp.GetPriorityStrategy(), "invalid strategy must not change state") _, persisted := s.data[keys.PriorityStrategy] assert.False(t, persisted, "invalid strategy must not be persisted") diff --git a/core/prioritizer/prioritizer.go b/core/prioritizer/prioritizer.go index 173d3db8c79..28e29d642e8 100644 --- a/core/prioritizer/prioritizer.go +++ b/core/prioritizer/prioritizer.go @@ -86,9 +86,9 @@ func (p *Prioritizer) effectiveBasis(lp loadpoint.API, candidates []loadpoint.AP return lp.GetPriorityBasis() } - tier := lp.EffectivePriority() + tier := priorityTier(lp) for _, other := range candidates { - if other.EffectivePriority() != tier || other.GetPriorityBasis() != api.PriorityBasisEnergy { + if priorityTier(other) != tier || other.GetPriorityBasis() != api.PriorityBasisEnergy { continue } if v := other.GetVehicle(); v == nil || v.Capacity() <= 0 { @@ -98,3 +98,10 @@ func (p *Prioritizer) effectiveBasis(lp loadpoint.API, candidates []loadpoint.AP return api.PriorityBasisEnergy } + +// priorityTier is the integer (cross-tier) part of a loadpoint's score. The +// strategy sub-ordering lives in the fraction and is basis-independent, so any +// basis yields the same tier. +func priorityTier(lp loadpoint.API) int { + return int(math.Floor(lp.EffectivePriorityScore(api.PriorityBasisPercent))) +} diff --git a/core/prioritizer/prioritizer_test.go b/core/prioritizer/prioritizer_test.go index e0a68886377..fd714bef896 100644 --- a/core/prioritizer/prioritizer_test.go +++ b/core/prioritizer/prioritizer_test.go @@ -134,7 +134,6 @@ func TestPrioritizerEnergyBasisMixedCapacity(t *testing.T) { // known capacity, lower soc -> percent score 0.80 (energy 0.40) known := loadpoint.NewMockAPI(ctrl) known.EXPECT().GetTitle().AnyTimes() - known.EXPECT().EffectivePriority().Return(0).AnyTimes() known.EXPECT().GetPriorityBasis().Return(api.PriorityBasisEnergy).AnyTimes() known.EXPECT().GetVehicle().Return(vehicle).AnyTimes() known.EXPECT().GetPriorityHysteresis().Return(0).AnyTimes() @@ -143,7 +142,6 @@ func TestPrioritizerEnergyBasisMixedCapacity(t *testing.T) { // unknown capacity, higher soc -> percent score 0.50 unknown := loadpoint.NewMockAPI(ctrl) unknown.EXPECT().GetTitle().AnyTimes() - unknown.EXPECT().EffectivePriority().Return(0).AnyTimes() unknown.EXPECT().GetPriorityBasis().Return(api.PriorityBasisEnergy).AnyTimes() unknown.EXPECT().GetVehicle().Return(nil).AnyTimes() unknown.EXPECT().GetPriorityHysteresis().Return(0).AnyTimes() diff --git a/i18n/en.json b/i18n/en.json index 4e98f0842c3..2b666502c8a 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -411,8 +411,8 @@ "priorityStrategyDeficit": "Largest deficit", "priorityStrategyHelp": "How to order loadpoints of equal priority when distributing solar surplus.", "priorityStrategyLabel": "Priority strategy", + "priorityStrategyNone": "None", "priorityStrategySoc": "Lower charge level first", - "priorityStrategyStatic": "Static", "save": "Save", "solarBehaviorCustomHelp": "Define your own enable and disable thresholds and delays.", "solarBehaviorDefaultHelp": "Start after {enableDelay} of sufficient surplus. Stop when there is not enough surplus for {disableDelay}.", @@ -1346,8 +1346,8 @@ "deficit": "Largest deficit", "help": "How to order loadpoints of equal priority when distributing solar surplus.", "label": "Strategy", - "soc": "Lower charge level first", - "static": "Static" + "none": "None", + "soc": "Lower charge level first" }, "smartCostCheap": "Cheap Grid Charging", "smartCostClean": "Clean Grid Charging", diff --git a/server/openapi.yaml b/server/openapi.yaml index 6781d77f8cf..dbb4bbebdff 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -1591,7 +1591,7 @@ components: description: "Priority strategy used to sub-order loadpoints of the same priority." type: string enum: - - "static" + - "none" - "soc" - "deficit" PriorityBasis: From d7c0f4fb01d3724c4838733b0973ef62dac30ca8 Mon Sep 17 00:00:00 2001 From: Alexander Herold Date: Sun, 21 Jun 2026 14:33:01 +0200 Subject: [PATCH 16/18] fix(ui): default new loadpoint priority strategy/basis to none/percent 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. --- assets/js/components/Config/LoadpointModal.vue | 4 ++-- assets/js/types/evcc.ts | 8 ++++---- core/loadpoint_effective.go | 3 +-- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/assets/js/components/Config/LoadpointModal.vue b/assets/js/components/Config/LoadpointModal.vue index e5e387ed21e..03a27f6fea7 100644 --- a/assets/js/components/Config/LoadpointModal.vue +++ b/assets/js/components/Config/LoadpointModal.vue @@ -707,8 +707,8 @@ const defaultValues = { minCurrent: 6, maxCurrent: 16, priority: 0, - priorityStrategy: "", - priorityBasis: "", + priorityStrategy: PRIORITY_STRATEGY.NONE, + priorityBasis: PRIORITY_BASIS.PERCENT, priorityHysteresis: 0, defaultMode: "", thresholds: { diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts index 40a65e9a80b..4aca4b8bb98 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -249,8 +249,8 @@ export interface ConfigLoadpoint { title: string; defaultMode: string; priority: number; - priorityStrategy: PRIORITY_STRATEGY | ""; - priorityBasis: PRIORITY_BASIS | ""; + priorityStrategy: PRIORITY_STRATEGY; + priorityBasis: PRIORITY_BASIS; priorityHysteresis: number; phasesConfigured: number; minCurrent: number; @@ -346,8 +346,8 @@ export interface Loadpoint { planProjectedStart: string | null; planTime: string | null; priority: number; - priorityStrategy: PRIORITY_STRATEGY | ""; - priorityBasis: PRIORITY_BASIS | ""; + priorityStrategy: PRIORITY_STRATEGY; + priorityBasis: PRIORITY_BASIS; priorityHysteresis: number; pvAction: PV_ACTION; pvRemaining: number; diff --git a/core/loadpoint_effective.go b/core/loadpoint_effective.go index 8408d6b73fb..457030684b6 100644 --- a/core/loadpoint_effective.go +++ b/core/loadpoint_effective.go @@ -22,8 +22,7 @@ func (lp *Loadpoint) PublishEffectiveValues() { lp.publish(keys.EffectiveLimitSoc, lp.EffectiveLimitSoc()) } -// effectivePriority returns the effective priority tier. It is the integer part -// of EffectivePriorityScore; the prioritizer derives the tier from the score. +// effectivePriority returns the effective priority tier (integer part of the score). func (lp *Loadpoint) effectivePriority() int { if v := lp.GetVehicle(); v != nil { if res, ok := v.OnIdentified().GetPriority(); ok { From 216274ce11bb4d413325ea58b9a6a5112822a06d Mon Sep 17 00:00:00 2001 From: Alexander Herold Date: Sun, 21 Jun 2026 14:41:04 +0200 Subject: [PATCH 17/18] chore(mcp): regenerate openapi.json for none enum (porcelain check) --- server/mcp/openapi.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/mcp/openapi.json b/server/mcp/openapi.json index 574e5daf548..9744d348943 100644 --- a/server/mcp/openapi.json +++ b/server/mcp/openapi.json @@ -660,7 +660,7 @@ "PriorityStrategy": { "description": "Priority strategy used to sub-order loadpoints of the same priority.", "enum": [ - "static", + "none", "soc", "deficit" ], From 4fe883601d49ed0c7081eb31e9ba35fc84fe6b46 Mon Sep 17 00:00:00 2001 From: Alexander Herold Date: Tue, 23 Jun 2026 18:43:02 +0200 Subject: [PATCH 18/18] feat: publish effectivePriorityScore via 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 --- assets/js/types/evcc.ts | 1 + core/keys/loadpoint.go | 13 +++++++------ core/loadpoint_effective.go | 1 + 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts index 4aca4b8bb98..10016649301 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -324,6 +324,7 @@ export interface Loadpoint { effectivePlanTime: string | null; effectivePlanStrategy: PlanStrategy; effectivePriority: number; + effectivePriorityScore: number; enableDelay: number; enableThreshold: number; enabled: boolean; diff --git a/core/keys/loadpoint.go b/core/keys/loadpoint.go index 5bc82bb1d7c..a3e4cc1dd2e 100644 --- a/core/keys/loadpoint.go +++ b/core/keys/loadpoint.go @@ -58,12 +58,13 @@ const ( SmartFeedInPriorityNextStart = "smartFeedInPriorityNextStart" // smart feed-in priority next start, time of next pause // effective values - EffectivePriority = "effectivePriority" // effective priority - EffectivePlanId = "effectivePlanId" // effective plan id - EffectivePlanTime = "effectivePlanTime" // effective plan time - EffectivePlanSoc = "effectivePlanSoc" // effective plan soc - EffectiveMinCurrent = "effectiveMinCurrent" // effective min current - EffectiveMaxCurrent = "effectiveMaxCurrent" // effective max current + EffectivePriority = "effectivePriority" // effective priority + EffectivePriorityScore = "effectivePriorityScore" // effective priority score (tier + strategy sub-ordering) + EffectivePlanId = "effectivePlanId" // effective plan id + EffectivePlanTime = "effectivePlanTime" // effective plan time + EffectivePlanSoc = "effectivePlanSoc" // effective plan soc + EffectiveMinCurrent = "effectiveMinCurrent" // effective min current + EffectiveMaxCurrent = "effectiveMaxCurrent" // effective max current EffectiveLimitSoc = "effectiveLimitSoc" // effective limit soc EffectivePlanStrategy = "effectivePlanStrategy" // effective plan strategy diff --git a/core/loadpoint_effective.go b/core/loadpoint_effective.go index 457030684b6..ce468e278e5 100644 --- a/core/loadpoint_effective.go +++ b/core/loadpoint_effective.go @@ -13,6 +13,7 @@ import ( // PublishEffectiveValues publishes all effective values func (lp *Loadpoint) PublishEffectiveValues() { lp.publish(keys.EffectivePriority, lp.effectivePriority()) + lp.publish(keys.EffectivePriorityScore, lp.EffectivePriorityScore(lp.GetPriorityBasis())) lp.publish(keys.EffectivePlanId, lp.EffectivePlanId()) lp.publish(keys.EffectivePlanTime, lp.EffectivePlanTime()) lp.publish(keys.EffectivePlanSoc, lp.EffectivePlanSoc())