Skip to content

Degradation API

The simses.degradation module composes a calendar and cyclic aging sub-model with a half-cycle detector into a single DegradationModel that Battery steps every timestep. For the SoH split, statelessness rule, and virtual-time continuation, see the Degradation concept page.

Degradation model

Composer for the two sub-models. Owns the DegradationState accumulators and runs the calendar pass every step, plus a cyclic pass each time a half-cycle completes. Provides calendar_only / cyclic_only factories for isolated studies.

simses.degradation.degradation.DegradationModel

Composes calendar and cyclic degradation with a half-cycle detector.

This is the object that gets passed to Battery(degradation=...). It owns a :class:~simses.degradation.state.DegradationState that accumulates all capacity loss and resistance increase components. Sub-models are stateless and receive the current accumulated values on each call.

Source code in src/simses/degradation/degradation.py
class DegradationModel:
    """Composes calendar and cyclic degradation with a half-cycle detector.

    This is the object that gets passed to ``Battery(degradation=...)``.
    It owns a :class:`~simses.degradation.state.DegradationState` that
    accumulates all capacity loss and resistance increase components.
    Sub-models are stateless and receive the current accumulated values on
    each call.
    """

    def __init__(
        self,
        calendar: CalendarDegradation,
        cyclic: CyclicDegradation,
        initial_soc: float,
        initial_state: DegradationState | None = None,
    ) -> None:
        """
        Args:
            calendar: Calendar aging sub-model.
            cyclic: Cyclic aging sub-model.
            initial_soc: SOC at the start of the simulation in p.u. Seeds
                the :class:`HalfCycleDetector`.
            initial_state: Optional pre-existing :class:`DegradationState`
                for warm-starting from a known aging history. ``None``
                (default) starts from a fresh state.
        """
        self.calendar = calendar
        self.cyclic = cyclic
        self.cycle_detector = HalfCycleDetector(initial_soc)
        self.state = initial_state if initial_state is not None else DegradationState()

    @classmethod
    def calendar_only(
        cls,
        calendar: CalendarDegradation,
        initial_soc: float,
        initial_state: DegradationState | None = None,
    ) -> "DegradationModel":
        """Create a model with only calendar aging (no cyclic component)."""
        return cls(calendar=calendar, cyclic=_NoOpCyclic(), initial_soc=initial_soc, initial_state=initial_state)

    @classmethod
    def cyclic_only(
        cls,
        cyclic: CyclicDegradation,
        initial_soc: float,
        initial_state: DegradationState | None = None,
    ) -> "DegradationModel":
        """Create a model with only cyclic aging (no calendar component)."""
        return cls(calendar=_NoOpCalendar(), cyclic=cyclic, initial_soc=initial_soc, initial_state=initial_state)

    def step(self, state: BatteryState, dt: float) -> None:
        """Run one degradation timestep.

        1. Calendar aging is applied every timestep.
        2. The cycle detector checks for SOC direction reversals; when a
           half-cycle completes, cyclic aging is applied.
        """
        # Calendar aging
        dq_cal = self.calendar.update_capacity(state, dt, self.state.qloss_cal)
        dr_cal = self.calendar.update_resistance(state, dt)
        self.state.qloss_cal += dq_cal
        self.state.rinc_cal += dr_cal
        state.soh_Q -= dq_cal
        state.soh_R += dr_cal

        # Cycle detection + cyclic aging
        if self.cycle_detector.step(state.soc, dt):
            half_cycle = self.cycle_detector.last_cycle
            dq_cyc = self.cyclic.update_capacity(state, half_cycle, self.state.qloss_cyc)
            dr_cyc = self.cyclic.update_resistance(state, half_cycle)
            self.state.qloss_cyc += dq_cyc
            self.state.rinc_cyc += dr_cyc
            state.soh_Q -= dq_cyc
            state.soh_R += dr_cyc

__init__(calendar, cyclic, initial_soc, initial_state=None)

Parameters:

Name Type Description Default
calendar CalendarDegradation

Calendar aging sub-model.

required
cyclic CyclicDegradation

Cyclic aging sub-model.

required
initial_soc float

SOC at the start of the simulation in p.u. Seeds the :class:HalfCycleDetector.

required
initial_state DegradationState | None

Optional pre-existing :class:DegradationState for warm-starting from a known aging history. None (default) starts from a fresh state.

None
Source code in src/simses/degradation/degradation.py
def __init__(
    self,
    calendar: CalendarDegradation,
    cyclic: CyclicDegradation,
    initial_soc: float,
    initial_state: DegradationState | None = None,
) -> None:
    """
    Args:
        calendar: Calendar aging sub-model.
        cyclic: Cyclic aging sub-model.
        initial_soc: SOC at the start of the simulation in p.u. Seeds
            the :class:`HalfCycleDetector`.
        initial_state: Optional pre-existing :class:`DegradationState`
            for warm-starting from a known aging history. ``None``
            (default) starts from a fresh state.
    """
    self.calendar = calendar
    self.cyclic = cyclic
    self.cycle_detector = HalfCycleDetector(initial_soc)
    self.state = initial_state if initial_state is not None else DegradationState()

calendar_only(calendar, initial_soc, initial_state=None) classmethod

Create a model with only calendar aging (no cyclic component).

Source code in src/simses/degradation/degradation.py
@classmethod
def calendar_only(
    cls,
    calendar: CalendarDegradation,
    initial_soc: float,
    initial_state: DegradationState | None = None,
) -> "DegradationModel":
    """Create a model with only calendar aging (no cyclic component)."""
    return cls(calendar=calendar, cyclic=_NoOpCyclic(), initial_soc=initial_soc, initial_state=initial_state)

cyclic_only(cyclic, initial_soc, initial_state=None) classmethod

Create a model with only cyclic aging (no calendar component).

Source code in src/simses/degradation/degradation.py
@classmethod
def cyclic_only(
    cls,
    cyclic: CyclicDegradation,
    initial_soc: float,
    initial_state: DegradationState | None = None,
) -> "DegradationModel":
    """Create a model with only cyclic aging (no calendar component)."""
    return cls(calendar=_NoOpCalendar(), cyclic=cyclic, initial_soc=initial_soc, initial_state=initial_state)

step(state, dt)

Run one degradation timestep.

  1. Calendar aging is applied every timestep.
  2. The cycle detector checks for SOC direction reversals; when a half-cycle completes, cyclic aging is applied.
Source code in src/simses/degradation/degradation.py
def step(self, state: BatteryState, dt: float) -> None:
    """Run one degradation timestep.

    1. Calendar aging is applied every timestep.
    2. The cycle detector checks for SOC direction reversals; when a
       half-cycle completes, cyclic aging is applied.
    """
    # Calendar aging
    dq_cal = self.calendar.update_capacity(state, dt, self.state.qloss_cal)
    dr_cal = self.calendar.update_resistance(state, dt)
    self.state.qloss_cal += dq_cal
    self.state.rinc_cal += dr_cal
    state.soh_Q -= dq_cal
    state.soh_R += dr_cal

    # Cycle detection + cyclic aging
    if self.cycle_detector.step(state.soc, dt):
        half_cycle = self.cycle_detector.last_cycle
        dq_cyc = self.cyclic.update_capacity(state, half_cycle, self.state.qloss_cyc)
        dr_cyc = self.cyclic.update_resistance(state, half_cycle)
        self.state.qloss_cyc += dq_cyc
        self.state.rinc_cyc += dr_cyc
        state.soh_Q -= dq_cyc
        state.soh_R += dr_cyc

Degradation state

Non-negative p.u. accumulators for calendar and cyclic capacity fade and resistance rise. The only place aging state lives — sub-models are stateless.

simses.degradation.state.DegradationState dataclass

Accumulated degradation components (all values in p.u., non-negative).

This is the authoritative state for a :class:DegradationModel. Calendar and cyclic sub-models are stateless — they receive the current accumulated value as an argument to their update method and return deltas, which the DegradationModel applies here.

Source code in src/simses/degradation/state.py
@dataclass(slots=True)
class DegradationState:
    """Accumulated degradation components (all values in p.u., non-negative).

    This is the authoritative state for a :class:`DegradationModel`.  Calendar
    and cyclic sub-models are stateless — they receive the current accumulated
    value as an argument to their ``update`` method and return deltas, which the
    ``DegradationModel`` applies here.
    """

    qloss_cal: float = 0.0  # calendar capacity loss
    qloss_cyc: float = 0.0  # cyclic capacity loss
    rinc_cal: float = 0.0  # calendar resistance increase
    rinc_cyc: float = 0.0  # cyclic resistance increase

Calendar degradation

Protocol for time-based aging laws (T, SOC, time). Every timestep receives the running accumulated_qloss so non-linear laws can continue correctly under varying stress. See Extending Degradation Models.

simses.degradation.calendar.CalendarDegradation

Bases: Protocol

Protocol for calendar aging models.

Implementations are stateless: all accumulated values live in :class:~simses.degradation.state.DegradationState (owned by the :class:~simses.degradation.degradation.DegradationModel). The current accumulated values are passed in on every call so that models using virtual-time continuation can compute the correct increments without maintaining their own internal state.

Source code in src/simses/degradation/calendar.py
class CalendarDegradation(Protocol):
    """Protocol for calendar aging models.

    Implementations are **stateless**: all accumulated values live in
    :class:`~simses.degradation.state.DegradationState` (owned by the
    :class:`~simses.degradation.degradation.DegradationModel`).  The current
    accumulated values are passed in on every call so that models using
    virtual-time continuation can compute the correct increments without
    maintaining their own internal state.
    """

    def update_capacity(self, state: BatteryState, dt: float, accumulated_qloss: float) -> float:
        """Compute incremental calendar capacity loss.

        Args:
            state: Current battery state.
            dt: Timestep in seconds.
            accumulated_qloss: Calendar capacity loss accumulated so far (p.u.,
                positive), used to seed virtual-time continuation.

        Returns:
            delta_qloss — positive increment in p.u. (capacity loss increases).
        """
        ...

    def update_resistance(self, state: BatteryState, dt: float) -> float:
        """Compute incremental calendar resistance increase.

        Args:
            state: Current battery state.
            dt: Timestep in seconds.

        Returns:
            delta_soh_R — positive increment in p.u. (resistance increases).
        """
        ...

update_capacity(state, dt, accumulated_qloss)

Compute incremental calendar capacity loss.

Parameters:

Name Type Description Default
state BatteryState

Current battery state.

required
dt float

Timestep in seconds.

required
accumulated_qloss float

Calendar capacity loss accumulated so far (p.u., positive), used to seed virtual-time continuation.

required

Returns:

Type Description
float

delta_qloss — positive increment in p.u. (capacity loss increases).

Source code in src/simses/degradation/calendar.py
def update_capacity(self, state: BatteryState, dt: float, accumulated_qloss: float) -> float:
    """Compute incremental calendar capacity loss.

    Args:
        state: Current battery state.
        dt: Timestep in seconds.
        accumulated_qloss: Calendar capacity loss accumulated so far (p.u.,
            positive), used to seed virtual-time continuation.

    Returns:
        delta_qloss — positive increment in p.u. (capacity loss increases).
    """
    ...

update_resistance(state, dt)

Compute incremental calendar resistance increase.

Parameters:

Name Type Description Default
state BatteryState

Current battery state.

required
dt float

Timestep in seconds.

required

Returns:

Type Description
float

delta_soh_R — positive increment in p.u. (resistance increases).

Source code in src/simses/degradation/calendar.py
def update_resistance(self, state: BatteryState, dt: float) -> float:
    """Compute incremental calendar resistance increase.

    Args:
        state: Current battery state.
        dt: Timestep in seconds.

    Returns:
        delta_soh_R — positive increment in p.u. (resistance increases).
    """
    ...

Cyclic degradation

Protocol for throughput-based aging laws. Called on each completed half-cycle with a HalfCycle object carrying the stress factors.

simses.degradation.cyclic.CyclicDegradation

Bases: Protocol

Protocol for cyclic aging models.

Implementations are stateless: all accumulated values live in :class:~simses.degradation.state.DegradationState (owned by the :class:~simses.degradation.degradation.DegradationModel). The current accumulated values are passed in on every call so that models using virtual-FEC continuation can compute the correct increments without maintaining their own internal state.

Source code in src/simses/degradation/cyclic.py
class CyclicDegradation(Protocol):
    """Protocol for cyclic aging models.

    Implementations are **stateless**: all accumulated values live in
    :class:`~simses.degradation.state.DegradationState` (owned by the
    :class:`~simses.degradation.degradation.DegradationModel`).  The current
    accumulated values are passed in on every call so that models using
    virtual-FEC continuation can compute the correct increments without
    maintaining their own internal state.
    """

    def update_capacity(self, state: BatteryState, half_cycle: HalfCycle, accumulated_qloss: float) -> float:
        """Compute incremental cyclic capacity loss for a completed half-cycle.

        Args:
            state: Current battery state.
            half_cycle: Stress factors of the completed half-cycle.
            accumulated_qloss: Cyclic capacity loss accumulated so far (p.u.,
                positive), used to seed virtual-FEC continuation.

        Returns:
            delta_qloss — positive increment in p.u. (capacity loss increases).
        """
        ...

    def update_resistance(self, state: BatteryState, half_cycle: HalfCycle) -> float:
        """Compute incremental cyclic resistance increase for a completed half-cycle.

        Args:
            state: Current battery state.
            half_cycle: Stress factors of the completed half-cycle.

        Returns:
            delta_soh_R — positive increment in p.u. (resistance increases).
        """
        ...

update_capacity(state, half_cycle, accumulated_qloss)

Compute incremental cyclic capacity loss for a completed half-cycle.

Parameters:

Name Type Description Default
state BatteryState

Current battery state.

required
half_cycle HalfCycle

Stress factors of the completed half-cycle.

required
accumulated_qloss float

Cyclic capacity loss accumulated so far (p.u., positive), used to seed virtual-FEC continuation.

required

Returns:

Type Description
float

delta_qloss — positive increment in p.u. (capacity loss increases).

Source code in src/simses/degradation/cyclic.py
def update_capacity(self, state: BatteryState, half_cycle: HalfCycle, accumulated_qloss: float) -> float:
    """Compute incremental cyclic capacity loss for a completed half-cycle.

    Args:
        state: Current battery state.
        half_cycle: Stress factors of the completed half-cycle.
        accumulated_qloss: Cyclic capacity loss accumulated so far (p.u.,
            positive), used to seed virtual-FEC continuation.

    Returns:
        delta_qloss — positive increment in p.u. (capacity loss increases).
    """
    ...

update_resistance(state, half_cycle)

Compute incremental cyclic resistance increase for a completed half-cycle.

Parameters:

Name Type Description Default
state BatteryState

Current battery state.

required
half_cycle HalfCycle

Stress factors of the completed half-cycle.

required

Returns:

Type Description
float

delta_soh_R — positive increment in p.u. (resistance increases).

Source code in src/simses/degradation/cyclic.py
def update_resistance(self, state: BatteryState, half_cycle: HalfCycle) -> float:
    """Compute incremental cyclic resistance increase for a completed half-cycle.

    Args:
        state: Current battery state.
        half_cycle: Stress factors of the completed half-cycle.

    Returns:
        delta_soh_R — positive increment in p.u. (resistance increases).
    """
    ...

Cycle detection

Rainflow-style detector that watches SOC across timesteps and emits a HalfCycle whenever the direction reverses. Drives the cyclic pass.

simses.degradation.cycle_detector.HalfCycleDetector

Detects half-cycles by tracking SOC direction reversals.

A half-cycle is completed when the SOC changes direction (from charging to discharging or vice versa). Rest periods (unchanged SOC) are ignored and do not trigger a cycle or contribute to elapsed time.

Attributes:

Name Type Description
total_fec float

Cumulative full equivalent cycles.

last_cycle HalfCycle | None

The most recently completed HalfCycle, or None.

Source code in src/simses/degradation/cycle_detector.py
class HalfCycleDetector:
    """Detects half-cycles by tracking SOC direction reversals.

    A half-cycle is completed when the SOC changes direction (from charging
    to discharging or vice versa). Rest periods (unchanged SOC) are ignored
    and do not trigger a cycle or contribute to elapsed time.

    Attributes:
        total_fec: Cumulative full equivalent cycles.
        last_cycle: The most recently completed HalfCycle, or None.
    """

    def __init__(self, initial_soc: float) -> None:
        """
        Args:
            initial_soc: SOC at the start of the simulation in p.u. The
                first :meth:`step` call compares against this value to
                establish the starting direction.
        """
        self._start_soc: float = initial_soc
        self._prev_soc: float = initial_soc
        self._direction: int = 0  # +1 charging, -1 discharging, 0 unknown
        self._elapsed_time: float = 0.0  # seconds
        self._soc_sum: float = 0.0  # for mean SOC calculation
        self._soc_samples: int = 0
        self.total_fec: float = 0.0
        self.last_cycle: HalfCycle | None = None

    def step(self, soc: float, dt: float) -> bool:
        """Update the detector with a new SOC value.

        Args:
            soc: Current state of charge in p.u.
            dt: Timestep in seconds.

        Returns:
            True if a half-cycle was completed (direction reversal detected).
        """
        delta = soc - self._prev_soc

        # Rest period — no SOC change
        if delta == 0.0:
            return False

        new_direction = 1 if delta > 0 else -1

        if self._direction == 0:
            # First movement — establish direction
            self._direction = new_direction
            self._elapsed_time += dt
            self._soc_sum += (self._prev_soc + soc) / 2
            self._soc_samples += 1
            self._prev_soc = soc
            return False

        if new_direction == self._direction:
            # Same direction — accumulate
            self._elapsed_time += dt
            self._soc_sum += (self._prev_soc + soc) / 2
            self._soc_samples += 1
            self._prev_soc = soc
            return False

        # Direction reversal — complete the half-cycle up to prev_soc
        cycle = self._make_half_cycle()
        self.last_cycle = cycle
        self.total_fec += cycle.full_equivalent_cycles

        # Start new half-cycle from prev_soc
        self._start_soc = self._prev_soc
        self._direction = new_direction
        self._elapsed_time = dt
        self._soc_sum = (self._prev_soc + soc) / 2
        self._soc_samples = 1
        self._prev_soc = soc
        return True

    def _make_half_cycle(self) -> HalfCycle:
        """Build a HalfCycle from the accumulated data."""
        dod = abs(self._prev_soc - self._start_soc)
        mean_soc = self._soc_sum / self._soc_samples if self._soc_samples > 0 else self._start_soc
        elapsed_hours = self._elapsed_time / 3600.0
        c_rate = dod / elapsed_hours if elapsed_hours > 0 else 0.0
        fec = dod / 2.0
        return HalfCycle(
            depth_of_discharge=dod,
            mean_soc=mean_soc,
            c_rate=c_rate,
            full_equivalent_cycles=fec,
        )

__init__(initial_soc)

Parameters:

Name Type Description Default
initial_soc float

SOC at the start of the simulation in p.u. The first :meth:step call compares against this value to establish the starting direction.

required
Source code in src/simses/degradation/cycle_detector.py
def __init__(self, initial_soc: float) -> None:
    """
    Args:
        initial_soc: SOC at the start of the simulation in p.u. The
            first :meth:`step` call compares against this value to
            establish the starting direction.
    """
    self._start_soc: float = initial_soc
    self._prev_soc: float = initial_soc
    self._direction: int = 0  # +1 charging, -1 discharging, 0 unknown
    self._elapsed_time: float = 0.0  # seconds
    self._soc_sum: float = 0.0  # for mean SOC calculation
    self._soc_samples: int = 0
    self.total_fec: float = 0.0
    self.last_cycle: HalfCycle | None = None

step(soc, dt)

Update the detector with a new SOC value.

Parameters:

Name Type Description Default
soc float

Current state of charge in p.u.

required
dt float

Timestep in seconds.

required

Returns:

Type Description
bool

True if a half-cycle was completed (direction reversal detected).

Source code in src/simses/degradation/cycle_detector.py
def step(self, soc: float, dt: float) -> bool:
    """Update the detector with a new SOC value.

    Args:
        soc: Current state of charge in p.u.
        dt: Timestep in seconds.

    Returns:
        True if a half-cycle was completed (direction reversal detected).
    """
    delta = soc - self._prev_soc

    # Rest period — no SOC change
    if delta == 0.0:
        return False

    new_direction = 1 if delta > 0 else -1

    if self._direction == 0:
        # First movement — establish direction
        self._direction = new_direction
        self._elapsed_time += dt
        self._soc_sum += (self._prev_soc + soc) / 2
        self._soc_samples += 1
        self._prev_soc = soc
        return False

    if new_direction == self._direction:
        # Same direction — accumulate
        self._elapsed_time += dt
        self._soc_sum += (self._prev_soc + soc) / 2
        self._soc_samples += 1
        self._prev_soc = soc
        return False

    # Direction reversal — complete the half-cycle up to prev_soc
    cycle = self._make_half_cycle()
    self.last_cycle = cycle
    self.total_fec += cycle.full_equivalent_cycles

    # Start new half-cycle from prev_soc
    self._start_soc = self._prev_soc
    self._direction = new_direction
    self._elapsed_time = dt
    self._soc_sum = (self._prev_soc + soc) / 2
    self._soc_samples = 1
    self._prev_soc = soc
    return True

simses.degradation.cycle_detector.HalfCycle dataclass

Stress factors for a completed half-cycle.

Attributes:

Name Type Description
depth_of_discharge float

Absolute SOC swing of the half-cycle in p.u.

mean_soc float

Average SOC during the half-cycle in p.u.

c_rate float

Average C-rate during the half-cycle in 1/h.

full_equivalent_cycles float

FEC contribution (depth_of_discharge / 2).

Source code in src/simses/degradation/cycle_detector.py
@dataclass(slots=True)
class HalfCycle:
    """Stress factors for a completed half-cycle.

    Attributes:
        depth_of_discharge: Absolute SOC swing of the half-cycle in p.u.
        mean_soc: Average SOC during the half-cycle in p.u.
        c_rate: Average C-rate during the half-cycle in 1/h.
        full_equivalent_cycles: FEC contribution (depth_of_discharge / 2).
    """

    depth_of_discharge: float
    mean_soc: float
    c_rate: float
    full_equivalent_cycles: float