From ea6cc990945fb89b322afb69b7ca0e2215307ecc Mon Sep 17 00:00:00 2001 From: andig Date: Wed, 22 Jul 2026 12:47:35 +0200 Subject: [PATCH] Planner: fix continuous plan selecting the current slot (#32041) --- core/planner/helper.go | 36 ++++++++++++++++++++++++++++++++---- core/planner/helper_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/core/planner/helper.go b/core/planner/helper.go index 53fc6978f..61bc66e08 100644 --- a/core/planner/helper.go +++ b/core/planner/helper.go @@ -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 { diff --git a/core/planner/helper_test.go b/core/planner/helper_test.go index 2f4c02b68..8088185bd 100644 --- a/core/planner/helper_test.go +++ b/core/planner/helper_test.go @@ -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)