Analysing degradation results¶
A campaign returns a time-resolved signal, four measurement tables, and degradation-mode diagnostics. This notebook goes through them in that order.
import plotly.express as px
from breathe_simulate import api_interface as api
from breathe_simulate.ageing import AgeingCycler, RptCycler
cell_name = "Molicel P45B"
CAP_AH = 4.5
ageing_builder = AgeingCycler(selected_unit="C", cell_capacity=CAP_AH)
rpt_builder = RptCycler(selected_unit="C", cell_capacity=CAP_AH)
ageing = ageing_builder.cyclic(
I_chg=1.0,
I_dch=-1.0,
I_cut=0.05,
V_max=4.2,
V_min=2.5,
t_rest_s=300,
t_max_cv_s=3600,
)
rpt = rpt_builder.build(
I_chg=1.0,
I_cut=0.05,
V_max=4.2,
V_min=2.5,
# two checks: the 1C reference (SoH and the SoC anchor) plus a slow C/3
# one, so the rate-dependent capacity gap is visible as the cell ages
capacity_checks=[{"current": 1.0, "reference": True}, {"current": 0.33}],
t_equilibration_s=1800,
pulses=[ # HPPC map: charge and discharge pulses at 80/50/20 % SoC
{"soc": 0.8, "current": -2.0, "duration_s": 30},
{"soc": 0.8, "current": 1.0, "duration_s": 30},
{"soc": 0.5, "current": -2.0, "duration_s": 30, "reference": True},
{"soc": 0.5, "current": 1.0, "duration_s": 30},
{"soc": 0.2, "current": -2.0, "duration_s": 30},
{"soc": 0.2, "current": 1.0, "duration_s": 30},
],
t_rest_s=300,
t_max_cv_s=3600,
reset_temperature=True,
)
1. Run the campaign¶
Two hundred cycles with an RPT every 50. The stop criteria are all
optional. Whichever fires first ends the campaign and is reported in
stop_condition.
Two options are on because this notebook analyses their output: the labelled timeseries and the differential analysis in section 5.
result = api.run_ageing_sim(
cell_name,
ageing,
rpt_cycler=rpt,
rpt_every_n_cycles=50,
max_cycles=200,
stop_on_soh_pct=80,
soc_reference="latest_rpt",
initialTemperature_degC=25.0,
ambientTemperature_degC=25.0,
heatTransferCoefficient=35.0,
return_timeseries=True, # section 2
timeseries_max_points_per_segment=150,
return_dva=True, # section 4
)
result.stop_condition
Running ageing campaign on 'Molicel P45B' (up to 200 cycles)... Campaign finished: max_cycles at cycle 200 (wall clock 00:02:14)
{'triggered': 'max_cycles', 'at_cycle': 200, 'at_time_s': 1806769.7907616678}
Printing the result shows what it holds.
result
AgeingSimulationResults campaign stopped: max_cycles at cycle 200, final SoH 92.72 % measurements .rpt (5 RPTs) | .dcir (30 pulses) | .dva (10 curves) | .performance (0 tests) trajectories .cycles (per cycle) | .timeseries (labelled, per segment) bookkeeping .stop_condition | .rpt_protocol | .ageing_protocol plots .plot_soh() .plot_capacity() .plot_dcir() .plot_degradation_modes() .plot_dva() .plot_ica() .plot_anode_potential() .plot_charge_time() .plot_timeseries() .performance.plot_dynamic_response()
2. The campaign as one labelled signal¶
return_timeseries=True returns the whole campaign on one time axis. Each
sample is labelled with its segment (ageing, rpt, performance,
conditioning), cycle number, and the RPT or test it belongs to. Headers
match run_sim. plot_timeseries takes a header or a list.
Downsampling is step-aware, so 30 s HPPC pulses stay visible inside hours-long RPTs.
result.plot_timeseries(["Voltage [V]", "Current [A]", "SoC"])
The same data as a frame. Slicing out any part of the campaign is a filter.
ts = result.timeseries
ts.groupby("segment", dropna=False).size().rename("samples")
segment ageing 29420 conditioning 1340 rpt 1708 Name: samples, dtype: int64
Filtering by protocol step¶
Each sample carries a step_number, the step's index within its segment.
All ageing cycles share one numbering because each block starts with a
calibration charge. ageing_protocol lists the meaning of each index, and
the timeseries carries the same text as step_label.
result.ageing_protocol
[{'phase': 0,
'steps': ['CC discharge at 4.5 A to 2.5 V',
'Rest for 300 s',
'CC charge at 4.5 A to 4.2 V',
'CV hold at 4.2 V to 0.225 A',
'Rest for 300 s']}]
Every charge leg of every cycle is one filter. Overlaying a few shows the fade:
charge_legs = ts[(ts["segment"] == "ageing") & (ts["step_number"] == 2)]
picked = charge_legs[charge_legs["cycle_number"].isin([1, 50, 100, 150, 200])]
# measure time from the start of each leg, so the legs overlay
picked = picked.assign(
leg_minutes=lambda d: (
(d["Time [s]"] - d.groupby("cycle_number")["Time [s]"].transform("min")) / 60.0
)
)
px.line(
picked,
x="leg_minutes",
y="Voltage [V]",
color="cycle_number",
title="Charge leg, every 50 cycles",
labels={"leg_minutes": "Time into charge leg [min]"},
)
result.rpt
| cycle_number | capacity_Ah | soh_pct | soc_ref_capacity_Ah | dcir_ohm | lli [%] | lli_sei [%] | lli_plating [%] | lam_ne [%] | lam_pe [%] | sei_thickness_m | li_plating [mol.m-3] | cracking | abs_ah_throughput_Ah | wh_in | wh_out | elapsed_time_s | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| rpt_number | |||||||||||||||||
| 0 | 0 | 4.437561 | 100.000000 | 4.437561 | 0.014378 | 0.195219 | 0.084881 | 0.110337 | 0.048326 | 0.0 | 3.237307e-09 | 27.525991 | 0.0 | 29.078189 | 51.013628 | 55.749361 | 4.030554e+04 |
| 1 | 50 | 4.344999 | 97.914117 | 4.344999 | 0.015264 | 2.126156 | 0.652467 | 1.473689 | 0.885109 | 0.0 | 1.374232e-08 | 367.643000 | 0.0 | 502.959324 | 952.191412 | 907.201577 | 4.924460e+05 |
| 2 | 100 | 4.263899 | 96.086536 | 4.263899 | 0.015753 | 3.855413 | 1.060001 | 2.795412 | 1.724707 | 0.0 | 1.965960e-08 | 697.374794 | 0.0 | 967.684271 | 1837.655627 | 1742.621748 | 9.372359e+05 |
| 3 | 150 | 4.187528 | 94.365528 | 4.187528 | 0.016112 | 5.488557 | 1.409753 | 4.078804 | 2.556363 | 0.0 | 2.425992e-08 | 1017.544212 | 0.0 | 1423.928532 | 2708.380006 | 2563.275053 | 1.375232e+06 |
| 4 | 200 | 4.114710 | 92.724581 | 4.114710 | 0.016411 | 7.046370 | 1.719402 | 5.326968 | 3.371376 | 0.0 | 2.812963e-08 | 1328.925063 | 0.0 | 1872.151955 | 3565.073877 | 3369.969330 | 1.806770e+06 |
Capacity checks: result.capacity¶
One row per check per RPT. The reference check feeds soh_pct and
anchors the SoC set points. This campaign declared a 1C reference and a
slow C/3 check. The slow check extracts more, and the widening gap tracks
the growing resistance.
result.capacity
| rpt_number | cycle_number | c_rate | check_index | current_A | capacity_Ah | reference | step_number | |
|---|---|---|---|---|---|---|---|---|
| 0 | 0 | 0 | 1.00 | 0 | 4.500 | 4.437561 | True | 3 |
| 1 | 0 | 0 | 0.33 | 1 | 1.485 | 4.445740 | False | 8 |
| 2 | 1 | 50 | 1.00 | 0 | 4.500 | 4.344999 | True | 3 |
| 3 | 1 | 50 | 0.33 | 1 | 1.485 | 4.354685 | False | 8 |
| 4 | 2 | 100 | 1.00 | 0 | 4.500 | 4.263899 | True | 3 |
| 5 | 2 | 100 | 0.33 | 1 | 1.485 | 4.273924 | False | 8 |
| 6 | 3 | 150 | 1.00 | 0 | 4.500 | 4.187528 | True | 3 |
| 7 | 3 | 150 | 0.33 | 1 | 1.485 | 4.197539 | False | 8 |
| 8 | 4 | 200 | 1.00 | 0 | 4.500 | 4.114710 | True | 3 |
| 9 | 4 | 200 | 0.33 | 1 | 1.485 | 4.124570 | False | 8 |
DCIR pulses: result.dcir¶
Every pulse of every RPT: SoC, C-rate, direction, resistance, and a
voltage_limited flag for pulses cut short. With an HPPC list this is the
resistance map against SoC, in both directions, across ageing.
result.dcir
| rpt_number | cycle_number | c_rate | soc | current_A | direction | duration_s | achieved_duration_s | dcir_ohm | voltage_limited | reference | step_number | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 0 | 0 | 2.0 | 0.8 | -9.0 | discharge | 30.0 | 30.0 | 0.015215 | False | False | 5 |
| 1 | 0 | 0 | 1.0 | 0.8 | 4.5 | charge | 30.0 | 30.0 | 0.017998 | False | False | 7 |
| 2 | 0 | 0 | 2.0 | 0.5 | -9.0 | discharge | 30.0 | 30.0 | 0.014378 | False | True | 11 |
| 3 | 0 | 0 | 1.0 | 0.5 | 4.5 | charge | 30.0 | 30.0 | 0.018483 | False | False | 13 |
| 4 | 0 | 0 | 2.0 | 0.2 | -9.0 | discharge | 30.0 | 30.0 | 0.015423 | False | False | 17 |
| 5 | 0 | 0 | 1.0 | 0.2 | 4.5 | charge | 30.0 | 30.0 | 0.031420 | False | False | 19 |
| 6 | 1 | 50 | 2.0 | 0.8 | -9.0 | discharge | 30.0 | 30.0 | 0.016093 | False | False | 5 |
| 7 | 1 | 50 | 1.0 | 0.8 | 4.5 | charge | 30.0 | 30.0 | 0.018923 | False | False | 7 |
| 8 | 1 | 50 | 2.0 | 0.5 | -9.0 | discharge | 30.0 | 30.0 | 0.015264 | False | True | 11 |
| 9 | 1 | 50 | 1.0 | 0.5 | 4.5 | charge | 30.0 | 30.0 | 0.019553 | False | False | 13 |
| 10 | 1 | 50 | 2.0 | 0.2 | -9.0 | discharge | 30.0 | 30.0 | 0.016259 | False | False | 17 |
| 11 | 1 | 50 | 1.0 | 0.2 | 4.5 | charge | 30.0 | 30.0 | 0.032709 | False | False | 19 |
| 12 | 2 | 100 | 2.0 | 0.8 | -9.0 | discharge | 30.0 | 30.0 | 0.016637 | False | False | 5 |
| 13 | 2 | 100 | 1.0 | 0.8 | 4.5 | charge | 30.0 | 30.0 | 0.019546 | False | False | 7 |
| 14 | 2 | 100 | 2.0 | 0.5 | -9.0 | discharge | 30.0 | 30.0 | 0.015753 | False | True | 11 |
| 15 | 2 | 100 | 1.0 | 0.5 | 4.5 | charge | 30.0 | 30.0 | 0.020200 | False | False | 13 |
| 16 | 2 | 100 | 2.0 | 0.2 | -9.0 | discharge | 30.0 | 30.0 | 0.016777 | False | False | 17 |
| 17 | 2 | 100 | 1.0 | 0.2 | 4.5 | charge | 30.0 | 30.0 | 0.033641 | False | False | 19 |
| 18 | 3 | 150 | 2.0 | 0.8 | -9.0 | discharge | 30.0 | 30.0 | 0.017098 | False | False | 5 |
| 19 | 3 | 150 | 1.0 | 0.8 | 4.5 | charge | 30.0 | 30.0 | 0.020085 | False | False | 7 |
| 20 | 3 | 150 | 2.0 | 0.5 | -9.0 | discharge | 30.0 | 30.0 | 0.016112 | False | True | 11 |
| 21 | 3 | 150 | 1.0 | 0.5 | 4.5 | charge | 30.0 | 30.0 | 0.020721 | False | False | 13 |
| 22 | 3 | 150 | 2.0 | 0.2 | -9.0 | discharge | 30.0 | 30.0 | 0.017225 | False | False | 17 |
| 23 | 3 | 150 | 1.0 | 0.2 | 4.5 | charge | 30.0 | 30.0 | 0.034471 | False | False | 19 |
| 24 | 4 | 200 | 2.0 | 0.8 | -9.0 | discharge | 30.0 | 30.0 | 0.017535 | False | False | 5 |
| 25 | 4 | 200 | 1.0 | 0.8 | 4.5 | charge | 30.0 | 30.0 | 0.020701 | False | False | 7 |
| 26 | 4 | 200 | 2.0 | 0.5 | -9.0 | discharge | 30.0 | 30.0 | 0.016411 | False | True | 11 |
| 27 | 4 | 200 | 1.0 | 0.5 | 4.5 | charge | 30.0 | 30.0 | 0.021179 | False | False | 13 |
| 28 | 4 | 200 | 2.0 | 0.2 | -9.0 | discharge | 30.0 | 30.0 | 0.017597 | False | False | 17 |
| 29 | 4 | 200 | 1.0 | 0.2 | 4.5 | charge | 30.0 | 30.0 | 0.035275 | False | False | 19 |
Per-cycle trajectory: result.cycles¶
One row per ageing cycle.
discharge_capacity_Ah/charge_capacity_Ah: the charge actually moved. Regen legs count as charge.soh_pct: the cycle's discharge relative to the campaign's largest.result.rptkeeps the calibrated SoH.charge_time_10_80_pct_s: charging time from 10 % to 80 % SoC. The window comes fromcharge_time_soc_windowand the column is named after it. Cycles that never traverse the window report nothing.min_anode_potential_mVand the LLI split: section 5.
result.cycles
| cycle_number | phase | time_s | lli [%] | lli_sei [%] | lli_plating [%] | lam_ne [%] | lam_pe [%] | sei_thickness_m | li_plating [mol.m-3] | cracking | min_anode_potential_mV | charge_time_10_80_pct_s | discharge_capacity_Ah | charge_capacity_Ah | soh_pct | abs_ah_throughput_Ah | wh_in | wh_out | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 1 | 0 | 5.284635e+04 | 0.265615 | 0.110487 | 0.155127 | 0.072336 | 0.0 | 3.824135e-09 | 38.699780 | 0.0 | 60.669721 | 2485.034145 | 4.429459 | 4.429628 | 100.000000 | 42.368136 | 84.665044 | 71.665136 |
| 1 | 2 | 0 | 6.103541e+04 | 0.305069 | 0.125340 | 0.179729 | 0.087720 | 0.0 | 4.150400e-09 | 44.837202 | 0.0 | 60.568504 | 2485.034145 | 4.427558 | 4.427782 | 99.957074 | 51.223476 | 101.484423 | 87.574017 |
| 2 | 3 | 0 | 6.922135e+04 | 0.344129 | 0.139720 | 0.204408 | 0.103219 | 0.0 | 4.460776e-09 | 50.993958 | 0.0 | 60.476429 | 2485.034145 | 4.425666 | 4.425935 | 99.914362 | 60.075077 | 118.297751 | 103.476109 |
| 3 | 4 | 0 | 7.740480e+04 | 0.382986 | 0.153657 | 0.229329 | 0.118844 | 0.0 | 4.757284e-09 | 57.211009 | 0.0 | 60.387999 | 2485.034145 | 4.423865 | 4.424127 | 99.873708 | 68.923070 | 135.105063 | 119.371683 |
| 4 | 5 | 0 | 8.558580e+04 | 0.421381 | 0.167199 | 0.254182 | 0.134520 | 0.0 | 5.041319e-09 | 63.411142 | 0.0 | 60.306868 | 2485.034145 | 4.422062 | 4.422340 | 99.833008 | 77.767472 | 151.906483 | 135.260783 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 195 | 196 | 0 | 1.736165e+06 | 6.812470 | 1.677681 | 5.134788 | 3.264140 | 0.0 | 2.753500e-08 | 1280.981797 | 0.0 | 54.091445 | 2345.015583 | 4.122906 | 4.123159 | 93.079219 | 1810.146336 | 3454.377065 | 3250.781982 |
| 196 | 197 | 0 | 1.743873e+06 | 6.840159 | 1.683077 | 5.157082 | 3.278857 | 0.0 | 2.760129e-08 | 1286.543417 | 0.0 | 54.073566 | 2345.015583 | 4.121599 | 4.121921 | 93.049714 | 1818.389856 | 3470.139271 | 3265.620565 |
| 197 | 198 | 0 | 1.751579e+06 | 6.867831 | 1.688464 | 5.179367 | 3.293576 | 0.0 | 2.766741e-08 | 1292.102944 | 0.0 | 54.055731 | 2345.015583 | 4.120316 | 4.120614 | 93.020754 | 1826.630786 | 3485.896873 | 3280.454690 |
| 198 | 199 | 0 | 1.759282e+06 | 6.895481 | 1.693842 | 5.201639 | 3.308296 | 0.0 | 2.773338e-08 | 1297.659073 | 0.0 | 54.037934 | 2345.015583 | 4.119022 | 4.119311 | 92.991543 | 1834.869120 | 3501.649892 | 3295.284315 |
| 199 | 200 | 0 | 1.766984e+06 | 6.923114 | 1.699203 | 5.223911 | 3.323017 | 0.0 | 2.779918e-08 | 1303.215336 | 0.0 | 54.017058 | 2345.015583 | 4.117720 | 4.117976 | 92.962144 | 1843.104817 | 3517.398203 | 3310.109409 |
200 rows × 19 columns
4. The plots¶
plot_soh() draws the RPT measurements. per_cycle=True overlays the
protocol's own per-cycle view, useful where the two diverge, for example a
SoC window whose per-cycle value stays flat until the cell can no longer
complete it.
result.plot_soh(per_cycle=True)
result.plot_capacity()
result.plot_dcir()
plot_charge_time() is a staircase, and the staircase comes from coulomb
counting. The 10 % and 80 % marks are counted against the reference
capacity from the latest RPT, so between check-ups the window is a fixed
number of amp hours and the time is constant. Each plateau is 70 % of the
reference capacity divided by the charging current. When an RPT re-measures
the capacity, the window shrinks and the time steps down.
The cell fades every cycle, but the protocol can only anchor to the last
measured capacity, like a lab. For a per-cycle signal, charge_capacity_Ah
in result.cycles falls every cycle.
result.plot_charge_time()
5. Why it faded: degradation-mode diagnostics¶
The physics model reports the mechanisms behind the fade, per cycle.
result.plot_degradation_modes()
The LLI split: SEI growth versus lithium plating¶
lli [%] splits exactly into lli_sei [%] (consumed by SEI growth) and
lli_plating [%] (locked up as plated metal). In a lab this attribution
needs destructive post-mortem analysis.
SEI dominated fade responds to storage SoC and temperature. Plating dominated fade responds to charge rate, cut-off voltage and cold-charging limits, and can become a safety concern before the capacity numbers look bad.
result.cycles[["cycle_number", "lli [%]", "lli_sei [%]", "lli_plating [%]"]].iloc[::25]
| cycle_number | lli [%] | lli_sei [%] | lli_plating [%] | |
|---|---|---|---|---|
| 0 | 1 | 0.265615 | 0.110487 | 0.155127 |
| 25 | 26 | 1.174341 | 0.401692 | 0.772648 |
| 50 | 51 | 2.183083 | 0.665261 | 1.517822 |
| 75 | 76 | 2.976207 | 0.859762 | 2.116445 |
| 100 | 101 | 3.908778 | 1.070524 | 2.838254 |
| 125 | 126 | 4.661019 | 1.237017 | 3.424001 |
| 150 | 151 | 5.539217 | 1.418945 | 4.120272 |
| 175 | 176 | 6.253159 | 1.565679 | 4.687480 |
Plating risk: the minimum anode potential¶
Plating becomes favourable below 0 mV anode potential.
plot_anode_potential() tracks each cycle's minimum with the threshold
marked. A 1C protocol stays well clear. A fast-charge protocol trending
toward the line as the cell ages is an early warning.
result.plot_anode_potential()
Differential analysis: DVA and ICA¶
return_dva=True returns every RPT differentiated on both branches. DVA is
|dV/dQ| against capacity: peaks are electrode phase transitions, peaks
moving closer together indicate loss of active material, and the pattern
sliding along the Q axis indicates loss of lithium inventory. ICA is
|dQ/dV| against voltage. Discharge branches plot solid, charge dashed, and
branch= selects.
result.plot_dva()
result.plot_ica(branch="charge")
The signed curves are also a table, one row per point, labelled by RPT and branch:
result.dva.head()
| rpt_number | cycle_number | branch | capacity_Ah | voltage_V | dv_dq | dq_dv | |
|---|---|---|---|---|---|---|---|
| 0 | 0 | 0 | charge | 0.000000 | 2.685118 | 15.146793 | 0.066021 |
| 1 | 0 | 0 | charge | 0.014309 | 2.889001 | 10.925950 | 0.094525 |
| 2 | 0 | 0 | charge | 0.028617 | 2.996321 | 6.461680 | 0.157448 |
| 3 | 0 | 0 | charge | 0.042926 | 3.073300 | 4.719355 | 0.215105 |
| 4 | 0 | 0 | charge | 0.057234 | 3.131483 | 3.601955 | 0.280984 |