717 lines
26 KiB
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)")
|
|
}
|