evcc-io/core/planner/planner_continuous_test.go

717 lines
26 KiB
Go

package planner
import (
"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 TestContinuous_CheapestContiguousSlots(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)
rates := api.Rates{
{Start: now, End: now.Add(1 * time.Hour), Value: 0.09},
{Start: now.Add(1 * time.Hour), End: now.Add(2 * time.Hour), Value: 0.20},
{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.11},
{Start: now.Add(4 * time.Hour), End: now.Add(5 * time.Hour), Value: 0.11},
{Start: now.Add(5 * time.Hour), End: now.Add(6 * time.Hour), Value: 0.25},
}
trf := api.NewMockTariff(ctrl)
trf.EXPECT().Rates().AnyTimes().Return(rates, nil)
p := &Planner{
log: util.NewLogger("foo"),
clock: c,
tariff: trf,
}
plan := p.Plan(2*time.Hour, 0, now.Add(6*time.Hour), true)
require.Len(t, plan, 2)
assert.Equal(t, rates[2].Start, plan[0].Start)
assert.Equal(t, rates[3].End, plan[len(plan)-1].End)
assert.Equal(t, rates[2].Value, plan[0].Value)
assert.Equal(t, rates[3].Value, plan[1].Value)
}
func TestContinuous_WindowWithPastRates(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)
rates := api.Rates{
{Start: now.Add(-3 * time.Hour), End: now.Add(-2 * time.Hour), Value: 0.05},
{Start: now.Add(-2 * time.Hour), End: now.Add(-1 * time.Hour), Value: 0.08},
{Start: now.Add(-1 * time.Hour), End: now, Value: 0.07},
{Start: now, End: now.Add(1 * time.Hour), Value: 0.20},
{Start: now.Add(1 * time.Hour), End: now.Add(2 * time.Hour), Value: 0.09},
{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.11},
{Start: now.Add(5 * time.Hour), End: now.Add(6 * time.Hour), Value: 0.25},
}
trf := api.NewMockTariff(ctrl)
trf.EXPECT().Rates().AnyTimes().Return(rates, nil)
p := &Planner{
log: util.NewLogger("foo"),
clock: c,
tariff: trf,
}
targetTime := now.Add(6 * time.Hour)
requiredDuration := 2 * time.Hour
plan := p.Plan(requiredDuration, 0, targetTime, true)
require.NotEmpty(t, plan)
require.Len(t, plan, 2)
assert.False(t, plan[0].Start.Before(now))
assert.Equal(t, now.Add(1*time.Hour), plan[0].Start)
assert.Equal(t, now.Add(3*time.Hour), plan[len(plan)-1].End)
assert.Equal(t, 0.09, plan[0].Value)
assert.Equal(t, 0.10, plan[1].Value)
}
func TestContinuous_WindowAllRatesInPast(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)
rates := api.Rates{
{Start: now.Add(-6 * time.Hour), End: now.Add(-5 * time.Hour), Value: 0.05},
{Start: now.Add(-5 * time.Hour), End: now.Add(-4 * time.Hour), Value: 0.08},
{Start: now.Add(-4 * time.Hour), End: now.Add(-3 * time.Hour), Value: 0.07},
{Start: now.Add(-3 * time.Hour), End: now.Add(-2 * time.Hour), Value: 0.09},
{Start: now.Add(-2 * time.Hour), End: now.Add(-1 * time.Hour), Value: 0.10},
{Start: now.Add(-1 * time.Hour), End: now, Value: 0.11},
}
trf := api.NewMockTariff(ctrl)
trf.EXPECT().Rates().AnyTimes().Return(rates, nil)
p := &Planner{
log: util.NewLogger("foo"),
clock: c,
tariff: trf,
}
targetTime := now.Add(3 * time.Hour)
requiredDuration := 2 * time.Hour
plan := p.Plan(requiredDuration, 0, targetTime, true) // continuous mode
// When all rates are in the past and target is in future, expect nil plan
assert.Empty(t, plan, "plan should be nil when all rates are in the past")
}
// TestContinuous_WindowRatesSpanningPastAndFuture tests continuous mode with rates
// spanning from past to future, where the optimal window would start in the past
func TestContinuous_WindowRatesSpanningPastAndFuture(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)
// Rates spanning from 3h before now to 6h after now
// The cheapest window would be -3h to -1h, but that's in the past
rates := api.Rates{
{Start: now.Add(-3 * time.Hour), End: now.Add(-2 * time.Hour), Value: 0.05}, // cheapest, but past
{Start: now.Add(-2 * time.Hour), End: now.Add(-1 * time.Hour), Value: 0.06}, // cheap, but past
{Start: now.Add(-1 * time.Hour), End: now, Value: 0.12}, // partially past
{Start: now, End: now.Add(1 * time.Hour), Value: 0.15},
{Start: now.Add(1 * time.Hour), End: now.Add(2 * time.Hour), Value: 0.08}, // cheapest future
{Start: now.Add(2 * time.Hour), End: now.Add(3 * time.Hour), Value: 0.09}, // second cheapest future
{Start: now.Add(3 * time.Hour), End: now.Add(4 * time.Hour), Value: 0.18},
{Start: now.Add(4 * time.Hour), End: now.Add(5 * time.Hour), Value: 0.14},
{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)
p := &Planner{
log: util.NewLogger("foo"),
clock: c,
tariff: trf,
}
targetTime := now.Add(6 * time.Hour)
requiredDuration := 2 * time.Hour
plan := p.Plan(requiredDuration, 0, targetTime, true) // continuous mode
require.NotEmpty(t, plan)
require.Len(t, plan, 2)
// Critical: plan must start at or after now, even if cheaper rates existed in the past
assert.False(t, plan[0].Start.Before(now), "plan must not start in the past")
// Should find cheapest 2-hour window starting from now or later
// Expected: 1h-3h window (two slots with prices 0.08 and 0.09)
assert.Equal(t, now.Add(1*time.Hour), plan[0].Start, "start should be at cheapest future window")
assert.Equal(t, now.Add(3*time.Hour), plan[len(plan)-1].End, "end should match 2-hour window")
assert.Equal(t, 0.08, plan[0].Value, "first slot should have actual price")
assert.Equal(t, 0.09, plan[1].Value, "second slot should have actual price")
}
// TestContinuous_WindowRatesStartInFuture tests continuous mode when tariff data
// starts in the future, but target time is within the tariff data range
func TestContinuous_WindowRatesStartInFuture(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)
rates := api.Rates{
{Start: now.Add(1 * time.Hour), End: now.Add(2 * time.Hour), Value: 0.20},
{Start: now.Add(2 * time.Hour), End: now.Add(3 * time.Hour), Value: 0.08},
{Start: now.Add(3 * time.Hour), End: now.Add(4 * time.Hour), Value: 0.09},
{Start: now.Add(4 * time.Hour), End: now.Add(5 * time.Hour), Value: 0.15},
{Start: now.Add(5 * time.Hour), End: now.Add(6 * time.Hour), Value: 0.18},
}
trf := api.NewMockTariff(ctrl)
trf.EXPECT().Rates().AnyTimes().Return(rates, nil)
p := &Planner{
log: util.NewLogger("foo"),
clock: c,
tariff: trf,
}
targetTime := now.Add(5 * time.Hour)
requiredDuration := 2 * time.Hour
plan := p.Plan(requiredDuration, 0, targetTime, true) // continuous mode
require.NotEmpty(t, plan)
require.Len(t, plan, 2)
// Plan must not start in the past
assert.False(t, plan[0].Start.Before(now), "plan must not start in the past")
// Should find cheapest 2-hour window within available rates
// Expected: 2h-4h window (two slots with prices 0.08 and 0.09)
assert.Equal(t, now.Add(2*time.Hour), plan[0].Start, "start should be at cheapest window in future rates")
assert.Equal(t, now.Add(4*time.Hour), plan[len(plan)-1].End, "end should match 2-hour window")
assert.Equal(t, 0.08, plan[0].Value, "first slot should have actual price")
assert.Equal(t, 0.09, plan[1].Value, "second slot should have actual price")
}
func TestContinuous_WindowLateChargingPreference(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)
rates := api.Rates{
{Start: now, End: now.Add(1 * time.Hour), Value: 0.10},
{Start: now.Add(1 * time.Hour), End: now.Add(2 * time.Hour), Value: 0.10},
{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.10},
{Start: now.Add(4 * time.Hour), End: now.Add(5 * time.Hour), Value: 0.10},
{Start: now.Add(5 * time.Hour), End: now.Add(6 * time.Hour), Value: 0.15},
}
trf := api.NewMockTariff(ctrl)
trf.EXPECT().Rates().AnyTimes().Return(rates, nil)
p := &Planner{
log: util.NewLogger("foo"),
clock: c,
tariff: trf,
}
targetTime := now.Add(6 * time.Hour)
requiredDuration := 2 * time.Hour
plan := p.Plan(requiredDuration, 0, targetTime, true) // continuous mode
require.NotEmpty(t, plan)
require.Len(t, plan, 2)
// Should select the latest window with equal cost (3h-5h)
// All windows from 0h-2h, 1h-3h, 2h-4h, and 3h-5h have the same total cost
// But we prefer late charging, so 3h-5h should be selected
assert.Equal(t, now.Add(3*time.Hour), plan[0].Start, "should select latest window with equal cost")
assert.Equal(t, now.Add(5*time.Hour), plan[len(plan)-1].End, "end should be 2 hours after start")
assert.Equal(t, 0.10, plan[0].Value, "first slot should have actual price")
assert.Equal(t, 0.10, plan[1].Value, "second slot should have actual price")
}
func TestContinuous_TargetAfterKnownPrices(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), true) // 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), true)
assert.True(t, !SlotAt(clock.Now(), plan).IsZero(), "should start if car can not be charged completely after known prices ")
}
func TestContinuous_Precondition(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), true)
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), true)
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), true)
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), true)
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 TestContinuous_Precondition_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, true)
// 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")
// In continuous mode, find cheapest continuous 30min window (after precondition reduction)
// Cheapest window: 01:00-01:30 (slots 0-1, prices 1+2)
// Precondition: 07:50-08:20 (exactly 30min before target at 08:20)
expectedPlan := api.Rates{
// Charging slots (cheapest continuous 30 minutes)
{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 continuous charging slots and trimmed precondition slots")
}
func TestPrecondition_Everything(t *testing.T) {
clock := clock.NewMock()
ctrl := gomock.NewController(t)
trf := api.NewMockTariff(ctrl)
// Create 8 hours of rates with varying prices (cheaper toward the end)
prices := []float64{10, 9, 8, 7, 6, 5, 4, 3}
trf.EXPECT().Rates().AnyTimes().Return(rates(prices, clock.Now(), tariff.SlotDuration), nil)
p := &Planner{
log: util.NewLogger("test"),
clock: clock,
tariff: trf,
}
targetTime := clock.Now().Add(8 * tariff.SlotDuration) // 8 hours from now
requiredDuration := 2 * tariff.SlotDuration // need 2 hours
precondition := 7 * 24 * time.Hour // "everything" = 7 days
// Test with continuous=false (cheapest mode - should be ignored)
plan := p.Plan(requiredDuration, precondition, targetTime, false)
require.NotEmpty(t, plan, "plan should not be empty")
// Plan should end exactly at target time
assert.Equal(t, targetTime, plan[len(plan)-1].End, "plan should end at target time")
// Plan should have total duration = requiredDuration (NOT precondition duration)
totalDuration := Duration(plan)
assert.Equal(t, requiredDuration, totalDuration, "plan duration should equal required duration, not precondition")
// Plan should start at latest possible time (targetTime - requiredDuration)
expectedStart := targetTime.Add(-requiredDuration)
assert.Equal(t, expectedStart, plan[0].Start, "plan should start at latest possible time")
// Should contain actual rate data (slots 6-7 with prices 4, 3)
assert.Len(t, plan, 2, "should have 2 slots for 30-minute duration")
assert.Equal(t, 4.0, plan[0].Value, "should have actual rate value from slot 6")
assert.Equal(t, 3.0, plan[1].Value, "should have actual rate value from slot 7")
// Test with continuous=true (should also be ignored when precondition=everything)
planContinuous := p.Plan(requiredDuration, precondition, targetTime, true)
assert.Equal(t, plan, planContinuous, "continuous flag should be ignored when precondition=everything")
}
func TestContinuous_ContinuousPlanNoTariff(t *testing.T) {
clock := clock.NewMock()
p := &Planner{
log: util.NewLogger("foo"),
clock: clock,
}
plan := p.Plan(time.Hour, 0, clock.Now(), true)
// 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 TestContinuous_ContinuousPlan(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(), true)
// 3-slot plan
assert.Len(t, plan, 3)
}
func TestContinuous_ContinuousPlanOutsideRates(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(), true)
// 3-slot plan
assert.Len(t, plan, 1)
}
// TestContinuous_StartBeforeRates 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 TestContinuous_StartBeforeRates(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")
}
// TestContinuous_StartBeforeRatesInsufficientTime tests that when current time
// is before the first available rate AND there's not enough time after rates
// start to complete charging before target, the planner starts at the latest
// possible time to reach target (best effort approach)
func TestContinuous_StartBeforeRatesInsufficientTime(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 available after rates start
plan := planner.Plan(requiredDuration, 0, targetTime, true) // continuous mode
require.NotEmpty(t, plan, "plan should not be empty")
// Best effort: start at latest possible time to maximize charging time
assert.Equal(t, now.Add(1*time.Hour), plan[0].Start, "should start at latest possible time")
assert.Equal(t, 0.0, plan[0].Value, "gap-filling slot before rates has no price")
}
// TestContinuous_StartBeforeRatesSufficientTime tests that when current time
// is before the first available rate AND there IS enough time to complete
// charging, the planner finds the cheapest continuous window
func TestContinuous_StartBeforeRatesSufficientTime(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, we need 2 hours to charge
// and target is 8 hours away (enough time to optimize)
rates := api.Rates{
{Start: now.Add(2 * time.Hour), End: now.Add(3 * time.Hour), Value: 0.20},
{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.10}, // cheapest
{Start: now.Add(5 * time.Hour), End: now.Add(6 * time.Hour), Value: 0.08}, // cheapest
{Start: now.Add(6 * time.Hour), End: now.Add(7 * time.Hour), Value: 0.12},
{Start: now.Add(7 * time.Hour), End: now.Add(8 * time.Hour), Value: 0.25},
}
trf := api.NewMockTariff(ctrl)
trf.EXPECT().Rates().AnyTimes().Return(rates, nil)
planner := &Planner{
log: log,
clock: c,
tariff: trf,
}
targetTime := now.Add(8 * time.Hour)
requiredDuration := 2 * time.Hour
plan := planner.Plan(requiredDuration, 0, targetTime, true) // continuous mode
require.NotEmpty(t, plan, "plan should not be empty")
require.Len(t, plan, 2, "should find 2-hour continuous window")
// Should find cheapest continuous 2-hour window (04:00-06:00)
assert.Equal(t, now.Add(4*time.Hour), plan[0].Start, "should start at cheapest window")
assert.Equal(t, 0.10, plan[0].Value, "first slot should have cheapest window price")
assert.Equal(t, 0.08, plan[1].Value, "second slot should have cheapest window price")
}
// the target time (even at non-slot boundaries) by starting early
func TestContinuous_ExcessTimeFinishesAtTarget(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")
slotDuration := 15 * time.Minute
// Create 20 slots of 15 minutes each (5 hours total)
// Prices: cheaper in the middle slots
prices := []float64{
0.30, 0.30, 0.30, 0.30, // 00:00-01:00 expensive
0.15, 0.10, 0.10, 0.10, // 01:00-02:00 medium+cheap
0.08, 0.08, 0.08, 0.08, // 02:00-03:00 cheapest
0.12, 0.12, 0.12, 0.12, // 03:00-04:00 medium
0.20, 0.20, 0.20, 0.20, // 04:00-05:00 expensive
}
trf := api.NewMockTariff(ctrl)
trf.EXPECT().Rates().AnyTimes().Return(rates(prices, now, slotDuration), nil)
planner := &Planner{
log: log,
clock: c,
tariff: trf,
}
// Target at 03:10 (non-slot boundary - 10 minutes into the 03:00-03:15 slot)
targetTime := now.Add(3*time.Hour + 10*time.Minute)
requiredDuration := 2*time.Hour + 5*time.Minute // need 2h5m, have 3h10m available
plan := planner.Plan(requiredDuration, 0, targetTime, true) // continuous, no precondition
require.NotEmpty(t, plan)
// Plan must not extend beyond target
lastSlot := plan[len(plan)-1]
assert.False(t, lastSlot.End.After(targetTime),
"plan must not extend beyond target")
// Total duration must equal required duration
assert.Equal(t, requiredDuration, Duration(plan), "plan duration must match required")
// Plan should use the cheapest slots (02:00-03:00 range, prices 0.08)
avgCost := AverageCost(plan)
assert.Less(t, avgCost, 0.12, "plan should use cheaper slots")
// Target at 03:10 (non-slot boundary - must finish before target)
requiredDurationShort := 12 * time.Minute // need 12m, have 3h10m available
plan = planner.Plan(requiredDurationShort, 0, targetTime, true) // continuous, no precondition
require.NotEmpty(t, plan)
// Plan must not extend beyond target
lastSlotShort := plan[len(plan)-1]
assert.False(t, lastSlotShort.End.After(targetTime),
"plan must not extend beyond target")
// Total duration must equal required duration
assert.Equal(t, requiredDurationShort, Duration(plan), "plan (short) duration must match required")
// Plan should use the cheapest slots
avgCostShort := AverageCost(plan)
assert.Equal(t, 0.08, avgCostShort, "plan (short) should use cheapest slots (0.08)")
// Target at 03:10 (non-slot boundary - must finish before target)
requiredDurationMedium := 27 * time.Minute // need 27m, have 3h10m available
plan = planner.Plan(requiredDurationMedium, 0, targetTime, true) // continuous, no precondition
require.NotEmpty(t, plan)
// Plan must not extend beyond target
lastSlotMedium := plan[len(plan)-1]
assert.False(t, lastSlotMedium.End.After(targetTime),
"plan must not extend beyond target")
// Total duration must equal required duration
assert.Equal(t, requiredDurationMedium, Duration(plan), "plan (medium) duration must match required")
// Plan should use the cheapest slots
avgCostMedium := AverageCost(plan)
assert.Equal(t, 0.08, avgCostMedium, "plan (medium) should use cheapest slots (0.08)")
}