From f6a5b07dd61b5a3048cbb36a220e5063e1560ede Mon Sep 17 00:00:00 2001 From: mfuchs1984 <57141790+mfuchs1984@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:32:51 +0200 Subject: [PATCH] Loadpoint: limit boost power to battery limits (#28178) --- core/loadpoint.go | 21 +++++--- core/loadpoint_boost_test.go | 93 +++++++++++++++++++++++++++++++++ core/site.go | 18 ++++++- core/site/api.go | 1 + core/site_api.go | 7 +++ core/site_battery_limit_test.go | 55 +++++++++++++++++++ 6 files changed, 185 insertions(+), 10 deletions(-) create mode 100644 core/loadpoint_boost_test.go create mode 100644 core/site_battery_limit_test.go diff --git a/core/loadpoint.go b/core/loadpoint.go index 66b1bd625..cd3618be5 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -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() diff --git a/core/loadpoint_boost_test.go b/core/loadpoint_boost_test.go new file mode 100644 index 000000000..78594c4fb --- /dev/null +++ b/core/loadpoint_boost_test.go @@ -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) +} diff --git a/core/site.go b/core/site.go index 369733287..9711fef09 100644 --- a/core/site.go +++ b/core/site.go @@ -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), ) diff --git a/core/site/api.go b/core/site/api.go index 992fb15a0..df9a6d17f 100644 --- a/core/site/api.go +++ b/core/site/api.go @@ -44,6 +44,7 @@ type API interface { // GetBatterySoc() float64 + GetBatteryMaxDischargePower() float64 GetPrioritySoc() float64 SetPrioritySoc(float64) error GetBufferSoc() float64 diff --git a/core/site_api.go b/core/site_api.go index 83cc37850..e6830c562 100644 --- a/core/site_api.go +++ b/core/site_api.go @@ -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 }) diff --git a/core/site_battery_limit_test.go b/core/site_battery_limit_test.go new file mode 100644 index 000000000..1cad1b716 --- /dev/null +++ b/core/site_battery_limit_test.go @@ -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()) +}