diff --git a/api/globalconfig/types.go b/api/globalconfig/types.go index d21127c80f..7c1a80f5c8 100644 --- a/api/globalconfig/types.go +++ b/api/globalconfig/types.go @@ -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 { diff --git a/api/rates.go b/api/rates.go index 364bcae69c..59891322c1 100644 --- a/api/rates.go +++ b/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 diff --git a/api/rates_test.go b/api/rates_test.go new file mode 100644 index 0000000000..97baa13138 --- /dev/null +++ b/api/rates_test.go @@ -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) +} diff --git a/cmd/setup.go b/cmd/setup.go index 08f626605c..d87b301ea8 100644 --- a/cmd/setup.go +++ b/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} diff --git a/core/loadpoint.go b/core/loadpoint.go index 3f47f9fdd0..415ae98584 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -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() diff --git a/core/loadpoint_smartcost.go b/core/loadpoint_smartcost.go index b3e7d6c9ee..af22f09b5d 100644 --- a/core/loadpoint_smartcost.go +++ b/core/loadpoint_smartcost.go @@ -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 diff --git a/core/site.go b/core/site.go index 86da0e4074..e905f07fc0 100644 --- a/core/site.go +++ b/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) diff --git a/tariff/combined.go b/tariff/combined.go new file mode 100644 index 0000000000..6517dcef86 --- /dev/null +++ b/tariff/combined.go @@ -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 +} diff --git a/tariff/combined_test.go b/tariff/combined_test.go new file mode 100644 index 0000000000..f90b6ebf05 --- /dev/null +++ b/tariff/combined_test.go @@ -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) +} diff --git a/tariff/helper.go b/tariff/helper.go index 40d14b72d3..9cbb8cc480 100644 --- a/tariff/helper.go +++ b/tariff/helper.go @@ -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), diff --git a/tariff/solcast.go b/tariff/solcast.go index 764934142c..ec6885e138 100644 --- a/tariff/solcast.go +++ b/tariff/solcast.go @@ -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 }) diff --git a/tariff/tariffs.go b/tariff/tariffs.go index 59fb27a7cd..1f2fd8a29b 100644 --- a/tariff/tariffs.go +++ b/tariff/tariffs.go @@ -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 { diff --git a/tariff/types.go b/tariff/types.go new file mode 100644 index 0000000000..e9af1c6993 --- /dev/null +++ b/tariff/types.go @@ -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 +} diff --git a/templates/definition/tariff/forecast-solar.yaml b/templates/definition/tariff/forecast-solar.yaml index 36799c9e09..86696ea27d 100644 --- a/templates/definition/tariff/forecast-solar.yaml +++ b/templates/definition/tariff/forecast-solar.yaml @@ -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 }} diff --git a/templates/definition/tariff/solcast.yaml b/templates/definition/tariff/solcast.yaml index fb396ccb47..f4468178c3 100644 --- a/templates/definition/tariff/solcast.yaml +++ b/templates/definition/tariff/solcast.yaml @@ -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 }}