Manufacturing variability¶
In this example we will use Breathe Simulate to see how variations in cell parameters due the manufacturing process, effect the KPIs of the cells.
We start by importing the Breathe Simulate api, as well as the montecarlo utils
import breathe_simulate as bd
from breathe_simulate import api_interface as api
import breathe_simulate.montecarlo_utils as mc
We'll use the Molicel P45B cell as an example, so lets load up the base design for that cell
base_battery = "Molicel P45B"
base_design = api.get_design_parameters(base_battery)
Lets look in the base dsign to see what parameters we could vary
base_design
{'NLiRatio': 1.192712619876931,
'NPratio': 1.039106051327928,
'Vmax_V': 4.200003037059671,
'Vmin_V': 2.4999805201342147,
'aluminiumThickness_um': 22.2,
'anode': 'molicel_p45b_anode',
'anodeArealLoading_mAhcm2': 3.8996207810294305,
'anodeCathodeAreaRatio': 1.065290648694187,
'anodeMassLoading_mgcm2': 8.44896611475494,
'anodePorosity': 0.16663641853607114,
'anodeTabResistance_uOhmAh': 0.0,
'anodeThickness_um': 48.90000000000002,
'capacityVariationPercent': 0.0,
'cathode': 'molicel_p45b_cathode',
'cathodeArealLoading_mAhcm2': 3.7528611983790308,
'cathodeMassLoading_mgcm2': 14.188761420434503,
'cathodePorosity': 0.14216134985845785,
'cathodeTabResistance_uOhmAh': 0.0,
'cathodeThickness_um': 37.48333333333334,
'concentration_molL': 1.0,
'copperThickness_um': 11.2,
'electrodeArea_cm2': 1495.6199999999994,
'electrolyte': 'molicel_p45b_electrolyte',
'electrolyteBuffer_rel': 0.2,
'format': 'molicel_p45b',
'lamne': 0.0,
'lampe': 0.0,
'lli': 0.0,
'separatorThickness_um': 16.8}
We'll use the Montecarlo tools to generate samples where we vary some design parameters.
Essentially, we generate a number of different cell designs, where each design is a slight variation on the base design, to represent a possible deviation from the nominal design, due to variations in the manufacturing process.
We'll choose to vary the porosity and thickness of both electrodes as pairs.
param_pairs = [
("anodePorosity", "cathodePorosity"),
("anodeThickness_um", "cathodeThickness_um"),
]
sigma_fraction = [
0.035, # vary the porosity by 3.5%
0.05, # vary the thickness by 5%
]
bound_range = 0.2 # bound all parameters to within 20% of the base value
changes = mc.make_manufacturing_variations(
base_design=base_design,
n_samples=200,
bound_range=bound_range,
sigma_fraction=sigma_fraction,
seed=42,
param_pairs=param_pairs,
preserve_np_ratio=False,
)
We can visualise the distribution of the samples from design parameter tolernace. This will help us understand the range of possible outcomes from the manufacturing process.
param_keys = [
"anodePorosity",
"cathodePorosity",
"anodeThickness_um",
"cathodeThickness_um",
]
fig_hist = mc.plot_variation_histograms(
base_design, changes, param_keys, title="Parameter distributions"
)
Now, we start to investigate what effect those variations will have on the cell KPIs.
results = api.get_eqm_kpis(base_battery, changes)
var_kpis = results.get_kpis()
Running...: 100%|██████████| 200/200 [00:36<00:00, 5.50 designs/s]
Lets see how the variations have effected the cell capacity.
This could be rather important if you have an absolute minimum capacity required for your system. We might find, using this analysis, that some expected manufacturing variations will result in cell capacities that are below our minimum, which would cause a problem at the system level.
fig = mc.plot_capacity_histogram(var_kpis, bins=15)
We'll now run a DCIR schedule on all of the cell samples. We will run a DCIR schedule with a C rate of 4.5 at 70% SoC
# define the cycler schedule
cycler = bd.Cycler("C", 4.5)
# and submit the simulations
results = api.run_sim(
base_battery=base_battery,
cycler=cycler.dcir(-1, 30, 5, 5, 2.5, 4.2),
designs=changes,
initialSoC=0.7,
initialTemperature_degC=25,
ambientTemperature_degC=25,
)
Running...: 100%|██████████| 200/200 [04:01<00:00, 1.21s/ designs]
We now extract the DCIR results from the dynamic simulations.
dcir = bd.extract_dynamic_kpis("DCIR", results.get_dynamic_data())
...and plot them
fig_dcir = mc.plot_dcir_histogram(dcir, bins=15, include_baseline=False)
We can also plot both the capacity and resistance on a heatmap
fig_heat = mc.plot_capacity_resistance_heatmap(
var_kpis,
dcir,
changes=changes,
base_design=base_design,
include_baseline=False,
bins=(30, 30),
title="Capacity vs DCIR",
marker_size=8,
)
Cell-to-cell capacity variation¶
The Monte Carlo sweep above varies the manufacturing inputs and lets the capacity spread emerge from the physics. In practice you often have the opposite information. The end-of-line tester hands you the measured capacity distribution of a batch, without telling you which upstream parameter caused it.
For that case there is a dedicated design knob called capacityVariationPercent. It takes a value between -5 and +5 and scales the cell capacity and both electrodes' maximum lithium concentrations together, so the OCV and the electrode balance stay exactly as designed. The same knob exists on the Breathe Simulate block in Simulink, under Cell-to-cell variation.
Lets grade a small batch of cells from a measured spread with a standard deviation of 1.5%.
import numpy as np
rng = np.random.default_rng(4)
offsets = np.clip(rng.normal(0.0, 1.5, size=6), -3.0, 3.0).round(2)
graded_cells = [
{"designName": f"Cell {i + 1} ({p:+.2f}%)", "capacityVariationPercent": float(p)}
for i, p in enumerate(offsets)
]
graded_cells
[{'designName': 'Cell 1 (-0.98%)', 'capacityVariationPercent': -0.98},
{'designName': 'Cell 2 (-0.26%)', 'capacityVariationPercent': -0.26},
{'designName': 'Cell 3 (+2.50%)', 'capacityVariationPercent': 2.5},
{'designName': 'Cell 4 (+0.99%)', 'capacityVariationPercent': 0.99},
{'designName': 'Cell 5 (-2.46%)', 'capacityVariationPercent': -2.46},
{'designName': 'Cell 6 (-0.01%)', 'capacityVariationPercent': -0.01}]
Each design is now one cell of the batch. A quick equilibrium KPI call confirms that every cell carries exactly the capacity offset we asked for.
graded_results = api.get_eqm_kpis(base_battery, graded_cells)
graded_results.get_kpis().loc[["Capacity [Ah]"]]
Running...: 100%|██████████| 6/6 [00:01<00:00, 3.78 designs/s]
| Baseline | Cell 1 (-0.98%) | Cell 2 (-0.26%) | Cell 3 (+2.50%) | Cell 4 (+0.99%) | Cell 5 (-2.46%) | Cell 6 (-0.01%) | |
|---|---|---|---|---|---|---|---|
| KPI | |||||||
| Capacity [Ah] | 4.5 | 4.4559 | 4.4883 | 4.6125 | 4.54455 | 4.3893 | 4.49955 |
One pack current, six different cells¶
In a series pack every cell carries the same current, so capacity variation turns into runtime variation. To see it, we discharge the whole batch at one fixed pack current, the 1C current of the nominal design, rather than letting each cell define its own C rate.
nominal_capacity_ah = 4.5
pack_cycler = bd.Cycler("A", nominal_capacity_ah)
pack_results = api.run_sim(
base_battery=base_battery,
cycler=pack_cycler.cc_dch(-nominal_capacity_ah, 2.5),
designs=graded_cells,
initialSoC=1.0,
initialTemperature_degC=25,
ambientTemperature_degC=25,
)
pack_results.plot_dynamic_response("Voltage [V]")
Running...: 100%|██████████| 6/6 [00:08<00:00, 1.35s/ designs]
The curves separate towards the end of the discharge, where the low capacity cells run out of lithium first. We can put a number on it by reading how long each cell lasted before hitting the cut-off voltage.
import pandas as pd
runtimes_min = {
name: data.get("Time [s]", data.get("Time"))[-1] / 60
for name, data in pack_results.get_dynamic_data().items()
}
pd.Series(runtimes_min, name="Minutes to 2.5 V").sort_values()
Cell 5 (-2.46%) 57.806394 Cell 1 (-0.98%) 58.693683 Cell 2 (-0.26%) 59.124207 Cell 6 (-0.01%) 59.273955 Baseline 59.279945 Cell 4 (+0.99%) 59.872951 Cell 3 (+2.50%) 60.777436 Name: Minutes to 2.5 V, dtype: float64
The runtime spread mirrors the built-in capacity spread almost one to one. In a series string the pack must stop when the first cell reaches the cut-off, so the weakest cell sets the usable capacity of the whole pack. Everything the stronger cells still hold at that moment is capacity you paid for but cannot use without balancing.
Combining variation with ageing¶
capacityVariationPercent is a design key like any other, so it combines freely with the ageing keys we have seen in earlier examples. A cell that starts 2% below nominal capacity at beginning of life (BOL) and has since lost 5% of its lithium inventory is a single design dict.
cell_stories = [
{"designName": "2% low at BOL", "capacityVariationPercent": -2.0},
{"designName": "Aged (5% LLI)", "lli": 0.05},
{
"designName": "2% low at BOL and aged",
"capacityVariationPercent": -2.0,
"lli": 0.05,
},
]
results = api.run_sim(
base_battery=base_battery,
cycler=pack_cycler.cc_dch(-nominal_capacity_ah, 2.5),
designs=cell_stories,
initialSoC=1.0,
initialTemperature_degC=25,
ambientTemperature_degC=25,
)
results.get_kpis().loc[["Capacity [Ah]"]]
The two effects compose multiplicatively. The cell that is 2% low at BOL and aged delivers roughly 0.98 x 0.95 of the nominal capacity, and its discharge curve sits below both single-effect cells.
results.plot_dynamic_response("Voltage [V]")