Skip to content

Commit 443ffb5

Browse files
committed
fix: make stabilization cell sizes local to each cell (#687)
Adapt only the mesh-size correction from lmoresi's 68e545f on feature/navier-stokes-supg; do not import Navier-Stokes or other branch changes. Cache _radii_own from current DM vertex coordinates and use it for mesh.cell_size(). Preserve the legacy kd-tree radius arrays and global timestep/mesh-motion consumers. Use coordinate-section offsets and the full vertex stratum so the own-cell RMS definition also handles hexahedra, which have eight vertices but six faces. Correct the field documentation and Nitsche mechanism tests for the new definition; retain physical solve tolerances and use the exact nearest-centroid <= own-centroid ordering instead of an arbitrary approximate-equality tolerance. Add a first-failing independent geometry/deformation regression for triangles, tetrahedra, quadrilaterals and hexahedra plus a regular-square analytical control. Before: four failures in serial and on eight ranks. After rebuild: 21 passed/one expected skip serial (22.90 s), 22 passed on eight ranks (40.45 s), covering Nitsche solves, radius accessors, frozen PC2 migration and memory/disk snapshots. Own-cell geometry error is zero in these tests; style and whitespace gates pass.
1 parent 9da04b7 commit 443ffb5

3 files changed

Lines changed: 103 additions & 34 deletions

File tree

src/underworld3/discretisation/discretisation_mesh.py

Lines changed: 25 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3224,7 +3224,8 @@ def cell_size(self):
32243224
32253225
Returns the ``.sym`` of a cell-constant (degree-0, discontinuous)
32263226
scalar MeshVariable holding each cell's characteristic length (the
3227-
``volume**(1/dim)`` equivalent radius, i.e. ``self._radii``). Unlike
3227+
RMS distance of its vertices from their own centroid). This is a
3228+
purely cell-local quantity, independent of the MPI partition. Unlike
32283229
the single *global* scalar from :meth:`get_min_radius` (the smallest
32293230
cell anywhere), this varies cell to cell, so a stabilisation that
32303231
scales as :math:`1/h` — e.g. the Nitsche free-slip penalty
@@ -3290,34 +3291,19 @@ def _refresh():
32903291
def _assemble_cell_size(self, var):
32913292
"""Fill ``var`` (degree-0 scalar) with each cell's characteristic size.
32923293
3293-
Uses the per-cell characteristic lengths ``self._radii`` computed by
3294+
Uses the own-cell characteristic lengths ``self._radii_own`` computed by
32943295
:meth:`_get_mesh_sizes` on the *current* geometry. A degree-0
3295-
discontinuous variable's local DOFs and ``self._radii`` are BOTH
3296+
discontinuous variable's local DOFs and ``self._radii_own`` are BOTH
32963297
indexed by this rank's cell-stratum order, so a direct assignment is
32973298
correct on every rank.
32983299
32993300
This is deliberately a purely RANK-LOCAL operation (no ``var.coords``
33003301
access, no collective): mixing a rank-local fast path with a
33013302
collective fallback would diverge across ranks and deadlock, because
33023303
``var.coords`` triggers the collective ``_get_coords_for_basis``."""
3303-
# TODO(BUG): this field is PARTITION-DEPENDENT, and so therefore is the
3304-
# Nitsche penalty gamma*mu/h that consumes it (local_h=True, the default).
3305-
# Not the indexing here — the values. `_get_mesh_sizes` measures a cell by
3306-
# the distance from its vertices to the NEAREST CENTROID in a kd-tree built
3307-
# from THIS RANK's centroids, so near a partition seam the nearest centroid
3308-
# may simply be absent. Measured on Annulus(cellSize=0.12): the field's sum
3309-
# is 26.0822 at np=1, 26.1211 at np=2 and 26.1386 at np=4, and its max moves
3310-
# at np=4. End to end that is 6.6e-03 in the velocity of a Nitsche free-slip
3311-
# annulus and it does NOT shrink with solver tolerance.
3312-
# This is a DIFFERENT defect from the boundary normal fixed for #564 (which
3313-
# is now clean: the same solve with local_h=False agrees to 3.6e-10 at
3314-
# np=1..4). It is the local h that is left, and it also reaches every other
3315-
# consumer of `cell_size()`. Not fixed here because `_get_mesh_sizes` also
3316-
# feeds `get_min_radius`, the adaptivity metrics and the free-surface
3317-
# relaxation, and it needs its own benchmarking.
3318-
# Guard/measurement: tests/parallel/test_1069_boundary_normal_parallel.py
3319-
# (_nitsche_annulus_diagnostics docstring records the numbers).
3320-
radii = numpy.asarray(self._radii).reshape(-1)
3304+
# Own-cell radii fix #687 without changing the legacy kd-tree radii
3305+
# used by global timestep estimates, adaptivity, and mesh relaxation.
3306+
radii = numpy.asarray(self._radii_own).reshape(-1)
33213307
# Empty partition (no local cells): nothing to fill on this rank.
33223308
if radii.size == 0 or var.data.shape[0] == 0:
33233309
return
@@ -6895,8 +6881,11 @@ def _eval_use_robust_location(self) -> bool:
68956881

68966882
def _get_mesh_sizes(self, verbose=False):
68976883
"""
6898-
Obtain the (local) mesh radii and centroids using kdtree distances
6899-
This routine is called when the mesh is built / rebuilt
6884+
Cache own-cell radii for cell_size and return legacy kd-tree radii.
6885+
6886+
Own-cell sizes use current DM vertices, so neither partition-local
6887+
neighbours nor stale coordinate views affect stabilization (#687).
6888+
Legacy radii remain unchanged for their other consumers.
69006889
"""
69016890

69026891
centroids = self._get_coords_for_basis(0, False)
@@ -6909,6 +6898,9 @@ def _get_mesh_sizes(self, verbose=False):
69096898
cell_length = np.empty(centroids.shape[0])
69106899
cell_min_r = np.empty(centroids.shape[0])
69116900
cell_r = np.empty(centroids.shape[0])
6901+
cell_r_own = np.empty(centroids.shape[0])
6902+
coordinate_section = self.dm.getCoordinateDM().getLocalSection()
6903+
vertex_coordinates = self.dm.getCoordinatesLocal().array
69126904

69136905
for cell in range(cEnd - cStart):
69146906
cell_num_points = self.dm.getConeSize(cell)
@@ -6922,6 +6914,16 @@ def _get_mesh_sizes(self, verbose=False):
69226914
cell_r[cell] = np.sqrt(distsq.mean())
69236915
cell_min_r[cell] = np.sqrt(distsq.min())
69246916

6917+
# A hex has six faces but eight vertices: select the vertex
6918+
# stratum, not a cone-sized suffix of its transitive closure.
6919+
closure = self.dm.getTransitiveClosure(cStart + cell)[0]
6920+
vertices = closure[(closure >= pStart) & (closure < pEnd)]
6921+
offsets = np.array([coordinate_section.getOffset(int(v)) for v in vertices])
6922+
own_coords = vertex_coordinates[offsets[:, None] + np.arange(self.cdim)]
6923+
delta = own_coords - own_coords.mean(axis=0)
6924+
cell_r_own[cell] = np.sqrt(np.mean(np.sum(delta ** 2, axis=1)))
6925+
6926+
self._radii_own = cell_r_own
69256927
return cell_min_r, cell_r, centroids, cell_length
69266928

69276929
# ==========
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""Issue #687: cell_size is an own-cell geometric quantity, including after deform.
2+
3+
The independent oracle reads vertex coordinates through the coordinate section;
4+
it does not use the mesh's cached radii or centroid kd-tree. Run serial and MPI.
5+
"""
6+
7+
import numpy as np
8+
import pytest
9+
10+
import underworld3 as uw
11+
12+
pytestmark = [pytest.mark.level_1, pytest.mark.tier_b]
13+
14+
15+
def _vertex_rms(mesh):
16+
dm = mesh.dm
17+
section = dm.getCoordinateDM().getLocalSection()
18+
coordinates = dm.getCoordinatesLocal().array
19+
start, end = dm.getHeightStratum(0)
20+
first_vertex, last_vertex = dm.getDepthStratum(0)
21+
radii = []
22+
for cell in range(start, end):
23+
vertices = [int(point) for point in dm.getTransitiveClosure(cell)[0]
24+
if first_vertex <= point < last_vertex]
25+
points = np.array([coordinates[section.getOffset(v):section.getOffset(v) + mesh.cdim]
26+
for v in vertices])
27+
radii.append(np.sqrt(np.mean(np.sum((points - points.mean(axis=0)) ** 2, axis=1))))
28+
return np.asarray(radii)
29+
30+
31+
@pytest.mark.parametrize("dim", [2, 3])
32+
@pytest.mark.parametrize("simplex", [True, False], ids=["simplex", "tensor"])
33+
def test_cell_size_matches_own_vertices_and_tracks_deform(dim, simplex):
34+
geometry = dict(minCoords=(0.0,) * dim, maxCoords=(1.0,) * dim, qdegree=3)
35+
mesh = (uw.meshing.UnstructuredSimplexBox(**geometry, cellSize=0.25, regular=False)
36+
if simplex else uw.meshing.StructuredQuadBox(**geometry, elementRes=(4,) * dim))
37+
mesh.cell_size()
38+
field = mesh._cell_size_variable
39+
errors = []
40+
for phase in ("initial", "deformed"):
41+
if phase == "deformed":
42+
coordinates = np.array(mesh.X.coords)
43+
coordinates[:, 0] = 1.7 * coordinates[:, 0] + 0.2 * coordinates[:, 1]
44+
mesh.deform(coordinates)
45+
expected = _vertex_rms(mesh)
46+
actual = np.asarray(field.array[:, 0, 0])
47+
shapes_match = actual.shape == expected.shape
48+
assert all(uw.mpi.comm.allgather(shapes_match)), (actual.shape, expected.shape)
49+
local_error = float(np.abs(actual - expected).max(initial=0.0))
50+
error = max(uw.mpi.comm.allgather(local_error))
51+
errors.append(error)
52+
uw.pprint(f"CELL_SIZE_GEOMETRY dim={dim} simplex={simplex} phase={phase} "
53+
f"ranks={uw.mpi.size} max_error={error:.12g}")
54+
assert max(errors) < 1e-12, errors
55+
56+
57+
def test_regular_square_cell_size_keeps_global_radius():
58+
mesh = uw.meshing.StructuredQuadBox(elementRes=(4, 4), qdegree=2)
59+
legacy = np.array(mesh._radii)
60+
global_radius = mesh.get_min_radius()
61+
mesh.cell_size()
62+
expected = np.sqrt(2.0) / 8.0
63+
error = float(np.abs(np.asarray(mesh._cell_size_variable.array) - expected).max(initial=0.0))
64+
assert max(uw.mpi.comm.allgather(error)) < 1e-12
65+
assert global_radius == pytest.approx(expected, rel=1e-12)
66+
assert all(uw.mpi.comm.allgather(np.array_equal(mesh._radii, legacy)))

tests/test_1065_nitsche_local_h.py

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -152,11 +152,11 @@ def _box_wobble(X0, amp):
152152
# --------------------------------------------------------------------------
153153
def test_cell_size_is_local_per_cell():
154154
"""``mesh.cell_size()`` is a per-cell field equal to each cell's
155-
characteristic size (``mesh._radii``), not the single global minimum."""
155+
own-vertex RMS size (``mesh._radii_own``), not the single global minimum."""
156156
mesh = _graded_box()
157157
h = mesh.cell_size() # sympy symbol -> backed by a P0 field
158-
field = np.asarray(mesh._cell_size_variable.data[:, 0]).reshape(-1)
159-
radii = np.asarray(mesh._radii).reshape(-1)
158+
field = np.asarray(mesh._cell_size_variable.array[:, 0, 0]).reshape(-1)
159+
radii = np.asarray(mesh._radii_own).reshape(-1)
160160

161161
# field exactly mirrors the per-cell characteristic size (rank-local check,
162162
# reduced to a single global pass/fail so all ranks agree)
@@ -168,22 +168,23 @@ def test_cell_size_is_local_per_cell():
168168
gfmin, gfmax = _gmin(field), _gmax(field)
169169
assert gfmax / gfmin > 3.0
170170

171-
# the global scalar that global-h would use is just the minimum cell size
172-
assert np.isclose(mesh.get_min_radius(), gfmin, rtol=1e-6)
171+
# The unchanged nearest-centroid minimum cannot exceed the own-cell
172+
# minimum; these are no longer the same definition on an irregular mesh.
173+
assert 0.0 < mesh.get_min_radius() <= gfmin * (1.0 + 1e-12)
173174

174175

175176
def test_local_h_at_coarse_freeslip_boundary_exceeds_global_min():
176177
"""At the COARSE Top free-slip boundary the LOCAL penalty size is many
177178
times the global minimum. global-h would over-stiffen that penalty by
178179
exactly this factor; local-h scales it correctly."""
179180
mesh = _graded_box(h_fine=0.04, h_coarse=0.12)
180-
# build/exercise the field; its data equals mesh._radii (asserted in
181+
# build/exercise the field; its data equals mesh._radii_own (asserted in
181182
# test_cell_size_is_local_per_cell), so we read the per-cell sizes directly
182-
# from _radii / _centroids — a rank-local lookup, avoiding the collective
183+
# from the field / _centroids — a rank-local lookup, avoiding the collective
183184
# arbitrary-point uw.function.evaluate (which deadlocks in parallel).
184185
_ = mesh.cell_size()
185186
cen = np.asarray(mesh._centroids)
186-
radii = np.asarray(mesh._radii).reshape(-1)
187+
radii = np.asarray(mesh._cell_size_variable.array[:, 0, 0]).reshape(-1)
187188

188189
near_top = cen[:, 1] > 0.85 # cells adjacent to the Top free-slip edge
189190
h_top = radii[near_top]
@@ -204,14 +205,14 @@ def test_cell_size_tracks_deformation():
204205
the Nitsche mis-scaling on the free surface."""
205206
mesh = _graded_box()
206207
_ = mesh.cell_size()
207-
h_before = mesh._cell_size_variable.data[:, 0].copy()
208+
h_before = np.array(mesh._cell_size_variable.array[:, 0, 0])
208209

209210
X = np.asarray(mesh.X.coords).copy()
210211
moved = mesh.deform(_box_wobble(X, amp=0.04))
211212
assert moved # geometry actually changed
212213

213-
h_after = mesh._cell_size_variable.data[:, 0].copy()
214-
radii_after = np.asarray(mesh._radii).reshape(-1)
214+
h_after = np.array(mesh._cell_size_variable.array[:, 0, 0])
215+
radii_after = np.asarray(mesh._radii_own).reshape(-1)
215216

216217
# not stale: the field changed with the geometry SOMEWHERE (global OR) ...
217218
nb = min(h_after.shape[0], h_before.shape[0])

0 commit comments

Comments
 (0)