From 1b8cb72476d17e88f28c7e7b13eed9e34cfca097 Mon Sep 17 00:00:00 2001 From: duck Date: Tue, 3 Oct 2023 11:04:53 +0100 Subject: [PATCH] Add ENTSO-E "Day Ahead Pricing" tariff provider (#9794) --- tariff/entsoe.go | 183 ++++++++++++++++++ tariff/entsoe/api.go | 119 ++++++++++++ tariff/entsoe/areas.go | 163 ++++++++++++++++ tariff/entsoe/static.go | 130 +++++++++++++ tariff/helper.go | 1 - tariff/ngeso/api.go | 10 +- tariff/ngeso/shortrfc3339.go | 35 ---- util/request/functions.go | 12 ++ util/shortrfc3339/shortrfc3339.go | 42 ++++ .../shortrfc3339}/shortrfc3339_test.go | 4 +- util/transport/decorators.go | 25 +++ util/transport/headers.go | 12 -- 12 files changed, 682 insertions(+), 54 deletions(-) create mode 100644 tariff/entsoe.go create mode 100644 tariff/entsoe/api.go create mode 100644 tariff/entsoe/areas.go create mode 100644 tariff/entsoe/static.go delete mode 100644 tariff/ngeso/shortrfc3339.go create mode 100644 util/shortrfc3339/shortrfc3339.go rename {tariff/ngeso => util/shortrfc3339}/shortrfc3339_test.go (93%) create mode 100644 util/transport/decorators.go delete mode 100644 util/transport/headers.go diff --git a/tariff/entsoe.go b/tariff/entsoe.go new file mode 100644 index 000000000..5e94d946b --- /dev/null +++ b/tariff/entsoe.go @@ -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 +} diff --git a/tariff/entsoe/api.go b/tariff/entsoe/api.go new file mode 100644 index 000000000..65137a48e --- /dev/null +++ b/tariff/entsoe/api.go @@ -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 +} diff --git a/tariff/entsoe/areas.go b/tariff/entsoe/areas.go new file mode 100644 index 000000000..6243c9859 --- /dev/null +++ b/tariff/entsoe/areas.go @@ -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) +} diff --git a/tariff/entsoe/static.go b/tariff/entsoe/static.go new file mode 100644 index 000000000..43be5cb36 --- /dev/null +++ b/tariff/entsoe/static.go @@ -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"` +} diff --git a/tariff/helper.go b/tariff/helper.go index d58df3ad8..9a5e701f3 100644 --- a/tariff/helper.go +++ b/tariff/helper.go @@ -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 } diff --git a/tariff/ngeso/api.go b/tariff/ngeso/api.go index f93ed14ea..0f96ee74d 100644 --- a/tariff/ngeso/api.go +++ b/tariff/ngeso/api.go @@ -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 { diff --git a/tariff/ngeso/shortrfc3339.go b/tariff/ngeso/shortrfc3339.go deleted file mode 100644 index 5f4051bca..000000000 --- a/tariff/ngeso/shortrfc3339.go +++ /dev/null @@ -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() -} diff --git a/util/request/functions.go b/util/request/functions.go index 396d99863..f18a2d8a3 100644 --- a/util/request/functions.go +++ b/util/request/functions.go @@ -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 diff --git a/util/shortrfc3339/shortrfc3339.go b/util/shortrfc3339/shortrfc3339.go new file mode 100644 index 000000000..7de693563 --- /dev/null +++ b/util/shortrfc3339/shortrfc3339.go @@ -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 +} diff --git a/tariff/ngeso/shortrfc3339_test.go b/util/shortrfc3339/shortrfc3339_test.go similarity index 93% rename from tariff/ngeso/shortrfc3339_test.go rename to util/shortrfc3339/shortrfc3339_test.go index b27c53cb5..8e8d5dc69 100644 --- a/tariff/ngeso/shortrfc3339_test.go +++ b/util/shortrfc3339/shortrfc3339_test.go @@ -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) } diff --git a/util/transport/decorators.go b/util/transport/decorators.go new file mode 100644 index 000000000..30d64a904 --- /dev/null +++ b/util/transport/decorators.go @@ -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 + } +} diff --git a/util/transport/headers.go b/util/transport/headers.go deleted file mode 100644 index 8320fc67f..000000000 --- a/util/transport/headers.go +++ /dev/null @@ -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 - } -}