diff --git a/api/api.go b/api/api.go index 6c938f419..f09290e1d 100644 --- a/api/api.go +++ b/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) diff --git a/api/mock.go b/api/mock.go index 7b4a28a46..3e93da220 100644 --- a/api/mock.go +++ b/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)) +} diff --git a/core/site_battery.go b/core/site_battery.go index 1914822d7..947580c47 100644 --- a/core/site_battery.go +++ b/core/site_battery.go @@ -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 } diff --git a/core/site_battery_test.go b/core/site_battery_test.go index 9e22d76d4..30860b7f9 100644 --- a/core/site_battery_test.go +++ b/core/site_battery_test.go @@ -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() + } +} diff --git a/templates/definition/meter/marstek-venus.yaml b/templates/definition/meter/marstek-venus.yaml index e129fc117..7a94230e3 100644 --- a/templates/definition/meter/marstek-venus.yaml +++ b/templates/definition/meter/marstek-venus.yaml @@ -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 }} # %