Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/day-ahead-nordpool.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

When the Sourceful price harvest is stale, FTW takes tomorrow's day-ahead from Nord Pool instead of filling the night with the ML twin.
49 changes: 49 additions & 0 deletions go/internal/prices/fallback.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package prices

import (
"context"
"log/slog"
"time"
)

// fallbackProvider tries primary first. If that day is empty (not yet in
// the harvest cache), it asks secondary. Name stays the primary so a
// configured sourceful box still reports sourceful unless the fallback
// actually supplied the rows — Fetch logs that case.
type fallbackProvider struct {
primary, secondary Provider
}

func withFallback(primary, secondary Provider) Provider {
if primary == nil {
return secondary
}
if secondary == nil {
return primary
}
return fallbackProvider{primary: primary, secondary: secondary}
}

func (f fallbackProvider) Name() string { return f.primary.Name() }

func (f fallbackProvider) Fetch(ctx context.Context, zone string, day time.Time) ([]RawPrice, error) {
rows, err := f.primary.Fetch(ctx, zone, day)
if err == nil && len(rows) > 0 {
return rows, nil
}
rows2, err2 := f.secondary.Fetch(ctx, zone, day)
if err2 != nil {
if err != nil {
return nil, err
}
return nil, err2
}
if len(rows2) > 0 {
slog.Info("price: primary empty, using Nord Pool day-ahead",
"primary", f.primary.Name(),
"zone", zone,
"day", day.Format("2006-01-02"),
"slots", len(rows2))
}
return rows2, nil
}
147 changes: 147 additions & 0 deletions go/internal/prices/nordpool.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package prices

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)

// NordPoolProvider reads the public Nord Pool day-ahead dataportal.
// No API key. Used when the Sourceful harvest has not yet stored a day
// that Nord Pool has already published.
type NordPoolProvider struct {
Client *http.Client
BaseURL string
Currency string
FX FXConverter
}

const nordPoolDefaultURL = "https://dataportal-api.nordpoolgroup.com/api/DayAheadPrices"

func NewNordPool() *NordPoolProvider {
return &NordPoolProvider{
Client: &http.Client{Timeout: 15 * time.Second},
BaseURL: nordPoolDefaultURL,
}
}

func (n *NordPoolProvider) Name() string { return "nordpool" }

func (n *NordPoolProvider) apiCurrency() string {
want := strings.ToUpper(strings.TrimSpace(n.Currency))
switch want {
case "SEK", "EUR", "NOK", "DKK", "GBP":
return want
default:
if want == "" {
return "SEK"
}
return "EUR"
}
}

func (n *NordPoolProvider) Fetch(ctx context.Context, zone string, day time.Time) ([]RawPrice, error) {
zone = strings.ToUpper(strings.TrimSpace(zone))
if zone == "" {
zone = "SE3"
}
loc, err := time.LoadLocation("Europe/Stockholm")
if err != nil {
loc = time.UTC
}
date := day.In(loc).Format("2006-01-02")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fallback fetches the wrong delivery date

Medium Severity

NordPoolProvider.Fetch reformats day in Europe/Stockholm, but fetchAndStore and the primary providers use day's own calendar date. When those dates differ, the fallback requests the wrong Nord Pool day and published prices are missed.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 6332d1d. Configure here.

apiCur := n.apiCurrency()
base := n.BaseURL
if base == "" {
base = nordPoolDefaultURL
}
endpoint := fmt.Sprintf("%s?date=%s&market=DayAhead&deliveryArea=%s&currency=%s",
base, url.QueryEscape(date), url.QueryEscape(zone), url.QueryEscape(apiCur))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "ftw (https://github.com/srcfl/ftw)")
client := n.Client
if client == nil {
client = http.DefaultClient
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, nil
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return nil, fmt.Errorf("nordpool: status %d: %s", resp.StatusCode, string(body))
}
var payload struct {
Currency string `json:"currency"`
MultiAreaEntries []struct {
DeliveryStart string `json:"deliveryStart"`
DeliveryEnd string `json:"deliveryEnd"`
EntryPerArea map[string]float64 `json:"entryPerArea"`
} `json:"multiAreaEntries"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return nil, fmt.Errorf("nordpool: decode: %w", err)
}
if payload.Currency != "" && !strings.EqualFold(payload.Currency, apiCur) {
return nil, fmt.Errorf("nordpool: asked for %s, got %q", apiCur, payload.Currency)
}
want := strings.ToUpper(strings.TrimSpace(n.Currency))
if want == "" {
want = "SEK"
}
convert := want != apiCur
if convert && n.FX == nil {
return nil, fmt.Errorf("nordpool: no exchange rate source for %s→%s", apiCur, want)
}
out := make([]RawPrice, 0, len(payload.MultiAreaEntries))
for _, e := range payload.MultiAreaEntries {
price, ok := e.EntryPerArea[zone]
if !ok {
for k, v := range e.EntryPerArea {
if strings.EqualFold(k, zone) {
price, ok = v, true
break
}
}
}
if !ok {
continue
}
start, err := time.Parse(time.RFC3339, e.DeliveryStart)
if err != nil {
return nil, fmt.Errorf("nordpool: deliveryStart %q: %w", e.DeliveryStart, err)
}
slotMin := 15
if e.DeliveryEnd != "" {
if end, err := time.Parse(time.RFC3339, e.DeliveryEnd); err == nil {
d := int(end.Sub(start).Minutes())
if d >= 5 && d <= 120 {
slotMin = d
}
}
}
perKWh := price / 1000.0
if convert {
native, ok := n.FX.Convert(perKWh, apiCur, want)
if !ok {
return nil, fmt.Errorf("nordpool: no %s→%s rate yet", apiCur, want)
}
perKWh = native
}
out = append(out, RawPrice{SlotStart: start, SlotLenMin: slotMin, SEKPerKWh: perKWh})
}
return out, nil
}
138 changes: 138 additions & 0 deletions go/internal/prices/nordpool_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package prices

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)

func TestNordPoolParsesDayAhead(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("date") != "2026-09-01" {
t.Errorf("date = %s", r.URL.Query().Get("date"))
}
if r.URL.Query().Get("deliveryArea") != "SE3" {
t.Errorf("area = %s", r.URL.Query().Get("deliveryArea"))
}
if r.Header.Get("User-Agent") == "" {
t.Error("User-Agent required")
}
_ = json.NewEncoder(w).Encode(map[string]any{
"currency": "SEK",
"multiAreaEntries": []map[string]any{
{
"deliveryStart": "2026-08-31T22:00:00Z",
"deliveryEnd": "2026-08-31T22:15:00Z",
"entryPerArea": map[string]float64{"SE3": 1243.02},
},
{
"deliveryStart": "2026-08-31T22:15:00Z",
"deliveryEnd": "2026-08-31T22:30:00Z",
"entryPerArea": map[string]float64{"SE3": 1083.3},
},
},
})
})
srv := httptest.NewServer(handler)
defer srv.Close()
p := &NordPoolProvider{Client: srv.Client(), BaseURL: srv.URL, Currency: "SEK"}
day := time.Date(2026, 9, 1, 8, 0, 0, 0, time.FixedZone("CEST", 2*3600))
rows, err := p.Fetch(context.Background(), "se3", day)
if err != nil {
t.Fatal(err)
}
if len(rows) != 2 {
t.Fatalf("got %d rows", len(rows))
}
if rows[0].SlotLenMin != 15 {
t.Errorf("slot = %d", rows[0].SlotLenMin)
}
if rows[0].SEKPerKWh < 1.24 || rows[0].SEKPerKWh > 1.25 {
t.Errorf("SEK/kWh = %g", rows[0].SEKPerKWh)
}
}

func TestNordPoolUnpublishedDay(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
p := &NordPoolProvider{Client: srv.Client(), BaseURL: srv.URL, Currency: "SEK"}
rows, err := p.Fetch(context.Background(), "SE3", time.Now())
if err != nil {
t.Fatalf("404 should not error: %v", err)
}
if len(rows) != 0 {
t.Errorf("got %d", len(rows))
}
}

func TestSourcefulFallsBackToNordPool(t *testing.T) {
primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer primary.Close()
secondary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"currency": "SEK",
"multiAreaEntries": []map[string]any{{
"deliveryStart": "2026-08-31T22:00:00Z",
"deliveryEnd": "2026-08-31T22:15:00Z",
"entryPerArea": map[string]float64{"SE3": 1000},
}},
})
}))
defer secondary.Close()
p := withFallback(
&SourcefulProvider{Client: primary.Client(), BaseURL: primary.URL},
&NordPoolProvider{Client: secondary.Client(), BaseURL: secondary.URL, Currency: "SEK"},
)
if p.Name() != "sourceful" {
t.Errorf("name = %s", p.Name())
}
rows, err := p.Fetch(context.Background(), "SE3", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 {
t.Fatalf("got %d, want Nord Pool row", len(rows))
}
}

func TestNextDayAheadCatch(t *testing.T) {
loc, err := time.LoadLocation("Europe/Stockholm")
if err != nil {
t.Fatal(err)
}
before := time.Date(2026, 8, 31, 12, 0, 0, 0, loc)
got := nextDayAheadCatch(before)
want := time.Date(2026, 8, 31, 13, 5, 0, 0, loc)
if !got.Equal(want) {
t.Errorf("before publication: got %s want %s", got, want)
}
after := time.Date(2026, 8, 31, 13, 6, 0, 0, loc)
got = nextDayAheadCatch(after)
want = time.Date(2026, 9, 1, 13, 5, 0, 0, loc)
if !got.Equal(want) {
t.Errorf("after publication: got %s want %s", got, want)
}
}

func TestNordPoolRejectsCurrencyMismatch(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"currency": "EUR",
"multiAreaEntries": []any{},
})
}))
defer srv.Close()
p := &NordPoolProvider{Client: srv.Client(), BaseURL: srv.URL, Currency: "SEK"}
_, err := p.Fetch(context.Background(), "SE3", time.Now())
if err == nil || !strings.Contains(err.Error(), "asked for SEK") {
t.Fatalf("err = %v", err)
}
}
Loading