EntsoE: fix dropped price series and UTC request offset (#31396)
This commit is contained in:
parent
0613eb8a0a
commit
2e22d16660
4 changed files with 184 additions and 21 deletions
|
|
@ -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
|
||||
|
||||
for _, ts := range ts {
|
||||
if ts.ClassificationSequenceAttributeInstanceComponentPosition > 1 {
|
||||
continue
|
||||
// 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 unit := ts.PriceMeasureUnitName; unit != "MWH" {
|
||||
return nil, fmt.Errorf("%w: invalid unit: %s", ErrInvalidData, unit)
|
||||
}
|
||||
|
|
@ -68,6 +72,17 @@ func GetTsPriceData(ts []TimeSeries, resolution ResolutionType) ([]Rate, error)
|
|||
continue
|
||||
}
|
||||
|
||||
key := interval{period.TimeInterval.Start.Time, period.TimeInterval.End.Time}
|
||||
|
||||
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
|
||||
|
|
@ -75,7 +90,6 @@ func GetTsPriceData(ts []TimeSeries, resolution ResolutionType) ([]Rate, error)
|
|||
|
||||
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
60
tariff/entsoe/api_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" . }}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue