Skip to content

Surface energy balance on immersed-boundary building faces (erf.ibseb), with immersed-boundary fixes for the forcing and the MRF/YSUNew schemes - #3960

Merged
asalmgren merged 44 commits into
erf-model:developmentfrom
hgopalan:claude-seb
Sep 15, 2026

Conversation

@hgopalan

@hgopalan hgopalan commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Surface energy balance on immersed-boundary building faces

Adds a prognostic surface energy balance on the faces of buildings represented by immersed forcing (erf.ibseb.*, off by default). Every wall and roof face carries a skin temperature and a conduction slab and closes

SW_abs + eps Q_ext + LW_net - H - LE - G = 0

each step, with the sensible flux deposited into the adjacent fluid cell. Each term has a verification case under Exec/CanonicalTests/SEB/ (one and four ranks, checked against independent Python implementations or analytic solutions), documented in Docs/sphinx_doc/theory/ImmersedBoundarySEB.rst and Docs/sphinx_doc/Inputs.rst. CI runs the balance through the CTest IBSEB_Cube (the PrognosticSkin cube for 40 steps against a gold plotfile) and the immersed-boundary-aware PBL schemes through the tiling-parity tests PBL_IBAware_MRF_Tiling and PBL_IBAware_YSUNew_Tiling. The balance needs erf.buildings_type = ImmersedForcing on a uniform vertical grid without regridding; terrain by immersed forcing is rejected.

case what check
FaceStorage compact per-rank face list (struct of device vectors), building ids, checkpoint slots face counts, area, restart round trip
Shortwave prescribed clear-sky provider (Spencer geometry, Bird beam, Liu-Jordan diffuse), ray-cast shadow shadow flag vs an independent cast on every face
Longwave hemisphere view fractions, gray sky, ground, isothermal walls fractions vs independent sampling; closure
SensibleHeat wall function per face, explicit source into rho-theta formulas; internal-energy budget
SlabConduction implicit slab per face, material CSV (SLUCM schema) erfc and steady solutions; restart
PrognosticSkin Newton on the skin, made exactly consistent with the implicit slab; Q_ext hook for external radiation; bounds as inputs residual 1e-8 W/m2; closure; independent re-integration to 1e-9 K
IsolatedBuilding isolated building over 24 h at Boulder sequence of the day; roof shortwave integral to 0.01 %; slab energy
WallFunction, BuildingSet wall function beyond neutral (Deardorff scale with a diurnal mixed-layer depth, stability functions on the face's own L), all opt-in; building set with three materials formulas to 1e-9; shadows, view fractions, materials, cost

Demo

The isolated building over a solstice day (IsolatedBuilding): the roof lags the sun by two hours on 30 cm of concrete, conduction reverses at 18:10, and every face radiates below the air at night.

day

roof budget

slab

The building set through a morning (BuildingSet): four height-map buildings, three materials; the two identical timber blocks track each other to 0.2 K, the brick cube and the concrete slab lag by their heavier walls.

set

faces

Sunrise over a cube (the PrognosticSkin case), the test that caught a mirrored solar azimuth in the provider:

sunrise

Also in this PR, both opt-in, both bit-identical when off

  • erf.if_snap_partial_cells: the immersed forcing on a height-map building grows a vertical checkerboard on the sliver cells of the reader's one-cell ramp over about two hours (with the balance off, a no-slip ground, the forcing outside the substeps and a coarser small-cell threshold as well). The switch reads the blanking snapped to whole cells and uses the point-implicit drag: a momentum face between a solid and a fluid cell gets the drag toward zero (no penetration), a face between two solid cells the roof or wall law of its row or the interior drag; buildings only, terrain by immersed forcing keeps its fractions. Regtest Exec/RegTests/ImmersedForcingTest/PartialCells.
  • erf.pbl_ib_aware (MRF and YSUNew only): the schemes measure height from the terrain surface and the ground surface layer evaluates u*, theta* and L on cells inside a building, so over an immersed building MRF fills the domain with NaN and YSUNew drives the density negative at its second step. The switch makes each column's surface the first fluid cell above the solid; the stored PBL height stays the absolute height in both schemes, the PBL height smoothing runs on that height and keeps every column at or above its own floor. Regtests Exec/RegTests/ImmersedForcingTest/PBL_IBAware, CTests PBL_IBAware_MRF_Tiling, PBL_IBAware_YSUNew_Tiling, PBL_IBAware_MRF_Smoothing.

Limitations and the effort to remove them

"Session" is one working day with this workflow (implement, regtest, document).

limitation what it costs today effort to remove
Radiation is the prescribed clear sky only (no clouds, aerosol, water vapour, no feedback from the column) fine for clear days, wrong for overcast 1 session: a two_stream provider behind the existing erf.ibseb.radiation input from the now-merged two-stream model (#3950, #4004); the balance keeps its own solar geometry (Spencer's declination and equation of time, the azimuth the CESM orbital form of the radiation models does not give) and only asks for the sun vector, the beam, the horizontal diffuse and the sky longwave
Building-to-building longwave is the isothermal-surroundings closure (a wall sees its own emission where it sees building) no net exchange between walls of different temperature, a few W/m2 in a street canyon 2 to 3 sessions: radiosity between faces using the stored hemisphere fractions, sparse by distance; cost scales with facing pairs
The ground is a fixed temperature for the walls' longwave; no ground balance the ground does not warm or cool under the walls 2 sessions once the land-surface models see radiation, coupling the walls' ground term to the LSM surface temperature
Latent flux is zero; no wet surfaces or green roofs dry buildings only 1 session: a wet fraction per material and a bulk evaporation into the moisture source slot
The interior is a fixed temperature no heat storage of the building volume 1 session: a lumped interior budget per building from the inward conduction of its faces (the building ids make it direct)
Wall function: neutral log law by default; the stability functions and the Deardorff scale are opt-in and tested against formulas, not data; the walls' free convection is carried by the building-height scale only fluxes off a hot roof in calm air need the switches on 1 session for Louis (1979) explicit functions as a cheaper option; validation against measurements is a separate effort (below)
Geometry: faces where the blanking crosses one half, so height-map buildings are stepped with a half-height rim and sliver corners; terrain faces are not under the balance walls of a 40 m cube on 10 m cells carry a rim; per-face fractions are not used 1 session to put terrain-by-immersed-forcing faces under the same balance; smoother geometry would need face fractions in the balance, 2 sessions
Tested on one AMR level and on CPU only; the kernels are written for GPU (ParallelFor over device vectors) but have not run on one unknown on multi-level and GPU 1 session each on a GPU machine and a nested case
No comparison with observations the physics is verified term by term against formulas, not against a measured building 2 to 3 sessions with a dataset (an instrumented isolated building or a canyon experiment such as BUBBLE)
The PBL awareness covers MRF and YSUNew; over a building column the surface scales are a neutral log law at the roof; not supported with terrain-fitted coordinates no roof heat flux in the scheme's surface scaling 1 to 2 sessions: take the roof's u* and H from the balance's faces, and the terrain-fitted offset
The snapped forcing turns a height-map building into a staircase and uses the implicit drag partial-cell smoothness is given up on those cells 1 session on a real city height map to check the staircase against the box form
Performance measured on small cases only (0.18 ms per step for 157 faces per rank; view-fraction sampling at initialisation scales with faces times rays times path length); memory per rank: the column map of the ray cast is 8 bytes per column of the built bounding box, replicated on every rank, and the checkpoint field lives on 8 x 8 column blocks up to the highest face-owning cell (6 x (2 + n_slab_layers) reals per cell there) unknown at 1e5 faces per rank; a city that fills the domain keeps an nx x ny column map per rank 1 session to profile a city-scale case and cap the ray path; a distributed column map if the replicated one ever binds
The fire coupling (radiant flux into Q_ext, ignition from the skin temperature) is not here by design: it lives in ERF-Hazard 2 sessions there once this merges

Review rounds

  • 13 September (8 findings): snap-aware masks, one PBL height convention (absolute) for MRF and YSUNew, depth origins in the bulk Richardson height, the previous step's sensible flux checkpointed, slab stack bound 32.
  • 14 September (5 findings): GNU Make wiring, YSUNew mixing length above the column's surface, the dead utc_offset_hours input removed, range checks on latitude, longitude and day of year, the ibseb_* plotfile variables documented.
  • 15 September (5 findings): the solar geometry back in the balance's own header after Two-stream radiation: share RRTMGP's sun, site and surface-temperature inputs, one solar-geometry code, rad_fluxes filled, one Radiation directory, Sphinx warnings cleared #4004 renamed the shared one (bitwise identical); the momentum kernels' face blanking under the snap from the two cells a face joins (wall-normal faces get the drag toward zero), the terrain kernels excluded from the switch; MRF's PBL height smoothing in the absolute frame with a per-column floor in both schemes, plus the PBL_IBAware_MRF_Smoothing gold test; the report and CSV step numbers as completed steps; a restart with another n_slab_layers rejected against the checkpoint's field width.
  • 15 September, second pass (5 findings): the snapped faces take the full log-law target (the partial-cell weight had made them no-slip; PartialCells now checks that erf.if_z0 changes the flow); the checkpoint field on column blocks around the buildings with a transfer layer so a restart works on any rank count (FaceStorage restarts on one rank from a four-rank checkpoint); the ray cast's column map over the built bounding box; the bulk Richardson shear from the wind vector; the slab response guarded like its sibling.

Findings to report separately

🤖 Generated with Claude Code

hgopalan and others added 29 commits September 3, 2026 21:55
…ance

Eight phases on faces of resolved buildings: face storage, shortwave with
ray-cast shadowing, longwave through sky/ground/building view fractions,
sensible heat through the existing immersed-forcing wall model, slab
conduction, the prognostic balance, and two canonical cases. Radiation
reaches the balance through a provider interface (prescribed, or the
two-stream column when that branch is merged), so the branch stays based
on development and carries only its own commits.

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

erf.ibseb.enable builds, on every level, a compact list of the wall faces of
the resolved buildings: a face between a fluid cell (blanking < 0.5) and a
solid cell (>= 0.5), stored once on the rank that owns the fluid cell as
device arrays (cell, direction, side, building id, material id, area, skin
temperature, slab temperatures, view fractions, fluxes), contiguous per
local box. Building ids label the solid columns of the blanking. Nothing
evolves yet.

Output through cell-centred fields on demand: ibseb_nfaces and ibseb_tskin
in the plotfile, IBSEBState in the checkpoint, refilled into the rebuilt
list on restart so restarts do not depend on the rank count. A [IBSEB]
summary line and a per-building CSV every erf.ibseb.csv_int steps.

Regtest Exec/CanonicalTests/SEB/Storage: the ImmersedForcingTest skyscraper;
face counts per direction checked against the plotfile mask, identical on
one and four ranks (2056 faces, 51400 m2), checkpoint at step 2 and restart
to step 4 reproducing the CSV row. Sphinx: inputs table and a theory page.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
plot_storage.py draws, with yt, a horizontal slice of the mask and of the
faces-per-cell field through the building and vertical slices of the mean
skin temperature through its centroid, with the mask outline, in absolute
metres (ERF plotfiles carry no unit metadata) and with the y-normal slice
swapped to (x, z).

The four sources of the balance get file, class and function documentation
in the Doxygen style of the existing code, spelling out the face convention
(fluid cell, direction, side, outward normal), the ownership and per-fab
layout of the list, the building labelling, the six-slot checkpoint field,
and which phase fills which array.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
erf.ibseb.debug now prints, in the fire module's [FIRE DEBUG] style: the
inputs and the global face counts at build, each building's footprint
columns and index box, every rank's face count with its fab ranges and
device memory, a summary with per-building rows every step, and the
checkpoint save and load of the face state.

The regtests move to Exec/CanonicalTests/SEB/Phase<n>_<name>, starting with
Phase1_Storage, so the phase order is visible in the tree; the plan, the
theory page and the README follow. The y-normal slice of plot_storage.py
gets its labels after yt's axis swap, so they read x and z.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A prescribed radiation provider (erf.ibseb.radiation = prescribed) gives the
direct-normal and horizontal diffuse irradiances and the sun vector, either
fixed (sun_mode = fixed, for analytic tests) or from the site and time
(sun_mode = solar: Spencer solar geometry, Bird direct beam, a diffuse share
of the attenuated beam), through a single copied header so the branch has
no dependency on ERF-Radiation.

Per face and per step: direct = DNI max(0, n.s) unless the ray from the
face centre toward the sun hits a building, decided by a 2D walk over the
column tops of the level (a small array replicated on every rank; the ray
only rises, so a column blocks it when the entry height is below its top);
diffuse = f_sky D + f_ground albedo_ground (DNI cos z + D) with the
placeholder view fractions (roof: sky 1; wall: sky and ground 0.5 each);
absorbed = (1 - albedo) times the sum. Called at the start of every step.
New plot fields ibseb_sw_abs and ibseb_shadow, shortwave columns in the
per-building CSV, a per-rank face dump (erf.ibseb.dump_faces_file), and the
sun in the [IBSEB DEBUG] output.

Regtest Exec/CanonicalTests/SEB/Phase2_Shortwave: a short box 40 m east of a
tall one; the shadow flag of every face matches an independent Python ray
cast, the fluxes match the formulas on every face, the tall core roof and
west wall are unshadowed with the exact incidence, the shadow on the short
box's core west wall stops at H - gap tan(elevation), one and four ranks
agree, and the solar mode gives the solstice-noon zenith at Boulder. The
embedded-boundary reader steps each edge over one cell, which the test
reads from the dump rather than assuming.

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

Once at initialisation every face samples a cosine-weighted hemisphere
around its outward normal (erf.ibseb.view_n_az x view_n_el rays, stratified
so the counts are view factors) through the column walk of the shadow test
made direction-aware: a rising ray is blocked by a column whose top is above
its entry height, a falling ray by a column it descends into, and reaches
the ground otherwise. The fractions of rays ending on the sky, the ground
and a building replace the phase 2 placeholders in the diffuse shortwave.

Longwave per step: LW_in = f_sky LW_sky + f_ground eps_g sigma T_g^4
+ f_bldg sigma T_skin^4, the sky term fixed (erf.ibseb.lw_down) or gray
(sky_emissivity sigma T_air^4 with the air temperature of the face's fluid
cell, through the equation of state), the building term the isothermal-
surroundings approximation; LW_net = eps (LW_in - sigma T_skin^4). No
face-to-face view factors, no radiosity. New plot fields ibseb_lw_net and
ibseb_f_sky, longwave in the CSV and the face dump, the sampling summary in
the debug output.

Regtest Exec/CanonicalTests/SEB/Phase3_Longwave on the two-box deck: the
three fractions of every face sum to one and equal an independent Python
hemisphere sampling; the tall core roof sees only sky, no roof sees the
ground, the tall west wall sees exactly half sky with its rim ledge filling
a quarter of the view just above it and little 50 m up, the short box's
core west wall sees the tall box; the longwave formulas hold on every face
for both sky modes; one and four ranks agree. Phase 2 still passes with
the sampled fractions.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Per face and per step: the tangential wind of the fluid cell at half a cell
from the wall gives u* with the roughness z0_wall, the skin-to-air
potential-temperature difference (the skin temperature converted with the
cell's Exner function) gives theta* with z0h_wall, and H = rho c_p u* theta*,
positive out of the face. Walls are neutral; erf.ibseb.stability_correction
applies the surface layer's similarity functions on roofs. The latent flux
stays zero with its argument in place.

The flux enters the atmosphere as an explicit source: every face deposits
H A / (c_p V Pi) into the rho-theta equation of its fluid cell, added after
make_sources at every slow stage with atomic adds; erf.ibseb.couple_heat =
false diagnoses without applying. The immersed forcing's own surface-
temperature inputs are refused when the balance is on, since it now owns
the temperature condition at the buildings. New plot field ibseb_H, the
flux in the CSV, the dump and the debug output, and H_total_W per step.

Regtest Exec/CanonicalTests/SEB/Phase4_Sensible: a 40 m cube held at 320 K
in an 8 m/s wind at 300 K. u* and H match the formulas on every face of the
dump, H is positive and largest on the windward wall, one and four ranks
agree, and with the flux applied the extra internal energy of the air
against the diagnostic run at the same step matches the summed face flux
(the domain is a rigid closed box, so it is c_v, not c_p, that closes). A
mass-inflow, pressure-outflow variant (as the Askervein canonical) runs
with a wake warmer than the inflow.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every face carries a slab of erf.ibseb.n_slab_layers uniform layers between
its skin and the building interior at T_interior, solved implicitly with
the Thomas algorithm in the form of the SLUCM branch's slab solver, with the
skin temperature as the top boundary instead of a flux (ERF_IBSEBSlab.H);
G = 2k/dz (T_skin - T_0) is the conduction into the wall, positive inward.
Unconditionally stable, up to 256 layers, advanced every step.

Materials: erf.ibseb.material_file names a CSV in the SLUCM schema (rank-0
read, broadcast), material_default and material_by_building assign them by
building id; the conductivity, heat capacity, thickness, albedo and
emissivity become per-face arrays, and the shortwave and longwave now use
the per-face optical properties. Without a file the uniform k_therm,
rho_cp, thickness, albedo and emissivity apply. New plot field ibseb_G,
G in the summary and the dump with the material columns and the top and
bottom slab layers.

Regtest Exec/CanonicalTests/SEB/Phase5_Ground: a 200 mm slab in 1 mm layers
follows the semi-infinite erfc solution for a boundary step (bottom layer
300.925 K against 300.921 K expected at 50 s) with no flux through the skin;
a light 20 mm slab reaches G = k dT / L exactly with a linear profile; two
buildings carry the concrete and timber of the CSV; the slab and its flux
restart exactly through a checkpoint. The atmosphere-derived columns differ
by about 1e-4 after a restart, which is the immersed-forcing atmosphere of
development, not the balance, and is noted in the plan.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every face now solves SW_abs + eps Q_ext + LW_net - H - LE - G = 0 for its
skin temperature at the end of each step (ERF_IBSEBBalance.H, lifted from
the SLUCM facet solver). The conduction term is the flux the implicit slab
step will take, G = a T - b from two trial slab steps, so the balance and
the slab agree to rounding and the slab energy changes by exactly
dt (G - G_bottom). The wall term of the incoming longwave folds into the
emission as (1 - f_bldg); the wall-function coefficient is frozen at the
wind of the step. Q_ext is an incident external flux absorbed with the
emissivity, the hook for a fire's radiation (erf.ibseb.Q_ext_uniform for
tests). The bounds and the step cap are inputs since fire exposure exceeds
the urban canopy model's 380 K. erf.ibseb.prognostic defaults to true; the
phase 2-5 decks pin it false to keep checking each term on its own.

Fixes the solar azimuth of phase 2, which was mirrored east-west (sign of
the sine term); the noon check could not see it, the sunrise deck did.

Regtest SEB/Phase6_Prognostic on a cube: residual below 1e-8 W/m2 on every
face at every step, every stored flux consistent with the skin
temperature, slab energy exact per step, closure over the run within the
summed residual, an independent Python model (own Newton, dense implicit
slab) driven by the per-step face dumps reproducing the skin temperature
to 1e-9 K, an external flux run past 380 K, a checkpoint restart, and the
sun rising over the cube at Boulder with the east wall warming first.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…l_cells)

A building from a nodal height map has sliver cells with 1-20 percent
solid at the corners of the reader's one-cell ramp. The drag on a partial
cell fades with its fraction, but the wall law was applied at full rate to
any cell above 0.005 solid whose normal neighbour was exactly zero, so the
top cell of a sliver column was forced like a wall on top of an almost
free cell. In a neutral 3 m/s run on a 40 m cube at 10 m and 0.5 s a
vertical two-cell checkerboard in theta grew there over hours (10 K, 5 m/s
spikes) until the floating-point trap fired after 1.8-2.5 h, with the
surface energy balance off, a no-slip ground, the forcing outside the
substeps and a coarser small-cell threshold as well.

With erf.if_snap_partial_cells the six forcing functions place the wall
law and the surface temperature, flux and Obukhov conditions on cells at
least half solid whose normal neighbour is less than half solid, the rule
the face balance uses; cells below half solid keep the fraction-weighted
drag only. Faces passing several wall tests at once have their relaxations
averaged so the explicit substep forcing stays within its limit. Default
false: with the switch off every result is bit-identical (checked on the
phase 6 closure deck).

Regtest Exec/RegTests/ImmersedForcingTest/PartialCells: the height-map
cube for 2.64 h with the switch on stays neutral to 1e-3 K with a wake;
--reproduce runs the original selection, which traps.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Exec/CanonicalTests/SEB/Phase7_IsolatedBuilding runs a 40 m concrete cube
at Boulder on the June solstice from midnight for 24 hours in a light
westerly with the prescribed clear-sky provider and a gray sky. The cube
is an exact box (eb2.geometry = box) so the immersed forcing has no
partial cells; the per-building CSV gains the sun (zenith, azimuth, DNI,
diffuse) so the day can be plotted without debug output.

The day: every face radiates below the air at night, the roof coldest at
dawn; the east wall is the first face to rise above the air after
sunrise, forty minutes before the roof; the east wall peaks at 09:39, the
roof at 13:55 at 340 K two hours after the sun, the west wall at 16:45;
the south wall is 9 K warmer than the north at 13:00; conduction turns
around at 18:10 and the slab's stored 16.5 MJ/m2 keeps the roof above the
air past 23:00. The checker asserts that sequence, the balance residual
over the day (4e-8 W/m2), the absorbed shortwave on the roof against the
clear-sky formulas integrated independently in Python (24.882 MJ/m2 both)
and the slab energy against the integrated conduction (0.01 %). The plot
script draws the skin temperature by orientation with the air, the roof
budget, the sun path, a slab Hovmoller and yt slices.

Records under findings that the sensible flux off the 340 K roof is only
10-30 W/m2 with the neutral wall function, which the next PR addresses
with the stability functions and a convective velocity scale, both
behind switches; and that the restart non-exactness of the immersed
forcing persists after erf-model erf-model#3956.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The neutral log law on the tangential wind cannot shed heat from a hot
face in calm air, which is why the day canonical's roof reached 340 K.
Two switches, both off by default so every earlier result is unchanged:

erf.ibseb.convective_velocity = deardorff puts a convective velocity
scale into the wind the wall function sees, U_eff^2 = U_tan^2 +
(beta w*)^2 (Beljaars), with w* = (g/theta H/(rho c_p) depth)^(1/3) from
the previous step's flux out of the face. The depth is the mixed layer
above a roof (z_i - z_face, floored at the building height) and the
building height for a wall; z_i follows the day by the bulk Richardson
diagnostic on the horizontal-mean profile (erf.ibseb.z_i_mode = bulk_ri),
from the surface layer's pblh at the column (pblh), or fixed (z_i).

erf.ibseb.stability_correction now iterates Dyer's similarity functions
on the roofs to convergence on the face's own Obukhov length, under-
relaxed as the surface layer's iteration is in erf-model erf-model#3486, seeded
from the ground surface layer's 2D field at the column
(erf.ibseb.obukhov_seed). Walls stay on the log law. The face's L stays
its own because a roof in a separation zone or a sunlit wall can be in
the opposite regime from the ground.

The face dump gains w_star, Olen, z_i and h_bld; the summary gains
w_star_max. Regtest SEB/Phase8_WallFunction: the cube in calm air under a
strong sun; the neutral law sheds 0.2 W/m2 from a 342 K roof and the
scale 270 W/m2; w*, the depth, u* and H follow the formulas to 1e-9; the
roofs' L is negative and consistent with u* and theta* and the corrected
log law to 1e-7; the bulk Richardson depth on a capped sounding is the
first cell above the inversion. The default path is bit-identical to the
phase 6 reference.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…dary aware

erf.if_snap_partial_cells now reads the cell and face blanking snapped to
solid or fluid at half, so a height-map building becomes the staircase of
whole cells an exact box is (wall faces fully blanked, interiors damped,
nothing on the sliver cells), and it selects the point-implicit drag: the
staircase puts full-strength forcing on every wall face, whose explicit
rate at rims and corners drives the density negative within seconds on a
thin slab (the first version, which only moved the selection threshold,
survived the cube for 2.6 h but killed a 3-cell slab in two minutes).
With the switch off every result is bit-identical. The PartialCells
regtest runs the height-map cube 2.6 h clean.

erf.pbl_ib_aware (MRF and YSUNew only; MYNN, MYJ, YSU and SHOC untouched)
makes each column's surface the first fluid cell above the immersed
solid: the bulk Richardson heights, the boundary-layer depth and the K
profile are measured from it, the diffusivities vanish inside the solid,
and the surface scales of a column with solid cells are a neutral log law
at its top with erf.pbl_ib_z0, since the ground surface layer evaluates
u*, theta* and L on cells inside the building. Without immersed cells the
results are bit-identical; not supported with terrain-fitted coordinates.
Regtest ImmersedForcingTest/PBL_IBAware: with the switch the schemes are
finite everywhere and zero inside the cube, and the profile over the roof
has the shape of the ground's against the local height (MRF peaks 15 m
above each); without it MRF fills the domain with NaN and YSUNew drives
the density negative at its second step, reported not asserted.

The balance prints a cost line (per-step time of the slowest rank, faces
per rank, initialisation time) for estimating city-scale cases.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Exec/CanonicalTests/SEB/Phase8_BuildingSet: four buildings from a nodal
height map on a 480 m periodic domain at 10 m (a 60 m concrete slab, a
40 m brick cube east of it, two 20 m timber blocks), three materials by
building, a 3 m/s westerly, Boulder on the solstice from 05:00 for six
hours with the prescribed provider, the convective velocity scale and the
stability functions on, and the immersed forcing snapped to whole cells.

Checked: the four buildings and their materials; the residual (3e-8 W/m2
over 1444 rows); the mutual shadowing (at sunrise the cube's shadow lies
on the slab's east wall, 17 percent of its faces, clearing to 6 percent;
the 20 m blocks free of shadow by late morning); the building view
fractions of the facing walls (28 and 48 percent) against the far block's
(18); the timber roofs ending at 333 K against the concrete slab's 320 K;
w* on every sunlit face and 93 percent of the roofs unstable; the cost
line (0.4 ms per step for 157 faces per rank). The plot script draws the
per-building temperatures, the shadow fractions, a face map from above
and a yt slice.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…3 through a restart

The report row gained net-longwave and sensible-flux columns after phase 1
was written; they read the air temperature and wind, which the immersed
forcing does not restart bit-for-bit (2e-5 relative, see Phase5_Ground).
The geometry, skin and slab columns stay exact.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Single-precision builds (Apple Clang and SYCL, Mesh SINGLE) refused two
brace-initialised arrays in ERF_IBFaceSet.cpp that narrowed double to
float: the face-centre coordinates and the cell-centred velocity. Both
are now built from Real expressions.

The IB-aware YSUNew kernels read the surface height into a local that
seven of them never used; those locals are removed (no change in
behaviour).

codespell flagged the Fourier-number variable "Fo" in the slab solver
and its Python re-implementation, and a "tha" alias in the phase 6
check; renamed to Fourier and th_air.

Phase 6 and the PBL IB-awareness regtests pass on the rebuilt binary.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
plot_buildingset.py gains three figures from the existing run output:
every face coloured by the incident direct beam at 06:00, 08:00 and
10:00 solar time (a view from the south-east and a top-down map with
roofs as squares and wall columns as bars), and the horizontal wind in
the first two cells above the ground with a vertical slice of u through
the slab and the cube from the plotfile.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Conflicts in the MRF and YSUNew kernels, where development's PBLH work
(erf-model#3486) meets this branch's immersed-boundary awareness (local surface
ksurf and effective surface scales us_eff/ts_eff/qs_eff/ol_eff):

- MRF passes 2 and 4: keep development's convective/shear w* blend,
  evaluated on the column's local surface (theta_v at ksurf) with the
  effective u* and theta*, as the branch does for every other surface term.
- MRF K-profile: development's ghost-cell guard and notes, with the
  branch's k < ksurf (no diffusivity inside a solid) condition.
- YSUNew K-profile: development's rho guard, plus the branch's ksurf and
  effective Obukhov length.
- VH96 shear correction (new on development, off by default): use the
  effective u*. On columns without immersed cells it equals u*, so runs
  without erf.pbl_ib_aware are unchanged.

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

- M_PI is undefined on MSVC (C2065 in ERF_IBSEBSolar.H, which failed the
  WIN32 jobs of the previous head); the solar geometry and the reports use
  PI from ERF_Constants.H.
- The reduced face counts were `long`, which is 32-bit on Windows and has
  no ReduceLongSum overload; they are amrex::Long.
- init_ibseb() aborts on a non-uniform vertical grid (the face areas,
  heights and the ray cast take the level's constant cell sizes) and on
  erf.regrid_int > 0 (the face list is built once from the blanking);
  ibseb_report() bounds its level loop by the face sets it holds.
- Inputs.rst states the grid and regrid requirements.
- The comments describe the routines instead of the development phases.

Verified: a Debug build with MPI, no FFT, all warnings, assertions and
bound checking is warning-free and runs the 471 unit tests; the Phase 6
closure, external-flux and restart checks and the PBL_IBAware regtest pass
on 2 ranks; the six touched translation units compile in single precision.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The face detection takes every solid column of the blanking for a
building, so with erf.terrain_type = ImmersedForcing the terrain would be
put under the balance as well, which the documentation says it is not.
init_ibseb() now requires erf.buildings_type = ImmersedForcing and aborts
on terrain by immersed forcing; Inputs.rst says so.

IBSEB_Cube runs the Phase6_Prognostic cube (32x32x16, 40 steps, fixed sun,
prognostic skin, slab, heat flux into the air) and compares the plotfile
with its face diagnostics against a gold; nothing in CI exercised the
balance before. Passes in a Debug build with MPI and no FFT in 57 s on
2 ranks; a run with a different albedo fails the comparison.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Resolves the MRF conflict with erf-model#3972 (PBL passes on the tile work box):
the immersed-boundary work arrays of MRF and YSUNew now cover the same
grown tile box as the PBLH passes, and the blanking's halo is part of the
smoothing halo check.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Development merged erf-model#3972 and erf-model#3992, which put the PBL passes on a grown tile
work box; the IB-aware per-column arrays now live on that box too (merge
commit), and this registers PBL_IBAware_MRF_Tiling and
PBL_IBAware_YSUNew_Tiling in the tiling-parity harness: the 40 m cube under
a heated surface layer with a capped mixed layer, so the PBL height differs
between the roof columns and open ground. Tiled and untiled runs agree
bitwise for both schemes. The solar header takes PI from
ERF_NumericalConstants.H, as the constants split of erf-model#3994 asks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…at they test

Source/ImmersedBoundarySEB/IBSEB_DEVELOPMENT.md is a development log; it
is removed from the branch and listed in .gitignore so a local copy stays
private, and nothing in the code or the documentation refers to it.

The cases under Exec/CanonicalTests/SEB are renamed after what they test
(FaceStorage, Shortwave, Longwave, SensibleHeat, SlabConduction,
PrognosticSkin, IsolatedBuilding, WallFunction, BuildingSet) and their
READMEs, decks, scripts and the theory page no longer describe the work
as numbered phases.

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

@asalmgren asalmgren left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated correctness review of the diff (8 inline findings), posted as comments with no verdict attached.

Three findings concern erf.if_snap_partial_cells silently disabling existing immersed-forcing wall/roof treatments, one is a pblh convention mismatch between MRF and YSUNew under erf.pbl_ib_aware, and four are in the new erf.ibseb module.

Things that checked out: the slab tridiagonal coefficients and the affine slab_skin_response / Newton Jacobian are mutually consistent (the balance closes exactly against the implicit slab step); the solar geometry is self-consistent with the documented azimuth convention; the new ibseb_* plot variables line up between ERF.H::derived_names and the mf_comp sequence in Write3DPlotFile; m_fab_start[mfi.LocalIndex()] is valid in every consumer; and the default-off paths look bit-identical (snapb returns b unchanged, and with pbl_ib_aware = false the *_eff work arrays are plain copies with ksrf = klo, zib = 0). All six modified/added .cpp files compile clean against the existing CMake configuration.

myhalf * (t_blank_arr(i, j-1, k ) + t_blank_arr(i-1, j-1, k ));
Real t_blank_south_raw = (t_blank_xface_arr) ? snapb(t_blank_xface_arr(i, j-1, k )) :
myhalf * (snapb(t_blank_arr(i, j-1, k )) + snapb(t_blank_arr(i-1, j-1, k )));
const Real t_blank_south = (t_blank_south_raw < small_volfrac) ? zero : t_blank_south_raw;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

erf.if_snap_partial_cells makes the MOST roof wall function unreachable.

(The issue is on line 461 just below, and identically on line 636 in ImmersedForcingBuildings_Ymom; anchored here because 461 is not inside a diff hunk.)

const Real roof_mask = (t_blank > zero && t_blank <  t_blank_below && t_blank_above == zero && l_use_most) ? one : zero;

Once snapb() maps the blanking to {0, 1}, the top solid cell of a building has t_blank == 1 and t_blank_below == 1, so t_blank < t_blank_below can never be true. roof_mask is therefore identically zero everywhere whenever erf.if_snap_partial_cells = true.

Concrete scenario: a height-map building run with erf.buildings_type = ImmersedForcing and erf.if_use_most = true currently gets a log-law target velocity on its roof cells (the top cell is partially blanked, e.g. t_blank = 0.6 < 1, so roof_mask == 1 and the compute_if_most_target_vel branch fires). Turning on the new switch silently drops that branch: the roof cell no longer matches roof_mask, falls through to interior_mask == 1, and instead gets the full interior drag rho*CdM*ux*windspeed toward zero. No warning is printed, and the same happens for y-momentum at line 636.

Suggest either relaxing the roof test to t_blank <= t_blank_below (matching the <= already used by the side-wall masks on lines 462-463) or warning/aborting when if_snap_partial_cells and if_use_most are combined.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 41c458d. With the snap the roof is the top solid cell: the test is t_blank <= t_blank_below when erf.if_snap_partial_cells is on and stays < otherwise, so the raw-fraction path is unchanged and roof_mask fires on a snapped staircase (x- and y-momentum). PartialCells reran for 19000 steps with the roof law active: theta within 0.000 K of neutral, max |w| 0.48 m/s (was 0.60), the wake unchanged.

myhalf * (snapb(t_blank_arr(i, j-1, k )) + snapb(t_blank_arr(i-1, j-1, k )));
const Real t_blank_south = (t_blank_south_raw < small_volfrac) ? zero : t_blank_south_raw;

const Real dx_z = (z_cc_arr) ? (z_cc_arr(i,j,k) - z_cc_arr(i,j,k-1)) : dx_arr[2];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Under the snap, a wall cell gets the MOST wall target and the full interior drag at the same time.

(Refers to lines 462-467 just below, and 637-642 in ImmersedForcingBuildings_Ymom; anchored here because those lines are not inside a diff hunk.)

The side-wall masks use <=, while the interior mask tests for exactly 1:

const Real south_mask    = (t_blank > zero && t_blank <= t_blank_north && t_blank_south == zero && l_use_most) ? one : zero;
const Real interior_mask = (t_blank == 1.0) ? one : zero;

With snapb() forcing t_blank to exactly 0 or 1, the southern-most solid cell of a building satisfies south_mask == 1 and interior_mask == 1. Before the snap the two were mutually exclusive: MOST fired only on partially blanked cells (0 < t_blank < 1) and interior_mask only on fully blanked ones.

Concrete scenario: on a snapped building wall the code first adds -(u_target - ux) * rho * CdM * U_s to pull the face toward the log-law target from compute_if_most_target_vel, then immediately adds interior_mask * rho * CdM * ux * windspeed pulling the same face toward zero. Both are summed into drag (and into lambda for the point-implicit form), so the wall law is cancelled by the interior damping and erf.if_use_most degenerates into a no-slip staircase. If that is intended it should be stated, since the wall-law inputs (z0, the stability correction, the theta_surf reconstruction) are still evaluated at cost with no effect.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 41c458d. Under the snap interior_mask excludes any cell that carries a wall or roof law (t_blank == 1 && !(l_snap && most_mask > 0), in all three momentum components, with the roof drag counted in z), so a boundary cell gets the wall law alone and the interior cells the drag; the point-implicit lambda follows the same masks. With the snap off the masks are as before, so the exact-box path (where a boundary cell does carry both) is bit-identical; the DataStruct comment, Inputs.rst and the PartialCells README now say so.

const Real t_blank_north = snapb(t_blank_arr(i , j+1, k));
const Real t_blank_south = snapb(t_blank_arr(i , j-1, k));
const Real t_blank_east = snapb(t_blank_arr(i+1, j , k));
const Real t_blank_west = snapb(t_blank_arr(i-1, j , k));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Under the snap, the building-wall thermal boundary conditions become unreachable while roofs keep working.

(Refers to the else if on lines 1122-1125 below, and the identical wall test in the SURFACE HEAT FLUX block around line 1195+.)

} else if (((t_blank > zero && t_blank < t_blank_west && t_blank_east == zero) || ...

Each disjunct requires t_blank > 0 and t_blank < t_blank_<neighbour>, i.e. a strictly partially blanked cell. With snapb() above forcing t_blank into {0, 1}, t_blank < t_blank_west implies t_blank == 0, which fails t_blank > zero. No cell can ever satisfy the wall test.

The roof branch on line 1118 (t_blank > 0 && t_blank_above == zero && t_blank_below == one) is not affected and still fires for the top solid cell.

Concrete scenario: a deck that sets erf.if_snap_partial_cells = true together with erf.if_init_surf_temp (or erf.if_surf_temp_flux) on a height-map building silently heats only the roofs. The walls stop receiving any surface-temperature relaxation, with no diagnostic, and the run looks superficially fine. Worth either aborting when the two are combined, or reformulating the wall test in terms of the neighbour blanking alone (e.g. t_blank > zero && t_blank_west == one && t_blank_east == zero) so that it survives the snap.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 41c458d. The four wall faces are named once per cell (south_face etc.): with the snap they read the neighbour blanking (t_blank == 1 && t_blank_north == 1 && t_blank_south == 0 for the south face), without it the original t_blank < neighbour forms, and both the surface-temperature and the surface-heat-flux blocks use them. The roof test was left as it was.

zval = (use_terrain_fitted_coords)
? Compute_Zrel_AtCellCenter(i, j, kpbl, z_nd_arr)
: (kpbl + myhalf) * gdata.CellSize(2);
: ((kpbl + myhalf) * gdata.CellSize(2) - zib);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MRF and YSUNew adopt opposite pblh conventions under erf.pbl_ib_aware, yet both feed the same SurfLayer->set_pblh().

Here MRF subtracts zib from every height:

zval = ... : ((kpbl + myhalf) * gdata.CellSize(2) - zib);

and clamps with pblh_emp = myhalf * gdata.CellSize(2) (line 434), so pblh_pred_arr / pblh_corr_arr end up as a depth measured from the top of the immersed column.

Source/PBL/ERF_ComputeDiffusivityYSUNew.cpp does the opposite: zval stays absolute and the surface height is carried as z_sfc = zib (line 666), with pblh_min = z_sfc + 0.5*dz_terrain (line 681), so its pblh_corr_arr is a height from the domain bottom.

Each is internally consistent, but both then do pblh_out(i,j,k) = pblh_corr_arr(i,j,0) (MRF:688, YSU:1116) and SurfLayer->set_pblh(level, pblh_mf) (MRF:1213, YSU:2025), and every downstream consumer -- the Beljaars correction, the pblh plot variable, erf.ibseb.z_i_mode = pblh -- reads that single field with one interpretation.

Concrete scenario: a 40 m building with a 500 m mixed layer above its roof. With erf.pbl_type = MRF the stored pblh for those columns is 500; with erf.pbl_type = YSUNew and the identical state it is 540. One of the two must be wrong for any consumer of get_pblh(). Please pick a single convention (absolute height looks safer, since that is what the non-IB columns already report) and make both schemes match.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 41c458d, absolute height in both schemes as you suggest. MRF keeps its internal arrays relative to the immersed column (that is what its K profile and bounds want) and adds zib when it copies out: pblh_out = pblh_corr + zib and Turb_lengthscale = pblh_corr + zib, so get_pblh(), the pblh plot variable and Lturb carry one convention. zib is zero without erf.pbl_ib_aware, so nothing else moves. The TurbStruct comment and Inputs.rst state the convention, and the new PBL_IBAware_MRF_Tiling test checks the stored field varies and is tiling-invariant.

if (d == 2) {
Real zi = use_pblh ? pb(i, j, 0) : zi_fallback;
if (!(zi > 0.0 && zi < 1.0e5)) { zi = zi_fallback; }
depth = amrex::max(zi - (pzf[f] - z_lo), phb[f]);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

erf.ibseb.z_i_mode = "pblh" double-subtracts the roof height when combined with MRF + erf.pbl_ib_aware.

depth = amrex::max(zi - (pzf[f] - z_lo), phb[f]);

zi comes from pb(i,j,0), i.e. m_SurfaceLayer->get_pblh(lev), and this line treats it as a height above the ground by subtracting the roof-face height pzf[f] - z_lo. But with erf.pbl_ib_aware on and the MRF scheme, ComputeDiffusivityMRF already stores pblh relative to the top of the immersed column (see ERF_ComputeDiffusivityMRF.cpp:388, where zib is subtracted from every zval), so the roof height is removed twice.

Concrete scenario: a 200 m building with a 300 m mixed layer above the roof. MRF stores pblh = 300; this line computes depth = max(300 - 200, 200) = 200 m instead of 300 m, so w* = cbrt(g/theta * H/(rho c_p) * depth) is about 13% low on every roof face, propagating into Ut_eff, u*, H_coeff and the skin temperature. With YSUNew (which keeps pblh absolute) the same input gives the intended 300 m, so the answer also depends on which PBL scheme is selected. Settling the MRF/YSUNew convention would fix this too; otherwise this line needs to know which convention it is handed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved by the convention fix (41c458d): get_pblh() is the absolute height for both schemes, so subtracting the roof-face height here is now right with MRF as well as YSUNew.

const Real U = std::sqrt(uu[k] * uu[k] + vv[k] * vv[k]);
const Real dU = U - U1;
const Real rib = CONST_GRAV * (k * dz) * (th - th1) / (th1 * (dU * dU + ustar_floor2));
if (rib > ibseb_params.ri_crit) { z_i = geom[lev].ProbLo(2) + (k + 0.5) * dz - geom[lev].ProbLo(2); break; }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mixed height origins in ibseb_bulk_richardson_height.

if (rib > ibseb_params.ri_crit) { z_i = geom[lev].ProbLo(2) + (k + 0.5) * dz - geom[lev].ProbLo(2); break; }

The + ProbLo(2) ... - ProbLo(2) cancels, so the crossing branch returns (k + 0.5) * dz, a depth measured above ProbLo(2). The fallback initialised on line 205, Real z_i = z_top; with z_top = geom[lev].ProbHi(2) (line 201), is an absolute coordinate. The two disagree whenever ProbLo(2) != 0.

Concrete scenario: a domain with geometry.prob_lo = 0 0 500 and geometry.prob_hi = ... 2500. On a neutral profile (rib never exceeds ri_crit) the function returns z_i = 2500 m instead of the 2000 m domain depth, 25% too deep. That value is handed to compute_sensible() as z_i_bulk, becomes zi_fallback, and inflates w* on every face. Suggest dropping the dead arithmetic and using z_top = geom[lev].ProbHi(2) - geom[lev].ProbLo(2) so that both branches are depths above the domain bottom.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 41c458d: z_top = ProbHi(2) - ProbLo(2) and the crossing branch returns (k + 0.5) * dz, so both branches are depths above the domain bottom; the dead + ProbLo - ProbLo is gone and the doc comment says which origin is used.

fill(d_ustar, nf, Real(0.0));
fill(d_SW_abs, nf, Real(0.0));
fill(d_LW_net, nf, Real(0.0));
fill(d_H, nf, Real(0.0));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The previous step's d_H is not checkpointed, so erf.ibseb.convective_velocity = deardorff does not restart consistently.

save_state() writes only d_T_skin and d_T_slab into the IBSEBState field, and build() re-fills d_H with 0 here. compute_sensible() reads d_H as the previous step's flux to form the convective velocity scale:

if (conv && pH[f] > 0.0) { ... wstar = std::cbrt(CONST_GRAV / th * pH[f] / (rho * c_p) * depth); }

On restart, ERF::init_ibseb() does call compute_sensible() once, but with olen_ground = nullptr, pblh_ground = nullptr and z_i_bulk = 0 (so zi_fallback = m_params.z_i), and against the t_new atmosphere -- not the value solve_balance() left at the end of the pre-checkpoint step, which was computed with that step's old atmosphere and the converged skin temperature.

Concrete scenario: run 200 steps with erf.ibseb.convective_velocity = deardorff, checkpoint, restart. On the first step after the restart every face sees a different w* than the continuous run, hence a different Ut_eff, u*, H_coeff and skin temperature, and the two trajectories separate. The PR description states "the balance itself restarts exactly", which holds only for the default convective_velocity = none. Adding d_H (one more slot per face) to save_state() / load_state() would close this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 41c458d. The sensible flux is the second component of each slot of IBSEBState (six slots of 2 + n_layers), and init_ibseb() reloads the state after its initial diagnostics so the checkpointed flux, not the diagnostic one, is what the next step's convective scale reads. The WallFunction case now runs the Deardorff deck through a checkpoint at step 300 and compares the step-599 face dumps: identical to 0.0 with the fix; on the previous binary the same check fails with the skin 4e-3 K off, so it discriminates. The PR text about restarting exactly now holds for the convective scale too.

if (N < 1 || N > SLAB_MAX_LAYERS) return 0.0;
const amrex::Real Fourier = k * dt / (rho_cp * dz * dz); // interior Fourier number
// Coefficients of a l T_{l-1} + b_l T_l + c_l T_{l+1} = d_l (all-plus form).
amrex::Real alpha[SLAB_MAX_LAYERS], gamma[SLAB_MAX_LAYERS];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed 256-element stack arrays in a per-face GPU kernel, regardless of n_slab_layers.

amrex::Real alpha[SLAB_MAX_LAYERS], gamma[SLAB_MAX_LAYERS];

plus amrex::Real W[SLAB_MAX_LAYERS]; in slab_skin_response (line 107), on top of Real T[ibseb::SLAB_MAX_LAYERS]; in IBFaceSet::compute_ground (ERF_IBFaceSet.cpp:451) and IBFaceSet::solve_balance (ERF_IBFaceSet.cpp:485). With SLAB_MAX_LAYERS = 256 and an 8-byte Real that is roughly 8 KB of thread-local storage per face, independent of erf.ibseb.n_slab_layers -- the default of 4 layers pays exactly the same as 256.

Both call sites are amrex::ParallelFor bodies over device vectors, so on a GPU build every thread spills about 8 KB to local memory. Concrete scenario: solve_balance launched over 1e4 faces on an A100 -- each thread needs ~8 KB of local memory, so occupancy collapses to a couple of warps per SM and the per-step cost of the balance is dominated by local-memory traffic rather than by the Newton solve. The PR notes GPU is untested; this is the part most likely to bite there.

Suggest sizing the arrays from a much smaller compile-time bound (16 or 32 layers covers every realistic wall) and tightening the n_slab_layers check in IBSEBParams::init_params() to match -- no cost on CPU, and it makes these kernels usable on GPU.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 41c458d: SLAB_MAX_LAYERS = 32, erf.ibseb.n_slab_layers is checked against it in init_params() and Inputs.rst says [1, 32]. That is 3 x 32 x 8 bytes per thread across the two kernels instead of 8 kB.

@hgopalan

Copy link
Copy Markdown
Contributor Author

Re the plotfile documentation finding in the review body: fixed in 9510a3e. Docs/sphinx_doc/plotfiles/Plotfile3DReference.rst now lists ibseb_nfaces, ibseb_tskin, ibseb_sw_abs, ibseb_shadow, ibseb_lw_net, ibseb_f_sky, ibseb_H and ibseb_G in the variable table, each marked as requiring erf.ibseb.enable, with a paragraph after the table stating both properties you named: the names are dropped from the stream silently when the balance is off, and each value is the mean over the wall faces touching the cell (up to three at an outside corner, six in a one-cell slot), zero where there are none. The page builds clean under Sphinx.

Also in that commit, from the same review: the GNU Make wiring, the YSUNew mixing length above the column's surface, the removal of the dead utc_offset_hours input, and range checks on latitude_deg, longitude_deg and day_of_year. As a check on the inputs as a whole, every erf.ibseb.* key was perturbed one at a time on a 6-step run of the PrognosticSkin deck and each moves the face state (the skin bounds once set inside the range the faces reach).

@asalmgren asalmgren left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correctness review of the diff (code only; data/inputs/docs checked for name consistency). Five findings below. The first is a hard build break that I reproduced locally — it blocks everything else, including CI, so the rest have not been exercised.

#include <AMReX_REAL.H>
#include <AMReX_GpuQualifiers.H>
#include <ERF_NumericalConstants.H>
#include <ERF_SolarGeometry.H>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The branch does not compile — ERF_SolarGeometry.H does not exist.

This header is not present anywhere in the branch (or in origin/development), and the five functions the wrappers below call — compute_solar_declination, compute_solar_hour_angle, compute_solar_zenith_angle, compute_solar_azimuth_angle, compute_earth_sun_distance_factor — are defined nowhere either.

Scenario: any build at all. ERF_IBFaceSet.cpp includes this header, and it is added unconditionally by both CMake/BuildERFExe.cmake and Source/ImmersedBoundarySEB/Make.package, so every configuration fails — not just runs with erf.ibseb.enable = true. Reproduced locally with this repo's own compile flags:

ERF_IBSEBSolar.H:7:10: fatal error: ERF_SolarGeometry.H: No such file or directory

Probably the shared solar-geometry header lived on a branch that was renamed/reworked before merging. With a stub header in place every other file in this PR compiles clean (double and single precision, -Wall -Wextra -Wshadow), so this is the only build blocker.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8d20391 (reference outputs in 27073f4). The five solar functions (Spencer declination and equation of time, hour angle from UTC and longitude, zenith, clockwise-from-north azimuth, Earth-Sun distance) now live in ERF_IBSEBSolar.H itself, in the ibseb namespace; the header includes what it uses (AMReX_Algorithm.H for amrex::min, which a standalone compile of the header caught). They were the wrappers' targets before #4004 replaced the shared header with the CESM orbital formulation of ERF_OrbCosZenith.H, which has no azimuth, so the balance keeps its own. Verified bitwise against the removed ERF_SolarGeometry.H over one million random (day, latitude, longitude, time) samples and the four case sites (0 mismatches), so the sunrise case and the IBSEB_Cube gold are unchanged. The lesson taken: a new module includes only headers that are on development; a header from an open PR is copied, not included.

// With the snap every solid cell has t_blank = 1, so a roof cell is
// the top solid cell (t_blank <= t_blank_below) and a cell carrying a
// wall law is not also an interior cell.
const Real roof_mask = (t_blank > zero && (l_snap ? t_blank <= t_blank_below : t_blank < t_blank_below) && t_blank_above == zero && l_use_most) ? one : zero; // roof cell

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

erf.if_snap_partial_cells does not actually give a 0/1 face blanking in the default build, which is what these masks assume.

snapb() snaps the cell-centred blanking, but the momentum kernels then average two cells onto the face:

myhalf * (snapb(t_blank_arr(i,j,k)) + snapb(t_blank_arr(i-1,j,k)))

The face-centred arrays that would give a true 0/1 are only allocated under USE_FC_FACTORY, which defaults to 0 (Source/EB/ERF_EB.H:98), so t_blank_xface_arr is an empty Array4 and the averaging path is what actually runs.

Scenario: a default build with erf.if_snap_partial_cells = true. Every wall face gets t_blank == 0.5, not 1, so:

  • interior_mask (t_blank == 1.0) never fires on a wall face — there is no full blanking and no no-penetration condition, contrary to the comment above (“wall faces fully blanked”, “every solid cell has t_blank = 1”);
  • the <<= relaxation on this line fires on vertical east/west wall x-faces at the top row of a building (t_blank == t_blank_below == 0.5, t_blank_above == 0), handing a side face the horizontal-roof wall law it did not get before.

Same in ImmersedForcingBuildings_Ymom (line 651). Either snap the face value (and the cell-average fallback) to {0,1}, or restate the masks in terms of the cell blanking, which ImmersedForcingBuildings_Scalar already does correctly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8d20391 (reference outputs in 27073f4), and you are right about the default build: snapb() snapped the two cells and the average of 1 and 0 is 0.5 on every wall face, so interior_mask never fired there and the <= relaxation handed side faces the roof law. The momentum kernels now take a face's blanking, with the snap on, from the two cells it joins (solid when either is), in both builds, so the staircase does not depend on USE_FC_FACTORY; a face between a solid and a fluid cell is wall-normal and gets the interior drag toward zero (no penetration) whatever row it lies in, and a face between two solid cells carries the roof or wall law of its row or the interior drag, never both. With the snap off the expressions reduce to the previous ones exactly, so the raw-fraction path is bit-identical. Same in y and z. While at it: the terrain kernels (ImmersedForcingTerrain_*) had also been reading snapb(), and their wall law is weighted by the fluid fraction (1 - t_blank), which a snapped face has not, so the top faces of terrain got no forcing at all under the snap; the switch now applies to the buildings kernels only and the doc rows say so. The comment above the kernel, SolverChoice::if_snap_partial_cells and the Inputs.rst row describe the face rule. PartialCells rerun with the new faces (4 ranks, 19000 steps): theta within 0.000 K of neutral, max |w| 0.49 m/s (0.48 before), the wake's minimum u -0.47 m/s (-0.33 before, the wall-normal faces now damped to zero rather than half-weighted). BuildingSet (height map, 4 ranks, 6 h) passes all eight checks; the roof means at 11:00 move by about -2 K (slab 318.6, north block 329.5, cube 321.8, far block 329.4 K, from 320.2/332.2/323.7/332.1), w* 0.13-0.75 m/s and every roof unstable by then; the README numbers are refreshed.

// SurfaceLayer::get_pblh(). zib is zero without erf.pbl_ib_aware.
ParallelFor(tbx, [=] AMREX_GPU_DEVICE(int i, int j, int k) noexcept {
pblh_out(i, j, k) = pblh_corr_arr(i, j, 0);
pblh_out(i, j, k) = pblh_corr_arr(i, j, 0) + zib_arr(i, j, 0);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Anchored here because the line the problem is really on — ApplyPBLHSmoothing(...) at line 673 — is unchanged context and not in a diff hunk. This line is the other half of the same issue.)

PBLH smoothing and erf.pbl_ib_aware operate in different vertical frames.

With pbl_ib_aware, pbl_height_corrector now holds a height above each column's own immersed surface, and zib is only added here. ApplyPBLHSmoothing runs in between, at line 673.

Scenario: erf.pbl_ib_aware = true together with erf.enable_pblh_smoothing = true, over a building. The smoothing stencil averages relative heights across neighbouring columns whose surfaces differ by the full building height, mixing a roof-relative value with a ground-relative one, and only then is the per-column offset restored — so the stored PBLH near a building edge is wrong by roughly the building height times the stencil weight.

Either smooth after adding zib, or exclude IB columns from the stencil. Note the new PBL_IBAware_*_Tiling tests run with smoothing off (the default), so CI will not catch this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8d20391 (reference outputs in 27073f4). MRF now adds zib on the work box before ApplyPBLHSmoothing and takes it off after, so the stencil mixes absolute heights (the stored convention, as YSUNew already did), and both schemes keep the smoothed value at or above the column's own floor (half a cell above its surface, at least 10 m), which the smoothing could otherwise violate beside lower neighbours. Measured on the tiling deck with erf.enable_pblh_smoothing = true (2 ranks, 10 steps, old binary 9510a3e vs this one), stored PBLH along the row through the 40 m cube: at the roof edge 98.3 m before, 93.3 m now; on the ground column beside it 80.1 m before (it had been averaged with the roof's roof-relative value), 85.1 m now; the largest change is 10.0 m, the building height times the stencil weight. The floor mattered too: on a strongly stable sounding (ground columns at the 10 m floor) YSUNew stored 44.8 m on the roof-edge columns and 39.7 m on the corner ones, below the roof's own first cell at 45 m, and now stores 45.0; MRF's roof-edge column goes 53.5 -> 50.0 (its 10 m floor above the roof). zib is zero without erf.pbl_ib_aware, and the flat case is bit-identical (the tiling parity tests pass unchanged). New regression test PBL_IBAware_MRF_Smoothing (gold plotfile, the same deck with the smoothing on): the previous binary fails it with Lturb 10 percent off, so CI now covers the pair.

* non-positive interval disables both.
*/
void
ERF::ibseb_report (int nstep, Real time)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The [IBSEB] summary and CSV step number are off by one, and step 0 is written twice.

ERF::Evolve calls post_timestep(step, ...) with the 0-based loop index, while the step just completed is step + 1; the other reporters in post_timestep compensate (e.g. cloud_chamber_budget->report(nstep + 1, ...)). ibseb_report passes nstep straight through to IBFaceSet::report(time, nstep, ...).

Scenario: a fresh run with erf.ibseb.csv_int = 100. init_ibseb() writes a row labelled step=0 for the initial state; after the first step this function writes a second row also labelled step=0 (with a different time_s), and every later row is one step earlier than the matching plotfile number. csv_now = (nstep % csv_int == 0) inherits the same shift, so rows land after steps 1, 101, 201 rather than 100, 200, 300.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8d20391 (reference outputs in 27073f4): post_timestep passes nstep + 1, as its other reporters do, so the summary and the CSV rows carry the number of completed steps (the plotfiles' numbering); the initial report of init_ibseb() is step 0 and is written once, the rows land after steps csv_int, 2 csv_int, ... and the face dumps are tagged the same way. The case scripts that had adapted to the old labels (the sunrise and building-set checkers used (step + 1) dt as the time of a dump, the closure and Deardorff restart checks compared step000199 / step000599) now use the dump's own step, and the shortwave-energy check of the isolated building takes the shortwave of a dump as the one computed at the start of its last step. The READMEs' reference lines are relabelled.

if (!restart_chkfile.empty()) {
const std::string name = MultiFabFileFullPrefix(lev, restart_chkfile, "Level_", "IBSEBState");
if (FileExists(name + "_H")) {
restored = std::make_unique<MultiFab>(grids[lev], dmap[lev], m_ibseb[lev]->state_ncomp(), 0);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Restarting with a different erf.ibseb.n_slab_layers reads a mismatched checkpoint field.

restored is sized state_ncomp() = 6 * (2 + n_slab_layers) from the current inputs and handed straight to VisMF::Read on the next line, with no check against what the checkpoint holds.

Scenario: run with erf.ibseb.n_slab_layers = 4, checkpoint, then restart with n_slab_layers = 6. The on-disk IBSEBState has 36 components and the target has 48; the read either aborts inside VisMF or, worse, load_state() then indexes with the new slot stride (dir*2 + (side>0)) * (2 + nl) and silently loads skin temperatures and slab profiles into the wrong slots, so the restart continues from garbage face state.

Writing n_slab_layers alongside the field (or comparing against the header ncomp before reading) would turn this into a clear error.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8d20391 (reference outputs in 27073f4): init_ibseb() reads the on-disk width with VisMF(name).nComp() before the VisMF::Read and aborts when it differs from 6 x (2 + n_slab_layers) of the deck, naming the checkpoint's layer count and the deck's ("IBSEBState in chk00002 has 36 components, written with erf.ibseb.n_slab_layers = 4; the deck sets erf.ibseb.n_slab_layers = 6. Restart with the checkpoint's value."). The FaceStorage case now restarts its 4-layer checkpoint with n_slab_layers = 6 and requires that abort (README updated); a restart with the same count is unaffected.

…ed face blanking, smoothing frame, report steps, restart width

- The five solar functions live in ERF_IBSEBSolar.H again (erf-model#4004 replaced the shared header with the CESM orbital form, which has no azimuth); bitwise identical to the removed header over 1e6 samples; the header includes what it uses.
- With erf.if_snap_partial_cells the momentum kernels take a face's blanking from the two cells it joins (solid when either is), in both builds; a face between a solid and a fluid cell is wall-normal and gets the drag toward zero, a face between two solid cells the roof or wall law of its row or the interior drag, never both; the snap off is bit-identical; the terrain kernels no longer read the switch (their wall law is weighted by the fluid fraction).
- MRF smooths the PBL height in the absolute frame (zib on before ApplyPBLHSmoothing, off after) and both MRF and YSUNew keep the smoothed height at or above the column's own floor; new gold CTest PBL_IBAware_MRF_Smoothing (the previous binary fails it, Lturb 10 percent off).
- ibseb_report takes the number of completed steps (post_timestep's nstep + 1), so the step-0 report is written once and the rows land after csv_int, 2 csv_int, ...; the case scripts and READMEs follow.
- A restart whose erf.ibseb.n_slab_layers differs from the checkpoint's aborts naming both (VisMF header width); FaceStorage runs that negative case.
- Shortwave/Longwave rank-independence checks: geometry, view, shadow and shortwave exact, atmosphere columns to 1e-9 (the dump moved from step 1 to step 2).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…k slab within the 32-layer bound, reference outputs rerun

- SlabConduction's thick-slab deck used 200 layers, outside the 32-layer bound set in the first review round; it is now 32 mm in 1 mm layers over 20 s (the wave from the interior travels 4 mm and the skin side stays untouched), README and checker text follow.
- PartialCells (max |w| 0.49 m/s, wake -0.47 m/s), BuildingSet (roof means about 2 K lower with the wall-normal faces damped, every roof unstable), PrognosticSkin (step labels, 201 dumps) and FaceStorage reference outputs rerun with the new binary.

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

@asalmgren asalmgren left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of the erf.ibseb surface energy balance and the two immersed-boundary switches. I read every source hunk (~5,100 lines under Source/) and opened the surrounding code for context; the rest of the diff is docs, decks and gold files.

Five findings, left inline. The first is the one I would hold the merge on -- it is a silent capability loss in a feature the PR advertises, and it changes what the PartialCells regtest is actually testing. The other four are robustness and scaling concerns rather than correctness bugs in the CI configurations.

Things I checked and found correct:

  • Backward compatibility of the snapb/fb refactor in all four buildings kernels: with l_snap == false the expressions reproduce the previous ones exactly, including the k == 0 and face-centred-blanking branches.
  • if_snap_partial_cells forcing if_implicit_drag is ordered after the if_implicit_drag queryAdd, so it does take effect.
  • Plotfile component ordering: setPlotVariables rebuilds plot_var_names in derived_names order, and the new ibseb_* write block sits between terrain_IB_mask and volfrac, matching ERF.H.
  • m_fab_start[mfi.LocalIndex()] is safe -- every MFIter used shares grids[lev]/dmap[lev] with terrain_blanking[lev], and none is tiled or inside an OpenMP region.
  • terrain_blanking is rebuilt and FillBoundary-ed on both the fresh-start and restart paths before InitData_post() calls init_ibseb().
  • The slab tridiagonal coefficients, slab_skin_response linearity, the Newton Jacobian sign, the LW_net/e_eff algebra, and the H*A/(c_p*V*Pi) source conversion all check out dimensionally and algebraically.
  • Solar geometry (declination, hour angle, zenith, clockwise-from-north azimuth, sun vector) is self-consistent, and the DDA nudge t0*s always steps into the fluid column for both wall sides.
  • MRF's relative-height convention with the +/-zib around ApplyPBLHSmoothing (which does write into grow(xybx_tile, passes-1)) and YSUNew's absolute-height convention with only the floor clamp are each internally consistent; the ng_avail check now includes terrain_blank's halo.
  • Inputs.rst and Plotfile3DReference.rst cover all the new erf.* inputs and all eight ibseb_* plot variables, per the project convention.

Thank you for the thorough test coverage and documentation -- the per-term verification cases made this much easier to review.

// With the snap every solid face has t_blank = 1: a roof face lies in
// the top solid row (t_blank <= t_blank_below, the face above fluid),
// and a face carrying a wall law is not also an interior face.
const Real roof_mask = (!normal_face && t_blank > zero && (l_snap ? t_blank <= t_blank_below : t_blank < t_blank_below) && t_blank_above == zero && l_use_most) ? one : zero; // roof cell

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Under erf.if_snap_partial_cells, the wall/roof law here degenerates to no-slip.

compute_if_most_target_vel() (line 62, unchanged by this PR) returns

Real uTarget = (1 - t_blank) * ustar / KAPPA * (std::log(myhalf * delta / z0) - psi_m);

With the snap on, fb() returns max(snapb(c1), snapb(c2)), which is exactly 0 or 1. Every face that can set roof_mask / south_mask / north_mask requires both t_blank > zero and !normal_face (i.e. the two cells snap alike), so t_blank == 1 on every such face and uTarget is identically zero. bc_forcing_x then collapses to ux, and the three MOST branches below just relax the momentum toward zero at rate CdM * U_s (with U_s = 1). erf.if_z0, erf.if_stability_correction and the log profile have no effect at all on a snapped building.

The same (1 - t_blank) weighting also makes wall_mask and east_west_mask unreachable under the snap, since both require t_blank < one.

This contradicts the stated intent in the PR description ("a face between two solid cells [gets] the roof or wall law of its row"), and it means Exec/RegTests/ImmersedForcingTest/PartialCells is validating a no-slip staircase rather than a wall-modelled one. The (1 - t_blank) factor is a partial-cell weighting that no longer makes sense once the blanking is snapped -- it needs to be bypassed (or replaced by a snap-appropriate weight) on the snap path.

Same issue at the Ymom and Zmom mask blocks (lines 636 and 826).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 665b641, and thank you: with the snap t_blank was 1 on every face that carries a law, so (1 - t_blank) made the target zero and the PartialCells deck was indeed running a no-slip staircase. The snap path now passes a zero blanking to compute_if_most_target_vel() (t_blank_law), so a snapped boundary face takes the full log-law target u* / kappa (ln(delta/2 z0) - psi_m); the raw-fraction path keeps its partial-cell weight and is unchanged. The kernel comment, SolverChoice::if_snap_partial_cells and the Inputs row say so, and note that the partial-cell branches (wall_mask, east_west_mask) do not arise under the snap. run_partial_cells.sh now proves the law is live: two 400-step runs with erf.if_z0 0.01 and 0.1 must differ in x_velocity (max |du| 0.20 m/s with this commit; the previous binary gives exactly 0). The 19000-step run and BuildingSet rerun with the law live: PartialCells (19000 steps): theta within 0.000 K of neutral, max |w| 0.57 m/s (0.49 with the no-slip faces), the wake's minimum u -0.41 m/s (-0.47). BuildingSet (height map, 6 h): all eight checks pass; the roof means at 11:00 rise by about 1 K with the log-law walls (slab 319.6, north block 331.1, cube 323.0, far block 331.0 K), w* 0.12-0.74 m/s, 96 percent of the roofs unstable; the README numbers are refreshed.

ERF::ibseb_write_checkpoint (const std::string& checkpointname, int lev) const
{
if (!ibseb_params.enable || lev >= static_cast<int>(m_ibseb.size()) || !m_ibseb[lev]) { return; }
MultiFab state(grids[lev], dmap[lev], m_ibseb[lev]->state_ncomp(), 0);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The face state is checkpointed through a full 3-D MultiFab over the whole level: state_ncomp() is 6 * (2 + n_slab_layers), i.e. 36 components at every cell with the default n_slab_layers = 4, and 204 at the allowed maximum of 32 -- to carry data that only exists on a thin shell of building faces.

On the 32x32x16 CI case this is invisible. On a realistic urban deck (say 1024x1024x100) it is on the order of 30 GB of extra checkpoint per level, plus the same amount of transient allocation inside WriteCheckpointFile, dwarfing the conserved state and likely OOM-ing the run at checkpoint time.

A face-indexed side file, or a sparse layout restricted to the building bounding boxes, would avoid this without changing the restart semantics.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 665b641. IBSEBState no longer lives on the level's grids: IBFaceSet::build() now also derives, from a reduced block-height map, a BoxArray of 8 x 8 column blocks that reach up to the highest face-owning cell of each block (state_boxarray()), and the field is defined on those boxes, so it scales with the built volume rather than the domain (on the 1024 x 1024 x 100 example with 30-cell buildings over a fifth of the columns that is a few GB against 30, and nothing where there are no buildings). A transfer layer keeps the restart semantics: each rank's faces are written into a global BoxArray of its own grids cut by the state boxes (AllGatherBoxes, owner = the rank the grid belongs to), ParallelCopy moves it onto the field's distribution, and the reverse on restart, so a checkpoint restarts on any rank count. init_ibseb() compares the on-disk BoxArray with the one the blanking gives and aborts naming both if they differ (a checkpoint of other buildings), next to the layer-count check; a level without faces writes nothing. FaceStorage restarts the 4-rank checkpoint on 1 rank and requires the same last CSV row as the 4-rank restart (both exact against the straight run); the debug summary prints the field's boxes and cells against the level's (on the FaceStorage deck, a 128^3 level with one skyscraper: 12 boxes, 15,616 of the level's 2,097,152 cells, 2.1 MB on disk against 288 MB before). The transient in WriteCheckpointFile is the same two sparse fields. On the four-building BuildingSet, the checkpoint written at the end of the six hours restarts on 4 and on 2 ranks with identical skin and slab temperatures on all 432 faces after two further steps (fluxes to 1e-10).

// Largest blanking in each column of this rank; reduced below so every
// rank labels the same solid columns. The highest solid cell of each
// column gives the column top for the ray cast.
std::vector<Real> colmax(static_cast<size_t>(nx) * ny, 0.0);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

build() allocates four full-domain host arrays of size nx*ny (colmax, colk, h_col_top, label), does two nx*ny all-reduces (lines 158-159), and uploads an nx*ny device copy (d_col_top, line 168) on every rank.

At city scale (4096x4096 columns) that is roughly 130 MB of host temporaries plus 33 MB of device memory per rank, and two 33 MB all-reduces. Multiplied by ranks-per-node this is a real OOM risk, and it is not covered by the performance caveats in the PR description, which mention only the ray-sampling cost.

Distributing the column arrays over the box array, or restricting them to the buildings' bounding box, would bound this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 665b641 as far as a replicated column map allows: build() first reduces the bounding box of the solid columns and allocates colmax, colk, the column tops, the labels and the device copy over that box only (8 bytes per built column per rank), and the two ray walks look a column up through ibseb::column_top(), which returns open ground outside the box; the faces' building ids and heights index the box. On an isolated building or a district that is a small fraction of the domain, which is what the cases run; a city that fills the domain still carries an nx x ny map per rank, which the ray cast needs because a ray can cross the domain. That is now stated in the PR's performance row, with a distributed column map as the follow-up if the replicated one ever binds. Shortwave and Longwave rerun: every view-fraction, shadow and shortwave column of the face dumps is identical to the previous run's (max difference 0.0 over 2616 faces), and IBSEB_Cube's gold is unchanged.

for (int k = 1; k < nz; ++k) {
const Real th = rth[k] / rho[k];
const Real U = std::sqrt(uu[k] * uu[k] + vv[k] * vv[k]);
const Real dU = U - U1;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dU is the difference of horizontal-mean speeds (U = sqrt(uu[k]^2 + vv[k]^2)), but the function's own doc comment specifies |U(z) - U_1|^2 -- the magnitude of the vector shear.

For a veering profile (an Ekman spiral, or any case where the wind turns while keeping its magnitude) the true shear can be large while dU is near zero. The denominator then falls back to the 100*ustar^2 = 1 m^2/s^2 floor, Ri_b is overestimated, and z_i comes back far too shallow -- which feeds straight into w* and the Deardorff wall function.

Suggest const Real dU2 = (uu[k]-uu[0])*(uu[k]-uu[0]) + (vv[k]-vv[0])*(vv[k]-vv[0]); and using that directly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 665b641: dU2 = (u(z) - u_1)^2 + (v(z) - v_1)^2 of the horizontal-mean wind vector, as the doc comment specified, so a veering wind of constant speed counts as shear. The WallFunction bulk-Richardson case (a unidirectional profile) gives the same depth as before.

amrex::Real dt, int N, amrex::Real& a, amrex::Real& b)
{
amrex::Real W[SLAB_MAX_LAYERS];
for (int l = 0; l < N; ++l) { W[l] = T[l]; }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

advance_slab_dirichlet guards its stack arrays with if (N < 1 || N > SLAB_MAX_LAYERS) return 0.0; (line 55); slab_skin_response has no such check and copies N entries into a Real W[SLAB_MAX_LAYERS].

This is safe today only because IBSEBParams::init_params() rejects n_slab_layers > 32. If that input bound is ever raised, or a caller passes N from somewhere else, this is a silent stack overflow instead of the graceful return 0.0 the sibling routine gives. Worth adding the same guard for symmetry.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 665b641: slab_skin_response() returns a = b = 0 for N < 1 or N > SLAB_MAX_LAYERS, as advance_slab_dirichlet() does, so the two agree if the input bound is ever raised or another caller passes a larger N.

hgopalan and others added 2 commits September 15, 2026 10:54
… snap, checkpoint field on column blocks, column map over the built box, vector shear, slab guard

- With erf.if_snap_partial_cells the boundary solid faces take the full log-law target (the partial-cell weight 1 - t_blank had made them no-slip); PartialCells now requires that erf.if_z0 changes the flow (0.20 m/s after 400 steps; the previous binary gave exactly 0).
- IBSEBState lives on 8 x 8 column blocks up to the highest face-owning cell around the buildings, with a transfer layer (this rank's grids cut by the blocks, AllGatherBoxes, ParallelCopy) so a restart works on any rank count; init_ibseb aborts on a checkpoint of other buildings; FaceStorage restarts the 4-rank checkpoint on 1 rank and rejects a rotated height map. On the 128^3 FaceStorage level the field covers 15616 of 2097152 cells (2.1 MB against 288 MB).
- The ray cast's column map, labels and building heights cover the bounding box of the built columns; the two ray walks look a column up through ibseb::column_top(), open ground outside; the shortwave and longwave dumps are unchanged.
- The bulk Richardson depth uses |U(z) - U_1|^2 of the wind vector; slab_skin_response guards N like advance_slab_dirichlet.
- Reference outputs rerun: PartialCells (w 0.57, wake -0.41), BuildingSet (roof means 319.6/331.1/323.0/331.0 K), PrognosticSkin closure lines.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Follows the fourth review of erf-model#3960, which left the checkpoint scaling with
the built volume and the ray cast's column map replicated at full width on
every rank.

- IBSEBState goes on 4 x 4 column blocks clipped to the k-range that owns
  faces, instead of 8 x 8 blocks running from the ground to the roof. Face-
  owning cells are the fluid cells against solid, a one-cell shell, so a
  block over the interior of a wide footprint now keeps only the layer above
  the roof while a block on a wall still spans its height.
- The field carries one slot per face of a cell, numbered in the (dir, side)
  order build() already walks, rather than a fixed six; its width is the
  largest face count on any cell of the level, which build() reproduces from
  the same blanking on restart. Three nodal fields would have been the
  obvious way to drop the slot index, but nodal block boxes share their faces
  at block boundaries and the ParallelCopy of save_state() would have two
  destinations for one node with only one of them written.
  On FaceStorage the field is now 32 boxes over 7872 cells at 18 components
  against 15616 cells at 36, a quarter of the size; n_slots is 3 there
  because three faces meet on a cell at the rim, and it is never worse than
  the six the layout used to assume.
- The slot numbering is relative, so it would shift if a cell's face list
  depended on the decomposition. It cannot -- the blanking carries
  ComputeGhostCells() + 2 ghost cells -- and build() now asserts the ghost
  layer it rests on.
- The column map of the ray cast drops from three Real arrays plus a mask to
  one int array of the highest solid cell of each column: colmax was
  redundant (a column is solid exactly when its top is not -1), h_col_top
  held the same fact again as a height, and ibseb::column_top() now builds
  the height from the index. That is 4 bytes per built column per rank
  instead of 24, and one all-reduce instead of two. The flood-fill label and
  stack are scoped so they free before the per-face uploads.
- Docs and the FaceStorage README follow the new layout, and the theory notes
  record what still scales with the built area.

FaceStorage, Shortwave and Longwave pass on 1 and 4 ranks: the checkpoint
round trip and its redistribution onto another rank count, both restart
aborts, 0/2616 shadow mismatches against the checkers' independent ray cast
at both zenith angles with the recorded 738 and 644 shadowed faces, and
0/2616 view-fraction mismatches against the independent hemisphere sampling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@asalmgren

Copy link
Copy Markdown
Collaborator

I have opened two follow-up issues for the parts of my earlier review that this PR narrows but does not close, so they are not lost when it merges:

  • erf.ibseb: store the checkpoint face state by face rather than as a cell field #4019 — store the checkpoint face state by face rather than as a cell field. The successive reductions of IBSEBState have brought it a long way down, but a cell field covering the shell with whole boxes still cannot be proportional to the face count: on the FaceStorage level it is 141696 values for 2056 faces, about 11.5x the data it carries.
  • erf.ibseb: distribute the ray-cast column map and the building labelling instead of replicating them #4020 — distribute the ray-cast column map and the building labelling. The map is much cheaper per column now, but it is still replicated at full width on every rank, so the cost scales with the domain rather than with a rank's share of it. The issue records the reason an exact fix is possible: both ray walks are already bounded in height, so a halo sized from the shallowest sampled elevation is lossless rather than approximate. The blocker is that the building labelling is a global connected-components problem.

Neither is a correctness problem and neither needs to hold up this PR; both are about behaviour at city scale. I am also pushing a commit to this branch that takes the cheap, answer-preserving part of each: the checkpoint field goes onto 4 x 4 blocks clipped to the k-range that owns faces and carries one slot per face of a cell instead of a fixed six (a quarter of the size on FaceStorage), and the column map collapses from three Real arrays plus a mask to one int array with a single all-reduce. FaceStorage, Shortwave and Longwave pass on one and four ranks, including 0/2616 shadow and view-fraction mismatches against the checkers' independent ray cast and hemisphere sampling.

@asalmgren
asalmgren merged commit 5fb78cb into erf-model:development Sep 15, 2026
0 of 47 checks passed
hgopalan added a commit to hgopalan/ERF that referenced this pull request Sep 15, 2026
…f-model#3960

The 24 h day passes unchanged; the table now starts from the initial state (step 0) and two last digits move with the relabelled dumps.

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

Copy link
Copy Markdown
Contributor Author

On 3d95fc7 (the 4 x 4 blocks clipped to the face-owning k-range, one slot per face of a cell, the integer column map): rebuilt and rerun here on top of the fourth-round fixes. Release and Debug (assertions + bound check, all warnings) build clean; 556 gtests; the four IBSEB / IB-aware CTests pass with the golds unchanged; FaceStorage, Shortwave, Longwave, SlabConduction, WallFunction and PrognosticSkin pass, the shadow / view-fraction / shortwave columns of the dumps bitwise equal to the previous layout's; a fresh checkpoint of the four-building BuildingSet (27 boxes, 1520 of 36864 cells at 3 slots) restarts on 4 and on 2 ranks with identical skin and slab temperatures on all 432 faces, and the checkpoint written by the 8 x 8 layout is rejected by the width check as it should be. GNU Make links. One thing the CI's single-precision jobs would have caught: std::max(m_col_top_max, plo[2] + (coltop_k[c] + 1.0) * dx[2]) mixes Real and double and does not deduce when Real is float; f219f20 casts the height to Real (identical in double). The IsolatedBuilding day was rerun as well after 665b641 (a0de343 refreshes its table, which now starts from the initial state).

asalmgren pushed a commit that referenced this pull request Sep 15, 2026


The 24 h day passes unchanged; the table now starts from the initial state (step 0) and two last digits move with the relabelled dumps.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
hgopalan added a commit to hgopalan/ERF that referenced this pull request Sep 15, 2026
Merge upstream/development (IBSEB, erf-model#3960 and erf-model#4022) into ERF-Fire
asalmgren added a commit that referenced this pull request Sep 15, 2026
…IBSEB

PR #3860 turned ERF::m_SurfaceLayer from a single unique_ptr into one entry
per domain face, and PR #3960 added ibseb_advance, which reads the surface
layer for the wall function beyond neutral. The two merged independently and
git merged them cleanly, so development did not compile: ERF_IBSEB.cpp still
dereferenced m_SurfaceLayer as a pointer.

Index zlo. What the wall function needs is the ground beneath the buildings;
a surface layer on a lateral or upper wall says nothing about the stability
of the column, and this is the same face that the other zlo-only consumers
select (see ERF.cpp:876).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
WeiqunZhang pushed a commit that referenced this pull request Sep 16, 2026
…IBSEB

PR #3860 turned ERF::m_SurfaceLayer from a single unique_ptr into one entry
per domain face, and PR #3960 added ibseb_advance, which reads the surface
layer for the wall function beyond neutral. The two merged independently and
git merged them cleanly, so development did not compile: ERF_IBSEB.cpp still
dereferenced m_SurfaceLayer as a pointer.

Index zlo. What the wall function needs is the ground beneath the buildings;
a surface layer on a lateral or upper wall says nothing about the stability
of the column, and this is the same face that the other zlo-only consumers
select (see ERF.cpp:876).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
asalmgren added a commit that referenced this pull request Sep 16, 2026
* Add split/merge AMR API stubs + flag + Redistribute hook

* Implement SplitMergeAtLevelBoundary: 3-part split-new/merge-departees/repopulate

* Implement SplitParticlesForRefinement (regrid-time): split coarse particles in newly-refined cells

* Implement MergeParticlesAfterDerefining (regrid-time): masked merge in de-refined cells plus escapee absorption

* Wire SplitParticlesForRefinement / MergeParticlesAfterDerefining into regrid + FillPatch S_new before microphysics

* Use SuperDropletsRealIdx/IntIdx (dev names) instead of stale SoA aliases

* Skip refRatio scaling of coalescence bins when split/merge AMR is on (splitting keeps per-cell SD density uniform across levels)

* Normalize active flag to 0/1 when used as a count weight in deposition + diagnostics (active=2 split tag must not double-count)

* Generalize SDM split-merge tag to level+1 (Phase 1 of multi-level redesign)

* Cumulative finest-level split in SplitParticlesForRefinement (Phase 2)

* Fold MergeParticlesAfterDerefining into per-level tag-normalizing sweep (Phase 3)

* Resync particle container dummy MFs at start of SplitParticlesForRefinement to fix MFIter assert after regrid creates/remakes a level

* Redistribute particles before SDM regrid-time split; iterate leaf level with per-tag cumulative factor so the m_particles state stays consistent with the post-regrid BoxArrays

* Restrict SDM Part 3 empty-cell repopulation to same coarse parent to prevent SD leakage and runaway L1 growth under count-based refinement

* Add ventilation factor (Bayley et al. 2025) to SDM condensation/evaporation phase change

* Revert per-level Part 2 no-host branch to retag-only to fix evaporation bias in long moist runs (matches per-step behaviour of 215b705)

* Add MergeParticlesAtDerefinement regrid-time pass to reduce np_bin to np_bin/merge_factor in cells that lost fine-level coverage, capping SD count growth from oscillating qt_refine in moist runs

* WIP: gradual SDM ice melting (Seifert-Beheng 2006 rate) with mixed ice-water super-droplets; replaces instantaneous melt, untested

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

* Add SDM_Box3D_IceMelt regression test seeding pure ice in a warm box that melts partially over 10 steps, exercising the gradual melt model [run-ci]

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

* Restrict SDM AMR merges to same log-radius bin (per-cell adaptive range, N_R = target SDs/cell) so only same-size super-droplets combine, preserving the rain-seeding large-droplet tail

* Add multiplicity floor to per-step SplitMergeAtLevelBoundary Part 1 split: entrants with multiplicity < split_factor are retagged native instead of split, preventing sub-unity ghost super-droplets

* Integer multiplicity split at both SDM split sites: each piece gets floor(mult/k) droplets plus one each of the remainder, keeping multiplicities integer >=1 and conserved so coalescence cannot produce sub-unity ghost super-droplets

* Fix missing PlaneAverage include in ERF_AdvanceDycore.cpp

* using std::ceil for density scaling of multiplicities to avoid non-integer and <1 values

* Implemented Gunn-Kinzer clamp on vterm calculation

* Bound wet/mixed-ice terminal velocity: blend Boehm dry-ice speed toward the equivalent raindrop speed by liquid water fraction (Frick et al. 2013), fixing the IceBohm divergence for near-melted ice cores

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* updated gold files

* Update SDM theory docs: gradual melting and mixed ice-water particles, plus terminal-velocity equations (raindrop 4 mm clamp, Bohm ice, Frick mixed-phase blend) with citations

* Add terminal-velocity unit tests (Gunn-Kinzer clamp, Bohm ice vs Boehm 1989 Fig.3, Frick melt blend) on host and device; factor the Frick blend into TerminalVelocity helpers

* Allow seeded/injected ice particles to take a prescribed apparent density (low-density snowflakes) via ice_apparent_density; default keeps the solid-ice sphere

* Add ice deposition-growth unit test (Chen-Lamb habit vs Takahashi 1991) on host and device: grows a 2um crystal 10 min at constant T + water saturation via the MassChange_SV routines, reproducing the plate/column habit transitions

* TerminalVelocity gtest: add EvalFromCsv mode (CSV-driven, phase-routed fall speed replicating the SD advection step) for Monte-Carlo diagnosis of anomalous terminal velocities

* SDM sublimation: release a fully-sublimated ice core back to a dry aerosol (m_ice=0, a=c=rime=nmono=0) instead of pinning a 1 nm ice sphere -- fixes the spurious IceBohm settling of sub-resolution remnants (residual large terminal velocities / Dane FPE); mirrors the full-melt path

* SDM freezing: if the freezing water is below the minimum ice mass (an essentially dry aerosol carrying only trace water), leave it as a dry aerosol instead of seeding a sub-floor ice crystal -- stops the spurious 1 nm ice remnants at the source (complements the sublimation-side release)

* SDM IceFrzDep test: seed resolvable droplet water so freezing yields ice above the minimum-ice floor

The trace default water (4.19e-42 kg) now freezes to a dry aerosol rather
than ice, leaving the test with no ice to grow. Seed 1.0e-14 kg of water per
super-droplet so the frozen crystal is well above the minimum ice mass, and
regenerate the gold plotfile.

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

* SDM MultiSpecies gold: regenerate after the trace-water-to-aerosol change

The freezing/sublimation fixes release trace default water as a dry aerosol,
which shifts the noise-level species fields in this test. Update the gold.

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

* SDM SV habit table: function-local static constexpr for host+device

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

* Fixed precision-related errors and warnings

* Bugfix: visc coeff needs to be converted to SI units

* TerminalVelocity test: recenter graupel band, add Stokes-regime check

* TerminalVelocity test: dump L-H habit + ice-sphere(+Stokes) curves for the size figure

* Updated ice benchmarks

* Add SDM microphysics unit tests: condensation, melting/sublimation, INAS freezing, coalescence

* Factor riming helpers into header; add riming unit tests

* Add fractional super-droplet injection with SDM_Bubble2D_FracInjection ctest [run-ci]

* Clean up branch-unique SDM inputs (DYCOMS/GATE/TempSourceCold); sync DYCOMS with run-repo

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* SDM: add prescribed_w steady-updraft advection option

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NiSV5oMFPUeWiTBnLuQU7K

* Add SDM ice-process ctests (sublimation/melting/freezing/aggregation/coalescence/riming)

Drop the redundant 0D box IceSub/IceMelt tests; EXTRA collision/freezing tests gated and CPU-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NiSV5oMFPUeWiTBnLuQU7K

* Add SDM ice-microphysics example inputs and READMEs in Exec

Sublimation/melting/freezing/riming columns (TemperatureSourceSpatial_cold) and the 0D ice-aggregation box (Bubble), with prescribed soundings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NiSV5oMFPUeWiTBnLuQU7K

* Removing sublimation shaft test for AMD GPUs

* SDM: per-box high-multiplicity injection [run-ci]

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016y46YhAwKTUV94hTCkfwEy

* Add ice_aggregation_efficiency input for SDM ice-ice aggregation (default 0.1)

* switch kernel velocity terms

* Fix fill boundary issue

* Rebuild FFT solvers on restart

* Fixed GATE readme

* SDM riming: at T>=0C route the collected droplet to meltwater instead of freezing it as rime -- guards the supercooled Heymsfield-Pflaum rime density (erf_esati=0 above 0C gives inf/inf in iceSurfaceTemperature; a CPU FPE, silently masked on GPU by std::min(-0.01,NaN)=-0.01)

* SDM: allow zero-mass constant aerosols; require at least one non-zero aerosol per mode

* SDM: add erf.random_seed input to vary the RNG (init sampling + collisions) per run

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NiSV5oMFPUeWiTBnLuQU7K

* SDM/particles: fix OOB indexing, div-by-zero, half-step drift, ghost fill, leak, mean free path [run-ci]

* SDM/particles: fix tiled in-place saturation ratio, RNG race, zeta convention, subsidence reduction, precip wiring [run-ci]

* Add OpenMP, ASan, terrain-SDM, tiled and single-rank test coverage for particle/SDM paths [run-ci]

* Narrow recycle terrain clamp to the lookup, clamp tail index instead of truncating, drop rank-variant test [run-ci]

* Add in-repo gold files for SDM_Bubble2D_WoA and SDM_Bubble2D_Tiled [run-ci]

* Use ThreadSanitizer for the threaded job; assert the sampled tail index is in range [run-ci]

* Update RadiationSimple to current IRadiation interface (double time/dt, updated_lsm)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd1XcC2jtby94LspffcTMb

* Restore lsf_data/nudge_data sizing and the RadiationSimple branch lost when upstream relocated the constructors

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd1XcC2jtby94LspffcTMb

* Advertise ice, snow and graupel plot variables for the super-droplet moisture model

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd1XcC2jtby94LspffcTMb

* Drop input keys upstream no longer reads; correct recycle-bounds names and terrain text in the SDM docs

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd1XcC2jtby94LspffcTMb

* Rename TemperatureSourceSpatial_cold to SDM_Congestus3D_cold to match the warm case

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd1XcC2jtby94LspffcTMb

* Reject a species that is already modeled; drop the duplicate ice entry from the aggregation decks

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd1XcC2jtby94LspffcTMb

* Remove local agent config and notes accidentally committed under .claude

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd1XcC2jtby94LspffcTMb

* Point LC CI back at the main gold-file ref

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd1XcC2jtby94LspffcTMb

* Restore SDM_Congestus3D and SDM_RICO3D golds as whole plotfiles; the merge had mixed two box layouts

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd1XcC2jtby94LspffcTMb

* Strip trailing whitespace from new decks and profile output

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd1XcC2jtby94LspffcTMb

* Regenerate SDM_SublimationShaft, SDM_Congestus3D and SDM_RICO3D golds after the upstream merge

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd1XcC2jtby94LspffcTMb

* Drop the ThreadSanitizer and AddressSanitizer CI jobs

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd1XcC2jtby94LspffcTMb

* Run the gated SDM cases as assertion-checked smoke tests in the GNU build

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd1XcC2jtby94LspffcTMb

* Fix single-precision build: keep the injected super-droplet count in Real [run-ci]

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd1XcC2jtby94LspffcTMb

* Leave the coalescence box tests out of the smoke pass; the kernels have unit tests

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd1XcC2jtby94LspffcTMb

* Update the moisture capability truth table for the super-droplet ice variables

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd1XcC2jtby94LspffcTMb

* Fix single-precision build in the large-scale forcing update; drop an unused variable [run-ci]

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd1XcC2jtby94LspffcTMb

* Temporarily point LC CI at the SDM_w_ice gold-file ref

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd1XcC2jtby94LspffcTMb

* Revert "Temporarily point LC CI at the SDM_w_ice gold-file ref"

This reverts commit d3f78f3.

* Rename fo to nbr_off and ofs, and fix a hyphenated word, to satisfy codespell

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd1XcC2jtby94LspffcTMb

* Build the super-droplet microphysics unit tests only when particles are enabled

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd1XcC2jtby94LspffcTMb

* Silence unused-parameter, unused-variable and shadow warnings in the cold-process code

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd1XcC2jtby94LspffcTMb

* Rename the inner reduction locals so they no longer shadow the ReduceData object

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd1XcC2jtby94LspffcTMb

* Review fixes: drop duplicate theta nudging, index LSF/nudge data by level, write valid tiles only, guard moisture paths, correct riming pressure units, conserve meltwater on sub-freezing riming

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd1XcC2jtby94LspffcTMb

* Review fixes: correct the momentum subsidence gradient and k-range, set lsf.start_time, parenthesize NVAR_max and restore the moist-state comparison

* Review fixes: guard log10 in the riming fit, gate the ghost refresh on Lagrangian moisture, drop redundant syncs and dead params, assert on malformed LSF input, document the LSF/nudging/seed options

* Compare against the signed Long returned by amrex::Vector::size()

* Temporarily point LC CI at the SDM_w_ice gold-file ref

* Revert "Temporarily point LC CI at the SDM_w_ice gold-file ref"

This reverts commit 63623d5.

* Regenerate in-repo golds for the gated SDM tests and drop the particle data from them

* Guard the ice-category active tag, convert erf_esati to Pa for the riming heat balance, call snowAccumulation and emit the accumulation fields in canonical order

* Regenerate the riming gold and keep the plotfile-selection semantic check on graupel accumulation

* Clean up old code

* Update unit test for new diffusion call signature

* Fix warning

* Update goldfile branch

* Cleanup diffusion

* Remove unused variables

* Update checkpoint names used for single face case

* Update PBL MOST average indices

* Add missing test

* Turn off FFT option for CI

* Fix warnings

* Add gold file for MOST walls test

* Skip test if not built with FFT enabled

* Add option for FFT to LC CI and temporarily enable

* Style

* Add doc section for surface layer on sides

* Remove goldfile treated as text

* Readd goldfile as binary

* Move LC gitlab goldfile ref back to main

* Fix stress mapping on x face

* Fix review issues

* Minor cleanup for plotfile variables

* Update gold files for new changes

* Fix unused warnings

* Fix extra ghost cells for z face in compute_fluxes

* Fix more unused parameter warnings

* Add fixes from review

* Generalize tau transpose writes for face corners

* Add unit tests for SurfaceLayer and MOSTAverage on side walls

* Update gold file

* Cleanup MOSTAverage k index functions and add tests

* Fix codespell error

* Split out unit tests into serial and parallel, add tiling checks

* Fix tiling issues for planar average/qsurf

* Updates for grid check and lateral boundary fill

* Add extra asserts for unsupported options

* Add additional asserts on layouts, update tiling and no-terrain paths

* Remove unused w rot field and add additional checks

* Update k indices for non terrain to be wall-relative

* Add check for regional average difference

* Update gold file

* Update new get_ng_fill and extrap function for lateral faces

* Cleanup redundant runtime assertions

* Fix warnings and CUDA/HIP builds

* Fix CUDA extended lambda in unit tests

* Fix another CUDA extended lambda issue

* Fixes from review

* Update tests to work with new constructor change

* Fix issue for SST test

* Fix zref interval for face orientation

* Update zref handling for explicit specified value

* Fix surface layer check to be face aware after incoming changes

* Fixes from review

* Reduce test max_step

* More fixes from review

* Fixes from review

* Generalize PlanarBoundary for any face

* Update regional average test with PlanarBoundary behavior

* Fix bounds issue for rans

* Fix error from new upstream changes to use zlo SurfaceLayer

* Fix RRTMGP SurfaceLayer temperature conversion

* Polish RRTMGP surface temperature regression

* Fixes from review

* Update qsurf tests to still check intended lateral behavior

* Fix the build: take the ground face of the per-face surface layer in IBSEB

PR #3860 turned ERF::m_SurfaceLayer from a single unique_ptr into one entry
per domain face, and PR #3960 added ibseb_advance, which reads the surface
layer for the wall function beyond neutral. The two merged independently and
git merged them cleanly, so development did not compile: ERF_IBSEB.cpp still
dereferenced m_SurfaceLayer as a pointer.

Index zlo. What the wall function needs is the ground beneath the buildings;
a surface layer on a lateral or upper wall says nothing about the stability
of the column, and this is the same face that the other zlo-only consumers
select (see ERF.cpp:876).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Detect legacy surface-layer inputs from what the user wrote, not the live table

With more than one surface-layer face, ERF decides whether to keep reading the
historical unqualified erf.most.* / erf.surface_layer.* keys for zlo by asking
whether any such key exists. It asked ParmParse directly, which cannot answer
the question: ERF_InputSoundingData.H queries most.surf_temp and most.surf_moist
with queryAdd while reading the sounding, and queryAdd inserts the default it
was handed whenever the key is absent. By the time InitData_post runs, both keys
are in the table holding their negative sentinels whether or not the user ever
typed them.

Every input_sounding run with more than one surface-layer face therefore looked
as though it carried legacy inputs. A deck that also wrote the face-qualified
spelling -- Tests/test_files/ABL_MOST_Cloudchamber, for one -- hit the "both
spellings present" abort and could not start at all; a deck without it silently
took the legacy path.

Snapshot the inputs table at the top of ReadParameters, which is the last point
at which presence in the table still means the user wrote it, and scan the
snapshot. This stays correct if another erf.most.* queryAdd is added elsewhere
later, which a fix targeting the two known keys would not.

ABL_MOST_Cloudchamber is the regression test: it aborts before this change and
initializes cleanly after it.

Also document the prefixing rule in SurfaceLayer.rst -- unqualified for a lone
zlo face, face-qualified once a second face is declared, unqualified applied to
zlo with a warning, both spellings an error -- along with the configurations
that now abort away from zlo, which the page previously covered only as "not
all existing options are supported".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix gmake. (#4028)

* more fixes for gmake. (#4029)

* SurfaceLayer::fill_qsurf_with_qsat: intersect with the cell box of the nodal heights

The loop box is cell-centred and z_phys_nd is nodal, so `gtbx &= z_phys_nd->fabbox()`
mixes index types, and with AMReX_ASSERTIONS every run that reaches this function
aborts at initialisation (sameType in AMReX_Box.H). The kernel reads nodes
i..i+1, j..j+1, k..k+1 through Compute_Zrel_AtCellCenter, so the right limit is
the nodal box converted to cells.

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

* Set up case for noah + rad, remove unused inputs, fix wps ml inputs.

* clean up inputs.

* Address comments.

---------

Co-authored-by: Debojyoti Ghosh <debojyoti.ghosh@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Cole Kendrick <kendrick6@llnl.gov>
Co-authored-by: Emma Catherine Ware <ware10@llnl.gov>
Co-authored-by: Debojyoti Ghosh <ghosh5@llnl.gov>
Co-authored-by: Kyle <kyle.pressel@pnnl.gov>
Co-authored-by: Ann Almgren <asalmgren@lbl.gov>
Co-authored-by: Harish <harish.gopalan@nrel.gov>
asalmgren added a commit that referenced this pull request Sep 16, 2026
* Add split/merge AMR API stubs + flag + Redistribute hook

* Implement SplitMergeAtLevelBoundary: 3-part split-new/merge-departees/repopulate

* Implement SplitParticlesForRefinement (regrid-time): split coarse particles in newly-refined cells

* Implement MergeParticlesAfterDerefining (regrid-time): masked merge in de-refined cells plus escapee absorption

* Wire SplitParticlesForRefinement / MergeParticlesAfterDerefining into regrid + FillPatch S_new before microphysics

* Use SuperDropletsRealIdx/IntIdx (dev names) instead of stale SoA aliases

* Skip refRatio scaling of coalescence bins when split/merge AMR is on (splitting keeps per-cell SD density uniform across levels)

* Normalize active flag to 0/1 when used as a count weight in deposition + diagnostics (active=2 split tag must not double-count)

* Generalize SDM split-merge tag to level+1 (Phase 1 of multi-level redesign)

* Cumulative finest-level split in SplitParticlesForRefinement (Phase 2)

* Fold MergeParticlesAfterDerefining into per-level tag-normalizing sweep (Phase 3)

* Resync particle container dummy MFs at start of SplitParticlesForRefinement to fix MFIter assert after regrid creates/remakes a level

* Redistribute particles before SDM regrid-time split; iterate leaf level with per-tag cumulative factor so the m_particles state stays consistent with the post-regrid BoxArrays

* Restrict SDM Part 3 empty-cell repopulation to same coarse parent to prevent SD leakage and runaway L1 growth under count-based refinement

* Add ventilation factor (Bayley et al. 2025) to SDM condensation/evaporation phase change

* Revert per-level Part 2 no-host branch to retag-only to fix evaporation bias in long moist runs (matches per-step behaviour of 215b705)

* Add MergeParticlesAtDerefinement regrid-time pass to reduce np_bin to np_bin/merge_factor in cells that lost fine-level coverage, capping SD count growth from oscillating qt_refine in moist runs

* WIP: gradual SDM ice melting (Seifert-Beheng 2006 rate) with mixed ice-water super-droplets; replaces instantaneous melt, untested

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

* Add SDM_Box3D_IceMelt regression test seeding pure ice in a warm box that melts partially over 10 steps, exercising the gradual melt model [run-ci]

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

* Restrict SDM AMR merges to same log-radius bin (per-cell adaptive range, N_R = target SDs/cell) so only same-size super-droplets combine, preserving the rain-seeding large-droplet tail

* Add multiplicity floor to per-step SplitMergeAtLevelBoundary Part 1 split: entrants with multiplicity < split_factor are retagged native instead of split, preventing sub-unity ghost super-droplets

* Integer multiplicity split at both SDM split sites: each piece gets floor(mult/k) droplets plus one each of the remainder, keeping multiplicities integer >=1 and conserved so coalescence cannot produce sub-unity ghost super-droplets

* Fix missing PlaneAverage include in ERF_AdvanceDycore.cpp

* using std::ceil for density scaling of multiplicities to avoid non-integer and <1 values

* Implemented Gunn-Kinzer clamp on vterm calculation

* Bound wet/mixed-ice terminal velocity: blend Boehm dry-ice speed toward the equivalent raindrop speed by liquid water fraction (Frick et al. 2013), fixing the IceBohm divergence for near-melted ice cores

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* updated gold files

* Update SDM theory docs: gradual melting and mixed ice-water particles, plus terminal-velocity equations (raindrop 4 mm clamp, Bohm ice, Frick mixed-phase blend) with citations

* Add terminal-velocity unit tests (Gunn-Kinzer clamp, Bohm ice vs Boehm 1989 Fig.3, Frick melt blend) on host and device; factor the Frick blend into TerminalVelocity helpers

* Allow seeded/injected ice particles to take a prescribed apparent density (low-density snowflakes) via ice_apparent_density; default keeps the solid-ice sphere

* Add ice deposition-growth unit test (Chen-Lamb habit vs Takahashi 1991) on host and device: grows a 2um crystal 10 min at constant T + water saturation via the MassChange_SV routines, reproducing the plate/column habit transitions

* TerminalVelocity gtest: add EvalFromCsv mode (CSV-driven, phase-routed fall speed replicating the SD advection step) for Monte-Carlo diagnosis of anomalous terminal velocities

* SDM sublimation: release a fully-sublimated ice core back to a dry aerosol (m_ice=0, a=c=rime=nmono=0) instead of pinning a 1 nm ice sphere -- fixes the spurious IceBohm settling of sub-resolution remnants (residual large terminal velocities / Dane FPE); mirrors the full-melt path

* SDM freezing: if the freezing water is below the minimum ice mass (an essentially dry aerosol carrying only trace water), leave it as a dry aerosol instead of seeding a sub-floor ice crystal -- stops the spurious 1 nm ice remnants at the source (complements the sublimation-side release)

* SDM IceFrzDep test: seed resolvable droplet water so freezing yields ice above the minimum-ice floor

The trace default water (4.19e-42 kg) now freezes to a dry aerosol rather
than ice, leaving the test with no ice to grow. Seed 1.0e-14 kg of water per
super-droplet so the frozen crystal is well above the minimum ice mass, and
regenerate the gold plotfile.

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

* SDM MultiSpecies gold: regenerate after the trace-water-to-aerosol change

The freezing/sublimation fixes release trace default water as a dry aerosol,
which shifts the noise-level species fields in this test. Update the gold.

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

* SDM SV habit table: function-local static constexpr for host+device

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

* Fixed precision-related errors and warnings

* Bugfix: visc coeff needs to be converted to SI units

* TerminalVelocity test: recenter graupel band, add Stokes-regime check

* TerminalVelocity test: dump L-H habit + ice-sphere(+Stokes) curves for the size figure

* Updated ice benchmarks

* Add SDM microphysics unit tests: condensation, melting/sublimation, INAS freezing, coalescence

* Factor riming helpers into header; add riming unit tests

* Add fractional super-droplet injection with SDM_Bubble2D_FracInjection ctest [run-ci]

* Clean up branch-unique SDM inputs (DYCOMS/GATE/TempSourceCold); sync DYCOMS with run-repo

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* SDM: add prescribed_w steady-updraft advection option

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NiSV5oMFPUeWiTBnLuQU7K

* Add SDM ice-process ctests (sublimation/melting/freezing/aggregation/coalescence/riming)

Drop the redundant 0D box IceSub/IceMelt tests; EXTRA collision/freezing tests gated and CPU-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NiSV5oMFPUeWiTBnLuQU7K

* Add SDM ice-microphysics example inputs and READMEs in Exec

Sublimation/melting/freezing/riming columns (TemperatureSourceSpatial_cold) and the 0D ice-aggregation box (Bubble), with prescribed soundings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NiSV5oMFPUeWiTBnLuQU7K

* Removing sublimation shaft test for AMD GPUs

* SDM: per-box high-multiplicity injection [run-ci]

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016y46YhAwKTUV94hTCkfwEy

* Add ice_aggregation_efficiency input for SDM ice-ice aggregation (default 0.1)

* switch kernel velocity terms

* Fix fill boundary issue

* Rebuild FFT solvers on restart

* Fixed GATE readme

* SDM riming: at T>=0C route the collected droplet to meltwater instead of freezing it as rime -- guards the supercooled Heymsfield-Pflaum rime density (erf_esati=0 above 0C gives inf/inf in iceSurfaceTemperature; a CPU FPE, silently masked on GPU by std::min(-0.01,NaN)=-0.01)

* SDM: allow zero-mass constant aerosols; require at least one non-zero aerosol per mode

* SDM: add erf.random_seed input to vary the RNG (init sampling + collisions) per run

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NiSV5oMFPUeWiTBnLuQU7K

* SDM/particles: fix OOB indexing, div-by-zero, half-step drift, ghost fill, leak, mean free path [run-ci]

* SDM/particles: fix tiled in-place saturation ratio, RNG race, zeta convention, subsidence reduction, precip wiring [run-ci]

* Add OpenMP, ASan, terrain-SDM, tiled and single-rank test coverage for particle/SDM paths [run-ci]

* Narrow recycle terrain clamp to the lookup, clamp tail index instead of truncating, drop rank-variant test [run-ci]

* Add in-repo gold files for SDM_Bubble2D_WoA and SDM_Bubble2D_Tiled [run-ci]

* Use ThreadSanitizer for the threaded job; assert the sampled tail index is in range [run-ci]

* Update RadiationSimple to current IRadiation interface (double time/dt, updated_lsm)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd1XcC2jtby94LspffcTMb

* Restore lsf_data/nudge_data sizing and the RadiationSimple branch lost when upstream relocated the constructors

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd1XcC2jtby94LspffcTMb

* Advertise ice, snow and graupel plot variables for the super-droplet moisture model

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd1XcC2jtby94LspffcTMb

* Drop input keys upstream no longer reads; correct recycle-bounds names and terrain text in the SDM docs

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd1XcC2jtby94LspffcTMb

* Rename TemperatureSourceSpatial_cold to SDM_Congestus3D_cold to match the warm case

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd1XcC2jtby94LspffcTMb

* Reject a species that is already modeled; drop the duplicate ice entry from the aggregation decks

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd1XcC2jtby94LspffcTMb

* Remove local agent config and notes accidentally committed under .claude

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd1XcC2jtby94LspffcTMb

* Point LC CI back at the main gold-file ref

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd1XcC2jtby94LspffcTMb

* Restore SDM_Congestus3D and SDM_RICO3D golds as whole plotfiles; the merge had mixed two box layouts

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd1XcC2jtby94LspffcTMb

* Strip trailing whitespace from new decks and profile output

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd1XcC2jtby94LspffcTMb

* Regenerate SDM_SublimationShaft, SDM_Congestus3D and SDM_RICO3D golds after the upstream merge

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd1XcC2jtby94LspffcTMb

* Drop the ThreadSanitizer and AddressSanitizer CI jobs

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd1XcC2jtby94LspffcTMb

* Run the gated SDM cases as assertion-checked smoke tests in the GNU build

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd1XcC2jtby94LspffcTMb

* Fix single-precision build: keep the injected super-droplet count in Real [run-ci]

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd1XcC2jtby94LspffcTMb

* Leave the coalescence box tests out of the smoke pass; the kernels have unit tests

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd1XcC2jtby94LspffcTMb

* Update the moisture capability truth table for the super-droplet ice variables

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd1XcC2jtby94LspffcTMb

* Fix single-precision build in the large-scale forcing update; drop an unused variable [run-ci]

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd1XcC2jtby94LspffcTMb

* Temporarily point LC CI at the SDM_w_ice gold-file ref

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd1XcC2jtby94LspffcTMb

* Revert "Temporarily point LC CI at the SDM_w_ice gold-file ref"

This reverts commit d3f78f3.

* Rename fo to nbr_off and ofs, and fix a hyphenated word, to satisfy codespell

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd1XcC2jtby94LspffcTMb

* Build the super-droplet microphysics unit tests only when particles are enabled

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd1XcC2jtby94LspffcTMb

* Silence unused-parameter, unused-variable and shadow warnings in the cold-process code

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd1XcC2jtby94LspffcTMb

* Rename the inner reduction locals so they no longer shadow the ReduceData object

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd1XcC2jtby94LspffcTMb

* Review fixes: drop duplicate theta nudging, index LSF/nudge data by level, write valid tiles only, guard moisture paths, correct riming pressure units, conserve meltwater on sub-freezing riming

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd1XcC2jtby94LspffcTMb

* Review fixes: correct the momentum subsidence gradient and k-range, set lsf.start_time, parenthesize NVAR_max and restore the moist-state comparison

* Review fixes: guard log10 in the riming fit, gate the ghost refresh on Lagrangian moisture, drop redundant syncs and dead params, assert on malformed LSF input, document the LSF/nudging/seed options

* Compare against the signed Long returned by amrex::Vector::size()

* Temporarily point LC CI at the SDM_w_ice gold-file ref

* Revert "Temporarily point LC CI at the SDM_w_ice gold-file ref"

This reverts commit 63623d5.

* Regenerate in-repo golds for the gated SDM tests and drop the particle data from them

* Guard the ice-category active tag, convert erf_esati to Pa for the riming heat balance, call snowAccumulation and emit the accumulation fields in canonical order

* Regenerate the riming gold and keep the plotfile-selection semantic check on graupel accumulation

* Clean up old code

* Update unit test for new diffusion call signature

* Fix warning

* Update goldfile branch

* Cleanup diffusion

* Remove unused variables

* Update checkpoint names used for single face case

* Update PBL MOST average indices

* Add missing test

* Turn off FFT option for CI

* Fix warnings

* Add gold file for MOST walls test

* Skip test if not built with FFT enabled

* Add option for FFT to LC CI and temporarily enable

* Style

* Add doc section for surface layer on sides

* Remove goldfile treated as text

* Readd goldfile as binary

* Move LC gitlab goldfile ref back to main

* Fix stress mapping on x face

* Fix review issues

* Minor cleanup for plotfile variables

* Update gold files for new changes

* Fix unused warnings

* Fix extra ghost cells for z face in compute_fluxes

* Fix more unused parameter warnings

* Add fixes from review

* Generalize tau transpose writes for face corners

* Add unit tests for SurfaceLayer and MOSTAverage on side walls

* Update gold file

* Cleanup MOSTAverage k index functions and add tests

* Fix codespell error

* Split out unit tests into serial and parallel, add tiling checks

* Fix tiling issues for planar average/qsurf

* Updates for grid check and lateral boundary fill

* Add extra asserts for unsupported options

* Add additional asserts on layouts, update tiling and no-terrain paths

* Remove unused w rot field and add additional checks

* Update k indices for non terrain to be wall-relative

* Add check for regional average difference

* Update gold file

* Update new get_ng_fill and extrap function for lateral faces

* Cleanup redundant runtime assertions

* Fix warnings and CUDA/HIP builds

* Fix CUDA extended lambda in unit tests

* Fix another CUDA extended lambda issue

* Fixes from review

* Update tests to work with new constructor change

* Fix issue for SST test

* Fix zref interval for face orientation

* Update zref handling for explicit specified value

* Fix surface layer check to be face aware after incoming changes

* Fixes from review

* Reduce test max_step

* More fixes from review

* Fixes from review

* Generalize PlanarBoundary for any face

* Update regional average test with PlanarBoundary behavior

* Fix bounds issue for rans

* Fix error from new upstream changes to use zlo SurfaceLayer

* Fix RRTMGP SurfaceLayer temperature conversion

* Polish RRTMGP surface temperature regression

* Fixes from review

* Update qsurf tests to still check intended lateral behavior

* Fix the build: take the ground face of the per-face surface layer in IBSEB

PR #3860 turned ERF::m_SurfaceLayer from a single unique_ptr into one entry
per domain face, and PR #3960 added ibseb_advance, which reads the surface
layer for the wall function beyond neutral. The two merged independently and
git merged them cleanly, so development did not compile: ERF_IBSEB.cpp still
dereferenced m_SurfaceLayer as a pointer.

Index zlo. What the wall function needs is the ground beneath the buildings;
a surface layer on a lateral or upper wall says nothing about the stability
of the column, and this is the same face that the other zlo-only consumers
select (see ERF.cpp:876).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Detect legacy surface-layer inputs from what the user wrote, not the live table

With more than one surface-layer face, ERF decides whether to keep reading the
historical unqualified erf.most.* / erf.surface_layer.* keys for zlo by asking
whether any such key exists. It asked ParmParse directly, which cannot answer
the question: ERF_InputSoundingData.H queries most.surf_temp and most.surf_moist
with queryAdd while reading the sounding, and queryAdd inserts the default it
was handed whenever the key is absent. By the time InitData_post runs, both keys
are in the table holding their negative sentinels whether or not the user ever
typed them.

Every input_sounding run with more than one surface-layer face therefore looked
as though it carried legacy inputs. A deck that also wrote the face-qualified
spelling -- Tests/test_files/ABL_MOST_Cloudchamber, for one -- hit the "both
spellings present" abort and could not start at all; a deck without it silently
took the legacy path.

Snapshot the inputs table at the top of ReadParameters, which is the last point
at which presence in the table still means the user wrote it, and scan the
snapshot. This stays correct if another erf.most.* queryAdd is added elsewhere
later, which a fix targeting the two known keys would not.

ABL_MOST_Cloudchamber is the regression test: it aborts before this change and
initializes cleanly after it.

Also document the prefixing rule in SurfaceLayer.rst -- unqualified for a lone
zlo face, face-qualified once a second face is declared, unqualified applied to
zlo with a warning, both spellings an error -- along with the configurations
that now abort away from zlo, which the page previously covered only as "not
all existing options are supported".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix gmake. (#4028)

* more fixes for gmake. (#4029)

* SurfaceLayer::fill_qsurf_with_qsat: intersect with the cell box of the nodal heights

The loop box is cell-centred and z_phys_nd is nodal, so `gtbx &= z_phys_nd->fabbox()`
mixes index types, and with AMReX_ASSERTIONS every run that reaches this function
aborts at initialisation (sameType in AMReX_Box.H). The kernel reads nodes
i..i+1, j..j+1, k..k+1 through Compute_Zrel_AtCellCenter, so the right limit is
the nodal box converted to cells.

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

* Canonical_RANS checks: a range check must fail outside its band

Report.check(kind="range") compared the distance outside [lo, hi] against half the
band width instead of zero, so a band [lo, hi] accepted [lo - w/2, hi + w/2] with
w = hi - lo. The midpoint and half width are still computed for the printed table;
only the verdict changes.

test_rans_checks.py states what each kind of comparison must decide for values
inside and just outside the tolerance or band, 14 cases, and runs as the
RANS_Checks_SelfTest entry (labels rans and unit; pure Python, no ERF run). On the
old logic it reports 11 of 14 and exits non-zero, disagreeing exactly on the three
range cases.

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

* Tests: guard the RANS self-test on a found interpreter, unpin a band edge

Two follow-ups to the range-check fix.

RANS_Checks_SelfTest is the only test carrying the "unit" label that is not
a built binary, and "ctest -L unit" runs in the gcc, macos, ci and windows
workflows. Its command is ERF_RANS_PYTHON, which falls back to the bare
name "python3" when find_package(Python3 ... QUIET) comes up empty, so a
configuration without that executable on PATH -- the MSVC job being the
likely one -- would fail the whole unit stage on a test that runs no ERF
code. Register it only under Python3_Interpreter_FOUND.

check_stable.py's "theta(k=0) - imposed surface theta" band had its lower
edge at exactly 0.0, a hard physical boundary. The old half-width slack
absorbed 0.75 K below it; with the band now enforced exactly, any transient
or round-off putting the first cell a hair below the imposed surface value
fails the check. Move the edge to -0.05 K, small enough that a real
unstable surface layer is still caught.

Note the other --physics range bands (check_stable.py 41, 55, 58;
check_convective.py 47, 57; check_hill.py 144, 149; check_hill3d.py 127,
130) have not been rerun under the strict comparison; only the band whose
edge sat on a physical zero is changed here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Debojyoti Ghosh <debojyoti.ghosh@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Cole Kendrick <kendrick6@llnl.gov>
Co-authored-by: Emma Catherine Ware <ware10@llnl.gov>
Co-authored-by: Debojyoti Ghosh <ghosh5@llnl.gov>
Co-authored-by: Kyle <kyle.pressel@pnnl.gov>
Co-authored-by: Ann Almgren <asalmgren@lbl.gov>
Co-authored-by: Aaron M. Lattanzi <103702284+AMLattanzi@users.noreply.github.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.

2 participants