Loadpoint: limit boost power to battery limits (#28178)
This commit is contained in:
parent
3093539af5
commit
f6a5b07dd6
6 changed files with 185 additions and 10 deletions
|
|
@ -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()
|
||||
|
|
|
|||
93
core/loadpoint_boost_test.go
Normal file
93
core/loadpoint_boost_test.go
Normal 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)
|
||||
}
|
||||
18
core/site.go
18
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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ type API interface {
|
|||
//
|
||||
|
||||
GetBatterySoc() float64
|
||||
GetBatteryMaxDischargePower() float64
|
||||
GetPrioritySoc() float64
|
||||
SetPrioritySoc(float64) error
|
||||
GetBufferSoc() float64
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
55
core/site_battery_limit_test.go
Normal file
55
core/site_battery_limit_test.go
Normal 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())
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue