Skip to content

Commit 2841eb4

Browse files
authored
Fix 3D boundary reaction flux recovery (#404)
* Fix 3D boundary reaction flux recovery Recover pointwise traction from 3D rotated free-slip reactions by assembling a lumped triangular boundary mass and dividing nodal reactions by that mass. Support P1 traces directly and P2 traces through four-triangle facet subdivision, with global coordinate-based assembly for partition independence and a surface-mass-weighted mean gauge. Add serial and MPI regressions using the Zhong et al. l=2 internal-load shell. The corrected surface and CMB topography coefficients reproduce the reference response and remain invariant across MPI partitions. Retain focused 2D traction and flux coverage. * Fix pointwise P2 surface reaction recovery Replace the invalid four-subtriangle P2 lumping with the exact six-node triangular surface mass matrix and a sparse consistent solve. Add mass=auto so existing 2D and 3D P1 traces retain lumped recovery while 3D P2 selects the mathematically required consistent operator; reject explicit P2 lumping and unsupported 3D traces. Add pointwise constant-flux P1/P2 tetrahedral-box regressions in serial and MPI, including two- and four-rank partitions. Extend the Zhong l=2 validation to compare all nodes, vertices, and edge midpoints independently, removing the prior all-node cancellation blind spot. Document trace-order behavior, non-triangular limitations, analytic-normal partition guarantees, and the corrected 3D CBF behavior. * Parameterize Zhong topography diagnostic resolution Allow the serial and MPI Zhong l=2 rotated free-slip diagnostics to accept an optional shell cell size while retaining 0.25 as the regression-test default. Extend the standalone MPI diagnostic output with the selected cell size so manual convergence results are self-describing. This supports serial/MPI comparisons at 1/4, 1/8, and 1/16 resolution without adding costly high-resolution cases to routine CI.
1 parent 5146786 commit 2841eb4

8 files changed

Lines changed: 499 additions & 41 deletions

File tree

docs/developer/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,13 @@ component exactly — correct on curved, tilted, and deformed boundaries (#293).
9595
- A general **consistent boundary flux (CBF) primitive** recovers boundary
9696
fluxes for any solver — surface heat flux / Nusselt number for scalar
9797
diffusion, boundary traction σ·n for Stokes (#294).
98+
- Three-dimensional CBF recovery now assembles the exact triangular trace mass:
99+
P1 supports lumped or consistent recovery, while P2 uses the required
100+
consistent six-node surface-mass solve. The default `mass="auto"` selects the
101+
valid method; explicit P2 lumping and non-triangular 3D traces raise instead
102+
of returning a non-pointwise reaction scaling. Strict MPI invariance of a
103+
vector normal projection requires an analytic normal; geometric facet-normal
104+
seam sensitivity is unchanged (#404).
98105
- Recorded as the preferred free-slip BC in the project guidance (#300);
99106
conda PETSc floor raised to ≥ 3.25 for FMG/rotation API consistency (#304).
100107

src/underworld3/cython/petsc_generic_snes_solvers.pyx

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2304,7 +2304,7 @@ class SolverBaseClass(uw_object):
23042304
self.dm.restoreLocalVec(xlocal)
23052305
self.dm.restoreGlobalVec(gvec)
23062306

2307-
def boundary_flux(self, boundary, mass="lumped", remove_mean=False, normal=None):
2307+
def boundary_flux(self, boundary, mass="auto", remove_mean=False, normal=None):
23082308
r"""Consistent boundary flux on ``boundary``, recovered from the essential-BC
23092309
reaction of the last solve (the Consistent Boundary Flux method).
23102310
@@ -2314,15 +2314,21 @@ class SolverBaseClass(uw_object):
23142314
number); for a **vector** solver the traction :math:`\sigma\cdot\hat n` (pass
23152315
``normal`` to get the scalar normal component :math:`\hat n\cdot\sigma\cdot\hat n`).
23162316
2317-
``mass`` de-smears the nodal reaction with the ``"lumped"`` (diagonal, monotone —
2318-
no overshoot at a flux jump) or ``"consistent"`` boundary mass. ``remove_mean``
2319-
subtracts the boundary mean — leave ``False`` for a physical flux (the mean is
2320-
the Nusselt number); ``True`` gives a gauge-free field (e.g. dynamic topography).
2321-
Parallel-safe and partition-independent."""
2317+
``mass`` de-smears the nodal reaction with ``"lumped"`` or ``"consistent"``
2318+
boundary mass. ``"auto"`` (default) selects lumped recovery for 2D traces and
2319+
3D P1 triangles, and the required consistent solve for 3D P2 triangles.
2320+
``remove_mean`` subtracts the boundary mean — leave ``False`` for a physical
2321+
flux (the mean is the Nusselt number); ``True`` gives a gauge-free field.
2322+
2323+
Three-dimensional recovery supports triangular P1/P2 traces; quadrilateral
2324+
traces raise explicitly. Reaction and mass assembly are partition-independent.
2325+
For vector fluxes, supply an analytic ``normal`` when strict partition
2326+
independence of the normal projection is required; geometric facet-normal
2327+
averaging at partition seams has a small pre-existing partition sensitivity."""
23222328
from underworld3.utilities.boundary_flux import boundary_flux as _bf
23232329
return _bf(self, boundary, mass=mass, remove_mean=remove_mean, normal=normal)
23242330

2325-
def boundary_flux_field(self, boundary, field, mass="lumped",
2331+
def boundary_flux_field(self, boundary, field, mass="auto",
23262332
remove_mean=False, scale=1.0, normal=None):
23272333
r"""Write the consistent boundary flux (see :meth:`boundary_flux`) onto a scalar
23282334
MeshVariable ``field`` at the boundary nodes (interior untouched), multiplied by
@@ -5374,26 +5380,26 @@ class SNES_Stokes_SaddlePt(SolverBaseClass):
53745380
J1.destroy(); J2.destroy()
53755381
return rel > tol
53765382

5377-
def boundary_normal_traction(self, boundary, mass="lumped"):
5383+
def boundary_normal_traction(self, boundary, mass="auto"):
53785384
r"""Return the boundary normal traction :math:`\sigma_{nn}` on a
53795385
rotated-free-slip ``boundary`` as the constraint reaction from the last
53805386
solve — the smooth, bounded quantity used for dynamic topography
53815387
(:math:`h_\infty=-(\sigma_{nn}-\overline{\sigma_{nn}})/\rho g`). Requires a
53825388
prior :meth:`add_rotated_freeslip_bc` on ``boundary`` and a completed
53835389
:meth:`solve`.
53845390
5385-
``mass`` chooses the boundary-mass de-smear of the nodal reaction:
5386-
``"lumped"`` (default) is monotone — it cannot overshoot where the traction
5387-
jumps (e.g. across a viscosity contrast), so it is the safe choice for driving
5388-
a free surface; ``"consistent"`` uses the full P2 line mass (marginally sharper
5389-
on smooth tractions, but overshoots at discontinuities)."""
5391+
``mass="auto"`` (default) uses lumped recovery for 2D traces and 3D P1
5392+
triangles, and the required consistent surface-mass solve for 3D P2 triangles.
5393+
Explicit ``"lumped"`` and ``"consistent"`` choices remain available where
5394+
mathematically valid. Three-dimensional recovery currently supports triangular
5395+
P1/P2 traces only."""
53905396
if self._rotated_freeslip_info is None:
53915397
raise RuntimeError(
53925398
"boundary_normal_traction requires a completed rotated-free-slip solve.")
53935399
from underworld3.utilities.rotated_bc import boundary_normal_traction as _bnt
53945400
return _bnt(self, boundary, self._rotated_freeslip_info, mass=mass)
53955401

5396-
def dynamic_topography(self, boundary, field, buoyancy_scale=1.0, mass="lumped"):
5402+
def dynamic_topography(self, boundary, field, buoyancy_scale=1.0, mass="auto"):
53975403
r"""Write the dynamic topography
53985404
:math:`h = -(\sigma_{nn}-\overline{\sigma_{nn}})/(\Delta\rho\,g)` on a
53995405
rotated-free-slip ``boundary`` onto a scalar MeshVariable ``field``, from the
@@ -5403,9 +5409,9 @@ class SNES_Stokes_SaddlePt(SolverBaseClass):
54035409
pass it here after each :meth:`solve`; its boundary nodes are filled and the
54045410
interior left untouched.
54055411
5406-
``buoyancy_scale`` is :math:`\Delta\rho\,g` (traction → length). ``mass`` selects
5407-
the recovery de-smear (``"lumped"`` default is monotone — no overshoot at a
5408-
stress jump — and is the safe choice for a free surface). Requires a prior
5412+
``buoyancy_scale`` is :math:`\Delta\rho\,g` (traction → length).
5413+
``mass="auto"`` selects lumped recovery where valid and the consistent
5414+
surface-mass solve for 3D P2 triangles. Requires a prior
54095415
:meth:`add_rotated_freeslip_bc` on ``boundary`` and a completed :meth:`solve`."""
54105416
if self._rotated_freeslip_info is None:
54115417
raise RuntimeError(

src/underworld3/utilities/boundary_flux.py

Lines changed: 182 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,40 @@
1515
assembled here in ``_desmear`` by SUMMING each rank's partial contribution by coordinate
1616
(the same rock-solid gather used for the boundary mass — no hand-rolled global assembly).
1717
18-
``mass="lumped"`` (default) uses the diagonal boundary mass: being an M-matrix it cannot
19-
overshoot where the flux jumps (no Gibbs wiggle) and is a purely local division.
18+
``mass="auto"`` (default) uses a diagonal lumped mass where the trace basis admits
19+
positive row sums (the 2D P2 line trace and the 3D P1 triangle trace), and the consistent
20+
mass otherwise. A 3D P2 triangle has exactly zero row sum at every vertex, so its
21+
pointwise recovery requires the consistent surface-mass solve.
2022
``remove_mean=False`` (default) keeps the physical mean flux (the Nusselt number);
2123
set ``remove_mean=True`` for a gauge-free field (e.g. dynamic topography).
2224
"""
2325
import numpy as np
2426
from mpi4py import MPI
2527

2628

29+
# M_e = (area / 12) * _P1_TRIANGLE_MASS.
30+
_P1_TRIANGLE_MASS = np.array(
31+
(
32+
(2.0, 1.0, 1.0),
33+
(1.0, 2.0, 1.0),
34+
(1.0, 1.0, 2.0),
35+
)
36+
)
37+
38+
# M_e = (area / 180) * _P2_TRIANGLE_MASS. Node order: vertices
39+
# (0, 1, 2), then edge nodes (01, 12, 20).
40+
_P2_TRIANGLE_MASS = np.array(
41+
(
42+
(6.0, -1.0, -1.0, 0.0, -4.0, 0.0),
43+
(-1.0, 6.0, -1.0, 0.0, 0.0, -4.0),
44+
(-1.0, -1.0, 6.0, -4.0, 0.0, 0.0),
45+
(0.0, 0.0, -4.0, 32.0, 16.0, 16.0),
46+
(-4.0, 0.0, 0.0, 16.0, 32.0, 16.0),
47+
(0.0, -4.0, 0.0, 16.0, 16.0, 32.0),
48+
)
49+
)
50+
51+
2752
def _key(c, dim):
2853
return tuple(round(float(t), 9) for t in np.asarray(c).ravel()[:dim])
2954

@@ -140,12 +165,160 @@ def _desmear(solver, boundary, xs, R, mass, remove_mean, partial_reaction=True):
140165
csec = dm.getCoordinateSection()
141166
cvec = np.asarray(dm.getCoordinatesLocal().array).reshape(-1, dim)
142167
v0, v1 = dm.getDepthStratum(0)
168+
if mass not in ("auto", "lumped", "consistent"):
169+
raise ValueError("mass must be 'auto', 'lumped', or 'consistent'.")
170+
if dim == 3:
171+
lsec = dm.getLocalSection()
172+
ncomp = lsec.getFieldComponents(0)
173+
f0, f1 = dm.getHeightStratum(1)
174+
e0, e1 = dm.getDepthStratum(1)
175+
176+
def coord(q):
177+
return _point_coord(dm, dim, cvec, csec, v0, v1, q)
178+
179+
nodeR = {_key(x, dim): float(r) for x, r in zip(xs, R)}
180+
sis = _boundary_stratum_is(dm, solver.mesh, boundary)
181+
facets = [] if not (sis and sis.getSize() > 0) else [
182+
int(q) for q in sis.getIndices() if f0 <= int(q) < f1
183+
]
184+
local_elements = []
185+
186+
for facet in facets:
187+
closure = [int(q) for q in dm.getTransitiveClosure(facet)[0]]
188+
vertices = [q for q in closure if v0 <= q < v1]
189+
edges = [q for q in closure if e0 <= q < e1]
190+
if len(vertices) != 3:
191+
raise NotImplementedError(
192+
"3D boundary-flux recovery currently requires triangular facets."
193+
)
194+
if lsec.getFieldDof(facet, 0) > 0:
195+
raise NotImplementedError(
196+
"3D boundary-flux recovery supports P1 or P2 triangular traces."
197+
)
198+
199+
vertex_coords = [np.asarray(coord(q), dtype=float) for q in vertices]
200+
vertex_keys = [_key(value, dim) for value in vertex_coords]
201+
edge_midpoints = {}
202+
for edge in edges:
203+
edge_dof = lsec.getFieldDof(edge, 0)
204+
if edge_dof <= 0:
205+
continue
206+
if edge_dof != ncomp:
207+
raise NotImplementedError(
208+
"3D boundary-flux recovery supports P1 or P2 triangular traces."
209+
)
210+
edge_vertices = [
211+
int(q)
212+
for q in dm.getTransitiveClosure(edge)[0]
213+
if v0 <= int(q) < v1
214+
]
215+
if len(edge_vertices) == 2:
216+
edge_key = frozenset(_key(coord(q), dim) for q in edge_vertices)
217+
edge_midpoints[edge_key] = _key(coord(edge), dim)
218+
219+
a, b, c = vertex_coords
220+
area = 0.5 * float(np.linalg.norm(np.cross(b - a, c - a)))
221+
if not edge_midpoints:
222+
local_elements.append((1, tuple(vertex_keys), area))
223+
continue
224+
if len(edge_midpoints) != 3:
225+
raise NotImplementedError(
226+
"3D boundary-flux recovery supports P1 or complete P2 triangular traces."
227+
)
228+
229+
m01 = edge_midpoints[frozenset((vertex_keys[0], vertex_keys[1]))]
230+
m12 = edge_midpoints[frozenset((vertex_keys[1], vertex_keys[2]))]
231+
m20 = edge_midpoints[frozenset((vertex_keys[2], vertex_keys[0]))]
232+
local_elements.append(
233+
(
234+
2,
235+
tuple(vertex_keys) + (m01, m12, m20),
236+
area,
237+
)
238+
)
239+
240+
R_by = {}
241+
for rank_values in comm.allgather(nodeR):
242+
for key, value in rank_values.items():
243+
R_by[key] = (
244+
R_by.get(key, 0.0) + value if partial_reaction else value
245+
)
246+
247+
elements = {}
248+
for rank_elements in comm.allgather(local_elements):
249+
for order, nodes, area in rank_elements:
250+
elements[(order, tuple(sorted(nodes)))] = (order, nodes, area)
251+
252+
orders = {order for order, _nodes, _area in elements.values()}
253+
if len(orders) != 1:
254+
raise RuntimeError(
255+
f"Expected one trace order on boundary {boundary!r}, found {sorted(orders)}."
256+
)
257+
order = orders.pop()
258+
if mass == "auto":
259+
mass = "consistent" if order == 2 else "lumped"
260+
if order == 2 and mass == "lumped":
261+
raise ValueError(
262+
"A 3D P2 triangular trace has zero row-sum mass at its vertices; "
263+
"use mass='consistent' for pointwise boundary-flux recovery."
264+
)
265+
266+
keys = sorted(R_by)
267+
global_index = {key: i for i, key in enumerate(keys)}
268+
reaction = np.array([R_by[key] for key in keys], dtype=float)
269+
if mass == "lumped":
270+
boundary_mass = np.zeros(len(keys), dtype=float)
271+
for _order, nodes, area in elements.values():
272+
for key in nodes:
273+
boundary_mass[global_index[key]] += area / 3.0
274+
missing = np.flatnonzero(boundary_mass <= 0.0)
275+
if missing.size:
276+
raise RuntimeError(
277+
f"Boundary mass is zero at {missing.size} nodes on {boundary!r}."
278+
)
279+
flux = reaction / boundary_mass
280+
elif mass == "consistent":
281+
from scipy.sparse import coo_matrix
282+
from scipy.sparse.linalg import spsolve
283+
284+
rows = []
285+
cols = []
286+
values = []
287+
reference_mass = (
288+
_P1_TRIANGLE_MASS if order == 1 else _P2_TRIANGLE_MASS
289+
)
290+
mass_scale = 12.0 if order == 1 else 180.0
291+
for _order, nodes, area in elements.values():
292+
indices = [global_index[key] for key in nodes]
293+
element_mass = (area / mass_scale) * reference_mass
294+
for i, row in enumerate(indices):
295+
for j, col in enumerate(indices):
296+
rows.append(row)
297+
cols.append(col)
298+
values.append(element_mass[i, j])
299+
surface_mass = coo_matrix(
300+
(values, (rows, cols)), shape=(len(keys), len(keys))
301+
).tocsr()
302+
surface_mass.sum_duplicates()
303+
flux = np.asarray(spsolve(surface_mass, reaction), dtype=float)
304+
boundary_mass = np.asarray(
305+
surface_mass @ np.ones(len(keys), dtype=float)
306+
)
307+
if not np.all(np.isfinite(flux)):
308+
raise RuntimeError(
309+
f"Consistent boundary-mass solve failed on boundary {boundary!r}."
310+
)
311+
if remove_mean:
312+
mean = float(np.dot(flux, boundary_mass) / np.sum(boundary_mass))
313+
flux -= mean
314+
return np.array([flux[global_index[_key(x, dim)]] for x in xs])
315+
143316
if dim != 2:
144-
# no line-mass geometry yet in 3D → global-mean lumped fallback
145-
tot = comm.allreduce(float(np.sum(R)), op=MPI.SUM)
146-
cnt = comm.allreduce(int(len(R)), op=MPI.SUM)
147-
m = tot / max(cnt, 1)
148-
return np.asarray(R) - (m if remove_mean else 0.0)
317+
raise NotImplementedError(
318+
f"Boundary-flux recovery is not implemented for mesh dimension {dim}."
319+
)
320+
if mass == "auto":
321+
mass = "lumped"
149322

150323
e0, e1 = dm.getDepthStratum(1)
151324
def vcoord(q): return cvec[csec.getOffset(q) // dim]
@@ -198,7 +371,7 @@ def vcoord(q): return cvec[csec.getOffset(q) // dim]
198371
return np.array([sig[gi[_key(x, dim)]] for x in xs])
199372

200373

201-
def boundary_flux(solver, boundary, mass="lumped", remove_mean=False, normal=None):
374+
def boundary_flux(solver, boundary, mass="auto", remove_mean=False, normal=None):
202375
"""See ``SolverBaseClass.boundary_flux``. Returns ``(xs, flux)`` for this rank's
203376
boundary nodes; scalar solver → normal flux, vector solver → traction (or its normal
204377
component if ``normal`` is given)."""
@@ -253,7 +426,7 @@ def write_boundary_scalar_field(solver, field, value_by_key, dim):
253426
return field
254427

255428

256-
def boundary_flux_field(solver, boundary, field, mass="lumped",
429+
def boundary_flux_field(solver, boundary, field, mass="auto",
257430
remove_mean=False, scale=1.0, normal=None):
258431
r"""See ``SolverBaseClass.boundary_flux_field`` (the documented entry point;
259432
this free function is its implementation and shares its name). Writes

src/underworld3/utilities/rotated_bc.py

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1042,7 +1042,7 @@ def _mode_satisfies_constraints(solver, Q, normal_rows, tg, tol=1e-8):
10421042
return viol < tol
10431043

10441044

1045-
def boundary_normal_traction(solver, boundary, solve_result, mass="lumped"):
1045+
def boundary_normal_traction(solver, boundary, solve_result, mass="auto"):
10461046
"""Boundary normal traction σ_nn on `boundary` from the constraint reaction of the
10471047
last rotated-free-slip solve (``solve_result`` is the dict returned by
10481048
``solve_rotated_freeslip`` / ``solve_rotated_freeslip_nonlinear`` — see their
@@ -1055,22 +1055,19 @@ def boundary_normal_traction(solver, boundary, solve_result, mass="lumped"):
10551055
rotated frame's normal row) is corner-correct — at a node shared with another
10561056
rotated-free-slip boundary the rotated frame's first row is a mix of both walls'
10571057
normals, but n̂·r_c is the true normal traction for this boundary. The pointwise
1058-
σ_nn is the boundary-mass de-smear of R (2D).
1058+
σ_nn is the boundary-mass de-smear of R.
10591059
10601060
``mass`` selects the de-smear:
1061-
* ``"lumped"`` (default) — the diagonal (row-sum) boundary mass. Being an M-matrix
1062-
it CANNOT overshoot at a stress discontinuity (no Gibbs wiggle where the traction
1063-
jumps, e.g. across a viscosity contrast), it is a purely local division (no global
1064-
mass solve → trivially parallel), and it is marginally more accurate than the
1065-
consistent mass on SolCx. Recommended for driving a free surface, where an
1066-
overshoot at a sharp feature injects a spurious surface-velocity pulse.
1067-
* ``"consistent"`` — the full consistent P2 line mass. Marginally sharper on smooth
1068-
tractions but overshoots at discontinuities.
1061+
* ``"auto"`` (default) — lumped for 2D traces and 3D P1 triangles, consistent for
1062+
3D P2 triangles.
1063+
* ``"lumped"`` — the diagonal row-sum mass. It is monotone for supported traces,
1064+
but invalid for 3D P2 triangles because their vertex row sums are exactly zero.
1065+
* ``"consistent"`` — the full trace mass. Required for pointwise 3D P2 recovery.
10691066
10701067
Parallel-safe: r_c is scattered to a local vector (ghosts included) and read by LOCAL
10711068
section offset; the boundary mass is assembled globally by a coordinate-keyed
10721069
allgather of the boundary elements, so every rank produces the same de-smear and the
1073-
mean-removal gauge is global.
1070+
mean-removal gauge is global. In 3D, only triangular P1/P2 traces are supported.
10741071
"""
10751072
dm = solver.dm
10761073
dim = solver.mesh.dim
@@ -1119,12 +1116,12 @@ def boundary_normal_traction(solver, boundary, solve_result, mass="lumped"):
11191116

11201117

11211118
def dynamic_topography_field(solver, boundary, solve_result, field,
1122-
buoyancy_scale=1.0, mass="lumped"):
1119+
buoyancy_scale=1.0, mass="auto"):
11231120
"""Populate a scalar MeshVariable ``field`` with the dynamic topography
11241121
:math:`h = -(\\sigma_{nn}-\\overline{\\sigma_{nn}})/(\\Delta\\rho\\,g)` on ``boundary``,
1125-
recovered from the rotated-free-slip constraint reaction (lumped by default —
1126-
monotone, no Gibbs overshoot at a stress jump). Interior nodes are left untouched.
1127-
Returns ``field``.
1122+
recovered from the rotated-free-slip constraint reaction. ``mass="auto"`` uses
1123+
lumped recovery where valid and the consistent surface mass for 3D P2 triangles.
1124+
Interior nodes are left untouched. Returns ``field``.
11281125
11291126
This is the hand-off to the free-surface machinery: the 3-number topography
11301127
integrator drives node motion from a surface field, so σ_nn is written onto the

0 commit comments

Comments
 (0)