EntsoE: fix dropped price series and UTC request offset (#31396)

This commit is contained in:
Michael Geers 2026-07-02 14:37:51 +02:00 • committed by GitHub
parent 0613eb8a0a
commit 2e22d16660
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 184 additions and 21 deletions

View file

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

60
tariff/entsoe/api_test.go Normal file
View file

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

View file

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

View file

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