Repository: evcc-io/evcc
Pull request #10698 (2023-11-08 17:17:53)

Fix using planner with Soc provided by charger
When the vehicles SoC is provided by the charger, the planner currently assumes that the EV is not charged at all. This can be reproduced by setting a target Soc to be identical to the current, and the planner will show a charge time that would be needed to reach the current Soc.

The reason is `s.vehicleSoc` in `estimator.go` not being set as `publichSocAndRange()` exits before setting it.


Patch:
diff --git a/api/api.go b/api/api.go
index 4d502922..2ef44502 100644
--- a/api/api.go
+++ b/api/api.go
@@ -9,7 +9,7 @@ import (
 	"time"
 )
 
-//go:generate mockgen -package api -destination mock.go github.com/evcc-io/evcc/api Charger,ChargeState,PhaseSwitcher,Identifier,Meter,MeterEnergy,Vehicle,ChargeRater,Battery,Tariff
+//go:generate mockgen -package api -destination mock.go github.com/evcc-io/evcc/api Charger,ChargeState,PhaseSwitcher,Identifier,Meter,MeterEnergy,Vehicle,ChargeRater,Battery,Tariff,BatteryController
 
 // ChargeMode is the charge operation mode. Valid values are off, now, minpv and pv
 type ChargeMode string
@@ -134,6 +134,12 @@ type CurrentGetter interface {
 	GetMaxCurrent() (float64, error)
 }
 
+// BatteryController optionally allows to control home battery (dis)charging behaviour
+type BatteryController interface {
+	SetBatteryMode(BatteryMode) error
+	GetBatteryMode() BatteryMode
+}
+
 // Charger provides current charging status and enable/disable charging
 type Charger interface {
 	ChargeState
diff --git a/api/batterymode.go b/api/batterymode.go
new file mode 100644
index 00000000..511fa5f0
--- /dev/null
+++ b/api/batterymode.go
@@ -0,0 +1,12 @@
+package api
+
+// BatteryMode is the home battery operation mode. Valid values are normal, locked and charge
+type BatteryMode int
+
+//go:generate enumer -type BatteryMode
+const (
+	BatteryUnknown BatteryMode = iota
+	BatteryNormal
+	BatteryLocked
+	BatteryCharge
+)
diff --git a/api/batterymode_enumer.go b/api/batterymode_enumer.go
new file mode 100644
index 00000000..d78db37a
--- /dev/null
+++ b/api/batterymode_enumer.go
@@ -0,0 +1,86 @@
+// Code generated by "enumer -type BatteryMode"; DO NOT EDIT.
+
+package api
+
+import (
+	"fmt"
+	"strings"
+)
+
+const _BatteryModeName = "BatteryUnknownBatteryNormalBatteryLockedBatteryCharge"
+
+var _BatteryModeIndex = [...]uint8{0, 14, 27, 40, 53}
+
+const _BatteryModeLowerName = "batteryunknownbatterynormalbatterylockedbatterycharge"
+
+func (i BatteryMode) String() string {
+	if i < 0 || i >= BatteryMode(len(_BatteryModeIndex)-1) {
+		return fmt.Sprintf("BatteryMode(%d)", i)
+	}
+	return _BatteryModeName[_BatteryModeIndex[i]:_BatteryModeIndex[i+1]]
+}
+
+// An "invalid array index" compiler error signifies that the constant values have changed.
+// Re-run the stringer command to generate them again.
+func _BatteryModeNoOp() {
+	var x [1]struct{}
+	_ = x[BatteryUnknown-(0)]
+	_ = x[BatteryNormal-(1)]
+	_ = x[BatteryLocked-(2)]
+	_ = x[BatteryCharge-(3)]
+}
+
+var _BatteryModeValues = []BatteryMode{BatteryUnknown, BatteryNormal, BatteryLocked, BatteryCharge}
+
+var _BatteryModeNameToValueMap = map[string]BatteryMode{
+	_BatteryModeName[0:14]:       BatteryUnknown,
+	_BatteryModeLowerName[0:14]:  BatteryUnknown,
+	_BatteryModeName[14:27]:      BatteryNormal,
+	_BatteryModeLowerName[14:27]: BatteryNormal,
+	_BatteryModeName[27:40]:      BatteryLocked,
+	_BatteryModeLowerName[27:40]: BatteryLocked,
+	_BatteryModeName[40:53]:      BatteryCharge,
+	_BatteryModeLowerName[40:53]: BatteryCharge,
+}
+
+var _BatteryModeNames = []string{
+	_BatteryModeName[0:14],
+	_BatteryModeName[14:27],
+	_BatteryModeName[27:40],
+	_BatteryModeName[40:53],
+}
+
+// BatteryModeString retrieves an enum value from the enum constants string name.
+// Throws an error if the param is not part of the enum.
+func BatteryModeString(s string) (BatteryMode, error) {
+	if val, ok := _BatteryModeNameToValueMap[s]; ok {
+		return val, nil
+	}
+
+	if val, ok := _BatteryModeNameToValueMap[strings.ToLower(s)]; ok {
+		return val, nil
+	}
+	return 0, fmt.Errorf("%s does not belong to BatteryMode values", s)
+}
+
+// BatteryModeValues returns all values of the enum
+func BatteryModeValues() []BatteryMode {
+	return _BatteryModeValues
+}
+
+// BatteryModeStrings returns a slice of all String values of the enum
+func BatteryModeStrings() []string {
+	strs := make([]string, len(_BatteryModeNames))
+	copy(strs, _BatteryModeNames)
+	return strs
+}
+
+// IsABatteryMode returns "true" if the value is listed in the enum definition. "false" otherwise
+func (i BatteryMode) IsABatteryMode() bool {
+	for _, v := range _BatteryModeValues {
+		if i == v {
+			return true
+		}
+	}
+	return false
+}
diff --git a/api/mock.go b/api/mock.go
index c46974f9..78bdeed3 100644
--- a/api/mock.go
+++ b/api/mock.go
@@ -1,5 +1,5 @@
 // Code generated by MockGen. DO NOT EDIT.
-// Source: github.com/evcc-io/evcc/api (interfaces: Charger,ChargeState,PhaseSwitcher,Identifier,Meter,MeterEnergy,Vehicle,ChargeRater,Battery,Tariff)
+// Source: github.com/evcc-io/evcc/api (interfaces: Charger,ChargeState,PhaseSwitcher,Identifier,Meter,MeterEnergy,Vehicle,ChargeRater,Battery,Tariff,BatteryController)
 
 // Package api is a generated GoMock package.
 package api
@@ -541,3 +541,54 @@ func (mr *MockTariffMockRecorder) Type() *gomock.Call {
 	mr.mock.ctrl.T.Helper()
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Type", reflect.TypeOf((*MockTariff)(nil).Type))
 }
+
+// MockBatteryController is a mock of BatteryController interface.
+type MockBatteryController struct {
+	ctrl     *gomock.Controller
+	recorder *MockBatteryControllerMockRecorder
+}
+
+// MockBatteryControllerMockRecorder is the mock recorder for MockBatteryController.
+type MockBatteryControllerMockRecorder struct {
+	mock *MockBatteryController
+}
+
+// NewMockBatteryController creates a new mock instance.
+func NewMockBatteryController(ctrl *gomock.Controller) *MockBatteryController {
+	mock := &MockBatteryController{ctrl: ctrl}
+	mock.recorder = &MockBatteryControllerMockRecorder{mock}
+	return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockBatteryController) EXPECT() *MockBatteryControllerMockRecorder {
+	return m.recorder
+}
+
+// GetBatteryMode mocks base method.
+func (m *MockBatteryController) GetBatteryMode() BatteryMode {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetBatteryMode")
+	ret0, _ := ret[0].(BatteryMode)
+	return ret0
+}
+
+// GetBatteryMode indicates an expected call of GetBatteryMode.
+func (mr *MockBatteryControllerMockRecorder) GetBatteryMode() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBatteryMode", reflect.TypeOf((*MockBatteryController)(nil).GetBatteryMode))
+}
+
+// SetBatteryMode mocks base method.
+func (m *MockBatteryController) SetBatteryMode(arg0 BatteryMode) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "SetBatteryMode", arg0)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// SetBatteryMode indicates an expected call of SetBatteryMode.
+func (mr *MockBatteryControllerMockRecorder) SetBatteryMode(arg0 interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetBatteryMode", reflect.TypeOf((*MockBatteryController)(nil).SetBatteryMode), arg0)
+}
diff --git a/core/loadpoint.go b/core/loadpoint.go
index c7079a7c..8651113d 100644
--- a/core/loadpoint.go
+++ b/core/loadpoint.go
@@ -1384,7 +1384,7 @@ func (lp *Loadpoint) publishSocAndRange() {
 	soc, err := lp.chargerSoc()
 
 	// guard for socEstimator removed by api
-	if lp.socEstimator == nil || !lp.vehicleHasSoc() {
+	if lp.socEstimator == nil || (!lp.vehicleHasSoc() && err != nil) {
 		// This is a workaround for heaters. Without vehicle, the soc estimator is not initialized.
 		// We need to check if the charger can provide soc and use it if available.
 		if err == nil {
diff --git a/core/loadpoint/api.go b/core/loadpoint/api.go
index 226f505b..f7c698c8 100644
--- a/core/loadpoint/api.go
+++ b/core/loadpoint/api.go
@@ -100,6 +100,8 @@ type API interface {
 	// charge progress
 	//
 
+	// GetPlanActive returns the active state of the planner
+	GetPlanActive() bool
 	// GetRemainingDuration is the estimated remaining charging duration
 	GetRemainingDuration() time.Duration
 	// GetRemainingEnergy is the remaining charge energy in Wh
diff --git a/core/loadpoint/mock.go b/core/loadpoint/mock.go
index a2c2addb..64a1dd8c 100644
--- a/core/loadpoint/mock.go
+++ b/core/loadpoint/mock.go
@@ -205,6 +205,20 @@ func (mr *MockAPIMockRecorder) GetPlan(arg0, arg1 interface{}) *gomock.Call {
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlan", reflect.TypeOf((*MockAPI)(nil).GetPlan), arg0, arg1)
 }
 
+// GetPlanActive mocks base method.
+func (m *MockAPI) GetPlanActive() bool {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetPlanActive")
+	ret0, _ := ret[0].(bool)
+	return ret0
+}
+
+// GetPlanActive indicates an expected call of GetPlanActive.
+func (mr *MockAPIMockRecorder) GetPlanActive() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlanActive", reflect.TypeOf((*MockAPI)(nil).GetPlanActive))
+}
+
 // GetPriority mocks base method.
 func (m *MockAPI) GetPriority() int {
 	m.ctrl.T.Helper()
diff --git a/core/loadpoint_api.go b/core/loadpoint_api.go
index a5faaa13..2c633d80 100644
--- a/core/loadpoint_api.go
+++ b/core/loadpoint_api.go
@@ -377,6 +377,13 @@ func (lp *Loadpoint) GetMaxPower() float64 {
 	return Voltage * lp.GetMaxCurrent() * float64(lp.maxActivePhases())
 }
 
+// GetPlanActive returns the active state of the planner
+func (lp *Loadpoint) GetPlanActive() bool {
+	lp.Lock()
+	defer lp.Unlock()
+	return lp.planActive
+}
+
 // SetRemainingDuration sets the estimated remaining charging duration
 func (lp *Loadpoint) SetRemainingDuration(chargeRemainingDuration time.Duration) {
 	lp.Lock()
diff --git a/core/site.go b/core/site.go
index 78607574..f69a0dcc 100644
--- a/core/site.go
+++ b/core/site.go
@@ -67,6 +67,7 @@ type Site struct {
 	BufferStartSoc                    float64      `mapstructure:"bufferStartSoc"`                    // start charging on battery above this Soc
 	MaxGridSupplyWhileBatteryCharging float64      `mapstructure:"maxGridSupplyWhileBatteryCharging"` // ignore battery charging if AC consumption is above this value
 	SmartCostLimit                    float64      `mapstructure:"smartCostLimit"`                    // always charge if cost is below this value
+	BatteryDischargeControl           bool         `mapstructure:"batteryDischargeControl"`           // shall discharge of home battery be adjusted
 
 	// meters
 	gridMeter     api.Meter   // Grid usage meter
@@ -81,10 +82,11 @@ type Site struct {
 	stats       *Stats                   // Stats
 
 	// cached state
-	gridPower    float64 // Grid power
-	pvPower      float64 // PV power
-	batteryPower float64 // Battery charge power
-	batterySoc   float64 // Battery soc
+	gridPower    float64         // Grid power
+	pvPower      float64         // PV power
+	batteryPower float64         // Battery charge power
+	batterySoc   float64         // Battery soc
+	batteryMode  api.BatteryMode // Battery discharge currently enabled
 
 	publishCache map[string]any // store last published values to avoid unnecessary republishing
 }
@@ -224,6 +226,7 @@ func NewSite() *Site {
 		log:          util.NewLogger("site"),
 		publishCache: make(map[string]any),
 		Voltage:      230, // V
+		batteryMode:  api.BatteryNormal,
 	}
 
 	return lp
@@ -251,6 +254,9 @@ func (site *Site) restoreSettings() {
 	if v, err := settings.Float("site.smartCostLimit"); err == nil {
 		site.SmartCostLimit = v
 	}
+	if v, err := settings.Bool("site.batteryDischargeControl"); err == nil {
+		site.BatteryDischargeControl = v
+	}
 }
 
 func meterCapabilities(name string, meter interface{}) string {
@@ -769,7 +775,6 @@ func (site *Site) update(lp Updater) {
 	}
 
 	if sitePower, batteryBuffered, batteryStart, err := site.sitePower(totalChargePower, flexiblePower); err == nil {
-
 		// ignore negative pvPower values as that means it is not an energy source but consumption
 		homePower := site.gridPower + max(0, site.pvPower) + site.batteryPower - totalChargePower
 		homePower = max(homePower, 0)
@@ -791,6 +796,10 @@ func (site *Site) update(lp Updater) {
 		site.log.ERROR.Println(err)
 	}
 
+	if site.BatteryDischargeControl {
+		site.updateBatteryMode(site.Loadpoints())
+	}
+
 	site.stats.Update(site)
 }
 
@@ -814,6 +823,8 @@ func (site *Site) prepare() {
 	site.publish("currency", site.tariffs.Currency.String())
 
 	site.publish("vehicles", vehicleTitles(site.GetVehicles()))
+	site.publish("batteryDischargeControl", site.BatteryDischargeControl)
+	site.publish("batteryMode", site.batteryMode)
 }
 
 // Prepare attaches communication channels to site and loadpoints
diff --git a/core/site_battery.go b/core/site_battery.go
new file mode 100644
index 00000000..ca46ccfe
--- /dev/null
+++ b/core/site_battery.go
@@ -0,0 +1,49 @@
+package core
+
+import (
+	"github.com/evcc-io/evcc/api"
+	"github.com/evcc-io/evcc/core/loadpoint"
+)
+
+// getBatteryMode returns the battery mode
+func (site *Site) getBatteryMode() api.BatteryMode {
+	site.Lock()
+	defer site.Unlock()
+	return site.batteryMode
+}
+
+// setBatteryMode sets the battery mode
+func (site *Site) setBatteryMode(batMode api.BatteryMode) {
+	site.Lock()
+	defer site.Unlock()
+	site.batteryMode = batMode
+}
+
+func (site *Site) updateBatteryMode(loadpoints []loadpoint.API) {
+	// determine expected state
+	batMode := api.BatteryNormal
+	for _, lp := range loadpoints {
+		if lp.GetStatus() == api.StatusC && (lp.GetMode() == api.ModeNow || lp.GetPlanActive()) {
+			batMode = api.BatteryLocked
+			break
+		}
+	}
+
+	if batMode == site.getBatteryMode() {
+		return
+	}
+
+	// update batteries
+	for _, meter := range site.batteryMeters {
+		if batCtrl, ok := meter.(api.BatteryController); ok {
+			if err := batCtrl.SetBatteryMode(batMode); err != nil {
+				site.log.ERROR.Println("battery mode:", err)
+				return
+			}
+		}
+	}
+
+	// update state and publish
+	site.setBatteryMode(batMode)
+	site.publish("batteryMode", batMode)
+}


Test patch:
diff --git a/core/site_battery_test.go b/core/site_battery_test.go
new file mode 100644
index 00000000..1c356fe6
--- /dev/null
+++ b/core/site_battery_test.go
@@ -0,0 +1,96 @@
+package core
+
+import (
+	"testing"
+
+	"github.com/evcc-io/evcc/api"
+	"github.com/evcc-io/evcc/core/loadpoint"
+	"github.com/evcc-io/evcc/util"
+	"github.com/golang/mock/gomock"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestBatteryDischarge(t *testing.T) {
+	ctrl := gomock.NewController(t)
+
+	tcs := []struct {
+		chargeStatus api.ChargeStatus
+		planActive   bool
+		expBatMode   api.BatteryMode
+		mode         api.ChargeMode
+	}{
+		{api.StatusB, false, api.BatteryNormal, api.ModeOff},   // mode off -> bat enabled
+		{api.StatusB, false, api.BatteryNormal, api.ModeNow},   // mode now, not charging -> bat enabled
+		{api.StatusC, false, api.BatteryLocked, api.ModeNow},   // mode now, charging -> bat disabled
+		{api.StatusB, false, api.BatteryNormal, api.ModeMinPV}, // mode minPV, not charging -> bat enabled
+		{api.StatusC, false, api.BatteryNormal, api.ModeMinPV}, // mode minPV, charging -> bat enabled
+		{api.StatusB, false, api.BatteryNormal, api.ModePV},    // mode PV, not charging -> bat enabled
+		{api.StatusC, false, api.BatteryNormal, api.ModePV},    // mode PV, charging, no planner -> bat enabled
+		{api.StatusC, true, api.BatteryLocked, api.ModePV},     // mode PV, charging, planner active -> bat disabled
+	}
+
+	log := util.NewLogger("foo")
+
+	for _, tc := range tcs {
+		batCtrl := struct {
+			*api.MockBatteryController
+			*api.MockMeter
+		}{
+			api.NewMockBatteryController(ctrl),
+			api.NewMockMeter(ctrl),
+		}
+		batCtrl.MockBatteryController.EXPECT().SetBatteryMode(tc.expBatMode).Times(1)
+
+		s := &Site{
+			log:                     log,
+			BatteryDischargeControl: true,
+			batteryMeters:           []api.Meter{batCtrl},
+		}
+
+		lp := loadpoint.NewMockAPI(ctrl)
+		lp.EXPECT().GetStatus().Return(tc.chargeStatus).AnyTimes()
+		lp.EXPECT().GetMode().Return(tc.mode).AnyTimes()
+		lp.EXPECT().GetPlanActive().Return(tc.planActive).AnyTimes()
+
+		loadpoints := []loadpoint.API{lp}
+		s.updateBatteryMode(loadpoints)
+		assert.Equal(t, tc.expBatMode, s.getBatteryMode(), tc)
+	}
+}
+
+// test that BatteryControllers are only called if batterymode changes
+func TestBatteryModeNoUpdate(t *testing.T) {
+	ctrl := gomock.NewController(t)
+
+	batCtrl := struct {
+		*api.MockBatteryController
+		*api.MockMeter
+	}{
+		api.NewMockBatteryController(ctrl),
+		api.NewMockMeter(ctrl),
+	}
+	batCtrl.MockBatteryController.EXPECT().SetBatteryMode(api.BatteryLocked).Times(1)
+
+	lp := loadpoint.NewMockAPI(ctrl)
+	lp.EXPECT().GetStatus().Return(api.StatusC).Times(2)
+	lp.EXPECT().GetMode().Return(api.ModeNow).Times(2)
+	lp.EXPECT().GetPlanActive().Times(0)
+	loadpoints := []loadpoint.API{lp}
+
+	s := &Site{
+		batteryMode:             api.BatteryNormal,
+		batteryMeters:           []api.Meter{batCtrl},
+		BatteryDischargeControl: true,
+		log:                     util.NewLogger("foo"),
+	}
+
+	s.updateBatteryMode(loadpoints) // first call should call BatteryController
+	s.updateBatteryMode(loadpoints) // this one should not
+
+	// adjust mocks to simulate charge stop, should cause batMode udpate
+	lp.EXPECT().GetStatus().Return(api.StatusB).Times(1)
+	lp.EXPECT().GetMode().Return(api.ModeNow).Times(0)
+	batCtrl.MockBatteryController.EXPECT().SetBatteryMode(api.BatteryNormal).Times(1)
+
+	s.updateBatteryMode(loadpoints) // this one should have updated again
+}