Skip to content

Nonuniform dz - #79

Open
magpowell wants to merge 5 commits into
microhh:developfrom
magpowell:nonuniform-dz
Open

Nonuniform dz#79
magpowell wants to merge 5 commits into
microhh:developfrom
magpowell:nonuniform-dz

Conversation

@magpowell

@magpowell magpowell commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Overview

The Monte Carlo ray tracer required an evenly spaced vertical grid. This PR removes that restriction (enables arbitrary, non-uniform vertical grids) for the forward shortwave and longwave tracers, the backward tracer, and the test drivers.

Changes

The simplest change is we replace the constant dz multiplies within the tau to k_ext and count to flux conversion calculations with a local dz.

The null collision grid requires changes too:

  • It is now also non-uniform, with null-cell boundaries on every n-th model interface. Placing boundaries on model interfaces keeps k_null >= k_ext by construction; boundaries at fixed heights would cut through layers and could locally underestimate cloud extinction.
  • At a potential collision we need both k_null and k_ext to decide probabilistically whether a true collision occurred. We know the z of the potential collision, but we need the physical layer that contains it (for k_ext) and the null cell that contains it (for k_null). On an equidistant grid this was a cheap int(z/dz). Replaced with a lookup table binned at the finest layer thickness: one read plus at most one correction, a small fixed cost per photon instead of a search.

Longwave emission positions are now sampled from the interface heights; the emission energy weighting is tau based and needs no change.

Tests

Cabauw, uniform grid, old code vs this PR

  • When the layer count divides the null cell count the old and new code are bit identical. The old and new code agree to roundoff (heating rates within 1e-7 K/day, per pixel fluxes within 1e-2 W/m2).
  • When the layer count does not divide the null cell count, the new code places null-cell boundaries at layer edges while the old code put them at fixed heights inside layers. Both bounds are valid, but the two codes take different random walks through the same atmosphere (HR and SW flux agree at Monte Carlo noise).

Same null grid boundaries:
fig1_null_test

Different null grid boundaries:
When the layer count does not divide the null cell count, the old code placed boundaries at fixed heights inside layers. With the constant-dz fast path the new code defaults back to those same boundaries on uniform grids, so this case is also bit identical.

RCEMIP on a uniform and stretched grid

  • We hold the inputs fixed (same cloud scenes) and only vary the grids (mass conserving remapping). Using the offline ray tracer on RCEMIP scenes, we ran 3D RT on a 144 level stretched grid and on a uniform 40 m grid with 806 levels, remapping the scene conservatively between them.
  • Shortwave and longwave fluxes and heating agree at Monte Carlo noise and the energy budget closes. Mean differences over twelve scenes are below 0.1 W/m2.
  • How do we know it's MC noise and not a grid effect? A grid effect would not depend on the samples per pixel (spp), but the MC noise does: going from 256 to 4096 spp (16x) shrinks the per pixel RMSE about 4x (direct 2.0 to 0.6 W/m2, diffuse 1.3 to 0.3 W/m2), as expected for MC noise.

SW surface fluxes (one timeslice):
fig2_grid_invariance

LW heating rates and surface flux (one timeslice):
fig4_lw_invariance

The spike in the heating rate difference sits at the cloud boundary: the mass conserving remap smears the cloud edge within one 40 m cell and slightly shifts the location of peak heating rate. The difference cancels in the column.

Pixel-wise comparison (12 timeslices):
fig3_pixel_fluxes

Performance

On uniform grids the radiation step costs 3.8% more. On uniform grids performance is unchanged (uses constant-dz path). Stretched grids need far fewer levels which improves overall performance. The RCEMIP scenes here use 144 levels where the equivalent uniform grid needs 806, a 5.6x reduction in cells, and the ray tracer runs ~1.2x faster on the same scene.

__device__
inline int height_to_int(const Float z, const Float* z_lev, const int* z_lut, const Float lut_dz, const int lut_size, const int ntot_max)
{
int k = z_lut[min(max(static_cast<int>(z / lut_dz), 0), lut_size-1)];

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.

here we are finding which physical layer the possible collision occurs. k is an index for z_lev such that z_lev[k] <= z < z_lev[k+1]

while (k > 0 && z < z_lev[k])
--k;
while (k < ntot_max-1 && z >= z_lev[k+1])
++k;

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.

this is a correction, a bin in the lookup table can overlap with at most two layers. the LUT stores the layer of the bin's lower edge so it's a possible upward correction.

inline int height_to_int(const Float z, const Float* z_lev, const int* z_lut, const Float lut_dz, const int lut_size, const int ntot_max)
{
int k = z_lut[min(max(static_cast<int>(z / lut_dz), 0), lut_size-1)];
while (k > 0 && z < z_lev[k])

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.

for floating point safety

dz_min = std::min(dz_min, z_lev({k+2}) - z_lev({k+1}));

const Float zsize = z_lev({nz+1});
const int lut_size = static_cast<int>(std::ceil(zsize/dz_min));

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.

we use the thinnest physical grid spacing (dz_min) to set the table vertical resolution

Comment thread src_test/test_rt_lite.cu
Comment thread include_rt/raytracer_lw.h
const Raytracer_definitions::Vector<int> grid_cells,
const Raytracer_definitions::Vector<Float> grid_d,
const Raytracer_definitions::Vector<int> kn_grid,
const Array_gpu<Float,1>& z_lev,

@magpowell magpowell Jul 23, 2026

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.

z_lev: physical grid interface heights
kn_z_lev: null-cell boundary heights (kn_ktot+1 vals), a subset of z_lev, so each null cell holds a whole number of layers (~ktot/kn_ktot each).
z_lut: height bin (k) to physical layer index table
kn_z_lut: height bin (k) to null cell index table

most of the code changes are just carrying these around. the ray tracer kernels can no longer derive k_ext and k_null from a single dz, each lookup needs the two tables. Once built, it's fairly cheap: per photon cost is one read, one compare.

const Float kext_tot = tau_tot[idx] / grid_d.z;
const Float kext_cld = tau_cld[idx] / grid_d.z;
const Float kext_aer = tau_aer[idx] / grid_d.z;
const Float dz = z_lev[iz+1] - z_lev[iz];

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.

switching to a local dz

const Float kext_tot = tau_tot[idx] / grid_d.z;
const Float kext_cld = tau_cld[idx] / grid_d.z;
const Float kext_aer = tau_aer[idx] / grid_d.z;
const Float dz = z_lev[iz+1] - z_lev[iz];

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.

local dz


const int km = k - 1;
photon.position.z = (km + rng()) * grid_d.z;
photon.position.z = z_lev[km] + rng() * (z_lev[km+1] - z_lev[km]);

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.

again using local not fixed dz

Comment thread src_test/test_rt_lite.cu
Status::print_message("Storing the bw raytracer output.");

auto nc_radiance = output_nc.add_variable<Float>("radiance" , {"ny", "nx"});
auto nc_radiance = output_nc.add_variable<Float>("radiance" , {"py", "px"});

@magpowell magpowell Jul 23, 2026

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.

unrelated to this PR, but I think this is a bug

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.

radiance is camera output, so it is sized by the camera pixel counts (py, px), not the domain (ny, nx). The old dims only work when the two happen to match. It breaks for a camera whose pixel count differs from the domain size. test_rte_rrtmgp_bw already writes radiance with py/px, so this just makes the two drivers consistent.


Array<int,1> z_lut({lut_size});
Array<int,1> kn_z_lut({lut_size});
for (int i=0; i<lut_size; ++i)

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.

Example: layers of 20, 25, 60, 200 m. LUT spacing 20 m.

Bin 0 (0 - 20 m) corresponds to layer 0 (0 - 20 m), LUT stores k = 0.
Bin 1 (20–40 m) lies inside layer 1 (20- 45 m), LUT stores k = 1
Bin 2 (40–60 m) includes the layers 1 and 2. LUT stores the layer of its lower edge: k = 1. A possible collision that falls within bin 2 would be eligible for a correction (++k) in the height_to_int func if z >= z_lev[k+1]
Bin 3 covers 60–80 m, which lies inside layer 2 (45–105 m), LUT stores k =2.
and so on

The lw/sw solve_gpu built a uniform vertical grid from grid_d.z instead of
the input interface heights, and the lw plane-parallel fallback divided by
a scalar dz; both broke the 3D absorption output on non-uniform grids.
@magpowell
magpowell force-pushed the nonuniform-dz branch 2 times, most recently from 2a5a777 to 3c06c15 Compare July 25, 2026 00:24
cudaMemcpy(&max_kext_gas, max_kext_gas_g, sizeof(Float), cudaMemcpyDeviceToHost);

const Float lowest_gas_mean_free_path = grid_d.z / max_tau_gas;
const Float lowest_gas_mean_free_path = Float(1.) / max_kext_gas;

@magpowell magpowell Jul 25, 2026

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.

Before the mean-free-path criterion divided a fixed spacing by the maximum layer tau, which was fine for uniform grid but for stretched grid biases the estimate low by dz/dz_min. Now we use the maximum of tau/dz, the largest extinction coefficient in the domain, which is grid-independent.

@magpowell
magpowell marked this pull request as ready for review July 27, 2026 19:58
@magpowell

Copy link
Copy Markdown
Contributor Author

Update: I added the constant-dz fast path Menno suggested. The kernels take a dz_constant template argument like independent_column. The path is chosen at runtime. The solver checks whether the interface heights are equally spaced, and if so it runs the original equidistant arithmetic, including the original null-cell boundaries at uniform spacing for any kn_ktot.

This simplifies what I wrote in the Cabauw test section. On uniform grids the new code now always uses the old null-cell boundaries, so the non-divisible case is also bit identical and the second test figure no longer applies.

const int mie_phase_table_size = mie_phase_ang.size();

// Constant dz implies the old uniform null-cell walls, valid for any kn_grid.
const bool dz_constant = vertical_spacing_is_constant(z_lev, grid_cells.z);

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.

check if there is a uniform grid to set the argument passed to the ray tracer kernels

@MennoVeerman MennoVeerman 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.

Code works like a charm, thanks for adding support for nonuniform dz grids!

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