From 5453820eb6db74abeabafa3227b4356a95e2b58a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20He=C3=9F?= Date: Thu, 9 Nov 2023 12:42:39 +0100 Subject: [PATCH] Add battery discharge control logic (#10553) --- api/api.go | 8 +++- api/batterymode.go | 12 +++++ api/batterymode_enumer.go | 86 +++++++++++++++++++++++++++++++++++ api/mock.go | 53 ++++++++++++++++++++- core/loadpoint/api.go | 2 + core/loadpoint/mock.go | 14 ++++++ core/loadpoint_api.go | 7 +++ core/site.go | 21 +++++++-- core/site_battery.go | 49 ++++++++++++++++++++ core/site_battery_test.go | 96 +++++++++++++++++++++++++++++++++++++++ 10 files changed, 341 insertions(+), 7 deletions(-) create mode 100644 api/batterymode.go create mode 100644 api/batterymode_enumer.go create mode 100644 core/site_battery.go create mode 100644 core/site_battery_test.go diff --git a/api/api.go b/api/api.go index 4d5029225..2ef445023 100644 --- a/api/api.go +++ b/api/api.go @@ -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 diff --git a/api/batterymode.go b/api/batterymode.go new file mode 100644 index 000000000..511fa5f0f --- /dev/null +++ b/api/batterymode.go @@ -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 +) diff --git a/api/batterymode_enumer.go b/api/batterymode_enumer.go new file mode 100644 index 000000000..d78db37ae --- /dev/null +++ b/api/batterymode_enumer.go @@ -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 +} diff --git a/api/mock.go b/api/mock.go index c46974f9d..78bdeed34 100644 --- a/api/mock.go +++ b/api/mock.go @@ -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) +} diff --git a/core/loadpoint/api.go b/core/loadpoint/api.go index 226f505bb..f7c698c80 100644 --- a/core/loadpoint/api.go +++ b/core/loadpoint/api.go @@ -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 diff --git a/core/loadpoint/mock.go b/core/loadpoint/mock.go index a2c2addb2..64a1dd8c9 100644 --- a/core/loadpoint/mock.go +++ b/core/loadpoint/mock.go @@ -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() diff --git a/core/loadpoint_api.go b/core/loadpoint_api.go index a5faaa13e..2c633d802 100644 --- a/core/loadpoint_api.go +++ b/core/loadpoint_api.go @@ -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() diff --git a/core/site.go b/core/site.go index 786075745..f69a0dcc3 100644 --- a/core/site.go +++ b/core/site.go @@ -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 diff --git a/core/site_battery.go b/core/site_battery.go new file mode 100644 index 000000000..ca46ccfe1 --- /dev/null +++ b/core/site_battery.go @@ -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) +} diff --git a/core/site_battery_test.go b/core/site_battery_test.go new file mode 100644 index 000000000..1c356fe6e --- /dev/null +++ b/core/site_battery_test.go @@ -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 +}