Tariff (solar): resample sub-slots along the source curve (#33105)
This commit is contained in:
parent
f43499ea97
commit
defe9403de
3 changed files with 94 additions and 57 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
}
|
||||
|
||||
if i+1 >= len(rates) {
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue