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

@ -79,7 +79,13 @@
:rows="textareaRows"
:disabled="disabled"
/>
<PropertyZonesField v-else-if="zones" :id="id" v-model="value" :currency="currency" />
<PropertyZonesField
v-else-if="zones"
:id="id"
v-model="value"
:currency="currency"
:valueLabel="zonesValueLabel"
/>
<div v-else class="d-flex" :class="sizeClass">
<div class="position-relative flex-grow-1">
<input
@ -276,6 +282,11 @@ export default {
zones() {
return this.type === "Zones";
},
zonesValueLabel() {
return this.property === "chargesZones"
? this.$t("config.tariff.zones.charge")
: this.$t("config.tariff.zones.price");
},
pricePerKWh() {
return this.type === "PricePerKWh";
},

View file

@ -2,9 +2,7 @@
<div class="border rounded p-3">
<!-- Price input -->
<div class="mb-3">
<label :for="formId('price')" class="form-label">{{
$t("config.tariff.zones.price")
}}</label>
<label :for="formId('price')" class="form-label">{{ valueLabel }}</label>
<div class="d-flex w-50 w-min-200">
<input
:id="formId('price')"
@ -123,6 +121,7 @@ export default {
zone: { type: Object as PropType<Zone>, required: true },
currency: { type: String as PropType<CURRENCY>, required: true },
index: { type: Number, required: true },
valueLabel: { type: String, required: true },
},
emits: ["update:zone", "save", "cancel"],
data() {

View file

@ -12,6 +12,7 @@
:zone="zone"
:currency="currency"
:index="index"
:valueLabel="valueLabel"
@save="saveEdit"
@cancel="cancelEdit"
/>
@ -56,6 +57,7 @@ export default {
default: () => [],
},
currency: { type: String as PropType<CURRENCY>, default: CURRENCY.EUR },
valueLabel: { type: String, required: true },
},
emits: ["update:modelValue"],
data() {

View file

@ -824,6 +824,7 @@
"allMonths": "Alle Monate",
"allTimes": "Alle Zeiten",
"cancel": "Abbrechen",
"charge": "Aufschlag",
"days": "Tage",
"edit": "Bearbeiten",
"hours": "Stunden",

View file

@ -824,6 +824,7 @@
"allMonths": "All months",
"allTimes": "All times",
"cancel": "Cancel",
"charge": "Charge",
"days": "Days",
"edit": "Edit",
"hours": "Hours",

View file

@ -7,6 +7,7 @@ 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"
)
@ -14,10 +15,17 @@ type embed struct {
Features_ []api.Feature `mapstructure:"features"`
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
}
if err := cc.embed.init(); err != nil {
return nil, err
}
zones, err := fixed.ParseZones(cc.Zones)
if err != nil {
return nil, err
}
t := &Fixed{
embed: &cc.embed,
clock: clock.New(),
dynamic: len(cc.Zones) >= 1,
}
for _, z := range cc.Zones {
days, err := fixed.ParseDays(z.Days)
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,
})
}
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},

View file

@ -40,9 +40,11 @@ params:
advanced: true
- name: interval
deprecated: true
- preset: tariff-base
render: |
type: custom
{{ include "tariff-base" . }}
forecast:
source: js
script: |

View file

@ -20,6 +20,7 @@ params:
de: Wenn keine Zone zutrifft
- name: zones
required: true
- preset: tariff-base
render: |
type: fixed
price: {{ .price }}
@ -36,3 +37,4 @@ render: |
months: {{ .months }}
{{- end }}
{{- end }}
{{ include "tariff-base" . }}

View file

@ -11,6 +11,8 @@ params:
description:
en: Price
de: Preis
- preset: tariff-base
render: |
type: fixed
price: {{ .price }}
{{ include "tariff-base" . }}

View file

@ -337,4 +337,55 @@ grid:
await expect(tariffGrid).toBeVisible();
await expect(tariffGrid).toContainText(["Forecast", "10.0 ct – 30.0 ct"].join(""));
});
test("charges zones", async ({ page }) => {
await start();
await page.goto("/#/config");
const modal = page.getByTestId("tariff-modal");
const tariffGrid = page.getByTestId("tariff-grid");
const save = modal.getByRole("button", { name: "Validate & save" });
await page.getByRole("button", { name: "Add tariff" }).click();
await expectModalVisible(modal);
await modal.getByRole("button", { name: "Add grid import tariff" }).click();
await modal.getByLabel("Provider").selectOption("Demo Market Price");
// expand advanced settings to reveal charges + chargesZones
await modal.getByRole("button", { name: "Advanced" }).click();
await expect(modal.getByText("Charges zones")).toBeVisible();
// base charge applies when no zone matches
await modal.getByLabel("Charge").fill("20");
// chargesZones uses the "Charge" label override (not "Price"). Negative
// values must be accepted to support reduced peak-hour grid fees.
await modal.getByRole("button", { name: "Add zone" }).click();
const zoneForm = modal.getByTestId("property-zone");
await expect(zoneForm.getByLabel("Charge")).toBeVisible();
await expect(zoneForm.getByLabel("Price")).not.toBeVisible();
await zoneForm.getByLabel("Charge").fill("-2.5");
await zoneForm.getByLabel("From", { exact: true }).fill("00:00");
await zoneForm.getByLabel("To", { exact: true }).fill("06:00");
await zoneForm.getByRole("button", { name: "Save", exact: true }).click();
const zones = modal.getByTestId("property-zone");
await expect(zones).toHaveCount(1);
await expect(zones.first()).toContainText(["-2.5 ct", "00:00 – 06:00"].join(""));
// round-trip persistence
await save.click();
await expectModalHidden(modal);
await expect(tariffGrid).toBeVisible();
await restart();
await page.reload();
await tariffGrid.getByRole("button", { name: "edit" }).click();
await expectModalVisible(modal);
await modal.getByRole("button", { name: "Advanced" }).click();
await expect(modal.getByLabel("Charge")).toHaveValue("20");
await expect(zones).toHaveCount(1);
await expect(zones.first()).toContainText(["-2.5 ct", "00:00 – 06:00"].join(""));
});
});

View file

@ -578,6 +578,15 @@ presets:
help:
de: Zusätzlicher fester Aufschlag pro kWh
en: Additional fixed charge per kWh
- name: chargesZones
type: zones
advanced: true
description:
en: Charges zones
de: Aufschlagszonen
help:
de: Zeitabhängige Aufschläge, die den Standardaufschlag überschreiben.
en: Time-based grid fees that override the default charge.
- name: tax
type: float
advanced: true

View file

@ -2,6 +2,21 @@
{{- if .charges }}
charges: {{ .charges }}
{{- end }}
{{- if .chargesZones }}
chargesZones:
{{- range .chargesZones }}
- charges: {{ .price }}
{{- if .days }}
days: {{ .days }}
{{- end }}
{{- if .hours }}
hours: {{ .hours }}
{{- end }}
{{- if .months }}
months: {{ .months }}
{{- end }}
{{- end }}
{{- end }}
{{- if .tax }}
tax: {{ .tax }}
{{- end }}