evcc-io/tariff/slots_test.go

257 lines
6.6 KiB
Go

package tariff
import (
"testing"
"time"
"github.com/evcc-io/evcc/api"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type testTariff struct {
rates api.Rates
typ api.TariffType
}
func (t *testTariff) Rates() (api.Rates, error) {
return t.rates, nil
}
func (t *testTariff) Type() api.TariffType {
return t.typ
}
// makeRates creates n consecutive rates starting at 'start', each with the given duration
// Values start at startVal and increase by 1 for each subsequent rate
func makeRates(start time.Time, duration time.Duration, n int, startVal float64) api.Rates {
var rates api.Rates
for i := range n {
s := start.Add(time.Duration(i) * duration)
rates = append(rates, api.Rate{
Start: s,
End: s.Add(duration),
Value: startVal + float64(i),
})
}
return rates
}
// TestBasicSlotConversionCounts ensures that different source durations are split into the expected number of 15-minute slots
func TestBasicSlotConversionCounts(t *testing.T) {
now := time.Now().Truncate(SlotDuration)
cases := []struct {
dur time.Duration
expected int
}{
{15 * time.Minute, 1},
{30 * time.Minute, 2},
{1 * time.Hour, 4},
{2 * time.Hour, 8},
}
for _, tc := range cases {
// Create a single rate of length tc.dur starting at "now"
rates := makeRates(now, tc.dur, 1, 5.0)
w := &SlotWrapper{&testTariff{
rates: rates,
typ: api.TariffTypePriceStatic,
}}
out, err := w.Rates()
require.NoError(t, err)
// Check the number of produced 15-minute slots
assert.Len(t, out, tc.expected, "duration %v", tc.dur)
// Additional lightweight checks:
// - first slot should begin at the original rate start
// - every produced slot must have the configured SlotDuration length
if len(out) > 0 {
assert.Equal(t, rates[0].Start, out[0].Start)
for _, r := range out {
assert.Equal(t, SlotDuration, r.End.Sub(r.Start))
}
}
}
}
// TestMixedSlots verifies a mix of a 15-minute rate followed by a 1-hour rate
// For price tariffs subslots from the hour should keep the same constant price
func TestMixedSlots(t *testing.T) {
now := time.Now().Truncate(SlotDuration)
// first: a single 15-minute rate
r0 := api.Rate{
Start: now,
End: now.Add(15 * time.Minute),
Value: 1.0,
}
// second: an hour that follows immediately
r1 := api.Rate{
Start: r0.End,
End: r0.End.Add(1 * time.Hour),
Value: 3.0,
}
w := &SlotWrapper{&testTariff{
rates: api.Rates{r0, r1},
typ: api.TariffTypePriceStatic,
}}
out, err := w.Rates()
require.NoError(t, err)
// expected: one 15m slot with value 1.0, then four 15m slots with value 3.0
expected := api.Rates{api.Rate{
Start: r0.Start,
End: r0.End,
Value: 1.0,
}}
for i := range 4 {
expected = append(expected, api.Rate{
Start: r1.Start.Add(time.Duration(i) * SlotDuration),
End: r1.Start.Add(time.Duration(i+1) * SlotDuration),
Value: 3.0,
})
}
assert.Equal(t, expected, out)
}
func TestDropOldRates(t *testing.T) {
now := time.Now().Truncate(SlotDuration)
// old rate that should be removed by the wrapper (ends before 'now')
old := api.Rate{
Start: now.Add(-1 * time.Hour),
End: now,
Value: 0.5,
}
w := &SlotWrapper{&testTariff{
rates: api.Rates{old},
typ: api.TariffTypeSolar,
}}
res, err := w.Rates()
require.NoError(t, err)
require.Len(t, res, 0)
}
// 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 {
assert.InDelta(t, r.Value, res[i*n].Value, 1e-9, "rate %d", i)
}
}
// 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)
// two consecutive hourly solar rates: 0.0 in the first hour, 4.0 in the next
r0 := api.Rate{
Start: now,
End: now.Add(time.Hour),
Value: 0.0,
}
r1 := api.Rate{
Start: r0.End,
End: r0.End.Add(time.Hour),
Value: 4.0,
}
w := &SlotWrapper{&testTariff{
rates: api.Rates{r0, r1},
typ: api.TariffTypeSolar,
}}
res, err := w.Rates()
require.NoError(t, err)
require.Len(t, res, 8)
for i, r := range res {
assert.Equal(t, now.Add(time.Duration(i)*SlotDuration), r.Start, "slot %d", i)
}
// 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)
}
assertSourceValues(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 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)
}
assertSourceValues(t, rr, res)
}
// 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)
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, expected := range []float64{-1, 0.25, 1.5, 2.75} {
assert.InDelta(t, expected, res[i].Value, 1e-9, "slot %d", i)
}
}