package planner import ( "slices" "testing" "time" "github.com/benbjohnson/clock" "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/util" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" ) func rates(prices []float64, start time.Time, slotDuration time.Duration) api.Rates { res := make(api.Rates, 0, len(prices)) for i, v := range prices { slotStart := start.Add(time.Duration(i) * time.Hour) ar := api.Rate{ Start: slotStart, End: slotStart.Add(slotDuration), Value: v, } res = append(res, ar) } return res } // func dumpRates(rr api.Rates) string { // var b []byte // for _, r := range rr { // b = fmt.Appendf(b, "%+v\n", r) // } // return string(b) // } // TODO start before start of rates func TestPlan(t *testing.T) { clock := clock.NewMock() ctrl := gomock.NewController(t) trf := api.NewMockTariff(ctrl) trf.EXPECT().Rates().AnyTimes().Return(rates([]float64{20, 60, 10, 80, 40, 90}, clock.Now(), time.Hour), nil) p := &Planner{ log: util.NewLogger("foo"), clock: clock, } rates, err := trf.Rates() require.NoError(t, err) slices.SortStableFunc(rates, sortByCost) { plan := p.plan(rates, time.Hour, clock.Now()) assert.Empty(t, plan) } tc := []struct { desc string // task duration time.Duration now time.Time target time.Time // result planStart time.Time planCost float64 }{ // numbers in brackets denote inactive partial slots { "plan 0-0-60-0-0-0", time.Hour, clock.Now(), clock.Now().Add(6 * time.Hour), clock.Now().Add(2 * time.Hour), 10, }, { "plan 60-0-60-0-0-0", 2 * time.Hour, clock.Now(), clock.Now().Add(6 * time.Hour), clock.Now().Add(0 * time.Hour), 30, }, { "plan (30)30-0-60-0-0-0", 90 * time.Minute, clock.Now(), clock.Now().Add(6 * time.Hour), clock.Now().Add(30 * time.Minute), 20, }, { "plan 0-0-60-0-0-0", time.Hour, clock.Now().Add(30 * time.Minute), clock.Now().Add(6 * time.Hour), clock.Now().Add(2 * time.Hour), 10, }, { "plan (30)30-0-60-0-30(30)-0", 2 * time.Hour, clock.Now().Add(30 * time.Minute), clock.Now().Add(6 * time.Hour), clock.Now().Add(30 * time.Minute), 40, }, { "plan (30)30-0-60-0-0-0", 90 * time.Minute, clock.Now().Add(30 * time.Minute), clock.Now().Add(6 * time.Hour), clock.Now().Add(30 * time.Minute), 20, }, } for i, tc := range tc { t.Log(tc.desc) clock.Set(tc.now) plan := p.plan(rates, tc.duration, tc.target) assert.Equalf(t, tc.planStart.UTC(), Start(plan).UTC(), "case %d start", i) assert.Equalf(t, tc.duration, Duration(plan), "case %d duration", i) assert.Equalf(t, tc.planCost, AverageCost(plan)*float64(Duration(plan))/float64(time.Hour), "case %d cost", i) } } func TestNilTariff(t *testing.T) { clock := clock.NewMock() p := &Planner{ log: util.NewLogger("foo"), clock: clock, } plan := p.Plan(time.Hour, 0, clock.Now().Add(30*time.Minute)) assert.Equal(t, api.Rates{ { Start: clock.Now(), End: clock.Now().Add(60 * time.Minute), }, }, plan, "expected simple plan") } func TestRatesError(t *testing.T) { clock := clock.NewMock() ctrl := gomock.NewController(t) trf := api.NewMockTariff(ctrl) trf.EXPECT().Rates().AnyTimes().Return(nil, api.ErrOutdated) p := &Planner{ log: util.NewLogger("foo"), clock: clock, tariff: trf, } plan := p.Plan(time.Hour, 0, clock.Now().Add(30*time.Minute)) assert.Equal(t, api.Rates{ { Start: clock.Now(), End: clock.Now().Add(60 * time.Minute), }, }, plan, "expected simple plan") } func TestFlatTariffTargetInThePast(t *testing.T) { clock := clock.NewMock() ctrl := gomock.NewController(t) trf := api.NewMockTariff(ctrl) trf.EXPECT().Rates().AnyTimes().Return(rates([]float64{0}, clock.Now(), time.Hour), nil) p := &Planner{ log: util.NewLogger("foo"), clock: clock, tariff: trf, } simplePlan := api.Rates{ { Start: clock.Now(), End: clock.Now().Add(60 * time.Minute), }, } plan := p.Plan(time.Hour, 0, clock.Now().Add(30*time.Minute)) assert.Equal(t, simplePlan, plan, "expected simple plan") plan = p.Plan(time.Hour, 0, clock.Now().Add(-30*time.Minute)) assert.Equal(t, simplePlan, plan, "expected simple plan") } func TestFlatTariffLongSlots(t *testing.T) { clock := clock.NewMock() ctrl := gomock.NewController(t) trf := api.NewMockTariff(ctrl) trf.EXPECT().Rates().AnyTimes().Return(rates([]float64{0}, clock.Now(), 24*time.Hour), nil) p := &Planner{ log: util.NewLogger("foo"), clock: clock, tariff: trf, } // for a single slot, we always expect charging to start early because tariffs ensure // that slots are not longer than 1 hour and with that context this is not a problem // expect 00:00-01:00 UTC plan := p.Plan(time.Hour, 0, clock.Now().Add(2*time.Hour)) assert.Equal(t, api.Rate{Start: clock.Now(), End: clock.Now().Add(time.Hour)}, SlotAt(clock.Now(), plan)) assert.Equal(t, api.Rate{}, SlotAt(clock.Now().Add(time.Hour), plan)) // expect 00:00-01:00 UTC plan = p.Plan(time.Hour, 0, clock.Now().Add(time.Hour)) assert.Equal(t, api.Rate{Start: clock.Now(), End: clock.Now().Add(time.Hour)}, SlotAt(clock.Now(), plan)) } func TestTargetAfterKnownPrices(t *testing.T) { clock := clock.NewMock() ctrl := gomock.NewController(t) trf := api.NewMockTariff(ctrl) trf.EXPECT().Rates().AnyTimes().Return(rates([]float64{0}, clock.Now(), time.Hour), nil) p := &Planner{ log: util.NewLogger("foo"), clock: clock, tariff: trf, } plan := p.Plan(40*time.Minute, 0, clock.Now().Add(2*time.Hour)) // charge efficiency does not allow to test with 1h assert.False(t, !SlotAt(clock.Now(), plan).IsZero(), "should not start if car can be charged completely after known prices ") plan = p.Plan(2*time.Hour, 0, clock.Now().Add(2*time.Hour)) assert.True(t, !SlotAt(clock.Now(), plan).IsZero(), "should start if car can not be charged completely after known prices ") } func TestChargeAfterTargetTime(t *testing.T) { clock := clock.NewMock() ctrl := gomock.NewController(t) trf := api.NewMockTariff(ctrl) trf.EXPECT().Rates().AnyTimes().Return(rates([]float64{0, 0, 0, 0}, clock.Now(), time.Hour), nil) p := &Planner{ log: util.NewLogger("foo"), clock: clock, tariff: trf, } simplePlan := api.Rates{ { Start: clock.Now(), End: clock.Now().Add(60 * time.Minute), }, } plan := p.Plan(time.Hour, 0, clock.Now()) assert.Equal(t, simplePlan, plan, "expected simple plan") plan = p.Plan(time.Hour, 0, clock.Now().Add(-time.Hour)) assert.Equal(t, simplePlan, plan, "expected simple plan") } func TestPrecondition(t *testing.T) { clock := clock.NewMock() ctrl := gomock.NewController(t) trf := api.NewMockTariff(ctrl) trf.EXPECT().Rates().AnyTimes().Return(rates([]float64{1, 2, 3, 4}, clock.Now(), time.Hour), nil) p := &Planner{ log: util.NewLogger("foo"), clock: clock, tariff: trf, } plan := p.Plan(time.Hour, time.Hour, clock.Now().Add(4*time.Hour)) assert.Equal(t, api.Rates{ { Start: clock.Now().Add(3 * time.Hour), End: clock.Now().Add(4 * time.Hour), Value: 4, }, }, plan, "expected last slot") // NOTE: with 15min slots we no longer expect late start of the slot plan = p.Plan(2*time.Hour, time.Hour, clock.Now().Add(4*time.Hour)) assert.Equal(t, api.Rates{ { Start: clock.Now(), End: clock.Now().Add(1 * time.Hour), Value: 1, }, { Start: clock.Now().Add(3 * time.Hour), End: clock.Now().Add(4 * time.Hour), Value: 4, }, }, plan, "expected two slots") plan = p.Plan(time.Hour, 30*time.Minute, clock.Now().Add(4*time.Hour)) assert.Equal(t, api.Rates{ { Start: clock.Now(), End: clock.Now().Add(30 * time.Minute), Value: 1, }, { Start: clock.Now().Add(210 * time.Minute), // 3.5h End: clock.Now().Add(4 * time.Hour), // 4.0h Value: 4, }, }, plan, "expected short early and split late slot") plan = p.Plan(time.Hour, 24*time.Hour, clock.Now().Add(4*time.Hour)) assert.Equal(t, api.Rates{ { Start: clock.Now().Add(3 * time.Hour), End: clock.Now().Add(4 * time.Hour), Value: 4, }, }, plan, "all precondition") } func TestContinuousPlanNoTariff(t *testing.T) { clock := clock.NewMock() p := &Planner{ log: util.NewLogger("foo"), clock: clock, } plan := p.Plan(time.Hour, 0, clock.Now()) // single-slot plan assert.Len(t, plan, 1) assert.Equal(t, clock.Now(), SlotAt(clock.Now(), plan).Start) assert.Equal(t, clock.Now().Add(time.Hour), SlotAt(clock.Now(), plan).End) } func TestContinuousPlan(t *testing.T) { clock := clock.NewMock() ctrl := gomock.NewController(t) trf := api.NewMockTariff(ctrl) trf.EXPECT().Rates().AnyTimes().Return(rates([]float64{0}, clock.Now().Add(time.Hour), time.Hour), nil) p := &Planner{ log: util.NewLogger("foo"), clock: clock, tariff: trf, } plan := p.Plan(150*time.Minute, 0, clock.Now()) // 3-slot plan assert.Len(t, plan, 3) } func TestContinuousPlanOutsideRates(t *testing.T) { clock := clock.NewMock() ctrl := gomock.NewController(t) trf := api.NewMockTariff(ctrl) trf.EXPECT().Rates().AnyTimes().Return(rates([]float64{0}, clock.Now().Add(time.Hour), time.Hour), nil) p := &Planner{ log: util.NewLogger("foo"), clock: clock, tariff: trf, } plan := p.Plan(30*time.Minute, 0, clock.Now()) // 3-slot plan assert.Len(t, plan, 1) }