Refactor charge planning (#5569)

* Correct plan duration and add test for long last slot
* Split planning and evaluating plan
This commit is contained in:
andig 2022-12-26 11:41:36 +01:00 • committed by GitHub
parent 36be9668a8
commit 48f22d5ab3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 1975 additions and 139 deletions

35
core/planner/helper.go Normal file
View file

@ -0,0 +1,35 @@
package planner
import (
"time"
"github.com/evcc-io/evcc/api"
)
func Start(plan api.Rates) time.Time {
var start time.Time
for _, slot := range plan {
if start.IsZero() || slot.Start.Before(start) {
start = slot.Start
}
}
return start
}
func Duration(plan api.Rates) time.Duration {
var duration time.Duration
for _, slot := range plan {
slotDuration := slot.End.Sub(slot.Start)
duration += slotDuration
}
return duration
}
func Cost(plan api.Rates) float64 {
var cost float64
for _, slot := range plan {
slotDuration := slot.End.Sub(slot.Start)
cost += float64(slotDuration) / float64(time.Hour) * slot.Price
}
return cost
}

View file

@ -6,7 +6,6 @@ import (
"github.com/benbjohnson/clock"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/soc"
"github.com/evcc-io/evcc/util"
)
@ -26,6 +25,54 @@ func New(log *util.Logger, tariff api.Tariff) *Planner {
}
}
// Plan creates a lowest-cost plan or required duration.
// It MUST already established that
// - rates are sorted ascendingly by cost and start time
// - target time and required duration are before end of rates
func (t *Planner) Plan(rates api.Rates, requiredDuration time.Duration, targetTime time.Time) api.Rates {
var plan api.Rates
for _, slot := range rates {
// slot not relevant
if slot.Start.After(targetTime) || slot.Start.Equal(targetTime) || slot.End.Before(t.clock.Now()) {
continue
}
slot := slot
// adjust slot start and end
if slot.Start.Before(t.clock.Now()) {
slot.Start = t.clock.Now()
}
if slot.End.After(targetTime) {
slot.End = targetTime
}
slotDuration := slot.End.Sub(slot.Start)
requiredDuration -= slotDuration
// slot covers more than we need, so lets start late
if requiredDuration < 0 {
slot.Start = slot.Start.Add(-requiredDuration)
requiredDuration = 0
if slot.End.Before(slot.Start) {
t.log.ERROR.Print("slot end before start")
}
}
plan = append(plan, slot)
t.log.TRACE.Printf(" slot from: %v to %v cost %.2f", slot.Start.Round(time.Minute), slot.End.Round(time.Minute), slot.Price)
// we found all necessary slots
if requiredDuration == 0 {
break
}
}
return plan
}
// Active determines if current slot should be used for charging for a total required duration until target time
func (t *Planner) Active(requiredDuration time.Duration, targetTime time.Time) (time.Time, bool, error) {
if t == nil || requiredDuration <= 0 {
@ -33,23 +80,20 @@ func (t *Planner) Active(requiredDuration time.Duration, targetTime time.Time) (
}
// calculate start time
requiredDuration = time.Duration(float64(requiredDuration) / soc.ChargeEfficiency)
latestStart := targetTime.Add(-requiredDuration)
startElapsed := t.clock.Now().After(latestStart)
afterStart := t.clock.Now().After(latestStart) || t.clock.Now().Equal(latestStart)
beforeTarget := t.clock.Now().Before(targetTime)
// target charging without tariff
if t.tariff == nil || startElapsed {
return time.Time{}, startElapsed, nil
if t.tariff == nil || afterStart {
return time.Time{}, afterStart && beforeTarget, nil
}
rates, err := t.tariff.Rates()
if err != nil {
return time.Time{}, false, err
}
// treat like normal target charging if we don't have rates
if len(rates) == 0 {
return time.Time{}, startElapsed, nil
if len(rates) == 0 || err != nil {
return time.Time{}, afterStart && beforeTarget, err
}
// rates are by default sorted by date, oldest to newest
@ -67,56 +111,32 @@ func (t *Planner) Active(requiredDuration time.Duration, targetTime time.Time) (
}
// need to use some of the available slots
t.log.DEBUG.Printf("target time beyond available slots- reducing plan horizon from %v to %v", requiredDuration.Round(time.Minute), durationAfterRates.Round(time.Minute))
t.log.DEBUG.Printf("target time beyond available slots- reducing plan horizon from %v to %v",
requiredDuration.Round(time.Minute), durationAfterRates.Round(time.Minute))
targetTime = last
requiredDuration -= durationAfterRates
}
t.log.DEBUG.Printf("planning %s until %v", requiredDuration.Round(time.Minute), targetTime.Round(time.Minute))
t.log.DEBUG.Printf("planning %v until %v", requiredDuration.Round(time.Minute), targetTime.Round(time.Minute))
var active bool
var plannedSlots, currentSlot int
plan := t.Plan(rates, requiredDuration, targetTime)
var activeSlot api.Rate
var planDuration time.Duration
var planSlotEnd time.Time
var planCost float64
for _, slot := range rates {
// slot not relevant
if slot.Start.After(targetTime) || slot.Start.Equal(targetTime) || slot.End.Before(t.clock.Now()) {
continue
}
for _, slot := range plan {
slotDuration := slot.End.Sub(slot.Start)
planDuration += slotDuration
planCost += float64(slotDuration) / float64(time.Hour) * slot.Price
plannedSlots++
// slot covers current timestamp
if (slot.Start.Before(t.clock.Now()) || slot.Start.Equal(t.clock.Now())) && slot.End.After(t.clock.Now()) {
active = true
slot.Start = t.clock.Now()
planSlotEnd = slot.End
currentSlot = plannedSlots
activeSlot = slot
}
planDuration += slot.End.Sub(slot.Start)
planCost += float64(slot.End.Sub(slot.Start)) / float64(time.Hour) * slot.Price
t.log.TRACE.Printf(" slot from: %v to %v cost %.2f, duration running total %s, active: %t",
slot.Start.Round(time.Minute), slot.End.Round(time.Minute),
slot.Price, planDuration.Round(time.Second), active)
// we found all necessary cheap slots
if planDuration >= requiredDuration {
break
}
}
// delay start of most expensive slot if it is not the last and only slot
if currentSlot == plannedSlots && plannedSlots > 1 && planDuration > requiredDuration {
t.log.DEBUG.Printf("delaying expensive slot for %s", (planDuration - requiredDuration).Round(time.Minute))
active = false
}
t.log.DEBUG.Printf("total plan duration: %v, cost: %.2f", planDuration.Round(time.Minute), planCost)
return planSlotEnd, active, nil
return activeSlot.End, !activeSlot.End.IsZero(), nil
}

14
core/planner/planner.md Normal file
View file

@ -0,0 +1,14 @@
# Planner
The `planner` is responsible for developing a lowest-cost plan for charging a `required duration` until `target time`. A plan consists of a number of slots in ascending order of cost.
If the `planner` has an associated `tariff`, costs are derived from the tariff's prices. Without `tariff`, the planner will only evaluate time, but not cost.
The developed plan is then evaluated in terms of total cost and being "active". A plan is considered active when the current time is covered by one of the plan's slots.
## Cases
<img src="planner.svg" width="600">
## Edge cases
- Target time elapsed: inactive
- No tariff: active if after or equal start time

1727
core/planner/planner.svg Normal file

File diff suppressed because it is too large Load diff

After

Width:  |  Height:  |  Size: 73 KiB

View file

@ -1,6 +1,7 @@
package planner
import (
"sort"
"testing"
"time"
@ -12,14 +13,22 @@ import (
"github.com/stretchr/testify/assert"
)
func rates(prices []float64, start time.Time) api.Rates {
func rates(prices []float64, start time.Time, slotEndFunc ...func(time.Time) time.Time) 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: slotStart.Add(1 * time.Hour),
End: slotEnd(slotStart),
Price: v,
}
res = append(res, ar)
@ -28,96 +37,107 @@ func rates(prices []float64, start time.Time) api.Rates {
return res
}
func TestPlanner(t *testing.T) {
dt := time.Hour
// TODO start before start of rates
func TestPlan(t *testing.T) {
clck := clock.NewMock()
ctrl := gomock.NewController(t)
type se struct {
delay time.Duration
cDuration time.Duration
res bool
trf := mock.NewMockTariff(ctrl)
trf.EXPECT().Rates().AnyTimes().Return(rates([]float64{20, 60, 10, 80, 40, 90}, clck.Now()), nil)
p := &Planner{
log: util.NewLogger("foo"),
clock: clck,
}
rates, err := trf.Rates()
assert.NoError(t, err)
sort.Sort(rates)
{
plan := p.Plan(rates, time.Hour, clck.Now())
assert.Equal(t, 0, len(plan))
}
tc := []struct {
desc string
prices []float64
end time.Duration
series []se
desc string
// task
duration time.Duration
now time.Time
target time.Time
// result
planStart time.Time
planDuration time.Duration
planCost float64
}{
{"falling prices", []float64{5, 4, 3, 2, 1, 0, 0, 0, 10}, 5 * time.Hour, []se{
{1*dt - 1, 20 * time.Minute, false},
{2*dt - 1, 20 * time.Minute, false},
{3*dt - 1, 20 * time.Minute, false},
{3 * dt, 20 * time.Minute, false},
{4*dt - 1, 20 * time.Minute, false},
{4*dt - 30*time.Minute, 20 * time.Minute, false}, // start as late as possible
{5*dt - 20*time.Minute, 20 * time.Minute, true},
{5 * dt, 5 * time.Minute, true}, // after desired charge timer,
}},
{"rising prices", []float64{1, 2, 3, 4, 5, 6, 7, 8}, 5 * time.Hour, []se{
{1*dt - 1, time.Hour, true},
{2*dt - 1, 5 * time.Minute, true}, // charging took longer than expected
{3*dt - 1, 0, false},
{5 * dt, 0, false}, // after desired charge timer
}},
{"last slot", []float64{5, 2, 5, 4, 3, 5, 5, 5, 10}, 5 * time.Hour, []se{
{1*dt - 1, 70 * time.Minute, false},
{2*dt - 1, 70 * time.Minute, true},
{3*dt - 1, 20 * time.Minute, false},
{4*dt - 1, 20 * time.Minute, false},
{4 * dt, 20 * time.Minute, true}, // start as late as possible
{4*dt + 40*time.Minute, 20 * time.Minute, true},
}},
{"don't pause last slot", []float64{5, 4, 5, 3, 2, 5, 5, 5, 10}, 5 * time.Hour, []se{
{1*dt - 1, 70 * time.Minute, false},
{2*dt - 1, 70 * time.Minute, false},
{3*dt - 1, 70 * time.Minute, false},
{4*dt - 1, 20 * time.Minute, false},
{4 * dt, 20 * time.Minute, true}, // don't pause last slot
}},
{"delay expensive middle", []float64{5, 4, 3, 5, 5, 5, 5, 5, 10}, 5 * time.Hour, []se{
{1*dt - 1, 70 * time.Minute, false},
{1 * dt, 70 * time.Minute, false},
{2*dt - 1, 61 * time.Minute, true}, // delayed start on expensive slot
{3*dt - 1, 60 * time.Minute, true}, // cheapest slot
}},
{"fixed tariff", []float64{2}, 30 * time.Minute, []se{
{1, 2 * time.Hour, true},
{1, 10 * time.Minute, true},
}},
{"always expensive", []float64{5, 4, 3, 2, 1, 0, 0, 0, 10}, 5 * time.Hour, []se{
{1*dt - 1, time.Minute, false},
{2*dt - 1, time.Minute, false},
{3*dt - 1, time.Minute, false},
{4*dt - 1, time.Minute, false},
{5*dt - 1, time.Minute, true}, // cheapest price
}},
// numbers in brackets denote inactive partial slots
{
"plan 0-0-60-0-0-0",
time.Hour,
clck.Now(),
clck.Now().Add(6 * time.Hour),
clck.Now().Add(2 * time.Hour),
time.Hour,
10,
},
{
"plan 60-0-60-0-0-0",
2 * time.Hour,
clck.Now(),
clck.Now().Add(6 * time.Hour),
clck.Now().Add(0 * time.Hour),
2 * time.Hour,
30,
},
{
"plan (30)30-0-60-0-0-0",
time.Duration(90 * time.Minute),
clck.Now(),
clck.Now().Add(6 * time.Hour),
clck.Now().Add(30 * time.Minute),
time.Duration(90 * time.Minute),
20,
},
{
"plan 0-0-60-0-0-0",
time.Hour,
clck.Now().Add(30 * time.Minute),
clck.Now().Add(6 * time.Hour),
clck.Now().Add(2 * time.Hour),
time.Hour,
10,
},
{
"plan (30)30-0-60-0-30(30)-0",
2 * time.Hour,
clck.Now().Add(30 * time.Minute),
clck.Now().Add(6 * time.Hour),
clck.Now().Add(30 * time.Minute),
2 * time.Hour,
40,
},
{
"plan (30)30-0-60-0-0-0",
time.Duration(90 * time.Minute),
clck.Now().Add(30 * time.Minute),
clck.Now().Add(6 * time.Hour),
clck.Now().Add(30 * time.Minute),
time.Duration(90 * time.Minute),
20,
},
}
clck := clock.NewMock()
for i, tc := range tc {
t.Log(tc.desc)
clck.Set(tc.now)
plan := p.Plan(rates, tc.duration, tc.target)
for _, tc := range tc {
t.Run(tc.desc, func(t *testing.T) {
t.Logf("set: %v", clck.Now())
trf := mock.NewMockTariff(ctrl)
trf.EXPECT().Rates().AnyTimes().Return(rates(tc.prices, clck.Now()), nil)
p := &Planner{
log: util.NewLogger("foo"),
clock: clck,
tariff: trf,
}
start := clck.Now()
for idx, se := range tc.series {
clck.Set(start.Add(se.delay))
_, res, err := p.Active(se.cDuration, start.Add(tc.end))
assert.NoError(t, err)
assert.Equalf(t, se.res, res, "%s case %d: expected %v, got %v", tc.desc, idx+1, se.res, res)
}
})
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.planCost, Cost(plan), "case %d cost", i)
}
}
@ -135,7 +155,7 @@ func TestNilTariff(t *testing.T) {
_, res, err = p.Active(time.Hour, clck.Now().Add(-30*time.Minute))
assert.NoError(t, err)
assert.True(t, res, "should start past target time")
assert.False(t, res, "should not start past target time")
}
func TestFlatTariffTargetInThePast(t *testing.T) {
@ -157,7 +177,31 @@ func TestFlatTariffTargetInThePast(t *testing.T) {
_, res, err = p.Active(time.Hour, clck.Now().Add(-30*time.Minute))
assert.NoError(t, err)
assert.True(t, res, "should start past target time")
assert.False(t, res, "should not start past target time")
}
func TestFlatTariffLongSlots(t *testing.T) {
clck := clock.NewMock()
ctrl := gomock.NewController(t)
trf := mock.NewMockTariff(ctrl)
trf.EXPECT().Rates().AnyTimes().Return(rates([]float64{0}, clck.Now(), func(start time.Time) time.Time {
return start.Add(24 * time.Hour)
}), nil)
p := &Planner{
log: util.NewLogger("foo"),
clock: clck,
tariff: trf,
}
_, res, err := p.Active(time.Hour, clck.Now().Add(2*time.Hour))
assert.NoError(t, err)
assert.False(t, res, "should not start long last slot before due time")
_, res, err = p.Active(time.Hour, clck.Now().Add(time.Hour))
assert.NoError(t, err)
assert.True(t, res, "should start long last slot after due time")
}
func TestTargetAfterKnownPrices(t *testing.T) {
@ -179,7 +223,7 @@ func TestTargetAfterKnownPrices(t *testing.T) {
_, res, err = p.Active(2*time.Hour, clck.Now().Add(2*time.Hour))
assert.NoError(t, err)
assert.True(t, res, "should plan if car can not be charged completely after known prices ")
assert.True(t, res, "should start if car can not be charged completely after known prices ")
}
func TestChargeAfterTargetTime(t *testing.T) {
@ -195,15 +239,11 @@ func TestChargeAfterTargetTime(t *testing.T) {
tariff: trf,
}
_, res, err := p.Active(time.Minute, clck.Now())
_, res, err := p.Active(time.Hour, clck.Now())
assert.NoError(t, err)
assert.True(t, res, "should start when target time reached and car not fully charged")
assert.False(t, res, "should not start past target time")
_, res, err = p.Active(time.Hour, clck.Now().Add(-time.Hour))
assert.NoError(t, err)
assert.True(t, res, "should start when target time past and car not fully charged")
_, res, err = p.Active(0, clck.Now().Add(-time.Hour))
assert.NoError(t, err)
assert.False(t, res, "should not start when fully charged")
assert.False(t, res, "should not start past target time")
}