Skip to content

Conversation

@braghiere
Copy link
Member

@braghiere braghiere commented Oct 22, 2025

Purpose

Add support for spatially-varying canopy height in ClimaLand vegetation model, replacing the fixed 1m default with realistic height data from CLM artifacts (capped at 8m).

Content

Core Implementation

  • Canopy height parameterization: Added clm_canopy_height() function to read spatially-varying canopy height from CLM artifact data (src/standalone/Vegetation/spatially_varying_parameters.jl)
  • Height capping: Implemented effective_canopy_height() function to cap heights at 8m for numerical stability while preserving spatial patterns
  • Type system updates: Extended PrescribedBiomassModel to support spatially-varying height through type parameter HTH (canopy height type)
  • Boundary layer integration: Updated displacement height and roughness length calculations to properly broadcast with spatially-varying height Fields (src/standalone/Vegetation/canopy_boundary_fluxes.jl)
  • Bug fixes:
    • Fixed h_leaf synchronization with canopy_height in PlantHydraulics
    • Resolved Field comparison performance issues in height assertions
    • Fixed integer overflow in effective_canopy_height Boolean operations
    • Corrected GPU Boolean Field handling

Testing and Validation

  • Unit tests: Comprehensive test suite covering spatially-varying height functionality, including Field operations, capping behavior, and integration with canopy model

Impact Assessment

image

Figure: Sensible heat flux comparison showing Run 1 (default 1m height), Run 2 (spatially-varying height), absolute difference, and percent change.

The spatially-varying canopy height primarily affects:

  • Turbulent fluxes: Changes in aerodynamic resistance modify sensible and latent heat exchange with atmosphere
  • Regional patterns: Strongest impacts in forested regions (tropical/temperate) where height exceeds 1m default
  • Minimal impact: Grasslands and croplands show small changes (CLM heights ~1m already)

To-do

  • Add NEWS.md entry after reviewer feedback

  • I have read and checked the items on the review checklist.

Previously h_leaf was hardcoded to FT(1), now it uses
toml_dict["canopy_height"] to ensure consistency between
biomass and hydraulics height parameters.
…acts

- Reads spatially varying canopy height from CLM vegetation_properties_map.nc
- Returns ClimaCore Field with height values for each grid cell
- Follows same pattern as clm_rooting_depth() and clm_medlyn_g1()
- Heights are based on dominant PFT from CLM5 MONTHLY_HEIGHT_TOP data
CLM data uses 'z_top' (Canopy top height) not 'canopy_height'.

Data investigation findings:
- 192x288 grid (0.9x1.25 degrees)
- Height range: 0-40.13m
- 92.5% < 1m (grasslands/crops)
- 7.4% >= 10m (4068 cells, tall forests)
- Max height: 40.13m (tropical forests)
- Median: 0m (sparse/no vegetation dominant)
- Add HTH type parameter to support Union{FT, Field} for height
- Update struct definition in biomass.jl to accept both scalar and Field height
- Update outer constructor to pass height type to struct instantiation
- Update Canopy.jl constructor to include height type parameter
- This allows height to be either a scalar (backward compatible) or a Field (new functionality)
- Implement effective_canopy_height() to cap heights below atmospheric reference
- Default buffer of 2m ensures adequate space for flux calculations
- Logs warning with statistics when capping occurs
- Addresses constraint that ERA5 forcing is at 10m reference height
- Prevents numerical issues from canopy extending above atmospheric layer
- Import clm_canopy_height and effective_canopy_height functions
- Read canopy height from CLM artifact data in snowy_land_pmodel.jl
- Apply capping to 8m (10m atmospheric height - 2m buffer)
- Pass spatially-varying height to PrescribedBiomassModel
- This replaces the previous fixed 1m canopy height with realistic CLM values
Tests added:
1. spatial_parameters.jl:
   - Test clm_canopy_height() reads CLM data correctly
   - Test effective_canopy_height() capping with default and custom buffers
   - Verify proper field structure and value constraints

2. test_spatially_varying_canopy_height.jl (new file):
   - Test PrescribedBiomassModel with scalar height (backward compatibility)
   - Test PrescribedBiomassModel with Field height (new functionality)
   - Test integration with CLM height data and capping
   - Test full CanopyModel construction with spatially-varying height

All tests verify:
- Correct Field axes and types
- Height constraints (capping below atmospheric reference)
- Non-negative heights
- Backward compatibility with scalar heights
Create experiment script to compare:
- Run 1: Default configuration with height=1m everywhere
- Run 2: Spatially-varying height from CLM data (capped at 8m)

Both runs use same 1-year period (2008-03-01 to 2009-03-01) to enable
direct comparison of model outputs and assess impact of spatially-varying
canopy height on simulated fluxes and states.
- Replace count() with ifelse() to avoid Boolean Fields on GPU
- Use float mask (1.0/0.0) instead of boolean for counting
- Fixes 'Bool cannot be represented using Float64' error on CUDA
- Remove Int() conversion that caused overflow with large GPU arrays
- Keep n_capped_sum as Float for display
- Use round() for cleaner output
…t_height()

- Changed FT(0.67) * height to FT(0.67) .* height
- Fixes MethodError when height is a Field (spatially-varying)
- Maintains backward compatibility with scalar height
- Changed biomass.height == hydraulics_height to use all(isapprox.(...))
- Original == with Field was creating Field of booleans (very slow)
- Maintains backward compatibility with scalar height using ≈
- This was causing 3x+ slowdown in spatially-varying height runs
These simulation outputs have been moved to ~/climaland_outputs/
for local analysis and are not part of the core feature implementation.
@braghiere braghiere added the enhancement New feature or request label Oct 22, 2025
@braghiere braghiere changed the title Rb/spatially varying canopy height spatially varying canopy height Oct 22, 2025
@braghiere braghiere changed the title spatially varying canopy height Spatially varying canopy height Oct 22, 2025
Wrap parent() array accesses with Array() to convert from GPU to CPU
arrays before scalar indexing. This prevents 'Scalar indexing is
disallowed' errors when running tests on GPU.
"""
function ClimaLand.displacement_height(model::CanopyModel{FT}, Y, p) where {FT}
return FT(0.67) * model.biomass.height
return FT(0.67) .* model.biomass.height
Copy link
Member

Choose a reason for hiding this comment

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

Any operations like:
a .* b will allocate, so we should be careful to not do this in functions that are evaluated each timestep (it is OK if we do it selectively in initialization).

Here maybe we can do:
@. lazy( FT(0.67) * model.biomass.height)

which will only execute the operation when it is part of a larger set of operations updating something in place (no allocation if in place)

isapprox.(biomass.height, hydraulics_height, rtol = 1e-10),
) "Biomass height Field must match hydraulics height"
else
@assert biomass.height hydraulics_height "Biomass height must match hydraulics height"
Copy link
Member

Choose a reason for hiding this comment

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

I dont quite understand how this works, because the hydraulics_height is a scalar. How can this pass when biomass.height is spatialy varying?

In general we need to go through src and make sure these are consistent, because in some places we use the 'hydraulics" height as the height

Copy link
Member

Choose a reason for hiding this comment

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

It would be good to document what biomass height means and what hydraulics height means, and what they're each used for

Copy link
Member Author

Choose a reason for hiding this comment

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

You're right - the hydraulics compartments are always scalar (1m). When biomass.height varies spatially, only the aerodynamic calculations use those varying heights. The hydraulics structure stays uniform everywhere.

This is a current limitation - we can extend hydraulics to support spatial variation in a follow-up PR.

effective_canopy_height(raw_canopy_height, FT(10.0); buffer = FT(2.0))

# Create biomass model with spatially-varying height
biomass = PrescribedBiomassModel{FT}(
Copy link
Member

Choose a reason for hiding this comment

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

I think it would be good to add lines 112-116 into the PrescribedBiomassModel constructor, so it is not something that has to be updated each time (if we decide to include this as the default)

Copy link
Member

@kmdeck kmdeck left a comment

Choose a reason for hiding this comment

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

let's try running this and see how the SHF, LHF, etc change compared to observations

Moving forwards, I think there are two ways this can be extended:

  • make a canopy roughness model type. right now we are using physical quantities (height) to compute roughness and displacement height (using physical equations someone has come up with). Something more flexible would be to predict roughness, displacement height, from inputs like LAI, canopy height, etc using a data driven model. Having a type for "canopy roughness" would let us switch between the two easily. (I am separately adding this already in another PR)
  • in Biomass - create a type of "height model": this would be prescribed (constant or spatially varying) for now, PrescribedCanopyHeight. Then in the future we can also easily make different parameterizations for canopy height (e.g. prognostic, predicting it from input/time varying data). but this seems very far in the future compared to the first point

Comment on lines +76 to +78
# Create a spatially-varying height field
coords = ClimaCore.Fields.coordinate_field(surface_space)
field_height = coords.lat .* 0 .+ FT(3.0) # Constant field of 3m for testing
Copy link
Member

Choose a reason for hiding this comment

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

This is a little simpler, but what you have is fine too

Suggested change
# Create a spatially-varying height field
coords = ClimaCore.Fields.coordinate_field(surface_space)
field_height = coords.lat .* 0 .+ FT(3.0) # Constant field of 3m for testing
# Create a spatially-varying height field
field_height = zeros(surface_space) .+ FT(3) # Constant field of 3m for testing

isapprox.(biomass.height, hydraulics_height, rtol = 1e-10),
) "Biomass height Field must match hydraulics height"
else
@assert biomass.height hydraulics_height "Biomass height must match hydraulics height"
Copy link
Member

Choose a reason for hiding this comment

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

It would be good to document what biomass height means and what hydraulics height means, and what they're each used for

Comment on lines +356 to +362
canopy_height = SpaceVaryingInput(
joinpath(clm_artifact_path, "vegetation_properties_map.nc"),
"z_top",
surface_space;
regridder_type,
regridder_kwargs = (; extrapolation_bc, interpolation_method),
)
Copy link
Member

Choose a reason for hiding this comment

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

We could provide the effective_canopy_height as a preprocessing function instead, so we don't have to think about it wherever we access the canopy height data. This would be safer because we'll avoid users forgetting to apply the clipping.

        file_reader_kwargs = (; preprocess_func = (data) -> effective_canopy_height(data, z_atm; buffer),),

And then we take z_atm and buffer as inputs to this function

Copy link
Member

Choose a reason for hiding this comment

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

effective_canopy_height would also have to change to take in a float canopy_height instead of the whole field, since here it'll be broadcasted over the field. We wouldn't be able to log the information you have in the warning, but I think that's okay. Usually when we add warnings like this we regret it later because it clutters the simulation output

@@ -0,0 +1,187 @@
# RUN 2 ONLY: Spatially-Varying Canopy Height
Copy link
Member

Choose a reason for hiding this comment

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

If this is going to be merged into main it would be good to rename to something like snowy_land_varying_height.jl

# module load climacommon
# export CLIMACOMMS_DEVICE=CUDA
# export CLIMACOMMS_CONTEXT=SINGLETON
# cd /home/renatob/ClimaLand.jl
Copy link
Member

Choose a reason for hiding this comment

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

can we remove this line since it's specific to your directory?

Comment on lines 128 to 130
# Create a test field with known values including some that exceed the cap
coords = ClimaCore.Fields.coordinate_field(surface_space)
test_heights = coords.lat .* 0 .+ FT(5.0) # Initialize to 5m
Copy link
Member

Choose a reason for hiding this comment

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

Same comment I left below -
This is a little simpler, but what you have is fine too

Suggested change
# Create a test field with known values including some that exceed the cap
coords = ClimaCore.Fields.coordinate_field(surface_space)
test_heights = coords.lat .* 0 .+ FT(5.0) # Initialize to 5m
# Create a spatially-varying height field
field_height = zeros(surface_space) .+ FT(5) # Initialize to 5m

@@ -0,0 +1,248 @@
using Test
Copy link
Member

Choose a reason for hiding this comment

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

Thank you for writing such good tests!! This file should be added to test/runtests.jl so it runs with the test suite

SurfaceFluxes = "0.12.3, 0.13"
Thermodynamics = "0.14.1, 0.15"
julia = "1.10"
JuliaFormatter = "2.1.7"
Copy link
Member

Choose a reason for hiding this comment

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

I don't recall why exactly, but we still use JuliaFormatter 1. (not 2.)

JuliaFormatter shouldn't be a dependency of ClimaLand, you can keep it in your default environment

do:
]add JuliaFormatter@1

this will install the latest v1

to reset this to main, do:
git checkout origin/main Project.toml

@braghiere
Copy link
Member Author

let's try running this and see how the SHF, LHF, etc change compared to observations

Moving forwards, I think there are two ways this can be extended:

  • make a canopy roughness model type. right now we are using physical quantities (height) to compute roughness and displacement height (using physical equations someone has come up with). Something more flexible would be to predict roughness, displacement height, from inputs like LAI, canopy height, etc using a data driven model. Having a type for "canopy roughness" would let us switch between the two easily. (I am separately adding this already in another PR)
  • in Biomass - create a type of "height model": this would be prescribed (constant or spatially varying) for now, PrescribedCanopyHeight. Then in the future we can also easily make different parameterizations for canopy height (e.g. prognostic, predicting it from input/time varying data). but this seems very far in the future compared to the first point

Largest changes are observed:

Sensible Heat (ERA5): Bias ↓ (8.27 → 7.89 W m⁻²) ~5% improvement, RMSE same.
here

Evaporative Fraction (ERA5): Bias slightly closer to 0 (−0.0626 → −0.0601) ~4% improvement, Phase same. (No RMSE.)
here

Surface Upward LW (CERES): Bias closer to 0 (−5.85 → −5.52 W m⁻²) ~6% improvement, RMSE ~ +0.1, Phase ≈ same.
here

All the other variables have minimal changes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants