Skip to content

Commit 08008d1

Browse files
authored
composite esoh (#5160)
* composite working * fix bug * forgot to evaluate * test voltage * update func name * add tests * changelog
1 parent 8881758 commit 08008d1

File tree

6 files changed

+518
-11
lines changed

6 files changed

+518
-11
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## Features
44

5+
- Adds a composite electrode electrode soh model ([#5160](https://github.com/pybamm-team/PyBaMM/pull/5129))
56
- Generalises `set_initial_soc` to `set_initial_state` and adds Thevenin initial state setting. ([#5129](https://github.com/pybamm-team/PyBaMM/pull/5129))
67

78
# [v25.8.0](https://github.com/pybamm-team/PyBaMM/tree/v25.8.0) - 2025-08-04

src/pybamm/models/full_battery_models/lithium_ion/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313
ElectrodeSOHHalfCell,
1414
get_initial_stoichiometry_half_cell,
1515
)
16+
from .electrode_soh_composite import (
17+
ElectrodeSOHComposite,
18+
get_initial_stoichiometries_composite,
19+
)
1620
from .initial_state import set_initial_state
1721
from .spm import SPM
1822
from .spme import SPMe
@@ -28,8 +32,9 @@
2832
from .mpm import MPM
2933
from .msmr import MSMR
3034
from .basic_splitOCVR import SplitOCVR
35+
from .util import check_if_composite
3136

3237
__all__ = ['Yang2017', 'base_lithium_ion_model', 'basic_dfn',
3338
'basic_dfn_composite', 'basic_dfn_half_cell', 'basic_spm', 'dfn',
34-
'electrode_soh', 'electrode_soh_half_cell', 'mpm', 'msmr',
39+
'electrode_soh', 'electrode_soh_half_cell', 'electrode_soh_composite', 'mpm', 'msmr',
3540
'newman_tobias', 'spm', 'spme', 'basic_splitOCVR', 'basic_spm_with_3d_thermal']
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
#
2+
# A model to calculate electrode-specific SOH, adapted for composite electrodes
3+
#
4+
import pybamm
5+
6+
from .util import check_if_composite
7+
8+
9+
def _get_stoich_variables(options):
10+
variables = {
11+
"x_100_1": pybamm.Variable("x_100_1"),
12+
"y_100_1": pybamm.Variable("y_100_1"),
13+
"x_0_1": pybamm.Variable("x_0_1"),
14+
"y_0_1": pybamm.Variable("y_0_1"),
15+
"x_init_1": pybamm.Variable("x_init_1"),
16+
"y_init_1": pybamm.Variable("y_init_1"),
17+
}
18+
is_positive_composite = check_if_composite(options, "positive")
19+
is_negative_composite = check_if_composite(options, "negative")
20+
if is_positive_composite:
21+
variables["y_100_2"] = pybamm.Variable("y_100_2")
22+
variables["y_0_2"] = pybamm.Variable("y_0_2")
23+
variables["y_init_2"] = pybamm.Variable("y_init_2")
24+
if is_negative_composite:
25+
variables["x_100_2"] = pybamm.Variable("x_100_2")
26+
variables["x_0_2"] = pybamm.Variable("x_0_2")
27+
variables["x_init_2"] = pybamm.Variable("x_init_2")
28+
return variables
29+
30+
31+
def _get_initial_conditions(options, soc_init):
32+
variables = _get_stoich_variables(options)
33+
ics = {}
34+
for name, var in variables.items():
35+
if "100" in name and "x" in name:
36+
ics[var] = 0.8
37+
elif "0" in name and "x" in name:
38+
ics[var] = 0.2
39+
elif "100" in name and "y" in name:
40+
ics[var] = 0.8
41+
elif "0" in name and "y" in name:
42+
ics[var] = 0.2
43+
elif "init" in name and "x" in name:
44+
ics[var] = soc_init
45+
elif "init" in name and "y" in name:
46+
ics[var] = 1 - soc_init
47+
return ics
48+
49+
50+
def _get_direction(electrode):
51+
if electrode == "positive":
52+
return pybamm.Scalar(-1)
53+
else:
54+
return pybamm.Scalar(1)
55+
56+
57+
def _get_prefix(electrode):
58+
if electrode == "positive":
59+
return "y"
60+
else:
61+
return "x"
62+
63+
64+
def _get_electrode_capacity_equation(options, electrode):
65+
prefix = _get_prefix(electrode)
66+
e = electrode[0]
67+
i_am_composite = check_if_composite(options, electrode)
68+
stoich_variables = _get_stoich_variables(options)
69+
direction = _get_direction(electrode)
70+
Q_1 = pybamm.InputParameter(f"Q_{e}_1")
71+
Q = (
72+
direction
73+
* (stoich_variables[f"{prefix}_100_1"] - stoich_variables[f"{prefix}_0_1"])
74+
* Q_1
75+
)
76+
if i_am_composite:
77+
Q_2 = pybamm.InputParameter(f"Q_{e}_2")
78+
Q += (
79+
direction
80+
* (stoich_variables[f"{prefix}_100_2"] - stoich_variables[f"{prefix}_0_2"])
81+
* Q_2
82+
)
83+
return Q
84+
85+
86+
def _get_cyclable_lithium_equation(options, soc="100"):
87+
x_soc_1 = pybamm.Variable(f"x_{soc}_1")
88+
y_soc_1 = pybamm.Variable(f"y_{soc}_1")
89+
Q_n_1 = pybamm.InputParameter("Q_n_1")
90+
Q_p_1 = pybamm.InputParameter("Q_p_1")
91+
lithium_primary_phases = Q_n_1 * x_soc_1 + Q_p_1 * y_soc_1
92+
lithium_secondary_phases = 0.0
93+
is_positive_composite = check_if_composite(options, "positive")
94+
is_negative_composite = check_if_composite(options, "negative")
95+
if is_positive_composite:
96+
Q_p_2 = pybamm.InputParameter("Q_p_2")
97+
y_soc_2 = pybamm.Variable(f"y_{soc}_2")
98+
lithium_secondary_phases += Q_p_2 * y_soc_2
99+
if is_negative_composite:
100+
Q_n_2 = pybamm.InputParameter("Q_n_2")
101+
x_soc_2 = pybamm.Variable(f"x_{soc}_2")
102+
lithium_secondary_phases += Q_n_2 * x_soc_2
103+
return lithium_primary_phases + lithium_secondary_phases
104+
105+
106+
class ElectrodeSOHComposite(pybamm.BaseModel):
107+
"""Model to calculate electrode-specific SOH for a cell with composite electrodes, adapted from
108+
:footcite:t:`Mohtat2019`.
109+
This model is mainly for internal use, to calculate summary variables in a
110+
simulation.
111+
112+
Subscript w indicates working electrode and subscript c indicates counter electrode.
113+
"""
114+
115+
def __init__(
116+
self, options, name="ElectrodeSOH model", initialization_method="voltage"
117+
):
118+
pybamm.citations.register("Mohtat2019")
119+
super().__init__(name)
120+
param = pybamm.LithiumIonParameters(options)
121+
122+
# Start by just assuming known value is cyclable lithium (and solve all at once)
123+
Q_Li = pybamm.InputParameter("Q_Li")
124+
is_negative_composite = check_if_composite(options, "negative")
125+
is_positive_composite = check_if_composite(options, "positive")
126+
variables = _get_stoich_variables(options)
127+
x_100_1 = variables["x_100_1"]
128+
y_100_1 = variables["y_100_1"]
129+
x_0_1 = variables["x_0_1"]
130+
y_0_1 = variables["y_0_1"]
131+
V_max = param.voltage_high_cut
132+
V_min = param.voltage_low_cut
133+
if is_negative_composite:
134+
x_100_2 = variables["x_100_2"]
135+
x_0_2 = variables["x_0_2"]
136+
self.algebraic[x_100_2] = param.n.prim.U(
137+
x_100_2, param.T_ref
138+
) - param.n.sec.U(x_100_1, param.T_ref)
139+
self.algebraic[x_0_2] = param.n.prim.U(x_0_2, param.T_ref) - param.n.sec.U(
140+
x_0_1, param.T_ref
141+
)
142+
if is_positive_composite:
143+
y_100_2 = variables["y_100_2"]
144+
y_0_2 = variables["y_0_2"]
145+
self.algebraic[y_100_2] = param.p.prim.U(
146+
y_100_2, param.T_ref
147+
) - param.p.sec.U(y_100_1, param.T_ref)
148+
self.algebraic[y_0_2] = param.p.prim.U(y_0_2, param.T_ref) - param.p.sec.U(
149+
y_0_1, param.T_ref
150+
)
151+
self.algebraic[x_100_1] = (
152+
param.p.prim.U(y_100_1, param.T_ref)
153+
- param.n.prim.U(x_100_1, param.T_ref)
154+
- V_max
155+
)
156+
self.algebraic[x_0_1] = (
157+
param.p.prim.U(y_0_1, param.T_ref)
158+
- param.n.prim.U(x_0_1, param.T_ref)
159+
- V_min
160+
)
161+
# arbitrary choice: use y_0_1 for the capacity equation
162+
self.algebraic[y_0_1] = _get_electrode_capacity_equation(
163+
options, "positive"
164+
) - _get_electrode_capacity_equation(options, "negative")
165+
self.algebraic[y_100_1] = Q_Li - _get_cyclable_lithium_equation(options)
166+
167+
x_init_1 = variables["x_init_1"]
168+
y_init_1 = variables["y_init_1"]
169+
if initialization_method == "voltage":
170+
V_init = pybamm.InputParameter("V_init")
171+
self.algebraic[x_init_1] = (
172+
param.p.prim.U(y_init_1, param.T_ref)
173+
- param.n.prim.U(x_init_1, param.T_ref)
174+
- V_init
175+
)
176+
self.algebraic[y_init_1] = (
177+
_get_cyclable_lithium_equation(options, "init") - Q_Li
178+
)
179+
elif initialization_method == "SOC":
180+
soc_init = pybamm.InputParameter("SOC_init")
181+
negative_soc = x_init_1 * pybamm.InputParameter("Q_n_1")
182+
if is_negative_composite:
183+
x_init_2 = variables["x_init_2"]
184+
negative_soc += x_init_2 * pybamm.InputParameter("Q_n_2")
185+
186+
negative_0_soc = x_0_1 * pybamm.InputParameter("Q_n_1")
187+
if is_negative_composite:
188+
negative_0_soc += x_0_2 * pybamm.InputParameter("Q_n_2")
189+
190+
negative_100_soc = x_100_1 * pybamm.InputParameter("Q_n_1")
191+
if is_negative_composite:
192+
negative_100_soc += x_100_2 * pybamm.InputParameter("Q_n_2")
193+
self.algebraic[x_init_1] = (
194+
(negative_soc - negative_0_soc) / (negative_100_soc - negative_0_soc)
195+
) - soc_init
196+
self.algebraic[y_init_1] = (
197+
_get_cyclable_lithium_equation(options, "init") - Q_Li
198+
)
199+
else:
200+
raise ValueError("Invalid initialization method")
201+
# Add voltage equations for secondary phases (init)
202+
if is_positive_composite:
203+
y_init_2 = variables["y_init_2"]
204+
self.algebraic[y_init_2] = param.p.prim.U(
205+
y_init_1, param.T_ref
206+
) - param.p.sec.U(y_init_2, param.T_ref)
207+
if is_negative_composite:
208+
x_init_2 = variables["x_init_2"]
209+
self.algebraic[x_init_2] = param.n.prim.U(
210+
x_init_1, param.T_ref
211+
) - param.n.sec.U(x_init_2, param.T_ref)
212+
213+
self.variables.update(variables)
214+
if initialization_method == "SOC":
215+
soc_init = pybamm.InputParameter("SOC_init")
216+
else:
217+
soc_init = 0.5
218+
self.initial_conditions.update(_get_initial_conditions(options, soc_init))
219+
220+
@property
221+
def default_solver(self):
222+
# Use AlgebraicSolver as CasadiAlgebraicSolver gives unnecessary warnings
223+
return pybamm.AlgebraicSolver(tol=1e-7)
224+
225+
226+
def get_initial_stoichiometries_composite(
227+
initial_value,
228+
parameter_values,
229+
param=None,
230+
options=None,
231+
tol=1e-6,
232+
inputs=None,
233+
known_value="cyclable lithium capacity",
234+
**kwargs,
235+
):
236+
"""
237+
Get the minimum and maximum stoichiometries from the parameter values
238+
239+
Parameters
240+
----------
241+
parameter_values : pybamm.ParameterValues
242+
The parameter values to use in the calculation
243+
options : dict, optional
244+
A dictionary of options to be passed to the parameters, see
245+
:class:`pybamm.BatteryModelOptions`.
246+
If None, the default is used: {"working electrode": "positive"}
247+
"""
248+
inputs = inputs or {}
249+
if known_value != "cyclable lithium capacity":
250+
raise ValueError(
251+
"Only `cyclable lithium capacity` is supported for composite electrodes"
252+
)
253+
254+
Q_n_1 = parameter_values.evaluate(param.n.prim.Q_init, inputs=inputs)
255+
Q_p_1 = parameter_values.evaluate(param.p.prim.Q_init, inputs=inputs)
256+
is_positive_composite = check_if_composite(options, "positive")
257+
is_negative_composite = check_if_composite(options, "negative")
258+
Qs = {
259+
"Q_n_1": Q_n_1,
260+
"Q_p_1": Q_p_1,
261+
}
262+
if is_positive_composite:
263+
Q_p_2 = parameter_values.evaluate(param.p.sec.Q_init, inputs=inputs)
264+
Qs["Q_p_2"] = Q_p_2
265+
if is_negative_composite:
266+
Q_n_2 = parameter_values.evaluate(param.n.sec.Q_init, inputs=inputs)
267+
Qs["Q_n_2"] = Q_n_2
268+
269+
Q_Li = parameter_values.evaluate(param.Q_Li_particles_init, inputs=inputs)
270+
all_inputs = {**inputs, **Qs, "Q_Li": Q_Li}
271+
# Solve the model and check outputs
272+
if isinstance(initial_value, str) and initial_value.endswith("V"):
273+
all_inputs["V_init"] = float(initial_value[:-1])
274+
initialization_method = "voltage"
275+
elif isinstance(initial_value, float) and initial_value >= 0 and initial_value <= 1:
276+
initialization_method = "SOC"
277+
all_inputs["SOC_init"] = initial_value
278+
else:
279+
raise ValueError("Invalid initial value")
280+
model = ElectrodeSOHComposite(options, initialization_method=initialization_method)
281+
sim = pybamm.Simulation(model, parameter_values=parameter_values)
282+
sol = sim.solve([0, 1], inputs=all_inputs)
283+
return {var: sol[var].entries[0] for var in model.variables.keys()}

0 commit comments

Comments
 (0)