Skip to content

Sum the heat of mixing over every particle phase - #5769

Open
mleot wants to merge 13 commits into
pybamm-team:mainfrom
mleot:heat-of-mixing-composite-electrodes
Open

mleot wants to merge 13 commits into
pybamm-team:mainfrom
mleot:heat-of-mixing-composite-electrodes

Conversation

@mleot

@mleot mleot commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Description

Fixes #5792

"heat of mixing": "true" could not be used with a composite (multi-phase) electrode.
BaseThermal._heat_of_mixing read the single-phase variable names and took its
material properties from param.n.prim / param.p.prim only, so any model built with
"particle phases": "2" failed at build time:

import pybamm

pybamm.lithium_ion.DFN(options={
    "particle phases": ("2", "1"),
    "thermal": "lumped",
    "heat of mixing": "true",
})
ModelError: Missing variable for submodel 'thermal': 'Negative particle radius [m]'.

The same failure occurred for SPM, SPMe and DFN, and for a composite positive as well as
a composite negative.

Changes

  • Sum over phases. _phase_heat_of_mixing evaluates Richardson's term for one phase;
    _electrode_heat_of_mixing sums it over the electrode's phases, looked up through
    param.domain_params and options.phases.
  • Effective diffusivity. The heat of mixing is the dissipation of the particle flux,
    so it now reads the particle's "effective diffusivity" variable instead of
    re-evaluating the base phase_param.D. They differ under "diffusivity": "current sigmoid" and "stress-induced diffusion": "true"; with lithiation and delithiation
    diffusivities of D/3 and 3D the heat sources were off the energy balance by 33% of the
    heat.
  • Uniform profile. A uniform particle profile has no gradient, so its heat of mixing
    is zero outright. A DFN with "particle": "uniform profile" and heat of mixing now
    builds; it raised a shape error before.
  • Which OCP. Unchanged: the equilibrium OCP, which the docstring now explains. The
    reaction and hysteresis heats sum to a j (phi_s - phi_e - U_eq), so the heat of
    mixing must use the same U_eq for energy to balance; the hysteresis-weighted OCP
    that sets the voltage would not.

Default results are unchanged. SPM, SPMe and DFN with "thermal": "lumped" and
"heat of mixing": "true" give bit-identical voltage, temperature and heat of mixing on
Chen2020 and Marquis2019 (1800 s, 1C).

Tests

  • Energy balance (test_thermal_models.py, new): over a 1C SPM discharge, the fall
    in stored chemical energy -F * sum int U_eq dc must equal electrical work plus heat
    to 0.5% of the heat, for a one-phase cell and for a composite positive whose second
    phase has current-sigmoid hysteresis (branches tilted away from its OCP) and
    current-sigmoid diffusivity. The SPM keeps the electrolyte uniform and an isothermal
    model with zero entropic change keeps temperature out of the stored energy. Residuals
    are about -0.1% while the heat of mixing is ~45% of the heat. Summing only the primary
    phase fails (+28%), as do the base diffusivity (-18%) and the hysteresis-weighted OCP
    (-2%).
  • Composite vs one phase (test_compare_outputs_two_phase.py, extending fix: sum the electrochemical heat sources over every particle phase #5770's
    test): a graphite electrode split into two identical halves must reproduce the
    one-phase heat sources, including the heat of mixing, for SPM, SPMe and DFN under
    every "thermal" option (isothermal heat source, lumped, x-full, x-lumped with 1+1D
    current collectors). It uses non-zero entropic change (Ai2020 for Chen2020;
    Marquis2019's own for the pouch cases), pins "surface form": "algebraic" on both
    sides (composite models default to it, one-phase models to "false") and solves at
    rtol 1e-6 so the ~300 K temperature state does not drift by more than the differences
    tested.
  • Si/graphite smoke test: Chen2020_composite with a lumped model heats the cell
    through a positive heat of mixing.
  • Well-posedness for "particle phases" "2", ("2", "1"), ("1", "2").

Not addressed here

  • The heat of mixing differentiates the OCP symbolically, which fails for tabulated
    OCPs: a cubic Interpolant crashes in CasADi at solve time and a linear one at build
    time (issues to follow). The tests use analytic OCPs; Chen2020_composite's graphite
    OCP is swapped for the analytic Chen2020 fit where needed.
  • Chen2020_composite has no "Negative electrode density [kg.m-3]", so no thermal
    model runs with it as shipped; the tests supply one (issue to follow).
  • "thermal": "lumped" with 1+1D current collectors does not discretise, with or
    without the heat of mixing (issue to follow), so the 1+1D case uses "x-lumped".

Type of change

Bug fix: two entries under ## Bug fixes in CHANGELOG.md (phase summation; effective
diffusivity and uniform profile).

Important checks:

  • No style issues: nox -s pre-commit (every hook passes on the branch diff,
    including comment-slop and repo-review)
  • All tests pass: nox -s tests. Locally the lithium-ion model, thermal and
    two-phase tests pass (353 integration tests in test_lithium_ion/ and
    test_submodels/test_thermal/). The only local failures need optional dependencies
    my environment lacks (skfem, matplotlib, pybtex, bpx) and fail the same way
    on main; CI runs them.
  • The documentation builds: nox -s doctests (not run locally; no docs changed,
    and the readthedocs check passes)
  • Code is commented for hard-to-understand areas
  • Tests added that prove fix is effective or that feature works

🤖 Generated with Claude Code

@mleot
mleot requested a review from a team as a code owner September 21, 2026 20:22
@mleot
mleot force-pushed the heat-of-mixing-composite-electrodes branch 2 times, most recently from 23233ef to a092e23 Compare September 21, 2026 20:23
@codecov

codecov Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.10%. Comparing base (3df83e8) to head (49e0254).
⚠️ Report is 2 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #5769      +/-   ##
==========================================
- Coverage   98.10%   98.10%   -0.01%     
==========================================
  Files         346      346              
  Lines       35482    35478       -4     
==========================================
- Hits        34809    34805       -4     
  Misses        673      673              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@BradyPlanden BradyPlanden left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @mleot - this looks good. One suggestion to tighten the phase summing.

Comment on lines +414 to +428
electrode_param = getattr(self.param, domain[0])
num_phases = int(getattr(self.options, domain)["particle phases"])
if num_phases == 1:
return self._phase_heat_of_mixing(
variables, domain, "", electrode_param.prim
)
return sum(
self._phase_heat_of_mixing(variables, domain, phase_name, phase_param)
for phase_name, phase_param in zip(
["primary ", "secondary "],
[electrode_param.prim, electrode_param.sec],
strict=True,
)
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: the below would avoid hard-coding the ['primary', 'secondary'] and [prim, sec] while removing the multi-line branching.

Suggested change
electrode_param = getattr(self.param, domain[0])
num_phases = int(getattr(self.options, domain)["particle phases"])
if num_phases == 1:
return self._phase_heat_of_mixing(
variables, domain, "", electrode_param.prim
)
return sum(
self._phase_heat_of_mixing(variables, domain, phase_name, phase_param)
for phase_name, phase_param in zip(
["primary ", "secondary "],
[electrode_param.prim, electrode_param.sec],
strict=True,
)
)
phases = self.options.phases[domain]
names = [""] if len(phases) == 1 else [f"{p} " for p in phases]
return sum(
self._phase_heat_of_mixing(variables, domain, n, electrode_param.phase_params[p])
for n, p in zip(names, phases, strict=True)
)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @BradyPlanden, I think I addressed this in d469838. Now the phase names now come from options.phases, with "" for a one-phase electrode, so there's no branching for the phases now.

mleot and others added 2 commits September 24, 2026 18:18
`"heat of mixing": "true"` could not be used with a composite electrode.
`BaseThermal._heat_of_mixing` read the single-phase variable names
("Negative particle radius [m]", "X-averaged negative particle
concentration [mol.m-3]") and took its material properties from
`param.n.prim` / `param.p.prim` alone, so any model built with
`"particle phases": "2"` failed at build time with

    ModelError: Missing variable for submodel 'thermal':
    'Negative particle radius [m]'.

Richardson's term is per-particle-population, so the fix is to evaluate it
per phase and add the contributions, which is what the reaction, reversible
and hysteresis heat sources in the same class already do. The phase loop
reuses the `phase_names = ["primary ", "secondary "]` idiom used there.

The single-phase path is untouched: it takes the `num_phases == 1` branch,
which builds the same expression from the same unprefixed variable names as
before. A DFN with Chen2020 and "heat of mixing": "true" returns voltage,
temperature and "Volume-averaged heat of mixing [W.m-3]" bit-identical to
the previous implementation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The composite heat-of-mixing tests only built the model. Extend the
graphite/graphite heat-source comparison from pybamm-team#5770 so that splitting one
graphite phase into two identical halves must also reproduce the one-phase
"Volume-averaged heat of mixing [W.m-3]":

- run it with "heat of mixing": "true", for isothermal (heat source only)
  and lumped thermal models, comparing the temperature rise for the latter
- give Chen2020 the Ai2020 entropic changes; with zero dU/dT the reversible
  heat and the temperature dependence of U inside the heat of mixing were
  compared as zero against zero
- solve at rtol 1e-6: at the default rtol the ~300 K temperature state
  drifts by ~0.03 K, which is 1% of the rise and failed SPM + lumped

A heat of mixing that sums only the primary phase fails all four cases.

Add a Si/graphite smoke test (Chen2020_composite, lumped) checking the heat
of mixing stays positive and heats the cell. It supplies the per-layer
"Negative electrode density [kg.m-3]" the set lacks and swaps the cubic
Interpolant graphite OCP for the analytic Chen2020 fit, whose derivative
the solver can convert.

Also look up the electrode and phase parameters through
param.domain_params and options.phases instead of getattr.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@mleot
mleot force-pushed the heat-of-mixing-composite-electrodes branch from a092e23 to 4ba0a9e Compare September 25, 2026 01:32
mleot and others added 4 commits September 24, 2026 18:38
Parametrise the graphite/graphite heat-source comparison over each value
of the "thermal" option, for SPM and DFN:

- isothermal (heat source only) and lumped, on Chen2020 with the Ai2020
  entropic changes, as before
- x-full, which resolves the temperature in x, so the heat of mixing reads
  the local electrode temperature instead of the x-averaged one
- x-lumped with 1+1D current collectors

The pouch cases use Marquis2019, which ships the tab and heat transfer
parameters, a non-zero entropic change and an analytic, temperature
dependent particle diffusivity. A heat of mixing that sums only the primary
phase fails all eight cases.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Composite models default to "surface form": "algebraic" while one-phase
models default to "false", so the comparison was between two electrolyte
formulations. Pin "algebraic" for both, which makes it like with like and
lets SPMe join SPM and DFN: with the defaults SPMe differs by 1.4e-3 in
voltage from the formulation change alone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…xing

pybamm had no check that its heat sources conserve energy. The reaction and
hysteresis heats sum to a j (phi_s - phi_e - U_eq) at the particle surface,
so the stored chemical energy is -F * int U_eq dc over the particles, and
integrating by parts leaves exactly the heat of mixing taken with U_eq.
The new test integrates a 1C SPM discharge and checks that the fall in that
stored energy equals electrical work plus heat to 0.5% of the heat, for a
one-phase cell and for a composite positive electrode whose second phase
has current-sigmoid hysteresis with branches tilted away from its OCP. The
SPM keeps the electrolyte uniform and an isothermal model with zero
entropic change keeps temperature out of the stored energy, so the
particles are the only store.

Residuals are about -0.1%. The heat of mixing is ~45% of the heat, and two
plausible mistakes fail: summing only the primary phase (+19%) and taking
the heat of mixing from the hysteresis-weighted branch instead of U_eq
(-1.4%). The heat-of-mixing docstring now says it must share U_eq with the
hysteresis heat.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The heat of mixing is the dissipation of the particle flux,
-N . grad(mu) = F D |grad c|^2 dU_eq/dc, so it needs the D the flux uses.
It re-evaluated the base `phase_param.D(c, T)` instead, which ignores the
"current sigmoid" diffusivity branches and the stress-induced diffusion
factor. Read the particle's "effective diffusivity" variable, x-averaged
for the x-averaged models, as the particle submodels publish it.

With lithiation and delithiation diffusivities of D/3 and 3D on one phase
the heat sources missed the energy balance by 33% of the heat. The
energy-balance test now gives the composite positive's hysteretic phase a
current-sigmoid diffusivity (D/2, 2D): the old code fails it at -18%, the
new code passes at -0.2%.

A uniform particle profile publishes no diffusivity and has no gradient,
so its heat of mixing is now zero outright. That also makes a DFN with
"particle": "uniform profile" and heat of mixing build; it raised a shape
error before.

Default options give bit-identical voltage, temperature and heat of mixing
for SPM, SPMe and DFN on Chen2020 and Marquis2019.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Apply the review suggestion: build the phase-name prefixes from
options.phases, with "" for a one-phase electrode, and sum over them, so
the one-phase case needs no branch of its own. One-phase results are
bit-identical.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@mleot

mleot commented Sep 25, 2026

Copy link
Copy Markdown
Contributor Author

@BradyPlanden thanks for the review! Since the review I rebased onto main (after #5770); the heat of mixing now uses the particle's effective diffusivity, which it needs to conserve energy under current-sigmoid or stress-induced diffusion.

There are some addition tests including an energy-balance check and composite-vs-one-phase comparisons under every thermal option.

I think it's ready for another review when you get the chance.

@mleot
mleot requested a review from BradyPlanden September 25, 2026 02:48
@github-actions

github-actions Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

🐰 Bencher Report

ProjectPyBaMM
Branchheat-of-mixing-composite-electrodes
Testbedbare-metal

⚠️ WARNING: Truncated view!

The full continuous benchmarking report exceeds the maximum length allowed on this platform.

🐰 View full continuous benchmarking report in Bencher

rtimms and others added 2 commits October 8, 2026 19:06
Merging main put them under the released v26.9.0.0 section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@rtimms rtimms left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @mleot, this looks good. Summing per phase through options.phases and param.domain_params is clean, and switching to the particle's effective diffusivity is the right call. The energy-balance test is a great addition: it fails if only the primary phase is summed, so it really tests the fix.

I've pushed two commits to get this ready for the release:

  • 8057d1a84 merges main.
  • 49e02543f moves both CHANGELOG bullets to # [Unreleased]. Since v26.9.0.0 was cut, git silently put them under the released section, with no conflict to flag it.

Checks I ran (model build and discretisation, main vs this branch):

  • SPM, SPMe and DFN with lumped and x-full thermal, and stress-induced diffusion (Ai2020): unchanged.
  • Composite "2" and ("2", "1"): now build (ModelError before).
  • DFN with "particle": "uniform profile": now builds (ValueError before).
  • MPM and size distributions: still raise the clear NotImplementedError.

I've filed the follow-ups mentioned in the description, plus one I found:

  • #5876: heat of mixing with a quadratic or quartic particle profile gives a ShapeError. This is already broken on main, so it doesn't block this PR.
  • #5877: Chen2020_composite gives the electrode density per phase, but the thermal model reads one value per electrode.
  • #5878: "lumped" thermal doesn't discretise with 1+1D current collectors.

rtimms
rtimms previously approved these changes Oct 8, 2026
…lynomial

# Conflicts:
#	CHANGELOG.md
#	packages/pybamm/tests/integration/test_models/test_full_battery_models/test_lithium_ion/test_compare_outputs_two_phase.py
@rtimms

rtimms commented Oct 20, 2026

Copy link
Copy Markdown
Contributor

@mleot I've merged main into your branch (14803e8), since this had picked up conflicts in the CHANGELOG and test_compare_outputs_two_phase.py. I kept your parametrised test_compare_heat_sources_graphite_graphite, kept main's new test_compare_graphite_graphite_differential_surface_form, and dropped main's old test_compare_heat_sources_DFN_graphite_graphite, which your parametrisation replaces. Both CHANGELOG bullets are back under # [Unreleased]. #5935 builds on your _phase_heat_of_mixing to fix #5876 (heat of mixing with polynomial profiles), so it'll follow this one.

rtimms and others added 3 commits October 22, 2026 13:05
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mleot

mleot commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for your review, support, and fixes for this @rtimms ! Let me know if I need to attend to anything else before it's good to go.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Heat of mixing for composite (multi-phase) electrodes

3 participants