diff --git a/core/solar_test.go b/core/solar_test.go index 55d418879..34c3c9281 100644 --- a/core/solar_test.go +++ b/core/solar_test.go @@ -6,8 +6,10 @@ import ( "github.com/benbjohnson/clock" "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/tariff" "github.com/jinzhu/now" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" ) @@ -112,3 +114,39 @@ func TestSolarEnergyNoRates(t *testing.T) { assert.Equal(t, 0.0, solarEnergy(api.Rates{}, now, now.Add(time.Hour))) assert.Equal(t, 0.0, solarEnergy(nil, now, now.Add(time.Hour))) } + +type solarTariff struct { + rates api.Rates +} + +func (t *solarTariff) Rates() (api.Rates, error) { return t.rates, nil } +func (t *solarTariff) Type() api.TariffType { return api.TariffTypeSolar } + +// TestSolarSlotSplitPreservesEnergy asserts that splitting hourly solar rates into +// 15min slots leaves the integrated energy untouched- the wrapper resamples the +// curve, it does not change it +func TestSolarSlotSplitPreservesEnergy(t *testing.T) { + start := time.Now().Truncate(tariff.SlotDuration) + + var rr api.Rates + for i, v := range []float64{0, 1000, 2500, 3000, 1500, 0} { + s := start.Add(time.Duration(i) * time.Hour) + rr = append(rr, api.Rate{Start: s, End: s.Add(time.Hour), Value: v}) + } + + w := &tariff.SlotWrapper{Tariff: &solarTariff{rates: rr}} + res, err := w.Rates() + require.NoError(t, err) + require.Len(t, res, 6*4) + + to := rr[len(rr)-1].Start + assert.InDelta(t, solarEnergy(rr, start, to), solarEnergy(res, start, to), 1e-9) + + // every sub-interval integrates identically, not just the total + for i := range res { + assert.InDelta(t, + solarEnergy(rr, start, res[i].Start), + solarEnergy(res, start, res[i].Start), + 1e-9, "slot %d", i) + } +} diff --git a/tariff/slots.go b/tariff/slots.go index 65dd46326..5b7888987 100644 --- a/tariff/slots.go +++ b/tariff/slots.go @@ -13,7 +13,7 @@ type SlotWrapper struct { } // Rates converts arbitrary slot lengths (multiples of SlotDuration) to 15m slots. -// Price sub-slots are constant, solar sub-slots interpolated around the slot center. +// Price sub-slots are constant, solar sub-slots interpolated towards the next slot. func (t *SlotWrapper) Rates() (api.Rates, error) { rates, err := t.Tariff.Rates() if err != nil { @@ -58,52 +58,32 @@ func (t *SlotWrapper) Rates() (api.Rates, error) { 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. +// shapeSolar samples the source curve at the sub-slot starts. A solar value is the +// power at its Start (see core.solarEnergy), so splitting a slot must interpolate +// towards the successor rather than redistribute the value across the sub-slots - +// only then does the split leave the integrated energy untouched. Interpolation runs +// over the distance between the two starts, which is the slot length only for a gapless +// series. The trailing slot has no successor and stays flat. 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 + + for j := range vals { + vals[j] = cur + } + + if i+1 >= len(rates) { 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 - } + next := rates[i+1] + span := next.Start.Sub(rates[i].Start) + if span <= 0 { return } - // preserve the slot average - scale := cur * float64(len(vals)) / sum for j := range vals { - vals[j] *= scale + // beyond the successor's start the curve is the successor's business + d := min(time.Duration(j)*SlotDuration, span) + vals[j] = cur + (next.Value-cur)*float64(d)/float64(span) } } diff --git a/tariff/slots_test.go b/tariff/slots_test.go index 62d0a923b..1584e3018 100644 --- a/tariff/slots_test.go +++ b/tariff/slots_test.go @@ -141,22 +141,18 @@ func TestDropOldRates(t *testing.T) { require.Len(t, res, 0) } -// assertSourceAverages verifies that the sub-slots preserve the average of their source slot -func assertSourceAverages(t *testing.T, rr, res api.Rates) { +// assertSourceValues verifies that each source slot start keeps its original value +func assertSourceValues(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) + assert.InDelta(t, r.Value, res[i*n].Value, 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 +// TestSolarInterpolation verifies that solar sub-slots ramp towards the +// following slot while keeping the source value at the source slot start func TestSolarInterpolation(t *testing.T) { now := time.Now().Truncate(SlotDuration) @@ -185,12 +181,12 @@ func TestSolarInterpolation(t *testing.T) { assert.Equal(t, now.Add(time.Duration(i)*SlotDuration), r.Start, "slot %d", i) } - // 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} { + // ramping up towards the next hour, flat towards the missing successor + for i, expected := range []float64{0, 1, 2, 3, 4, 4, 4, 4} { assert.InDelta(t, expected, res[i].Value, 1e-9, "slot %d", i) } - assertSourceAverages(t, api.Rates{r0, r1}, res) + assertSourceValues(t, api.Rates{r0, r1}, res) } // TestSolarInterpolationInterior verifies an interior slot with both neighbours differing @@ -209,15 +205,38 @@ func TestSolarInterpolationInterior(t *testing.T) { 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} { + // interior slot ramps linearly from its own value to the next one + for i, expected := range []float64{4, 5, 6, 7} { assert.InDelta(t, expected, res[4+i].Value, 1e-9, "slot %d", i) } - assertSourceAverages(t, rr, res) + assertSourceValues(t, rr, res) } -// TestSolarNegativeSlot verifies that a non-positive slot is not shaped +// TestSolarInterpolationGap verifies that the ramp spans the distance between the +// two slot starts, not the slot length, when the series has a gap +func TestSolarInterpolationGap(t *testing.T) { + now := time.Now().Truncate(SlotDuration) + + // one hour, then a one hour gap before the successor + rr := api.Rates{ + {Start: now, End: now.Add(time.Hour), Value: 0}, + {Start: now.Add(2 * time.Hour), End: now.Add(3 * time.Hour), Value: 8}, + } + + w := &SlotWrapper{&testTariff{rates: rr, typ: api.TariffTypeSolar}} + + res, err := w.Rates() + require.NoError(t, err) + require.Len(t, res, 8) + + // ramp reaches the successor after two hours, not after one + for i, expected := range []float64{0, 1, 2, 3} { + assert.InDelta(t, expected, res[i].Value, 1e-9, "slot %d", i) + } +} + +// TestSolarNegativeSlot verifies that a non-positive slot ramps like any other func TestSolarNegativeSlot(t *testing.T) { now := time.Now().Truncate(SlotDuration) @@ -232,7 +251,7 @@ func TestSolarNegativeSlot(t *testing.T) { 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) + for i, expected := range []float64{-1, 0.25, 1.5, 2.75} { + assert.InDelta(t, expected, res[i].Value, 1e-9, "slot %d", i) } }