Soc: extrapolate from charged energy when vehicle api is unavailable (#33122)

This commit is contained in:
andig 2026-08-24 10:45:02 +02:00 • committed by GitHub
parent 7f33bb9317
commit b15f81a85a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 58 additions and 10 deletions

View file

@ -2027,13 +2027,15 @@ func (lp *Loadpoint) publishSocAndRange() {
} }
} }
if socR != nil { if socEstimator != nil {
if socEstimator == nil { // nil soc extrapolates from charged energy while vehicle api is unavailable;
lp.vehicleSoc = *socR // don't overwrite a known soc while a freshly created estimator returns 0
} else { if soc := socEstimator.Soc(socR, lp.GetChargedEnergy()); socR != nil || soc > 0 {
lp.vehicleSoc = socEstimator.Soc(socR, lp.GetChargedEnergy()) lp.vehicleSoc = soc
lp.log.DEBUG.Printf("vehicle soc (estimator): %.0f%%", lp.vehicleSoc) lp.log.DEBUG.Printf("vehicle soc (estimator): %.0f%%", lp.vehicleSoc)
} }
} else if socR != nil {
lp.vehicleSoc = *socR
} }
lp.publish(keys.VehicleSoc, lp.vehicleSoc) lp.publish(keys.VehicleSoc, lp.vehicleSoc)

View file

@ -166,6 +166,9 @@ func (lp *Loadpoint) setActiveVehicle(v api.Vehicle) {
// resolve optional config // resolve optional config
if v.Capacity() > 0 && (lp.Soc.Estimate == nil || *lp.Soc.Estimate) { if v.Capacity() > 0 && (lp.Soc.Estimate == nil || *lp.Soc.Estimate) {
lp.socEstimator = soc.NewEstimator(lp.log, v) 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()) lp.publish(keys.VehicleName, vehicle.Settings(lp.log, v).Name())

View file

@ -30,6 +30,7 @@ type Estimator struct {
initialEnergy float64 // energy counter at first valid soc in Wh initialEnergy float64 // energy counter at first valid soc in Wh
prevSoc float64 // vehicle soc at last soc change in % prevSoc float64 // vehicle soc at last soc change in %
prevChargedEnergy float64 // charged energy at last soc change in Wh prevChargedEnergy float64 // charged energy at last soc change in Wh
sampled bool // a valid vehicle soc was received
} }
// NewEstimator creates new estimator // 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 // Soc replaces the api.Vehicle.Soc interface to take charged energy into account
func (s *Estimator) Soc(fetchedSoc *float64, chargedEnergy float64) float64 { func (s *Estimator) Soc(fetchedSoc *float64, chargedEnergy float64) float64 {
if fetchedSoc == nil { 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 return s.vehicleSoc
} }
@ -100,13 +106,15 @@ func (s *Estimator) Soc(fetchedSoc *float64, chargedEnergy float64) float64 {
socDelta := *fetchedSoc - s.prevSoc socDelta := *fetchedSoc - s.prevSoc
energyDelta := chargedEnergy - s.prevChargedEnergy energyDelta := chargedEnergy - s.prevChargedEnergy
// no soc change and no energy reset: interpolate soc from charged energy // no soc change and no energy reset: interpolate soc from charged energy.
if socDelta == 0 && energyDelta >= 0 { // 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.vehicleSoc = min(*fetchedSoc+energyDelta/s.energyPerSocStep, 100)
s.log.DEBUG.Printf("soc estimated: %.2f%% (vehicle: %.2f%%)", s.vehicleSoc, *fetchedSoc) s.log.DEBUG.Printf("soc estimated: %.2f%% (vehicle: %.2f%%)", s.vehicleSoc, *fetchedSoc)
return s.vehicleSoc return s.vehicleSoc
} }
s.sampled = true
s.vehicleSoc = *fetchedSoc s.vehicleSoc = *fetchedSoc
if s.initialSoc == 0 { if s.initialSoc == 0 {

View file

@ -88,13 +88,48 @@ func TestMissingSoc(t *testing.T) {
ce := NewEstimator(util.NewLogger("foo"), vehicle) 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 soc := 20.0
assert.Equal(t, 20.0, ce.Soc(&soc, 0)) assert.Equal(t, 20.0, ce.Soc(&soc, 0))
assert.Equal(t, 21.0, ce.Soc(&soc, 100)) assert.Equal(t, 21.0, ce.Soc(&soc, 100))
// missing soc keeps the estimate and must not corrupt the sampled state // missing soc extrapolates from charged energy and must not corrupt the sampled state
assert.Equal(t, 21.0, ce.Soc(nil, 200)) assert.Equal(t, 22.0, ce.Soc(nil, 200))
assert.Equal(t, 22.0, ce.Soc(&soc, 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) { func TestImprovedEstimatorRemainingChargeDuration(t *testing.T) {