Refactor charge planning (#5569)
* Correct plan duration and add test for long last slot * Split planning and evaluating plan
This commit is contained in:
parent
36be9668a8
commit
48f22d5ab3
5 changed files with 1975 additions and 139 deletions
35
core/planner/helper.go
Normal file
35
core/planner/helper.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
14
core/planner/planner.md
Normal 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
1727
core/planner/planner.svg
Normal file
File diff suppressed because it is too large
Load diff
|
After Width: | Height: | Size: 73 KiB |
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue