From 2e22d1666007318a664e19f8606cca4773dd67a7 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Thu, 2 Jul 2026 14:37:51 +0200 Subject: [PATCH] EntsoE: fix dropped price series and UTC request offset (#31396) --- tariff/entsoe/api.go | 38 ++++++--- tariff/entsoe/api_test.go | 60 ++++++++++++++ tariff/entsoe/areas.go | 4 +- templates/definition/tariff/entsoe.yaml | 103 ++++++++++++++++++++++-- 4 files changed, 184 insertions(+), 21 deletions(-) create mode 100644 tariff/entsoe/api_test.go diff --git a/tariff/entsoe/api.go b/tariff/entsoe/api.go index 6bad0f25b..c49e13d8a 100644 --- a/tariff/entsoe/api.go +++ b/tariff/entsoe/api.go @@ -32,8 +32,9 @@ func DayAheadPricesRequest(domain string, duration time.Duration) *http.Request "DocumentType": {string(ProcessTypeDayAhead)}, "In_Domain": {domain}, "Out_Domain": {domain}, - "PeriodStart": {now.Format(numericDateFormat)}, - "PeriodEnd": {now.Add(duration).Format(numericDateFormat)}, + // PeriodStart/PeriodEnd must be UTC: the numeric date format carries no timezone. + "PeriodStart": {now.UTC().Format(numericDateFormat)}, + "PeriodEnd": {now.Add(duration).UTC().Format(numericDateFormat)}, } uri := BaseURI + "?" + params.Encode() @@ -52,13 +53,16 @@ type Rate struct { // GetTsPriceData accepts a set of TimeSeries data entries, and // returns a sorted array of Rate based on the timestamp of each data entry. func GetTsPriceData(ts []TimeSeries, resolution ResolutionType) ([]Rate, error) { - var res []Rate + // ENTSO-E may publish multiple TimeSeries for the same interval, distinguished by + // classificationSequence position; keep only the lowest position per interval. + type interval struct { + start, end time.Time + } + + best := make(map[interval]TimeSeriesPeriod) + position := make(map[interval]int) for _, ts := range ts { - if ts.ClassificationSequenceAttributeInstanceComponentPosition > 1 { - continue - } - if unit := ts.PriceMeasureUnitName; unit != "MWH" { return nil, fmt.Errorf("%w: invalid unit: %s", ErrInvalidData, unit) } @@ -68,15 +72,25 @@ func GetTsPriceData(ts []TimeSeries, resolution ResolutionType) ([]Rate, error) continue } - data, err := ExtractPeriodPriceData(&period) - if err != nil { - return nil, err - } + key := interval{period.TimeInterval.Start.Time, period.TimeInterval.End.Time} - res = append(res, data...) + if pos, ok := position[key]; !ok || ts.ClassificationSequenceAttributeInstanceComponentPosition < pos { + best[key] = period + position[key] = ts.ClassificationSequenceAttributeInstanceComponentPosition + } } } + var res []Rate + for _, period := range best { + data, err := ExtractPeriodPriceData(&period) + if err != nil { + return nil, err + } + + res = append(res, data...) + } + if len(res) == 0 { return nil, fmt.Errorf("no data for resolution: %v", resolution) } diff --git a/tariff/entsoe/api_test.go b/tariff/entsoe/api_test.go new file mode 100644 index 000000000..0ea3e356d --- /dev/null +++ b/tariff/entsoe/api_test.go @@ -0,0 +1,60 @@ +package entsoe + +import ( + "testing" + "time" + + "github.com/evcc-io/evcc/util/shortrfc3339" +) + +func hourlySeries(position int, price float64) TimeSeries { + start := time.Date(2026, 7, 1, 22, 0, 0, 0, time.UTC) + + ts := TimeSeries{ + PriceMeasureUnitName: "MWH", + ClassificationSequenceAttributeInstanceComponentPosition: position, + } + + period := TimeSeriesPeriod{Resolution: ResolutionHour} + period.TimeInterval.Start = shortrfc3339.Timestamp{Time: start} + period.TimeInterval.End = shortrfc3339.Timestamp{Time: start.Add(24 * time.Hour)} + + for i := 1; i <= 24; i++ { + period.Point = append(period.Point, Point{Position: i, PriceAmount: price}) + } + + ts.Period = []TimeSeriesPeriod{period} + + return ts +} + +// A single TimeSeries at position 2 is still valid data and must not be discarded. +func TestGetTsPriceDataSinglePosition2(t *testing.T) { + res, err := GetTsPriceData([]TimeSeries{hourlySeries(2, 100)}, ResolutionHour) + if err != nil { + t.Fatal(err) + } + + if len(res) != 24 { + t.Fatalf("expected 24 rates, got %d", len(res)) + } +} + +// When two TimeSeries cover the same interval, the lower classification position wins. +func TestGetTsPriceDataDualPositionSameInterval(t *testing.T) { + ts := []TimeSeries{ + hourlySeries(2, 999), + hourlySeries(1, 100), + } + + res, err := GetTsPriceData(ts, ResolutionHour) + if err != nil { + t.Fatal(err) + } + + for _, r := range res { + if r.Value != 100.0/1e3 { + t.Fatalf("expected position 1 data (100), got %v", r.Value*1e3) + } + } +} diff --git a/tariff/entsoe/areas.go b/tariff/entsoe/areas.go index 2e49ddbb0..a3b8554df 100644 --- a/tariff/entsoe/areas.go +++ b/tariff/entsoe/areas.go @@ -149,12 +149,12 @@ func Area(typ AreaType, name string) (string, error) { suffix := fmt.Sprintf(" (%s)", name) for code, names := range zones { - if code == name { + if strings.EqualFold(code, name) { return code, nil } for _, n := range names { - if n == name || n == combined || strings.HasSuffix(n, suffix) { + if strings.EqualFold(n, name) || strings.EqualFold(n, combined) || strings.HasSuffix(strings.ToUpper(n), strings.ToUpper(suffix)) { return code, nil } } diff --git a/templates/definition/tariff/entsoe.yaml b/templates/definition/tariff/entsoe.yaml index bc27dd3ce..10e06b469 100644 --- a/templates/definition/tariff/entsoe.yaml +++ b/templates/definition/tariff/entsoe.yaml @@ -13,21 +13,110 @@ group: price countries: ["EU"] params: - name: securitytoken + type: string + mask: true + required: true description: - generic: Security token + generic: Web API Security Token help: - de: "Registrierung und anschließende Helpdesk-Anfrage erforderlich. Details zum Ablauf gibts hier [transparency.entsoe.eu](https://transparency.entsoe.eu/content/static_content/Static%20content/web%20api/Guide.html#_authentication_and_authorisation)" - en: "Registration and subsequent helpdesk request required. Details on the process can be found here [transparency.entsoe.eu](https://transparency.entsoe.eu/content/static_content/Static%20content/web%20api/Guide.html#_authentication_and_authorisation)" + de: "Anleitung zur Token-Beantragung siehe [transparencyplatform.zendesk.com](https://transparencyplatform.zendesk.com/hc/en-us/articles/12845911031188-How-to-get-security-token). Nach Freischaltung abrufbar unter [transparency.entsoe.eu](https://transparency.entsoe.eu/myAccount/webApiAccess)" + en: "Instructions for obtaining the token at [transparencyplatform.zendesk.com](https://transparencyplatform.zendesk.com/hc/en-us/articles/12845911031188-How-to-get-security-token). Available after approval at [transparency.entsoe.eu](https://transparency.entsoe.eu/myAccount/webApiAccess)" - name: domain - example: BZN|DE-LU + type: choice + required: true + default: BZN|DE-LU + choice: + - "BZN|AL" + - "BZN|AM" + - "BZN|AT" + - "BZN|AZ" + - "BZN|BA" + - "BZN|BE" + - "BZN|BG" + - "BZN|BY" + - "BZN|CH" + - "BZN|CY" + - "BZN|CZ" + - "BZN|CZ+DE+SK" + - "BZN|DE-AT-LU" + - "BZN|DE-LU" + - "BZN|DK1" + - "BZN|DK1-NO1" + - "BZN|DK1A" + - "BZN|DK2" + - "BZN|EE" + - "BZN|ES" + - "BZN|FI" + - "BZN|FR" + - "BZN|GB" + - "BZN|GB(ElecLink)" + - "BZN|GB(IFA)" + - "BZN|GB(IFA2)" + - "BZN|GE" + - "BZN|GR" + - "BZN|HR" + - "BZN|HU" + - "BZN|IE(SEM)" + - "BZN|IT-Brindisi" + - "BZN|IT-Calabria" + - "BZN|IT-Centre-North" + - "BZN|IT-Centre-South" + - "BZN|IT-Foggia" + - "BZN|IT-GR" + - "BZN|IT-Malta" + - "BZN|IT-North" + - "BZN|IT-North-AT" + - "BZN|IT-North-CH" + - "BZN|IT-North-FR" + - "BZN|IT-North-SI" + - "BZN|IT-Priolo" + - "BZN|IT-Rossano" + - "BZN|IT-SACOAC" + - "BZN|IT-SACODC" + - "BZN|IT-Sardinia" + - "BZN|IT-Sicily" + - "BZN|IT-South" + - "BZN|LT" + - "BZN|LV" + - "BZN|MD" + - "BZN|ME" + - "BZN|MK" + - "BZN|MT" + - "BZN|NL" + - "BZN|NO1" + - "BZN|NO1A" + - "BZN|NO2" + - "BZN|NO2A" + - "BZN|NO2NSL" + - "BZN|NO3" + - "BZN|NO4" + - "BZN|NO5" + - "BZN|PL" + - "BZN|PT" + - "BZN|RO" + - "BZN|RS" + - "BZN|RU" + - "BZN|RU-KGD" + - "BZN|SE1" + - "BZN|SE2" + - "BZN|SE3" + - "BZN|SE4" + - "BZN|SI" + - "BZN|SK" + - "BZN|TR" + - "BZN|UA" + - "BZN|UA-BEI" + - "BZN|UA-DobTPP" + - "BZN|UA-IPS" + - "BZN|XK" help: - de: "siehe [transparency.entsoe.eu](https://transparency.entsoe.eu/content/static_content/Static%20content/web%20api/Guide.html#_areas)" - en: "see [transparency.entsoe.eu](https://transparency.entsoe.eu/content/static_content/Static%20content/web%20api/Guide.html#_areas)" + de: "Liste der Gebotszonen siehe [transparencyplatform.zendesk.com](https://transparencyplatform.zendesk.com/hc/en-us/articles/15885757676308-Area-List-with-Energy-Identification-Code-EIC)" + en: "See list of bidding zones at [transparencyplatform.zendesk.com](https://transparencyplatform.zendesk.com/hc/en-us/articles/15885757676308-Area-List-with-Energy-Identification-Code-EIC)" - preset: tariff-base - preset: tariff-features render: | type: entsoe securitytoken: {{ .securitytoken }} - domain: {{ .domain }} + domain: "{{ .domain }}" {{ include "tariff-base" . }} {{ include "tariff-features" . }}