diff --git a/core/planner/helper.go b/core/planner/helper.go index 1b12f5818..2f1840c93 100644 --- a/core/planner/helper.go +++ b/core/planner/helper.go @@ -1,10 +1,11 @@ package planner import ( + "iter" + "slices" "time" "github.com/evcc-io/evcc/api" - "github.com/samber/lo" ) // Start returns the earliest slot's start time @@ -87,42 +88,58 @@ func IsFirst(r api.Rate, plan api.Rates) bool { // clampRates filters rates to the given time window and adjusts boundary slots func clampRates(rates api.Rates, start, end time.Time) api.Rates { - res := make(api.Rates, 0, len(rates)+2) + res := make(api.Rates, 0, len(rates)) + return slices.AppendSeq(res, clampRatesSeq(rates, start, end)) +} - for _, r := range rates { - // slot before continuous plan - if !r.End.After(start) { - continue +// clampRatesSeq returns an iterator for filtering rates to the given time window and adjusts boundary slots +func clampRatesSeq(rates api.Rates, start, end time.Time) iter.Seq[api.Rate] { + return func(yield func(api.Rate) bool) { + for _, r := range rates { + // slot before continuous plan + if !r.End.After(start) { + continue + } + + // slot after continuous plan + if !r.Start.Before(end) { + continue + } + + // calculate adjusted bounds + adjustedStart := r.Start + if adjustedStart.Before(start) { + adjustedStart = start + } + + adjustedEnd := r.End + if adjustedEnd.After(end) { + adjustedEnd = end + } + + // skip if adjustment would create invalid slot + if !adjustedEnd.After(adjustedStart) { + continue + } + + if !yield(api.Rate{ + Start: adjustedStart, + End: adjustedEnd, + Value: r.Value, + }) { + return // Stop early if yield returns false + } } - - // slot after continuous plan - if !r.Start.Before(end) { - continue - } - - // calculate adjusted bounds - adjustedStart := r.Start - if adjustedStart.Before(start) { - adjustedStart = start - } - - adjustedEnd := r.End - if adjustedEnd.After(end) { - adjustedEnd = end - } - - // skip if adjustment would create invalid slot - if !adjustedEnd.After(adjustedStart) { - continue - } - - slot := r - slot.Start = adjustedStart - slot.End = adjustedEnd - res = append(res, slot) } +} - return res +// SumBySeq sums over a sequence +func SumBySeq[T any, R float64](seq iter.Seq[T], iteratee func(item T) R) R { + var sum R + for t := range seq { + sum += iteratee(t) + } + return sum } // findContinuousWindow finds the cheapest continuous window of slots for the given duration. @@ -138,7 +155,7 @@ func findContinuousWindow(rates api.Rates, effectiveDuration time.Duration, targ break } - cost := lo.SumBy(clampRates(rates[i:], rates[i].Start, windowEnd), func(r api.Rate) float64 { + cost := SumBySeq(clampRatesSeq(rates[i:], rates[i].Start, windowEnd), func(r api.Rate) float64 { return float64(r.End.Sub(r.Start)) * r.Value }) diff --git a/core/planner/helper_test.go b/core/planner/helper_test.go index 92d656225..c2759ea23 100644 --- a/core/planner/helper_test.go +++ b/core/planner/helper_test.go @@ -7,6 +7,8 @@ import ( "github.com/benbjohnson/clock" "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/tariff" + "github.com/samber/lo" "github.com/stretchr/testify/require" ) @@ -93,3 +95,23 @@ func TestSlotAt(t *testing.T) { require.Equal(t, 2.0, SlotAt(now.Add(90*time.Minute), plan).Value) require.True(t, SlotAt(now.Add(3*time.Hour), plan).IsZero()) } + +func BenchmarkFindContinuousWindow(b *testing.B) { + rr := rates(lo.RepeatBy(96, func(i int) float64 { + return float64(i) + }), time.Now(), tariff.SlotDuration) + + for b.Loop() { + findContinuousWindow(rr, 4*tariff.SlotDuration, rr[len(rr)-1].End) + } +} + +func BenchmarkOptimalPlan(b *testing.B) { + rr := rates(lo.RepeatBy(96, func(i int) float64 { + return float64(i) + }), time.Now(), tariff.SlotDuration) + + for b.Loop() { + optimalPlan(rr, 4*tariff.SlotDuration, rr[len(rr)-1].End) + } +} diff --git a/core/planner/planner.go b/core/planner/planner.go index d27a2f88b..c0bbe7ecb 100644 --- a/core/planner/planner.go +++ b/core/planner/planner.go @@ -6,6 +6,7 @@ import ( "github.com/benbjohnson/clock" "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/tariff" "github.com/evcc-io/evcc/util" ) @@ -35,8 +36,8 @@ func New(log *util.Logger, tariff api.Tariff, opt ...func(t *Planner)) *Planner // It MUST already be established that: // - rates are sorted in ascending order by cost and descending order by start time (prefer late slots) // - rates are filtered to [now, targetTime] window by caller -func (t *Planner) plan(rates api.Rates, requiredDuration time.Duration, targetTime time.Time) api.Rates { - var plan api.Rates +func optimalPlan(rates api.Rates, requiredDuration time.Duration, targetTime time.Time) api.Rates { + plan := make(api.Rates, 0, int64(requiredDuration)/int64(tariff.SlotDuration)+3) for _, slot := range rates { slotDuration := slot.End.Sub(slot.Start) @@ -199,7 +200,7 @@ func (t *Planner) Plan(requiredDuration, precondition time.Duration, targetTime // sort rates by price and time slices.SortStableFunc(rates, sortByCost) - plan = t.plan(rates, requiredDuration, targetTime) + plan = optimalPlan(rates, requiredDuration, targetTime) // sort plan by time plan.Sort() diff --git a/core/planner/planner_test.go b/core/planner/planner_test.go index e3825e21b..011d7a473 100644 --- a/core/planner/planner_test.go +++ b/core/planner/planner_test.go @@ -48,11 +48,6 @@ func TestPlan(t *testing.T) { trf := api.NewMockTariff(ctrl) trf.EXPECT().Rates().AnyTimes().Return(rates([]float64{20, 60, 10, 80, 40, 90}, clock.Now(), time.Hour), nil) - p := &Planner{ - log: util.NewLogger("foo"), - clock: clock, - } - rates, err := trf.Rates() require.NoError(t, err) @@ -60,7 +55,7 @@ func TestPlan(t *testing.T) { { // filter rates to [now, now] window - should return empty - plan := p.plan(clampRates(rates, clock.Now(), clock.Now()), time.Hour, clock.Now()) + plan := optimalPlan(clampRates(rates, clock.Now(), clock.Now()), time.Hour, clock.Now()) assert.Empty(t, plan) } @@ -129,7 +124,7 @@ func TestPlan(t *testing.T) { t.Log(tc.desc) clock.Set(tc.now) // filter rates to [now, target] window as caller would do - plan := p.plan(clampRates(rates, tc.now, tc.target), tc.duration, tc.target) + plan := optimalPlan(clampRates(rates, tc.now, tc.target), tc.duration, tc.target) assert.Equalf(t, tc.planStart.UTC(), Start(plan).UTC(), "case %d start", i) assert.Equalf(t, tc.duration, Duration(plan), "case %d duration", i)