diff --git a/core/loadpoint.go b/core/loadpoint.go index 988af0906..1afee984e 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -58,6 +58,7 @@ type LoadPoint struct { SoC struct { AlwaysUpdate bool `mapstructure:"alwaysUpdate"` Levels []int `mapstructure:"levels"` + Estimate bool `mapstructure:"estimate"` } OnDisconnect struct { Mode api.ChargeMode `mapstructure:"mode"` // Charge mode to apply when car disconnected @@ -71,8 +72,9 @@ type LoadPoint struct { chargeTimer api.ChargeTimer chargeRater api.ChargeRater - chargeMeter api.Meter // Charger usage meter - vehicle api.Vehicle // Vehicle + chargeMeter api.Meter // Charger usage meter + vehicle api.Vehicle // Vehicle + socEstimator *wrapper.SocEstimator // cached state status api.ChargeStatus // Charger status @@ -82,7 +84,7 @@ type LoadPoint struct { pvTimer time.Time // PV enabled/disable timer socCharge float64 // Vehicle SoC - chargedEnergy float64 // Charged energy while connected + chargedEnergy float64 // Charged energy while connected in Wh chargeDuration time.Duration // Charge duration } @@ -111,6 +113,7 @@ func NewLoadPointFromConfig(log *util.Logger, cp configProvider, other map[strin } if lp.VehicleRef != "" { lp.vehicle = cp.Vehicle(lp.VehicleRef) + lp.socEstimator = wrapper.NewSocEstimator(log, lp.vehicle, lp.SoC.Estimate) } if lp.ChargerRef == "" { @@ -281,6 +284,9 @@ func (lp *LoadPoint) evVehicleConnectHandler() { lp.connectedTime = lp.clock.Now() lp.publish("connectedDuration", 0) + // soc estimation reset on car change + lp.socEstimator.Reset() + lp.notify(evVehicleConnect) } @@ -570,43 +576,28 @@ func (lp *LoadPoint) publishChargeProgress() { lp.publish("chargeDuration", lp.chargeDuration) } -// remainingChargeDuration returns the remaining charge time -func (lp *LoadPoint) remainingChargeDuration(chargePercent float64) time.Duration { - if !lp.charging { - return -1 - } - - if lp.chargePower > 0 && lp.vehicle != nil { - chargePercent = chargePercent / 100.0 - targetPercent := float64(lp.TargetSoC) / 100 - - if chargePercent >= targetPercent { - return 0 - } - - whTotal := float64(lp.vehicle.Capacity()) * 1e3 - whRemaining := (targetPercent - chargePercent) * whTotal - return time.Duration(float64(time.Hour) * whRemaining / lp.chargePower).Round(time.Second) - } - - return -1 -} - // publish state of charge and remaining charge duration func (lp *LoadPoint) publishSoC() { - if lp.vehicle == nil { + if lp.socEstimator == nil { return } if lp.SoC.AlwaysUpdate || lp.connected() { - f, err := lp.vehicle.ChargeState() + f, err := lp.socEstimator.SoC(lp.chargedEnergy) if err == nil { lp.socCharge = f lp.log.DEBUG.Printf("vehicle soc: %.0f%%", lp.socCharge) lp.publish("socCharge", lp.socCharge) - lp.publish("chargeEstimate", lp.remainingChargeDuration(f)) + + chargeEstimate := time.Duration(-1) + if lp.charging { + chargeEstimate = lp.socEstimator.RemainingChargeDuration(lp.chargePower, lp.TargetSoC) + } + lp.publish("chargeEstimate", chargeEstimate) + return } + lp.log.ERROR.Printf("vehicle error: %v", err) } diff --git a/core/loadpoint_test.go b/core/loadpoint_test.go index 371a3a009..72cd1890f 100644 --- a/core/loadpoint_test.go +++ b/core/loadpoint_test.go @@ -5,6 +5,7 @@ import ( "time" "github.com/andig/evcc/api" + "github.com/andig/evcc/core/wrapper" "github.com/andig/evcc/mock" "github.com/andig/evcc/push" "github.com/andig/evcc/util" @@ -366,32 +367,16 @@ func TestPVHysteresisForStatusOtherThanC(t *testing.T) { ctrl.Finish() } -func TestRemainingChargeDuration(t *testing.T) { - lp := NewLoadPoint(util.NewLogger("foo")) - - ctrl := gomock.NewController(t) - vehicle := mock.NewMockVehicle(ctrl) - - lp.vehicle = vehicle - lp.charging = true - - soc := 20.0 - lp.TargetSoC = 80 - lp.chargePower = 1000 - - vehicle.EXPECT().Capacity().Return(int64(10)) - - if remaining := lp.remainingChargeDuration(soc); remaining != 6*time.Hour { - t.Error("wrong remaining charge duration") - } -} - func TestDisableAndEnableAtTargetSoC(t *testing.T) { clock := clock.NewMock() ctrl := gomock.NewController(t) handler := mock.NewMockHandler(ctrl) vehicle := mock.NewMockVehicle(ctrl) + // wrap vehicle with estimator + vehicle.EXPECT().Capacity().Return(int64(10)) + socEstimator := wrapper.NewSocEstimator(util.NewLogger("foo"), vehicle, false) + lp := &LoadPoint{ log: util.NewLogger("foo"), bus: evbus.New(), @@ -403,10 +388,10 @@ func TestDisableAndEnableAtTargetSoC(t *testing.T) { MinCurrent: lpMinCurrent, MaxCurrent: lpMaxCurrent, }, - handler: handler, - vehicle: vehicle, - status: api.StatusC, - TargetSoC: 90, + handler: handler, + socEstimator: socEstimator, // instead of vehicle: vehicle, + status: api.StatusC, + TargetSoC: 90, } handler.EXPECT().Prepare().Return() diff --git a/core/wrapper/socestimator.go b/core/wrapper/socestimator.go new file mode 100644 index 000000000..576cc0953 --- /dev/null +++ b/core/wrapper/socestimator.go @@ -0,0 +1,91 @@ +package wrapper + +import ( + "math" + "time" + + "github.com/andig/evcc/api" + "github.com/andig/evcc/util" +) + +// SocEstimator provides vehicle soc and charge duration +// Vehicle SoC can be estimated to provide more granularity +type SocEstimator struct { + log *util.Logger + vehicle api.Vehicle + estimate bool + + capacity float64 // vehicle capacity in Wh cached to simplify testing + socCharge float64 // estimated vehicle SoC + prevSoC float64 // previous vehicle SoC in % + prevChargedEnergy float64 // previous charged energy in Wh + energyPerSocStep float64 // Energy per SoC percent in Wh +} + +// NewSocEstimator creates new estimator +func NewSocEstimator(log *util.Logger, vehicle api.Vehicle, estimate bool) *SocEstimator { + s := &SocEstimator{ + log: log, + vehicle: vehicle, + estimate: estimate, + } + + s.Reset() + + return s +} + +// Reset resets the estimation process to default values +func (s *SocEstimator) Reset() { + s.prevSoC = 0 + s.prevChargedEnergy = 0 + s.capacity = float64(s.vehicle.Capacity()) * 1e3 // cache to simplify debugging + s.energyPerSocStep = s.capacity / 100 +} + +// RemainingChargeDuration returns the remaining duration estimate based on SoC, target and charge power +func (s *SocEstimator) RemainingChargeDuration(chargePower float64, targetSoC int) time.Duration { + if chargePower > 0 { + percentRemaining := float64(targetSoC) - s.socCharge + if percentRemaining <= 0 { + return 0 + } + + whRemaining := percentRemaining / 100 * s.capacity + return time.Duration(float64(time.Hour) * whRemaining / chargePower).Round(time.Second) + } + + return -1 +} + +// SoC implements Vehicle.ChargeState with addition of given charged energy +func (s *SocEstimator) SoC(chargedEnergy float64) (float64, error) { + f, err := s.vehicle.ChargeState() + if err != nil { + return s.socCharge, err + } + + s.socCharge = f + + if s.estimate { + socDelta := s.socCharge - s.prevSoC + energyDelta := math.Max(chargedEnergy, 0) - s.prevChargedEnergy + + if socDelta != 0 || energyDelta < 0 { // soc value change or unexpected energy reset + // calculate gradient, wh per soc % + if socDelta > 1 && energyDelta > 0 && s.prevSoC > 0 { + s.energyPerSocStep = energyDelta / socDelta + s.log.TRACE.Printf("soc gradient updated: energyPerSocStep: %0.0fWh, virtualBatCap: %0.1fkWh", s.energyPerSocStep, s.energyPerSocStep*100/1e3) + } + + // sample charged energy at soc change, reset energy delta + s.prevChargedEnergy = math.Max(chargedEnergy, 0) + s.prevSoC = s.socCharge + } else { + s.socCharge = math.Min(f + energyDelta / s.energyPerSocStep, 100) + s.log.TRACE.Printf("soc estimated: %.2f%% (vehicle: %.2f%%)", s.socCharge, f) + } + } + + return s.socCharge, nil +} diff --git a/core/wrapper/socestimator_test.go b/core/wrapper/socestimator_test.go new file mode 100644 index 000000000..c402358e0 --- /dev/null +++ b/core/wrapper/socestimator_test.go @@ -0,0 +1,82 @@ +package wrapper + +import ( + "testing" + "time" + + "github.com/andig/evcc/mock" + "github.com/andig/evcc/util" + "github.com/golang/mock/gomock" +) + +func TestRemainingChargeDuration(t *testing.T) { + ctrl := gomock.NewController(t) + vehicle := mock.NewMockVehicle(ctrl) + vehicle.EXPECT().Capacity().Return(int64(10)) + + ce := NewSocEstimator(util.NewLogger("foo"), vehicle, false) + ce.socCharge = 20.0 + + chargePower := 1000.0 + targetSoC := 80 + + if remaining := ce.RemainingChargeDuration(chargePower, targetSoC); remaining != 6*time.Hour { + t.Error("wrong remaining charge duration") + } +} + +func TestSoCEstimation(t *testing.T) { + ctrl := gomock.NewController(t) + vehicle := mock.NewMockVehicle(ctrl) + vehicle.EXPECT().Capacity().Return(int64(10)) + + ce := NewSocEstimator(util.NewLogger("foo"), vehicle, true) + ce.socCharge = 20.0 + + tc := []struct { + chargedEnergy float64 + vehicleSoC float64 + estimatedSoC float64 + }{ + {10, 20.0, 20.0}, + {0, 20.0, 20.0}, + {123, 20.0, 21.23}, + {1000, 20.0, 30.0}, + {1100, 31.0, 31.0}, + {1200, 32.0, 32.0}, + {1900, 39.0, 39.0}, + {2000, 40.0, 40.0}, + {4000, 50.0, 50.0}, + {6000, 60.0, 60.0}, + {6500, 65.0, 65.0}, + {7000, 65.0, 70.0}, + {7100, 71.0, 71.0}, + {7300, 72.0, 72.0}, + {7400, 73.0, 73.0}, + {7700, 75.0, 75.0}, + {8200, 80.0, 80.0}, + {0, 25.0, 25.0}, + {2500, 25.0, 50.0}, + {-10000, 50.0, 50.0}, + {4990, 50.0, 99.9}, + {5000, 50.0, 100.0}, + {5001, 50.0, 100.0}, + {0, 0.0, 0.0}, + {1000, 0.0, 10.0}, + } + + for _, tc := range tc { + t.Logf("%+v", tc) + vehicle.EXPECT().ChargeState().Return(tc.vehicleSoC, nil) + + soc, err := ce.SoC(tc.chargedEnergy) + if err != nil { + t.Error(err) + } + t.Logf("%+v", ce) + + if tc.estimatedSoC != soc { + t.Errorf("expected: %g, got: %g", tc.estimatedSoC, soc) + } + } +} diff --git a/evcc.dist.yaml b/evcc.dist.yaml index aa891c7bb..3e2de81e2 100644 --- a/evcc.dist.yaml +++ b/evcc.dist.yaml @@ -132,6 +132,7 @@ loadpoints: targetSoC: 100 # charge to 100% soc: alwaysUpdate: false # set true to update vehicle soc even when disconnected + estimate: false # set true to interpolate between api updates levels: # target soc levels for UI - 30 - 50