From 608aa36629b14a419f91d6a08ca72d9160a9f012 Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 21 Jul 2026 13:14:40 +0200 Subject: [PATCH] Curtailment: report curtailed percent instead of bool (#32010) --- api/api.go | 4 +- api/implement/implementations.go | 6 +-- assets/js/components/Config/DeviceTags.vue | 6 ++- assets/js/views/Config.vue | 3 +- cmd/dumper.go | 4 +- core/site_circuits.go | 6 +-- meter/eebus.go | 22 ++++++-- meter/eebus_lpc_lpp_test.go | 50 +++++++++++++++---- meter/measurement/action.go | 10 ++-- meter/rct.go | 5 +- server/http_config_helper.go | 5 +- templates/definition/meter/atmoce.yaml | 15 +++++- templates/definition/meter/demo-meter.yaml | 2 +- .../definition/meter/enphase-modbus.yaml | 25 ++++++++-- .../meter/huawei-sun2000-hybrid.yaml | 9 +++- .../meter/kostal-plenticore-gen2.yaml | 9 +++- templates/definition/meter/sungrow-ihm.yaml | 35 ++++++++++--- .../meter/sunspec-hybrid-curtailable.yaml | 9 +++- .../meter/sunspec-inverter-curtailable.yaml | 9 +++- 19 files changed, 178 insertions(+), 56 deletions(-) diff --git a/api/api.go b/api/api.go index ad485e052..3535b9dc7 100644 --- a/api/api.go +++ b/api/api.go @@ -208,8 +208,8 @@ 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%) - SetCurtailPercent(int) error // limit feed-in to the given percent of nominal (0..100, 100 = uncurtailed) + 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) } // ChargeController allows to start/stop the charging session on the vehicle side diff --git a/api/implement/implementations.go b/api/implement/implementations.go index cc5254da3..610e8b7df 100644 --- a/api/implement/implementations.go +++ b/api/implement/implementations.go @@ -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() } diff --git a/assets/js/components/Config/DeviceTags.vue b/assets/js/components/Config/DeviceTags.vue index e8973baf3..260d4ae5d 100644 --- a/assets/js/components/Config/DeviceTags.vue +++ b/assets/js/components/Config/DeviceTags.vue @@ -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 diff --git a/assets/js/views/Config.vue b/assets/js/views/Config.vue index 522065113..cdbadbe52 100644 --- a/assets/js/views/Config.vue +++ b/assets/js/views/Config.vue @@ -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; }, diff --git a/cmd/dumper.go b/cmd/dumper.go index 2cb26ec3b..21e59036d 100644 --- a/cmd/dumper.go +++ b/cmd/dumper.go @@ -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 }) } diff --git a/core/site_circuits.go b/core/site_circuits.go index 575f7fc51..da0fbcee5 100644 --- a/core/site_circuits.go +++ b/core/site_circuits.go @@ -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 { diff --git a/meter/eebus.go b/meter/eebus.go index 832027035..4da224985 100644 --- a/meter/eebus.go +++ b/meter/eebus.go @@ -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 diff --git a/meter/eebus_lpc_lpp_test.go b/meter/eebus_lpc_lpp_test.go index 57844cfff..9a05dd875 100644 --- a/meter/eebus_lpc_lpp_test.go +++ b/meter/eebus_lpc_lpp_test.go @@ -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) { diff --git a/meter/measurement/action.go b/meter/measurement/action.go index 7d1230357..2e11d165c 100644 --- a/meter/measurement/action.go +++ b/meter/measurement/action.go @@ -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 diff --git a/meter/rct.go b/meter/rct.go index 278ad3706..8a323184f 100644 --- a/meter/rct.go +++ b/meter/rct.go @@ -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)) diff --git a/server/http_config_helper.go b/server/http_config_helper.go index d82c1944f..0da57c72a 100644 --- a/server/http_config_helper.go +++ b/server/http_config_helper.go @@ -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) } } }) diff --git a/templates/definition/meter/atmoce.yaml b/templates/definition/meter/atmoce.yaml index 7c5758254..49dd6662a 100644 --- a/templates/definition/meter/atmoce.yaml +++ b/templates/definition/meter/atmoce.yaml @@ -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" }} diff --git a/templates/definition/meter/demo-meter.yaml b/templates/definition/meter/demo-meter.yaml index dfb06dd8e..64d89227e 100644 --- a/templates/definition/meter/demo-meter.yaml +++ b/templates/definition/meter/demo-meter.yaml @@ -70,7 +70,7 @@ render: | source: js vm: shared script: | - typeof demoMeterCurtailed !== "undefined" && demoMeterCurtailed < 100 + typeof demoMeterCurtailed === "undefined" ? 100 : demoMeterCurtailed {{- end }} {{- if .energy }} energy: diff --git a/templates/definition/meter/enphase-modbus.yaml b/templates/definition/meter/enphase-modbus.yaml index 82c3f67f6..704deae84 100644 --- a/templates/definition/meter/enphase-modbus.yaml +++ b/templates/definition/meter/enphase-modbus.yaml @@ -75,9 +75,26 @@ render: | source: sunspec {{- include "modbus" . | indent 10 }} value: 704:WMaxLimPctEna - curtailed: - source: sunspec - {{- include "modbus" . | indent 2 }} - value: 704:WMaxLimPctEna + curtailed: # the limit percentage only applies while the limit is enabled + source: go + in: + - name: ena + type: bool + config: + source: sunspec + {{- 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 }} diff --git a/templates/definition/meter/huawei-sun2000-hybrid.yaml b/templates/definition/meter/huawei-sun2000-hybrid.yaml index 02ead1915..132c98eab 100644 --- a/templates/definition/meter/huawei-sun2000-hybrid.yaml +++ b/templates/definition/meter/huawei-sun2000-hybrid.yaml @@ -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: diff --git a/templates/definition/meter/kostal-plenticore-gen2.yaml b/templates/definition/meter/kostal-plenticore-gen2.yaml index d621fd00a..106a7de52 100644 --- a/templates/definition/meter/kostal-plenticore-gen2.yaml +++ b/templates/definition/meter/kostal-plenticore-gen2.yaml @@ -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 diff --git a/templates/definition/meter/sungrow-ihm.yaml b/templates/definition/meter/sungrow-ihm.yaml index 99722b5c2..a54b431bb 100644 --- a/templates/definition/meter/sungrow-ihm.yaml +++ b/templates/definition/meter/sungrow-ihm.yaml @@ -132,13 +132,34 @@ render: | address: 8027 # Register 8028 (Feed-in limitation) type: writesingle decode: uint16 - curtailed: - source: modbus - {{- include "modbus" . | indent 2 }} - register: - address: 8027 # Register 8028 (Feed-in limitation, 1=on) - type: holding - decode: uint16 + 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 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 diff --git a/templates/definition/meter/sunspec-hybrid-curtailable.yaml b/templates/definition/meter/sunspec-hybrid-curtailable.yaml index 8713fcbba..c1a686bed 100644 --- a/templates/definition/meter/sunspec-hybrid-curtailable.yaml +++ b/templates/definition/meter/sunspec-hybrid-curtailable.yaml @@ -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 diff --git a/templates/definition/meter/sunspec-inverter-curtailable.yaml b/templates/definition/meter/sunspec-inverter-curtailable.yaml index a2f2762f9..4035ffcbf 100644 --- a/templates/definition/meter/sunspec-inverter-curtailable.yaml +++ b/templates/definition/meter/sunspec-inverter-curtailable.yaml @@ -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