From 410f62316a838702cc788544584f3a47257f735a Mon Sep 17 00:00:00 2001 From: Andreas Linde <42185+DerAndereAndi@users.noreply.github.com> Date: Sat, 14 Aug 2021 13:51:26 +0200 Subject: [PATCH] Allow chargers to provide vehicle SoC via ISO15118 (#1283) Some chargers (e.g. Porsche/Audi Mobile Charger Connect) can provide the EVs current SoC via the ISO15118 connection to the car. This change allows chargers to implement the SoC method and return the SoC or api.ErrNotAvailable if the currently connected car does not provide the data. --- api/api.go | 2 +- core/loadpoint.go | 14 ++++++- core/loadpoint_test.go | 2 +- core/soc/estimator.go | 44 +++++++++++++++------- core/soc/estimator_test.go | 75 ++++++++++++++++++++++++-------------- mock/mock_api.go | 40 +++++++++++++++++++- 6 files changed, 130 insertions(+), 47 deletions(-) diff --git a/api/api.go b/api/api.go index 9b8b25c0d5..a8fd08d65b 100644 --- a/api/api.go +++ b/api/api.go @@ -2,7 +2,7 @@ package api import "time" -//go:generate mockgen -package mock -destination ../mock/mock_api.go github.com/andig/evcc/api Charger,ChargeState,Identifier,Meter,MeterEnergy,Vehicle,ChargeRater +//go:generate mockgen -package mock -destination ../mock/mock_api.go github.com/andig/evcc/api Charger,ChargeState,Identifier,Meter,MeterEnergy,Vehicle,ChargeRater,Battery // ChargeMode are charge modes modeled after OpenWB type ChargeMode string diff --git a/core/loadpoint.go b/core/loadpoint.go index 84c505a65e..389dfa67bd 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -694,7 +694,7 @@ func (lp *LoadPoint) setActiveVehicle(vehicle api.Vehicle) { lp.log.INFO.Printf("vehicle updated: %s -> %s", from, to) if lp.vehicle = vehicle; vehicle != nil { - lp.socEstimator = soc.NewEstimator(lp.log, vehicle, lp.SoC.Estimate) + lp.socEstimator = soc.NewEstimator(lp.log, lp.charger, vehicle, lp.SoC.Estimate) lp.publish("hasVehicle", true) lp.publish("socTitle", lp.vehicle.Title()) @@ -975,13 +975,23 @@ func (lp *LoadPoint) socPollAllowed() bool { return lp.charging() || honourUpdateInterval && (remaining <= 0) || lp.connected() && lp.socUpdated.IsZero() } +// checks if the connected charger can provide SoC to the connected vehicle +func (lp *LoadPoint) socProvidedByCharger() bool { + if charger, ok := lp.charger.(api.Battery); ok { + if _, err := charger.SoC(); err == nil { + return true + } + } + return false +} + // publish state of charge, remaining charge duration and range func (lp *LoadPoint) publishSoCAndRange() { if lp.socEstimator == nil { return } - if lp.socPollAllowed() { + if lp.socPollAllowed() || lp.socProvidedByCharger() { lp.socUpdated = lp.clock.Now() f, err := lp.socEstimator.SoC(lp.chargedEnergy) diff --git a/core/loadpoint_test.go b/core/loadpoint_test.go index bf54acc98f..431c0ffcc8 100644 --- a/core/loadpoint_test.go +++ b/core/loadpoint_test.go @@ -370,7 +370,7 @@ func TestDisableAndEnableAtTargetSoC(t *testing.T) { // wrap vehicle with estimator vehicle.EXPECT().Capacity().Return(int64(10)) - socEstimator := soc.NewEstimator(util.NewLogger("foo"), vehicle, false) + socEstimator := soc.NewEstimator(util.NewLogger("foo"), charger, vehicle, false) lp := &LoadPoint{ log: util.NewLogger("foo"), diff --git a/core/soc/estimator.go b/core/soc/estimator.go index 7f5d477d8e..46a5cb3f91 100644 --- a/core/soc/estimator.go +++ b/core/soc/estimator.go @@ -15,6 +15,7 @@ const chargeEfficiency = 0.9 // assume charge 90% efficiency // Vehicle SoC can be estimated to provide more granularity type Estimator struct { log *util.Logger + charger api.Charger vehicle api.Vehicle estimate bool @@ -27,9 +28,10 @@ type Estimator struct { } // NewEstimator creates new estimator -func NewEstimator(log *util.Logger, vehicle api.Vehicle, estimate bool) *Estimator { +func NewEstimator(log *util.Logger, charger api.Charger, vehicle api.Vehicle, estimate bool) *Estimator { s := &Estimator{ log: log, + charger: charger, vehicle: vehicle, estimate: estimate, } @@ -91,24 +93,38 @@ func (s *Estimator) RemainingChargeEnergy(targetSoC int) float64 { // SoC replaces the api.Vehicle.SoC interface to take charged energy into account func (s *Estimator) SoC(chargedEnergy float64) (float64, error) { - f, err := s.vehicle.SoC() - if err != nil { - if errors.Is(err, api.ErrMustRetry) { - return 0, err + var fetchedSoC *float64 + + if charger, ok := s.charger.(api.Battery); ok { + f, err := charger.SoC() + + if err == nil { + s.socCharge = f + fetchedSoC = &f } + } + + if fetchedSoC == nil { + f, err := s.vehicle.SoC() + if err != nil { + if errors.Is(err, api.ErrMustRetry) { + return 0, err + } + + s.log.WARN.Printf("updating soc failed: %v", err) - s.log.WARN.Printf("updating soc failed: %v", err) + // try to recover from temporary vehicle-api errors + if s.prevSoC == 0 { // never received a soc value + return s.socCharge, err + } - // try to recover from temporary vehicle-api errors - if s.prevSoC == 0 { // never received a soc value - return s.socCharge, err + f = s.prevSoC // recover last received soc } - f = s.prevSoC // recover last received soc + fetchedSoC = &f + s.socCharge = f } - s.socCharge = f - if s.estimate { socDelta := s.socCharge - s.prevSoC energyDelta := math.Max(chargedEnergy, 0) - s.prevChargedEnergy @@ -126,8 +142,8 @@ func (s *Estimator) SoC(chargedEnergy float64) (float64, error) { s.prevChargedEnergy = math.Max(chargedEnergy, 0) s.prevSoC = s.socCharge } else { - s.socCharge = math.Min(f+energyDelta/s.energyPerSocStep, 100) - s.log.DEBUG.Printf("soc estimated: %.2f%% (vehicle: %.2f%%)", s.socCharge, f) + s.socCharge = math.Min(*fetchedSoC+energyDelta/s.energyPerSocStep, 100) + s.log.DEBUG.Printf("soc estimated: %.2f%% (vehicle: %.2f%%)", s.socCharge, *fetchedSoC) } } diff --git a/core/soc/estimator_test.go b/core/soc/estimator_test.go index c94078c106..3532918d24 100644 --- a/core/soc/estimator_test.go +++ b/core/soc/estimator_test.go @@ -4,6 +4,7 @@ import ( "testing" "time" + "github.com/andig/evcc/api" "github.com/andig/evcc/mock" "github.com/andig/evcc/util" "github.com/golang/mock/gomock" @@ -11,11 +12,12 @@ import ( func TestRemainingChargeDuration(t *testing.T) { ctrl := gomock.NewController(t) + charger := mock.NewMockCharger(ctrl) vehicle := mock.NewMockVehicle(ctrl) //9 kWh userBatCap => 10 kWh virtualBatCap vehicle.EXPECT().Capacity().Return(int64(9)) - ce := NewEstimator(util.NewLogger("foo"), vehicle, false) + ce := NewEstimator(util.NewLogger("foo"), charger, vehicle, false) ce.socCharge = 20.0 chargePower := 1000.0 @@ -27,14 +29,20 @@ func TestRemainingChargeDuration(t *testing.T) { } func TestSoCEstimation(t *testing.T) { + type chargerStruct struct { + *mock.MockCharger + *mock.MockBattery + } + ctrl := gomock.NewController(t) vehicle := mock.NewMockVehicle(ctrl) + charger := &chargerStruct{mock.NewMockCharger(ctrl), mock.NewMockBattery(ctrl)} // 9 kWh user battery capacity is converted to initial value of 10 kWh virtual capacity var capacity int64 = 9 vehicle.EXPECT().Capacity().Return(capacity) - ce := NewEstimator(util.NewLogger("foo"), vehicle, true) + ce := NewEstimator(util.NewLogger("foo"), charger, vehicle, true) ce.socCharge = 20.0 tc := []struct { @@ -70,33 +78,44 @@ func TestSoCEstimation(t *testing.T) { {1000, 0.0, 10.0, 10000}, } - for _, tc := range tc { - t.Logf("%+v", tc) - vehicle.EXPECT().SoC().Return(tc.vehicleSoC, nil) - - soc, err := ce.SoC(tc.chargedEnergy) - if err != nil { - t.Error(err) - } - - // validate soc estimate - if tc.estimatedSoC != soc { - t.Errorf("expected estimated soc: %g, got: %g", tc.estimatedSoC, soc) + for i := 1; i < 3; i++ { + useVehicleSoC := true + if i == 2 { + useVehicleSoC = false } - - // validate capacity estimate - if tc.virtualCapacity != ce.virtualCapacity { - t.Errorf("expected virtual capacity: %v, got: %v", tc.virtualCapacity, ce.virtualCapacity) - } - - // validate duration estimate - chargePower := 1e3 - targetSoC := 100 - remainingHours := (float64(targetSoC) - soc) / 100 * tc.virtualCapacity / chargePower - remainingDuration := time.Duration(float64(time.Hour) * remainingHours).Round(time.Second) - - if rm := ce.RemainingChargeDuration(chargePower, targetSoC); rm != remainingDuration { - t.Errorf("expected estimated duration: %v, got: %v", remainingDuration, rm) + for _, tc := range tc { + t.Logf("%+v", tc) + if useVehicleSoC { + charger.MockBattery.EXPECT().SoC().Return(tc.vehicleSoC, nil) + } else { + charger.MockBattery.EXPECT().SoC().Return(0.0, api.ErrNotAvailable) + vehicle.EXPECT().SoC().Return(tc.vehicleSoC, nil) + } + + soc, err := ce.SoC(tc.chargedEnergy) + if err != nil { + t.Error(err) + } + + // validate soc estimate + if tc.estimatedSoC != soc { + t.Errorf("expected estimated soc: %g, got: %g", tc.estimatedSoC, soc) + } + + // validate capacity estimate + if tc.virtualCapacity != ce.virtualCapacity { + t.Errorf("expected virtual capacity: %v, got: %v", tc.virtualCapacity, ce.virtualCapacity) + } + + // validate duration estimate + chargePower := 1e3 + targetSoC := 100 + remainingHours := (float64(targetSoC) - soc) / 100 * tc.virtualCapacity / chargePower + remainingDuration := time.Duration(float64(time.Hour) * remainingHours).Round(time.Second) + + if rm := ce.RemainingChargeDuration(chargePower, targetSoC); rm != remainingDuration { + t.Errorf("expected estimated duration: %v, got: %v", remainingDuration, rm) + } } } } diff --git a/mock/mock_api.go b/mock/mock_api.go index 39b9cffe9b..1dde0a78be 100644 --- a/mock/mock_api.go +++ b/mock/mock_api.go @@ -1,5 +1,5 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: github.com/andig/evcc/api (interfaces: Charger,ChargeState,Identifier,Meter,MeterEnergy,Vehicle,ChargeRater) +// Source: github.com/andig/evcc/api (interfaces: Charger,ChargeState,Identifier,Meter,MeterEnergy,Vehicle,ChargeRater,Battery) // Package mock is a generated GoMock package. package mock @@ -362,3 +362,41 @@ func (mr *MockChargeRaterMockRecorder) ChargedEnergy() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChargedEnergy", reflect.TypeOf((*MockChargeRater)(nil).ChargedEnergy)) } + +// MockBattery is a mock of Battery interface. +type MockBattery struct { + ctrl *gomock.Controller + recorder *MockBatteryMockRecorder +} + +// MockBatteryMockRecorder is the mock recorder for MockBattery. +type MockBatteryMockRecorder struct { + mock *MockBattery +} + +// NewMockBattery creates a new mock instance. +func NewMockBattery(ctrl *gomock.Controller) *MockBattery { + mock := &MockBattery{ctrl: ctrl} + mock.recorder = &MockBatteryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockBattery) EXPECT() *MockBatteryMockRecorder { + return m.recorder +} + +// SoC mocks base method. +func (m *MockBattery) SoC() (float64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SoC") + ret0, _ := ret[0].(float64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SoC indicates an expected call of SoC. +func (mr *MockBatteryMockRecorder) SoC() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SoC", reflect.TypeOf((*MockBattery)(nil).SoC)) +}