From aa7c685fb6e3b881ba632e79284f68346c74f40f Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 26 Jul 2026 15:24:17 +0200 Subject: [PATCH] Tariff (solar): center sub-slot interpolation on the source slot (#32116) --- tariff/slots.go | 81 +++++++++++++++++++++++++-------- tariff/slots_test.go | 104 ++++++++++++++++++++++++++++++++----------- 2 files changed, 141 insertions(+), 44 deletions(-) diff --git a/tariff/slots.go b/tariff/slots.go index 8ab934953..65dd46326 100644 --- a/tariff/slots.go +++ b/tariff/slots.go @@ -12,10 +12,8 @@ type SlotWrapper struct { api.Tariff } -// Rates converts arbitrary slot lengths (e.g. 1h, 30m) to 15m slots. -// Slot length must be multiple of SlotDuration. -// For price tariffs, the value is constant over all sub-slots. -// For solar/co2, linear interpolation is used between slot boundaries. +// Rates converts arbitrary slot lengths (multiples of SlotDuration) to 15m slots. +// Price sub-slots are constant, solar sub-slots interpolated around the slot center. func (t *SlotWrapper) Rates() (api.Rates, error) { rates, err := t.Tariff.Rates() if err != nil { @@ -37,28 +35,75 @@ func (t *SlotWrapper) Rates() (api.Rates, error) { numSlots := max(int(r.End.Sub(r.Start)/SlotDuration), 1) + vals := make([]float64, numSlots) + for j := range vals { + vals[j] = r.Value + } + + if t.Type() == api.TariffTypeSolar { + shapeSolar(rates, i, vals) + } + for j := range numSlots { start := r.Start.Add(time.Duration(j) * SlotDuration) - end := start.Add(SlotDuration) - val := r.Value - - switch t.Type() { - case api.TariffTypeSolar: //, api.TariffTypeCo2 - if i+1 < len(rates) { - start0 := r.Start - start1 := rates[i+1].Start - frac := float64(start.Sub(start0)) / float64(start1.Sub(start0)) - val = r.Value + frac*(rates[i+1].Value-r.Value) - } - } res = append(res, api.Rate{ Start: start, - End: end, - Value: val, + End: start.Add(SlotDuration), + Value: vals[j], }) } } return res, nil } + +// shapeSolar interpolates solar sub-slots between the slot centers. The slot value +// applies to the entire period, so sub-slots are centered and rescaled to it. Slot +// edges meet the average of both neighbouring values, keeping the curve continuous. +func shapeSolar(rates api.Rates, i int, vals []float64) { + if len(vals) < 2 { + return + } + + cur := rates[i].Value + if cur <= 0 { + // empty slot stays empty, shaping a non-positive slot would flip signs when rescaling + return + } + + prev, next := cur, cur + if i > 0 { + prev = rates[i-1].Value + } + if i+1 < len(rates) { + next = rates[i+1].Value + } + + var sum float64 + for j := range vals { + // sub-slot midpoint relative to the slot midpoint, [-0.5,0.5) + f := (float64(j)+0.5)/float64(len(vals)) - 0.5 + + delta := next - cur + if f < 0 { + delta = cur - prev + } + + vals[j] = max(cur+f*delta, 0) + sum += vals[j] + } + + if sum <= 0 { + for j := range vals { + vals[j] = cur + } + return + } + + // preserve the slot average + scale := cur * float64(len(vals)) / sum + for j := range vals { + vals[j] *= scale + } +} diff --git a/tariff/slots_test.go b/tariff/slots_test.go index 4b6c511e1..62d0a923b 100644 --- a/tariff/slots_test.go +++ b/tariff/slots_test.go @@ -141,46 +141,98 @@ func TestDropOldRates(t *testing.T) { require.Len(t, res, 0) } -// TestSolarAndCo2Interpolation -// -// For solar tariffs we expect power at time of interval start (see https://github.com/evcc-io/evcc/issues/23184 for changing this). -// When converting to 15min slots, solar interpolation needs to take care of this -func TestSolarAndCo2Interpolation(t *testing.T) { +// assertSourceAverages verifies that the sub-slots preserve the average of their source slot +func assertSourceAverages(t *testing.T, rr, res api.Rates) { + t.Helper() + + n := len(res) / len(rr) + for i, r := range rr { + var sum float64 + for _, sub := range res[i*n : (i+1)*n] { + sum += sub.Value + } + assert.InDelta(t, r.Value, sum/float64(n), 1e-9, "rate %d", i) + } +} + +// TestSolarInterpolation verifies that solar sub-slots follow the neighbouring +// slots while preserving the average of the slot they originate from +func TestSolarInterpolation(t *testing.T) { now := time.Now().Truncate(SlotDuration) - // Two consecutive hourly solar rates: 0.0 in the first hour, 4.0 in the next - // With linear interpolation, the first hour's four 15m slots should have values 0,1,2,3 + // two consecutive hourly solar rates: 0.0 in the first hour, 4.0 in the next r0 := api.Rate{ Start: now, - End: now.Add(1 * time.Hour), + End: now.Add(time.Hour), Value: 0.0, } r1 := api.Rate{ Start: r0.End, - End: r0.End.Add(1 * time.Hour), + End: r0.End.Add(time.Hour), Value: 4.0, } - for _, typ := range []api.TariffType{api.TariffTypeSolar} { //, api.TariffTypeCo2 - w := &SlotWrapper{&testTariff{ - rates: api.Rates{r0, r1}, - typ: typ, - }} + w := &SlotWrapper{&testTariff{ + rates: api.Rates{r0, r1}, + typ: api.TariffTypeSolar, + }} - res, err := w.Rates() - require.NoError(t, err) + res, err := w.Rates() + require.NoError(t, err) + require.Len(t, res, 8) - // Build expected results: r0 interpolated into 4 slots (0..3), then r1 as four slots with value 4.0 - expected := makeRates(now, SlotDuration, 4, 0) + for i, r := range res { + assert.Equal(t, now.Add(time.Duration(i)*SlotDuration), r.Start, "slot %d", i) + } - for j := range 4 { - expected = append(expected, api.Rate{ - Start: r1.Start.Add(time.Duration(j) * SlotDuration), - End: r1.Start.Add(time.Duration(j+1) * SlotDuration), - Value: 4.0, - }) - } + // ramping up from the empty hour, flat towards the missing successor + for i, expected := range []float64{0, 0, 0, 0, 20.0 / 7, 4, 32.0 / 7, 32.0 / 7} { + assert.InDelta(t, expected, res[i].Value, 1e-9, "slot %d", i) + } - assert.Equal(t, expected, res) + assertSourceAverages(t, api.Rates{r0, r1}, res) +} + +// TestSolarInterpolationInterior verifies an interior slot with both neighbours differing +func TestSolarInterpolationInterior(t *testing.T) { + now := time.Now().Truncate(SlotDuration) + + rr := make(api.Rates, 3) + for i, v := range []float64{0, 4, 8} { + start := now.Add(time.Duration(i) * time.Hour) + rr[i] = api.Rate{Start: start, End: start.Add(time.Hour), Value: v} + } + + w := &SlotWrapper{&testTariff{rates: rr, typ: api.TariffTypeSolar}} + + res, err := w.Rates() + require.NoError(t, err) + require.Len(t, res, 12) + + // interior slot ramps linearly between the neighbouring slot centers + for i, expected := range []float64{2.5, 3.5, 4.5, 5.5} { + assert.InDelta(t, expected, res[4+i].Value, 1e-9, "slot %d", i) + } + + assertSourceAverages(t, rr, res) +} + +// TestSolarNegativeSlot verifies that a non-positive slot is not shaped +func TestSolarNegativeSlot(t *testing.T) { + now := time.Now().Truncate(SlotDuration) + + rr := api.Rates{ + {Start: now, End: now.Add(time.Hour), Value: -1}, + {Start: now.Add(time.Hour), End: now.Add(2 * time.Hour), Value: 4}, + } + + w := &SlotWrapper{&testTariff{rates: rr, typ: api.TariffTypeSolar}} + + res, err := w.Rates() + require.NoError(t, err) + require.Len(t, res, 8) + + for i, r := range res[:4] { + assert.Equal(t, -1.0, r.Value, "slot %d", i) } }