Skip to content

Closed form is prescribed-temperature only - #121

Open
dfalster wants to merge 1 commit into
masterfrom
fix/116-closed-form-deficit
Open

Closed form is prescribed-temperature only#121
dfalster wants to merge 1 commit into
masterfrom
fix/116-closed-form-deficit

Conversation

@dfalster

@dfalster dfalster commented Aug 20, 2026

Copy link
Copy Markdown
Member

Its four deficits divided by atm_vpd_ where #93 moved the live model to
vpd_leaf_. All four now read vpd_leaf_, which off the energy-balance path
IS atm_vpd_ exactly, so no number moves and the drift is gone by
construction. On that path both entry points now refuse: the deficit
depends on the leaf temperature, which depends on the transpiration they
solve for, so the form is not closed -- and the error, up to 178% in
assimilation against a 4000-point argmax, passes within_guard.

Dormant header, nothing calls it by default; both golden files
bit-identical. Closes #116.

@dfalster

Copy link
Copy Markdown
Member Author

What settled it

The issue offered three options and said option 3 (gate it) "is cheap and honest and may be the right answer". It is — but the measurement changes the framing from drift to wrong answer, and it disqualifies the mechanism the header points at as its safety net.

Closed form against a 4000-point argmax of the exact TF24 objective, single layer with the collar at zero (matching the header's own limitation), PPFD 900:

Gate ON:

Tair D_air A exact A closed error E exact E closed error within_guard
25 1.0 16.369 18.756 +14.6% 8.43e-05 6.32e-05 −25.0% pass
25 2.0 14.828 17.053 +15.0% 8.63e-05 7.46e-05 −13.5% pass
30 1.0 11.857 16.373 +38.1% 8.84e-05 6.20e-05 −29.9% pass
35 1.0 4.187 10.284 +145.6% 8.25e-05 5.29e-05 −35.8% pass
40 1.0 −3.759 2.941 +178.2% 2.71e-05 3.28e-05 +21.1% pass
45 −3.739 NaN NaN fail

Gate OFF (the accuracy the header claims, and gets):

Tair A error E error guard
25 0.0% … +0.6% 0.0% … +2.5% pass
30 −0.1% … +0.3% −0.9% … +1.1% pass
35 −0.9% … −1.2% −4.7% … −6.1% pass
40 −2.5% … −3.9% −17.3% … −18.1% pass

Two things follow.

1. within_guard passes on a 178% error. That is the finding, not the percentage. The guard tests ci/ca > 0.5 — a diagnostic for whether the leaf is near the wet-end limit the leading order is expanded about. It compares the solution against itself, so it is structurally incapable of seeing an input being wrong. The header's STATUS says accuracy "has to be established per use (see within_guard)"; on this path that instruction returns a false pass. So the protection has to be a refusal in the code, which is what this PR adds.

2. The sign is consistent and explains itself. The closed form divides by atm_vpd_ where the model divides by vpd_leaf_, which is 3–4× larger on this path. A smaller deficit means a higher ci/ca and more open stomata, so assimilation is over-estimated on every gate-on row while transpiration is under-estimated (a smaller D in E = 1.67e-3·A·D/(ca−ci)). Not noise.

Why not repair it instead

The substitution is a rename off the gate and is not one on it. The USO relation

ci/ca = xi/(xi + sqrt(D))

needs D before it can produce ci. On the energy-balance path D = vpd_leaf_(Tleaf), Tleaf = Tleaf(E), and E is what these expressions solve for.

Working through the issue's option 2 (linearise esat, as #97 has just done for the radiation term) — it gets further than I expected and still does not close. With vpd_leaf ≈ atm_vpd + Δ(Tair)·(Tleaf − Tair) and Tleaf − Tair = (Rn − λE)/g_total, the deficit becomes affine in E, so the demand relation E·(ca−ci) = k·A·D can be solved for E explicitly:

E = k·A·D0 / ((ca − ci) − k·A·m),   D0 = atm_vpd + Δ·Rn/g_total,  m = −Δ·λ/g_total

But sqrt(D) still appears in the USO relation that produced ci, and D now depends on E, which depends on ci. A scalar fixed point in ci survives. So option 2 recovers an explicit E and not an explicit solve — and at beta2 = 1/stem_c, the 47× case whose claim is that the leaf solve "has essentially vanished", there would suddenly be something to solve again. Recorded in the header rather than attempted.

Option 1 (one Picard pass over D) would work and roughly halves the speedup, which is the header's entire reason for existing. Both are left in #116's record and in the header.

The change

  • Four sites: atm_vpd_vpd_leaf_. Off the gate set_leaf_vpd returns atm_vpd_ exactly (that is the arithmetic Leaf-to-air VPD, and Sperry's ProfitMax #93 arranged to keep the golden file bit-identical), so this moves no number — and it removes the drift by construction, which is the point. A future change to the live model's deficit now shows up here as a compile-visible agreement rather than a silent divergence.
  • require_prescribed_temperature, shared by solve and solve_exact_beta2 so the two cannot drift apart. A hard util::stop, not a NaN: a NaN propagates into within_guard, which reports false, which a caller reads as "fall back to the exact solve" — the same silent behaviour as before, one step further from the cause.
  • The header's "not repaired here / filed rather than guessed" paragraph is replaced by what the code now does, the measured error table, and why the guard cannot see it. within_guard's own comment now says what it is not.

Tests

test_closed_form_is_prescribed_temperature_only:

  • both entry points throw with the gate on, including the beta2 = 1/stem_c explicit arm, and the message names use_energy_balance_ so the failure is self-explaining;
  • vpd_leaf_ == atm_vpd_ bit-for-bit off the gate — this is the assertion that keeps the header in step with the live model, and its absence is how the drift went unnoticed;
  • the closed form's conductance equals the live model's expression at the same (E, ci), bit-for-bit;
  • within_guard(l, sol) == (sol.ci/l.ca_ > 0.5) — the guard's scope pinned as an identity, so its role cannot be misread as an accuracy guarantee later.

677 C++ checks, 0 failures. Both golden files bit-identical. make bench builds. R testthat green.

Interaction with #120

#120 (temperature-dependent longwave) changes PM-path leaf temperatures, so the exact percentages above shift once it lands. The conclusion does not — the error is driven by the deficit ratio, not by the absolute temperature — and after this PR the path refuses anyway, so the numbers are historical rather than load-bearing. This branch is cut from master, i.e. pre-#120.

Its four deficits divided by atm_vpd_ where #93 moved the live model to
vpd_leaf_. All four now read vpd_leaf_, which off the energy-balance path
IS atm_vpd_ exactly, so no number moves and the drift is gone by
construction. On that path both entry points now refuse: the deficit
depends on the leaf temperature, which depends on the transpiration they
solve for, so the form is not closed -- and the error, up to 178% in
assimilation against a 4000-point argmax, passes within_guard.

Dormant header, nothing calls it by default; both golden files
bit-identical. Closes #116.
@dfalster

Copy link
Copy Markdown
Member Author

Renumbered 0.5.4 → 0.5.5 (patch), sequencing after #120. This one genuinely is a patch: no numbers move, and the header it touches is dormant.

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.

closed_form.hpp divides by the air deficit, not the leaf-to-air one (#93 drift)

1 participant