Lithium Battery SOH Estimation: Cross-Checking Measured Capacity Against Cycle C

4 阅读14分钟

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:

  1. Charge learning: from some SOC up to 100%, log the charge in ΔQ_chg, back out total capacity
  2. Discharge learning: from some SOC down to 0%, log the charge out ΔQ_dhg, back out total capacity
  3. 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:

  1. Direct: no model assumptions, reflects actual capacity
  2. Adaptive: tracks real aging
  3. Verifiable: offline capacity tests can check it

Limits:

  1. Slow convergence: multiple full cycles before the number settles
  2. Narrow operating window: users rarely go 0 to 100
  3. Temperature bias: cold measurements read low without actual aging
  4. 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:

  1. Cycle aging: each full cycle takes out a sliver of capacity
  2. Calendar aging: even sitting unused, capacity slowly drops
  3. 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:

  1. Always responsive: updates every cycle, no waiting
  2. Cheap: an accumulator and a divide
  3. Stable trend: single-measurement noise doesn't move it

Limits:

  1. Linear is a lie: actual fade isn't linear
  2. Ignores conditions: high temp and high C-rate accelerate aging, model doesn't know
  3. No calendar aging: long shelf life isn't captured
  4. 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  │   0100%    │  100%    │ 100%   │ Initial              │
  ├────────┼────────┼──────────┼──────────┼────────┼──────────────────────┤
  │  6 mo  │  20097.8%   │  98.0%   │ 98.0%  │ Δ<5%, use counting   │
  ├────────┼────────┼──────────┼──────────┼────────┼──────────────────────┤
  │ 12 mo  │  45095.2%   │  95.5%   │ 95.5%  │ Δ<5%, use counting   │
  ├────────┼────────┼──────────┼──────────┼────────┼──────────────────────┤
  │ 18 mo  │  72092.8%   │  92.8%   │ 92.8%  │ Same answer          │
  └────────┴────────┴──────────┴──────────┴────────┴──────────────────────┘

  Scenario 2: winter cold

  ┌────────┬────────┬──────────┬──────────┬────────┬──────────────────────────┐
  │  Time  │ Cycles │ Measured │ Counted  │ Fused  │           Note           │
  ├────────┼────────┼──────────┼──────────┼────────┼──────────────────────────┤
  │ 12 mo  │  45095.5%   │  95.5%   │ 95.5%  │ Normal temp              │
  ├────────┼────────┼──────────┼──────────┼────────┼──────────────────────────┤
  │ 13 mo  │  46588.2%   │  95.0%   │ 91.6%  │ Cold, average            │
  ├────────┼────────┼──────────┼──────────┼────────┼──────────────────────────┤
  │ 14 mo  │  48087.5%   │  94.5%   │ 91.0%  │ Still cold               │
  ├────────┼────────┼──────────┼──────────┼────────┼──────────────────────────┤
  │ 15 mo  │  49594.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  │  45095.5%   │  95.5%   │ 95.5%  │ Normal                │
  ├──────────┼────────┼──────────┼──────────┼────────┼───────────────────────┤
  │ 12.1 mo  │  45282.3%   │  95.3%   │ 88.8%  │ Bad measurement       │
  ├──────────┼────────┼──────────┼──────────┼────────┼───────────────────────┤
  │ 12.2 mo  │  45483.1%   │  95.1%   │ 88.3%  │ Slew-rate clamp kicks │
  ├──────────┼────────┼──────────┼──────────┼────────┼───────────────────────┤
  │ 12.3 mo  │  45695.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℃ │ 6070%          │
  ├──────┼─────────────────┤
  │  0℃  │ 8085%          │
  ├──────┼─────────────────┤
  │ 25℃  │ 100%            │
  ├──────┼─────────────────┤
  │ 45℃  │ 102105%        │
  └──────┴─────────────────┘

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     1594.5%        94.0%       94.3%     Normal
  Dec     -586.2%        93.5%       89.9%     Cold, average
  Jan     -1082.1%        93.0%       87.6%     Still cold
  Feb     593.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:

  1. Rated cycle life (ratedCycle) - Comes from cycle-life testing - Varies across production lots - Rule of thumb: be conservative, leave headroom
  2. EOL SOH threshold - Hardcoded at 80% - Different applications want different values (EV vs. storage) - Rule of thumb: make it configurable
  3. Fusion threshold (5%) - Controls when methods switch roles - Tune against real data - Rule of thumb: 3–8%
  4. 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:

  1. High-rate discharge dragging measured capacity down
  2. Rated cycle life miscalibrated Fixes:
  3. Gate capacity learning on low C-rate
  4. 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