Skip to content

Fix momenta ghost faces and planar surface-layer arrays on z-split BoxArrays (terrain-fitted meshes) - #3970

Open
hgopalan wants to merge 10 commits into
erf-model:developmentfrom
hgopalan:box-split-fix
Open

Fix momenta ghost faces and planar surface-layer arrays on z-split BoxArrays (terrain-fitted meshes)#3970
hgopalan wants to merge 10 commits into
erf-model:developmentfrom
hgopalan:box-split-fix

Conversation

@hgopalan

@hgopalan hgopalan commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Summary

On a terrain-fitted mesh whose BoxArray is split in z (more cells in z than amr.max_grid_size, so a column is more than one box) five things went wrong. The first two made the run blow up. The other three made a run that did complete depend on where the columns were split.

  1. Projection, before the solve. VelocityToMomentum and ConvertForProjection write the valid momentum faces only. project_momenta then converts rho0 w to Omega with OmegaFromW, which averages rho0 u and rho0 v over the faces below and above each w-face. At the lowest and highest w-face of a box it therefore reads one face in the z-ghost layer. Where that box face lies inside the domain, the ghost face held bogus_large_value (1e150) from allocation in the initial projection, and the divergence handed to the solver was of order 1e139. The domain boundaries never showed it, for other reasons: the w-face k = 0 is set to zero and nothing below it is read, and the top ghost face holds the momentum VelocityToMomentum extrapolates there. Fix: project_momenta fills the z-ghost layer of x- and y-momentum after the rho0 conversion, just before OmegaFromW, for every caller. In the time step apply_bcs has already filled those faces, so this fill changes no value there.

  2. Surface layer and MOST averages. The planar 2D MultiFabs are built on the z-collapse of the 3D BoxArray, one 2D box per 3D box. They are u_star, w_star when computed, t_star, q_star, olen, t_surf, q_surf, pblh, the diagnostic source, and the region averages in MOSTAverage.

    • The defect: a z-split gives duplicate 2D boxes with overlapping valid regions, and only the copy belonging to the surface box is ever computed. FillBoundary on such a BoxArray is ill-defined: it fills the surface copy's ghost cells from the uncomputed duplicate. That puts 1e150 into the surface stress at box-boundary faces, then -inf momenta, then 0 * inf in WFromOmega, and an invalid-operation trap in ERFPhysBCFunct_w during the first advance.
    • The fix: new PlanarBoundary (Source/Utils/ERF_PlanarBoundary.{H,cpp}) records the surface copies once per level. It gathers them into a cached buffer without duplicates, one per index type and component count, and fills every copy from that with ParallelCopy, valid region and ghost cells alike. Unlike FillBoundary it also overwrites the valid region of the uncomputed copies, so any copy can be read afterwards. Plain FillBoundary is kept when there are no duplicates, and with EB terrain (3D fields).
  3. Column rebalance (input_sounding). rebalance_columns integrated every box of the level from its own lowest cell. A box stacked on another box therefore started from its lowest cell's density as interpolated from the sounding, not from the rebalanced column below. dens_hse and the density first differed at the split (k = 16) by 2.1e-7, and pres_hse by 0.025 Pa.

    • The fix: the boxes are integrated in bands of equal lowest index, bottom up. Each column's state (pressure, theta, qv, qt, cell-centre height) is carried across the split in a MultiFab whose z ghosts are filled between bands.
    • Unchanged cases: a column with nothing of the level below it (the domain bottom, a refined patch aloft) starts from its own lowest cell as before, and with a single band nothing is kept or filled. The use_sfc assertion now accepts a split column whose cells below are covered.
  4. Projection, after the solve. WFromOmega reads the same z-ghost faces as OmegaFromW, but the fluxes are added to the valid faces only, so those ghost faces still held the momentum from before the projection. GMRES residual histories were identical between layouts; only w at the split faces differed, by 0.0093 m/s after the initial projection. Fix: fill the z-ghost layer of x- and y-momentum again before WFromOmega.

  5. Fine-level hydrostatic initialisation over terrain. erf_init_dens_hse_dry (terrain path) and erf_enforce_hse integrated every box of a level from the ghost cell below it.

    • Why level 0 never showed it: initHSE copies a z-split base state onto full-height columns first.
    • On a refined level: a box stacked on another fine box started from the density and pressure interpolated from the coarse level, not from the column below. Measured on the 32 x 32 x 40 hill with Isentropic init and level 1 over the centre (fine level split at k = 16 and 32, against full columns), at step 0: dens_hse differed from k = 16 up by 3.9e-5, and pres_hse by 4.7 Pa.
    • The fix: both loops visit the boxes in bands of equal lowest z index, bottom up. Before each band, new fill_below_band (Source/Utils/ERF_ColumnBands.{H,cpp}) fills the cells just below it, widened laterally by one cell, from the bands already done. It copies the lateral ghost cells of the boxes below first, then their valid cells, which take precedence. The lateral ghost cells matter because FillPatchTwoLevels adds the fine base state into the coarse-fine ghost cells.
    • Result: the split fine level is bitwise identical to full columns at step 0, and within 1e-15 (density) and 2e-13 (theta) after 5 steps. Level 0, and every layout with a single band, are bitwise unchanged.

Items 1 and 2 are silent under amrex.init_snan = 1 with the invalid-operation trap armed, because the leaked value is the deterministic placeholder, not a NaN. PlanarBoundary aborts with a message if the surface boxes overlap in the plane, or if a surface box does not match its planar box.

Numbers

Mesh that exposed items 1 and 2: a periodic 64 x 64 x 40 flat fitted mesh (dx = dy = 40 m, dz = 20 m), uniform 10 m/s wind, anelastic. With amr.max_grid_size = 32, each column is boxes k = 0..23 and k = 24..39.

quantity development this PR
momenta ghost face at the split (k = 24), rho0 u 1e150 (placeholder) 11.16 (neighbour's value)
Omega at the split face, (i, j) = (34, 34) 3.58e140 order 1e-9
cells with divergence above 1e50 8 178 of 163 840 (the two layers k = 23, 24) 0
max / L2 divergence before the first solve 1.79e139 / 2.53e140 7.28e-10 / 7.34e-9
max / L2 divergence after the solve 1.53e130 / 1.38e131 7.28e-10 / 7.34e-9
velocities in the plotfile after the solve 1e136 to 1e140 everywhere 10 m/s, w below 1e-8
u_star ghost cells at box-boundary columns after update_fluxes 1e150 computed values
x-momentum at surface faces i = 0, 32, 64 after the first RK stage -inf on about half the columns finite
first advance under amrex.fpe_trap_invalid = 1 SIGILL in ERFPhysBCFunct_w 40 steps clean, 1 and 2 ranks agree

Items 3 and 4 on ABL_MOST_WOA_ZSplit (below). The reference is the same deck on one box on one rank (amr.max_grid_size = 64). The table gives the max-norm difference after 10 steps; development cannot run the split layouts at all.

layout items 1 and 2 only this PR
2 boxes on 2 ranks, split in x or y only u 5.5e-13, theta 2.8e-13 u 5.5e-13, theta 2.8e-13
2 boxes on 2 ranks, split in z u 0.0039, theta 0.088 K u 4.8e-13, theta 2.8e-13
12 boxes on 1 rank (4 columns of 3) u 0.0091, theta 0.18 K u 4.4e-13, theta 2.8e-13
12 boxes on 2 ranks u 0.0106, theta 0.18 K u 6.6e-13, theta 2.8e-13, density identical

Single-box runs are bitwise unchanged by all five items. So is the 64 x 64 x 20 single-box-per-column mesh above: 40 steps, all 12 plotfile variables at 0 difference against development.

Tests

ABL_MOST_WOA_ZSplit (regression, FFT builds only).

  • Setup: anelastic neutral flow over a radial Witch of Agnesi hill (h = 100 m, L = 300 m) on a 32 x 32 x 40 fitted mesh (dx = dy = 80 m, dz = 20 m). MOST surface layer (z0 = 0.1 m, zref = 10 m), Smagorinsky, dt = 1.5 s, 10 steps on 2 ranks.
  • Layout: amr.max_grid_size = 16 gives 4 columns of 3 boxes, split in z at k = 16 and 32. The surface fill therefore crosses box boundaries in the plane, and rank boundaries on 2 ranks.
  • Cost: about 1.3 s under CTest; the gold plotfile is 2.7 MB.
  • Gold: generated on this branch, since the case cannot run on development. There it trips the invalid-operation trap in the first advance. With the trap disabled, the first step's projection sees a NaN divergence and the step aborts on a negative theta (RhoTheta is negative ... -inf).
  • Registration: the anelastic projection on a terrain-fitted mesh is the FFT-preconditioned GMRES, and no CI job builds with ERF_ENABLE_FFT, so this test and its box-parity test are registered only under if(ERF_ENABLE_FFT). They run in FFT builds locally; the first push of this PR had them unguarded, and every CI platform that runs ctest failed them with Rebuild with USE_FFT = TRUE.

ABL_MOST_WOA_ZSplit_NoSub (regression, every build). The same hill, surface layer, sounding and 4 x 3 split, run compressible with erf.substepping_type = None and fixed_dt = 0.02 (the acoustic limit). It covers items 2 and 3; the acoustic substep stays off because it has its own per-box column assumption (Dirichlet rows at every box's lowest and highest face), which is not addressed here. Over its 10 steps u moves by 0.76 m/s and w by 1.9 m/s, so the comparison is on a live field. On development and on the first commit of this PR the split run aborts at start-up with Base state is too far out of HSE, with the extrema at the split faces k = 16 and 32 (item 3), while one box runs; with this branch the split agrees with one box to 1e-14. Gold from this branch, 2.7 MB. On Windows (no MPI) it runs on one rank as add_test_r tests do.

ABL_MOST_WOA_ZSplit_BoxParity and _NoSub_BoxParity (regression, box-parity label). New add_test_box_parity / Tests/RunBoxParity.cmake. Each runs its deck on one box on one rank and on its 12 boxes on 2 ranks, then compares the plotfiles at a relative tolerance of 1e-9, and fails unless the split run has more level-0 boxes than the reference, so a deck change cannot make the comparison trivial. They take about 2 s each. Without items 3 and 4 the anelastic one fails with the numbers above; the _NoSub one fails on the first commit of this PR because its split run aborts. The runner is a cmake -P script, so both are registered under if(ERF_ENABLE_MPI AND NOT WIN32); it checks its arguments for a value rather than DEFINED (-DX= defines X as empty). Checked by configuring with MPI off (only _NoSub is registered) and with MPI on but FFT off (_NoSub and _NoSub_BoxParity), and by running the script with the split option removed, an empty TEST_EXE, a missing numproc flag and the old binary, each of which fails with its own message.

PlanarBoundaryParallel (parallel gtest). It checks that:

  • the surface copies are the lowest box of each column;
  • on a z-split BoxArray, every copy of a planar field holds the surface copy's value after the fill, in both valid region and ghost cells. This covers cell- and face-centered fields with 1 and 3 components, across periodic boundaries, and again after a refill with new values;
  • a BoxArray without a split fills like FillBoundary;
  • a level with no box at the surface is left as it is.

It passes on 1 and 2 ranks, and fails when fill is replaced by a plain FillBoundary: the 1e30 placeholder is left in the uncomputed copies.

RebalanceColumnsParallel (parallel gtest). (rebalance_columns shares column_bands() with item 5; results are bitwise unchanged.) Layouts of 4 columns, 3 boxes per column, uneven boxes and staggered footprints all match one box. This holds with maintain_Th true and false, with and without use_sfc, and for a patch aloft. On the old code the split cases fail and the use_sfc cases abort.

ColumnBandsParallel (parallel gtest). It covers:

  • the fill precedence of fill_below_band;
  • erf_init_dens_hse_dry on a full periodic domain, a refined patch and a patch aloft, in five split layouts, compared exactly with one box on valid and lateral ghost cells.

Without the fill all four tests fail. With only the valid-cell copy (what a FillBoundary would give), the two patch tests fail.

Full runs (Release, MPI, FFT on, macOS arm64, on the branch merged with development at dab7826):

  • all 80 tests of the regression label pass on 2 ranks, including the four ZSplit tests, BTF fine level: make the terrain-fitted mesh independent of the z split #3976's Terrain2Lev_BTF_ZSplit, and the existing MOST and terrain cases (ABL_MOST, ABL_MOST_IMP_DIFF_WOA, MovingTerrain_nosub, and the two-level Terrain2Lev_STF_interp / _transform, which have one band per level) against their unchanged gold files;
  • all 32 parallel gtest cases pass;
  • all 471 serial unit tests pass.

Not run locally: Debug builds, single precision, GPU backends and Windows; CI covers those, and the first CI round showed the FFT and Windows-glob failures described above, both now fixed by the registration guards and the _NoSub variant.

Item 5 changes no level-0 result: the ABL_MOST_WOA_ZSplit layouts are bit-identical with and without it.

Notes

Found while running a 3D hill deck at dz = dx/2 in the ERF-Hazard RANS work, where the failure was first attributed to the aspect ratio. The same class of bug is worth keeping in mind for any new per-column operation or planar field: per-column data or integrations indexed by the 3D MFIter. The new box-parity test is a cheap way to catch the next one.

Same class, not changed here. The remaining per-box column integrations on development fail loudly rather than silently: init_base_state_from_wrfinput, the NetCDF initialisation and the column diagnostics in ERF_Derive assert that boxes reach the bottom of the domain. Two are silent: the compressible acoustic substep (MakeFastCoeffs and the w solve set Dirichlet rows at every box's lowest and highest face) and the implicit vertical diffusion (vert_implicit_fac, box-local klo/khi), which is why the _NoSub deck runs without substepping and every ZSplit test passes erf.vert_implicit=false.

Not changed here:

  • ConvertForProjection also skips the momentum faces in the ghost layer above the domain top. So with erf.fixed_density = 0, the extrapolated top ghost face keeps rho u while the valid faces hold rho0 u. That is on development already, independent of the box split.
  • With terrain_smoothing = 0 (BTF), the fine-level mesh itself still depends on the z split: z_phys differs by 0.54 m above k = 16. That is being looked at separately.

🤖 Generated with Claude Code

…ZSplit

On a terrain-fitted mesh whose BoxArray is split in z (more cells in z
than amr.max_grid_size) two things went wrong, independently of the
closure, the boundaries, the terrain source, stretching or the solver:

1. project_initial_velocity converted velocity to momentum on the valid
   faces only, and project_momenta's OmegaFromW averages (rho0 u, rho0 v)
   across each w-face, reading one z-ghost face at the top and bottom face
   of every box; at a box face inside the domain that ghost still held the
   bogus_large_value placeholder, so the divergence handed to the solver
   was of order 1e139 (the domain top reads the same placeholder times an
   exactly zero slope, which is why single-box columns never showed it).
   Fill the momenta ghost faces after the conversion.

2. The planar surface-layer arrays (u*, t*, q*, L, t_surf, q_surf, pblh,
   diagnostic source) and the MOST region averages hold one 2D box per 3D
   box, so the split gave duplicate 2D boxes of which only the surface
   copy is computed, and FillBoundary filled the surface copy's ghost
   cells from the uncomputed duplicate: 1e150 in the surface stress at
   box-boundary faces, -inf momenta, then 0 * inf in WFromOmega and an
   invalid-operation trap in ERFPhysBCFunct_w during the first advance.
   New FillPlanarBoundary (Source/Utils/ERF_PlanarBoundary) gathers the
   surface copies on a BoxArray without duplicates and fills every copy
   from that; plain FillBoundary is kept when there are no duplicates or
   with EB terrain.

Both are silent under amrex.init_snan with the trap armed because the
leaked value is the deterministic placeholder. Release-active asserts now
abort if a placeholder would be used at either site.

ABL_MOST_WOA_ZSplit: anelastic neutral flow over a radial Witch of Agnesi
hill on a 32 x 32 x 40 fitted mesh with amr.max_grid_size = 32, MOST and
Smagorinsky, 10 steps. On development it traps in the first advance; the
gold plotfile is from this branch. Existing MOST and terrain regression
tests are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@hgopalan

hgopalan commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

@asalmgren @AMLattanzi I do not understand the bug per-se. I was trying to clean-up the one equation RANS model which Eliot put added and this bug came along the way.

@AMLattanzi
AMLattanzi self-requested a review September 10, 2026 17:28
@AMLattanzi

Copy link
Copy Markdown
Collaborator

Review: ERF PR #3970

Substantive

1. The FillBoundary is on the wrong side of ConvertForProjection

The momenta ghosts are filled in project_initial_velocity immediately after VelocityToMomentum, i.e. while they hold ρu. project_momenta then runs ConvertForProjection when !fixed_density, and that operates on nodaltilebox() — valid faces only. The ghost faces therefore keep ρu while the valid faces become ρ₀u, and OmegaFromW at the split face mixes the two scalings.

The result is finite, so the new assert will not catch it, and it is invisible in the added test because anelastic forces fixed_density = 1. It bites with erf.fixed_density = 0, or with anelastic = 0 + project_initial_velocity = 1, where the default is 0.

Suggested change: move the three FillBoundary calls into project_momenta, just ahead of the MeshType::VariableDz block. That covers every caller instead of one, and makes the assert trivially satisfied rather than a separate contract on callers.

2. The new assert is release-active on the per-step path

project_momenta is called every step from ERF_TI_no_substep_fun.H:323, with apply_bcs immediately before it, so the ghosts are always filled there and the assert can never fire. But every VariableDz anelastic run now pays up to four device kernel launches per box plus a global ReduceRealMax per step, permanently.

Suggested change: make it AMREX_ASSERT, gate it on solverChoice.verbose, or fuse the two sides and two slabs into a single reduce.

Additional: the loop reads kg = vbx.smallEnd(2)-1 unconditionally. Add a guard on mom_mf[IntVars::xmom].nGrowVect()[2] >= 1 so a zero-ghost caller does not walk off the fab.

3. The stated reason the domain top is safe is not the real one

VelocityToMomentum already extrapolates momx/momy into the k = domhi+1 slab, with a comment saying it is specifically for OmegaFromW. The top ghost therefore holds extrapolated momentum, not 1e150 × zero slope. Both the 2nd- and 4th-order OmegaFromW use one-sided extrapolation at k == 0, so the bottom ghost is never read.

That is why skipping kg outside the domain in the assert is correct — the comment should say so, since as written it implies the domain-boundary ghosts are unfilled and merely harmless. The PR body and commit message need the same correction; otherwise someone will build on the zero-slope claim.


Worth a second look

FillPlanarBoundary allocates and communicates on every call. A fresh MultiFab plus a ParallelCopy runs roughly 8–10 times per step per level once anything is z-split (five in update_fluxes, ~three in compute_region_averages, plus t_surf/q_surf/pblh). ParallelCopy is considerably heavier than the FillBoundary it replaces. Caching the gather buffer next to m_ba_sfc/m_dm_sfc costs one member and removes the per-call allocation.

Related: the header comment should state outright that this writes the destination's valid region as well. That differs from FillBoundary semantics and is load-bearing for the duplicate copies.

nsfc == 0 returns silently. A fine level whose grids do not reach klo gets no fill at all. Harmless today because those averages are uncomputed either way, but ERF_MOSTAverage already carries special handling for isolated fine patches, so a comment (or warning) marking this case as deliberate would keep the next reader from treating it as a bug.

Index-type conversion for the nodal averages. MOSTAverage::fill_planar_boundary converts m_ba_sfc to mf.ixType(), which is right, but the converted surface boxes then overlap at shared x- (or y-) faces, so which source wins at a shared face is undefined. Same ambiguity the old FillBoundary had and the two copies should agree, so not a regression — but it is the kind of thing that resurfaces later as a rank-count-dependent last-digit difference. It also quietly assumes the xvel/yvel BoxArrays are index-order-identical to the cons BoxArray; true today, and the box-equality assert catches it if that ever changes.

w_star is the one planar field left out. It lives on ba_flux, is initialized to bogus_large_value, and is never filled. It has no FillBoundary today so nothing changes, but it is the same latent trap. A line in the header comment naming which fields are covered, and why w_star is not, would help.


Minor

  • Both new declarations are wedged between an existing doxygen block and the function it documents. In ERF_MOSTAverage.H the @param ng_fill block now attaches to fill_planar_boundary instead of extrap_ghost_cells; in ERF_SurfaceLayer.H the impose_SurfaceLayer_bcs block does the same. ERF_MOSTAverage.H also ends up with a stray // comment alongside the stolen block.
  • sfc[mfi].template copy<RunOn::Device>(...) — the template disambiguator is not needed in a non-dependent context.
  • MakeSurfaceBoxes tests smallEnd(2) == klo with klo = Domain().smallEnd(2) but then does setRange(2,0). These agree only because ba_flux also collapses to 0. Either pass 0 or collapse to klo, so the two are not silently coupled through a shared assumption that smallEnd(2) == 0.
  • The test input comment says boxes split at k = 32. maxSize bisects 40 into 20 + 20, so the split is at k = 20.
  • 32×32×40 with max_grid_size = 32 gives exactly two boxes, so on two ranks the test covers only one surface box per rank. max_grid_size = 16 would give four columns of two boxes and exercise the ParallelCopy alongside a real in-plane fill, still in well under a second.
  • The mechanism is generic enough to deserve a direct test in Tests/Unit — duplicate detection, ncomp > 1, two ranks — rather than being covered only through a full solver run.
  • The self-generated gold file is fine given the case cannot run on development, and the author flags it. The convincing evidence is the divergence table plus the bit-identical single-box comparison, not the gold itself.

…ed planar fill, unit test)

Momenta ghost faces. The FillBoundary in project_initial_velocity ran
before project_momenta's ConvertForProjection, which rescales (rho u)
to (rho0 u) on the valid faces only, so with erf.fixed_density = 0 the
ghost faces OmegaFromW reads at a split kept (rho u) while the valid
faces held (rho0 u). The fill now sits in project_momenta, after the
conversion and just before OmegaFromW, for every caller, and covers
only what OmegaFromW reads: one z-ghost layer of x- and y-momentum.
The per-step release-active reduce that checked those faces is gone.
The comment now gives the real reasons the domain boundaries are safe:
the w-face k = 0 is set to zero and nothing below it is read, and the
top ghost face holds the momentum VelocityToMomentum extrapolates there
(an exactly zero metric slope was not the reason).

Planar fill. MakeSurfaceBoxes/FillPlanarBoundary become a PlanarBoundary
class that keeps a gather buffer per index type and component count,
so a fill no longer allocates a MultiFab each call. It takes the planar
BoxArray itself, so the surface copies are the planar boxes as built
rather than a second collapse to k = 0. The header states that the fill
also overwrites the valid region of the uncomputed copies, how shared
faces of face-centered averages are handled, and that a level with no
box at the surface is left as it is. w_star is now filled with the
other surface-layer fields when it is computed (erf.most.include_wstar).
The doxygen blocks the new declarations had displaced are back on
their functions.

Tests. ABL_MOST_WOA_ZSplit runs with amr.max_grid_size = 16 (4 columns
of 3 boxes, split in z at k = 16 and 32; the old comment's k = 32 split
was wrong, it was 20 + 20), so the surface fill crosses box and rank
boundaries in the plane; gold regenerated. New parallel gtest
PlanarBoundaryParallel covers the surface-copy selection, cell- and
face-centered fields with 1 and 3 components, a refill with new values,
a BoxArray without a split, and a level above the surface.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
hgopalan and others added 2 commits September 10, 2026 14:06
rebalance_columns integrated every box of the level from its own lowest
cell. A box stacked on another box of the level (a BoxArray split in z,
e.g. amr.max_grid_size below n_cell z) therefore started from its lowest
cell's density as interpolated from the sounding, not from the rebalanced
column below, and the hydrostatic base state depended on the split.

On ABL_MOST_WOA_ZSplit (32x32x40 hill, input_sounding) at step 0, one box
against 12 boxes (split at k = 16 and 32): dens_hse and density first
differ at k = 16 by 2.1e-7 and again at k = 32, pres_hse by 0.025 Pa;
every cell below k = 16 is bitwise identical. With this change both are
bitwise identical in every layout, and a single-box run is unchanged.

The boxes are now integrated in bands of equal lowest index, bottom up.
The state each column reaches in a cell (pressure, theta, qv, qt and the
cell-centre height) is kept in a MultiFab whose z ghost cells are filled
from the bands already done, and a box continues from the cell below it
where that cell belongs to the level. A column with nothing of the level
below it (the domain bottom, a refined patch aloft) starts from its own
lowest cell as before. With a single band nothing is kept or filled. The
use_sfc assertion now accepts a split column whose cells below are
covered.

New parallel gtest RebalanceColumnsParallel: 4-column, 3-box, uneven and
staggered-footprint layouts match one box (maintain_Th true and false,
with and without use_sfc), and so does a patch aloft. On the old code the
split cases fail and the use_sfc cases abort.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
After the terrain Poisson solve, project_momenta adds the fluxes to the
valid faces of (rho0 u, rho0 v, Omega) and converts Omega back to rho0 w
with WFromOmega, which averages (rho0 u) and (rho0 v) over the faces below
and above each w-face. At the lowest and highest w-face of a box inside
the domain it reads a z-ghost face, and that face still held the momentum
from before the projection (filled for OmegaFromW). With a BoxArray split
in z, w at every split face came out wrong.

ABL_MOST_WOA_ZSplit, one box against 12 boxes on one rank: after the
initial projection w differed by 0.0093 m/s at the faces k = 16 and 32
only, with identical GMRES residual histories; after 10 steps u differed
by 0.0091 m/s and theta by 0.18 K. With this fill and the previous
commit, every split layout (12 boxes on 1 and 2 ranks, 2 boxes on 2 ranks)
agrees with one box to round-off (u 6.6e-13, theta 2.8e-13), as the
x/y-only split already did. The fill alone leaves u 9.7e-7 and density
2.1e-7 from the base state. Single-box runs are bitwise unchanged.

The ABL_MOST_WOA_ZSplit gold held the split-dependent result (u 0.011 and
theta 0.18 K from the new one) and is regenerated. A new box-parity CTest,
ABL_MOST_WOA_ZSplit_BoxParity (add_test_box_parity, RunBoxParity.cmake),
runs the deck on one box against its 12 boxes and compares the plotfiles
at a relative tolerance of 1e-9; the unfixed binary fails it with the
numbers above.

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

Copy link
Copy Markdown
Contributor Author

Thanks, @AMLattanzi, this was a useful review. Everything is addressed in 3202eaa. Two further commits, 6d5a6bd and de4ecf3, fix a split dependence that came up while checking the result. Point by point:

1. Fill before ConvertForProjection. Agreed. The fill is now in project_momenta, after the rho0 conversion and just before OmegaFromW, so every caller gets it. It fills only what OmegaFromW reads: one z-ghost layer of x- and y-momentum (FillBoundary(IntVect(0,0,1), period)). In the time step apply_bcs has already filled those faces, and anelastic has fixed_density = 1, so the fill changes no value there.

2. Release-active assert on the per-step path. Removed. With the fill inside project_momenta there is nothing left to check. What remains is a host-only AMREX_ALWAYS_ASSERT that x- and y-momentum have at least one z-ghost layer, so a caller without one does not walk off the fab.

3. Why the domain boundaries are safe. You're right; the zero-slope explanation was wrong. The comment now says that the w-face k = 0 is set to zero and nothing below it is read, and that the top ghost face holds the momentum VelocityToMomentum extrapolates there. The PR description is corrected as well. The first commit's message still carries the old explanation; 3202eaa corrects it, and it can be squashed on merge.

One related thing I left alone: ConvertForProjection also skips that extrapolated top ghost face. With fixed_density = 0 it keeps rho u next to valid faces holding rho0 u. That is on development already and independent of the split.

FillPlanarBoundary allocating per call. It is now a PlanarBoundary class, one per level in SurfaceLayer and MOSTAverage. It keeps a gather buffer per index type and component count, so only the first fill of each kind allocates. The header now says the fill also overwrites the valid region of the uncomputed copies.

nsfc == 0. Documented as deliberate: a level with no box at the surface has no computed copy, and the fill leaves the MultiFab as it is. The unit test covers it.

Face-centered averages. The header notes that neighbouring surface boxes share a face in the buffer, and that ParallelCopy picks which box supplies it, as FillBoundary did; both should hold the same value. The index-order assumption for the xvel/yvel BoxArrays is still caught by the box-equality assert, which now also checks the owning rank.

w_star. It is now filled with the other surface-layer fields when it is computed (erf.most.include_wstar). Otherwise the 2D plotfile or a checkpoint could pick up an uncomputed copy on a split BoxArray. The member comment in ERF_SurfaceLayer.H lists the fields covered.

Minor:

  • Both doxygen blocks are back on their own functions, and the stray // comment is gone.

  • The template keyword is gone.

  • klo coupling: PlanarBoundary::define now takes the planar BoxArray itself and uses its boxes as built, so there is no second collapse to k = 0.

  • The deck comment was wrong, as you said; the old layout was 20 + 20.

  • The test now uses amr.max_grid_size = 16: 4 columns of 3 boxes, split in z at k = 16 and 32. The surface fill crosses box and rank boundaries in the plane. The review changes alone left every result bit-identical to the first commit, at both layouts.

  • New parallel gtest PlanarBoundaryParallel covers:

    • surface-copy selection;
    • cell- and face-centered fields with 1 and 3 components, across periodic boundaries;
    • a refill with new values, to check the cached buffer;
    • a BoxArray without a split;
    • a level above the surface.

    It passes on 1 and 2 ranks, and fails with plain FillBoundary: the 1e30 placeholder is left in the uncomputed copies.

Beyond the review: the result still depended on the split. Comparing the deck on one box with its split layouts showed two more defects:

layout difference after 10 steps
x/y split round-off, about 5e-13
any z split up to 0.011 m/s in u and 0.18 K in theta
  • 6d5a6bd: rebalance_columns integrated each box from its own lowest cell. A box stacked on another started from the sounding's unbalanced density instead of continuing the column, so dens_hse differed from k = 16 up. It now integrates in bands of equal lowest index and carries the column state across the split. There is a new parallel gtest, RebalanceColumnsParallel.
  • de4ecf3: after the solve, WFromOmega reads the same z-ghost faces as OmegaFromW, but the fluxes are added to valid faces only. w at every split face was therefore converted back with the pre-projection horizontal momenta. The ghost layer is filled again before WFromOmega.

With both commits, every split layout agrees with one box to about 6e-13 (density identical), and single-box runs are bitwise unchanged. The ABL_MOST_WOA_ZSplit gold is regenerated. A new ABL_MOST_WOA_ZSplit_BoxParity test (add_test_box_parity) compares one box with the 12-box layout at rtol 1e-9, so a regression of this kind shows up directly.

All 76 tests of the regression label pass locally (Release, MPI, 2 ranks), and so do all 23 parallel gtest cases and all 450 serial unit tests.

erf_init_dens_hse_dry (terrain path) and erf_enforce_hse integrated every box
of a level from the ghost cell below it. On level 0 that never matters: initHSE
copies a z-split base state onto full-height ChopGrids2D columns first. On a
refined level it does: a box stacked on another fine box started from the
density and pressure interpolated from the coarse level, not from the column
below, so the fine base state depended on where the fine boxes were split.

32x32x40 WoA hill, Isentropic init, level 1 over the centre (ref 2 2 1,
terrain_smoothing 2 so both runs have the same fine mesh), fine level as full
columns against split at k = 16 and 32: at step 0 dens_hse differed from k = 16
up by 3.9e-5 (3.6e-7 from k = 32), pres_hse by 4.7 Pa, and still did after 5
steps. With this change the split fine level is bitwise identical at step 0
and within 1e-15 (density) and 2e-13 (theta) after 5 steps. Level 0 split
against one box, and every layout with a single band, are bitwise unchanged;
Terrain2Lev_STF_interp and _transform have one band per level on 1 and 2
ranks, so their gold stands.

Both loops now visit the boxes in bands of equal lowest z index, bottom up.
Before each band but the first, fill_below_band (new, Utils/ERF_ColumnBands)
fills the cells just below the band, widened laterally by one cell, from the
bands already done: the lateral ghost cells of the boxes below first, then
their valid cells, which take precedence. A cell with nothing of the level
below it (under a patch aloft) keeps its interpolated value. The lateral ghost
cells matter because FillPatchTwoLevels adds the fine base state into the
coarse-fine ghost cells of the state.

New parallel gtest ColumnBandsParallel: the fill precedence, and
erf_init_dens_hse_dry on a full periodic domain, a patch and a patch aloft in
five split layouts, compared exactly with one box on valid and lateral ghost
cells. Without the fill all four tests fail; with only the valid-cell copy
(what a FillBoundary would give) the two patch tests fail.

Not addressed: with terrain_smoothing = 0 (BTF) the fine mesh itself depends
on the z split (z_phys differs by 0.54 m above k = 16).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
asalmgren and others added 3 commits September 11, 2026 00:35
… runner

CI on every platform that runs ctest failed ABL_MOST_WOA_ZSplit and its
box-parity test: the deck is anelastic on a terrain-fitted mesh, so its
projection is the FFT-preconditioned GMRES, and no CI job builds with
ERF_ENABLE_FFT (amrex::Abort "Rebuild with USE_FFT = TRUE"). On WIN32 the
box-parity runner also received the unexpanded exe glob, since it was
registered outside the MPI block its sibling add_test_cloud_chamber_parity
lives in.

- The anelastic tests are registered only with ERF_ENABLE_FFT, and the
  cmake -P box-parity tests only with ERF_ENABLE_MPI AND NOT WIN32.
- New ABL_MOST_WOA_ZSplit_NoSub: the same hill, MOST surface layer,
  sounding and 4 x 3 box split, run compressible with
  erf.substepping_type = None and fixed_dt = 0.02 (the acoustic limit),
  so it runs in every build. It covers the planar surface-layer fill and
  rebalance_columns; the acoustic substep is off because it has its own
  per-box column assumption. The e279ca5 binary aborts on the split at
  start-up ("Base state is too far out of HSE", extrema at the split
  faces k = 16 and 32) and runs on one box; with this branch the split
  agrees with one box to 1e-14 after 10 steps, over which u moves by
  0.76 m/s and w by 1.9 m/s. Gold and a box-parity test added.
- RunBoxParity.cmake tests its arguments for a value rather than DEFINED
  (-DX= defines X as empty), requires MPIEXEC_NUMPROC_FLAG only with
  MPIEXEC, and fails unless the split run has more level-0 boxes than the
  reference, so a deck change cannot turn the comparison trivial.
- rebalance_columns uses column_bands() from ERF_ColumnBands instead of
  its own copy; results are bitwise unchanged.

Checked: configure with MPI off registers only ABL_MOST_WOA_ZSplit_NoSub;
with MPI on and FFT off, _NoSub and _NoSub_BoxParity only. The runner
fails as intended with the split option removed, an empty TEST_EXE, a
missing numproc flag, and the e279ca5 binary.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@hgopalan

Copy link
Copy Markdown
Contributor Author

The CI reds on this PR were both mine, in the tests rather than the fixes, and are addressed in d3e29c9:

The two new tests aborted on every platform that runs ctest. ABL_MOST_WOA_ZSplit is anelastic on a terrain-fitted mesh, so its projection is the FFT-preconditioned GMRES, and no CI job builds with ERF_ENABLE_FFT: every run died with Rebuild with USE_FFT = TRUE so you can use the FFT preconditioner for GMRES. My local build has FFT on, which is why 76/76 passed here. On WIN32 the box-parity runner additionally received the unexpanded */erf_exec.exe glob, because I had registered it next to the add_test_r line instead of inside the MPI block its sibling add_test_cloud_chamber_parity lives in (the same slip as on #3972).

  • The anelastic test and its box-parity test are now registered only under if(ERF_ENABLE_FFT), and the cmake -P box-parity tests only under if(ERF_ENABLE_MPI AND NOT WIN32). They still run in FFT builds and cover all four level-0 items.
  • New ABL_MOST_WOA_ZSplit_NoSub, which every build runs: the same hill, MOST surface layer, sounding and 4 x 3 split, compressible with erf.substepping_type = None and fixed_dt = 0.02 (the acoustic limit). It covers the planar surface-layer fill and rebalance_columns; the acoustic substep stays off because it has its own per-box column assumption, which this PR does not touch. Over its 10 steps u moves by 0.76 m/s and w by 1.9 m/s. On the first commit of this PR the split run aborts at start-up with Base state is too far out of HSE, extrema at the split faces k = 16 and 32, while one box runs; on this branch the split agrees with one box to 1e-14. Gold and a _NoSub_BoxParity test added.
  • RunBoxParity.cmake now tests its arguments for a value rather than DEFINED (-DX= defines X as empty), needs MPIEXEC_NUMPROC_FLAG only when MPIEXEC is given, and fails unless the split run has more level-0 boxes than the reference, so a deck change cannot make the comparison trivial.
  • rebalance_columns uses column_bands() from ERF_ColumnBands instead of its own copy; bitwise unchanged.

Checks, in the spirit of the #3972 review: configuring with MPI off registers only ABL_MOST_WOA_ZSplit_NoSub; with MPI on and FFT off, _NoSub and _NoSub_BoxParity. The runner fails with its own message when the split option is removed, when TEST_EXE is empty, when the numproc flag is missing, and with the old binary. Locally (Release, MPI, FFT on): all 78 regression tests, all 27 parallel gtest cases and all 461 serial unit tests pass; the column_bands change leaves both ZSplit golds bitwise unchanged.

I also went through the rest of development for the same class of per-box column integration. init_base_state_from_wrfinput, the NetCDF initialisation and the ERF_Derive column diagnostics assert that boxes reach the bottom, so they fail loudly. Two are silent and are not addressed here: the compressible acoustic substep (Dirichlet rows at every box's lowest and highest face in MakeFastCoeffs and the w solve) and the implicit vertical diffusion (box-local klo/khi), which is why the _NoSub deck runs without substepping and the ZSplit tests pass erf.vert_implicit=false. The PR description has the details.

hgopalan added a commit to hgopalan/ERF that referenced this pull request Sep 11, 2026
…; correct the dz != dx notes

The terrain_height wall distance read z_nd(:,:,klo) from every box, but
boxes above a z split of the BoxArray do not hold the surface nodes, so
their distance was garbage (778 m error on the 3D hill deck at
amr.max_grid_size_z = 10, clamped to epsilon). Gather the surface slab onto
every box with a ParallelCopy first, and guard the gathered slab with a
reduction over its own ghost width (a scalar-ghost MultiFab::max would read
past the slab, which has no z ghosts). With the fix the split and unsplit
layouts give bit-identical wall distances, and the unsplit distance is
unchanged.

The 1.788e139 pre-projection divergence recorded in PLAN.md phase 6, the
3D hill README, RESULTS.md, the deck and the Poisson solver comment as a
dz != dx failure was the same z-split layout (unfilled momenta ghost faces
in the initial projection and duplicated planar surface-layer arrays,
fixed in erf-model#3970), not the aspect ratio; the text now says so.
The Poisson wall-distance multigrid does still diverge at dx = 2 dz.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.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.

3 participants