-
+
, 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 }}