evcc-io/core/planner/planner_test.go
2025-12-31 11:30:23 +01:00

388 lines
9.2 KiB
Go

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