Skip to content

Commit 325b5cd

Browse files
committed
feat: download XML document with market data
1 parent e74ccb8 commit 325b5cd

11 files changed

Lines changed: 1176 additions & 45 deletions

‎entsoe/api_client.go‎

Lines changed: 52 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22
package entsoe
33

44
import (
5+
"bytes"
56
"context"
67
"fmt"
8+
"io"
79
"net/http"
810
"time"
911

@@ -51,6 +53,9 @@ func DownloadPublicationMarketData(ctx context.Context, securityToken string, ur
5153

5254
now := time.Now().In(location)
5355
client := NewAPIClient()
56+
opts := &DownloadOptions{
57+
UserAgent: client.userAgent,
58+
}
5459
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
5560
defer cancel()
5661

@@ -68,40 +73,42 @@ func DownloadPublicationMarketData(ctx context.Context, securityToken string, ur
6873
url := buildPublicationMarketDataURL(securityToken, urlFormat, now)
6974
fmt.Println(url)
7075
var err error
71-
marketDocument, err = client.DownloadPublicationMarketData(ctx, url)
76+
var rawXML []byte
77+
marketDocument, rawXML, err = DownloadPublicationMarketDataWithRaw(ctx, url, opts)
7278
if err != nil {
7379
return nil, err
7480
}
7581
if cache != nil {
76-
cache.StoreDocument(todayKey, marketDocument, CacheSourceDownload)
82+
cache.StoreDocumentWithRaw(todayKey, marketDocument, rawXML, CacheSourceDownload)
7783
}
7884
}
7985

80-
// If instructed, also retrieve data for the next day.
81-
if fetchNextDay {
82-
tomorrow := now.AddDate(0, 0, 1)
83-
tomorrowKey := tomorrow.Format("2006-01-02")
86+
// Retrieve data for the next day – use cache when available, or fetch if fetchNextDay is true.
87+
tomorrow := now.AddDate(0, 0, 1)
88+
tomorrowKey := tomorrow.Format("2006-01-02")
8489

85-
var marketDocumentNextDay *PublicationMarketData
86-
if cache != nil {
87-
if cached, ok := cache.Get(tomorrowKey); ok {
88-
fmt.Printf("Using cached market data for %s\n", tomorrowKey)
89-
marketDocumentNextDay = cached
90-
}
90+
var marketDocumentNextDay *PublicationMarketData
91+
if cache != nil {
92+
if cached, ok := cache.Get(tomorrowKey); ok {
93+
fmt.Printf("Using cached market data for %s\n", tomorrowKey)
94+
marketDocumentNextDay = cached
9195
}
96+
}
9297

93-
if marketDocumentNextDay == nil {
94-
urlNextDay := buildPublicationMarketDataURL(securityToken, urlFormat, tomorrow)
95-
var err error
96-
marketDocumentNextDay, err = client.DownloadPublicationMarketData(ctx, urlNextDay)
97-
if err != nil {
98-
return nil, err
99-
}
100-
if cache != nil {
101-
cache.StoreDocument(tomorrowKey, marketDocumentNextDay, CacheSourceDownload)
102-
}
98+
if marketDocumentNextDay == nil && fetchNextDay {
99+
urlNextDay := buildPublicationMarketDataURL(securityToken, urlFormat, tomorrow)
100+
var err error
101+
var rawXMLNextDay []byte
102+
marketDocumentNextDay, rawXMLNextDay, err = DownloadPublicationMarketDataWithRaw(ctx, urlNextDay, opts)
103+
if err != nil {
104+
return nil, err
103105
}
106+
if cache != nil {
107+
cache.StoreDocumentWithRaw(tomorrowKey, marketDocumentNextDay, rawXMLNextDay, CacheSourceDownload)
108+
}
109+
}
104110

111+
if marketDocumentNextDay != nil {
105112
// Merge the data from both days
106113
marketDocument = mergePublicationMarketData(marketDocument, marketDocumentNextDay)
107114
}
@@ -144,50 +151,63 @@ func mergePublicationMarketData(first *PublicationMarketData, second *Publicatio
144151

145152
// DownloadPublicationMarketDataWithOptions downloads and decodes a PublicationMarketData with custom options
146153
func DownloadPublicationMarketDataWithOptions(ctx context.Context, apiURL string, opts *DownloadOptions) (*PublicationMarketData, error) {
154+
doc, _, err := DownloadPublicationMarketDataWithRaw(ctx, apiURL, opts)
155+
return doc, err
156+
}
157+
158+
// DownloadPublicationMarketDataWithRaw downloads and decodes a PublicationMarketData along with its raw XML bytes
159+
func DownloadPublicationMarketDataWithRaw(ctx context.Context, apiURL string, opts *DownloadOptions) (*PublicationMarketData, []byte, error) {
147160
if apiURL == "" {
148-
return nil, fmt.Errorf("API URL cannot be empty")
161+
return nil, nil, fmt.Errorf("API URL cannot be empty")
149162
}
150163

151164
client := &http.Client{}
152165

153166
// Create HTTP request with context
154167
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
155168
if err != nil {
156-
return nil, fmt.Errorf("failed to create HTTP request: %w", err)
169+
return nil, nil, fmt.Errorf("failed to create HTTP request: %w", err)
157170
}
158171

159172
// Set default headers
160173
userAgent := "entsoe-go-client/1.0"
161-
if opts.UserAgent != "" {
174+
if opts != nil && opts.UserAgent != "" {
162175
userAgent = opts.UserAgent
163176
}
164177
req.Header.Set("User-Agent", userAgent)
165178
req.Header.Set("Accept", "application/xml, text/xml")
166179

167180
// Set custom headers
168-
for key, value := range opts.Headers {
169-
req.Header.Set(key, value)
181+
if opts != nil {
182+
for key, value := range opts.Headers {
183+
req.Header.Set(key, value)
184+
}
170185
}
171186

172187
// Execute the request
173188
resp, err := client.Do(req)
174189
if err != nil {
175-
return nil, fmt.Errorf("failed to execute HTTP request: %w", err)
190+
return nil, nil, fmt.Errorf("failed to execute HTTP request: %w", err)
176191
}
177192
defer resp.Body.Close()
178193

179194
// Check HTTP status code
180195
if resp.StatusCode != http.StatusOK {
181-
return nil, fmt.Errorf("HTTP request failed with status %d: %s", resp.StatusCode, resp.Status)
196+
return nil, nil, fmt.Errorf("HTTP request failed with status %d: %s", resp.StatusCode, resp.Status)
197+
}
198+
199+
rawBytes, err := io.ReadAll(resp.Body)
200+
if err != nil {
201+
return nil, nil, fmt.Errorf("failed to read response body: %w", err)
182202
}
183203

184204
// Decode the XML response using the existing decoder
185-
doc, err := DecodeEnergyPricesXML(resp.Body)
205+
doc, err := DecodeEnergyPricesXML(bytes.NewReader(rawBytes))
186206
if err != nil {
187-
return nil, fmt.Errorf("failed to decode XML response: %w", err)
207+
return nil, nil, fmt.Errorf("failed to decode XML response: %w", err)
188208
}
189209

190-
return doc, nil
210+
return doc, rawBytes, nil
191211
}
192212

193213
// ValidateAPIURL performs basic validation on the API URL

‎entsoe/energy_prices_decoder_test.go‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -628,3 +628,67 @@ func TestDocumentDecode(t *testing.T) {
628628
t.Errorf("Returned price: %f, want %f", price, 57.73)
629629
}
630630
}
631+
632+
func TestDocumentDecode_20260902(t *testing.T) {
633+
file, err := os.Open("../test_data/Energy_Prices_202609012200-202609022200.xml")
634+
if err != nil {
635+
t.Fatal(err)
636+
}
637+
defer file.Close()
638+
639+
doc, err := DecodeEnergyPricesXML(file)
640+
if err != nil {
641+
t.Fatal(err)
642+
}
643+
644+
if len(doc.TimeSeries) != 1 {
645+
t.Fatalf("expected 1 time series, got %d", len(doc.TimeSeries))
646+
}
647+
648+
period := doc.TimeSeries[0].Period
649+
if len(period.Points) != 96 {
650+
t.Fatalf("expected 96 points, got %d", len(period.Points))
651+
}
652+
653+
// Verify position 1 (01:00-01:15) = 139.05
654+
if period.Points[0].PriceAmount != 139.05 {
655+
t.Errorf("pos 1 price = %f, want 139.05", period.Points[0].PriceAmount)
656+
}
657+
658+
// Verify position 96 (00:45-01:00) = 26.08
659+
if period.Points[95].PriceAmount != 26.08 {
660+
t.Errorf("pos 96 price = %f, want 26.08", period.Points[95].PriceAmount)
661+
}
662+
}
663+
664+
func TestDocumentDecode_20260901(t *testing.T) {
665+
file, err := os.Open("../test_data/Energy_Prices_202608312200-202609012200.xml")
666+
if err != nil {
667+
t.Fatal(err)
668+
}
669+
defer file.Close()
670+
671+
doc, err := DecodeEnergyPricesXML(file)
672+
if err != nil {
673+
t.Fatal(err)
674+
}
675+
676+
if len(doc.TimeSeries) != 1 {
677+
t.Fatalf("expected 1 time series, got %d", len(doc.TimeSeries))
678+
}
679+
680+
period := doc.TimeSeries[0].Period
681+
if len(period.Points) != 96 {
682+
t.Fatalf("expected 96 points, got %d", len(period.Points))
683+
}
684+
685+
// Verify position 1 (01:00-01:15) = 180.41
686+
if period.Points[0].PriceAmount != 180.41 {
687+
t.Errorf("pos 1 price = %f, want 180.41", period.Points[0].PriceAmount)
688+
}
689+
690+
// Verify position 96 (00:45-01:00) = 73.23
691+
if period.Points[95].PriceAmount != 73.23 {
692+
t.Errorf("pos 96 price = %f, want 73.23", period.Points[95].PriceAmount)
693+
}
694+
}

‎entsoe/xml_cache.go‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ type CacheEntry struct {
2424
// Document is the parsed market data.
2525
Document *PublicationMarketData
2626

27+
// RawXML holds the original raw XML document bytes.
28+
RawXML []byte
29+
2730
// UploadedAt is the time the document was stored in the cache.
2831
UploadedAt time.Time
2932

@@ -58,25 +61,44 @@ func (c *XMLDocumentCache) Store(date string, xmlData []byte) error {
5861
return fmt.Errorf("failed to parse XML for date %s: %w", date, err)
5962
}
6063

61-
c.StoreDocument(date, doc, CacheSourceUpload)
64+
c.StoreDocumentWithRaw(date, doc, xmlData, CacheSourceUpload)
6265
return nil
6366
}
6467

6568
// StoreDocument stores an already-parsed PublicationMarketData document under
6669
// the given date key (expected format: YYYY-MM-DD).
6770
// Any existing entry for that date is overwritten.
6871
func (c *XMLDocumentCache) StoreDocument(date string, doc *PublicationMarketData, source CacheSource) {
72+
c.StoreDocumentWithRaw(date, doc, nil, source)
73+
}
74+
75+
// StoreDocumentWithRaw stores an already-parsed PublicationMarketData document alongside
76+
// its raw XML bytes under the given date key (expected format: YYYY-MM-DD).
77+
func (c *XMLDocumentCache) StoreDocumentWithRaw(date string, doc *PublicationMarketData, rawXML []byte, source CacheSource) {
6978
c.mu.Lock()
7079
defer c.mu.Unlock()
7180

7281
c.entries[date] = &CacheEntry{
7382
Document: doc,
83+
RawXML: rawXML,
7484
UploadedAt: time.Now(),
7585
Date: date,
7686
Source: source,
7787
}
7888
}
7989

90+
// GetRaw returns the raw XML bytes for the given date key, if available.
91+
func (c *XMLDocumentCache) GetRaw(date string) ([]byte, bool) {
92+
c.mu.RLock()
93+
defer c.mu.RUnlock()
94+
95+
entry, ok := c.entries[date]
96+
if !ok || len(entry.RawXML) == 0 {
97+
return nil, false
98+
}
99+
return entry.RawXML, true
100+
}
101+
80102
// Get returns the cached PublicationMarketData for the given date key.
81103
// Returns (nil, false) when no entry exists for that date.
82104
func (c *XMLDocumentCache) Get(date string) (*PublicationMarketData, bool) {

‎scheduler/mpc.go‎

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,24 @@ func (s *MinerScheduler) RunMPCOptimize(ctx context.Context) error {
1919

2020
config := s.GetConfig()
2121

22-
// Check if Plant Modbus Address is configured
23-
if config.PlantModbusAddress == "" {
24-
s.logger.Printf("MPC optimization skipped: PlantModbusAddress not configured")
25-
return nil
26-
}
27-
28-
// Step 1: Read plant running info from inverter
29-
plantInfo, err := s.readPlantRunningInfo(ctx, config)
30-
if err != nil {
31-
s.logger.Printf("Error reading plant running info from inverter: %v", err)
32-
return err
22+
// Step 1: Read plant running info from inverter if configured, or use default plant info
23+
var plantInfo *sigenergy.PlantRunningInfo
24+
if config.PlantModbusAddress != "" {
25+
var err error
26+
plantInfo, err = s.readPlantRunningInfo(ctx, config)
27+
if err != nil {
28+
s.logger.Printf("Warning: Error reading plant running info from inverter: %v, using default plant info", err)
29+
plantInfo = &sigenergy.PlantRunningInfo{
30+
ESSSOC: 50.0,
31+
ESSAvgCellTemperature: 20.0,
32+
}
33+
}
34+
} else {
35+
s.logger.Printf("PlantModbusAddress not configured, using default plant info for MPC optimization")
36+
plantInfo = &sigenergy.PlantRunningInfo{
37+
ESSSOC: 50.0,
38+
ESSAvgCellTemperature: 20.0,
39+
}
3340
}
3441

3542
// Extract initial SOC from plant info

‎scheduler/pricing.go‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,37 @@ import (
1111
// StoreMarketDataXML parses the given XML bytes and stores the resulting document
1212
// in the XML document cache under the given date key (YYYY-MM-DD format).
1313
func (s *MinerScheduler) StoreMarketDataXML(date string, xmlData []byte) error {
14-
return s.xmlCache.Store(date, xmlData)
14+
if err := s.xmlCache.Store(date, xmlData); err != nil {
15+
return err
16+
}
17+
go s.refreshMarketDataAndMPC()
18+
return nil
1519
}
1620

1721
// DeleteMarketDataXML removes the cached XML document for the given date key.
1822
func (s *MinerScheduler) DeleteMarketDataXML(date string) {
1923
s.xmlCache.Delete(date)
24+
go s.refreshMarketDataAndMPC()
25+
}
26+
27+
// refreshMarketDataAndMPC invalidates cached prices market data and triggers GetMarketData
28+
// followed by RunMPCOptimize to ensure MPC optimization decisions are updated immediately.
29+
func (s *MinerScheduler) refreshMarketDataAndMPC() {
30+
s.mu.Lock()
31+
s.pricesMarketData = nil
32+
s.pricesMarketDataExpiry = time.Time{}
33+
s.mu.Unlock()
34+
35+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
36+
defer cancel()
37+
38+
if _, err := s.GetMarketData(ctx); err != nil {
39+
s.logger.Printf("Warning: failed to refresh market data after cache update: %v", err)
40+
}
41+
42+
if err := s.RunMPCOptimize(ctx); err != nil {
43+
s.logger.Printf("Warning: failed to run MPC optimization after market data cache update: %v", err)
44+
}
2045
}
2146

2247
// GetXMLCacheEntries returns a snapshot of all entries currently held in the

0 commit comments

Comments
 (0)