Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions api/implement/implementations.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions cmd/implement/implement.go
Original file line number Diff line number Diff line change
Expand Up @@ -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](),
Expand Down
46 changes: 41 additions & 5 deletions core/site_battery.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
9 changes: 9 additions & 0 deletions meter/meter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions meter/usage_battery.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
48 changes: 48 additions & 0 deletions meter/victron_setpoint_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
21 changes: 21 additions & 0 deletions templates/definition/meter/victron-energy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -165,5 +185,6 @@ render: |
type: writesingle
decode: uint16
scale: 10
{{- end }}
{{- include "battery-params" . }}
{{- end }}
Loading