From de52d3c4f57c0f9e8b127832b2fa7cdbae4ddd2b Mon Sep 17 00:00:00 2001 From: Tomas Date: Sun, 8 Feb 2026 17:28:54 +0100 Subject: [PATCH] Add merged tariff to combine multiple forecast horizons (#27172) --- tariff/merged.go | 91 +++++++++++++++++++++++ tariff/merged_test.go | 169 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 260 insertions(+) create mode 100644 tariff/merged.go create mode 100644 tariff/merged_test.go diff --git a/tariff/merged.go b/tariff/merged.go new file mode 100644 index 000000000..c6ec3b5b8 --- /dev/null +++ b/tariff/merged.go @@ -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() +} diff --git a/tariff/merged_test.go b/tariff/merged_test.go new file mode 100644 index 000000000..1699650b6 --- /dev/null +++ b/tariff/merged_test.go @@ -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) +}