Tariff: add time-based grid fees (#29766)
This commit is contained in:
parent
4bc9ba4ca5
commit
908e6cf6f3
16 changed files with 345 additions and 53 deletions
|
|
@ -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
136
tariff/embed_test.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import (
|
|||
|
||||
func TestFixed(t *testing.T) {
|
||||
tf := &Fixed{
|
||||
embed: &embed{},
|
||||
clock: clock.NewMock(),
|
||||
zones: []fixed.Zone{
|
||||
{Price: 0.3},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue