Drop-in R package replacement for
rsofunthat removes the Fortran 90 dependency, keeps the same user-facing API, and validates every C++ routine against a pure-R reference implementation.
- Replace the ~12,926 lines of Fortran 90 in
rsofunwith C++17/Rcpp. - Preserve the existing R API:
run_pmodel_f_bysite(),runread_pmodel_f(),run_biomee_f_bysite(),runread_biomee_f(),calib_sofun(), and the cost functions. - Re-use the same validation data sets shipped with
rsofun(p_model_drivers,biomee_p_model_drivers,biomee_gs_leuning_drivers, etc.). - Achieve numerical parity with
rsofunon all bundled examples (tolerances documented per output variable). - Add a modern test suite, CI, documentation, and CRAN-ready packaging.
- Do not change the scientific equations or published calibration targets.
- Do not introduce new user-facing parameters without a compatibility shim.
- Do not delete the original
_rreference functions once the C++ versions are active — they document the math and act as regression ground-truth.
These principles are taken from the SSDLC used in xsdm, nicher, xclim,
maxentcpp, and ucminfcpp:
- Science first. Each C++ routine must match a pure-R reference line-by-line before it is used in a production wrapper.
- R as orchestrator, C++ as engine. The R layer validates, reshapes, and dispatches; the C++ layer computes.
- Defensive input validation. All exported R functions use
checkmate. - Numerical parity before performance. Parallelism and micro-optimizations are introduced only after cross-language tests pass.
- CRAN hygiene from day one.
R CMD check --as-cranmust pass with 0 errors and 0 warnings at the end of every phase.
The tree below is the target state. Empty directories are not committed; the
first file in a directory makes it appear after git clone.
rsofuncpp/
├── DESCRIPTION
├── NAMESPACE # roxygen2-generated
├── NEWS.md
├── cran-comments.md
├── README.Rmd / README.md
├── _pkgdown.yml
├── .Rbuildignore
├── .github/
│ └── workflows/
│ ├── R-CMD-check.yaml
│ ├── test-coverage.yaml
│ └── pkgdown.yaml
│
├── R/ # User-facing R wrappers + reference fns
│ ├── rsofuncpp-package.R
│ ├── run_pmodel_cpp_bysite.R
│ ├── runread_pmodel_cpp.R
│ ├── run_biomee_cpp_bysite.R
│ ├── runread_biomee_cpp.R
│ ├── calib_sofun.R
│ ├── cost_functions.R
│ ├── init_dates_dataframe.R
│ ├── validate_inputs.R # shared checkmate helpers
│ └── reference/ # Stage 1 pure-R implementations (_r suffix)
│ ├── pmodel_photosynth_r.R
│ ├── pmodel_plant_traits_r.R
│ ├── pmodel_gpp_r.R
│ ├── pmodel_soilmstress_r.R
│ ├── pmodel_temp_response_r.R
│ ├── splash_r.R
│ ├── biomee_gpp_r.R
│ ├── biomee_demography_r.R
│ └── biomee_soil_r.R
│
├── src/
│ ├── Makevars
│ ├── Makevars.win
│ ├── .gitignore # *.o, *.so, *.dll, RcppExports.cpp auto
│ ├── RcppExports.cpp # auto-generated
│ ├── rcpp_pmodel.cpp # Rcpp bridge for P-model
│ ├── rcpp_biomee.cpp # Rcpp bridge for BiomeE
│ └── cpp/ # standalone C++17 core
│ ├── CMakeLists.txt # optional standalone build + Catch2
│ ├── include/rsofuncpp/
│ │ ├── types.hpp
│ │ ├── params_core.hpp # constants & shared types
│ │ ├── utils.hpp # sofunutils replacements
│ │ ├── forcing_pmodel.hpp
│ │ ├── forcing_biomee.hpp
│ │ ├── pmodel/
│ │ │ ├── photosynth.hpp # Farquhar biochemistry
│ │ │ ├── plant_traits.hpp # leaf traits from optimality
│ │ │ ├── gpp.hpp # P-model GPP / LUE
│ │ │ ├── temp_response.hpp # ftemp_kphio
│ │ │ ├── soilm_stress.hpp # empirical soil moisture stress
│ │ │ └── biosphere.hpp # annual P-model driver loop
│ │ ├── splash/
│ │ │ └── water_balance.hpp # SPLASH soil water balance
│ │ └── biomee/
│ │ ├── gpp.hpp # gs_leuning / pmodel photosynthesis
│ │ ├── vegetation.hpp # cohort demography
│ │ ├── soil.hpp # multi-layer soil hydraulics
│ │ └── biosphere.hpp # annual BiomeE driver loop
│ └── src/
│ └── *.cpp # translation units listed in Makevars
│
├── tests/
│ ├── testthat.R
│ └── testthat/
│ ├── helper-*.R # fixtures, tolerances, comparison helpers
│ ├── test-input-validation.R
│ ├── test-pmodel-cpp-vs-r.R
│ ├── test-biomee-cpp-vs-r.R
│ ├── test-pmodel-vs-rsofun.R
│ ├── test-biomee-vs-rsofun.R
│ ├── test-calibration.R
│ └── test-performance-regression.R
│
├── data/ # .rda objects inherited from rsofun
├── data-raw/ # scripts to regenerate data/
├── vignettes/
│ ├── pmodel_use.Rmd
│ ├── biomee_use.Rmd
│ └── migrating_from_rsofun.Rmd
├── inst/
│ └── benchmarks/
│ ├── bench_pmodel.R
│ └── bench_biomee.R
└── man/
└── ... # roxygen2 Rd files
Fortran module (rsofun src/) |
C++ header namespace | Responsibility |
|---|---|---|
md_params_core |
rsofuncpp::params |
Constants, physical constants, compile-time sizes |
md_sofunutils |
rsofuncpp::utils |
dampen_variability, running, patm, median, trig helpers |
md_forcing_pmodel |
rsofuncpp::pmodel::forcing |
Build annual climate/CO2/fAPAR vectors |
md_forcing_biomee |
rsofuncpp::biomee::forcing |
BiomeE climate/CO2 staging |
md_params_siml_pmodel |
rsofuncpp::pmodel::simulation |
getsteering, spin-up/recycle logic |
md_params_siml_biomee |
rsofuncpp::biomee::simulation |
BiomeE steering |
md_interface_pmodel |
rsofuncpp::pmodel::state |
myinterface-style state object |
md_interface_biomee |
rsofuncpp::biomee::state |
BiomeE state object |
md_photosynth |
rsofuncpp::pmodel::photosynth |
Farquhar Vcmax, Jmax, gammastar, kmm |
md_plant_pmodel |
rsofuncpp::pmodel::plant_traits |
Optimal chi, ci, LUE |
md_gpp_pmodel |
rsofuncpp::pmodel::gpp |
Daily GPP, ftemp_kphio, soilmstress |
md_biosphere_pmodel |
rsofuncpp::pmodel::biosphere |
Annual loop + output packing |
md_waterbal (SPLASH) |
rsofuncpp::splash::water_balance |
Soil water balance, AET/PET |
md_gpp_biomee |
rsofuncpp::biomee::gpp |
gpp() / gs_leuning() |
md_vegetation_biomee |
rsofuncpp::biomee::vegetation |
Cohort growth, mortality, reproduction |
md_soil_biomee |
rsofuncpp::biomee::soil |
Layered soil water uptake |
md_biosphere_biomee |
rsofuncpp::biomee::biosphere |
Annual BiomeE loop |
sofun_r_mod / wrappersc.c |
src/rcpp_*.cpp |
Rcpp .Call() entry points |
User code written for rsofun should continue to work after substituting
rsofuncpp:: for rsofun:::
run_pmodel_cpp_bysite(
sitename,
params_siml,
site_info,
forcing,
params_modl,
makecheck = TRUE,
verbose = TRUE
)
runread_pmodel_cpp(
drivers,
par,
makecheck = TRUE,
parallel = FALSE,
ncores = 1
)
run_biomee_cpp_bysite(
sitename,
params_siml,
site_info,
forcing,
params_tile,
params_species,
params_soil,
init_cohort,
init_soil,
makecheck = TRUE
)
runread_biomee_cpp(
drivers,
makecheck = TRUE,
parallel = FALSE,
ncores = 2
)
calib_sofun(
drivers,
obs,
settings,
...
)- Function names mirror the
_f_bysite/_fconvention but use_cpp. - Column order in
forcingdata frames is standardized inside the R wrapper, so callers are not responsible for Fortran column order. - Output names and units are identical to
rsofun.
Every migrated routine follows the four-stage pattern used in xsdm and
nicher:
- File:
R/reference/<name>_r.R - Suffix:
_r @keywords internal, never exported.- Base R only; speed is irrelevant, correctness is everything.
- Matches the published equations line-by-line.
Example:
calc_optimal_chi_r <- function(kmm, gammastar, ns_star, ca, vpd) {
# Eq. from Stocker et al. 2020 GMD
chi <- (gammastar / ca) + (1.0 - gammastar / ca) * ns_star / (ns_star + sqrt(vpd))
ci <- chi * ca
list(chi = chi, ci = ci)
}- File:
src/cpp/src/<name>.cpp+src/cpp/include/rsofuncpp/<name>.hpp - Suffix:
_cppin the R wrapper. - R wrapper validates with
checkmate, reshapes, calls C++, resets threads, and returns. - If using
RcppParallel, always reset threads withon.exit().
calc_optimal_chi_cpp <- function(kmm, gammastar, ns_star, ca, vpd) {
checkmate::assert_numeric(kmm, finite = TRUE, any.missing = FALSE, len = 1)
# ...
on.exit(RcppParallel::setThreadOptions(numThreads = RcppParallel::defaultNumThreads()),
add = TRUE)
RcppParallel::setThreadOptions(numThreads = 1L)
.Call("_rsofuncpp_calc_optimal_chi_cpp", kmm, gammastar, ns_star, ca, vpd)
}- File:
tests/testthat/test-<name>-cpp-vs-r.R - Test:
expect_equal(res_cpp, res_r, tolerance = 1e-10). - Must pass before the
_rfunction can be "retired" from the public API; the_rfunction is never deleted. - Test edge cases:
NA,Inf, zero-length inputs, single time steps, boundary parameters.
- Replace the
_rdefault with_cppbehind the same public name. - Add performance regression test in
inst/benchmarks/. - Run
R CMD check --as-cran.
| Test file | What it checks |
|---|---|
test-pmodel-cpp-vs-r.R |
Each P-model kernel (photosynth, plant_traits, gpp, soil stress, temp response, SPLASH) against pure-R reference |
test-biomee-cpp-vs-r.R |
Each BiomeE kernel (gpp, cohort growth, mortality, reproduction, soil water) against pure-R reference |
test-input-validation.R |
checkmate assertions fire with informative messages |
test-pmodel-vs-rsofun.R |
Full run_pmodel_cpp_bysite output equals rsofun::run_pmodel_f_bysite on bundled drivers |
test-biomee-vs-rsofun.R |
Full run_biomee_cpp_bysite output equals rsofun::run_biomee_f_bysite on bundled drivers |
test-calibration.R |
calib_sofun returns same structure and converges with same optimum as rsofun |
test-performance-regression.R |
C++ version is measurably faster than Fortran baseline on a 100-site synthetic workload |
test-parallel.R |
parallel = TRUE with ncores > 1 reproduces parallel = FALSE results |
- Kernel-level cross-language tests:
1e-10relative or absolute. - Full P-model run vs
rsofun: variable-specific tolerance derived frommean(abs(cpp - fortran)) / mean(abs(fortran)). - The reference tolerance table for each output column lives in
tests/testthat/helper-tolerances.Rso it can be updated as migration progresses.
- Bundle the same
.rdavalidation objects asrsofun. - Use
data-raw/to document how validation objects are generated. - Avoid downloading data in tests; all tests must run offline and on CI.
Package: rsofuncpp
Type: Package
Title: C++17 Reimplementation of the 'rsofun' Ecosystem Model
Version: 0.0.0.9000
Authors@R: c(
person("Angel Luis", "Robles Fernandez",
email = "a.l.robles.fernandez@gmail.com",
role = c("aut", "cre"),
comment = c(ORCID = "0000-0002-4674-4270")),
person("Benjamin", "Stocker", role = "aut"),
person("Koen", "Hufkens", role = "aut"),
person("...", ..., role = "ctb")
)
Description:
A drop-in C++17 replacement for the 'rsofun' R package
(Stocker et al. 2020 <doi:10.5194/gmd-13-1545-2020>),
removing the Fortran 90 dependency while preserving the same R API
and validation data. Provides the P-model and BiomeE ecosystem
simulators with 'Rcpp' and 'RcppParallel' backends.
License: GPL (>= 3)
Encoding: UTF-8
Depends: R (>= 4.1.0)
SystemRequirements: GNU make, C++17
Imports:
checkmate,
dplyr,
purrr,
tidyr,
tibble,
stats,
utils,
GenSA,
BayesianTools,
Rcpp (>= 1.0.0)
LinkingTo:
Rcpp (>= 1.0.0),
RcppParallel (>= 5.1.10)
Suggests:
testthat (>= 3.0.0),
rsofun,
knitr,
rmarkdown,
ggplot2,
covr,
microbenchmark
VignetteBuilder: knitr
Config/testthat/edition: 3
URL: https://alrobles.github.io/rsofuncpp/, https://github.com/alrobles/rsofuncpp
BugReports: https://github.com/alrobles/rsofuncpp/issues
RoxygenNote: 7.3.3CXX_STD = CXX17
PKG_CXXFLAGS += $(SHLIB_OPENMP_CXXFLAGS) -Icpp/include
PKG_LIBS += $(SHLIB_OPENMP_CXXFLAGS) $(LAPACK_LIBS) $(BLAS_LIBS) $(FLIBS)
PKG_LIBS += $(shell ${R_HOME}/bin/Rscript -e "RcppParallel::RcppParallelLibs()")
OBJECTS = \
RcppExports.o \
rcpp_pmodel.o \
rcpp_biomee.o \
cpp/src/utils.o \
cpp/src/forcing_pmodel.o \
cpp/src/forcing_biomee.o \
cpp/src/pmodel_photosynth.o \
cpp/src/pmodel_plant_traits.o \
cpp/src/pmodel_gpp.o \
cpp/src/pmodel_biosphere.o \
cpp/src/splash_water_balance.o \
cpp/src/biomee_gpp.o \
cpp/src/biomee_vegetation.o \
cpp/src/biomee_soil.o \
cpp/src/biomee_biosphere.osrc/Makevars.win mirrors this for Windows.
^.*\.Rproj$
^\.Rproj\.user$
^README\.Rmd$
^cran-comments\.md$
^\.github$
^data-raw$
^inst/benchmarks/.*\.pdf$
^src/cpp/CMakeLists\.txt$
^src/cpp/tests$
- Within-site: Use
RcppParalleland OpenMP only for kernels that are embarrassingly parallel across time steps or cohorts (e.g., daily GPP loop, cohort photosynthesis). - Across-site: Keep
runread_*_cppusingfuture/furrrorRcppParallelover sites, matchingrsofun'sparallelargument. - Thread safety: Pure functions only; no global mutable state in the C++
core. The Fortran
myinterfacemodule becomes an explicit state object passed by reference. - Reset threads: every R wrapper that changes
RcppParallelthread count must restore it on exit.
| Phase | Deliverable | Quality gate |
|---|---|---|
| 0. Skeleton | DESCRIPTION, NAMESPACE, R/, src/, tests/, CI workflows |
R CMD check passes with 0 errors/warnings on an empty but valid package |
| 1. P-model kernels | calc_*_r + calc_*_cpp for photosynthesis, plant traits, GPP, soil stress, temp response, SPLASH |
All test-pmodel-cpp-vs-r.R tests pass |
| 2. P-model integration | run_pmodel_cpp_bysite, runread_pmodel_cpp |
Output equal to rsofun::run_pmodel_f_bysite on p_model_drivers and p_model_drivers_vcmax25 |
| 3. BiomeE kernels | Demography, soil, GPP, annual loop as _r + _cpp |
All test-biomee-cpp-vs-r.R tests pass |
| 4. BiomeE integration | run_biomee_cpp_bysite, runread_biomee_cpp |
Output equal to rsofun::run_biomee_f_bysite on biomee_p_model_drivers and biomee_gs_leuning_drivers |
| 5. Calibration | calib_sofun + cost functions |
Same calibration results as rsofun on bundled examples |
| 6. Polish | Vignettes, pkgdown, benchmarks, CRAN preflight |
R CMD check --as-cran clean; vignettes build; site deploys |
No phase is merged until its gate passes. The _r reference functions from
Stage 1 remain in R/reference/ permanently as executable documentation and as
regression anchors.
- All exported functions use roxygen2 with runnable examples.
- Vignettes:
pmodel_use.Rmd— drop-in replacement example.biomee_use.Rmd— BiomeE drop-in example.migrating_from_rsofun.Rmd— API differences, parameter mapping, performance tips.
README.Rmdbuilds a benchmark table againstrsofun.pkgdownsite auto-deployed via GitHub Actions.
R-CMD-check.yaml— Ubuntu, macOS, Windows; R release and R-devel.test-coverage.yaml—covrwith upload to Codecov.pkgdown.yaml— build and deploy site on merge tomain.
Lessons from previous migrations:
| Lesson source | What to reuse in rsofuncpp |
|---|---|
ucminfcpp |
Header-only C++ core, standalone cpp/tests with CMake, numerical parity vs reference package |
maxentcpp |
Method-by-method Fortran/Java → C++ mapping table, _cpp wrappers with checkmate, per-feature cross-language tests |
xsdm |
_r reference implementations + test-*_cpp_vs_r.R tolerance gates, RcppParallel thread reset pattern |
nicher |
Ellipsoidal _r math written line-by-line, multi-start tryCatch safety, S3 class wrappers |
xclim |
Bioclim kernels tested against pure R, matrix/vector dispatch in Rcpp, OpenMP via Makevars |
The bundled data sets are small enough for local CI. For larger regression campaigns (e.g., 100-site ensembles, long-term benchmarks, CRAN preflight on multiple platforms):
- Use
hermes.ecoseek.org/ OpenClaw as an execution gateway. - Store nightly results in
inst/benchmarks/or a separatersofuncpp-resultsrepository. - Keep the CI workflow in
.github/workflows/but allow a manual dispatch that offloads to the reumanlab runner.
- Original
rsofunauthors (Stocker, Hufkens, and co-authors) are credited inAuthors@Rwithrole = "ctb". - References and DOIs are listed in
DESCRIPTIONandinst/CITATION. - License:
GPL (>= 3)to remain compatible withrsofun. - This is an independent reimplementation; no Fortran source is copied or translated verbatim.
calib_sofun(drivers, obs, settings, ...)is the dispatcher.settings$method == "gensa"callsGenSA::GenSA()on a scalar cost.settings$method == "bayesiantools"builds aBayesianTools::createBayesianSetup()and runsBayesianTools::runMCMC()with the DEzs sampler.
cost_likelihood_pmodel()andcost_likelihood_biomee()compute a Gaussian log-likelihood of observations given model predictions, with an iid errorerr_<target>per target.cost_rmse_pmodel()andcost_rmse_biomee()compute RMSE for deterministic (GenSA) optimization.- The cost functions currently depend on
dplyr,tidyr, andpurrrto unnest and join nestedobs/driversdata frames. Forrsofuncppthese must be rewritten in base R so thatrsofuncppdoes not import the tidyverse.
BayesianToolsis maintenance-heavy and pullsDHARMa,coda,numDeriv, and other dependencies.- The DEzs sampler is robust but slow and does not use gradient information.
- HMC/NUTS via Stan gives better exploration in the 9–12 dimensional P-model parameter space, richer diagnostics (divergences, treedepth, rhat, ess), and a clear path to hierarchical / partial-pooling extensions.
Because the P-model likelihood depends on a deterministic simulation, the posterior cannot be written entirely inside Stan without a major rewrite. The recommended, least-risk path is:
- Expose the C++ time-series kernel as a callable function (e.g.
rsofuncpp::run_pmodel_bysite_matrix(params, forcing, site_info)). This is the same code used byrun_pmodel_cpp_bysite, just with a C++ signature that accepts rawdouble*buffers. - Provide an external C++ log-likelihood that Stan can link against. The
function signature is approximately:
It builds
double pmodel_log_lik(const Eigen::VectorXd& par, const Eigen::MatrixXd& obs, const Eigen::MatrixXd& forcing, const Eigen::VectorXd& site_info);
params_modlfrompar, calls the exposed P-model routine, and sumsnormal_lpdf(obs | mod, err)for each target. - Generate a tiny Stan model per calibration problem with
cmdstanr. The.stanfile declares the external function and only describes the parameter block and priors:functions { real pmodel_log_lik(vector par, matrix obs, matrix forcing, vector site_info); } parameters { real<lower=0.04, upper=0.09> kphio; real<lower=0.01, upper=4> err_gpp; // ... other calibrated parameters and errors } model { vector[N_par] par; par[1] = kphio; par[2] = err_gpp; // ... target += pmodel_log_lik(par, obs, forcing, site_info); }
cmdstanr::cmdstan_model(..., allow_undefined = TRUE)links thersofuncppobject or a user-supplied glue file. - R wrapper
calib_sofun(..., method = "stan")collectsdriversandobs, writes the external Stan glue, compiles once per machine, and returns aCmdStanMCMCobject together with a cleaned parameter summary.
- Rewrite
cost_rmse_pmodel()andcost_likelihood_pmodel()in base R usingrsofuncpp::runread_pmodel(). Add parity tests against the pre-computedrsofunoutput. - Reimplement
calib_sofun()withGenSAsupport and a lightweight parameter builder that no longer depends onBayesianToolsfor the setup phase. - Add
method = "stan"behind aSuggests: cmdstanrdependency once the C++17 time-series P-model backend (run_pmodel_cpp_bysite) is solid. - For now, keep
BayesianToolsas a transitionalSuggestsonly if needed for strict API compatibility; the long-term default sampler becomes Stan.
- Create package skeleton (
DESCRIPTION,NAMESPACE,.Rbuildignore, CI,tests/testthat.R). - Port
md_params_coreandmd_sofunutilstoparams.hppandutils.hpp. - Implement
calc_optimal_chi_r→calc_optimal_chi_cppas the first SSDLC demonstration, includingtest-calc-optimal-chi-cpp-vs-r.R. - Expand outward through P-model kernels, then full P-model integration, then BiomeE.