Repository navigation
Cell size: one definition, one name — three are in play, the docstrings describe a fourth, and get_*_radius() is partition-dependent #694
Description
The defect
get_min_radius(), get_max_radius() and get_mean_radius() present as the
canonical global "mesh length" API. Their docstrings say so explicitly:
get_min_radius: "the smallest cell anywhere in the mesh, not just on this
rank. Parallel-safe via MPI allreduce."get_max_radius: "the largest cell anywhere in the mesh."get_mean_radius: "Use this anywhere you need a representative h0 ...
rather than reaching for the rank-localself._radiiarray, which gives
different answers on different MPI ranks and leaks downstream."
All three reduce over self._radii — the array that last docstring warns
against. The allreduce makes the answer identical on every rank. It does not
make it identical for every rank count, because the values being reduced are
themselves partition-dependent: _radii comes from a kd-tree over this rank's
centroids, so a cell near a partition boundary gets a different size depending
on how the mesh was split.
"Same on every rank" and "same for every rank count" are different properties.
These functions deliver the first and their names and docstrings promise the
second.
Measured
UnstructuredSimplexBox, cellSize=0.12, 198 cells, on development:
| accessor | np=1 | np=2 | np=4 | np=8 |
|---|---|---|---|---|
get_min_radius() |
0.0502908499358 | same | same | same |
get_max_radius() |
0.0670934714626 | same | same | 0.070373950134 |
get_mean_radius() |
0.0592989430362 | 0.0594222426856 | 0.0594775301273 | 0.0597593860929 |
get_max_radius() moves by 4.9% at np=8. get_mean_radius() moves at every
rank count. get_min_radius() is stable here — but incidentally, because the
minimising cell does not happen to sit where the kd-tree lookup differs, not
because anything guarantees it.
Ruling (maintainer, 2026-09-05)
These ought to be rank-independent — that is part of the parallel contract they
advertise. If a quantity is local-only, its name has to say so.
The fix is already half-built
#692 adds _radii_own: each cell's characteristic length from its own
vertices and centroid, which cannot depend on the partition. Measured on that
branch, per-cell values are bit-identical at np=1/2/4 (max abs diff 0.000e+00)
where the legacy field differs by 3.3e-03 and 4.1e-03.
Building the three accessors on _radii_own makes all of them genuinely
rank-count-independent and makes the docstrings true.
Two things to get right when doing it:
minandmaxbecome exactly reproducible;meanneeds care. A
distributed sum reduces in partition order, so even over identical per-cell
values the mean can differ in the last bits between rank counts. If
bit-identity is wanted rather than agreement to ~1e-16, the reduction has to
be made order-independent.- Timesteps are currently insulated, and that should not be relied on.
Everyestimate_dtpath reduces to a global minimum, andestimate_dt()
measures bit-identical at np=1/2/4/8. It is stable for the same incidental
reasonget_min_radius()is.
Also
follow_metric's docstring in src/underworld3/meshing/smoothing/api.py
recommends gradient_smoothing_length=2.0 * mesh._radii.mean() — a rank-local
mean, a different value on every rank. Docstring only, but it teaches exactly the
pattern get_mean_radius's docstring warns against.
Related: #569 and #687 (the same defect in mesh.cell_size(), fixed by #692).
Activity
lmoresi commentedon Sep 5, 2026
Correction to point 1 above — I overstated it.
get_mean_radius()uses a numpy local sum with anMPI.SUMallreduce of both the sum and the count:That is a true global mean over all cells, not a mean-of-means, and the partition-order last-bit difference is not worth engineering around (maintainer, 2026-09-05). A PETSc vector reduction would reduce in partition order too, so it buys nothing.
So this issue is simpler than I wrote it. All three accessors already reduce correctly; they just reduce over the wrong array. Swapping
self._radiifor #692's_radii_ownfixes all three, and no change to the reduction machinery is needed. The 4.9% jump inget_max_radius()at np=8 is entirely the input.Point 2 stands:
estimate_dt()is currently safe only because every path takes a global min, and that stability is incidental.lmoresi commentedon Sep 6, 2026
Naming, raised on #692 and recorded here because this issue inherits it.
own/ownedmeans rank ownership everywhere else in this codebase ("locally-owned points", "owned by a different rank", "non-owned on this rank"), so #692's_radii_ownparses as "the cells this rank owns" — the opposite of what it is, which is the one field that is partition-independent by construction.Neither field is about rank ownership; both are per-cell local arrays. The distinction is provenance:
_radii_radii_ownSuggested:
_cell_radiiand_neighbour_radii.This matters here because the fix for this issue is pointing
get_min/max/mean_radius()at that field, which puts the name in three more call sites — in the very functions whose contract is about partition independence.lmoresi commentedon Sep 6, 2026
The PETSc FV route works, and it is the definition the docstrings already claim
Maintainer's question: is one of these the PETSc FV element-radius routine? It
was tried, errors were found, and it was abandoned — but it is the obvious place
to get this.
Neither field is that routine today.
petsc_fvm_get_local_cell_sizes(
cython/petsc_discretisation.pyx) wrapsDMPlexComputeGeometryFVMand returnsvolume**(1/dim). It is commented out at its call site indiscretisation_mesh.pyand marked "Deprecated in favour of_get_mesh_sizes",still carrying the note:
That no longer reproduces. Same mesh as the measurements above
(
UnstructuredSimplexBox,cellSize=0.12, 198 cells):One value per local cell, all finite and positive, and per-cell exactly
equal across rank counts:
A cell's volume is intrinsic, so
volume**(1/dim)cannot depend on the split.It is partition-independent by construction rather than by careful
reimplementation.
This also fixes a documentation bug
The docstrings already describe the PETSc definition, not what the code
computes:
cell_size(): "thevolume**(1/dim)equivalent radius, i.e.self._radii"get_mean_radius(): "volume^(1/dim), i.e. the equivalent radius derivedfrom each cell's volume"
_radiiis not that._get_mesh_sizesbuilds a kd-tree over this rank'scentroids, queries it with each cell's vertices, and takes
cell_r = sqrt(distsq.mean())— an RMS vertex-to-nearest-centroid distance.Near a partition boundary the true nearest centroid can belong to another rank
and be missing from the tree, which is the drift measured above. So the
docstrings describe the abandoned routine and the code does something else.
What this implies for #692
#692 defines
_radii_ownas the RMS distance of a cell's vertices from itsown centroid — a third definition, reimplementing by hand a partition-
independent cell size that PETSc already computes correctly.
Worth deciding which is wanted before the naming is settled, because the two are
not the same number: FVM mean here is 0.0708408 against
_radii_own's 0.0627204.Both are defensible characteristic lengths; only one matches what the docstrings
have been promising, and only one avoids a hand-rolled geometric routine that
has to be maintained for triangles, tets, quads and hexes.
If
DMPlexComputeGeometryFVMis used, the naming question largely dissolves —there is one per-cell radius, it is
volume**(1/dim), and it is partitionindependent.
_radiias it stands is then a neighbour-spacing measure, whichis a different quantity that deserves its own name wherever it is still wanted.
Caveat, stated plainly: this is one mesh type, 2-D simplex, at np ≤ 4. Before
acting on it the same check is wanted on quads/hexes, in 3-D, and on a
deformed mesh — the original complaint may have come from a case none of this
touches.
lmoresi commentedon Sep 6, 2026
Consolidated proposal — one cell size, one name
Superseding the scattered comments above. Everything here is measured on
developmentunless marked otherwise; the caveats are at the end.There are three definitions of "cell radius" in play
_radii(live)sqrt(distsq.mean())_radii_own(#692)petsc_fvm_get_local_cell_sizes(abandoned)DMPlexComputeGeometryFVM→volume**(1/dim)Three different numbers for the same words. Definition 1 is what the code
computes; definition 3 is what the docstrings say it computes:
cell_size(): "thevolume**(1/dim)equivalent radius, i.e.self._radii"get_mean_radius(): "volume^(1/dim), i.e. the equivalent radius derivedfrom each cell's volume"
Neither is true of
_radii. That is the first loose end: the documentation hasbeen describing the abandoned routine for as long as it has been abandoned.
The abandoned routine works
The note on it reads "does not compute all cells and does not obtain the minimum
radius for the mesh". Measured (2-D simplex, 198 cells):
Per-cell, across rank counts:
max|np1-np2| = max|np1-np4| = 0.0. Volume isintrinsic, so this is partition-independent by construction rather than by
careful reimplementation.
Two of the four arrays are dead
_get_mesh_sizesreturns(_min_size, _radii, _centroids, _search_lengths)._min_sizeand_search_lengthsare assigned and never read anywhere insrc/ortests/— a per-cell kd-tree query computes three distancestatistics and two are discarded.
_centroidsdoes not need the loop either:_get_mesh_sizesgets them from_get_coords_for_basis(0, False)beforebuilding the tree.
Proposal
volume**(1/dim)viaDMPlexComputeGeometryFVM, named_cell_radii.cell_size()andget_min/max/mean_radius()all read it. All fourbecome partition-independent, and the docstrings become true without being
rewritten. The reduction machinery in the three accessors is already correct
and does not change.
_min_sizeand_search_lengths.Keep
_centroidsfrom the basis coords.quantity and gets its own name —
_neighbour_spacing— and its ownjustification at the call site. Nothing currently asks for it:
cell_size()feeds Nitsche penalties and SUPG stabilisation, which want a cell length h,
and
volume**(1/dim)is the standard choice for that.This makes
ownunnecessary as a qualifier, which resolves the naming problemrather than renaming around it —
own/ownedgoes back to meaning rankownership everywhere, as it does in the rest of the codebase.
What this costs, stated plainly
It changes answers.
cell_size()moves from 0.0592989 to 0.0708408 in themean — about 20% — and it feeds
add_nitsche_bc(local_h=True), which is thedefault, and SUPG stabilisation. That is a physics-affecting change and
needs the Nitsche and SUPG benchmarks run before and after, not just unit tests.
#692's definition 2 has the same problem at about 6%; there is no
partition-independent choice that leaves the numbers alone, because the current
numbers are the partition-dependent ones.
The measurements are narrow. 2-D simplex,
UnstructuredSimplexBox, np ≤ 4,undeformed. Before acting: quads and hexes, 3-D, and a deformed mesh. The
original complaint about
DMPlexComputeGeometryFVMmay have come from a casenone of this touches —
DMPlexGetMinRadiusis a different call and the noteconflates the two.
If the FVM route is not taken
#692 stands on its own and should merge: it fixes
cell_size(), which is theuser-visible half, and its scope is right. The requests on it are then unchanged
— a guard that runs at more than one rank count, and a name that is not
_radii_own. This proposal is the larger tidy, not a blocker for that.Loose ends this closes
owncolliding with rank ownership.follow_metric's docstring recommending2.0 * mesh._radii.mean(), arank-local mean, in the same breath as
get_mean_radius()warns againstexactly that.
[-]get_min/max/mean_radius() advertise a global mesh length but reduce over partition-dependent values — max moves 4.9% at np=8[/-][+]Cell size: one definition, one name — three are in play, the docstrings describe a fourth, and get_*_radius() is partition-dependent[/+]lmoresi commentedon Sep 6, 2026
Ruling (maintainer, 2026-09-05)
Recorded, and it changes the shape of the proposal above: the ~20% move in
cell_size()is not a cost to be weighed. The current numbers are thepartition-dependent ones — anything validated against them was validated against
a quantity that depends on how many ranks the run was given.
I also framed the cost wrong, and it is smaller than I implied. I said this
"needs the Nitsche and SUPG benchmarks run before and after". Checking what the
guards actually assert:
test_1065_nitsche_local_hvrms_refis computed in-test from an exact essential free-slip solve; Nitsche must match it within 2%. The rest are ratios and inequalities (gfmax/gfmin > 3.0,h_top_min > 2.0*g, leak< 1e-3).test_1060_nitsche_freeslip< 0.01— the same oracle.test_1064_constrained_spherical_shell_responseNone of these stores a cell_size-derived number. They compare against an exact
solution computed alongside, or against the literature. A better
cell_size()should leave them passing, and if it does not, that is a finding rather than a
re-baselining chore.
There is already a workaround in the tree that this fix should delete.
tests/parallel/test_1069_boundary_normal_parallel.pysetslocal_h=Falsespecifically to avoid this defect, and records the numbers in its docstring:
So a partition-independence test is currently forced to disable the default
code path in order to measure anything. That workaround is the debt marker:
removing it, and letting the test run with
local_h=True, is part of the fixand is the best single check that the fix worked.
Revised plan, replacing the "what this costs" section above:
_cell_radiifromDMPlexComputeGeometryFVM;cell_size()and allthree
get_*_radius()read it; delete the kd-tree loop,_min_sizeand_search_lengths.local_h=Falseworkaround intest_1069and let it exercise thedefault.
unchanged; a failure means the new definition is wrong, not that a baseline
moved.
measured so far is 2-D simplex at np ≤ 4.
The scope caveat stands: I have audited the three Nitsche tests above and
test_1069. I have not audited SUPG's guards, or anything consumingget_max_radius()/get_mean_radius()in adaptivity and relaxation.lmoresi commentedon Sep 6, 2026
Decision tree and work estimate
Gate: does the PETSc route work? — ANSWERED, yes
The one thing that decided this.
DMPlexComputeGeometryFVM, every cell familywe ship, 2-D and 3-D, undeformed and deformed:
One finite value per local cell everywhere, bit-identical across rank counts,
and it tracks deformation (mean 0.122822957 → 0.122822102). The "does not
compute all cells" note does not reproduce in any of them.
The tree
The NO branch is strictly more work and leaves more debt. The only argument for
it is if the FVM definition is wrong for the purpose — but
volume**(1/dim)is the standard element length for Nitsche and SUPG scaling, and it is what the
docstrings have claimed all along.
What needs updating, and how much
No call site changes. The accessors keep their names and signatures; only
the array behind them changes. That is what makes this small.
_cell_radiifrompetsc_fvm_get_local_cell_sizes, replacing the_get_mesh_sizeskd-tree loopdiscretisation_mesh.pycell_size()fill reads_cell_radiiget_min/max/mean_radius()read_cell_radii_min_size,_search_lengths(assigned, never read)_centroidsfrom_get_coords_for_basis(0, False)local_h=Falseworkaround intest_1069tests/parallel/follow_metricdocstring (2.0 * mesh._radii.mean())Call sites that do not change:
cell_size()11 in src / 6 in tests;get_min_radius()19 / 16;get_max_radius()2 / 4;get_mean_radius()1 / 3.Estimate: half a day for 1-8, most of it writing the test rather than the
change. The change itself is a net deletion.
The risk is the answers, not the diff
cell_size()moves ~20% in the mean and feedsadd_nitsche_bc(local_h=True),the default, plus SUPG. Per the maintainer's ruling, that is not a reason to
keep the broken definition — the current numbers are the partition-dependent
ones. But it is where the time goes if something is wrong.
Validation, in order of what would actually catch a mistake:
test_1069with the workaround removed — the single best check. It is apartition-independence test currently forced to disable the default path.
test_1065andtest_1060compare against an exact essential free-slip solve computed in-test;
test_1064against published Zhong values within 5%). They should passunchanged; a failure is a finding, not a re-baseline.
get_max_radius()andget_mean_radius(), the two that are demonstrably partition-dependent today.Not yet audited.
Items 3 and 4 are the honest unknowns. Everything else above is measured.
Merge pull request #697 from underworldcode/bugfix/cell-radii-from-pe…
lmoresi commentedon Oct 5, 2026
Already fixed. Two independent probes on
development(1391e4aa).The three definitions are down to one. A fresh
UnstructuredSimplexBoxcarries['_cell_radii', 'get_min_radius', 'get_max_radius']— the dead_min_sizeand_search_lengthsare gone.And the partition-dependent accessor is now tied to the field:
test_0010_cell_size_geometry.py:142 test_cell_size_and_min_radius_are_now_one_measureassertsmesh.cell_size()andmesh.get_min_radius()read the same quantity, which is what this issue asked for. Closed under the issue-adjudication rule in #819.lmoresi commentedon Oct 12, 2026
Reopened and labelled
fixed-in-development, under the policy now in CLAUDE.md:The fix stands and the evidence above is unchanged — this is a bookkeeping correction, not a reversal. Closing it on merge was wrong because
developmenthas not been released, so anyone running the release still has the defect; the label says where the fix is and the release sweep closes it.