This is the SCOPEinR-specific companion to How-in-R.Rmd
(which covers ToolsRTM’s leaf/canopy models alone). SCOPE
(Soil Canopy Observation, Photochemistry and Energy fluxes) couples soil
reflectance, canopy optical BRDF, leaf photosynthesis, and a full
energy-balance closure loop into one simulation – given one LUT input
row (leaf/canopy/meteo/soil parameters), get.SCOPE()
returns TOC reflectance, converged leaf/soil temperatures, energy
fluxes, and (optionally) canopy-level fluorescence.
library(ToolsRTM)
library(SCOPEinR)
Load SCOPE’s own bundled example LUT row and model options, and run
the full leaf optics -> soil -> optical BRDF -> energy balance
-> fluorescence pipeline in one get.SCOPE() call.
table.with.opts <- read.table(system.file("input", "setoptions.csv", package = "SCOPEinR"),
header = TRUE, sep = ",")
Table.LUT <- read.table(system.file("input", "LUT_input.csv", package = "SCOPEinR"),
header = TRUE, sep = ",")
invisible(capture.output(
db.sim <- SCOPEinR::get.SCOPE(
LUT = Table.LUT[1, ],
options.SCOPE = table.with.opts,
optipar = SCOPEinR::optipar2021.Pro.CX,
leaf.model = "fluspect-CX",
canopy.model = "fourSAIL",
get.outputs = "ALL",
get.plots = FALSE
)
))
res <- db.sim[[1]] # get.SCOPE() always returns a list-of-results, even for one LUT row
cat("Canopy layers:", res$data.canopy$nlayers, "\n")
## Canopy layers: 30
cat("TOC reflectance at 550nm:", round(res$data.rad$refl[550 - 400 + 1], 4), "\n")
## TOC reflectance at 550nm: 0.0438
cat("TOC reflectance at 800nm:", round(res$data.rad$refl[800 - 400 + 1], 4), "\n")
## TOC reflectance at 800nm: 0.3588
cat("Canopy-average leaf temperature (Tcave):", round(res$data.fluxes$Tcave, 2), "degC\n")
## Canopy-average leaf temperature (Tcave): 22.04 degC
cat("Soil temperature, sunlit/shaded:", round(res$data.thermal$Tsu, 2), "/",
round(res$data.thermal$Tsh, 2), "degC\n")
## Soil temperature, sunlit/shaded: 39.84 / 23.79 degC
cat("Net radiation, total (Rntot):", round(res$data.fluxes$Rntot, 1), "W/m2\n")
## Net radiation, total (Rntot): 496.7 W/m2
cat("Latent heat flux, total (lEtot):", round(res$data.fluxes$lEtot, 1), "W/m2\n")
## Latent heat flux, total (lEtot): 181.7 W/m2
cat("Sensible heat flux, total (Htot):", round(res$data.fluxes$Htot, 1), "W/m2\n")
## Sensible heat flux, total (Htot): 275 W/m2
cat("Canopy photosynthesis (Actot):", round(res$data.fluxes$Actot, 2), "umol m-2 s-1\n")
## Canopy photosynthesis (Actot): 19.89 umol m-2 s-1
cat("Canopy fluorescence flux (EoutF):", round(res$data.rad$EoutF, 4), "W/m2\n")
## Canopy fluorescence flux (EoutF): 0.3889 W/m2
cat("F685/F740:", round(res$data.rad$F685, 3), "/", round(res$data.rad$F740, 3), "\n")
## F685/F740: 0.311 / 1.808
A note on get.SCOPE()’s return
structure: unlike toolsrtm’s Python port (a clean
ScopeResult dataclass, see the Python version of this
tutorial), the R version returns SCOPE’s own internal nested-list
structure directly (data.rad, data.thermal,
data.fluxes, data.canopy, … – 19 top-level
fields). This is deliberate: it’s exactly what the original SCOPE model
itself produces, so it stays directly comparable to SCOPE
documentation/literature, at the cost of needing to know these field
names (see below for a lookup table for the fields used in this
tutorial).
| What you want | Where it is |
|---|---|
| TOC reflectance (full spectrum) | res$data.rad$refl |
| Bi-hemispherical / directional components | res$data.rad$rdd / rsd / rdo
/ rso |
| Wavelength grid for the above | res$data.spectral$wlS (400-2400nm @ 1nm, then coarser
out to 50000nm – NOT uniform 1nm throughout) |
| Fluorescence spectrum | res$data.rad$LoF_, on
res$data.spectral$wlF (640-850nm @ 1nm) |
| Sunlit/shaded leaf temperature per layer | res$data.thermal$Tcu / Tch (length =
res$data.canopy$nlayers) |
| Soil temperature | res$data.thermal$Tsu / Tsh |
| Energy-balance totals | res$data.fluxes$Rntot / lEtot /
Htot / Gtot |
wl_optical <- 400:2400 # the uniform-1nm part of data.spectral$wlS
n <- length(wl_optical)
op <- par(mfrow = c(2, 2))
plot(wl_optical, res$data.rad$refl[1:n], type = "l", col = "black", lwd = 1.5,
xlab = "Wavelength (nm)", ylab = "Reflectance", main = "TOC reflectance components")
lines(wl_optical, res$data.rad$rdd[1:n], col = "steelblue")
lines(wl_optical, res$data.rad$rso[1:n], col = "firebrick")
legend("topright", c("refl (apparent)", "rdd (bi-hemispherical)", "rso (bidirectional)"),
col = c("black", "steelblue", "firebrick"), lty = 1, cex = 0.7)
wlF <- res$data.spectral$wlF
plot(wlF, res$data.rad$LoF_, type = "l", col = "red3", lwd = 1.5,
xlab = "Wavelength (nm)", ylab = "Fluorescence radiance (mW m-2 nm-1 sr-1)",
main = sprintf("TOC fluorescence (F685=%.2f, F740=%.2f)", res$data.rad$F685, res$data.rad$F740))
layers <- seq_len(res$data.canopy$nlayers)
plot(layers, res$data.thermal$Tcu, type = "o", pch = 16, col = "darkorange",
ylim = range(c(res$data.thermal$Tcu, res$data.thermal$Tch, res$data.thermal$Tsu, res$data.thermal$Tsh)),
xlab = "Canopy layer (1 = top)", ylab = "Temperature (degC)",
main = sprintf("Converged temperature profile (%d iterations)", res$iter.ebal$counter))
lines(layers, res$data.thermal$Tch, type = "o", pch = 16, col = "steelblue")
abline(h = res$data.thermal$Tsu, col = "brown", lty = 2)
abline(h = res$data.thermal$Tsh, col = "tan", lty = 2)
legend("topright", c("Tcu (sunlit)", "Tch (shaded)", "Tsu (soil, sunlit)", "Tsh (soil, shaded)"),
col = c("darkorange", "steelblue", "brown", "tan"), lty = c(1, 1, 2, 2), cex = 0.7)
fluxes <- c(Rntot = res$data.fluxes$Rntot, lEtot = res$data.fluxes$lEtot,
Htot = res$data.fluxes$Htot, Gtot = res$data.fluxes$Gtot)
barplot(fluxes, col = c("darkorange", "steelblue", "firebrick", "grey60"),
ylab = "W m-2", main = "Canopy energy-balance totals")
par(op)
Four different views of the same one simulation: the reflectance
components (Section 2’s refl line among them), the
fluorescence spectrum, the converged sunlit/shaded canopy and soil
temperatures, and where the absorbed energy actually goes.
getLUT.SCOPE() builds a LUT the same way
ToolsRTM::getLUT() does for PROSAIL – sample every
parameter’s own default range from SCOPEinR’s bundled
inputs_SCOPE.csv. get.SCOPE() itself is much
more expensive per call than foursail() (dominated by the
energy-balance nonlinear solve, ~0.1-0.3s per call vs ~2ms, so 100 rows
takes on the order of a minute) – see
Scripts/R/ForSCOPE/1-getSCOPE.R for the same idea at
production scale with diagnostic plots and saved outputs.
inputLUT <- read.table(system.file("input", "inputs_SCOPE.csv", package = "SCOPEinR"),
header = TRUE, sep = ",")
N_SAMPLES <- 100
Table.LUT.many <- getLUT.SCOPE(inputLUT = inputLUT, nLUT = N_SAMPLES)
start_time <- Sys.time()
db.sim.many <- get.SCOPE(
LUT = Table.LUT.many, n.LUT = N_SAMPLES, options.SCOPE = table.with.opts,
optipar = SCOPEinR::optipar2021.Pro.CX, leaf.model = "fluspect-CX",
canopy.model = "fourSAIL", get.outputs = "ALL", get.plots = FALSE
)
cat("Ran", N_SAMPLES, "simulations in", round(as.numeric(Sys.time() - start_time), 1), "s\n")
## Ran 100 simulations in 1.9 s
Actot_vals <- sapply(db.sim.many, function(r) r$data.fluxes$Actot)
EoutF_vals <- sapply(db.sim.many, function(r) r$data.rad$EoutF)
op <- par(mfrow = c(1, 2))
plot(Table.LUT.many$Vcmax25[seq_len(N_SAMPLES)], Actot_vals, pch = 19, col = "#2166AC",
xlab = "Vcmax25 (umol m-2 s-1)", ylab = "Canopy photosynthesis, Actot (umol m-2 s-1)",
main = "Actot vs Vcmax25")
plot(Table.LUT.many$Cab[seq_len(N_SAMPLES)], EoutF_vals, pch = 19, col = "#B2182B",
xlab = "Cab", ylab = "Canopy fluorescence flux, EoutF (W/m2)",
main = "SIF vs Cab")
par(op)
Vcmax25 (maximum carboxylation capacity) is the direct
physiological driver of Actot in SCOPE’s Farquhar-type
photosynthesis model, so across 100 randomly-drawn LUT rows the
relationship comes through clearly. The right panel shows the other half
of what makes SCOPEinR more than plain PROSAIL: fluorescence
(EoutF) is sensitive to leaf pigments, not just to
LAI/structure – substitute Table.LUT.many$Anth or
Table.LUT.many$Car for Cab above to see the
same fluorescence output against anthocyanin or carotenoid content
instead.
The reverse problem: given ONLY the sensor-band reflectance a
satellite would actually measure, retrieve a trait (LAI here – SCOPE’s
classic structural retrieval target) without knowing the ground truth.
Convolve each of the 100 simulations’ apparent reflectance
(reflapp – SCOPE’s radiance-derived, sensor-observed
spectrum, Section 3) onto Sentinel-2A’s bands, then train on 70% of the
rows and evaluate on the 30% the model never saw:
library(randomForest)
band_refl <- t(sapply(db.sim.many, function(r) {
rfl_i <- r$data.rad$reflapp[1:n]
# reflapp is a radiance ratio (Lo_/incident irradiance), and incident
# irradiance is genuinely near-zero at a handful of water-vapor/O2
# absorption wavelengths (~849-850, ~1355-1420, ~1800-1950nm) --
# numerically unstable there in EVERY simulation, including SCOPEinR's
# own canonical example row, not just unusual random draws. Real
# sensors avoid placing bands on these features for the same reason;
# linearly interpolate over just those narrow gaps before convolving
# rather than let a handful of bad wavelengths poison an otherwise
# good spectrum.
bad <- !is.finite(rfl_i)
if (any(bad)) rfl_i[bad] <- approx(wl_optical[!bad], rfl_i[!bad], xout = wl_optical[bad])$y
df_i <- data.frame(wave = wl_optical, rfl = rfl_i)
get.spectral.convolution.srf(df_i, ToolsRTM::srf.sentinel2a)$RFL
}))
colnames(band_refl) <- paste0("B", seq_len(ncol(band_refl)))
ml_data <- data.frame(band_refl, LAI = Table.LUT.many$LAI)
train_idx <- sample(seq_len(N_SAMPLES), size = round(0.7 * N_SAMPLES))
rf <- randomForest(LAI ~ ., data = ml_data[train_idx, ], ntree = 300)
pred <- predict(rf, ml_data[-train_idx, ])
obs <- ml_data$LAI[-train_idx]
cat("R2:", cor(pred, obs)^2, " RMSE:", sqrt(mean((pred - obs)^2)), "\n")
## R2: 0.7238131 RMSE: 1.090676
plot(obs, pred, xlab = "Observed LAI", ylab = "Predicted LAI", pch = 19, col = "#2166AC",
main = "LAI retrieved from Sentinel-2A bands (SCOPE reflapp)")
abline(0, 1, col = "grey40", lty = 2)
Same interface as How-in-R.Rmd’s own step 6
(RandomForest on convolved sensor bands) – what changes here is the
input spectrum: reflapp comes out of the full
energy-balance/fluorescence-coupled SCOPE run instead of plain PROSAIL,
so this trains on what a sensor would see over a canopy whose leaf/soil
temperatures were actually solved for, not assumed.
Read the scatter plot, not just R²: the points
aren’t scattered evenly around the 1:1 line – low observed LAI (<1.5)
is systematically over-predicted, and high observed LAI (>5)
is systematically under-predicted, so the point cloud visibly
flattens toward the training set’s own mean at both tails. This is real,
expected Random Forest behaviour, not a modelling error: an ensemble of
trees can only average values it saw in training, so it can never
extrapolate past the training range and pulls extreme predictions back
toward the bulk of the data – worse here than usual because only 70 rows
(0.7 * N_SAMPLES) are available to train on.
cor(pred, obs)^2 (a correlation-based R²) is fairly
insensitive to exactly this kind of bias, since it only asks whether
predicted and observed move together, not whether they agree in
absolute terms – which is why the number alone (0.72) undersells how
much the retrieval degrades at the LAI extremes visible in the plot
above. More/better-distributed training LUT rows (Section 4’s
nLUT) or a model less prone to mean-reversion (e.g. a
linear/PLSR baseline for comparison) are the two standard fixes, not
shown here to keep this tutorial’s scope to “one clean end-to-end
example.”
If you compare this R² to the Python version of this
section: R’s getLUT.SCOPE() (Section 4) randomizes
essentially every free parameter at once – leaf biochemistry, LAI,
leaf-angle distribution, and illumination/view geometry
(tts/tto/psi) all vary
simultaneously across the 100 rows. Python’s version of this tutorial
perturbs only Cab/LAI/EWT/Vcmax25 around one fixed baseline, holding
geometry and leaf-angle distribution constant. More
simultaneously-varying nuisance parameters means more confounding for
the model to see through, so R’s LAI retrieval is a genuinely harder
task and a lower R² here is expected – not a bug, a real methodological
difference between the two tutorials’ sampling designs.
Scripts/R/ForSCOPE/1-getSCOPE.R/2-Explore_outputsSCOPE.R
for the full version with diagnostic figures and saved .rds
outputs, and SCOPEinR_tutorial.Rmd (Section 7) for
get.SCOPE.parallel() and chunked production runs.res$iter.ebal already exposes
counter/maxEBercu/maxEBerch/maxEBers;
see Scripts/R/ForSCOPE/6-validate_ebal_convergence.R for a
dedicated analysis of these across many runs.See CCGCAM/RTM-Suite
for the full course materials and the R/Python numerical verification
writeup (python/README.md).