Performance testing fresh and aged cells¶
An ageing campaign tells you how the cell degrade. A performance test tells
you what that degration does to the cell. This notebook closes the series by
running the same run_sim tests at beginning of life and on the aged
cell, and comparing them.
In this notebook
- Performance tests on the aged cell: any
run_simcycler executed on the live aged cell once the campaign stops, cross-checked against the RPT it follows - The same power test, fresh and aged: a duty that fits the fresh cell's voltage window with margin and no longer fits after 200 cycles, compared in voltage and in temperature, for charge and discharge
import pandas as pd
import plotly.express as px
from breathe_simulate import Cycler, 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,
capacity_checks=[{"current": 1.0, "reference": True}],
t_equilibration_s=1800,
pulses=[{"soc": 0.5, "current": -2.0, "duration_s": 30, "reference": True}],
t_rest_s=300,
t_max_cv_s=3600,
reset_temperature=True,
)
CAMPAIGN = dict(
rpt_cycler=rpt,
rpt_every_n_cycles=50,
max_cycles=200,
initialTemperature_degC=25.0,
ambientTemperature_degC=25.0,
heatTransferCoefficient=35.0,
)
1. Performance tests on the aged cell¶
A campaign can close with any run_sim cycler executed on the live aged
cell, including custom and drive cycles. The test sees everything the
ageing accumulated and returns run_sim's shape.
The cell is first preconditioned: CC-CV charge to the RPT's full, then a
coulomb-counted discharge to performance_start_soc.
The test runs once, when the campaign stops. For fresh against aged, compare with the campaign's baseline RPT.
capacity_test = Cycler(selected_unit="C", cell_capacity=CAP_AH).cc_dch(
I_dch=-1.0,
V_min=2.5, # 1C capacity discharge, but ANY run_sim cycler works
)
result = api.run_ageing_sim(
cell_name, ageing, performance_cycler=capacity_test, **CAMPAIGN
)
result.performance.tests
Running ageing campaign on 'Molicel P45B' (up to 200 cycles)... Campaign finished: max_cycles at cycle 200 (wall clock 00:01:34)
| rpt_number | cycle_number | elapsed_time_s | start_soc | cycle_type | |
|---|---|---|---|---|---|
| performance_number | |||||
| 0 | 4 | 200 | 1.715449e+06 | 1.0 | CC_DCH |
dynamic_data carries run_sim's headers with time starting at 0, so
existing run_sim analysis code works unchanged.
result.performance.plot_dynamic_response("Voltage [V]")
Cross-check against the RPT¶
The test replicated the RPT's reference capacity protocol, so it should reproduce the final RPT's capacity:
test = pd.DataFrame(result.performance[-1].dynamic_data)
dt_h = test["Time [s]"].diff().fillna(0) / 3600.0
discharged_Ah = (test["Current [A]"].clip(lower=0) * dt_h).sum()
pd.Series(
{
"test discharged [Ah]": discharged_Ah,
"final RPT capacity [Ah]": result.rpt["capacity_Ah"].iloc[-1],
"baseline RPT capacity [Ah]": result.rpt["capacity_Ah"].iloc[0],
"residual vs final RPT [mAh]": 1000
* (result.rpt["capacity_Ah"].iloc[-1] - discharged_Ah),
}
).round(4)
test discharged [Ah] 4.1217 final RPT capacity [Ah] 4.1242 baseline RPT capacity [Ah] 4.4376 residual vs final RPT [mAh] 2.5853 dtype: float64
The small deficit is expected. Degradation stays active through the RPT's remaining steps and the test's preconditioning, so the test runs a couple of full-equivalent cycles later and extracts slightly less.
The test also appears in the campaign timeseries as
segment="performance".
2. The same power test, fresh and aged¶
Pack electronics are sized against the fresh cell, but the pack demands the
same power in year five. This section runs one power test twice: at
beginning of life with run_sim, and after 200 cycles as the campaign's
performance test.
The test is a 30 W round trip from 50 % SoC. Each leg is time bounded or limit bounded, so hitting a limit ends the leg and the test continues. Sized on the fresh cell it clears both limits: the charge peaks at 4.17 V, the discharge bottoms out at 2.67 V.
power_test = Cycler(selected_unit="A", cell_capacity=CAP_AH).custom(
experiment_text=[
"Charge at 30 W for 15 minutes or until 4.2 V",
"Rest for 300 seconds",
"Discharge at 30 W for 28 minutes or until 2.5 V",
"Rest for 300 seconds",
],
period="10 seconds",
)
# the fresh reference: plain run_sim on the beginning-of-life cell
fresh = api.run_sim(
cell_name,
power_test,
designs=[],
initialSoC=0.5,
initialTemperature_degC=25.0,
ambientTemperature_degC=25.0,
heatTransferCoefficient=35.0,
)
# the aged measurement: the IDENTICAL test on the live aged cell, after the
# same 200-cycle campaign as section 1
result_power = api.run_ageing_sim(
cell_name,
ageing,
performance_cycler=power_test,
performance_start_soc=0.5,
**CAMPAIGN,
)
Running ageing campaign on 'Molicel P45B' (up to 200 cycles)... Campaign finished: max_cycles at cycle 200 (wall clock 00:01:15)
Both carry the same headers, so the overlay is a concat. Voltage first:
fresh_dyn = pd.DataFrame(next(iter(fresh.dynamic_data.values())))
aged_dyn = pd.DataFrame(result_power.performance[-1].dynamic_data)
test = pd.concat(
[
fresh_dyn.assign(state="fresh (run_sim)"),
aged_dyn.assign(state="aged 200 cycles"),
]
).assign(minutes=lambda d: d["Time [s]"] / 60.0)
fig = px.line(
test,
x="minutes",
y="Voltage [V]",
color="state",
title="The same 30 W test, fresh and after 200 cycles",
labels={"minutes": "Time [min]"},
)
fig.add_hline(y=4.2, line_dash="dot")
fig.add_hline(y=2.5, line_dash="dot")
fig
The fresh cell completes the full 53 minutes. The aged cell, at 93 % SoH, hits 4.2 V on the charge leg and 2.5 V before the end of the discharge leg, and loses two minutes of the commanded duty. The faded cell holds less and its higher resistance costs more voltage at the same power.
Temperature shows the same effect. Constant power on a sagging voltage means more current, and heat scales with current squared:
px.line(
test,
x="minutes",
y="Cell temperature [°C]",
color="state",
title="Heat under the same 30 W duty",
labels={"minutes": "Time [min]"},
)