Tariff: add time-based grid fees (#29766)

This commit is contained in:
Michael Geers 2026-05-16 14:18:33 +02:00 • committed by GitHub
parent 4bc9ba4ca5
commit 908e6cf6f3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 345 additions and 53 deletions

View file

@ -7,17 +7,25 @@ import (
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/plugin/golang/stdlib"
"github.com/evcc-io/evcc/tariff/fixed"
"github.com/traefik/yaegi/interp"
)
type embed struct {
Features_ []api.Feature `mapstructure:"features"`
Charges float64 `mapstructure:"charges"`
Tax float64 `mapstructure:"tax"`
Formula string `mapstructure:"formula"`
Charges float64 `mapstructure:"charges"`
ChargesZones_ []chargesZoneConfig `mapstructure:"chargesZones"`
Tax float64 `mapstructure:"tax"`
Formula string `mapstructure:"formula"`
calc func(float64, time.Time) (float64, error)
chargesZones fixed.Zones
calc func(price, charges float64, ts time.Time) (float64, error)
}
type chargesZoneConfig struct {
Charges float64
Days, Hours, Months string
}
func (t *embed) init() (err error) {
@ -27,11 +35,21 @@ func (t *embed) init() (err error) {
}
}()
specs := make([]fixed.ZoneSpec, len(t.ChargesZones_))
for i, c := range t.ChargesZones_ {
specs[i] = fixed.ZoneSpec{Price: c.Charges, Days: c.Days, Hours: c.Hours, Months: c.Months}
}
zones, err := fixed.ParseZones(specs)
if err != nil {
return err
}
t.chargesZones = zones
if t.Formula == "" {
return nil
}
t.calc = func(price float64, ts time.Time) (float64, error) {
t.calc = func(price, charges float64, ts time.Time) (float64, error) {
vm := interp.New(interp.Options{})
if err := vm.Use(stdlib.Symbols); err != nil {
return 0, err
@ -44,7 +62,7 @@ func (t *embed) init() (err error) {
charges float64 = %f
tax float64 = %f
ts = time.Unix(%d, 0).Local()
)`, price, t.Charges, t.Tax, ts.Unix())); err != nil {
)`, price, charges, t.Tax, ts.Unix())); err != nil {
return 0, err
}
@ -61,17 +79,38 @@ func (t *embed) init() (err error) {
}
// test the formula
_, err = t.calc(0, time.Now())
_, err = t.calc(0, t.Charges, time.Now())
return err
}
// effectiveCharges resolves the charge for ts in local time; later zones win.
func (t *embed) effectiveCharges(ts time.Time) float64 {
if len(t.chargesZones) == 0 {
return t.Charges
}
ts = ts.Local()
day := fixed.Day(int(ts.Weekday()))
month := fixed.Month(ts.Month() - 1)
hm := fixed.HourMin{Hour: ts.Hour(), Min: ts.Minute()}
zones := t.chargesZones.ForDayAndMonth(day, month)
for j := len(zones) - 1; j >= 0; j-- {
if zones[j].Hours.Contains(hm) {
return zones[j].Price
}
}
return t.Charges
}
func (t *embed) totalPrice(price float64, ts time.Time) float64 {
charges := t.effectiveCharges(ts)
if t.calc != nil {
res, _ := t.calc(price, ts)
res, _ := t.calc(price, charges, ts)
return res
}
return (price + t.Charges) * (1 + t.Tax)
return (price + charges) * (1 + t.Tax)
}
var _ api.FeatureDescriber = (*embed)(nil)

136
tariff/embed_test.go Normal file
View file

@ -0,0 +1,136 @@
package tariff
import (
"testing"
"time"
"github.com/evcc-io/evcc/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTotalPriceFallback(t *testing.T) {
e := embed{Charges: 0.10, Tax: 0.19}
require.NoError(t, e.init())
got := e.totalPrice(0.20, time.Now())
assert.InDelta(t, (0.20+0.10)*1.19, got, 1e-9)
}
func TestTotalPriceMatchingZone(t *testing.T) {
e := embed{
Charges: 0.50,
ChargesZones_: []chargesZoneConfig{
{Charges: 0.10, Hours: "00:00-06:00"},
},
}
require.NoError(t, e.init())
ts := time.Date(2026, 1, 15, 2, 0, 0, 0, time.Local)
got := e.totalPrice(0.20, ts)
assert.InDelta(t, 0.20+0.10, got, 1e-9)
}
func TestTotalPriceNonMatchingZone(t *testing.T) {
e := embed{
Charges: 0.50,
ChargesZones_: []chargesZoneConfig{
{Charges: 0.10, Hours: "00:00-06:00"},
},
}
require.NoError(t, e.init())
ts := time.Date(2026, 1, 15, 12, 0, 0, 0, time.Local)
got := e.totalPrice(0.20, ts)
assert.InDelta(t, 0.20+0.50, got, 1e-9)
}
func TestTotalPriceNegativeChargesZone(t *testing.T) {
e := embed{
Charges: 0.10,
ChargesZones_: []chargesZoneConfig{
{Charges: -0.05, Hours: "10:00-12:00"},
},
}
require.NoError(t, e.init())
ts := time.Date(2026, 1, 15, 11, 0, 0, 0, time.Local)
got := e.totalPrice(0.20, ts)
assert.InDelta(t, 0.20-0.05, got, 1e-9)
}
func TestTotalPriceLastZoneWins(t *testing.T) {
e := embed{
ChargesZones_: []chargesZoneConfig{
{Charges: 0.10, Hours: "00:00-06:00"},
{Charges: 0.05, Hours: "02:00-04:00"},
},
}
require.NoError(t, e.init())
// 03:00 is covered by both zones; later entry wins
ts := time.Date(2026, 1, 15, 3, 0, 0, 0, time.Local)
assert.InDelta(t, 0.20+0.05, e.totalPrice(0.20, ts), 1e-9)
// 05:00 is only covered by the broader zone
ts = time.Date(2026, 1, 15, 5, 0, 0, 0, time.Local)
assert.InDelta(t, 0.20+0.10, e.totalPrice(0.20, ts), 1e-9)
}
func TestEffectiveChargesMonthFilter(t *testing.T) {
e := embed{
Charges: 0.20,
ChargesZones_: []chargesZoneConfig{
{Charges: 0.05, Months: "Jan-Mar,Oct-Dec", Hours: "00:00-05:00"},
},
}
require.NoError(t, e.init())
// February, in window: zone applies
ts := time.Date(2026, 2, 15, 3, 0, 0, 0, time.Local)
assert.InDelta(t, 0.05, e.effectiveCharges(ts), 1e-9)
// June, in hour window but month does not match: fallback
ts = time.Date(2026, 6, 15, 3, 0, 0, 0, time.Local)
assert.InDelta(t, 0.20, e.effectiveCharges(ts), 1e-9)
}
func TestTotalPriceFormulaSeesResolvedCharges(t *testing.T) {
e := embed{
Charges: 0.10,
ChargesZones_: []chargesZoneConfig{
{Charges: 0.30, Hours: "10:00-12:00"},
},
Formula: "(price + charges) * 2",
}
require.NoError(t, e.init())
// In zone: charges resolves to 0.30
ts := time.Date(2026, 1, 15, 11, 0, 0, 0, time.Local)
assert.InDelta(t, (0.20+0.30)*2, e.totalPrice(0.20, ts), 1e-9)
// Out of zone: falls back to base 0.10
ts = time.Date(2026, 1, 15, 14, 0, 0, 0, time.Local)
assert.InDelta(t, (0.20+0.10)*2, e.totalPrice(0.20, ts), 1e-9)
}
func TestEmbedDecodeChargesZones(t *testing.T) {
other := map[string]any{
"charges": 0.20,
"chargesZones": []map[string]any{
{"charges": 0.05, "months": "Jan-Mar", "hours": "00:00-05:00"},
{"charges": 0.30, "hours": "18:00-21:00"},
},
}
var cc struct {
embed `mapstructure:",squash"`
}
require.NoError(t, util.DecodeOther(other, &cc))
require.NoError(t, cc.embed.init())
assert.Len(t, cc.ChargesZones_, 2)
assert.InDelta(t, 0.05, cc.ChargesZones_[0].Charges, 1e-9)
assert.Equal(t, "Jan-Mar", cc.ChargesZones_[0].Months)
assert.Len(t, cc.chargesZones, 2)
}

View file

@ -13,6 +13,7 @@ import (
)
type Fixed struct {
*embed
clock clock.Clock
zones fixed.Zones
dynamic bool
@ -26,55 +27,29 @@ func init() {
func NewFixedFromConfig(other map[string]any) (api.Tariff, error) {
var cc struct {
embed `mapstructure:",squash"`
Price float64
Zones []struct {
Price float64
Days, Hours, Months string
}
Zones []fixed.ZoneSpec
}
if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}
t := &Fixed{
clock: clock.New(),
dynamic: len(cc.Zones) >= 1,
if err := cc.embed.init(); err != nil {
return nil, err
}
for _, z := range cc.Zones {
days, err := fixed.ParseDays(z.Days)
if err != nil {
return nil, err
}
zones, err := fixed.ParseZones(cc.Zones)
if err != nil {
return nil, err
}
months, err := fixed.ParseMonths(z.Months)
if err != nil {
return nil, err
}
hours, err := fixed.ParseTimeRanges(z.Hours)
if err != nil && z.Hours != "" {
return nil, err
}
if len(hours) == 0 {
t.zones = append(t.zones, fixed.Zone{
Price: z.Price,
Days: days,
Months: months,
})
continue
}
for _, h := range hours {
t.zones = append(t.zones, fixed.Zone{
Price: z.Price,
Days: days,
Months: months,
Hours: h,
})
}
t := &Fixed{
embed: &cc.embed,
clock: clock.New(),
dynamic: len(cc.Zones) >= 1 || len(cc.ChargesZones_) >= 1,
zones: zones,
}
sort.Sort(t.zones)
@ -128,7 +103,7 @@ func (t *Fixed) Rates() (api.Rates, error) {
rate := api.Rate{
Start: ts,
End: end,
Value: zone.Price,
Value: t.totalPrice(zone.Price, ts),
}
res = append(res, rate)

View file

@ -13,6 +13,52 @@ type Zone struct {
type Zones []Zone
// ZoneSpec is the un-parsed config form of a zone (string day/hour/month fields).
type ZoneSpec struct {
Price float64
Days, Hours, Months string
}
// ParseZones expands ZoneSpecs into Zones, one per comma-separated hour range.
func ParseZones(specs []ZoneSpec) (Zones, error) {
var zones Zones
for _, z := range specs {
days, err := ParseDays(z.Days)
if err != nil {
return nil, err
}
months, err := ParseMonths(z.Months)
if err != nil {
return nil, err
}
hours, err := ParseTimeRanges(z.Hours)
if err != nil && z.Hours != "" {
return nil, err
}
if len(hours) == 0 {
zones = append(zones, Zone{
Price: z.Price,
Days: days,
Months: months,
})
continue
}
for _, h := range hours {
zones = append(zones, Zone{
Price: z.Price,
Days: days,
Months: months,
Hours: h,
})
}
}
return zones, nil
}
// implement sort.Interface
func (r Zones) Len() int {
return len(r)

View file

@ -14,6 +14,7 @@ import (
func TestFixed(t *testing.T) {
tf := &Fixed{
embed: &embed{},
clock: clock.NewMock(),
zones: []fixed.Zone{
{Price: 0.3},