Lithium Battery SOH Estimation: Cross-Checking Measured Capacity Against Cycle Counting
Introduction
If SOC is the fuel gauge on your battery, SOH is its physical exam. In EV batteries and stationary storage, getting SOH right isn't just about range prediction, it feeds directly into safety calls, maintenance planning, and how you value the asset.
A pack that ships rated at 100Ah might be down to 80Ah two years in. How do you quantify that 20% fade? When do you pull the pack? The answers all live inside the SOH algorithm.
SOH estimation is a harder problem than SOC. Capacity shifts with temperature, C-rate, cycle count, shelf time, and no single method captures all of it. This article digs into two mainstream approaches used in a real automotive-grade BMS project, measured capacity and cycle counting, and how they get fused together.
1. What SOH Means and Why It Matters
1.1 The Math
Standard definition:
SOH = (current maximum usable capacity / nameplate capacity) × 100%
"Maximum usable capacity" means the charge delivered from full to cutoff voltage under standard conditions (25°C, 0.5C rate).
The code implements this directly:
u16 GetGCapSohTenthAPI(void) {
u16 capSoh = 0;
u32 totalCap = 0;
u32 ratedCap = 0;
totalCap = GetGroupTotalCapAPI(); // current max capacity (mAh)
ratedCap = GetGroupRatedCapAPI(); // nameplate capacity (mAh)
if(ratedCap > 0) {
capSoh = (u16)(totalCap * 10 / ratedCap); // units of 0.1%
}
return capSoh;
}
1.2 Why Anyone Cares
Safety thresholds
Below 80% SOH, internal resistance climbs and thermal runaway risk rises. Many OEMs draw the hard replacement line at 80%.
Warranty disputes
Typical EV battery warranties read "8 years or 150,000 km, SOH ≥ 70%." SOH is the deciding evidence.
Second-life decisions
Retired EV packs get reused in stationary storage. An 80% pack is wrong for a car but fine for home storage. Good second-life programs depend on trustworthy SOH numbers.
Asset valuation
For swap stations and shared-mobility fleets, batteries are the core asset. SOH drives depreciation and residual value.
1.3 What Makes SOH Hard
Unlike SOC, you can't integrate your way to SOH. The challenges:
- Long timescales: fade is slow, you need months or years of data
- Many variables: temperature, C-rate, DOD, shelf time all play a role
- Hard to measure: real capacity needs a full cycle, users rarely do that
- Reversible effects: cold weather drops capacity temporarily, that's not aging
These problems are exactly why you fuse multiple methods instead of trusting one.
2. Method One: Measured Capacity
2.1 The Idea
The direct approach, cycle the pack, measure how much charge it actually stores and releases.
In the code, GetGroupTotalCapAPI() returns this measured value:
u32 GetGroupTotalCapAPI(void) {
return(sCapForm.topCap - CAP_ZERO_POINT);
}
Where does topCap come from? Capacity learning.
2.2 When Capacity Learning Triggers
Capacity learning in SocSoeCorr.c keys off the ends of the charge/discharge curve:
// End of charge: voltage at the top, SOC near 100%
if((GetGCellMaxVoltAPI() >= CORR_CHG_END_MAX_V)
&& (GetGRealSocMilliAPI() >= CORR_CHG_END_LES_SOC)) {
CorrGroupTotalCapAPI(learnedCap);
}
// End of discharge: voltage at the bottom, SOC near 0%
if((GetGCellMinVoltAPI() <= CORR_DHG_END_MIN_V)
&& (GetGRealSocMilliAPI() <= CORR_DHG_END_MOS_SOC)) {
CorrGroupTotalCapAPI(learnedCap);
}
Three flavors of learning:
- Charge learning: from some SOC up to 100%, log the charge in ΔQ_chg, back out total capacity
- Discharge learning: from some SOC down to 0%, log the charge out ΔQ_dhg, back out total capacity
- Full-cycle learning: 0 to 100 or 100 to 0, capacity drops out directly
2.3 Smoothing the Update
To stop a single noisy measurement from yanking the capacity, the code uses weighted smoothing:
void CorrGroupTotalCapAPI(u32 cap) {
u32 nowCap = sCapForm.nowCap;
u32 remCap = sCapForm.remCap;
u32 lowCap = sCapForm.baseCap;
u32 allCap = sCapForm.topCap;
u32 befCap = sCapForm.topCap - CAP_ZERO_POINT;
// current SOC
u32 soc = (nowCap - lowCap) * 10000 / befCap;
// scale current capacity to match
nowCap = cap * soc / 10000 + CAP_ZERO_POINT;
// update total capacity
sCapForm.topCap = cap + CAP_ZERO_POINT;
sCapForm.nowCap = nowCap;
StoreGroupTotalCapToEEP();
}
Three things worth noting:
- SOC stays put (user doesn't see a jump)
- Current capacity rescales proportionally
- Changes persist to EEPROM immediately
2.4 Strengths and Limits
Strengths:
- Direct: no model assumptions, reflects actual capacity
- Adaptive: tracks real aging
- Verifiable: offline capacity tests can check it
Limits:
- Slow convergence: multiple full cycles before the number settles
- Narrow operating window: users rarely go 0 to 100
- Temperature bias: cold measurements read low without actual aging
- Rate bias: high-current discharges read low, need compensation
Those limits are why you bring in a second method.
3. Method Two: Linear Fade via Cycle Counting
3.1 Empirical Aging Model
Lab data shows lithium cells roughly follow three patterns:
- Cycle aging: each full cycle takes out a sliver of capacity
- Calendar aging: even sitting unused, capacity slowly drops
- Nonlinearity: fast early, slower in the middle, faster again at end of life
For simplicity, engineering teams often fit a linear model:
SOH = 100% - (actual cycles / rated cycle life) × (100% - EOL_SOH)
EOL_SOH (end-of-life SOH) is typically 80%, meaning the pack is considered dead at 80%.
3.2 The Code
CellFadeCalc.c implements cycle-count SOH:
u16 GetGTimSohTenthAPI(void) {
u16 timSoh = 0;
u32 fadeCycle = 0;
u32 ratedCycle = 0;
fadeCycle = GetGroupFadeCycleAPI(); // actual cycles
ratedCycle = GetGroupRatedCycleAPI(); // rated cycle life
if(ratedCycle > 0) {
// SOH = 100% - (fadeCycle / ratedCycle) × 20%
// unit: 0.1%, so 100% = 1000
timSoh = 1000 - (fadeCycle * 200 / ratedCycle);
if(timSoh > 1000) {
timSoh = 1000; // upper clamp
}
} else {
timSoh = 1000; // default 100%
}
return timSoh;
}
Walk-through with numbers:
Assume:
- Rated cycle life = 2000
- EOL SOH = 80%
- Current cycle count = 500
Then:
SOH = 100% - (500 / 2000) × (100% - 80%) = 100% - 0.25 × 20% = 100% - 5% = 95%
3.3 Counting Cycles
Defining a "cycle" is trickier than it looks. What counts?
- Full cycle: 0 to 100 or 100 to 0
- Equivalent cycle: accumulated throughput equal to rated capacity
The code uses equivalent cycles:
void GroupFadeCycleCalcTask(void) {
static u32 sHisChgCap = 0;
static u32 sHisDhgCap = 0;
u32 nowChgCap = GetChgIntCapAPI(); // total charge in
u32 nowDhgCap = GetDhgIntCapAPI(); // total charge out
u32 ratedCap = GetGroupRatedCapAPI();
u32 chgDelta = nowChgCap - sHisChgCap;
u32 dhgDelta = nowDhgCap - sHisDhgCap;
// both directions hit rated capacity, count one cycle
if((chgDelta >= ratedCap) && (dhgDelta >= ratedCap)) {
sFadeInfo.fadeCycle++;
sHisChgCap = nowChgCap;
sHisDhgCap = nowDhgCap;
StoreGroupFadeCycleToEEP();
}
}
What makes this approach work:
- Doesn't need a full 0-100 cycle
- Handles piecemeal charging (top-ups, partial discharges)
- Counts both directions, so it's symmetric
3.4 Strengths and Limits
Strengths:
- Always responsive: updates every cycle, no waiting
- Cheap: an accumulator and a divide
- Stable trend: single-measurement noise doesn't move it
Limits:
- Linear is a lie: actual fade isn't linear
- Ignores conditions: high temp and high C-rate accelerate aging, model doesn't know
- No calendar aging: long shelf life isn't captured
- Parameter-sensitive: rated cycle life has to be right
4. Fusion
4.1 Why Fuse?
Each method has its weakness:
- Measured capacity: accurate but slow, sensitive to temperature and C-rate
- Cycle counting: fast but coarse, doesn't see actual capacity
Fusing covers the gaps.
4.2 The Fusion Algorithm
The code implements a clean fusion strategy:
void GroupFadeSohCalcTask(void) {
u16 capSoh = 0;
u16 timSoh = 0;
u16 nowSoh = 0;
u16 hisSoh = 0;
capSoh = GetGCapSohTenthAPI(); // measured-capacity SOH
timSoh = GetGTimSohTenthAPI(); // cycle-count SOH
hisSoh = GetGSohTenthAPI(); // previous SOH
// Rule 1: methods agree within 5%, trust cycle counting
if(ABS(capSoh, timSoh) < 50) { // 50 = 5% × 1000
nowSoh = timSoh;
}
// Rule 2: big gap, average them
else {
nowSoh = (capSoh + timSoh) / 2;
}
// Rule 3: SOH is monotonic down, no recovery
if(nowSoh > hisSoh) {
nowSoh = hisSoh;
}
// Rule 4: one-step drop capped at 0.5%
if((hisSoh > nowSoh) && ((hisSoh - nowSoh) > 5)) {
nowSoh = hisSoh - 5;
}
sFadeInfo.soh = nowSoh;
StoreGroupSohToEEP();
}
4.3 Why Each Rule Exists
Rule 1: when methods agree, take cycle counting
if(ABS(capSoh, timSoh) < 50) {
nowSoh = timSoh;
}
Under 5% disagreement, the pack is aging normally. Cycle counting is steadier and isn't thrown off by a single capacity read.
Example:
- capSoh = 92.3%
- timSoh = 94.1%
- Delta = 1.8% < 5%
- Use timSoh = 94.1%
Rule 2: when they disagree, average
else {
nowSoh = (capSoh + timSoh) / 2;
}
Gaps ≥ 5% mean something's off:
- Measured capacity is temperature-biased (cold)
- Cycle-count model is off (actual aging outpacing prediction)
Averaging hedges, no extremes.
Example:
- capSoh = 85.2% (cold measurement)
- timSoh = 94.1% (model)
- Delta = 8.9% ≥ 5%
- Use average = (85.2% + 94.1%) / 2 = 89.65%
Rule 3: no recovery allowed
if(nowSoh > hisSoh) { nowSoh = hisSoh; }
Hard rule: SOH only goes down.
Physics: aging is one-way, capacity doesn't come back. A computed SOH above history is measurement or math error.
The trap this avoids: cold-winter SOH reads 85%, warm-summer reads 90%. Stay at 85%, because the cold-winter dip was reversible and doesn't reflect real aging.
Rule 4: rate-of-change cap
if((hisSoh > nowSoh) && ((hisSoh - nowSoh) > 5)) {
nowSoh = hisSoh - 5;
}
Soft rule: single-step drop capped at 0.5%.
Physics: fade is gradual, no cell drops 5% in one cycle. Big drops are one of:
- Sensor fault
- Botched capacity measurement (wasn't actually full, but flagged as 100%)
- Temperature transient
Capping the slew rate suppresses these.
4.4 What Fusion Buys You
A few scenarios:
Scenario 1: normal aging
┌────────┬────────┬──────────┬──────────┬────────┬──────────────────────┐
│ Time │ Cycles │ Measured │ Counted │ Fused │ Note │
├────────┼────────┼──────────┼──────────┼────────┼──────────────────────┤
│ 0 mo │ 0 │ 100% │ 100% │ 100% │ Initial │
├────────┼────────┼──────────┼──────────┼────────┼──────────────────────┤
│ 6 mo │ 200 │ 97.8% │ 98.0% │ 98.0% │ Δ<5%, use counting │
├────────┼────────┼──────────┼──────────┼────────┼──────────────────────┤
│ 12 mo │ 450 │ 95.2% │ 95.5% │ 95.5% │ Δ<5%, use counting │
├────────┼────────┼──────────┼──────────┼────────┼──────────────────────┤
│ 18 mo │ 720 │ 92.8% │ 92.8% │ 92.8% │ Same answer │
└────────┴────────┴──────────┴──────────┴────────┴──────────────────────┘
Scenario 2: winter cold
┌────────┬────────┬──────────┬──────────┬────────┬──────────────────────────┐
│ Time │ Cycles │ Measured │ Counted │ Fused │ Note │
├────────┼────────┼──────────┼──────────┼────────┼──────────────────────────┤
│ 12 mo │ 450 │ 95.5% │ 95.5% │ 95.5% │ Normal temp │
├────────┼────────┼──────────┼──────────┼────────┼──────────────────────────┤
│ 13 mo │ 465 │ 88.2% │ 95.0% │ 91.6% │ Cold, average │
├────────┼────────┼──────────┼──────────┼────────┼──────────────────────────┤
│ 14 mo │ 480 │ 87.5% │ 94.5% │ 91.0% │ Still cold │
├────────┼────────┼──────────┼──────────┼────────┼──────────────────────────┤
│ 15 mo │ 495 │ 94.8% │ 94.0% │ 91.0% │ Warmed up, SOH holds │
└────────┴────────┴──────────┴──────────┴────────┴──────────────────────────┘
Fusion stops cold weather from falsely aging the pack.
Scenario 3: measurement glitch
┌──────────┬────────┬──────────┬──────────┬────────┬───────────────────────┐
│ Time │ Cycles │ Measured │ Counted │ Fused │ Note │
├──────────┼────────┼──────────┼──────────┼────────┼───────────────────────┤
│ 12.0 mo │ 450 │ 95.5% │ 95.5% │ 95.5% │ Normal │
├──────────┼────────┼──────────┼──────────┼────────┼───────────────────────┤
│ 12.1 mo │ 452 │ 82.3% │ 95.3% │ 88.8% │ Bad measurement │
├──────────┼────────┼──────────┼──────────┼────────┼───────────────────────┤
│ 12.2 mo │ 454 │ 83.1% │ 95.1% │ 88.3% │ Slew-rate clamp kicks │
├──────────┼────────┼──────────┼──────────┼────────┼───────────────────────┤
│ 12.3 mo │ 456 │ 95.2% │ 94.9% │ 88.3% │ Recovered, SOH holds │
└──────────┴────────┴──────────┴──────────┴────────┴───────────────────────┘
Fusion absorbs the glitch instead of broadcasting it.
5. Update Cadence and Persistence
5.1 When to Update
SOH doesn't need SOC-frequency updates:
void GroupFadeSohCalcTask(void) {
static u32 sHisCycle = 0;
u32 nowCycle = GetGroupFadeCycleAPI();
// Only recompute when cycle count ticks up
if(nowCycle != sHisCycle) {
// SOH math
// ...
sHisCycle = nowCycle;
}
}
One equivalent cycle → one SOH update.
Payoffs:
- Lower overhead: SOH math involves divides, not free
- Fewer EEPROM writes: extends part life
- Statistical stability: a full cycle's worth of data is enough to trust
5.2 EEPROM Writes
SOH is critical state, has to persist:
static void StoreGroupSohToEEP(void) {
static u16 sHisSoh = 0;
u16 nowSoh = sFadeInfo.soh;
// Only write if ≥ 0.1% change
if(ABS(nowSoh, sHisSoh) >= 1) {
EnerChangEepGSohHook(nowSoh);
sHisSoh = nowSoh;
}
}
The tradeoff between safety and EEPROM endurance:
- Threshold: 0.1%
- Write rate: if SOH drops 0.01% per cycle, one write per 10 cycles
- Endurance math: 100k write endurance supports roughly 1M cycles
5.3 Power-Loss Recovery
At boot, load last-known SOH from EEPROM:
void GroupFadeSohInit(void) {
u32 data[3] = {0};
if(TRUE == ParaReadStoreFadeInfo(data, 3)) {
sFadeInfo.fadeCycle = data[0]; // cycles
sFadeInfo.soh = data[1]; // SOH
sFadeInfo.soc = data[2]; // accumulated SOC delta
} else {
sFadeInfo.fadeCycle = 0;
sFadeInfo.soh = 1000; // default 100%
sFadeInfo.soc = 0;
}
}
Power loss doesn't cost the SOH history.
6. Where This Implementation Falls Short
6.1 No Temperature Compensation
SOH math ignores temperature, and that's a real gap.
Problem: lithium capacity is strongly temperature-dependent:
┌──────┬─────────────────┐
│ Temp │ Relative Cap │
├──────┼─────────────────┤
│ -20℃ │ 60–70% │
├──────┼─────────────────┤
│ 0℃ │ 80–85% │
├──────┼─────────────────┤
│ 25℃ │ 100% │
├──────┼─────────────────┤
│ 45℃ │ 102–105% │
└──────┴─────────────────┘
At -20°C you'll measure 70%, but the pack isn't 70%-aged, it's just cold. Reversible loss.
Fix: add a temperature correction:
u16 GetGCapSohWithTempCorr(void) {
u16 capSoh = GetGCapSohTenthAPI();
s16 temp = GetGAvgTempAPI(); // mean temperature
u16 tempCoeff = 1000; // default 1.0
// temperature compensation table
if(temp < 0) {
tempCoeff = 1000 + (0 - temp) * 5; // +0.5% per °C below zero
} else if(temp > 25) {
tempCoeff = 1000 - (temp - 25) * 2; // -0.2% per °C above 25
}
return (capSoh * tempCoeff / 1000);
}
6.2 No C-Rate Compensation
High-current discharge reads low capacity:
┌──────────┬─────────────────┐
│ C-rate │ Relative Cap │
├──────────┼─────────────────┤
│ 0.2C │ 100% │
├──────────┼─────────────────┤
│ 0.5C │ 98% │
├──────────┼─────────────────┤
│ 1C │ 95% │
├──────────┼─────────────────┤
│ 2C │ 88% │
├──────────┼─────────────────┤
│ 3C │ 80% │
└──────────┴─────────────────┘
Users who drive hard will see measured SOH tank, even though nothing aged.
Fix: gate capacity learning on low C-rate:
void CapacityLearningTask(void) {
s16 curr = GetGSampOutCurrAPI();
u32 ratedCap = GetGroupRatedCapAPI();
// C-rate in 0.001 C units
u16 cRate = ABS(curr, 0) * 1000 / ratedCap;
// Only learn at low current
if(cRate < 500) { // < 0.5C
// do the learning
// ...
}
}
6.3 No Calendar Aging
Batteries age even when they're not used, typically 2–3% per year.
Problem: a pack in long-term storage (bikes mothballed for winter) racks up zero cycles, capacity drops anyway. Current algorithm misses it.
Fix: add a time factor:
u16 GetGTimSohWithCalendar(void) {
u16 cycleSoh = GetGTimSohTenthAPI();
u32 days = GetSystemRunDays();
// Calendar aging: 2% per year
u16 calendarFade = days * 20 / 365; // 0.1% units
u16 totalSoh = 1000 - calendarFade;
// Take whichever is lower
return (cycleSoh < totalSoh) ? cycleSoh : totalSoh;
}
6.4 Linear Isn't Right
Actual fade is nonlinear:
- Early (0–200 cycles): fast fade, 100% → 95%
- Middle (200–1500 cycles): slow fade, 95% → 85%
- Late (1500–2000 cycles): fast again, 85% → 80%
Linear model misses this shape.
Fix: piecewise linear or exponential:
u16 GetGTimSohNonlinear(void) {
u32 fadeCycle = GetGroupFadeCycleAPI();
u32 ratedCycle = GetGroupRatedCycleAPI();
u16 soh = 1000;
if(fadeCycle < ratedCycle / 10) { // first 10%
// fast fade: 5%
soh = 1000 - fadeCycle * 500 / (ratedCycle / 10);
} else if(fadeCycle < ratedCycle * 3 / 4) { // middle 65%
// slow fade: 10%
soh = 950 - (fadeCycle - ratedCycle / 10) * 100 / (ratedCycle * 65 / 100);
} else { // last 25%
// fast fade: 5%
soh = 850 - (fadeCycle - ratedCycle * 3 / 4) * 50 / (ratedCycle / 4);
}
return soh;
}
6.5 SOC/SOH Coupling
Current SOH calc depends on accurate SOC. But SOC depends on capacity, which depends on SOH. Circular:
SOC = current capacity / total capacity
total capacity = f(SOH)
SOH = f(total capacity)
Fix: iterate to convergence:
void SohSocIterativeUpdate(void) {
u16 soh_old = GetGSohTenthAPI();
u16 soc_old = GetGRealSocMilliAPI();
u16 soh_new = 0;
u16 soc_new = 0;
// three iterations usually converges
for(u8 i = 0; i < 3; i++) {
// capacity from current SOH
u32 totalCap = GetGroupRatedCapAPI() * soh_old / 1000;
// SOC from new capacity
soc_new = GetGroupNowCapAPI() * 1000 / totalCap;
// new capacity from new SOC
u32 learnedCap = GetAccumulatedCap() * 1000 / soc_new;
// SOH from new capacity
soh_new = learnedCap * 1000 / GetGroupRatedCapAPI();
// convergence check
if((ABS(soh_new, soh_old) < 1) && (ABS(soc_new, soc_old) < 1)) {
break;
}
soh_old = soh_new;
soc_old = soc_new;
}
UpdateSohValue(soh_new);
UpdateSocValue(soc_new);
}
7. How It Actually Behaves
7.1 Typical Fade Curve
From real fleet data on a 200Ah LFP pack:
Month Cycles MeasuredSOH CountedSOH FusedSOH Note
0 0 100.0% 100.0% 100.0% Fresh
3 120 98.5% 98.8% 98.8% Early fast fade
6 280 97.2% 97.2% 97.2% Settling down
12 600 95.1% 94.0% 94.6% Measured slightly high
18 950 92.8% 90.5% 91.7% Gap widening
24 1350 89.5% 86.5% 88.0% Averaging kicks in
30 1720 86.2% 82.8% 84.5% Late life
36 2050 82.1% 79.5% 80.8% Replacement threshold
Reading the trend:
- First 6 months: methods track tight, fusion mostly uses cycle counting
- 6-18 months: measured runs slightly above counted (temperature, C-rate effects)
- After 18 months: gap opens up, averaging smooths things out
- Month 36: SOH hits 80%, replacement alert
7.2 Edge Cases
Winter cold
Month AmbientTemp MeasuredSOH CountedSOH FusedSOH Handling
Nov 15℃ 94.5% 94.0% 94.3% Normal
Dec -5℃ 86.2% 93.5% 89.9% Cold, average
Jan -10℃ 82.1% 93.0% 87.6% Still cold
Feb 5℃ 93.8% 92.5% 87.6% Warmed up, SOH holds
Fusion resists the false winter drop and stays monotonic.
Sensor fault
Time MeasuredSOH CountedSOH FusedSOH Handling
Normal 94.5% 94.0% 94.3% Fine
Fault starts 65.2% 93.8% 79.5% Sensor bad, average
Fault holds 62.8% 93.6% 78.2% Slew clamp
Fault ends 94.2% 93.4% 78.2% No recovery, hold floor
Sensor goes sideways, fusion contains the damage via averaging and slew limits.
High-rate discharge
Usage MeasuredSOH CountedSOH FusedSOH Note
Normal (0.5C) 94.5% 94.0% 94.3% Baseline
Hard accel (2C) 88.2% 93.8% 91.0% High rate drops capacity
Sustained (1.5C) 90.1% 93.6% 91.0% SOH holds
Back to normal 94.3% 93.4% 91.0% Capacity returns, SOH pins
Rate-induced drops read as anomalies, no false SOH hit.
7.3 Different Chemistries
How this algorithm fares across chemistries:
LFP (lithium iron phosphate):
- Long cycle life (3000–5000)
- Flat fade curve
- Linear model fits well
- Fit rating: ★★★★★
NCM (nickel cobalt manganese):
- Medium life (1500–2500)
- Fast early fade, slow after
- Linear model is off
- Fit rating: ★★★☆☆ (use a nonlinear model)
LTO (lithium titanate):
- Massive cycle life (10,000+)
- Very slow fade
- Measured-capacity convergence is slow
- Fit rating: ★★★★☆ (cycle counting is the right lead)
Lead-acid:
- Short life (300–500 cycles)
- Fast, nonlinear fade
- Heavy temperature dependence
- Fit rating: ★★☆☆☆ (needs substantial parameter rework)
8. SOH in the Wider BMS
8.1 Dynamic Power Limits
Lower SOH means higher internal resistance, so trim the allowed current:
u16 CalcMaxDischargeCurrent(void) {
u16 soh = GetGSohTenthAPI();
u16 baseCurr = GetRatedDischargeCurrent();
u16 maxCurr = baseCurr;
// start derating at SOH < 90%
if(soh < 900) {
// linear: 2% current drop per 1% SOH drop
maxCurr = baseCurr * soh / 900;
}
// hard limit at SOH < 80%
if(soh < 800) {
maxCurr = baseCurr * 60 / 100; // cap at 60%
}
return maxCurr;
}
8.2 Adaptive Charging
Aged packs need gentler charging:
void AdaptiveChargingStrategy(void) {
u16 soh = GetGSohTenthAPI();
if(soh >= 950) {
// healthy: fast charge
SetChargeVoltage(4.20);
SetChargeCurrent(1.0);
} else if(soh >= 900) {
// mild aging: standard
SetChargeVoltage(4.15);
SetChargeCurrent(0.8);
} else if(soh >= 850) {
// moderate aging: conservative
SetChargeVoltage(4.10);
SetChargeCurrent(0.5);
} else {
// heavy aging: protective
SetChargeVoltage(4.05);
SetChargeCurrent(0.3);
SetWarningFlag(BATTERY_AGING_WARNING);
}
}
8.3 Range Correction
SOH flows straight into range estimates:
u16 EstimateRemainingRange(void) {
u16 soc = GetGRealSocMilliAPI();
u16 soh = GetGSohTenthAPI();
u16 ratedRange = GetRatedRange(); // rated range (km)
// available range = rated × SOC × SOH
u16 range = ratedRange * soc / 1000 * soh / 1000;
// temperature adjustment
s16 temp = GetGAvgTempAPI();
if(temp < 0) {
range = range * (100 + temp) / 100;
}
// driving-style adjustment (historical consumption)
u16 avgConsumption = GetAvgEnergyConsumption();
u16 ratedConsumption = GetRatedEnergyConsumption();
range = range * ratedConsumption / avgConsumption;
return range;
}
8.4 Warranty and Warnings
void WarrantyAndWarningCheck(void) {
u16 soh = GetGSohTenthAPI();
u32 days = GetSystemRunDays();
u32 mileage = GetTotalMileage();
// SOH inside warranty window
if((days < 365 * 8) && (mileage < 150000)) { // 8 years or 150k km
if(soh < 700) { // SOH < 70%
SetFaultCode(FAULT_WARRANTY_SOH_LOW);
NotifyServiceCenter();
}
}
// tiered warnings
if(soh < 850) {
SetWarningLevel(WARNING_LEVEL_1); // L1: service recommended
}
if(soh < 820) {
SetWarningLevel(WARNING_LEVEL_2); // L2: reduced performance
}
if(soh < 800) {
SetWarningLevel(WARNING_LEVEL_3); // L3: forced replacement
LimitVehicleSpeed(80); // cap at 80 km/h
}
}
8.5 Second-Life Grading
typedef enum {
BATTERY_GRADE_A, // SOH > 90%, keep in vehicle
BATTERY_GRADE_B, // 80% < SOH ≤ 90%, second-life storage
BATTERY_GRADE_C, // 70% < SOH ≤ 80%, low-power applications
BATTERY_GRADE_D, // SOH ≤ 70%, recycle
} BatteryGrade_e;
BatteryGrade_e EvaluateBatteryGrade(void) {
u16 soh = GetGSohTenthAPI();
u32 cycle = GetGroupFadeCycleAPI();
u16 consistency = EvaluateCellConsistency();
// SOH leads the grading
if(soh > 900) {
return BATTERY_GRADE_A;
} else if(soh > 800) {
// also needs cell consistency
if(consistency > 950) { // good consistency
return BATTERY_GRADE_B;
} else {
return BATTERY_GRADE_C; // poor consistency, downgrade
}
} else if(soh > 700) {
return BATTERY_GRADE_C;
} else {
return BATTERY_GRADE_D;
}
}
9. Lessons from Deployment
9.1 Calibration Matters
SOH accuracy hinges on parameter calibration:
Key parameters:
- Rated cycle life (ratedCycle) - Comes from cycle-life testing - Varies across production lots - Rule of thumb: be conservative, leave headroom
- EOL SOH threshold - Hardcoded at 80% - Different applications want different values (EV vs. storage) - Rule of thumb: make it configurable
- Fusion threshold (5%) - Controls when methods switch roles - Tune against real data - Rule of thumb: 3–8%
- Slew-rate cap (0.5%) - Controls anomaly sensitivity - Too small: slow response. Too large: misses anomalies - Rule of thumb: 0.3–1.0%
9.2 How to Validate
Accelerated aging
Conditions:
- Temperature: 45°C (accelerated)
- Rate: 1C charge/discharge
- DOD: 100%
- Cadence: 10 cycles per day
Metrics:
- SOH curve fit against measured capacity
- Stability of fused output
- Behavior under abnormal conditions
Fleet road testing
Scenarios:
- City duty (frequent stops)
- Highway (constant speed)
- Mountain (high-power climbs)
- Temperature extremes (-20°C to 50°C)
Metrics:
- SOH correlation with actual range
- Effectiveness of temperature correction
- User experience (how often does SOH jump)
Long-term monitoring
Duration: 2–3 years
Data to collect:
- Per-cycle SOH
- Measured capacity
- Ambient temperature
- C-rate
- Fault history
Analysis axes:
- SOH prediction accuracy
- Anomaly recall rate
- Opportunities for algorithm work
9.3 Common Issues and Fixes
Issue 1: initial SOH isn't 100%
Symptom: fresh pack boots up showing SOH = 98% Cause: factory capacity slightly exceeds nameplate (e.g., 105Ah vs 100Ah) Fix: pin the initial value:
void GroupFadeSohInit(void) {
if(GetGroupFadeCycleAPI() == 0) { // fresh pack
sFadeInfo.soh = 1000; // force 100%
} else {
// load from EEPROM
}
}
Issue 2: SOH drops in winter, recovers in spring
Symptom: SOH goes 95% → 85% in winter, back to 90% in spring Cause: no temperature compensation Fix: suspend capacity learning in the cold:
if(GetGAvgTempAPI() < 5) { // below 5°C
DisableCapacityLearning();
}
Issue 3: SOH drops too fast
Symptom: SOH at 90% after 200 cycles (expected 98%) Causes:
- High-rate discharge dragging measured capacity down
- Rated cycle life miscalibrated Fixes:
- Gate capacity learning on low C-rate
- Recalibrate the rated cycle-life parameter
Issue 4: SOH never changes
Symptom: six months in, SOH still reads 100% Cause: user never does a full charge or discharge, learning never triggers Fix: relax the learning conditions:
// old: only trigger at 100% or 0%
// new: 95% or 5% is enough
if((soc >= 950) || (soc <= 50)) {
TriggerCapacityLearning();
}
9.4 Where to Take This
Direction 1: machine learning
Inputs:
- Historical SOH trajectories
- Temperature profiles
- Charge/discharge rates
- Voltage curve features
Outputs:
- SOH prediction
- Remaining-life estimate
- Anomaly detection
Why bother:
- Adapts to different use patterns
- Captures nonlinearity
- Early warning on anomalies
Direction 2: cloud-side analytics
Architecture:
- Edge: real-time SOH on the vehicle
- Cloud: large-scale modeling
Cloud-side functions:
- Cross-batch comparison
- Outlier pack identification
- Parameter optimization
- OTA algorithm updates
Why bother:
- Leverages fleet-scale data
- Continuous improvement
- Per-pack modeling
Direction 3: multi-signal fusion
Beyond capacity and cycle count, you can fuse in:
- Internal resistance growth
- Self-discharge rate
- Charge-curve evolution
- AC impedance spectroscopy
Why bother:
- Richer health picture
- Earlier fault signals
- More accurate life prediction
10. Wrapping Up
SOH is one of the hardest jobs in a BMS. Unlike SOC, there's no clean physical definition or direct measurement, you're inferring long-term health from bounded data under uncertain conditions.
This article walked through a fusion of measured capacity and cycle counting, drawn from a real automotive-grade BMS project. The approach is simple, but it's proven itself in the field:
- Measured capacity gives accuracy, the SOH reflects what's actually there
- Cycle counting gives stability, shrugs off single-measurement noise
- Fusion gives robustness, stays sane when things go sideways
And the limits are clear too: no temperature compensation, no C-rate compensation, no calendar aging. All directions for later work.
In practice, SOH isn't just a number. It's the bridge between the pack's state and what the user sees, feeding into range, charging, warnings, warranty calls. A good SOH algorithm balances accuracy, stability, and responsiveness, and it has to square theoretical models against engineering constraints.
Battery tech keeps moving and datasets keep growing, so SOH algorithms keep evolving too. From linear models to machine learning, from standalone estimates to cloud-scale analytics. But however the tech advances, two things stay foundational: a deep read of battery physics, and careful attention to engineering detail.
Reference files:
- CellFadeCalc.c/h: core SOH math
- CapInfoCalc.c/h: measured capacity
- CurrIntegral.c/h: cycle counting
- ParaIntEep.c/h: EEPROM management
Key constants:
- EOL_SOH = 800: end-of-life threshold (80%)
- SOH_FUSION_THRESHOLD = 50: fusion switch (5%)
- CurrIntegral.c/h: cycle counting
- ParaIntEep.c/h: EEPROM management
Key constants:
- EOL_SOH = 800: end-of-life threshold (80%)
- SOH_FUSION_THRESHOLD = 50: fusion switch (5%)
- SOH_MAX_DECLINE = 5: max single drop (0.5%)
- SOH_UPDATE_PERIOD: one update per cycle
Related standards:
- GB/T 31484-2015: Cycle life requirements and test methods for traction batteries in electric vehicles
- IEC 62660-1: Performance testing of lithium-ion cells for electric road vehicles
- SAE J2929: Electric and hybrid vehicle propulsion battery system safety