vignette("phylloptim") shows the model running.
vignette("fitting") fits it to data. This page is the
reference for what is being solved: the objective, the cost
curves that distinguish one published model from another, the
first-order conditions, and the two optional blocks that change
them.
Most numbers quoted in the prose here are produced by the code chunk beside them. That is deliberate. A formula sitting next to code it no longer matches is the failure this document is most exposed to.
Some numbers cannot be generated that way. Timings, cross-platform magnitudes, and the fitted vulnerability curves in Appendix A all come from elsewhere: the benchmark harness, CI, the literature. Nothing re-checks those when this page is rendered, so treat them as reported measurements rather than as live output.
Several counts below are quoted against the reference grid, which is worth naming once. It is a fixed set of 672 driver combinations — 7 soil potentials, 4 light levels, 4 vapour pressure deficits and 3 soil-layer counts, at two leaf temperatures — that the package solves and stores as a regression baseline. When a passage says “51 of 288 feasible rows”, those are rows of that grid.
| If you want | Read |
|---|---|
| the shape of the problem | A leaf choosing where to operate, then The problem, stated |
| what distinguishes the published models | The cost curves, then The variants, under one roof |
| a single number that compares models | λ is the common currency |
| the photosynthesis and hydraulics underneath | The biological components |
| how a vulnerability curve is parameterised | Appendix A |
| where the gradients come from | Appendix B |
| whether a number you computed means anything | Appendix C |
A leaf with open stomata takes up CO₂ and loses water. Opening further buys more carbon, but the water then has to be pulled through the plant at greater tension, and that tension is not free. Somewhere between shut and wide open there is a best place to sit. Locating it is what all the models on this page do.
To see the trade-off, put both effects on one axis. Take the leaf water potential as the dial, with more tension to the right and the stomata wider open, then plot what the leaf gains and what it gives up. That is panel (a) below. The other three panels summarise the rest of this page.
l0$set_model("TF24", "leaf"); l0$optimise()
cat(sprintf("best operating point: %.3f MPa, profit %.3f\n",
l0$opt_psi_leaf_, l0$profit_))
#> best operating point: 3.215 MPa, profit 13.852Each panel answers a different question.
| panel | what it shows |
|---|---|
| (a) the trade-off | The benefit rises and then flattens, because pulling harder recruits progressively less conductance. The cost keeps climbing. Their difference therefore has an interior maximum, and the dotted line is where the solver puts it. |
| (b) the root-find inside the benefit | c_i is not free. It sits where diffusive supply meets
biochemical demand, and that intersection is recomputed at every
candidate ψ. |
| (c) the wiring | One dial feeds two branches, which meet again at the objective. The two gates are drawn dashed because both default to off. The arrows crossing the gutter are the interactions: the energy balance is a cycle, and the thermal cost reaches past it into the benefit column. |
| (d) the physical path | The plant that all of this describes, with the modelling choices separated from the physics. |
Everything else on this page is a variation on that picture. The models differ in the cost curve. The supply topologies differ in what the dial is physically connected to. The two gates change both curves at once.
The figure is a function, plot_model_overview(), so it
can be regenerated as a vector file for use elsewhere:
\[\psi^{*}=\arg\max_{\psi\in[\psi_{\rm soil},\,\psi_{\rm crit}]} J(\psi), \qquad J(\psi)=B(\psi)-C(\psi)\]
B is net assimilation, and C is the cost of
the water used to get it. The argmax is the ψ at which
J is largest, as distinct from that largest value itself;
the difference between the two matters enough that Appendix C is largely
about it. What varies between models is C. What varies
between supply topologies is which ψ the argmax is taken over.
Water reaches the leaf through soil, then roots, then the root collar — the point where the root system joins the stem — and then up the stem. The model can be set up to optimise the potential at either of two points on that path, and the two set-ups are called routes:
| route | what is optimised | what supplies it |
|---|---|---|
the leaf route, "leaf" (the
default) |
the leaf potential ψ_leaf, with the potential upstream
of it held at ψ_soil |
the soil-to-collar path is ignored entirely |
the collar route, "collar" |
the collar potential, which is then mapped to a leaf potential | the root path in series |
Those names are used throughout this page. The literature calls the
first “non-root-based”, which is also what the package’s refusal message
means by the term, and find_root_collar_psi() is the second
under its own name.
These are the same optimality problem in two coordinates, over different supply. Neither is a check on the other. Over 30 driver rows they disagree on 20, and the disagreement is the soil-to-collar drop, which only the root-based route charges for. Remove that drop and the two converge. Sweeping the series resistance down through three decades closes the gap by about a factor of ten per decade, reaching 4.0e-04 MPa.
One point in that sweep looks like a structural gap and is not. At a resistance small enough that the whole range of flux fits inside a collar interval a few times the resistance wide, a collar known to double precision no longer resolves the flux, and once the interval closes the root-based solve reports hydraulic shutdown (#176). Real resistances are around 1e3, far from this, so read the trend rather than the endpoint.
Different papers in this literature optimise with respect to different things. Cowan and Farquhar (1977) solve for stomatal conductance. Sperry et al. (2017) solves for water potential. Others work in terms of transpiration, or of internal CO₂. It is easy to read that as a substantive disagreement about what the plant controls.
For the class of models here, it is not. These variables are interchangeable, and any of the models could be solved in any of them. The choice is one of convenience rather than of biology.
The reason is that, given the drivers, the quantities are not independent. They are linked by a chain of monotone maps:
\[\psi \;\xrightarrow{\;\text{supply}\;}\; E \;\xrightarrow{\;\text{diffusion}\;}\; g_c \;\xrightarrow{\;\text{supply}=\text{demand}\;}\; c_i \;\xrightarrow{\;A(\cdot)\;}\; A\]
Fix any one of these and the rest follow. Because each map is
monotone, it carries the maximum to the maximum, so an optimum located
in one coordinate is the optimum in all of them. “Optimise over
leaf potential”, “optimise over stomatal conductance” and “optimise over
c_i” therefore name one problem written three ways, and a
model’s operating point does not depend on which was chosen.
This package solves in water potential, because that is the coordinate in which the hydraulic supply constraint is simplest to state. That is an implementation choice and nothing more. What actually distinguishes the models is the cost function, which is the next section.
The maps are monotone but not linear, and that limits how far the
equivalence goes. A derivative does depend on the coordinate.
dA/dE and dA/dψ are different numbers, related
by dE/dψ. So a marginal cost only means something once you
say what it is per unit of, and comparing a λ from one paper with a λ
from another means checking that they share a denominator. A leaf’s
$lambda is per unit E, which is the convention
the λ literature uses.
There are two ways to find ψ*: search J for
its maximum, or solve
\[\frac{dJ}{d\psi}=0\]
for a stationary point. Every solver in the package takes the second route, and the three subsections here are why.
The comparison below is against golden section, the standard bracketing search, because it is the natural alternative and because the difference is instructive on all three axes that can be measured. It is also the reason the rest of this page can promise exact gradients at all, so the argument is worth following even if you never touch a solver.
Precision. A bracketing search terminates on the
width of an interval, so it never locates the argmax better
than its own tolerance. Over the reference grid, golden section run to a
1e-3 bracket leaves |dJ/dψ| at a median of 7.8e-04 at the
point it returns. The root-find leaves 5.6e-15. That gap is eleven
orders of magnitude, and it is not a matter of tightening a tolerance.
The two methods answer different questions. One says “the maximum is
somewhere in here”. The other says “the derivative is zero at this
point”.
Smoothness. This is what a gradient actually needs. An argmax determined only to a bracket width is piecewise constant at fine scales, so differentiating it with respect to a trait yields noise dressed as a derivative. Over eleven steps in a trait, golden section returns 6 distinct argmax values out of 11, with second differences of 3.9e-04. That figure is the step size itself, which is to say nothing at all. The root-find returns 11 distinct values of 11, with second differences of 3.4e-07.
The noisy answer is worse than merely noisy, because it is not obviously broken. One trait derivative taken that way comes back smooth, plausible, and sign-inverted: −2.6e-03 against a true +2.6e-04.
Speed. Golden section shrinks its bracket by a constant factor per evaluation. That is linear convergence, about 0.62 per step, and it cannot be improved on, because the method uses only comparisons. A Brent-class root-finder interpolates through the points it already has and converges superlinearly. On the collar solve, with both binaries built from one tree and run interleaved, golden section costs 3.51 µs and the root-find 2.65: a saving of 24%. The root-find is therefore faster and eleven orders more accurate, from the same model.
The three claims are one picture. Both methods in the figure below are given the same sign-changing bracket, which flatters the search: it is handed the basin rather than having to find it, and finding the basin is what the scan exists for.
e_search <- abs(sc$search$x - sc$star)
e_root <- abs(sc$rootfind$x - sc$star)
reach <- function(e, tol) if (any(e < tol)) which(e < tol)[1] else NA_integer_
for (tol in c(1e-4, 1e-8, 1e-12)) {
cat(sprintf("to reach %5.0e MPa: root-find %2s evaluations, search %s\n", tol,
reach(e_root, tol),
ifelse(is.na(reach(e_search, tol)), "never", reach(e_search, tol))))
}
#> to reach 1e-04 MPa: root-find 4 evaluations, search 7
#> to reach 1e-08 MPa: root-find 5 evaluations, search never
#> to reach 1e-12 MPa: root-find 5 evaluations, search never
cat(sprintf("search stalls at %.2e MPa; sqrt(4.eps.|J|/k) predicts %.2e\n",
min(e_search), sc$search_floor))
#> search stalls at 2.08e-08 MPa; sqrt(4.eps.|J|/k) predicts 2.61e-08The search does not merely converge more slowly. It stops. After about thirty evaluations it stops improving at all, and no further spending moves it.
That floor is not a tolerance anyone chose. It is where comparing
values of J runs out of information. Near its maximum
J is flat, so ψ can move while J
changes by less than a rounding error, and the comparison driving the
search becomes noise. The limit is sqrt(4ε|J|/k) for
curvature k, drawn as the dashed line in the figure. The
observed stall sits within a factor of two of it, which is close enough
to identify the mechanism; the remaining factor is the constant in that
expression rather than anything about the model.
A root-find has no such floor, because it is looking for a
zero of a function that is steep exactly where J
is flat. The quantity it tests still carries information at 1e-15, which
is why panel (b) keeps descending.
This is the same flat maximum that Appendix C is about, seen from the inside. There it decides what a difference of the model can mean. Here it decides what a search can find.
Having the derivative in hand is what makes the trade one-sided.
dJ/dψ is not a second model to evaluate: it comes out of
the same forward pass as J. So one iteration of the
root-find costs about what one iteration of the search costs, and the
superlinear convergence is pure gain.
Take the derivative away and the comparison closes up again.
Differencing dJ/dψ would cost two objective evaluations per
iteration, and would bring a step-size problem of its own: a difference
through a nested root-find has a noise floor of tolerance divided by the
step, so the obvious small step is the wrong one. The efficiency
argument here is therefore not really about Brent versus golden section.
It is about what a cheap exact derivative buys, which is also why the
trait gradients in Appendix B are worth their complexity.
A root-find finds a stationary point, not the best one, so where the objective has two humps something has to choose between them. That job falls to a scan: a sweep of the interval on a coarse grid, picking the cell that contains the taller hump, after which the root-find refines inside that cell.
The sweep costs 64 objective evaluations before refinement begins, which is expensive in a package built around a solve of a few microseconds. So it is switched on only where the objective is measurably two-humped, which over a 1728-row driver sweep is only ever with the energy balance on. With that gate off, which is the default, there is no scan and the solver is endpoints plus a root-find.
| entry point | cost per call, relative to TF24 |
|---|---|
leaf route, TF24 |
1 |
leaf route, CF77 |
1.05 |
leaf route, TF24_floor |
1.4 |
leaf route, ProfitMax |
2.1 |
Where profit is still climbing as it reaches the end of the feasible
interval, dJ/dψ never crosses zero inside that interval and
the optimum is the endpoint itself. This is a constrained
optimum, and it is not a corner case: it happens on 51 of the 288
feasible rows of the reference grid at 25 °C.
A solver therefore has to test the derivative’s sign at each end and say explicitly that the answer is a bound. Handed no sign change, a bracketing root-find cannot on its own distinguish “the answer is the endpoint” from “I failed”, so the operating-point kind is what carries the distinction.
The feasible interval runs from the wet bound, where the stomata are
closed and the stem carries only the cuticle’s residual flux
(ψ_soil itself, with no flow, at the default
g_min = 0), to the dry end. For SOX, JW26, CF77 and
LeastCost the dry end is ψ_crit, where their own published
forms end. For every other curve ψ_crit is a labelled point
on a curve that continues past it, and the dry end is the end of the
stem curve, where it carries all it ever can: their marginal cost of
water diverges as the stem dries, and that, rather than a bound, keeps
the optimum inside. Constrained answers are named for the bound that
binds:
| kind | where | what it means |
|---|---|---|
interior |
strictly inside | dJ/dψ = 0, the stationary case |
boundary-soil |
ψ_leaf at the wet bound |
no interior point earns its water, and the leaf declines to open; a leaf in the dark, or too hot to fix carbon, lands here too |
boundary-dry |
ψ_leaf at the dry end |
still worth pulling harder: at ψ_crit for the four
curves bounded there; for the rest, a dry soil where the leaf transpires
all its stem can carry |
hydraulic-shutdown |
no interval exists | the soil is drier than the dry end (or than the stem curve resolves), so nothing is optimised and the leaf sits shut at its wet bound |
The solve records eight kinds in all; the others are rarer or are
refusals. vignette("derivative-surface") lists them with
what each means for a derivative.
boundary-soil is the stressed case, despite sitting at
the wet end of the interval. That is why the two are named for the bound
rather than for the moisture: ψ_soil is the least-tension
end, so “wet” reads as the comfortable case, whereas in fact it
describes what a leaf does when carbon cannot pay for water. The
reference grid makes the direction plain: warming the leaf from 25 °C to
40 °C takes boundary-soil from 45 rows to 116, and
boundary-dry from 11 rows to none.
boundary-dry is rarer than it looks. Under TF24 it is
reached only where the soil is dry and the air humid enough that the
leaf takes all its stem can still carry, and any product objective is
immune to it at ψ_crit by construction, because
g(ψ_crit) = 0 makes the cost diverge before the bound and
water becomes infinitely expensive first.
A bound that binds is also not the same as a bound that is hit
exactly. The bracket steps in by around one part in a million of its
width, so testing ψ* == ψ_soil to detect closure is a test
that never fires. Compare against the bound with a tolerance instead, or
read the sign of the gradient there.
hydraulic-shutdown is separate from the other three and
easily confused with them. It accounts for 96 of the 672 reference
points, 48 at each leaf temperature, and it is a statement about
feasibility rather than about a choice the leaf made.
Finally, a stationary point need not be unique. Where the marginal cost is peaked, which the next section explains, the first-order condition can have two roots and a bracketing solve finds one of them. This is the one real argument for keeping a scan: searching the objective over a grid finds the taller hump but cannot feed a gradient, while solving the condition feeds a gradient but can land on the wrong hump.
The two properties are not in conflict. What reconciles them is multi-start on the first-order condition, using several sub-brackets, each yielding an exact stationary point, compared on profit. Every candidate is then a genuine stationary point, so each one is smooth in the traits, and the comparison between them is a comparison of maxima, which is the well-conditioned quantity. A grid argmax is neither.
B(ψ) = A(c_i) is colimited assimilation, and
c_i is not free. It is fixed by supply meeting demand,
which is a root-find at every candidate ψ. There is therefore a solver
inside the solver, and both its cost and its precision multiply through
everything above.
The same move works one level down: differentiate the condition
rather than the search. dA/dψ cannot be taken directly,
because c_i is implicit. The chain runs
A'(c_i) · dc_i/dψ, and dc_i/dψ comes from
applying the implicit function theorem to the supply-equals-demand
residual. The derivative therefore never passes through the
iteration that located c_i.
That buys two things. Differencing the inner solve would multiply its iteration count into every outer step, and it would inherit a noise floor of the inner tolerance divided by the step, which is what makes an apparently sensible step return an apparently sensible wrong derivative. Differentiating the residual is exact and costs one solve.
The inner tolerance sets what every reported output of this model means, so it was chosen by measurement rather than by taste. Perturbing the model identically at each tolerance and reading the reference grid gives:
c_i tolerance |
worst difference from a converged (1e-15) solve |
|---|---|
| 1e-7 | 1.82e-07 |
| 1e-8 | 3.15e-08 |
| 1e-10 | 5.41e-10 |
| 1e-11 | 5.41e-10 |
| 1e-13 | 7.48e-13 ← here |
The model runs at 1e-13. 1e-10 looks like the knee of that table, and it is cheaper by about 5% per solve and per gradient, but what the table measures is the worst case at fixed drivers. A caller that differences the solve sees something else: where the root-finder stops inside its tolerance band depends on the state, so at 1e-10 the outputs are noisy along any smooth path through the drivers. Along plant height at fixed light that noise reached 3.7e-11 relative, which a finite difference at a step of 1e-7 turns into an error of 1e-4. At 1e-13 the same paths are smooth to 2.3e-14, and the trait gradient’s own finite-difference partials stop inheriting the noise: on the reference gradient file they moved by up to 2.3e-3 relative, toward a Richardson-extrapolated difference of the solve, which they now match to 1e-8.
The plateaus in that table matter. 1e-10 and 1e-11 return the same number, because they are the same count of solver iterations, so tightening between plateaus costs something and buys nothing. 1e-12 still left 1.2e-12 of noise at some states.
A general point follows, and it is the reason this subsection sits directly after the last one: a nested solver’s precision is only as visible as the loosest tolerance above it. An outer search terminating on a 1e-3 bracket would hide this inner tolerance completely, whatever it was set to. Solving the outer first-order condition is what makes it the model’s dominant amplifier. If you tighten one tolerance in a stack, expect the next one down to become the answer.
The tolerance you can set from R is not this one. The inner
c_i root-find is fixed at 1e-13 on both routes.
ci_abs_tol in leaf_control() is read in
exactly one place, solve_medlyn_ci_numerical, which is the
empirical Medlyn-conductance route. It reaches neither family of
optimality solver, so tightening it buys no precision anywhere in the
optimality model. The name promises otherwise, which is why it is worth
stating.
There is one regime where the implicit function theorem does not
apply. When the leaf is dark enough, or hot enough, that assimilation is
negative across the whole [Γ*, c_a] bracket, there is no
supply-equals-demand root. c_i is then put at
c_a and the leaf nets A(c_a), as a leaf moving
no gas does, so the open branch meets the shut one continuously as
E → 0. There is no residual to differentiate there, so the
derivatives take A(c_a)’s own slope instead. Only a genuine
absence of a root reaches that branch; a failure of the root-finder
stops rather than land there, because c_a would net the
largest A on offer.
The vulnerability curve is the familiar Weibull, and this package
writes it in the equivalent form that names P50 instead of
b:
\[f(\psi)=\exp\!\big[-(\psi/b)^{c}\big] \;=\;2^{-(\psi/P_{50})^{c}}, \qquad b=\frac{P_{50}}{(\ln 2)^{1/c}}\]
These are the same curve, not an approximation of one. Substituting
that b into the left-hand form gives the right-hand one
identically, and b is still available on a
Leaf as a derived read-only value. Nothing is lost by
reading the conventional form if that is what you have.
The reparameterisation is for interpretability rather than for speed.
Both forms cost the same to evaluate. What changes is which parameter is
named. P50 is a measured, reported, comparable quantity;
b is none of those, being the potential at which
f = 1/e ≈ 0.37, which nobody measures. In base 2 the scale
parameter is the 50% point, because log₂(1/f) is
exactly 1 at f = 0.5. In base e that factor is
ln 2 ≈ 0.693, which is why b sits above
P50.
The gain is more than tidiness. Every quantile follows from
(P50, c) in closed form,
\[P_f = P_{50}\big(\log_2(1/f)\big)^{1/c}\]
which is why psi_crit is not a free trait. It
is P_f at f = 0.05, and Sperry’s
reference conductance reads the same 0.05, so psi_crit,
P95 and k_crit are one number under three
names. weibull_p50_c() converts from whatever form a
published curve arrives in, and Appendix A has the derivation and the
caveats.
With that settled, write the integral and the fluxes as
\[F(\psi)=\int_0^{\psi}\!f,\qquad k(\psi)=k_{\max}f(\psi),\qquad E(\psi;\psi_s)=k_{\max}\big[F(\psi)-F(\psi_s)\big]\]
so f is the fraction of maximum conductivity remaining,
k the conductance and E the transpiration.
| model | code name | cost C(ψ) |
marginal cost dC/dψ |
units of C |
|---|---|---|---|---|
| Towers & Falster (Towers et al. 2024) | TF24 |
\(\gamma\,(1-f)^{\beta}\) | \(\gamma\beta(1-f)^{\beta-1}\lvert f'\rvert\) | carbon |
| Cowan & Farquhar (Cowan and Farquhar 1977) | CF77 |
\(\lambda\,E\) | \(\lambda\,k_{\max}f\) | carbon |
| Sperry et al. (Sperry et al. 2017) | ProfitMax |
\(\dfrac{k(\psi_s)-k(\psi)}{k(\psi_s)-k_{\rm crit}}\) | \(\dfrac{k_{\max}\lvert f'\rvert}{k(\psi_s)-k_{\rm crit}}\) | dimensionless |
| Joshi & Stocker (Joshi et al. 2022) | JS22 |
\(\gamma_{J}\,(\Delta\psi)^{2}\) | \(2\gamma_{J}\,\Delta\psi\) | carbon |
| Wolf et al. (Wolf et al. 2016) | CMax |
\(\tfrac{a}{2}(\psi^{2}-\psi_s^{2})+b(\psi-\psi_s)\) | \(a\,\psi+b\) | carbon |
| Eller (Eller et al. 2020) | SOX |
\(-\log g_{\rm SOX}(\psi)\) | \(-g'/g\) | log carbon |
| Jones et al. (Jones et al. 2026) | JW26 |
\(-\log g_{\rm JW}(\psi)\) | \(1/(\psi_{\rm crit}-\psi)\) | log carbon |
| TF24 with a price floor | TF24_floor |
\(\gamma\,(1-f)^{\beta}+\lambda_o E\) | \(\gamma\beta(1-f)^{\beta-1}\lvert f'\rvert+\lambda_o k_{\max}f\) | carbon |
| Prentice et al. (Prentice et al. 2014) | LeastCost |
\(\log\big(E+\beta_{\rm LC}\,V_{c\max}\big)\) | \(\dfrac{k_{\max}f}{E+\beta_{\rm LC}V_{c\max}}\) | log carbon |
In the last row E and V_cmax are both in
mol m⁻² s⁻¹, which is what makes β_LC
(LeastCost_beta, the paper’s β = c_V/c_E, 146)
dimensionless.
The SOX, JW26 and LeastCost
rows are in that table because of a benefit link, and it is
worth being explicit about how they got there. None of the three
subtracts a cost. The first two maximise the product
A · g(ψ), and they differ only in g:
\[g_{\rm SOX}(\psi)=\frac{f(\psi)-0.05}{1-0.05}, \qquad g_{\rm JW}(\psi)=1-\frac{\psi}{\psi_{\rm crit}}\]
Both run from 1 at zero tension to exactly 0 at
psi_crit, so they are two interpolations between the
same two anchors — one following the vulnerability curve, one
straight. That is what makes them comparable without matching a
parameter, and neither needs one: SOX’s 0.05 is the same constant
psi_crit is derived from, so both curves inherit their
anchors from the vulnerability curve rather than carrying parameters of
their own.
They cross, which is the whole of the difference between them:
l <- leaf_model()
psi <- c(1, 2, 3, 4, 5)
cat(sprintf("%7s %9s %9s\n", "psi", "g_SOX", "g_JW"))
#> psi g_SOX g_JW
for (p in psi) {
cat(sprintf("%7.1f %9.4f %9.4f\n", p,
l$sox_reduction(p), l$jw26_reduction(p)))
}
#> 1.0 0.9729 0.8297
#> 2.0 0.8379 0.6593
#> 3.0 0.5886 0.4890
#> 4.0 0.3079 0.3186
#> 5.0 0.0973 0.1483SOX sits above the straight line while the vulnerability
curve is still flat, and below it once the curve turns over, so it
charges less for early tension and more for late. JW26
therefore operates at slightly higher tension in dry soil. That is the
opposite of what “a linear cost is harsher” would suggest.
Taking logs turns either into the same additive skeleton as every other row:
\[\log\big(A\,g\big) = \log A + \log g \quad\Longrightarrow\quad C(\psi)=-\log g(\psi)\]
So a product objective is a difference objective with a transformed
benefit. The link, log here and the identity everywhere
else, is the only new machinery the family needs. Two consequences
follow, and both are substantive rather than bookkeeping:
A, because the cost is levied
on proportional rather than absolute carbon. The section on λ derives
this in one line, and shows that it is a property of the product form
rather than of this particular g.psi_crit with
A > 0. g(psi_crit) is exactly
zero, so C diverges there. Water becomes infinitely
expensive before the bound is reached, and no parameter of the model’s
own is involved in arranging that.The link states the cost; it is not how the optimum is
found. At full closure A ≤ 0 (zero in light at
g_min = 0, negative in the dark), so log A
does not exist, and that endpoint has to be a candidate. The optimiser
therefore maximises the product directly, which is exact because
log is monotone, while the derivative uses the log
form.
LeastCost reaches the same link from the other side.
Prentice et al. (2014) minimise the cost of acquiring
a unit of carbon, \((c_E E + c_V
V_{c\max})/A\), which is a ratio; minimising it is
maximising \(\log A-\log\big(E+\beta_{\rm
LC}V_{c\max}\big)\), so its log-cost is \(+\log W\) where a product’s is \(-\log g\), and the sign lands in the
marginal cost rather than in the link. Two things follow. Its penalty is
the only one in the table that weighs something other than water —
photosynthetic capacity, against transpiration — so a light sweep on
this curve measures acclimation rather than the price of water. And its
λ does not vanish at the wet end: as E → 0
the denominator tends to the capacity term and λ to a finite value set
by it, where a product curve’s g carries λ to zero. Its
first-order condition is the paper’s own, \((c_i-\Gamma^*)/(c_a-c_i)=\xi/\sqrt{D}\)
with \(\xi=\sqrt{\beta_{\rm
LC}(K_m+\Gamma^*)/1.6}\), and the C++ suite checks the solved
\(c_i/c_a\) against it to 1e-11 on the
Rubisco-limited branch. The widely quoted \(\chi=\xi/(\xi+\sqrt{D})\) is that condition
with \(\Gamma^*\) dropped from the
numerator, and understates \(\chi\) by
4–7% over D = 1–3 kPa; the suite predicts that gap rather
than tolerating it.
The code names are the prefix on every parameter belonging to that
model: TF24_cost_scale, CF77_lambda_,
JS22_gamma. A parameter therefore says which curve reads
it, and returns an exactly zero gradient on all the others.
JS22 is Joshi & Stocker’s hydraulic term and not
their model. Theirs is \(A-\alpha
J_{\max}-\gamma_{J}(\Delta\psi)^{2}\), jointly optimised over
\(J_{\max}\) as well as \(\psi\). The capacity term is not
implemented here, so a run of this curve should not be described as
Joshi & Stocker (2022). \(\gamma_J\) is written distinctly from
TF24’s \(\gamma\) because they are
different parameters in different units.
JS22 is also the one cost in the table that reads the
drop, \(\Delta\psi=\psi-\psi_s\), rather than the
absolute potential. That is not a detail of parameterisation, and a
later section measures what it costs.
The marginal costs are where the models actually differ, and the difference is one of shape:
\[\lvert f'\rvert=\frac{c}{P_{50}}\Big(\frac{\psi}{P_{50}}\Big)^{c-1}f\ln 2\]
For c > 1 this vanishes as ψ → 0 and
again as ψ → ∞, so it is peaked. Cowan-Farquhar therefore
prices water at a marginal rate proportional to f, which
falls monotonically as the leaf dries, while the conductance-based costs
price it at a rate proportional to |f'|, which rises from
zero before it falls.
l <- leaf_model()
psi <- seq(0.02, l$psi_crit, length.out = 600)
f <- vapply(psi, l$proportion_of_conductivity, numeric(1))
h <- 1e-4
fp <- abs(vapply(psi, function(p) {
(l$proportion_of_conductivity(p + h) - l$proportion_of_conductivity(p - h)) / (2 * h)
}, numeric(1)))
cat(sprintf("f is monotone decreasing over the bracket: %s\n",
all(diff(f) < 0)))
#> f is monotone decreasing over the bracket: TRUE
cat(sprintf("|f'| peaks at %.2f MPa; %.1f%% of peak at the wet end, %.0f%% at psi_crit\n",
psi[which.max(fp)], 100 * fp[1] / max(fp),
100 * fp[length(fp)] / max(fp)))
#> |f'| peaks at 3.27 MPa; 0.0% of peak at the wet end, 25% at psi_critRead that dry-end figure before generalising. |f'| is
asymptotically zero at both ends, but the bracket stops at
psi_crit, and there it is still a sizeable fraction of its
peak. Within the operating range the shape is therefore “rises from
nothing, then falls somewhat”. The vanishing that matters in practice is
at the wet end, which is where boundary optima accumulate.
That is still enough to matter. A marginal cost starting at zero, against a marginal benefit that also saturates, is the shape whose first-order condition can cross zero twice. That is why the conductance forms need a search able to see past a local maximum and the monotone Cowan-Farquhar cost does not.
JS22 is monotone for a related but different reason.
2γ_J Δψ rises linearly from zero at the wet end and never
turns over, so its first-order condition crosses once. The two-humped
case needs a marginal cost that falls at the dry end, and only
the |f'| forms do that.
Three of those nine rows do not subtract a cost: two are products and one is a ratio. This section shows exactly how they belong in one table with the six that do, and that nothing is lost in getting them there.
Write the objective as
\[J(\psi)=h\big(A(\psi)\big)-C(\psi)\]
with h a benefit link, meaning a transform
applied to assimilation before the cost is subtracted. Three links cover
every model here.
| link | h(A) |
h'(A) |
curves |
|---|---|---|---|
| identity | \(A\) | \(1\) | TF24, CF77, JS22,
CMax, TF24_floor |
| log | \(\log A\) | \(1/A\) | SOX, JW26, LeastCost |
| scaled | \(A/\lvert A\rvert_{\max}\) | \(1/\lvert A\rvert_{\max}\) | ProfitMax |
For a product objective, take the cost to be the logarithm of the reduction factor,
\[C(\psi):=-\log g(\psi) \qquad\Longrightarrow\qquad J=\log A+\log g=\log\big(A\,g\big)=\log P\]
so the additive form is not an analogy. J is
the logarithm of the product. The link therefore applies to the whole
objective rather than to half of it: the cost’s share of the transform
is absorbed into which cost curve you name, which is the
−log g row of the table above.
The first-order condition is unchanged, which can be shown by differentiating both descriptions of the same maximum:
\[\frac{dP}{d\psi}=A'g+Ag', \qquad \frac{dJ}{d\psi}=\frac{A'}{A}+\frac{g'}{g}=\frac{A'g+Ag'}{A\,g}=\frac{1}{A\,g}\frac{dP}{d\psi}\]
The two differ by the factor A·g, which is strictly
positive wherever the log form is defined. They therefore vanish at
exactly the same potentials. The framework’s stationary points
are the product’s stationary points, and putting the link on
the benefit drops nothing. It is the product rule, divided through.
That claim is checkable rather than decorative. In the chunk below,
the left-hand side comes from the model’s own dJ/dψ, the
single expression it uses for all nine curves. The right-hand side is
A'g + Ag' assembled from parts: A' as the
derivative of a zero-cost curve, and g' differentiated by
hand from the closed form for g quoted above. So the check
does not simply ask the implementation to agree with itself.
psi_soil <- 1.0
tr <- leaf_traits(JS22_gamma = 0) # a zero unit cost: dC/dpsi is exactly 0,
l <- leaf_model(tr, supply = leaf_supply_singlelayer()) # so JS22's derivative IS dA/dpsi
set_drivers(l, psi_soil = psi_soil, PPFD = 900)
f <- function(p) 2^-((p / tr$stem_P50)^tr$stem_c)
fprime <- function(p) -f(p) * log(2) * tr$stem_c * p^(tr$stem_c - 1) / tr$stem_P50^tr$stem_c
model <- list(
SOX = list(P = function(p) l$profit_psi_leaf_SOX(p, psi_soil),
g = function(p) (f(p) - 0.05) / 0.95,
gp = function(p) fprime(p) / 0.95),
JW26 = list(P = function(p) l$profit_psi_leaf_JW26(p, psi_soil),
g = function(p) 1 - p / l$psi_crit,
gp = function(p) -1 / l$psi_crit))
cat(sprintf("%-5s %6s %14s %14s %9s\n", "curve", "psi", "(logP)'*P", "A'g + Ag'", "rel"))
#> curve psi (logP)'*P A'g + Ag' rel
for (nm in names(model)) {
m <- model[[nm]]
for (q in c(1.5, 2.5, 3.5, 4.5)) {
P <- m$P(q) # the UNtransformed objective, A*g
A <- l$assim_colimited_
# The derivative reads whichever model is seated, so the seat moves with the
# curve being checked. No index anywhere: a name in, a name out.
l$set_model(nm, "leaf")
lhs <- l$dprofit_dpsi_leaf_checked(q)[1] * P
l$set_model("JS22", "leaf")
Ap <- l$dprofit_dpsi_leaf_checked(q)[1]
rhs <- Ap * m$g(q) + A * m$gp(q)
cat(sprintf("%-5s %6.1f %14.6f %14.6f %9.1e\n", nm, q, lhs, rhs,
abs(lhs - rhs) / max(abs(Ap * m$g(q)), abs(A * m$gp(q)))))
}
}
#> SOX 1.5 9.072038 9.072038 0.0e+00
#> SOX 2.5 -0.754568 -0.754568 9.6e-17
#> SOX 3.5 -4.010238 -4.010238 0.0e+00
#> SOX 4.5 -3.486972 -3.486972 5.0e-16
#> JW26 1.5 6.897052 6.897052 0.0e+00
#> JW26 2.5 -0.180299 -0.180299 1.2e-17
#> JW26 3.5 -2.214832 -2.214832 1.6e-16
#> JW26 4.5 -2.765833 -2.765833 0.0e+00Agreement is at the last bit. The relative error is measured against
the scale of the two terms rather than against their sum, and that is
deliberate: A'g + Ag' cancels to exactly zero at the
optimum, so an error relative to the sum would diverge there even for a
perfectly correct implementation.
One consequence matters when reading profit_ across
models. It now carries three kinds of number: carbon for the four
difference objectives, a dimensionless normalised profit for
ProfitMax, and carbon times a dimensionless factor for the
two products. Comparing it between curves is not meaningful. Comparing
each curve’s argmax is.
Read down the marginal-cost column and every entry but two carries a
factor of f or of |f'|, both of which vanish
as the drop closes. So for those curves
\[\lambda(\psi)=\frac{dC/d\psi}{dE/d\psi}\;\longrightarrow\;0 \qquad\text{as }\psi\to\psi_s\]
— water is free precisely when it is abundant. That
is not a modelling choice anyone made; it is what “the cost of water is
the conductivity you lose to move it” implies, and every
conductance-loss cost in the literature inherits it. CF77
escapes it by not being a conductivity cost at all: its λ is a
prescribed constant everywhere.
TF24_floor is the repair, and it takes the form the
decomposition already suggests. Any cost splits into a part depending on
the potential alone and a part linear in the flux,
\[\Theta(E)=\tilde\Theta(\psi)+\lambda_o E, \qquad \lambda=\frac{\tilde\Theta'(\psi)}{K(\psi)}+\lambda_o\]
with K = k_max f the stem conductance. The second term
is what every curve above sets to zero.
\(\tilde\Theta\) here is
TF24’s own cost, read at TF24’s own traits — so
λ_o is this curve’s only parameter, and TF24
is the same curve at λ_o = 0. That is deliberate: it makes
“is TF24 missing a price of water?” a one-restriction question rather
than a comparison between two differently-parameterised fits. The two
reductions are λ_o = 0 → TF24 and
TF24_cost_scale = 0 → CF77, both bit-for-bit,
which is how the implementation is pinned.
l <- leaf_model(supply = leaf_supply_singlelayer())
l$TF24_floor_lambda_o <- 1.5e5
set_drivers(l, psi_soil = 1.5, PPFD = 1500,
root_network = series_resistance(1e4))
# The price at one micro-MPa of tension: essentially no flow at all. The limit is
# at zero POTENTIAL rather than at zero drop, because these two costs read the
# absolute potential.
cat(sprintf("at psi = 1e-06 MPa: TF24 %10.3e TF24_floor %9.1f\n",
l$lambda_TF24(1e-6),
l$lambda_TF24_floor(1e-6)))
#> at psi = 1e-06 MPa: TF24 0.000e+00 TF24_floor 150000.0λ_o has CF77_lambda_’s units and meaning —
carbon per unit transpiration. Its default is 0, as in plant, where this
curve is TF24 bit for bit, so
TF24_floor is the default model of [leaf_solve()] and the
gradients without changing a number. [leaf_solve()] and [leaf_batch()]
take it as an argument; on a bare Leaf, assign the
field.
JS22 is the only cost here that reads Δψ.
The cleanest way to see what that changes is to ask which
γ_J would reproduce TF24’s cost at TF24’s own optimum. If
the two curves were reparameterisations of one another, the answer would
be a constant.
out <- do.call(rbind, lapply(c(0.5, 1, 1.5, 2, 3), function(ps) {
l <- leaf_model()
set_drivers(l, psi_soil = ps, PPFD = 1500)
l$set_model("TF24", "leaf"); l$optimise()
dpsi <- l$opt_psi_leaf_ - ps
data.frame(psi_soil = ps, psi_leaf = l$opt_psi_leaf_, dpsi = dpsi,
cost_TF24 = l$hydraulic_cost_,
# JS22 prices the DROP: C = gamma_J * dpsi^2.
gamma_J = l$hydraulic_cost_ / dpsi^2,
# CMax prices the ABSOLUTE potential, so with CMax_b = 0 the
# matching slope solves C = CMax_a*(psi^2 - psi_soil^2)/2.
CMax_a = 2 * l$hydraulic_cost_ / (l$opt_psi_leaf_^2 - ps^2))
}))
print(out, row.names = FALSE, digits = 4)
#> psi_soil psi_leaf dpsi cost_TF24 gamma_J CMax_a
#> 0.5 2.917 2.417 1.679 0.2872 0.4064
#> 1.0 3.215 2.215 2.260 0.4604 0.4840
#> 1.5 3.554 2.054 2.990 0.7091 0.5763
#> 2.0 3.931 1.931 3.844 1.0306 0.6711
#> 3.0 4.782 1.782 5.596 1.7614 0.8068
spread <- function(x) max(x) / min(x)
cat(sprintf("\ngamma_J (prices the drop) spans %.3f to %.3f -- %.1fx",
min(out$gamma_J), max(out$gamma_J), spread(out$gamma_J)))
#>
#> gamma_J (prices the drop) spans 0.287 to 1.761 -- 6.1x
cat(sprintf("\nCMax_a (prices the potential) spans %.3f to %.3f -- %.1fx\n",
min(out$CMax_a), max(out$CMax_a), spread(out$CMax_a)))
#>
#> CMax_a (prices the potential) spans 0.406 to 0.807 -- 2.0xNeither is a constant, so neither curve is TF24 reparameterised. But the two spreads are not the same size, and the gap between them is the finding.
As the soil dries, TF24’s cost rises while Δψ shrinks.
The leaf cannot pull as far, so the drop narrows even as the water gets
dearer, and a cost quadratic in the drop has to be scaled up
several-fold to keep pace. CMax is under no such pressure,
because it reads the same absolute potential TF24 does, and its required
spread is correspondingly smaller: roughly a third.
The sharpest division in the cost table is therefore not any of the individual formulae. It is whether a cost prices the potential the leaf has reached, or the distance it had to pull. That is the one difference no parameter can absorb.
It is also why JS22_gamma’s default carries a warning
rather than a citation. The default matches TF24 at one reference soil
potential (see Comparable at
their defaults), and no single value would be matched across this
band.
Divide any model’s marginal cost by the marginal water use and you get the same kind of quantity: carbon given up per unit of water. That is the marginal cost of water,
\[\lambda(\psi)=\frac{dC/d\psi}{dE/d\psi},\]
and a solved leaf’s $lambda reports it for the curve in
force, at its operating point ($curve_lambda() evaluates
any curve at any leaf potential). It is the axis on which otherwise
incomparable models can be compared, and it is the whole content of the
family resemblance between them.
For Cowan-Farquhar, λ is by construction the prescribed constant,
because that model’s first-order condition is exactly the classical
dA/dE = λ. For ProfitMax it is derived,
λ* = |A|max / [k(ψ_s) − k_crit], and it is a function of
the drivers: as the soil dries, k(ψ_s) falls, the
denominator shrinks, and water automatically gets dearer.
That contrast is the difference between the two models. A prescribed constant and a driver-dependent price agree at one operating point and diverge across any drydown.
implied <- vapply(c(0.5, 1, 2, 3), function(ps) {
x <- leaf_model()
set_drivers(x, psi_soil = ps, PPFD = 1500)
x$set_model("TF24", "leaf"); x$optimise()
x$lambda
}, numeric(1))
cat(sprintf(paste0("implied lambda over psi_soil 0.5-3 MPa: ",
"%.3g to %.3g umol C / kg, a %.1fx range\n"),
min(implied), max(implied), max(implied) / min(implied)))
#> implied lambda over psi_soil 0.5-3 MPa: 9.19e+04 to 3.15e+05 umol C / kg, a 3.4x rangeA Cowan-Farquhar λ therefore has to be chosen on the leaf’s own scale. A value far below that band makes water free and pins the optimum wide open. A value far above it pins the stomata shut. Either way the model is not being exercised.
Because both optima satisfy dA/dE = λ, handing
Cowan-Farquhar the λ that TF24 implies at its own optimum must land it
on that same optimum. This is a check with no derivative approximation
anywhere in it:
ref <- leaf_model()
set_drivers(ref, psi_soil = 1.0, PPFD = 1500)
ref$set_model("TF24", "leaf"); ref$optimise()
cf <- leaf_model()
set_drivers(cf, psi_soil = 1.0, PPFD = 1500)
cf$CF77_lambda_ <- ref$lambda
cf$set_model("CF77", "leaf"); cf$optimise()
cat(sprintf(paste0("TF24 optimum %.6f MPa; Cowan-Farquhar at its implied ",
"lambda %.6f MPa (gap %.2e)\n"),
ref$opt_psi_leaf_, cf$opt_psi_leaf_,
abs(cf$opt_psi_leaf_ - ref$opt_psi_leaf_)))
#> TF24 optimum 3.215350 MPa; Cowan-Farquhar at its implied lambda 3.215350 MPa (gap 2.78e-13)The two are easy to conflate. They share units, and both read as “carbon per water”. They are different quantities, and the difference is between a marginal rate and an average.
\[\text{WUE}=\frac{A}{E}\quad\text{(an average)}, \qquad \lambda=\frac{dA}{dE}\quad\text{(a marginal rate)}\]
WUE is the slope of the chord from the origin to the operating point.
λ is the slope of the tangent there. On a saturating A(E)
those differ, and the gap is not a fixed factor:
cat(" psi_soil lambda A/E (WUE) lambda/WUE\n")
#> psi_soil lambda A/E (WUE) lambda/WUE
for (ps in c(0.5, 1, 2, 3)) {
lw <- leaf_model()
set_drivers(lw, psi_soil = ps, PPFD = 1500)
lw$set_model("TF24", "leaf"); lw$optimise()
cat(sprintf(" %7.1f %10.4g %10.4g %9.3f\n", ps, lw$lambda,
lw$assim_colimited_ / lw$transpiration_,
lw$lambda / (lw$assim_colimited_ / lw$transpiration_)))
}
#> 0.5 9.19e+04 2.606e+05 0.353
#> 1.0 1.195e+05 2.883e+05 0.414
#> 2.0 1.999e+05 3.549e+05 0.563
#> 3.0 3.15e+05 4.322e+05 0.729So λ runs between a third and three-quarters of WUE here, and the ratio rises as the soil dries. Neither can be inferred from the other, in either direction.
What is true is narrower, and holds only at the optimum. The
first-order condition is dA/dE = λ, so at the operating
point λ is the marginal water use efficiency. Away from the
optimum it is not even that. And “WUE” unqualified almost always means
either the average A/E or the intrinsic A/g_s,
so a λ from this model is not comparable with a WUE from the literature
without checking which was meant.
The claim above needs one qualification, and it is structural rather
than incidental. Every model in the cost table except the last two
maximises a difference, A(ψ) − C(ψ). Its
first-order condition is A' = C', so
\[\lambda=\frac{dA}{dE}=\frac{C'(\psi)}{E'(\psi)}\]
in which A does not appear at all. That is what makes λ
comparable across those models. Each prices water in absolute carbon, at
a price set by the cost curve and the supply curve alone.
A product objective, A(ψ)·g(ψ), does not behave
that way. Its first-order condition is A'g + Ag' = 0, so
A' = -A(g'/g) and
\[\lambda=-\,\frac{A\,g'(\psi)}{g(\psi)\,E'(\psi)}\]
which is proportional to A. A model of that kind prices
water relative to current assimilation rather than in absolute carbon.
It is one line of algebra, and it holds for any g.
SOX is of this kind, with
g = (f − 0.05)/(1 − 0.05), and so is JW26
(Jones et al.
2026), with g = 1 − ψ/ψ_crit giving
λ = A / [(ψ_crit − ψ)·k_max·f(ψ)]. The two differ only in
g, and everything in this section follows from the product
form and holds for both.
Two consequences are worth carrying away.
The first is that a product objective’s optimum is invariant to a
uniform rescaling of assimilation, where a difference objective’s is
not. Replacing A by κA leaves
A'g + Ag' = 0 unchanged, while κA' = C' moves.
That is the same statement as λ ∝ A.
That is a property of the objective rather than a test you can run
here, which is worth stating because the reverse is the obvious
assumption. No trait uniformly rescales A(ψ).
R_d is subtracted rather than scaled, and more
fundamentally c_i sits at a root-find at fixed
g_c, so scaling the capacities moves c_i and
A with it. What is observable is a reduced sensitivity, and
the reduction is large.
The second is that a product objective decouples stomatal behaviour
from assimilation for free. As A falls, under warming for
instance, λ falls in proportion, water gets cheaper, and the stomata do
not close. Under a difference objective C' is unchanged
while A' falls, so A' = C' is met at lower
tension and the stomata do close. This is a first-order argument rather
than an exact one, since it holds to the extent that a thermal decline
acts as a scale reduction in A.
SOX is the product objective in the package, so the
sensitivity claim can be measured rather than asserted. Scaling
vcmax_25 and jmax_25 together is the closest
the trait interface gets to a κ on A:
shift <- function(method, kappa) {
l <- leaf_model(leaf_traits(vcmax_25 = 96 * kappa, jmax_25 = 157.44 * kappa),
supply = leaf_supply_singlelayer())
set_drivers(l, psi_soil = 1.5, PPFD = 1500)
l$set_model(if (method == "TF") "TF24" else method, "leaf")
l$optimise()
l$opt_psi_leaf_
}
for (m in c("SOX", "TF", "CMax")) {
lo <- shift(m, 1)
hi <- shift(m, 2)
cat(sprintf("%-5s psi* %.4f -> %.4f (%+.0f%%)\n", m, lo, hi,
100 * (hi - lo) / lo))
}
#> SOX psi* 2.7424 -> 2.8782 (+5%)
#> TF psi* 3.5536 -> 4.3617 (+23%)
#> CMax psi* 3.5410 -> 4.1144 (+16%)The product objective moves by a few per cent where the two
difference objectives move an order of magnitude more. That is the
algebra showing through an imperfect experiment. The movement is not
zero, because doubling the capacities is not a uniform rescaling of
A, for the two reasons given above.
Both default to off, and both change more than their names suggest.
The energy balance
(use_energy_balance_) makes leaf temperature a function of
transpiration rather than a driver. With it off, a change to any
temperature response is inert at the reference temperature by
construction. With it on, Tleaf feeds vcmax,
jmax, R_d and the leaf-to-air deficit, and
every derivative picks up a dT/dE term.
The thermal cost (use_thermal_cost_)
adds a sigmoid penalty in leaf temperature, and also scales
Jmax by (1 − TC). It therefore moves the
benefit as well as the cost, which makes it the only gate that changes
both sides of the ledger.
The thermal cost is inert on its own, without the energy balance. With a prescribed leaf temperature the penalty is an additive constant in ψ, so it changes the value of the objective but not its argmax.
A third gate, use_thermal_damage_, also off by default
and not drawn in the overview, carries what heat has already done to the
leaf. Its states are an acclimation state A_, an impaired
fraction I_ and a damaged fraction D_. The
functional fraction F = 1 − I − D, held above zero by a
soft floor, scales both photosynthetic capacities, acclimation shifts
their temperature optima, and the layer’s maintenance costs add to dark
respiration. All of this is on the benefit side: the cost curves do not
read the states. The states are read when the drivers are set, so a
caller sets them first. At one driver, an undamaged leaf with the gate
on is the same leaf as with it off, and a leaf with 30% of its capacity
impaired or damaged loses that share of Vcmax and draws less water:
damaged_leaf <- function(gate, I = 0, D = 0) {
l <- leaf_model()
l$use_thermal_damage_ <- gate
l$I_ <- I
l$D_ <- D
set_drivers(l, psi_soil = 1, PPFD = 1500, leaf_temp = 38)
l$optimise()
data.frame(gate = gate, I = I, D = D, F = l$functional_, vcmax = l$vcmax_,
A = l$assim_colimited_, E = l$transpiration_, psi_leaf = l$opt_psi_leaf_)
}
rbind(damaged_leaf(FALSE), damaged_leaf(TRUE), damaged_leaf(TRUE, I = 0.2, D = 0.1))
#> gate I D F vcmax A E psi_leaf
#> 1 FALSE 0.0 0.0 1.0 84.37631 4.704700 3.486730e-05 2.230840
#> 2 TRUE 0.0 0.0 1.0 84.37631 4.704700 3.486730e-05 2.230840
#> 3 TRUE 0.2 0.1 0.7 59.06342 2.804131 2.759735e-05 1.951201The states themselves move on a timescale of hours to days, which no
single solve sees. leaf_damage_trajectory() integrates them
through a temperature series, and leaf_damage_gradient()
gives their exact sensitivities to the layer’s parameters, for fitting
it to a heat experiment.
Where the soil is drier than the stem’s critical potential there is no interval to optimise over, and the leaf sits at its wet bound with its stomata shut: the leaf potential at which the supply delivers exactly what the cuticle loses. The package evaluates the objective there with the same expressions as an open leaf, so every output describes one consistent state and the shut leaf cannot disagree with the open one next to it.
What the shut leaf nets depends on its residual conductance
g_min (a trait, default 0). With g_min > 0
the cuticle still passes CO₂, at ρ g_min with
ρ = 0.05, so c_i is an ordinary root of the
supply–demand balance: below c_a in light, above it in the
dark or when too hot to fix carbon, where the CO₂ the leaf respires
leaves through its cuticle. At g_min = 0 there is no
conductance, and the shut leaf takes the g_min → 0 limit of
that: the root of A(c_i) = 0, which is zero in light, as a
transpiring leaf tends to as E → 0; and, where
A has no root, its value as c_i → ∞, which is
−R_d in the dark. For Cowan-Farquhar at
g_min = 0 the result is exactly predictable: E
is zero, so the cost is zero, so in light profit is zero whatever λ
is.
d <- leaf_model() # psi_soil drier than psi_crit
set_drivers(d, psi_soil = 6.0, PPFD = 1500)
d$CF77_lambda_ <- 1.5e5
d$set_model("CF77", "leaf"); d$optimise()
cat(sprintf("E = %.1e, cost = %.1e, A = %.6f, profit = %.6f\n",
d$transpiration_, d$hydraulic_cost_, d$assim_colimited_, d$profit_))
#> E = 0.0e+00, cost = 0.0e+00, A = 0.000000, profit = 0.000000There are four axes, chosen independently. A published model is a point in this space rather than a separate program.
| axis | options | what it changes |
|---|---|---|
| cost curve | TF24, CF77, ProfitMax,
JS22, CMax, SOX,
JW26, TF24_floor, LeastCost |
C(ψ) and its units, and for the two product curves and
the ratio whether the objective is a difference at all |
| supply topology | single soil potential, or multi-layer root network | the decision variable, and the feasible bracket |
| leaf temperature | prescribed, or energy balance | whether Tleaf responds to E, and so
whether the temperature responses are active at all |
| thermal cost | off, or on | adds a temperature penalty and scales Jmax, the only
axis that moves both sides |
The published models map onto that space as follows.
| paper | code name | what it is here |
|---|---|---|
| Cowan and Farquhar (1977) | CF77 |
the λE cost, with λ prescribed. Any topology. |
| Sperry et al. (2017) | ProfitMax |
the normalised gain-risk cost, with λ derived per operating point. Their own formulation is single-supply. |
| Towers et al. (2024) | TF24 |
the carbon cost, solved over the collar potential with a multi-layer
root network. What plant consumes; the default model,
TF24_floor, is it at a price of 0. |
| Joshi et al. (2022) | JS22 |
the γ_J(Δψ)² cost, the only one priced on the drop.
Their hydraulic term only; see below. |
| Wolf et al. (2016) | CMax |
a marginal cost linear in the absolute potential,
CMax_a·ψ + CMax_b, in the form Anderegg et al. (2018) gave
it. |
| Eller et al. (2020) | SOX |
the product objective. Takes no parameter of its own. |
| Jones et al. (2026) | JW26 |
the same product form as SOX, with a linear
g. Takes no parameter of its own; see below. |
| Sicangco et al. (2026) | ProfitMax |
ProfitMax with the thermal-cost gate on. |
| Prentice et al. (2014) | LeastCost |
the cost per unit carbon, (c_E E + c_V V_cmax)/A, as
log A − log(E + β V_cmax) under the log link. One
parameter, the dimensionless unit-cost ratio β = c_V/c_E
(LeastCost_beta, 146 in the paper, which at this package’s
default leaf pins the optimum at ψ_crit from the reference
ψ_soil of 1 MPa drier; the default is aligned instead).
Zero is refused, because at zero the objective is water-use efficiency
and the optimum is the wet bound. The only cost here that weighs
capacity. |
| — | TF24_floor |
the canonical two-term split, Θ̃(ψ) + λ_o E, with
Θ̃ taken to be TF24’s own cost. Not a paper’s model: the
form is Wolf et al.’s case 8, endorsed in a sentence by Buckley (2017)
and fitted once in Anderegg et al. (2018)’s supplement, but never argued
for. Here so the claim can be tested rather than argued — and attached
to TF24 so that the test is about our cost. |
Of the difference objectives, CMax is the one nearest
TF24, for the reason the gamma-match chunk
measures: both price the absolute potential.
JS22 is Joshi & Stocker’s hydraulic term rather than
their model. Their capacity term αJ_max, and the joint
optimisation over J_max that goes with it, are not
implemented here.
JW26 is Jones et al.’s objective and not their model,
and the gap is worth stating precisely because it is a small one. Their
objective is implemented exactly: a product, with g linear
in the potential and zero at ψ_crit. What differs is the
hydraulics underneath it.
They supply ψ_crit as a free parameter, and their supply
is linear in the potential, Ψleaf = Ψpd − r_p E. Here
psi_crit is derived from the stem curve like everything
else, and the supply is that curve’s integral, which saturates.
Deriving psi_crit is a deliberate choice. It costs one
parameter of theirs, but it is what puts JW26 and
SOX on the same two anchors, so the comparison between the
two is structural rather than a fitted match. Reproducing the paper’s
own numbers would need the free ψ_crit and the linear
supply together, and those two are coupled: flattening the vulnerability
curve to recover their supply sends a derived psi_crit to
infinity and deletes the cost.
Both constraints on the log form are stated where they arise.
log A needs A > 0, which fails at closure,
and C diverges where g reaches zero, which is
why no product objective can end up at the dry bound. They apply to
JW26 exactly as to SOX.
A comparison across curves at each curve’s own published defaults
mixes two things: how each curve responds to the environment,
and where its parameters happen to sit. So the defaults here
are aligned. Each curve with a level parameter takes the value at which
its leaf optimum has TF24’s conductance at one reference environment: a
single soil potential of 1 MPa, PPFD 900, a 2 kPa deficit, 25 °C with
the energy balance off, and every trait at its default. TF24 is the
anchor and does not move. Because the supply is shared, matching the
conductance there also matches ψ_leaf, E,
A and ci.
[align_curves()] does the alignment, and the defaults are its answer
at its own defaults. Four curves have a level parameter:
CF77 (its price), JS22
(JS22_gamma), CMax (CMax_a) and
LeastCost (LeastCost_beta). CF77
aligned to TF24 is an identity rather than a fit, since at TF24’s
emergent λ the two first-order conditions coincide.
align_curves()
#> CF77_lambda JS22_gamma CMax_a LeastCost_beta
#> 1.077509e+05 4.717888e-01 6.385597e-01 5.159667e+01
#> attr(,"target")
#> [1] 0.09042876
sp <- leaf_supply_singlelayer()
curves <- c("TF24", "CF77", "JS22", "CMax", "LeastCost", "SOX", "JW26",
"ProfitMax")
gc <- sapply(curves, function(m) {
leaf_solve(psi_soil = c(0.5, 1, 2, 3), model = m, route = "leaf",
supply = sp)$gc
})
rownames(gc) <- paste("psi_soil", c(0.5, 1, 2, 3))
print(signif(gc, 3))
#> TF24 CF77 JS22 CMax LeastCost SOX JW26 ProfitMax
#> psi_soil 0.5 0.1060 0.0904 0.1000 0.1050 0.0904 0.0776 0.0764 0.0770
#> psi_soil 1 0.0904 0.0904 0.0904 0.0904 0.0904 0.0650 0.0678 0.0651
#> psi_soil 2 0.0613 0.0804 0.0643 0.0600 0.0804 0.0415 0.0470 0.0421
#> psi_soil 3 0.0354 0.0416 0.0358 0.0307 0.0416 0.0218 0.0257 0.0225The first five columns agree at psi_soil 1 and part
either side of it, which is the comparison an alignment is for. Four
curves are left unaligned:
SOX, JW26 and ProfitMax have
no level parameter; the supply puts their optimum. Matching them through
kmax would work, but they would then no longer share the
one supply every other curve uses.TF24_floor adds a price to TF24’s own cost at TF24’s
own traits, so the only λ_o that reaches TF24’s conductance
is zero, where the curve is TF24. That zero is its default.Call [align_curves()] with a different reference or
target to align for a different study, and pass its answer
back as traits (JS22_gamma, CMax_a) or as
[leaf_solve()] arguments (CF77_lambda,
LeastCost_beta).
The optimality argument above is indifferent to what A
and E actually are. What makes this a plant model rather
than a calculus exercise is the six blocks underneath, given here in the
forms the code evaluates.
Farquhar-type assimilation (Farquhar et al. 1980). A
Rubisco-limited rate and an electron-transport-limited rate, each a
saturating function of c_i:
\[A_c=\frac{V_{c\max}\,(c_i-\Gamma^{*})}{c_i+K_m}, \qquad A_j=\frac{J}{4}\cdot\frac{c_i-\Gamma^{*}}{c_i+2\Gamma^{*}}\]
Electron transport itself is a smooth minimum of light supply and capacity:
\[J=\frac{aQ+J_{\max}-\sqrt{(aQ+J_{\max})^{2}-4\,\theta_J\,aQ\,J_{\max}}}{2\theta_J}\]
and the two rates are colimited by the same smooth-minimum form before respiration is subtracted:
\[A=\frac{A_c+A_j-\sqrt{(A_c+A_j)^{2}-4\,\theta_c A_c A_j}}{2\theta_c}-R_d\]
θ_J and θ_c are curvature factors. At 1 the
expression is an exact minimum; below 1 it rounds the corner. The
Michaelis-Menten composite carries the competitive inhibition by
oxygen:
\[K_m=K_c\Big(1+\frac{O_2}{K_o}\Big)\]
A here is net assimilation.
R_d is inside it, which is why A can be
negative. A leaf with its stomata closed exchanges CO₂ only through its
cuticle, at ρ g_min; at the default g_min = 0
it nets zero in light, where the transpiring leaf tends to
A = 0 as E → 0, and −R_d in the
dark.
V_cmax, J_max, Γ*,
K_c, K_o and R_d are all defined
at 25 °C and moved from there. The first two take a peaked Arrhenius
form, rising and then falling as denaturation takes over:
\[X(T)=X_{25}\exp\!\Big[\frac{E_a\,(T-T_{25})}{T_{25}\,R\,T}\Big] \cdot\frac{1+\exp\!\big[(\Delta S\,T_{25}-H_d)/(R\,T_{25})\big]} {1+\exp\!\big[(\Delta S\,T-H_d)/(R\,T)\big]}\]
with T in kelvin. Γ*, K_c and
K_o take the first factor alone. Respiration is different
again, following a Q₁₀ whose own value declines with temperature:
\[R_d=R_{d,25}\;q^{(T-25)/10},\qquad q=q_0-q_1\frac{T+25}{2}\]
A grid at 25 °C is blind to every one of these by construction, because each response is defined to be 1 at its reference. Any test of a temperature response needs a second temperature.
The thermal-cost gate reaches into this block. When it is on,
J_max is multiplied by (1 − TC), which is why
it moves the benefit and not only the cost.
CO₂ in and water out share the stomata, so one conductance sets both.
Converting the mass flux E to a molar CO₂ conductance:
\[g_c=\frac{P\,E\,M_{\rm H_2O}}{1.6\;D_{\rm leaf}}\]
where 1.6 is the ratio of diffusivities of water vapour and CO₂,
P is atmospheric pressure, and D_leaf is the
leaf-to-air vapour pressure deficit. Fixing E fixes
g_c, which is the first link of the coordinate chain
above.
$stom_cond_CO2_ is a total leaf-to-air
conductance rather than a stomatal one: the relation above is the whole
path from leaf interior to bulk air, paired with a leaf-to-bulk-air
deficit, which is the right quantity for the c_i solve
below. With the energy balance on, the leaf has a boundary layer
($gb_H2O, its conductance to vapour), and that part of the
path diffuses CO₂ at a different ratio from the stoma’s,
r_b = r_s^{2/3} for a laminar layer, as in Sabot et al. (2022)’s equation 10. So the CO₂
conductance is the series of the two, with the stomatal water
conductance taken from the total g_w = $gs_H2O:
\[\frac{1}{g_c}=\frac{r_s}{g_w}-\frac{r_s-r_b}{g_{bw}}\]
The transpiration itself is unchanged, because the hydraulics set it;
the boundary layer changes only how much CO₂ that water buys: a median
+0.3% in A at 5 m/s of wind and +0.8% at 0.5 m/s, at most
+1.4%, measured on the default traits (#122). A measured
g_s has the boundary layer removed from it, so compare it
with $gs_stomatal_H2O, which takes $gb_H2O out
of $gs_H2O in series, not with $gs_H2O. With
the energy balance off there is no boundary layer, so
$gb_H2O is Inf, the two are identical, and the
relation above is exact.
c_i is not free. Diffusive supply must equal biochemical
demand, which is a scalar root-find at every candidate ψ:
\[g(c_i)\;=\;A(c_i)\;-\;\frac{g_c\,(c_a-c_i)}{P}\;=\;0\]
solved over [Γ*, c_a] by a bracketing method. This is
the innermost loop of the whole model. It runs at every objective
evaluation, and its tolerance sets what every reported output
means.
# The residual really is zero at the solved ci -- the statement above, checked.
l2 <- leaf_model()
set_drivers(l2, psi_soil = 1.0, PPFD = 1500)
l2$set_model("TF24", "leaf"); l2$optimise()
ci <- l2$ci_
gc <- l2$stom_cond_CO2_
resid <- l2$assim_colimited(ci) * 1e-6 - gc * (l2$ca_ - ci) / (l2$atm_kpa_ * 1000)
cat(sprintf("ci = %.4f Pa, supply-demand residual = %.2e mol m-2 s-1\n",
ci, resid))
#> ci = 22.6517 Pa, supply-demand residual = -2.71e-20 mol m-2 s-1Water arrives through a path whose conductivity falls as tension rises — the Weibull vulnerability curve of Appendix A. Transpiration at a given leaf potential is that curve integrated along the path:
\[E(\psi_{\rm stem};\psi_{\rm up})=k_{\max}\int_{\psi_{\rm up}}^{\psi_{\rm stem}}\! f(u)\,du\]
This integral is what makes the optimisation non-trivial. Because
f falls, E saturates: pulling harder recruits
progressively less conductance, so the carbon return on further tension
diminishes. Without that saturation, more tension would always buy more
carbon and there would be no interior optimum to find.
A transpiring leaf is cooler than the air. Linearised, the leaf temperature is
\[T_{\rm leaf}=T_{\rm air}+\frac{R_{n,\rm iso}-\lambda_v E}{\rho c_p/r_a+g_R},\qquad g_R=4\varepsilon_l\sigma T_{\rm air}^3\]
with no clamp: set_drivers() refuses drivers under which
a leaf the solve can reach would be past what the model can evaluate.
\(R_{n,\rm iso}\) is the isothermal net
radiation, absorbed shortwave less \((1-\varepsilon_a)\sigma T_{\rm air}^4\)
with a Brutsaert sky emissivity \(\varepsilon_a\) that rises with air
temperature and humidity and is held below 1 by a soft minimum of width
0.002, so the longwave term goes to zero smoothly where the fit passes
unity. The leaf’s own emission, which goes as \(T_{\rm leaf}^4\), is linearised about \(T_{\rm air}\) into the radiative
conductance \(g_R\), which is what
keeps the balance explicit: \(T_{\rm
leaf}\) is affine in E, so
dT/dE = −λ_v/(\rho c_p/r_a + g_R) everywhere. Warming the
leaf also raises its own vapour pressure, so the deficit driving
diffusion is not the atmospheric one:
\[D_{\rm leaf}=\max\!\big(D_{\rm air}+e_s(T_{\rm leaf})-e_s(T_{\rm air}),\;D_{\min}\big)\]
That closes a feedback loop: E sets T_leaf,
which sets D_leaf, which sets g_c, which sets
E. It is why the energy-balance path is solved rather than
evaluated, and why every derivative on it carries a dT/dE
term.
Finally, the thermal cost is a sigmoid in leaf temperature, between a critical and a half-lethal value (Sicangco et al. 2026):
\[TC(T)=\frac{1}{1+\exp\!\big[-r\,(T-T_{50})\big]},\qquad r=\frac{2}{T_{50}-T_{\rm crit}}\]
With this on, a leaf has a reason to transpire that is not photosynthetic at all: evaporative cooling becomes a benefit in its own right.
Blocks 1 to 5 are shared. Every published model in the table above accepts them, and differs only in the cost. The options are a carbon penalty rising with the fraction of conductivity lost (Towers et al. 2024), the water itself priced at a constant marginal rate (Cowan and Farquhar 1977), the normalised loss of remaining conductance (Sperry et al. 2017), the square of the water-potential drop (Joshi et al. 2022), a rate rising linearly with the potential (Wolf et al. 2016), or the log of the conductance remaining, following either the vulnerability curve (Eller et al. 2020) or a straight line (Jones et al. 2026). These are not refinements of one another. They disagree about what is being spent.
Two divisions cut across that list, and both are sharper than any of the individual formulae.
The first is what the marginal cost reads, which is the
third column of the table and the quantity that actually locates the
optimum. For seven of the nine it is a function of ψ alone.
JS22 is the exception that matters, because
2γ_J Δψ reads the drop; LeastCost’s reads the
flux, and so the drop, only through its denominator. That is the one
difference no parameter can absorb, since as the soil dries the drop
shrinks while every absolute-potential cost rises. The
gamma-match chunk measures both spreads side by side.
The second is whether the cost is subtracted or the objective is a
product. Five subtract; SOX and JW26 multiply.
That decides whether λ carries a factor of A, and so
whether water is priced in absolute carbon or as a share of what the
leaf is currently earning.
Every model simplifies. The assumptions that matter most when interpreting output from this one are:
leaf_gradient(psi = ) is the entry point that
supports that, and Prescribed versus optimised in Appendix B is
the derivation.| Term | Meaning here |
|---|---|
Objective J |
What the leaf is assumed to maximise: a benefit minus a cost,
h(A) − C(ψ) |
| Cost curve | The C(ψ) that distinguishes one published model from
another |
Benefit link h |
A transform applied to assimilation before the cost is subtracted:
identity, log, or scaled |
| Root collar | Where the root system joins the stem, and the point the collar route optimises the potential at |
| Route | Which potential is optimised. The leaf route takes
ψ_leaf with the upstream potential held at
ψ_soil; the collar route takes the collar
potential with the root path in series |
| Reference grid | The package’s 672-point regression baseline: 7 soil potentials × 4 light levels × 4 deficits × 3 layer counts, at two leaf temperatures |
| Operating point | The solved state: ψ* and every output evaluated
there |
| Operating-point kind | Which branch produced the answer: interior, at either bound, or hydraulic shutdown |
| λ | The marginal cost of water, (dC/dψ)/(dE/dψ), in carbon
per unit water |
ψ_crit |
The stem curve’s P95, derived from (P50, c), and the
dry end of the feasible interval |
|A|max |
ProfitMax’s normaliser: the largest assimilation available on the supply stream |
| Gate | An optional block that changes the model: the energy balance, or the thermal cost. Both default to off |
| Argmax | The ψ at which the objective is largest, as opposed to that largest value |
| Basin | A region of the interval containing one local maximum of the objective |
vignette("phylloptim") is the tour, and
leaf_solve() is the one-call entry point.vignette("fitting") works through a calibration, including
when the exact gradient is worth its complexity and when it is not.vignette("cpp-interface") covers the header-only model,
which needs no R.The Weibull vulnerability curve is conventionally written
exp(-(ψ/b)^c), and most published fits report
b and c. Substituting
b = P50 / (ln 2)^(1/c) gives the algebraically
identical
\[f(\psi)=2^{-(\psi/P_{50})^{c}}\]
in which scale and shape are cleanly separated. P50 is
the potential at 50% loss, in MPa, and c is the steepness.
b remains available as a derived read-only quantity, but it
is not a parameter anyone should set.
One convention to note: published P50 values are
negative, such as −3.95 MPa. This package uses positive magnitudes
throughout, so P50 here means |P50|.
(P50, b)b is a scale parameter. It is the 63% loss
point, in the same units and the same role as P50.
Specifying both means specifying two points on one curve and recovering
the shape from their ratio:
\[c=\frac{\ln(\ln 2)}{\ln(P_{50}/b)}\]
This diverges as P50 → b and is undefined at
P50 = b, so small errors in either input make the shape
explode. The pair is also badly correlated for estimation, which is why
fitplc (Duursma and Choat 2017) fits
Px and the slope at Px rather than
b and c. c is the only genuinely
independent second axis.
c is not a constantc carries real biology. At c ≈ 1 the curve
is exponential, with conductivity bleeding away from ψ = 0.
At c > 2 it is sigmoid, with a safe plateau followed by
rapid failure. Conifers are much steeper than angiosperms, and leaves
and roots are flatter than stems in the same plant.
Fitted directly in (P50, c) from raw PLC curves for
eight species:
| species | source / method | |P50| (MPa) | c | S50 (%/MPa) |
|---|---|---|---|---|
| Eucalyptus sideroxylon | Blackman 2019, bench | 3.95 | 1.99 | 17 |
| Eucalyptus tereticornis | Blackman 2019, bench | 4.31 | 2.59 | 21 |
| Casuarina cunninghamiana | Blackman 2019, bench | 2.91 | 1.81 | 22 |
| Pinus radiata | Blackman 2019, cavitron | 4.09 | 13.0 | 110 |
| Gossia bidwillii | Choat 2003 | 5.25 | 0.52 | 3 |
| Brachychiton australis | Choat 2003 | 3.49 | 1.21 | 12 |
| Alphitonia excelsa | Choat 2003 | 5.69 | 1.70 | 10 |
| Cochlospermum gillivraei | Choat 2003 | 1.42 | 5.15 | 126 |
The four Choat 2003 curves are not trustworthy for c.
They are 13 to 14 points each, and already sit at 25 to 30% PLC by 0.8
MPa, which is a native-embolism or open-vessel artefact rather than a
trait.
The defensible comparison is the three Blackman angiosperms, measured
the same way. Across those, c runs 1.8 to 2.6 while
|P50| spans 2.9 to 4.3 MPa: shape roughly conserved, scale
free. That is the evidence for treating c as a parameter
with a stable default rather than as a constant.
The default is c = 2.0 for angiosperm stems. Conifers
need a substantially higher value; the 13.0 fitted above is inflated by
the cavitron technique, and 4 to 5 is a safer literature-style default.
Guard c > 0, and treat anything below about 1 with
suspicion, since it means no safe plateau at all and in practice usually
flags a measurement artefact.
Published curves arrive in almost any form except
(P50, c). Everything inverts in closed form. For a
remaining conductivity fraction f, so that
PLC = 1 − f:
\[P_f = P_{50}\big(\log_2(1/f)\big)^{1/c} \qquad\Longleftrightarrow\qquad P_{50} = P_f\big(\log_2(1/f)\big)^{-1/c}\]
From any two quantiles, both parameters follow:
\[c=\frac{\ln(L_2/L_1)}{\ln(P_{f_2}/P_{f_1})},\qquad L_i=\log_2(1/f_i)\]
And from P50 plus the slope at P50 in %
MPa⁻¹, which is the other commonly published form:
\[S_{50}=\frac{50\,c\ln 2}{|P_{50}|}=\frac{34.657\,c}{|P_{50}|}\]
S50 should not be used as the internal parameter,
because it has P50 built into it by construction.
L <- function(f) log2(1 / f) # f = fraction REMAINING
p_at <- function(P50, c, f) P50 * L(f)^(1 / c) # any quantile from (P50, c)
c_from_two <- function(P1, f1, P2, f2) log(L(f2) / L(f1)) / log(P2 / P1)
s50_from <- function(P50, c) 50 * c * log(2) / P50
# round-trip: the package defaults, recovered from a (P50, P88) pair
tr <- leaf_traits()
l <- leaf_model()
P88 <- p_at(tr$stem_P50, tr$stem_c, 0.12)
c_hat <- c_from_two(tr$stem_P50, 0.50, P88, 0.12)
P50_hat <- P88 * L(0.12)^(-1 / c_hat)
cat(sprintf("defaults: P50 = %.6f MPa, c = %.6f, so P88 = %.6f, P95 = %.6f\n",
tr$stem_P50, tr$stem_c, P88, p_at(tr$stem_P50, tr$stem_c, 0.05)))
#> defaults: P50 = 3.400000 MPa, c = 2.680147, so P88 = 5.160000, P95 = 5.870283
cat(sprintf("recovered from (P50, P88): c = %.6f, P50 = %.6f (errors %.1e, %.1e)\n",
c_hat, P50_hat, abs(c_hat - tr$stem_c), abs(P50_hat - tr$stem_P50)))
#> recovered from (P50, P88): c = 2.680147, P50 = 3.400000 (errors 4.4e-16, 4.4e-16)
cat(sprintf("S50 at the defaults: %.1f %%/MPa\n", s50_from(tr$stem_P50, tr$stem_c)))
#> S50 at the defaults: 27.3 %/MPa
# the derived pair, at FULL precision -- b and psi_crit are read off the model,
# the right-hand sides are this appendix's own formulae
cat(sprintf("stem_b = %.17g vs P50/(ln2)^(1/c) = %.17g\n",
l$stem_b, tr$stem_P50 / log(2)^(1 / tr$stem_c)))
#> stem_b = 3.8982451221145307 vs P50/(ln2)^(1/c) = 3.8982451221145307
cat(sprintf("psi_crit = %.17g vs P95 = %.17g\n",
l$psi_crit, p_at(tr$stem_P50, tr$stem_c, 0.05)))
#> psi_crit = 5.8702827267723245 vs P95 = 5.8702827267723245
cat(sprintf("f(psi_crit) = %.17g (the critical fraction, exactly)\n",
l$proportion_of_conductivity(l$psi_crit)))
#> f(psi_crit) = 0.05000000000000001 (the critical fraction, exactly)Those are equalities rather than agreements. stem_b and
psi_crit are computed from (P50, c) by the
expressions printed beside them, so there is no gap to quote.
That matters more than it looks, because rounding a derived
quantity is amplified by the solve. If you set
stem_b and psi_crit by hand to a rounded
P95 instead of deriving them, the error does not stay where
you put it. Rounding psi_crit to seven figures displaces it
by 4.7e-08 and stem_b by 3.1e-08, and that propagates to a
worst case of 4.4e-05 on assimilation across 576 of the reference grid’s
points. A thousandfold amplification of a rounding-level input, which is
still below the 1e-4 at which this project calls a difference real, but
well above where it would be if the amplification were 1.
So specify (P50, c) at whatever precision you have, and
let everything else be derived.
psi_crit is a quantile, not a parameterpsi_crit is P_f at f = 0.05,
which is to say P95. Sperry’s reference conductivity
k_crit = f·kmax uses the same f. So
psi_crit, P95 and k_crit are one
number wearing three names. Derived from a single named constant they
cannot disagree; held as a free trait plus a hard-coded 0.05, they
needed a consistency check to police them. f = 0.05 is a
convention rather than a knob, and it is 0.05 everywhere it appears
across this family.
Two things follow, and both are easy to miss.
psi_crit describes the curve it was derived from, so there
is no consistency left to validate.psi_crit (CF77 and
LeastCost; SOX and JW26 end there too, but shut a leaf with
A <= 0 rather than pin it), a leaf pinned there has a
hydraulic cost that does not depend on the curve’s scale: the remaining
conductivity is exactly f whatever P50 is, so
dprofit/dP50 at that pin is exactly zero rather than merely
small.All three are asserted in the suite rather than described here, and the numbers below are what those assertions compare against.
1. P50 has a homogeneity shortcut. At
fixed c, b is a constant multiple of
P50, so the cumulative-vulnerability integral is
homogeneous of degree 1 and d/dP50 = (ln 2)^{-1/c} · d/db,
a factor of 1.1465 at the defaults. It is also why the stem’s
vulnerability table is built in ψ/b and depends on
c alone: moving stem_P50 rebuilds nothing.
psi_crit scales by the same factor.
2. The c gradient splits. Once
P50 is the scale parameter, b depends on
c as well:
\[\frac{d}{dc}\bigg|_{P_{50}}=\frac{d}{dc}\bigg|_{b}+\frac{db}{dc}\frac{d}{db}, \qquad \frac{db}{dc}=\frac{b\ln(\ln 2)}{c^{2}}\]
The second term is nearly free, because it reuses the cheap
b-derivative. But omitting it silently converts a total
derivative into a partial, which produces a plausible wrong gradient
rather than an error.
3. A derived dry end adds a term. Moving
P50 moves the dry end, so d/dP50 picks up a
term through it: dpsi_crit/dP50 = (log₂(1/f))^{1/c} (1.7266
at the defaults) for the curves ψ_crit bounds, and
dpsi_end/dP50 = (40/ln 2)^{1/c} (4.5409) for the rest, both
constant at fixed c. The term is exactly zero at an
interior optimum and of order 1 at one sitting on a bound, so the clean
identity in (1) holds only where the optimum is interior.
The size of that term is the reason to care. At a ψ_crit
pin the two halves were 1.1465 × 4.6991 from the curve and
1.7266 × 1.2605 through the bound, so dropping the second
gave 5.39 where the answer was 7.56. That is a 29% error with nothing
about it that looks wrong.
The root curve obeys the identical homogeneity identity and takes the
same (root_P50, root_c) treatment. Two curves on different
conventions is the shape of the mistake that once carried a root
parameter into a stem cost.
leaf_gradient() and leaf_gradient_batch()
return exact derivatives of a solved operating point with respect to the
parameters. model and route select which
optimum is differentiated: every curve in the table above, on the collar
route or the leaf route. leaf_gradient() is the batch with
one observation.
The reported outputs are evaluated at the optimum ψ, so
a parameter moves them in two ways: directly, and by moving ψ
itself. At an interior optimum dJ/dψ = 0, and
differentiating that condition gives ψ*’s response without
re-solving:
\[\frac{d\psi^*}{d\theta}=-\frac{M}{H},\qquad M=\frac{\partial^2 J}{\partial\psi\,\partial\theta},\qquad H=\frac{\partial^2 J}{\partial\psi^2}\]
\[\frac{dY}{d\theta}=\left.\frac{\partial Y}{\partial\theta}\right|_{\psi} +\frac{\partial Y}{\partial\psi}\,\frac{d\psi^*}{d\theta}\]
The second term is not a correction. For the cost parameters it is the whole answer.
Every term is exact. The outputs, dJ/dψ and the bounds
are closed forms in the parameters, and one pass of forward-mode
automatic differentiation carries their derivatives in every parameter
at once, so a gradient costs the same whatever it is asked for. Only
H is a difference, of the exact dJ/dψ at ψ*.
vignette("derivative-surface") has the machinery.
M and H are second derivatives of
J = h(A) − C, so both need dJ/dψ. That is
where the benefit link earns its place:
\[\frac{dJ}{d\psi}=h'(A)\,\frac{dA}{d\psi}-\frac{dC}{d\psi}\]
That is one expression, with h' the only per-model term:
1, 1/A, or 1/|A|max. A product
objective is therefore not a special case needing its own derivative,
and neither is ProfitMax.
At a stationary point dJ/dψ = 0 identically, so the
objective’s own indirect term vanishes and dJ/dθ is the
direct partial alone. The gradient reads J at the held ψ*
there, so that column is the direct partial exactly.
The envelope theorem does not survive a boundary. Where the optimum
is a bound, ψ* is a parameter-dependent bound, dJ/dψ is not
zero there, and −M/H is not the bound’s derivative. So the
premise is not tested numerically: the solve records which kind of point
it found, and at a bound the gradient differentiates the bound itself,
the wet end where uptake meets the residual flux or the dry end of the
stem.
Every route root-finds dJ/dψ = 0, so a point classified
interior is a root. Measured over 427 interior rows across six curves,
the residual |dJ/dψ| at the returned ψ* has a median of
1.2e-15, against 4.4e-05 when those routes refined on bracket width
instead.
ProfitMax is the one curve where the two routes answer
different questions, and the next section is about that.
Pass psi and the outputs are evaluated at that potential
instead of at the argmax. This is for a caller whose model
tracks the optimum rather than finding it. There ψ does not
move with θ, so there is no indirect term for any output and no envelope
identity to invoke: the answer is the direct partial.
The exceptions are at the dry end. A psi outside the
feasible interval is refused rather than moved onto the end, whose
derivative would be the bound’s; one inside it whose leaf potential lies
in the stem curve’s tail has a value and no gradient, because every row
there would be rounding. The returned status says which.
ProfitMax normalises by |A|max, the largest assimilation
available on the supply stream. That is itself an optimum, found by
endpoints plus a root-find on dA/dψ = 0, and it sits at the
dry bound on 1318 of 1320 driver rows.
Because it is an optimum, there are two things dY/dθ
could mean for this curve, and they are different numbers. Holding
|A|max fixed across a perturbation gives a
partial derivative at a fixed normaliser. Letting it
move with the parameters gives the total. At
psi_soil 1.5 and PPFD 1500, for vcmax_25, the
partial is 0.0766 and the total 0.0579: the difference is 32% of the
answer, so this is not a rounding distinction.
The gradient returns the total, which is what a fit needs. The model
re-solves |A|max at every parameter vector, so an optimiser
fed the partial would stop where the partial vanishes rather than where
the objective does. The gradient carries |A|max as a
function of the parameters, through its own first-order condition,
giving 0.057885, which a difference of the whole solve reproduces to
seven figures at relative steps from 1e-06 to 1e-03. No other curve
carries this distinction, because no other curve normalises by a
quantity that is itself solved for.
With the energy balance on, ψ reaches profit by two routes beyond the
two the gate-off derivative already carries, both through the leaf
temperature: directly, ψ → ψ_leaf → E → T_leaf → the temperature
responses → A; and indirectly, through the c_i root-find, whose residual
reads the temperature-dependent members and so gains an explicit T
argument. dprofit_energy_balance_term() adds both, and
subtracts the temperature slope of the two costs that read the leaf
temperature (ProfitMax’s thermal cost and LeastCost’s capacity term);
dprofit_dpsi_leaf() and
dprofit_at_collar_psi() call it, and it is exactly zero
with the gate off. Below, \(A_T=\partial
A/\partial T\) at fixed \(c_i\)
and \(A'=\partial A/\partial c_i\)
at fixed temperature (not the \(d
A/d\psi\) written \(A'\)
earlier on this page).
The c_i solve finds the root of
\[g(c_i;\psi_{\rm stem},\psi,T)=A(c_i,T)\,u-g_c(T)\,(c_a-c_i)\,a=0,\]
with \(u\) the µmol-to-mol factor and \(a\) the inverse of atmospheric pressure. So \(g_T=A_T u-g_c'(T)(c_a-c_i)a\), \(g_{c_i}=A' u+g_c a\), and \(dc_i/dT=-g_T/g_{c_i}\). The total temperature slope of assimilation is then
\[\frac{dA}{dT}=A_T+A'\frac{dc_i}{dT}=\frac{A_T\,g_c a+A'\,g_c'(T)\,(c_a-c_i)\,a}{g_{c_i}},\]
and the term added to \(dJ/d\psi\) is that slope times \(\tau=(dT/dE)(dE/d\psi)\), through the curve’s link \(h'(A)\).
Two checks come free. At \(g_c'(T)=0\) the slope is \(A_T\) times the damping factor \(g_c a/g_{c_i}\), which lies strictly in (0, 1): the direct thermal effect is partly undone as \(c_i\) re-equilibrates, so an implementation returning more than \(|A_T\tau|\) is wrong. And \(dE/d\psi>0\) while \(dT/dE<0\), so \(\tau<0\): above the thermal optimum (\(A_T<0\)) the term is positive and moves the optimum drier, toward more transpiration, because the extra flux buys evaporative cooling. That is the decoupling mechanism, and its sign is the sharpest test of the term.
\(g_c'(T)\) is not zero, and a form that puts it under \(A_T\) rather than under \(A'\) agrees with the one above only at \(g_c'(T)=0\), so it passes every test written while that slope was zero.
Three facts about this model that decide whether a number you compute from it means anything. All three are consequences of the same thing — the objective is flat where it matters — seen from three distances: in the value itself, in a difference of it, and in a derivative of its argmax.
J is flat at its optimum, with curvature
k ≈ 1 in J ≈ J* − k(ψ − ψ*)². An error
dJ in the value therefore displaces its
location by √(dJ/k). The two are not
interchangeable as things to check, and that has two practical
consequences.
The first is that you should check the residual |dJ/dψ|
rather than the profit when judging a change to a solver. Profit is the
wrong instrument precisely because it is flat: a change that locates the
optimum ten orders of magnitude more precisely can still report a profit
6e-07 lower at a handful of points, simply because the two
points differ by less than the objective can resolve. A change that
improves the answer can make the objective look worse.
The second is that a relative disagreement in either quantity needs
care. Comparing this model’s outputs between platforms, where glibc and
Apple’s libm do not agree in the last bits of exp and
pow, the reference grid disagrees by 9.05e-13 µmol C m⁻²
s⁻¹ in profit and 3.78e-11 Pa in c_i. Those figures are set
by the solver’s tolerances rather than by libm: with the inner
c_i root-find at 1e-10 rather than 1e-13, both were about
1e-9.
The worst relative figures, 2.13e-11 in profit and 8.6e-08 among the argmax-evaluated fields, land on rows where the quantity concerned is small: a profit of 0.029 against a grid median of 2.47, and an assimilation of 5.4e-06 against a median of 1.44, a leaf whose assimilation barely clears respiration. A relative difference divides by the value, so at such rows the metric is cancelling rather than the model disagreeing.
Differencing anything computed by a solver divides that solver’s
tolerance by the step, so the relative error grows as 1/h
until truncation takes over. The error-versus-h curve is a
V, and only its floor is a derivative. A small step is not the safe
choice; it is the noise side.
The V’s position is a property of the solver rather than of the model, and that is the part that catches people. How well a solver locates its argmax sets how small a step can still move it.
A root-find on dJ/dψ = 0 locates ψ* to about 1e-15, so a
1e-06 relative parameter step moves it far more than the solve’s own
precision, and the floor sits near 1e-06. A search refining on bracket
width locates ψ* only to about 1e-06 of the bracket, which is
4e-06 MPa on a 4.4 MPa one, and the same step then differences pure
quantisation. Measured on SOX for vcmax_25,
against an exact gradient of 0.0551:
| relative step | width-refined | root-found |
|---|---|---|
| 1e-06 | 0.1855 | 0.0551 |
| 1e-03 | 0.0552 | 0.0551 |
| 1e-02 | 0.0551 | 0.0551 |
At 1e-06 the width-refined difference also gets the sign wrong for
JW26. The lesson is that a single step is not a
verification: two bugs can be reported against a correct gradient on the
strength of one, and were.
Every route in the package root-finds, so a step of 1e-06 sits on the floor for all of them, and the package’s own referee of the gradient sweeps it anyway. Re-sweep if a solver changes, because the number is not a constant of the model.
The section above is about resolution. This one is about a harder obstruction.
A grid argmax is piecewise constant in the parameters. It sits at one grid point until the winner changes cells, so its derivative is not merely imprecise: it is zeros interrupted by jumps. No tolerance fixes that. It is why a bracketing search cannot support a trait gradient however tightly it is run, and why every solver here solves a first-order condition instead. Over eleven parameter steps, golden section gives 6 distinct argmax values where the root-find gives 11, with second differences of 3.9e-04 against 3.4e-07. The larger figure is the step size itself, which is to say nothing.
This is also why ProfitMax’s |A|max is found by a
root-find rather than by a sweep of the supply stream: it is
differentiated, so it has to be differentiable. Appendix B has what that
buys.