Skip to content

Add unstructured finite volume spatial method - #5688

Merged
aabills merged 24 commits into
mainfrom
ufv-2-spatial
Sep 5, 2026
Merged

Add unstructured finite volume spatial method#5688
aabills merged 24 commits into
mainfrom
ufv-2-spatial

Conversation

@aabills

@aabills aabills commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Summary

Stack

  1. Generalise VectorField to N components #5686 — VectorField N-comp
  2. Add unstructured mesh infrastructure #5687 — Meshing
  3. This PR (Add unstructured finite volume spatial method #5688) — Spatial method + ProcessedVariable
  4. Add VTK plotting for unstructured meshes #5689 — VTK plotting
  5. Add unstructured 2D/3D DFN battery models #5690 — Unstructured DFN models

Review tip: the spatial-only commit is feat: add unstructured finite volume spatial method; earlier commits are the two dependencies.

Test plan

  • test_finite_volume_unstructured.py + mesh + tensor-field tests (158 passed locally)
  • CI unit suite

Also in this stack: #5691 deprecates pybamm.Magnitude.

@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.67752% with 13 lines in your changes missing coverage. Please review.
✅ Project coverage is 98.15%. Comparing base (78fe10a) to head (f80e2ec).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
...es/pybamm/src/pybamm/solvers/processed_variable.py 95.27% 6 Missing ⚠️
...ybamm/src/pybamm/discretisations/discretisation.py 90.69% 4 Missing ⚠️
...bamm/spatial_methods/finite_volume_unstructured.py 99.62% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #5688      +/-   ##
==========================================
- Coverage   98.21%   98.15%   -0.06%     
==========================================
  Files         340      341       +1     
  Lines       32743    33712     +969     
==========================================
+ Hits        32158    33090     +932     
- Misses        585      622      +37     

☔ 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.

Adds FiniteVolumeUnstructured (TPFA Laplacian, fused div_D_grad,
Green-Gauss gradient), unstructured processed variables, and the
discretisation dispatch for div(D*grad(u)) and graph-topology internal
boundary conditions, on top of the unstructured meshing (#5687) and
N-component VectorField (#5686) already on main.

Includes the fixes from the review of the previous revision:
- Neumann values on named axis sides are coordinate-direction
  derivatives (matching FiniteVolume); custom tags stay outward-normal
- unknown BC sides and bc_types raise DiscretisationError instead of
  being silently dropped; boundary_integral supports "entire"
- auxiliary domains are handled in laplacian/gradient BC assembly and
  in secondary/tertiary broadcasts
- divergence of a BC-bearing flux raises rather than silently dropping
  the boundary flux; the Green-Gauss gradient warns for BC-less buckets
- scalar reductions (Max/Min) on unstructured domains post-process as 0D
- operator matrices are cached per submesh (invalidated when the cell
  ordering changes) and BC assembly is vectorised
- ParameterSubstitutor.process_boundary_conditions processes every
  boundary side present, not a fixed whitelist, so tab and named-region
  Dirichlet conditions are no longer silently dropped

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
aabills and others added 9 commits August 11, 2026 17:54
…D_grad

A BC value with one entry per boundary face broke with a ShapeError when
the variable had auxiliary domains; lift it to n_bnd * repeats entries,
matching _bc_contribution's convention.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
y was silently dropped when building the query grid, returning
z-midplane values for a query the user thinks is at y; raise like the
existing r/R check. 3D unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
scipy's interpolators accept (n_points, n_t) value arrays, so the
nearest-neighbour KDTree and the domain boundary mask are now built once
per query instead of once per time step (~40x on a 100-step animation).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
spatial_variable silently fell back to the x column for unknown names
and directions, matched loose prefixes (zeta -> z), and accepted y/fb on
2D x-z meshes. Match the leading name token exactly and raise otherwise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the hasattr duck-typing dispatch with a documented base-class
hook returning None (use the default 1D-stack routine); the unstructured
method's implementation becomes a plain override.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
With arbitrary boundary tags now allowed, a region name containing
'tab' (e.g. 'tab_weld') was misrouted into check_tab_conditions and
raised ModelError; share one exact-name set across both check sites.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
build() now warns for a submesh with exterior faces but no boundary
tags (BCs cannot apply, interface discovery cannot pair it), and
_internal_neumann_unstructured raises instead of returning zeros when
no interface pairs two meshes, which silently decoupled the domains.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment on lines +979 to +980
N_VIS = 200
N_VIS_3D = 80

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.

I think these should be defined in the visualization, not in the processed variables

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.

Agreed, and done. The visualisation grid (N_VIS/N_VIS_3D and the *_dim_pts it populated), the 3D mid-plane slices, the quiver sampling and the slice-plane positions are gone from this PR: the unstructured processed variables are now pure point interpolators (pv(t, x=, y=, z=)), and QuickPlot here raises a clear NotImplementedError for unstructured variables. The sampling lives on the plotting side in #5689 as pybamm.plotting.unstructured_plot_grid (plot_grid, default_slice_positions, midplane_slices, quiver_data), and QuickPlot there owns the grid and slice positions per plotted variable instead of mutating the variable.

Comment on lines +1104 to +1105
radius = 1e-9 * max(
np.ptp(self.mesh.vertices, axis=0).max(), np.finfo(float).tiny

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.

I think this functionality is fine but should live in a mesh processing file, not the processed variables

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.

Moved: point-in-domain now lives on the mesh as UnstructuredSubMesh.contains_points (2D even-odd rule over the cached boundary loops, 3D delegating to contains_points_3d), with a direct test. The processed variable just asks the mesh.

vector-valued data.
"""

N_QUIVER = 20

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.

same

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.

Same treatment for the vector-field processed variable: N_QUIVER/get_quiver_data and the copied grid attributes are removed here; it only wraps the per-component interpolators. Quiver sampling is quiver_data in #5689's plotting module.

e_ij = delta / dist[:, np.newaxis]

# Non-orthogonality correction: project normal onto centroid vector
cos_theta = np.abs(np.sum(normals * e_ij, axis=1))

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.

The docstring calls this "the non-orthogonality correction," but multiplying by |n·e| isn't one — it drops the tangential (cross-diffusion) part of ∇u·n entirely, and that term is O(1) on a fixed-angle non-orthogonal grid, not O(h). Plain TPFA on cell centroids is only consistent when the mesh is orthogonal (quad/hex here); on triangles/tets it doesn't converge at all — see the numbers in the review summary.

A real correction needs an over-relaxed decomposition with an explicit gradient-reconstructed cross term, or MPFA. Alternatively, restrict the method to orthogonal quad/hex meshes — but then the module docstring ("unstructured simplex meshes (2D triangles / 3D tets)"), the class docstring ("Supports triangles (2D) and tetrahedra (3D)"), and the generator's 2D default all need to change to match.

Same expression at line 542 in _div_D_grad_matrices.

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.

You were right, and it was worse than the review summary suggested: on the structured tri/tet meshes there is no error cancellation at all (Poisson L2 rate ≈ 0 under refinement, Laplacian of a linear field = 2.9 on the default 2D mesh).

Implemented the full decomposition n = α ê + k, fully implicit since everything is linear: the compact part carries α (u_j − u_i)/d and the cross term k·∇u_f is a matrix built from a cell-gradient reconstruction. α is an option ("non-orthogonal correction": "over-relaxed" = 1/cosθ default, "minimum" = cosθ); both are exact on linear fields, the choice only sets the implicit/explicit split. The gradient had to become a weighted least-squares fit rather than Green-Gauss, because Green-Gauss with distance weights is itself O(1) wrong on tets (0.74 error on a linear field on the Kuhn mesh — face centroids are not on the centroid lines). The same correction is applied across concatenated-domain interfaces (cross-mesh LS rows), and faces orthogonal to within 1e-8 are skipped so hex/quad meshes are unchanged.

Result: linear patch test 1e-13 on tri, perturbed tri, Kuhn tet and perturbed tet; Poisson rates 1.95–2.04 (tri) and 1.74–1.88 (tet) for both α; quad/hex still 2.0. Docstrings now describe what is actually computed.

submesh.face_centroids[face_indices]
- submesh.cell_centroids[owners]
)
d_perp = np.linalg.norm(delta, axis=1)

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.

d_perp is the Euclidean distance |face_centroid − cell_centroid|, not the perpendicular one — the name asserts the opposite of what the code does. The correct boundary transmissibility is A / (Δ·n), i.e. this divided by cos θ. Same at line 651 in div_D_grad. Related to the interior issue on _tpfa_matrix, but independently fixable.

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.

Fixed. The boundary term now uses the perpendicular distance Δ·n̂ (i.e. A/(Δ·n̂) for the over-relaxed α, consistent with the interior decomposition) plus its own k·∇u cross term from the owner cell's least-squares gradient. The Neumann-side gradient rows use the normal direction directly.


return result

def _divergence_matrices(self, submesh):

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.

This is identical to _green_gauss_matrices (line 798) apart from local variable names — I confirmed the assembled matrices match exactly. ~55 duplicated lines, and they're cached under two separate keys so both get built. Could be _divergence_matrices = _green_gauss_matrices (or one delegating to the other) with a comment on why divergence and Green-Gauss gradient share an assembly.

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.

Aliased: _divergence_matrices now returns _green_gauss_matrices(submesh) with a note on why the two assemblies coincide. (Green-Gauss stays as the divergence assembly; the gradient operator itself moved to least squares, see the thread on the correction.)

mat = csr_matrix(kron(eye(repeats, dtype=np.float64), int_mat))
return pybamm.Matrix(mat) @ discretised_child

def definite_integral_matrix(self, child, vector_type="row", **kwargs):

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.

This returns a raw scipy.sparse.csr_matrix rather than a pybamm.Matrix. Discretisation._process_symbol hands it straight back for DefiniteIntegralVector, and process_symbol then calls .test_shape() on it:

AttributeError: 'csr_matrix' object has no attribute 'test_shape'

Reproduces for both vector_type="row" and "column" — so DefiniteIntegralVector is unusable on any unstructured mesh. Untested.

Separately, vector_type is accepted and silently ignored: Discretisation passes vector_type=symbol.vector_type, so a "column" request quietly gets a (1, n) row. FiniteVolume raises NotImplementedError for the unsupported combination.

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.

Fixed: definite_integral_matrix returns a pybamm.Matrix (lifted by the auxiliary-domain repeats), vector_type is honoured for "row"/"column" and rejected otherwise, and there is a Discretisation-level test that DefiniteIntegralVector processes in both orientations.

# Integral operators
# ------------------------------------------------------------------

def integral(

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.

integration_dimension is accepted and dropped — Discretisation passes symbol._integration_dimension here, so an Integral over a secondary dimension silently integrates the primary volume instead. Worth raising rather than being silently wrong, as the rest of this file does.

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.

Fixed: integral now passes integration_dimension through and definite_integral_matrix raises NotImplementedError for anything but "primary" instead of silently integrating the cell volume; tested.

if result.ndim == 2:
mask = mask.all(axis=1)
if np.any(mask):
nearest = NearestNDInterpolator(pts, vals)

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.

The nearest fill is mostly wasted work: out-of-hull points are filled here and then overwritten with fill_value two lines down. On the default 200×200 grid over a convex domain I measured 25,220 of 40,000 query points nearest-filled, 24,380 of them (97%) immediately NaN'd. Computing outside first and filling only mask & ~outside avoids building the NearestNDInterpolator in the common case.

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.

Fixed: the outside mask is computed first and excluded from the nearest-neighbour fill, so NearestNDInterpolator is only built when some in-domain point actually fell outside the hull.

def get_quiver_data(self, t):
"""Interpolate vector components onto a coarser grid for quiver arrows.

Returns ``(X, Z, U, W)`` for 2D or ``(X, Y, Z, U, V, W)`` for 3D.

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.

The 3D branch actually returns 10 values — (X1, Z1, u_xz, w_xz, y_mid, X2, Y2, u_xy, v_xy, z_mid) — not (X, Y, Z, U, V, W).

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.

Docstring corrected — and the function has since moved out of the processed variable entirely (see the thread at the top of the class); the 3D return is documented as the two mid-plane slices there.

aabills and others added 7 commits September 2, 2026 15:07
…tors

The two-point flux only carried alpha (u_j - u_i)/d with alpha = cos(theta)
and dropped the k . grad(u) remainder of n = alpha e + k, so the Laplacian
of a linear field was nonzero on any non-orthogonal mesh and the scheme did
not converge on triangles or tetrahedra (L2 rate ~0 under refinement).

- Split the face normal as n = alpha e_ij + k and add the k . grad(u)_f
  cross flux implicitly, for internal faces (laplacian, div_D_grad) and
  Dirichlet boundary faces (perpendicular distance delta . n).
- "non-orthogonal correction" option: "over-relaxed" (default,
  alpha = 1/cos theta, floored) or "minimum" (alpha = cos theta).
- Replace the Green-Gauss gradient with a batched weighted least-squares
  reconstruction that is exact on linear fields on skewed meshes; internal,
  Dirichlet and Neumann faces each contribute one directional-derivative
  row. Green-Gauss remains the divergence assembly.
- Raise GeometryError for inverted cells, warn at build above 70 degrees
  of non-orthogonality, skip the correction entirely on orthogonal meshes.
- _divergence_matrices now aliases _green_gauss_matrices.

Linear patch test is at 1e-13 on tri, perturbed tri and Kuhn tet meshes;
Poisson convergence rates are 1.95-2.04 (tri) and 1.74-1.88 (tet) for both
options, unchanged (2.0) on quad/hex.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
definite_integral_matrix returned a raw csr_matrix, so DefiniteIntegralVector
failed with AttributeError in process_symbol's shape check; it also ignored
vector_type (a "column" request came back as a row) and integral() dropped
integration_dimension, silently integrating the primary volume for a
secondary-dimension Integral. Support row/column, raise NotImplementedError
for non-primary dimensions, and lift by the auxiliary-domain repeats.

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

UnstructuredSubMesh.contains_points now owns the 2D even-odd loop test
(loops cached on the mesh) and delegates to contains_points_3d, so the
processed variable only asks the mesh. In _interpolate_spatial the outside
mask is computed first and excluded from the nearest-neighbour fill, which
previously filled ~97% of out-of-hull points only to overwrite them with
fill_value. Also correct the get_quiver_data docstring for the 3D return.

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

The interface Neumann value between concatenated domains was the plain
two-point difference (u_r - u_l)/d, the same alpha = 1 truncation that made
the Laplacian inconsistent inside a domain, so a linear field was not a
steady state of a two-domain tet model (residual 4.6 on the Kuhn mesh).

- internal_neumann_condition now returns alpha (u_r - u_l)/d + k . grad(u)_f
  with the same n = alpha e + k split as the interior; the face gradient is
  the distance-weighted mean of both sides' least-squares gradients, which
  take the interface faces as cross-mesh rows towards the paired cell and
  each side's external boundary conditions (passed by
  set_internal_bcs_for_concat). Orthogonal interfaces are unchanged.
- interface_data records left_faces/right_faces on both pairing paths.
- Least-squares rows for boundary faces without a condition now fit a zero
  normal derivative, matching the operators' zero-flux treatment, so cells
  on untagged boundaries stay fully determined.
- UnstructuredSubMesh.contains_points gets a direct test.

Two-domain linear field on tets: residual 4.6 -> 5e-14; interface flux is
conservative to 1e-10 for a random field.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The old expectation hard-coded the two-point (u_r - u_l)/d formula, which
is only the full interface gradient on orthogonal pairs; check u = x gives
exactly 1 on the skewed triangle interface and keep the two-point check
for a quad interface where it still holds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Codacy's Bandit B105 flags `token == "y"` as a hardcoded password purely
because of the variable name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
aabills and others added 6 commits September 3, 2026 07:16
main already documents both in unary_operator.rst; the second copy made
Sphinx fail with "duplicate object description" under -W.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Time- or input-dependent scalars (e.g. a current-density Neumann value)
evaluate for shape to () rather than (1, 1), so _bc_contribution built
Matrix @ scalar and failed once div_D_grad started assembling the
least-squares gradient with boundary rows. Centralise the check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The face coefficient was a linear interpolation of the two cell values.
For a flux the physically consistent face value is the resistances-in-
series (distance-weighted harmonic) mean, which FiniteVolume already uses
for coefficients of a gradient: it reproduces the exact two-cell flux
across a material interface and gives zero flux into an impermeable cell,
whereas the arithmetic mean lets half the conductive side leak through.
On combined concatenation meshes this is the current-collector/electrode
face, where sigma jumps by ~1e5. Linear weights are kept for the face
gradient of the cross term; D must be strictly positive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Centroid rounding on high-aspect-ratio cells (10 um thick, cm wide, as in
a pouch cell) puts ~1e-11 into the face direction, so with a 1e-12 cut-off
the cross term and boundary cross terms were assembled on every hex mesh
with coefficients of 1e-11. That doubled the Jacobian stencil (57k -> 101k
nonzeros on the pouch demo) and the IDAKLU time for no change in the
answer. k is now zeroed below a dimensionless 1e-8 (an angle far below any
real skew) and explicit zeros are dropped, so orthogonal faces never widen
the stencil, also on mixed meshes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The visualisation grid (N_VIS/N_VIS_3D and the first/second/third_dim_pts
it populated), mid-plane 3D slices, quiver sampling and slice positions
only exist to feed QuickPlot, whose unstructured support lives in the
plotting PR. Processed variables here now only interpolate at requested
points; QuickPlot refuses unstructured variables with a clear message until
that support lands, instead of failing deep inside its 2D branch.

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

@MarcBerliner MarcBerliner 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!

@aabills
aabills enabled auto-merge (squash) September 5, 2026 00:05
@aabills
aabills merged commit 258fdc8 into main Sep 5, 2026
29 checks passed
@aabills
aabills deleted the ufv-2-spatial branch September 5, 2026 00:18
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.

2 participants