Extending Degradation Models¶
How to implement new calendar and cyclic aging laws, compose them into a DegradationModel, and plug them into the existing test suite.
Who this is for
Researchers fitting an aging model to measurement data, or engineers wanting to simulate a chemistry whose aging differs from the Naumann LFP defaults. For the conceptual picture — SoH axes, running totals, why sub-models are stateless — see Degradation concept first.
The two protocols¶
Degradation splits into two independent sub-models, each a Protocol. Neither requires inheritance — structural subtyping only.
CalendarDegradation¶
Fires every timestep — even when the battery is idle.
def update_capacity(self, state: BatteryState, dt: float, accumulated_qloss: float) -> float: ...
def update_resistance(self, state: BatteryState, dt: float) -> float: ...
state— current battery state (read SOC, T, etc.).dt— timestep in seconds.accumulated_qloss— calendar capacity loss accumulated so far (p.u., ≥ 0). Your model reads this to continue a non-linear aging law under varying stress; memoryless laws can ignore it.- Returns a non-negative delta — never an absolute value.
CyclicDegradation¶
Fires only on completed half-cycles — DegradationModel delegates to the HalfCycleDetector for triggering.
def update_capacity(self, state: BatteryState, half_cycle: HalfCycle, accumulated_qloss: float) -> float: ...
def update_resistance(self, state: BatteryState, half_cycle: HalfCycle) -> float: ...
half_cycle— aHalfCyclecarryingdepth_of_discharge,mean_soc,c_rate, andfull_equivalent_cycles.- Same
accumulated_qlosspattern on the capacity side. - Same delta-only return convention.
The statelessness rule¶
Both sub-models must be stateless. All accumulators live on the DegradationState that DegradationModel owns. The framework passes accumulated_qloss into update_capacity so your model can reconstruct history without storing anything internally; resistance rise doesn't accumulate the same way (most rise laws are memoryless in their independent variable).
This rule keeps checkpointing, warm-starts, and sub-model swapping trivial — the only state lives in one place.
Worked walkthrough: √t calendar + DoD² cyclic¶
examples/extending/custom_degradation.py implements a minimal pair:
Calendar — q_cal(t) = s(T) · √t with a temperature-dependent stress factor, using virtual-time continuation to stay correct under varying T:
import math
from simses.battery.state import BatteryState
class SqrtTimeCalendar:
K_REF = 1e-5 # [1/sqrt(s)] loss rate at T_ref
T_REF = 25.0 # [°C]
T_ACC = 20.0 # [K] Q10-style acceleration
def _stress(self, T: float) -> float:
return self.K_REF * math.exp((T - self.T_REF) / self.T_ACC)
def update_capacity(self, state: BatteryState, dt: float, accumulated_qloss: float) -> float:
stress = self._stress(state.T)
if stress <= 0.0:
return 0.0
t_virt = (accumulated_qloss / stress) ** 2
return stress * math.sqrt(t_virt + dt) - accumulated_qloss
def update_resistance(self, state: BatteryState, dt: float) -> float:
return 1e-8 * self._stress(state.T) / self.K_REF * dt
The capacity method inverts the √t law at each call to find the virtual time that would have produced accumulated_qloss under the current stress, then steps forward — so T can change between steps without double-counting. If your law is linear in time (dq = k · dt), just ignore accumulated_qloss and return k(state) · dt. If it follows a different exponent (t^0.75, SEI double-exponential, etc.), apply the same inversion principle with the right formula.
Cyclic — Δq_cyc = K_CYC · DoD² · ΔFEC per completed half-cycle, no memory across cycles:
from simses.degradation.cycle_detector import HalfCycle
class DodSquaredCyclic:
K_CYC = 1e-2
K_RINC = 5e-3
def update_capacity(self, state: BatteryState, half_cycle: HalfCycle, accumulated_qloss: float) -> float:
return self.K_CYC * half_cycle.depth_of_discharge**2 * half_cycle.full_equivalent_cycles
def update_resistance(self, state: BatteryState, half_cycle: HalfCycle) -> float:
return self.K_RINC * half_cycle.depth_of_discharge**2 * half_cycle.full_equivalent_cycles
Composing and attaching¶
A DegradationModel combines the two sub-models with a HalfCycleDetector seeded by the battery's initial SOC:
from simses.degradation import DegradationModel
degradation = DegradationModel(
calendar=SqrtTimeCalendar(),
cyclic=DodSquaredCyclic(),
initial_soc=0.5,
)
battery = Battery(
cell=SonyLFP(),
circuit=(13, 10),
initial_states={"start_soc": 0.5, "start_T": 25.0},
degradation=degradation,
)
Calendar-only / cyclic-only¶
For studies where you want only one mechanism active — decomposing an observed fade curve against experiment, isolating the effect of cycling, etc. — two factory methods inject a no-op on the other leg:
DegradationModel.calendar_only(SqrtTimeCalendar(), initial_soc=0.5)
DegradationModel.cyclic_only(DodSquaredCyclic(), initial_soc=0.5)
Warm-starting from a prior history¶
Pass an explicit DegradationState to start from a non-fresh battery:
from simses.degradation.state import DegradationState
prior = DegradationState(qloss_cal=0.05, qloss_cyc=0.02)
DegradationModel(
calendar=SqrtTimeCalendar(), cyclic=DodSquaredCyclic(),
initial_soc=0.5, initial_state=prior,
)
The virtual-time / virtual-FEC continuation picks up seamlessly from the accumulated damage.
Testing with the spec registries¶
tests/test_degradation_models.py keeps two separate registries — calendar and cyclic can be tested independently. Append a spec to each:
# tests/test_degradation_models.py
CALENDAR_SPECS = [
CalendarModelSpec(name="SonyLFPCalendar", factory=SonyLFPCalendarDegradation),
CalendarModelSpec(name="SqrtTimeCalendar", factory=SqrtTimeCalendar),
]
CYCLIC_SPECS = [
CyclicModelSpec(name="SonyLFPCyclic", factory=SonyLFPCyclicDegradation),
CyclicModelSpec(name="DodSquaredCyclic", factory=DodSquaredCyclic),
]
The generic tests check:
- Positive capacity loss and resistance rise under realistic stress.
- Zero-
dt/ zero-FEC yields zero degradation. - More time / more FEC produces more loss (monotonicity).
Model-specific tests (e.g. the SonyLFP √t-behaviour test and the accumulated-loss-continuity test) live alongside the generic ones in the same file — copy the TestSonyLFPCalendar class as a template for a targeted behaviour check on your own model.
Shipping as a cell default¶
Once your calendar and cyclic pair is written, wire it into a CellType subclass so Battery(..., degradation=True) works out of the box:
from simses.battery.cell import CellType
from simses.degradation import DegradationModel
class MyCell(CellType):
# ... __init__, open_circuit_voltage, internal_resistance ...
@classmethod
def default_degradation_model(
cls,
initial_soc: float,
initial_state=None,
) -> DegradationModel:
return DegradationModel(
calendar=SqrtTimeCalendar(),
cyclic=DodSquaredCyclic(),
initial_soc=initial_soc,
initial_state=initial_state,
)
See Extending Cell Models for the complete cell-class context.
See Also¶
- Degradation concept — SoH axes, running totals, virtual-time continuation.
examples/extending/custom_degradation.py— the full runnable walkthrough.SonyLFPCalendarDegradationandSonyLFPCyclicDegradation— reference implementations grounded in the Naumann 2018 / 2020 measurements.CalendarDegradation/CyclicDegradationAPI reference.