Skip to contents
library(ToolsRTM)

Why soil matters to a canopy simulation

Top-of-canopy reflectance is a mixture of leaf optics and the soil background showing through the canopy gaps, weighted by how closed the canopy is (LAI, leaf angle, hotspot). Most examples elsewhere in this tutorial series use a fixed, arbitrary rsoil – e.g. a 50/50 blend of a dry and wet reference spectrum (Tutorial 01), or a flat rep(0.15, ...) (Tutorials 04-13). That’s fine for isolating a leaf/canopy trait’s own effect, but it sidesteps a real question: how much does the soil’s actual moisture state change what a sensor sees – at the canopy level AND at the satellite level – and does that depend on how much vegetation covers it?

ToolsRTM::get.marmit.rsoil() (MARMIT: Bablet et al., soil reflectance as a function of surface water film thickness) gives a physically-based answer instead of an arbitrary blend. This page feeds its output into BOTH foursail() (TOC only, Tutorials 01-02) and SPART() (TOC and TOA together, Tutorial 03) – MARMIT’s soil physics is the same either way; what changes is how far downstream that soil signal survives.

MARMIT (soil moisture)
        |
        v
   rsoil spectrum
     /       \
    v         v
fourSAIL    SPART
 (TOC)    (TOC AND TOA,
            + atmosphere)

1. A soil-wetness sweep with MARMIT

L (water film thickness, cm) and eps (fraction of the surface that’s wet) control how wet the simulated soil is – Scripts/R/ForMARMIT/1-simulate_LUT.R samples L from [0.001, 0.15] and eps from [0, 1] for a full LUT; here, five fixed steps across that range are enough to see the trend:

wetness_steps <- data.frame(
  label = c("Very dry", "Dry", "Moderate", "Wet", "Saturated"),
  L     = c(0.001, 0.02, 0.05, 0.09, 0.15),
  eps   = c(0.05,  0.3,  0.55, 0.8,  1.0)
)

soils <- lapply(seq_len(nrow(wetness_steps)), function(i) {
  get.marmit.rsoil(database = "Bablet_2016", id = 1, L = wetness_steps$L[i],
                    eps = wetness_steps$eps[i], wl.out = 400:2500)
})

cat("Estimated soil moisture (SMC, %) across the sweep:\n")
#> Estimated soil moisture (SMC, %) across the sweep:
print(round(sapply(soils, function(s) s$SMC), 1))
#> [1]  3.8  9.6 43.6 47.1 47.1
wl <- soils[[1]]$wavelength
cols <- colorRampPalette(c("gold", "saddlebrown"))(nrow(wetness_steps))
matplot(wl, sapply(soils, function(s) s$rsoil.wet), type = "l", lty = 1, col = cols,
        xlab = "Wavelength (nm)", ylab = "Soil reflectance",
        main = "MARMIT soil reflectance across the wetness sweep")
legend("topright", wetness_steps$label, col = cols, lty = 1, cex = 0.8)

Soil reflectance darkens broadly with wetness (as real wet soil looks darker than dry soil), with the strongest relative change in the SWIR water-absorption region – the same physics MARMIT is built around.

2. Feeding it into foursail() at a fixed canopy (TOC only)

Same leaf/canopy trait set throughout (LAI = 2, a moderate canopy), only rsoil changes across the five wetness steps:

row <- data.frame(
  LAI = 2, hspot = 0.01, LIDFa = -0.35, LIDFb = -0.15, TypeLidf = 1,
  tts = 30, tto = 0, psi = 0,
  N = 1.5, Cab = 40, Car = 8, Anth = 2, Cbrown = 0, EWT = 0.009, LMA = 0.009, alpha = 40
)

toc_by_soil <- sapply(soils, function(s) {
  sail_i <- foursail(inputLUT = row, rsoil = s$rsoil.wet, LeafModel = "PROSPECT-D")
  Compute_BRF(rdot = sail_i$rdot, rsot = sail_i$rsot, tts = row$tts, data.light = dataSpec_PDB)
})

matplot(dataSpec_PDB[, 1], toc_by_soil, type = "l", lty = 1, col = cols,
        xlab = "Wavelength (nm)", ylab = "TOC reflectance",
        main = "Top-of-canopy reflectance, same canopy, soil wetness varied (LAI=2)")
legend("topright", wetness_steps$label, col = cols, lty = 1, cex = 0.8)

The canopy’s own spectral shape (chlorophyll absorption, red edge, NIR plateau) stays recognizable throughout – but the SWIR bands still shift noticeably with soil moisture alone, nothing about the vegetation itself changed between these five curves.

3. Does this matter more at low LAI than high LAI?

Physically, yes: a sparse canopy leaves more soil visible through the gaps, so soil moisture should influence the sensor-observed signal more at low LAI than at high LAI, where the canopy itself dominates. Repeat the same sweep at LAI = 0.5 (sparse) and LAI = 6 (dense), and compare the driest-vs-wettest difference at a SWIR band (1650nm, in MARMIT’s strongest water-sensitive region):

swir_idx <- which(dataSpec_PDB[, 1] == 1650)

toc_by_lai <- function(lai) {
  row_i <- row; row_i$LAI <- lai
  sapply(soils, function(s) {
    sail_i <- foursail(inputLUT = row_i, rsoil = s$rsoil.wet, LeafModel = "PROSPECT-D")
    Compute_BRF(rdot = sail_i$rdot, rsot = sail_i$rsot, tts = row_i$tts, data.light = dataSpec_PDB)[swir_idx]
  })
}

refl_sparse <- toc_by_lai(0.5)
refl_dense  <- toc_by_lai(6)

cat("SWIR (1650nm) reflectance, driest vs. wettest soil:\n")
#> SWIR (1650nm) reflectance, driest vs. wettest soil:
cat("  Sparse canopy (LAI=0.5): ", round(refl_sparse[1], 4), "->", round(refl_sparse[5], 4),
    " (delta =", round(refl_sparse[1] - refl_sparse[5], 4), ")\n")
#>   Sparse canopy (LAI=0.5):  0.4415 -> 0.0637  (delta = 0.3777 )
cat("  Dense canopy  (LAI=6):   ", round(refl_dense[1], 4),  "->", round(refl_dense[5], 4),
    " (delta =", round(refl_dense[1] - refl_dense[5], 4), ")\n")
#>   Dense canopy  (LAI=6):    0.2134 -> 0.2071  (delta = 0.0064 )
plot(wetness_steps$eps, refl_sparse, type = "o", pch = 19, col = "#B2182B",
     ylim = range(c(refl_sparse, refl_dense)),
     xlab = "Wetness fraction (eps)", ylab = "TOC reflectance at 1650nm",
     main = "Soil-moisture sensitivity shrinks as LAI increases")
lines(wetness_steps$eps, refl_dense, type = "o", pch = 19, col = "#2166AC")
legend("topright", c("LAI = 0.5 (sparse)", "LAI = 6 (dense)"), col = c("#B2182B", "#2166AC"), pch = 19, lty = 1)

The sparse canopy’s SWIR reflectance swings much more across the wetness sweep than the dense canopy’s – a real, physically-expected result: at LAI=6 the canopy itself blocks most of the soil signal regardless of how wet it is underneath. This is the same story Tutorial 03’s own soil- contribution section demonstrates with a flat-brightness rsoil instead of MARMIT’s moisture-realistic one – consistent across both, as it should be.

4. MARMIT + SPART: does the soil-moisture signal survive to the satellite?

Section 2 stopped at TOC (canopy-level, no atmosphere). SPART() (Tutorial 03) carries the same MARMIT soil spectrum through the full soil-plant-atmosphere chain to TOA – the question this section answers that Tutorial 03 doesn’t: is the soil-moisture signal still visible after the atmosphere, or does it get washed out?

SPART() wants rsoil at 400-2400nm (2001 points, not 2101) – get.marmit.rsoil(wl.out = 400:2400) matches that directly:

soils_spart <- lapply(seq_len(nrow(wetness_steps)), function(i) {
  get.marmit.rsoil(database = "Bablet_2016", id = 1, L = wetness_steps$L[i],
                    eps = wetness_steps$eps[i], wl.out = 400:2400)
})

LUT <- as.data.frame(getLUT(inputs = ToolsRTM::inputsSPART, nLUT = 1, setseed = 1))
LUT$Cs <- 0; LUT$fqe <- 0.01; LUT$Cx <- 0
LUT$cell.d <- 40; LUT$inter.c <- 0.045; LUT$baseline.abs <- 0.0006
LUT$leaf.thick <- 1.6; LUT$albino.abs <- 0; LUT$lign.cell <- 2; LUT$Nitrogen <- 1
LUT$Pa <- 1000; LUT$aot550 <- 0.3246; LUT$uo3 <- 0.3480; LUT$uh2o <- 1.4116
LUT$alt_m <- 0; LUT$Pa0 <- 1000
LUT$LAI <- 1.5  # sparse enough that soil still reaches the sensor (Section 3's lesson)

spart_by_soil <- lapply(soils_spart, function(s) {
  suppressWarnings(SPART(inputLUT = LUT[1, ], CanopyModel = "fourSAIL", LeafModel = "PROSPECT-PRO",
                          sensor.i = ToolsRTM::Sentinel2A.MSI, rsoil = s$rsoil.wet, get.plots = FALSE))
})

toc_means <- sapply(spart_by_soil, function(s) mean(s$output$rfl.toc.BRDF))
toa_means <- sapply(spart_by_soil, function(s) mean(s$output$rfl.toa))
comparison <- data.frame(Wetness = wetness_steps$label, SMC_pct = round(sapply(soils_spart, function(s) s$SMC), 1),
                          Mean_TOC = round(toc_means, 4), Mean_TOA = round(toa_means, 4))
knitr::kable(comparison)
Wetness SMC_pct Mean_TOC Mean_TOA
Very dry 3.8 0.1895 0.1555
Dry 9.6 0.1749 0.1461
Moderate 43.6 0.1573 0.1352
Wet 47.1 0.1392 0.1243
Saturated 47.1 0.1244 0.1155
plot(wetness_steps$eps, toc_means, type = "o", pch = 19, col = "#2166AC",
     ylim = range(c(toc_means, toa_means)),
     xlab = "Wetness fraction (eps)", ylab = "Mean reflectance (Sentinel-2A bands)",
     main = "Soil moisture -> TOC vs. TOA (SPART, LAI=1.5)")
lines(wetness_steps$eps, toa_means, type = "o", pch = 19, col = "#D55E00")
legend("topright", c("TOC (canopy)", "TOA (satellite)"), col = c("#2166AC", "#D55E00"), pch = 19, lty = 1)

Confirmed directly, not assumed: the soil-moisture signal that Section 3 showed at the canopy level is still present at TOA – the atmosphere attenuates and reshapes the overall reflectance level (Tutorial 03’s own finding), but does not erase the wet-vs-dry soil contrast underneath it. This is exactly the kind of question SPART() exists to answer that a plain foursail() TOC simulation cannot – see Tutorial 03’s take-home for the general version of that distinction.

Practical note

For a real LUT-based inversion or sensitivity study, this suggests: if your target trait is canopy structure/LAI on a closed canopy, a fixed rsoil may be fine (soil influence is naturally small once the canopy closes, Section 3). If your study covers sparse/senescent vegetation, semi-arid areas, or early-season crops – where LAI is often below 1-2 – sample rsoil from get.marmit.rsoil() across a realistic moisture range (as in Scripts/R/ForMARMIT/1-simulate_LUT.R) rather than fixing it, so soil moisture variation doesn’t get silently absorbed into the trait estimates. This holds whether the downstream model is foursail()/ foursail2()/inform() (TOC only) or SPART() (TOC and TOA) – Section 4 above confirms the same soil-realism argument applies at the satellite level too, not just at the canopy level. Scripts/R/Pipeline/0-integrate_MARMIT_soil.R shows the same get.marmit.rsoil() output wired into foursail2() and inform() as well, for the other canopy models this package supports.