chore: avoid allocating intermediate slices (#26320)

This commit is contained in:
andig 2026-01-01 17:30:51 +01:00 • committed by GitHub
parent 109eaeb696
commit 3115bb1a2e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 79 additions and 44 deletions

View file

@ -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
})

View file

@ -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)
}
}

View file

@ -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()

View file

@ -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)