Skip to content

Commit 1b8cb72

Browse files
authored
Add ENTSO-E "Day Ahead Pricing" tariff provider (evcc-io#9794)
1 parent 5bdf309 commit 1b8cb72

File tree

12 files changed

+682
-54
lines changed

12 files changed

+682
-54
lines changed

tariff/entsoe.go

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
package tariff
2+
3+
import (
4+
"bytes"
5+
"encoding/xml"
6+
"errors"
7+
"net/http"
8+
"slices"
9+
"strings"
10+
"sync"
11+
"time"
12+
13+
"github.com/cenkalti/backoff/v4"
14+
"github.com/evcc-io/evcc/api"
15+
"github.com/evcc-io/evcc/tariff/entsoe"
16+
"github.com/evcc-io/evcc/util"
17+
"github.com/evcc-io/evcc/util/request"
18+
"github.com/evcc-io/evcc/util/transport"
19+
)
20+
21+
type Entsoe struct {
22+
*request.Helper
23+
*embed
24+
mux sync.Mutex
25+
log *util.Logger
26+
token string
27+
domain string
28+
data api.Rates
29+
updated time.Time
30+
}
31+
32+
var _ api.Tariff = (*Entsoe)(nil)
33+
34+
func init() {
35+
registry.Add("entsoe", NewEntsoeFromConfig)
36+
}
37+
38+
func NewEntsoeFromConfig(other map[string]interface{}) (api.Tariff, error) {
39+
var cc struct {
40+
embed `mapstructure:",squash"`
41+
Securitytoken string
42+
Domain string
43+
}
44+
45+
if err := util.DecodeOther(other, &cc); err != nil {
46+
return nil, err
47+
}
48+
49+
if cc.Securitytoken == "" {
50+
return nil, errors.New("securitytoken must be defined")
51+
}
52+
53+
domain, err := entsoe.Area(entsoe.BZN, strings.ToUpper(cc.Domain))
54+
if err != nil {
55+
return nil, err
56+
}
57+
58+
log := util.NewLogger("entsoe").Redact(cc.Securitytoken)
59+
60+
t := &Entsoe{
61+
log: log,
62+
Helper: request.NewHelper(log),
63+
embed: &cc.embed,
64+
token: cc.Securitytoken,
65+
domain: domain,
66+
}
67+
68+
// Wrap the client with a decorator that adds the security token to each request.
69+
t.Client.Transport = &transport.Decorator{
70+
Base: t.Client.Transport,
71+
Decorator: transport.DecorateQuery(map[string]string{
72+
"securityToken": cc.Securitytoken,
73+
}),
74+
}
75+
76+
done := make(chan error)
77+
go t.run(done)
78+
err = <-done
79+
80+
return t, err
81+
}
82+
83+
func (t *Entsoe) run(done chan error) {
84+
var once sync.Once
85+
86+
bo := newBackoff()
87+
88+
// Data updated by ESO every half hour, but we only need data every hour to stay current.
89+
for ; true; <-time.Tick(time.Hour) {
90+
var tr entsoe.PublicationMarketDocument
91+
92+
if err := backoff.Retry(func() error {
93+
// Request the next 24 hours of data.
94+
data, err := t.DoBody(entsoe.DayAheadPricesRequest(t.domain, time.Hour*24))
95+
96+
// Consider whether errors.As would be more appropriate if this needs to start dealing with wrapped errors.
97+
if se, ok := err.(request.StatusError); ok {
98+
if se.HasStatus(http.StatusBadRequest) {
99+
return backoff.Permanent(se)
100+
}
101+
102+
return se
103+
}
104+
105+
var doc entsoe.Document
106+
if err := xml.NewDecoder(bytes.NewReader(data)).Decode(&doc); err != nil {
107+
return backoff.Permanent(err)
108+
}
109+
110+
switch doc.XMLName.Local {
111+
case entsoe.AcknowledgementMarketDocumentName:
112+
var doc entsoe.AcknowledgementMarketDocument
113+
if err := xml.NewDecoder(bytes.NewReader(data)).Decode(&doc); err != nil {
114+
return backoff.Permanent(err)
115+
}
116+
117+
return backoff.Permanent(errors.New(doc.Reason.Text))
118+
119+
case entsoe.PublicationMarketDocumentName:
120+
if err := xml.NewDecoder(bytes.NewReader(data)).Decode(&tr); err != nil {
121+
return backoff.Permanent(err)
122+
}
123+
124+
if tr.Type != string(entsoe.ProcessTypeDayAhead) {
125+
return backoff.Permanent(errors.New("invalid document type: " + tr.Type))
126+
}
127+
128+
return nil
129+
130+
default:
131+
return backoff.Permanent(errors.New("invalid document name: " + doc.XMLName.Local))
132+
}
133+
}, bo); err != nil {
134+
once.Do(func() { done <- err })
135+
136+
t.log.ERROR.Println(err)
137+
continue
138+
}
139+
140+
if len(tr.TimeSeries) == 0 {
141+
once.Do(func() { done <- entsoe.ErrInvalidData })
142+
t.log.ERROR.Println(entsoe.ErrInvalidData)
143+
continue
144+
}
145+
146+
// extract desired series
147+
tsdata, err := entsoe.GetTsPriceData(tr.TimeSeries, entsoe.ResolutionHour)
148+
if err != nil {
149+
once.Do(func() { done <- err })
150+
t.log.ERROR.Println(err)
151+
continue
152+
}
153+
154+
once.Do(func() { close(done) })
155+
156+
t.mux.Lock()
157+
t.updated = time.Now()
158+
159+
t.data = make(api.Rates, 0, len(tsdata))
160+
for _, r := range tsdata {
161+
ar := api.Rate{
162+
Start: r.ValidityStart,
163+
End: r.ValidityEnd,
164+
Price: t.totalPrice(r.Value),
165+
}
166+
t.data = append(t.data, ar)
167+
}
168+
169+
t.mux.Unlock()
170+
}
171+
}
172+
173+
// Rates implements the api.Tariff interface
174+
func (t *Entsoe) Rates() (api.Rates, error) {
175+
t.mux.Lock()
176+
defer t.mux.Unlock()
177+
return slices.Clone(t.data), outdatedError(t.updated, time.Hour)
178+
}
179+
180+
// Type implements the api.Tariff interface
181+
func (t *Entsoe) Type() api.TariffType {
182+
return api.TariffTypePriceDynamic
183+
}

tariff/entsoe/api.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
// Package entsoe implements a minimalized version of the European Network of Transmission System Operators for Electricity's
2+
// Transparency Platform API (https://transparency.entsoe.eu)
3+
package entsoe
4+
5+
import (
6+
"errors"
7+
"fmt"
8+
"net/http"
9+
"net/url"
10+
"sort"
11+
"time"
12+
13+
"github.com/dylanmei/iso8601"
14+
"github.com/evcc-io/evcc/util/request"
15+
)
16+
17+
const (
18+
// BaseURI is the root path that the API is accessed from.
19+
BaseURI = "https://web-api.tp.entsoe.eu/api"
20+
21+
// numericDateFormat is a time.Parse compliant formatting string for the numeric date format used by entsoe get requests.
22+
numericDateFormat = "200601021504"
23+
)
24+
25+
var ErrInvalidData = errors.New("invalid data received")
26+
27+
// DayAheadPricesRequest constructs a new DayAheadPricesRequest.
28+
func DayAheadPricesRequest(domain string, duration time.Duration) *http.Request {
29+
now := time.Now().Truncate(time.Hour)
30+
31+
params := url.Values{
32+
"DocumentType": {string(ProcessTypeDayAhead)},
33+
"In_Domain": {domain},
34+
"Out_Domain": {domain},
35+
"PeriodStart": {now.Format(numericDateFormat)},
36+
"PeriodEnd": {now.Add(duration).Format(numericDateFormat)},
37+
}
38+
39+
uri := BaseURI + "?" + params.Encode()
40+
req, _ := request.New(http.MethodGet, uri, nil, request.AcceptXML)
41+
42+
return req
43+
}
44+
45+
// RateData defines the per-unit Value over a period of time spanning ValidityStart and ValidityEnd.
46+
type RateData struct {
47+
ValidityStart time.Time
48+
ValidityEnd time.Time
49+
Value float64
50+
}
51+
52+
// GetTsPriceData accepts a set of TimeSeries data entries, and
53+
// returns a sorted array of RateData based on the timestamp of each data entry.
54+
func GetTsPriceData(ts []TimeSeries, resolution ResolutionType) ([]RateData, error) {
55+
for _, v := range ts {
56+
if v.Period.Resolution != resolution {
57+
continue
58+
}
59+
60+
data, err := ExtractTsPriceData(&v)
61+
if err != nil {
62+
return nil, err
63+
}
64+
65+
// Now sort all entries by timestamp.
66+
// Not sure if this is entirely necessary for evcc's use, could consider removing this if it becomes a performance issue.
67+
sort.Slice(data, func(i, j int) bool {
68+
return data[i].ValidityStart.Before(data[j].ValidityStart)
69+
})
70+
71+
return data, nil
72+
}
73+
74+
return nil, fmt.Errorf("no data for resolution: %v", resolution)
75+
}
76+
77+
// ExtractTsPriceData massages the given TimeSeries data set to provide RateData entries with associated start and end timestamps.
78+
func ExtractTsPriceData(timeseries *TimeSeries) ([]RateData, error) {
79+
data := make([]RateData, 0, len(timeseries.Period.Point))
80+
81+
duration, err := iso8601.ParseDuration(string(timeseries.Period.Resolution))
82+
if err != nil {
83+
return nil, err
84+
}
85+
86+
// tCurrencyUnit := timeseries.CurrencyUnitName
87+
// tPriceMeasureUnit := timeseries.PriceMeasureUnitName
88+
// Brief check just to make sure we're about to decode the data as expected.
89+
if timeseries.PriceMeasureUnitName != "MWH" {
90+
return nil, fmt.Errorf("%w: price data not in expected unit", ErrInvalidData)
91+
}
92+
93+
tPointer := timeseries.Period.TimeInterval.Start.Time
94+
for _, point := range timeseries.Period.Point {
95+
d := RateData{
96+
Value: point.PriceAmount / 1e3, // Price/MWh to Price/kWh
97+
ValidityStart: tPointer,
98+
}
99+
100+
// Nudge pointer on as required by defined data resolution
101+
switch timeseries.Period.Resolution {
102+
case ResolutionQuarterHour, ResolutionHalfHour, ResolutionHour:
103+
tPointer = tPointer.Add(duration)
104+
case ResolutionDay:
105+
tPointer = tPointer.AddDate(0, 0, 1)
106+
case ResolutionWeek:
107+
tPointer = tPointer.AddDate(0, 0, 7)
108+
case ResolutionYear:
109+
tPointer = tPointer.AddDate(0, 1, 0)
110+
default:
111+
return nil, fmt.Errorf("invalid resolution: %v", timeseries.Period.Resolution)
112+
}
113+
d.ValidityEnd = tPointer
114+
115+
data = append(data, d)
116+
}
117+
118+
return data, nil
119+
}

0 commit comments

Comments
 (0)