evcc-io/core/planner/planner_test.go

623 lines
19 KiB
Go

package planner
import (
"slices"
"testing"
"time"
"github.com/benbjohnson/clock"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/tariff"
"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) * slotDuration)
ar := api.Rate{
Start: slotStart,
End: slotStart.Add(slotDuration),
Value: v,
}
res = append(res, ar)
}
return res
}
func TestClampRates(t *testing.T) {
clock := clock.NewMock()
rr := rates([]float64{0, 1}, clock.Now(), time.Hour)
assert.Equal(t, rr, clampRates(rr, clock.Now(), clock.Now().Add(2*time.Hour)))
assert.Equal(t, rates([]float64{0}, clock.Now(), time.Hour), clampRates(rr, clock.Now(), clock.Now().Add(time.Hour)))
exp := api.Rates{{Start: clock.Now().Add(time.Hour), End: clock.Now().Add(2 * time.Hour), Value: 1}}
assert.Equal(t, exp, clampRates(rr, clock.Now().Add(time.Hour), clock.Now().Add(2*time.Hour)))
}
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)
rates, err := trf.Rates()
require.NoError(t, err)
slices.SortStableFunc(rates, sortByCost)
{
// filter rates to [now, now] window - should return empty
plan := optimalPlan(clampRates(rates, clock.Now(), clock.Now()), 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)
// filter rates to [now, target] window as caller would do
plan := optimalPlan(clampRates(rates, tc.now, tc.target), 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), false)
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), false)
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), false)
assert.Equal(t, simplePlan, plan, "expected simple plan")
plan = p.Plan(time.Hour, 0, clock.Now().Add(-30*time.Minute), false)
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), false)
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), false)
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), false) // 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), false)
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(), false)
assert.Equal(t, simplePlan, plan, "expected simple plan")
plan = p.Plan(time.Hour, 0, clock.Now().Add(-time.Hour), false)
assert.Equal(t, simplePlan, plan, "expected simple plan")
}
func TestGappedRatesCoverage(t *testing.T) {
clock := clock.NewMock()
ctrl := gomock.NewController(t)
// two 1h slots separated by a 4h gap: 2h of real coverage spanning 6h
rr := api.Rates{
{Start: clock.Now(), End: clock.Now().Add(time.Hour), Value: 10},
{Start: clock.Now().Add(5 * time.Hour), End: clock.Now().Add(6 * time.Hour), Value: 10},
}
trf := api.NewMockTariff(ctrl)
trf.EXPECT().Rates().AnyTimes().Return(rr, nil)
p := &Planner{
log: util.NewLogger("foo"),
clock: clock,
tariff: trf,
}
requiredDuration := 3 * time.Hour
// summed coverage (2h) is below requiredDuration (3h), so the planner must
// fall back to simplePlan and still cover the full duration rather than
// returning an under-covered plan based on the wall-clock span (6h)
for _, continuous := range []bool{false, true} {
plan := p.Plan(requiredDuration, 0, clock.Now().Add(6*time.Hour), continuous)
assert.GreaterOrEqual(t, Duration(plan), requiredDuration,
"plan must cover requiredDuration even when the rate series has gaps (continuous=%v)", continuous)
}
}
func TestExactCoverageNoFallback(t *testing.T) {
clock := clock.NewMock()
ctrl := gomock.NewController(t)
// 3h of real coverage inside a 4h window (1h gap), exactly equal to
// requiredDuration. Duration(rates) == requiredDuration must NOT trigger the
// simplePlan fallback (the guard uses a strict <), so the planner builds a
// plan from the real rates instead. Locks the < behavior against a slip to <=.
rr := api.Rates{
{Start: clock.Now(), End: clock.Now().Add(time.Hour), Value: 10},
{Start: clock.Now().Add(time.Hour), End: clock.Now().Add(2 * time.Hour), Value: 20},
{Start: clock.Now().Add(3 * time.Hour), End: clock.Now().Add(4 * time.Hour), Value: 30},
}
trf := api.NewMockTariff(ctrl)
trf.EXPECT().Rates().AnyTimes().Return(rr, nil)
p := &Planner{
log: util.NewLogger("foo"),
clock: clock,
tariff: trf,
}
requiredDuration := 3 * time.Hour // == Duration(rr)
targetTime := clock.Now().Add(4 * time.Hour)
simplePlan := api.Rates{
{Start: targetTime.Add(-requiredDuration), End: targetTime},
}
for _, continuous := range []bool{false, true} {
plan := p.Plan(requiredDuration, 0, targetTime, continuous)
assert.NotEqual(t, simplePlan, plan,
"exact coverage (Duration == requiredDuration) must not fall back to simplePlan (continuous=%v)", continuous)
}
}
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(), tariff.SlotDuration), nil)
p := &Planner{
log: util.NewLogger("foo"),
clock: clock,
tariff: trf,
}
plan := p.Plan(tariff.SlotDuration, tariff.SlotDuration, clock.Now().Add(4*tariff.SlotDuration), false)
assert.Equal(t, api.Rates{
{
Start: clock.Now().Add(3 * tariff.SlotDuration),
End: clock.Now().Add(4 * tariff.SlotDuration),
Value: 4,
},
}, plan, "expected last slot")
plan = p.Plan(2*tariff.SlotDuration, tariff.SlotDuration, clock.Now().Add(4*tariff.SlotDuration), false)
assert.Equal(t, api.Rates{
{
Start: clock.Now(),
End: clock.Now().Add(1 * tariff.SlotDuration),
Value: 1,
},
{
Start: clock.Now().Add(3 * tariff.SlotDuration),
End: clock.Now().Add(4 * tariff.SlotDuration),
Value: 4,
},
}, plan, "expected two slots")
plan = p.Plan(time.Duration(1.5*float64(tariff.SlotDuration)), tariff.SlotDuration, clock.Now().Add(4*tariff.SlotDuration), false)
assert.Equal(t, api.Rates{
{
Start: clock.Now(),
End: clock.Now().Add(time.Duration(0.5 * float64(tariff.SlotDuration))),
Value: 1,
},
{
Start: clock.Now().Add(3 * tariff.SlotDuration),
End: clock.Now().Add(4 * tariff.SlotDuration),
Value: 4,
},
}, plan, "expected trimmed slot at beginning and precondition slot")
plan = p.Plan(tariff.SlotDuration, 24*time.Hour, clock.Now().Add(time.Hour), false)
assert.Equal(t, api.Rates{
{
Start: clock.Now().Add(3 * tariff.SlotDuration),
End: clock.Now().Add(4 * tariff.SlotDuration),
Value: 4,
},
}, plan, "all precondition")
}
func TestPrecondition_NonSlotBoundary(t *testing.T) {
clock := clock.NewMock()
ctrl := gomock.NewController(t)
trf := api.NewMockTariff(ctrl)
slotDuration := 15 * time.Minute
// Create rates with 15-minute slots covering 8 hours (32 slots)
prices := make([]float64, 32)
for i := range prices {
prices[i] = float64(i + 1)
}
trf.EXPECT().Rates().AnyTimes().Return(rates(prices, clock.Now(), slotDuration), nil)
p := &Planner{
log: util.NewLogger("foo"),
clock: clock,
tariff: trf,
}
// Target time at 7:20 (non-slot boundary, between 7:15 and 7:30)
// 7:20 is 29 slots + 5 minutes from now
targetTime := clock.Now().Add(29*slotDuration + 5*time.Minute)
// 30 minutes preconditioning, 1 hour charging
precondition := 30 * time.Minute
requiredDuration := 1 * time.Hour
plan := p.Plan(requiredDuration, precondition, targetTime, false)
// Verify precondition ends exactly at target time
require.NotEmpty(t, plan)
lastSlot := plan[len(plan)-1]
assert.Equal(t, targetTime, lastSlot.End, "precondition should end exactly at target time")
// Calculate total precondition duration
var precondDuration time.Duration
// Precondition starts at targetTime - 30min = 6:50
precondStart := targetTime.Add(-precondition)
for _, slot := range plan {
if !slot.Start.Before(precondStart) {
precondDuration += slot.End.Sub(slot.Start)
}
}
assert.Equal(t, precondition, precondDuration, "total precondition duration should be exactly 30 minutes")
// Verify expected slots structure
// Note: precondition (30min) reduces effective required duration from 1h to 30min
// Cheapest 30min charging: slots at 01:00-01:30 (slots 0-1, prices 1,2)
// Precondition: 07:50-08:20 (exactly 30min before target at 08:20)
// - 07:45-08:00 (slot 27, price 28) -> trimmed to 07:50-08:00 (10min)
// - 08:00-08:15 (slot 28, price 29) -> full slot (15min)
// - 08:15-08:30 (slot 29, price 30) -> trimmed to 08:15-08:20 (5min)
expectedPlan := api.Rates{
// Charging slots (cheapest 30 minutes after precondition reduction)
{Start: clock.Now(), End: clock.Now().Add(slotDuration), Value: 1},
{Start: clock.Now().Add(slotDuration), End: clock.Now().Add(2 * slotDuration), Value: 2},
// Precondition slots (exactly 30min before target, trimmed at both ends)
{Start: targetTime.Add(-precondition), End: clock.Now().Add(28 * slotDuration), Value: 28},
{Start: clock.Now().Add(28 * slotDuration), End: clock.Now().Add(29 * slotDuration), Value: 29},
{Start: clock.Now().Add(29 * slotDuration), End: targetTime, Value: 30},
}
assert.Equal(t, expectedPlan, plan, "expected charging slots and trimmed precondition slots")
}
func TestContinuousPlanNoTariff(t *testing.T) {
clock := clock.NewMock()
p := &Planner{
log: util.NewLogger("foo"),
clock: clock,
}
plan := p.Plan(time.Hour, 0, clock.Now(), false)
// 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(), false)
// 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(), false)
// 3-slot plan
assert.Len(t, plan, 1)
}
// TestStartBeforeRates tests that when current time is before
// the first available rate, the planner waits and starts charging when
// rates become available, as long as there's enough time to reach the target
func TestStartBeforeRates(t *testing.T) {
now := time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC)
c := clock.NewMock()
c.Set(now)
ctrl := gomock.NewController(t)
log := util.NewLogger("test")
// Rates start 2 hours in the future (gap from now until first rate)
rates := api.Rates{
{Start: now.Add(2 * time.Hour), End: now.Add(3 * time.Hour), Value: 0.10},
{Start: now.Add(3 * time.Hour), End: now.Add(4 * time.Hour), Value: 0.15},
{Start: now.Add(4 * time.Hour), End: now.Add(5 * time.Hour), Value: 0.08}, // cheapest
{Start: now.Add(5 * time.Hour), End: now.Add(6 * time.Hour), Value: 0.20},
}
trf := api.NewMockTariff(ctrl)
trf.EXPECT().Rates().AnyTimes().Return(rates, nil)
planner := &Planner{
log: log,
clock: c,
tariff: trf,
}
targetTime := now.Add(6 * time.Hour)
requiredDuration := time.Hour
plan := planner.Plan(requiredDuration, 0, targetTime, true) // continuous mode
require.NotEmpty(t, plan, "plan should not be empty")
require.Len(t, plan, 1, "should create single slot with actual price")
// Should wait until rates are available and pick the cheapest slot
assert.Equal(t, now.Add(4*time.Hour), plan[0].Start, "should start at cheapest available rate")
assert.Equal(t, now.Add(5*time.Hour), plan[0].End, "should end after required duration")
assert.Equal(t, 0.08, plan[0].Value, "should have actual price from cheapest slot")
// Plan must not start before rates are available
assert.False(t, plan[0].Start.Before(rates[0].Start), "plan must not start before first available rate")
}
// TestStartBeforeRatesInsufficientTime tests that when current time
// is before the first available rate AND there's not enough rate coverage
// to complete charging, the planner falls back to simplePlan (ignoring rates)
func TestStartBeforeRatesInsufficientTime(t *testing.T) {
now := time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC)
c := clock.NewMock()
c.Set(now)
ctrl := gomock.NewController(t)
log := util.NewLogger("test")
// Rates start 2 hours in the future, but we need 3 hours to charge
// and target is only 4 hours away (not enough time to fully charge)
rates := api.Rates{
{Start: now.Add(2 * time.Hour), End: now.Add(3 * time.Hour), Value: 0.10},
{Start: now.Add(3 * time.Hour), End: now.Add(4 * time.Hour), Value: 0.15},
}
trf := api.NewMockTariff(ctrl)
trf.EXPECT().Rates().AnyTimes().Return(rates, nil)
planner := &Planner{
log: log,
clock: c,
tariff: trf,
}
targetTime := now.Add(4 * time.Hour)
requiredDuration := 3 * time.Hour // Need 3h but only 2h rate coverage
plan := planner.Plan(requiredDuration, 0, targetTime, false) // dispersed mode
require.NotEmpty(t, plan, "plan should not be empty")
// Insufficient rate coverage: fall back to simplePlan starting at latestStart
assert.Equal(t, now.Add(1*time.Hour), plan[0].Start, "should start at latestStart (target - required)")
assert.Equal(t, 0.0, plan[0].Value, "simplePlan has no price info")
}
// TestEmptyRatesAfterClamping tests fallback to simplePlan when no rates cover [now, targetTime]
func TestEmptyRatesAfterClamping(t *testing.T) {
c := clock.NewMock()
ctrl := gomock.NewController(t)
trf := api.NewMockTariff(ctrl)
trf.EXPECT().Rates().AnyTimes().Return(rates([]float64{0.20}, c.Now().Add(3*time.Hour), time.Hour), nil)
p := &Planner{
log: util.NewLogger("test"),
clock: c,
tariff: trf,
}
plan := p.Plan(time.Hour, 0, c.Now().Add(90*time.Minute), false)
require.Len(t, plan, 1)
assert.Equal(t, c.Now().Add(30*time.Minute), plan[0].Start)
}