Skip to content

Add unstructured mesh infrastructure - #5687

Merged
aabills merged 26 commits into
mainfrom
ufv-1-meshing
Aug 4, 2026
Merged

Add unstructured mesh infrastructure#5687
aabills merged 26 commits into
mainfrom
ufv-1-meshing

Conversation

@aabills

@aabills aabills commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Add UnstructuredSubMesh, generators (UnstructuredMeshGenerator, UserSuppliedUnstructuredMesh, TaggedSubMeshGenerator), and Mesh interface coupling.
  • Independent of the VectorField PR; both land before the spatial-method PR.

Stack

  1. Generalise VectorField to N components #5686 — VectorField N-comp
  2. This PR (Add unstructured mesh infrastructure #5687) — Meshing
  3. 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

Full pre-split branch preserved on #5397 / backup/unstructured-finite-volume-full.

Test plan

  • tests/unit/test_meshes/test_unstructured_submesh.py (41 tests)
  • CI unit suite

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

Introduce UnstructuredSubMesh, mesh generators, and Mesh interface
coupling so arbitrary 2D/3D domains can be discretised later. Extracted
from the unstructured finite-volume stack for isolated review.

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

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.89349% with 21 lines in your changes missing coverage. Please review.
✅ Project coverage is 98.09%. Comparing base (d63699d) to head (c7e557e).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
...s/pybamm/src/pybamm/meshes/unstructured_submesh.py 97.06% 19 Missing ⚠️
packages/pybamm/src/pybamm/meshes/meshes.py 92.59% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #5687      +/-   ##
==========================================
- Coverage   98.21%   98.09%   -0.12%     
==========================================
  Files         339      340       +1     
  Lines       31996    32671     +675     
==========================================
+ Hits        31424    32049     +625     
- Misses        572      622      +50     

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

aabills and others added 3 commits July 31, 2026 15:08
`_combine_unstructured_submeshes` put 110 lines of unstructured-specific
node welding in the generic `meshes.py`, which then needed two
function-level `from .unstructured_submesh import ...` calls to dodge the
module-level cycle (`unstructured_submesh` already imports `SubMesh` and
`MeshGenerator` from `meshes`).

Move it to `UnstructuredSubMesh.combine`, where `_hex_to_tet` and the
class itself are local, and reach `compute_interface_data` through the
`pybamm` namespace like the rest of `meshes.py` does. `meshes.py` no
longer references `unstructured_submesh` at all.

Pure move: the welding and boundary-tag-recovery bodies are unchanged
apart from `pybamm.UnstructuredSubMesh(...)` becoming `cls(...)`.

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

`interface_data` stores "left_cells" as this mesh's cells and "right_cells"
as the neighbour's, but `optimize_ordering` permuted both with this mesh's
`inv_perm`. That silently re-pointed the neighbour's indices, and where the
neighbour has fewer cells it produced out-of-range indices (an 18-cell mesh
next to a 12-cell one yielded `right_cells.max() == 17`).

`compute_interface_data` also gives the neighbour a mirrored view of the
same pairing, which aliases the very array being replaced, so the mirror
went stale. Permute only this mesh's indices and write the result through
to the neighbour's mirror.

The existing test asserted only on "left_cells" — the one key that was
already correct — so add a case that pins the neighbour's indices and the
mirror, using meshes of different sizes so a mis-permutation is caught by
bounds rather than by luck.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`UnstructuredSubMesh.combine` welds coincident interface nodes so the seam
becomes internal faces and TPFA carries flux across it. When the interface
nodes don't coincide — mismatched transverse grids, wrong coordinate units,
or a non-conforming input mesh — nothing welds, the seam stays boundary
faces on both sides, and the domains form separate connected components. The
solve then runs to completion with the regions silently decoupled and
returns a physically wrong answer.

Add a post-weld check that the combined mesh is a single connected
component, using the integer face_owner/face_neighbor connectivity so it
introduces no distance tolerance. Under the conforming-interface
requirement welding is all-or-nothing, so total disconnection is the only
failure mode and this check is sufficient. Document the conforming
requirement on the two gmsh-reading generators.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
aabills and others added 2 commits July 31, 2026 16:09
Four review findings on #5687:

- Reject non-manifold input: a face shared by more than two cells matched
  neither the internal (count 2) nor boundary (count 1) branch of
  _build_face_connectivity and silently vanished from the face list,
  losing flux paths. Now raises GeometryError.

- Make the 3D generator honour element_type: _generate_3d always built
  hexes regardless of the argument (docstring claimed a tetrahedron
  default). Hexahedron stays the default, matching existing tests and the
  3D DFN model; "tetrahedron" now works and records _hex_gen_params so
  combine() can rebuild tet domains with a cumulative parity offset,
  keeping interface triangulations conforming — previously that combine
  branch was unreachable. Unknown types raise.

- Remove dead code: _cell_faces (unused since the vectorised
  connectivity path) and an n_fpc assignment overwritten two lines later.

- Conventions: framework errors now raise pybamm.GeometryError instead
  of ValueError/KeyError/RuntimeError; meshio is imported via
  pybamm.import_optional_dependency; the CHANGELOG bullet carries the PR
  link.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Codecov flagged the meshio-backed generators as entirely untested. Add
tests for UserSuppliedUnstructuredMesh (whole-file load with 2D trimming,
subdomain filtering, missing-cell-data and unsupported-cell-type errors,
lims-to-domain mapping) and TaggedSubMeshGenerator (region extraction with
coordinate scaling, missing region, region with no tets), using synthetic
meshio objects injected into the class cache so no .msh round-trip is
needed. Also cover combine()'s custom boundary-tag recovery,
compute_interface_data's no-matching-faces and transverse-mismatch raises,
and Mesh leaving interface_data empty when grids don't pair.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread packages/pybamm/src/pybamm/meshes/unstructured_submesh.py Outdated
Comment thread packages/pybamm/src/pybamm/meshes/unstructured_submesh.py
Comment thread packages/pybamm/src/pybamm/meshes/unstructured_submesh.py Outdated
aabills and others added 15 commits August 3, 2026 12:47
Volumes from the 5-tet split are ill-defined on warped
(non-planar-faced) hexes: the two valid diagonal splits differ by
~20% on a displaced-corner cube. Enforce planar faces at
construction and restrict UserSuppliedUnstructuredMesh to
tetra/triangle/quad cells, since gmsh/VTK hex meshes of non-box
geometry are warped in general.

Pattern-A tets were also inconsistently oriented (signed volumes
sum to zero on a unit cube), masked by the per-tet abs. Reorder to
a consistent orientation and sum signed so inverted or degenerate
cells become detectable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
numpy 2.0.0 (the declared floor) returns return_inverse with shape
(n, 1) instead of (n,) — a regression reverted in 2.0.1. The 2-D
inverse makes every face-connectivity mask 2-D and construction
raises IndexError on any mesh. reshape(-1) at both np.unique sites
is a no-op on fixed numpy versions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 5-tet hex split must alternate mirror patterns cell-to-cell to
keep shared-face diagonals conforming, so multi-domain meshes needed
a cumulative parity offset. combine() applied it by regenerating
every domain after the first but never wrote the result back to the
Mesh: with an odd preceding nx, mesh[domain] and the combined view
described geometrically different cells (silent wrong volumes and
connectivity for the same state entries).

The Kuhn (Freudenthal) 6-tet split is translation-invariant — every
hex splits identically and opposite faces carry the same face-local
diagonal — so seams conform with no parity bookkeeping. Delete the
regeneration block and _hex_gen_params breadcrumb entirely.

Strengthen test_combine_tetrahedron_domains_conforming with an odd
left nx (the old even-nx choice made the offset branch a no-op) and
assert both views agree cell-for-cell.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Vertex-mean centroids are exact only for simplices. On trapezoid
quads the error is 12% in the off-axis coordinate; TPFA builds
transmissibility from centroid-to-centroid distances, so distorted
2D file meshes silently lost first-order accuracy. Use the polygon
(shoelace) centroid for quad cells, signed-volume-weighted tet
centroids for hex cells, and area-weighted triangle centroids for
quad faces — all exact for the planar-faced cells the mesh accepts,
verified against a symmetric trapezoid and a square frustum.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Auto-detection bucketed every exterior face of any mesh by dominant
normal axis, so a file mesh of curved geometry (e.g. a triangulated
disc) got six 'left'/'right'/... buckets that are not surfaces, and
downstream BC/interface code consumed them silently. Construction now
assigns no tags by default; the detection becomes an explicit
detect_box_boundaries() that the built-in generator calls on its own
output, where the assumption is true by construction. combine()
propagates all input tags by centroid matching instead of re-detecting
on the welded mesh. File meshes get no guessed names — boundary tags
must come from the mesh file or be set explicitly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
boundary_mapping was stored and never read, and with guessed box tags
gone file meshes had no way to name their boundaries. Tagged facet
blocks (gmsh physical surface groups, or any cell-data key) are now
matched to submesh boundary faces by sorted vertex indices — through
the quantisation weld and node compaction — and become named
boundary_faces entries. TaggedSubMeshGenerator gains the same
parameter, resolving group names via field_data. Unmatched entries
log a warning instead of vanishing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A swallowed GeometryError in Mesh._compute_unstructured_interfaces
meant a domain pair could silently lose all flux coupling — the exact
failure combine() raises for. Keep the no-raise property of
Mesh.__init__ but log which pair was skipped and why.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Consecutive-order pairing stapled whichever domains happened to be
adjacent in the geometry dict: declaring {separator, negative
electrode} out of spatial order paired sep.right (x=2) with neg.left
(x=0), and because interface matching uses transverse coordinates
only, the bogus coupling succeeded silently with cell distances
spanning the whole geometry. Pair domains by comparing the planes of
their right/left boundary faces instead, making declaration order
irrelevant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Combining a triangle domain with a quad domain leaked numpy's raw
concatenate ValueError. Check up front and raise a GeometryError
naming both element types.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The weld (1e-9 absolute), tag-match (1e-10 * max(ptp, 1)), and
interface-match (1e-8 * max(ptp, 1)) tolerances were unrelated to the
mesh's own length scale: the max(..., 1.0) floors made the relative
ones absolute for every sub-metre (i.e. every SI battery) mesh, a
3 nm interface jitter on a 100 um domain rejected the whole mesh as
disconnected, and tags could vanish because tag matching was tighter
than welding. Derive one tolerance from the smallest element edge
(1e-3 of it): distinct nodes are at least an edge apart, so no false
merges, while file-precision jitter is absorbed. The file-loader
quantisation stays separate (a snapping grid, now documented).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The nearest-neighbour interface match is one-directional: a surplus
boundary face on the right mesh silently lost its flux, and a doubly
claimed face would double-count it. Require equal face counts and
unique pairing, and document the assumption that domains stack along
x (faces are matched by transverse coordinates).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Everywhere else in PyBaMM, submesh 'nodes' means cell centres
(SubMesh1D.nodes) and mesh vertices are 'edges'. UnstructuredSubMesh
used 'nodes' for the (n, d) vertex coordinate array, colliding with
that vocabulary just as three PRs stack on top of this one. Rename
before anything else hard-codes it.

BREAKING CHANGE: UnstructuredSubMesh.nodes is now
UnstructuredSubMesh.vertices (constructor parameter renamed too).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The per-cell Python loop over 5 tets was ~95% of construction time
(~2 s for a 30^3 hex grid). One einsum over the (n_cells, 5, 4, 3)
tet array computes identical signed volumes and volume-weighted
centroids; 27k-cell construction now takes ~0.1 s end to end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
contains_points_3d looped over boundary triangles and vectorised over
query points, making its cost O(n_triangles) Python iterations even
for a single point (~0.3 s for 2 points against 5400 faces). Swap the
nesting: few query points iterate, the triangle arithmetic vectorises.
Identical boolean output.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The weld built a Python dict per submesh, appended nodes one at a
time, and remapped elements with a nested list comprehension — 4M
dict lookups for a 1M-cell tet mesh. Replace with a KD-tree query
plus cumsum-based index map; identical welded mesh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
aabills and others added 3 commits August 3, 2026 14:37
Replaces the per-face Python loop with array bucketing, and removes
its silent tie-break: a zero-area face normalises to a zero normal,
argmax returned axis 0, and the face landed in 'right'. Degenerate
boundary faces now raise instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pure-Python double/triple loops built element lists row by row
(~0.05 s at 27k hexes, ~2 s extrapolated at 1M). Vertex ids are
affine in (i, j, k), so meshgrid plus index arithmetic produces
byte-identical connectivity, verified against the loop versions
across grid sizes including the bandwidth-sorted hex ordering.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review request (MarcBerliner). The str mixin keeps plain strings
working everywhere an ElementType is expected, so element_type="quad"
and downstream string comparisons are unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MarcBerliner
MarcBerliner previously approved these changes Aug 4, 2026

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

Looks good to go, one non-blocking thing

Comment on lines +958 to +968
if any(block.type == "hexahedron" for block in mesh.cells):
raise pybamm.GeometryError(
"Hexahedral cells in mesh files are not supported: warped "
"(non-planar-faced) hexahedra have ill-defined volumes and "
"face fluxes. Convert the mesh to tetrahedra (e.g. with "
"gmsh or meshio) and reload. Hexahedral meshes are still "
"available through pybamm.UnstructuredMeshGenerator, whose "
"axis-aligned cells are always well-defined."
)
# Prefer 3D cells when present, otherwise fall back to 2D.
for cell_type in ("tetra", "triangle", "quad"):

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.

can compare with the enum

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.

Done in 0c92328 — the loop compares enum members now. One subtlety: meshio's vocabulary says tetra where ours says tetrahedron, so ElementType gained a meshio_name property holding that one divergence rather than scattering both spellings.

Review request (MarcBerliner). meshio's vocabulary says 'tetra' where
ElementType says 'tetrahedron', so the enum gains a meshio_name
property holding the one divergence; all loader comparisons now go
through enum members instead of magic strings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@aabills
aabills disabled auto-merge August 4, 2026 18:00
@aabills
aabills enabled auto-merge (squash) August 4, 2026 18:05
@aabills
aabills merged commit cd79ce4 into main Aug 4, 2026
30 checks passed
@aabills
aabills deleted the ufv-1-meshing branch August 4, 2026 18:19
aabills added a commit that referenced this pull request Aug 6, 2026
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 added a commit that referenced this pull request Sep 5, 2026
* feat: add unstructured finite volume spatial method

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>

* fix: tile per-face BC vectors across auxiliary-domain repeats in div_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>

* fix: raise on y= queries of 2D unstructured processed variables

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>

* perf: interpolate all time steps of unstructured variables in one pass

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>

* fix: raise DomainError for unresolvable unstructured spatial variables

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>

* refactor: define set_internal_bcs_for_concat on the base SpatialMethod

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>

* fix: match legacy tab BC sides exactly instead of by substring

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>

* style: pre-commit fixes

* fix: fail loudly when unstructured meshes lack tags or interface data

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>

* Add implicit non-orthogonal correction to the unstructured TPFA operators

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>

* Test the build-time non-orthogonality warning; make zip strict

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

* Return a pybamm.Matrix from the unstructured definite integral

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>

* Move point-in-domain onto UnstructuredSubMesh; skip nearest fill outside 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>

* Apply the non-orthogonal correction across unstructured domain interfaces

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>

* Test the corrected interface gradient on a linear field

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>

* Rename the axis token variable that Bandit mistakes for a password

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>

* docs: drop duplicate Component/Norm entries after merging main

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>

* Cover the inconsistent-face-count error and the no-loops containment path

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

* Treat every scalar-shaped boundary value as a broadcast scalar

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>

* Use the harmonic mean of D at unstructured faces in div(D * grad(u))

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>

* Treat faces orthogonal to within 1e-8 as orthogonal, not 1e-12

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>

* Keep plotting out of the unstructured processed variables

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>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
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