Loadpoint: add battery boost (experimental) (#16599)

This commit is contained in:
andig 2024-10-13 18:46:21 +02:00 • committed by GitHub
parent 52a9082c90
commit 16c467a65d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 165 additions and 27 deletions

View file

@ -14,6 +14,7 @@ const (
DisableThreshold = "disableThreshold"
EnableDelay = "enableDelay"
DisableDelay = "disableDelay"
BatteryBoost = "batteryBoost"
PhasesConfigured = "phasesConfigured" // configured phases (1/3, 0 for auto on 1p3p chargers, nil for plain chargers)
PhasesEnabled = "phasesEnabled" // enabled phases (1/3)

View file

@ -55,6 +55,11 @@ const (
chargerSwitchDuration = 60 * time.Second // allow out of sync during this timespan
phaseSwitchDuration = 60 * time.Second // allow out of sync and do not measure phases during this timespan
// battery boost states
boostDisabled = 0
boostStart = 1
boostContinue = 2
)
// elapsed is the time an expired timer will be set to
@ -127,6 +132,7 @@ type Loadpoint struct {
limitSoc int // Session limit for soc
limitEnergy float64 // Session limit for energy
smartCostLimit *float64 // always charge if cost is below this value
batteryBoost int // battery boost state
mode api.ChargeMode
enabled bool // Charger enabled state
@ -531,6 +537,11 @@ func (lp *Loadpoint) evVehicleDisconnectHandler() {
// soc update reset
lp.socUpdated = time.Time{}
// boost
if err := lp.SetBatteryBoost(false); err != nil {
lp.log.ERROR.Printf("battery boost: %v", err)
}
// reset session
lp.SetLimitSoc(0)
lp.SetLimitEnergy(0)
@ -800,10 +811,16 @@ func (lp *Loadpoint) syncCharger() error {
return nil
}
// coarseCurrent returns true if charger or vehicle require full amp steps
func (lp *Loadpoint) coarseCurrent() bool {
_, ok := lp.charger.(api.ChargerEx)
return !ok || lp.vehicleHasFeature(api.CoarseCurrent)
}
// roundedCurrent rounds current down to full amps if charger or vehicle require it
func (lp *Loadpoint) roundedCurrent(chargeCurrent float64) float64 {
// full amps only?
if _, ok := lp.charger.(api.ChargerEx); !ok || lp.vehicleHasFeature(api.CoarseCurrent) {
if lp.coarseCurrent() {
chargeCurrent = math.Trunc(chargeCurrent)
}
return chargeCurrent
@ -1273,12 +1290,44 @@ 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, batteryBuffered bool) float64 {
boost := lp.getBatteryBoost()
if boost == boostDisabled || !batteryBuffered {
return 0
}
// push demand to drain battery
delta := lp.effectiveStepPower()
// start boosting by setting maximum power
if boost == boostStart {
delta = lp.EffectiveMaxPower()
// expire timers
lp.phaseTimer = elapsed
lp.pvTimer = elapsed
if lp.charging() {
lp.setBatteryBoost(boostContinue)
}
}
res := batteryBoostPower + delta
lp.log.DEBUG.Printf("pv charge battery boost: %.0fW = -%.0fW battery - %.0fW boost", -res, batteryBoostPower, delta)
return res
}
// pvMaxCurrent calculates the maximum target current for PV mode
func (lp *Loadpoint) pvMaxCurrent(mode api.ChargeMode, sitePower float64, batteryBuffered, batteryStart bool) float64 {
func (lp *Loadpoint) pvMaxCurrent(mode api.ChargeMode, sitePower, batteryBoostPower 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, batteryBuffered)
// switch phases up/down
var scaledTo int
if lp.hasPhaseSwitching() && lp.phaseSwitchCompleted() {
@ -1656,7 +1705,7 @@ func (lp *Loadpoint) phaseSwitchCompleted() bool {
}
// Update is the main control function. It reevaluates meters and charger state
func (lp *Loadpoint) Update(sitePower float64, rates api.Rates, batteryBuffered, batteryStart bool, greenShare float64, effPrice, effCo2 *float64) {
func (lp *Loadpoint) Update(sitePower, batteryBoostPower float64, rates api.Rates, batteryBuffered, batteryStart bool, greenShare float64, effPrice, effCo2 *float64) {
// smart cost
smartCostActive := lp.smartCostActive(rates)
lp.publish(keys.SmartCostActive, smartCostActive)
@ -1781,7 +1830,7 @@ func (lp *Loadpoint) Update(sitePower float64, rates api.Rates, batteryBuffered,
break
}
targetCurrent := lp.pvMaxCurrent(mode, sitePower, batteryBuffered, batteryStart)
targetCurrent := lp.pvMaxCurrent(mode, sitePower, batteryBoostPower, batteryBuffered, batteryStart)
if targetCurrent == 0 && lp.vehicleClimateActive() {
targetCurrent = lp.effectiveMinCurrent()

View file

@ -111,6 +111,11 @@ type API interface {
// SetDisableDelay sets loadpoint disable delay
SetDisableDelay(delay time.Duration)
// GetBatteryBoost returns the battery boost
GetBatteryBoost() bool
// SetBatteryBoost sets the battery boost
SetBatteryBoost(enable bool) error
// RemoteControl sets remote status demand
RemoteControl(string, RemoteDemand)

View file

@ -110,6 +110,20 @@ func (mr *MockAPIMockRecorder) EffectivePriority() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EffectivePriority", reflect.TypeOf((*MockAPI)(nil).EffectivePriority))
}
// GetBatteryBoost mocks base method.
func (m *MockAPI) GetBatteryBoost() bool {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetBatteryBoost")
ret0, _ := ret[0].(bool)
return ret0
}
// GetBatteryBoost indicates an expected call of GetBatteryBoost.
func (mr *MockAPIMockRecorder) GetBatteryBoost() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBatteryBoost", reflect.TypeOf((*MockAPI)(nil).GetBatteryBoost))
}
// GetChargePower mocks base method.
func (m *MockAPI) GetChargePower() float64 {
m.ctrl.T.Helper()
@ -501,6 +515,20 @@ func (mr *MockAPIMockRecorder) RemoteControl(arg0, arg1 any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoteControl", reflect.TypeOf((*MockAPI)(nil).RemoteControl), arg0, arg1)
}
// SetBatteryBoost mocks base method.
func (m *MockAPI) SetBatteryBoost(arg0 bool) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "SetBatteryBoost", arg0)
ret0, _ := ret[0].(error)
return ret0
}
// SetBatteryBoost indicates an expected call of SetBatteryBoost.
func (mr *MockAPIMockRecorder) SetBatteryBoost(arg0 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetBatteryBoost", reflect.TypeOf((*MockAPI)(nil).SetBatteryBoost), arg0)
}
// SetDisableDelay mocks base method.
func (m *MockAPI) SetDisableDelay(arg0 time.Duration) {
m.ctrl.T.Helper()

View file

@ -57,6 +57,8 @@ func (lp *Loadpoint) SetMode(mode api.ChargeMode) {
if lp.mode != mode {
lp.setMode(mode)
lp.batteryBoost = 0
// reset timers
switch mode {
case api.ModeNow, api.ModeOff:
@ -316,6 +318,49 @@ func (lp *Loadpoint) SetDisableDelay(delay time.Duration) {
}
}
// getBatteryBoost returns the battery boost
func (lp *Loadpoint) getBatteryBoost() int {
lp.RLock()
defer lp.RUnlock()
return lp.batteryBoost
}
// GetBatteryBoost returns the battery boost
func (lp *Loadpoint) GetBatteryBoost() bool {
return lp.getBatteryBoost() > 0
}
// setBatteryBoost returns the battery boost
func (lp *Loadpoint) setBatteryBoost(boost int) {
lp.Lock()
defer lp.Unlock()
lp.batteryBoost = boost
}
// SetBatteryBoost sets the battery boost
func (lp *Loadpoint) SetBatteryBoost(enable bool) error {
lp.Lock()
defer lp.Unlock()
if enable && lp.mode != api.ModePV && lp.mode != api.ModeMinPV {
return errors.New("battery boost is only available in PV modes")
}
lp.log.DEBUG.Println("set battery boost:", enable)
if enable != (lp.batteryBoost != boostDisabled) {
lp.publish(keys.BatteryBoost, enable)
lp.batteryBoost = boostDisabled
if enable {
lp.batteryBoost = boostStart
lp.requestUpdate()
}
}
return nil
}
// RemoteControl sets remote status demand
func (lp *Loadpoint) RemoteControl(source string, demand loadpoint.RemoteDemand) {
lp.Lock()

View file

@ -124,6 +124,11 @@ func (lp *Loadpoint) effectiveLimitSoc() int {
return 100
}
// effectiveStepPower returns the effective step power for the currently active phases
func (lp *Loadpoint) effectiveStepPower() float64 {
return Voltage * float64(lp.ActivePhases())
}
// EffectiveMinPower returns the effective min power for the minimum active phases
func (lp *Loadpoint) EffectiveMinPower() float64 {
return Voltage * lp.effectiveMinCurrent() * float64(lp.minActivePhases())

View file

@ -182,7 +182,7 @@ func TestUpdatePowerZero(t *testing.T) {
}
lp.mode = tc.mode
lp.Update(0, nil, false, false, 0, nil, nil) // false,sitePower false,0
lp.Update(0, 0, nil, false, false, 0, nil, nil) // false,sitePower false,0
ctrl.Finish()
}
@ -338,7 +338,7 @@ func TestPVHysteresis(t *testing.T) {
// charger.EXPECT().Enabled().Return(tc.enabled, nil)
lp.enabled = tc.enabled
current := lp.pvMaxCurrent(api.ModePV, se.site, false, false)
current := lp.pvMaxCurrent(api.ModePV, se.site, 0, false, false)
if current != se.current {
t.Errorf("step %d: wanted %.1f, got %.1f", step, se.current, current)
@ -371,7 +371,7 @@ func TestPVHysteresisForStatusOtherThanC(t *testing.T) {
// maxCurrent will read enabled state in PV mode
sitePower := -float64(phases)*minA*Voltage + 1 // 1W below min power
current := lp.pvMaxCurrent(api.ModePV, sitePower, false, false)
current := lp.pvMaxCurrent(api.ModePV, sitePower, 0, false, false)
if current != 0 {
t.Errorf("PV mode could not disable charger as expected. Expected 0, got %.f", current)
@ -428,7 +428,7 @@ func TestDisableAndEnableAtTargetSoc(t *testing.T) {
charger.EXPECT().Status().Return(api.StatusC, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().MaxCurrent(int64(maxA)).Return(nil)
lp.Update(500, nil, false, false, 0, nil, nil)
lp.Update(500, 0, nil, false, false, 0, nil, nil)
ctrl.Finish()
t.Log("charging above target - soc deactivates charger")
@ -437,7 +437,7 @@ func TestDisableAndEnableAtTargetSoc(t *testing.T) {
charger.EXPECT().Status().Return(api.StatusC, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().Enable(false).Return(nil)
lp.Update(500, nil, false, false, 0, nil, nil)
lp.Update(500, 0, nil, false, false, 0, nil, nil)
ctrl.Finish()
t.Log("deactivated charger changes status to B")
@ -445,14 +445,14 @@ func TestDisableAndEnableAtTargetSoc(t *testing.T) {
vehicle.EXPECT().Soc().Return(95.0, nil)
charger.EXPECT().Status().Return(api.StatusB, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
lp.Update(-500, nil, false, false, 0, nil, nil)
lp.Update(-500, 0, nil, false, false, 0, nil, nil)
ctrl.Finish()
t.Log("soc has risen below target - soc update prevented by timer")
clock.Add(5 * time.Minute)
charger.EXPECT().Status().Return(api.StatusB, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
lp.Update(-500, nil, false, false, 0, nil, nil)
lp.Update(-500, 0, nil, false, false, 0, nil, nil)
ctrl.Finish()
t.Log("soc has fallen below target - soc update timer expired")
@ -462,7 +462,7 @@ func TestDisableAndEnableAtTargetSoc(t *testing.T) {
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().MaxCurrent(int64(maxA)).Return(nil)
charger.EXPECT().Enable(true).Return(nil)
lp.Update(-500, nil, false, false, 0, nil, nil)
lp.Update(-500, 0, nil, false, false, 0, nil, nil)
ctrl.Finish()
}
@ -497,14 +497,14 @@ func TestSetModeAndSocAtDisconnect(t *testing.T) {
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().Status().Return(api.StatusC, nil)
charger.EXPECT().MaxCurrent(int64(maxA)).Return(nil)
lp.Update(500, nil, false, false, 0, nil, nil)
lp.Update(500, 0, nil, false, false, 0, nil, nil)
t.Log("switch off when disconnected")
clock.Add(5 * time.Minute)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().Status().Return(api.StatusA, nil)
charger.EXPECT().Enable(false).Return(nil)
lp.Update(-300, nil, false, false, 0, nil, nil)
lp.Update(-300, 0, nil, false, false, 0, nil, nil)
if mode := lp.GetMode(); mode != api.ModeOff {
t.Error("unexpected mode", mode)
@ -567,14 +567,14 @@ func TestChargedEnergyAtDisconnect(t *testing.T) {
rater.EXPECT().ChargedEnergy().Return(0.0, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().Status().Return(api.StatusC, nil)
lp.Update(-1, nil, false, false, 0, nil, nil)
lp.Update(-1, 0, nil, false, false, 0, nil, nil)
t.Log("at 1:00h charging at 5 kWh")
clock.Add(time.Hour)
rater.EXPECT().ChargedEnergy().Return(5.0, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().Status().Return(api.StatusC, nil)
lp.Update(-1, nil, false, false, 0, nil, nil)
lp.Update(-1, 0, nil, false, false, 0, nil, nil)
expectCache("chargedEnergy", 5000.0)
t.Log("at 1:00h stop charging at 5 kWh")
@ -582,7 +582,7 @@ func TestChargedEnergyAtDisconnect(t *testing.T) {
rater.EXPECT().ChargedEnergy().Return(5.0, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().Status().Return(api.StatusB, nil)
lp.Update(-1, nil, false, false, 0, nil, nil)
lp.Update(-1, 0, nil, false, false, 0, nil, nil)
expectCache("chargedEnergy", 5000.0)
t.Log("at 1:00h restart charging at 5 kWh")
@ -590,7 +590,7 @@ func TestChargedEnergyAtDisconnect(t *testing.T) {
rater.EXPECT().ChargedEnergy().Return(5.0, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().Status().Return(api.StatusC, nil)
lp.Update(-1, nil, false, false, 0, nil, nil)
lp.Update(-1, 0, nil, false, false, 0, nil, nil)
expectCache("chargedEnergy", 5000.0)
t.Log("at 1:30h continue charging at 7.5 kWh")
@ -598,7 +598,7 @@ func TestChargedEnergyAtDisconnect(t *testing.T) {
rater.EXPECT().ChargedEnergy().Return(7.5, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().Status().Return(api.StatusC, nil)
lp.Update(-1, nil, false, false, 0, nil, nil)
lp.Update(-1, 0, nil, false, false, 0, nil, nil)
expectCache("chargedEnergy", 7500.0)
t.Log("at 2:00h stop charging at 10 kWh")
@ -606,7 +606,7 @@ func TestChargedEnergyAtDisconnect(t *testing.T) {
rater.EXPECT().ChargedEnergy().Return(10.0, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().Status().Return(api.StatusB, nil)
lp.Update(-1, nil, false, false, 0, nil, nil)
lp.Update(-1, 0, nil, false, false, 0, nil, nil)
expectCache("chargedEnergy", 10000.0)
ctrl.Finish()
@ -758,7 +758,7 @@ func TestPVHysteresisAfterPhaseSwitch(t *testing.T) {
for step, se := range tc.series {
clck.Set(start.Add(se.delay))
assert.Equal(t, se.current, lp.pvMaxCurrent(api.ModePV, se.site, false, false), step)
assert.Equal(t, se.current, lp.pvMaxCurrent(api.ModePV, se.site, 0, false, false), step)
}
ctrl.Finish()

View file

@ -270,7 +270,7 @@ func TestReconnectVehicle(t *testing.T) {
// vehicle not updated yet
vehicle.MockChargeState.EXPECT().Status().Return(api.StatusA, nil)
lp.Update(0, nil, false, false, 0, nil, nil)
lp.Update(0, 0, nil, false, false, 0, nil, nil)
ctrl.Finish()
// detection started
@ -284,7 +284,7 @@ func TestReconnectVehicle(t *testing.T) {
// vehicle not updated yet
vehicle.MockChargeState.EXPECT().Status().Return(api.StatusB, nil)
lp.Update(0, nil, false, false, 0, nil, nil)
lp.Update(0, 0, nil, false, false, 0, nil, nil)
ctrl.Finish()
// vehicle detected

View file

@ -38,7 +38,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(availablePower float64, rates api.Rates, batteryBuffered, batteryStart bool, greenShare float64, effectivePrice, effectiveCo2 *float64)
Update(sitePower, batteryBoostPower float64, rates api.Rates, batteryBuffered, batteryStart bool, greenShare float64, effectivePrice, effectiveCo2 *float64)
}
// meterMeasurement is used as slice element for publishing structured data
@ -942,7 +942,10 @@ func (site *Site) update(lp updater) {
greenShareHome := site.greenShare(0, homePower)
greenShareLoadpoints := site.greenShare(nonChargePower, nonChargePower+totalChargePower)
lp.Update(sitePower, rates, batteryBuffered, batteryStart, greenShareLoadpoints, site.effectivePrice(greenShareLoadpoints), site.effectiveCo2(greenShareLoadpoints))
lp.Update(
sitePower, max(0, site.batteryPower), rates, batteryBuffered, batteryStart,
greenShareLoadpoints, site.effectivePrice(greenShareLoadpoints), site.effectiveCo2(greenShareLoadpoints),
)
site.Health.Update()

View file

@ -104,7 +104,7 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, valueChan chan<- util.Param)
"health": {"GET", "/health", healthHandler(site)},
"buffersoc": {"POST", "/buffersoc/{value:[0-9.]+}", floatHandler(site.SetBufferSoc, site.GetBufferSoc)},
"bufferstartsoc": {"POST", "/bufferstartsoc/{value:[0-9.]+}", floatHandler(site.SetBufferStartSoc, site.GetBufferStartSoc)},
"batterydischargecontrol": {"POST", "/batterydischargecontrol/{value:[a-z]+}", boolHandler(site.SetBatteryDischargeControl, site.GetBatteryDischargeControl)},
"batterydischargecontrol": {"POST", "/batterydischargecontrol/{value:[01truefalse]+}", boolHandler(site.SetBatteryDischargeControl, site.GetBatteryDischargeControl)},
"batterygridcharge": {"POST", "/batterygridchargelimit/{value:-?[0-9.]+}", floatPtrHandler(pass(site.SetBatteryGridChargeLimit), site.GetBatteryGridChargeLimit)},
"batterygridchargedelete": {"DELETE", "/batterygridchargelimit", floatPtrHandler(pass(site.SetBatteryGridChargeLimit), site.GetBatteryGridChargeLimit)},
"maxgridsupply": {"POST", "/maxgridsupply/{value:[0-9.]+}", floatHandler(site.SetMaxGridSupplyWhileBatteryCharging, site.GetMaxGridSupplyWhileBatteryCharging)},
@ -117,7 +117,7 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, valueChan chan<- util.Param)
"updatesession": {"PUT", "/session/{id:[0-9]+}", updateSessionHandler},
"deletesession": {"DELETE", "/session/{id:[0-9]+}", deleteSessionHandler},
"telemetry": {"GET", "/settings/telemetry", boolGetHandler(telemetry.Enabled)},
"telemetry2": {"POST", "/settings/telemetry/{value:[a-z]+}", boolHandler(telemetry.Enable, telemetry.Enabled)},
"telemetry2": {"POST", "/settings/telemetry/{value:[01truefalse]+}", boolHandler(telemetry.Enable, telemetry.Enabled)},
}
for _, r := range routes {
@ -181,6 +181,7 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, valueChan chan<- util.Param)
"smartCost": {"POST", "/smartcostlimit/{value:-?[0-9.]+}", floatPtrHandler(pass(lp.SetSmartCostLimit), lp.GetSmartCostLimit)},
"smartCostDelete": {"DELETE", "/smartcostlimit", floatPtrHandler(pass(lp.SetSmartCostLimit), lp.GetSmartCostLimit)},
"priority": {"POST", "/priority/{value:[0-9]+}", intHandler(pass(lp.SetPriority), lp.GetPriority)},
"batteryBoost": {"POST", "/batteryboost/{value:[01truefalse]}", boolHandler(lp.SetBatteryBoost, lp.GetBatteryBoost)},
}
for _, r := range routes {

View file

@ -206,6 +206,7 @@ func (m *MQTT) listenLoadpointSetters(topic string, site site.API, lp loadpoint.
{"/enableDelay", durationSetter(pass(lp.SetEnableDelay))},
{"/disableDelay", durationSetter(pass(lp.SetDisableDelay))},
{"/smartCostLimit", floatPtrSetter(pass(lp.SetSmartCostLimit))},
{"/batteryBoost", boolSetter(lp.SetBatteryBoost)},
{"/planEnergy", func(payload string) error {
var plan struct {
Time time.Time `json:"time"`