Add battery discharge control logic (#10553)

This commit is contained in:
Michael Heß 2023-11-09 12:42:39 +01:00 • committed by GitHub
parent 0242dad7e9
commit 5453820eb6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 341 additions and 7 deletions

View file

@ -9,7 +9,7 @@ import (
"time"
)
//go:generate mockgen -package api -destination mock.go github.com/evcc-io/evcc/api Charger,ChargeState,PhaseSwitcher,Identifier,Meter,MeterEnergy,Vehicle,ChargeRater,Battery,Tariff
//go:generate mockgen -package api -destination mock.go github.com/evcc-io/evcc/api Charger,ChargeState,PhaseSwitcher,Identifier,Meter,MeterEnergy,Vehicle,ChargeRater,Battery,Tariff,BatteryController
// ChargeMode is the charge operation mode. Valid values are off, now, minpv and pv
type ChargeMode string
@ -134,6 +134,12 @@ type CurrentGetter interface {
GetMaxCurrent() (float64, error)
}
// BatteryController optionally allows to control home battery (dis)charging behaviour
type BatteryController interface {
SetBatteryMode(BatteryMode) error
GetBatteryMode() BatteryMode
}
// Charger provides current charging status and enable/disable charging
type Charger interface {
ChargeState

12
api/batterymode.go Normal file
View file

@ -0,0 +1,12 @@
package api
// BatteryMode is the home battery operation mode. Valid values are normal, locked and charge
type BatteryMode int
//go:generate enumer -type BatteryMode
const (
BatteryUnknown BatteryMode = iota
BatteryNormal
BatteryLocked
BatteryCharge
)

86
api/batterymode_enumer.go Normal file
View file

@ -0,0 +1,86 @@
// Code generated by "enumer -type BatteryMode"; DO NOT EDIT.
package api
import (
"fmt"
"strings"
)
const _BatteryModeName = "BatteryUnknownBatteryNormalBatteryLockedBatteryCharge"
var _BatteryModeIndex = [...]uint8{0, 14, 27, 40, 53}
const _BatteryModeLowerName = "batteryunknownbatterynormalbatterylockedbatterycharge"
func (i BatteryMode) String() string {
if i < 0 || i >= BatteryMode(len(_BatteryModeIndex)-1) {
return fmt.Sprintf("BatteryMode(%d)", i)
}
return _BatteryModeName[_BatteryModeIndex[i]:_BatteryModeIndex[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 _BatteryModeNoOp() {
var x [1]struct{}
_ = x[BatteryUnknown-(0)]
_ = x[BatteryNormal-(1)]
_ = x[BatteryLocked-(2)]
_ = x[BatteryCharge-(3)]
}
var _BatteryModeValues = []BatteryMode{BatteryUnknown, BatteryNormal, BatteryLocked, BatteryCharge}
var _BatteryModeNameToValueMap = map[string]BatteryMode{
_BatteryModeName[0:14]: BatteryUnknown,
_BatteryModeLowerName[0:14]: BatteryUnknown,
_BatteryModeName[14:27]: BatteryNormal,
_BatteryModeLowerName[14:27]: BatteryNormal,
_BatteryModeName[27:40]: BatteryLocked,
_BatteryModeLowerName[27:40]: BatteryLocked,
_BatteryModeName[40:53]: BatteryCharge,
_BatteryModeLowerName[40:53]: BatteryCharge,
}
var _BatteryModeNames = []string{
_BatteryModeName[0:14],
_BatteryModeName[14:27],
_BatteryModeName[27:40],
_BatteryModeName[40:53],
}
// BatteryModeString retrieves an enum value from the enum constants string name.
// Throws an error if the param is not part of the enum.
func BatteryModeString(s string) (BatteryMode, error) {
if val, ok := _BatteryModeNameToValueMap[s]; ok {
return val, nil
}
if val, ok := _BatteryModeNameToValueMap[strings.ToLower(s)]; ok {
return val, nil
}
return 0, fmt.Errorf("%s does not belong to BatteryMode values", s)
}
// BatteryModeValues returns all values of the enum
func BatteryModeValues() []BatteryMode {
return _BatteryModeValues
}
// BatteryModeStrings returns a slice of all String values of the enum
func BatteryModeStrings() []string {
strs := make([]string, len(_BatteryModeNames))
copy(strs, _BatteryModeNames)
return strs
}
// IsABatteryMode returns "true" if the value is listed in the enum definition. "false" otherwise
func (i BatteryMode) IsABatteryMode() bool {
for _, v := range _BatteryModeValues {
if i == v {
return true
}
}
return false
}

View file

@ -1,5 +1,5 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/evcc-io/evcc/api (interfaces: Charger,ChargeState,PhaseSwitcher,Identifier,Meter,MeterEnergy,Vehicle,ChargeRater,Battery,Tariff)
// Source: github.com/evcc-io/evcc/api (interfaces: Charger,ChargeState,PhaseSwitcher,Identifier,Meter,MeterEnergy,Vehicle,ChargeRater,Battery,Tariff,BatteryController)
// Package api is a generated GoMock package.
package api
@ -541,3 +541,54 @@ func (mr *MockTariffMockRecorder) Type() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Type", reflect.TypeOf((*MockTariff)(nil).Type))
}
// MockBatteryController is a mock of BatteryController interface.
type MockBatteryController struct {
ctrl *gomock.Controller
recorder *MockBatteryControllerMockRecorder
}
// MockBatteryControllerMockRecorder is the mock recorder for MockBatteryController.
type MockBatteryControllerMockRecorder struct {
mock *MockBatteryController
}
// NewMockBatteryController creates a new mock instance.
func NewMockBatteryController(ctrl *gomock.Controller) *MockBatteryController {
mock := &MockBatteryController{ctrl: ctrl}
mock.recorder = &MockBatteryControllerMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockBatteryController) EXPECT() *MockBatteryControllerMockRecorder {
return m.recorder
}
// GetBatteryMode mocks base method.
func (m *MockBatteryController) GetBatteryMode() BatteryMode {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetBatteryMode")
ret0, _ := ret[0].(BatteryMode)
return ret0
}
// GetBatteryMode indicates an expected call of GetBatteryMode.
func (mr *MockBatteryControllerMockRecorder) GetBatteryMode() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBatteryMode", reflect.TypeOf((*MockBatteryController)(nil).GetBatteryMode))
}
// SetBatteryMode mocks base method.
func (m *MockBatteryController) SetBatteryMode(arg0 BatteryMode) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "SetBatteryMode", arg0)
ret0, _ := ret[0].(error)
return ret0
}
// SetBatteryMode indicates an expected call of SetBatteryMode.
func (mr *MockBatteryControllerMockRecorder) SetBatteryMode(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetBatteryMode", reflect.TypeOf((*MockBatteryController)(nil).SetBatteryMode), arg0)
}

View file

@ -100,6 +100,8 @@ type API interface {
// charge progress
//
// GetPlanActive returns the active state of the planner
GetPlanActive() bool
// GetRemainingDuration is the estimated remaining charging duration
GetRemainingDuration() time.Duration
// GetRemainingEnergy is the remaining charge energy in Wh

View file

@ -205,6 +205,20 @@ func (mr *MockAPIMockRecorder) GetPlan(arg0, arg1 interface{}) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlan", reflect.TypeOf((*MockAPI)(nil).GetPlan), arg0, arg1)
}
// GetPlanActive mocks base method.
func (m *MockAPI) GetPlanActive() bool {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetPlanActive")
ret0, _ := ret[0].(bool)
return ret0
}
// GetPlanActive indicates an expected call of GetPlanActive.
func (mr *MockAPIMockRecorder) GetPlanActive() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlanActive", reflect.TypeOf((*MockAPI)(nil).GetPlanActive))
}
// GetPriority mocks base method.
func (m *MockAPI) GetPriority() int {
m.ctrl.T.Helper()

View file

@ -377,6 +377,13 @@ func (lp *Loadpoint) GetMaxPower() float64 {
return Voltage * lp.GetMaxCurrent() * float64(lp.maxActivePhases())
}
// GetPlanActive returns the active state of the planner
func (lp *Loadpoint) GetPlanActive() bool {
lp.Lock()
defer lp.Unlock()
return lp.planActive
}
// SetRemainingDuration sets the estimated remaining charging duration
func (lp *Loadpoint) SetRemainingDuration(chargeRemainingDuration time.Duration) {
lp.Lock()

View file

@ -67,6 +67,7 @@ type Site struct {
BufferStartSoc float64 `mapstructure:"bufferStartSoc"` // start charging on battery above this Soc
MaxGridSupplyWhileBatteryCharging float64 `mapstructure:"maxGridSupplyWhileBatteryCharging"` // ignore battery charging if AC consumption is above this value
SmartCostLimit float64 `mapstructure:"smartCostLimit"` // always charge if cost is below this value
BatteryDischargeControl bool `mapstructure:"batteryDischargeControl"` // shall discharge of home battery be adjusted
// meters
gridMeter api.Meter // Grid usage meter
@ -81,10 +82,11 @@ type Site struct {
stats *Stats // Stats
// cached state
gridPower float64 // Grid power
pvPower float64 // PV power
batteryPower float64 // Battery charge power
batterySoc float64 // Battery soc
gridPower float64 // Grid power
pvPower float64 // PV power
batteryPower float64 // Battery charge power
batterySoc float64 // Battery soc
batteryMode api.BatteryMode // Battery discharge currently enabled
publishCache map[string]any // store last published values to avoid unnecessary republishing
}
@ -224,6 +226,7 @@ func NewSite() *Site {
log: util.NewLogger("site"),
publishCache: make(map[string]any),
Voltage: 230, // V
batteryMode: api.BatteryNormal,
}
return lp
@ -251,6 +254,9 @@ func (site *Site) restoreSettings() {
if v, err := settings.Float("site.smartCostLimit"); err == nil {
site.SmartCostLimit = v
}
if v, err := settings.Bool("site.batteryDischargeControl"); err == nil {
site.BatteryDischargeControl = v
}
}
func meterCapabilities(name string, meter interface{}) string {
@ -769,7 +775,6 @@ func (site *Site) update(lp Updater) {
}
if sitePower, batteryBuffered, batteryStart, err := site.sitePower(totalChargePower, flexiblePower); err == nil {
// ignore negative pvPower values as that means it is not an energy source but consumption
homePower := site.gridPower + max(0, site.pvPower) + site.batteryPower - totalChargePower
homePower = max(homePower, 0)
@ -791,6 +796,10 @@ func (site *Site) update(lp Updater) {
site.log.ERROR.Println(err)
}
if site.BatteryDischargeControl {
site.updateBatteryMode(site.Loadpoints())
}
site.stats.Update(site)
}
@ -814,6 +823,8 @@ func (site *Site) prepare() {
site.publish("currency", site.tariffs.Currency.String())
site.publish("vehicles", vehicleTitles(site.GetVehicles()))
site.publish("batteryDischargeControl", site.BatteryDischargeControl)
site.publish("batteryMode", site.batteryMode)
}
// Prepare attaches communication channels to site and loadpoints

49
core/site_battery.go Normal file
View file

@ -0,0 +1,49 @@
package core
import (
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/loadpoint"
)
// getBatteryMode returns the battery mode
func (site *Site) getBatteryMode() api.BatteryMode {
site.Lock()
defer site.Unlock()
return site.batteryMode
}
// setBatteryMode sets the battery mode
func (site *Site) setBatteryMode(batMode api.BatteryMode) {
site.Lock()
defer site.Unlock()
site.batteryMode = batMode
}
func (site *Site) updateBatteryMode(loadpoints []loadpoint.API) {
// determine expected state
batMode := api.BatteryNormal
for _, lp := range loadpoints {
if lp.GetStatus() == api.StatusC && (lp.GetMode() == api.ModeNow || lp.GetPlanActive()) {
batMode = api.BatteryLocked
break
}
}
if batMode == site.getBatteryMode() {
return
}
// update batteries
for _, meter := range site.batteryMeters {
if batCtrl, ok := meter.(api.BatteryController); ok {
if err := batCtrl.SetBatteryMode(batMode); err != nil {
site.log.ERROR.Println("battery mode:", err)
return
}
}
}
// update state and publish
site.setBatteryMode(batMode)
site.publish("batteryMode", batMode)
}

96
core/site_battery_test.go Normal file
View file

@ -0,0 +1,96 @@
package core
import (
"testing"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/loadpoint"
"github.com/evcc-io/evcc/util"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
)
func TestBatteryDischarge(t *testing.T) {
ctrl := gomock.NewController(t)
tcs := []struct {
chargeStatus api.ChargeStatus
planActive bool
expBatMode api.BatteryMode
mode api.ChargeMode
}{
{api.StatusB, false, api.BatteryNormal, api.ModeOff}, // mode off -> bat enabled
{api.StatusB, false, api.BatteryNormal, api.ModeNow}, // mode now, not charging -> bat enabled
{api.StatusC, false, api.BatteryLocked, api.ModeNow}, // mode now, charging -> bat disabled
{api.StatusB, false, api.BatteryNormal, api.ModeMinPV}, // mode minPV, not charging -> bat enabled
{api.StatusC, false, api.BatteryNormal, api.ModeMinPV}, // mode minPV, charging -> bat enabled
{api.StatusB, false, api.BatteryNormal, api.ModePV}, // mode PV, not charging -> bat enabled
{api.StatusC, false, api.BatteryNormal, api.ModePV}, // mode PV, charging, no planner -> bat enabled
{api.StatusC, true, api.BatteryLocked, api.ModePV}, // mode PV, charging, planner active -> bat disabled
}
log := util.NewLogger("foo")
for _, tc := range tcs {
batCtrl := struct {
*api.MockBatteryController
*api.MockMeter
}{
api.NewMockBatteryController(ctrl),
api.NewMockMeter(ctrl),
}
batCtrl.MockBatteryController.EXPECT().SetBatteryMode(tc.expBatMode).Times(1)
s := &Site{
log: log,
BatteryDischargeControl: true,
batteryMeters: []api.Meter{batCtrl},
}
lp := loadpoint.NewMockAPI(ctrl)
lp.EXPECT().GetStatus().Return(tc.chargeStatus).AnyTimes()
lp.EXPECT().GetMode().Return(tc.mode).AnyTimes()
lp.EXPECT().GetPlanActive().Return(tc.planActive).AnyTimes()
loadpoints := []loadpoint.API{lp}
s.updateBatteryMode(loadpoints)
assert.Equal(t, tc.expBatMode, s.getBatteryMode(), tc)
}
}
// test that BatteryControllers are only called if batterymode changes
func TestBatteryModeNoUpdate(t *testing.T) {
ctrl := gomock.NewController(t)
batCtrl := struct {
*api.MockBatteryController
*api.MockMeter
}{
api.NewMockBatteryController(ctrl),
api.NewMockMeter(ctrl),
}
batCtrl.MockBatteryController.EXPECT().SetBatteryMode(api.BatteryLocked).Times(1)
lp := loadpoint.NewMockAPI(ctrl)
lp.EXPECT().GetStatus().Return(api.StatusC).Times(2)
lp.EXPECT().GetMode().Return(api.ModeNow).Times(2)
lp.EXPECT().GetPlanActive().Times(0)
loadpoints := []loadpoint.API{lp}
s := &Site{
batteryMode: api.BatteryNormal,
batteryMeters: []api.Meter{batCtrl},
BatteryDischargeControl: true,
log: util.NewLogger("foo"),
}
s.updateBatteryMode(loadpoints) // first call should call BatteryController
s.updateBatteryMode(loadpoints) // this one should not
// adjust mocks to simulate charge stop, should cause batMode udpate
lp.EXPECT().GetStatus().Return(api.StatusB).Times(1)
lp.EXPECT().GetMode().Return(api.ModeNow).Times(0)
batCtrl.MockBatteryController.EXPECT().SetBatteryMode(api.BatteryNormal).Times(1)
s.updateBatteryMode(loadpoints) // this one should have updated again
}