Soc: extrapolate from charged energy when vehicle api is unavailable (#33149)
This commit is contained in:
parent
661693952a
commit
bafcd4f5dc
4 changed files with 58 additions and 10 deletions
|
|
@ -2025,13 +2025,15 @@ func (lp *Loadpoint) publishSocAndRange() {
|
|||
}
|
||||
}
|
||||
|
||||
if socR != nil {
|
||||
if socEstimator == nil {
|
||||
lp.vehicleSoc = *socR
|
||||
} else {
|
||||
lp.vehicleSoc = socEstimator.Soc(socR, lp.GetChargedEnergy())
|
||||
if socEstimator != nil {
|
||||
// nil soc extrapolates from charged energy while vehicle api is unavailable;
|
||||
// don't overwrite a known soc while a freshly created estimator returns 0
|
||||
if soc := socEstimator.Soc(socR, lp.GetChargedEnergy()); socR != nil || soc > 0 {
|
||||
lp.vehicleSoc = soc
|
||||
lp.log.DEBUG.Printf("vehicle soc (estimator): %.0f%%", lp.vehicleSoc)
|
||||
}
|
||||
} else if socR != nil {
|
||||
lp.vehicleSoc = *socR
|
||||
}
|
||||
lp.publish(keys.VehicleSoc, lp.vehicleSoc)
|
||||
|
||||
|
|
|
|||
|
|
@ -149,6 +149,9 @@ func (lp *Loadpoint) setActiveVehicle(v api.Vehicle) {
|
|||
// resolve optional config
|
||||
if v.Capacity() > 0 && (lp.Soc.Estimate == nil || *lp.Soc.Estimate) {
|
||||
lp.socEstimator = soc.NewEstimator(lp.log, v)
|
||||
} else {
|
||||
// drop the previous vehicle's estimator
|
||||
lp.socEstimator = nil
|
||||
}
|
||||
|
||||
lp.publish(keys.VehicleName, vehicle.Settings(lp.log, v).Name())
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ type Estimator struct {
|
|||
initialEnergy float64 // energy counter at first valid soc in Wh
|
||||
prevSoc float64 // vehicle soc at last soc change in %
|
||||
prevChargedEnergy float64 // charged energy at last soc change in Wh
|
||||
sampled bool // a valid vehicle soc was received
|
||||
}
|
||||
|
||||
// NewEstimator creates new estimator
|
||||
|
|
@ -92,7 +93,12 @@ func remainingChargeEnergy(targetSoc, vehicleSoc, virtualCapacity float64) float
|
|||
// Soc replaces the api.Vehicle.Soc interface to take charged energy into account
|
||||
func (s *Estimator) Soc(fetchedSoc *float64, chargedEnergy float64) float64 {
|
||||
if fetchedSoc == nil {
|
||||
s.log.WARN.Println("missing vehicle soc- ignored by estimator")
|
||||
// extrapolate soc from charged energy while no vehicle soc is available,
|
||||
// never below the current estimate to stay monotonic across energy resets
|
||||
if energyDelta := max(chargedEnergy, 0) - s.prevChargedEnergy; s.sampled && energyDelta >= 0 {
|
||||
s.vehicleSoc = min(max(s.vehicleSoc, s.prevSoc+energyDelta/s.energyPerSocStep), 100)
|
||||
s.log.DEBUG.Printf("soc extrapolated: %.2f%%", s.vehicleSoc)
|
||||
}
|
||||
return s.vehicleSoc
|
||||
}
|
||||
|
||||
|
|
@ -100,13 +106,15 @@ func (s *Estimator) Soc(fetchedSoc *float64, chargedEnergy float64) float64 {
|
|||
socDelta := *fetchedSoc - s.prevSoc
|
||||
energyDelta := chargedEnergy - s.prevChargedEnergy
|
||||
|
||||
// no soc change and no energy reset: interpolate soc from charged energy
|
||||
if socDelta == 0 && energyDelta >= 0 {
|
||||
// no soc change and no energy reset: interpolate soc from charged energy.
|
||||
// the first valid soc always takes the sampling path below to seed the baseline.
|
||||
if s.sampled && socDelta == 0 && energyDelta >= 0 {
|
||||
s.vehicleSoc = min(*fetchedSoc+energyDelta/s.energyPerSocStep, 100)
|
||||
s.log.DEBUG.Printf("soc estimated: %.2f%% (vehicle: %.2f%%)", s.vehicleSoc, *fetchedSoc)
|
||||
return s.vehicleSoc
|
||||
}
|
||||
|
||||
s.sampled = true
|
||||
s.vehicleSoc = *fetchedSoc
|
||||
|
||||
if s.initialSoc == 0 {
|
||||
|
|
|
|||
|
|
@ -88,13 +88,48 @@ func TestMissingSoc(t *testing.T) {
|
|||
|
||||
ce := NewEstimator(util.NewLogger("foo"), vehicle)
|
||||
|
||||
// missing soc without any prior sample keeps the zero estimate
|
||||
assert.Equal(t, 0.0, ce.Soc(nil, 100))
|
||||
|
||||
soc := 20.0
|
||||
assert.Equal(t, 20.0, ce.Soc(&soc, 0))
|
||||
assert.Equal(t, 21.0, ce.Soc(&soc, 100))
|
||||
|
||||
// missing soc keeps the estimate and must not corrupt the sampled state
|
||||
assert.Equal(t, 21.0, ce.Soc(nil, 200))
|
||||
// missing soc extrapolates from charged energy and must not corrupt the sampled state
|
||||
assert.Equal(t, 22.0, ce.Soc(nil, 200))
|
||||
assert.Equal(t, 22.0, ce.Soc(&soc, 200))
|
||||
|
||||
// energy reset while soc is missing keeps the estimate (monotonic clamp)
|
||||
assert.Equal(t, 22.0, ce.Soc(nil, 50))
|
||||
|
||||
// resample the baseline at a higher energy, then reset the energy below it:
|
||||
// the reset guard must hold the estimate
|
||||
soc = 25.0
|
||||
assert.Equal(t, 25.0, ce.Soc(&soc, 400))
|
||||
assert.Equal(t, 25.0, ce.Soc(nil, 50))
|
||||
|
||||
// extrapolation resumes once the energy passes the sampled baseline again
|
||||
assert.Equal(t, 26.0, ce.Soc(nil, 500))
|
||||
}
|
||||
|
||||
func TestMissingSocFromZero(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
vehicle := api.NewMockVehicle(ctrl)
|
||||
vehicle.EXPECT().Capacity().Return(8.5)
|
||||
|
||||
ce := NewEstimator(util.NewLogger("foo"), vehicle)
|
||||
|
||||
// a fetched soc of exactly 0% must still enable extrapolation
|
||||
soc := 0.0
|
||||
assert.Equal(t, 0.0, ce.Soc(&soc, 0))
|
||||
assert.Equal(t, 1.0, ce.Soc(nil, 100))
|
||||
|
||||
// the first sample seeds the energy baseline: a 0% soc at nonzero session
|
||||
// energy must not jump ahead by the pre-baseline energy
|
||||
vehicle.EXPECT().Capacity().Return(8.5)
|
||||
ce = NewEstimator(util.NewLogger("foo"), vehicle)
|
||||
assert.Equal(t, 0.0, ce.Soc(&soc, 5000))
|
||||
assert.Equal(t, 1.0, ce.Soc(nil, 5100))
|
||||
}
|
||||
|
||||
func TestImprovedEstimatorRemainingChargeDuration(t *testing.T) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue