diff --git a/.changeset/keep-uncovered-price-slots.md b/.changeset/keep-uncovered-price-slots.md new file mode 100644 index 000000000..6e816ddb5 --- /dev/null +++ b/.changeset/keep-uncovered-price-slots.md @@ -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. diff --git a/go/internal/prices/prices.go b/go/internal/prices/prices.go index 7fe398bec..973b6c11a 100644 --- a/go/internal/prices/prices.go +++ b/go/internal/prices/prices.go @@ -34,6 +34,7 @@ import ( "log/slog" "net/http" "net/url" + "sort" "strconv" "strings" "sync" @@ -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 } diff --git a/go/internal/prices/prices_overlap_test.go b/go/internal/prices/prices_overlap_test.go new file mode 100644 index 000000000..c792d0b4b --- /dev/null +++ b/go/internal/prices/prices_overlap_test.go @@ -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) + } + }) + } +}