From 4a64c657789f45e23a9b19d485e848e97dfe37e8 Mon Sep 17 00:00:00 2001 From: Maschga <88616799+Maschga@users.noreply.github.com> Date: Wed, 29 Oct 2025 17:09:36 +0100 Subject: [PATCH] Tariffs: add optional hourly averaging (#24829) --- api/feature.go | 1 + api/feature_enumer.go | 52 +++--- tariff/embed.go | 9 + tariff/proxy.go | 175 ++---------------- tariff/proxy_average.go | 66 +++++++ tariff/proxy_average_test.go | 35 ++++ tariff/proxy_cache.go | 175 ++++++++++++++++-- .../{proxy_error.go => proxy_cache_error.go} | 0 tariff/proxy_cache_helper.go | 30 +++ tariff/template.go | 2 +- templates/definition/tariff/entsoe.yaml | 2 + util/templates/defaults.yaml | 8 + util/templates/includes/tariff-features.tpl | 5 + util/templates/includes/vehicle-features.tpl | 2 +- 14 files changed, 361 insertions(+), 201 deletions(-) create mode 100644 tariff/proxy_average.go create mode 100644 tariff/proxy_average_test.go rename tariff/{proxy_error.go => proxy_cache_error.go} (100%) create mode 100644 tariff/proxy_cache_helper.go create mode 100644 util/templates/includes/tariff-features.tpl diff --git a/api/feature.go b/api/feature.go index 26273af85..b901a8d43 100644 --- a/api/feature.go +++ b/api/feature.go @@ -8,6 +8,7 @@ const ( CoarseCurrent // charger IntegratedDevice // charger Heating // charger + Average // tariff Cacheable // tariff Offline // vehicle Retryable // vehicle diff --git a/api/feature_enumer.go b/api/feature_enumer.go index d5c18e426..130f4275d 100644 --- a/api/feature_enumer.go +++ b/api/feature_enumer.go @@ -7,11 +7,11 @@ import ( "strings" ) -const _FeatureName = "CoarseCurrentIntegratedDeviceHeatingCacheableOfflineRetryableStreamingWelcomeCharge" +const _FeatureName = "CoarseCurrentIntegratedDeviceHeatingAverageCacheableOfflineRetryableStreamingWelcomeCharge" -var _FeatureIndex = [...]uint8{0, 13, 29, 36, 45, 52, 61, 70, 83} +var _FeatureIndex = [...]uint8{0, 13, 29, 36, 43, 52, 59, 68, 77, 90} -const _FeatureLowerName = "coarsecurrentintegrateddeviceheatingcacheableofflineretryablestreamingwelcomecharge" +const _FeatureLowerName = "coarsecurrentintegrateddeviceheatingaveragecacheableofflineretryablestreamingwelcomecharge" func (i Feature) String() string { i -= 1 @@ -28,14 +28,15 @@ func _FeatureNoOp() { _ = x[CoarseCurrent-(1)] _ = x[IntegratedDevice-(2)] _ = x[Heating-(3)] - _ = x[Cacheable-(4)] - _ = x[Offline-(5)] - _ = x[Retryable-(6)] - _ = x[Streaming-(7)] - _ = x[WelcomeCharge-(8)] + _ = x[Average-(4)] + _ = x[Cacheable-(5)] + _ = x[Offline-(6)] + _ = x[Retryable-(7)] + _ = x[Streaming-(8)] + _ = x[WelcomeCharge-(9)] } -var _FeatureValues = []Feature{CoarseCurrent, IntegratedDevice, Heating, Cacheable, Offline, Retryable, Streaming, WelcomeCharge} +var _FeatureValues = []Feature{CoarseCurrent, IntegratedDevice, Heating, Average, Cacheable, Offline, Retryable, Streaming, WelcomeCharge} var _FeatureNameToValueMap = map[string]Feature{ _FeatureName[0:13]: CoarseCurrent, @@ -44,27 +45,30 @@ var _FeatureNameToValueMap = map[string]Feature{ _FeatureLowerName[13:29]: IntegratedDevice, _FeatureName[29:36]: Heating, _FeatureLowerName[29:36]: Heating, - _FeatureName[36:45]: Cacheable, - _FeatureLowerName[36:45]: Cacheable, - _FeatureName[45:52]: Offline, - _FeatureLowerName[45:52]: Offline, - _FeatureName[52:61]: Retryable, - _FeatureLowerName[52:61]: Retryable, - _FeatureName[61:70]: Streaming, - _FeatureLowerName[61:70]: Streaming, - _FeatureName[70:83]: WelcomeCharge, - _FeatureLowerName[70:83]: WelcomeCharge, + _FeatureName[36:43]: Average, + _FeatureLowerName[36:43]: Average, + _FeatureName[43:52]: Cacheable, + _FeatureLowerName[43:52]: Cacheable, + _FeatureName[52:59]: Offline, + _FeatureLowerName[52:59]: Offline, + _FeatureName[59:68]: Retryable, + _FeatureLowerName[59:68]: Retryable, + _FeatureName[68:77]: Streaming, + _FeatureLowerName[68:77]: Streaming, + _FeatureName[77:90]: WelcomeCharge, + _FeatureLowerName[77:90]: WelcomeCharge, } var _FeatureNames = []string{ _FeatureName[0:13], _FeatureName[13:29], _FeatureName[29:36], - _FeatureName[36:45], - _FeatureName[45:52], - _FeatureName[52:61], - _FeatureName[61:70], - _FeatureName[70:83], + _FeatureName[36:43], + _FeatureName[43:52], + _FeatureName[52:59], + _FeatureName[59:68], + _FeatureName[68:77], + _FeatureName[77:90], } // FeatureString retrieves an enum value from the enum constants string name. diff --git a/tariff/embed.go b/tariff/embed.go index 9f0506546..e3cad9f18 100644 --- a/tariff/embed.go +++ b/tariff/embed.go @@ -5,11 +5,14 @@ import ( "fmt" "time" + "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/plugin/golang/stdlib" "github.com/traefik/yaegi/interp" ) type embed struct { + Features_ []api.Feature `mapstructure:"features"` + Charges float64 `mapstructure:"charges"` Tax float64 `mapstructure:"tax"` Formula string `mapstructure:"formula"` @@ -70,3 +73,9 @@ func (t *embed) totalPrice(price float64, ts time.Time) float64 { } return (price + t.Charges) * (1 + t.Tax) } + +var _ api.FeatureDescriber = (*embed)(nil) + +func (t *embed) Features() []api.Feature { + return t.Features_ +} diff --git a/tariff/proxy.go b/tariff/proxy.go index c466bc8f2..6b5ee3780 100644 --- a/tariff/proxy.go +++ b/tariff/proxy.go @@ -2,43 +2,14 @@ package tariff import ( "context" - "crypto/sha256" - "errors" - "fmt" "slices" - "sync" - "time" "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/util" - "github.com/jinzhu/now" ) -// CachingProxy wraps a tariff with caching -type CachingProxy struct { - mu sync.Mutex - hash [32]byte - - key string - ctx context.Context - typ string - config map[string]any - - cached *cached - tariff api.Tariff -} - -var _ api.Tariff = (*CachingProxy)(nil) - -// NewCachedFromConfig creates a proxy that controls tariff instantiation and caching -func NewCachedFromConfig(ctx context.Context, typ string, other map[string]any) (api.Tariff, error) { - tariffType := typ - if typ == "template" { - if template, ok := other["template"].(string); ok { - tariffType = template - } - } - +// NewProxyFromConfig creates a tariff proxy supporting average or caching +func NewProxyFromConfig(ctx context.Context, typ string, other map[string]any) (api.Tariff, error) { var embed struct { Features []api.Feature `mapstructure:"features"` Other map[string]any `mapstructure:",remain"` @@ -48,143 +19,25 @@ func NewCachedFromConfig(ctx context.Context, typ string, other map[string]any) return nil, err } - if !slices.Contains(embed.Features, api.Cacheable) { - return NewFromConfig(ctx, typ, embed.Other) - } - - p := &CachingProxy{ - ctx: ctx, - typ: typ, - config: embed.Other, - key: tariffType + "-" + cacheKey(typ, other), - } - - // check if we have cached data until end of tomorrow - data, err := p.cacheGet(untilEndOfTomorrow()) - if err != nil { - // attempt to create a new instance - tariff, err := NewFromConfig(ctx, typ, embed.Other) - if err != nil { - // check if we have at least data for the next 24 hours - atLeast2hrs, err2 := p.cacheGet(for24hrs()) - if err2 != nil { - // if not available, return error - return nil, err - } - - // use cached data for the next 24 hours - data = atLeast2hrs - } - - // if instance creation was successful, cache it, otherwise use cached 24hrs of data - if err == nil { - p.tariff = tariff - } - } - - if data != nil { - log := util.NewLogger("tariff") - log.DEBUG.Printf("using cache: %s (start: %s, end: %s)", p.key, - data.Rates[0].Start.Local(), data.Rates[len(data.Rates)-1].End.Local(), - ) - } - - return p, nil -} - -func (p *CachingProxy) createInstance() { - t, err := NewFromConfig(p.ctx, p.typ, p.config) - if err != nil { - t = &proxyError{err} - } - - p.tariff = t -} - -// Rates returns cached data until underlying tariff is created, then delegates to tariff -func (p *CachingProxy) Rates() (api.Rates, error) { - p.mu.Lock() - defer p.mu.Unlock() - - if p.tariff == nil { - if res, err := p.cacheGet(for24hrs()); err == nil { - return res.Rates, nil - } - - p.createInstance() - } - - res, err := p.tariff.Rates() - if err != nil { - return nil, err - } - - if p.dynamicTariff() { - err = p.cachePut(p.tariff.Type(), res) - } - - return res, err -} - -// Type returns the tariff type -func (p *CachingProxy) Type() api.TariffType { - p.mu.Lock() - defer p.mu.Unlock() - - if p.tariff == nil { - if res, err := p.cacheGet(for24hrs()); err == nil { - return res.Type - } - - p.createInstance() - } - - return p.tariff.Type() -} - -func (p *CachingProxy) dynamicTariff() bool { - return slices.Contains([]api.TariffType{ - api.TariffTypePriceForecast, - api.TariffTypeCo2, - api.TariffTypeSolar, - }, p.tariff.Type()) -} - -func (p *CachingProxy) cacheGet(until time.Time) (*cached, error) { - if p.cached == nil { - res, err := cacheGet(p.key) + if slices.Contains(embed.Features, api.Average) { + embed.Other["features"] = sliceMinusElement(embed.Features, api.Average) + t, err := NewFromConfig(ctx, typ, embed.Other) if err != nil { return nil, err } - - p.cached = res + return NewAverageProxy(t) } - if !ratesValid(p.cached.Rates, until) { - return nil, errors.New("not enough rates") + if slices.Contains(embed.Features, api.Cacheable) { + embed.Other["features"] = sliceMinusElement(embed.Features, api.Cacheable) + return NewCachedFromConfig(ctx, typ, embed.Other) } - return p.cached, nil + return NewFromConfig(ctx, typ, other) } -func (p *CachingProxy) cachePut(typ api.TariffType, rates api.Rates) error { - hash := sha256.Sum256(fmt.Append(nil, rates)) - if hash == p.hash { - return nil - } - - p.hash = hash - return cachePut(p.key, typ, rates) -} - -func for24hrs() time.Time { - return time.Now().Add(24 * time.Hour) -} - -func untilEndOfTomorrow() time.Time { - return now.BeginningOfDay().AddDate(0, 0, 2) -} - -func ratesValid(rr api.Rates, until time.Time) bool { - return len(rr) > 0 && !rr[len(rr)-1].End.Before(until) +func sliceMinusElement[T comparable](s []T, el T) []T { + return slices.DeleteFunc(s, func(f T) bool { + return el == f + }) } diff --git a/tariff/proxy_average.go b/tariff/proxy_average.go new file mode 100644 index 000000000..e43166a75 --- /dev/null +++ b/tariff/proxy_average.go @@ -0,0 +1,66 @@ +package tariff + +import ( + "slices" + "time" + + "github.com/evcc-io/evcc/api" +) + +// average wraps a tariff with averaging +type average struct { + average time.Duration + api.Tariff +} + +// NewAverageProxy creates a proxy that tariff averaging +func NewAverageProxy(t api.Tariff) (api.Tariff, error) { + return &average{ + average: time.Hour, + Tariff: t, + }, nil +} + +func (t *average) Rates() (api.Rates, error) { + rates, err := t.Tariff.Rates() + if len(rates) == 0 || err != nil { + return rates, err + } + + return averageSlots(rates, t.average), nil +} + +// averageSlots averages 15-minute slots by period +func averageSlots(rates api.Rates, average time.Duration) api.Rates { + if len(rates) == 0 { + return nil + } + + // accumulate sums and counts per period + avgs := make(map[time.Time]*struct { + sum float64 + cnt int + }) + + for _, r := range rates { + ts := r.Start.Truncate(average) + avg, ok := avgs[ts] + if !ok { + avg = new(struct { + sum float64 + cnt int + }) + avgs[ts] = avg + } + avg.sum += r.Value + avg.cnt++ + } + + res := slices.Clone(rates) + for i, r := range res { + avg := avgs[r.Start.Truncate(average)] + res[i].Value = avg.sum / float64(avg.cnt) + } + + return res +} diff --git a/tariff/proxy_average_test.go b/tariff/proxy_average_test.go new file mode 100644 index 000000000..5e2e356bf --- /dev/null +++ b/tariff/proxy_average_test.go @@ -0,0 +1,35 @@ +package tariff + +import ( + "testing" + "time" + + "github.com/benbjohnson/clock" + "github.com/evcc-io/evcc/api" + "github.com/stretchr/testify/require" +) + +func TestAverage(t *testing.T) { + clock := clock.NewMock() + clock.Add(30 * time.Minute) + + var rr api.Rates + for i := range 5 { + rr = append(rr, api.Rate{ + Start: clock.Now(), + End: clock.Now().Add(SlotDuration), + Value: float64(i + 1), + }) + clock.Add(SlotDuration) + } + + res := averageSlots(rr, time.Hour) + + rr[0].Value = 1.5 + rr[1].Value = 1.5 + rr[2].Value = 4.0 + rr[3].Value = 4.0 + rr[4].Value = 4.0 + + require.Equal(t, rr, res) +} diff --git a/tariff/proxy_cache.go b/tariff/proxy_cache.go index b4fe00ed0..586589bd6 100644 --- a/tariff/proxy_cache.go +++ b/tariff/proxy_cache.go @@ -1,30 +1,177 @@ package tariff import ( + "context" "crypto/sha256" + "errors" "fmt" + "slices" + "sync" + "time" "github.com/evcc-io/evcc/api" - "github.com/evcc-io/evcc/server/db/cache" + "github.com/evcc-io/evcc/util" + "github.com/jinzhu/now" ) -type cached struct { - Type api.TariffType `json:"type"` - Rates api.Rates `json:"rates"` +// cachingProxy wraps a tariff with caching +type cachingProxy struct { + mu sync.Mutex + hash [32]byte + + key string + ctx context.Context + typ string + config map[string]any + + cached *cached + tariff api.Tariff } -func cacheKey(typ string, other map[string]any) string { - return fmt.Sprintf("%x", sha256.Sum256(fmt.Appendf(nil, "%s-%v", typ, other))) +var _ api.Tariff = (*cachingProxy)(nil) + +// NewCachedFromConfig creates a proxy that controls tariff instantiation and caching +func NewCachedFromConfig(ctx context.Context, typ string, other map[string]any) (api.Tariff, error) { + tariffType := typ + if typ == "template" { + if template, ok := other["template"].(string); ok { + tariffType = template + } + } + + p := &cachingProxy{ + ctx: ctx, + typ: typ, + config: other, + key: tariffType + "-" + cacheKey(typ, other), + } + + // check if we have cached data until end of tomorrow + data, err := p.cacheGet(untilEndOfTomorrow()) + if err != nil { + // attempt to create a new instance + tariff, err := NewFromConfig(ctx, typ, other) + if err != nil { + // check if we have at least data for the next 24 hours + atLeast2hrs, err2 := p.cacheGet(for24hrs()) + if err2 != nil { + // if not available, return error + return nil, err + } + + // use cached data for the next 24 hours + data = atLeast2hrs + } + + // if instance creation was successful, cache it, otherwise use cached 24hrs of data + if err == nil { + p.tariff = tariff + } + } + + if data != nil { + log := util.NewLogger("tariff") + log.DEBUG.Printf("using cache: %s (start: %s, end: %s)", p.key, + data.Rates[0].Start.Local(), data.Rates[len(data.Rates)-1].End.Local(), + ) + } + + return p, nil } -func cachePut(key string, typ api.TariffType, rates api.Rates) error { - return cache.Put(key, &cached{ - Type: typ, - Rates: rates, - }) +func (p *cachingProxy) createInstance() { + t, err := NewFromConfig(p.ctx, p.typ, p.config) + if err != nil { + t = &proxyError{err} + } + + p.tariff = t } -func cacheGet(key string) (*cached, error) { - var res cached - return &res, cache.Get(key, &res) +// Rates returns cached data until underlying tariff is created, then delegates to tariff +func (p *cachingProxy) Rates() (api.Rates, error) { + p.mu.Lock() + defer p.mu.Unlock() + + if p.tariff == nil { + if res, err := p.cacheGet(for24hrs()); err == nil { + return res.Rates, nil + } + + p.createInstance() + } + + res, err := p.tariff.Rates() + if err != nil { + return nil, err + } + + if p.dynamicTariff() { + err = p.cachePut(p.tariff.Type(), res) + } + + return res, err +} + +// Type returns the tariff type +func (p *cachingProxy) Type() api.TariffType { + p.mu.Lock() + defer p.mu.Unlock() + + if p.tariff == nil { + if res, err := p.cacheGet(for24hrs()); err == nil { + return res.Type + } + + p.createInstance() + } + + return p.tariff.Type() +} + +func (p *cachingProxy) dynamicTariff() bool { + return slices.Contains([]api.TariffType{ + api.TariffTypePriceForecast, + api.TariffTypeCo2, + api.TariffTypeSolar, + }, p.tariff.Type()) +} + +func (p *cachingProxy) cacheGet(until time.Time) (*cached, error) { + if p.cached == nil { + res, err := cacheGet(p.key) + if err != nil { + return nil, err + } + + p.cached = res + } + + if !ratesValid(p.cached.Rates, until) { + return nil, errors.New("not enough rates") + } + + return p.cached, nil +} + +func (p *cachingProxy) cachePut(typ api.TariffType, rates api.Rates) error { + hash := sha256.Sum256(fmt.Append(nil, rates)) + if hash == p.hash { + return nil + } + + p.hash = hash + return cachePut(p.key, typ, rates) +} + +func for24hrs() time.Time { + return time.Now().Add(24 * time.Hour) +} + +func untilEndOfTomorrow() time.Time { + return now.BeginningOfDay().AddDate(0, 0, 2) +} + +func ratesValid(rr api.Rates, until time.Time) bool { + return len(rr) > 0 && !rr[len(rr)-1].End.Before(until) } diff --git a/tariff/proxy_error.go b/tariff/proxy_cache_error.go similarity index 100% rename from tariff/proxy_error.go rename to tariff/proxy_cache_error.go diff --git a/tariff/proxy_cache_helper.go b/tariff/proxy_cache_helper.go new file mode 100644 index 000000000..b4fe00ed0 --- /dev/null +++ b/tariff/proxy_cache_helper.go @@ -0,0 +1,30 @@ +package tariff + +import ( + "crypto/sha256" + "fmt" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/server/db/cache" +) + +type cached struct { + Type api.TariffType `json:"type"` + Rates api.Rates `json:"rates"` +} + +func cacheKey(typ string, other map[string]any) string { + return fmt.Sprintf("%x", sha256.Sum256(fmt.Appendf(nil, "%s-%v", typ, other))) +} + +func cachePut(key string, typ api.TariffType, rates api.Rates) error { + return cache.Put(key, &cached{ + Type: typ, + Rates: rates, + }) +} + +func cacheGet(key string) (*cached, error) { + var res cached + return &res, cache.Get(key, &res) +} diff --git a/tariff/template.go b/tariff/template.go index 2c9ad18f4..2cd097451 100644 --- a/tariff/template.go +++ b/tariff/template.go @@ -17,5 +17,5 @@ func NewTariffFromTemplateConfig(ctx context.Context, other map[string]interface return nil, err } - return NewCachedFromConfig(ctx, instance.Type, instance.Other) + return NewProxyFromConfig(ctx, instance.Type, instance.Other) } diff --git a/templates/definition/tariff/entsoe.yaml b/templates/definition/tariff/entsoe.yaml index ff9d62191..f0bb99015 100644 --- a/templates/definition/tariff/entsoe.yaml +++ b/templates/definition/tariff/entsoe.yaml @@ -24,8 +24,10 @@ params: de: "siehe https://transparency.entsoe.eu/content/static_content/Static%20content/web%20api/Guide.html#_areas" en: "see https://transparency.entsoe.eu/content/static_content/Static%20content/web%20api/Guide.html#_areas" - preset: tariff-base + - preset: tariff-features render: | type: entsoe securitytoken: {{ .securitytoken }} domain: {{ .domain }} {{ include "tariff-base" . }} + {{ include "tariff-features" . }} diff --git a/util/templates/defaults.yaml b/util/templates/defaults.yaml index d07df49a0..0ea7ccebf 100644 --- a/util/templates/defaults.yaml +++ b/util/templates/defaults.yaml @@ -515,6 +515,13 @@ presets: de: Individuelle Formel zur Berechnung des Preises en: Individual formula for calculating the price example: "math.Max((price + charges) * (1 + tax), 0.0)" + tariff-features: + - name: average + type: bool + description: + en: Average by hour + de: Stündliche Durchschnittskosten verwenden + advanced: true forecast-base: - name: lat required: true @@ -565,6 +572,7 @@ presets: en: Shows °C instead of % - name: icon advanced: true + ocpp: - name: stationid type: string diff --git a/util/templates/includes/tariff-features.tpl b/util/templates/includes/tariff-features.tpl new file mode 100644 index 000000000..80d458b11 --- /dev/null +++ b/util/templates/includes/tariff-features.tpl @@ -0,0 +1,5 @@ +{{ define "tariff-features" }} +{{- if eq .average "true" }} +features: ["average"] +{{- end }} +{{- end }} diff --git a/util/templates/includes/vehicle-features.tpl b/util/templates/includes/vehicle-features.tpl index 9b9089940..ecb50c647 100644 --- a/util/templates/includes/vehicle-features.tpl +++ b/util/templates/includes/vehicle-features.tpl @@ -1,5 +1,5 @@ {{ define "vehicle-features" }} -{{- if or (eq .coarsecurrent "true") (eq .welcomecharge "true") }} +{{- if or (eq .coarsecurrent "true") (eq .welcomecharge "true") (eq .streaming "true") }} features: {{- if eq .coarsecurrent "true" }} - coarsecurrent