evcc-io/core/planner/helper.go
2026-01-10 11:35:48 +01:00

170 lines
4 KiB
Go

package planner
import (
"iter"
"slices"
"time"
"github.com/evcc-io/evcc/api"
"github.com/samber/lo/it"
)
// Start returns the earliest slot's start time
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 End(plan api.Rates) time.Time {
var end time.Time
for _, slot := range plan {
if end.IsZero() || slot.End.After(end) {
end = slot.End
}
}
return end
}
// Duration returns the sum of all slot's durations
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
}
// AverageCost returns the time-weighted average cost
func AverageCost(plan api.Rates) float64 {
var cost float64
var duration time.Duration
for _, slot := range plan {
slotDuration := slot.End.Sub(slot.Start)
duration += slotDuration
cost += float64(slotDuration) * slot.Value
}
if duration == 0 {
return 0
}
return cost / float64(duration)
}
// SlotAt returns the slot for the given time or an empty slot
func SlotAt(time time.Time, plan api.Rates) api.Rate {
for _, slot := range plan {
if !slot.Start.After(time) && slot.End.After(time) {
return slot
}
}
return api.Rate{}
}
// SlotHasSuccessor returns if the slot has an immediate successor.
// Does not require the plan to be sorted by start time.
func SlotHasSuccessor(r api.Rate, plan api.Rates) bool {
for _, slot := range plan {
if r.End.Equal(slot.Start) {
return true
}
}
return false
}
// IsFirst returns if the slot is the first slot in the plan.
// Does not require the plan to be sorted by start time.
func IsFirst(r api.Rate, plan api.Rates) bool {
for _, slot := range plan {
if r.Start.After(slot.Start) {
return false
}
}
return true
}
// 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))
return slices.AppendSeq(res, clampRatesSeq(rates, start, end))
}
// 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) {
return
}
// 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
}
}
}
}
// 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.
func findContinuousWindow(rates api.Rates, effectiveDuration time.Duration, targetTime time.Time) api.Rates {
var bestCost *float64
var bestIndex *int
for i := range rates {
windowEnd := rates[i].Start.Add(effectiveDuration)
if windowEnd.After(targetTime) {
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
})
// Prefer later start if equal cost
if bestCost == nil || cost <= *bestCost {
bestCost = &cost
bestIndex = &i
}
}
// No valid window found
if bestIndex == nil {
return nil
}
// Build the best window only once
windowEnd := rates[*bestIndex].Start.Add(effectiveDuration)
return clampRates(rates[*bestIndex:], rates[*bestIndex].Start, windowEnd)
}