evcc-io/core/planner/planner.go

244 lines
5.4 KiB
Go

package planner
import (
"slices"
"time"
"github.com/benbjohnson/clock"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
)
// Planner plans a series of charging slots for a given (variable) tariff
type Planner struct {
log *util.Logger
clock clock.Clock // mockable time
tariff api.Tariff
}
// New creates a price planner
func New(log *util.Logger, tariff api.Tariff, opt ...func(t *Planner)) *Planner {
p := &Planner{
log: log,
clock: clock.New(),
tariff: tariff,
}
for _, o := range opt {
o(p)
}
return p
}
// plan creates a lowest-cost plan or required duration.
// It MUST already established that
// - rates are sorted in ascending order by cost and descending order by start time (prefer late slots)
// - target time and required duration are before end of rates
func (t *Planner) plan(rates api.Rates, requiredDuration time.Duration, targetTime time.Time) api.Rates {
var plan api.Rates
for _, source := range rates {
// slot not relevant
if !(source.End.After(t.clock.Now()) && source.Start.Before(targetTime)) {
continue
}
// adjust slot start and end
slot := source
if slot.Start.Before(t.clock.Now()) {
slot.Start = t.clock.Now()
}
if slot.End.After(targetTime) {
slot.End = targetTime
}
slotDuration := slot.End.Sub(slot.Start)
requiredDuration -= slotDuration
// slot covers more than we need, so shorten it
if requiredDuration < 0 {
// the first (if not single) slot should start as late as possible
if IsFirst(slot, plan) && len(plan) > 0 {
slot.Start = slot.Start.Add(-requiredDuration)
} else {
slot.End = slot.End.Add(requiredDuration)
}
requiredDuration = 0
if slot.End.Before(slot.Start) {
panic("slot end before start")
}
}
plan = append(plan, slot)
// we found all necessary slots
if requiredDuration == 0 {
break
}
}
return plan
}
// Plan creates a continuous emergency charging plan
func (t *Planner) continuousPlan(rates api.Rates, start, end time.Time) api.Rates {
rates.Sort()
res := make(api.Rates, 0, len(rates)+2)
for _, r := range rates {
// slot before continuous plan
if !r.End.After(start) {
continue
}
// slot after continuous plan
if !r.Start.Before(end) {
continue
}
// adjust first slot
if r.Start.Before(start) && r.End.After(start) {
r.Start = start
}
// adjust last slot
if r.Start.Before(end) && r.End.After(end) {
r.End = end
}
res = append(res, r)
}
if len(res) == 0 {
return []api.Rate{{
Start: start,
End: end,
}}
}
// prepend missing slot
if res[0].Start.After(start) {
res = slices.Insert(res, 0, api.Rate{
Start: start,
End: res[0].Start,
})
}
// append missing slot
if last := res[len(res)-1]; last.End.Before(end) {
res = append(res, api.Rate{
Start: last.End,
End: end,
})
}
return res
}
func (t *Planner) Plan(requiredDuration, precondition time.Duration, targetTime time.Time) api.Rates {
if t == nil || requiredDuration <= 0 {
return nil
}
latestStart := targetTime.Add(-requiredDuration)
if latestStart.Before(t.clock.Now()) {
latestStart = t.clock.Now()
targetTime = latestStart.Add(requiredDuration)
}
// simplePlan only considers time, but not cost
simplePlan := api.Rates{
api.Rate{
Start: latestStart,
End: targetTime,
},
}
// target charging without tariff or late start
if t.tariff == nil {
return simplePlan
}
rates, err := t.tariff.Rates()
// treat like normal target charging if we don't have rates
if len(rates) == 0 || err != nil {
return simplePlan
}
// consume remaining time
if t.clock.Until(targetTime) <= requiredDuration {
return t.continuousPlan(rates, latestStart, targetTime)
}
// cut off all rates after target time
for i := 1; i < len(rates); i++ {
if !rates[i].Start.Before(targetTime) {
rates = rates[:i]
break
}
}
// rates are by default sorted by date, oldest to newest
last := rates[len(rates)-1].End
// reduce planning horizon to available rates
if targetTime.After(last) {
// there is enough time for charging after end of current rates
durationAfterRates := targetTime.Sub(last)
if durationAfterRates >= requiredDuration {
return nil
}
// need to use some of the available slots
t.log.DEBUG.Printf("target time beyond available slots- reducing plan horizon from %v to %v",
requiredDuration.Round(time.Second), durationAfterRates.Round(time.Second))
targetTime = last
requiredDuration -= durationAfterRates
precondition = max(precondition-durationAfterRates, 0)
}
// reduce target time by precondition duration
targetTime = targetTime.Add(-precondition)
requiredDuration = max(requiredDuration-precondition, 0)
// separate precond rates, to be appended to plan afterwards
var precond api.Rates
if precondition > 0 {
rates, precond = splitPreconditionSlots(rates, targetTime)
}
// sort rates by price and time
slices.SortStableFunc(rates, sortByCost)
// create plan unless only precond slots remaining
var plan api.Rates
if requiredDuration > 0 {
plan = t.plan(rates, requiredDuration, targetTime)
// sort plan by time
plan.Sort()
}
// re-append precondition slots
plan = append(plan, precond...)
return plan
}
func splitPreconditionSlots(rates api.Rates, preCondStart time.Time) (api.Rates, api.Rates) {
var res, precond api.Rates
for _, r := range slices.Clone(rates) {
if !r.End.After(preCondStart) {
res = append(res, r)
continue
}
precond = append(precond, r)
}
return res, precond
}