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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + PPPride + + + Case 1:Charge during most expensiveslot as little as possible. Delaybegin charging in slot 4 + Case 2:Do not delay charging in lastslot. If charge duration iscalculated wrong we canfinish before end. + Case 3:Charge in cheapest times + Case 4:For fixed prices (or very longslots), we start charging as lateas possible + + Case 5a:Delay charge to unknownprices as much as possible + + + + price + + + now + + + 1 + + + + 2 + + + 3 + + + 4 + + + + 5 + + + end + + + + + price + + + now + + + 1 + + + + 2 + + + 3 + + + 4 + + + + 5 + + + end + + + + + + price + + + now + + + 1 + + + + 2 + + + 3 + + + 4 + + + + 5 + + + end + + + + + + price + + + now + + + 1 + + + end + + + removed + + + price + + + now + + + 1 + + + + 2 + + + end + + + + 3* + + + + 4* + + + + 5* + + + + + price + + + now + + + 1 + + + + 2 + + + end + + + + 3* + + + + 4* + + + + 5* + + + Case 5b:Delay charge to unknownprices as much as possible + + 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") }