This document orients coding agents working in the plant R package. Read it
before making changes, especially changes that touch the C++/R boundary.
plant is an extensible framework for modelling size- and trait-structured
demography, ecology and evolution in simulated forests. It is an
individual-based model in which plant physiology and demography are mediated by
traits. Plants of multiple species can be grown in isolation, in competing
patches, or in metapopulations under a disturbance regime; these dynamics are
integrated up to metapopulation-level estimates of invasion fitness and
vegetation structure.
The performance-critical core is written in C++ and accessed from R. Reference: Falster et al. (2016) Methods in Ecology and Evolution 7:136–146, doi:10.1111/2041-210X.12525.
┌─────────────────────────────────────────────────────────────┐
│ R layer (R/*.R) │
│ - user-facing constructors, dispatch, hyperparameters │
│ - run_scm (collect/refine_schedule) / run_stochastic │
│ - tidy outputs, plotting, utilities │
└───────────────────────────▲─────────────────────────────────┘
│ RcppR6 + Rcpp generated bindings
┌───────────────────────────▼─────────────────────────────────┐
│ C++ core (inst/include/plant/*.h + src/*.cpp) │
│ - templated class hierarchy: Strategy, Environment, │
│ Individual, Node, Species, Patch, SCM, Stochastic* │
│ - numerics: ODE solver, adaptive quadrature, interpolators │
└─────────────────────────────────────────────────────────────┘
The two layers are bridged by RcppR6 on top of Rcpp. The single source of truth for the interface is inst/RcppR6_classes.yml.
These files are generated and carry "do not edit by hand" headers. Editing them directly will be overwritten and is almost always a mistake:
- R/RcppR6.R and src/RcppR6.cpp — generated by
make RcppR6 - R/RcppExports.R and src/RcppExports.cpp — generated by
Rcpp::compileAttributes() - inst/include/plant/RcppR6_pre.hpp,
RcppR6_post.hpp— generated - NAMESPACE and man/ — generated by roxygen (
devtools::document())
To change the interface, edit the C++ headers and inst/RcppR6_classes.yml, then regenerate (see §6).
All headers live in inst/include/plant/; implementations
in src/. Headers are included via -isystem../inst/include/ (see
src/Makevars).
The core classes are templated on two type parameters:
T= a Strategy type (the biological/physiological model)E= an Environment type (the resource environment the strategy reads)
Strategy<E> the biological model for one species
└─ concrete: FF16_Strategy, TF24_Strategy, K93_Strategy
Environment resource environment (e.g. light profile)
└─ concrete: FF16_Environment, TF24_Environment, K93_Environment
Individual<T,E> one plant: state + rates from a Strategy
Node<T,E> a cohort in the method-of-characteristics
Species<T,E> all nodes/cohorts of one Strategy
Patch<T,E> all Species sharing one Environment
SCM<T,E> the deterministic solver (see §3)
Parameters<T,E> model configuration (strategies, schedule, …)
NodeSchedule the times at which cohorts are introduced
Key headers to know:
| Concern | Header |
|---|---|
| Biological model interface | strategy.h |
| Concrete models | models/ff16_strategy.h, models/tf24_strategy.h, models/k93_strategy.h |
| Environments | models/ff16_environment.h (and tf24/k93 variants) |
| Single plant | individual.h, per-plant state in internals.h |
| Cohort / species / patch | node.h, species.h, patch.h |
| Deterministic solver | scm.h, helpers in scm_utils.h |
| Stochastic solver | stochastic_patch_runner.h, stochastic_patch.h, stochastic_species.h |
| Config | parameters.h, control.h |
| Disturbance | disturbances/weibull_disturbance.h, no_disturbance.h |
| Leaf-level physiology | leaf_model.h (photosynthesis + hydraulics; used by TF24) |
| Numerics | ode_solver/, qag.h/qk.h (adaptive quadrature), interpolator.h/adaptive_interpolator.h, uniroot.h, gradient.h |
A Strategy defines how a plant grows, dies, and reproduces. When subclassing
Strategy<E> (see strategy.h and FF16 as the
reference), the key overridable surface is:
static size_t state_size()andstatic std::vector<std::string> state_names()— keep these in sync with the actual state vector (FF16 returns 5:height, mortality, fecundity, area_heartwood, mass_heartwood).aux_size()/aux_names()— derived/auxiliary outputs.compute_rates(env, vars)— the ODE right-hand side for one plant.competition_effect(size)— how the plant contributes to the shared environment.establishment_probability,fecundity_dt,mortality_dt,initial_size.
Every Strategy carries a Control and an ExtrinsicDrivers object.
plant solves the same model two ways:
-
Deterministic — SCM (Solver via method of Characteristics). Cohorts (
Nodes) are introduced on a schedule and integrated as characteristic curves of the size-density PDE. Entry point:SCM<T,E>in C++, run_scm() in R. Tidied-output collection and adaptive schedule refinement are now options onrun_scm()(collect = TRUE,refine_schedule = TRUE); the refinement loop itself lives inSCM::refine_schedule()in C++ (see §3.1). -
Stochastic — finite-size population. Individuals arrive and die as discrete events.
StochasticPatchRunner<T,E>in C++; R/stochastic.R:stochastic_schedule(),run_stochastic_collect().
Both share the same Strategy, Environment, Individual, and Parameters.
The deterministic solver previously split its work across R and C++: R drove the
adaptive node-schedule refinement (build_schedule(), internal
run_scm_error()) and a separate function returned tidied output
(run_scm_collect()). This all now lives in C++ on SCM<T,E>, and the R
surface is a single run_scm(). See
issue #408 for background.
Migration map for callers (other repos/scripts must be updated):
| Removed (R) | Replacement |
|---|---|
build_schedule(p, …) |
run_scm(p, …, refine_schedule = TRUE)$parameters |
run_scm_collect(p, …) |
run_scm(p, …, collect = TRUE) |
run_scm_error(p, …) |
run with scm$collect_errors <- TRUE, then read scm$combined_node_errors |
split_times() |
internal to SCM::refine_schedule() (no R equivalent) |
run_scm(p, env, ctrl, refine_schedule = FALSE, collect = FALSE, use_ode_times = FALSE) returns the SCM object by default, or — when collect = TRUE — the
tidied results list (whose p element holds the possibly-refined parameters).
The pragmatic "fast-ish" numeric settings that essentially every plant run uses
previously lived in two R helpers (fast_control(), scm_base_control()) that
layered them on top of a tight-tolerance Control() constructor. Those values
are now the defaults in the C++ Control() constructor itself
(src/control.cpp) — a single source of truth — and the two R
helpers are removed. Control() (lowercase alias control()) is therefore
the fast default; control_accurate() opts back into tight ODE/schedule
tolerances for high-accuracy runs.
Migration map for callers (other repos/scripts must be updated):
| Removed (R) | Replacement |
|---|---|
fast_control() |
Control() / control() (these defaults are now built in) |
scm_base_control() |
Control() / control() |
| (high-accuracy run) | control_accurate() (tightens ode_tol_rel/abs, ode_step_size_max, schedule_eps) |
scm_base_parameters() is unaffected — it builds a Parameters object, not
a Control.
What moved into C++ (all on Node/Species/SCM in
node.h, species.h,
scm.h):
Noderecords itsintroduction_timeand patch-age density at introduction, so fitness/error calcs no longer re-derive these fromnode_scheduleor the disturbance regime after the run.SCM::run()accumulates the per-node refinement error whencollect_errorsis set; the combined competition + reproduction error is exposed ascombined_node_errors.SCM::refine_schedule()runs the adaptive loop (flag nodes whose error exceedsschedule_eps, bisect the interval below each, repeat up toschedule_nsteps) and writes the refined schedule + ode times back into itsparametersso theParametersobject stays self-describing.
Known downstream breakage: regnans calls the removed functions in
R/community_plant.R and scripts/example/ESA.Rmd; update per the table above.
Renaming, removing, or changing the meaning of anything a user calls (functions,
arguments, argument order, Control/Parameters fields, R6 types) breaks
downstream products (analysis repos, regnans, notebooks). Two
obligations whenever you make such a change:
- Record an
old -> newmapping in NEWS.md under the development version's "Breaking changes" subsection. Write it to be machine-actionable — explicitold(...)->new(...)lines, one per affected symbol, the way §3.1/§3.2's tables and the existing NEWS entries are. This section is the single source of truth for migration; agents.md narrates the why, NEWS carries the exactly what changed. Pure-internal/perf changes go under "Internals & performance" instead and need no migration line. - Downstream migration is a skill, not a manual chase. The
plant-update-interfaceskill (.claude/skills/plant-update-interface/) reads NEWS's "Breaking changes" entries and applies them to a product repo (locate call sites, propose per-site fixes, rebuild + test, record a baseline marker). It does not memorise the API — it re-reads NEWS each run — so the only thing that keeps it working is keeping NEWS disciplined per (1). To bring a product up to date, run that skill in the product repo. If you find a breaking change with no NEWS mapping, that's a NEWS bug — fix it at the source.
The migration skill reads these entries mechanically, so the format matters as much as the prose. Rules:
- One bullet per change; PR number(s) at the end in parens, e.g.
(#463). - State the
old -> newmapping explicitly, with the call form, not just the name.run_scm_collect(p, …) -> run_scm(p, …, collect = TRUE)is actionable; "consolidated the SCM interface" is not. - One sub-bullet per affected symbol when a change touches several:
* <one-line what-and-why> (#NNN). Migration: * `old_fn(a, b)` -> `new_fn(a, b, flag = TRUE)` * `old_field` -> `obj$new_field` * `helper()` (removed) -> `<replacement, or "no equivalent">`
- Capture side-channels, not just the renamed call. If callers read a result
via an attribute, a returned list element, or a follow-up getter, map that
too — a name-only mapping silently drops it. (Real example:
build_schedule()returned its result withattr(., "offspring_production"); the faithful migration isscm <- run_scm(p, refine_schedule = TRUE); scm$parameters; scm$offspring_production— the$parameters-only mapping loses the offspring count.) - Flag semantic (not mechanical) changes loudly. A symbol whose meaning or
default changed while its name stayed the same (e.g. raw
Control()now means fast, not accurate) must say so in words — the skill cannot infer it from a grep and will (correctly) escalate it for human review. - Argument-order / signature changes get their own bullet; advise callers to switch to named arguments.
- For larger reorganisations, also add a narrative subsection here in §3 (as §3.1/§3.2 do) and link it from the NEWS bullet — NEWS carries the exact what, agents.md the why.
R files in R/ (ignore the two large generated files RcppR6.R,
RcppExports.R):
| File | Responsibility |
|---|---|
| ff16.R, tf24.R, k93.R | Per-model constructors (FF16_Individual, FF16_Parameters, …), hyperpar functions, expand_state, stand reports |
| strategy_support.R | Dispatch tables mapping a model name → its functions (hyperpar, make_hyperpar, param_hyperpar, environment_type, Environment, expand_state, node-schedule helpers). These are switch() statements that must list every model. |
| scm_support.R | run_scm (with collect / refine_schedule flags), scm_base_parameters, and the Control presets control (alias) / control_accurate. Fast settings are now C++ Control() defaults (§3.2); adaptive schedule refinement lives in C++ (SCM::refine_schedule, §3.1) — there is no longer an R build_schedule.R |
| stochastic.R | Stochastic simulation driver |
| individual.R | grow_individual_to_{size,height,time}, optimise_individual_rate_*, compensation points |
| util_model.R | trait_matrix, generate_strategy, add_strategies, add_mutant (and deprecated strategy_list/expand_parameters/mutant_parameters shims) |
| tidy_outputs.R | tidy_patch, tidy_species, tidy_env, interpolate_to_{times,heights}, integrate_over_size_distribution |
| tidy_plots.R | Plotting helpers (plot_size_distribution) |
| logging.R | plant_log_console and logging via loggr |
| benchmark.R | run_plant_benchmarks |
| util.R, solar_model.R, qk.R, utils-pipe.R | Misc utilities |
Because the C++ classes are templated on <T,E>, RcppR6 generates one
explicit instantiation per registered pair (FF16/FF16_Env,
TF24/TF24_Env, K93/K93_Env). R-side constructors therefore look like:
FF16_Individual <- function(s = FF16_Strategy()) Individual("FF16", "FF16_Env")(s)
FF16_Parameters <- function(...) Parameters("FF16", "FF16_Env")(...)Individual(...) / Parameters(...) return the correctly-typed generated
constructor. Generic code recovers the type with
extract_RcppR6_template_types(obj, "Parameters") and then dispatches
(see run_scm in scm_support.R).
A Strategy exposes many low-level C++ parameters, held in a nested pars
sub-object (FF16_Pars/K93_Pars/TF24_Pars) and accessed from R as
s$pars$lma, s$pars$rho, … (the strategy's top level only carries control,
birth_rate_*, is_variable_birth_rate, collect_all_auxiliary). In C++ these
are read as pars.<name>; derived/precomputed quantities (e.g. eta_c,
height_0, canopy_shape, the TF24 Leaf) stay as plain strategy members set
in prepare_strategy(), not in pars.
Users normally set a smaller set of ecologically meaningful traits (e.g.
LMA, wood density). The hyperpar functions (FF16_hyperpar, etc.) translate
traits → strategy parameters and encode trade-offs. The pipe-friendly,
object-first entry points are generate_strategy(p, traits) (→ a list of
strategies) and add_strategies(p, traits, …) / add_mutant(p, traits, …) (→ a
Parameters object); trait_matrix() builds the traits matrix. The former
strategy_list() / expand_parameters() / mutant_parameters() are deprecated
shims (see NEWS). Keep hyperpar functions and the
strategy_support.R switch tables consistent — a comment
there flags that the scaffolder also depends on these.
Allometric/size functions (area_leaf, diameter_stem, the mass cascade) live
only in C++; the R *_expand_state() helpers call them via the exported
FF16/TF24_strategy_expand_allometry() (src/strategy_expand.cpp) rather than
re-deriving the formulas.
inst/RcppR6_classes.yml declares, for each exposed
class: its name_cpp, constructor signature, active bindings (fields/getters,
often named with a trailing _ for internal/debug fields), methods, and —
for templated classes — the templates: block listing the concrete
<T,E> pairs to instantiate. This file is what makes a C++ class reachable from
R. If a method/field is not in the yml, R cannot see it.
Use the Makefile targets. The dependency you must internalise: changing the C++/R interface requires regeneration before recompiling.
| Command | What it does |
|---|---|
make RcppR6 |
Regenerate R/RcppR6.R + src/RcppR6.cpp from RcppR6_classes.yml |
make attributes |
Rcpp::compileAttributes() → R/RcppExports.R + src/RcppExports.cpp |
make compile |
pkgbuild::compile_dll() (no attribute regen) |
make full_compile |
compile and regenerate RcppExports |
make roxygen |
devtools::document() → man/ + NAMESPACE |
make rebuild |
The full pipeline: clean → RcppR6 → full_compile → roxygen |
make test |
make all then devtools::test() |
make check / make build / make install |
R CMD check / build / INSTALL |
make benchmark |
run scripts/benchmark.R |
make scenarios |
run the TF24 hydraulic scenario gateway → writes a scorecard RDS (scripts/run_scenario_gateway.R) |
make bless-scenarios |
regenerate the gateway test baseline (tests/testthat/test_data/scenario_baseline.rds) — only when a changed outcome is intended |
Practical rules:
- Edited a header signature, added a method/field, or touched the yml?
run
make rebuild(or at minimummake RcppR6 && make full_compile && make roxygen). - Edited only
.cppimplementation (no interface change)?make compileis enough. - Edited only R code / roxygen comments?
make roxygen(thendevtools::load_all()). - During development,
devtools::load_all()is the fastest way to test changes.
C++ standard is C++20 (CXX_STD = CXX20 in src/Makevars);
note the README still mentions C++14. Requires R ≥ 4.5.0.
This is the most cross-cutting change in the package because every templated
class and every R dispatch table must learn the new <Strategy, Environment>
pair. Use the scaffolder rather than doing it by hand:
source("scripts/new_strategy_scaffolder.R")
create_strategy_scaffold("MyModel", "FF16") # copy FF16 as the template
# variant that reuses an existing environment (issue #274):
create_strategy_scaffold("FF16r", "FF16", environment = "FF16")The scaffolder (scripts/new_strategy_scaffolder.R):
- adds the new pair to the
templates:blocks inRcppR6_classes.yml, - copies and renames the strategy (and, in own-environment mode, the environment) header + source files,
- extends the R dispatch
switch()tables andhelper-plant.Rlists, - with
environment = "<model>", reuses an existing environment instead of generating a new one (no<name>_Environmentfiles/bindings/tests).
After scaffolding, implement the biology: growth/mortality/reproduction in the
new strategy, map competition_effect to the environment, wire rates into
compute_rates, and update state_names()/state_size(). Then run
make rebuild and add tests. The plant-new-strategy skill
(.claude/skills/plant-new-strategy/) captures the full workflow, including a worked
walkthrough implementing Kohyama 1993 as K93
(.claude/skills/plant-new-strategy/worked-example-k93.md).
The three shipped models:
- FF16 — the default model (Falster et al. 2011, 2016, 2018). The reference implementation to copy from.
- K93 — Kohyama 1993, a simpler size-structured model.
- TF24 — newer model using the leaf-level Leaf photosynthesis/hydraulics submodel (incl. the recently added Medlyn stomatal model).
Each model carries a scientific version separate from the package Version:
a static constexpr int scientific_version on the strategy class in
inst/include/plant/models/, surfaced to R as
model_version(type) / model_id(type) ("FF16@v1") via
src/strategy_version.cpp and
R/strategy_support.R. Downstream tools (notably
logpile, which content-addresses archived simulations) read it to decide when
to re-run: reruns follow scientific changes, not software releases.
Rules:
- Bump
scientific_version(in the model header, in the same commit) when you change equations or default parameters such that the simulation output changes for identical inputs. - Do NOT bump for refactors, performance work, interface renames, or serialisation-format changes — the science is unchanged.
- FF16/K93 are scientifically frozen and should rarely move; TF24/TF24f change
often and will bump frequently. Current:
FF16@v1,K93@v1,TF24@v2. - TF24f is a compound version. It is a fast approximation of TF24 that
inherits TF24's equations/parameters, so its version is
"<TF24 version>.<approximation revision>"(TF24f@v2.1). The major component auto-tracksTF24_Strategy::scientific_version(a TF24 change invalidates TF24f too — the safe direction); bumpapproximation_revision(intf24f_strategy.h) only for changes specific to the fast approximation.model_version()therefore returns a string ("1","2.1"), not an integer. - A bump is deliberate: it invalidates that model's
logpilecache and forces reruns. The drift-guard test tests/testthat/test-model-version.R fails when a default changes without a bump (snapshot of each model's defaultpars/control); pure equation changes are not auto-detected and rely on review. Adding a new model → add its constant and a dispatch arm insrc/strategy_version.cpp(the scaffolder should do this).
testthat tests live in tests/testthat/, roughly one file
per component (test-scm.R, test-patch.R, test-strategy-ff16.R,
test-leaf.r, …). Shared setup is in helper-plant.R. Regression baselines for
FF16 are in tests/testthat/FF16_reference/ and checked by
test-strategy-ff16-reference-comparison.R — if you intentionally change FF16
numerics you may need to regenerate these.
Run the suite with make test or devtools::test(); a single file with
devtools::test_active_file() or testthat::test_file(...).
The suite runs under testthat edition 3 and is parallelised by file
(Config/testthat/parallel: true in DESCRIPTION). testthat takes the worker
count from getOption("Ncpus") / the TESTTHAT_CPUS env var, defaulting to
2 — set e.g. Sys.setenv(TESTTHAT_CPUS = "8") in your ~/.Rprofile to use
more cores. Parallelism only pays off with an optimised build: run make
(compiles with -O2) before timing, not a bare devtools::load_all(), which
builds unoptimised and is much slower.
There is also an opt-in scenario gateway test
(test-scenario-gateway.R), skipped by
default and enabled with PLANT_RUN_SCENARIOS=1. It runs the full SCM for every
TF24 hydraulic scenario in inst/scenarios/ and diffs the per-scenario
outcomes against a recorded baseline — a baseline diff, not an "all pass"
assertion, so it catches both regressions and improvements (many scenarios are
expected to fail by design). When a changed outcome is intended, re-bless the
baseline with make bless-scenarios. See
notes/plan-tf24-scenario-framework.md.
CI: .github/workflows/R-CMD-check.yaml and
benchmarks.yml.
Current development is tracked on GitHub:
- Issue tracker: https://github.com/traitecoevo/plant/issues
- Project board (roadmap): https://github.com/orgs/traitecoevo/projects/5 (org-level Projects v2)
Agents can read both through the gh CLI (installed; the repo's
.claude/settings.json pre-allows read-only gh
commands so they run without prompting):
gh issue list -R traitecoevo/plant --state open
gh issue view <n> -R traitecoevo/plant
gh project item-list 5 --owner traitecoevoRequirements: gh must be authenticated (gh auth login). Projects v2 needs
the project scope, which is not granted by default — add it with
gh auth refresh -s project,read:project. Writing to issues/PRs/the board
(create, comment, edit, move) is intentionally not in the allowlist and will
prompt for confirmation.
Documentation is split across several homes — put new content in the right one:
| Content | Home | Source |
|---|---|---|
Narrative docs — task-oriented guides, theory/maths, worked examples (the former vignettes/: plant overview, individuals, patch, demography, parameters, extrinsic_drivers, emergent, self_thinning) |
Overstorey | https://github.com/traitecoevo/overstorey (Quarto) |
Blog / dated experiments — posts pinned to the plant version they were built against (was vignettes/blog/) |
Overstorey's "Adaptively" notebook | same repo |
| Extending the model — adding a new strategy/environment | plant-new-strategy skill (see §7) |
this repo (.claude/skills/plant-new-strategy/) |
| Migrating downstream code to a newer plant interface | plant-update-interface skill (see §3.3) |
this repo (.claude/skills/plant-update-interface/) |
| Function/API reference — per-function docs generated from roxygen | pkgdown site https://traitecoevo.github.io/plant/ | this repo (man/, pkgdown/_pkgdown.yml) |
| Architecture / contributor guide | this file (agents.md) | this repo |
| Installation & citation | README.md | this repo |
| Changelog | NEWS.md | this repo |
| Roadmap / planning | GitHub Projects board (see §9) | — |
Rule of thumb: prose that a user reads → Overstorey; the docstring for a
function → roxygen comment in R/ (rebuilt into the pkgdown reference);
guidance for someone changing the code → this file.
The vignette content was migrated into Overstorey in
799a668
(guides, theory, and the dated posts, with committed Quarto freezes).
The pkgdown site is the function reference only (narrative articles now live
on Overstorey, §above). It is built from roxygen output and config in
pkgdown/ (_pkgdown.yml defines the navbar + reference: layout).
There is no CI workflow for it — build and deploy manually:
devtools::document() # refresh man/ first (or: make roxygen)
pkgdown::build_site() # renders into the gh-pages destination_pkgdown.yml sets destination: gh-pages; the rendered site is published from
the repo's gh-pages branch (pkgdown::deploy_to_branch() does the build +
push in one step). The navbar links out to Overstorey for the guides.
Overstorey itself is a Quarto site with its own CI (publish.yml) — see that
repo's README for how it renders, freezes (_freeze/), and version-pins posts.
- ❌ Do not edit generated files:
R/RcppR6.R,R/RcppExports.R,src/RcppR6.cpp,src/RcppExports.cpp,RcppR6_pre/post.hpp,NAMESPACE,man/. - ✅ Edit headers in inst/include/plant/ and the yml; then regenerate.
- ✅ Adding/removing strategy state → update
state_size()andstate_names(). - ✅ Exposing a new C++ method/field to R → add it to
RcppR6_classes.yml, thenmake RcppR6. - ✅ Adding a model → use the scaffolder; remember the R
switch()tables in strategy_support.R and the hyperpar functions, and add ascientific_versionconstant + dispatch arm insrc/strategy_version.cpp. - ✅ Changing a model's equations/default parameters (output changes for the
same inputs) → bump its
scientific_versionin the model header (see §7). - ✅ After interface changes run the full
make rebuild; after C++-only changesmake compile. - ℹ️ Active bindings ending in
_generally expose internal C++ fields for inspection/testing, not part of the stable user API. - ℹ️ Two solvers (deterministic SCM, stochastic) share the same model classes — changes to a Strategy affect both.
⚠️ Several hot-path constructs look "wrong" but are deliberate performance choices — see §12 before "tidying" them (inline helpers in headers, integer index constants, ratio-first signatures, scratch buffers).
The deterministic SCM solver spends almost all of its time in one nested loop
(SCM::run → ode::derivs → Patch::compute_rates → Species::compute_rates
→ Node::compute_rates/growth_rate_gradient → Strategy::compute_rates →
assimilation/competition). Because that loop runs for every node, every
quadrature point, every timestep, small per-call costs dominate. The codebase
uses a consistent set of techniques to keep it fast. Many of these make the
code look more complicated than the underlying maths — do not "simplify" them
back without re-profiling. The profiling workflow and the catalogue of
techniques that paid off are captured in the profile-plant skill
(.claude/skills/profile-plant/); the umbrella issue is #466, with
follow-up #470 (LTO).
Algorithmic (the big wins):
- Spline-based competition environment — turns O(n²) into ~O(n). Naively,
computing the light each plant experiences means summing the shading of every
other plant, i.e. O(n²) per timestep for n individuals. Instead
Patch::compute_environment()builds a resource spline (resource_spline.h) once per timestep by evaluating cumulative competition at a fixed set of heights; each individual then queries the environment with an O(1) spline lookup (get_environment_at_height). Cost becomes O(n) to build + O(1) per query. - Uniform-grid O(1) spline index.
tk::spline::operator()(tk/spline.h, src/tk_spline.cpp) detects an equidistant knot grid inset_points()and replaces thestd::lower_boundbinary search with direct index arithmetic. Falls back to binary search for adaptive/non-uniform grids. (#435) - Finite-difference gradient without reallocation.
Node::growth_rate_gradient()(node.h) needs a mutableIndividualto perturb height on; it reuses athread_localscratch (copy-assigned each call, reusing vector storage) instead of copy-constructing freshInternalsevery call.
Templated headers & inlining (this build has no LTO). src/Makevars uses
CXX_STD = CXX20 with no -flto and DESCRIPTION has no UseLTO, so a
function defined in a .cpp translation unit cannot be inlined into the
templated Individual<T>/Node/Species code instantiated in another TU.
Every such call is a real, non-inlinable call on the hot path. Consequences you
will see in the code:
- Small, hot strategy helpers are defined inline in the header, not in the
.cpp:area_leaf,update_dependent_aux, thecompute_competitionoverloads, andcompute_competition_by_ratiolive in ff16_strategy.h / tf24_strategy.h;util::is_finiteand theInterpolatoraccessors are inline in their headers. Moving them back into a.cppre-introduces a cross-TU call and measurably slows the loop. - The cleanest fix for the remaining large cross-TU calls (
assimilation,compute_rates) is enabling LTO — tracked separately in #470 because it is a build-config change with toolchain/portability trade-offs.
Avoiding repeated per-call overhead:
- Integer index slots instead of string-map lookups. State/aux/rate access
in the hot path uses fixed integer constants (
HEIGHT_INDEX,MORTALITY_INDEX,*_AUX_INDEXconstexprs in the strategy headers, and the cached*_aux_indexmembers in individual.h) rather thanstd::map<std::string,int>::at("name"). These constants must stay in sync with the order ofstate_names()/aux_names()— there are comments saying so at each declaration. Named string access is kept for the R-facing/diagnostic paths. - Cached dependent auxiliary state. Values that depend only on height are
computed once when height is set (
update_dependent_aux) and stored in aux slots:competition_effect(=area_leaf(height)) andheight_inverse(=1/height). The competition and assimilation paths read these instead of recomputingarea_leafand the division every call. - Eta-specialised canopy shape. canopy_shape.h
(
CanopyShape, shared by FF16/TF24/K93) selects a multiplication-chain implementation ofu^etaonce inprepare_strategy()for common integereta(1,2,4,8,10,12), avoiding the libmpow()slow path per quadrature point; also caches1/eta. (#465, libmpowcost from #361) - Ratio-first signatures.
q()/Q()and the competition helpers take the height-normalised ratiou = z/H(plus a cached1/height) directly, so thez/Hdivision is hoisted out of inner loops rather than repeated per point. - No
std::functionin quadrature.assimilation()passes its integrand lambda to the templatedQK::integrateby its own closure type, not wrapped instd::function, so the integrand inlines at each quadrature point instead of making a type-erased indirect call. - Hoisting loop invariants. The light-spline upper bound (
canopy top) is fetched once perassimilation()call and passed into the cappedget_environment_at_height(z, cap)overload, instead of re-readingspline.max()per quadrature point; within the crown integral the bounds are already guaranteed, so the uncheckedspline(height)is used in place ofspline.eval(). - De-duplicated math kernels. Where two allocation-derivative terms share a
pow(area_leaf, a_l2), it is computed once (seeFF16_Strategy::darea_leaf_dmass_live).
Measuring. Always benchmark with make compile (matches release flags) —
devtools::load_all() alone is not representative. Use
scripts/profile-benchmarks.R:
make compile
PLANT_PROFILE_REPEATS=20 Rscript scripts/profile-benchmarks.R FF16Record results following the profile-plant skill (.claude/skills/profile-plant/).
Bit-identical changes are strongly preferred; where a reciprocal-multiply
reorders floating-point ops, the affected reference tests were relaxed to an
explicit tolerance (noted in that file).
Development across plant, regnans, and overstorey is tracked on a
shared project board. New issues
are auto-added to the board with no Status — that's the triage queue. A maintainer
sets Status (e.g. Backlog) during triage, so you don't need to set it yourself.
When opening an issue (including whenever the user asks you to create one), always:
-
Set exactly one type label. Only three labels exist in these repos — do not invent new ones:
bug— an existing feature not functioning as intendedtask— a discrete task needed for a feature (the default for normal work)epic— a new feature or capability, usually an umbrella over several tasks
-
Prefix the title with a theme tag in square brackets so the board sorts cleanly. Reuse an existing theme where it fits; only fall back to
[other]when nothing applies:Tag Scope [TF24 hydraulics]Hydraulics component of the TF24 strategy [TF24 allometry]Flexible allometry for the TF24 model [TF24 nsc]Non-structural carbohydrate storage in TF24 [acclimation]Acclimation of leaf and other traits [simplify interface]Consistent interface to the plant & regnans models [evol assembly]Evolutionary assembly linking plant to regnans [Env drivers]Driving the model with environmental drivers [speed]Performance — making the model run faster [patch variations]Multiple patch setups (multi-patch, stochastic metapopulation, continuous patch) [AutoDiff]Enabling automatic differentiation in plant (e.g. for gradient-based calibration) [forecasting]Enabling forecasting with the plant model [documentation]Documenting model capabilities (any of the three repos) [other]Anything not covered above A title may carry more than one tag when it genuinely spans themes (e.g.
[speed] [TF24 hydraulics] …).
Create issues with gh issue create -R traitecoevo/plant --title "[tag] …" --label task (swap in bug/epic as appropriate).
plant is part of the plant family in the traitecoevo
org — a hub-and-spoke set of packages built around the
plant size- and trait-structured forest model.
- Docs hub — family user guides & theory: https://traitecoevo.github.io/overstorey/
- Cross-package orientation — how the family fits together (who depends on whom,
source-of-truth rules, cross-repo gotchas) lives in
plant-meta; start with itsAGENTS.md. Keep family-wide concerns there, not here. - Issues & board — follow the
issue guide;
work is tracked on board #5 (new issues
auto-add with no Status = the triage queue). Labels:
bug/task/epicplusblocked,needs-info,cross-package,breaking,question.