From 908e6cf6f332f6dccf6876630bc96f414a62fdbd Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Sat, 16 May 2026 14:18:33 +0200 Subject: [PATCH] Tariff: add time-based grid fees (#29766) --- assets/js/components/Config/PropertyField.vue | 13 +- .../js/components/Config/PropertyZoneForm.vue | 5 +- .../components/Config/PropertyZonesField.vue | 2 + i18n/de.json | 1 + i18n/en.json | 1 + tariff/embed.go | 57 ++++++-- tariff/embed_test.go | 136 ++++++++++++++++++ tariff/fixed.go | 55 ++----- tariff/fixed/zone.go | 46 ++++++ tariff/fixed_test.go | 1 + .../definition/tariff/demo-dynamic-grid.yaml | 2 + templates/definition/tariff/fixed-zones.yaml | 2 + templates/definition/tariff/fixed.yaml | 2 + tests/config-tariffs.spec.ts | 51 +++++++ util/templates/defaults.yaml | 9 ++ util/templates/includes/tariff-base.tpl | 15 ++ 16 files changed, 345 insertions(+), 53 deletions(-) create mode 100644 tariff/embed_test.go diff --git a/assets/js/components/Config/PropertyField.vue b/assets/js/components/Config/PropertyField.vue index d6d823747..41b76dd68 100644 --- a/assets/js/components/Config/PropertyField.vue +++ b/assets/js/components/Config/PropertyField.vue @@ -79,7 +79,13 @@ :rows="textareaRows" :disabled="disabled" /> - +
- +
, required: true }, currency: { type: String as PropType, required: true }, index: { type: Number, required: true }, + valueLabel: { type: String, required: true }, }, emits: ["update:zone", "save", "cancel"], data() { diff --git a/assets/js/components/Config/PropertyZonesField.vue b/assets/js/components/Config/PropertyZonesField.vue index 0dd4dbd64..8027ef8bd 100644 --- a/assets/js/components/Config/PropertyZonesField.vue +++ b/assets/js/components/Config/PropertyZonesField.vue @@ -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, default: CURRENCY.EUR }, + valueLabel: { type: String, required: true }, }, emits: ["update:modelValue"], data() { diff --git a/i18n/de.json b/i18n/de.json index ce03a5390..99caf3cc5 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -824,6 +824,7 @@ "allMonths": "Alle Monate", "allTimes": "Alle Zeiten", "cancel": "Abbrechen", + "charge": "Aufschlag", "days": "Tage", "edit": "Bearbeiten", "hours": "Stunden", diff --git a/i18n/en.json b/i18n/en.json index 8728a6c5e..875d591cc 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -824,6 +824,7 @@ "allMonths": "All months", "allTimes": "All times", "cancel": "Cancel", + "charge": "Charge", "days": "Days", "edit": "Edit", "hours": "Hours", diff --git a/tariff/embed.go b/tariff/embed.go index e3cad9f18..4ff62ce3a 100644 --- a/tariff/embed.go +++ b/tariff/embed.go @@ -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) diff --git a/tariff/embed_test.go b/tariff/embed_test.go new file mode 100644 index 000000000..b50ef8c2f --- /dev/null +++ b/tariff/embed_test.go @@ -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) +} diff --git a/tariff/fixed.go b/tariff/fixed.go index 4c3e42e45..705b9a787 100644 --- a/tariff/fixed.go +++ b/tariff/fixed.go @@ -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) diff --git a/tariff/fixed/zone.go b/tariff/fixed/zone.go index fe464040e..82bb0d469 100644 --- a/tariff/fixed/zone.go +++ b/tariff/fixed/zone.go @@ -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) diff --git a/tariff/fixed_test.go b/tariff/fixed_test.go index ca56e2348..6be7603d1 100644 --- a/tariff/fixed_test.go +++ b/tariff/fixed_test.go @@ -14,6 +14,7 @@ import ( func TestFixed(t *testing.T) { tf := &Fixed{ + embed: &embed{}, clock: clock.NewMock(), zones: []fixed.Zone{ {Price: 0.3}, diff --git a/templates/definition/tariff/demo-dynamic-grid.yaml b/templates/definition/tariff/demo-dynamic-grid.yaml index 674ad3802..ab5874645 100644 --- a/templates/definition/tariff/demo-dynamic-grid.yaml +++ b/templates/definition/tariff/demo-dynamic-grid.yaml @@ -40,9 +40,11 @@ params: advanced: true - name: interval deprecated: true + - preset: tariff-base render: | type: custom + {{ include "tariff-base" . }} forecast: source: js script: | diff --git a/templates/definition/tariff/fixed-zones.yaml b/templates/definition/tariff/fixed-zones.yaml index 6084d9a37..7e1c8e4fb 100644 --- a/templates/definition/tariff/fixed-zones.yaml +++ b/templates/definition/tariff/fixed-zones.yaml @@ -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" . }} diff --git a/templates/definition/tariff/fixed.yaml b/templates/definition/tariff/fixed.yaml index 0e028fcd1..b0e5a6411 100644 --- a/templates/definition/tariff/fixed.yaml +++ b/templates/definition/tariff/fixed.yaml @@ -11,6 +11,8 @@ params: description: en: Price de: Preis + - preset: tariff-base render: | type: fixed price: {{ .price }} + {{ include "tariff-base" . }} diff --git a/tests/config-tariffs.spec.ts b/tests/config-tariffs.spec.ts index bdbc7beaf..bfc7d393b 100644 --- a/tests/config-tariffs.spec.ts +++ b/tests/config-tariffs.spec.ts @@ -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("")); + }); }); diff --git a/util/templates/defaults.yaml b/util/templates/defaults.yaml index 46c310184..8899b2d45 100644 --- a/util/templates/defaults.yaml +++ b/util/templates/defaults.yaml @@ -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 diff --git a/util/templates/includes/tariff-base.tpl b/util/templates/includes/tariff-base.tpl index 8f9e3e964..f508795ed 100644 --- a/util/templates/includes/tariff-base.tpl +++ b/util/templates/includes/tariff-base.tpl @@ -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 }}