Skip to content

Add distributed harmonic projection of rotated reactions - #646

Open
gthyagi wants to merge 24 commits into
underworldcode:developmentfrom
gthyagi:feature/mantle-convection-benchmarks
Open

Add distributed harmonic projection of rotated reactions#646
gthyagi wants to merge 24 commits into
underworldcode:developmentfrom
gthyagi:feature/mantle-convection-benchmarks

Conversation

@gthyagi

@gthyagi gthyagi commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR adds a distributed weak projection of rotated free-slip boundary reactions and exposes it through the generic spherical-shell geoid postprocessing adapter.

The change is intentionally opt-in:

  • projection="centroid" remains the default for compatibility.
  • projection="reaction" directly contracts the assembled normal reaction with the requested harmonic.
  • The new Stokes.boundary_normal_traction_integral(boundary, fn, remove_mean=True) API is generic and is not tied to Zhong 2008.

Problem

The existing centroid workflow first recovers pointwise boundary-normal traction, gathers boundary samples to rank zero, reconstructs a spherical triangulation with ConvexHull, and fits the harmonic over triangle centroids.

That workflow was introduced for a valid reason: as discussed in #414, #431, and #404, raw P2 vertex values on faceted curved boundaries contain a slowly converging chord-versus-arc geometry error. Midpoint, fitted, and integral quantities are preferable to treating those vertices as physical point samples.

However, when the required output is only a harmonic coefficient, pointwise recovery is unnecessary. It also introduces:

  • a global boundary-coordinate/value gather;
  • rank-zero surface triangulation and integration;
  • pointwise P2 mass recovery before fitting;
  • an opportunity to mix a faceted FE reaction with an analytical spherical norm.

Method

For the converged rotated-free-slip solve, let R be the assembled nodal normal-reaction load and let phi be a scalar boundary test function. The new API computes

I(phi) = phi^T R - (1^T R / area) * integral(phi)

using owned reaction DOFs followed by MPI scalar reductions.

For a spherical harmonic phi_l = P_l(cos(theta)), the topography coefficient is normalized with the matching discrete boundary inner product:

h_l = sign * I(phi_l) / (buoyancy_scale * integral(phi_l^2))

Using BdIntegral(phi_l^2) is important because both numerator and denominator then use the same faceted FE geometry. The method never reconstructs or consumes recovered P2 vertex values, so it follows the midpoint/fitted/integral guidance from #414 rather than bypassing it.

Zhong 2008 layered validation

Benchmark: bench_020_zhong2008_layered_viscosity_response.py

Configuration: l=5, load depth 0.5d, cellsize=1/64, fitted 10^4 viscosity lid, P2P1, rotated free slip, 192 MPI ranks, stokes.tolerance=1e-5.

Both runs used the identical mesh, Stokes formulation, solution fields, and Zhong propagator reference. Only the topography projection changed.

Self-gravity response Analytic Centroid Centroid error Reaction Reaction error
Surface topography 0.456720 0.444767 -2.62% 0.457084 +0.08%
CMB topography 0.510978 0.515630 +0.91% 0.510959 -0.00%
Surface geoid 0.026961 0.025881 -4.01% 0.026994 +0.12%
CMB geoid 0.014956 0.015134 +1.19% 0.014956 +0.00%
No-self-gravity response Analytic Centroid Centroid error Reaction Reaction error
Surface topography 0.408246 0.398236 -2.45% 0.408551 +0.07%
CMB topography 0.466978 0.471107 +0.88% 0.466957 -0.00%
Surface geoid 0.022494 0.021589 -4.02% 0.022521 +0.12%
CMB geoid 0.012534 0.012695 +1.28% 0.012534 +0.00%

The surface-geoid error falls from about 4.0% to 0.12%. All eight topography/geoid coefficients are within 0.13% of the semi-analytical propagator after the change.

Velocity is measured independently and is unchanged by this postprocessing method. In this run, CMB velocity differs from the propagator by 0.02%; the near-zero surface velocity differs by 8.46%.

Parallel and performance properties

  • Each rank reads only reaction DOFs it owns.
  • Ghost/shared boundary nodes are not double counted.
  • Only scalar sums are reduced across MPI.
  • No global boundary topology or recovered pointwise traction is gathered.
  • No rank-zero ConvexHull or surface mass solve is used.

A controlled 192-rank before/after run used the identical mesh, solver configuration, and benchmark script revision apart from the projection selection. The centroid baseline was rerun from benchmark commit a3344ac as Gadi job 177431139; the reaction run was job 177429270.

Metric Centroid baseline Reaction Change
Mesh loading 45.16 s 47.04 s +4.2%
Stokes solve 784.55 s 772.01 s -1.6%
HDF5 output 4.22 s 4.73 s +12.1%
Response integrals, topography, and geoid 120.14 s 36.92 s -69.3% (3.25x faster)
PBS wall time 16:58 14:59 -11.7%
Peak memory 324.27 GB 337.75 GB +4.2%
Exit status 0 0 unchanged

Benchmark.ResponseIntegrals includes the common velocity/divergence integrals as well as topography recovery and geoid evaluation. Those common calculations are identical between runs, so the measured 83.22 s reduction isolates the effect of replacing centroid recovery's global gather, rank-zero triangulation, and pointwise traction fit with the distributed reaction contraction.

The small Stokes, mesh, HDF5, and whole-job memory differences are treated as run-to-run variation; this PR does not claim improvements in those stages. The measured postprocessing improvement is the 120.14 s to 36.92 s reduction, together with removal of the global boundary gather and rank-zero surface reconstruction from the reaction path.

Validation

  • Focused serial geoid suite: 13 passed.
  • Focused four-rank MPI geoid regression: passed on macOS and Gadi OpenMPI.
  • Gadi production validation: 192 ranks, cellsize=1/64, exit status 0.
  • The MPI rank-consistency assertion uses near-machine-precision tolerance because collective reductions can differ in their final floating-point bits across MPI implementations.

Files changed

  • Add the reaction-integral kernel and Stokes wrapper.
  • Add opt-in reaction projection to generic geoid postprocessing.
  • Document when to use pointwise recovery versus weak projection.
  • Add focused serial and MPI regressions.

Expose boundary_normal_traction_integral() to contract the assembled rotated free-slip normal reaction directly with a scalar boundary test function. Count only owned reaction DOFs, reduce the weak functional across MPI ranks, and remove the constant traction gauge without recovering pointwise P2 values.

Add an opt-in reaction projection to spherical-shell geoid postprocessing. Normalize with the matching discrete boundary inner product so the reaction numerator and harmonic norm use the same faceted geometry. Retain the centroid projection as the compatibility default.

Document the relationship to curved-boundary P2 midpoint/fitted guidance from issue underworldcode#414 and add focused serial and MPI Zhong regressions for the new path.
Collective reductions may differ by the final floating-point bits across MPI implementations. Compare projected geoid and topography coefficients at near-machine precision instead of requiring bitwise-identical arrays.
@lmoresi

lmoresi commented Aug 26, 2026

Copy link
Copy Markdown
Member

Reading this while finishing #633, which lands in the neighbouring code (_desmear / boundary_normal_traction). Two notes, both supportive.

The direction is right, and it is the same lesson from the other side. #633 turned on the fact that the nodal reaction is a dual object — an integrated force. Asking it for pointwise values is the ill-posed direction, and that is where every failure mode lives: the zero-mean P2 vertex basis (#414), the singular vertex rows of the P2 triangle mass, the chord-versus-arc geometry error (#431). boundary_normal_traction_integral never asks that question, so none of them apply to it. For a coefficient it is strictly the better construction.

The two changes are complementary rather than overlapping: this adds a new function and leaves boundary_normal_traction and _desmear untouched, while #633 improves the pointwise recovery that dynamic_topography_field still needs for a nodal field. The only collisions are adjacent text in rotated_bc.py, the .pyx and the changelog.

On the workflow this replaces — the centroid path is worth retiring rather than keeping as the default. Filed separately as #647 with measurements. _project_spherical_harmonic_samples gathers the boundary to rank zero and re-derives a triangulation with ConvexHull(..., "QJ"), when the boundary facets already exist and uw.maths.BdIntegral already integrates over them via DMPlexComputeBdIntegral. That path cannot work on a non-convex boundary at all, joggles its input, and applies analytic spherical triangle areas to a faceted FE reaction.

Measured on the Zhong l=2 shell, relative error against the analytic coefficients:

cellSize 0.25 0.20 0.16 0.13
surface, ConvexHull 0.0028 0.0001 0.0014 0.0013
surface, BdIntegral 0.0003 0.0014 0.0005 0.0008
CMB, ConvexHull 0.0041 0.0081 0.0059 0.0027
CMB, BdIntegral 0.0091 0.0098 0.0084 0.0037
CMB, pointwise fit 0.0313 0.0236 0.0188 0.0097

To be clear about what that does and does not show: on accuracy the two integral arms are a wash, so this is not an accuracy argument. It is that the same answer is available on the mesh we already have, on any boundary shape, without a gather or a joggle. The last row is the separate point — both integral forms beat the pointwise fit by 3-10x at coarse resolution, which independently supports #414.

Given your projection="reaction" is better still, the question for this PR is whether centroid needs to stay the default, or whether it can be deprecated in the same change.

Underworld development team with AI support from Claude Code

gthyagi added 20 commits August 26, 2026 14:13
For order-one advection-diffusion with theta=1, the Adams-Moulton flux has coefficients [1, 0]. Avoid projecting and tracing the zero-weight stored flux on every solve while still refreshing the symbolic coefficients when theta changes after construction.

Preserve the existing DFDt lifecycle for Crank-Nicolson, forward Euler, and higher-order Adams-Moulton configurations. Add focused regression coverage for lifecycle call counts and numerical equivalence with the previous forced-history path.
Exercise 38 canonical Crank-Nicolson transport solves on a three-dimensional spherical shell. Verify in serial and MPI that transient global-evaluation swarms do not survive a solve, interpolation cache entries remain bounded, solver report histories cap at 32, and temperature remains finite.
Introduce uw.systems.AdvDiffusionSUPG as a Python systems-layer solver without changing the existing semi-Lagrangian aliases. Assemble the streamline test contribution through the scalar F1 flux, keep advection out of Eulerian history to avoid double counting, and compute coordinate-invariant simplex streamline lengths in internal P0 fields. Support generic transient and CitcomS-compatible steady tau models for isotropic diffusion plus explicit user tau expressions. Add Level 1 residual and limiting-case tests and Level 2 manufactured, high-Peclet, and spherical MPI validation.
Preserve live UWexpression atoms when deep-copying Symbolic_DDt history state while copying the mutable history containers and timestep metadata. Generic deepcopy reconstructed parameter symbols without their wrapped values, causing Jacobian rebuilds after model restore to fail in existing Diffusion and the new SUPG solver. Add a base diffusion regression, SUPG BDF2 discarded-step equivalence, bounded repeated-solve state checks, and measured BDF1/BDF2 temporal convergence.
Extend AdvDiffusionSUPG with a continuous-P1 CitcomS-compatible time integrator using geometric row-sum lumped mass, initialized temperature-rate state, gamma=0.5 prediction, and two fixed residual corrections. Assemble the SUPG residual through the existing scalar SNES callback, retain constrained boundary handling, snapshot integrator startup metadata, and compute conservative advection and discrete M_L^-1 K diffusion timestep limits. Add constant-residual mass verification, exact source startup, second-order scalar decay, snapshot restart, and serial/MPI spherical tests.
Allow callers to supply a named continuous-P1 temperature-rate field for production checkpoint workflows and expose it through the solver. Rebuild cached lumped mass after mesh changes and cache the discrete diffusion timestep limit by mesh version and diffusivity so coupled convection steps only recompute the velocity-dependent advective limit.
Allocate the global temperature, residual, correction-rate, and stored-rate PETSc vectors once per mesh version instead of recreating and destroying them for every corrector sweep. Preserve the one-off residual helper ownership contract for diagnostics and tests.

Add a focused regression that advances two timesteps and verifies the PETSc workspace handles are reused. Serial SUPG tests pass (14 tests), the focused spherical and snapshot tests pass on eight MPI ranks, and the isolated 120-step MPI memory diagnostic has no persistent object or RSS growth.
Base the zero-diffusivity path and diffusion-cache return on communicator-wide decisions so ranks with empty or locally zero-diffusivity partitions cannot skip collectives reached by their peers. Handle empty local diffusivity arrays without a local maximum.

Add a rank-varying diffusivity regression that would deadlock before this fix. The collective guard scan passes, the regression passes in serial and on eight MPI ranks, and focused SUPG tests remain green.
Add a runnable SUPG guide covering the implicit BDF and CitcomS-compatible predictor-corrector paths, stabilization controls, restart state, and method-selection tradeoffs. Cross-link the guide from the advanced documentation and SLCN time-integration page, and update the solver docstring to describe both supported integrators.
Distinguish residual consistency, nodal boundedness, and integral conservation for SUPG and semi-Lagrangian transport, and state the partition-independent diagnostics required for serial/MPI comparison.
Advect a Gaussian through one rigid-body revolution in an annulus with the public CitcomS-compatible predictor-corrector and estimate_dt(). Verify that the finite-element L2 return error decreases under mesh refinement, covering curved streamlines and the zero-diffusivity timestep path.
Write an owned global-vector payload alongside existing DMPlex checkpoint metadata so same-layout restarts do not depend on invalid local point-number assumptions after parallel redistribution. Reload that payload through the live global layout, scatter it to local and ghost dofs, and invalidate the mesh-wide packed auxiliary vector before the next residual assembly.

Reject snapshot restarts on a different MPI rank count, retain the legacy DMPlex local-vector fallback for older files, and document the distinction from coordinate-remapped timestep reads.

Strengthen snapshot regressions to verify coordinate-defined pointwise field values under four MPI ranks, scalar and P2 vector payload presence, post-reload coefficient use in a solve, and rank-count validation. Focused serial tests, the four-rank snapshot test, all three eight-rank Zhong transport replay gates, and the full Level 1 suite pass.
Add disabled-by-default internal counters for global_evaluate's best-claim fallback. When explicitly enabled by a diagnostic, report call counts, local and globally replicated extrapolated points, cumulative temporary-array bytes per rank, and per-call peaks without changing the public evaluator API or production behavior.

Add a two-rank regression with exterior query points that verifies finite results and exact point/replica accounting. The focused MPI test and seven evaluator tests pass; the Level 1 suite passes with the known divergent-rank fixture excluded (1659 passed, 35 skipped, 2 xfailed).
Include located-but-nonfinite interpolation results in global_evaluate's parallel best-claim fallback. This prevents finite SLCN midpoint coordinates from receiving NaN velocities when a rank-local interpolation reports a false located status.\n\nAdd a focused regression for fallback index selection. Validate with the two-rank migration suite and an eight-rank Zhong A1 cellsize=1/16 step that previously diverged with DIVERGED_FNORM_NAN.
Cache the scalar solver field decomposition on first volume-reaction recovery instead of creating a new PETSc IS and sub-DM for every boundary-flux call. The existing solver reset lifecycle now owns and destroys these objects.

Add a focused regression proving repeated upper/lower boundary recovery retains the same decomposition objects. Validate the complete serial boundary-flux suite and the two-rank parallel recovery suite.
Cache local simplex connectivity, basis gradients, and volumes by mesh version so automatic stabilization and timestep estimation do not rebuild large geometry arrays every step. Mesh deformation or adaptation invalidates the cache through the existing mesh-version lifecycle.

Add a focused identity regression for repeated automatic operations. The change removes repeated allocator high-water growth from the coupled A1 timestep-estimation stage without changing the CFL or diffusion limits.
Extend the simplex-geometry reuse regression through a public mesh deformation. Confirm the cache retains array identities on an unchanged mesh and rebuilds connectivity-derived arrays and volumes after the mesh version advances.
Replace the per-call cells-by-basis temporary used by automatic SUPG stabilization and timestep estimation with two mesh-versioned one-dimensional work arrays. Compute each basis-direction contribution in place and accumulate it without changing the streamline-rate formula.

Validate numerical equivalence against the vectorized expression, workspace reuse, deformation invalidation, and a five-step eight-rank A1 run with unchanged diagnostics. This targets the remaining native allocator growth observed in the Gadi timestep-estimation stage.
Scatter a scalar solver's sole local unknown directly through its solver DM when assembling volume reactions. This removes the need to construct or retain PETSc field-decomposition IS and sub-DM objects for boundary heat-flux recovery.

Update the lifecycle regression to require an empty scalar decomposition cache. Validate all 15 serial boundary-flux tests, the two-rank parallel suite, and a five-step eight-rank A1 run with unchanged thermal diagnostics and a 4.28 MiB aggregate repeat-diagnostic delta.
Expose SolverBaseClass.boundary_flux_integral() for integral diagnostics such as Nusselt numbers. The implementation sums consistent scalar nodal reactions collectively, using the boundary basis partition of unity, and avoids pointwise mass recovery, a temporary MeshVariable, and a second boundary quadrature.

Add serial and MPI regressions against the analytic manufactured heat flux, the established recovered-field result, and the serial direct-integral reference. Validate partitions that cut the measured boundary on two and four ranks.
@lmoresi

lmoresi commented Aug 27, 2026

Copy link
Copy Markdown
Member

CI test is failing on this branch. Here is what I could establish, including what I could not reproduce.

The failure

tests/test_1120_SLVectorCartesian.py::test_SLVec_boxmesh[mesh1]
>   assert np.allclose(vx_to_eval, anax_to_eval, atol=0.01)
E   assert False

Not a magnitude error. The two arrays shown are ~3e-18 and ~1e-5, both far inside atol=0.01, so allclose returning False points at a non-finite element somewhere in the middle of the array rather than a numerical discrepancy. Worth checking with np.isfinite(vec_prof_uw).all() before chasing tolerances — and note vec_prof_uw comes straight from uw.function.evaluate, which is #604/#641 territory.

What rules out the easy explanations

  • The branch is current. 0 commits behind development, 23 ahead. Not a stale base.
  • development CI is green on its recent runs, so the test is not broken upstream.
  • I cannot reproduce it locally. test_1120 passes 3/3 on macOS/arm64 both on this branch and on a branch without it. So it is Linux/CI-specific, and I could not bisect it.

The one thing in the diff I would question

Beyond the harmonic projection, this branch also changes SolverBaseClass:

-  _names, _iss, _subdms = self.dm.createFieldDecomposition()
-  sgvec = gvec.getSubVector(_iss[0])
-  _subdms[0].localToGlobal(self.Unknowns.u.vec, sgvec)
-  gvec.restoreSubVector(_iss[0], sgvec)
+  self.dm.localToGlobal(self.Unknowns.u.vec, gvec)

Three reasons this is worth a second look:

  1. It is in the else branch — the single-field path — which is exactly what SNES_Vector takes, and test_1120 is a semi-Lagrangian vector test. The changed code is on the failing test's path.
  2. self.Unknowns.u.vec is the variable's local vector. The old code mapped it through field 0's subDM; the new code assumes the solver DM's local layout matches the variable's. Where they coincide it is equivalent, and where they do not it writes to the wrong slots — which would produce exactly the ~1e-18 (i.e. never-written) values in the failure.
  3. It is not mentioned in the PR description, which describes the harmonic projection. It reads as an opportunistic simplification carried along from the benchmark branch.

Cheapest decisive experiment: revert that hunk alone and re-run CI. If it goes green, that is the answer; if not, it is eliminated and the NaN hypothesis above is next.

A process note, offered rather than insisted on

23 commits reach development under a title describing one feature, and they include shared-solver changes. Extracting the unrelated SolverBaseClass change into its own PR would make both reviewable and would let the harmonic projection — which I think is the right construction, and which I have argued elsewhere should replace the ConvexHull path (#647) — land on its own merits rather than waiting on an unrelated regression.

Happy to push the revert-and-retest to this branch if you would like, but I did not want to touch someone else's branch uninvited.

Underworld development team with AI support from Claude Code

CI was red on tests/test_1120_SLVectorCartesian.py::test_SLVec_boxmesh[mesh1],
a semi-Lagrangian VECTOR test — i.e. exactly the single-field path this branch
simplified:

    -  _names, _iss, _subdms = self.dm.createFieldDecomposition()
    -  sgvec = gvec.getSubVector(_iss[0])
    -  _subdms[0].localToGlobal(self.Unknowns.u.vec, sgvec)
    -  gvec.restoreSubVector(_iss[0], sgvec)
    +  self.dm.localToGlobal(self.Unknowns.u.vec, gvec)

`self.Unknowns.u.vec` is the VARIABLE's local vector. The old code mapped it
through field 0's subDM; the direct call assumes the solver DM's local layout
matches the variable's. On a plain single-field solver the two coincide, which
is why the simplification looked equivalent — where they differ it writes to
the wrong slots and the field comes back never-written. The failure is
consistent with that: recovered values ~1e-18 against an analytic ~1e-5, and
both arrays sit far inside the assertion's atol=0.01, so `allclose` returned
False on a non-finite entry rather than on a magnitude error.

This restores the original mapping and leaves everything else on the branch
untouched. The harmonic projection this PR is actually for is unaffected.

NOT independently confirmed as the cause: the failure does not reproduce on
macOS/arm64 (test_1120 passes 3/3 there both with and without the change), so
this is the cheapest decisive experiment rather than a verified fix. If CI is
still red after it, the next candidate is a NaN out of `uw.function.evaluate`
feeding `vec_prof_uw` (see underworldcode#604, underworldcode#641).

Verified before pushing: test_1120 3 passed; test_1070 (this PR's own geoid
suite) 13 passed, so the revert does not undo what the branch is for; and
`level_1 and tier_a` 1093 passed, 0 failed.

Underworld development team with AI support from Claude Code
@lmoresi

lmoresi commented Aug 27, 2026

Copy link
Copy Markdown
Member

Pushed fb268de3 to this branch (with @lmoresi's go-ahead) restoring the field-decomposition path, as the cheapest decisive experiment on the red CI.

Only that one hunk is touched. Everything else on the branch — including the harmonic projection this PR is actually for — is untouched.

Why this hunk. The failing test is test_1120_SLVectorCartesian.py::test_SLVec_boxmesh[mesh1], a semi-Lagrangian vector test, which takes the single-field else branch that was simplified. self.Unknowns.u.vec is the variable's local vector: the original mapped it through field 0's subDM, the direct call assumes the solver DM's local layout matches it. Those coincide on a plain single-field solver, which is why the simplification looks equivalent — where they differ it writes to the wrong slots and the field comes back never-written. That matches the failure, where recovered values were ~1e-18 against an analytic ~1e-5.

Stated plainly: this is not a confirmed fix. The failure does not reproduce on macOS/arm64 — test_1120 passes 3/3 there both with and without the change — so I could not bisect it locally. If CI is still red, this hunk is eliminated and the next candidate is a NaN coming out of uw.function.evaluate into vec_prof_uw: the two compared arrays both sit far inside atol=0.01, so allclose returning False implies a non-finite entry rather than a numerical discrepancy (#604, #641).

Checked before pushing, since it is your branch:

  • test_1120: 3 passed
  • test_1070_postprocessing_geoid (this PR's own suite): 13 passed — the revert does not undo what the branch is for
  • level_1 and tier_a: 1093 passed, 0 failed

Revert it freely if the simplification was deliberate and load-bearing for something downstream — in which case the right move is probably to split it into its own PR with a test that pins whatever it fixes, so it does not ride along with the projection work.

Underworld development team with AI support from Claude Code

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