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

@ -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))