Curtailment: report curtailed percent instead of bool (#32010)

This commit is contained in:
andig 2026-07-21 13:14:40 +02:00 • committed by GitHub
parent 435ed72a56
commit 608aa36629
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 178 additions and 56 deletions

View file

@ -208,7 +208,7 @@ type Dimmer interface {
// Curtailer provides EEG §9 curtailment
type Curtailer interface {
Curtailed() (bool, error) // curtailed if feed-in is limited to less than nominal (<100%)
CurtailedPercent() (int, error) // feed-in limit as percent of nominal (0..100, 100 = uncurtailed)
SetCurtailPercent(int) error // limit feed-in to the given percent of nominal (0..100, 100 = uncurtailed)
}

View file

@ -173,7 +173,7 @@ func (i *iCurrentGetter) GetMaxCurrent() (float64, error) {
return i.currentGetter0()
}
func Curtailer(curtailer0 func() (bool, error), curtailer1 func(int) error) api.Curtailer {
func Curtailer(curtailer0 func() (int, error), curtailer1 func(int) error) api.Curtailer {
if curtailer0 == nil || curtailer1 == nil {
return nil
}
@ -181,11 +181,11 @@ func Curtailer(curtailer0 func() (bool, error), curtailer1 func(int) error) api.
}
type iCurtailer struct {
curtailer0 func() (bool, error)
curtailer0 func() (int, error)
curtailer1 func(int) error
}
func (i *iCurtailer) Curtailed() (bool, error) {
func (i *iCurtailer) CurtailedPercent() (int, error) {
return i.curtailer0()
}

View file

@ -241,6 +241,11 @@ export default {
return `${this.fmtW(value[0])} / ${this.fmtW(value[1])}`;
case "currentRange":
return `${this.fmtNumber(value[0], 1)} A / ${this.fmtNumber(value[1], 1)} A`;
case "curtailed":
// devices report the allowed feed-in percent, the hems a plain flag
return typeof value === "number"
? this.fmtPercentage(value, 0)
: this.$t(`config.deviceValue.${value ? "yes" : "no"}`);
case "controllable":
case "curtailable":
case "phases1p3p":
@ -249,7 +254,6 @@ export default {
case "configured":
case "connected":
case "dimmed":
case "curtailed":
case "loginBlocked":
case "remoteEnabled":
return value

View file

@ -1271,7 +1271,8 @@ export default defineComponent({
return this.deviceValues[type][id] || {};
},
meterBanner(name: string): string | undefined {
return this.deviceTags("meter", name)["curtailed"]?.value
// the tag is only present while curtailing, a zero percent limit is still one
return this.deviceTags("meter", name)["curtailed"]?.value !== undefined
? this.$t("config.deviceValue.productionLimited")
: undefined;
},

View file

@ -180,8 +180,8 @@ func (d *dumper) Dump(name string, v any) {
if v, ok := api.Cap[api.Curtailer](v); ok {
d.measureTime(w, "Curtailed", func() (string, error) {
curtailed, err := v.Curtailed()
return fmt.Sprintf("%t", curtailed), err
percent, err := v.CurtailedPercent()
return fmt.Sprintf("%d%%", percent), err
})
}

View file

@ -90,8 +90,6 @@ func (site *Site) curtailPV(percent *int) error {
return nil
}
curtail := *percent < 100
var errs error
for _, dev := range site.pvMeters {
m, ok := api.Cap[api.Curtailer](dev.Instance())
@ -99,8 +97,8 @@ func (site *Site) curtailPV(percent *int) error {
continue
}
if curtailed, err := backoff.RetryWithData(m.Curtailed, modbus.Backoff()); err == nil {
if curtail == curtailed {
if curtailed, err := backoff.RetryWithData(m.CurtailedPercent, modbus.Backoff()); err == nil {
if curtailed == *percent {
continue
}
} else {

View file

@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"math"
"sync"
"time"
@ -264,18 +265,29 @@ func (c *EEBus) Dim(dim bool) error {
var _ api.Curtailer = (*EEBus)(nil)
// Curtailed implements the api.Curtailer interface
func (c *EEBus) Curtailed() (bool, error) {
// CurtailedPercent implements the api.Curtailer interface
func (c *EEBus) CurtailedPercent() (int, error) {
c.mu.Lock()
defer c.mu.Unlock()
limit, err := eebusReadValue(c.eg.EgLPPInterface, c.egLppEntity, eebus.LPPLimit, c.eg.EgLPPInterface.ProductionLimit)
if err != nil {
return false, err
return 0, err
}
// Check if limit is active and has a valid power value (valid is zero or negative)
return limit.IsActive && limit.Value <= 0, nil
// production limits are negative watts, a positive value is invalid
if !limit.IsActive || limit.Value > 0 {
return 100, nil
}
// without a nominal reference the limit cannot be expressed as a percent
nominal, err := c.eg.EgLPPInterface.ProductionNominalMax(c.egLppEntity)
if err != nil || nominal <= 0 {
return 0, api.ErrNotAvailable
}
// round, the watt conversion does not reproduce the written percent exactly
return int(math.Round(-limit.Value / nominal * 100)), nil
}
// SetCurtailPercent implements the api.Curtailer interface

View file

@ -189,31 +189,63 @@ func TestLPP_SetCurtailPercent_Gating(t *testing.T) {
})
}
// Curtailed reports an active production limit. Per LPP-TS-001 valid values are ≤ 0,
// so a positive value is not treated as curtailed.
func TestLPP_Curtailed(t *testing.T) {
// CurtailedPercent expresses an active production limit as percent of nominal.
// Per LPP-TS-001 valid values are ≤ 0, so a positive value is not treated as curtailed.
func TestLPP_CurtailedPercent(t *testing.T) {
for _, tc := range []struct {
name string
limit ucapi.LoadLimit
want bool
want int
}{
{"active_negative", ucapi.LoadLimit{IsActive: true, Value: -2000}, true},
{"active_zero", ucapi.LoadLimit{IsActive: true, Value: 0}, true},
{"active_positive_invalid", ucapi.LoadLimit{IsActive: true, Value: 100}, false},
{"inactive", ucapi.LoadLimit{IsActive: false, Value: -2000}, false},
{"active_negative", ucapi.LoadLimit{IsActive: true, Value: -2000}, 40},
{"active_zero", ucapi.LoadLimit{IsActive: true, Value: 0}, 0},
{"active_positive_invalid", ucapi.LoadLimit{IsActive: true, Value: 100}, 100},
{"inactive", ucapi.LoadLimit{IsActive: false, Value: -2000}, 100},
} {
t.Run(tc.name, func(t *testing.T) {
c, _, lpp, entity := newEGMeter(t)
lpp.EXPECT().IsScenarioAvailableAtEntity(entity, eebus.LPPLimit).Return(true)
lpp.EXPECT().ProductionLimit(entity).Return(tc.limit, nil)
if tc.want != 100 {
lpp.EXPECT().ProductionNominalMax(entity).Return(5000.0, nil)
}
got, err := c.Curtailed()
got, err := c.CurtailedPercent()
require.NoError(t, err)
assert.Equal(t, tc.want, got)
})
}
}
// The watt conversion must reproduce the written percent, else the site would
// rewrite the same limit on every update.
func TestLPP_CurtailedPercent_RoundTrip(t *testing.T) {
const nominal = 4600.0
for percent := range 101 {
c, _, lpp, entity := newEGMeter(t)
lpp.EXPECT().IsScenarioAvailableAtEntity(entity, eebus.LPPLimit).Return(true)
lpp.EXPECT().ProductionLimit(entity).
Return(ucapi.LoadLimit{IsActive: true, Value: -float64(percent) / 100 * nominal}, nil)
lpp.EXPECT().ProductionNominalMax(entity).Return(nominal, nil)
got, err := c.CurtailedPercent()
require.NoError(t, err)
assert.Equal(t, percent, got)
}
}
// Without a nominal reference the watt limit cannot be expressed as a percent.
func TestLPP_CurtailedPercent_NoNominal(t *testing.T) {
c, _, lpp, entity := newEGMeter(t)
lpp.EXPECT().IsScenarioAvailableAtEntity(entity, eebus.LPPLimit).Return(true)
lpp.EXPECT().ProductionLimit(entity).Return(ucapi.LoadLimit{IsActive: true, Value: -2000}, nil)
lpp.EXPECT().ProductionNominalMax(entity).Return(0.0, api.ErrNotAvailable)
_, err := c.CurtailedPercent()
assert.ErrorIs(t, err, api.ErrNotAvailable)
}
// TestLPC_LPP_NonCoverage records the Controllable-System and connection/heartbeat
// abstract test cases that belong to eebus-go and the evcc HEMS/charger, not the meter.
func TestLPC_LPP_NonCoverage(t *testing.T) {

View file

@ -54,7 +54,7 @@ type Curtailer struct {
func (cc *Curtailer) Configure(ctx context.Context) (
func(int64) error,
func() (bool, error),
func() (int64, error),
error,
) {
curtailS, err := cc.Curtail.IntSetter(ctx, "curtail")
@ -62,7 +62,7 @@ func (cc *Curtailer) Configure(ctx context.Context) (
return nil, nil, fmt.Errorf("curtail: %w", err)
}
curtailedG, err := cc.Curtailed.BoolGetter(ctx)
curtailedG, err := cc.Curtailed.IntGetter(ctx)
if err != nil {
return nil, nil, fmt.Errorf("curtailed: %w", err)
}
@ -81,8 +81,12 @@ func (cc *Curtailer) Implement(ctx context.Context, i implement.Caps) error {
}
if curtailS != nil {
getPercent := func() (int, error) {
percent, err := curtailedG()
return int(percent), err
}
setPercent := func(percent int) error { return curtailS(int64(percent)) }
implement.May(i, implement.Curtailer(curtailedG, setPercent))
implement.May(i, implement.Curtailer(getPercent, setPercent))
}
return nil

View file

@ -141,9 +141,10 @@ func NewRCT(ctx context.Context, uri, usage string, batterySocLimits batterySocL
return m.conn.Write(rct.BufVControlPowerReduction, floatVal(float64(percent)/100))
}
curtailed := func() (bool, error) {
curtailed := func() (int, error) {
r, err := m.queryFloat(rct.BufVControlPowerReduction)
return r != 1, err
// round, the float32 round-trip does not reproduce the written percent exactly
return int(math.Round(r * 100)), err
}
implement.Has(m, implement.Curtailer(curtailed, curtail))

View file

@ -441,8 +441,9 @@ func testInstance(ctx context.Context, instance any) map[string]testResult {
wg.Go(func() {
if dev, ok := api.Cap[api.Curtailer](instance); ok {
makeResult("curtailable", true, nil)
if val, err := dev.Curtailed(); err != nil || val {
makeResult("curtailed", true, err)
// only reported while actually curtailing
if val, err := dev.CurtailedPercent(); err != nil || val < 100 {
makeResult("curtailed", val, err)
}
}
})

View file

@ -174,7 +174,7 @@ render: |
address: 60322 # Photovoltaic Maximum Power (kW * 1000)
type: writemultiple
encoding: uint32
curtailed:
curtailed: # the power limit is expressed as percent of nominal AC power
source: go
in:
- name: limit
@ -186,7 +186,18 @@ render: |
address: 60322 # Photovoltaic Maximum Power (kW * 1000)
type: holding
decode: uint32
script: limit != 0xFFFFFFFF
- name: maxacpower
type: int
config:
source: const
value: {{ .maxacpower }}
script: |
percent := 100
if limit != 0xFFFFFFFF && maxacpower > 0 {
// round, the watt conversion does not reproduce the written percent exactly
percent = (limit*100 + maxacpower/2) / maxacpower
}
percent
{{- end }}
{{- end }}
{{- if eq .usage "battery" }}

View file

@ -70,7 +70,7 @@ render: |
source: js
vm: shared
script: |
typeof demoMeterCurtailed !== "undefined" && demoMeterCurtailed < 100
typeof demoMeterCurtailed === "undefined" ? 100 : demoMeterCurtailed
{{- end }}
{{- if .energy }}
energy:

View file

@ -75,9 +75,26 @@ render: |
source: sunspec
{{- include "modbus" . | indent 10 }}
value: 704:WMaxLimPctEna
curtailed:
curtailed: # the limit percentage only applies while the limit is enabled
source: go
in:
- name: ena
type: bool
config:
source: sunspec
{{- include "modbus" . | indent 2 }}
{{- include "modbus" . | indent 8 }}
value: 704:WMaxLimPctEna
- name: limit
type: float
config:
source: sunspec
{{- include "modbus" . | indent 8 }}
value: 704:WMaxLimPct
script: |
percent := 100
if ena {
percent = int(limit)
}
percent
maxacpower: {{ .maxacpower }}
{{- end }}

View file

@ -283,8 +283,13 @@ render: |
type: holding
decode: uint16
scale: 0.1
# mode 7 (percentage limit) at 100% means limited-grid-connection is enabled but not actually curtailing
script: mode != 0 && !(mode == 7 && percent == 100)
# the percentage register is only meaningful while a limit mode is active
script: |
limit := percent
if mode == 0 {
limit = 100
}
limit
{{- end }}
{{- if eq .usage "battery" }}
power:

View file

@ -83,7 +83,7 @@ render: |
source: sunspec
{{- include "modbus" . | indent 2 }}
value: 123:WMaxLimPct
curtailed: # curtailed only while the limit is enabled and below nominal (< 100 %)
curtailed: # the limit percentage only applies while the limit is enabled
source: go
in:
- name: ena
@ -98,7 +98,12 @@ render: |
source: sunspec
{{- include "modbus" . | indent 6 }}
value: 123:WMaxLimPct
script: ena && limit < 100
script: |
percent := 100
if ena {
percent = int(limit)
}
percent
{{- end }}
{{- if eq .usage "battery" }}
type: custom

View file

@ -132,13 +132,34 @@ render: |
address: 8027 # Register 8028 (Feed-in limitation)
type: writesingle
decode: uint16
curtailed:
curtailed: # the limitation ratio only applies while the feed-in limitation is enabled
source: go
in:
- name: ena
type: int
config:
source: modbus
{{- include "modbus" . | indent 2 }}
{{- include "modbus" . | indent 6 }}
register:
address: 8027 # Register 8028 (Feed-in limitation, 1=on)
type: holding
decode: uint16
- name: ratio
type: int
config:
source: modbus
{{- include "modbus" . | indent 6 }}
register:
address: 8030 # Register 8031 (Feed-in limitation ratio, 0-1000 = 0.1%)
type: holding
decode: int16
scale: 0.1
script: |
percent := 100
if ena != 0 {
percent = ratio
}
percent
{{- end }}
{{- if eq .usage "battery" }}
type: custom

View file

@ -63,7 +63,7 @@ render: |
value:
- 704:WMaxLimPctEna
- 123:WMaxLim_Ena
curtailed: # curtailed only while the limit is enabled and below nominal (< 100 %)
curtailed: # the limit percentage only applies while the limit is enabled
source: go
in:
- name: ena
@ -82,5 +82,10 @@ render: |
value:
- 704:WMaxLimPct
- 123:WMaxLimPct
script: ena && limit < 100
script: |
percent := 100
if ena {
percent = int(limit)
}
percent
maxacpower: {{ .maxacpower }} # W

View file

@ -62,7 +62,7 @@ render: |
value:
- 704:WMaxLimPctEna
- 123:WMaxLim_Ena
curtailed: # curtailed only while the limit is enabled and below nominal (< 100 %)
curtailed: # the limit percentage only applies while the limit is enabled
source: go
in:
- name: ena
@ -81,4 +81,9 @@ render: |
value:
- 704:WMaxLimPct
- 123:WMaxLimPct
script: ena && limit < 100
script: |
percent := 100
if ena {
percent = int(limit)
}
percent