Skip to content

Commit

Permalink
Forecast: allow multiple solar tariffs (#18920)
Browse files Browse the repository at this point in the history
  • Loading branch information
andig authored Feb 18, 2025
1 parent 2808a29 commit 70dca20
Show file tree
Hide file tree
Showing 15 changed files with 274 additions and 51 deletions.
2 changes: 1 addition & 1 deletion api/globalconfig/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ type Tariffs struct {
FeedIn config.Typed
Co2 config.Typed
Planner config.Typed
Solar config.Typed
Solar []config.Typed
}

type Network struct {
Expand Down
29 changes: 16 additions & 13 deletions api/rates.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package api

import (
"encoding/json"
"fmt"
"slices"
"time"
)
Expand All @@ -23,25 +22,29 @@ func (r Rate) IsZero() bool {
type Rates []Rate

// Sort rates by start time
func (r Rates) Sort() {
slices.SortStableFunc(r, func(i, j Rate) int {
func (rr Rates) Sort() {
slices.SortStableFunc(rr, func(i, j Rate) int {
return i.Start.Compare(j.Start)
})
}

// Current returns the rates current rate or error
func (r Rates) Current(now time.Time) (Rate, error) {
for _, rr := range r {
if !rr.Start.After(now) && rr.End.After(now) {
return rr, nil
// At returns the rate for given timestamp or error.
// Rates MUST be sorted by start time.
func (rr Rates) At(ts time.Time) (Rate, error) {
if i, ok := slices.BinarySearchFunc(rr, ts, func(r Rate, ts time.Time) int {
switch {
case ts.Before(r.Start):
return +1
case !ts.Before(r.End):
return -1
default:
return 0
}
}); ok {
return rr[i], nil
}

if len(r) == 0 {
return Rate{}, fmt.Errorf("no matching rate for: %s", now.Local().Format(time.RFC3339))
}
return Rate{}, fmt.Errorf("no matching rate for: %s, %d rates (%s to %s)",
now.Local().Format(time.RFC3339), len(r), r[0].Start.Local().Format(time.RFC3339), r[len(r)-1].End.Local().Format(time.RFC3339))
return Rate{}, ErrNotAvailable
}

// MarshalMQTT implements server.MQTTMarshaler
Expand Down
38 changes: 38 additions & 0 deletions api/rates_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package api

import (
"testing"
"time"

"github.com/benbjohnson/clock"
"github.com/stretchr/testify/assert"
)

func TestRates(t *testing.T) {
clock := clock.NewMock()
rate := func(start int, val float64) Rate {
return Rate{
Start: clock.Now().Add(time.Duration(start) * time.Hour),
End: clock.Now().Add(time.Duration(start+1) * time.Hour),
Price: val,
}
}

rr := Rates{rate(1, 1), rate(2, 2), rate(3, 3), rate(4, 4)}

_, err := rr.At(clock.Now())
assert.Error(t, err)

for i := 1; i <= 4; i++ {
r, err := rr.At(clock.Now().Add(time.Duration(i) * time.Hour))
assert.NoError(t, err)
assert.Equal(t, float64(i), r.Price)

r, err = rr.At(clock.Now().Add(time.Duration(i)*time.Hour + 30*time.Minute))
assert.NoError(t, err)
assert.Equal(t, float64(i), r.Price)
}

_, err = rr.At(clock.Now().Add(5 * time.Hour))
assert.Error(t, err)
}
35 changes: 34 additions & 1 deletion cmd/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -768,6 +768,35 @@ func configureTariff(u api.TariffUsage, conf config.Typed, t *api.Tariff) error
return nil
}

func configureSolarTariff(conf []config.Typed, t *api.Tariff) error {
var eg errgroup.Group
tt := make([]api.Tariff, len(conf))

for i, conf := range conf {
eg.Go(func() error {
if conf.Type == "" {
return errors.New("missing type")
}

name := fmt.Sprintf("%s-%s-%d", api.TariffUsageSolar, tariff.Name(conf), i)
res, err := tariffInstance(name, conf)
if err != nil {
return &DeviceError{name, err}
}

tt[i] = res
return nil
})
}

if err := eg.Wait(); err != nil {
return err
}

*t = tariff.NewCombined(tt)
return nil
}

func configureTariffs(conf globalconfig.Tariffs) (*tariff.Tariffs, error) {
// migrate settings
if settings.Exists(keys.Tariffs) {
Expand All @@ -789,7 +818,11 @@ func configureTariffs(conf globalconfig.Tariffs) (*tariff.Tariffs, error) {
eg.Go(func() error { return configureTariff(api.TariffUsageFeedIn, conf.FeedIn, &tariffs.FeedIn) })
eg.Go(func() error { return configureTariff(api.TariffUsageCo2, conf.Co2, &tariffs.Co2) })
eg.Go(func() error { return configureTariff(api.TariffUsagePlanner, conf.Planner, &tariffs.Planner) })
eg.Go(func() error { return configureTariff(api.TariffUsageSolar, conf.Solar, &tariffs.Solar) })
if len(conf.Solar) == 1 {
eg.Go(func() error { return configureTariff(api.TariffUsageSolar, conf.Solar[0], &tariffs.Planner) })
} else {
eg.Go(func() error { return configureSolarTariff(conf.Solar, &tariffs.Solar) })
}

if err := eg.Wait(); err != nil {
return nil, &ClassError{ClassTariff, err}
Expand Down
2 changes: 1 addition & 1 deletion core/loadpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -1847,8 +1847,8 @@ func (lp *Loadpoint) Update(sitePower, batteryBoostPower float64, rates api.Rate

case mode == api.ModeMinPV || mode == api.ModePV:
// cheap tariff
rate, _ := rates.Current(time.Now())
if smartCostActive {
rate, _ := rates.At(time.Now())
lp.log.DEBUG.Printf("smart cost active: %.2f", rate.Price)
err = lp.fastCharging()
lp.resetPhaseTimer()
Expand Down
9 changes: 2 additions & 7 deletions core/loadpoint_smartcost.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,9 @@ import (
)

func (lp *Loadpoint) smartCostActive(rates api.Rates) bool {
// potential error has already been logged by site, ignore
rate, _ := rates.Current(time.Now())
if rate.IsZero() {
return false
}

rate, err := rates.At(time.Now())
limit := lp.GetSmartCostLimit()
return limit != nil && rate.Price <= *limit
return err == nil && limit != nil && rate.Price <= *limit
}

// smartCostNextStart returns the next start time for a smart cost rate below the limit
Expand Down
12 changes: 10 additions & 2 deletions core/site.go
Original file line number Diff line number Diff line change
Expand Up @@ -873,9 +873,17 @@ func (site *Site) update(lp updater) {
site.log.WARN.Println("planner:", err)
}

rate, err := rates.Current(time.Now())
rate, err := rates.At(time.Now())
if rates != nil && err != nil {
site.log.WARN.Println("planner:", err)
msg := fmt.Sprintf("no matching rate for: %s", time.Now().Format(time.RFC3339))
if len(rates) > 0 {
msg += fmt.Sprintf(", %d rates (%s to %s)", len(rates),
rates[0].Start.Local().Format(time.RFC3339),
rates[len(rates)-1].End.Local().Format(time.RFC3339),
)
}

site.log.WARN.Println("planner:", msg)
}

batteryGridChargeActive := site.batteryGridChargeActive(rate)
Expand Down
72 changes: 72 additions & 0 deletions tariff/combined.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package tariff

import (
"errors"
"slices"
"time"

"github.com/evcc-io/evcc/api"
)

type combined struct {
tariffs []api.Tariff
}

func NewCombined(tariffs []api.Tariff) api.Tariff {
return &combined{
tariffs: tariffs,
}
}

func (t *combined) Rates() (api.Rates, error) {
var keys []time.Time
for _, t := range t.tariffs {
rr, err := t.Rates()
if err != nil {
return nil, err
}

for _, r := range rr {
if !slices.ContainsFunc(keys, func(ts time.Time) bool {
return ts.Equal(r.Start)
}) {
keys = append(keys, r.Start)
}
}
}

keys = slices.SortedFunc(slices.Values(keys), func(a, b time.Time) int {
return a.Compare(b)
})

var res api.Rates
for _, ts := range keys {
var rate api.Rate

for _, t := range t.tariffs {
r, err := At(t, ts)
if err != nil {
continue
}

if rate.Start.IsZero() {
rate = r
continue
}

if !r.End.Equal(rate.End) {
return nil, errors.New("combined tariffs must have the same period length")
}

rate.Price += r.Price
}

res = append(res, rate)
}

return res, nil
}

func (t *combined) Type() api.TariffType {
return api.TariffTypeSolar
}
42 changes: 42 additions & 0 deletions tariff/combined_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package tariff

import (
"testing"
"time"

"github.com/benbjohnson/clock"
"github.com/evcc-io/evcc/api"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

type tariff struct {
rates api.Rates
}

func (t *tariff) Rates() (api.Rates, error) {
return t.rates, nil
}

func (t *tariff) Type() api.TariffType {
return api.TariffTypeSolar
}

func TestCombined(t *testing.T) {
clock := clock.NewMock()
rate := func(start int, val float64) api.Rate {
return api.Rate{
Start: clock.Now().Add(time.Duration(start) * time.Hour),
End: clock.Now().Add(time.Duration(start+1) * time.Hour),
Price: val,
}
}

a := &tariff{api.Rates{rate(1, 1), rate(2, 2)}}
b := &tariff{api.Rates{rate(2, 2), rate(3, 3)}}
c := &combined{[]api.Tariff{a, b}}

rr, err := c.Rates()
require.NoError(t, err)
assert.Equal(t, api.Rates{rate(1, 1), rate(2, 4), rate(3, 3)}, rr)
}
9 changes: 9 additions & 0 deletions tariff/helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,18 @@ import (
"github.com/cenkalti/backoff/v4"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/config"
"github.com/evcc-io/evcc/util/request"
)

// Name returns the tariff type name
func Name(conf config.Typed) string {
if conf.Other != nil && conf.Other["tariff"] != nil {
return conf.Other["tariff"].(string)
}
return conf.Type
}

func bo() backoff.BackOff {
return backoff.NewExponentialBackOff(
backoff.WithInitialInterval(time.Second),
Expand Down
Loading

0 comments on commit 70dca20

Please sign in to comment.