Add merged tariff to combine multiple forecast horizons (#27172)
This commit is contained in:
parent
ff0ee105be
commit
de52d3c4f5
2 changed files with 260 additions and 0 deletions
91
tariff/merged.go
Normal file
91
tariff/merged.go
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
package tariff
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
)
|
||||
|
||||
// Merged combines a primary tariff with a secondary (forecast) tariff.
|
||||
// Primary rates are used where available, secondary fills gaps after primary ends.
|
||||
type Merged struct {
|
||||
log *util.Logger
|
||||
primary api.Tariff
|
||||
secondary api.Tariff
|
||||
}
|
||||
|
||||
func init() {
|
||||
registry.AddCtx("merged", NewMergedFromConfig)
|
||||
}
|
||||
|
||||
func NewMergedFromConfig(ctx context.Context, other map[string]any) (api.Tariff, error) {
|
||||
var cc struct {
|
||||
Primary, Secondary Typed
|
||||
}
|
||||
|
||||
if err := util.DecodeOther(other, &cc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
primary, err := NewFromConfig(ctx, cc.Primary.Type, cc.Primary.Other)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
secondary, err := NewFromConfig(ctx, cc.Secondary.Type, cc.Secondary.Other)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if pType, sType := primary.Type(), secondary.Type(); pType != sType {
|
||||
return nil, fmt.Errorf("primary and secondary tariff types are not compatible: %v vs %v", pType, sType)
|
||||
}
|
||||
|
||||
t := &Merged{
|
||||
log: util.NewLogger("merged"),
|
||||
primary: primary,
|
||||
secondary: secondary,
|
||||
}
|
||||
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// Rates implements the api.Tariff interface
|
||||
func (t *Merged) Rates() (api.Rates, error) {
|
||||
result, err := t.primary.Rates()
|
||||
if err != nil {
|
||||
t.log.DEBUG.Printf("primary tariff failed, falling back to secondary: %v", err)
|
||||
return t.secondary.Rates()
|
||||
}
|
||||
|
||||
secondaryRates, err := t.secondary.Rates()
|
||||
if err != nil {
|
||||
t.log.DEBUG.Printf("secondary tariff failed, using primary only: %v", err)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// If primary is empty, use all secondary rates
|
||||
if len(result) == 0 {
|
||||
return secondaryRates, nil
|
||||
}
|
||||
|
||||
// Find where primary data ends and append secondary rates starting there
|
||||
primaryEnd := result[len(result)-1].End
|
||||
if idx, found := slices.BinarySearchFunc(secondaryRates, primaryEnd, func(r api.Rate, t time.Time) int {
|
||||
return r.Start.Compare(t)
|
||||
}); found {
|
||||
return append(result, secondaryRates[idx:]...), nil
|
||||
}
|
||||
|
||||
t.log.WARN.Printf("secondary tariff does not align gapless with primary, ignoring secondary")
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Type implements the api.Tariff interface
|
||||
func (t *Merged) Type() api.TariffType {
|
||||
return t.primary.Type()
|
||||
}
|
||||
169
tariff/merged_test.go
Normal file
169
tariff/merged_test.go
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
package tariff
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/benbjohnson/clock"
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type mockTariff struct {
|
||||
rates api.Rates
|
||||
err error
|
||||
typ api.TariffType
|
||||
}
|
||||
|
||||
func (m *mockTariff) Rates() (api.Rates, error) {
|
||||
return m.rates, m.err
|
||||
}
|
||||
|
||||
func (m *mockTariff) Type() api.TariffType {
|
||||
return m.typ
|
||||
}
|
||||
|
||||
func TestMergedRates(t *testing.T) {
|
||||
now := clock.NewMock().Now()
|
||||
|
||||
primaryRates := api.Rates{
|
||||
{Start: now, End: now.Add(time.Hour), Value: 0.10},
|
||||
{Start: now.Add(time.Hour), End: now.Add(2 * time.Hour), Value: 0.12},
|
||||
}
|
||||
|
||||
secondaryRates := api.Rates{
|
||||
{Start: now.Add(time.Hour), End: now.Add(2 * time.Hour), Value: 0.20}, // overlaps with primary
|
||||
{Start: now.Add(2 * time.Hour), End: now.Add(3 * time.Hour), Value: 0.22}, // after primary
|
||||
{Start: now.Add(3 * time.Hour), End: now.Add(4 * time.Hour), Value: 0.24}, // after primary
|
||||
}
|
||||
|
||||
ext := &Merged{
|
||||
log: util.NewLogger("foo"),
|
||||
primary: &mockTariff{rates: primaryRates, typ: api.TariffTypePriceForecast},
|
||||
secondary: &mockTariff{rates: secondaryRates, typ: api.TariffTypePriceForecast},
|
||||
}
|
||||
|
||||
rates, err := ext.Rates()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should have primary rates plus secondary rates that start at or after primary ends
|
||||
expected := api.Rates{
|
||||
{Start: now, End: now.Add(time.Hour), Value: 0.10},
|
||||
{Start: now.Add(time.Hour), End: now.Add(2 * time.Hour), Value: 0.12},
|
||||
{Start: now.Add(2 * time.Hour), End: now.Add(3 * time.Hour), Value: 0.22},
|
||||
{Start: now.Add(3 * time.Hour), End: now.Add(4 * time.Hour), Value: 0.24},
|
||||
}
|
||||
|
||||
assert.Equal(t, expected, rates)
|
||||
}
|
||||
|
||||
func TestMergedPrimaryFailure(t *testing.T) {
|
||||
now := clock.NewMock().Now()
|
||||
|
||||
secondaryRates := api.Rates{
|
||||
{Start: now, End: now.Add(time.Hour), Value: 0.20},
|
||||
{Start: now.Add(time.Hour), End: now.Add(2 * time.Hour), Value: 0.22},
|
||||
}
|
||||
|
||||
ext := &Merged{
|
||||
log: util.NewLogger("foo"),
|
||||
primary: &mockTariff{err: assert.AnError, typ: api.TariffTypePriceForecast},
|
||||
secondary: &mockTariff{rates: secondaryRates, typ: api.TariffTypePriceForecast},
|
||||
}
|
||||
|
||||
rates, err := ext.Rates()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, secondaryRates, rates)
|
||||
}
|
||||
|
||||
func TestMergedSecondaryFailure(t *testing.T) {
|
||||
now := clock.NewMock().Now()
|
||||
|
||||
primaryRates := api.Rates{
|
||||
{Start: now, End: now.Add(time.Hour), Value: 0.10},
|
||||
{Start: now.Add(time.Hour), End: now.Add(2 * time.Hour), Value: 0.12},
|
||||
}
|
||||
|
||||
ext := &Merged{
|
||||
log: util.NewLogger("foo"),
|
||||
primary: &mockTariff{rates: primaryRates, typ: api.TariffTypePriceForecast},
|
||||
secondary: &mockTariff{err: assert.AnError, typ: api.TariffTypePriceForecast},
|
||||
}
|
||||
|
||||
rates, err := ext.Rates()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, primaryRates, rates)
|
||||
}
|
||||
|
||||
func TestMergedEmptyPrimary(t *testing.T) {
|
||||
now := clock.NewMock().Now()
|
||||
|
||||
secondaryRates := api.Rates{
|
||||
{Start: now, End: now.Add(time.Hour), Value: 0.20},
|
||||
{Start: now.Add(time.Hour), End: now.Add(2 * time.Hour), Value: 0.22},
|
||||
}
|
||||
|
||||
ext := &Merged{
|
||||
log: util.NewLogger("foo"),
|
||||
primary: &mockTariff{rates: api.Rates{}, typ: api.TariffTypePriceForecast},
|
||||
secondary: &mockTariff{rates: secondaryRates, typ: api.TariffTypePriceForecast},
|
||||
}
|
||||
|
||||
rates, err := ext.Rates()
|
||||
require.NoError(t, err)
|
||||
|
||||
// With empty primary, all secondary rates should be included
|
||||
assert.Equal(t, secondaryRates, rates)
|
||||
}
|
||||
|
||||
func TestMergedType(t *testing.T) {
|
||||
ext := &Merged{
|
||||
log: util.NewLogger("foo"),
|
||||
primary: &mockTariff{typ: api.TariffTypePriceForecast},
|
||||
secondary: &mockTariff{typ: api.TariffTypePriceForecast},
|
||||
}
|
||||
|
||||
assert.Equal(t, api.TariffTypePriceForecast, ext.Type())
|
||||
}
|
||||
|
||||
func TestMergedBothFail(t *testing.T) {
|
||||
ext := &Merged{
|
||||
log: util.NewLogger("foo"),
|
||||
primary: &mockTariff{err: assert.AnError, typ: api.TariffTypePriceForecast},
|
||||
secondary: &mockTariff{err: assert.AnError, typ: api.TariffTypePriceForecast},
|
||||
}
|
||||
|
||||
rates, err := ext.Rates()
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, rates)
|
||||
}
|
||||
|
||||
func TestMergedGapBetweenPrimaryAndSecondary(t *testing.T) {
|
||||
now := clock.NewMock().Now()
|
||||
|
||||
// Primary ends at hour 2
|
||||
primaryRates := api.Rates{
|
||||
{Start: now, End: now.Add(time.Hour), Value: 0.10},
|
||||
{Start: now.Add(time.Hour), End: now.Add(2 * time.Hour), Value: 0.12},
|
||||
}
|
||||
|
||||
// Secondary starts at hour 4, leaving a gap from hour 2 to hour 4
|
||||
secondaryRates := api.Rates{
|
||||
{Start: now.Add(4 * time.Hour), End: now.Add(5 * time.Hour), Value: 0.22},
|
||||
{Start: now.Add(5 * time.Hour), End: now.Add(6 * time.Hour), Value: 0.24},
|
||||
}
|
||||
|
||||
ext := &Merged{
|
||||
log: util.NewLogger("foo"),
|
||||
primary: &mockTariff{rates: primaryRates, typ: api.TariffTypePriceForecast},
|
||||
secondary: &mockTariff{rates: secondaryRates, typ: api.TariffTypePriceForecast},
|
||||
}
|
||||
|
||||
rates, err := ext.Rates()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Secondary should be ignored since it would create a gap
|
||||
assert.Equal(t, primaryRates, rates)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue