Add battery discharge control logic (#10553)
This commit is contained in:
parent
0242dad7e9
commit
5453820eb6
10 changed files with 341 additions and 7 deletions
|
|
@ -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
12
api/batterymode.go
Normal 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
86
api/batterymode_enumer.go
Normal 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
|
||||
}
|
||||
53
api/mock.go
53
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
21
core/site.go
21
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
|
||||
|
|
|
|||
49
core/site_battery.go
Normal file
49
core/site_battery.go
Normal 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
96
core/site_battery_test.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue