package core import ( "errors" "testing" "time" "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/config" "github.com/stretchr/testify/assert" "go.uber.org/mock/gomock" ) // TestBatterySocRetainOnReadError guards that a failed soc read keeps the last // known soc instead of reporting the pack as empty (discussion #26560). func TestBatterySocRetainOnReadError(t *testing.T) { ctrl := gomock.NewController(t) meter := api.NewMockMeter(ctrl) meter.EXPECT().CurrentPower().Return(0.0, nil).AnyTimes() battery := api.NewMockBattery(ctrl) battery.EXPECT().Soc().Return(0.0, errors.New("read failed")).AnyTimes() var bat api.Meter = &struct { api.Meter api.Battery }{ Meter: meter, Battery: battery, } site := &Site{ log: util.NewLogger("foo"), batteryMeters: []config.Device[api.Meter]{config.NewStaticDevice(config.Named{}, bat)}, } site.battery.Soc = 84 site.updateBatteryMeters() assert.Equal(t, 84.0, site.battery.Soc, "soc retained when the read fails") } func TestApplyBatteryMode(t *testing.T) { for _, tc := range []struct { internal, expected api.BatteryMode }{ {api.BatteryUnknown, api.BatteryUnknown}, // no change required {api.BatteryNormal, api.BatteryUnknown}, // no change required {api.BatteryHold, api.BatteryNormal}, {api.BatteryCharge, api.BatteryNormal}, } { t.Logf("%+v", tc) ctrl := gomock.NewController(t) var bat api.Meter batCon := api.NewMockBatteryController(ctrl) bat = &struct { api.Meter api.BatteryController }{ BatteryController: batCon, } site := &Site{ log: util.NewLogger("foo"), batteryMeters: []config.Device[api.Meter]{config.NewStaticDevice(config.Named{}, bat)}, batteryMode: tc.internal, } // verify mode applied to battery if tc.expected != api.BatteryUnknown { batCon.EXPECT().SetBatteryMode(tc.expected).Times(1) } site.updateBatteryMode(false, api.Rate{}) if tc.internal != api.BatteryNormal { assert.Equal(t, tc.expected, site.batteryMode) } ctrl.Finish() } } // battery meter with soc, controller and soc limits func batteryControlMock(ctrl *gomock.Controller, soc, maxSoc float64) (api.Meter, *api.MockBatteryController) { batSoc := api.NewMockBattery(ctrl) batSoc.EXPECT().Soc().Return(soc, nil).AnyTimes() batSocLimit := api.NewMockBatterySocLimiter(ctrl) batSocLimit.EXPECT().GetSocLimits().Return(0.0, maxSoc).AnyTimes() batCon := api.NewMockBatteryController(ctrl) return &struct { api.Meter api.Battery api.BatteryController api.BatterySocLimiter }{ Battery: batSoc, BatteryController: batCon, BatterySocLimiter: batSocLimit, }, batCon } // TestBatteryHoldAppliedOnce guards that reaching max soc during grid charge switches // the battery to hold mode once instead of on every update func TestBatteryHoldAppliedOnce(t *testing.T) { ctrl := gomock.NewController(t) bat, batCon := batteryControlMock(ctrl, 90, 80) site := &Site{ log: util.NewLogger("foo"), batteryMeters: []config.Device[api.Meter]{config.NewStaticDevice(config.Named{Name: "bat"}, bat)}, batteryMode: api.BatteryCharge, } batCon.EXPECT().SetBatteryMode(api.BatteryHold).Times(1) for range 3 { site.updateBatteryMode(true, api.Rate{}) } ctrl.Finish() } // TestBatteryHoldNotShared guards that one battery reaching max soc does not put the // remaining batteries into hold mode func TestBatteryHoldNotShared(t *testing.T) { ctrl := gomock.NewController(t) full, fullCon := batteryControlMock(ctrl, 90, 80) empty, emptyCon := batteryControlMock(ctrl, 50, 80) site := &Site{ log: util.NewLogger("foo"), batteryMeters: []config.Device[api.Meter]{ config.NewStaticDevice(config.Named{Name: "full"}, full), config.NewStaticDevice(config.Named{Name: "empty"}, empty), }, batteryMode: api.BatteryCharge, } fullCon.EXPECT().SetBatteryMode(api.BatteryHold).Times(1) emptyCon.EXPECT().SetBatteryMode(gomock.Any()).Times(0) site.updateBatteryMode(true, api.Rate{}) ctrl.Finish() } func TestRequiredExternalBatteryMode(t *testing.T) { for _, tc := range []struct { internal, external, new api.BatteryMode }{ {api.BatteryUnknown, api.BatteryUnknown, api.BatteryUnknown}, {api.BatteryUnknown, api.BatteryNormal, api.BatteryNormal}, {api.BatteryUnknown, api.BatteryCharge, api.BatteryCharge}, {api.BatteryNormal, api.BatteryUnknown, api.BatteryUnknown}, {api.BatteryNormal, api.BatteryNormal, api.BatteryUnknown}, // no change required {api.BatteryNormal, api.BatteryCharge, api.BatteryCharge}, {api.BatteryCharge, api.BatteryUnknown, api.BatteryNormal}, {api.BatteryCharge, api.BatteryNormal, api.BatteryNormal}, {api.BatteryCharge, api.BatteryCharge, api.BatteryUnknown}, // no change required } { t.Logf("%+v", tc) site := &Site{ log: util.NewLogger("foo"), batteryMeters: []config.Device[api.Meter]{nil}, } site.batteryMode = tc.internal site.batteryModeExternal = tc.external mode := site.requiredBatteryMode(false, api.Rate{}) assert.Equal(t, tc.new.String(), mode.String(), "internal mode expected %s got %s", tc.new, mode) } } func TestExternalBatteryModeChange(t *testing.T) { for _, tc := range []struct { internal, external, expected api.BatteryMode }{ {api.BatteryUnknown, api.BatteryUnknown, api.BatteryUnknown}, {api.BatteryUnknown, api.BatteryNormal, api.BatteryNormal}, {api.BatteryUnknown, api.BatteryCharge, api.BatteryCharge}, {api.BatteryNormal, api.BatteryUnknown, api.BatteryUnknown}, {api.BatteryNormal, api.BatteryNormal, api.BatteryUnknown}, {api.BatteryNormal, api.BatteryCharge, api.BatteryCharge}, {api.BatteryHold, api.BatteryUnknown, api.BatteryNormal}, // return to normal {api.BatteryHold, api.BatteryNormal, api.BatteryNormal}, {api.BatteryHold, api.BatteryHold, api.BatteryUnknown}, {api.BatteryCharge, api.BatteryUnknown, api.BatteryNormal}, // return to normal {api.BatteryCharge, api.BatteryNormal, api.BatteryNormal}, {api.BatteryCharge, api.BatteryCharge, api.BatteryUnknown}, } { t.Logf("%+v", tc) ctrl := gomock.NewController(t) var bat api.Meter batCon := api.NewMockBatteryController(ctrl) bat = &struct { api.Meter api.BatteryController }{ BatteryController: batCon, } site := &Site{ log: util.NewLogger("foo"), batteryMeters: []config.Device[api.Meter]{config.NewStaticDevice(config.Named{}, bat)}, batteryMode: tc.internal, } // 1. set required external mode site.SetBatteryModeExternal(tc.external) assert.Equal(t, site.batteryModeExternal, tc.external, "external mode expected %s got %s", tc.external, site.batteryModeExternal) assert.Equal(t, site.batteryMode, tc.internal, "internal mode expected unchanged %s got %s", tc.internal, site.batteryMode) // 2. verify external mode applied to battery if tc.expected != api.BatteryUnknown { batCon.EXPECT().SetBatteryMode(tc.expected).Times(1) } site.updateBatteryMode(false, api.Rate{}) if !ctrl.Satisfied() { ctrl.Finish() } // 3. verify required external mode only applied once site.updateBatteryMode(false, api.Rate{}) if !ctrl.Satisfied() { ctrl.Finish() } // 4. verify timer expiry site.batteryModeExternalTimer = site.batteryModeExternalTimer.Add(-time.Hour) site.batteryModeWatchdogExpired() // mode reverted to unknown, timer still active assert.Equal(t, site.batteryModeExternal, api.BatteryUnknown) assert.False(t, site.batteryModeExternalTimer.IsZero()) // battery switched back to normal mode unless already applied in step 2 if tc.expected != api.BatteryNormal { batCon.EXPECT().SetBatteryMode(api.BatteryNormal).Times(1) } site.updateBatteryMode(false, api.Rate{}) // timer disabled assert.True(t, site.batteryModeExternalTimer.IsZero()) 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() } }