diff --git a/assets/js/components/Config/defaultYaml/customHems.yaml b/assets/js/components/Config/defaultYaml/customHems.yaml index 9c361cfedee..86c26f4eab2 100644 --- a/assets/js/components/Config/defaultYaml/customHems.yaml +++ b/assets/js/components/Config/defaultYaml/customHems.yaml @@ -1,6 +1,8 @@ ## external limit via relay contact (binary on/off) type: relay maxpower: 4200 # total load limit while signal is active (W) +failsafeconsumptionactivepowerlimit: 4200 # fallback limit if reading the input fails (W) +#failsafedurationminimum: 30m # keep the failsafe limit active for at least this long limit: # input signal, plugin source: const value: false # 0/false = normal, 1/true = limit active diff --git a/hems/fnn/fnn.go b/hems/fnn/fnn.go index acfdf824538..9c67ef7a628 100644 --- a/hems/fnn/fnn.go +++ b/hems/fnn/fnn.go @@ -32,6 +32,7 @@ func NewFromConfig(ctx context.Context, other map[string]any, site site.API) (*F W4 *plugin.Config Interval time.Duration FailsafeConsumptionActivePowerLimit float64 + FailsafeProductionActivePowerLimit float64 FailsafeDurationMinimum time.Duration }{ Interval: 10 * time.Second, @@ -40,6 +41,12 @@ func NewFromConfig(ctx context.Context, other map[string]any, site site.API) (*F if err := util.DecodeOther(other, &cc); err != nil { return nil, err } + if cc.FailsafeConsumptionActivePowerLimit < 0 { + return nil, errors.New("failsafe consumption limit cannot be negative") + } + if cc.FailsafeProductionActivePowerLimit < 0 { + return nil, errors.New("failsafe production limit cannot be negative") + } w3G, err := cc.W3.BoolGetter(ctx) if err != nil { @@ -71,27 +78,40 @@ func NewFromConfig(ctx context.Context, other map[string]any, site site.API) (*F maxCurtailPower = cc.MaxPower } - return NewFnn(site, math.Abs(cc.MaxDimPower), maxCurtailPower, w3G, s1G, s2G, w4G, cc.Interval, math.Abs(cc.FailsafeConsumptionActivePowerLimit), cc.FailsafeDurationMinimum) + return NewFnn( + site, + math.Abs(cc.MaxDimPower), + maxCurtailPower, + w3G, s1G, s2G, w4G, + cc.Interval, + cc.FailsafeConsumptionActivePowerLimit, + cc.FailsafeProductionActivePowerLimit, + cc.FailsafeDurationMinimum, + ) } -func NewFnn(site site.API, maxDimPower, maxCurtailPower float64, w3G, s1G, s2G, w4G func() (bool, error), interval time.Duration, failsafeConsumptionLimit float64, failsafeDurationMinimum time.Duration) (*Fnn, error) { +func NewFnn(site site.API, maxDimPower, maxCurtailPower float64, w3G, s1G, s2G, w4G func() (bool, error), interval time.Duration, failsafeConsumptionLimit, failsafeProductionLimit float64, failsafeDurationMinimum time.Duration) (*Fnn, error) { if w4G != nil && maxDimPower == 0 { return nil, errors.New("cannot have w4 without power limit") } + if failsafeDurationMinimum < 0 { + return nil, errors.New("failsafe duration cannot be negative") + } c := &Fnn{ - log: util.NewLogger("fnn"), - site: site, - maxDimPower: maxDimPower, - maxCurtailPower: maxCurtailPower, - s1: s1G, - s2: s2G, - w3: w3G, - w4: w4G, - productionPercent: 100, - interval: interval, - failsafeConsumptionLimit: failsafeConsumptionLimit, - failsafeDurationMinimum: failsafeDurationMinimum, + log: util.NewLogger("fnn"), + site: site, + maxDimPower: maxDimPower, + maxCurtailPower: maxCurtailPower, + s1: s1G, + s2: s2G, + w3: w3G, + w4: w4G, + productionPercent: 100, + interval: interval, + failsafeConsumptionLimit: failsafeConsumptionLimit, + failsafeProductionLimit: failsafeProductionLimit, + failsafeDurationMinimum: failsafeDurationMinimum, } // read the relays once synchronously so limits are valid as soon as NewFnn returns @@ -122,14 +142,23 @@ type Fnn struct { smartgridProductionID uint consumptionLimit *float64 + productionLimit *float64 productionPercent int // allowed feed-in percent (0..100), 100 = uncurtailed interval time.Duration - failsafeConsumptionLimit float64 - failsafeDurationMinimum time.Duration - failsafeActive bool - failsafeEnteredAt time.Time + failsafeConsumptionLimit float64 + failsafeProductionLimit float64 + failsafeDurationMinimum time.Duration + consumptionFailsafeActive bool + productionFailsafeActive bool + consumptionFailsafeSince time.Time + productionFailsafeSince time.Time +} + +type curtailmentState struct { + percent int + active bool } func (c *Fnn) SetUpdated(f func()) { @@ -171,6 +200,8 @@ func (c *Fnn) runCurtail() error { {get: c.s1, percent: 60}, } + states := make([]curtailmentState, 0, len(rules)) + for _, rule := range rules { if rule.get == nil { continue @@ -178,16 +209,49 @@ func (c *Fnn) runCurtail() error { active, err := rule.get() if err != nil { - return err + if !c.hasProductionFailsafe() { + return err + } + c.mu.Lock() + if !c.productionFailsafeActive { + c.log.WARN.Printf("curtail read error, entering failsafe mode: %v", err) + c.productionFailsafeActive = true + c.productionFailsafeSince = time.Now() + } + c.mu.Unlock() + return c.setProductionPowerLimit(c.failsafeProductionLimit) + } + + states = append(states, curtailmentState{ + percent: rule.percent, + active: active, + }) + } + + c.mu.Lock() + inFailsafe := false + if c.productionFailsafeActive { + if time.Since(c.productionFailsafeSince) >= c.failsafeDurationMinimum { + c.log.DEBUG.Println("leaving production failsafe mode") + c.productionFailsafeActive = false + } else { + inFailsafe = true } + } + c.mu.Unlock() + + if inFailsafe { + return nil + } - if active { - return c.setProductionLimit(rule.percent) + for _, state := range states { + if state.active { + return c.setProductionPercent(state.percent) } } // 100% - return c.setProductionLimit(100) + return c.setProductionPercent(100) } // runDim evaluates the dimming rule and applies the dim limit. @@ -201,14 +265,14 @@ func (c *Fnn) runDim() error { active, err := c.w4() if err != nil { - if c.failsafeConsumptionLimit <= 0 { + if !c.hasConsumptionFailsafe() { return err } c.mu.Lock() - if !c.failsafeActive { - c.log.WARN.Println("w4 read error, entering failsafe mode") - c.failsafeActive = true - c.failsafeEnteredAt = time.Now() + if !c.consumptionFailsafeActive { + c.log.WARN.Printf("w4 read error, entering failsafe mode: %v", err) + c.consumptionFailsafeActive = true + c.consumptionFailsafeSince = time.Now() } c.mu.Unlock() return c.setConsumptionLimit(c.failsafeConsumptionLimit) @@ -216,10 +280,10 @@ func (c *Fnn) runDim() error { c.mu.Lock() inFailsafe := false - if c.failsafeActive { - if time.Since(c.failsafeEnteredAt) >= c.failsafeDurationMinimum { - c.log.DEBUG.Println("leaving failsafe mode") - c.failsafeActive = false + if c.consumptionFailsafeActive { + if time.Since(c.consumptionFailsafeSince) >= c.failsafeDurationMinimum { + c.log.DEBUG.Println("leaving consumption failsafe mode") + c.consumptionFailsafeActive = false } else { inFailsafe = true } @@ -238,17 +302,30 @@ func (c *Fnn) runDim() error { return c.setConsumptionLimit(limit) } -// setProductionLimit applies the curtailment limit. -func (c *Fnn) setProductionLimit(percent int) error { +// setProductionPercent applies the curtailment limit. +func (c *Fnn) setProductionPercent(percent int) error { + active := percent < 100 + return c.setProductionState(percent, c.percentToProductionLimit(percent), active) +} + +func (c *Fnn) setProductionPowerLimit(limit float64) error { + percent := 100 + active := limit > 0 + if active { + percent = c.productionLimitToPercent(limit) + } + + return c.setProductionState(percent, limit, active) +} + +func (c *Fnn) setProductionState(percent int, limit float64, active bool) error { c.mu.Lock() defer c.mu.Unlock() - active := percent < 100 c.productionPercent = percent - - limit := 0.0 + c.productionLimit = nil if active { - limit = float64(percent) / 100 * c.maxCurtailPower + c.productionLimit = &limit } if err := smartgrid.UpdateSession(&c.smartgridProductionID, smartgrid.Curtail, c.site.GetGridPower(), limit, active); err != nil { @@ -258,6 +335,31 @@ func (c *Fnn) setProductionLimit(percent int) error { return nil } +func (c *Fnn) hasConsumptionFailsafe() bool { + return c.failsafeConsumptionLimit > 0 +} + +func (c *Fnn) hasProductionFailsafe() bool { + return c.failsafeProductionLimit > 0 +} + +func (c *Fnn) percentToProductionLimit(percent int) float64 { + if percent >= 100 { + return 0 + } + + return float64(percent) / 100 * c.maxCurtailPower +} + +func (c *Fnn) productionLimitToPercent(limit float64) int { + if c.maxCurtailPower <= 0 { + return 0 + } + + percent := int(math.Round(limit / c.maxCurtailPower * 100)) + return max(0, min(100, percent)) +} + // setConsumptionLimit applies the dimming limit. func (c *Fnn) setConsumptionLimit(limit float64) error { c.mu.Lock() @@ -316,5 +418,9 @@ func (c *Fnn) MaxProductionPower() *float64 { return new(0.0) } - return new(float64(c.productionPercent) / 100 * c.maxCurtailPower) + if c.productionLimit == nil { + return new(0.0) + } + + return new(*c.productionLimit) } diff --git a/hems/fnn/fnn_test.go b/hems/fnn/fnn_test.go index 96605b5b4de..1cc934b4c01 100644 --- a/hems/fnn/fnn_test.go +++ b/hems/fnn/fnn_test.go @@ -33,7 +33,7 @@ func errG() func() (bool, error) { func TestCurtailmentNotConfigured(t *testing.T) { require.NoError(t, db.NewInstance("sqlite", ":memory:")) - fnn, err := NewFnn(&stubSite{}, 1e3, 1e3, nil, nil, nil, boolG(true), 0, 0, 0) + fnn, err := NewFnn(&stubSite{}, 1e3, 1e3, nil, nil, nil, boolG(true), 0, 0, 0, 0) require.NoError(t, err) assert.Nil(t, fnn.CurtailedPercent()) @@ -47,7 +47,7 @@ func TestCurtailmentNotConfigured(t *testing.T) { // TestDimmingNotConfigured verifies that without W4 no dimming statement is // made, while curtailment via W3 remains available. func TestDimmingNotConfigured(t *testing.T) { - fnn, err := NewFnn(&stubSite{}, 0, 1e3, boolG(false), nil, nil, nil, 0, 0, 0) + fnn, err := NewFnn(&stubSite{}, 0, 1e3, boolG(false), nil, nil, nil, 0, 0, 0, 0) require.NoError(t, err) assert.Nil(t, fnn.MaxConsumptionPower()) @@ -64,8 +64,14 @@ func TestDecodeConfig(t *testing.T) { other := map[string]any{ "maxDimPower": 4200, + "maxCurtailPower": 10000, "failsafeConsumptionActivePowerLimit": 4200, + "failsafeProductionActivePowerLimit": 2500, "failsafeDurationMinimum": "30m", + "w3": map[string]any{ + "source": "const", + "value": false, + }, "w4": map[string]any{ "source": "const", "value": false, @@ -75,6 +81,7 @@ func TestDecodeConfig(t *testing.T) { f, err := NewFromConfig(t.Context(), other, &stubSite{}) require.NoError(t, err) assert.Equal(t, 4200.0, f.failsafeConsumptionLimit) + assert.Equal(t, 2500.0, f.failsafeProductionLimit) assert.Equal(t, 30*time.Minute, f.failsafeDurationMinimum) } @@ -84,11 +91,12 @@ func TestFailsafeActivatesOnReadError(t *testing.T) { require.NoError(t, db.NewInstance("sqlite", ":memory:")) const failsafeLimit = 4200.0 - fnn, err := NewFnn(&stubSite{}, failsafeLimit, 0, nil, nil, nil, errG(), 0, failsafeLimit, 0) + fnn, err := NewFnn(&stubSite{}, failsafeLimit, 0, nil, nil, nil, boolG(false), 0, failsafeLimit, 0, 0) require.NoError(t, err) + fnn.w4 = errG() + require.NoError(t, fnn.runDim()) - // construction calls runDim once — failsafe should already be active - assert.True(t, fnn.failsafeActive) + assert.True(t, fnn.consumptionFailsafeActive) require.NotNil(t, fnn.MaxConsumptionPower()) assert.Equal(t, failsafeLimit, *fnn.MaxConsumptionPower()) } @@ -99,16 +107,16 @@ func TestFailsafeExitsAfterDuration(t *testing.T) { require.NoError(t, db.NewInstance("sqlite", ":memory:")) const failsafeLimit = 4200.0 - // duration of 0 means failsafe exits on next successful read - fnn, err := NewFnn(&stubSite{}, failsafeLimit, 0, nil, nil, nil, errG(), 0, failsafeLimit, 0) + fnn, err := NewFnn(&stubSite{}, failsafeLimit, 0, nil, nil, nil, boolG(false), 0, failsafeLimit, 0, 0) require.NoError(t, err) - assert.True(t, fnn.failsafeActive) + fnn.w4 = errG() + require.NoError(t, fnn.runDim()) + assert.True(t, fnn.consumptionFailsafeActive) - // switch to successful read fnn.w4 = boolG(false) require.NoError(t, fnn.runDim()) - assert.False(t, fnn.failsafeActive) + assert.False(t, fnn.consumptionFailsafeActive) require.NotNil(t, fnn.MaxConsumptionPower()) assert.Equal(t, 0.0, *fnn.MaxConsumptionPower()) } @@ -119,25 +127,119 @@ func TestFailsafeRemainsActiveDuringDuration(t *testing.T) { require.NoError(t, db.NewInstance("sqlite", ":memory:")) const failsafeLimit = 4200.0 - fnn, err := NewFnn(&stubSite{}, failsafeLimit, 0, nil, nil, nil, errG(), 0, failsafeLimit, time.Hour) + fnn, err := NewFnn(&stubSite{}, failsafeLimit, 0, nil, nil, nil, boolG(false), 0, failsafeLimit, 0, time.Hour) require.NoError(t, err) - assert.True(t, fnn.failsafeActive) + fnn.w4 = errG() + require.NoError(t, fnn.runDim()) + assert.True(t, fnn.consumptionFailsafeActive) - // switch to successful read, but duration not yet elapsed fnn.w4 = boolG(false) require.NoError(t, fnn.runDim()) - assert.True(t, fnn.failsafeActive) + assert.True(t, fnn.consumptionFailsafeActive) require.NotNil(t, fnn.MaxConsumptionPower()) assert.Equal(t, failsafeLimit, *fnn.MaxConsumptionPower()) } // TestFailsafeNotConfiguredPropagatesError verifies that without a configured -// failsafe limit, a W4 read error is returned to the caller unchanged. +// failsafe limit, runDim returns the original W4 read error. func TestFailsafeNotConfiguredPropagatesError(t *testing.T) { require.NoError(t, db.NewInstance("sqlite", ":memory:")) - // no failsafe limit: construction error because runDim is called in NewFnn - _, err := NewFnn(&stubSite{}, 1e3, 0, nil, nil, nil, errG(), 0, 0, 0) - assert.Error(t, err) + fnn, err := NewFnn(&stubSite{}, 1e3, 0, nil, nil, nil, boolG(false), 0, 0, 0, 0) + require.NoError(t, err) + + want := errors.New("w4 read error") + fnn.w4 = func() (bool, error) { return false, want } + + assert.ErrorIs(t, fnn.runDim(), want) +} + +// TestProductionFailsafeActivatesOnReadError verifies that a curtailment input +// read error triggers the production failsafe and applies the configured limit. +func TestProductionFailsafeActivatesOnReadError(t *testing.T) { + require.NoError(t, db.NewInstance("sqlite", ":memory:")) + + const ( + maxCurtailPower = 10000.0 + failsafeLimit = 2500.0 + ) + + fnn, err := NewFnn(&stubSite{}, 0, maxCurtailPower, boolG(false), nil, nil, nil, 0, 0, failsafeLimit, 0) + require.NoError(t, err) + fnn.w3 = errG() + require.NoError(t, fnn.runCurtail()) + + assert.True(t, fnn.productionFailsafeActive) + require.NotNil(t, fnn.MaxProductionPower()) + assert.Equal(t, failsafeLimit, *fnn.MaxProductionPower()) + assert.Equal(t, 25, *fnn.CurtailedPercent()) +} + +// TestProductionFailsafeExitsAfterDuration verifies that the production +// failsafe clears once the minimum duration has elapsed and reads succeed. +func TestProductionFailsafeExitsAfterDuration(t *testing.T) { + require.NoError(t, db.NewInstance("sqlite", ":memory:")) + + const ( + maxCurtailPower = 10000.0 + failsafeLimit = 2500.0 + ) + + fnn, err := NewFnn(&stubSite{}, 0, maxCurtailPower, boolG(false), nil, nil, nil, 0, 0, failsafeLimit, 0) + require.NoError(t, err) + fnn.w3 = errG() + require.NoError(t, fnn.runCurtail()) + assert.True(t, fnn.productionFailsafeActive) + + fnn.w3 = boolG(false) + require.NoError(t, fnn.runCurtail()) + + assert.False(t, fnn.productionFailsafeActive) + require.NotNil(t, fnn.MaxProductionPower()) + assert.Equal(t, 0.0, *fnn.MaxProductionPower()) + assert.Equal(t, 100, *fnn.CurtailedPercent()) +} + +// TestProductionFailsafeRemainsActiveDuringDuration verifies that production +// failsafe stays active until the minimum duration has elapsed. +func TestProductionFailsafeRemainsActiveDuringDuration(t *testing.T) { + require.NoError(t, db.NewInstance("sqlite", ":memory:")) + + const ( + maxCurtailPower = 10000.0 + failsafeLimit = 2500.0 + ) + + fnn, err := NewFnn(&stubSite{}, 0, maxCurtailPower, boolG(false), nil, nil, nil, 0, 0, failsafeLimit, time.Hour) + require.NoError(t, err) + fnn.w3 = errG() + require.NoError(t, fnn.runCurtail()) + assert.True(t, fnn.productionFailsafeActive) + + fnn.w3 = boolG(false) + require.NoError(t, fnn.runCurtail()) + + assert.True(t, fnn.productionFailsafeActive) + require.NotNil(t, fnn.MaxProductionPower()) + assert.Equal(t, failsafeLimit, *fnn.MaxProductionPower()) +} + +func TestFailsafeNegativeDurationRejected(t *testing.T) { + _, err := NewFnn(&stubSite{}, 1e3, 1e3, nil, nil, nil, boolG(false), 0, 0, 0, -time.Second) + assert.ErrorContains(t, err, "failsafe duration cannot be negative") +} + +func TestDecodeRejectsNegativeFailsafeLimits(t *testing.T) { + other := map[string]any{ + "maxDimPower": 4200, + "failsafeConsumptionActivePowerLimit": -1, + "w4": map[string]any{ + "source": "const", + "value": false, + }, + } + + _, err := NewFromConfig(t.Context(), other, &stubSite{}) + assert.ErrorContains(t, err, "failsafe consumption limit cannot be negative") } diff --git a/hems/relay/relay.go b/hems/relay/relay.go index 3ace1e15234..5f844a52a1e 100644 --- a/hems/relay/relay.go +++ b/hems/relay/relay.go @@ -32,15 +32,22 @@ type Relay struct { limit *float64 maxPower float64 interval time.Duration + + failsafeConsumptionLimit float64 + failsafeDurationMinimum time.Duration + failsafeActive bool + failsafeSince time.Time } // NewFromConfig creates an Relay HEMS from generic config func NewFromConfig(ctx context.Context, other map[string]any, site site.API) (*Relay, error) { cc := struct { - MaxPower float64 - Limit plugin.Config - Passthrough *plugin.Config - Interval time.Duration + MaxPower float64 + Limit plugin.Config + Passthrough *plugin.Config + Interval time.Duration + FailsafeConsumptionActivePowerLimit float64 + FailsafeDurationMinimum time.Duration }{ Interval: 10 * time.Second, } @@ -48,6 +55,9 @@ func NewFromConfig(ctx context.Context, other map[string]any, site site.API) (*R if err := util.DecodeOther(other, &cc); err != nil { return nil, err } + if cc.FailsafeConsumptionActivePowerLimit < 0 { + return nil, errors.New("failsafe consumption limit cannot be negative") + } // limit getter limitG, err := cc.Limit.BoolGetter(ctx) @@ -60,23 +70,36 @@ func NewFromConfig(ctx context.Context, other map[string]any, site site.API) (*R return nil, err } - return NewRelay(site, limitG, passthroughS, cc.MaxPower, cc.Interval) + return NewRelay( + site, + limitG, + passthroughS, + cc.MaxPower, + cc.Interval, + cc.FailsafeConsumptionActivePowerLimit, + cc.FailsafeDurationMinimum, + ) } // NewRelay creates Relay HEMS -func NewRelay(site site.API, w1 func() (bool, error), passthrough func(bool) error, maxPower float64, interval time.Duration) (*Relay, error) { +func NewRelay(site site.API, w1 func() (bool, error), passthrough func(bool) error, maxPower float64, interval time.Duration, failsafeConsumptionLimit float64, failsafeDurationMinimum time.Duration) (*Relay, error) { c := &Relay{ - log: util.NewLogger("relay"), - site: site, - passthrough: passthrough, - maxPower: maxPower, - w1: w1, - interval: interval, + log: util.NewLogger("relay"), + site: site, + passthrough: passthrough, + maxPower: maxPower, + w1: w1, + interval: interval, + failsafeConsumptionLimit: failsafeConsumptionLimit, + failsafeDurationMinimum: failsafeDurationMinimum, } if maxPower == 0 { return nil, errors.New("missing power limit") } + if failsafeDurationMinimum < 0 { + return nil, errors.New("failsafe duration cannot be negative") + } // read the relay once synchronously so the limit is valid as soon as NewRelay returns if err := c.run(); err != nil { @@ -108,7 +131,33 @@ func (c *Relay) Run() { func (c *Relay) run() error { active, err := c.w1() if err != nil { - return err + if !c.hasFailsafe() { + return err + } + c.mu.Lock() + if !c.failsafeActive { + c.log.WARN.Printf("limit read error, entering failsafe mode: %v", err) + c.failsafeActive = true + c.failsafeSince = time.Now() + } + c.mu.Unlock() + return c.setConsumptionLimit(c.failsafeConsumptionLimit) + } + + c.mu.Lock() + inFailsafe := false + if c.failsafeActive { + if time.Since(c.failsafeSince) >= c.failsafeDurationMinimum { + c.log.DEBUG.Println("leaving failsafe mode") + c.failsafeActive = false + } else { + inFailsafe = true + } + } + c.mu.Unlock() + + if inFailsafe { + return nil } var limit float64 @@ -127,6 +176,10 @@ func (c *Relay) run() error { return nil } +func (c *Relay) hasFailsafe() bool { + return c.failsafeConsumptionLimit > 0 +} + func (c *Relay) setConsumptionLimit(limit float64) error { c.mu.Lock() defer c.mu.Unlock() diff --git a/hems/relay/relay_test.go b/hems/relay/relay_test.go index dad5c53aecc..cb37c955107 100644 --- a/hems/relay/relay_test.go +++ b/hems/relay/relay_test.go @@ -1,13 +1,31 @@ package relay import ( + "errors" "testing" + "time" + "github.com/evcc-io/evcc/core/site" "github.com/evcc-io/evcc/hems/hems" + "github.com/evcc-io/evcc/server/db" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +type testSiteStub struct { + site.API +} + +func (s *testSiteStub) GetGridPower() float64 { return 0 } + +func boolG(v bool) func() (bool, error) { + return func() (bool, error) { return v, nil } +} + +func errG() func() (bool, error) { + return func() (bool, error) { return false, errors.New("read error") } +} + // TestCurtailmentNotConfigured verifies that relay never makes a curtailment // statement, and that an active relay dims to maxPower. func TestCurtailmentNotConfigured(t *testing.T) { @@ -28,3 +46,84 @@ func TestCurtailmentNotConfigured(t *testing.T) { assert.Nil(t, c.CurtailedPercent()) assert.Nil(t, hems.Curtailed(c)) } + +func TestFailsafeActivatesOnReadError(t *testing.T) { + require.NoError(t, db.NewInstance("sqlite", ":memory:")) + + const failsafeLimit = 4200.0 + c, err := NewRelay(&testSiteStub{}, boolG(false), nil, 1e3, 0, failsafeLimit, 0) + require.NoError(t, err) + c.w1 = errG() + require.NoError(t, c.run()) + + assert.True(t, c.failsafeActive) + require.NotNil(t, c.MaxConsumptionPower()) + assert.Equal(t, failsafeLimit, *c.MaxConsumptionPower()) +} + +func TestFailsafeExitsAfterDuration(t *testing.T) { + require.NoError(t, db.NewInstance("sqlite", ":memory:")) + + const failsafeLimit = 4200.0 + c, err := NewRelay(&testSiteStub{}, boolG(false), nil, 1e3, 0, failsafeLimit, 0) + require.NoError(t, err) + c.w1 = errG() + require.NoError(t, c.run()) + assert.True(t, c.failsafeActive) + + c.w1 = boolG(false) + require.NoError(t, c.run()) + + assert.False(t, c.failsafeActive) + require.NotNil(t, c.MaxConsumptionPower()) + assert.Equal(t, 0.0, *c.MaxConsumptionPower()) +} + +func TestFailsafeRemainsActiveDuringDuration(t *testing.T) { + require.NoError(t, db.NewInstance("sqlite", ":memory:")) + + const failsafeLimit = 4200.0 + c, err := NewRelay(&testSiteStub{}, boolG(false), nil, 1e3, 0, failsafeLimit, time.Hour) + require.NoError(t, err) + c.w1 = errG() + require.NoError(t, c.run()) + assert.True(t, c.failsafeActive) + + c.w1 = boolG(false) + require.NoError(t, c.run()) + + assert.True(t, c.failsafeActive) + require.NotNil(t, c.MaxConsumptionPower()) + assert.Equal(t, failsafeLimit, *c.MaxConsumptionPower()) +} + +func TestFailsafeNotConfiguredPropagatesError(t *testing.T) { + require.NoError(t, db.NewInstance("sqlite", ":memory:")) + + c, err := NewRelay(&testSiteStub{}, boolG(false), nil, 1e3, 0, 0, 0) + require.NoError(t, err) + + want := errors.New("limit read error") + c.w1 = func() (bool, error) { return false, want } + + assert.ErrorIs(t, c.run(), want) +} + +func TestFailsafeNegativeDurationRejected(t *testing.T) { + _, err := NewRelay(&testSiteStub{}, boolG(false), nil, 1e3, 0, 0, -time.Second) + assert.ErrorContains(t, err, "failsafe duration cannot be negative") +} + +func TestDecodeRejectsNegativeFailsafeLimit(t *testing.T) { + other := map[string]any{ + "maxPower": 4200, + "failsafeConsumptionActivePowerLimit": -1, + "limit": map[string]any{ + "source": "const", + "value": false, + }, + } + + _, err := NewFromConfig(t.Context(), other, &testSiteStub{}) + assert.ErrorContains(t, err, "failsafe consumption limit cannot be negative") +} diff --git a/templates/definition/hems/fnn-gpio.yaml b/templates/definition/hems/fnn-gpio.yaml index a92448de31a..5315d3a2536 100644 --- a/templates/definition/hems/fnn-gpio.yaml +++ b/templates/definition/hems/fnn-gpio.yaml @@ -22,6 +22,16 @@ params: help: en: Input pin for the W4 signal (Dimmer for controllable loads) de: Eingangspin für das W4-Signal (Dimmen der steuerbaren Verbrauchseinrichtungen) + - name: failsafeconsumptionpower + type: float + unit: W + advanced: true + description: + en: Failsafe consumption limit + de: Failsafe-Bezugsgrenze + help: + en: Consumption limit applied if reading W4 fails. + de: Bezugsgrenze, die angewendet wird, wenn W4 nicht gelesen werden kann. - name: maxproductionpower type: float unit: W @@ -57,14 +67,42 @@ params: help: en: Input pin for the S2 signal (30% export power limit) de: Eingangspin für das S2-Signal (Reduzierung der Einspeiseleistung auf 30%) + - name: failsafeproductionpower + type: float + unit: W + advanced: true + description: + en: Failsafe production limit + de: Failsafe-Einspeisebegrenzung + help: + en: Feed-in limit applied if reading the curtailment inputs fails. + de: Einspeisebegrenzung, die angewendet wird, wenn die Curtailment-Eingänge nicht gelesen werden können. + - name: failsafeduration + type: duration + advanced: true + description: + en: Failsafe minimum duration + de: Failsafe-Mindestdauer + help: + en: Minimum time the failsafe limit stays active once entered. + de: Mindestdauer, für die der Failsafe-Wert nach Aktivierung gehalten wird. render: | type: fnn maxdimpower: {{ .maxconsumptionpower }} + {{- if .failsafeconsumptionpower }} + failsafeconsumptionactivepowerlimit: {{ .failsafeconsumptionpower }} + {{- end }} w4: source: gpio pin: {{ .w4pin }} function: read maxcurtailpower: {{ .maxproductionpower }} + {{- if .failsafeproductionpower }} + failsafeproductionactivepowerlimit: {{ .failsafeproductionpower }} + {{- end }} + {{- if .failsafeduration }} + failsafedurationminimum: {{ .failsafeduration }} + {{- end }} w3: source: gpio pin: {{ .w3pin }} diff --git a/templates/definition/hems/hemspro-gpio.yaml b/templates/definition/hems/hemspro-gpio.yaml index 93897afda82..67aaac02c56 100644 --- a/templates/definition/hems/hemspro-gpio.yaml +++ b/templates/definition/hems/hemspro-gpio.yaml @@ -24,14 +24,52 @@ params: help: en: Rated generator/module power (Wp) of the installation. de: Gesamtnennleistung bzw. Modul- oder Generatorleistung der Anlage (Bezugsgröße nach § 9 EEG). + - name: failsafeconsumptionpower + type: float + unit: W + advanced: true + description: + en: Failsafe consumption limit + de: Failsafe-Bezugsgrenze + help: + en: Consumption limit applied if reading W4 fails. + de: Bezugsgrenze, die angewendet wird, wenn W4 nicht gelesen werden kann. + - name: failsafeproductionpower + type: float + unit: W + advanced: true + description: + en: Failsafe production limit + de: Failsafe-Einspeisebegrenzung + help: + en: Feed-in limit applied if reading the curtailment inputs fails. + de: Einspeisebegrenzung, die angewendet wird, wenn die Curtailment-Eingänge nicht gelesen werden können. + - name: failsafeduration + type: duration + advanced: true + description: + en: Failsafe minimum duration + de: Failsafe-Mindestdauer + help: + en: Minimum time the failsafe limit stays active once entered. + de: Mindestdauer, für die der Failsafe-Wert nach Aktivierung gehalten wird. render: | type: fnn maxdimpower: {{ .maxconsumptionpower }} + {{- if .failsafeconsumptionpower }} + failsafeconsumptionactivepowerlimit: {{ .failsafeconsumptionpower }} + {{- end }} w4: source: gpio pin: 13 function: read maxcurtailpower: {{ .maxproductionpower }} + {{- if .failsafeproductionpower }} + failsafeproductionactivepowerlimit: {{ .failsafeproductionpower }} + {{- end }} + {{- if .failsafeduration }} + failsafedurationminimum: {{ .failsafeduration }} + {{- end }} w3: source: gpio pin: 5