Skip to content
Merged
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/keep-uncovered-price-slots.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

When ENTSO-E returns hourly prices and only some quarter-hour prices, keep the hourly price for the uncovered minutes. A partial quarter-hour series no longer creates gaps in the plan's price data.
40 changes: 28 additions & 12 deletions go/internal/prices/prices.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import (
"log/slog"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"sync"
Expand Down Expand Up @@ -431,25 +432,40 @@ func (e *ENTSOEProvider) parseXML(body []byte) ([]RawPrice, error) {
return keepFinestRows(out), nil
}

// keepFinestRows drops each row that overlaps a finer one. One A44 document
// can publish a delivery day at both PT15M and PT60M; storing both would give
// the planner overlapping slots.
// keepFinestRows gives finer rows precedence only over the time they cover.
// A partial PT15M series must not erase the rest of a PT60M delivery hour.
func keepFinestRows(rows []RawPrice) []RawPrice {
out := make([]RawPrice, 0, len(rows))
for _, r := range rows {
end := r.SlotStart.Add(time.Duration(r.SlotLenMin) * time.Minute)
finer := false
parts := []RawPrice{r}
for _, o := range rows {
if o.SlotLenMin < r.SlotLenMin && o.SlotStart.Before(end) &&
r.SlotStart.Before(o.SlotStart.Add(time.Duration(o.SlotLenMin)*time.Minute)) {
finer = true
break
if o.SlotLenMin >= r.SlotLenMin {
continue
}
fineEnd := o.SlotStart.Add(time.Duration(o.SlotLenMin) * time.Minute)
var remaining []RawPrice
for _, part := range parts {
end := part.SlotStart.Add(time.Duration(part.SlotLenMin) * time.Minute)
if !o.SlotStart.Before(end) || !part.SlotStart.Before(fineEnd) {
remaining = append(remaining, part)
continue
}
if part.SlotStart.Before(o.SlotStart) {
left := part
left.SlotLenMin = int(o.SlotStart.Sub(part.SlotStart) / time.Minute)
remaining = append(remaining, left)
}
if fineEnd.Before(end) {
part.SlotStart = fineEnd
part.SlotLenMin = int(end.Sub(fineEnd) / time.Minute)
remaining = append(remaining, part)
}
}
parts = remaining
}
if !finer {
out = append(out, r)
}
out = append(out, parts...)
}
sort.SliceStable(out, func(i, j int) bool { return out[i].SlotStart.Before(out[j].SlotStart) })
return out
}

Expand Down
43 changes: 43 additions & 0 deletions go/internal/prices/prices_overlap_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package prices

import (
"reflect"
"testing"
"time"
)

func TestMixedPriceResolutionPreservesUncoveredMinutes(t *testing.T) {
start := time.Date(2026, 9, 27, 12, 0, 0, 0, time.UTC)
row := func(offset, minutes int, price float64) RawPrice {
return RawPrice{SlotStart: start.Add(time.Duration(offset) * time.Minute), SlotLenMin: minutes, SEKPerKWh: price}
}
for _, tc := range []struct {
name string
fine []RawPrice
want []RawPrice
}{
{"first quarter", []RawPrice{row(0, 15, 2)}, []RawPrice{row(0, 15, 2), row(15, 45, 1)}},
{"middle quarter", []RawPrice{row(15, 15, 2)}, []RawPrice{row(0, 15, 1), row(15, 15, 2), row(30, 30, 1)}},
{"last quarter", []RawPrice{row(45, 15, 2)}, []RawPrice{row(0, 45, 1), row(45, 15, 2)}},
{"two separated quarters", []RawPrice{row(45, 15, 3), row(0, 15, 2)}, []RawPrice{row(0, 15, 2), row(15, 30, 1), row(45, 15, 3)}},
{"nested resolutions", []RawPrice{row(0, 30, 2), row(15, 15, 3)}, []RawPrice{row(0, 15, 2), row(15, 15, 3), row(30, 30, 1)}},
{"touching boundaries", []RawPrice{row(-15, 15, 2), row(60, 15, 3)}, []RawPrice{row(-15, 15, 2), row(0, 60, 1), row(60, 15, 3)}},
} {
t.Run(tc.name, func(t *testing.T) {
rows := append([]RawPrice{row(0, 60, 1)}, tc.fine...)
original := append([]RawPrice(nil), rows...)
if got := keepFinestRows(rows); !reflect.DeepEqual(got, tc.want) {
t.Fatalf("prices = %+v, want %+v", got, tc.want)
}
if !reflect.DeepEqual(rows, original) {
t.Fatal("input prices changed")
}
for i, j := 0, len(rows)-1; i < j; i, j = i+1, j-1 {
rows[i], rows[j] = rows[j], rows[i]
}
if got := keepFinestRows(rows); !reflect.DeepEqual(got, tc.want) {
t.Fatalf("reversed prices = %+v, want %+v", got, tc.want)
}
})
}
}
Loading