From f571d502d6ab8ae9d80b39a41efd2c17beea7dc6 Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 3 Aug 2026 21:16:33 +0200 Subject: [PATCH] Soc: simplify estimator (#32495) --- core/loadpoint_test.go | 2 +- core/loadpoint_vehicle.go | 2 +- core/loadpoint_vehicle_test.go | 4 +- core/soc/estimator.go | 141 ++++++++++++++++----------------- core/soc/estimator_test.go | 32 +++++--- 5 files changed, 94 insertions(+), 87 deletions(-) diff --git a/core/loadpoint_test.go b/core/loadpoint_test.go index 22d82b4a9..59f55c5c9 100644 --- a/core/loadpoint_test.go +++ b/core/loadpoint_test.go @@ -390,7 +390,7 @@ func TestDisableAndEnableAtTargetSoc(t *testing.T) { // wrap vehicle with estimator expectVehiclePublish(vehicle) - socEstimator := soc.NewEstimator(util.NewLogger("foo"), charger, vehicle) + socEstimator := soc.NewEstimator(util.NewLogger("foo"), vehicle) lp := &Loadpoint{ log: util.NewLogger("foo"), diff --git a/core/loadpoint_vehicle.go b/core/loadpoint_vehicle.go index 33681d86d..e9947b2b5 100644 --- a/core/loadpoint_vehicle.go +++ b/core/loadpoint_vehicle.go @@ -148,7 +148,7 @@ 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, lp.charger, v) + lp.socEstimator = soc.NewEstimator(lp.log, v) } lp.publish(keys.VehicleName, vehicle.Settings(lp.log, v).Name()) diff --git a/core/loadpoint_vehicle_test.go b/core/loadpoint_vehicle_test.go index bdd0cf90f..e03d21746 100644 --- a/core/loadpoint_vehicle_test.go +++ b/core/loadpoint_vehicle_test.go @@ -44,7 +44,7 @@ func TestPublishSocAndRange(t *testing.T) { chargeMeter: &Null{}, // silence nil panics chargeRater: &Null{}, // silence nil panics chargeTimer: &Null{}, // silence nil panics - socEstimator: soc.NewEstimator(log, charger, vehicle), + socEstimator: soc.NewEstimator(log, vehicle), minCurrent: minA, maxCurrent: maxA, phases: 1, @@ -188,7 +188,7 @@ func TestPublishSocAndRangeVehiclesAndChargers(t *testing.T) { t.Run(tc.name+" wo/estimator", test) - lp.socEstimator = soc.NewEstimator(log, tc.charger, tc.vehicle) + lp.socEstimator = soc.NewEstimator(log, tc.vehicle) t.Run(tc.name+" w/estimator", test) } } diff --git a/core/soc/estimator.go b/core/soc/estimator.go index f1d241877..fdc747cd6 100644 --- a/core/soc/estimator.go +++ b/core/soc/estimator.go @@ -10,123 +10,122 @@ import ( const ( ChargeEfficiency = 0.85 // assume 85% charge efficiency - minChargePower = 1000.0 // Lowest charge power (just before vehicle stops charging at 100%) - maxChargePower = 50000.0 // default 50 kW - maxChargeSoc = 50.0 // default 50% - minChargeSoc = 100.0 + minChargePower = 1000.0 // charge power at 100% soc (just before the vehicle stops charging) + maxChargePower = 50000.0 // charge power up to maxChargeSoc + maxChargeSoc = 50.0 // soc up to which maxChargePower is available - gradient = (minChargePower - maxChargePower) / (minChargeSoc - maxChargeSoc) + // power reduction per soc percent above maxChargeSoc + powerPerSoc = (maxChargePower - minChargePower) / (100 - maxChargeSoc) ) // Estimator provides vehicle soc and charge duration // Vehicle Soc can be estimated to provide more granularity type Estimator struct { - log *util.Logger - charger api.Charger - vehicle api.Vehicle + log *util.Logger - virtualCapacity float64 // estimated virtual vehicle capacity in Wh - vehicleSoc float64 // estimated vehicle Soc - initialSoc float64 // first received valid vehicle Soc - initialEnergy float64 // energy counter at first valid Soc - prevSoc float64 // previous vehicle Soc in % - prevChargedEnergy float64 // previous charged energy in Wh - energyPerSocStep float64 // Energy per Soc percent in Wh + capacity float64 // vehicle capacity in Wh + energyPerSocStep float64 // energy per soc percent in Wh + vehicleSoc float64 // estimated vehicle soc in % + initialSoc float64 // first received valid vehicle soc in % + 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 } // NewEstimator creates new estimator -func NewEstimator(log *util.Logger, charger api.Charger, vehicle api.Vehicle) *Estimator { - s := &Estimator{ - log: log, - charger: charger, - vehicle: vehicle, +func NewEstimator(log *util.Logger, vehicle api.Vehicle) *Estimator { + capacity := vehicle.Capacity() * 1e3 + + return &Estimator{ + log: log, + capacity: capacity, + energyPerSocStep: capacity / ChargeEfficiency / 100, // initial gradient taking efficiency into account } +} - s.virtualCapacity = s.vehicle.Capacity() * 1e3 / ChargeEfficiency // initial capacity taking efficiency into account - s.energyPerSocStep = s.virtualCapacity / 100 - - return s +// virtualCapacity returns the estimated capacity in Wh, never below the vehicle's physical capacity +func (s *Estimator) virtualCapacity() float64 { + return max(s.capacity, s.energyPerSocStep*100) } // RemainingChargeDuration returns the estimated remaining duration func (s *Estimator) RemainingChargeDuration(targetSoc, chargePower float64) time.Duration { - return remainingChargeDuration(targetSoc, chargePower, s.vehicleSoc, s.virtualCapacity) + return remainingChargeDuration(targetSoc, chargePower, s.vehicleSoc, s.virtualCapacity()) } -func RemainingChargeDuration(targetSoc, chargePower, vehicleSoc, virtualCapacity float64) time.Duration { - return remainingChargeDuration(targetSoc, chargePower, vehicleSoc, virtualCapacity*1e3/ChargeEfficiency) +func RemainingChargeDuration(targetSoc, chargePower, vehicleSoc, capacity float64) time.Duration { + return remainingChargeDuration(targetSoc, chargePower, vehicleSoc, capacity*1e3/ChargeEfficiency) } func remainingChargeDuration(targetSoc, chargePower, vehicleSoc, virtualCapacity float64) time.Duration { - // Relativer Reduktionspunkt - rrp := (chargePower-minChargePower)/gradient + minChargeSoc + // soc above which charge power starts to taper off + taperSoc := 100 - (chargePower-minChargePower)/powerPerSoc - var t1, t2 float64 + var hours float64 - // Zeit von vehicleSoc bis Reduktionspunkt (linear) - if vehicleSoc < rrp { - t1 = (min(float64(targetSoc), rrp) - vehicleSoc) / minChargeSoc * virtualCapacity / chargePower + // below the taper point the vehicle charges at full power + if vehicleSoc < taperSoc { + hours += (min(targetSoc, taperSoc) - vehicleSoc) / 100 * virtualCapacity / chargePower } - // Zeit von Reduktionspunkt bis targetSoc (degressiv) - if float64(targetSoc) > rrp { - t2 = (float64(targetSoc) - max(vehicleSoc, rrp)) / minChargeSoc * virtualCapacity / ((chargePower-minChargePower)/2 + minChargePower) + // above the taper point power decreases linearly towards minChargePower + if targetSoc > taperSoc { + hours += (targetSoc - max(vehicleSoc, taperSoc)) / 100 * virtualCapacity / ((chargePower + minChargePower) / 2) } - return max(0, time.Duration(float64(time.Hour)*(t1+t2))).Round(time.Second) + return max(0, time.Duration(float64(time.Hour)*hours)).Round(time.Second) } // RemainingChargeEnergy returns the remaining charge energy in kWh func (s *Estimator) RemainingChargeEnergy(targetSoc int) float64 { - return remainingChargeEnergy(targetSoc, s.vehicleSoc, s.virtualCapacity) + return remainingChargeEnergy(float64(targetSoc), s.vehicleSoc, s.virtualCapacity()) } func RemainingChargeEnergy(targetSoc int, vehicleSoc, capacity float64) float64 { - return remainingChargeEnergy(targetSoc, vehicleSoc, capacity*1e3/ChargeEfficiency) + return remainingChargeEnergy(float64(targetSoc), vehicleSoc, capacity*1e3/ChargeEfficiency) } -func remainingChargeEnergy(targetSoc int, vehicleSoc, virtualCapacity float64) float64 { - percentRemaining := float64(targetSoc) - vehicleSoc - if percentRemaining <= 0 || virtualCapacity <= 0 { - return 0 - } - return percentRemaining / 100 * virtualCapacity / 1e3 +func remainingChargeEnergy(targetSoc, vehicleSoc, virtualCapacity float64) float64 { + return max(0, targetSoc-vehicleSoc) / 100 * max(0, virtualCapacity) / 1e3 } // 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.vehicleSoc = *fetchedSoc - } else { - s.log.WARN.Printf("missing vehicle soc- ignored by estimator") + if fetchedSoc == nil { + s.log.WARN.Println("missing vehicle soc- ignored by estimator") + return s.vehicleSoc } - socDelta := s.vehicleSoc - s.prevSoc - energyDelta := max(chargedEnergy, 0) - s.prevChargedEnergy + chargedEnergy = max(chargedEnergy, 0) + socDelta := *fetchedSoc - s.prevSoc + energyDelta := chargedEnergy - s.prevChargedEnergy - if socDelta != 0 || energyDelta < 0 { // soc value change or unexpected energy reset - if s.initialSoc == 0 { - s.initialSoc = s.vehicleSoc - s.initialEnergy = chargedEnergy - } - - socDiff := s.vehicleSoc - s.initialSoc - energyDiff := chargedEnergy - s.initialEnergy - - // recalculate gradient, wh per soc % - if socDiff > 10 && energyDiff > 0 { - s.energyPerSocStep = energyDiff / socDiff - s.virtualCapacity = max(s.vehicle.Capacity()*1e3, s.energyPerSocStep*100) - s.log.DEBUG.Printf("soc gradient updated: soc: %.1f%%, socDiff: %.1f%%, energyDiff: %.0fWh, energyPerSocStep: %.1fWh, virtualCapacity: %.0fWh", s.vehicleSoc, socDiff, energyDiff, s.energyPerSocStep, s.virtualCapacity) - } - - // sample charged energy at soc change, reset energy delta - s.prevChargedEnergy = max(chargedEnergy, 0) - s.prevSoc = s.vehicleSoc - } else { + // no soc change and no energy reset: interpolate soc from charged energy + if 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.vehicleSoc = *fetchedSoc + + if s.initialSoc == 0 { + s.initialSoc = s.vehicleSoc + s.initialEnergy = chargedEnergy + } + + socDiff := s.vehicleSoc - s.initialSoc + energyDiff := chargedEnergy - s.initialEnergy + + // recalculate gradient, wh per soc % + if socDiff > 10 && energyDiff > 0 { + s.energyPerSocStep = energyDiff / socDiff + s.log.DEBUG.Printf("soc gradient updated: soc: %.1f%%, socDiff: %.1f%%, energyDiff: %.0fWh, energyPerSocStep: %.1fWh, virtualCapacity: %.0fWh", s.vehicleSoc, socDiff, energyDiff, s.energyPerSocStep, s.virtualCapacity()) + } + + // sample charged energy at soc change + s.prevSoc = s.vehicleSoc + s.prevChargedEnergy = chargedEnergy + return s.vehicleSoc } diff --git a/core/soc/estimator_test.go b/core/soc/estimator_test.go index 5edd30350..ee76ecd63 100644 --- a/core/soc/estimator_test.go +++ b/core/soc/estimator_test.go @@ -12,12 +12,11 @@ import ( func TestRemainingChargeDuration(t *testing.T) { ctrl := gomock.NewController(t) - charger := api.NewMockCharger(ctrl) vehicle := api.NewMockVehicle(ctrl) // 8.5 kWh userBatCap => 10 kWh virtualBatCap (at 85% efficiency) vehicle.EXPECT().Capacity().Return(float64(8.5)) - ce := NewEstimator(util.NewLogger("foo"), charger, vehicle) + ce := NewEstimator(util.NewLogger("foo"), vehicle) ce.vehicleSoc = 20.0 chargePower := 1000.0 @@ -29,19 +28,13 @@ func TestRemainingChargeDuration(t *testing.T) { } func TestSocEstimation(t *testing.T) { - type chargerStruct struct { - *api.MockCharger - *api.MockBattery - } - ctrl := gomock.NewController(t) vehicle := api.NewMockVehicle(ctrl) - charger := &chargerStruct{api.NewMockCharger(ctrl), api.NewMockBattery(ctrl)} // 8.5 kWh user battery capacity is converted to initial value of 10 kWh virtual capacity (at 85% efficiency) vehicle.EXPECT().Capacity().Return(8.5).AnyTimes() - ce := NewEstimator(util.NewLogger("foo"), charger, vehicle) + ce := NewEstimator(util.NewLogger("foo"), vehicle) tc := []struct { chargedEnergy float64 @@ -76,7 +69,7 @@ func TestSocEstimation(t *testing.T) { // validate soc/capacity estimate assert.Equal(t, tc.estimatedSoc, soc, "estimated soc") - assert.Equal(t, tc.virtualCapacity, ce.virtualCapacity, "virtual capacity") + assert.Equal(t, tc.virtualCapacity, ce.virtualCapacity(), "virtual capacity") // validate duration estimate chargePower := 1e3 @@ -88,9 +81,24 @@ func TestSocEstimation(t *testing.T) { } } +func TestMissingSoc(t *testing.T) { + ctrl := gomock.NewController(t) + vehicle := api.NewMockVehicle(ctrl) + vehicle.EXPECT().Capacity().Return(8.5) + + ce := NewEstimator(util.NewLogger("foo"), vehicle) + + 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)) + assert.Equal(t, 22.0, ce.Soc(&soc, 200)) +} + func TestImprovedEstimatorRemainingChargeDuration(t *testing.T) { ctrl := gomock.NewController(t) - charger := api.NewMockCharger(ctrl) vehicle := api.NewMockVehicle(ctrl) // https://github.com/evcc-io/evcc/pull/7510#issuecomment-1512688548 @@ -117,7 +125,7 @@ func TestImprovedEstimatorRemainingChargeDuration(t *testing.T) { vehicle.EXPECT().Capacity().Return(tc.capacity) - ce := NewEstimator(util.NewLogger("foo"), charger, vehicle) + ce := NewEstimator(util.NewLogger("foo"), vehicle) ce.vehicleSoc = tc.soc assert.Equal(t, tc.duration, ce.RemainingChargeDuration(tc.targetsoc, tc.chargePower))