Curtailment: report curtailed percent instead of bool (#32010)
This commit is contained in:
parent
435ed72a56
commit
608aa36629
19 changed files with 178 additions and 56 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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" }}
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ render: |
|
|||
source: js
|
||||
vm: shared
|
||||
script: |
|
||||
typeof demoMeterCurtailed !== "undefined" && demoMeterCurtailed < 100
|
||||
typeof demoMeterCurtailed === "undefined" ? 100 : demoMeterCurtailed
|
||||
{{- end }}
|
||||
{{- if .energy }}
|
||||
energy:
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue