Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Planner: remove special handling of last slot #6970

Merged
merged 7 commits into from
Apr 2, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,11 @@ type Rate struct {
Price float64 `json:"price"`
}

// IsEmpty returns is the rate is the zero value
func (r Rate) IsEmpty() bool {
return r.Start.IsZero() && r.End.IsZero() && r.Price == 0
}

// Rates is a slice of (future) tariff rates
type Rates []Rate

Expand Down
5 changes: 2 additions & 3 deletions core/loadpoint_plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,13 +118,12 @@ func (lp *Loadpoint) plannerActive() (active bool) {
lp.log.TRACE.Printf(" slot from: %v to %v cost %.3f", slot.Start.Round(time.Second).Local(), slot.End.Round(time.Second).Local(), slot.Price)
}

activeSlot := planner.ActiveSlot(lp.clock, plan)
activeSlot := planner.SlotAt(lp.clock.Now(), plan)
active = !activeSlot.End.IsZero()

if active {
// ignore short plans if not already active
// TODO only ignore if the next adjacent slot is inactive, too
if slotRemaining := lp.clock.Until(activeSlot.End); !lp.planActive && slotRemaining < smallSlotDuration {
if slotRemaining := lp.clock.Until(activeSlot.End); !lp.planActive && slotRemaining < smallSlotDuration && !planner.SlotHasSuccessor(activeSlot, plan) {
lp.log.DEBUG.Printf("plan slot too short- ignoring remaining %v", slotRemaining.Round(time.Second))
return false
}
Expand Down
28 changes: 25 additions & 3 deletions core/planner/helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package planner
import (
"time"

"github.com/benbjohnson/clock"
"github.com/evcc-io/evcc/api"
)

Expand Down Expand Up @@ -37,11 +36,34 @@ func AverageCost(plan api.Rates) float64 {
return cost / float64(duration)
}

func ActiveSlot(clock clock.Clock, plan api.Rates) api.Rate {
// SlotAt returns the slot for the given time or an empty slot
func SlotAt(time time.Time, plan api.Rates) api.Rate {
for _, slot := range plan {
if (slot.Start.Before(clock.Now()) || slot.Start.Equal(clock.Now())) && slot.End.After(clock.Now()) {
if (slot.Start.Before(time) || slot.Start.Equal(time)) && slot.End.After(time) {
return slot
}
}
return api.Rate{}
}

// SlotHasSuccessor returns if the slot has an immediate successor.
// Does not require the plan to be sorted by start time.
func SlotHasSuccessor(r api.Rate, plan api.Rates) bool {
for _, slot := range plan {
if r.End.Equal(slot.Start) {
return true
}
}
return false
}

// IsFirst returns if the slot is the first slot in the plan.
// Does not require the plan to be sorted by start time.
func IsFirst(r api.Rate, plan api.Rates) bool {
for _, slot := range plan {
if r.Start.After(slot.Start) {
return false
}
}
return true
}
49 changes: 49 additions & 0 deletions core/planner/helper_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package planner

import (
"math/rand"
"testing"
"time"

"github.com/benbjohnson/clock"
"github.com/evcc-io/evcc/api"
"github.com/stretchr/testify/require"
)

func TestSlotHasSuccessor(t *testing.T) {
plan := rates([]float64{20, 60, 10, 80, 40, 90}, time.Now(), time.Hour)

last := plan[len(plan)-1]
rand.Shuffle(len(plan)-1, func(i, j int) {
plan[i], plan[j] = plan[j], plan[i]
})

for i := 0; i < len(plan); i++ {
if plan[i] != last {
require.True(t, SlotHasSuccessor(plan[i], plan))
}
}

require.False(t, SlotHasSuccessor(last, plan))
}

func TestIsFirst(t *testing.T) {
clock := clock.NewMock()
plan := rates([]float64{20, 60, 10, 80, 40, 90}, clock.Now(), time.Hour)

first := plan[0]
rand.Shuffle(len(plan), func(i, j int) {
plan[i], plan[j] = plan[j], plan[i]
})

for i := 1; i < len(plan); i++ {
if plan[i] != first {
require.False(t, IsFirst(plan[i], plan))
}
}

require.True(t, IsFirst(first, plan))

// ensure single slot is always first
require.True(t, IsFirst(first, []api.Rate{first}))
}
9 changes: 7 additions & 2 deletions core/planner/planner.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,14 @@ func (t *Planner) plan(rates api.Rates, requiredDuration time.Duration, targetTi
slotDuration := slot.End.Sub(slot.Start)
requiredDuration -= slotDuration

// slot covers more than we need, so lets start late
// slot covers more than we need, so shorten it
if requiredDuration < 0 {
slot.Start = slot.Start.Add(-requiredDuration)
// the first (if not single) slot should start as late as possible
if IsFirst(slot, plan) && len(plan) > 0 {
slot.Start = slot.Start.Add(-requiredDuration)
} else {
slot.End = slot.End.Add(requiredDuration)
}
requiredDuration = 0

if slot.End.Before(slot.Start) {
Expand Down
64 changes: 26 additions & 38 deletions core/planner/planner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,14 @@ import (
"golang.org/x/exp/slices"
)

func rates(prices []float64, start time.Time, slotEndFunc ...func(time.Time) time.Time) api.Rates {
func rates(prices []float64, start time.Time, slotDuration time.Duration) api.Rates {
res := make(api.Rates, 0, len(prices))

slotEnd := func(start time.Time) time.Time {
return start.Add(time.Hour)
}

if len(slotEndFunc) == 1 {
slotEnd = slotEndFunc[0]
}

for i, v := range prices {
slotStart := start.Add(time.Duration(i) * time.Hour)
ar := api.Rate{
Start: slotStart,
End: slotEnd(slotStart),
End: slotStart.Add(slotDuration),
Price: v,
}
res = append(res, ar)
Expand All @@ -44,7 +36,7 @@ func TestPlan(t *testing.T) {
ctrl := gomock.NewController(t)

trf := mock.NewMockTariff(ctrl)
trf.EXPECT().Rates().AnyTimes().Return(rates([]float64{20, 60, 10, 80, 40, 90}, clock.Now()), nil)
trf.EXPECT().Rates().AnyTimes().Return(rates([]float64{20, 60, 10, 80, 40, 90}, clock.Now(), time.Hour), nil)

p := &Planner{
log: util.NewLogger("foo"),
Expand All @@ -68,9 +60,8 @@ func TestPlan(t *testing.T) {
now time.Time
target time.Time
// result
planStart time.Time
planDuration time.Duration
planCost float64
planStart time.Time
planCost float64
}{
// numbers in brackets denote inactive partial slots
{
Expand All @@ -79,7 +70,6 @@ func TestPlan(t *testing.T) {
clock.Now(),
clock.Now().Add(6 * time.Hour),
clock.Now().Add(2 * time.Hour),
time.Hour,
10,
},
{
Expand All @@ -88,7 +78,6 @@ func TestPlan(t *testing.T) {
clock.Now(),
clock.Now().Add(6 * time.Hour),
clock.Now().Add(0 * time.Hour),
2 * time.Hour,
30,
},
{
Expand All @@ -97,17 +86,14 @@ func TestPlan(t *testing.T) {
clock.Now(),
clock.Now().Add(6 * time.Hour),
clock.Now().Add(30 * time.Minute),
time.Duration(90 * 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),
time.Hour,
10,
},
{
Expand All @@ -116,7 +102,6 @@ func TestPlan(t *testing.T) {
clock.Now().Add(30 * time.Minute),
clock.Now().Add(6 * time.Hour),
clock.Now().Add(30 * time.Minute),
2 * time.Hour,
40,
},
{
Expand All @@ -125,7 +110,6 @@ func TestPlan(t *testing.T) {
clock.Now().Add(30 * time.Minute),
clock.Now().Add(6 * time.Hour),
clock.Now().Add(30 * time.Minute),
time.Duration(90 * time.Minute),
20,
},
}
Expand All @@ -136,7 +120,7 @@ func TestPlan(t *testing.T) {
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.planDuration, Duration(plan), "case %d duration", 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)
}
}
Expand All @@ -151,19 +135,19 @@ func TestNilTariff(t *testing.T) {

plan, err := p.Plan(time.Hour, clock.Now().Add(30*time.Minute))
assert.NoError(t, err)
assert.True(t, !ActiveSlot(clock, plan).End.IsZero(), "should start past start time")
assert.True(t, !SlotAt(clock.Now(), plan).IsEmpty(), "should start past start time")

plan, err = p.Plan(time.Hour, clock.Now().Add(-30*time.Minute))
assert.NoError(t, err)
assert.False(t, !ActiveSlot(clock, plan).End.IsZero(), "should not start past target time")
assert.False(t, !SlotAt(clock.Now(), plan).IsEmpty(), "should not start past target time")
}

func TestFlatTariffTargetInThePast(t *testing.T) {
clock := clock.NewMock()
ctrl := gomock.NewController(t)

trf := mock.NewMockTariff(ctrl)
trf.EXPECT().Rates().AnyTimes().Return(rates([]float64{0}, clock.Now()), nil)
trf.EXPECT().Rates().AnyTimes().Return(rates([]float64{0}, clock.Now(), time.Hour), nil)

p := &Planner{
log: util.NewLogger("foo"),
Expand All @@ -173,43 +157,47 @@ func TestFlatTariffTargetInThePast(t *testing.T) {

plan, err := p.Plan(time.Hour, clock.Now().Add(30*time.Minute))
assert.NoError(t, err)
assert.True(t, !ActiveSlot(clock, plan).End.IsZero(), "should start past start time")
assert.True(t, !SlotAt(clock.Now(), plan).IsEmpty(), "should start past start time")

plan, err = p.Plan(time.Hour, clock.Now().Add(-30*time.Minute))
assert.NoError(t, err)
assert.False(t, !ActiveSlot(clock, plan).End.IsZero(), "should not start past target time")
assert.False(t, !SlotAt(clock.Now(), plan).IsEmpty(), "should not start past target time")
}

func TestFlatTariffLongSlots(t *testing.T) {
clock := clock.NewMock()
ctrl := gomock.NewController(t)

trf := mock.NewMockTariff(ctrl)
trf.EXPECT().Rates().AnyTimes().Return(rates([]float64{0}, clock.Now(), func(start time.Time) time.Time {
return start.Add(24 * time.Hour)
}), nil)
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, err := p.Plan(time.Hour, clock.Now().Add(2*time.Hour))
assert.NoError(t, err)
assert.False(t, !ActiveSlot(clock, plan).End.IsZero(), "should not start long last slot before due time")
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, err = p.Plan(time.Hour, clock.Now().Add(time.Hour))
assert.NoError(t, err)
assert.True(t, !ActiveSlot(clock, plan).End.IsZero(), "should start long last slot after due time")
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 := mock.NewMockTariff(ctrl)
trf.EXPECT().Rates().AnyTimes().Return(rates([]float64{0}, clock.Now()), nil)
trf.EXPECT().Rates().AnyTimes().Return(rates([]float64{0}, clock.Now(), time.Hour), nil)

p := &Planner{
log: util.NewLogger("foo"),
Expand All @@ -219,19 +207,19 @@ func TestTargetAfterKnownPrices(t *testing.T) {

plan, err := p.Plan(40*time.Minute, clock.Now().Add(2*time.Hour)) // charge efficiency does not allow to test with 1h
assert.NoError(t, err)
assert.False(t, !ActiveSlot(clock, plan).End.IsZero(), "should not start if car can be charged completely after known prices ")
assert.False(t, !SlotAt(clock.Now(), plan).IsEmpty(), "should not start if car can be charged completely after known prices ")

plan, err = p.Plan(2*time.Hour, clock.Now().Add(2*time.Hour))
assert.NoError(t, err)
assert.True(t, !ActiveSlot(clock, plan).End.IsZero(), "should start if car can not be charged completely after known prices ")
assert.True(t, !SlotAt(clock.Now(), plan).IsEmpty(), "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 := mock.NewMockTariff(ctrl)
trf.EXPECT().Rates().AnyTimes().Return(rates([]float64{0, 0, 0, 0}, clock.Now()), nil)
trf.EXPECT().Rates().AnyTimes().Return(rates([]float64{0, 0, 0, 0}, clock.Now(), time.Hour), nil)

p := &Planner{
log: util.NewLogger("foo"),
Expand All @@ -241,9 +229,9 @@ func TestChargeAfterTargetTime(t *testing.T) {

plan, err := p.Plan(time.Hour, clock.Now())
assert.NoError(t, err)
assert.False(t, !ActiveSlot(clock, plan).End.IsZero(), "should not start past target time")
assert.False(t, !SlotAt(clock.Now(), plan).IsEmpty(), "should not start past target time")

plan, err = p.Plan(time.Hour, clock.Now().Add(-time.Hour))
assert.NoError(t, err)
assert.False(t, !ActiveSlot(clock, plan).End.IsZero(), "should not start past target time")
assert.False(t, !SlotAt(clock.Now(), plan).IsEmpty(), "should not start past target time")
}
20 changes: 20 additions & 0 deletions tariff/fixed/zone.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,5 +53,25 @@ func (r Zones) TimeTableMarkers() []HourMin {
}
}

HOURS:
// 1hr intervals
for hour := 0; hour < 24; hour++ {
for _, m := range res {
if m.Hour == hour && m.Min == 0 {
continue HOURS
}
}

// hour is missing
for i, m := range res {
if m.Hour >= hour {
res = slices.Insert(res, i, HourMin{Hour: hour, Min: 0})
continue HOURS
}
}

res = append(res, HourMin{Hour: hour, Min: 0})
}

return res
}
Loading