Planner: fix continuous plan selecting the current slot (#32041)

This commit is contained in:
andig 2026-07-22 12:47:35 +02:00 • committed by GitHub
parent 1b16f23da3
commit ea6cc99094
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 58 additions and 4 deletions

View file

@ -1,12 +1,12 @@
package planner
import (
"cmp"
"iter"
"slices"
"time"
"github.com/evcc-io/evcc/api"
"github.com/samber/lo/it"
)
// Start returns the earliest slot's start time
@ -134,6 +134,36 @@ func clampRatesSeq(rates api.Rates, start, end time.Time) iter.Seq[api.Rate] {
}
}
// windowCost returns the cost of the given window. Durations are summed per price before
// multiplication, hence the result does not depend on how the window's slots are clamped.
func windowCost(rates api.Rates, start, end time.Time) float64 {
type pricedDuration struct {
value float64
dur time.Duration
}
var acc []pricedDuration
for r := range clampRatesSeq(rates, start, end) {
dur := r.End.Sub(r.Start)
if idx := slices.IndexFunc(acc, func(p pricedDuration) bool { return p.value == r.Value }); idx >= 0 {
acc[idx].dur += dur
continue
}
acc = append(acc, pricedDuration{r.Value, dur})
}
slices.SortFunc(acc, func(i, j pricedDuration) int { return cmp.Compare(i.value, j.value) })
var cost float64
for _, p := range acc {
cost += p.value * float64(p.dur)
}
return cost
}
// findContinuousWindow finds the cheapest continuous window of slots for the given duration.
// - rates are filtered to [now, targetTime] window by caller
// Returns the selected rates.
@ -147,9 +177,7 @@ func findContinuousWindow(rates api.Rates, effectiveDuration time.Duration, targ
break
}
cost := it.SumBy(clampRatesSeq(rates[i:], rates[i].Start, windowEnd), func(r api.Rate) float64 {
return float64(r.End.Sub(r.Start)) * r.Value
})
cost := windowCost(rates[i:], rates[i].Start, windowEnd)
// Prefer later start if equal cost
if bestCost == nil || cost <= *bestCost {

View file

@ -105,6 +105,32 @@ func TestSlotAt(t *testing.T) {
require.True(t, SlotAt(now.Add(3*time.Hour), plan).IsZero())
}
// TestFindContinuousWindowFlatRates ensures that equal-cost windows always select the
// latest slot. The current slot is clamped to now, hence its cost is summed from
// different addends than the aligned slots and must not decide the comparison.
func TestFindContinuousWindowFlatRates(t *testing.T) {
day := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
rr := rates(lo.RepeatBy(3*96, func(int) float64 { return 0.291 }), day, tariff.SlotDuration)
target := day.Add(2 * 24 * time.Hour).Add(6*time.Hour + 45*time.Minute)
for _, tc := range []struct {
now time.Duration
duration time.Duration
}{
{16*time.Hour + 45*time.Minute + 35*time.Second, 15*time.Minute + 30*time.Second + 464868247},
{16*time.Hour + 45*time.Minute + 37*time.Second, 15*time.Minute + 30*time.Second + 239819456},
{16*time.Hour + 46*time.Minute + 2*time.Second, 26*time.Minute + 39*time.Second + 438201128},
} {
now := day.Add(tc.now)
plan := findContinuousWindow(clampRates(rr, now, target), tc.duration, target)
require.NotEmpty(t, plan)
latest := target.Add(-tc.duration).Truncate(tariff.SlotDuration)
assert.Equal(t, latest, Start(plan), "now %v, duration %v", now, tc.duration)
}
}
func BenchmarkFindContinuousWindow(b *testing.B) {
rr := rates(lo.RepeatBy(96, func(i int) float64 {
return float64(i)