Battery control: limit grid charging by max soc (#23855)
This commit is contained in:
parent
4b291ed86c
commit
55fc2e40c5
5 changed files with 219 additions and 73 deletions
12
api/api.go
12
api/api.go
|
|
@ -7,7 +7,7 @@ import (
|
|||
"time"
|
||||
)
|
||||
|
||||
//go:generate go tool mockgen -package api -destination mock.go github.com/evcc-io/evcc/api Charger,ChargeState,CurrentLimiter,CurrentGetter,PhaseSwitcher,PhaseGetter,FeatureDescriber,Identifier,Meter,MeterEnergy,PhaseCurrents,Vehicle,ChargeRater,Battery,Tariff,BatteryController,Circuit
|
||||
//go:generate go tool mockgen -package api -destination mock.go github.com/evcc-io/evcc/api Charger,ChargeState,CurrentLimiter,CurrentGetter,PhaseSwitcher,PhaseGetter,FeatureDescriber,Identifier,Meter,MeterEnergy,PhaseCurrents,Vehicle,ChargeRater,Battery,BatteryController,BatterySocLimiter,Circuit,Tariff
|
||||
|
||||
// Meter provides total active power in W
|
||||
type Meter interface {
|
||||
|
|
@ -44,11 +44,6 @@ type BatteryCapacity interface {
|
|||
Capacity() float64
|
||||
}
|
||||
|
||||
// MaxACPowerGetter provides max AC power in W
|
||||
type MaxACPowerGetter interface {
|
||||
MaxACPower() float64
|
||||
}
|
||||
|
||||
// BatteryPowerLimiter provides max AC charge- and discharge power in W
|
||||
type BatteryPowerLimiter interface {
|
||||
GetPowerLimits() (charge, discharge float64)
|
||||
|
|
@ -59,6 +54,11 @@ type BatterySocLimiter interface {
|
|||
GetSocLimits() (min, max float64)
|
||||
}
|
||||
|
||||
// MaxACPowerGetter provides max AC power in W
|
||||
type MaxACPowerGetter interface {
|
||||
MaxACPower() float64
|
||||
}
|
||||
|
||||
// ChargeState provides current charging status
|
||||
type ChargeState interface {
|
||||
Status() (ChargeStatus, error)
|
||||
|
|
|
|||
149
api/mock.go
149
api/mock.go
|
|
@ -1,9 +1,9 @@
|
|||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: github.com/evcc-io/evcc/api (interfaces: Charger,ChargeState,CurrentLimiter,CurrentGetter,PhaseSwitcher,PhaseGetter,FeatureDescriber,Identifier,Meter,MeterEnergy,PhaseCurrents,Vehicle,ChargeRater,Battery,Tariff,BatteryController,Circuit)
|
||||
// Source: github.com/evcc-io/evcc/api (interfaces: Charger,ChargeState,CurrentLimiter,CurrentGetter,PhaseSwitcher,PhaseGetter,FeatureDescriber,Identifier,Meter,MeterEnergy,PhaseCurrents,Vehicle,ChargeRater,Battery,BatteryController,BatterySocLimiter,Circuit,Tariff)
|
||||
//
|
||||
// Generated by this command:
|
||||
//
|
||||
// mockgen -package api -destination mock.go github.com/evcc-io/evcc/api Charger,ChargeState,CurrentLimiter,CurrentGetter,PhaseSwitcher,PhaseGetter,FeatureDescriber,Identifier,Meter,MeterEnergy,PhaseCurrents,Vehicle,ChargeRater,Battery,Tariff,BatteryController,Circuit
|
||||
// mockgen -package api -destination mock.go github.com/evcc-io/evcc/api Charger,ChargeState,CurrentLimiter,CurrentGetter,PhaseSwitcher,PhaseGetter,FeatureDescriber,Identifier,Meter,MeterEnergy,PhaseCurrents,Vehicle,ChargeRater,Battery,BatteryController,BatterySocLimiter,Circuit,Tariff
|
||||
//
|
||||
|
||||
// Package api is a generated GoMock package.
|
||||
|
|
@ -715,59 +715,6 @@ func (mr *MockBatteryMockRecorder) Soc() *gomock.Call {
|
|||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Soc", reflect.TypeOf((*MockBattery)(nil).Soc))
|
||||
}
|
||||
|
||||
// MockTariff is a mock of Tariff interface.
|
||||
type MockTariff struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockTariffMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockTariffMockRecorder is the mock recorder for MockTariff.
|
||||
type MockTariffMockRecorder struct {
|
||||
mock *MockTariff
|
||||
}
|
||||
|
||||
// NewMockTariff creates a new mock instance.
|
||||
func NewMockTariff(ctrl *gomock.Controller) *MockTariff {
|
||||
mock := &MockTariff{ctrl: ctrl}
|
||||
mock.recorder = &MockTariffMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockTariff) EXPECT() *MockTariffMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// Rates mocks base method.
|
||||
func (m *MockTariff) Rates() (Rates, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Rates")
|
||||
ret0, _ := ret[0].(Rates)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Rates indicates an expected call of Rates.
|
||||
func (mr *MockTariffMockRecorder) Rates() *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Rates", reflect.TypeOf((*MockTariff)(nil).Rates))
|
||||
}
|
||||
|
||||
// Type mocks base method.
|
||||
func (m *MockTariff) Type() TariffType {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Type")
|
||||
ret0, _ := ret[0].(TariffType)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// Type indicates an expected call of Type.
|
||||
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
|
||||
|
|
@ -806,6 +753,45 @@ func (mr *MockBatteryControllerMockRecorder) SetBatteryMode(arg0 any) *gomock.Ca
|
|||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetBatteryMode", reflect.TypeOf((*MockBatteryController)(nil).SetBatteryMode), arg0)
|
||||
}
|
||||
|
||||
// MockBatterySocLimiter is a mock of BatterySocLimiter interface.
|
||||
type MockBatterySocLimiter struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockBatterySocLimiterMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockBatterySocLimiterMockRecorder is the mock recorder for MockBatterySocLimiter.
|
||||
type MockBatterySocLimiterMockRecorder struct {
|
||||
mock *MockBatterySocLimiter
|
||||
}
|
||||
|
||||
// NewMockBatterySocLimiter creates a new mock instance.
|
||||
func NewMockBatterySocLimiter(ctrl *gomock.Controller) *MockBatterySocLimiter {
|
||||
mock := &MockBatterySocLimiter{ctrl: ctrl}
|
||||
mock.recorder = &MockBatterySocLimiterMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockBatterySocLimiter) EXPECT() *MockBatterySocLimiterMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// GetSocLimits mocks base method.
|
||||
func (m *MockBatterySocLimiter) GetSocLimits() (float64, float64) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetSocLimits")
|
||||
ret0, _ := ret[0].(float64)
|
||||
ret1, _ := ret[1].(float64)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetSocLimits indicates an expected call of GetSocLimits.
|
||||
func (mr *MockBatterySocLimiterMockRecorder) GetSocLimits() *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSocLimits", reflect.TypeOf((*MockBatterySocLimiter)(nil).GetSocLimits))
|
||||
}
|
||||
|
||||
// MockCircuit is a mock of Circuit interface.
|
||||
type MockCircuit struct {
|
||||
ctrl *gomock.Controller
|
||||
|
|
@ -1031,3 +1017,56 @@ func (mr *MockCircuitMockRecorder) Wrap(parent any) *gomock.Call {
|
|||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Wrap", reflect.TypeOf((*MockCircuit)(nil).Wrap), parent)
|
||||
}
|
||||
|
||||
// MockTariff is a mock of Tariff interface.
|
||||
type MockTariff struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockTariffMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockTariffMockRecorder is the mock recorder for MockTariff.
|
||||
type MockTariffMockRecorder struct {
|
||||
mock *MockTariff
|
||||
}
|
||||
|
||||
// NewMockTariff creates a new mock instance.
|
||||
func NewMockTariff(ctrl *gomock.Controller) *MockTariff {
|
||||
mock := &MockTariff{ctrl: ctrl}
|
||||
mock.recorder = &MockTariffMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockTariff) EXPECT() *MockTariffMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// Rates mocks base method.
|
||||
func (m *MockTariff) Rates() (Rates, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Rates")
|
||||
ret0, _ := ret[0].(Rates)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Rates indicates an expected call of Rates.
|
||||
func (mr *MockTariffMockRecorder) Rates() *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Rates", reflect.TypeOf((*MockTariff)(nil).Rates))
|
||||
}
|
||||
|
||||
// Type mocks base method.
|
||||
func (m *MockTariff) Type() TariffType {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Type")
|
||||
ret0, _ := ret[0].(TariffType)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// Type indicates an expected call of Type.
|
||||
func (mr *MockTariffMockRecorder) Type() *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Type", reflect.TypeOf((*MockTariff)(nil).Type))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,9 +40,14 @@ func (site *Site) SetBatteryMode(batMode api.BatteryMode) {
|
|||
}
|
||||
|
||||
func (site *Site) updateBatteryMode(batteryGridChargeActive bool, rate api.Rate) {
|
||||
if batteryMode := site.requiredBatteryMode(batteryGridChargeActive, rate); batteryMode != api.BatteryUnknown {
|
||||
batteryMode := site.requiredBatteryMode(batteryGridChargeActive, rate)
|
||||
|
||||
// NOTE: applyBatteryMode is always called when charge mode is active to validate max soc
|
||||
if modeChanged := batteryMode != api.BatteryUnknown; modeChanged || site.batteryMode == api.BatteryCharge {
|
||||
if err := site.applyBatteryMode(batteryMode); err == nil {
|
||||
site.SetBatteryMode(batteryMode)
|
||||
if modeChanged {
|
||||
site.SetBatteryMode(batteryMode)
|
||||
}
|
||||
} else {
|
||||
site.log.ERROR.Println("battery mode:", err)
|
||||
}
|
||||
|
|
@ -62,7 +67,7 @@ func (site *Site) requiredBatteryMode(batteryGridChargeActive bool, rate api.Rat
|
|||
site.Unlock()
|
||||
}
|
||||
|
||||
mapper := func(s api.BatteryMode) api.BatteryMode {
|
||||
keepUnlessModified := func(s api.BatteryMode) api.BatteryMode {
|
||||
return map[bool]api.BatteryMode{false: s, true: api.BatteryUnknown}[batMode == s]
|
||||
}
|
||||
|
||||
|
|
@ -78,9 +83,9 @@ func (site *Site) requiredBatteryMode(batteryGridChargeActive bool, rate api.Rat
|
|||
res = extMode
|
||||
}
|
||||
case batteryGridChargeActive:
|
||||
res = mapper(api.BatteryCharge)
|
||||
res = keepUnlessModified(api.BatteryCharge)
|
||||
case site.dischargeControlActive(rate):
|
||||
res = mapper(api.BatteryHold)
|
||||
res = keepUnlessModified(api.BatteryHold)
|
||||
case batteryModeModified(batMode):
|
||||
res = api.BatteryNormal
|
||||
}
|
||||
|
|
@ -88,12 +93,59 @@ func (site *Site) requiredBatteryMode(batteryGridChargeActive bool, rate api.Rat
|
|||
return res
|
||||
}
|
||||
|
||||
// batteryMaxSocReached checks is battery has exceed max soc limit
|
||||
func batteryMaxSocReached(meter api.Meter) (bool, error) {
|
||||
batLimiter, ok := meter.(api.BatterySocLimiter)
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
batSoc, ok := meter.(api.Battery)
|
||||
if !ok {
|
||||
return false, errors.New("battery with soc limits must have soc")
|
||||
}
|
||||
|
||||
soc, err := batSoc.Soc()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
_, max := batLimiter.GetSocLimits()
|
||||
return soc >= max, nil
|
||||
}
|
||||
|
||||
// applyBatteryMode applies the mode to each battery
|
||||
//
|
||||
// api.BatteryCharge:
|
||||
//
|
||||
// The current soc is validated against max soc.
|
||||
// In case max soc is reached, hold mode is applied.
|
||||
func (site *Site) applyBatteryMode(mode api.BatteryMode) error {
|
||||
for _, dev := range site.batteryMeters {
|
||||
meter := dev.Instance()
|
||||
|
||||
if batCtrl, ok := meter.(api.BatteryController); ok {
|
||||
batCtrl, ok := meter.(api.BatteryController)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
isCharge := mode == api.BatteryCharge || mode == api.BatteryUnknown && site.batteryMode == api.BatteryCharge
|
||||
|
||||
// validate max soc
|
||||
if isCharge {
|
||||
ok, err := batteryMaxSocReached(meter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// put battery into hold mode when soc limit reached
|
||||
if ok {
|
||||
// TODO do this once only
|
||||
mode = api.BatteryHold
|
||||
}
|
||||
}
|
||||
|
||||
if mode != api.BatteryUnknown {
|
||||
if err := batCtrl.SetBatteryMode(mode); err != nil && !errors.Is(err, api.ErrNotAvailable) {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -163,3 +163,62 @@ func TestExternalBatteryModeChange(t *testing.T) {
|
|||
ctrl.Finish()
|
||||
}
|
||||
}
|
||||
|
||||
func TestForcedBatteryChargeLimits(t *testing.T) {
|
||||
limit := 80.0
|
||||
|
||||
for _, tc := range []struct {
|
||||
internal, expected api.BatteryMode
|
||||
soc float64
|
||||
}{
|
||||
{api.BatteryUnknown, api.BatteryCharge, 50},
|
||||
{api.BatteryUnknown, api.BatteryHold, 90},
|
||||
|
||||
{api.BatteryNormal, api.BatteryCharge, 50},
|
||||
{api.BatteryNormal, api.BatteryHold, 90},
|
||||
|
||||
{api.BatteryHold, api.BatteryCharge, 50},
|
||||
{api.BatteryHold, api.BatteryHold, 90}, // TODO make this api.BatteryUnknown
|
||||
|
||||
{api.BatteryCharge, api.BatteryUnknown, 50},
|
||||
{api.BatteryCharge, api.BatteryHold, 90},
|
||||
} {
|
||||
t.Logf("%+v", tc)
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
|
||||
var bat api.Meter
|
||||
batSoc := api.NewMockBattery(ctrl)
|
||||
batCon := api.NewMockBatteryController(ctrl)
|
||||
batSocLimit := api.NewMockBatterySocLimiter(ctrl)
|
||||
|
||||
bat = &struct {
|
||||
api.Meter
|
||||
api.Battery
|
||||
api.BatteryController
|
||||
api.BatterySocLimiter
|
||||
}{
|
||||
Meter: bat,
|
||||
Battery: batSoc,
|
||||
BatteryController: batCon,
|
||||
BatterySocLimiter: batSocLimit,
|
||||
}
|
||||
|
||||
site := &Site{
|
||||
log: util.NewLogger("foo"),
|
||||
batteryMeters: []config.Device[api.Meter]{config.NewStaticDevice(config.Named{}, bat)},
|
||||
batteryMode: tc.internal,
|
||||
}
|
||||
|
||||
batSoc.EXPECT().Soc().Return(tc.soc, nil).Times(1)
|
||||
batSocLimit.EXPECT().GetSocLimits().Return(0.0, limit).Times(1)
|
||||
|
||||
if tc.expected != api.BatteryUnknown {
|
||||
batCon.EXPECT().SetBatteryMode(tc.expected).Times(1)
|
||||
}
|
||||
|
||||
site.updateBatteryMode(true, api.Rate{})
|
||||
|
||||
ctrl.Finish()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,15 +18,9 @@ params:
|
|||
en: Venus-E 5.12 kWh, Venus-C 2.56 kWh
|
||||
- name: minsoc
|
||||
default: 11
|
||||
type: int
|
||||
advanced: true
|
||||
help:
|
||||
en: Venus can only be discharged down to 11%.
|
||||
de: Venus kann nur bis 11% entladen werden.
|
||||
example: 11
|
||||
- name: maxsoc
|
||||
default: 100
|
||||
type: int
|
||||
advanced: true
|
||||
- name: maxchargepower
|
||||
default: 2500
|
||||
|
|
@ -159,3 +153,5 @@ render: |
|
|||
decode: uint16
|
||||
# Do not disable RS485 Control Mode because it will reset the device and let it charge/discharge again
|
||||
capacity: {{ .capacity }} # kWh
|
||||
minsoc: {{ .minsoc }} # %
|
||||
maxsoc: {{ .maxsoc }} # %
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue