diff --git a/core/planner/helper.go b/core/planner/helper.go
new file mode 100644
index 000000000..62a266f6d
--- /dev/null
+++ b/core/planner/helper.go
@@ -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
+}
diff --git a/core/planner/planner.go b/core/planner/planner.go
index 864b4279b..3723ebe8b 100644
--- a/core/planner/planner.go
+++ b/core/planner/planner.go
@@ -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
}
diff --git a/core/planner/planner.md b/core/planner/planner.md
new file mode 100644
index 000000000..a26f7405e
--- /dev/null
+++ b/core/planner/planner.md
@@ -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
+
+
+
+## Edge cases
+
+- Target time elapsed: inactive
+- No tariff: active if after or equal start time
diff --git a/core/planner/planner.svg b/core/planner/planner.svg
new file mode 100644
index 000000000..0d8aaeaa3
--- /dev/null
+++ b/core/planner/planner.svg
@@ -0,0 +1,1727 @@
+
+
diff --git a/core/planner/planner_test.go b/core/planner/planner_test.go
index 0ad889dfb..f693ab662 100644
--- a/core/planner/planner_test.go
+++ b/core/planner/planner_test.go
@@ -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")
}