Add ENTSO-E "Day Ahead Pricing" tariff provider (#9794)

This commit is contained in:
duck 2023-10-03 11:04:53 +01:00 • committed by GitHub
parent 5bdf309d8a
commit 1b8cb72476
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 682 additions and 54 deletions

183
tariff/entsoe.go Normal file
View 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
View 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
View 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
View 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"`
}

View file

@ -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
}

View file

@ -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 {

View file

@ -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()
}

View file

@ -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

View 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
}

View file

@ -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)
}

View 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
}
}

View file

@ -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
}
}