Skip to content

Add an optional coarse-root pool to FF16 and TF24 - #644

Open
dfalster wants to merge 1 commit into
developfrom
feature/coarse-roots-349
Open

Add an optional coarse-root pool to FF16 and TF24#644
dfalster wants to merge 1 commit into
developfrom
feature/coarse-roots-349

Conversation

@dfalster

Copy link
Copy Markdown
Member

Both models carried one root pool, fine roots proportional to leaf
area, so below-ground structure did not scale with tree size. Coarse
roots are now the below-ground continuation of the sapwood cylinder,
m_cr = a_cr1 * m_s, which makes the structural root:shoot ratio
size-invariant and keeps the growth allocation closed-form.

a_cr1 defaults to 0, so no model's output and no scientific version
moves. expand_state() gains a mass_coarse_root column.

Closes #349

Both models carried one root pool, fine roots proportional to leaf
area, so below-ground structure did not scale with tree size. Coarse
roots are now the below-ground continuation of the sapwood cylinder,
m_cr = a_cr1 * m_s, which makes the structural root:shoot ratio
size-invariant and keeps the growth allocation closed-form.

a_cr1 defaults to 0, so no model's output and no scientific version
moves. expand_state() gains a mass_coarse_root column.

Closes #349

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dfalster

Copy link
Copy Markdown
Member Author

Why this shape

Three functional forms were on the table for m_cr. Scaling on sapwood mass won for two reasons:

  1. It is the only one that scales with size. Fine roots are a_r1 * A_l; a coarse pool of the same form would just be a relabelled a_r1, and the gap [TF24 allometry] Missing coarse root biomass in ff16 model #349 names — no below-ground structure growing with the tree — would still be there.
  2. It keeps the growth allocation closed-form. d m_cr / d A_l = a_cr1 * d m_s / d A_l, so darea_leaf_dmass_live gains one addend that reuses the dmass_sapwood_darea_leaf term bark already shares. Scaling on total stem was the alternative and was rejected: heartwood is an ODE state rather than a function of A_l, so mass_live stops being f(A_l) and the allocation derivative needs a full rederivation.

Why a_cr1 = 0 and no version bump

The brief was to bump the FF16 scientific version. That changed once the blast radius was clear: FF16's baselines are not snapshots but a cross-check against the original 2012 R implementation in tests/testthat/FF16_reference/, which asserts parameter-name parity in both directions. A non-zero default would put C++ and the published FF16 equations out of agreement on growth, for a parameter value nobody has calibrated. Shipping the pool off keeps that agreement and leaves the choice of a default to whoever has the data.

Every new term is therefore appended, never interleaved, so the defaults are bit-identical rather than merely close: x + 0.0 == x exactly, and the association order of the existing terms is untouched. (mass_total was the one place I first asserted exactness and shouldn't have — its term order already differs from mass_live's, so the two disagree in the last ULP on develop too. That assertion is now a tolerance.)

FF16@v1, TF24@v9 and TF24f@v9.1 all stand.

The drift guard fired, deliberately

test-model-version.R snapshots each model's default pars/control and failed on the three added names. I re-blessed it after checking the diff is exactly three added entries per model —

FF16 TF24 TF24f
a_cr1 0 0 0
k_cr 0.2 (= k_s) 0.2 0.2
r_cr 6.5987 (= r_s) 6.5987 6.5987

— with no existing default changed and no version string moved. r_cr/k_cr default to the sapwood rates because coarse roots are woody; as with r_b, that is a default and not a link, so setting r_s afterwards does not move them.

Coarse roots are kept out of TF24's water uptake

TF24 distributes fine-root mass over soil depth to drive uptake and reads a_r1 directly to do it. Absorbing surface is a fine-root property, so coarse roots are excluded by construction, and test-coarse-roots.R pins it: at fixed size, root_mass, transpiration, E_up_, opt_psi_stem, opt_root_psi and assimilation are all bit-identical with the pool on.

Testing

New tests/testthat/test-coarse-roots.R, 22 assertions, split between "it really is inert at the defaults" and "it is correct when switched on" — a default-off feature only ever run at its default is not tested at all.

The load-bearing one reconstructs dH/dt outright from the published chain rather than checking a ratio, because a ratio survives an error common to both denominators:

dH/dt = dH/dA_l * P * (1 - r(H)) / (dM_live/dA_l)

with r_cr = k_cr = 0 so net production is identical on both sides and the whole change is allocation. Agreement to 1e-12 against R formulas written from inst/docs/FF16/FF16-eqns.csv, not read back from the strategy.

Also extended: test-expand-state.R re-runs its oracle comparison with a_cr1 = 0.25 (at the default every coarse-root term is zero, so the existing checks would pass even if C++ ignored a_cr1 entirely), and both AD kernel tests now carry a non-zero a_cr1 so the new terms are actually differentiated.

Full suite: 3221 pass, 0 fail, plus the scenario gateway run separately with PLANT_RUN_SCENARIOS=1 — green, so TF24's end-to-end numbers have not moved.

Note for whoever builds this

develop pins odelia (== 0.3.1) but 0.4.0 is what's installed locally, so compile_dll() refuses before it compiles anything (the #643 blocker). I built against a git archive of the v0.3.1 tag into a scratch library rather than touching the pin.

Not done here

The vertical distribution of root mass over depth — @aornugent's comment on #349, mapping the Rhizomorph cases with an eta-analogue over (depth_max, 0] — is a separate piece of work. This adds the pool; it does not place it in the soil.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[TF24 allometry] Missing coarse root biomass in ff16 model

1 participant