From e2c59fc6e156cc259b884f929b538d8573eaa690 Mon Sep 17 00:00:00 2001 From: andig Date: Thu, 20 Aug 2026 20:14:13 +0200 Subject: [PATCH] Meter (SunSpec): read max AC power from nameplate (#32732) --- core/site.go | 4 ++- meter/meter.go | 4 +-- meter/meter_test.go | 21 +++++++++++++ meter/usage_battery.go | 8 ++--- meter/usage_pv.go | 31 +++++++++++++++---- .../definition/meter/enphase-modbus.yaml | 5 ++- .../definition/meter/fronius-argeno.yaml | 3 +- templates/definition/meter/fronius-gen24.yaml | 3 +- .../definition/meter/fronius-vertoplus.yaml | 3 +- .../meter/kostal-plenticore-gen2.yaml | 4 +-- .../definition/meter/solaredge-hybrid.yaml | 2 +- .../definition/meter/solaredge-inverter.yaml | 1 + .../meter/sunspec-hybrid-curtailable.yaml | 3 +- .../definition/meter/sunspec-hybrid.yaml | 3 +- .../meter/sunspec-inverter-curtailable.yaml | 1 + .../definition/meter/sunspec-inverter.yaml | 1 + templates/definition/meter/sunspec.tpl | 18 +++++++++++ 17 files changed, 91 insertions(+), 24 deletions(-) create mode 100644 templates/definition/meter/sunspec.tpl diff --git a/core/site.go b/core/site.go index 24e0b2535..d79b786e0 100644 --- a/core/site.go +++ b/core/site.go @@ -685,7 +685,9 @@ func (site *Site) updatePvMeters() { } if m, ok := api.Cap[api.MaxACPowerGetter](meter); ok { - if dc := power - m.MaxACPower(); dc > 0 && power > 0 { + // unknown rating (0) must not turn the entire production into excess DC + if maxACPower := m.MaxACPower(); maxACPower > 0 && power > maxACPower { + dc := power - maxACPower mm[i].ExcessDCPower = dc site.log.DEBUG.Printf("pv %d excess DC: %.0fW", i+1, dc) } diff --git a/meter/meter.go b/meter/meter.go index 0a21ef4e3..f11925757 100644 --- a/meter/meter.go +++ b/meter/meter.go @@ -24,7 +24,7 @@ func NewConfigurableFromConfig(ctx context.Context, other map[string]any) (api.M measurement.Curtailer `mapstructure:",squash"` // optional // pv - pvMaxACPower `mapstructure:",squash"` + pvMaxACPowerCtx `mapstructure:",squash"` // battery batteryCapacityCtx `mapstructure:",squash"` @@ -127,7 +127,7 @@ func NewConfigurableFromConfig(ctx context.Context, other map[string]any) (api.M implement.May(m, implement.PhaseCurrents(currentsG)) implement.May(m, implement.PhaseVoltages(voltagesG)) implement.May(m, implement.PhasePowers(powersG)) - implement.May(m, implement.MaxACPowerGetter(cc.pvMaxACPower.Decorator())) + implement.May(m, implement.MaxACPowerGetter(cc.pvMaxACPowerCtx.Decorator(ctx))) return m, nil } diff --git a/meter/meter_test.go b/meter/meter_test.go index a1d528f90..c33c462a2 100644 --- a/meter/meter_test.go +++ b/meter/meter_test.go @@ -22,6 +22,27 @@ func TestPV(t *testing.T) { _, ok := api.Cap[api.MaxACPowerGetter](m) assert.True(t, ok, "MaxACPowerGetter") } + +func TestPVMaxACPowerPlugin(t *testing.T) { + newMeter := func(maxacpower any) api.Meter { + m, err := NewConfigurableFromConfig(t.Context(), map[string]any{ + "power": map[string]any{"source": "const", "value": 1000}, + "maxacpower": maxacpower, + }) + require.NoError(t, err) + return m + } + + m := newMeter(map[string]any{"source": "const", "value": 5000}) + mp, ok := api.Cap[api.MaxACPowerGetter](m) + require.True(t, ok, "MaxACPowerGetter") + assert.Equal(t, 5000.0, mp.MaxACPower()) + + // device without rating must not be decorated + m = newMeter(map[string]any{"source": "error", "error": "ErrNotAvailable"}) + _, ok = api.Cap[api.MaxACPowerGetter](m) + assert.False(t, ok, "MaxACPowerGetter") +} func TestBattery(t *testing.T) { m, err := NewConfigurableFromConfig(t.Context(), map[string]any{ "power": map[string]any{ diff --git a/meter/usage_battery.go b/meter/usage_battery.go index 213ca6428..58524b005 100644 --- a/meter/usage_battery.go +++ b/meter/usage_battery.go @@ -43,11 +43,11 @@ func resolveFloat(ctx context.Context, v any) (func() float64, error) { case nil: return nil, nil case int: - return staticCapacity(float64(v)), nil + return staticFloat(float64(v)), nil case int64: - return staticCapacity(float64(v)), nil + return staticFloat(float64(v)), nil case float64: - return staticCapacity(v), nil + return staticFloat(v), nil default: var cfg plugin.Config if err := util.DecodeOther(v, &cfg); err != nil { @@ -67,7 +67,7 @@ func resolveFloat(ctx context.Context, v any) (func() float64, error) { } } -func staticCapacity(f float64) func() float64 { +func staticFloat(f float64) func() float64 { if f == 0 { return nil } diff --git a/meter/usage_pv.go b/meter/usage_pv.go index d578927a9..f8eed6932 100644 --- a/meter/usage_pv.go +++ b/meter/usage_pv.go @@ -1,5 +1,11 @@ package meter +import ( + "context" + + "github.com/evcc-io/evcc/util" +) + type pvMaxACPower struct { MaxACPower float64 } @@ -8,10 +14,23 @@ type pvMaxACPower struct { // Decorator returns the max AC power decorator func (m *pvMaxACPower) Decorator() func() float64 { - if m.MaxACPower == 0 { - return nil - } - return func() float64 { - return m.MaxACPower - } + return staticFloat(m.MaxACPower) +} + +type pvMaxACPowerCtx struct { + MaxACPower any // static W value or float plugin +} + +// var _ api.MaxACPowerGetter = (*pvMaxACPowerCtx)(nil) + +// Decorator returns the max AC power decorator. MaxACPower may be a static value or a +// float plugin like the SunSpec nameplate rating. Since the rating is constant, the +// plugin is read once. Unavailable or zero means not configured. +func (m *pvMaxACPowerCtx) Decorator(ctx context.Context) func() float64 { + get, err := resolveFloat(ctx, m.MaxACPower) + if err != nil { + // not all devices expose the rating- must not fail configuration + util.NewLogger("meter").WARN.Printf("maxacpower: %v", err) + } + return staticFloat(floatOr0(get)) } diff --git a/templates/definition/meter/enphase-modbus.yaml b/templates/definition/meter/enphase-modbus.yaml index 704deae84..8202b8458 100644 --- a/templates/definition/meter/enphase-modbus.yaml +++ b/templates/definition/meter/enphase-modbus.yaml @@ -17,8 +17,7 @@ params: choice: ["tcpip"] id: 1 - name: maxacpower - service: modbus/read?address=40227&type=holding&encoding=uint16&scale=1&{modbus} - usages: ["pv"] + deprecated: true # read from nameplate render: | type: custom # sunspec model 701 (DER AC Measurement) @@ -96,5 +95,5 @@ render: | percent = int(limit) } percent - maxacpower: {{ .maxacpower }} + {{- include "sunspec-maxacpower" . }} {{- end }} diff --git a/templates/definition/meter/fronius-argeno.yaml b/templates/definition/meter/fronius-argeno.yaml index 6b36bb851..9a9304c62 100644 --- a/templates/definition/meter/fronius-argeno.yaml +++ b/templates/definition/meter/fronius-argeno.yaml @@ -16,6 +16,7 @@ params: - name: port default: 502 - name: maxacpower + deprecated: true # read from nameplate render: | type: custom # Fronius Argeno: AC output via SunSpec inverter model 113 (float, primary) @@ -38,4 +39,4 @@ render: | - 113:WH - 103:WH scale: 0.001 - maxacpower: {{ .maxacpower }} # W + {{- include "sunspec-maxacpower-tcp" . }} diff --git a/templates/definition/meter/fronius-gen24.yaml b/templates/definition/meter/fronius-gen24.yaml index b9df2c487..219455e4e 100644 --- a/templates/definition/meter/fronius-gen24.yaml +++ b/templates/definition/meter/fronius-gen24.yaml @@ -31,6 +31,7 @@ params: generic: Integer deprecated: true - name: maxacpower + deprecated: true # read from nameplate - preset: battery-params - name: maxchargerate render: | @@ -167,7 +168,7 @@ render: | id: 1 value: 160:2:DCWH # mppt 2 scale: 0.001 - maxacpower: {{ .maxacpower }} # W + {{- include "sunspec-maxacpower-tcp" . }} curtail: source: sequence set: diff --git a/templates/definition/meter/fronius-vertoplus.yaml b/templates/definition/meter/fronius-vertoplus.yaml index ca391755b..13b3e27c0 100644 --- a/templates/definition/meter/fronius-vertoplus.yaml +++ b/templates/definition/meter/fronius-vertoplus.yaml @@ -18,6 +18,7 @@ params: de: "Zähleradresse von Primär- oder Sekundärzählern. Auf der Weboberfläche des Wechselrichters kann nur die Adresse des ersten Zählers (z.B. 200) eingestellt werden. Zusätzliche Zähler erhalten eine aufsteigende Nummer (z.B: 201)." usages: ["grid"] - name: maxacpower + deprecated: true # read from nameplate - name: maxchargerate - preset: battery-params render: | @@ -163,7 +164,7 @@ render: | id: 1 value: 160:3:DCWH # mppt 3 scale: 0.001 - maxacpower: {{ .maxacpower }} # W + {{- include "sunspec-maxacpower-tcp" . }} {{- end }} {{- if eq .usage "battery" }} power: diff --git a/templates/definition/meter/kostal-plenticore-gen2.yaml b/templates/definition/meter/kostal-plenticore-gen2.yaml index 543f147e7..2cb321e4e 100644 --- a/templates/definition/meter/kostal-plenticore-gen2.yaml +++ b/templates/definition/meter/kostal-plenticore-gen2.yaml @@ -35,7 +35,7 @@ params: default: little advanced: true - name: maxacpower - service: modbus/read?address=531&type=holding&encoding=uint16&{modbus} + deprecated: true # read from nameplate - preset: battery-params - name: capacity service: modbus/read?address=1068&type=holding&encoding=float32s&scale=0.001&{modbus} @@ -74,7 +74,7 @@ render: | type: holding encoding: {{ if (eq .endianness "big") }}float32{{ else }}float32s{{ end }} scale: 0.001 # Wh -> kWh - maxacpower: {{ .maxacpower }} # W + {{- include "sunspec-maxacpower" . }} # feed-in curtailment via active power limit (SunSpec model 123, WMaxLimPct @ 40217). # written as a single register (fn 06); the Plenticore rejects write multiple (fn 16) here. # WMaxLim_Ena (register 40221) is enabled by default and left to the inverter diff --git a/templates/definition/meter/solaredge-hybrid.yaml b/templates/definition/meter/solaredge-hybrid.yaml index be9b481f7..a8dbb91b8 100644 --- a/templates/definition/meter/solaredge-hybrid.yaml +++ b/templates/definition/meter/solaredge-hybrid.yaml @@ -109,7 +109,7 @@ render: | address: 0xE174 # Battery 1 Instantaneous Power type: holding decode: float32nans - maxacpower: {{ .maxacpower }} # W + {{- include "sunspec-maxacpower" . }} curtail: source: sequence set: diff --git a/templates/definition/meter/solaredge-inverter.yaml b/templates/definition/meter/solaredge-inverter.yaml index 6dde3d676..0e4c14205 100644 --- a/templates/definition/meter/solaredge-inverter.yaml +++ b/templates/definition/meter/solaredge-inverter.yaml @@ -149,4 +149,5 @@ render: | percent = 100 } percent + {{- include "sunspec-maxacpower" . }} {{- end }} diff --git a/templates/definition/meter/sunspec-hybrid-curtailable.yaml b/templates/definition/meter/sunspec-hybrid-curtailable.yaml index c1a686bed..a4a8cdc25 100644 --- a/templates/definition/meter/sunspec-hybrid-curtailable.yaml +++ b/templates/definition/meter/sunspec-hybrid-curtailable.yaml @@ -11,6 +11,7 @@ params: - name: modbus choice: ["tcpip", "rs485"] - name: maxacpower + deprecated: true # read from nameplate render: | type: custom # sunspec model 203 (int+sf)/ 213 (float) meter @@ -88,4 +89,4 @@ render: | percent = int(limit) } percent - maxacpower: {{ .maxacpower }} # W + {{- include "sunspec-maxacpower" . }} diff --git a/templates/definition/meter/sunspec-hybrid.yaml b/templates/definition/meter/sunspec-hybrid.yaml index f16e02805..beff62086 100644 --- a/templates/definition/meter/sunspec-hybrid.yaml +++ b/templates/definition/meter/sunspec-hybrid.yaml @@ -11,6 +11,7 @@ params: - name: modbus choice: ["tcpip", "rs485"] - name: maxacpower + deprecated: true # read from nameplate - preset: battery-params render: | type: custom @@ -106,7 +107,7 @@ render: | {{- include "modbus" . | indent 6 }} value: 160:2:DCWH # mppt 2 scale: 0.001 - maxacpower: {{ .maxacpower }} # W + {{- include "sunspec-maxacpower" . }} {{- end }} {{- if eq .usage "battery" }} power: diff --git a/templates/definition/meter/sunspec-inverter-curtailable.yaml b/templates/definition/meter/sunspec-inverter-curtailable.yaml index 4035ffcbf..19d581c33 100644 --- a/templates/definition/meter/sunspec-inverter-curtailable.yaml +++ b/templates/definition/meter/sunspec-inverter-curtailable.yaml @@ -87,3 +87,4 @@ render: | percent = int(limit) } percent + {{- include "sunspec-maxacpower" . }} diff --git a/templates/definition/meter/sunspec-inverter.yaml b/templates/definition/meter/sunspec-inverter.yaml index d3088c229..44b57f50c 100644 --- a/templates/definition/meter/sunspec-inverter.yaml +++ b/templates/definition/meter/sunspec-inverter.yaml @@ -153,6 +153,7 @@ render: | - 103:WH - 113:WH scale: 0.001 + {{- include "sunspec-maxacpower" . }} {{- end }} {{- if eq .usage "battery" }} power: diff --git a/templates/definition/meter/sunspec.tpl b/templates/definition/meter/sunspec.tpl new file mode 100644 index 000000000..1d33a273f --- /dev/null +++ b/templates/definition/meter/sunspec.tpl @@ -0,0 +1,18 @@ +{{ define "sunspec-maxacpower" }} +maxacpower: # nameplate rating + source: sunspec + {{- include "modbus" . | indent 2 }} + value: + - 120:WRtg + - 702:WMaxRtg +{{- end }} + +{{ define "sunspec-maxacpower-tcp" }} +maxacpower: # nameplate rating + source: sunspec + uri: {{ joinHostPort .host .port }} + id: 1 + value: + - 120:WRtg + - 702:WMaxRtg +{{- end }}