library(phylloptim)
library(dplyr)
library(tidyr)
library(ggplot2)
theme_set(theme_bw(base_size = 11) +
theme(panel.grid.minor = element_blank(),
strip.background = element_rect(fill = "grey95",
colour = NA)))Most leaf gas-exchange models obtain stomatal conductance from an empirical function of assimilation, VPD and CO₂. Ball-Berry, Medlyn and their descendants all work that way. This model has no such function.
Instead it builds an explicit hydraulic path from the soil, through the roots and the root collar, up the stem to the leaf. Then it asks which leaf water potential maximises profit, where profit is assimilation minus a hydraulic cost that rises as the xylem loses conductivity. Conductance is whatever falls out of the answer.
Stomatal closure under drought is therefore not fitted here. It is derived, from two Weibull vulnerability curves, the soil potential, and the plant’s own conductance.
| Section | What it covers |
|---|---|
| One solve to Light and VPD together | driving the model and reading the result |
| Traits and numerical settings are separate | which arguments are physiology and which are numerics |
| Water potentials are positive magnitudes | the one convention that will break a ported script |
| Two supply paths, two routes | the root network plant uses, and the collar route |
| Trait gradients | exact derivatives, and when they are worth using |
| A faster method | the closed form: what it costs in accuracy, and when it refuses |
vignette("the-models") is the reference for what is
being solved. vignette("fitting") works through a
calibration.
leaf_solve(psi_soil = 2.0, PPFD = 900) |>
as_tibble() |>
glimpse()
#> Rows: 1
#> Columns: 23
#> $ psi_soil <dbl> 2
#> $ layers <int> 1
#> $ PPFD <dbl> 900
#> $ atm_vpd <dbl> 2
#> $ ca <dbl> 40
#> $ leaf_temp <dbl> 25
#> $ atm_kpa <dbl> 101.3
#> $ psi_leaf <dbl> 3.864854
#> $ collar <dbl> NaN
#> $ ci <dbl> 18.71375
#> $ A <dbl> 12.8818
#> $ E <dbl> 3.641312e-05
#> $ gc <dbl> 0.0613037
#> $ profit <dbl> 9.188099
#> $ hydraulic_cost <dbl> 3.693699
#> $ E_up <dbl> NaN
#> $ uptake <dbl> 0
#> $ lambda <dbl> 191735.2
#> $ g1_eff <dbl> 1.243302
#> $ Tleaf <dbl> 25
#> $ shadow_cost <dbl> 0
#> $ residual_flux <dbl> 0
#> $ carbon <dbl> 9.188099Drivers in, operating point out. psi_soil is a positive
magnitude in MPa, which a later section covers. The defaults are
plant’s model, TF24_floor, which at its
default price is TF24, maximised over the leaf potential with the
potential upstream of the stem held at psi_soil (the
leaf route). collar is NaN because
that route never solves for a collar potential; the root network
plant uses, and the collar route that solves for
one, come later.
Most of those columns are the usual gas-exchange quantities. Two are less usual.
lambda is the marginal cost of water, dA/dE, at the
operating point. It is how much carbon the leaf gives up per unit of
water it declines to transpire, and it is the quantity the optimisation
equalises.
g1_eff re-expresses the solved conductance as the
g1 of the Medlyn model, which is a convenient common scale
if that is the model you are used to. It is computed from the solve
rather than supplied, so it varies with conditions.
leaf_solve() is vectorised over its drivers, so a
response curve is one call.
drought <- leaf_solve(psi_soil = seq(0.2, 5.5, length.out = 40), PPFD = 900) |>
as_tibble()
drought |>
select(psi_soil, A, gc) |>
pivot_longer(c(A, gc), names_to = "variable") |>
mutate(variable = recode(variable,
A = "assimilation~(mu*mol~m^-2~s^-1)",
gc = "stomatal~conductance~(mol~m^-2~s^-1)")) |>
ggplot(aes(psi_soil, value)) +
geom_line(linewidth = 0.8, colour = "steelblue4") +
facet_wrap(~variable, scales = "free_y", labeller = label_parsed) +
labs(x = "soil water potential (MPa)", y = NULL)Both fall as the soil dries, because each unit of water costs more as
the stem nears the end of its vulnerability curve. Neither is held up by
a minimum-conductance floor: g_min is zero by default.
g1_eff across the same drought, over the range where the
stomata are open:
drought |>
filter(gc > 0, is.finite(g1_eff)) |>
ggplot(aes(psi_soil, g1_eff)) +
geom_line(linewidth = 0.8, colour = "firebrick") +
labs(x = "soil water potential (MPa)",
y = expression(effective~g[1]~(kPa^0.5)))It declines as the soil dries, because it is computed from the solve rather than being a parameter of it.
Because leaf_solve() recycles its drivers elementwise, a
factorial design is an expand_grid() and a single call.
There is no loop, and nothing to join back afterwards, because the
drivers come back as columns of the answer.
expand_grid(PPFD = seq(50, 1800, length.out = 30),
atm_vpd = c(0.5, 1, 2, 3)) |>
with(leaf_solve(psi_soil = 1.0, PPFD = PPFD, atm_vpd = atm_vpd)) |>
ggplot(aes(PPFD, A, colour = factor(atm_vpd))) +
geom_line(linewidth = 0.8) +
scale_colour_viridis_d(end = 0.9, direction = -1) +
labs(x = expression(PPFD~(mu*mol~m^-2~s^-1)),
y = expression(A~(mu*mol~m^-2~s^-1)),
colour = "VPD (kPa)")The curves saturate at different heights because the hydraulic cost of the transpiration needed to support a given assimilation rises with VPD.
The C++ constructor takes fifteen positional arguments, four of which
are tolerances sitting among the physiology. There are also four traits
it does not take at all, which have to be assigned afterwards.
leaf_traits() and leaf_control() split the
first fifteen and supply the missing four, so a calibration loop varying
traits never has to know either fact.
vulnerable <- leaf_traits(stem_P50 = 2.2, root_P50 = 2.2)
bind_rows(
default = leaf_solve(psi_soil = 3.0, PPFD = 900),
vulnerable = leaf_solve(psi_soil = 3.0, PPFD = 900, traits = vulnerable),
.id = "traits"
) |>
select(traits, psi_leaf, A, gc, g1_eff)
#> traits psi_leaf A gc g1_eff
#> 1 default 4.760361 9.0675958 0.035387735 0.7651323
#> 2 vulnerable 3.278536 0.8185905 0.002452242 0.2586561stem_P50/stem_c and
root_P50/root_c are two different
vulnerability curves. The stem pair drives the hydraulic cost; the root
pair drives uptake. They are named explicitly rather than left as an
unmarked b/c pair, because using one where the
other belongs is easy to do and produces a plausible wrong answer rather
than an error.
Tolerances go the other way, through leaf_control():
tibble(
setting = c("default", "tight"),
psi_leaf = c(
leaf_solve(psi_soil = 2.0, PPFD = 900)$psi_leaf,
leaf_solve(psi_soil = 2.0, PPFD = 900,
control = leaf_control(GSS_tol_abs = 1e-5))$psi_leaf
)
)
#> # A tibble: 2 × 2
#> setting psi_leaf
#> <chr> <dbl>
#> 1 default 3.86
#> 2 tight 3.86Those two are identical, because the solve root-finds its own
first-order condition rather than searching profit, to about 1e-12.
GSS_tol_abs does not reach the leaf route at all.
It is read in two places, both on the collar route: the threshold below which the feasible collar interval is treated as a single point, and the tolerance of a fallback search used when neither bracket endpoint has a usable gradient.
This is not only a precision story. It is what makes the trait gradients below possible. An argmax resolved only to 1e-3 moves in discontinuous jumps as a trait varies, so its derivative comes back as noise, or worse, as something smooth, plausible and sign-inverted.
There is one representation throughout: every ψ is a positive number
of MPa, including psi_soil, psi_crit and the
collar potential. Where an equation needs one potential to oppose
another, the minus sign is in the equation.
This is asserted rather than documented, which matters if you are porting a script that passed signed potentials:
leaf_solve(psi_soil = -2.0, PPFD = 900)
#> Error:
#> ! `psi_soil` must be positive magnitudes in MPa, not signed potentials. If this came from an older script, drop the minus sign rather than negating the result.That is an error rather than a quiet abs(). A model
accepting both conventions would let such a script return a plausible
wrong number instead.
Everything above used the single-potential supply, which collapses
the whole soil-to-collar journey into one series resistance. The other
path is the multi-layer root network that plant uses. Both
are driven through the same root_network argument, because
both take their resistances as a per-call driver.
On the leaf route that resistance never reaches the solve: the
potential upstream of the stem is pinned at psi_soil. The
collar route maximises over the root-collar potential
instead, so water has to be pulled through the roots, and the resistance
costs the leaf. Every cost curve takes either route; the collar route is
the only one on the multi-layer supply.
leaf_solve(psi_soil = 1.5, PPFD = 900, route = "collar",
root_network = series_resistance(1e3)) |>
as_tibble() |>
select(psi_soil, psi_leaf, collar, A, gc, g1_eff)
#> # A tibble: 1 × 6
#> psi_soil psi_leaf collar A gc g1_eff
#> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
#> 1 1.5 3.49 2.52 8.22 0.0310 0.691The resistance is per unit leaf area, because nothing in this model is allowed to scale with plant size. On the collar route, raising it makes water harder to get, and the leaf closes down:
tibble(resistance = 10^seq(2.5, 4.5, length.out = 25)) |>
rowwise() |>
mutate(A = leaf_solve(psi_soil = 1.5, PPFD = 900, route = "collar",
root_network = series_resistance(resistance))$A) |>
ungroup() |>
ggplot(aes(resistance, A)) +
geom_line(linewidth = 0.8, colour = "steelblue4") +
scale_x_log10() +
labs(x = expression(soil-to-collar~resistance~(MPa~s~mol^-1~m^2)),
y = expression(A~(mu*mol~m^-2~s^-1)))The single potential is what makes comparison against other optimality models meaningful. Medlyn, Prentice least-cost and Cowan-Farquhar are all formulated against a single soil potential, so comparing them against a multi-layer root network would compare two things at once.
The supply is chosen when the leaf is built rather than afterwards.
There is no settable leaf$supply_kind:
Flipping the tag alone would leave the other path’s state configured
and silently ignored. Both leaf_supply_*() functions
therefore reconfigure the object completely, so it can never be in a
state where the tag and the supply disagree.
On the multi-layer supply, pass a vector for a layered profile, or a list of vectors for several profiles. Layers default to 1 m thick with the root carbon split evenly.
leaf_solve(psi_soil = list(c(1.0, 1.5, 2.0), c(3.0, 3.5, 4.0)), PPFD = 900,
supply = leaf_supply_multilayer(), route = "collar") |>
as_tibble() |>
select(psi_soil, layers, A, gc, uptake)
#> # A tibble: 2 × 5
#> psi_soil layers A gc uptake
#> <dbl> <int> <dbl> <dbl> <dbl>
#> 1 1 3 5.00 0.0168 0.000555
#> 2 3 3 1.42 0.00431 0.000142psi_soil in the result is the wettest layer, with
layers alongside, so the column stays numeric and the frame
stays rectangular.
leaf_solve() is a wrapper over an R6 object you can
drive yourself. This is what plant does, and it is what you
want when you care about the intermediate state rather than only the
operating point.
It is also the cheaper route per row: driving one object by hand, as
below, measures at about half of leaf_solve()’s cost per
row. Both are dominated by the same thing, which is that a call across
the R boundary costs about half as much as the whole C++ solve. If you
need an inner loop faster than that, what has to go is the number of R
calls per row rather than which R function makes them. The measured
figures are in the README’s Performance
from R.
l <- leaf_model()
set_drivers(l, psi_soil = 2.0, PPFD = 900)
l$optimise()
tibble(profit = l$profit_, cost = l$hydraulic_cost_, A = l$assim_colimited_)
#> # A tibble: 1 × 3
#> profit cost A
#> <dbl> <dbl> <dbl>
#> 1 9.19 3.69 12.9The object exposes the whole model, not just the operating point. The stem vulnerability curve is defined for any potential:
tibble(psi = seq(0, 6, length.out = 200)) |>
mutate(k = vapply(psi, l$proportion_of_conductivity, numeric(1))) |>
ggplot(aes(psi, k)) +
geom_line(linewidth = 0.8, colour = "darkgreen") +
labs(x = expression(psi~(MPa)), y = "proportion of conductivity",
title = "stem vulnerability")The profit function is not defined everywhere. Water flows from the
soil to the leaf, so psi_leaf must be at least the upstream
potential. Below it the leaf would be wetter than the soil, so
transpiration(), stom_cond_CO2() and
psi_leaf_to_ci() stop with an error rather than return a
negative flux. profit_psi_leaf_TF24() reads that pair as
zero flux, as the solve’s evaluator does, so it returns a number, but
not one on the curve the solve maximises. psi_leaf equal to
the upstream potential is the shut stomata’s state, with no stomatal
flux. Plot the domain the solve actually searches:
psi_upstream <- 2.0
tibble(psi = seq(psi_upstream, 6, length.out = 200)) |>
mutate(profit = vapply(psi, l$profit_psi_leaf_TF24, numeric(1),
psi_upstream = psi_upstream)) |>
ggplot(aes(psi, profit)) +
geom_line(linewidth = 0.8, colour = "darkgreen") +
geom_vline(xintercept = l$opt_psi_leaf_, linetype = 2, colour = "grey40") +
labs(x = expression(psi[leaf]~(MPa)), y = "profit",
title = "the function being maximised")The dashed line is where the solve landed, and the maximum it sits on
is flat. That flatness has a practical consequence: the profit
value is much better determined than its location. An
error dp in the value displaces the location by
sqrt(dp/k) for curvature k, so locating the
peak by comparing heights costs precision that solving
dprofit = 0 keeps. It is why the solve does not search this
curve. vignette("the-models") Appendix C has the
measurements.
Because the operating point is now located precisely, it can be
differentiated. leaf_gradient() returns the
derivative of each solved output with respect to each trait:
g <- leaf_gradient(psi_soil = 2.0, PPFD = 900,
pars = c("vcmax_25", "jmax_25", "stem_P50", "TF24_cost_scale"))
round(g$gradient, 5)
#> A gc psi_leaf collar profit
#> vcmax_25 0.05412 0.00007 0.00374 NaN 0.04565
#> jmax_25 0.01923 0.00008 0.00408 NaN 0.01000
#> stem_P50 3.78753 0.03326 0.57970 NaN 5.04967
#> TF24_cost_scale -0.29384 -0.00258 -0.12968 NaN -0.49249pars selects them, and by default you get all of them.
Omit it and leaf_gradient() differentiates every parameter
it can:
dim(leaf_gradient(psi_soil = 2.0, PPFD = 900)$gradient)
#> [1] 18 5
rownames(leaf_gradient(psi_soil = 2.0, PPFD = 900)$gradient)
#> [1] "vcmax_25" "stem_c"
#> [3] "stem_P50" "root_c"
#> [5] "root_P50" "TF24_beta2"
#> [7] "jmax_25" "phi_J"
#> [9] "curv_fact_elec_trans" "curv_fact_colim"
#> [11] "TF24_cost_scale" "R_d_25"
#> [13] "JS22_gamma" "CMax_a"
#> [15] "CMax_b" "g_min"
#> [17] "leaf_specific_conductance_max" "TF24_floor_lambda_o"That is 16 traits from leaf_traits(), plus
leaf_specific_conductance_max, which is not a trait but is
fitted like one, and TF24_floor_lambda_o, the default
model’s price of water: 18 in all. The collar route over the
single-potential path adds resistance, its series
resistance, which the leaf route never reads;
model = "TF24" drops the price, and
model = "CF77" swaps in CF77_lambda_, that
curve’s prescribed marginal value of water. These counts are computed
rather than written out, for the reason the hidden chunk below
records.
R_d_25, dark respiration at 25 °C, is one of the traits
and behaves like any other. So dY/dvcmax_25 is a partial
derivative at fixed respiration. A fit that wants respiration to follow
Vcmax moves both and adds the two columns.
Selecting fewer parameters costs no less. The work is one solve plus one pass that carries the derivative in every parameter at once, so asking for one parameter costs what asking for all of them does.
| request | ms per call |
|---|---|
pars = NULL (all 18) |
2.80 |
| 4 parameters | 2.49 |
| 1 parameter | 2.48 |
So name the parameters you are fitting for the shape of the result, not for speed.
The five outputs are not selectable. Every call returns the
derivative of A, gc, psi_leaf,
collar and profit with respect to each
requested parameter, because they are read off the same solve and cost
nothing extra. pars chooses the rows of that matrix, never
the columns.
The first four are what a gas-exchange calibration observes.
profit is the objective, and it is there for a demographic
consumer: plant bills carbon from the leaf’s
profit rather than its assimilation. It is also the one
output the envelope theorem reaches, so at an interior optimum its
gradient is the direct partial alone. ?leaf_gradient
explains that, along with the measurement that makes it worth doing
rather than merely true.
If your model carries the collar potential rather than solving for it
each step, as plant does, pass it and the gradient is taken
there. This and the rest of the section are on the collar route over the
multi-layer supply:
collar <- list(supply = leaf_supply_multilayer(), route = "collar")
g <- leaf_gradient(psi_soil = 2.0, PPFD = 900, pars = "vcmax_25", psi = 2.7,
supply = collar$supply, route = collar$route)
g$status
#> [1] "prescribed"psi is exogenous, so the answer is the partial
derivative at fixed collar, plus dpsi_dtheta if the collar
you imposed itself moves with the traits. M and
dY_dpsi come back too, so a caller integrating its own
sensitivity of psi has the coefficients.
Hand back the collar the solver found, with the response it derived, and the two paths agree to rounding:
a <- leaf_gradient(psi_soil = 2.0, PPFD = 900, pars = "vcmax_25",
supply = collar$supply, route = collar$route)
b <- leaf_gradient(psi_soil = 2.0, PPFD = 900, pars = "vcmax_25",
psi = a$psi, dpsi_dtheta = -a$M / a$H,
supply = collar$supply, route = collar$route)
all.equal(a$gradient, b$gradient, tolerance = 1e-12)
#> [1] TRUEA psi outside the feasible collar interval is refused:
the leaf does not move it onto the interval’s end, where the outputs
would follow the bound rather than your
dpsi_dtheta. status reports
"refused", g$psi is the one you gave, and
everything else is NA. A feasible psi whose
leaf potential lies in the stem curve’s tail, just inside the dry end,
is "no-gradient": it has a value, but every row would be
rounding there.
Naming something that cannot be differentiated is an error rather than a silent omission, and the message says what is available instead:
leaf_gradient(psi_soil = 2.0, PPFD = 900, pars = "resistance",
supply = collar$supply, route = collar$route)
#> Error:
#> ! `pars` names things this cannot differentiate: resistance. Available: the traits from leaf_traits(), plus `leaf_specific_conductance_max`. `resistance` is a parameter of the single-potential path only; on the multi-layer path the soil-to-collar resistances are derived from root carbon.These are not finite differences of the solve. The outputs are read
at the profit-maximising collar potential, so a trait moves them twice
over: directly, and by moving the optimum itself. Differentiating the
optimality condition dprofit/dψ = 0, rather than the solved
output, captures both terms, and one pass of automatic differentiation
carries every parameter at once.
For TF24_cost_scale, TF24_beta2,
stem_P50 and stem_c the second term is the
entire answer. Change any of them and assimilation moves only because
the leaf chooses a different operating point.
The interesting part is where that derivation stops being valid. It
assumes the optimum is interior, and in dry soil it often is not: the
leaf ends up at the very edge of the range of collar potentials it can
physically occupy. There dprofit/dψ is not zero at the
answer, and the formula is not merely imprecise but wrong by orders of
magnitude, without failing loudly. The solve records which kind of point
it found, and at a pinned one leaf_gradient()
differentiates the bound that holds the leaf instead:
sweep <- lapply(seq(1.0, 5.0, by = 0.125), function(p) {
g <- leaf_gradient(psi_soil = p, PPFD = 900, atm_vpd = 3.0, pars = "stem_P50",
supply = collar$supply, route = collar$route)
tibble(psi_soil = p, dA_dstem_P50 = g$gradient["stem_P50", "A"],
status = g$status)
}) |> bind_rows()
ggplot(sweep, aes(psi_soil, dA_dstem_P50)) +
geom_line(colour = "grey60") +
geom_point(aes(colour = status), size = 2) +
labs(x = expression(psi[soil]~(MPa)),
y = expression(dA/d*italic(stem_P50)),
colour = NULL,
title = "sensitivity to stem vulnerability, and where the optimum pins")On the leaf route this sweep stays interior throughout: the potential
upstream of the stem is pinned at psi_soil, so there is no
collar interval to pin against.
Read that left to right. In wet soil, a less vulnerable stem is worth
about two units of assimilation per MPa of stem_P50. The
sensitivity rises as the soil dries, because vulnerability matters more
when water is scarce, peaks near 2.9 MPa, eases off, and then falls away
sharply. Past about 3.9 MPa the optimum is a bound: the leaf is stuck at
the wettest collar potential it can physically reach, and a stem trait
it has no freedom to exploit stops mattering at all. The tail is exactly
zero.
The transition itself is a genuine kink in trait space, and a real
non-differentiable set. It belongs to the constrained problem rather
than to this implementation. A gradient-based fit that wanders across it
is optimising through a corner, and no amount of care in the derivative
changes that. What leaf_gradient() can do is tell you when
you are there, which a naive finite difference cannot:
status marks these points, from the kind of point the solve
recorded.
Everything above reaches the operating point the same way: a
root-find of the first-order condition over the feasible interval. That
is the method = "exact" path, and it is the default.
set_model() takes a third argument, and
"closed" selects a different route to the same optimum.
The idea is the one vignette("the-models") builds the
whole comparison on. Every model here satisfies
dA/dE = lambda at its optimum, and given
lambda the answer collapses to the Medlyn relation
ci/ca = xi/(xi + sqrt(D)) with
xi = sqrt(Q/lambda). The difficulty is that
lambda depends on the psi you are solving for
— but where it is a power law in psi at the wet end, that
circularity inverts explicitly. One Newton step on the
supply-minus-demand residual finishes the job, and a search over the
bracket is not needed at all.
cf <- leaf_model(supply = leaf_supply_singlelayer())
set_drivers(cf, psi_soil = 0.5, PPFD = 1500, atm_vpd = 1,
root_network = series_resistance(1500))
cf$set_model("TF24", "leaf", "closed")
cf$optimise()
tibble(method = cf$model_method(), psi_leaf = cf$opt_psi_leaf_,
A = cf$assim_colimited_, fell_back = cf$last_solve_fell_back_)
#> # A tibble: 1 × 4
#> method psi_leaf A fell_back
#> <chr> <dbl> <dbl> <lgl>
#> 1 closed 2.43 19.7 FALSEIt is refused rather than approximated wherever it does not hold, and the refusals carry their reasons:
TF24, CF77 and
TF24_floor only, of the nine cost curves. The
three-way split is worth knowing, because only one part of it is a gap
in the code. A closed form exists exactly when h'(A), the
derivative of the benefit link, does not depend on the solution. For
JS22, CMax and ProfitMax it does
not — so the inversion exists for them and is simply not
written yet. For SOX and JW26 it does: their
link is log, so h'(A) = 1/A, and
lambda therefore carries the assimilation the solve is for.
Given lambda -> xi -> ci -> A the dependency
closes into a fixed point, which can be iterated but not inverted. Those
two cannot have a closed form.
⚠️ TF24_floor needs
$TF24_floor_lambda_o > 0, and its tail is worse than the
other two’s. Its wet-end lambda is
lambda_o — a constant, so the same n = 0 case
as CF77 — and at lambda_o = 0 that limit is
zero, xi = sqrt(Q/lambda) diverges, and there is nothing to
expand about; that case is TF24, which has its own start,
and the refusal points there. Where it does apply, the leading order
discards the whole Θ̃'(ψ)/K(ψ) term, which at the DEFAULT
TF24_cost_scale dominates lambda_o at the
operating point. Measured over a 150-row driver grid, the worst served
error is 2.9e-01 at TF24_cost_scale = 0.5 (against
CF77’s own 2.4e-01) and 5.2e-01 at the
default 7.5. The ci/ca guard does not catch that, because
it reads the closed form’s own lambda and stays plausible
while psi* is wrong — so on this curve the fast arm is a
measurement tool rather than a production path.
The leaf route only, and one soil layer only.
The inversion is against a supply measured from psi_soil;
it says nothing about the soil-to-collar path, and the multi-layer
closed form does not exist.
Not with the energy balance on. Fick’s law
divides by the leaf-to-air deficit, which depends on leaf temperature,
which depends on transpiration — which is what the inversion solves for.
So D is not known at the moment of inversion and the form
is not closed. This is a statement about the form rather than about its
accuracy: measured with the refusal bypassed, the error reaches 2.3 MPa
in psi_leaf even in the wettest bin, against 0.04 MPa on
the prescribed-temperature path.
cf$set_model("SOX", "leaf", "closed")
cf$optimise()
#> Error:
#> ! the closed form does not exist for the SOX cost curve, and it is not a matter of unwritten algebra. SOX, JW26 and LeastCost are the three curves with the LOG benefit link, where h'(A) = 1/A -- so lambda carries the assimilation the solve is for, and given lambda -> xi -> ci -> A the dependency closes into a fixed point rather than an inversion. Use method "exact".The guard on validity tests an output —
ci/ca at the answer — so it can only be applied after
solving, and a rejected row falls back to the exact solve. That makes
the fallback fraction phi part of the price: the realised
speedup is 1/[phi + (1 - phi)/s] rather than the ceiling
s.
Measured in tests/cpp/bench_solve at 0.16.2, with both
arms in one process on a 96-point single-layer driver grid:
| curve | realised speedup | phi |
ceiling at phi = 0 |
|---|---|---|---|
TF24 |
2.2× | 0.27 | 4.0× |
CF77 |
2.0× | 0.39 | 5.3× |
Two-fold, in other words — not the order of magnitude an earlier version of this code advertised. Those figures predated the current solver, which root-finds the first-order condition where it used to search the objective, and they also priced the inversion alone rather than a solve a caller can read outputs from.
phi is reported per leaf, so you can measure it on your
own drivers rather than taking the grid’s:
sweep_leaf <- leaf_model(supply = leaf_supply_singlelayer())
sweep_leaf$set_model("TF24", "leaf", "closed")
for (p in seq(0.25, 5, length.out = 20)) {
set_drivers(sweep_leaf, psi_soil = p, PPFD = 1500, atm_vpd = 1,
root_network = series_resistance(1500))
sweep_leaf$optimise()
}
tibble(calls = sweep_leaf$closed_form_calls_,
fell_back = sweep_leaf$closed_form_fallbacks_,
phi = sweep_leaf$closed_form_fallback_fraction())
#> # A tibble: 1 × 3
#> calls fell_back phi
#> <int> <int> <dbl>
#> 1 20 6 0.3Three approximations sit inside the closed form: the power law for
lambda, the single Newton step, and the Medlyn collapse
itself — whose 3*Gstar group is the
electron-transport-limited dA/dci, so it is not exact under
colimitation. The last of those is the binding one, and the error is
organised by ci/ca:
grid <- expand.grid(psi_soil = c(0.25, 0.5, 1, 2, 3, 4, 5),
PPFD = c(100, 500, 1500),
atm_vpd = c(0.5, 1, 2, 3))
solve_both <- function(psi_soil, PPFD, atm_vpd) {
one <- function(method) {
l <- leaf_model(supply = leaf_supply_singlelayer())
set_drivers(l, psi_soil = psi_soil, PPFD = PPFD, atm_vpd = atm_vpd,
root_network = series_resistance(1500))
l$set_model("TF24", "leaf", method)
l$optimise()
l
}
ex <- one("exact")
cl <- one("closed")
tibble(ci_ca = ex$ci_ / ex$ca_, fell_back = cl$last_solve_fell_back_,
err_A = abs(cl$assim_colimited_ - ex$assim_colimited_) /
max(1, abs(ex$assim_colimited_)),
err_psi = abs(cl$opt_psi_leaf_ - ex$opt_psi_leaf_))
}
res <- Map(solve_both, grid$psi_soil, grid$PPFD, grid$atm_vpd) |>
bind_rows() |>
filter(is.finite(ci_ca))
res |>
mutate(bin = cut(ci_ca, c(0, 0.25, 0.4, 0.5, 0.6, 0.8, 1))) |>
group_by(bin) |>
summarise(n = n(), fell_back = sum(fell_back),
mean_err_A = mean(err_A), worst_err_A = max(err_A),
worst_err_psi = max(err_psi), .groups = "drop")
#> # A tibble: 6 × 6
#> bin n fell_back mean_err_A worst_err_A worst_err_psi
#> <fct> <int> <int> <dbl> <dbl> <dbl>
#> 1 (0,0.25] 11 11 0 0 0
#> 2 (0.25,0.4] 13 13 0 0 0
#> 3 (0.4,0.5] 6 3 0.0106 0.0245 0.164
#> 4 (0.5,0.6] 14 0 0.0219 0.0659 1.50
#> 5 (0.6,0.8] 25 0 0.00740 0.0364 0.265
#> 6 (0.8,1] 15 0 0.00299 0.00679 0.0862Read it as three regimes. Above ci/ca about 0.6 the
closed form agrees with the exact solve to well under a percent in
assimilation on average, and a few percent at worst. Between 0.5 and 0.6
the mean is a couple of percent. And below about 0.4 the guard has fired
on nearly every row, so the tabulated error collapses to the solver’s
own floor — those rows are the exact solve.
⚠️ The failure below ci/ca about 0.3 is
topological, not a matter of tolerance. There the exact optimum
turns back toward shutdown while the inversion runs on to
psi_crit: the fixed point the closed form is looking for
has ceased to exist, so the answer is wrong rather than imprecise, and
no amount of refinement recovers it. That is why there is a guard and a
fallback at all rather than a tighter convergence criterion — and it is
why $last_solve_fell_back_ is worth reading if you are
going to trust an individual row.
plant differentiates the operating point with respect to
plant state, and that needs the argmax to vary smoothly with
its inputs. Sweeping kmax — which is how height reaches the
leaf — over ±30% and taking the mean absolute second difference of
opt_psi_leaf_:
| the sweep | exact | closed |
|---|---|---|
| never crosses the guard | 6.1e-06 | 7.2e-06 |
| crosses the guard | 1.0e-05 | 1.0e-03 |
The closed form’s own arithmetic is barely rougher than the solve, at about 1.2×. What costs two orders of magnitude is the fallback boundary: the guard tests an output, so the set of drivers on which the method switches is a surface in driver space, and the argmax jumps as a smooth input carries the leaf across it. A mixture of two methods is discontinuous even where both are individually smooth.
So this is usable for a large batch of independent operating points,
and it is not usable underneath a derivative.
leaf_gradient() reflects that: seating a model resets the
method to "exact", so a gradient always differentiates the
exact solve whatever was seated before.
Every model simplifies. The assumptions most worth keeping in mind when reading output from this one are:
TF24 is the default because it is what
plant runs, not because it has been shown to be the right
account of what a leaf is spending. vignette("the-models")
has the other six and how they differ.vignette("the-models") is the reference: the objective, the
nine cost curves, the first-order conditions, and the two optional
gates.vignette("fitting") works through a calibration, and covers
when the exact gradient earns its complexity.vignette("cpp-interface")
covers it.COMPARISON.md sets it against
plantecophys, tealeaves and
bigleaf.One loose end worth flagging if you are supplying your own
hydraulics. Both supply paths take resistances through the same
root_network argument, so a caller with measured or fitted
resistances never has to go through a root carbon model. The single
exception is gravity_head, which
leaf_supply_singlelayer() still takes as configuration,
because that path has no depth profile to derive it from.