Skip to content

Commit 1c90e28

Browse files
committed
fix: check acknowledgement market data
1 parent 325b5cd commit 1c90e28

4 files changed

Lines changed: 237 additions & 15 deletions

File tree

‎entsoe/acknowledgement.go‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package entsoe
2+
3+
import (
4+
"encoding/xml"
5+
"errors"
6+
"fmt"
7+
"strings"
8+
)
9+
10+
// ReasonCodeNoMatchingData is the ENTSO-E reason code returned when no data is
11+
// available for the requested data item and interval (e.g. day-ahead prices for
12+
// tomorrow have not been published yet).
13+
const ReasonCodeNoMatchingData = "999"
14+
15+
// ErrNoMatchingData is returned when ENTSO-E replies with an
16+
// Acknowledgement_MarketDocument carrying reason code 999 ("No matching data found").
17+
// This is an expected, non-fatal condition for not-yet-published periods.
18+
var ErrNoMatchingData = errors.New("entsoe: no matching data found")
19+
20+
// Reason represents a <Reason> element of an Acknowledgement_MarketDocument.
21+
type Reason struct {
22+
Code string `xml:"code"`
23+
Text string `xml:"text"`
24+
}
25+
26+
// AcknowledgementMarketDocument represents the Acknowledgement_MarketDocument that
27+
// the ENTSO-E Transparency Platform returns instead of a Publication_MarketDocument
28+
// when a request cannot be served (no data, invalid parameters, quota exceeded, ...).
29+
// Note: it is returned with HTTP 200 in several cases, so the status code alone is
30+
// not sufficient to detect it.
31+
type AcknowledgementMarketDocument struct {
32+
XMLName xml.Name `xml:"Acknowledgement_MarketDocument"`
33+
Xmlns string `xml:"xmlns,attr"`
34+
MRID string `xml:"mRID"`
35+
CreatedDateTime string `xml:"createdDateTime"`
36+
SenderMarketParticipantMRID MarketParticipantMRID `xml:"sender_MarketParticipant.mRID"`
37+
SenderMarketParticipantRoleType string `xml:"sender_MarketParticipant.marketRole.type"`
38+
ReceiverMarketParticipantMRID MarketParticipantMRID `xml:"receiver_MarketParticipant.mRID"`
39+
ReceiverMarketParticipantRoleType string `xml:"receiver_MarketParticipant.marketRole.type"`
40+
ReceivedDocumentCreatedDateTime string `xml:"received_MarketDocument.createdDateTime"`
41+
Reason []Reason `xml:"Reason"`
42+
}
43+
44+
// AcknowledgementError wraps an AcknowledgementMarketDocument as an error so that
45+
// callers can inspect the reason codes returned by ENTSO-E.
46+
type AcknowledgementError struct {
47+
Document *AcknowledgementMarketDocument
48+
}
49+
50+
// Error implements the error interface.
51+
func (e *AcknowledgementError) Error() string {
52+
if e == nil || e.Document == nil {
53+
return "entsoe: acknowledgement received"
54+
}
55+
56+
parts := make([]string, 0, len(e.Document.Reason))
57+
for _, r := range e.Document.Reason {
58+
parts = append(parts, fmt.Sprintf("%s: %s", r.Code, strings.TrimSpace(r.Text)))
59+
}
60+
if len(parts) == 0 {
61+
return "entsoe: acknowledgement received without reason"
62+
}
63+
64+
return "entsoe: acknowledgement received (" + strings.Join(parts, "; ") + ")"
65+
}
66+
67+
// Unwrap allows errors.Is(err, ErrNoMatchingData) to succeed for reason code 999.
68+
func (e *AcknowledgementError) Unwrap() error {
69+
if e != nil && e.Document != nil && e.Document.HasReasonCode(ReasonCodeNoMatchingData) {
70+
return ErrNoMatchingData
71+
}
72+
return nil
73+
}
74+
75+
// HasReasonCode reports whether the acknowledgement contains the given reason code.
76+
func (d *AcknowledgementMarketDocument) HasReasonCode(code string) bool {
77+
if d == nil {
78+
return false
79+
}
80+
for _, r := range d.Reason {
81+
if r.Code == code {
82+
return true
83+
}
84+
}
85+
return false
86+
}
87+
88+
// IsNoMatchingData reports whether err represents an ENTSO-E "no matching data found"
89+
// acknowledgement.
90+
func IsNoMatchingData(err error) bool {
91+
return errors.Is(err, ErrNoMatchingData)
92+
}

‎entsoe/acknowledgement_test.go‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package entsoe
2+
3+
import (
4+
"errors"
5+
"strings"
6+
"testing"
7+
)
8+
9+
const acknowledgementNoDataXML = `<?xml version="1.0" encoding="UTF-8"?>
10+
<Acknowledgement_MarketDocument xmlns="urn:iec62325.351:tc57wg16:451-1:acknowledgementdocument:7:0">
11+
<mRID>920295cf-fbfb-4</mRID>
12+
<createdDateTime>2026-09-18T11:58:51Z</createdDateTime>
13+
<sender_MarketParticipant.mRID codingScheme="A01">10X1001A1001A450</sender_MarketParticipant.mRID>
14+
<sender_MarketParticipant.marketRole.type>A32</sender_MarketParticipant.marketRole.type>
15+
<receiver_MarketParticipant.mRID codingScheme="A01">10X1001A1001A450</receiver_MarketParticipant.mRID>
16+
<receiver_MarketParticipant.marketRole.type>A39</receiver_MarketParticipant.marketRole.type>
17+
<received_MarketDocument.createdDateTime>2026-09-18T11:58:51Z</received_MarketDocument.createdDateTime>
18+
<Reason>
19+
<code>999</code>
20+
<text>No matching data found for Data item ENERGY_PRICES [12.1.D] (10YLV-1001A00074, 10YLV-1001A00074) and interval 2026-09-19T00:00:00Z/2026-09-20T00:00:00Z.</text>
21+
</Reason>
22+
</Acknowledgement_MarketDocument>`
23+
24+
func TestDecodeEnergyPricesXML_AcknowledgementNoMatchingData(t *testing.T) {
25+
doc, err := DecodeEnergyPricesXML(strings.NewReader(acknowledgementNoDataXML))
26+
if doc != nil {
27+
t.Fatalf("expected nil document, got %+v", doc)
28+
}
29+
if err == nil {
30+
t.Fatal("expected an error for an Acknowledgement_MarketDocument")
31+
}
32+
if !IsNoMatchingData(err) {
33+
t.Fatalf("expected ErrNoMatchingData, got %v", err)
34+
}
35+
36+
var ackErr *AcknowledgementError
37+
if !errors.As(err, &ackErr) {
38+
t.Fatalf("expected *AcknowledgementError, got %T", err)
39+
}
40+
if ackErr.Document.MRID != "920295cf-fbfb-4" {
41+
t.Errorf("unexpected mRID: %q", ackErr.Document.MRID)
42+
}
43+
if ackErr.Document.SenderMarketParticipantMRID.Value != "10X1001A1001A450" {
44+
t.Errorf("unexpected sender mRID: %q", ackErr.Document.SenderMarketParticipantMRID.Value)
45+
}
46+
if len(ackErr.Document.Reason) != 1 || ackErr.Document.Reason[0].Code != "999" {
47+
t.Fatalf("unexpected reasons: %+v", ackErr.Document.Reason)
48+
}
49+
if !strings.Contains(err.Error(), "No matching data found") {
50+
t.Errorf("error message should carry the reason text, got %q", err.Error())
51+
}
52+
}
53+
54+
func TestDecodeEnergyPricesXML_AcknowledgementOtherReason(t *testing.T) {
55+
xmlData := strings.Replace(acknowledgementNoDataXML, "<code>999</code>", "<code>A01</code>", 1)
56+
57+
_, err := DecodeEnergyPricesXML(strings.NewReader(xmlData))
58+
if err == nil {
59+
t.Fatal("expected an error")
60+
}
61+
if IsNoMatchingData(err) {
62+
t.Fatal("reason code A01 must not be reported as ErrNoMatchingData")
63+
}
64+
}
65+
66+
func TestDecodeEnergyPricesXML_UnknownRootElement(t *testing.T) {
67+
_, err := DecodeEnergyPricesXML(strings.NewReader(`<?xml version="1.0"?><Something_Else/>`))
68+
if err == nil {
69+
t.Fatal("expected an error")
70+
}
71+
if !strings.Contains(err.Error(), "<Something_Else>") {
72+
t.Errorf("error should name the unexpected root element, got %q", err.Error())
73+
}
74+
}
75+
76+
func TestDecodeEnergyPricesXML_EmptyDocument(t *testing.T) {
77+
if _, err := DecodeEnergyPricesXML(strings.NewReader("")); err == nil {
78+
t.Fatal("expected an error for an empty document")
79+
}
80+
}

‎entsoe/api_client.go‎

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,6 @@ func DownloadPublicationMarketData(ctx context.Context, securityToken string, ur
5656
opts := &DownloadOptions{
5757
UserAgent: client.userAgent,
5858
}
59-
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
60-
defer cancel()
6159

6260
// Retrieve today's market data – use cache when available.
6361
todayKey := now.Format("2006-01-02")
@@ -74,7 +72,7 @@ func DownloadPublicationMarketData(ctx context.Context, securityToken string, ur
7472
fmt.Println(url)
7573
var err error
7674
var rawXML []byte
77-
marketDocument, rawXML, err = DownloadPublicationMarketDataWithRaw(ctx, url, opts)
75+
marketDocument, rawXML, err = downloadWithTimeout(ctx, url, opts)
7876
if err != nil {
7977
return nil, err
8078
}
@@ -99,11 +97,18 @@ func DownloadPublicationMarketData(ctx context.Context, securityToken string, ur
9997
urlNextDay := buildPublicationMarketDataURL(securityToken, urlFormat, tomorrow)
10098
var err error
10199
var rawXMLNextDay []byte
102-
marketDocumentNextDay, rawXMLNextDay, err = DownloadPublicationMarketDataWithRaw(ctx, urlNextDay, opts)
100+
marketDocumentNextDay, rawXMLNextDay, err = downloadWithTimeout(ctx, urlNextDay, opts)
103101
if err != nil {
104-
return nil, err
105-
}
106-
if cache != nil {
102+
if IsNoMatchingData(err) {
103+
// Day-ahead prices for tomorrow are not published yet. This is expected
104+
// before the market coupling results are released, so continue with
105+
// today's data only instead of failing the whole forecast.
106+
fmt.Printf("No market data published yet for %s: %v\n", tomorrowKey, err)
107+
marketDocumentNextDay = nil
108+
} else {
109+
return nil, err
110+
}
111+
} else if cache != nil {
107112
cache.StoreDocumentWithRaw(tomorrowKey, marketDocumentNextDay, rawXMLNextDay, CacheSourceDownload)
108113
}
109114
}
@@ -116,6 +121,19 @@ func DownloadPublicationMarketData(ctx context.Context, securityToken string, ur
116121
return marketDocument, nil
117122
}
118123

124+
// requestTimeout bounds a single ENTSO-E API call. The Transparency Platform can be
125+
// slow, so the budget is applied per request instead of once for the whole
126+
// today + tomorrow sequence.
127+
const requestTimeout = 30 * time.Second
128+
129+
// downloadWithTimeout performs a single download bounded by requestTimeout.
130+
func downloadWithTimeout(ctx context.Context, apiURL string, opts *DownloadOptions) (*PublicationMarketData, []byte, error) {
131+
reqCtx, cancel := context.WithTimeout(ctx, requestTimeout)
132+
defer cancel()
133+
134+
return DownloadPublicationMarketDataWithRaw(reqCtx, apiURL, opts)
135+
}
136+
119137
// buildPublicationMarketDataURL extracts the URL assignment logic for DownloadPublicationMarketData.
120138
func buildPublicationMarketDataURL(securityToken string, urlFormat string, now time.Time) string {
121139
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())

‎entsoe/energy_prices_decoder.go‎

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -401,16 +401,48 @@ func (p *Period) GetTimeRangeForPosition(position int) (start, end time.Time, va
401401
return start, end, true
402402
}
403403

404-
// DecodeEnergyPricesXML decodes the XML file and returns the parsed data
404+
// DecodeEnergyPricesXML decodes the XML file and returns the parsed data.
405+
//
406+
// The ENTSO-E Transparency Platform may answer a Publication_MarketDocument request
407+
// with an Acknowledgement_MarketDocument (even with HTTP 200), for example when the
408+
// day-ahead prices for the requested interval have not been published yet. In that
409+
// case an *AcknowledgementError is returned, which unwraps to ErrNoMatchingData for
410+
// reason code 999.
405411
func DecodeEnergyPricesXML(file io.Reader) (*PublicationMarketData, error) {
406-
407-
// Parse the XML
408-
var doc PublicationMarketData
409412
decoder := xml.NewDecoder(file)
410-
err := decoder.Decode(&doc)
411-
if err != nil {
412-
return nil, fmt.Errorf("error parsing XML: %v", err)
413+
414+
// Find the root element so we can dispatch on the document type.
415+
var root *xml.StartElement
416+
for {
417+
token, err := decoder.Token()
418+
if err != nil {
419+
if err == io.EOF {
420+
return nil, fmt.Errorf("error parsing XML: empty document, no root element found")
421+
}
422+
return nil, fmt.Errorf("error parsing XML: %v", err)
423+
}
424+
if start, ok := token.(xml.StartElement); ok {
425+
root = &start
426+
break
427+
}
413428
}
414429

415-
return &doc, nil
430+
switch root.Name.Local {
431+
case "Publication_MarketDocument":
432+
var doc PublicationMarketData
433+
if err := decoder.DecodeElement(&doc, root); err != nil {
434+
return nil, fmt.Errorf("error parsing XML: %v", err)
435+
}
436+
return &doc, nil
437+
438+
case "Acknowledgement_MarketDocument":
439+
var ack AcknowledgementMarketDocument
440+
if err := decoder.DecodeElement(&ack, root); err != nil {
441+
return nil, fmt.Errorf("error parsing Acknowledgement_MarketDocument: %v", err)
442+
}
443+
return nil, &AcknowledgementError{Document: &ack}
444+
445+
default:
446+
return nil, fmt.Errorf("error parsing XML: expected element type <Publication_MarketDocument> but have <%s>", root.Name.Local)
447+
}
416448
}

0 commit comments

Comments
 (0)