Skip to content

Commit f4b2405

Browse files
Fix error in reflected radiation; computed transmitted fraction differently (#1156)
1 parent 89bb2eb commit f4b2405

File tree

3 files changed

+156
-95
lines changed

3 files changed

+156
-95
lines changed

src/standalone/Vegetation/canopy_parameterizations.jl

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -236,15 +236,17 @@ function canopy_sw_rt_two_stream(
236236
α_soil::FT,
237237
frac_diff::FT,
238238
) where {FT}
239-
239+
α_soil = max(eps(FT), α_soil) # this prevents division by zero, below.
240+
cosθs = max(eps(FT), cosθs) # The insolations package returns θs > π/2 (nighttime), but this assumes cosθs >0
240241
# Compute μ̄, the average inverse diffuse optical length per LAI
241-
μ̄ = 1 / (2G)
242+
μ̄ = 1 / max(2G, eps(FT))
242243

243-
# Clip this to eps(FT) to prevent dividing by zero
244-
ω = max(α_leaf + τ_leaf, eps(FT))
244+
# Clip this to eps(FT) to prevent dividing by zero; the sum must also be < 1
245+
ω = min(max(α_leaf + τ_leaf, eps(FT)), 1 - eps(FT))
245246

246247
# Compute aₛ, the single scattering albedo
247-
aₛ = 0.5 * ω * (1 - cosθs * log((abs(cosθs) + 1) / abs(cosθs)))
248+
aₛ =
249+
0.5 * ω * (1 - cosθs * log((abs(cosθs) + 1) / max(abs(cosθs), eps(FT))))
248250

249251
# Compute β₀, the direct upscattering parameter
250252
β₀ = (1 / ω) * aₛ * (1 + μ̄ * K) / (μ̄ * K)
@@ -315,9 +317,12 @@ function canopy_sw_rt_two_stream(
315317
F_abs = 0
316318
i = 0
317319

318-
# Total light reflected form top of canopy
320+
# Total light reflected from top of canopy
319321
F_refl = 0
320322

323+
# Total light transmitted through the canopy on the downward pass
324+
F_trans = 0
325+
321326
# Intialize vars to save computed fluxes from each layer for the next layer
322327
I_dir_up_prev = 0
323328
I_dir_dn_prev = 0
@@ -341,7 +346,7 @@ function canopy_sw_rt_two_stream(
341346
h₅ * exp(-h * L * Ω) +
342347
h₆ * exp(h * L * Ω)
343348

344-
# Add collimated radiation to downard flux
349+
# Add collimated radiation to downward flux
345350
I_dir_dn += exp(-K * L * Ω)
346351

347352
# Compute the diffuse fluxes into/out of the layer
@@ -357,8 +362,12 @@ function canopy_sw_rt_two_stream(
357362
I_dif_abs = I_dif_up - I_dif_up_prev - I_dif_dn + I_dif_dn_prev
358363
end
359364

360-
if i == 1
361-
F_refl = (1 - frac_diff) * I_dir_up + (frac_diff) * I_dif_up
365+
if i == 1 # prev = top of layer
366+
F_refl =
367+
(1 - frac_diff) * I_dir_up_prev + (frac_diff) * I_dif_up_prev
368+
end
369+
if i == n_layers # not prev = bottom of layer
370+
F_trans = (1 - frac_diff) * I_dir_dn + (frac_diff) * I_dif_dn
362371
end
363372

364373

@@ -375,9 +384,13 @@ function canopy_sw_rt_two_stream(
375384
i += 1
376385
end
377386

378-
# Convert fractional absorption into absorption and return
379-
# Ensure floating point precision is correct (it may be different for PAR)
380-
F_trans = (1 - F_abs - F_refl) / (1 - α_soil)
387+
# Reflected is reflected from the land surface. F_abs
388+
# refers to radiation absorbed by the canopy on either pass.
389+
# F_trans refers to the downward pass only. Note that
390+
# (1-α_soil)*F_trans + F_abs = total absorbed fraction by land, so the following
391+
# must hold
392+
# @assert (1 - α_soil) * FT(F_trans) + FT(F_abs) + FT(F_refl) ≈ 1
393+
# This is tested in test/standalone/Vegetation/test_two_stream.jl
381394
return (; abs = FT(F_abs), refl = FT(F_refl), trans = FT(F_trans))
382395
end
383396

@@ -388,9 +401,12 @@ end
388401
Computes the vegetation extinction coefficient (`K`), as a function
389402
of the cosine of the sun zenith angle (`cosθs`),
390403
and the leaf angle distribution (`G`).
404+
405+
In the two-stream scheme, values of K ~ 1/epsilon can lead to numerical issues.
406+
Here we clip it to 1e6.
391407
"""
392408
function extinction_coeff(G::FT, cosθs::FT) where {FT}
393-
K = G / max(cosθs, eps(FT))
409+
K = min(G / max(cosθs, eps(FT)), FT(1e6))
394410
return K
395411
end
396412

test/integrated/soil_canopy_lsm.jl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ using ClimaLand
66
using ClimaLand.Soil
77
using ClimaLand.Canopy
88
using Dates
9+
using ClimaParams
910
import ClimaLand.Parameters as LP
1011
using ClimaLand.Soil.Biogeochemistry
1112
using ClimaLand.Canopy.PlantHydraulics
Lines changed: 126 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,109 +1,153 @@
1-
# This script tests the output of the ClimaLand TwoStream implementation
1+
# This script tests the canopy absorbed output of the ClimaLand TwoStream implementation
22
# against the T. Quaife pySellersTwoStream implementation by providing
33
# the same setups to each model and checking that the outputs are equal.
44
# The output of the python module for a variety of inputs is given in the
55
# linked file which then gets read and compared to the Clima version, ensuring
66
# the FAPAR of each model is within 1/2 of a percentage point.
77

8+
# It also tests that the sum of absorbed and reflected radiation is 1.
9+
810
using Test
911
using ClimaLand
1012
import ClimaComms
1113
ClimaComms.@import_required_backends
1214
using ClimaLand.Canopy
1315
using DelimitedFiles
1416
using ClimaLand.Domains: Point
17+
import ClimaLand.Parameters as LP
18+
import ClimaParams
1519

16-
include("../../Artifacts.jl")
20+
@testset "Comparison to pySellersTwoStream" begin
21+
include("../../Artifacts.jl")
1722

18-
# Read the test data from the ClimaArtifact
19-
datapath = twostr_test_data_path()
23+
# Read the test data from the ClimaArtifact
24+
datapath = twostr_test_data_path()
2025

21-
data = joinpath(datapath, "twostr_test.csv")
22-
test_set = readdlm(data, ',')
26+
data = joinpath(datapath, "twostr_test.csv")
27+
test_set = readdlm(data, ',')
2328

24-
# Floating point precision to use
25-
for FT in (Float32, Float64)
26-
@testset "Two-Stream Model Correctness, FT = $FT" begin
27-
# Read the conditions for each setup parameter from the test file
28-
column_names = test_set[1, :]
29-
cosθs = FT.(test_set[2:end, column_names .== "mu"])
30-
LAI = FT.(test_set[2:end, column_names .== "LAI"])
31-
a_soil = FT.(test_set[2:end, column_names .== "a_soil"])
32-
n_layers = UInt64.(test_set[2:end, column_names .== "n_layers"])
33-
PropDif = FT.(test_set[2:end, column_names .== "prop_diffuse"])
29+
# Floating point precision to use
30+
for FT in (Float32, Float64)
31+
@testset "Two-Stream Model Correctness, FT = $FT" begin
32+
# Read the conditions for each setup parameter from the test file
33+
column_names = test_set[1, :]
34+
cosθs = FT.(test_set[2:end, column_names .== "mu"])
35+
LAI = FT.(test_set[2:end, column_names .== "LAI"])
36+
a_soil = FT.(test_set[2:end, column_names .== "a_soil"])
37+
n_layers = UInt64.(test_set[2:end, column_names .== "n_layers"])
38+
PropDif = FT.(test_set[2:end, column_names .== "prop_diffuse"])
3439

35-
# setup spatially varying params as both float and spatially varying
36-
domain = Point(; z_sfc = FT(0.0))
37-
lds = FT.(test_set[2:end, column_names .== "ld"])
38-
lds_field = map(x -> fill(x, domain.space.surface), lds)
39-
α_PAR_leaf_scalars = FT.(test_set[2:end, column_names .== "rho"])
40-
α_PAR_leaf_fields =
41-
map(x -> fill(x, domain.space.surface), α_PAR_leaf_scalars)
42-
τ_scalars = FT.(test_set[2:end, column_names .== "tau"])
43-
τ_fields = map(x -> fill(x, domain.space.surface), τ_scalars)
44-
# loop through once with params as floats, then with params as fields
45-
Ω_cases = (FT(1), fill(FT(1), domain.space.surface))
46-
α_PAR_leaf_cases = (α_PAR_leaf_scalars, α_PAR_leaf_fields)
47-
τ_PAR_leaf_cases = (τ_scalars, τ_fields)
48-
α_NIR_leaf_cases = (FT(0.4), fill(FT(0.4), domain.space.surface))
49-
τ_NIR_leaf_cases = (FT(0.25), fill(FT(0.24), domain.space.surface))
50-
lds_cases = (lds, lds_field)
51-
zipped_params = zip(
52-
Ω_cases,
53-
α_PAR_leaf_cases,
54-
τ_PAR_leaf_cases,
55-
α_NIR_leaf_cases,
56-
τ_NIR_leaf_cases,
57-
lds_cases,
58-
)
59-
for (Ω, α_PAR_leaf, τ_PAR_leaf, α_NIR_leaf, τ_NIR_leaf, lds) in
60-
zipped_params
61-
# Read the result for each setup from the Python output
62-
py_FAPAR = FT.(test_set[2:end, column_names .== "FAPAR"])
40+
# setup spatially varying params as both float and spatially varying
41+
domain = Point(; z_sfc = FT(0.0))
42+
lds = FT.(test_set[2:end, column_names .== "ld"])
43+
lds_field = map(x -> fill(x, domain.space.surface), lds)
44+
α_PAR_leaf_scalars = FT.(test_set[2:end, column_names .== "rho"])
45+
α_PAR_leaf_fields =
46+
map(x -> fill(x, domain.space.surface), α_PAR_leaf_scalars)
47+
τ_scalars = FT.(test_set[2:end, column_names .== "tau"])
48+
τ_fields = map(x -> fill(x, domain.space.surface), τ_scalars)
49+
# loop through once with params as floats, then with params as fields
50+
Ω_cases = (FT(1), fill(FT(1), domain.space.surface))
51+
α_PAR_leaf_cases = (α_PAR_leaf_scalars, α_PAR_leaf_fields)
52+
τ_PAR_leaf_cases = (τ_scalars, τ_fields)
53+
α_NIR_leaf_cases = (FT(0.4), fill(FT(0.4), domain.space.surface))
54+
τ_NIR_leaf_cases = (FT(0.25), fill(FT(0.24), domain.space.surface))
55+
lds_cases = (lds, lds_field)
56+
zipped_params = zip(
57+
Ω_cases,
58+
α_PAR_leaf_cases,
59+
τ_PAR_leaf_cases,
60+
α_NIR_leaf_cases,
61+
τ_NIR_leaf_cases,
62+
lds_cases,
63+
)
64+
for (Ω, α_PAR_leaf, τ_PAR_leaf, α_NIR_leaf, τ_NIR_leaf, lds) in
65+
zipped_params
66+
# Read the result for each setup from the Python output
67+
py_FAPAR = FT.(test_set[2:end, column_names .== "FAPAR"])
6368

64-
# Python code does not use clumping index, and λ_γ does not impact FAPAR
65-
# Test over all rows in the stored output from the Python module
66-
for i in 2:(size(test_set, 1) - 1)
69+
# Python code does not use clumping index, and λ_γ does not impact FAPAR
70+
# Test over all rows in the stored output from the Python module
71+
for i in 2:(size(test_set, 1) - 1)
6772

68-
# Set the parameters based on the setup read from the file
69-
RT_params = TwoStreamParameters(
70-
FT;
71-
Ω = Ω,
72-
G_Function = ConstantGFunction(FT.(lds[i])),
73-
α_PAR_leaf = α_PAR_leaf[i],
74-
τ_PAR_leaf = τ_PAR_leaf[i],
75-
α_NIR_leaf = α_NIR_leaf,
76-
τ_NIR_leaf = τ_NIR_leaf,
77-
n_layers = n_layers[i],
78-
)
73+
# Set the parameters based on the setup read from the file
74+
RT_params = TwoStreamParameters(
75+
FT;
76+
Ω = Ω,
77+
G_Function = ConstantGFunction(FT.(lds[i])),
78+
α_PAR_leaf = α_PAR_leaf[i],
79+
τ_PAR_leaf = τ_PAR_leaf[i],
80+
α_NIR_leaf = α_NIR_leaf,
81+
τ_NIR_leaf = τ_NIR_leaf,
82+
n_layers = n_layers[i],
83+
)
7984

80-
# Initialize the TwoStream model
81-
RT = TwoStreamModel(RT_params)
85+
# Initialize the TwoStream model
86+
RT = TwoStreamModel(RT_params)
8287

83-
# Compute the predicted FAPAR using the ClimaLand TwoStream implementation
84-
G = compute_G(RT_params.G_Function, cosθs)
85-
K = extinction_coeff.(G, cosθs[i])
86-
output =
87-
canopy_sw_rt_two_stream.(
88-
G,
89-
RT_params.Ω,
90-
RT_params.n_layers,
91-
RT_params.α_PAR_leaf,
92-
RT_params.τ_PAR_leaf,
93-
LAI[i],
94-
K,
95-
cosθs[i],
96-
a_soil[i],
97-
PropDif[i],
88+
# Compute the predicted FAPAR using the ClimaLand TwoStream implementation
89+
G = compute_G(RT_params.G_Function, cosθs)
90+
K = extinction_coeff.(G, cosθs[i])
91+
output =
92+
canopy_sw_rt_two_stream.(
93+
G,
94+
RT_params.Ω,
95+
RT_params.n_layers,
96+
RT_params.α_PAR_leaf,
97+
RT_params.τ_PAR_leaf,
98+
LAI[i],
99+
K,
100+
cosθs[i],
101+
a_soil[i],
102+
PropDif[i],
103+
)
104+
FAPAR = output.abs
105+
# Check that the predictions are app. equivalent to the Python model
106+
# Create a field of the expect value because isapprox cannot be broadcast
107+
# over a field of floats. The domain is a point, so it makes no difference
108+
# to the error when FAPAR is a float
109+
expected_output = fill(py_FAPAR[i], domain.space.surface)
110+
@test isapprox(
111+
0,
112+
sum(FAPAR .- expected_output),
113+
atol = 0.005,
98114
)
99-
FAPAR = output.abs
100-
# Check that the predictions are app. equivalent to the Python model
101-
# Create a field of the expect value because isapprox cannot be broadcast
102-
# over a field of floats. The domain is a point, so it makes no difference
103-
# to the error when FAPAR is a float
104-
expected_output = fill(py_FAPAR[i], domain.space.surface)
105-
@test isapprox(0, sum(FAPAR .- expected_output), atol = 0.005)
115+
end
106116
end
107117
end
108118
end
109119
end
120+
121+
@testset "Test physicality" begin
122+
N = 100000
123+
θs = [rand(N - 1) * 2π..., π / 2]
124+
cosθs = cos.(θs)
125+
α_leaf = [rand(N - 4)..., 0.0, 0.0, 1.0, 1.0]
126+
τ_leaf = (1.0 .- α_leaf) .* rand(N)
127+
α_soil = [rand(N - 6)..., 0.0, 1.0, 0.2, 0.2, 0.2, 0.2]
128+
G = 0.5
129+
K = ClimaLand.Canopy.extinction_coeff.(G, cosθs)
130+
frac_diff = rand(N)
131+
n_layers = UInt64(20)
132+
Ω = rand(N)
133+
LAI = round.(rand(N))
134+
output =
135+
ClimaLand.Canopy.canopy_sw_rt_two_stream.(
136+
G,
137+
Ω,
138+
n_layers,
139+
α_leaf,
140+
τ_leaf,
141+
LAI,
142+
K,
143+
cosθs,
144+
α_soil,
145+
frac_diff,
146+
)
147+
expected = zeros(N)
148+
for i in 1:N
149+
expected[i] =
150+
output[i].trans * (1 - α_soil[i]) + output[i].abs + output[i].refl
151+
end
152+
@assert all(expected .≈ 1)
153+
end

0 commit comments

Comments
 (0)