Add ENTSO-E "Day Ahead Pricing" tariff provider (#9794)
This commit is contained in:
parent
5bdf309d8a
commit
1b8cb72476
12 changed files with 682 additions and 54 deletions
183
tariff/entsoe.go
Normal file
183
tariff/entsoe.go
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
package tariff
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/tariff/entsoe"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
"github.com/evcc-io/evcc/util/request"
|
||||
"github.com/evcc-io/evcc/util/transport"
|
||||
)
|
||||
|
||||
type Entsoe struct {
|
||||
*request.Helper
|
||||
*embed
|
||||
mux sync.Mutex
|
||||
log *util.Logger
|
||||
token string
|
||||
domain string
|
||||
data api.Rates
|
||||
updated time.Time
|
||||
}
|
||||
|
||||
var _ api.Tariff = (*Entsoe)(nil)
|
||||
|
||||
func init() {
|
||||
registry.Add("entsoe", NewEntsoeFromConfig)
|
||||
}
|
||||
|
||||
func NewEntsoeFromConfig(other map[string]interface{}) (api.Tariff, error) {
|
||||
var cc struct {
|
||||
embed `mapstructure:",squash"`
|
||||
Securitytoken string
|
||||
Domain string
|
||||
}
|
||||
|
||||
if err := util.DecodeOther(other, &cc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if cc.Securitytoken == "" {
|
||||
return nil, errors.New("securitytoken must be defined")
|
||||
}
|
||||
|
||||
domain, err := entsoe.Area(entsoe.BZN, strings.ToUpper(cc.Domain))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log := util.NewLogger("entsoe").Redact(cc.Securitytoken)
|
||||
|
||||
t := &Entsoe{
|
||||
log: log,
|
||||
Helper: request.NewHelper(log),
|
||||
embed: &cc.embed,
|
||||
token: cc.Securitytoken,
|
||||
domain: domain,
|
||||
}
|
||||
|
||||
// Wrap the client with a decorator that adds the security token to each request.
|
||||
t.Client.Transport = &transport.Decorator{
|
||||
Base: t.Client.Transport,
|
||||
Decorator: transport.DecorateQuery(map[string]string{
|
||||
"securityToken": cc.Securitytoken,
|
||||
}),
|
||||
}
|
||||
|
||||
done := make(chan error)
|
||||
go t.run(done)
|
||||
err = <-done
|
||||
|
||||
return t, err
|
||||
}
|
||||
|
||||
func (t *Entsoe) run(done chan error) {
|
||||
var once sync.Once
|
||||
|
||||
bo := newBackoff()
|
||||
|
||||
// Data updated by ESO every half hour, but we only need data every hour to stay current.
|
||||
for ; true; <-time.Tick(time.Hour) {
|
||||
var tr entsoe.PublicationMarketDocument
|
||||
|
||||
if err := backoff.Retry(func() error {
|
||||
// Request the next 24 hours of data.
|
||||
data, err := t.DoBody(entsoe.DayAheadPricesRequest(t.domain, time.Hour*24))
|
||||
|
||||
// Consider whether errors.As would be more appropriate if this needs to start dealing with wrapped errors.
|
||||
if se, ok := err.(request.StatusError); ok {
|
||||
if se.HasStatus(http.StatusBadRequest) {
|
||||
return backoff.Permanent(se)
|
||||
}
|
||||
|
||||
return se
|
||||
}
|
||||
|
||||
var doc entsoe.Document
|
||||
if err := xml.NewDecoder(bytes.NewReader(data)).Decode(&doc); err != nil {
|
||||
return backoff.Permanent(err)
|
||||
}
|
||||
|
||||
switch doc.XMLName.Local {
|
||||
case entsoe.AcknowledgementMarketDocumentName:
|
||||
var doc entsoe.AcknowledgementMarketDocument
|
||||
if err := xml.NewDecoder(bytes.NewReader(data)).Decode(&doc); err != nil {
|
||||
return backoff.Permanent(err)
|
||||
}
|
||||
|
||||
return backoff.Permanent(errors.New(doc.Reason.Text))
|
||||
|
||||
case entsoe.PublicationMarketDocumentName:
|
||||
if err := xml.NewDecoder(bytes.NewReader(data)).Decode(&tr); err != nil {
|
||||
return backoff.Permanent(err)
|
||||
}
|
||||
|
||||
if tr.Type != string(entsoe.ProcessTypeDayAhead) {
|
||||
return backoff.Permanent(errors.New("invalid document type: " + tr.Type))
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
default:
|
||||
return backoff.Permanent(errors.New("invalid document name: " + doc.XMLName.Local))
|
||||
}
|
||||
}, bo); err != nil {
|
||||
once.Do(func() { done <- err })
|
||||
|
||||
t.log.ERROR.Println(err)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(tr.TimeSeries) == 0 {
|
||||
once.Do(func() { done <- entsoe.ErrInvalidData })
|
||||
t.log.ERROR.Println(entsoe.ErrInvalidData)
|
||||
continue
|
||||
}
|
||||
|
||||
// extract desired series
|
||||
tsdata, err := entsoe.GetTsPriceData(tr.TimeSeries, entsoe.ResolutionHour)
|
||||
if err != nil {
|
||||
once.Do(func() { done <- err })
|
||||
t.log.ERROR.Println(err)
|
||||
continue
|
||||
}
|
||||
|
||||
once.Do(func() { close(done) })
|
||||
|
||||
t.mux.Lock()
|
||||
t.updated = time.Now()
|
||||
|
||||
t.data = make(api.Rates, 0, len(tsdata))
|
||||
for _, r := range tsdata {
|
||||
ar := api.Rate{
|
||||
Start: r.ValidityStart,
|
||||
End: r.ValidityEnd,
|
||||
Price: t.totalPrice(r.Value),
|
||||
}
|
||||
t.data = append(t.data, ar)
|
||||
}
|
||||
|
||||
t.mux.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Rates implements the api.Tariff interface
|
||||
func (t *Entsoe) Rates() (api.Rates, error) {
|
||||
t.mux.Lock()
|
||||
defer t.mux.Unlock()
|
||||
return slices.Clone(t.data), outdatedError(t.updated, time.Hour)
|
||||
}
|
||||
|
||||
// Type implements the api.Tariff interface
|
||||
func (t *Entsoe) Type() api.TariffType {
|
||||
return api.TariffTypePriceDynamic
|
||||
}
|
||||
119
tariff/entsoe/api.go
Normal file
119
tariff/entsoe/api.go
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
// Package entsoe implements a minimalized version of the European Network of Transmission System Operators for Electricity's
|
||||
// Transparency Platform API (https://transparency.entsoe.eu)
|
||||
package entsoe
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/dylanmei/iso8601"
|
||||
"github.com/evcc-io/evcc/util/request"
|
||||
)
|
||||
|
||||
const (
|
||||
// BaseURI is the root path that the API is accessed from.
|
||||
BaseURI = "https://web-api.tp.entsoe.eu/api"
|
||||
|
||||
// numericDateFormat is a time.Parse compliant formatting string for the numeric date format used by entsoe get requests.
|
||||
numericDateFormat = "200601021504"
|
||||
)
|
||||
|
||||
var ErrInvalidData = errors.New("invalid data received")
|
||||
|
||||
// DayAheadPricesRequest constructs a new DayAheadPricesRequest.
|
||||
func DayAheadPricesRequest(domain string, duration time.Duration) *http.Request {
|
||||
now := time.Now().Truncate(time.Hour)
|
||||
|
||||
params := url.Values{
|
||||
"DocumentType": {string(ProcessTypeDayAhead)},
|
||||
"In_Domain": {domain},
|
||||
"Out_Domain": {domain},
|
||||
"PeriodStart": {now.Format(numericDateFormat)},
|
||||
"PeriodEnd": {now.Add(duration).Format(numericDateFormat)},
|
||||
}
|
||||
|
||||
uri := BaseURI + "?" + params.Encode()
|
||||
req, _ := request.New(http.MethodGet, uri, nil, request.AcceptXML)
|
||||
|
||||
return req
|
||||
}
|
||||
|
||||
// RateData defines the per-unit Value over a period of time spanning ValidityStart and ValidityEnd.
|
||||
type RateData struct {
|
||||
ValidityStart time.Time
|
||||
ValidityEnd time.Time
|
||||
Value float64
|
||||
}
|
||||
|
||||
// GetTsPriceData accepts a set of TimeSeries data entries, and
|
||||
// returns a sorted array of RateData based on the timestamp of each data entry.
|
||||
func GetTsPriceData(ts []TimeSeries, resolution ResolutionType) ([]RateData, error) {
|
||||
for _, v := range ts {
|
||||
if v.Period.Resolution != resolution {
|
||||
continue
|
||||
}
|
||||
|
||||
data, err := ExtractTsPriceData(&v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Now sort all entries by timestamp.
|
||||
// Not sure if this is entirely necessary for evcc's use, could consider removing this if it becomes a performance issue.
|
||||
sort.Slice(data, func(i, j int) bool {
|
||||
return data[i].ValidityStart.Before(data[j].ValidityStart)
|
||||
})
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no data for resolution: %v", resolution)
|
||||
}
|
||||
|
||||
// ExtractTsPriceData massages the given TimeSeries data set to provide RateData entries with associated start and end timestamps.
|
||||
func ExtractTsPriceData(timeseries *TimeSeries) ([]RateData, error) {
|
||||
data := make([]RateData, 0, len(timeseries.Period.Point))
|
||||
|
||||
duration, err := iso8601.ParseDuration(string(timeseries.Period.Resolution))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// tCurrencyUnit := timeseries.CurrencyUnitName
|
||||
// tPriceMeasureUnit := timeseries.PriceMeasureUnitName
|
||||
// Brief check just to make sure we're about to decode the data as expected.
|
||||
if timeseries.PriceMeasureUnitName != "MWH" {
|
||||
return nil, fmt.Errorf("%w: price data not in expected unit", ErrInvalidData)
|
||||
}
|
||||
|
||||
tPointer := timeseries.Period.TimeInterval.Start.Time
|
||||
for _, point := range timeseries.Period.Point {
|
||||
d := RateData{
|
||||
Value: point.PriceAmount / 1e3, // Price/MWh to Price/kWh
|
||||
ValidityStart: tPointer,
|
||||
}
|
||||
|
||||
// Nudge pointer on as required by defined data resolution
|
||||
switch timeseries.Period.Resolution {
|
||||
case ResolutionQuarterHour, ResolutionHalfHour, ResolutionHour:
|
||||
tPointer = tPointer.Add(duration)
|
||||
case ResolutionDay:
|
||||
tPointer = tPointer.AddDate(0, 0, 1)
|
||||
case ResolutionWeek:
|
||||
tPointer = tPointer.AddDate(0, 0, 7)
|
||||
case ResolutionYear:
|
||||
tPointer = tPointer.AddDate(0, 1, 0)
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid resolution: %v", timeseries.Period.Resolution)
|
||||
}
|
||||
d.ValidityEnd = tPointer
|
||||
|
||||
data = append(data, d)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
163
tariff/entsoe/areas.go
Normal file
163
tariff/entsoe/areas.go
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
package entsoe
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var zones = map[string][]string{
|
||||
"10Y1001A1001A016": {"CTA|NIE", "MBA|SEM(SONI)", "SCA|NIE"},
|
||||
"10Y1001A1001A39I": {"SCA|EE", "MBA|EE", "CTA|EE", "BZN|EE", "Estonia (EE)"},
|
||||
"10Y1001A1001A44P": {"IPA|SE1", "BZN|SE1", "MBA|SE1", "SCA|SE1"},
|
||||
"10Y1001A1001A45N": {"SCA|SE2", "MBA|SE2", "BZN|SE2", "IPA|SE2"},
|
||||
"10Y1001A1001A46L": {"IPA|SE3", "BZN|SE3", "MBA|SE3", "SCA|SE3"},
|
||||
"10Y1001A1001A47J": {"SCA|SE4", "MBA|SE4", "BZN|SE4", "IPA|SE4"},
|
||||
"10Y1001A1001A48H": {"IPA|NO5", "IBA|NO5", "BZN|NO5", "MBA|NO5", "SCA|NO5"},
|
||||
"10Y1001A1001A49F": {"SCA|RU", "MBA|RU", "BZN|RU", "CTA|RU"},
|
||||
"10Y1001A1001A50U": {"CTA|RU-KGD", "BZN|RU-KGD", "MBA|RU-KGD", "SCA|RU-KGD"},
|
||||
"10Y1001A1001A51S": {"SCA|BY", "MBA|BY", "BZN|BY", "CTA|BY"},
|
||||
"10Y1001A1001A59C": {"BZN|IE(SEM)", "MBA|IE(SEM)", "SCA|IE(SEM)", "LFB|IE-NIE", "SNA|Ireland"},
|
||||
"10Y1001A1001A63L": {"BZN|DE-AT-LU"},
|
||||
"10Y1001A1001A64J": {"BZN|NO1A"},
|
||||
"10Y1001A1001A65H": {"Denmark (DK)"},
|
||||
"10Y1001A1001A66F": {"BZN|IT-GR"},
|
||||
"10Y1001A1001A67D": {"BZN|IT-North-SI"},
|
||||
"10Y1001A1001A68B": {"BZN|IT-North-CH"},
|
||||
"10Y1001A1001A699": {"BZN|IT-Brindisi", "SCA|IT-Brindisi", "MBA|IT-Z-Brindisi"},
|
||||
"10Y1001A1001A70O": {"MBA|IT-Z-Centre-North", "SCA|IT-Centre-North", "BZN|IT-Centre-North"},
|
||||
"10Y1001A1001A71M": {"BZN|IT-Centre-South", "SCA|IT-Centre-South", "MBA|IT-Z-Centre-South"},
|
||||
"10Y1001A1001A72K": {"MBA|IT-Z-Foggia", "SCA|IT-Foggia", "BZN|IT-Foggia"},
|
||||
"10Y1001A1001A73I": {"BZN|IT-North", "SCA|IT-North", "MBA|IT-Z-North"},
|
||||
"10Y1001A1001A74G": {"MBA|IT-Z-Sardinia", "SCA|IT-Sardinia", "BZN|IT-Sardinia"},
|
||||
"10Y1001A1001A75E": {"BZN|IT-Sicily", "SCA|IT-Sicily", "MBA|IT-Z-Sicily"},
|
||||
"10Y1001A1001A76C": {"MBA|IT-Z-Priolo", "SCA|IT-Priolo", "BZN|IT-Priolo"},
|
||||
"10Y1001A1001A77A": {"BZN|IT-Rossano", "SCA|IT-Rossano", "MBA|IT-Z-Rossano"},
|
||||
"10Y1001A1001A788": {"MBA|IT-Z-South", "SCA|IT-South", "BZN|IT-South"},
|
||||
"10Y1001A1001A796": {"CTA|DK"},
|
||||
"10Y1001A1001A80L": {"BZN|IT-North-AT"},
|
||||
"10Y1001A1001A81J": {"BZN|IT-North-FR"},
|
||||
"10Y1001A1001A82H": {"BZN|DE-LU", "IPA|DE-LU", "SCA|DE-LU", "MBA|DE-LU"},
|
||||
"10Y1001A1001A83F": {"Germany (DE)"},
|
||||
"10Y1001A1001A84D": {"MBA|IT-MACRZONENORTH", "SCA|IT-MACRZONENORTH"},
|
||||
"10Y1001A1001A85B": {"SCA|IT-MACRZONESOUTH", "MBA|IT-MACRZONESOUTH"},
|
||||
"10Y1001A1001A869": {"SCA|UA-DobTPP", "BZN|UA-DobTPP", "CTA|UA-DobTPP"},
|
||||
"10Y1001A1001A877": {"BZN|IT-Malta"},
|
||||
"10Y1001A1001A885": {"BZN|IT-SACOAC"},
|
||||
"10Y1001A1001A893": {"BZN|IT-SACODC", "SCA|IT-SACODC"},
|
||||
"10Y1001A1001A91G": {"SNA|Nordic", "REG|Nordic", "LFB|Nordic"},
|
||||
"10Y1001A1001A92E": {"United Kingdom (UK)"},
|
||||
"10Y1001A1001A93C": {"Malta (MT)", "BZN|MT", "CTA|MT", "SCA|MT", "MBA|MT"},
|
||||
"10Y1001A1001A990": {"MBA|MD", "SCA|MD", "CTA|MD", "BZN|MD", "Moldova (MD)"},
|
||||
"10Y1001A1001B004": {"Armenia (AM)", "BZN|AM", "CTA|AM"},
|
||||
"10Y1001A1001B012": {"CTA|GE", "BZN|GE", "Georgia (GE)", "SCA|GE", "MBA|GE"},
|
||||
"10Y1001A1001B05V": {"Azerbaijan (AZ)", "BZN|AZ", "CTA|AZ"},
|
||||
"10Y1001C--00003F": {"BZN|UA", "Ukraine (UA)", "MBA|UA", "SCA|UA"},
|
||||
"10Y1001C--000182": {"SCA|UA-IPS", "MBA|UA-IPS", "BZN|UA-IPS", "CTA|UA-IPS"},
|
||||
"10Y1001C--00038X": {"BZA|CZ-DE-SK-LT-SE4"},
|
||||
"10Y1001C--00059P": {"REG|CORE"},
|
||||
"10Y1001C--00090V": {"REG|AFRR", "SCA|AFRR"},
|
||||
"10Y1001C--00095L": {"REG|SWE"},
|
||||
"10Y1001C--00096J": {"SCA|IT-Calabria", "MBA|IT-Z-Calabria", "BZN|IT-Calabria"},
|
||||
"10Y1001C--00098F": {"BZN|GB(IFA)"},
|
||||
"10Y1001C--00100H": {"BZN|XK", "CTA|XK", "Kosovo (XK)", "MBA|XK", "LFB|XK", "LFA|XK"},
|
||||
"10Y1001C--00119X": {"SCA|IN"},
|
||||
"10Y1001C--001219": {"BZN|NO2A"},
|
||||
"10Y1001C--00137V": {"REG|ITALYNORTH"},
|
||||
"10Y1001C--00138T": {"REG|GRIT"},
|
||||
"10YAL-KESH-----5": {"LFB|AL", "LFA|AL", "BZN|AL", "CTA|AL", "Albania (AL)", "SCA|AL", "MBA|AL"},
|
||||
"10YAT-APG------L": {"MBA|AT", "SCA|AT", "Austria (AT)", "IPA|AT", "CTA|AT", "BZN|AT", "LFA|AT", "LFB|AT"},
|
||||
"10YBA-JPCC-----D": {"LFA|BA", "BZN|BA", "CTA|BA", "Bosnia and Herz. (BA)", "SCA|BA", "MBA|BA"},
|
||||
"10YBE----------2": {"MBA|BE", "SCA|BE", "Belgium (BE)", "CTA|BE", "BZN|BE", "LFA|BE", "LFB|BE"},
|
||||
"10YCA-BULGARIA-R": {"LFB|BG", "LFA|BG", "BZN|BG", "CTA|BG", "Bulgaria (BG)", "SCA|BG", "MBA|BG"},
|
||||
"10YCB-GERMANY--8": {"SCA|DE_DK1_LU", "LFB|DE_DK1_LU"},
|
||||
"10YCB-JIEL-----9": {"LFB|RS_MK_ME"},
|
||||
"10YCB-POLAND---Z": {"LFB|PL"},
|
||||
"10YCB-SI-HR-BA-3": {"LFB|SI_HR_BA"},
|
||||
"10YCH-SWISSGRIDZ": {"LFB|CH", "LFA|CH", "SCA|CH", "MBA|CH", "Switzerland (CH)", "CTA|CH", "BZN|CH"},
|
||||
"10YCS-CG-TSO---S": {"BZN|ME", "CTA|ME", "Montenegro (ME)", "MBA|ME", "SCA|ME", "LFA|ME"},
|
||||
"10YCS-SERBIATSOV": {"LFA|RS", "SCA|RS", "MBA|RS", "Serbia (RS)", "CTA|RS", "BZN|RS"},
|
||||
"10YCY-1001A0003J": {"BZN|CY", "CTA|CY", "Cyprus (CY)", "MBA|CY", "SCA|CY"},
|
||||
"10YCZ-CEPS-----N": {"SCA|CZ", "MBA|CZ", "Czech Republic (CZ)", "CTA|CZ", "BZN|CZ", "LFA|CZ", "LFB|CZ"},
|
||||
"10YDE-ENBW-----N": {"LFA|DE(TransnetBW)", "CTA|DE(TransnetBW)", "SCA|DE(TransnetBW)"},
|
||||
"10YDE-EON------1": {"SCA|DE(TenneT GER)", "CTA|DE(TenneT GER)", "LFA|DE(TenneT GER)"},
|
||||
"10YDE-RWENET---I": {"LFA|DE(Amprion)", "CTA|DE(Amprion)", "SCA|DE(Amprion)"},
|
||||
"10YDE-VE-------2": {"SCA|DE(50Hertz)", "CTA|DE(50Hertz)", "LFA|DE(50Hertz)", "BZA|DE(50HzT)"},
|
||||
"10YDK-1-------AA": {"BZN|DK1A"},
|
||||
"10YDK-1--------W": {"IPA|DK1", "IBA|DK1", "BZN|DK1", "SCA|DK1", "MBA|DK1", "LFA|DK1"},
|
||||
"10YDK-2--------M": {"LFA|DK2", "MBA|DK2", "SCA|DK2", "IBA|DK2", "IPA|DK2", "BZN|DK2"},
|
||||
"10YDOM-1001A082L": {"CTA|PL-CZ", "BZA|PL-CZ"},
|
||||
"10YDOM-CZ-DE-SKK": {"BZA|CZ-DE-SK", "BZN|CZ+DE+SK"},
|
||||
"10YDOM-PL-SE-LT2": {"BZA|LT-SE4"},
|
||||
"10YDOM-REGION-1V": {"REG|CWE"},
|
||||
"10YES-REE------0": {"LFB|ES", "LFA|ES", "BZN|ES", "Spain (ES)", "CTA|ES", "SCA|ES", "MBA|ES"},
|
||||
"10YEU-CONT-SYNC0": {"SNA|Continental Europe"},
|
||||
"10YFI-1--------U": {"MBA|FI", "SCA|FI", "CTA|FI", "Finland (FI)", "BZN|FI", "IPA|FI", "IBA|FI"},
|
||||
"10YFR-RTE------C": {"BZN|FR", "France (FR)", "CTA|FR", "SCA|FR", "MBA|FR", "LFB|FR", "LFA|FR"},
|
||||
"10YGB----------A": {"LFA|GB", "LFB|GB", "SNA|GB", "MBA|GB", "SCA|GB", "CTA|National Grid", "BZN|GB"},
|
||||
"10YGR-HTSO-----Y": {"BZN|GR", "Greece (GR)", "CTA|GR", "SCA|GR", "MBA|GR", "LFB|GR", "LFA|GR"},
|
||||
"10YHR-HEP------M": {"LFA|HR", "MBA|HR", "SCA|HR", "CTA|HR", "Croatia (HR)", "BZN|HR"},
|
||||
"10YHU-MAVIR----U": {"BZN|HU", "Hungary (HU)", "CTA|HU", "SCA|HU", "MBA|HU", "LFA|HU", "LFB|HU"},
|
||||
"10YIE-1001A00010": {"MBA|SEM(EirGrid)", "SCA|IE", "CTA|IE", "Ireland (IE)"},
|
||||
"10YIT-GRTN-----B": {"Italy (IT)", "CTA|IT", "SCA|IT", "MBA|IT", "LFB|IT", "LFA|IT"},
|
||||
"10YLT-1001A0008Q": {"MBA|LT", "SCA|LT", "CTA|LT", "Lithuania (LT)", "BZN|LT"},
|
||||
"10YLU-CEGEDEL-NQ": {"Luxembourg (LU)", "CTA|LU"},
|
||||
"10YLV-1001A00074": {"CTA|LV", "Latvia (LV)", "BZN|LV", "SCA|LV", "MBA|LV"},
|
||||
"10YMK-MEPSO----8": {"MBA|MK", "SCA|MK", "BZN|MK", "North Macedonia (MK)", "CTA|MK", "LFA|MK"},
|
||||
"10YNL----------L": {"LFA|NL", "LFB|NL", "CTA|NL", "Netherlands (NL)", "BZN|NL", "SCA|NL", "MBA|NL"},
|
||||
"10YNO-0--------C": {"MBA|NO", "SCA|NO", "Norway (NO)", "CTA|NO"},
|
||||
"10YNO-1--------2": {"BZN|NO1", "IBA|NO1", "IPA|NO1", "SCA|NO1", "MBA|NO1"},
|
||||
"10YNO-2--------T": {"MBA|NO2", "SCA|NO2", "IPA|NO2", "IBA|NO2", "BZN|NO2"},
|
||||
"10YNO-3--------J": {"BZN|NO3", "IBA|NO3", "IPA|NO3", "SCA|NO3", "MBA|NO3"},
|
||||
"10YNO-4--------9": {"MBA|NO4", "SCA|NO4", "IPA|NO4", "IBA|NO4", "BZN|NO4"},
|
||||
"10YPL-AREA-----S": {"BZN|PL", "Poland (PL)", "CTA|PL", "SCA|PL", "MBA|PL", "BZA|PL", "LFA|PL"},
|
||||
"10YPT-REN------W": {"LFA|PT", "LFB|PT", "MBA|PT", "SCA|PT", "CTA|PT", "Portugal (PT)", "BZN|PT"},
|
||||
"10YRO-TEL------P": {"BZN|RO", "Romania (RO)", "CTA|RO", "SCA|RO", "MBA|RO", "LFB|RO", "LFA|RO"},
|
||||
"10YSE-1--------K": {"MBA|SE", "SCA|SE", "CTA|SE", "Sweden (SE)"},
|
||||
"10YSI-ELES-----O": {"Slovenia (SI)", "BZN|SI", "CTA|SI", "SCA|SI", "MBA|SI", "LFA|SI"},
|
||||
"10YSK-SEPS-----K": {"LFA|SK", "LFB|SK", "MBA|SK", "SCA|SK", "CTA|SK", "BZN|SK", "Slovakia (SK)"},
|
||||
"10YTR-TEIAS----W": {"Turkey (TR)", "BZN|TR", "CTA|TR", "SCA|TR", "MBA|TR", "LFB|TR", "LFA|TR"},
|
||||
"10YUA-WEPS-----0": {"LFA|UA-BEI", "LFB|UA-BEI", "MBA|UA-BEI", "SCA|UA-BEI", "CTA|UA-BEI", "BZN|UA-BEI"},
|
||||
"11Y0-0000-0265-K": {"BZN|GB(ElecLink)"},
|
||||
"17Y0000009369493": {"BZN|GB(IFA2)"},
|
||||
"46Y000000000007M": {"BZN|DK1-NO1"},
|
||||
"50Y0JVU59B4JWQCU": {"BZN|NO2NSL"},
|
||||
"BY": {"Belarus (BY)"},
|
||||
"RU": {"Russia (RU)"},
|
||||
"IS": {"Iceland (IS)"},
|
||||
}
|
||||
|
||||
type AreaType string
|
||||
|
||||
const (
|
||||
BZN AreaType = "Bidding Zone"
|
||||
BZA AreaType = "Bidding Zone Aggregation"
|
||||
CTA AreaType = "Control Area"
|
||||
MBA AreaType = "Market Balance Area"
|
||||
IBA AreaType = "Imbalance Area"
|
||||
IPA AreaType = "Imbalance Price Area"
|
||||
LFA AreaType = "Load Frequency Control Area"
|
||||
LFB AreaType = "Load Frequency Control Block"
|
||||
REG AreaType = "Region"
|
||||
SCA AreaType = "Scheduling Area"
|
||||
SNA AreaType = "Synchronous Area"
|
||||
)
|
||||
|
||||
func Area(typ AreaType, name string) (string, error) {
|
||||
combined := fmt.Sprintf("%s|%s", typ, name)
|
||||
|
||||
// allows matching country codes
|
||||
suffix := fmt.Sprintf(" (%s)", name)
|
||||
|
||||
for code, names := range zones {
|
||||
if code == name {
|
||||
return code, nil
|
||||
}
|
||||
|
||||
for _, n := range names {
|
||||
if n == name || n == combined || strings.HasSuffix(n, suffix) {
|
||||
return code, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("unknown area: %s", name)
|
||||
}
|
||||
130
tariff/entsoe/static.go
Normal file
130
tariff/entsoe/static.go
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
package entsoe
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
|
||||
"github.com/evcc-io/evcc/util/shortrfc3339"
|
||||
)
|
||||
|
||||
// This file contains static declarations of structs and constants.
|
||||
// Heavily derived from https://github.com/energy-forecast/go-entsoe (MIT license), many thanks!
|
||||
|
||||
type ProcessType string
|
||||
|
||||
const (
|
||||
ProcessTypeDayAhead ProcessType = "A44"
|
||||
)
|
||||
|
||||
type ResolutionType string
|
||||
|
||||
const (
|
||||
ResolutionQuarterHour ResolutionType = "PT15M"
|
||||
ResolutionHalfHour ResolutionType = "PT30M"
|
||||
ResolutionHour ResolutionType = "PT60M"
|
||||
ResolutionDay ResolutionType = "P1D"
|
||||
ResolutionWeek ResolutionType = "P7D"
|
||||
ResolutionYear ResolutionType = "P1Y"
|
||||
)
|
||||
|
||||
type AttributeInstanceComponent struct {
|
||||
XMLName xml.Name `xml:"AttributeInstanceComponent"`
|
||||
Attribute string `xml:"attribute"`
|
||||
AttributeValue string `xml:"attributeValue"`
|
||||
}
|
||||
|
||||
const (
|
||||
AcknowledgementMarketDocumentName = "Acknowledgement_MarketDocument"
|
||||
PublicationMarketDocumentName = "Publication_MarketDocument"
|
||||
)
|
||||
|
||||
type Document struct {
|
||||
XMLName xml.Name
|
||||
}
|
||||
|
||||
type AcknowledgementMarketDocument struct {
|
||||
XMLName xml.Name `xml:"Acknowledgement_MarketDocument"`
|
||||
Xmlns string `xml:"xmlns,attr"`
|
||||
MRID string `xml:"mRID"` // abbbeef260884cb9b43858124...
|
||||
RevisionNumber string `xml:"revisionNumber"` // 1, 1, 1, 1, 1, 1, 1, 1, 1...
|
||||
Type string `xml:"type"` // A44, A25, A25, A09, A11, ...
|
||||
SenderMarketParticipantMRID struct {
|
||||
Text string `xml:",chardata"` // 10X1001A1001A450, 10X1001...
|
||||
CodingScheme string `xml:"codingScheme,attr"`
|
||||
} `xml:"sender_MarketParticipant.mRID"`
|
||||
SenderMarketParticipantMarketRoleType string `xml:"sender_MarketParticipant.marketRole.type"` // A32, A32, A32, A32, A32, ...
|
||||
ReceiverMarketParticipantMRID struct {
|
||||
Text string `xml:",chardata"` // 10X1001A1001A450, 10X1001...
|
||||
CodingScheme string `xml:"codingScheme,attr"`
|
||||
} `xml:"receiver_MarketParticipant.mRID"`
|
||||
ReceiverMarketParticipantMarketRoleType string `xml:"receiver_MarketParticipant.marketRole.type"` // A33, A33, A33, A33, A33, ...
|
||||
CreatedDateTime string `xml:"createdDateTime"` // 2020-09-12T00:13:15Z, 202...
|
||||
Reason struct {
|
||||
Code int `xml:"code"`
|
||||
Text string `xml:"text"`
|
||||
} `xml:"Reason"`
|
||||
}
|
||||
|
||||
type PublicationMarketDocument struct {
|
||||
XMLName xml.Name `xml:"Publication_MarketDocument"`
|
||||
Text string `xml:",chardata"`
|
||||
Xmlns string `xml:"xmlns,attr"`
|
||||
MRID string `xml:"mRID"` // abbbeef260884cb9b43858124...
|
||||
RevisionNumber string `xml:"revisionNumber"` // 1, 1, 1, 1, 1, 1, 1, 1, 1...
|
||||
Type string `xml:"type"` // A44, A25, A25, A09, A11, ...
|
||||
SenderMarketParticipantMRID struct {
|
||||
Text string `xml:",chardata"` // 10X1001A1001A450, 10X1001...
|
||||
CodingScheme string `xml:"codingScheme,attr"`
|
||||
} `xml:"sender_MarketParticipant.mRID"`
|
||||
SenderMarketParticipantMarketRoleType string `xml:"sender_MarketParticipant.marketRole.type"` // A32, A32, A32, A32, A32, ...
|
||||
ReceiverMarketParticipantMRID struct {
|
||||
Text string `xml:",chardata"` // 10X1001A1001A450, 10X1001...
|
||||
CodingScheme string `xml:"codingScheme,attr"`
|
||||
} `xml:"receiver_MarketParticipant.mRID"`
|
||||
ReceiverMarketParticipantMarketRoleType string `xml:"receiver_MarketParticipant.marketRole.type"` // A33, A33, A33, A33, A33, ...
|
||||
CreatedDateTime string `xml:"createdDateTime"` // 2020-09-12T00:13:15Z, 202...
|
||||
PeriodTimeInterval struct {
|
||||
Text string `xml:",chardata"`
|
||||
Start shortrfc3339.Timestamp `xml:"start"` // 2015-12-31T23:00Z, 2015-1...
|
||||
End shortrfc3339.Timestamp `xml:"end"` // 2016-12-31T23:00Z, 2016-1...
|
||||
} `xml:"period.timeInterval"`
|
||||
TimeSeries []TimeSeries `xml:"TimeSeries"`
|
||||
}
|
||||
type TimeSeries struct {
|
||||
Text string `xml:",chardata"`
|
||||
MRID string `xml:"mRID"` // 1, 2, 3, 4, 5, 6, 7, 8, 9...
|
||||
BusinessType string `xml:"businessType"` // A62, A62, A62, A62, A62, ...
|
||||
InDomainMRID struct {
|
||||
Text string `xml:",chardata"` // 10YCZ-CEPS-----N, 10YCZ-C...
|
||||
CodingScheme string `xml:"codingScheme,attr"`
|
||||
} `xml:"in_Domain.mRID"`
|
||||
OutDomainMRID struct {
|
||||
Text string `xml:",chardata"` // 10YCZ-CEPS-----N, 10YCZ-C...
|
||||
CodingScheme string `xml:"codingScheme,attr"`
|
||||
} `xml:"out_Domain.mRID"`
|
||||
CurrencyUnitName string `xml:"currency_Unit.name"` // EUR, EUR, EUR, EUR, EUR, ...
|
||||
PriceMeasureUnitName string `xml:"price_Measure_Unit.name"` // MWH, MWH, MWH, MWH, MWH, ...
|
||||
CurveType string `xml:"curveType"` // A01, A01, A01, A01, A01, ...
|
||||
Period TimeSeriesPeriod `xml:"Period"`
|
||||
AuctionType string `xml:"auction.type"` // A01, A01, A01, A01, A01, ...
|
||||
ContractMarketAgreementType string `xml:"contract_MarketAgreement.type"` // A01, A01, A01, A01, A01, ...
|
||||
QuantityMeasureUnitName string `xml:"quantity_Measure_Unit.name"` // MAW, MAW, MAW, MAW, MAW, ...
|
||||
AuctionMRID string `xml:"auction.mRID"` // CP_A_Hourly_SK-UA, CP_A_D...
|
||||
AuctionCategory string `xml:"auction.category"` // A04, A04, A01, A01, A01, ...
|
||||
ClassificationSequenceAttributeInstanceComponentPosition string `xml:"classificationSequence_AttributeInstanceComponent.position"` // 1, 1
|
||||
}
|
||||
|
||||
type TimeSeriesPeriod struct {
|
||||
Text string `xml:",chardata"`
|
||||
TimeInterval struct {
|
||||
Text string `xml:",chardata"`
|
||||
Start shortrfc3339.Timestamp `xml:"start"` // 2015-12-31T23:00Z, 2016-0...
|
||||
End shortrfc3339.Timestamp `xml:"end"` // 2016-01-01T23:00Z, 2016-0...
|
||||
} `xml:"timeInterval"`
|
||||
Resolution ResolutionType `xml:"resolution"` // PT60M, PT60M, PT60M, PT60...
|
||||
Point []struct {
|
||||
Text string `xml:",chardata"`
|
||||
Position int `xml:"position"` // 1, 2, 3, 4, 5, 6, 7, 8, 9...
|
||||
PriceAmount float64 `xml:"price.amount"` // 16.50, 15.50, 14.00, 10.0...
|
||||
Quantity string `xml:"quantity"` // 226, 87, 104, 189, 217, 8...
|
||||
} `xml:"Point"`
|
||||
}
|
||||
|
|
@ -9,7 +9,6 @@ import (
|
|||
func newBackoff() backoff.BackOff {
|
||||
bo := backoff.NewExponentialBackOff()
|
||||
bo.InitialInterval = time.Second
|
||||
bo.MaxInterval = time.Minute
|
||||
bo.MaxElapsedTime = time.Minute
|
||||
return bo
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,10 @@ package ngeso
|
|||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/evcc-io/evcc/util/request"
|
||||
"time"
|
||||
|
||||
"github.com/evcc-io/evcc/util/request"
|
||||
"github.com/evcc-io/evcc/util/shortrfc3339"
|
||||
)
|
||||
|
||||
// BaseURI is the root path that the API is accessed from.
|
||||
|
|
@ -122,9 +124,9 @@ func (r NationalIntensityResult) Results() []CarbonIntensityForecastEntry {
|
|||
}
|
||||
|
||||
type CarbonIntensityForecastEntry struct {
|
||||
ValidityStart shortRFC3339Timestamp `json:"from"`
|
||||
ValidityEnd shortRFC3339Timestamp `json:"to"`
|
||||
Intensity CarbonIntensity `json:"intensity"`
|
||||
ValidityStart shortrfc3339.Timestamp `json:"from"`
|
||||
ValidityEnd shortrfc3339.Timestamp `json:"to"`
|
||||
Intensity CarbonIntensity `json:"intensity"`
|
||||
}
|
||||
|
||||
type CarbonIntensity struct {
|
||||
|
|
|
|||
|
|
@ -1,35 +0,0 @@
|
|||
package ngeso
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// shortRFC3339Timestamp is a custom JSON encoder / decoder for shortened RFC3339-compliant timestamps (those without seconds).
|
||||
// Please don't ask why NGESO uses this format instead of standards-compliant RFC3339. 🇬🇧
|
||||
type shortRFC3339Timestamp struct {
|
||||
time.Time
|
||||
}
|
||||
|
||||
const s3339Layout = "2006-01-02T15:04Z"
|
||||
|
||||
func (ct *shortRFC3339Timestamp) UnmarshalJSON(b []byte) (err error) {
|
||||
s := strings.Trim(string(b), "\"")
|
||||
if s == "null" {
|
||||
ct.Time = time.Time{}
|
||||
return
|
||||
}
|
||||
ct.Time, err = time.Parse(s3339Layout, s)
|
||||
return
|
||||
}
|
||||
|
||||
func (ct *shortRFC3339Timestamp) MarshalJSON() ([]byte, error) {
|
||||
if ct.Time.IsZero() {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
return []byte(ct.Time.Format(s3339Layout)), nil
|
||||
}
|
||||
|
||||
func (ct *shortRFC3339Timestamp) IsSet() bool {
|
||||
return !ct.IsZero()
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ var (
|
|||
FormContent = "application/x-www-form-urlencoded"
|
||||
JSONContent = "application/json"
|
||||
PlainContent = "text/plain"
|
||||
XMLContent = "application/xml"
|
||||
|
||||
// URLEncoding specifies application/x-www-form-urlencoded
|
||||
URLEncoding = map[string]string{"Content-Type": FormContent}
|
||||
|
|
@ -24,6 +25,17 @@ var (
|
|||
AcceptJSON = map[string]string{
|
||||
"Accept": JSONContent,
|
||||
}
|
||||
|
||||
// XMLEncoding specifies application/xml
|
||||
XMLEncoding = map[string]string{
|
||||
"Content-Type": XMLContent,
|
||||
"Accept": XMLContent,
|
||||
}
|
||||
|
||||
// AcceptXML accepting application/xml
|
||||
AcceptXML = map[string]string{
|
||||
"Accept": XMLContent,
|
||||
}
|
||||
)
|
||||
|
||||
// StatusError indicates unsuccessful http response
|
||||
|
|
|
|||
42
util/shortrfc3339/shortrfc3339.go
Normal file
42
util/shortrfc3339/shortrfc3339.go
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
// Package shortrfc3339 implements helpers for working with shortened RFC-3339 compliant timestamps (those without seconds).
|
||||
package shortrfc3339
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Timestamp is a custom JSON encoder / decoder for shortened RFC3339-compliant timestamps (those without seconds).
|
||||
type Timestamp struct {
|
||||
time.Time
|
||||
}
|
||||
|
||||
// Layout is the time.Parse compliant parsing string for use when parsing Shortened RFC-3339 compliant timestamps.
|
||||
const Layout = "2006-01-02T15:04Z"
|
||||
|
||||
func (ct *Timestamp) UnmarshalJSON(b []byte) (err error) {
|
||||
s := strings.Trim(string(b), "\"")
|
||||
if s == "null" {
|
||||
ct.Time = time.Time{}
|
||||
return
|
||||
}
|
||||
ct.Time, err = time.Parse(Layout, s)
|
||||
return
|
||||
}
|
||||
|
||||
func (ct *Timestamp) MarshalJSON() ([]byte, error) {
|
||||
if ct.Time.IsZero() {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
return []byte(ct.Time.Format(Layout)), nil
|
||||
}
|
||||
|
||||
func (ct *Timestamp) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
||||
var s string
|
||||
err := d.DecodeElement(&s, &start)
|
||||
if err == nil {
|
||||
ct.Time, err = time.Parse(Layout, s)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package ngeso
|
||||
package shortrfc3339
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
|
@ -11,7 +11,7 @@ var (
|
|||
|
||||
func TestMarshalling(t *testing.T) {
|
||||
// Firstly, test that we can unmarshal into a struct.
|
||||
ct := shortRFC3339Timestamp{}
|
||||
ct := Timestamp{}
|
||||
if err := ct.UnmarshalJSON(tTsBytes); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
25
util/transport/decorators.go
Normal file
25
util/transport/decorators.go
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
package transport
|
||||
|
||||
import "net/http"
|
||||
|
||||
// DecorateHeaders wraps the given http.Request with a decorator that adds the given parameters to the request headers.
|
||||
func DecorateHeaders(headers map[string]string) func(req *http.Request) error {
|
||||
return func(req *http.Request) error {
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// DecorateQuery wraps the given http.Request with a decorator that adds the given parameters to the GET query string.
|
||||
func DecorateQuery(params map[string]string) func(req *http.Request) error {
|
||||
return func(req *http.Request) error {
|
||||
q := req.URL.Query()
|
||||
for k, v := range params {
|
||||
q.Add(k, v)
|
||||
}
|
||||
req.URL.RawQuery = q.Encode()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
package transport
|
||||
|
||||
import "net/http"
|
||||
|
||||
func DecorateHeaders(headers map[string]string) func(req *http.Request) error {
|
||||
return func(req *http.Request) error {
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue