Forecast: allow multiple solar tariffs (#18920)

This commit is contained in:
andig 2025-02-18 15:26:26 +01:00 • committed by GitHub
parent 2808a29c84
commit 70dca20f47
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 274 additions and 51 deletions

View file

@ -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 {

View file

@ -2,7 +2,6 @@ package api
import (
"encoding/json"
"fmt"
"slices"
"time"
)
@ -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

38
api/rates_test.go Normal file
View file

@ -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)
}

View file

@ -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) {
@ -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}

View file

@ -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()

View file

@ -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

View file

@ -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)

72
tariff/combined.go Normal file
View file

@ -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
tariff/combined_test.go Normal file
View file

@ -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)
}

View file

@ -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),

View file

@ -18,9 +18,9 @@ import (
type Solcast struct {
*request.Helper
log *util.Logger
sites []string
data *util.Monitor[api.Rates]
log *util.Logger
site string
data *util.Monitor[api.Rates]
}
var _ api.Tariff = (*Solcast)(nil)
@ -30,9 +30,12 @@ func init() {
}
func NewSolcastFromConfig(other map[string]interface{}) (api.Tariff, error) {
var cc struct {
Site string
Token string
cc := struct {
Site string
Token string
Interval time.Duration
}{
Interval: 3 * time.Hour,
}
if err := util.DecodeOther(other, &cc); err != nil {
@ -42,10 +45,6 @@ func NewSolcastFromConfig(other map[string]interface{}) (api.Tariff, error) {
if cc.Site == "" {
return nil, errors.New("missing site id")
}
// TODO multiple sites
// if len(cc.Site) > 1 {
// return nil, errors.New("multiple sites not supported (yet)")
// }
if cc.Token == "" {
return nil, errors.New("missing token")
@ -55,35 +54,30 @@ func NewSolcastFromConfig(other map[string]interface{}) (api.Tariff, error) {
t := &Solcast{
log: log,
sites: []string{cc.Site},
site: cc.Site,
Helper: request.NewHelper(log),
data: util.NewMonitor[api.Rates](6 * /*len(cc.Sites)*/ time.Hour),
data: util.NewMonitor[api.Rates](2 * cc.Interval),
}
t.Client.Transport = transport.BearerAuth(cc.Token, t.Client.Transport)
done := make(chan error)
go t.run(done)
go t.run(cc.Interval, done)
err := <-done
return t, err
}
func (t *Solcast) run(done chan error) {
func (t *Solcast) run(interval time.Duration, done chan error) {
var once sync.Once
// don't exceed 10 requests per 24h
for ; true; <-time.Tick(time.Duration(3*len(t.sites)) * time.Hour) {
for ; true; <-time.Tick(interval) {
var res solcast.Forecasts
if err := backoff.Retry(func() error {
for _, site := range t.sites {
uri := fmt.Sprintf("https://api.solcast.com.au/rooftop_sites/%s/forecasts?period=PT60M&format=json", site)
if err := t.GetJSON(uri, &res); err != nil {
return err
}
}
return nil
uri := fmt.Sprintf("https://api.solcast.com.au/rooftop_sites/%s/forecasts?period=PT60M&format=json", t.site)
return t.GetJSON(uri, &res)
}, bo()); err != nil {
once.Do(func() { done <- err })

View file

@ -13,15 +13,22 @@ type Tariffs struct {
Grid, FeedIn, Co2, Planner, Solar api.Tariff
}
func Now(t api.Tariff) (float64, error) {
// At returns the rate at the given time
func At(t api.Tariff, ts time.Time) (api.Rate, error) {
if t != nil {
if rr, err := t.Rates(); err == nil {
if r, err := rr.Current(time.Now()); err == nil {
return r.Price, nil
if r, err := rr.At(ts); err == nil {
return r, nil
}
}
}
return 0, api.ErrNotAvailable
return api.Rate{}, api.ErrNotAvailable
}
// Now returns the price/cost/value at the given time
func Now(t api.Tariff) (float64, error) {
r, err := At(t, time.Now())
return r.Price, err
}
func Forecast(t api.Tariff) api.Rates {

14
tariff/types.go Normal file
View file

@ -0,0 +1,14 @@
package tariff
type Typed struct {
Type string `json:"type"`
Tariff string `json:"tariff"`
Other map[string]any `mapstructure:",remain" yaml:",inline"`
}
func (t Typed) Name() string {
if t.Type == "template" {
return t.Tariff
}
return t.Type
}

View file

@ -58,6 +58,9 @@ params:
en: API key
de: API-Key
advanced: true
- name: interval
default: 1h
advanced: true
render: |
type: custom
tariff: solar
@ -70,3 +73,4 @@ render: |
"end": (.key | strptime("%FT%T%z") | mktime+3600 | strftime("%FT%TZ")),
"price": .value
} ] | tostring
interval: {{ .interval }}

View file

@ -18,7 +18,11 @@ params:
en: Solcast API Token
de: Solcast API Token
required: true
- name: interval
default: 3h
advanced: true
render: |
type: solcast
site: {{ .site }}
token: {{ .token }}
interval: {{ .interval }}