Extend SurfaceLayer for all boundary sides - #3860
Conversation
…x::average_down on RhoTheta+RhoQ_n
… terrain-aware moist state sync
…on AMR coarse/fine boundaries
…to dg/sdm_w_cold_processes
…it kernel for partial-z AMR fabs
…e_mapped_z's max read offset Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…particles correctly across AMR levels without breaking AMReX cic_/mac_interpolate_mapped_z which read idata(k) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…_MANAGED works under CUDA Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…nce_microphysics so next sub-cycle's FillPatchFineLevel sees consistent boundary values and doesn't drain the bubble interior Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…qs/qg) plus qt; abort at setup if requested field is unsupported by the active moisture model
Code review: PR #3860 — "Extend SurfaceLayer for all boundary sides"
High1. Inverted assert kills the stress-rotation path
AMREX_ALWAYS_ASSERT_WITH_MESSAGE(dir == 0 && m_face.isLow(),
"Stress rotation only supported for zlo face")zlo has 2.
|
|
@asalmgren I think the review comments should be addressed now |
asalmgren
left a comment
There was a problem hiding this comment.
Review: index handling on the new high faces
Thanks for this — the overall structure (per-orientation SurfaceLayer instances, face-prefixed inputs, face-indexed checkpoint entries) reads well, and the zlo path is mostly preserved. I focused on the new non-zlo code paths and found a consistent class of index bug there, plus one regression that reaches the existing zlo path.
The most important structural point: the new ABL_MOST_Cloudchamber test sets most.average_policy = 1 on all six faces and sets most.zref explicitly on all six, so the plane-average path and the default-zref path on high faces get no CI coverage — and both are broken (comments 1-4 below).
High severity
ERF_MOSTAverage.cpp:1290, 1381, 1483—sm_index = bigEnd(dir) + 1compared against cell-centered boxes means the plane averages for U/V/Theta/Qv, theta_v and Umag are never accumulated on any high face and end up zero.ERF_MOSTAverage.cpp:645— high-facezrefis not snapped to the cell centre (so opposite walls disagree for a symmetric domain); thelkformula treatszrefas a wall distance while the asserts and the default treat it as an absolute coordinate, so the no-most.zrefdefault selectslk = 0; andm_geom[0]is mixed withm_geom[lev].ERF_SurfaceLayer.cpp:976—bxyis not nodal in x, so the xhi wall stress on w-momentum is written to an interior node and never reaches the boundary node.ERF_SurfaceLayer.cpp:956, 1016, 1020— theis_low_face ? n : n+1shift is applied to tangential indices fordir == 0/dir == 1, offsetting opposite walls by one cell.ERF_DiffusionSrcForState_N.cpp:76(and_S.cpp:78,_T.cpp:94) —use_SurfLayeris now ignored, so the native-SHOC "don't re-apply the surface scalar flux" decision made inERF_SlowRhsPre.cpp:267is discarded and the flux is double-counted.
Medium
6. ERF_MOSTStress.H:1417 — q_star becomes exactly 0 for existing moist zlo runs that don't set most.surf_moist. Looks intentional, but it changes previously-working zlo behavior.
7. ERF_SurfaceLayer.cpp:395 — compute_fluxes expands the box to the full domain height while the FABs only own their own box's k-range; out-of-bounds if the grid is decomposed in z.
Low: ERF.cpp:1336 (O(N^2) checkpoint reads on restart), ERF_Plotfile.cpp:1820 (unguarded Tau11 deref with diffusion off), ERF_MOSTAverage.cpp:797 (2*prob_lo offset in the low-face terrain zref), ERF_MakeNewArrays.cpp:717 (SFS_q2fx3_lev not nulled).
Suggested gate before merge: switch the test to most.average_policy = 0 (the block already commented out at lines 74-79 of ABL_MOST_Cloudchamber.i) and drop the explicit most.zref lines. That alone exercises items 1 and 2.
Review produced with Claude Code.
| if (imf < 3 && imf == dir) { | ||
| sm_index += 1; | ||
| } | ||
| if (dir != imf) sm_index += 1; |
There was a problem hiding this comment.
Plane averages are silently zero on every high face.
For any field that is cell-centered in dir — the two tangential velocities (imf < 3 && imf != dir), Theta (imf == 3) and Qv (imf == 4) — this makes sm_index = Domain().bigEnd(dir) + 1. No box's vbx.bigEnd(dir) can ever equal that, so every box hits the continue at line 1311 and the average is left at 0.
The +1 is only correct for the face-normal nodal velocity, which the preceding if (imf < 3 && imf == dir) already handles. Compare compute_region_averages (line ~1749), which computes the same quantity with a plain bigEnd(dir) and no extra increment.
Effect: run the new ABL_MOST_Cloudchamber input with the default most.average_policy = 0 and the xhi/yhi/zhi faces get tm = 0 and umm = WSMIN in surface_temp::iterate_flux, producing garbage u*/t* and wall fluxes. The new test uses average_policy = 1 everywhere, so CI does not cover this path at all.
| if (m_face.isLow()) { | ||
| sm_index = m_geom[lev].Domain().smallEnd(dir); | ||
| } else { | ||
| sm_index = m_geom[lev].Domain().bigEnd(dir) + 1; |
There was a problem hiding this comment.
Same class of bug as line 1290. fields[4] (Qv) is cell-centered — the comment three lines above literally says "This is cell-centered so we don't need to worry about double-counting" — so pbx.bigEnd(dir) maxes out at Domain().bigEnd(dir) and can never equal bigEnd(dir) + 1. The virtual-potential-temperature average (iavg = 5) is therefore never accumulated on any high face.
The corresponding region-average block at line ~1749 uses bigEnd(dir) with no +1, which confirms the intended value.
| if (m_face.isLow()) { | ||
| sm_index = m_geom[lev].Domain().smallEnd(dir); | ||
| } else { | ||
| sm_index = m_geom[lev].Domain().bigEnd(dir) + 1; |
There was a problem hiding this comment.
Same bug again, for the tangential velocity magnitude. The MFIter is over fields[imf_cc] with imf_cc = 3 (cell-centered Theta), so pbx.bigEnd(dir) != bigEnd(dir) + 1 always and iavg = m_navg-3 .. m_navg-1 (Umag, Umag_xz, Umag_yz) stay zero on high faces.
Region-average counterpart at line ~1870 uses bigEnd(dir).
| AMREX_ALWAYS_ASSERT_WITH_MESSAGE(zref_tmp >= m_zlo + 0.5 * m_dz, | ||
| "Query point must be below the last z-cell!"); | ||
|
|
||
| lk = m_geom[0].Domain().bigEnd(dir) - static_cast<int>(floor(zref_tmp / m_dz - 0.5)); |
There was a problem hiding this comment.
Three problems in the high-face branch.
(a) m_zref is not snapped to the selected cell centre. The low-face branch above stores (lk + 0.5)*m_dz + m_zlo; here the raw user input is stored. With the cloudchamber setup (zref = 0.03125, dz = 0.03125) both faces select the wall-adjacent cell, whose centre is dz/2 = 0.015625 from the wall — but xlo stores zref = 0.015625 and xhi stores 0.03125. log(zref/z0) and Rib then differ, so a geometrically symmetric chamber produces asymmetric fluxes on opposite walls.
(b) The formula disagrees with the asserts and with the default. lk = bigEnd(dir) - floor(zref_tmp/m_dz - 0.5) treats zref_tmp as a distance from the wall, but the two asserts just above (and the default computed at line ~613, ProbHi(dir) - 0.5*m_dz) treat it as an absolute coordinate. So when the user does not set most.zref, the high-face default gives
zref_tmp = ProbHi - dz/2
lk = bigEnd - floor(N - 0.5 - 0.5) = bigEnd - (N-1) = 0
i.e. the xhi/yhi/zhi face samples the cell adjacent to the low wall. The new test only avoids this because it sets most.zref explicitly on all six faces.
(c) Mixed level indexing. m_geom[0].Domain().bigEnd(dir) is combined with m_dz from m_geom[lev], so on a refined level the returned index refers to the level-0 domain.
| "K index must be larger than averaging radius!"); | ||
| //k_arr(i,j,k) = lk; | ||
| k_arr(i,j,k) = k; | ||
| zref_arr(i,j,k) = face + myhalf * (z_hi + z_lo); |
There was a problem hiding this comment.
z_lo = face + lk*dx and z_hi = face + (lk+1)*dx, so myhalf*(z_hi + z_lo) = face + (lk+0.5)*dx and this line evaluates to 2*prob_lo + (lk+0.5)*dx.
The high-face branch below (line ~815) does face - myhalf*(z_hi+z_lo), which correctly reduces to the wall distance (lk+0.5)*dx. This line should subtract, not add — it is only correct today because geometry.prob_lo == 0 in the new test.
Separately, k_arr(i,j,k) = k with //k_arr(i,j,k) = lk; commented out just above looks unintended — the whole point of the search loop is to find lk.
| const GpuArray<Real,AMREX_SPACEDIM> grav_gpu, | ||
| const BCRec* bc_ptr, | ||
| const bool use_SurfLayer, | ||
| const bool /*use_SurfLayer*/, |
There was a problem hiding this comment.
Disabling this parameter re-introduces double-counting of the surface scalar flux under native SHOC.
The flux override is now driven by SurfLayer_zlo = (SurfLayer[2] != nullptr) (ERF_SetupDiff.H:10) rather than by this flag. But the callers still compute and pass a meaningful value:
ERF_SlowRhsPre.cpp:267setsl_apply_surface_layer_fluxes_in_diffusion = falsewhennative_shoc_lev->owns_scalar_surface_fluxes(), precisely so diffusion does not re-apply the MOST heat/moisture flux atk == dom_lo.z;ERF_SlowRhsPost.cpp:155-165does the same for EAMxx SHOC and for native SHOC withoutuses_host_diffusion().
With the parameter commented out, that decision is silently discarded and zflux(i,j,dom_lo.z) = hfx_z(...) is applied anyway, double-counting the surface scalar flux for surface_layer + native-SHOC runs.
_S.cpp:78 has the identical change. _T.cpp is inconsistent rather than uniform: it still uses use_SurfLayer at line 873, but its SurfLayer_on_zlo checks no longer consult it — and it now declares an unused parameter, which will warn under -Wunused-parameter.
Suggested fix: keep the flag and gate on use_SurfLayer && SurfLayer_zlo.
| q_star_arr(i,j,k) = -mdata.surf_moist_flux / u_star_arr(i,j,k); | ||
| } else { | ||
| q_star_arr(i,j,k) = mdata.kappa * (qvm_arr(i,j,k) - q_surf_arr(i,j,k)) / (C - psi_h); | ||
| q_star_arr(i,j,k) = mdata.kappa * (qv_a - qv_s) / (C - psi_h); |
There was a problem hiding this comment.
Behavior change on the existing zlo path — please confirm it's intended.
This was mdata.kappa * (qvm_arr(i,j,k) - q_surf_arr(i,j,k)) / (C - psi_h). The new qv_s falls back to qvm_arr whenever q_surf_arr <= 0 (lines 1341-1349), and default_land_surf_moist is 0 (ERF_SurfaceLayer.H:1278, applied at :600).
So for any existing zlo moist run that does not set most.surf_moist, q_star changes from kappa*qvm/(C - psi_h) to exactly 0 — the surface moisture flux is silently removed. The fallback itself looks deliberate (the comment says "there will be no flux"), and it's arguably more physical than the old behavior, but it is a change to previously-working zlo results rather than new-face-only behavior. Worth calling out in the PR description, and worth checking whether any existing moist MOST regression baseline should have moved.
| } | ||
| if (restart_chkfile != "") { | ||
| // Update surface fields if needed (and available) | ||
| ReadCheckpointFileSurfaceLayer(); |
There was a problem hiding this comment.
ReadCheckpointFileSurfaceLayer() itself loops over all orientations (ERF_Checkpoint.cpp:1631,1638), but this call sits inside the per-orientation construction loop. On restart with N active faces it runs N times and re-reads every already-constructed face's Ustar/Tstar/MOSTAvg* MultiFabs — O(N²) VisMF reads, and the first invocation happens before the remaining faces exist.
Functionally idempotent, but it should move to after the orientation loop closes.
| } | ||
|
|
||
| if (containerHasElement(plot_var_names, "Tau11")) { | ||
| MultiFab::Copy(mf[lev],*Tau[lev][TauType::tau11],0,mf_comp,1,0); |
There was a problem hiding this comment.
Unguarded dereference. The Tau12/Tau13/... paths below carefully fall back to Array4<Real>{} when the pointer is null, but Tau11 (and the Tau22/Tau33 copies) dereference Tau[lev][...] directly.
When l_use_diff == false, all nine entries are set to nullptr (ERF_MakeNewArrays.cpp:714), so erf.plot_vars_1 = ... Tau11 in a run without diffusion is a null dereference. The new doc rows in Plotfile3DReference.rst say "only available when diffusion is enabled", but nothing enforces or diagnoses it — either guard the copy or Abort with a clear message during plot-variable validation.
| Tau[lev][i] = nullptr; | ||
| } | ||
| SFS_hfx1_lev[lev] = nullptr; SFS_hfx2_lev[lev] = nullptr; SFS_hfx3_lev[lev] = nullptr; | ||
| SFS_q1fx1_lev[lev] = nullptr; SFS_q1fx2_lev[lev] = nullptr; SFS_q1fx3_lev[lev] = nullptr; |
There was a problem hiding this comment.
This else (no-diffusion) branch nulls SFS_hfx1/2/3, SFS_q1fx1/2/3 and SFS_diss, but not SFS_q2fx3_lev[lev] — even though the moisture-off branch just above (line 710) was extended to do exactly that.
On a RemakeLevel that turns diffusion off, SFS_q2fx3_lev[lev] can therefore retain a MultiFab built on the previous BoxArray. The new plotfile block at ERF_Plotfile.cpp:1878 reads SFS_q2fx3_lev[lev]->array(mfi) guarded only by a null check, so a stale pointer would be indexed with an mfi from a different BoxArray.
This allows the MOST/Surface Layer boundary condition to be specified on all 6 faces instead of only zlo.
Main changes to
SurfaceLayer/MOSTAverageclasses:SurfaceLayerandMOSTAverageclasses to determine behavior on different faces.erf.most.surf_temp, can now beerf.xlo.most.surf_temp).SurfaceLayerwrites to Tau and heat/moisture flux arrays for each face. Directional velocity averages and orientation is passed to MOSTStress.Main changes to
ERF:SurfaceLayerinstance all 6 faces where the BC type is set tosurface_layer(otherwise nullptr)A follow-up PR will be made to adapt this for the new cloudchamber interface and problem configuration.