Meter: make battery parameters dynamic (#31668)

This commit is contained in:
andig 2026-07-18 16:24:12 +02:00 • committed by GitHub
parent 579df2ad67
commit d3ae621688
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 296 additions and 22 deletions

View file

@ -163,8 +163,8 @@ func (d *dumper) Dump(name string, v any) {
if v, ok := api.Cap[api.BatteryPowerLimiter](v); ok {
charge, discharge := v.GetPowerLimits()
fmt.Fprintf(w, "Charge power:\t%.0fW\t\t\n", charge)
fmt.Fprintf(w, "Discharge power:\t%.0fW\t\t\n", discharge)
fmt.Fprintf(w, "Max charge power:\t%.0fW\t\t\n", charge)
fmt.Fprintf(w, "Max discharge power:\t%.0fW\t\t\n", discharge)
}
if v, ok := api.Cap[api.MaxACPowerGetter](v); ok {

View file

@ -27,23 +27,26 @@ func NewConfigurableFromConfig(ctx context.Context, other map[string]any) (api.M
pvMaxACPower `mapstructure:",squash"`
// battery
batteryCapacity `mapstructure:",squash"`
batterySocLimits `mapstructure:",squash"`
batteryPowerLimits `mapstructure:",squash"`
batteryCapacityCtx `mapstructure:",squash"`
batterySocLimitsCtx `mapstructure:",squash"`
batteryPowerLimitsCtx `mapstructure:",squash"`
Soc *plugin.Config // optional
LimitSoc *plugin.Config // optional
BatteryMode *plugin.Config // optional
}{
batterySocLimits: batterySocLimits{
MinSoc: 20,
MaxSoc: 95,
},
}
}{}
if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}
// default soc limits (nil-preset avoids mapstructure coercing plugin config into the default's type)
if cc.batterySocLimitsCtx.MinSoc == nil {
cc.batterySocLimitsCtx.MinSoc = 20
}
if cc.batterySocLimitsCtx.MaxSoc == nil {
cc.batterySocLimitsCtx.MaxSoc = 95
}
powerG, energyG, returnG, err := cc.Energy.Configure(ctx)
if err != nil {
return nil, err
@ -68,10 +71,25 @@ func NewConfigurableFromConfig(ctx context.Context, other map[string]any) (api.M
}
if socG != nil {
capacity, err := cc.batteryCapacityCtx.Decorator(ctx)
if err != nil {
return nil, err
}
socLimiter, err := cc.batterySocLimitsCtx.Decorator(ctx)
if err != nil {
return nil, err
}
powerLimiter, err := cc.batteryPowerLimitsCtx.Decorator(ctx)
if err != nil {
return nil, err
}
implement.Has(m, implement.Battery(socG))
implement.May(m, implement.BatteryCapacity(cc.batteryCapacity.Decorator()))
implement.May(m, implement.BatterySocLimiter(cc.batterySocLimits.Decorator()))
implement.May(m, implement.BatteryPowerLimiter(cc.batteryPowerLimits.Decorator()))
implement.May(m, implement.BatteryCapacity(capacity))
implement.May(m, implement.BatterySocLimiter(socLimiter))
implement.May(m, implement.BatteryPowerLimiter(powerLimiter))
switch {
case cc.Soc != nil && cc.LimitSoc != nil:
@ -80,7 +98,12 @@ func NewConfigurableFromConfig(ctx context.Context, other map[string]any) (api.M
return nil, fmt.Errorf("battery limit soc: %w", err)
}
implement.Has(m, implement.BatteryController(cc.batterySocLimits.LimitController(socG, limitSocS)))
limitController, err := cc.batterySocLimitsCtx.LimitController(ctx, socG, limitSocS)
if err != nil {
return nil, err
}
implement.Has(m, implement.BatteryController(limitController))
case cc.BatteryMode != nil:
modeS, err := cc.BatteryMode.IntSetter(ctx, "batteryMode")

View file

@ -1,6 +1,12 @@
package meter
import "github.com/evcc-io/evcc/api"
import (
"context"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/plugin"
"github.com/evcc-io/evcc/util"
)
type batteryCapacity struct {
Capacity float64
@ -18,6 +24,64 @@ func (m *batteryCapacity) Decorator() func() float64 {
}
}
type batteryCapacityCtx struct {
Capacity any // static kWh value or float plugin
}
// var _ api.BatteryCapacity = (*batteryCapacityCtx)(nil)
// Decorator returns an api.BatteryCapacity decorator. Capacity may be a static
// number or a float plugin config; nil/zero means not configured.
func (m *batteryCapacityCtx) Decorator(ctx context.Context) (func() float64, error) {
return resolveFloat(ctx, m.Capacity)
}
// resolveFloat resolves a static number or float plugin config to a getter.
// nil/zero static returns a nil getter (not configured).
func resolveFloat(ctx context.Context, v any) (func() float64, error) {
switch v := v.(type) {
case nil:
return nil, nil
case int:
return staticCapacity(float64(v)), nil
case int64:
return staticCapacity(float64(v)), nil
case float64:
return staticCapacity(v), nil
default:
var cfg plugin.Config
if err := util.DecodeOther(v, &cfg); err != nil {
return nil, err
}
get, err := cfg.FloatGetter(ctx)
if err != nil {
return nil, err
}
return func() float64 {
f, err := get()
if err != nil {
return 0 // ponytail: treat plugin error as unknown value
}
return f
}, nil
}
}
func staticCapacity(f float64) func() float64 {
if f == 0 {
return nil
}
return func() float64 { return f }
}
// floatOr0 evaluates g, returning 0 for a nil (unconfigured) getter.
func floatOr0(g func() float64) float64 {
if g == nil {
return 0
}
return g()
}
type batteryPowerLimits struct {
MaxChargePower float64
MaxDischargePower float64
@ -35,6 +99,32 @@ func (m *batteryPowerLimits) Decorator() func() (float64, float64) {
}
}
type batteryPowerLimitsCtx struct {
MaxChargePower any // static W value or float plugin
MaxDischargePower any // static W value or float plugin
}
// var _ api.BatteryPowerLimiter = (*batteryPowerLimitsCtx)(nil)
// Decorator returns an api.BatteryPowerLimiter decorator. Each limit may be a
// static number or a float plugin config; either unset means not configured.
func (m *batteryPowerLimitsCtx) Decorator(ctx context.Context) (func() (float64, float64), error) {
charge, err := resolveFloat(ctx, m.MaxChargePower)
if err != nil {
return nil, err
}
discharge, err := resolveFloat(ctx, m.MaxDischargePower)
if err != nil {
return nil, err
}
if charge == nil || discharge == nil {
return nil, nil
}
return func() (float64, float64) {
return charge(), discharge()
}, nil
}
type batterySocLimits struct {
MinSoc, MaxSoc float64
}
@ -74,3 +164,64 @@ func (m *batterySocLimits) LimitController(socG func() (float64, error), limitSo
}
}
}
type batterySocLimitsCtx struct {
MinSoc, MaxSoc any // static % value or float plugin
}
// var _ api.BatterySocLimiter = (*batterySocLimitsCtx)(nil)
func (m *batterySocLimitsCtx) getters(ctx context.Context) (func() float64, func() float64, error) {
minG, err := resolveFloat(ctx, m.MinSoc)
if err != nil {
return nil, nil, err
}
maxG, err := resolveFloat(ctx, m.MaxSoc)
if err != nil {
return nil, nil, err
}
return minG, maxG, nil
}
// Decorator returns an api.BatterySocLimiter decorator. Each limit may be a
// static number or a float plugin config; both unset means not configured.
func (m *batterySocLimitsCtx) Decorator(ctx context.Context) (func() (float64, float64), error) {
minG, maxG, err := m.getters(ctx)
if err != nil {
return nil, err
}
if minG == nil && maxG == nil {
return nil, nil
}
return func() (float64, float64) {
return floatOr0(minG), floatOr0(maxG)
}, nil
}
// LimitController returns an api.BatteryController decorator
func (m *batterySocLimitsCtx) LimitController(ctx context.Context, socG func() (float64, error), limitSocS func(float64) error) (func(api.BatteryMode) error, error) {
minG, maxG, err := m.getters(ctx)
if err != nil {
return nil, err
}
return func(mode api.BatteryMode) error {
switch mode {
case api.BatteryNormal:
return limitSocS(floatOr0(minG))
case api.BatteryHold:
soc, err := socG()
if err != nil {
return err
}
return limitSocS(min(100, max(soc, floatOr0(minG))))
case api.BatteryCharge:
return limitSocS(floatOr0(maxG))
// BatteryHoldCharge not implementable via limit soc
default:
return api.ErrNotAvailable
}
}, nil
}

View file

@ -1,12 +1,59 @@
package meter
import (
"context"
"testing"
"github.com/evcc-io/evcc/util"
"github.com/stretchr/testify/require"
)
func TestBatteryCapacity(t *testing.T) {
ctx := context.TODO()
// static value
{
var cc batteryCapacityCtx
require.NoError(t, util.DecodeOther(map[string]any{"capacity": 10}, &cc))
g, err := cc.Decorator(ctx)
require.NoError(t, err)
require.NotNil(t, g)
require.Equal(t, 10.0, g())
}
// zero value is treated as not configured
{
var cc batteryCapacityCtx
require.NoError(t, util.DecodeOther(map[string]any{"capacity": 0}, &cc))
g, err := cc.Decorator(ctx)
require.NoError(t, err)
require.Nil(t, g)
}
// unset is not configured
{
var cc batteryCapacityCtx
g, err := cc.Decorator(ctx)
require.NoError(t, err)
require.Nil(t, g)
}
// float plugin
{
var cc batteryCapacityCtx
require.NoError(t, util.DecodeOther(map[string]any{
"capacity": map[string]any{
"source": "const",
"value": "12.5",
},
}, &cc))
g, err := cc.Decorator(ctx)
require.NoError(t, err)
require.NotNil(t, g)
require.Equal(t, 12.5, g())
}
}
func TestBatterySocLimits(t *testing.T) {
other := map[string]any{
"minsoc": 1,

View file

@ -8,7 +8,15 @@ params:
- name: usage
choice: ["battery"]
- name: host
- preset: battery-params
- preset: battery-capacity
- name: minsoc
deprecated: true
- name: maxsoc
deprecated: true
- name: maxchargepower
deprecated: true
- name: maxdischargepower
deprecated: true
- name: cache
default: 1s
render: |
@ -23,4 +31,26 @@ render: |
uri: http://{{ .host }}/properties/report
jq: .properties.electricLevel
cache: {{ .cache }}
{{- include "battery-params" . }}
minsoc:
source: http
uri: http://{{ .host }}/properties/report
jq: .properties.minSoc
scale: 0.1
cache: {{ .cache }}
maxsoc:
source: http
uri: http://{{ .host }}/properties/report
jq: .properties.socSet
scale: 0.1
cache: {{ .cache }}
maxchargepower:
source: http
uri: http://{{ .host }}/properties/report
jq: .properties.chargeMaxLimit
cache: {{ .cache }}
maxdischargepower:
source: http
uri: http://{{ .host }}/properties/report
jq: .properties.inverseMaxPower
cache: {{ .cache }}
{{- include "battery-capacity" . }}

View file

@ -824,6 +824,17 @@ presets:
- name: maxchargepower
- name: maxdischargepower
battery-capacity:
- name: capacity
battery-minmaxsoc:
- name: minsoc
- name: maxsoc
battery-power:
- name: maxchargepower
- name: maxdischargepower
modbus:
interfaces:
rs485: ["rs485serial", "rs485tcpip"]

View file

@ -1,7 +1,19 @@
{{ define "battery-params" }}
{{ define "battery-capacity" }}
capacity: {{ .capacity }} # kWh
{{- end }}
{{ define "battery-minmaxsoc" }}
minsoc: {{ .minsoc }} # %
maxsoc: {{ .maxsoc }} # %
{{- end }}
{{ define "battery-power" }}
maxchargepower: {{ .maxchargepower }} # W
maxdischargepower: {{ .maxdischargepower }} # W
{{- end }}
{{ define "battery-params" }}
{{- include "battery-capacity" . }}
{{- include "battery-minmaxsoc" . }}
{{- include "battery-power" . }}
{{- end }}