Loadpoint: limit boost power to battery limits (#28178)

This commit is contained in:
mfuchs1984 2026-08-07 15:32:51 +02:00 • committed by GitHub
parent 3093539af5
commit f6a5b07dd6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 185 additions and 10 deletions

View file

@ -1558,7 +1558,7 @@ func (lp *Loadpoint) publishTimer(name string, delay time.Duration, action strin
}
// boostPower returns the additional power that the loadpoint should draw from the battery
func (lp *Loadpoint) boostPower(batteryBoostPower float64) float64 {
func (lp *Loadpoint) boostPower(batteryPower float64) float64 {
boost := lp.GetBatteryBoost()
if boost == boostDisabled || boost == boostHold {
return 0
@ -1590,20 +1590,25 @@ func (lp *Loadpoint) boostPower(batteryBoostPower float64) float64 {
}
}
res := batteryBoostPower + delta + lp.site.GetResidualPower()
lp.log.DEBUG.Printf("pv charge battery boost: %.0fW = -%.0fW battery - %.0fW boost", -res, batteryBoostPower, delta)
if maxDischargePower := lp.site.GetBatteryMaxDischargePower(); maxDischargePower > 0 {
// limit delta to what the battery can still provide
delta = min(delta, max(0, maxDischargePower-batteryPower))
}
res := max(0, batteryPower) + delta + lp.site.GetResidualPower()
lp.log.DEBUG.Printf("pv charge battery boost: %.0fW = -%.0fW battery - %.0fW boost - %.0fW residual", -res, max(0, batteryPower), delta, lp.site.GetResidualPower())
return res
}
// pvMaxCurrent calculates the maximum target current for PV mode
func (lp *Loadpoint) pvMaxCurrent(mode api.ChargeMode, sitePower, batteryBoostPower float64, batteryBuffered, batteryStart bool) float64 {
func (lp *Loadpoint) pvMaxCurrent(mode api.ChargeMode, sitePower, batteryPower float64, batteryBuffered, batteryStart bool) float64 {
// read only once to simplify testing
minCurrent := lp.effectiveMinCurrent()
maxCurrent := lp.effectiveMaxCurrent()
// push demand to drain battery
sitePower -= lp.boostPower(batteryBoostPower)
sitePower -= lp.boostPower(batteryPower)
// switch phases up/down
var scaledTo int
@ -1626,7 +1631,7 @@ func (lp *Loadpoint) pvMaxCurrent(mode api.ChargeMode, sitePower, batteryBoostPo
targetCurrent := max(effectiveCurrent+deltaCurrent, 0)
// in MinPV mode or under special conditions return at least minCurrent
if battery := batteryStart || batteryBuffered && lp.charging(); (mode == api.ModeMinPV || battery) && targetCurrent < minCurrent {
if battery := batteryStart || batteryBuffered && lp.charging() || lp.GetBatteryBoost() == boostContinue; (mode == api.ModeMinPV || battery) && targetCurrent < minCurrent {
lp.log.DEBUG.Printf("pv charge current: min %.3gA > %.3gA (%.0fW @ %dp, battery: %t)", minCurrent, targetCurrent, sitePower, activePhases, battery)
return minCurrent
}
@ -2085,7 +2090,7 @@ func (lp *Loadpoint) phaseSwitchCompleted() bool {
}
// Update is the main control function. It reevaluates meters and charger state
func (lp *Loadpoint) Update(sitePower, batteryBoostPower float64, consumption, feedin api.Rates, batteryBuffered, batteryStart bool, greenShare float64, effPrice, effCo2 *float64, dim *bool) {
func (lp *Loadpoint) Update(sitePower, batteryPower float64, consumption, feedin api.Rates, batteryBuffered, batteryStart bool, greenShare float64, effPrice, effCo2 *float64, dim *bool) {
// hold battery boost when SOC drops below the limit: stop draining the battery, but
// keep the vehicle prioritised over recharging it (via sitePower priorityAdjustment)
// until the vehicle disconnects. This holds the battery at the configured level
@ -2275,7 +2280,7 @@ func (lp *Loadpoint) Update(sitePower, batteryBoostPower float64, consumption, f
break
}
targetCurrent := lp.pvMaxCurrent(mode, sitePower, batteryBoostPower, batteryBuffered, batteryStart)
targetCurrent := lp.pvMaxCurrent(mode, sitePower, batteryPower, batteryBuffered, batteryStart)
if targetCurrent == 0 && lp.vehicleClimateActive() {
targetCurrent = lp.effectiveMinCurrent()

View file

@ -0,0 +1,93 @@
package core
import (
"testing"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/site"
"github.com/evcc-io/evcc/util"
"github.com/stretchr/testify/assert"
)
type mockSite struct {
site.API
maxDischargePower float64
residualPower float64
}
func (m *mockSite) GetBatteryMaxDischargePower() float64 {
return m.maxDischargePower
}
func (m *mockSite) GetResidualPower() float64 {
return m.residualPower
}
func TestBoostPower(t *testing.T) {
Voltage = 230
lp := &Loadpoint{
log: util.NewLogger("lp"),
status: api.StatusC,
batteryBoost: boostStart,
maxCurrent: 16,
phases: 3,
}
s := &mockSite{}
lp.site = s
// No max discharge power limit
s.maxDischargePower = 0
// EffectiveMaxPower will be 230 * 16 * 3 = 11040
res := lp.boostPower(0)
assert.Equal(t, 11040.0, res)
assert.Equal(t, boostContinue, lp.batteryBoost)
// With max discharge power limit
s.maxDischargePower = 5000
lp.batteryBoost = boostStart
res = lp.boostPower(0)
assert.Equal(t, 5000.0, res)
assert.Equal(t, boostContinue, lp.batteryBoost)
// boostContinue with limit
lp.batteryBoost = boostContinue
s.residualPower = 0
// delta = math.Max(100, 0) = 100
// plus EffectiveStepPower = 690
// delta = 790
// delta = min(790, max(0, 5000 - 0)) = 790
// res = 0 + 790 + 0 = 790
res = lp.boostPower(0)
assert.Equal(t, 790.0, res)
// boostContinue at limit
// delta = min(790, max(0, 5000 - 5000)) = 0
// res = 5000 + 0 + 0 = 5000
res = lp.boostPower(5000)
assert.Equal(t, 5000.0, res)
// boostContinue over limit
// delta = min(790, max(0, 5000 - 6000)) = 0
// res = 6000 + 0 + 0 = 6000
res = lp.boostPower(6000)
assert.Equal(t, 6000.0, res)
// boostStart while battery is charging (negative power)
// battery charging at 2000W, limit is 5000W
// max discharge capacity = 5000 - (-2000) = 7000W
// res = max(0, -2000) + 7000 + 0 = 7000W
lp.batteryBoost = boostStart
res = lp.boostPower(-2000)
assert.Equal(t, 7000.0, res)
// boostContinue while battery is charging (negative power)
// limit is 50W (less than the standard 790W delta)
// without raw negative power, delta would be restricted to 50W
// with raw negative power (-2000W), headroom is 2050W, so delta is allowed to be 790W
s.maxDischargePower = 50
s.residualPower = 0 // base delta = 100 + 690 = 790
lp.batteryBoost = boostContinue
res = lp.boostPower(-2000)
// res = max(0, -2000) + 790 + 0 = 790W
assert.Equal(t, 790.0, res)
}

View file

@ -46,7 +46,7 @@ const standbyPower = 10 // consider less than 10W as charger in standby
// updater abstracts the Loadpoint implementation for testing
type updater interface {
loadpoint.API
Update(sitePower, batteryBoostPower float64, consumption, feedin api.Rates, batteryBuffered, batteryStart bool, greenShare float64, effectivePrice, effectiveCo2 *float64, dim *bool)
Update(sitePower, batteryPower float64, consumption, feedin api.Rates, batteryBuffered, batteryStart bool, greenShare float64, effectivePrice, effectiveCo2 *float64, dim *bool)
}
var _ site.API = (*Site)(nil)
@ -112,6 +112,7 @@ type Site struct {
excessDCPower float64 // PV excess DC charge power (hybrid only)
auxPower float64 // Aux power
battery types.BatteryState // Battery cached and published state
batteryMaxDischargePower float64 // Max discharge power of all battery meters
batteryMode api.BatteryMode // Battery mode (runtime only, not persisted)
batteryModeExternal api.BatteryMode // Battery mode (external, runtime only, not persisted)
batteryModeExternalTimer time.Time // Battery mode timer for external control
@ -692,6 +693,7 @@ func (site *Site) updateBatteryMeters() {
mm := site.collectMeters("battery", site.batteryMeters)
var maxDischargePower float64
for i, dev := range site.batteryMeters {
meter := dev.Instance()
@ -711,10 +713,22 @@ func (site *Site) updateBatteryMeters() {
}
}
if bpl, ok := api.Cap[api.BatteryPowerLimiter](meter); ok && maxDischargePower >= 0 {
_, discharge := bpl.GetPowerLimits()
maxDischargePower += discharge
} else {
maxDischargePower = -1 // any battery without a limit disables the cap
}
_, controllable := api.Cap[api.BatteryController](meter)
mm[i].Controllable = new(controllable)
}
// written from the meter goroutine, read via GetBatteryMaxDischargePower
site.Lock()
site.batteryMaxDischargePower = max(0, maxDischargePower)
site.Unlock()
// retain the last known soc when every battery read failed this cycle, so a
// transient meter error does not report the pack as empty (0%)
if lo.EveryBy(mm, func(m types.Measurement) bool { return m.Soc == nil }) {
@ -1164,7 +1178,7 @@ func (site *Site) update(lp updater) {
}
lp.Update(
sitePower, max(0, site.battery.Power), consumption, feedin, batteryBuffered, batteryStart,
sitePower, site.battery.Power, consumption, feedin, batteryBuffered, batteryStart,
greenShareLoadpoints, site.effectivePrice(greenShareLoadpoints), site.effectiveCo2(greenShareLoadpoints),
hems.Dimmed(site.hems),
)

View file

@ -44,6 +44,7 @@ type API interface {
//
GetBatterySoc() float64
GetBatteryMaxDischargePower() float64
GetPrioritySoc() float64
SetPrioritySoc(float64) error
GetBufferSoc() float64

View file

@ -168,6 +168,13 @@ func (site *Site) GetBatterySoc() float64 {
return site.battery.Soc
}
// GetBatteryMaxDischargePower returns the current battery max discharge power
func (site *Site) GetBatteryMaxDischargePower() float64 {
site.RLock()
defer site.RUnlock()
return site.batteryMaxDischargePower
}
// Loadpoints returns the loadpoints as api interfaces
func (site *Site) Loadpoints() []loadpoint.API {
return lo.Map(site.loadpoints, func(lp *Loadpoint, _ int) loadpoint.API { return lp })

View file

@ -0,0 +1,55 @@
package core
import (
"testing"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/config"
"github.com/stretchr/testify/assert"
)
type mockBatteryPowerLimiter struct {
api.Meter
charge, discharge float64
}
func (m *mockBatteryPowerLimiter) GetPowerLimits() (float64, float64) {
return m.charge, m.discharge
}
type mockMeter struct {
api.Meter
}
func (m *mockMeter) CurrentPower() (float64, error) {
return 0, nil
}
func TestBatteryMaxDischargePowerAggregation(t *testing.T) {
site := &Site{
log: util.NewLogger("foo"),
}
// one battery with limit, one without
m1 := &mockBatteryPowerLimiter{Meter: &mockMeter{}, discharge: 2000}
m2 := &mockMeter{}
site.batteryMeters = []config.Device[api.Meter]{
config.NewStaticDevice[api.Meter](config.Named{Name: "bat1"}, m1),
config.NewStaticDevice[api.Meter](config.Named{Name: "bat2"}, m2),
}
site.updateBatteryMeters()
assert.Equal(t, 0.0, site.GetBatteryMaxDischargePower())
// both batteries with limit
m3 := &mockBatteryPowerLimiter{Meter: &mockMeter{}, discharge: 3000}
site.batteryMeters = []config.Device[api.Meter]{
config.NewStaticDevice[api.Meter](config.Named{Name: "bat1"}, m1),
config.NewStaticDevice[api.Meter](config.Named{Name: "bat3"}, m3),
}
site.updateBatteryMeters()
assert.Equal(t, 5000.0, site.GetBatteryMaxDischargePower())
}