Add unstructured finite volume spatial method - #5688
Conversation
Codecov Report❌ Patch coverage is 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. 🚀 New features to boost your workflow:
|
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>
…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>
| N_VIS = 200 | ||
| N_VIS_3D = 80 |
There was a problem hiding this comment.
I think these should be defined in the visualization, not in the processed variables
There was a problem hiding this comment.
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.
| radius = 1e-9 * max( | ||
| np.ptp(self.mesh.vertices, axis=0).max(), np.finfo(float).tiny |
There was a problem hiding this comment.
I think this functionality is fine but should live in a mesh processing file, not the processed variables
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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)) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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): |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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): |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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.
…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>
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>
Summary
FiniteVolumeUnstructuredspatial method, discretisation hooks (div_D_grad, internal BC dispatch), and unstructuredProcessedVariable*classes.Stack
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)Also in this stack: #5691 deprecates
pybamm.Magnitude.