Skip to content

Leaf-to-air VPD, and Sperry's ProfitMax - #93

Merged
dfalster merged 13 commits into
masterfrom
feature/profitmax-sicangco
Aug 20, 2026
Merged

Leaf-to-air VPD, and Sperry's ProfitMax#93
dfalster merged 13 commits into
masterfrom
feature/profitmax-sicangco

Conversation

@dfalster

Copy link
Copy Markdown
Member

Fick's law divided by the AIR deficit however hot the leaf got (#7,
PLAN 13.1). Off the energy balance the leaf is at air temperature and
vpd_leaf_ == atm_vpd_ exactly, so the prescribed-temperature path and
the golden file are untouched. On it the deficit is 3-4x the air's over
Tair 25-45, so gs and A were overstated by that factor.

Also adds optimise_psi_stem_ProfitMax() (Sperry 2017, both terms
normalised, reporting the lambda that makes the existing entry point
agree), an optional instantaneous thermal cost default off, and four
fixes on the single-layer optimisers. Blast radius in the first comment.

dfalster and others added 5 commits August 19, 2026 12:21
Three changes, all on the energy-balance and single-layer paths; the
golden file is bit-identical and the R gradient golden is bit-exact.

1. Fick's law now divides by the LEAF-to-air deficit, not the air's
   (PLAN 13.1, #7). Off the energy balance the leaf is at air
   temperature and `vpd_leaf_ == atm_vpd_` exactly, so nothing on the
   prescribed-temperature path moves. On it, the deficit is 1.5x the
   air's at Tair 25 and 3-4x by Tair 45, so gs and A were overstated by
   that factor. The collar first-order condition gains the matching
   `dgc_dT` term -- and the placeholder algebra that stood there while
   `dgc_dT` was zero did NOT generalise, so it is re-derived rather than
   sign-flipped.

2. `optimise_psi_stem_ProfitMax()` runs Sperry (2017) with both terms
   normalised, computing |A|max over the supply stream itself. It
   reports the lambda that makes `optimise_psi_stem_Sperry` find the
   same point, so the equivalence is checkable. `profitmax_curve()`
   returns the whole cost/gain/profit curve in one crossing.

3. An optional instantaneous thermal cost (Sicangco et al. 2026),
   default off: TC sigmoid in leaf temperature, Jmax scaled by (1-TC).
   NOT plant's ATLS ratchet, which has memory.

Plus three bugs on the single-layer optimisers: a NaN `lambda_` was
searched silently, and `opt_root_psi_`/`E_up_`/`soil_consumption_`
survived from an earlier collar solve.

The decoupling acceptance test now asserts on transpiration rather than
conductance, because the deficit correction removes the conductance
signature at those drivers. Measured in the test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`set_leaf_states_rates_from_psi_stem` zeroed transpiration and
conductance wherever `assim_max_ < 0`. Transpiration on that path is the
hydraulic supply at the candidate potential and does not depend on
photosynthesis, so the branch made two leaves at the SAME operating
point disagree about whether water was moving purely because one had
respiration switched on.

The shut-down STATE it reached for is unchanged: where assim_max_ < 0
the ci root-find has no root in [gamma*, ca] and takes its
compensation-point fallback, giving ci = gamma* and net A = -R_d, which
is what the branch set by hand. That fallback did not exist when the
branch was written.

Unreachable from the collar solve -- prepare_collar_solve has its own
`assim_max_ < 0` exit and returns before any candidate is evaluated --
so plant is unaffected. The golden grid's minimum assim_max_ is 3.71, so
the file is bit-identical and says nothing about this;
test_transpiration_survives_negative_assim is the test that can.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Regenerating man/ also picks up a stale gradient_par_names.Rd, which
said fifteen names where its own roxygen source has said sixteen since
R_d_25 was added.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
brent_fmin is a local optimiser that steps in from the bounds, so it
could return neither an endpoint nor the global maximum of a
multi-modal objective -- and this objective is both, in the regime the
model is interesting in. Measured at Tair 50 C with the thermal cost
on: the profit is highest at full closure (-1.5314 at psi_soil) with a
local maximum near 1.9, and the solver returned 1.643. It reported an
open stoma where the objective says shut.

The scan prepare_profitmax() already runs now stores A and Tleaf per
point, so the objective is rebuilt on it for no extra model
evaluations. An endpoint argmax is returned as the endpoint.

optimise_psi_stem_TF and _Sperry share the hazard and are documented
rather than changed: neither has a scan to reuse. The collar solve is
unaffected -- it handles a pinned optimum explicitly.

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

Copy link
Copy Markdown
Member Author

What moved, and what did not

The golden file is bit-identical across all five commits, and that is a
property of the grid rather than evidence the changes are inert:

  • the grid runs with use_energy_balance_ off, so vpd_leaf_ == atm_vpd_ by
    construction;
  • its minimum assim_max_ is 3.71 (the figure this repo's guide records for that
    grid), so it never reaches the branch that zeroed transpiration.

C++ suite 486 → 534 checks, 0 failures. The baseline was built from a
detached worktree at origin/master rather than remembered — an earlier draft of
this comment said 532, which was an intermediate count from partway through this
branch's own work.

what measured
leaf-to-air deficit vs air deficit, package defaults 3.4x / 3.2x / 4.0x at Tair 25 / 35 / 45 °C
the same in test_leaf_to_air_vpd's setting (Rn_ 400, ra_ 12, atm_vpd 1.5) 1.52x at Tair 25 °C
decoupling acceptance test the signature moves from conductance to transpiration: E ×1.034 against D_leaf ×1.089 over the window, so gs ×0.950
ProfitMax solver Brent returned 1.643 MPa at Tair 50 °C where the objective's maximum is full closure (−1.5314 at psi_soil, −1.5459 at the interior local max)
normalised HC invariance to kmax worst difference 1.1e-16 across a 3x change

The four fixes on the single-layer optimisers

  1. optimise_psi_stem_Sperry searched a NaN objective silently. lambda_ is an
    input with no default, cleared by setup_clean_leaf and never set by
    set_physiology, so a caller who drove the leaf and called this got a
    plausible-looking potential (2.551266 MPa at the defaults) beside
    profit_ = NaN. It now refuses.
  2. opt_root_psi_, E_up_ and soil_consumption_ survived from an earlier
    collar solve, so the object reported E = 9.216e-5 beside E_up = 2.626e-5.
    Hazard 8, live on this path.
  3. set_leaf_states_rates_from_psi_stem zeroed transpiration wherever
    assim_max_ < 0. Transpiration there is the hydraulic supply and does not
    depend on photosynthesis; the branch made two leaves at the same operating
    point disagree about whether water was moving, purely because one had
    respiration switched on. The shut-down state it reached for is unchanged —
    the ci root-find has no root in [gamma*, ca] and takes its
    compensation-point fallback, which is what the branch set by hand. That
    fallback did not exist when the branch was written.
  4. optimise_psi_stem_ProfitMax now scans a grid before refining. See the
    measurement above.

Two things a reviewer should push back on if they disagree

The dgc_dT algebra was re-derived, not sign-flipped.
dprofit_energy_balance_term carried a named const double dgc_dT = 0.0 with a
note promising that PLAN 13.1 would make it "a one-line change instead of a
re-derivation". It would not have been: the damping factor it multiplied puts the
new term under A_T where the derivation puts it under A_prime. The two agree
only at dgc_dT = 0, which is why nothing caught it. The derivation is at the
function.

plant is unaffected by fix 3, and this is provable rather than likely.
prepare_collar_solve has its own assim_max_ < 0 exit and returns false before
any candidate potential is evaluated, so the branch is unreachable from
find_root_collar_psi — the only entry point plant uses.

Where this came from

leaf_calibration_test/sicangco-2026, a replication of Sicangco et al. (2026),
which needed Sperry's ProfitMax on the same footing as TF24. Fix 4 was found by
comparing against their Figure 4: their model closed at 48–56 °C and ours did
not, and the whole difference was the search.

dfalster and others added 2 commits August 19, 2026 15:41
Doxygen reads a `|` in a paragraph as a table delimiter, and `\verbatim`
is not allowed inside a table cell: it drops the OPEN and then reports
`unexpected command endverbatim` at a line that is neither -- here, 80
lines further on, inside unrelated code.

`|` in prose is common in these headers and was harmless until a comment
that had one also gained an indented block, which the filter turns into
a verbatim. Found by comparing every verbatim-carrying comment block on
this branch against every one on master: a pipe was the only structural
feature present in mine and absent from all of theirs.

⚠️ Doxygen 1.17 renders the same input in silence; only the 1.9 that CI
installs objects, so a local `doxygen` run is not evidence either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Doxygen 1.9, which is what CI installs, drops the second `\verbatim`
open in a `///` block and reports `unexpected command endverbatim` at
a filtered-stream line 120 lines away in unrelated code. 1.17 renders
it in silence, so the docs job was the only oracle. The ProfitMax
banner had two displays; they are now one.

docs.yml asserts the shape and names the block, so the next one fails
legibly. The `|`-escaping added earlier is reverted: it was a wrong
hypothesis, and master's prose is full of unescaped pipes.
@dfalster

Copy link
Copy Markdown
Member Author

The docs job, and the two explanations that were wrong

The failure was leaf_model.hpp:3470: error: unexpected command endverbatim, at a line that is code in both the original and the filtered stream. Doxygen 1.17 (Homebrew) renders the same input in silence; 1.9.8 is what the runner installs, so CI was the only oracle. Bisected on a throwaway branch by workflow_dispatch, then settled with a probe header of eight isolated constructs in one render:

construct 1.9.8
one indented run clean
=== / --- banner rules above a run clean
| in prose, escaped by the filter clean
| inside the run's content clean
emoji in prose clean
two runs in one /// block errors, at the SECOND \endverbatim

So Doxygen 1.9 handles the first \verbatim in a /// block and drops the second one's OPEN. The reported line is in the filtered stream and lands wherever the parser next gets confused — 120 lines past the comment responsible, here.

Two things I asserted before measuring, both wrong, both now removed from the tree:

  • A setext heading swallowing the block. The banner's --------- rule is directly above the prose. Probe 3 says it is clean.
  • A bare \| opening a Markdown table, with \verbatim illegal in a cell. I had committed a \|-escaping change to tools/doxygen_filter.awk on this reasoning and written it into the filter's header as fact. Probes 4 and 5 say pipes are irrelevant either way, and origin/master renders green with unescaped | throughout its prose. That commit is reverted.

The fix is the banner carrying one display instead of two. docs.yml gains a step that counts \verbatim opens per block and names the offending block, so the next occurrence fails legibly instead of pointing at unrelated code.

⚠️ The /*! \file */ block is exempt from that count, and deliberately: closed_form.hpp's file block has carried two runs across a long green master, so counting it would fail the build on code Doxygen renders happily.

dfalster and others added 5 commits August 19, 2026 19:27
One conflict: the generated hash line in `R/RcppR6.R`, where neither
side's value describes the merged bindings. Resolved by regenerating
rather than picking a side -- RcppR6::RcppR6(), compileAttributes(),
roxygenise() -- which gives a9a3c8f and leaves every other generated
line byte-identical to the auto-merge.

C++ 534 checks / 0 failures, 576 golden points bit-identical. R suite
0 failures, `gradient_golden.tsv` worst relative difference 0. Both
`docs.yml` checks pass locally, including the verbatim-block shape one
against master's new `gradient.hpp` comments.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The branch changes results on the energy-balance path -- gs and A were
overstated by the leaf-to-air deficit ratio, 3.2-4.0x over Tair 25-45
-- and had no NEWS entry, where #89 and #90 both do.

Six sections: the VPD fix and its floor, ProfitMax and the unitless
members it writes, the four single-layer optimiser fixes, the dgc_dT
re-derivation, and the one-verbatim-run-per-block finding. Every number
is from the PR comments, checked against the code rather than copied.

No behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The section was written unwrapped, one line per paragraph, beside a file
wrapped at ~80: longest prose line 666 characters against 85 in the
neighbouring section. Wrapped to 81.

Content is unchanged. The heading's issue link becomes a bare `(#7)`,
matching the file's other headings, and the full link moves into the
prose beside PLAN 13.1 -- a word-level diff of the whole file shows
those two moves and nothing else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…angco

Four conflicts, all in leaf_model.hpp, all where this branch and master changed the
same expression for different reasons:

  * three conductance conversions -- this branch replaced atm_vpd_ with vpd_leaf_,
    master made H2O_CO2_stom_diff_ratio a settable member. Both changes kept.
  * dprofit's early-return dE_dpsi -- this branch rewrote the derivation to state it
    directly, because the coefficient master's version cancels is no longer a single
    number once the deficit moves with Tleaf. This branch's version kept; it does not
    reference the ratio.

R/RcppR6.R was regenerated rather than hand-resolved, and man/ + NAMESPACE were
stale after the merge and regenerated too.

Verified: 566 C++ checks / 0 failures, golden bit-identical, gradient golden
unchanged, fingerprint unchanged at 3e36d9b94fd0, 1137 R tests / 0 failures,
R CMD check --as-cran Status: OK, ctest 2/2, bench targets build, and the CI
consumer program built against an installed prefix exits 0.

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

Copy link
Copy Markdown
Member Author

Merged master (#99, #100, #101, #102, #103). MERGEABLE / CLEAN, all six CI jobs green.

The four conflicts, all in leaf_model.hpp

Every one was the same shape — this branch and master changed the same expression for different reasons — so three of them wanted both changes, not a choice:

Three conductance conversions. This branch replaced atm_vpd_ with vpd_leaf_; master (#103) made H2O_CO2_stom_diff_ratio a settable member. Resolved as vpd_leaf_ and H2O_CO2_stom_diff_ratio_:

stom_cond_CO2_ = atm_kpa_ * transpiration_ * kg_to_mol_h2o / vpd_leaf_
                 / H2O_CO2_stom_diff_ratio_;

dprofit's early-return dE_dpsi. Master still derives it as the two gc partials divided back by their shared gc_c. This branch rewrote it to state dE/dpsi directly, because "that cancelled a coefficient that is no longer a single number now that the deficit moves with Tleaf". This branch's version is kept — it is the substantive fix, and it references neither the VPD nor the ratio, so there was nothing of master's to fold in.

Audited afterwards rather than assumed: every ratio site in leaf_model.hpp now pairs the member with vpd_leaf_, and the only surviving bare H2O_CO2_stom_diff_ratio is the constexpr in constants.hpp that the member initialises from.

Two things the merge did not fix, both worth a look

closed_form.hpp is still on atm_vpd_. This branch introduced vpd_leaf_ as the operative VPD for the main path but did not touch the closed-form solver, so the fast approximate path and the exact path now use different VPDs wherever Tleaf != Tair. That is this branch's own scope decision rather than something the merge broke — closed_form.hpp is default-off and "not wired in" — but it is a latent inconsistency now that the two VPDs can differ, and it did not exist before this branch. Worth either updating it or saying in the header why it stays on air VPD.

man/ and NAMESPACE were stale after the merge and I regenerated them. Same gap I flagged on #103: CI regenerates the RcppR6 glue and diffs it, but nothing does that for roxygen, so man/ drifts silently. This merge would have landed with man pages describing the pre-#103 API.

Verification

  • make -C tests/cpp566 checks, 0 failures, and the golden file is bit-identical
  • gradient_golden.tsv unchanged; leaf_behaviour_fingerprint() unchanged at 3e36d9b94fd0
  • devtools::test()1137 pass, 0 fail, 0 skip
  • R CMD check --as-cran with CI's own arguments — Status: OK (0 errors, 0 warnings, 0 notes)
  • ctest 2/2; both bench targets build
  • The CI consumer program, extracted from this branch's own workflow heredocs and built against an installed prefix — exit 0, against master's updated pinned profit and dA/dvcmax

The bit-identical golden file is the load-bearing result: it says this branch's features are genuinely opt-in and that #101's molar-mass change and #102's gas-constant change did not disturb them. vpd_leaf_ reduces to atm_vpd_ when Tleaf == Tair, which is the default path.

Two conflicts, both in the leaf's per-candidate state update.

#106 wrote `Tleaf_` on all three branches of
set_leaf_states_rates_from_psi_stem; this branch had already deleted one of
them, the `assim_max_ < 0` exit, so the note now describes two. And #106
stores `Tleaf_` before the temperature update, which this branch had
recomputing `leaf_temp_from_E` for its own `set_leaf_vpd` call: both now read
the stored value, so the temperature the Farquhar parameters were derived at,
the one the deficit was derived at, and the one reported to the caller cannot
be three different numbers.

`R/RcppR6.R` conflicted only on its generated hash; regenerated rather than
merged.

Golden files bit-identical: 576 operating points and 544 primitives, no cell
moved. 605 checks, 0 failures.

@itowers1 itowers1 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me, though there is a lot of new content, so still having to get used to it. Question about optimise_psi_stem_ProfitMax vs optimise_psi_stem_sperry. For conceptual simplicity woud like to streamline number of optimisers.

// since maximise_profit_over_collar handles a pinned optimum explicitly.

// need docs on Golden Section Search.
inline void Leaf::optimise_psi_stem_Sperry() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still need optimise_psi_stem_Sperry, if we also now have optimise_psi_stem_ProfitMax?

@dfalster

Copy link
Copy Markdown
Member Author

Merging master (#104, #106, #107, #108) in — what the conflicts were, and the blast radius

Blast radius: none. operating_points.tsv 576 points and primitives.tsv 544 values both bit-identical; 605 checks, 0 failures; bench builds. So this catch-up merge is behaviour-preserving on the golden grid, and the branch is now 0 behind master.

Two conflicts, both in set_leaf_states_rates_from_psi_stem, and both had content rather than being textual:

  1. Report the leaf's own temperature #106 wrote Tleaf_ on all three branches of that function; this branch had already deleted one of them. The assim_max_ < 0 early exit is gone here (56450b4, "water moves whether or not there is carbon to be had"), so Report the leaf's own temperature #106's note — "the two shut-down branches transpire nothing" — now describes one. Reworded to two paths, keeping Report the leaf's own temperature #106's reasoning for why the shut-down branch must write its own Tleaf_: on the PM path a leaf that transpires nothing is the hottest one, so leaving it to the transpiring branch reports the previous candidate's temperature exactly where the answer is most extreme.

  2. Report the leaf's own temperature #106 stores Tleaf_ before the temperature update; this branch was recomputing leaf_temp_from_E for its own set_leaf_vpd call. Both now read the stored Tleaf_, so the temperature the Farquhar parameters were derived at, the temperature the leaf-to-air deficit was derived at, and the temperature reported to the caller cannot be three different numbers. Same value by construction (identical expression, identical order), hence bit-identical — and it drops a redundant leaf_temp_from_E from a path that runs ~10³ times per solve, which is what Report the leaf's own temperature #106's own comment asks for.

R/RcppR6.R conflicted only on its generated hash line. Regenerated with RcppR6::RcppR6() rather than hand-merged; src/RcppR6.cpp and the three support headers came out unchanged.

One thing found and deliberately not fixed here

The remaining shut-down branch (psi_upstream >= psi_stem) writes Tleaf_ but not vpd_leaf_, so a reported leaf-to-air deficit there is the previous candidate's. That is hazard 8 applied to one output over from the one #106 fixed, and it is pre-existing on both sides of this merge. It may move golden rows, so it wants its own change with its own before/after rather than being folded into a conflict resolution. Filing separately.

@dfalster
dfalster merged commit 2d78f8b into master Aug 20, 2026
7 checks passed
@dfalster
dfalster deleted the feature/profitmax-sicangco branch August 20, 2026 02:22
dfalster added a commit that referenced this pull request Aug 20, 2026
#93 added `vpd_leaf_`, derived from a leaf temperature and read by
`g1_eff()`, and seated it at its own zero-transpiration branch --
but not at the two shut-down exits outside the solve, which is
where this PR already had to fix the same class of disagreement.
So a shut-down leaf reported a 39.3 C temperature beside the
deficit belonging to 30 C, 2.44x too small.

A no-op off the energy-balance path, where `set_leaf_vpd` returns
`atm_vpd_` exactly. NEWS carries all four fields now.
dfalster added a commit that referenced this pull request Aug 20, 2026
Four sites described the model as it was before two merged fixes.
The one that mattered is user-facing: fitting.Rmd told readers
that changing a trait and re-driving takes a temperature-cache
hit and silently reports the first vcmax it ever saw. #55 put
every input of the temperature block in the key, so it does not.
The advice to use set_traits() stands on the splines, the solved
operating point and the #25 checks instead.

Comments and prose only; both golden files bit-identical. The
closed_form/#93 divergence is recorded, not repaired -- see NEWS.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants