From eea55d282dafe5c699529d15dd9a57bf433ffaaa Mon Sep 17 00:00:00 2001 From: andig Date: Thu, 2 Jul 2026 20:43:12 +0200 Subject: [PATCH] Curtailment: distribute limits to all devices by percent (#31390) --- api/api.go | 6 +- api/implement/implementations.go | 16 ++--- api/mock.go | 14 ++--- assets/js/components/HemsWarning.vue | 3 +- assets/js/types/evcc.ts | 2 +- assets/js/views/Config.vue | 4 +- cmd/capabilities.go | 2 +- cmd/flags.go | 2 +- cmd/root.go | 4 +- core/helper.go | 6 +- core/site_circuits.go | 12 ++-- hems/eebus/eebus.go | 25 ++++++-- hems/eebus/eebus_test.go | 6 +- hems/fnn/fnn.go | 63 +++++++++---------- hems/fnn/fnn_test.go | 2 +- hems/relay/relay.go | 4 +- meter/eebus.go | 24 +++---- meter/eebus_events.go | 6 +- meter/measurement/action.go | 9 ++- meter/rct.go | 10 +-- templates/definition/meter/demo-meter.yaml | 2 +- .../meter/huawei-sun2000-hybrid.yaml | 56 ++++++++--------- 22 files changed, 147 insertions(+), 131 deletions(-) diff --git a/api/api.go b/api/api.go index fc4eb53e6..b764073bc 100644 --- a/api/api.go +++ b/api/api.go @@ -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 } diff --git a/api/implement/implementations.go b/api/implement/implementations.go index c7c4776c2..cc5254da3 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() (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 { diff --git a/api/mock.go b/api/mock.go index 86663c9a0..4d774dbe4 100644 --- a/api/mock.go +++ b/api/mock.go @@ -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. diff --git a/assets/js/components/HemsWarning.vue b/assets/js/components/HemsWarning.vue index 9beacc264..104e76da3 100644 --- a/assets/js/components/HemsWarning.vue +++ b/assets/js/components/HemsWarning.vue @@ -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; }, diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts index df72ba367..d8053d5fa 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -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; } diff --git a/assets/js/views/Config.vue b/assets/js/views/Config.vue index bf66dc0aa..f1fa98ab5 100644 --- a/assets/js/views/Config.vue +++ b/assets/js/views/Config.vue @@ -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; diff --git a/cmd/capabilities.go b/cmd/capabilities.go index 69626974a..d126ceb27 100644 --- a/cmd/capabilities.go +++ b/cmd/capabilities.go @@ -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 { diff --git a/cmd/flags.go b/cmd/flags.go index 0880e94ef..2d6a33e04 100644 --- a/cmd/flags.go +++ b/cmd/flags.go @@ -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)" diff --git a/cmd/root.go b/cmd/root.go index 9595f946c..42029c32e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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(), }, diff --git a/core/helper.go b/core/helper.go index 91a0128f5..80f5fbea4 100644 --- a/core/helper.go +++ b/core/helper.go @@ -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. diff --git a/core/site_circuits.go b/core/site_circuits.go index 1b0e3cc68..fc78b33d7 100644 --- a/core/site_circuits.go +++ b/core/site_circuits.go @@ -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)) } diff --git a/hems/eebus/eebus.go b/hems/eebus/eebus.go index 79c10fd46..453593018 100644 --- a/hems/eebus/eebus.go +++ b/hems/eebus/eebus.go @@ -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 diff --git a/hems/eebus/eebus_test.go b/hems/eebus/eebus_test.go index 7374a9f32..5e898e25e 100644 --- a/hems/eebus/eebus_test.go +++ b/hems/eebus/eebus_test.go @@ -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: diff --git a/hems/fnn/fnn.go b/hems/fnn/fnn.go index 01f90dad8..99000b984 100644 --- a/hems/fnn/fnn.go +++ b/hems/fnn/fnn.go @@ -78,15 +78,16 @@ func NewFnn(site site.API, maxDimPower, maxCurtailPower float64, w3G, s1G, s2G, } return &Fnn{ - log: util.NewLogger("fnn"), - site: site, - maxDimPower: maxDimPower, - maxCurtailPower: maxCurtailPower, - s1: s1G, - s2: s2G, - w3: w3G, - w4: w4G, - interval: interval, + log: util.NewLogger("fnn"), + site: site, + maxDimPower: maxDimPower, + maxCurtailPower: maxCurtailPower, + s1: s1G, + s2: s2G, + w3: w3G, + w4: w4G, + productionPercent: 100, + interval: interval, }, nil } @@ -106,8 +107,8 @@ type Fnn struct { smartgridConsumptionID uint smartgridProductionID uint - consumptionLimit float64 - productionLimit *float64 + consumptionLimit float64 + productionPercent int // allowed feed-in percent (0..100), 100 = uncurtailed interval time.Duration } @@ -144,12 +145,12 @@ func (c *Fnn) runCurtail() error { } rules := []struct { - get func() (bool, error) - frac float64 + get func() (bool, error) + 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) } diff --git a/hems/fnn/fnn_test.go b/hems/fnn/fnn_test.go index 7a835291a..76f08725a 100644 --- a/hems/fnn/fnn_test.go +++ b/hems/fnn/fnn_test.go @@ -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()) } diff --git a/hems/relay/relay.go b/hems/relay/relay.go index 601da0bf4..deabf25ac 100644 --- a/hems/relay/relay.go +++ b/hems/relay/relay.go @@ -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 } diff --git a/meter/eebus.go b/meter/eebus.go index 0884aef15..0f293784d 100644 --- a/meter/eebus.go +++ b/meter/eebus.go @@ -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) }) diff --git a/meter/eebus_events.go b/meter/eebus_events.go index a1ce1cae0..f67db0819 100644 --- a/meter/eebus_events.go +++ b/meter/eebus_events.go @@ -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 } diff --git a/meter/measurement/action.go b/meter/measurement/action.go index 1dddbb226..7d1230357 100644 --- a/meter/measurement/action.go +++ b/meter/measurement/action.go @@ -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 } diff --git a/meter/rct.go b/meter/rct.go index c64209923..55a12b577 100644 --- a/meter/rct.go +++ b/meter/rct.go @@ -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)) } diff --git a/templates/definition/meter/demo-meter.yaml b/templates/definition/meter/demo-meter.yaml index 0f64ea68d..dfb06dd8e 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 + typeof demoMeterCurtailed !== "undefined" && demoMeterCurtailed < 100 {{- end }} {{- if .energy }} energy: diff --git a/templates/definition/meter/huawei-sun2000-hybrid.yaml b/templates/definition/meter/huawei-sun2000-hybrid.yaml index 12e21f534..aea6b0fdf 100644 --- a/templates/definition/meter/huawei-sun2000-hybrid.yaml +++ b/templates/definition/meter/huawei-sun2000-hybrid.yaml @@ -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 }} + source: sequence + set: + # 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 + # active power control mode: 7 = percentage limit while curtailed, 0 = unlimited at 100% + - source: switch + switch: + - case: 100 # uncurtailed set: - source: modbus - {{- include "modbus" . | indent 10 }} - register: - address: 47418 # Maximum Feed Grid Power (0-100 %) - type: writesingle - encoding: uint16 - scale: 10 - - source: const - value: 7 # Power-limited grid connection (%) - set: - source: modbus - {{- include "modbus" . | indent 10 }} - register: - address: 47415 # Active power control mode - type: writesingle - encoding: uint16 - else: - source: convert - convert: bool2int - set: + source: const + value: 0 # Unlimited + set: + source: modbus + {{- include "modbus" . | indent 12 }} + register: + address: 47415 # Active power control mode + type: writesingle + encoding: uint16 + default: source: const - value: 0 # Unlimited + value: 7 # Power-limited grid connection (%) set: source: modbus {{- include "modbus" . | indent 8 }}