Add optional interpolation of SoC between vehicle api updates (#277)
This commit is contained in:
parent
89695b2d9e
commit
f691538660
5 changed files with 202 additions and 52 deletions
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
91
core/wrapper/socestimator.go
Normal file
91
core/wrapper/socestimator.go
Normal file
|
|
@ -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
|
||||
}
|
||||
82
core/wrapper/socestimator_test.go
Normal file
82
core/wrapper/socestimator_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue