From b15f81a85ac0b369e649f297bdf581b1ceae52c6 Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 24 Aug 2026 10:45:02 +0200 Subject: [PATCH] Soc: extrapolate from charged energy when vehicle api is unavailable (#33122) --- core/loadpoint.go | 12 +++++++----- core/loadpoint_vehicle.go | 3 +++ core/soc/estimator.go | 14 +++++++++++--- core/soc/estimator_test.go | 39 ++++++++++++++++++++++++++++++++++++-- 4 files changed, 58 insertions(+), 10 deletions(-) diff --git a/core/loadpoint.go b/core/loadpoint.go index db2a1daed..7dc2bf611 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -2027,13 +2027,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) diff --git a/core/loadpoint_vehicle.go b/core/loadpoint_vehicle.go index c156682cf..35c7f3ce4 100644 --- a/core/loadpoint_vehicle.go +++ b/core/loadpoint_vehicle.go @@ -166,6 +166,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()) diff --git a/core/soc/estimator.go b/core/soc/estimator.go index fdc747cd6..e02c30739 100644 --- a/core/soc/estimator.go +++ b/core/soc/estimator.go @@ -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 { diff --git a/core/soc/estimator_test.go b/core/soc/estimator_test.go index ee76ecd63..ba28868ec 100644 --- a/core/soc/estimator_test.go +++ b/core/soc/estimator_test.go @@ -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) {