diff --git a/api/api.go b/api/api.go index 7bb11c8b2bb..4c26158f717 100644 --- a/api/api.go +++ b/api/api.go @@ -90,6 +90,13 @@ type BatteryController interface { SetBatteryMode(BatteryMode) error } +// BatteryHoldPower optionally holds the battery by importing active load (e.g. EV charging) +// from the grid instead of an SOC/mode hold. It is called every control cycle while hold is +// active; a power of 0 clears the hold. +type BatteryHoldPower interface { + SetBatteryHoldPower(power float64) error +} + // Charger provides current charging status and enable/disable charging type Charger interface { ChargeState diff --git a/api/implement/implementations.go b/api/implement/implementations.go index c7c4776c212..171af866bdd 100644 --- a/api/implement/implementations.go +++ b/api/implement/implementations.go @@ -53,6 +53,21 @@ func (i *iBatteryController) SetBatteryMode(p0 api.BatteryMode) error { return i.batteryController0(p0) } +func BatteryHoldPower(batteryHoldPower0 func(float64) error) api.BatteryHoldPower { + if batteryHoldPower0 == nil { + return nil + } + return &iBatteryHoldPower{batteryHoldPower0} +} + +type iBatteryHoldPower struct { + batteryHoldPower0 func(float64) error +} + +func (i *iBatteryHoldPower) SetBatteryHoldPower(p0 float64) error { + return i.batteryHoldPower0(p0) +} + func BatteryPowerLimiter(batteryPowerLimiter0 func() (float64, float64)) api.BatteryPowerLimiter { if batteryPowerLimiter0 == nil { return nil diff --git a/cmd/implement/implement.go b/cmd/implement/implement.go index ec034e1f4e1..803ead56bde 100644 --- a/cmd/implement/implement.go +++ b/cmd/implement/implement.go @@ -61,6 +61,7 @@ func generate(out io.Writer) error { reflect.TypeFor[api.Battery](), reflect.TypeFor[api.BatteryCapacity](), reflect.TypeFor[api.BatteryController](), + reflect.TypeFor[api.BatteryHoldPower](), reflect.TypeFor[api.BatteryPowerLimiter](), reflect.TypeFor[api.BatterySocLimiter](), reflect.TypeFor[api.ChargeController](), diff --git a/core/site_battery.go b/core/site_battery.go index b379b9ccec6..d92e53d218b 100644 --- a/core/site_battery.go +++ b/core/site_battery.go @@ -22,7 +22,7 @@ func (site *Site) hasBatteryControl() bool { for _, dev := range site.batteryMeters { meter := dev.Instance() - if api.HasCap[api.BatteryController](meter) { + if api.HasCap[api.BatteryController](meter) || api.HasCap[api.BatteryHoldPower](meter) { return true } } @@ -72,6 +72,31 @@ func (site *Site) updateBatteryMode(batteryGridChargeActive bool, rate api.Rate) site.log.ERROR.Println("battery mode:", err) } } + + // power-based hold for batteries that support it (e.g. Victron grid setpoint override). + // Unlike the mode hold above, this is (re-)applied every cycle to track the charge power. + active, holdPower := site.dischargeControlPower(rate) + site.applyBatteryHoldPower(active, holdPower) +} + +// applyBatteryHoldPower pushes the active discharge-control power to batteries that support an +// explicit power-based hold (api.BatteryHoldPower). When inactive, 0 is sent to clear the hold. +func (site *Site) applyBatteryHoldPower(active bool, power float64) { + for _, dev := range site.batteryMeters { + ctrl, ok := api.Cap[api.BatteryHoldPower](dev.Instance()) + if !ok { + continue + } + + var p float64 + if active { + p = power + } + + if err := ctrl.SetBatteryHoldPower(p); err != nil && !errors.Is(err, api.ErrNotAvailable) { + site.log.ERROR.Println("battery hold power:", err) + } + } } // requiredBatteryMode determines required battery mode based on grid charge and rate @@ -202,17 +227,28 @@ func (site *Site) batteryGridChargeActive(rate api.Rate) bool { return limit != nil && !rate.IsZero() && rate.Value <= *limit } -func (site *Site) dischargeControlActive(rate api.Rate) bool { +// dischargeControlPower reports whether battery discharge control is active and the total +// charge power of the loadpoints driving it (the power to be covered from grid). +func (site *Site) dischargeControlPower(rate api.Rate) (bool, float64) { if !site.GetBatteryDischargeControl() { - return false + return false, 0 } + var active bool + var power float64 + for _, lp := range site.Loadpoints() { smartCostActive := site.smartCostActive(lp, rate) if lp.GetStatus() == api.StatusC && (smartCostActive || lp.IsFastChargingActive()) { - return true + active = true + power += lp.GetChargePower() } } - return false + return active, power +} + +func (site *Site) dischargeControlActive(rate api.Rate) bool { + active, _ := site.dischargeControlPower(rate) + return active } diff --git a/meter/meter.go b/meter/meter.go index 8cfc61381cb..d0791cf6ad3 100644 --- a/meter/meter.go +++ b/meter/meter.go @@ -33,6 +33,7 @@ func NewConfigurableFromConfig(ctx context.Context, other map[string]any) (api.M Soc *plugin.Config // optional LimitSoc *plugin.Config // optional BatteryMode *plugin.Config // optional + Setpoint *plugin.Config // optional }{ batterySocLimits: batterySocLimits{ MinSoc: 20, @@ -91,6 +92,14 @@ func NewConfigurableFromConfig(ctx context.Context, other map[string]any) (api.M implement.Has(m, implement.BatteryController(func(mode api.BatteryMode) error { return modeS(int64(mode)) })) + + case cc.Setpoint != nil: + setS, err := cc.Setpoint.FloatSetter(ctx, "setpoint") + if err != nil { + return nil, fmt.Errorf("battery setpoint: %w", err) + } + + implement.Has(m, implement.BatteryHoldPower((&batterySetpoint{}).SetpointController(setS))) } return m, nil diff --git a/meter/usage_battery.go b/meter/usage_battery.go index aaadc387240..eddd99f50d9 100644 --- a/meter/usage_battery.go +++ b/meter/usage_battery.go @@ -74,3 +74,25 @@ func (m *batterySocLimits) LimitController(socG func() (float64, error), limitSo } } } + +type batterySetpoint struct { + holding bool +} + +// SetpointController returns an api.BatteryHoldPower implementation that holds the battery by +// writing the active load to a grid setpoint override (setS, positive = grid import) so the +// load is supplied from grid and the battery is left to its normal behavior. It is called +// every control cycle while hold is active; a power of 0 clears the override. +func (m *batterySetpoint) SetpointController(setS func(float64) error) func(float64) error { + return func(power float64) error { + if power > 0 { + m.holding = true + return setS(power) + } + if m.holding { + m.holding = false + return setS(0) + } + return nil + } +} diff --git a/meter/victron_setpoint_test.go b/meter/victron_setpoint_test.go new file mode 100644 index 00000000000..98e682903ca --- /dev/null +++ b/meter/victron_setpoint_test.go @@ -0,0 +1,48 @@ +package meter + +import ( + "testing" + + "github.com/evcc-io/evcc/api" +) + +// TestVictronGridSetpoint verifies the opt-in batterycontrol: gridsetpoint variant renders, +// parses, and wires the BatteryHoldPower capability instead of the limit-SOC BatteryController. +func TestVictronGridSetpoint(t *testing.T) { + values := map[string]any{ + "template": "victron-energy", + "usage": "battery", + "host": "localhost", + "batterycontrol": "gridsetpoint", + "capacity": 32, + "minsoc": 20, + "maxsoc": 100, + "maxchargepower": 6500, + "maxdischargepower": 10000, + } + + m, err := NewFromConfig(t.Context(), "template", values) + if err != nil { + t.Fatal(err) + } + + if !api.HasCap[api.BatteryHoldPower](m) { + t.Error("expected BatteryHoldPower capability for gridsetpoint variant") + } + if api.HasCap[api.BatteryController](m) { + t.Error("did not expect BatteryController (limit-SOC) for gridsetpoint variant") + } + + // default limitsoc variant must still wire the limit-SOC BatteryController + values["batterycontrol"] = "limitsoc" + m, err = NewFromConfig(t.Context(), "template", values) + if err != nil { + t.Fatal(err) + } + if !api.HasCap[api.BatteryController](m) { + t.Error("expected BatteryController for default limitsoc variant") + } + if api.HasCap[api.BatteryHoldPower](m) { + t.Error("did not expect BatteryHoldPower for limitsoc variant") + } +} diff --git a/templates/definition/meter/victron-energy.yaml b/templates/definition/meter/victron-energy.yaml index 4234c1be0a5..0856b2db956 100644 --- a/templates/definition/meter/victron-energy.yaml +++ b/templates/definition/meter/victron-energy.yaml @@ -24,6 +24,16 @@ params: help: de: "Kann im VRM Portal oder im RemoteUI ausgelesen werden." en: "Can be read out in VRM portal or via remoteUI." + - name: batterycontrol + description: + en: Battery hold method + de: Batterie-Haltemethode + choice: ["limitsoc", "gridsetpoint"] + default: limitsoc + usages: ["battery"] + help: + en: "limitsoc holds the battery via min-SOC. gridsetpoint holds it via the ESS grid setpoint override (hub4 /Overrides/Setpoint) by importing the charge power from grid - use this with Dynamic ESS, where setting min-SOC interferes." + de: "limitsoc hält die Batterie über Min-SOC. gridsetpoint hält sie über den ESS-Grid-Setpoint-Override (hub4 /Overrides/Setpoint), indem die Ladeleistung aus dem Netz bezogen wird - empfohlen mit Dynamic ESS, wo das Setzen von Min-SOC stört." - preset: battery-params render: | type: custom @@ -153,6 +163,16 @@ render: | address: 843 # Soc type: input decode: uint16 + {{- if eq .batterycontrol "gridsetpoint" }} + setpoint: + source: modbus + uri: {{ joinHostPort .host .port }} + id: 100 # com.victronenergy.hub4 /Overrides/Setpoint + register: + address: 2716 # grid setpoint override (W, positive = grid import); 0 clears + type: writemultiple + encoding: int32 + {{- else }} limitsoc: source: convert convert: float2int @@ -165,5 +185,6 @@ render: | type: writesingle decode: uint16 scale: 10 + {{- end }} {{- include "battery-params" . }} {{- end }}