Skip to content

One molar mass of water, not two - #101

Merged
dfalster merged 3 commits into
masterfrom
fix/water-molar-mass
Aug 19, 2026
Merged

One molar mass of water, not two#101
dfalster merged 3 commits into
masterfrom
fix/water-molar-mass

Conversation

@dfalster

Copy link
Copy Markdown
Member

kg_to_mol_h2o was 55.4939 and kg_per_mol_h2o was 0.018015: two constants naming the same quantity in opposite directions, disagreeing by 0.0277%, used in opposite halves of the model. The demand side converted transpiration kg → mol with the first, the supply side converted uptake mol → kg with the second, so a flux pushed through both did not come back. The header recorded the discrepancy as deliberate, "kept at the historical 0.018015 to preserve results".

Which one was wrong is not a convention question, which is what let this be settled rather than argued. 55.4939 is 1/0.018020, i.e. a molar mass of 18.0200 g/mol; water's is 18.015 (2×1.008 + 15.999). So there is now one molar_mass_h2o = 0.018015 with both old names derived from it, reciprocal by construction. Both names are kept — plant using-declares both. Note this is the larger of the two possible moves; the other preserves more digits by adopting a molar mass water does not have.

Moves results: 3710 of 5184 golden cells, median 9.4e-05, of which 3377 are within 2× the constant's own 2.77e-04; largest absolute move anywhere 1.6e-03. Gradients 60 of 100, worst 2.9e-03. Split by cause in the first comment.

⚠️ plant's LinkingTo: phylloptim (>= 0.2.0) floor should become >= 0.4.0.

Closes #51. Unblocks #50 and #56, which both said this had to be settled first.

kg_to_mol_h2o was 55.4939 and kg_per_mol_h2o was 0.018015: two constants naming the
same quantity in opposite directions, disagreeing by 0.0277%, used in opposite halves
of the model. A flux converted kg -> mol on the demand side and mol -> kg on the
supply side did not come back.

Which was wrong is not a convention question. 55.4939 is 1/0.018020, a molar mass of
18.0200 g/mol; water's is 18.015 (2(1.008) + 15.999). So there is now one
molar_mass_h2o = 0.018015 and both old names derive from it, reciprocal by
construction. Both names are kept: plant using-declares both (#51).

Moves results: 3710 of 5184 golden cells, median 9.4e-05, of which 3377 within 2x the
constant's own 2.77e-04; largest absolute move 1.6e-03. Gradients 60 of 100, worst
2.9e-03. Full split in the first PR comment.

Version to 0.4.0, per the rule #99 set.

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

Copy link
Copy Markdown
Member Author

The arithmetic, in full

kg_to_mol_h2o  = 55.4939      -> implies M = 0.018019999 kg/mol = 18.0200 g/mol
kg_per_mol_h2o = 0.018015                                       = 18.0150 g/mol
                                 1/0.018015 = 55.509298 mol/kg
disagreement                                                      0.0277%

Water's molar mass from the standard atomic weights is 2(1.008) + 15.999 = 18.015 g/mol. So 0.018015 is the physical value, 55.4939 is not the reciprocal of anything water has, and the choice is forced.

Two consequences of taking the forced option rather than the digit-preserving one:

  • It is the larger move. Unifying on 0.018015 shifts the forward constant by +0.0277%; unifying on 55.4939 would have shifted the reverse one by the same amount but left the more used constant alone. The issue flagged this and asked for the convention to be stated rather than for the cheaper option to be taken.
  • It leaves plant's kg_per_mol_h2otf24_strategy.cpp:191, the water consumption rate, and the only live use of either name over there — multiplying by an unchanged constant. That is a convenience, not the reason. plant's results still move, because the leaf's operating point does.

Blast radius: operating_points.tsv

3710 of 5184 cells move. Median relative 9.43e-05.

relative move cells
< 1e-9 178
1e-9 – 1e-6 68
1e-6 – 1e-5 238
1e-5 – 1e-4 1412
1e-4 – 1e-3 1630
> 1e-3 184

The interpretation that matters: 3377 of the 3710 moved cells move by no more than 2× the constant's own 2.77e-04. That is the constant propagating, not the solver amplifying it, and it is what you would predict for a multiplicative change to the conductance conversion.

The 184 cells above 1e-3 are small denominators, not large changes. They sit at hot, dry, multi-layer points where the quantity itself is near zero; the largest absolute move anywhere in the file is 1.61e-03, and among those 184 the median absolute move is 1.5e-06. The five worst by relative measure:

psi_soil ppfd vpd T layers field old new rel
2 500 0.5 40 5 profit 0.008784 0.009019 2.67e-02
1 500 4.0 40 5 profit 0.002645 0.002715 2.64e-02
1 900 4.0 40 5 profit 0.004012 0.004083 1.76e-02
1 1500 4.0 40 5 profit 0.004478 0.004549 1.59e-02
2 100 4.0 25 1 profit −0.020600 −0.020333 1.29e-02

profit dominates that tail for a structural reason: it is benefit − cost, so where the two nearly cancel a small change in either is large relative to the difference. Point classification is unchanged — 198/42/48 at 25 °C, 160/80/48 at 40 °C, no point changed branch.

Blast radius: gradient_golden.tsv

60 of 100 cells; median 3.15e-04; worst 2.90e-03. By case: interior-5layer 2.90e-03, interior-1layer 1.96e-03, single-potential 8.24e-04, pinned-dry-3layer 4.13e-04, shutdown-1layer 0 (never reaches the collar solve).

⚠️ Worth stating plainly: 2.90e-03 is the same order as that file's own cross-platform disagreement (~2.3e-03). So on macOS/arm64 this change is a clean deliberate diff, and off it, this change is not cleanly separable from noise in the gradient file. The guide already warns the headroom there is one order rather than two; this consumes a good part of it.

Two things in the suite that this broke, and neither was the change

1. A hard-coded Hessian. test-gradient.R pinned g$H = -8.9561; now −8.9578, a move of 1.86e-04 — the constant propagating, same band as everything else. Updated with the magnitude recorded.

2. test-gradient.R's envelope-theorem test lost its operating point, and this is the interesting one.

That test asserts two things at once: the exact instrument says the profit gradient is stationary (|exact| < 1e-9) and a central difference says nothing of the kind (|fd| > 1e-5). It only means something at a point where the differenced instrument is noisy enough to see, and its comment says so: it pinned psi_soil = 0.5, vpd = 2, 3 layers as the worst of the interior rows at fd = 8.0e-05.

After #51 that point's fd collapsed to 1.5e-09 — a ratio of 24 between the two instruments, where the test needs orders. It failed on the fd side, i.e. the test stopped being able to demonstrate its own claim, rather than the claim becoming false.

I re-derived the point rather than adjusting the bound: swept all 198 interior rows of the golden grid, computed exact and fd at each, and took the largest |fd|. It is now psi_soil = 1.0, ppfd = 1500, vpd = 1.0, 1 layer, where exact = 7.8e-16 against fd = 2.7e-04 — a ratio of 3.5e+11, a considerably stronger demonstration than the point it replaces.

The general lesson is now in the test: the choice of operating point does not survive a results change, and a failure on the fd assertion means the point went quiet, not that the feature broke. The sweep recipe is written down next to it so the next person re-derives rather than relaxes. That was not known when the test was written — it reads as though the point were a fixed property of the model, and it is a property of the model's current numbers.

Verification

  • make -C tests/cpp512 checks, 0 failures (6 new, on the reciprocity), golden bit-identical after deliberate regeneration
  • make -C tests/cpp bench_solve bench_gradient — both build
  • make test-cmake — 2/2
  • devtools::test()1107 pass, 0 fail, 0 skip
  • The reciprocity test asserts kg_to_mol_h2o == 1.0 / molar_mass_h2o exactly, that kg_per_mol_h2o is the molar mass rather than a second copy, that a kg → mol → kg round trip returns its input to 4ε across five decades, and that the value is 18.015 g/mol and not the pre-kg_to_mol_h2o and kg_per_mol_h2o are deliberately not reciprocals — 0.028%, waiting on a baseline to unify against #51 55.4939. Written because "these two constants are reciprocals" is exactly the kind of claim that was already in this header and was false.

For the record: #100's fingerprint did its job on its first outing

tools/fingerprint.R reported 7a0f1e194fc8 -> e4f5a6ebd157 with the "the numbers moved" warning, unprompted, as part of regenerating. That is the mechanism working the first time it was given a real results change to notice.

The inline consumer program in cpp-tests.yml asserts a profit and a gradient at
fixed operating points, and #51 moved both: profit by 5.2e-04 against a 1e-6
tolerance, and dA/dvcmax_25 by 1.61e-05 against 1e-5. Two of the three C++ jobs
went red while `make -C tests/cpp`, `make -C tests/cpp bench` and `make
test-cmake` were all green locally -- which is the hazard the guide records about
this heredoc.

Both values now name the file to copy them from, and say what moved them.

Verified by extracting the two heredocs by line range and building the consumer
against a locally installed prefix, which is the only way to exercise it outside
CI: exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread inst/include/phylloptim/constants.hpp Outdated
// directions are reciprocal by construction and a round trip is the identity to
// within one rounding.
//
// ⚠️ THIS USED TO BE TWO INDEPENDENT NUMBERS THAT DISAGREED BY 0.0277%, and the

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Do we really need to leave this history here?

Comment thread .github/workflows/cpp-tests.yml Outdated
return std::fabs(l.profit_ - 2.5158434915102319) < 1e-6 ? 0 : 1;
//
// ⚠️ COPY THIS FROM THE GOLDEN FILE WHENEVER RESULTS MOVE. It was
// 2.5158434915102319 until #51 unified the water molar mass, which moved

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Do we really need to leave this history here?

Comment thread tests/cpp/test_leaf.cpp Outdated
// argmax correction and not rounding. profit_ and transpiration_ did not move
// at this tolerance -- profit_ because it is the maximum and therefore flat.
//
// #51, unifying the kg <-> mol water constants on the molar mass of water:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Do we really need to leave this history here?

Review feedback: comments should carry only what has enduring forward value --
the contract, the invariant, the hazard a future editor could reintroduce -- not
an account of what the values used to be or how far they moved. That belongs in
the PR body, the first PR comment and NEWS.md, all of which already have it.

Net -74 lines across five files. What survives is phrased as a rule: "both
directions derive from this, do not reintroduce a second literal", "copy this
value from the golden file when results move -- no local build compiles this
program", "re-derive the operating point rather than adjusting the bounds".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dfalster
dfalster merged commit 37bed51 into master Aug 19, 2026
7 checks passed
@dfalster
dfalster deleted the fix/water-molar-mass branch August 19, 2026 23:59
dfalster added a commit that referenced this pull request Aug 20, 2026
Stacked on #101 — base it there, not on `master`.

You asked what else moves results. This is the sweep: every physical
constant in `constants.hpp` checked against its accepted value, the same
class of defect #51 was.

**One was forced.** `gas_constant` was `8.314`; R = N_A k_B has been
*exact* since the 2019 SI redefinition, at 8.314462618153240. The
5.56e-05 correction earns a results change because the amplification is
not 5.56e-05 — the constant appears only as `Ea/(R*T)` with `Ea/RT` ~
24, so rates move ~1.3e-03.

**And it moves nothing at 25 °C, exactly** — predicted from the algebra
before measuring, then confirmed: **1728 of 1728 moved golden cells are
at 40 °C and the 25 °C block is byte-identical.** `gradient_golden.tsv`
does not move, and all 1107 R tests passed unaltered, the R suite having
no coverage above 25 °C. Median relative move 6.49e-05, largest absolute
2.04e-03.

**Two were flagged and deliberately left**, documented at the constant:
`gravity_head` (6.78e-04 off `rho_w*g`, but the water-density convention
is unstated — a 0.3% spread, four times the discrepancy) and
`latent_heat_vap` (its comment claimed 25 °C for a value that is ~21.5
°C; belongs to #28). Full audit table in the first comment.

Contributes to #51's audit; does not close an issue on its own.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
dfalster added a commit that referenced this pull request Aug 20, 2026
…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>
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.

kg_to_mol_h2o and kg_per_mol_h2o are deliberately not reciprocals — 0.028%, waiting on a baseline to unify against

1 participant