Battery control: limit grid charging by max soc (#23855)

This commit is contained in:
andig 2025-09-28 16:29:30 +02:00 • committed by GitHub
parent 4b291ed86c
commit 55fc2e40c5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 219 additions and 73 deletions

View file

@ -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)

View file

@ -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))
}

View file

@ -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
}

View file

@ -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()
}
}

View file

@ -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 }} # %