Skip to content

Fix based on review - #2

Merged
NicolaCourtier merged 78 commits into
NicolaCourtier:step-cycle-numbersfrom
SarahRo:update-step-cycle-numbers
Oct 13, 2026
Merged

NicolaCourtier merged 78 commits into
NicolaCourtier:step-cycle-numbersfrom
SarahRo:update-step-cycle-numbers

Conversation

@SarahRo

@SarahRo SarahRo commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator

Description

Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context.

Fixes # (issue)

Type of change

Please add a line in the relevant section of CHANGELOG.md to document the change (include PR #)

Important checks:

Please confirm the following before marking the PR as ready for review:

  • No style issues: nox -s pre-commit
  • All tests pass: nox -s tests
  • The documentation builds: nox -s doctests
  • Code is commented for hard-to-understand areas
  • Tests added that prove fix is effective or that feature works

rtimms and others added 30 commits September 17, 2026 15:45
…eam#5755)

* Use true node spacing for finite-volume node-to-edge shifts

`FiniteVolume.shift`'s "node to edge" matrices hard-coded weights that
assume uniformly spaced nodes: `[0.5, 0.5]` on interior edges and
`[1.5, -0.5]` / `[-0.5, 1.5]` on the two exterior edges.

`boundary_value_or_flux` extrapolates from the true node positions, so on
a non-uniform mesh the two disagree. That breaks mass conservation
wherever a flux is imposed as a gradient divided by a surface value, as
the Fickian particle does, because the flux the gradient produces
multiplies by the node-to-edge diffusivity while the boundary condition
divided by `surf(D_eff)`. The resulting bias acts in the same direction
every step, so it accumulates: with a concentration-dependent particle
diffusivity on an `Exponential1DSubMesh`, `ORegan2022` gains 26% of its
particle lithium over three 1C cycles.

Weight interior edges by the true distances and share one true-position
extrapolation for the exterior edges between the arithmetic and harmonic
means. Both reduce to the old values on a uniform mesh, so uniform-mesh
results are unchanged.

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

* Add CHANGELOG entry for pybamm-team#5755

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…y with 2 updates (pybamm-team#5757)

Signed-off-by: dependabot[bot] <support@github.com>
…els (pybamm-team#5745) (pybamm-team#5747)

* Keep t_plus * i_e / F inside the electrolyte flux in the Basic* models (pybamm-team#5745)

* Fix sodium BasicDFN electrolyte conservation
* fix(discretisation): carry domain on a primary-broadcast Vector

A Vector produced by a primary broadcast dropped its domain during
discretisation, leaving downstream operations (including DiffSL export)
with an empty domain. Carry the broadcast's domain onto the Vector.

* feat(diffsl): support Interpolant nodes in DiffSLExport

Export a pybamm.Interpolant to DiffSL's native interp1d over the table
data (declared as constant tensors), so models with interpolated
parameters (OCP, diffusivity lookup tables, ...) can be exported. This is
piecewise-linear with endpoint clamping, matching numpy.interp and
pybamm's own linear interpolant, and stays compact for any table size.
2D interpolants use successive 1D interpolation (interp1d along the inner
axis, linear blend along the outer) until DiffSL gains an interp2d.

Covered by unit tests (1D/2D export shape) and an integration test that
solves the exported model and matches an independent pybamm CasadiSolver
solve.

---------

Co-authored-by: Martin Robinson <martinjrobins@gmail.com>
…y with 2 updates (pybamm-team#5760)

Signed-off-by: dependabot[bot] <support@github.com>
…ble name (pybamm-team#5759)

* Fix duplicated prefix in minimum particle surface concentration name

An implicit string concatenation in base_particle.py repeated its first
line, so the dimensional minimum particle surface concentration was only
reachable as "Minimum negative particle Minimum negative particle surface
concentration [mol.m-3]" and the intended name resolved to nothing.

* Generalize the variable-name test to catch repeated words

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* chore: bump pinned CasADi from 3.7.2 to 3.8.1

CasADi 3.8 ships cp311-abi3 wheels on every platform, so one wheel covers
3.11 and every later CPython. 3.7.x publishes version-specific wheels only
and stops at cp314, which blocks Python 3.15.

Two migration points:

* CasADi 3.8 changes the result type of numpy functions applied to CasADi
  values (casadi#2959) and warns about it. PyBaMM's Function nodes depend
  on the legacy types, and the new mode returns ArrayInterfaceMX rather
  than MX, so pin the legacy behaviour with setNumpyMode(-1) at import.
* CasADi 3.8 no longer dispatches scipy's erf ufunc onto symbolic values,
  which broke the generic pybamm.Function(special.erf, ...) path. Map it
  to casadi.erf explicitly. pybamm.erf already overrode _casadi_evaluate
  and was unaffected.

Also bump the auditwheel exclude to libcasadi.so.3.8.

Verified locally on macOS arm64 / CPython 3.14: solver builds, 57 solver
tests, 4353 pybamm unit tests, and 496 integration tests all pass.

NOT done here: pybamm-team/casadi-vcpkg-registry has no 3.8.1 port, so the
Windows vcpkg baseline in vcpkg-configuration.json still points at 3.7.2.
Windows wheels need that registry bump before this can merge.

* ci: point the vcpkg casadi registry at the 3.8.1 port

Windows solver wheels build CasADi from the pybamm-team casadi-vcpkg
registry, whose baseline still resolved to the 3.7.2 port. Move it to the
3.0.0 port entry (CasADi 3.8.1).

TEMPORARY: baseline/reference point at the unmerged branch
update-casadi-3.8.1 (casadi-vcpkg-registry#7) so Windows CI can validate
the port. Repoint both at the post-merge main SHA before this PR merges.

This rotates the vcpkg binary-cache key, so the first Windows run
rebuilds the ports from source.

* fix: keep the auditwheel exclude on libcasadi.so.3.7

CasADi 3.8.1 still ships libcasadi.so.3.7 -- the soname does not track
the release version -- so excluding libcasadi.so.3.8 left the real
dependency unexcluded and auditwheel failed to locate it:

  ValueError: Cannot repair wheel, because required library
  "libcasadi.so.3.7" could not be located

The macOS and Windows repair commands match on an unversioned
"libcasadi", so they were unaffected.

* ci: repoint the vcpkg casadi registry at merged main

casadi-vcpkg-registry#7 is merged, so the baseline/reference no longer
need to track the unmerged branch.
* fix: correct half-cell electrolyte lithium diagnostics

* docs: link half-cell fix changelog entry

* fix: serialise electrolyte integral
Signed-off-by: dependabot[bot] <support@github.com>
…m-team#5773)

Co-authored-by: Brady Planden <55357039+BradyPlanden@users.noreply.github.com>
Signed-off-by: dependabot[bot] <support@github.com>
…amm-team#5775)

* Report whole-cell lithium in mol and fix a_j label in BasicDFN2D

The lithium integrals over x and z were in mol per metre of width; scale
them by the electrode width L_y so they are in mol. Rename the volumetric
a_j variable from "Current density [A.m-2]" to the full DFN's
"Sum of volumetric interfacial current densities [A.m-3]".

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

* Add PR link to CHANGELOG entry

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Subscript "cool" was not encapsulated correctly.
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
* Fix typo in docstring: occured to occurred

* Fix typos in Serialise docstrings: seperate to separate

* Fix typo in solver error message: occured to occurred

* Fix typos in test comment: propegated/sucessfully
pybamm-team#5776)

* Use the local active material fraction for the BasicDFN2D surface area

The surface area used the electrode-averaged active material volume fraction
while the solid lithium and conductivity used the position-dependent one, so a
graded loading failed to discretise (x grading) or to solve (z grading). Adds a
lithium conservation test with uniform and +/-10% graded loadings.

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

* Add PR link to CHANGELOG entry

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Robert Timms <43040151+rtimms@users.noreply.github.com>
* improve composite coupling

* improved sparsity

* jacobian spy helpers

* improve spy and style

* idaklu jac unit tests

* Update CHANGELOG.md

* Scope the x-averaged electrolyte concentration state to SPMe

The full conductivity submodel never reads the x-average, so on DFN it was
only an extra algebraic equation. Also document that get_jacobian_sparsity
reports the pattern of J - cj*M, and only call tight_layout in spy when it
created the figure.
…#5774)

* Stop flooring cos(theta) in the over-relaxed TPFA weight

FiniteVolumeUnstructured floored cos(theta) at 0.05 (an OpenFOAM
convention), capping the two-point weight alpha = 1/cos(theta) at 20. Kuhn
tetrahedra of a thin pouch-cell slab have internal faces about 89.9 degrees
from their centroid line (cos(theta) ~ 1e-3), so the cap under-weighted the
implicit two-point part and the explicit least-squares cross term gave the
discrete diffusion operator eigenvalues with positive real part. A tetrahedral
DFN on the default pouch geometry grew an electrolyte mode with a 5 s e-folding
time and IDAKLU failed. With the exact weight the operator is stable there and
the tetrahedral DFN matches hexahedra to about 1 mV. Meshes whose faces never
reached the floor are unchanged.

The floor also kept boundary faces with cos(theta) <= 0 finite; those now
raise the same GeometryError that inverted internal faces already raise.

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

* Add changelog entry for the unfloored TPFA weight

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

* Drop the boundary-face GeometryError guard

Keep this PR to removing the cos(theta) floor. Boundary faces only reach
cos(theta) <= 0 on inverted cells, so the guard is unrelated validation.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat: add VTK plotting for unstructured meshes

Add VTKQuickPlot, QuickPlot/dynamic_plot hooks, the vtk optional extra,
and headless OSMesa CI setup for GIF export. Extracted from the
unstructured finite-volume stack.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(plotting): draw each VTK panel on its own variable's mesh

Every panel was built from the FIRST spatial variable's mesh, so a
3-domain variable (e.g. electrolyte concentration) plotted alongside
a 5-domain one was painted onto the larger grid: all values shifted
by the leading domains' cell count, rendering electrolyte data on
current-collector tabs. Build each panel's grid from that variable's
own mesh, and make _set_cell/point_scalars raise on a length mismatch
so this bug class errors instead of drawing a wrong picture.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Sample unstructured variables for plotting on the plotting side

The regular visualisation grid, mid-plane slices, quiver sampling and
slice-plane positions used to live on the unstructured processed variables;
the spatial-method PR now ships them as pure point interpolators. Add
pybamm.plotting.unstructured_plot_grid with plot_grid, default_slice_positions,
midplane_slices and quiver_data, and have QuickPlot own the grid and slice
positions per plotted variable (the slice sliders update QuickPlot state).
Meshes expose `vertices`, not `nodes`; fix plot_vtk and its test stand-ins.

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

* Use vtkViewport.AddViewProp; VTK 9.7 removed the AddActor2D alias

The CI matrix installs extras unlocked (prebuilt-solver-wheel mode uses
uv pip install), so it picked up VTK 9.7.0, where the legacy AddActor2D
name no longer exists. AddViewProp is the underlying method in every
supported VTK. Lock VTK 9.7.0 so local environments test the same version.

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

* Build the NaN-as-white colormap with with_extremes

matplotlib 3.11 (which the unlocked CI matrix installs) emits a
PendingDeprecationWarning for Colormap.set_bad, and the test suite treats
warnings as errors. with_extremes(bad=...) has been available since 3.4.
Lock matplotlib 3.11.1 so local runs see what CI sees.

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

* feat: add unstructured 2D/3D DFN battery models

Add BasicDFN2DUnstructured and BasicDFN3DUnstructured on top of the
meshing, spatial-method, and plotting stack, plus Max/Min reduction and
domain-size helpers needed by those models.

Co-authored-by: Cursor <cursoragent@cursor.com>

* style: match export/strategy whitespace from full branch

Co-authored-by: Cursor <cursoragent@cursor.com>

* Cover the VTK dynamic_plot dispatch and remaining plotting branches

Tests for dynamic_plot(backend="vtk"), the on-screen render window, the
matplotlib-less lookup-table fallback, QuickPlot's 3D "tight" limits path
and the wireframe helper's 3D guard. The interactive-only lines in
VTKQuickPlot.dynamic_plot (interactor.Start and the render inside the
slider callback) cannot run headless and are marked no cover, as
QuickPlot already does for plt.show.

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

* Don't assert on-screen rendering state; headless CI returns an OSMesa window

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

* Make VTK panel labels legible: three ticks per axis, plain scalar-bar numbers

VTK's automatic major ticks piled a dozen labels onto short or stretched
axes ("0.060.080.100.12") and the through-cell axis became a smear under
the 100x display stretch. Set three explicit labels per axis, print the
range of any axis thinner than 5% of the largest in its title instead of
ticks, drop the "(x10^-6)" label scaling, and format scalar-bar values
with %.4g so "1.27e+03" is no longer clipped to "1.27e+" and "303." loses
its dangling point. Slightly smaller axis and panel-title fonts.

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

* Label a thin axis at its two ends rather than hiding its ticks

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

* Reject non-unstructured spatial variables in VTKQuickPlot

Only unstructured-mesh scalars and 0D time series are plottable; vector
fields and structured spatial variables used to fall through to the 0D
chart path, crashing or silently plotting mesh node 0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Read node-centred mesh coordinates through one accessor

ScikitFemSubMesh3D stores coordinates as nodes, not vertices, so the
FEM path of VTKQuickPlot only worked against test fakes. The node-data
fixture now mirrors the real mesh.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Forward dynamic_plot arguments unchanged to the chosen backend

The VTK branch re-passed output_variables by keyword next to *args, so
the positional form raised TypeError; unknown backends now raise
OptionError instead of silently using matplotlib.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Place VTK slices and cube axes on each panel's own mesh

Slice position and axis ranges came from the first variable's mesh, so a
panel on a sub-domain mesh was cut and labelled against the wrong
extents. 2D meshes now map their (x, z) columns explicitly instead of
indexing a missing third column.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Give every nox entry point the headless VTK setup

The OSMesa packages and default window class were wired only into the
reusable workflow, so the periodic matrix ran the unit suite without
them. The env default now lives in noxfile.py for headless Linux, and
the periodic workflow installs the same system packages.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Let VTK's factory choose the render window; declare pillow

Off-screen Linux no longer hard-codes the OSMesa window class, which
crashed desktops without libOSMesa; headless hosts select it through
VTK_DEFAULT_OPENGL_WINDOW instead. save_gif's PIL import is now an
optional-dependency import backed by the vtk extra.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Share QuickPlot's unstructured rendering between plot and slider_update

The 2D quiver, unstructured pcolormesh and 3D slice blocks were pasted
into both methods; they now live in one helper each. The pcolormesh
helper also replaces the previous frame's artists instead of stacking
them, and the fixed-limits scan uses a single comprehension pair.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Draw 3D scalar slices and slice sliders in the display unit

The plot_surface panels and the y/z slice sliders used raw metres with
unit-less labels while every other panel honoured spatial_unit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Transfer mesh and scalar arrays to VTK with numpy_support

Points, connectivity and per-frame scalar updates went through
per-element Python loops; they are now single vectorised copies, and the
cell/point scalar setters share one implementation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Bring the VTK plotting module in line with repo conventions

Log through pybamm.logger, raise OptionError/ShapeError/GeometryError
instead of ValueError, import optional dependencies through
import_optional_dependency, keep inline comments to two lines, add the
future-annotations import and type hints on the public functions.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Count surface points as inside and cache 3D containment masks

contains_points_3d compared the winding number against exactly 2*pi, the
value a point on a face subtends, so points on the bounding-box faces were
classified pseudo-randomly and every default 3D slice came back with NaN
speckle along its outline. Use a small positive threshold so surface
points count as inside, as in 2D, and vectorise the winding-number sum in
bounded chunks.

contains_points now remembers the mask per query-point array, so a slider
redrawing the same display grid no longer recomputes the containment test
on every frame.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Fix QuickPlot colour limits and 3D guards for unstructured variables

- Reduce quiver magnitudes with nanmax: samples outside a non-rectangular
  domain are NaN, which made np.max NaN and collapsed the colour scale to
  [0, 1].
- Keep user-specified variable_limits for vector fields instead of
  discarding them, and apply them to the quiver colour normalisation.
- Update the 3D colorbar in slider_update when the limits are per-frame,
  matching the 2D branch.
- Take 'fixed' limits from the raw cell values for every unstructured FVM
  variable, not just 3D, instead of interpolating the 200x200 display grid
  over every time step.
- Raise NotImplementedError pointing at VTKQuickPlot for 3D variables that
  are not unstructured FVM, instead of an AttributeError deep in
  _get_spatial_var.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Validate VTKQuickPlot inputs and make its colour range NaN-safe

- Unwrap solutions with QuickPlot.preprocess_solutions so a list of
  simulations (as passed by BatchStudy.plot) works, and raise OptionError
  for several solutions instead of silently plotting the first.
- Raise OptionError for an empty output_variables list instead of a
  ZeroDivisionError in the panel layout.
- Compute the lookup-table range from the finite values only, so one NaN
  cell no longer blanks the whole panel, and raise when nothing is finite.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Mark the contains_points SHA-1 cache key as non-security use

Bandit (via Codacy) flags bare hashlib.sha1; the digest only keys a
per-mesh cache of query grids, so usedforsecurity=False is accurate.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Cover the contains_points cache eviction branch

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Validate VTKQuickPlot options and read the stored variable history

- reject scale values that are not 'auto', None or one factor per axis,
  and slice fractions outside [0, 1], with OptionError instead of numpy
  IndexError/ValueError
- keep the slice plane 1e-6 of the extent inside the mesh so fractions
  0 and 1 no longer cut nothing and leave an empty panel
- use the (points x times) _entries_raw the variable already holds
  instead of rebuilding it through the time interpolator
- raise OptionError for an all-NaN variable, drop the unused mesh
  attribute, document interpolate_time

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Keep QuickPlot 3D panels stable across slider updates

Slider updates replace each panel's artists instead of calling
ax.clear(), so the limits, locators and labels set by plot() survive.
The slice sliders now carry across variables on different meshes as a
fraction of each variable's own extent instead of writing one absolute
position into every variable.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Drop banner comments flagged by the comment-slop hook

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Give BasicDFN3DUnstructured an element_type argument

Mirror the 2D model so the 3D model can be built on tetrahedra as well
as the hexahedral default, and correct the docstring, which claimed
tetrahedral elements while the generator produced hexahedra.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Cover the unstructured DFN defaults and Reduction shape in unit tests

Build both unstructured models through Simulation for every element
type so the default geometry, spatial methods, submesh types and var_pts
run under the coverage job, and check that Max/Min clear domains and
report a scalar shape. Drop the per-test numpy imports in the
integration file, which already imports numpy at module level.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Drop section banners flagged by the comment-slop hook

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Shrink contains_points_3d chunk to bound temporaries at ~20 MB

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Default quiver_data slice positions to the mesh mid-planes

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Drop forced equal aspect on unstructured 2D panels

Thin electrode cross-sections collapsed to a sliver; other 2D QuickPlot
panels stretch to the subplot, so match them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Evaluate 0D chart data in one vectorised call

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Factor the VTK panel viewport helper and drop a duplicate name map

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Share VTK 3d cameras per grid bounds instead of across all panels

A panel on a differently placed mesh was framed by the first panel's
camera and drawn off-centre.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Reserve layout room for the 3D slice sliders in QuickPlot

The time slider moves up to make space for two slice sliders, but plot()
still reserved only the single-slider strip, so panels overlapped it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Reject VTKQuickPlot options for unknown variables or several slice axes

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Make BasicDFN3DUnstructured a subclass of BasicDFN2DUnstructured

The 3D model was a near-verbatim copy of the 2D file. The 2D model now builds
its transverse spatial variables, position-dependent parameter inputs,
zero-flux boundary sides, default geometry and default var_pts from class
attributes, and the 3D model only overrides those attributes. As a result the
3D porosity and active-material FunctionParameters gain the missing
"Horizontal distance (y) [m]" input, and the 3D default mesh is resolved
comparably to the 2D one (10 through-cell points per domain, 5 x 5 transverse)
instead of 5 x 3 x 3.

Both modules now start with `from __future__ import annotations` and the
constructors are type-hinted. Solutions of the 2D quad/triangle and 3D
hexahedron models are unchanged to the last bit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Conserve lithium in the unstructured DFN electrolyte balance

The unstructured models used the non-conservative (1 - t_plus) a_j / F source
that pybamm-team#5745 removed from the other basic DFNs. Since the unstructured
divergence only accepts div(D * grad(u)) products, -div(N_e) with
N_e = -D_e grad(c_e) + t_plus i_e / F is expanded into three such terms. With
ORegan2022 (concentration-dependent t_plus) the total lithium inventory now
stays constant to 1e-15 relative instead of drifting by 1.8e-2.

Also expose the electrolyte current density i_e and the electrolyte flux N_e
under the variable names that previously held a_j and D_e_eff, and use the
surface-stoichiometry-dependent electrode conductivity like BasicDFN2D so
parameter sets such as ORegan2022 process.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Test the unstructured DFN models like the other basic models

TestBasicDFN2DUnstructured and TestBasicDFN3DUnstructured now inherit
BaseBasicModelTest, so both models run through a three-step Experiment, and
TestElectrolyteConservation checks both against ORegan2022 using the models'
own total lithium variables. The 2D-vs-structured and 3D-vs-2D voltage
comparisons pass an explicit coarse mesh instead of relying on the default
var_pts, and the 3D-vs-2D tolerance tightens from 5 mV to 0.1 mV since the
3D model is uniform in y.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Default VTKQuickPlot to the model's plottable quick-plot variables

Replace the unused _is_unstructured_spatial_variable helper with
_variable_kind and use it both to classify requested variables and to
filter default_quick_plot_variables, instead of plotting the model's
first variable key ("Time [s]" for every battery model).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Reject unknown VTKQuickPlot panel option keys and plot_type values

A misspelled key or plot_type (e.g. "Slice") used to fall through to
the default 3D pipeline without any error.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Read spatial variable history through the public entries property

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Make VTK 0D chart ranges NaN-safe and fill chart tables in one call

The chart axis range now comes from the finite samples, matching the
spatial panels, and the time/value columns are transferred with
numpy_to_vtk instead of a per-value InsertNextValue loop.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Honour fixed colour limits for quiver panels and draw wireframes once

- variable_limits="fixed" (the default) now spans the vector magnitude
  over every time and cell instead of silently behaving as "tight"
- the static 2D mesh wireframe is drawn once per figure (zorder above
  the QuadMesh) rather than rebuilt on every slider update
- the 3D error message only points at VTKQuickPlot for node-centred
  unstructured variables, which is what it accepts

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Require VTK >= 9.3.1, the first release whose numpy_support works with NumPy 2

pybamm needs numpy>=2.0.0, so the previous >=9.0.0 bound admitted VTK
wheels whose numpy bridge fails at import.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Mention the QuickPlot unstructured support and vtk backend in the changelog

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Flatten grouped quick-plot defaults and reject empty VTK panel options

A model's `default_quick_plot_variables` may group several names onto one
QuickPlot axis, so `_default_output_variables` was indexing the solution with a
list and dying with `TypeError: unhashable type: 'list'` — e.g. any
`VTKQuickPlot(solution)` on a Thevenin, lead-acid or `basic_splitOCVR`
solution. Flatten the groups first; VTK draws one panel per variable anyway.

An empty per-variable options list passed every other validation check but left
the variable with no panel, and `dynamic_plot` then divided by a zero panel
column count. Reject it where the other malformed options are rejected.

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

* Drop the unreachable unstructured branch and list the new plotting modules

`_get_spatial_var` returns early for every unstructured variable through the
display-grid lookup above, so the unstructured classes in the `second`
dimension's isinstance check could never be reached — and neither class carries
the `second_dim_pts` the branch reads. Leave `ProcessedVariable2DFVM`, which is
the only class that gets there.

`plot_vtk` and `unstructured_plot_grid` were missing from the plotting
package's `__all__`.

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

* Cover the zero-magnitude fallback for quiver colour limits

Codecov's one patch miss on this PR: a vector field whose magnitude is zero
everywhere leaves `float(np.nanmax(magnitude))` at 0, and the (0, 0) range would
paint every arrow the same colour, so the limits fall back to (0, 1). Give
`_triangle_vector_solution` a `values` argument and assert the fallback; the
test fails if the guard is removed.

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

* Move the VTK changelog entry to Unreleased and share one slider update path.

The bullet had landed under the released v26.8.0.0 section. The time slider updated each panel twice and read cell data through a private helper the public call already covers.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix 3D plotting controls and magnitudes

Co-authored-by: Cursor <cursoragent@cursor.com>

* Label VTK frames with the snapped time that is drawn

Without interpolate_time the panels show the nearest stored time, but the
time text and chart markers used the raw slider value.

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

* Step notebook slice sliders by the mesh extent

ipywidgets' default step of 0.1 display units left a metre-unit slice slider
snapping between its two ends.

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

* Point VTK x/y slice cameras along the plane normal

The slice camera only moved the axis_idx component of ResetCamera's
default +z position, so x- and y-slices were viewed ~59 degrees off
their normal. Place the camera at the focal point offset along the
normal only.

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

* Label VTK slice axes with the slice's own extent

Slice panels drew their cube axes around the cut geometry but labelled
them with the whole mesh's range, so a slice covering part of a
non-box domain showed wrong coordinates. Derive the physical ranges
from the drawn bounds divided by the panel scale.

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

* Keep surface points inside contains_points_3d on off-axis meshes

A point exactly on a boundary triangle makes that triangle's solid
angle +-2*pi (+-pi on an edge) with the sign set by round-off. On
axis-aligned faces the round-off happened to vanish, but on a rotated
mesh ~half the surface points came back outside, so plots showed NaN
gaps along the boundary. Inflate the boundary by 1e-9 of the mesh span
along the vertex normals, keeping the surface closed, and classify with
the 2*pi midpoint again.

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

* Merge the slice-camera comments into two lines

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

* Keep VTK scalar and chart data in float64

Casting to float32 collapsed fields with a small spread on a large offset
(e.g. 298.15 K + 2e-5 K) onto a couple of colours while the colour bar,
computed in float64, showed the full range.

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

* Drop matplotlib 3D support from QuickPlot

VTKQuickPlot is the 3D path. QuickPlot keeps 2D unstructured scalar and
vector-field plotting and now raises for any 3D variable, pointing
unstructured ones to VTKQuickPlot. Removes the mid-plane slice panels,
3D quiver panels, the slice sliders, and their sampling helpers.

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

* Restore the 2D-only multi-solution error assertion

QuickPlot's multiple-solution error is 2D-only again after dropping
matplotlib 3D support, so the test matches main's message.

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

* Report whole-cell lithium in mol and fix a_j label in unstructured DFN

The 2D model integrated lithium over x and z only (mol per metre of width);
scale by the unresolved length L_y so both models report mol. Rename the
volumetric a_j variable from "Current density [A.m-2]" to the full DFN's
"Sum of volumetric interfacial current densities [A.m-3]".

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

* Use the local active material fraction for the unstructured DFN surface area

The surface area used the electrode-averaged active material volume fraction
while the solid lithium and conductivity used the position-dependent one, so a
graded loading reacted as if uniform and total lithium drifted (~0.2% over
20 min). The conservation test now also runs with a +/-10% graded loading.

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

* Drop the Max/Min Reduction and domain_size changes from the unstructured DFN PR

Neither unstructured model uses them and the models solve without them,
so they belong in their own PR once something needs them.

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

* Simplify the unstructured DFN axis bookkeeping

List only the resolved axis letters and look up each axis's
FunctionParameter label and boundary sides in one table. Drop the
direction tags (the unstructured method reads the axis from the name),
the separate z_2d/z_3d geometry variables, and the spatial variables
in model.variables; geometry and var_pts are keyed by plain strings.

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

* Write the unstructured DFN geometry out explicitly

Replace the axis table and nested helpers with a single
_three_dimensional flag, explicit spatial-variable lists per domain and
inline integrals, sides and geometry.

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

* Merge the unstructured DFN models into BasicDFNUnstructured

Replace BasicDFN2DUnstructured and BasicDFN3DUnstructured with one
BasicDFNUnstructured(dimension=2|3, element_type=None). The element type
defaults to quad in 2D and hexahedron in 3D; other dimensions raise
OptionError.

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

* Select the unstructured DFN dimension with a 'mesh dimensionality' option

BasicDFNUnstructured now takes (options=None, name=...) like the other
basic models. The new 'mesh dimensionality' option (1 default, 2 or 3)
sets the electrode/separator mesh dimension; only BasicDFNUnstructured
accepts 2 or 3 and it defaults to 2. The element_type argument is gone:
simplex elements are chosen through Simulation(submesh_types=...).

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

* Use the existing 'dimensionality' option for the unstructured DFN

'dimensionality' already counts the directions resolved besides x
(1 = z, 2 = y and z), so it selects the x-z or x-y-z mesh directly.
Drop the 'mesh dimensionality' option and its base-class check; the
model defaults to 1 and rejects 0 and 3.

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

* Solve the unstructured DFN for every element type in the unit test

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

* Name the interfacial current density correctly in the 2D basic DFN models

"Negative/Positive electrode current density [A.m-2]" is the solid-phase
current density elsewhere in PyBaMM; these models stored j under it.

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

* Include L_y in the unstructured DFN conditioning scale in 3D

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

* Add "Voltage expression [V]" to the unstructured DFN

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

* Build the unstructured DFN integration-test models inside the test

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

* Restore the blank line in pybamm/__init__.py

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

* Say tetrahedra are not supported by the unstructured DFN

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Implement Mayur2024

Added Mayur2024 parameter file for composite degradation (Si/Gr anode) and tested.

* Removed redundant folder

* style: pre-commit fixes

* fix: rename Mayur2024 to Bonkile2024 and fix citation wiring

Addresses PR review comments on the composite Si/graphite parameter set:

- Rename the parameter set from Mayur2024 to Bonkile2024 (surname+year,
  matching every other set's convention) before release makes it a
  breaking change. Renamed the module, and updated the pyproject.toml
  entry point and the lithium_ion __init__.py __all__ list (placed
  alphabetically, dropped the trailing comma before the closing bracket).
- Register the actual source paper as a citation: added a Bonkile2024
  BibTeX entry to CITATIONS.bib (alphabetically between Beeckman1990 and
  BrosaPlanella2021), put "Bonkile2024" first in the parameter set's
  citations list, and changed the get_parameter_values() docstring's
  plain-prose attribution to :footcite:t:`Bonkile2024` so it renders
  like the neighbouring references.
- Fix the invalid "Ai2020" citation key (no such entry in CITATIONS.bib)
  to "Ai2019", both in the citations list and in a docstring footcite
  reference -- this was breaking pybamm.print_citations() for every
  remaining unknown citation, and breaking the docs build.
- Register "Yang2017", which was already cited in prose but missing
  from the citations list.
- Fix an accidental implicit string concatenation that split
  "Electrolyte conductivity [S.m-1]" across two literals for no reason.

Verified every :footcite:t: key in the file resolves against
CITATIONS.bib and is present in the citations list (checked
programmatically in both directions).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Add Bonkile2024 to param_to_model test and changelog

- Adds a `Bonkile2024` entry to test_parameter_values_with_model
  covering the model options it needs: dual particle phases with a
  current-sigmoid/single OCP split, solvent-diffusion-limited SEI,
  partially reversible lithium plating, swelling-and-cracking/
  swelling-only particle mechanics, and stress-driven LAM.
- Adds a Features changelog entry under Unreleased for PR pybamm-team#5694.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015UqRkqrpsTfNkfwzuf68CD

* Clear comment-slop hits in Bonkile2024 and EntryPoint docstrings

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: rtimms <rob@ionworks.com>
dependabot Bot and others added 29 commits October 2, 2026 11:53
…curity-updates group across 1 directory (pybamm-team#5833)

Co-authored-by: Brady Planden <55357039+BradyPlanden@users.noreply.github.com>
Signed-off-by: dependabot[bot] <support@github.com>
…ybamm-team#5835)

* feat: anisotropic coefficients in FiniteVolumeUnstructured div(D grad u)

div_D_grad now accepts a VectorField (one value per axis, a diagonal
tensor, as FiniteVolume2D does) or a rank-2 TensorField (full tensor), each
component a cell field. Each face uses its normal conductivity n.K.n from
the owner and neighbour cells, combined by the same distance-weighted
harmonic mean as a scalar D, and n.K.n of the owner on boundary faces.
Identically-zero constant components are skipped. Exact when K's principal
axes are aligned with the faces; the tangential part of K n is not
included. Checks: hex box with K = diag(1, 10, 100) reproduces the scalar
operator with each axis's k to 1e-15, and linear fields to 6e-15.

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

* test: anisotropic div(K grad u) in FiniteVolumeUnstructured

Isotropic VectorField / TensorField match the scalar operator on triangle,
quad, tet and hex meshes; a diagonal tensor uses each axis's conductivity
(interior, Dirichlet and Neumann faces); on axis-aligned faces a full tensor
reduces to its diagonal (the documented limitation), while a rotated tensor
differs on a tet mesh; zero components are skipped; secondary-domain
repeats; shape and zero-coefficient errors; pybamm.div(K * pybamm.grad(u))
dispatches to div_D_grad. The zero check now looks through broadcasts and
treats an unevaluable component as non-zero. Replaces the test that a
VectorField coefficient raises.

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

* docs: changelog PR number for pybamm-team#5835

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

* style: pre-commit fixes

* fix: satisfy comment-slop and cover state-dependent anisotropic components

Drops the unreachable NotImplementedError branch, adds a state-dependent
VectorField test, and trims comments flagged by the style job. The
comment-slop hook diffs against HEAD locally, so it was checked against
the base ref instead.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
…n a direction (pybamm-team#5838)

* Reject FiniteVolume2D meshes with a single node in either direction

Node-to-edge shifts and boundary extrapolation in FiniteVolume2D need at
least two nodes per direction, so a 2D submesh with one node (e.g.
BasicDFN2D with z_2d = 1) failed deep in harmonic_mean with an opaque
scipy ValueError. build() now raises a DiscretisationError up front;
ghost-cell submeshes are exempt.

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

* Add PR number to CHANGELOG entry

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

* ci: retrigger cancelled Read the Docs build

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
… nodes (pybamm-team#5839)

* Compute FiniteVolume2D extrapolation spacings only when needed

Boundary extrapolation read the second node spacing in both directions
unconditionally, so any 2D mesh with fewer than 3 nodes in either
direction raised IndexError. Spacings are now looked up lazily and a
DiscretisationError is raised when the mesh is too coarse for the
requested extrapolation.

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

* Add PR link to CHANGELOG entry

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

* test: drop one-node coarse-mesh cases now rejected by pybamm-team#5838

FiniteVolume2D raises on meshes with a single node in a direction (pybamm-team#5838),
before extrapolation runs, so these two cases can no longer reach the
extrapolation check. pybamm-team#5838's own tests cover the one-node rejection.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…m#5849)

* fix: log NonlinearSolver Newton iterations at DEBUG level

StandaloneNewtonSolver never attached a SolverLog to its NonlinearSolver,
so per-iteration messages were dropped. Accept an optional logger in
solve/solve_batch and pass pybamm.logger.debug when DEBUG is enabled.

* test: regression test for NonlinearSolver debug logging

* docs: link PR in changelog
… per zone (pybamm-team#5815)

Co-authored-by: tleo <tleo@silanano.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: mleot <140573653+mleot@users.noreply.github.com>
…-team#5837)

* fix(idaklu): run a single solver outside any OpenMP parallel region

With one solver the solve still ran inside a one-thread parallel region, so
OpenMP code the model calls during the solve (a CasADi external function, for
example) ran nested at level 1. It then took the nested-level ICVs, so under
OMP_NUM_THREADS=8,1 its regions got one thread instead of eight. A single
solver now runs directly on the calling thread; several solvers keep the
self-scheduled team.

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

* docs: changelog for the single-solver OpenMP fix

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

* docs: link the changelog entry to pybamm-team#5837

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

* test(idaklu): a single solve runs outside any OpenMP region

The solver's logger runs on the calling thread mid-solve, so it can read
omp_get_level() from the loaded OpenMP runtime. It reads 1 on main and 0
with the fix.

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

* test(idaklu): satisfy ruff in the OpenMP runtime lookup

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

* test(idaklu): multiple solvers still run in an OpenMP region

Companion to the single-solve test: with num_solvers=2 the calling
thread's logger reads omp_get_level() == 1, so the multi-solver path is
unchanged and the level probe can see a nonzero level.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…_time schedule (pybamm-team#5817)

* fix: give repeated experiment steps their own start/end times

Steps are deduplicated by repr, so repeated steps shared one object and
_set_next_start_time left every repeat with the first occurrence's times.
Each position now gets a shallow copy.

Fixes pybamm-team#5809

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

* docs: add changelog entry for pybamm-team#5817

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ads (pybamm-team#5785)

* fix: processed-variable ordering, 3D unrolling and faster computed reads

Return unsorted non-Hermite time queries in query order, unroll computed 3D
variables time-major, and stack mixed scalar and vector inputs. Build computed
variables' xarray lazily, read 0D ones with np.interp; add Solution.sensitivity_names.

* fix: lay out as_computed() rows as the solver returns them

as_computed() flattened each time point in C order, the reverse of the
F-ordered CasADi output that unroll_2D/unroll_3D expect, so 2D and 3D
variables round-tripped with their spatial axes reordered. It now
flattens the kernel's raw observation, before per-class reordering.

initialise_3D_scikit_fem read a 2+1D model's current-collector nodes
y-fastest, but the mesh numbers them z-fastest, so x-y-z variables from
output_variables had y and z swapped. The as_computed tests now use
spatially varying data, which the constant data could not catch.

* fix: convert 0D time integrals in as_computed()

as_computed() reshaped a time integral's entries as if they kept a time
axis. A 0D integral is already summed to one value, so it raised a
ValueError; scikit-fem integrals are already reordered, so 2D and 3D
ones came back scrambled. A 0D integral now passes its summed value
with time_indep=True, as an output_variables solve stores it, and
spatial time integrals raise NotImplementedError.

The 3D-only output_variables test becomes a matrix over every layout
ProcessedVariableComputed supports and every time-integral method, each
checked against an output_variables solve. A guard fails when a new
ProcessedVariable subclass or time-integral method has no case.

* fix: join output_variables solutions at a shared boundary

Solution.__add__ drops the second solution's first time point when it
repeats the first's last, but ProcessedVariableComputed._concat kept
that sample's data and sensitivities, so the joined variable held one
more value than time points and raised on read. The join now drops them
too, and takes the joined variable's times from its segments, because
Solution.from_sub_solutions passes the final solution to every join.

A time integral from output_variables or as_computed() lost its time
independence in the join and could not be read. The variable now
carries its ProcessedVariableTimeIntegral: an ExplicitTimeIntegral joins
into its integral over both segments, counting the initial condition
once. A DiscreteTimeSum, or an expression of a time integral, has no
value over the join, since every segment samples its end points; the
join still succeeds, and reading the variable raises
NotImplementedError.

The output_variables matrix now joins every layout and time-integral
method through Solution.__add__ at a shared boundary, checked against a
full-state join. New tests cover sensitivities at a shared boundary,
__add__ and from_sub_solutions over three segments, and an experiment
with a DiscreteTimeSum output variable.

* docs: say a joined time integral leaves out any gap between segments

ProcessedVariableComputed._concat sums each segment's own integral, so
nothing is integrated between segments that do not share a boundary.
The docstring now says so, and a test covers the gap for a join of two
output_variables solves and of a full-state solve with one.

* test: read a variable from a solve with mixed scalar and vector inputs

The all_inputs_stacked test only checked the stacked array. It now also
reads a variable from an IDAKLUSolver solve with a scalar and a vector
input, which raised a ValueError before the stacking fix.

* docs: regenerate the bpx notebook's parameter search output

The stored output of parameter_values.search("Negative electrode") had
one line edited by hand to the new diffusivity name, but that name no
longer matches the search, and the output predates the current search
format. It is now the output of running the cell.

* docs: say the np.interp fast path also takes an empty or tuple t

The comment said every query the fast path takes keeps xarray's errors, but an empty or tuple t, which xarray rejected, now returns values.

* refactor: stack Solution inputs with flatten_inputs

Solution.all_inputs_stacked re-implemented base_solver's
flatten_inputs, which handles mixed scalar and vector inputs and an
empty dict the same way. It now uses the helper.

solution.py now imports base_solver while pybamm is still initialising,
so base_solver defers its annotations; on Python < 3.14 its
pybamm.Solution annotation otherwise fails at class creation.

* docs: trim the changelog entries and comments

Each changelog entry is now one line, leaving the detail to the PR, and
the performance entry moves from Features to Optimizations. Four
two-line comments become one line, and a test comment that restated the
test's name is removed.

* docs: say how a joined time integral treats a gap between solutions

A full-state join integrates an ExplicitTimeIntegral across a gap between
the solutions, while a join involving output_variables sums each
solution's integral. Solution.__add__ now says so.

* fix: read and join computed variables pickled by v26.9

_unjoinable, _xr_interp_args and time_integral were set only in
__init__, so a ProcessedVariableComputed loaded from a v26.9 pickle
raised AttributeError on its first read or join. Class-level defaults
cover it; a new test strips them from a pickled variable, before and
after its first read.

* docs: say the joined-solutions fix leaves out sparse variables
… and first_state (pybamm-team#5794)

* fix: leave the initial condition out of time-integral sensitivities

postfix_sensitivities summed the sensitivities with postfix_sum, which
adds the time integral's initial condition. The initial condition does
not depend on the inputs, so every sensitivity of
ExplicitTimeIntegral(c, 5) came back 5 too large, on both the
full-state and output_variables paths. The sum over time is now kept
apart from the initial condition, which only the value adds.

A new test checks the sensitivity against its analytic value on both
paths, and the output_variables join test now uses a nonzero initial
condition, which the joined value counts once and its sensitivity not
at all.

* fix: store every entry of sparse output variables

IDAKLUSolver returns only the structural nonzeros of an output
variable, such as the zero-flux boundary edges of "Electrolyte current
density [A.m-2]", while as_computed() data holds every entry.
ProcessedVariableComputed inferred one layout for a joined variable
from its first CasADi function and filled every segment from the first
segment's data, so joining two such solutions, or the steps of an
experiment, raised an IndexError, and joining one to a full-state
solution raised a ValueError. The solver now scatters the nonzeros into
every entry, and _unroll_nnz is gone.

The output_variables matrix gains a 1D and a 2D sparse case, and its
join test also joins two output_variables solutions. Its comparisons
take an atol, since the full-state path rounds values within about
1e-16 of zero to zero.

* fix: trim joined sensitivities and sum time-integral ones over all segments

Adding solutions solved with sensitivities dropped the repeated time
point at a shared boundary from the states but not from their
sensitivities, so reading the sensitivities of any variable raised a
CasADi RuntimeError. _merge_sensitivities now drops that time point's
rows, in __add__ and from_sub_solutions alike.

A time integral's value sums over every segment at once, but its
sensitivities were summed per segment and stacked, one row per segment.
They now go through a single postfix over all segments.

* fix: differentiate a time integral's post-sum expression at the integral

postfix_sensitivities evaluated the derivative of a post-sum
expression, such as ExplicitTimeIntegral(c, 0) ** 2, at the
expression's own value rather than at the integral, so the square gave
2 I^2 dI/da instead of 2 I dI/da. It now takes the summed variable at
each time point, as postfix does, and forms the integral itself; both
callers pass those raw values.

test_explicit_time_integral and test_discrete_data_sum encoded the wrong
derivative and are corrected. At the discrete test's a = 2 the model
reproduces its data, and the square root of a zero sum has no
derivative, so that one sensitivity assertion is skipped. The joined
time-integral sensitivity test gains a post-sum case.

* fix: take first_state sensitivities from the first time point

first_state sliced the last n_states rows of the first segment's
sensitivities, which belong to its last time point, so it carried the
sensitivities of the wrong state. It now takes the first rows, as the
states themselves do.

* fix: keep an output_variables solve's initial state on its Solution

first_state of a solve that returns only output variables read the
model's y0full when first accessed. That vector is the solver's input:
it carries the initial sensitivities after the states, holds the
algebraic states from before consistent initialization (a DFN's
potentials were off by up to 0.08 V), and is overwritten by the model's
next solve. In an experiment whose cycle repeats a step, the cycle's
first state and every "Change in ..." summary variable came from the
last repeat, halving "Change in throughput capacity [A.h]".

pybammsolvers now returns y_init alongside y_term when it saves only
output variables: the states at t0 after consistent initialization,
from which the outputs at t0 are computed. IDAKLUSolver keeps it on the
Solution, and __add__, from_sub_solutions, copy and reduce_solution
carry it. It ships in the unreleased pybammsolvers 0.11.0, which pybamm
already requires.

Solution gains a _y0 slot, which a solution pickled before it existed
loads as None.

* refactor: scatter sparse output variables at their flat indices

The fill of a sparse output variable indexed the dense entries by the
sparsity's row indices, which are the flat indices only because every
output variable is a column vector. It now uses find(), the flat
indices for any shape, and reads the sparsity from sparsity_out()
instead of evaluating the variable at zero.

* fix: take sensitivities only with respect to the sensitivity inputs

An output variable's parameter Jacobian, which pybammsolvers uses to
compute its sensitivities during an output_variables solve, was taken
with respect to every input, but the solver reads one column per
sensitivity input. With calculate_sensitivities=["b"] and inputs a and
b, the solver took the derivative with respect to a, returned it as
"a", left "b" empty, and wrote past the end of its buffer for the
column of b. The Jacobian is now taken with respect to the sensitivity
inputs, in their order, and the output variables' sensitivities are
named by them.

The post-sum expression of a time integral was differentiated with
respect to every input too, so its (1 x inputs) derivative broadcast
against the (1 x sensitivities) chain-rule term: "b" came back as the
derivative with respect to a. postfix_sensitivities now takes the
sensitivity names, keeps the other inputs as values, and differentiates
with respect to those names only.

* fix: keep the state sensitivities of an output_variables solve

An output_variables solve filled the states' sensitivities from sol.yS,
which then holds the outputs' sensitivities as (n_t, n_outputs, n_p):
indexing it by parameter picked a time point. With more sensitivity
inputs than time points the solve raised an IndexError; otherwise
first_state and last_state held one entry per output, of the wrong
quantity, and solver.step started the next step from them, raising
"y0 has wrong number of cols" or, when the counts matched, giving wrong
sensitivities (du/da = 0.23 for an exact 0.86).

pybammsolvers now returns yS_init and yS_term alongside y_init and
y_term: the states' sensitivities at the first and last time points,
one row per sensitivity parameter. IDAKLUSolver keeps them on the
Solution for first_state and last_state, and __add__,
from_sub_solutions, copy and reduce_solution carry them as they carry
y_init and y_event. The states' sensitivities over time are empty, as
the states are.

Solution gains _y0_sensitivities and _y_event_sensitivities slots,
which a solution pickled before they existed loads as None.

BaseSolver._check_restart_sensitivities rejected every sensitivity
restart from an output_variables solve, whose step boundary had no
state sensitivities to seed from. It now rejects only a solve without
them, and the step test that expected the rejection checks the
restarted sensitivities instead.

* docs: trim the changelog entries and comments

Each changelog entry is now one line, leaving the detail to the PR, and
eleven two-line comments become one line.

* docs: remove pybammsolvers version
changelog entry

* test: keep the event sensitivities of a trailing last_state join

from_sub_solutions takes _y_event_sensitivities from the last kept
segment, not sols[-1], matching __add__'s single-sample short-circuit,
which keeps self's. The comment above it covered only closest_event_idx,
so it now says why the event sensitivities follow it too.

Simulation joins a trailing single-sample duplicate when every step of
a cycle is infeasible: it carries last_state over, which holds no event
sensitivities of its own. Taking them from sols[-1] would drop them, so
last_state.sensitivities would come from the outputs' rows and a
restart would raise. A new test joins a solve with its last_state
through both __add__ and from_sub_solutions and checks them.
…ybamm-team#5786)

* refactor: put solution observation behind an ObservationBackend seam

A Solution reads variables through one ObservationBackend and a ProcessedVariable
through one VariableObserver (CasADi only); OutputAssembly owns the output_variables
layout and keys its sensitivities by sensitivity parameter.

Solution carries its backend in an _observation slot, which a solution
pickled before it existed loads as CASADI_OBSERVATION.

* test: mark the new pickle round trips nosec, as main's tests do

* refactor: read each segment through its own model's observer cache

A Solution carried an observation backend, the module-level
CASADI_OBSERVATION, which first_state, last_state, +, from_sub_solutions
and copy sliced and joined. The backend held no state, so every slice
and join returned it unchanged, while the CasADi leaves it compiled were
cached on each model.

A Solution now holds no observation state. build_variable reads each
segment through its own model's ObserverCache, which replaces
model._variables_casadi and keeps its rules: keyed by variable name,
copied by new_copy, and skipping time integrals. A model pickled before
the cache existed builds one on first read.

ObservationBackend, CasadiObservation, CASADI_OBSERVATION and
join_observations are removed with their tests.

* perf: convert each time integral to CasADi once per model

ObserverCache skipped time integrals: it cached only the CasADi leaf, so
a hit lost the integral and would have read its integrand. Every read
of a time integral therefore converted it again for each segment, 20
times for a 20-step experiment, and again for every solve of the model.

The cache now keeps a leaf with the expression it evaluates and its
time integral. The integral's analysis depends only on its expression,
and nothing changes it after conversion, so solutions of one model can
share it. Reading an ExplicitTimeIntegral from a 20-step experiment
drops from 1.9 ms to 0.6 ms on first read and from 1.5 ms to 0.01 ms on
later solves of the model, with identical values.

* docs: trim the changelog entries and comments

Each changelog entry is now one line, leaving the detail to the PR, and
the performance entry moves from Features to Optimizations. Eight
two-line comments become one line, and four that restated the code they
moved with are removed. VariableObserver holds its leaves rather than
owning them: the model's ObserverCache shares them.

* test: read an output variable with no structural nonzeros

The solver returns no rows for such a variable, so its payload is
(n_t, 0). Covers it as the only output and alongside a regular one.

* docs: say a cached observer leaf keeps its first solve's inputs and options
…MD (pybamm-team#5864)

* perf(pybammsolvers): order KLU factorisation with AMD instead of COLAMD

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

* docs: add PR number to changelog

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

* docs: shorten changelog entry

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

* test: loosen integration solver rtol to 1e-6

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

* test: rtol 1e-7 for integration solves, 1e-4 for serialisation round-trip check

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Robert Timms <43040151+rtimms@users.noreply.github.com>
…t-up (pybamm-team#5880)

Co-authored-by: Marc Berliner <marc@ionworks.com>
* Add model zoo policy

* Rob's comments

* Apply review suggestions: zoo PR checklist wording, model.toml as maintainer source of truth

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

---------

Co-authored-by: rtimms <rob@ionworks.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Robert Timms <43040151+rtimms@users.noreply.github.com>
…team#5869)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brady Planden <55357039+BradyPlanden@users.noreply.github.com>
* Require BSD-3-Clause for in-tree model zoo models

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

* Add PR link to zoo changelog

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

* Make the zoo license rule its own unwaivable contract check

A skip_contract waiver of 'packaging' would otherwise bypass it.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
)

* fix: average BasicDFN2D voltage over the positive face

Voltage was phi_s_p at the top-right corner, so it depended on the z mesh
whenever properties vary with z. Use the z-average of the right-boundary
value, consistent with the uniform-current Neumann BC there.

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

* docs: link CHANGELOG entry to pybamm-team#5865

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

* test: pin BasicDFN2D voltage to the corner value for uniform properties

Mesh independence alone would not catch a wrong normalisation. Also say
why the face average is the right voltage.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: rtimms <rob@ionworks.com>
…amm-team#5805)

* fix: sum the phases' surface area to volume ratios for the total

The total surface area to volume ratio of a multi-phase electrode (pybamm-team#5165)
was sum(eps_k * R_k**2) / (3 * sum(R_k**3)). The surface area per unit
electrode volume is the sum over phases of particle number density
n_k = eps_k / (4/3 pi R_k**3) times particle surface 4 pi R_k**2, that is
sum(3 * eps_k / R_k), the sum of the per-phase ratios the model already
defines. The old expression took a ratio of sums, weighting each phase by
its volume fraction instead of its number density, and divided by 3 where
it should multiply: for Chen2020's graphite split into two identical
halves it gave 21 331 m-1 instead of 383 959 m-1 (pybamm-team#5802).

The total sets the double-layer charging under "surface form":
"differential", so that split differed from the one-phase electrode by
7e-5 in voltage at 1C; it now agrees to ~1e-9 for SPM, SPMe and DFN. The
new test checks the total and the voltage for all three models; it fails
without the fix.

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

* docs: link the surface-area fix's CHANGELOG entry to its PR

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

* test: unit tests for the total surface area to volume ratio of composite electrodes

Lifted from pybamm-team#5803 by @shaurya20335. Checks two distinct phases against
3 eps_1 / R_1 + 3 eps_2 / R_2, and that splitting the Chen2020 negative
electrode into two identical phases keeps the single-phase total.

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Shaurya Shukla <shauryabhushanshukla@gmail.com>
Co-authored-by: rtimms <rob@ionworks.com>
Move the Unreleased entries under v26.10.0.0 and bump CITATION.cff. Move the
pybamm-team#5781, pybamm-team#5784, pybamm-team#5813 and pybamm-team#5817 entries out of v26.9.0.0, which was tagged
before they merged, and add the missing pybammsolvers>=0.11.0 breaking change.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ity fixes (pybamm-team#5882)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…-team#5872)

* fix: give Marcus kinetics the right sign and consistent units

In the Marcus rate law the oxidation rate goes with (lambda - e*eta)^2 and the
reduction rate with (lambda + e*eta)^2. The two were swapped, so the current
was negative for a positive overpotential. The reorganization energy, in eV,
was also used as a bare number next to RT in J/mol, so the current depended on
the unit system, and models with Marcus kinetics failed to solve.

Nondimensionalise lambda and eta with F/RT, as MarcusHushChidsey does, and put
the terms the right way round. Add a unit test against the closed-form rate
law and an integration test that solves SPM, SPMe, DFN and Newman-Tobias with
Marcus kinetics.

Fixes pybamm-team#5870

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

* add changelog entry for pybamm-team#5872

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat: add Python 3.15 support to pybamm and pybammsolvers

Raise requires-python to <3.16 in pybamm, pybammsolvers and
pybamm-model-zoo, add the 3.15 trove classifier, and re-lock the
workspace over the widened range. The zoo must move too: uv locks a
workspace to the intersection of its members' ranges.

CI installs from uv.lock, so the locked packages with no cp315 wheels
(numpy, scipy, pandas, matplotlib, contourpy, kiwisolver, cffi,
markupsafe, pydantic-core, rpds-py, watchfiles, memray, pywinpty) are
upgraded; otherwise every 3.15 leg builds numpy and scipy from source.

The jax and vtk extras skip 3.15: no jaxlib inside the jax<0.9.0 pin has
cp315 wheels, and vtk publishes neither cp315 wheels nor an sdist.

CI: cibuildwheel already targets cp315, so only the cp315t skip is added
(CasADi has no free-threaded build; its cp311-abi3 wheels cover 3.15).
3.15 becomes the max endpoint of the gated unit-test matrix on Linux
x86/arm and macOS and of the advisory Windows job, with 3.14 moving to
the nightly sweep alongside the other interior versions. setup-python
allows prereleases until 3.15.0 is published.

* fix: lock xarray 2026.9.0 for numpy 2.5

numpy 2.5 deprecates generic-unit timedeltas, and xarray before
2026.04.0 builds one at import, so the model zoo suite (whose
filterwarnings is a bare "error") failed to load its conftest.

* ci: drop setup-python prereleases now that 3.15.0 is out

3.15.0 is in the setup-python manifest, so the 3.15 legs resolve to the
final release without allow-prereleases.

* feat: allow jax up to 0.11 so the jax extra installs on Python 3.15

jaxlib publishes cp315 wheels only from 0.11.1, so widen the jax extra
to <0.12.0 and drop its python_version < '3.15' marker. JAX 0.11 needs
Python 3.12, so uv forks the lock: 3.11 gets jax 0.10.2 and 3.12-3.15
get 0.11.2.

JAX 0.11 removed APIs the solvers used:

- The BDF solver's closure conversion relied on jax.core.get_aval,
  lu.transformation and pe.trace_to_jaxpr_dynamic. Use the public
  jax.closure_convert (also what jax.experimental.ode uses) and a plain
  closure for ravel_first_arg. Drop the unused _check_arg, which called
  the removed core.valid_jaxtype.
- IDAKLU-JAX's JVP used lax.zeros_like_array; use jnp.zeros_like.

Newer JAX also hands a primitive's eager implementation a scalar time as
a 0-d np.ndarray rather than np.float64, which ProcessedVariable indexed
with t_observe[0]. Treat a 0-d array like a scalar.

The jax tests pass on jax 0.7.0, 0.8.3, 0.10.2 and 0.11.2.
…ad of being added (pybamm-team#5886)

* Add strict=True to ParameterValues.update so unknown keys raise (pybamm-team#5874)

With strict=True every key must already exist; all keys are checked before
any is written, and the KeyError lists the closest existing names from
FuzzyDict.get_best_matches. The default is unchanged and still adds new
parameters.

* Run the strict check after list values are expanded

check_parameter_values expands a list value into indexed keys, so the
check on raw keys rejected a valid strict update of an existing list
parameter. The check now runs on the expanded keys, still before any
write. A name with no close match is reported without an empty match list.

* Reject an unknown name with an empty list under strict=True

An empty list expands to no indexed keys, so the strict check never saw
the name. The name itself, or its first indexed key, must now exist.

---------

Co-authored-by: Saltwater Energy Solutions <266172925+saltwater-energy-solutions@users.noreply.github.com>
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.16.10 → v0.17.0](astral-sh/ruff-pre-commit@v0.16.10...v0.17.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
@NicolaCourtier
NicolaCourtier merged commit 98455d5 into NicolaCourtier:step-cycle-numbers Oct 13, 2026
39 of 41 checks passed
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.