Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,15 @@ byte-for-byte. If you add a header, you need do nothing. If you want a literal
Doxygen command, write a `///` comment and the filter will leave it alone.
Publishing is off until someone sets the repo variable `PUBLISH_DOCS=true`.

⚠️ **One indented display per comment block.** An indented run becomes
`\verbatim`, and Doxygen 1.9 — which is the version CI installs, where local
Homebrew is 1.17 — drops the second one's OPEN and then reports `unexpected
command endverbatim` at a line in the *filtered* stream that lands in unrelated
code, 120 lines away in the case that found it. **A local `doxygen` run renders
it in silence**, so this is only ever visible in CI and only cryptically. Put the
equations in one display rather than one on each side of a paragraph; `docs.yml`
now asserts the shape and names the block, so it fails legibly.

## Cost: the one thing the golden file cannot see

`tests/cpp/bench_solve.cpp` and `bench_gradient.cpp` cover the C++ side.
Expand Down Expand Up @@ -815,6 +824,31 @@ the per-cause split and the tolerance bands go in the first PR comment — see
removed the same composite wins 4.4× on the eleven traits that touch no spline.


11. **The single-layer optimisers search a LOCAL maximum, and the profit is
neither unimodal nor interior.** `brent_fmin` steps in from the bounds, so it
can return neither an endpoint nor the global maximum of a multi-modal
objective. Both happen: at a leaf hot enough that net assimilation is negative
across the whole supply stream, the ProfitMax profit is highest at **full
closure** and carries a local maximum out in the interior. Measured at Tair
50 °C with the thermal cost on — profit −1.5314 at `psi_soil`, −1.5510 at 1.19,
−1.5459 at 1.88 — and the solver returned **1.643**, reporting an open stoma
where the objective says shut.

`optimise_psi_stem_ProfitMax` now evaluates the objective on the scan
`prepare_profitmax` already runs, takes the grid argmax, and refines with Brent
only when it is interior. No extra model evaluations: `A` and `Tleaf` are
stored per grid point and `HC`/`TC` are analytic.

⚠️ **`optimise_psi_stem_TF` and `optimise_psi_stem_Sperry` still have it**, and
are documented rather than fixed because neither has a scan to reuse. The
collar solve is unaffected — `maximise_profit_over_collar` handles a pinned
optimum explicitly, which is why 42 of 240 feasible golden rows are pinned and
correct.

The general form: **a bracketing optimiser answers "where is the interior
maximum", and that is not the same question as "where is the maximum".** If an
objective can be maximised at a constraint, the search has to be told.

## Validating against plant

⚠️ **There is no longer a harness that compares against plant, and there cannot be
Expand Down
47 changes: 47 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,53 @@ jobs:
done
exit $status

# ⚠️ Doxygen 1.9 -- the version this runner installs -- handles the FIRST
# `\verbatim` in a comment block and drops the second one's OPEN, then
# reports `unexpected command endverbatim` at its close. The line it names
# is in the FILTERED stream and lands in unrelated code: in the case that
# found this, 120 lines past the comment responsible. Doxygen 1.17 renders
# the same input in silence, so a local run says nothing and the only
# oracle is this job. Assert the shape here, where the message can name the
# block, rather than leaving the next person to bisect the render.
#
# ⚠️ It is the `///` blocks that break, NOT the `/*! \file */` one, and the
# difference is load-bearing rather than untested: closed_form.hpp's file
# block has carried two runs since long before this check, on a green
# master. Counting it too fails the build on code Doxygen renders happily.
- name: Check no comment block opens two verbatim runs
run: |
set -eu
status=0
for f in $(find inst/include -name '*.hpp' | sort); do
awk -f tools/doxygen_filter.awk "$f" \
| awk -v f="$f" '
function note(t) {
if (first == "") {
sub(/^[ \t]*(\/\/\/|\*)?[ \t]*/, "", t)
first = substr(t, 1, 60)
}
}
function flush() {
if (opens > 1) {
printf "::error file=%s::comment block [%s] opens %d verbatim runs; Doxygen 1.9 drops all but the first. One indented display per block -- see tools/doxygen_filter.awk\n", f, first, opens
bad = 1
}
opens = 0; first = ""
}
/^[ \t]*\/\*![ \t]*\\file/ { infile = 1; flush(); next }
infile {
if ($0 ~ /^[ \t]*\*\/[ \t]*$/) infile = 0
next
}
/^[ \t]*\/\// {
if ($0 ~ /\\verbatim[ \t]*$/) opens++
note($0); next
}
{ flush() }
END { flush(); exit (bad ? 1 : 0) }' || status=1
done
exit $status

# Overriding on stdin is Doxygen's documented way to change one setting
# without a second Doxyfile. Warnings stay non-fatal for a local `doxygen`
# run, where they are informative rather than blocking.
Expand Down
4 changes: 2 additions & 2 deletions COMPARISON.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,10 @@ source read at 1.0.6.1). All three are **pure R with no compiled code**.
| Soil water potential | **yes, multi-layer** (separable — PLAN 7b) | prescribed scalar (Tuzet only) | no | no |
| Root resistance | **yes, per layer** | explicitly not implemented | no | no |
| Profit / gain-risk optimisation | **yes** | Cowan-Farquhar with fixed λ | no | no |
| Thermal acclimation or damage | yes (in plant's TF24t) | no | no | no |
| Thermal acclimation or damage | yes — an instantaneous PSII cost here (Sicangco et al. 2026, default off), and a lasting damage ratchet in plant's TF24t | no | no | no |
| Fits to measured data | **no** | yes (A-Ci, Ball-Berry) | no | yes (that's the point) |
| Swappable *empirical* gs schemes | Medlyn present, not dispatched | **yes — 4, plus Tuzet** | n/a | n/a |
| Swappable *hydraulic optimality* schemes | **planned — TF24, Sperry, Prentice14** | no (none are hydraulic) | no | no |
| Swappable *hydraulic optimality* schemes | **TF24 and Sperry ProfitMax runnable at identical drivers; Prentice14 planned** | no (none are hydraulic) | no | no |
| Exact derivatives | **yes — forward-mode AD (XAD)** | no | no | no |
| Language | C++ header-only | R | R | R |
| Cost per solve | ~4 µs | not measured; `mapply` over scalars | one `uniroot` per leaf | vectorised over time series |
Expand Down
120 changes: 120 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -570,6 +570,126 @@ of the wrong shape — see the note in `update_temperature_dependent_params()`.
`rd_to_vcmax_ratio` is still not a `leaf_traits()` member, so it cannot yet be
fitted or differentiated; it is set as a field.

## Fick's law uses the LEAF-to-air vapour deficit, not the air's (#7)

Transpiration and stomatal conductance divided by `atm_vpd_` however hot the leaf
got. Diffusion out of a stoma is driven by the deficit *at the leaf*, and on the
energy-balance path the leaf runs above air temperature, so that deficit is the
larger one. Measured at the package defaults it is **3.4× / 3.2× / 4.0× the air's
at Tair 25 / 35 / 45 °C** — the factor by which `gs` and `A` were overstated
([#7](https://github.com/traitecoevo/phylloptim/issues/7), PLAN 13.1).

⚠️ **Off the energy balance nothing moves, and that is a property of the grid
rather than evidence the change is inert.** At air temperature
`vpd_leaf_ == atm_vpd_` exactly, so the prescribed-temperature path is untouched
and `tests/cpp/golden/` is **bit-identical across all five commits** — because
the grid runs with `use_energy_balance_` off. A bit-identical golden run says
nothing here.

⚠️ **The decoupling signature moves from conductance to transpiration.** Over the
acceptance window `E` rises ×1.034 against `D_leaf` ×1.089, so `gs` falls ×0.950.
A test written against the old arithmetic would read the same physics off the
wrong variable.

`constants::vpd_leaf_min = 0.01` kPa floors it. A leaf cooled below the dew point
has a *negative* deficit, and dividing a positive transpiration by it reports a
negative conductance; the floor keeps such points finite and unattractive instead
of sign-inverted. The driver defaults are 1.5–2 kPa, so it binds only where a
one-way diffusion equation has already stopped describing the leaf.

## Sperry's ProfitMax, on the same footing as TF24

`$optimise_psi_stem_ProfitMax()` maximises Sperry (2017)'s profit with **both**
terms normalised, and reports the λ that makes the existing entry point agree.
`$profitmax_curve()` returns the whole cost, gain and profit curve in one call,
and `$optimise_psi_stem_TF()` runs the TF24 cost through the same solver — so the
two formulations can be compared at identical drivers. An optional instantaneous
thermal cost is included, **default off**.

⚠️ **These write `$carbon_gain_`, `$hydraulic_cost_norm_` and `$thermal_cost_`,
which are unitless and are NOT `$hydraulic_cost_`.** Reading the normalised cost
as the TF24 one is a units error the names are chosen to prevent.

The normalised hydraulic cost is invariant to `kmax` by construction, and it is
asserted rather than assumed: worst difference **1.1e-16** across a 3× change.

Motivated by `leaf_calibration_test/sicangco-2026`, a replication of Sicangco et
al. (2026), which needed ProfitMax measured against TF24 rather than described.

## ⚠️ Four fixes on the single-layer optimisers, and the fourth is a solver bug

These paths are reachable only from `optimise_psi_stem_*`, not from
`find_root_collar_psi()` — so **`plant` is unaffected, provably rather than
probably**: `prepare_collar_solve` has its own `assim_max_ < 0` exit and returns
false before any candidate potential is evaluated.

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 potential (2.551266 MPa at the defaults) beside `profit_ = NaN`. It
now refuses.
2. **Hazard 8, live on this path.** `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`.
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, 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:
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.**
`brent_fmin` steps in from the bounds, so it returns neither an endpoint nor
the global maximum of a multi-modal objective — and at Tair 50 °C with the
thermal cost on it returned **1.643 MPa where the objective is maximised at
full closure** (−1.5314 at `psi_soil` against −1.5459 at the interior local
max), reporting an open stoma where the model says shut. The scan reuses
`prepare_profitmax`'s own grid, so this costs no extra model evaluations.
Found by comparing against Sicangco et al.'s Figure 4: their model closed at
48–56 °C and ours did not, and the whole difference was the search.

⚠️ **`optimise_psi_stem_TF` and `optimise_psi_stem_Sperry` still have fault 4**,
and are documented rather than fixed because neither has a scan to reuse. The
general form is now hazard 11 in the developer guide: *a bracketing optimiser
answers "where is the interior maximum", which is not the same question as "where
is the maximum".*

## The `dgc_dT` term in the energy-balance derivative was re-derived, not sign-flipped

`dprofit_energy_balance_term` carried a named `const double dgc_dT = 0.0` and a
note promising 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
written out at the function, and at `dgc_dT = 0` it reduces to exactly the old
expression — so the prescribed-VPD behaviour is unchanged, which is the other
half of why the golden file does not move.

## A comment block may open only one `\verbatim` run, and only CI can see it

Doxygen 1.9.8 — what the runner installs — handles the first `\verbatim` in a
`///` block and drops the second one's OPEN, then reports `unexpected command
endverbatim` at a line in the *filtered* stream that lands in unrelated code: 120
lines past the responsible comment, in the case that found it. Doxygen 1.17
renders the same input in silence, so a local run is not an oracle. `docs.yml`
now counts opens per block and names the offending block.

Established with a probe header of eight isolated constructs in one render: one
run, banner rules, `|` in prose, `|` inside a run, and emoji are all clean; only
two-runs-in-one-block errors. ⚠️ **Two earlier explanations were asserted before
being measured and are both wrong** — a setext heading swallowing the block, and
a bare `\|` opening a Markdown table. A `\|`-escaping change to
`tools/doxygen_filter.awk` had been committed on the second and written into the
filter's header as fact; **that commit is reverted**, and the filter's header now
records the measurement instead.

⚠️ The `/*! \file */` block is exempt from the count, measured rather than
assumed: `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.

C++ suite **486 → 534 checks**, 0 failures.

## A trait gradient at a collar potential the caller supplies

`leaf_gradient()` and `leaf_gradient_batch()` take `psi`, and evaluate there
Expand Down
23 changes: 18 additions & 5 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ the user-visible history.
| [#74](https://github.com/traitecoevo/phylloptim/issues/74) | 11f, 11g | The `stem_b` shortcut is undone by a rebuild once per observation | 11f's 24.5× is **2.4×** through `leaf_gradient_batch()`. Unmotivated until something frees a vulnerability curve; filed so the wrong figure is not quoted meanwhile |
| [#6](https://github.com/traitecoevo/phylloptim/issues/6) | 12 | Real-data calibration, then inversion | **The one with the most downstream** — it specifies what is left of #4, and doing it found #38, #40, #41 and #52. ⚠️ The *synthetic* vignette is done ([#53](https://github.com/traitecoevo/phylloptim/pull/53)) and **AD lost**: read 12a before repeating the comparison |
| [#52](https://github.com/traitecoevo/phylloptim/issues/52) | 12 | `leaf_gradient()` rebuilds a `Leaf` per call, with no way to pass one in | From #53. A third of a per-observation gradient's cost, and why item 12's "reuse one object" advice cannot be followed as written |
| [#7](https://github.com/traitecoevo/phylloptim/issues/7) | 13 | Energy balance, in priority order | Leaf-to-air VPD is the cheap win and is **not wired in**; free convection is not worth it |
| [#7](https://github.com/traitecoevo/phylloptim/issues/7) | 13 | Energy balance, in priority order | ⚠️ **Item 1, leaf-to-air VPD, is DONE.** What is left is temperature-dependent longwave (#28); free convection is not worth it |
| [#28](https://github.com/traitecoevo/phylloptim/issues/28) | 13 | Temperature-dependent outgoing longwave in the Penman-Monteith Rn | from plant #581 / #567 review |
| [#31](https://github.com/traitecoevo/phylloptim/issues/31) | 31 | `profit_psi_stem_TF` returns a plausible number below `psi_upstream` | Found writing the vignette. Profit is **discontinuous by 1.58** at the boundary |
| [#34](https://github.com/traitecoevo/phylloptim/issues/34) | 6d | Delete plant's `Leaf` bindings | Unblocked (plant #591 merged, and #33 is done). The hazard-7 payoff, and the only stage that can break plant |
Expand Down Expand Up @@ -86,12 +86,25 @@ The package carries three formulations and only one is usable:
| formulation | state |
|---|---|
| TF24 hydraulic gain-risk | production; the whole solve is built on it |
| Sperry et al. (2017) cost | present but **hardwired to `psi_soil_[0]`**, and nothing routes to it |
| Sperry et al. (2017) ProfitMax | **runnable**: `optimise_psi_stem_ProfitMax()` on the single-potential path, with both terms normalised as the paper defines them |
| Medlyn et al. (2011) USO | present but **bypasses the hydraulic solve altogether** |

So you cannot run the same drivers through two of them and compare, which is the
obvious thing to want. The goal is each as a first-class member, alongside Prentice
et al. (2014) least-cost and Cowan-Farquhar.
⚠️ **The Sperry row used to read "hardwired to `psi_soil_[0]`, and nothing routes to
it", and both halves were misleading.** Being evaluated at `psi_soil` is what Eqn 5
of Sperry (2017) *specifies* — the cost is defined against `k(psi_soil)` — so that
was never the defect. The defect was that `profit_psi_stem_Sperry` uses the
unnormalised form `A - lambda*cost` with `lambda_` a prescribed input that nothing
sets, and `lambda_` is not a constant: the equivalent value is
`|A|max/(k_soil - kcrit)`, which moves with every driver. `optimise_psi_stem_ProfitMax`
computes it and reports it in `lambda_`, so the two forms can be checked against
each other rather than assumed equivalent.

`optimise_psi_stem_TF()` is the same off-path solver for the TF24 cost, so **the same
drivers can now be run through both formulations on the same footing** — which this
section used to say was impossible. What is still missing is dispatch on the
PRODUCTION path (`find_root_collar_psi`, the collar first-order condition, the root
network); the goal is each as a first-class member there, alongside Prentice et al.
(2014) least-cost and Cowan-Farquhar.

**What should be pluggable is λ, not the cost function.** All of these maximise a
profit, so all satisfy `dA/dE = λ` and differ **only** in λ(state). Given λ, each
Expand Down
Loading
Loading