Forecast: allow multiple solar tariffs (#18920)
This commit is contained in:
parent
2808a29c84
commit
70dca20f47
15 changed files with 274 additions and 51 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
29
api/rates.go
29
api/rates.go
|
|
@ -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
38
api/rates_test.go
Normal 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)
|
||||
}
|
||||
35
cmd/setup.go
35
cmd/setup.go
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
12
core/site.go
12
core/site.go
|
|
@ -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
72
tariff/combined.go
Normal 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
42
tariff/combined_test.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
||||
|
|
|
|||
|
|
@ -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
14
tariff/types.go
Normal 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
|
||||
}
|
||||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue