Curtailment: distribute limits to all devices by percent (#31390)

This commit is contained in:
andig 2026-07-02 20:43:12 +02:00 • committed by GitHub
parent 949daf4670
commit eea55d282d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 147 additions and 131 deletions

View file

@ -210,8 +210,8 @@ type Dimmer interface {
// Curtailer provides EEG §9 curtailment
type Curtailer interface {
Curtailed() (bool, error)
Curtail(bool) error
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)
}
// ChargeController allows to start/stop the charging session on the vehicle side
@ -293,8 +293,8 @@ type Circuit interface {
type HEMS interface {
SetUpdated(func())
Dimmed() *bool // nil = no statement
Curtailed() *bool // nil = no statement
MaxConsumptionPower() float64 // 0 = no limit
CurtailedPercent() *int // allowed feed-in percent of nominal production power (0..100), nil = no statement
MaxProductionPower() *float64 // nil = no limit
}

View file

@ -173,7 +173,7 @@ func (i *iCurrentGetter) GetMaxCurrent() (float64, error) {
return i.currentGetter0()
}
func Curtailer(curtailer0 func(bool) error, curtailer1 func() (bool, error)) api.Curtailer {
func Curtailer(curtailer0 func() (bool, error), curtailer1 func(int) error) api.Curtailer {
if curtailer0 == nil || curtailer1 == nil {
return nil
}
@ -181,16 +181,16 @@ func Curtailer(curtailer0 func(bool) error, curtailer1 func() (bool, error)) api
}
type iCurtailer struct {
curtailer0 func(bool) error
curtailer1 func() (bool, error)
}
func (i *iCurtailer) Curtail(p0 bool) error {
return i.curtailer0(p0)
curtailer0 func() (bool, error)
curtailer1 func(int) error
}
func (i *iCurtailer) Curtailed() (bool, error) {
return i.curtailer1()
return i.curtailer0()
}
func (i *iCurtailer) SetCurtailPercent(p0 int) error {
return i.curtailer1(p0)
}
func Dimmer(dimmer0 func(bool) error, dimmer1 func() (bool, error)) api.Dimmer {

View file

@ -1212,18 +1212,18 @@ func (m *MockHEMS) EXPECT() *MockHEMSMockRecorder {
return m.recorder
}
// Curtailed mocks base method.
func (m *MockHEMS) Curtailed() *bool {
// CurtailedPercent mocks base method.
func (m *MockHEMS) CurtailedPercent() *int {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Curtailed")
ret0, _ := ret[0].(*bool)
ret := m.ctrl.Call(m, "CurtailedPercent")
ret0, _ := ret[0].(*int)
return ret0
}
// Curtailed indicates an expected call of Curtailed.
func (mr *MockHEMSMockRecorder) Curtailed() *gomock.Call {
// CurtailedPercent indicates an expected call of CurtailedPercent.
func (mr *MockHEMSMockRecorder) CurtailedPercent() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Curtailed", reflect.TypeOf((*MockHEMS)(nil).Curtailed))
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CurtailedPercent", reflect.TypeOf((*MockHEMS)(nil).CurtailedPercent))
}
// Dimmed mocks base method.

View file

@ -38,7 +38,8 @@ export default defineComponent({
: null;
},
feedInLimit(): string | null {
return this.status?.curtailed && this.status.maxProductionPower !== undefined
return (this.status?.curtailed ?? 100) < 100 &&
this.status?.maxProductionPower !== undefined
? this.fmtW(this.status.maxProductionPower, POWER_UNIT.KW)
: null;
},

View file

@ -43,7 +43,7 @@ export interface HemsConfig {
export interface HemsStatus {
dimmed?: boolean;
curtailed?: boolean;
curtailed?: number; // allowed feed-in percent (0..100 integer), <100 = curtailed
maxConsumptionPower?: number;
maxProductionPower?: number;
}

View file

@ -837,13 +837,13 @@ export default defineComponent({
} else if (status.dimmed !== undefined) {
result["dimmed"] = { value: status.dimmed };
}
if (status.curtailed && status.maxProductionPower !== undefined) {
if ((status.curtailed ?? 100) < 100 && status.maxProductionPower !== undefined) {
result["curtailLimit"] = {
value: status.maxProductionPower,
warning: true,
};
} else if (status.curtailed !== undefined) {
result["curtailed"] = { value: status.curtailed };
result["curtailed"] = { value: status.curtailed < 100 };
}
return result;

View file

@ -19,7 +19,7 @@ func handleCurtailFlag(cmd *cobra.Command, v any) bool {
}
if vv, ok := api.Cap[api.Curtailer](v); ok {
if err := vv.Curtail(val > 0); err != nil {
if err := vv.SetCurtailPercent(val); err != nil {
log.ERROR.Println("curtail:", err)
}
} else {

View file

@ -52,7 +52,7 @@ const (
flagDiagnoseDescription = "Diagnose"
flagCurtail = "curtail"
flagCurtailDescription = "Curtail (0/1 to switch, only available if supported by device)"
flagCurtailDescription = "Curtail feed-in to percent (0-100, only available if supported by device)"
flagDim = "dim"
flagDimDescription = "Dim (0/1 to switch, only available if supported by device)"

View file

@ -369,12 +369,12 @@ func runRoot(cmd *cobra.Command, args []string) {
YamlSource: yamlSource.hems,
Status: struct {
Dimmed *bool `json:"dimmed,omitempty"`
Curtailed *bool `json:"curtailed,omitempty"`
Curtailed *int `json:"curtailed,omitempty"`
MaxConsumptionPower float64 `json:"maxConsumptionPower,omitempty"`
MaxProductionPower *float64 `json:"maxProductionPower,omitempty"`
}{
Dimmed: hemsInstance.Dimmed(),
Curtailed: hemsInstance.Curtailed(),
Curtailed: hemsInstance.CurtailedPercent(),
MaxConsumptionPower: hemsInstance.MaxConsumptionPower(),
MaxProductionPower: hemsInstance.MaxProductionPower(),
},

View file

@ -86,13 +86,13 @@ func hemsDimmed(hems api.HEMS) *bool {
return hems.Dimmed()
}
// hemsCurtailed returns the HEMS curtail status, nil-safe
func hemsCurtailed(hems api.HEMS) *bool {
// hemsCurtailed returns the HEMS curtail percent, nil-safe
func hemsCurtailed(hems api.HEMS) *int {
if hems == nil {
return nil
}
return hems.Curtailed()
return hems.CurtailedPercent()
}
// nonZeroEnergy reports a zero lifetime energy reading as api.ErrNotAvailable.

View file

@ -89,11 +89,13 @@ func (site *Site) dimMeters(dim *bool) error {
return errs
}
func (site *Site) curtailPV(curtail *bool) error {
if curtail == nil {
func (site *Site) curtailPV(percent *int) error {
if percent == nil {
return nil
}
curtail := *percent < 100
var errs error
for _, dev := range site.pvMeters {
m, ok := api.Cap[api.Curtailer](dev.Instance())
@ -102,7 +104,7 @@ func (site *Site) curtailPV(curtail *bool) error {
}
if curtailed, err := backoff.RetryWithData(m.Curtailed, modbus.Backoff()); err == nil {
if *curtail == curtailed {
if curtail == curtailed {
continue
}
} else {
@ -112,8 +114,8 @@ func (site *Site) curtailPV(curtail *bool) error {
continue
}
if err := m.Curtail(*curtail); err == nil {
site.log.DEBUG.Printf("%s curtail: %t", deviceTitleOrName(dev), *curtail)
if err := m.SetCurtailPercent(*percent); err == nil {
site.log.DEBUG.Printf("%s curtail: %d%%", deviceTitleOrName(dev), *percent)
} else if !errors.Is(err, api.ErrNotAvailable) {
errs = errors.Join(errs, fmt.Errorf("%s curtail: %w", deviceTitleOrName(dev), err))
}

View file

@ -41,9 +41,10 @@ type EEBus struct {
failsafeConsumptionLimit float64
smartgridProductionId uint
productionLimit ucapi.LoadLimit
productionLimit ucapi.LoadLimit // feed-in limit (NOT production despite its name)
productionLimitActivated time.Time
failsafeProductionLimit *float64
failsafeProductionLimit *float64 // feed-in limit (NOT production despite its name)
productionNominalMax float64
heartbeat *util.Value[struct{}]
interval time.Duration
@ -113,6 +114,7 @@ func NewEEBus(ctx context.Context, ski string, limits Limits, passthrough func(b
failsafeDuration: limits.FailsafeDurationMinimum,
failsafeConsumptionLimit: limits.FailsafeConsumptionActivePowerLimit,
failsafeProductionLimit: limits.FailsafeProductionActivePowerLimit,
productionNominalMax: limits.ProductionNominalMax,
}
// simulate a received heartbeat
@ -308,11 +310,24 @@ func (c *EEBus) Dimmed() *bool {
return new(!c.consumptionLimitActivated.IsZero())
}
// Curtailed implements api.HEMS, derived from productionLimitActivated.
func (c *EEBus) Curtailed() *bool {
// CurtailedPercent implements api.HEMS, converting the active LPP production
// limit to an allowed production percent via the configured nominal production power.
func (c *EEBus) CurtailedPercent() *int {
c.mux.RLock()
defer c.mux.RUnlock()
return new(!c.productionLimitActivated.IsZero())
// without a nominal reference the W limit cannot be expressed as a percent
if c.productionNominalMax <= 0 {
return nil
}
percent := 100
if !c.productionLimitActivated.IsZero() {
// production limits are negative watts
percent = int(-c.productionLimit.Value / c.productionNominalMax * 100)
}
return &percent
}
// MaxConsumptionPower implements api.HEMS, returning the consumption cap

View file

@ -16,6 +16,7 @@ const (
testFailsafeConsumption = 4200.0
testFailsafeProduction = 1000.0
testFailsafeDuration = 2 * time.Hour
testProductionNominal = 2000.0
)
// stubSite implements site.API for testing — only GetGridPower is exercised;
@ -43,6 +44,7 @@ func newTestEEBus(t *testing.T) *EEBus {
failsafeConsumptionLimit: testFailsafeConsumption,
failsafeProductionLimit: &failsafeProduction,
failsafeDuration: testFailsafeDuration,
productionNominalMax: testProductionNominal,
}
}
@ -56,7 +58,9 @@ func assertConsumptionLimit(t *testing.T, c *EEBus, limit float64) {
// assertProductionLimit checks the HEMS production state through the api.HEMS surface.
func assertProductionLimit(t *testing.T, c *EEBus, active bool) {
t.Helper()
assert.Equal(t, new(active), c.Curtailed())
percent := c.CurtailedPercent()
require.NotNil(t, percent)
assert.Equal(t, active, *percent < 100)
}
// TestRun_HeartbeatLost_EntersFailsafe verifies the LPC-911/LPP-911 transition:

View file

@ -86,6 +86,7 @@ func NewFnn(site site.API, maxDimPower, maxCurtailPower float64, w3G, s1G, s2G,
s2: s2G,
w3: w3G,
w4: w4G,
productionPercent: 100,
interval: interval,
}, nil
}
@ -107,7 +108,7 @@ type Fnn struct {
smartgridProductionID uint
consumptionLimit float64
productionLimit *float64
productionPercent int // allowed feed-in percent (0..100), 100 = uncurtailed
interval time.Duration
}
@ -145,11 +146,11 @@ func (c *Fnn) runCurtail() error {
rules := []struct {
get func() (bool, error)
frac float64
percent int
}{
{get: c.w3, frac: 0.0},
{get: c.s2, frac: 0.3},
{get: c.s1, frac: 0.6},
{get: c.w3, percent: 0},
{get: c.s2, percent: 30},
{get: c.s1, percent: 60},
}
for _, rule := range rules {
@ -163,12 +164,12 @@ func (c *Fnn) runCurtail() error {
}
if active {
return c.setProductionLimit(rule.frac)
return c.setProductionLimit(rule.percent)
}
}
// 100%
return c.setProductionLimit(1.0)
return c.setProductionLimit(100)
}
// runDim evaluates the dimming rule and applies the dim limit.
@ -192,20 +193,16 @@ func (c *Fnn) runDim() error {
}
// setProductionLimit applies the curtailment limit.
func (c *Fnn) setProductionLimit(frac float64) error {
func (c *Fnn) setProductionLimit(percent int) error {
c.mu.Lock()
defer c.mu.Unlock()
active := frac < 1.0
c.productionLimit = nil
if active {
c.productionLimit = new(c.maxCurtailPower * frac)
}
active := percent < 100
c.productionPercent = percent
limit := 0.0
if c.productionLimit != nil {
limit = *c.productionLimit
if active {
limit = float64(percent) / 100 * c.maxCurtailPower
}
if err := smartgrid.UpdateSession(&c.smartgridProductionID, smartgrid.Curtail, c.site.GetGridPower(), limit, active); err != nil {
@ -243,15 +240,17 @@ func (c *Fnn) Dimmed() *bool {
return new(c.consumptionLimit > 0)
}
// Curtailed implements api.HEMS.
func (c *Fnn) Curtailed() *bool {
// CurtailedPercent implements api.HEMS, returning the allowed production percent.
func (c *Fnn) CurtailedPercent() *int {
if c.w3 == nil {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
return new(c.productionLimit != nil)
percent := c.productionPercent
return &percent
}
// MaxConsumptionPower implements api.HEMS.
@ -265,9 +264,9 @@ func (c *Fnn) MaxConsumptionPower() float64 {
func (c *Fnn) MaxProductionPower() *float64 {
c.mu.Lock()
defer c.mu.Unlock()
if c.productionLimit == nil {
if c.productionPercent >= 100 {
return nil
}
return new(*c.productionLimit)
return new(float64(c.productionPercent) / 100 * c.maxCurtailPower)
}

View file

@ -12,7 +12,7 @@ func TestFnn(t *testing.T) {
return true, nil
}, 0)
require.NoError(t, err)
require.Nil(t, fnn.Curtailed())
require.Nil(t, fnn.CurtailedPercent())
// require.NoError(t, fnn.runDim())
// require.Equal(t, new(true), fnn.Dimmed())
}

View file

@ -149,9 +149,9 @@ func (c *Relay) Dimmed() *bool {
return new(c.limit != nil)
}
// Curtailed implements api.HEMS. Relay does not curtail production and
// CurtailedPercent implements api.HEMS. Relay does not curtail production and
// hence makes no statement.
func (c *Relay) Curtailed() *bool {
func (c *Relay) CurtailedPercent() *int {
return nil
}

View file

@ -278,18 +278,9 @@ func (c *EEBus) Curtailed() (bool, error) {
return limit.IsActive && limit.Value <= 0, nil
}
// Curtail implements the api.Curtailer interface
func (c *EEBus) Curtail(curtail bool) error {
// Sets or removes the production power limit
// TODO: change api.Curtailer to make limit configurable
// For now, we use a fixed safe limit of 0W
limit := 0.0
var value float64
if curtail {
value = limit
}
// SetCurtailPercent implements the api.Curtailer interface
func (c *EEBus) SetCurtailPercent(percent int) error {
curtail := percent < 100
c.mu.Lock()
entity := c.egLppEntity
@ -299,6 +290,15 @@ func (c *EEBus) Curtail(curtail bool) error {
return api.ErrNotAvailable
}
// derive a proportional feed-in limit from the producer's nominal power
// (limits are negative watts); fall back to a safe 0W limit if unavailable
var value float64
if curtail {
if nominal, err := c.eg.EgLPPInterface.ProductionNominalMax(entity); err == nil && nominal > 0 {
value = -float64(percent) / 100 * nominal
}
}
return eebus.Await(func(cb func(model.ResultDataType)) (*model.MsgCounterType, error) {
return c.eg.EgLPPInterface.WriteProductionLimit(entity, ucapi.LoadLimit{Value: value, IsActive: curtail}, cb)
})

View file

@ -62,7 +62,7 @@ func (c *EEBus) maUseCaseSupportUpdate(entity spineapi.EntityRemoteInterface) {
c.mu.Lock()
defer c.mu.Unlock()
// use most specific selector
// prefer the shallowest (device-level) entity
if c.maEntity == nil || len(entity.Address().Entity) < len(c.maEntity.Address().Entity) {
c.maEntity = entity
}
@ -76,7 +76,7 @@ func (c *EEBus) egLpcUseCaseSupportUpdate(entity spineapi.EntityRemoteInterface)
c.mu.Lock()
defer c.mu.Unlock()
// use most specific selector
// prefer the shallowest (device-level) entity
if c.egLpcEntity == nil || len(entity.Address().Entity) < len(c.egLpcEntity.Address().Entity) {
c.egLpcEntity = entity
}
@ -90,7 +90,7 @@ func (c *EEBus) egLppUseCaseSupportUpdate(entity spineapi.EntityRemoteInterface)
c.mu.Lock()
defer c.mu.Unlock()
// use most specific selector
// prefer the shallowest (device-level) entity
if c.egLppEntity == nil || len(entity.Address().Entity) < len(c.egLppEntity.Address().Entity) {
c.egLppEntity = entity
}

View file

@ -53,11 +53,11 @@ type Curtailer struct {
}
func (cc *Curtailer) Configure(ctx context.Context) (
func(bool) error,
func(int64) error,
func() (bool, error),
error,
) {
curtailS, err := cc.Curtail.BoolSetter(ctx, "curtail")
curtailS, err := cc.Curtail.IntSetter(ctx, "curtail")
if err != nil {
return nil, nil, fmt.Errorf("curtail: %w", err)
}
@ -80,7 +80,10 @@ func (cc *Curtailer) Implement(ctx context.Context, i implement.Caps) error {
return err
}
implement.May(i, implement.Curtailer(curtailS, curtailedG))
if curtailS != nil {
setPercent := func(percent int) error { return curtailS(int64(percent)) }
implement.May(i, implement.Curtailer(curtailedG, setPercent))
}
return nil
}

View file

@ -107,12 +107,8 @@ func NewRCT(ctx context.Context, uri, usage string, batterySocLimits batterySocL
}
if usage == "pv" {
curtail := func(b bool) error {
var r float64
if !b {
r = 1.0
}
return m.conn.Write(rct.BufVControlPowerReduction, floatVal(r))
curtail := func(percent int) error {
return m.conn.Write(rct.BufVControlPowerReduction, floatVal(float64(percent)/100))
}
curtailed := func() (bool, error) {
@ -120,7 +116,7 @@ func NewRCT(ctx context.Context, uri, usage string, batterySocLimits batterySocL
return r != 1, err
}
implement.Has(m, implement.Curtailer(curtail, curtailed))
implement.Has(m, implement.Curtailer(curtailed, curtail))
implement.May(m, implement.MaxACPowerGetter(maxACPower))
}

View file

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

View file

@ -37,6 +37,7 @@ params:
en: Active power limit in percent
advanced: true
usages: ["pv"]
deprecated: true # curtailment now applies the HEMS-distributed percent directly
- name: storageunit
- name: forceaccharging
default: false
@ -226,38 +227,33 @@ render: |
scale: 0.01
maxacpower: {{ .maxacpower }}
curtail:
source: ifelse
if:
source: convert
convert: bool2int
set:
source: sequence
set:
- source: const
value: {{ .curtailpercent }}
set:
source: modbus
{{- include "modbus" . | indent 10 }}
# allowed feed-in power as percent of nominal (0-100 %)
- source: modbus
{{- include "modbus" . | indent 4 }}
register:
address: 47418 # Maximum Feed Grid Power (0-100 %)
type: writesingle
encoding: uint16
scale: 10
- source: const
value: 7 # Power-limited grid connection (%)
# active power control mode: 7 = percentage limit while curtailed, 0 = unlimited at 100%
- source: switch
switch:
- case: 100 # uncurtailed
set:
source: const
value: 0 # Unlimited
set:
source: modbus
{{- include "modbus" . | indent 10 }}
{{- include "modbus" . | indent 12 }}
register:
address: 47415 # Active power control mode
type: writesingle
encoding: uint16
else:
source: convert
convert: bool2int
set:
default:
source: const
value: 0 # Unlimited
value: 7 # Power-limited grid connection (%)
set:
source: modbus
{{- include "modbus" . | indent 8 }}