Skip to content

Commit 02ddd5d

Browse files
rtimmsclaude
andcommitted
Fix DomainError for hysteresis/MSMR OCP with particle size distribution and thermal
Combining hysteresis / MSMR / single open-circuit-potential submodels with particle-size distribution and a non-isothermal thermal submodel raised a DomainError when the thermal submodel built the hysteresis-heating term. The root cause was that the "equilibrium open-circuit potential [V]" and "OCP hysteresis [V]" variables were published on the particle-size domain under PSD, whereas the parallel "open-circuit potential [V]" was correctly size-averaged onto the electrode domain. base_thermal then subtracted them and concatenated across electrodes, hitting a domain mismatch. - base_hysteresis_ocp: size-average U_eq and H under PSD, publish distribution-named variables parallel to the existing "open-circuit potential distribution [V]", and properly x-average U_eq_x_av / H_x_av (previously they were the same expression as U_eq / H, never averaged). - single_ocp and msmr_ocp: after _get_standard_ocp_variables has shaped the main OCP variables, reuse them for the equilibrium variables so both sides line up regardless of PSD. Adds unit and integration regression tests for SPM, SPMe, DFN, MPM, and Newman-Tobias covering: - hysteresis OCP + PSD + lumped thermal - single OCP + PSD + lumped thermal (pins equilibrium OCP domain) - MSMR + PSD + lumped thermal Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9209226 commit 02ddd5d

7 files changed

Lines changed: 175 additions & 17 deletions

File tree

src/pybamm/models/submodels/interface/open_circuit_potential/base_hysteresis_ocp.py

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,21 +82,44 @@ def _get_coupled_variables(self, variables):
8282
variables
8383
)
8484
U_eq = self.phase_param.U(sto_surf, T)
85-
U_eq_x_av = self.phase_param.U(sto_surf, T)
8685
U_lith = self.phase_param.U(sto_surf, T, "lithiation")
8786
U_lith_bulk = self.phase_param.U(sto_bulk, T_bulk, "lithiation")
8887
U_delith = self.phase_param.U(sto_surf, T, "delithiation")
8988
U_delith_bulk = self.phase_param.U(sto_bulk, T_bulk, "delithiation")
9089

9190
H = U_lith - U_delith
92-
H_x_av = pybamm.x_average(H)
91+
92+
# Under particle-size distribution the OCPs above live on the
93+
# "<domain> particle size" domain. Publish the distribution-named
94+
# variables at that shape, and size-average before publishing under
95+
# the electrode-level names so they line up with other
96+
# electrode-domain variables (e.g. in the thermal submodel).
97+
if domain_options["particle size"] == "distribution":
98+
variables.update(
99+
{
100+
f"{Domain} electrode {phase_name}OCP hysteresis distribution [V]": H,
101+
f"X-averaged {domain} electrode {phase_name}OCP hysteresis distribution [V]": pybamm.x_average(
102+
H
103+
),
104+
f"{Domain} electrode {phase_name}equilibrium open-circuit potential distribution [V]": U_eq,
105+
f"X-averaged {domain} electrode {phase_name}equilibrium open-circuit potential distribution [V]": pybamm.x_average(
106+
U_eq
107+
),
108+
}
109+
)
110+
U_eq = pybamm.size_average(U_eq)
111+
H = pybamm.size_average(H)
93112

94113
variables.update(
95114
{
96115
f"{Domain} electrode {phase_name}OCP hysteresis [V]": H,
97-
f"X-averaged {domain} electrode {phase_name}OCP hysteresis [V]": H_x_av,
116+
f"X-averaged {domain} electrode {phase_name}OCP hysteresis [V]": pybamm.x_average(
117+
H
118+
),
98119
f"{Domain} electrode {phase_name}equilibrium open-circuit potential [V]": U_eq,
99-
f"X-averaged {domain} electrode {phase_name}equilibrium open-circuit potential [V]": U_eq_x_av,
120+
f"X-averaged {domain} electrode {phase_name}equilibrium open-circuit potential [V]": pybamm.x_average(
121+
U_eq
122+
),
100123
}
101124
)
102125

src/pybamm/models/submodels/interface/open_circuit_potential/msmr_ocp.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,31 @@ def get_coupled_variables(self, variables):
6969
dUdT = self.phase_param.dUdT(sto_surf)
7070

7171
variables.update(self._get_standard_ocp_variables(ocp_surf, ocp_bulk, dUdT))
72+
73+
# MSMR has no hysteresis, so the equilibrium OCP equals the OCP.
74+
# Reuse the already-shaped variables published by
75+
# _get_standard_ocp_variables so the equilibrium variable has the
76+
# same domain regardless of particle-size distribution (it is
77+
# consumed by the thermal hysteresis-heating term).
7278
variables.update(
7379
{
74-
f"{Domain} electrode {phase_name}equilibrium open-circuit potential [V]": ocp_surf,
75-
f"X-averaged {domain} electrode {phase_name}equilibrium open-circuit potential [V]": pybamm.x_average(
76-
ocp_surf
77-
),
80+
f"{Domain} electrode {phase_name}equilibrium open-circuit potential [V]": variables[
81+
f"{Domain} electrode {phase_name}open-circuit potential [V]"
82+
],
83+
f"X-averaged {domain} electrode {phase_name}equilibrium open-circuit potential [V]": variables[
84+
f"X-averaged {domain} electrode {phase_name}open-circuit potential [V]"
85+
],
7886
}
7987
)
88+
if domain_options["particle size"] == "distribution":
89+
variables.update(
90+
{
91+
f"{Domain} electrode {phase_name}equilibrium open-circuit potential distribution [V]": variables[
92+
f"{Domain} electrode {phase_name}open-circuit potential distribution [V]"
93+
],
94+
f"X-averaged {domain} electrode {phase_name}equilibrium open-circuit potential distribution [V]": variables[
95+
f"X-averaged {domain} electrode {phase_name}open-circuit potential distribution [V]"
96+
],
97+
}
98+
)
8099
return variables

src/pybamm/models/submodels/interface/open_circuit_potential/single_ocp.py

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
class SingleOpenCircuitPotential(BaseOpenCircuitPotential):
1010
def get_coupled_variables(self, variables):
1111
domain, Domain = self.domain_Domain
12+
domain_options = getattr(self.options, domain)
1213
phase_name = self.phase_name
1314

1415
if self.reaction == "lithium-ion main":
@@ -21,15 +22,6 @@ def get_coupled_variables(self, variables):
2122

2223
dUdT = self.phase_param.dUdT(sto_surf)
2324

24-
variables.update(
25-
{
26-
f"{Domain} electrode {phase_name}equilibrium open-circuit potential [V]": ocp_surf,
27-
f"X-averaged {domain} electrode {phase_name}equilibrium open-circuit potential [V]": pybamm.x_average(
28-
ocp_surf
29-
),
30-
}
31-
)
32-
3325
elif self.reaction == "lithium metal plating":
3426
T = variables[f"{Domain} electrode temperature [K]"]
3527
ocp_surf = 0 * T
@@ -52,4 +44,32 @@ def get_coupled_variables(self, variables):
5244
dUdT = pybamm.Scalar(0)
5345

5446
variables.update(self._get_standard_ocp_variables(ocp_surf, ocp_bulk, dUdT))
47+
48+
if self.reaction == "lithium-ion main":
49+
# For "single" OCP the equilibrium OCP equals the OCP. Reuse the
50+
# already-shaped variables published by _get_standard_ocp_variables
51+
# so the equilibrium variable has the same domain regardless of
52+
# particle-size distribution (it is consumed by the thermal
53+
# hysteresis-heating term).
54+
variables.update(
55+
{
56+
f"{Domain} electrode {phase_name}equilibrium open-circuit potential [V]": variables[
57+
f"{Domain} electrode {phase_name}open-circuit potential [V]"
58+
],
59+
f"X-averaged {domain} electrode {phase_name}equilibrium open-circuit potential [V]": variables[
60+
f"X-averaged {domain} electrode {phase_name}open-circuit potential [V]"
61+
],
62+
}
63+
)
64+
if domain_options["particle size"] == "distribution":
65+
variables.update(
66+
{
67+
f"{Domain} electrode {phase_name}equilibrium open-circuit potential distribution [V]": variables[
68+
f"{Domain} electrode {phase_name}open-circuit potential distribution [V]"
69+
],
70+
f"X-averaged {domain} electrode {phase_name}equilibrium open-circuit potential distribution [V]": variables[
71+
f"X-averaged {domain} electrode {phase_name}open-circuit potential distribution [V]"
72+
],
73+
}
74+
)
5575
return variables

tests/integration/test_models/test_full_battery_models/test_lithium_ion/base_lithium_ion_tests.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,35 @@ def test_both_swelling_only(self):
316316
parameter_values = pybamm.ParameterValues("Ai2020")
317317
self.run_basic_processing_test(options, parameter_values=parameter_values)
318318

319+
def test_psd_hysteresis_thermal(self):
320+
# Regression test: hysteresis OCP + particle size distribution + a
321+
# non-isothermal thermal submodel previously raised a DomainError when
322+
# the thermal submodel built the hysteresis heating term.
323+
options = {
324+
"open-circuit potential": ("one-state hysteresis", "single"),
325+
"particle size": "distribution",
326+
"surface form": "algebraic",
327+
"thermal": "lumped",
328+
}
329+
parameter_values = pybamm.ParameterValues("Chen2020")
330+
parameter_values = pybamm.get_size_distribution_parameters(parameter_values)
331+
parameter_values.update(
332+
{
333+
"Negative electrode lithiation OCP [V]": lambda sto: (
334+
parameter_values["Negative electrode OCP [V]"](sto) - 0.1
335+
),
336+
"Negative electrode delithiation OCP [V]": lambda sto: (
337+
parameter_values["Negative electrode OCP [V]"](sto) + 0.1
338+
),
339+
"Negative particle lithiation hysteresis decay rate": 10,
340+
"Negative particle delithiation hysteresis decay rate": 10,
341+
"Initial hysteresis state in negative electrode": 0.0,
342+
}
343+
)
344+
model = self.model(options)
345+
modeltest = tests.StandardModelTest(model, parameter_values=parameter_values)
346+
modeltest.test_all(skip_output_tests=True)
347+
319348
def test_composite_graphite_silicon(self):
320349
options = {
321350
"particle phases": ("2", "1"),
@@ -356,6 +385,7 @@ def test_basic_processing_msmr(self):
356385
modeltest = tests.StandardModelTest(model, parameter_values=parameter_values)
357386
modeltest.test_all(skip_output_tests=True)
358387

388+
359389
def test_basic_processing_temperature_interpolant(self):
360390
times = np.arange(0, 4000, 10)
361391
tmax = max(times)

tests/unit/test_models/test_full_battery_models/test_lithium_ion/base_lithium_ion_tests.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,54 @@ def test_well_posed_psd_swelling_only(self):
589589
}
590590
self.check_well_posedness(options)
591591

592+
def test_well_posed_psd_hysteresis_thermal(self):
593+
# Regression test: hysteresis OCP + particle size distribution + a
594+
# non-isothermal thermal submodel previously raised a DomainError in
595+
# the thermal hysteresis-heating term because the "equilibrium
596+
# open-circuit potential [V]" variable was left on the particle-size
597+
# domain while "open-circuit potential [V]" was size-averaged.
598+
options = {
599+
"open-circuit potential": "one-state hysteresis",
600+
"particle size": "distribution",
601+
"surface form": "algebraic",
602+
"thermal": "lumped",
603+
}
604+
self.check_well_posedness(options)
605+
606+
def test_well_posed_psd_single_ocp_thermal(self):
607+
# Regression test: for "single" OCP + particle size distribution, the
608+
# "equilibrium open-circuit potential [V]" variable used to be
609+
# published on the particle-size domain. The thermal submodel skips
610+
# the hysteresis branch for "single" so this did not raise today, but
611+
# the variable itself was on the wrong domain; this test pins the
612+
# corrected electrode-domain shape.
613+
options = {
614+
"particle size": "distribution",
615+
"surface form": "algebraic",
616+
"thermal": "lumped",
617+
}
618+
model = self.model(options)
619+
model.check_well_posedness()
620+
v = model.variables[
621+
"Positive electrode equilibrium open-circuit potential [V]"
622+
]
623+
assert v.domains["primary"] == ["positive electrode"]
624+
625+
def test_well_posed_psd_msmr_thermal(self):
626+
# Regression test: MSMR + particle size distribution + thermal
627+
# previously failed building Q_hys because "equilibrium open-circuit
628+
# potential [V]" was on the particle-size domain.
629+
options = {
630+
"open-circuit potential": "MSMR",
631+
"particle": "MSMR",
632+
"intercalation kinetics": "MSMR",
633+
"number of MSMR reactions": ("6", "4"),
634+
"particle size": "distribution",
635+
"surface form": "differential",
636+
"thermal": "lumped",
637+
}
638+
self.check_well_posedness(options)
639+
592640
def test_well_posed_transport_efficiency_Bruggeman(self):
593641
options = {"transport efficiency": "Bruggeman"}
594642
self.check_well_posedness(options)

tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_mpm.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,17 @@ def test_one_state_hysteresis_ocp(self):
111111
model = pybamm.lithium_ion.MPM(options)
112112
model.check_well_posedness()
113113

114+
def test_one_state_hysteresis_ocp_lumped_thermal(self):
115+
# Regression test: MPM always uses particle size distribution, and
116+
# combining hysteresis OCP with a non-isothermal thermal submodel
117+
# previously raised a DomainError in the hysteresis heating term.
118+
options = {
119+
"open-circuit potential": "one-state hysteresis",
120+
"thermal": "lumped",
121+
}
122+
model = pybamm.lithium_ion.MPM(options)
123+
model.check_well_posedness()
124+
114125
def test_mpm_with_lithium_plating(self):
115126
options = {
116127
"lithium plating": "irreversible",

tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_newman_tobias.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,10 @@ def test_well_posed_composite_differential_surface_form(self):
5959
@pytest.mark.skip(reason="Test currently not implemented")
6060
def test_well_posed_composite_algebraic_surface_form(self):
6161
pass # skip this test
62+
63+
@pytest.mark.skip(
64+
reason="Newman-Tobias + MSMR + particle size distribution is not "
65+
"supported (pre-existing DomainError in MSMR Butler-Volmer kinetics)"
66+
)
67+
def test_well_posed_psd_msmr_thermal(self):
68+
pass

0 commit comments

Comments
 (0)