1.3.82
 
Loading...
Searching...
No Matches
selfTest.cpp
2
3#include <filesystem>
4#include <fstream>
5#include <sstream>
6
7#define DOCTEST_CONFIG_IMPLEMENT
8#include <doctest.h>
9#include "doctest_utils.h"
10
11using namespace std;
12using namespace helios;
13
14float err_tol = 1e-3;
15
16DOCTEST_TEST_CASE("PhotosynthesisModel Farquhar Model Type Setting") {
17 Context context_test;
18 uint UUID = context_test.addPatch(make_vec3(0, 0, 0), make_vec2(1, 1));
19 PhotosynthesisModel photomodel(&context_test);
20
21 DOCTEST_CHECK_NOTHROW(photomodel.setModelType_Farquhar());
22
23 FarquharModelCoefficients coeffs = photomodel.getFarquharModelCoefficients(UUID);
24 DOCTEST_CHECK(coeffs.Vcmax == -1.0f); // Default uninitialized value
25 DOCTEST_CHECK(coeffs.Jmax == -1.0f); // Default uninitialized value
26}
27
28DOCTEST_TEST_CASE("PhotosynthesisModel Light Response Curve - Farquhar Model") {
29 Context context_test;
30 uint UUID = context_test.addPatch(make_vec3(0, 0, 0), make_vec2(1, 1));
31 PhotosynthesisModel photomodel(&context_test);
32
33 float Qin[9] = {0, 50, 100, 200, 400, 800, 1200, 1500, 2000};
34 std::vector<float> AQ_expected{-2.37932, 8.29752, 12.5566, 16.2075, 16.7448, 16.7448, 16.7448, 16.7448, 16.7448};
35
36 // Set Farquhar model coefficients
38 fcoeffs.setVcmax(78.5f, 65.33f);
39 fcoeffs.setJmax(150.f, 43.54f);
40 fcoeffs.setRd(2.12f, 46.39f);
41 fcoeffs.setQuantumEfficiency_alpha(0.45f);
42
43 DOCTEST_CHECK_NOTHROW(photomodel.setModelCoefficients(fcoeffs));
44
45 for (int i = 0; i < 9; i++) {
46 DOCTEST_CHECK_NOTHROW(context_test.setPrimitiveData(UUID, "radiation_flux_PAR", Qin[i]));
47 DOCTEST_CHECK_NOTHROW(photomodel.run());
48
49 float A;
50 DOCTEST_CHECK_NOTHROW(context_test.getPrimitiveData(UUID, "net_photosynthesis", A));
51 DOCTEST_CHECK(A == doctest::Approx(AQ_expected[i]).epsilon(err_tol));
52 }
53}
54
55DOCTEST_TEST_CASE("PhotosynthesisModel CO2 Response Curve - Farquhar Model") {
56 Context context_test;
57 uint UUID = context_test.addPatch(make_vec3(0, 0, 0), make_vec2(1, 1));
58 PhotosynthesisModel photomodel(&context_test);
59
60 float CO2[9] = {100, 200, 300, 400, 500, 600, 700, 800, 1000};
61 std::vector<float> ACi_expected{1.72714, 7.32672, 12.4749, 17.199, 21.5281, 25.4923, 28.4271, 29.656, 31.3813};
62
64 fcoeffs.setVcmax(78.5f, 65.33f);
65 fcoeffs.setJmax(150.f, 43.54f);
66 fcoeffs.setRd(2.12f, 46.39f);
67 fcoeffs.setQuantumEfficiency_alpha(0.45f);
68
69 DOCTEST_CHECK_NOTHROW(photomodel.setModelCoefficients(fcoeffs));
70 DOCTEST_CHECK_NOTHROW(context_test.setPrimitiveData(UUID, "radiation_flux_PAR", 2000.0f)); // High light
71
72 for (int i = 0; i < 9; i++) {
73 DOCTEST_CHECK_NOTHROW(context_test.setPrimitiveData(UUID, "air_CO2", CO2[i]));
74 DOCTEST_CHECK_NOTHROW(photomodel.run());
75
76 float A;
77 DOCTEST_CHECK_NOTHROW(context_test.getPrimitiveData(UUID, "net_photosynthesis", A));
78 DOCTEST_CHECK(A == doctest::Approx(ACi_expected[i]).epsilon(err_tol));
79 }
80}
81
82DOCTEST_TEST_CASE("PhotosynthesisModel Temperature Response Curve - Farquhar Model") {
83 Context context_test;
84 uint UUID = context_test.addPatch(make_vec3(0, 0, 0), make_vec2(1, 1));
85 PhotosynthesisModel photomodel(&context_test);
86
87 float TL[7] = {270, 280, 290, 300, 310, 320, 330};
88 std::vector<float> AT_expected{3.87863, 8.74928, 14.4075, 17.199, 16.0928, 11.6431, 4.00821};
89
91 fcoeffs.setVcmax(78.5f, 65.33f);
92 fcoeffs.setJmax(150.f, 43.54f);
93 fcoeffs.setRd(2.12f, 46.39f);
94 fcoeffs.setQuantumEfficiency_alpha(0.45f);
95
96 DOCTEST_CHECK_NOTHROW(photomodel.setModelCoefficients(fcoeffs));
97 DOCTEST_CHECK_NOTHROW(context_test.setPrimitiveData(UUID, "radiation_flux_PAR", 2000.0f)); // High light
98 DOCTEST_CHECK_NOTHROW(context_test.setPrimitiveData(UUID, "air_CO2", 400.0f)); // Normal CO2
99
100 for (int i = 0; i < 7; i++) {
101 DOCTEST_CHECK_NOTHROW(context_test.setPrimitiveData(UUID, "temperature", TL[i]));
102 DOCTEST_CHECK_NOTHROW(photomodel.run());
103
104 float A;
105 DOCTEST_CHECK_NOTHROW(context_test.getPrimitiveData(UUID, "net_photosynthesis", A));
106 DOCTEST_CHECK(A == doctest::Approx(AT_expected[i]).epsilon(err_tol));
107 }
108}
109
110DOCTEST_TEST_CASE("PhotosynthesisModel Farquhar gm setter overloads") {
112
113 // Default is +infinity (sentinel: Cc ≡ Ci, no mesophyll limitation).
114 DOCTEST_CHECK(std::isinf(fq.getMesophyllConductance_gmTempResponse().value_at_25C));
115
116 SUBCASE("Constant gm") {
118 DOCTEST_CHECK(fq.getMesophyllConductance_gmTempResponse().value_at_25C == doctest::Approx(0.5f));
119 DOCTEST_CHECK(fq.getMesophyllConductance_gmTempResponse().dHa == doctest::Approx(0.f));
120 }
121 SUBCASE("Arrhenius gm") {
122 fq.setMesophyllConductance_gm(0.8f, 50.f);
123 DOCTEST_CHECK(fq.getMesophyllConductance_gmTempResponse().value_at_25C == doctest::Approx(0.8f));
124 DOCTEST_CHECK(fq.getMesophyllConductance_gmTempResponse().dHa == doctest::Approx(50.f));
125 }
126 SUBCASE("Peaked Arrhenius gm") {
127 fq.setMesophyllConductance_gm(0.4f, 45.f, 35.f);
128 DOCTEST_CHECK(fq.getMesophyllConductance_gmTempResponse().value_at_25C == doctest::Approx(0.4f));
129 DOCTEST_CHECK(fq.getMesophyllConductance_gmTempResponse().Topt == doctest::Approx(273.15f + 35.f));
130 }
131 SUBCASE("Peaked Arrhenius gm with explicit dHd") {
132 fq.setMesophyllConductance_gm(0.6f, 49.f, 40.f, 420.f);
133 DOCTEST_CHECK(fq.getMesophyllConductance_gmTempResponse().dHd == doctest::Approx(420.f));
134 }
135}
136
137DOCTEST_TEST_CASE("PhotosynthesisModel Farquhar default gm = infinity preserves legacy A") {
138 // Reruns the three existing C3 reference sweeps (Light, CO2, Temperature) with
139 // the default-infinite gm. The solver must short-circuit to the legacy Cc ≡ Ci
140 // path and return values bit-for-bit identical (to within 1e-5) to the existing
141 // hardcoded expected values. This guards the short-circuit in evaluateCi_Farquhar.
142 const float strict_tol = 1e-5f;
143
144 auto make_coeffs = []() {
146 fcoeffs.setVcmax(78.5f, 65.33f);
147 fcoeffs.setJmax(150.f, 43.54f);
148 fcoeffs.setRd(2.12f, 46.39f);
149 fcoeffs.setQuantumEfficiency_alpha(0.45f);
150 return fcoeffs;
151 };
152
153 SUBCASE("Light response") {
154 Context context_test;
155 uint UUID = context_test.addPatch(make_vec3(0, 0, 0), make_vec2(1, 1));
156 PhotosynthesisModel photomodel(&context_test);
157 float Qin[9] = {0, 50, 100, 200, 400, 800, 1200, 1500, 2000};
158 std::vector<float> AQ_expected{-2.37932f, 8.29752f, 12.5566f, 16.2075f, 16.7448f, 16.7448f, 16.7448f, 16.7448f, 16.7448f};
159 FarquharModelCoefficients fcoeffs = make_coeffs();
160 photomodel.setModelCoefficients(fcoeffs);
161 for (int i = 0; i < 9; i++) {
162 context_test.setPrimitiveData(UUID, "radiation_flux_PAR", Qin[i]);
163 photomodel.run();
164 float A;
165 context_test.getPrimitiveData(UUID, "net_photosynthesis", A);
166 DOCTEST_CHECK(A == doctest::Approx(AQ_expected[i]).epsilon(strict_tol));
167 }
168 }
169 SUBCASE("CO2 response") {
170 Context context_test;
171 uint UUID = context_test.addPatch(make_vec3(0, 0, 0), make_vec2(1, 1));
172 PhotosynthesisModel photomodel(&context_test);
173 float CO2[9] = {100, 200, 300, 400, 500, 600, 700, 800, 1000};
174 std::vector<float> ACi_expected{1.72714f, 7.32672f, 12.4749f, 17.199f, 21.5281f, 25.4923f, 28.4271f, 29.656f, 31.3813f};
175 FarquharModelCoefficients fcoeffs = make_coeffs();
176 photomodel.setModelCoefficients(fcoeffs);
177 context_test.setPrimitiveData(UUID, "radiation_flux_PAR", 2000.0f);
178 for (int i = 0; i < 9; i++) {
179 context_test.setPrimitiveData(UUID, "air_CO2", CO2[i]);
180 photomodel.run();
181 float A;
182 context_test.getPrimitiveData(UUID, "net_photosynthesis", A);
183 DOCTEST_CHECK(A == doctest::Approx(ACi_expected[i]).epsilon(strict_tol));
184 }
185 }
186 SUBCASE("Temperature response") {
187 Context context_test;
188 uint UUID = context_test.addPatch(make_vec3(0, 0, 0), make_vec2(1, 1));
189 PhotosynthesisModel photomodel(&context_test);
190 float TL[7] = {270, 280, 290, 300, 310, 320, 330};
191 std::vector<float> AT_expected{3.87863f, 8.74928f, 14.4075f, 17.199f, 16.0928f, 11.6431f, 4.00821f};
192 FarquharModelCoefficients fcoeffs = make_coeffs();
193 photomodel.setModelCoefficients(fcoeffs);
194 context_test.setPrimitiveData(UUID, "radiation_flux_PAR", 2000.0f);
195 context_test.setPrimitiveData(UUID, "air_CO2", 400.0f);
196 for (int i = 0; i < 7; i++) {
197 context_test.setPrimitiveData(UUID, "temperature", TL[i]);
198 photomodel.run();
199 float A;
200 context_test.getPrimitiveData(UUID, "net_photosynthesis", A);
201 DOCTEST_CHECK(A == doctest::Approx(AT_expected[i]).epsilon(strict_tol));
202 }
203 }
204}
205
206DOCTEST_TEST_CASE("PhotosynthesisModel Farquhar very large gm reproduces legacy") {
207 // Force the finite-gm quadratic path (gm = 1e9, below the 1e6 short-circuit threshold
208 // is gm > 1e6; 1e9 keeps it finite with std::isfinite but above the threshold — so to
209 // actually enter the quadratic branch we use gm slightly below threshold, 9e5). The
210 // quadratic branch should agree with the legacy branch to within 1e-3.
211 Context context_test;
212 uint UUID = context_test.addPatch(make_vec3(0, 0, 0), make_vec2(1, 1));
213 PhotosynthesisModel photo_legacy(&context_test);
214 PhotosynthesisModel photo_gm(&context_test);
215
216 FarquharModelCoefficients legacy_coeffs;
217 legacy_coeffs.setVcmax(80.f, 65.33f);
218 legacy_coeffs.setJmax(150.f, 43.54f);
219 legacy_coeffs.setRd(2.0f, 46.39f);
220 legacy_coeffs.setQuantumEfficiency_alpha(0.45f);
221
222 FarquharModelCoefficients gm_coeffs = legacy_coeffs;
223 gm_coeffs.setMesophyllConductance_gm(9.0e5f); // just below 1e6 guard → enters quadratic branch
224
225 photo_legacy.setModelCoefficients(legacy_coeffs);
226 photo_gm.setModelCoefficients(gm_coeffs);
227
228 context_test.setPrimitiveData(UUID, "radiation_flux_PAR", 2000.f);
229 float Ci_sweep[5] = {200.f, 400.f, 600.f, 800.f, 1000.f};
230 for (float CO2: Ci_sweep) {
231 context_test.setPrimitiveData(UUID, "air_CO2", CO2);
232 photo_legacy.run();
233 float A_legacy;
234 context_test.getPrimitiveData(UUID, "net_photosynthesis", A_legacy);
235 photo_gm.run();
236 float A_gm;
237 context_test.getPrimitiveData(UUID, "net_photosynthesis", A_gm);
238 DOCTEST_CHECK(A_gm == doctest::Approx(A_legacy).epsilon(1e-3));
239 }
240}
241
242DOCTEST_TEST_CASE("PhotosynthesisModel Farquhar gm finite regression") {
243 // Regression anchors for the finite-gm quadratic branch with gm = 0.2 mol/m^2/s/bar.
244 // Expected A values are produced by this implementation — not independent reference
245 // data — and serve only to detect future unintended drift.
246 Context context_test;
247 uint UUID = context_test.addPatch(make_vec3(0, 0, 0), make_vec2(1, 1));
248 PhotosynthesisModel photomodel(&context_test);
249
251 fcoeffs.setVcmax(80.f, 65.33f);
252 fcoeffs.setJmax(150.f, 43.54f);
253 fcoeffs.setRd(2.f, 46.39f);
254 fcoeffs.setQuantumEfficiency_alpha(0.45f);
255 fcoeffs.setMesophyllConductance_gm(0.2f);
256
257 photomodel.setModelCoefficients(fcoeffs);
258 context_test.setPrimitiveData(UUID, "radiation_flux_PAR", 2000.f);
259
260 float CO2_sweep[6] = {100.f, 200.f, 400.f, 600.f, 800.f, 1000.f};
261 // Regression anchors: values produced by the current quadratic implementation.
262 // A is strictly less than the legacy (infinite-gm) value at each point — mesophyll
263 // diffusion limitation reduces net assimilation as expected.
264 float A_expected[6] = {1.43302f, 5.90503f, 14.192f, 21.6148f, 27.8598f, 30.2768f};
265 for (int i = 0; i < 6; ++i) {
266 context_test.setPrimitiveData(UUID, "air_CO2", CO2_sweep[i]);
267 photomodel.run();
268 float A;
269 context_test.getPrimitiveData(UUID, "net_photosynthesis", A);
270 DOCTEST_CHECK(A == doctest::Approx(A_expected[i]).epsilon(1e-3));
271 }
272}
273
274DOCTEST_TEST_CASE("PhotosynthesisModel Empirical Model Coefficients") {
275 Context context_test;
276 uint UUID = context_test.addPatch(make_vec3(0, 0, 0), make_vec2(1, 1));
277 PhotosynthesisModel photomodel(&context_test);
278
280 emp_coeffs.Asat = 20.0f;
281
282 DOCTEST_CHECK_NOTHROW(photomodel.setModelCoefficients(emp_coeffs));
283
284 EmpiricalModelCoefficients retrieved_coeffs = photomodel.getEmpiricalModelCoefficients(UUID);
285 DOCTEST_CHECK(retrieved_coeffs.Asat == doctest::Approx(20.0f).epsilon(err_tol));
286}
287
288DOCTEST_TEST_CASE("PhotosynthesisModel Empirical Model Coefficients with UUIDs") {
289 Context context_test;
290 uint UUID = context_test.addPatch(make_vec3(0, 0, 0), make_vec2(1, 1));
291 PhotosynthesisModel photomodel(&context_test);
292
294 emp_coeffs.Asat = 20.0f;
295 std::vector<uint> UUIDs = {UUID};
296
297 DOCTEST_CHECK_NOTHROW(photomodel.setModelCoefficients(emp_coeffs, UUIDs));
298
299 EmpiricalModelCoefficients retrieved_coeffs = photomodel.getEmpiricalModelCoefficients(UUID);
300 DOCTEST_CHECK(retrieved_coeffs.Asat == doctest::Approx(20.0f).epsilon(err_tol));
301}
302
303DOCTEST_TEST_CASE("PhotosynthesisModel Farquhar Model Coefficients with UUIDs") {
304 Context context_test;
305 uint UUID = context_test.addPatch(make_vec3(0, 0, 0), make_vec2(1, 1));
306 PhotosynthesisModel photomodel(&context_test);
307
308 FarquharModelCoefficients farq_coeffs;
309 farq_coeffs.Vcmax = 90.0f;
310 std::vector<uint> UUIDs = {UUID};
311
312 DOCTEST_CHECK_NOTHROW(photomodel.setModelCoefficients(farq_coeffs, UUIDs));
313
314 FarquharModelCoefficients retrieved_coeffs = photomodel.getFarquharModelCoefficients(UUID);
315 DOCTEST_CHECK(retrieved_coeffs.Vcmax == doctest::Approx(90.0f).epsilon(err_tol));
316}
317
318DOCTEST_TEST_CASE("PhotosynthesisModel Invalid Input Handling") {
319 Context context_test;
320 uint UUID = context_test.addPatch(make_vec3(0, 0, 0), make_vec2(1, 1));
321 PhotosynthesisModel photomodel(&context_test);
322
323 // Set up model with valid coefficients
325 DOCTEST_CHECK_NOTHROW(photomodel.setModelCoefficients(fcoeffs));
326
327 // Set invalid inputs
328 DOCTEST_CHECK_NOTHROW(context_test.setPrimitiveData(UUID, "radiation_flux_PAR", -50.0f)); // Should be clipped to 0
329 DOCTEST_CHECK_NOTHROW(context_test.setPrimitiveData(UUID, "temperature", 150.0f)); // Should be replaced with 300K default
330 DOCTEST_CHECK_NOTHROW(context_test.setPrimitiveData(UUID, "air_CO2", -10.0f)); // Should be clipped to 0
331
332 // Disable messages to avoid warning output about invalid inputs
333 photomodel.disableMessages();
334 DOCTEST_CHECK_NOTHROW(photomodel.run());
335 photomodel.enableMessages();
336
337 float A_invalid;
338 DOCTEST_CHECK_NOTHROW(context_test.getPrimitiveData(UUID, "net_photosynthesis", A_invalid));
339
340 // Compute expected A using default values (PAR=0, TL=300K, CO2=390ppm)
341 DOCTEST_CHECK_NOTHROW(context_test.setPrimitiveData(UUID, "radiation_flux_PAR", 0.0f));
342 DOCTEST_CHECK_NOTHROW(context_test.setPrimitiveData(UUID, "temperature", 300.0f));
343 DOCTEST_CHECK_NOTHROW(context_test.setPrimitiveData(UUID, "air_CO2", 390.0f));
344
345 DOCTEST_CHECK_NOTHROW(photomodel.run());
346
347 float A_expected;
348 DOCTEST_CHECK_NOTHROW(context_test.getPrimitiveData(UUID, "net_photosynthesis", A_expected));
349
350 DOCTEST_CHECK(A_invalid == doctest::Approx(A_expected).epsilon(err_tol));
351}
352
353DOCTEST_TEST_CASE("PhotosynthesisModel Optional Output Primitive Data") {
354 Context context_test;
355 uint UUID = context_test.addPatch(make_vec3(0, 0, 0), make_vec2(1, 1));
356 PhotosynthesisModel photomodel(&context_test);
357
358 DOCTEST_CHECK_NOTHROW(photomodel.optionalOutputPrimitiveData("Ci"));
359 DOCTEST_CHECK_NOTHROW(photomodel.optionalOutputPrimitiveData("limitation_state"));
360 DOCTEST_CHECK_NOTHROW(photomodel.optionalOutputPrimitiveData("Gamma_CO2"));
361 DOCTEST_CHECK_NOTHROW(photomodel.optionalOutputPrimitiveData("electron_transport_ratio"));
362
363 DOCTEST_CHECK_NOTHROW(context_test.setPrimitiveData(UUID, "radiation_flux_PAR", 400.0f));
364 DOCTEST_CHECK_NOTHROW(photomodel.run());
365
366 float Ci, Gamma, J_over_Jmax;
367 int limitation_state;
368 DOCTEST_CHECK_NOTHROW(context_test.getPrimitiveData(UUID, "Ci", Ci));
369 DOCTEST_CHECK_NOTHROW(context_test.getPrimitiveData(UUID, "limitation_state", limitation_state));
370 DOCTEST_CHECK_NOTHROW(context_test.getPrimitiveData(UUID, "Gamma_CO2", Gamma));
371 DOCTEST_CHECK_NOTHROW(context_test.getPrimitiveData(UUID, "electron_transport_ratio", J_over_Jmax));
372
373 DOCTEST_CHECK(Ci != 0.0f);
374 DOCTEST_CHECK(Gamma != 0.0f);
375 DOCTEST_CHECK(J_over_Jmax >= 0.0f);
376 DOCTEST_CHECK(J_over_Jmax <= 1.0f);
377}
378
379DOCTEST_TEST_CASE("PhotosynthesisModel Print Default Value Report") {
380 Context context_test;
381 PhotosynthesisModel photomodel(&context_test);
382
383 // Disable messages to avoid verbose output
384 photomodel.disableMessages();
385
386 // Capture stdout from default value report
387 capture_cout cout_buffer;
388 DOCTEST_CHECK_NOTHROW(photomodel.printDefaultValueReport());
389
390 // Verify the report was generated
391 std::string report_output = cout_buffer.get_captured_output();
392 DOCTEST_CHECK_MESSAGE(report_output.find("Photosynthesis Model Default Value Report") != std::string::npos, "Default value report should be generated");
393
394 // Re-enable messages
395 photomodel.enableMessages();
396}
397
398DOCTEST_TEST_CASE("PhotosynthesisModel Empirical Model Type Setting") {
399 Context context_test;
400 uint UUID = context_test.addPatch(make_vec3(0, 0, 0), make_vec2(1, 1));
401 PhotosynthesisModel photomodel(&context_test);
402
403 DOCTEST_CHECK_NOTHROW(photomodel.setModelType_Empirical());
404
405 // After setting empirical model, should be able to get empirical coefficients
406 EmpiricalModelCoefficients coeffs = photomodel.getEmpiricalModelCoefficients(UUID);
407 DOCTEST_CHECK(coeffs.Asat > 0); // Should have positive default values
408}
409
410DOCTEST_TEST_CASE("PhotosynthesisModel Vector Coefficients with Size Mismatch") {
411 Context context_test;
412 uint UUID1 = context_test.addPatch(make_vec3(0, 0, 0), make_vec2(1, 1));
413 uint UUID2 = context_test.addPatch(make_vec3(1, 0, 0), make_vec2(1, 1));
414 PhotosynthesisModel photomodel(&context_test);
415
416 std::vector<FarquharModelCoefficients> coeffs_vector(2);
417 std::vector<uint> UUIDs = {UUID1}; // Only one UUID, but 2 coefficients
418
419 // Capture the warning message from stderr
420 capture_cerr cerr_buffer;
421
422 // This should print a warning and return without setting coefficients
423 DOCTEST_CHECK_NOTHROW(photomodel.setModelCoefficients(coeffs_vector, UUIDs));
424
425 // Verify we captured the expected warning message
426 std::string captured_warnings = cerr_buffer.get_captured_output();
427 DOCTEST_CHECK_MESSAGE(captured_warnings.find("number of model coefficients (2) does not match number of UUIDs (1)") != std::string::npos, "Size mismatch should produce warning message");
428}
429
430DOCTEST_TEST_CASE("PhotosynthesisModel Vector Coefficients Matching Size") {
431 Context context_test;
432 uint UUID1 = context_test.addPatch(make_vec3(0, 0, 0), make_vec2(1, 1));
433 uint UUID2 = context_test.addPatch(make_vec3(1, 0, 0), make_vec2(1, 1));
434 PhotosynthesisModel photomodel(&context_test);
435
436 std::vector<FarquharModelCoefficients> coeffs_vector(2);
437 coeffs_vector[0].setVcmax(80.0f);
438 coeffs_vector[1].setVcmax(90.0f);
439 std::vector<uint> UUIDs = {UUID1, UUID2};
440
441 DOCTEST_CHECK_NOTHROW(photomodel.setModelCoefficients(coeffs_vector, UUIDs));
442
443 FarquharModelCoefficients retrieved1 = photomodel.getFarquharModelCoefficients(UUID1);
444 FarquharModelCoefficients retrieved2 = photomodel.getFarquharModelCoefficients(UUID2);
445 DOCTEST_CHECK(retrieved1.getVcmaxTempResponse().value_at_25C == doctest::Approx(80.0f).epsilon(err_tol));
446 DOCTEST_CHECK(retrieved2.getVcmaxTempResponse().value_at_25C == doctest::Approx(90.0f).epsilon(err_tol));
447}
448
449DOCTEST_TEST_CASE("PhotosynthesisModel Library Species") {
450 Context context_test;
451 uint UUID = context_test.addPatch(make_vec3(0, 0, 0), make_vec2(1, 1));
452 PhotosynthesisModel photomodel(&context_test);
453
454 // Disable messages to avoid verbose output about setting coefficients
455 photomodel.disableMessages();
456
457 // Test getting coefficients from library
458 DOCTEST_CHECK_NOTHROW(photomodel.getFarquharCoefficientsFromLibrary("Almond"));
459
460 // Test setting coefficients from library for all primitives
461 DOCTEST_CHECK_NOTHROW(photomodel.setFarquharCoefficientsFromLibrary("Almond"));
462
463 // Test setting coefficients from library for specific UUIDs
464 std::vector<uint> UUIDs = {UUID};
465 DOCTEST_CHECK_NOTHROW(photomodel.setFarquharCoefficientsFromLibrary("Apple", UUIDs));
466
467 // Re-enable messages
468 photomodel.enableMessages();
469
470 // Verify coefficients were set correctly
471 FarquharModelCoefficients almond_coeffs = photomodel.getFarquharModelCoefficients(UUID);
472 DOCTEST_CHECK(almond_coeffs.getVcmaxTempResponse().value_at_25C == doctest::Approx(101.08f).epsilon(err_tol)); // Apple Vcmax
473}
474
475DOCTEST_TEST_CASE("PhotosynthesisModel Message Control") {
476 Context context_test;
477 PhotosynthesisModel photomodel(&context_test);
478
479 // Test disabling and enabling messages
480 DOCTEST_CHECK_NOTHROW(photomodel.disableMessages());
481 DOCTEST_CHECK_NOTHROW(photomodel.enableMessages());
482}
483
484DOCTEST_TEST_CASE("PhotosynthesisModel Default Value Reports") {
485 Context context_test;
486 uint UUID1 = context_test.addPatch(make_vec3(0, 0, 0), make_vec2(1, 1));
487 uint UUID2 = context_test.addPatch(make_vec3(1, 0, 0), make_vec2(1, 1));
488 PhotosynthesisModel photomodel(&context_test);
489
490 // Disable messages to avoid verbose output
491 photomodel.disableMessages();
492
493 // Capture stdout from default value report for all primitives
494 capture_cout cout_buffer_all;
495 DOCTEST_CHECK_NOTHROW(photomodel.printDefaultValueReport());
496 std::string report_all = cout_buffer_all.get_captured_output();
497 DOCTEST_CHECK_MESSAGE(report_all.find("Photosynthesis Model Default Value Report") != std::string::npos, "Default value report should be generated for all primitives");
498
499 // Capture stdout from default value report for specific UUIDs
500 capture_cout cout_buffer_subset;
501 std::vector<uint> UUIDs = {UUID1, UUID2};
502 DOCTEST_CHECK_NOTHROW(photomodel.printDefaultValueReport(UUIDs));
503 std::string report_subset = cout_buffer_subset.get_captured_output();
504 DOCTEST_CHECK_MESSAGE(report_subset.find("Photosynthesis Model Default Value Report") != std::string::npos, "Default value report should be generated for specific UUIDs");
505
506 // Re-enable messages
507 photomodel.enableMessages();
508}
509
510DOCTEST_TEST_CASE("PhotosynthesisModel Run with Specific UUIDs") {
511 Context context_test;
512 uint UUID1 = context_test.addPatch(make_vec3(0, 0, 0), make_vec2(1, 1));
513 uint UUID2 = context_test.addPatch(make_vec3(1, 0, 0), make_vec2(1, 1));
514 PhotosynthesisModel photomodel(&context_test);
515
516 // Set up some basic data
517 context_test.setPrimitiveData(UUID1, "radiation_flux_PAR", 500.0f);
518 context_test.setPrimitiveData(UUID2, "radiation_flux_PAR", 600.0f);
519
520 // Disable messages to avoid default value reports
521 photomodel.disableMessages();
522
523 // Test running model on specific UUIDs
524 std::vector<uint> UUIDs = {UUID1};
525 DOCTEST_CHECK_NOTHROW(photomodel.run(UUIDs));
526
527 float A1, A2;
528 DOCTEST_CHECK_NOTHROW(context_test.getPrimitiveData(UUID1, "net_photosynthesis", A1));
529
530 // UUID2 shouldn't have been processed by run(UUIDs), but run() without arguments processes all
531 DOCTEST_CHECK_NOTHROW(photomodel.run());
532 DOCTEST_CHECK_NOTHROW(context_test.getPrimitiveData(UUID2, "net_photosynthesis", A2));
533
534 // Re-enable messages
535 photomodel.enableMessages();
536
537 DOCTEST_CHECK(A1 != 0.0f);
538 DOCTEST_CHECK(A2 != 0.0f);
539}
540
541DOCTEST_TEST_CASE("PhotosyntheticTemperatureResponseParameters Constructors") {
542 // Test default constructor
544 DOCTEST_CHECK(params_default.value_at_25C == doctest::Approx(100.0f).epsilon(err_tol));
545 DOCTEST_CHECK(params_default.dHa == doctest::Approx(60.0f).epsilon(err_tol));
546 DOCTEST_CHECK(params_default.dHd == doctest::Approx(600.0f).epsilon(err_tol));
547 DOCTEST_CHECK(params_default.Topt == doctest::Approx(10000.0f).epsilon(err_tol));
548
549 // Test single parameter constructor with value only
551 DOCTEST_CHECK(params_single.value_at_25C == doctest::Approx(75.0f).epsilon(err_tol));
552 DOCTEST_CHECK(params_single.dHa == doctest::Approx(0.0f).epsilon(err_tol));
553
554 // Test constructor with value and negative dHa (should trigger else branch)
555 PhotosyntheticTemperatureResponseParameters params_negative(80.0f, -10.0f);
556 DOCTEST_CHECK(params_negative.value_at_25C == doctest::Approx(80.0f).epsilon(err_tol));
557 DOCTEST_CHECK(params_negative.dHa == doctest::Approx(-10.0f).epsilon(err_tol));
558 DOCTEST_CHECK(params_negative.dHd == doctest::Approx(600.0f).epsilon(err_tol)); // Should use default
559
560 // Test constructor with value, dHa, and optimum temperature
561 PhotosyntheticTemperatureResponseParameters params_three(90.0f, 50.0f, 35.0f);
562 DOCTEST_CHECK(params_three.value_at_25C == doctest::Approx(90.0f).epsilon(err_tol));
563 DOCTEST_CHECK(params_three.dHa == doctest::Approx(50.0f).epsilon(err_tol));
564 DOCTEST_CHECK(params_three.dHd == doctest::Approx(500.0f).epsilon(err_tol)); // 10 * dHa
565 DOCTEST_CHECK(params_three.Topt == doctest::Approx(273.15f + 35.0f).epsilon(err_tol));
566
567 // Test constructor with value, negative dHa, and optimum temperature (triggers else branch)
568 PhotosyntheticTemperatureResponseParameters params_three_neg(85.0f, -15.0f, 32.0f);
569 DOCTEST_CHECK(params_three_neg.value_at_25C == doctest::Approx(85.0f).epsilon(err_tol));
570 DOCTEST_CHECK(params_three_neg.dHa == doctest::Approx(-15.0f).epsilon(err_tol));
571 DOCTEST_CHECK(params_three_neg.dHd == doctest::Approx(600.0f).epsilon(err_tol)); // Should use default 600
572 DOCTEST_CHECK(params_three_neg.Topt == doctest::Approx(273.15f + 32.0f).epsilon(err_tol));
573
574 // Test full constructor with all parameters
575 PhotosyntheticTemperatureResponseParameters params_full(95.0f, 45.0f, 30.0f, 400.0f);
576 DOCTEST_CHECK(params_full.value_at_25C == doctest::Approx(95.0f).epsilon(err_tol));
577 DOCTEST_CHECK(params_full.dHa == doctest::Approx(45.0f).epsilon(err_tol));
578 DOCTEST_CHECK(params_full.dHd == doctest::Approx(400.0f).epsilon(err_tol));
579 DOCTEST_CHECK(params_full.Topt == doctest::Approx(273.15f + 30.0f).epsilon(err_tol));
580}
581
582DOCTEST_TEST_CASE("FarquharModelCoefficients Temperature Response Methods") {
583 Context context_test;
584 uint UUID = context_test.addPatch(make_vec3(0, 0, 0), make_vec2(1, 1));
585 PhotosynthesisModel photomodel(&context_test);
586
588
589 // Test all the temperature response setter methods and getters
590
591 // Test Jmax with optimum temperature (3 parameter version)
592 coeffs.setJmax(150.0f, 43.5f, 35.0f);
593 PhotosyntheticTemperatureResponseParameters jmax_params = coeffs.getJmaxTempResponse();
594 DOCTEST_CHECK(jmax_params.value_at_25C == doctest::Approx(150.0f).epsilon(err_tol));
595 DOCTEST_CHECK(jmax_params.dHa == doctest::Approx(43.5f).epsilon(err_tol));
596
597 // Test Jmax with optimum temperature and specified rate of decrease (4 parameter version)
598 coeffs.setJmax(160.0f, 45.0f, 40.0f, 500.0f);
599 jmax_params = coeffs.getJmaxTempResponse();
600 DOCTEST_CHECK(jmax_params.dHd == doctest::Approx(500.0f).epsilon(err_tol));
601
602 // Test Vcmax with optimum temperature (3 parameter version)
603 coeffs.setVcmax(100.0f, 65.0f, 32.0f);
604 PhotosyntheticTemperatureResponseParameters vcmax_params = coeffs.getVcmaxTempResponse();
605 DOCTEST_CHECK(vcmax_params.value_at_25C == doctest::Approx(100.0f).epsilon(err_tol));
606
607 // Test Vcmax with optimum temperature and specified rate of decrease (4 parameter version)
608 coeffs.setVcmax(110.0f, 70.0f, 38.0f, 600.0f);
609 vcmax_params = coeffs.getVcmaxTempResponse();
610 DOCTEST_CHECK(vcmax_params.dHd == doctest::Approx(600.0f).epsilon(err_tol));
611
612 // Test TPU methods
613 coeffs.setTPU(8.0f);
614 PhotosyntheticTemperatureResponseParameters tpu_params = coeffs.getTPUTempResponse();
615 DOCTEST_CHECK(tpu_params.value_at_25C == doctest::Approx(8.0f).epsilon(err_tol));
616 DOCTEST_CHECK(coeffs.TPU_flag == 1);
617
618 coeffs.setTPU(9.0f, 25.0f);
619 tpu_params = coeffs.getTPUTempResponse();
620 DOCTEST_CHECK(tpu_params.dHa == doctest::Approx(25.0f).epsilon(err_tol));
621
622 coeffs.setTPU(10.0f, 30.0f, 33.0f);
623 tpu_params = coeffs.getTPUTempResponse();
624 DOCTEST_CHECK(tpu_params.Topt == doctest::Approx(273.15f + 33.0f).epsilon(err_tol));
625
626 coeffs.setTPU(11.0f, 35.0f, 36.0f, 450.0f);
627 tpu_params = coeffs.getTPUTempResponse();
628 DOCTEST_CHECK(tpu_params.dHd == doctest::Approx(450.0f).epsilon(err_tol));
629
630 // Test Rd methods
631 coeffs.setRd(2.5f, 46.0f, 34.0f);
632 PhotosyntheticTemperatureResponseParameters rd_params = coeffs.getRdTempResponse();
633 DOCTEST_CHECK(rd_params.value_at_25C == doctest::Approx(2.5f).epsilon(err_tol));
634
635 coeffs.setRd(2.8f, 48.0f, 37.0f, 480.0f);
636 rd_params = coeffs.getRdTempResponse();
637 DOCTEST_CHECK(rd_params.dHd == doctest::Approx(480.0f).epsilon(err_tol));
638
639 // Test quantum efficiency alpha methods
640 coeffs.setQuantumEfficiency_alpha(0.4f, 20.0f);
641 PhotosyntheticTemperatureResponseParameters alpha_params = coeffs.getQuantumEfficiencyTempResponse();
642 DOCTEST_CHECK(alpha_params.dHa == doctest::Approx(20.0f).epsilon(err_tol));
643
644 coeffs.setQuantumEfficiency_alpha(0.5f, 25.0f, 30.0f);
645 alpha_params = coeffs.getQuantumEfficiencyTempResponse();
646 DOCTEST_CHECK(alpha_params.Topt == doctest::Approx(273.15f + 30.0f).epsilon(err_tol));
647
648 coeffs.setQuantumEfficiency_alpha(0.6f, 30.0f, 32.0f, 350.0f);
649 alpha_params = coeffs.getQuantumEfficiencyTempResponse();
650 DOCTEST_CHECK(alpha_params.dHd == doctest::Approx(350.0f).epsilon(err_tol));
651
652 // Test light response curvature theta methods
653 coeffs.setLightResponseCurvature_theta(0.1f, 15.0f);
654 PhotosyntheticTemperatureResponseParameters theta_params = coeffs.getLightResponseCurvatureTempResponse();
655 DOCTEST_CHECK(theta_params.dHa == doctest::Approx(15.0f).epsilon(err_tol));
656
657 coeffs.setLightResponseCurvature_theta(0.2f, 20.0f, 35.0f);
658 theta_params = coeffs.getLightResponseCurvatureTempResponse();
659 DOCTEST_CHECK(theta_params.Topt == doctest::Approx(273.15f + 35.0f).epsilon(err_tol));
660
661 coeffs.setLightResponseCurvature_theta(0.3f, 25.0f, 38.0f, 400.0f);
662 theta_params = coeffs.getLightResponseCurvatureTempResponse();
663 DOCTEST_CHECK(theta_params.dHd == doctest::Approx(400.0f).epsilon(err_tol));
664}
665
666DOCTEST_TEST_CASE("PhotosynthesisModel Edge Cases and Error Conditions") {
667 Context context_test;
668 uint UUID = context_test.addPatch(make_vec3(0, 0, 0), make_vec2(1, 1));
669 PhotosynthesisModel photomodel(&context_test);
670
671 // Disable messages to avoid warning output
672 photomodel.disableMessages();
673
674 // Test with unknown optional output primitive data
675 DOCTEST_CHECK_NOTHROW(photomodel.optionalOutputPrimitiveData("unknown_primitive"));
676
677 // Test running model with no coefficients set (should use defaults)
678 DOCTEST_CHECK_NOTHROW(photomodel.run());
679
680 // Test getting coefficients for empirical model when none are set
681 EmpiricalModelCoefficients emp_default = photomodel.getEmpiricalModelCoefficients(UUID);
682 DOCTEST_CHECK(emp_default.Asat == doctest::Approx(18.18f).epsilon(err_tol)); // Default value
683
684 // Test getting Farquhar coefficients when none are set
685 FarquharModelCoefficients farq_default = photomodel.getFarquharModelCoefficients(UUID);
686 DOCTEST_CHECK(farq_default.Vcmax == doctest::Approx(-1.0f).epsilon(err_tol)); // Uninitialized value
687
688 // Test with extreme input values that trigger warnings - capture stderr
689 // Re-enable messages to allow warnings to be produced
690 photomodel.enableMessages();
691
692 context_test.setPrimitiveData(UUID, "radiation_flux_PAR", -100.0f); // Negative PAR
693 context_test.setPrimitiveData(UUID, "temperature", 150.0f); // Very low temperature
694 context_test.setPrimitiveData(UUID, "air_CO2", -50.0f); // Negative CO2
695 context_test.setPrimitiveData(UUID, "moisture_conductance", -0.1f); // Negative moisture conductance
696 context_test.setPrimitiveData(UUID, "boundarylayer_conductance", -1.0f); // Negative boundary layer conductance
697
698 // Capture warnings from extreme conditions (expected behavior)
699 capture_cerr cerr_buffer;
700 DOCTEST_CHECK_NOTHROW(photomodel.run());
701
702 // Verify we captured convergence warnings (expected for these extreme conditions)
703 std::string captured_warnings = cerr_buffer.get_captured_output();
704 DOCTEST_CHECK_MESSAGE(captured_warnings.find("Photosynthesis model failed to converge") != std::string::npos, "Extreme conditions should produce convergence warnings");
705
706 // Verify the model still produces reasonable output despite bad inputs
707 float A;
708 DOCTEST_CHECK_NOTHROW(context_test.getPrimitiveData(UUID, "net_photosynthesis", A));
709 DOCTEST_CHECK(!std::isnan(A)); // Should not be NaN
710}
711
712DOCTEST_TEST_CASE("FarquharModelCoefficients TPU Testing") {
713 Context context_test;
714 PhotosynthesisModel photomodel(&context_test);
715
716 // Test that TPU flag is properly set and cleared
718 DOCTEST_CHECK(coeffs.TPU_flag == 0); // Should start as 0
719
720 // Test all TPU setting methods set the flag to 1
721 coeffs.setTPU(5.0f);
722 DOCTEST_CHECK(coeffs.TPU_flag == 1);
723
724 coeffs.setTPU(6.0f, 20.0f);
725 DOCTEST_CHECK(coeffs.TPU_flag == 1);
726
727 coeffs.setTPU(7.0f, 25.0f, 30.0f);
728 DOCTEST_CHECK(coeffs.TPU_flag == 1);
729
730 coeffs.setTPU(8.0f, 30.0f, 35.0f, 400.0f);
731 DOCTEST_CHECK(coeffs.TPU_flag == 1);
732}
733
734DOCTEST_TEST_CASE("Farquhar TPU Singularity Fix") {
735 // Regression test: The TPU limitation formula Wp = 3*TPU*Ci/(Ci - Gamma*) has a
736 // singularity at Ci = Gamma*. When Ci is forced near Gamma*, the old formulation
737 // produces non-physical assimilation values (huge or negative). The fix moves the
738 // TPU comparison to the assimilation level where A_TPU = 3*TPU - Rd is a constant.
739
740 Context context_test;
741 PhotosynthesisModel photomodel(&context_test);
742 photomodel.disableMessages();
743
744 // Use Olive coefficients (species with TPU, known to trigger the bug)
745 FarquharModelCoefficients coeffs = photomodel.getFarquharCoefficientsFromLibrary("Olive");
746 DOCTEST_CHECK(coeffs.TPU_flag == 1);
747
748 // Gamma* at 300K ≈ exp(19.02 - 37.83 / (0.0083144598 * 300)) ≈ 56.6 ppm
749 // Test Ci values that bracket the singularity
750 std::vector<float> Ci_values = {40.0f, 50.0f, 55.0f, 56.0f, 57.0f, 58.0f, 60.0f, 80.0f, 150.0f, 250.0f};
751 std::vector<float> A_values;
752
753 for (float Ci_val : Ci_values) {
754 uint UUID = context_test.addPatch(make_vec3(0, 0, 0), make_vec2(1, 1));
755
756 photomodel.setModelCoefficients(coeffs, std::vector<uint>{UUID});
757 context_test.setPrimitiveData(UUID, "radiation_flux_PAR", 1500.0f); // Full sun
758 context_test.setPrimitiveData(UUID, "temperature", 300.0f); // ~27C
759 context_test.setPrimitiveData(UUID, "air_CO2", 400.0f);
760 context_test.setPrimitiveData(UUID, "moisture_conductance", 0.3f);
761
762 // Force Ci to a specific value to directly probe the singularity region
763 photomodel.setCi(Ci_val, std::vector<uint>{UUID});
764
765 photomodel.run(std::vector<uint>{UUID});
766
767 float A;
768 context_test.getPrimitiveData(UUID, "net_photosynthesis", A);
769 A_values.push_back(A);
770 }
771
772 // Gamma* at 300K ≈ 56.6 ppm. Below this, A must be negative (plant is respiring).
773 // The buggy code produces A=25+ at Ci=40 due to the Wp singularity.
774 for (size_t i = 0; i < A_values.size(); i++) {
775 DOCTEST_CHECK_MESSAGE(std::isfinite(A_values[i]),
776 "Non-finite A at Ci=" << Ci_values[i] << ": A=" << A_values[i]);
777 if (Ci_values[i] < 55.0f) {
778 DOCTEST_CHECK_MESSAGE(A_values[i] < 0.0f,
779 "A should be negative below compensation point: Ci=" << Ci_values[i]
780 << " A=" << A_values[i]);
781 }
782 }
783
784 // A should be monotonically increasing with Ci
785 for (size_t i = 1; i < A_values.size(); i++) {
786 DOCTEST_CHECK_MESSAGE(A_values[i] >= A_values[i-1] - 0.5f,
787 "Non-monotonic A: A[Ci=" << Ci_values[i] << "]=" << A_values[i]
788 << " < A[Ci=" << Ci_values[i-1] << "]=" << A_values[i-1]);
789 }
790}
791
792DOCTEST_TEST_CASE("PhotosynthesisModel Complex Farquhar Testing") {
793 Context context_test;
794 uint UUID = context_test.addPatch(make_vec3(0, 0, 0), make_vec2(1, 1));
795 PhotosynthesisModel photomodel(&context_test);
796
797 // Test complex Farquhar model with TPU limitation
799 coeffs.setVcmax(100.0f, 65.0f);
800 coeffs.setJmax(200.0f, 43.0f);
801 coeffs.setRd(2.0f, 46.0f);
802 coeffs.setQuantumEfficiency_alpha(0.4f);
803 coeffs.setTPU(8.0f); // Enable TPU limitation
804
805 photomodel.setModelCoefficients(coeffs);
806
807 // Set conditions that might trigger different limitation states
808 context_test.setPrimitiveData(UUID, "radiation_flux_PAR", 1800.0f);
809 context_test.setPrimitiveData(UUID, "temperature", 298.15f); // 25C
810 context_test.setPrimitiveData(UUID, "air_CO2", 300.0f);
811
812 // Enable optional outputs to trigger more code paths
813 photomodel.optionalOutputPrimitiveData("Ci");
814 photomodel.optionalOutputPrimitiveData("limitation_state");
815 photomodel.optionalOutputPrimitiveData("Gamma_CO2");
816
817 DOCTEST_CHECK_NOTHROW(photomodel.run());
818
819 // Verify outputs exist
820 float A, Ci, Gamma;
821 int limitation_state;
822 DOCTEST_CHECK_NOTHROW(context_test.getPrimitiveData(UUID, "net_photosynthesis", A));
823 DOCTEST_CHECK_NOTHROW(context_test.getPrimitiveData(UUID, "Ci", Ci));
824 DOCTEST_CHECK_NOTHROW(context_test.getPrimitiveData(UUID, "Gamma_CO2", Gamma));
825 DOCTEST_CHECK_NOTHROW(context_test.getPrimitiveData(UUID, "limitation_state", limitation_state));
826
827 // Verify reasonable values
828 DOCTEST_CHECK(A != 0.0f);
829 DOCTEST_CHECK(Ci > 0.0f);
830 DOCTEST_CHECK(Gamma > 0.0f);
831 DOCTEST_CHECK(limitation_state >= 0);
832}
833
834DOCTEST_TEST_CASE("PhotosynthesisModel Empirical Model Edge Cases") {
835 Context context_test;
836 uint UUID = context_test.addPatch(make_vec3(0, 0, 0), make_vec2(1, 1));
837 PhotosynthesisModel photomodel(&context_test);
838
839 // Test empirical model with extreme conditions
840 EmpiricalModelCoefficients empirical_coeffs;
841 empirical_coeffs.Asat = 25.0f;
842 empirical_coeffs.theta = 70.0f;
843
844 photomodel.setModelCoefficients(empirical_coeffs);
845
846 // Test with very high light
847 context_test.setPrimitiveData(UUID, "radiation_flux_PAR", 3000.0f);
848 context_test.setPrimitiveData(UUID, "temperature", 310.0f); // High temperature
849 context_test.setPrimitiveData(UUID, "air_CO2", 800.0f); // High CO2
850
851 DOCTEST_CHECK_NOTHROW(photomodel.run());
852
853 float A;
854 DOCTEST_CHECK_NOTHROW(context_test.getPrimitiveData(UUID, "net_photosynthesis", A));
855 DOCTEST_CHECK(!std::isnan(A));
856
857 // Test with zero light
858 context_test.setPrimitiveData(UUID, "radiation_flux_PAR", 0.0f);
859 DOCTEST_CHECK_NOTHROW(photomodel.run());
860
861 DOCTEST_CHECK_NOTHROW(context_test.getPrimitiveData(UUID, "net_photosynthesis", A));
862 DOCTEST_CHECK(A < 0.0f); // Should be negative (respiration only)
863}
864
865DOCTEST_TEST_CASE("PhotosynthesisModel Temperature Response Edge Cases") {
866 Context context_test;
867 uint UUID = context_test.addPatch(make_vec3(0, 0, 0), make_vec2(1, 1));
868 PhotosynthesisModel photomodel(&context_test);
869
870 // Test with temperature response that has dHa = 0 (constant response)
872 coeffs.setVcmax(100.0f); // Single parameter - constant (dHa = 0)
873 coeffs.setJmax(200.0f); // Single parameter - constant (dHa = 0)
874 coeffs.setRd(2.0f); // Single parameter - constant (dHa = 0)
875 coeffs.setQuantumEfficiency_alpha(0.4f); // Single parameter - constant (dHa = 0)
876
877 photomodel.setModelCoefficients(coeffs);
878
879 // Test at different temperatures - should give same result due to dHa = 0
880 std::vector<float> temperatures = {280.0f, 300.0f, 320.0f};
881 std::vector<float> results;
882
883 context_test.setPrimitiveData(UUID, "radiation_flux_PAR", 800.0f);
884 context_test.setPrimitiveData(UUID, "air_CO2", 400.0f);
885
886 for (float temp: temperatures) {
887 context_test.setPrimitiveData(UUID, "temperature", temp);
888 DOCTEST_CHECK_NOTHROW(photomodel.run());
889
890 float A;
891 DOCTEST_CHECK_NOTHROW(context_test.getPrimitiveData(UUID, "net_photosynthesis", A));
892 results.push_back(A);
893 }
894
895 // Even with dHa = 0, there are other temperature effects (Kc, Ko, Gamma, etc.)
896 // So just verify all computations completed without error and gave finite results
897 for (float result: results) {
898 DOCTEST_CHECK(!std::isnan(result));
899 DOCTEST_CHECK(std::isfinite(result));
900 }
901}
902
903DOCTEST_TEST_CASE("PhotosynthesisModel - Material-Based Coefficients") {
904 Context context;
905 PhotosynthesisModel photomodel(&context);
906
907 // Suppress messages
908 photomodel.disableMessages();
909
910 SUBCASE("Farquhar Model - Set and Retrieve Coefficients via Material") {
911 // Create material
912 context.addMaterial("test_leaf");
913
914 // Set custom coefficients using setter methods
915 FarquharModelCoefficients custom_coeffs;
916 custom_coeffs.setVcmax(150.0f);
917 custom_coeffs.setJmax(200.0f);
918 custom_coeffs.setRd(2.0f);
919 custom_coeffs.setQuantumEfficiency_alpha(0.4f);
920 custom_coeffs.O = 210.0f;
921
922 photomodel.setModelCoefficients("test_leaf", custom_coeffs);
923
924 // Create primitives with this material
925 uint p1 = context.addPatch(make_vec3(0, 0, 0), make_vec2(1, 1));
926 uint p2 = context.addPatch(make_vec3(1, 0, 0), make_vec2(1, 1));
927 context.assignMaterialToPrimitive(p1, "test_leaf");
928 context.assignMaterialToPrimitive(p2, "test_leaf");
929
930 // Verify material has the data
931 DOCTEST_CHECK(context.doesMaterialDataExist("test_leaf", "photo_fq_Vcmax"));
932 DOCTEST_CHECK(context.doesMaterialDataExist("test_leaf", "photo_fq_Jmax"));
933 DOCTEST_CHECK(context.doesMaterialDataExist("test_leaf", "photo_fq_Rd"));
934 DOCTEST_CHECK(context.doesMaterialDataExist("test_leaf", "photo_fq_alpha"));
935
936 // Verify values
937 float Vcmax, Jmax, Rd, alpha;
938 context.getMaterialData("test_leaf", "photo_fq_Vcmax", Vcmax);
939 context.getMaterialData("test_leaf", "photo_fq_Jmax", Jmax);
940 context.getMaterialData("test_leaf", "photo_fq_Rd", Rd);
941 context.getMaterialData("test_leaf", "photo_fq_alpha", alpha);
942
943 DOCTEST_CHECK(Vcmax == doctest::Approx(150.0f));
944 DOCTEST_CHECK(Jmax == doctest::Approx(200.0f));
945 DOCTEST_CHECK(Rd == doctest::Approx(2.0f));
946 DOCTEST_CHECK(alpha == doctest::Approx(0.4f));
947 }
948
949 SUBCASE("Multiple Primitives Share Material Coefficients") {
950 // Create material
951 context.addMaterial("shared_leaf");
952
953 // Set coefficients using setter methods
955 coeffs.setVcmax(120.0f);
956 coeffs.setJmax(180.0f);
957 coeffs.setRd(1.5f);
958 coeffs.setQuantumEfficiency_alpha(0.35f);
959 photomodel.setModelCoefficients("shared_leaf", coeffs);
960
961 // Create many primitives with same material
962 std::vector<uint> primitives;
963 for (int i = 0; i < 50; i++) {
964 uint p = context.addPatch(make_vec3(i, 0, 0), make_vec2(1, 1));
965 primitives.push_back(p);
966 context.assignMaterialToPrimitive(p, "shared_leaf");
967 }
968
969 // Verify all primitives have access to same coefficients via material
970 uint shared_mat_id = context.getMaterialIDFromLabel("shared_leaf");
971 for (uint p: primitives) {
972 DOCTEST_CHECK(context.getPrimitiveMaterialID(p) == shared_mat_id);
973 }
974
975 // Material should only store coefficients once
976 DOCTEST_CHECK(context.doesMaterialDataExist("shared_leaf", "photo_fq_Vcmax"));
977 DOCTEST_CHECK(context.doesMaterialDataExist("shared_leaf", "photo_fq_Jmax"));
978 }
979
980 SUBCASE("Library Integration with Materials") {
981 // Create materials for different species
982 context.addMaterial("almond_leaf");
983 context.addMaterial("grape_leaf");
984
985 // Set coefficients from library
986 photomodel.setFarquharCoefficientsFromLibrary("Almond", "almond_leaf");
987 photomodel.setFarquharCoefficientsFromLibrary("Grape", "grape_leaf");
988
989 // Verify both materials have coefficient data
990 DOCTEST_CHECK(context.doesMaterialDataExist("almond_leaf", "photo_fq_Vcmax"));
991 DOCTEST_CHECK(context.doesMaterialDataExist("grape_leaf", "photo_fq_Vcmax"));
992
993 // Coefficients should be different for different species
994 float almond_Vcmax, grape_Vcmax;
995 context.getMaterialData("almond_leaf", "photo_fq_Vcmax", almond_Vcmax);
996 context.getMaterialData("grape_leaf", "photo_fq_Vcmax", grape_Vcmax);
997 DOCTEST_CHECK(almond_Vcmax != grape_Vcmax);
998 }
999
1000 SUBCASE("Empirical Model - Material-Based API") {
1001 context.addMaterial("empirical_mat");
1002
1004 emp.Asat = 25.0f;
1005 emp.theta = 70.0f;
1006 emp.Topt = 305.0f;
1007 emp.kC = 0.9f;
1008
1009 DOCTEST_CHECK_NOTHROW(photomodel.setModelCoefficients("empirical_mat", emp));
1010 DOCTEST_CHECK(context.doesMaterialDataExist("empirical_mat", "photo_emp_Asat"));
1011 DOCTEST_CHECK(context.doesMaterialDataExist("empirical_mat", "photo_emp_theta"));
1012 DOCTEST_CHECK(context.doesMaterialDataExist("empirical_mat", "photo_emp_Topt"));
1013
1014 // Verify retrieved values
1015 float Asat_retrieved;
1016 context.getMaterialData("empirical_mat", "photo_emp_Asat", Asat_retrieved);
1017 DOCTEST_CHECK(Asat_retrieved == doctest::Approx(25.0f));
1018 }
1019}
1020
1021DOCTEST_TEST_CASE("PhotosynthesisModel - Manual Ci Setting") {
1022 Context context_test;
1023 uint UUID = context_test.addPatch(make_vec3(0, 0, 0), make_vec2(1, 1));
1024 PhotosynthesisModel photomodel(&context_test);
1025
1026 // Set up Farquhar model
1028 fcoeffs.setVcmax(78.5f, 65.33f);
1029 fcoeffs.setJmax(150.f, 43.54f);
1030 fcoeffs.setRd(2.12f, 46.39f);
1031 fcoeffs.setQuantumEfficiency_alpha(0.45f);
1032 DOCTEST_CHECK_NOTHROW(photomodel.setModelCoefficients(fcoeffs));
1033
1034 // Enable Ci output
1035 DOCTEST_CHECK_NOTHROW(photomodel.optionalOutputPrimitiveData("Ci"));
1036
1037 // Set environmental conditions
1038 DOCTEST_CHECK_NOTHROW(context_test.setPrimitiveData(UUID, "radiation_flux_PAR", 800.0f));
1039 DOCTEST_CHECK_NOTHROW(context_test.setPrimitiveData(UUID, "temperature", 298.15f));
1040 DOCTEST_CHECK_NOTHROW(context_test.setPrimitiveData(UUID, "air_CO2", 400.0f));
1041 DOCTEST_CHECK_NOTHROW(context_test.setPrimitiveData(UUID, "moisture_conductance", 0.3f));
1042
1043 // Set manual Ci
1044 float manual_Ci_value = 280.0f;
1045 std::vector<uint> UUIDs = {UUID};
1046 DOCTEST_CHECK_NOTHROW(photomodel.setCi(manual_Ci_value, UUIDs));
1047
1048 // Run and verify Ci is manual value
1049 DOCTEST_CHECK_NOTHROW(photomodel.run());
1050 float Ci_retrieved;
1051 DOCTEST_CHECK_NOTHROW(context_test.getPrimitiveData(UUID, "Ci", Ci_retrieved));
1052 DOCTEST_CHECK(Ci_retrieved == doctest::Approx(manual_Ci_value).epsilon(err_tol));
1053}
1054
1055DOCTEST_TEST_CASE("PhotosynthesisModel - Manual Ci Persistence") {
1056 Context context_test;
1057 uint UUID = context_test.addPatch(make_vec3(0, 0, 0), make_vec2(1, 1));
1058 PhotosynthesisModel photomodel(&context_test);
1059
1061 fcoeffs.setVcmax(78.5f, 65.33f);
1062 fcoeffs.setJmax(150.f, 43.54f);
1063 fcoeffs.setRd(2.12f, 46.39f);
1064 fcoeffs.setQuantumEfficiency_alpha(0.45f);
1065 DOCTEST_CHECK_NOTHROW(photomodel.setModelCoefficients(fcoeffs));
1066 DOCTEST_CHECK_NOTHROW(photomodel.optionalOutputPrimitiveData("Ci"));
1067
1068 DOCTEST_CHECK_NOTHROW(context_test.setPrimitiveData(UUID, "radiation_flux_PAR", 800.0f));
1069 DOCTEST_CHECK_NOTHROW(context_test.setPrimitiveData(UUID, "temperature", 298.15f));
1070 DOCTEST_CHECK_NOTHROW(context_test.setPrimitiveData(UUID, "air_CO2", 400.0f));
1071 DOCTEST_CHECK_NOTHROW(context_test.setPrimitiveData(UUID, "moisture_conductance", 0.3f));
1072
1073 // Set manual Ci
1074 float manual_Ci_value = 250.0f;
1075 std::vector<uint> UUIDs = {UUID};
1076 DOCTEST_CHECK_NOTHROW(photomodel.setCi(manual_Ci_value, UUIDs));
1077
1078 // Run multiple times
1079 DOCTEST_CHECK_NOTHROW(photomodel.run());
1080 float Ci_run1;
1081 DOCTEST_CHECK_NOTHROW(context_test.getPrimitiveData(UUID, "Ci", Ci_run1));
1082
1083 DOCTEST_CHECK_NOTHROW(photomodel.run());
1084 float Ci_run2;
1085 DOCTEST_CHECK_NOTHROW(context_test.getPrimitiveData(UUID, "Ci", Ci_run2));
1086
1087 DOCTEST_CHECK_NOTHROW(photomodel.run());
1088 float Ci_run3;
1089 DOCTEST_CHECK_NOTHROW(context_test.getPrimitiveData(UUID, "Ci", Ci_run3));
1090
1091 // All runs should use same manual Ci
1092 DOCTEST_CHECK(Ci_run1 == doctest::Approx(manual_Ci_value).epsilon(err_tol));
1093 DOCTEST_CHECK(Ci_run2 == doctest::Approx(manual_Ci_value).epsilon(err_tol));
1094 DOCTEST_CHECK(Ci_run3 == doctest::Approx(manual_Ci_value).epsilon(err_tol));
1095}
1096
1097DOCTEST_TEST_CASE("PhotosynthesisModel - Invalid Ci Error Handling") {
1098 Context context_test;
1099 uint UUID = context_test.addPatch(make_vec3(0, 0, 0), make_vec2(1, 1));
1100 PhotosynthesisModel photomodel(&context_test);
1101
1102 std::vector<uint> UUIDs = {UUID};
1103
1104 // Test negative Ci
1105 capture_cerr cerr_capture;
1106 DOCTEST_CHECK_THROWS_AS(photomodel.setCi(-100.0f, UUIDs), std::runtime_error);
1107
1108 // Test NaN
1109 DOCTEST_CHECK_THROWS_AS(photomodel.setCi(std::numeric_limits<float>::quiet_NaN(), UUIDs), std::runtime_error);
1110
1111 // Test infinity
1112 DOCTEST_CHECK_THROWS_AS(photomodel.setCi(std::numeric_limits<float>::infinity(), UUIDs), std::runtime_error);
1113
1114 // Test invalid UUID
1115 std::vector<uint> invalid_UUIDs = {99999};
1116 DOCTEST_CHECK_THROWS_AS(photomodel.setCi(300.0f, invalid_UUIDs), std::runtime_error);
1117}
1118
1119DOCTEST_TEST_CASE("PhotosynthesisModel - Mixed Manual and Automatic Ci") {
1120 Context context_test;
1121 uint UUID1 = context_test.addPatch(make_vec3(0, 0, 0), make_vec2(1, 1));
1122 uint UUID2 = context_test.addPatch(make_vec3(2, 0, 0), make_vec2(1, 1));
1123 PhotosynthesisModel photomodel(&context_test);
1124
1126 fcoeffs.setVcmax(78.5f, 65.33f);
1127 fcoeffs.setJmax(150.f, 43.54f);
1128 fcoeffs.setRd(2.12f, 46.39f);
1129 fcoeffs.setQuantumEfficiency_alpha(0.45f);
1130 DOCTEST_CHECK_NOTHROW(photomodel.setModelCoefficients(fcoeffs));
1131 DOCTEST_CHECK_NOTHROW(photomodel.optionalOutputPrimitiveData("Ci"));
1132
1133 // Set same conditions for both primitives
1134 DOCTEST_CHECK_NOTHROW(context_test.setPrimitiveData(UUID1, "radiation_flux_PAR", 800.0f));
1135 DOCTEST_CHECK_NOTHROW(context_test.setPrimitiveData(UUID2, "radiation_flux_PAR", 800.0f));
1136 DOCTEST_CHECK_NOTHROW(context_test.setPrimitiveData(UUID1, "temperature", 298.15f));
1137 DOCTEST_CHECK_NOTHROW(context_test.setPrimitiveData(UUID2, "temperature", 298.15f));
1138 DOCTEST_CHECK_NOTHROW(context_test.setPrimitiveData(UUID1, "air_CO2", 400.0f));
1139 DOCTEST_CHECK_NOTHROW(context_test.setPrimitiveData(UUID2, "air_CO2", 400.0f));
1140 DOCTEST_CHECK_NOTHROW(context_test.setPrimitiveData(UUID1, "moisture_conductance", 0.3f));
1141 DOCTEST_CHECK_NOTHROW(context_test.setPrimitiveData(UUID2, "moisture_conductance", 0.3f));
1142
1143 // Set manual Ci only for UUID1
1144 float manual_Ci_value = 250.0f;
1145 std::vector<uint> manual_UUIDs = {UUID1};
1146 DOCTEST_CHECK_NOTHROW(photomodel.setCi(manual_Ci_value, manual_UUIDs));
1147
1148 // Run both
1149 DOCTEST_CHECK_NOTHROW(photomodel.run());
1150
1151 // UUID1 should have manual Ci
1152 float Ci1;
1153 DOCTEST_CHECK_NOTHROW(context_test.getPrimitiveData(UUID1, "Ci", Ci1));
1154 DOCTEST_CHECK(Ci1 == doctest::Approx(manual_Ci_value).epsilon(err_tol));
1155
1156 // UUID2 should have calculated Ci (different from manual)
1157 float Ci2;
1158 DOCTEST_CHECK_NOTHROW(context_test.getPrimitiveData(UUID2, "Ci", Ci2));
1159 DOCTEST_CHECK(Ci2 != doctest::Approx(manual_Ci_value).epsilon(err_tol));
1160 DOCTEST_CHECK(Ci2 > 0.0f);
1161 DOCTEST_CHECK(Ci2 < 400.0f);
1162}
1163
1164DOCTEST_TEST_CASE("PhotosynthesisModel - Topt Parameter Validation") {
1165 Context context_test;
1166 PhotosynthesisModel photomodel(&context_test);
1168
1169 SUBCASE("Negative Topt should throw error") {
1170 // Test negative Topt for Vcmax
1171 DOCTEST_CHECK_THROWS_WITH_AS(coeffs.setVcmax(100.0f, 60.0f, -10.0f),
1172 "ERROR (PhotosyntheticTemperatureResponseParameters): Optimal temperature cannot be negative. Received Topt = -10.000000 C. Please check that temperature is provided in units of Celsius, not Kelvin.",
1173 std::runtime_error);
1174
1175 // Test negative Topt for Jmax
1176 DOCTEST_CHECK_THROWS_WITH_AS(coeffs.setJmax(200.0f, 50.0f, -5.0f),
1177 "ERROR (PhotosyntheticTemperatureResponseParameters): Optimal temperature cannot be negative. Received Topt = -5.000000 C. Please check that temperature is provided in units of Celsius, not Kelvin.",
1178 std::runtime_error);
1179
1180 // Test negative Topt for Rd
1181 DOCTEST_CHECK_THROWS_WITH_AS(coeffs.setRd(2.0f, 40.0f, -15.0f),
1182 "ERROR (PhotosyntheticTemperatureResponseParameters): Optimal temperature cannot be negative. Received Topt = -15.000000 C. Please check that temperature is provided in units of Celsius, not Kelvin.",
1183 std::runtime_error);
1184
1185 // Test negative Topt for TPU
1186 DOCTEST_CHECK_THROWS_WITH_AS(coeffs.setTPU(5.0f, 50.0f, -20.0f),
1187 "ERROR (PhotosyntheticTemperatureResponseParameters): Optimal temperature cannot be negative. Received Topt = -20.000000 C. Please check that temperature is provided in units of Celsius, not Kelvin.",
1188 std::runtime_error);
1189
1190 // Test negative Topt for alpha
1191 DOCTEST_CHECK_THROWS_WITH_AS(coeffs.setQuantumEfficiency_alpha(0.5f, 30.0f, -25.0f),
1192 "ERROR (PhotosyntheticTemperatureResponseParameters): Optimal temperature cannot be negative. Received Topt = -25.000000 C. Please check that temperature is provided in units of Celsius, not Kelvin.",
1193 std::runtime_error);
1194
1195 // Test negative Topt for theta
1196 DOCTEST_CHECK_THROWS_WITH_AS(coeffs.setLightResponseCurvature_theta(0.7f, 35.0f, -30.0f),
1197 "ERROR (PhotosyntheticTemperatureResponseParameters): Optimal temperature cannot be negative. Received Topt = -30.000000 C. Please check that temperature is provided in units of Celsius, not Kelvin.",
1198 std::runtime_error);
1199 }
1200
1201 SUBCASE("Topt > 100C should throw error (likely Kelvin instead of Celsius)") {
1202 // Test Topt > 100°C for Vcmax (e.g., user provided 310 K instead of 36.85 C)
1203 DOCTEST_CHECK_THROWS_WITH_AS(
1204 coeffs.setVcmax(100.0f, 60.0f, 310.0f),
1205 "ERROR (PhotosyntheticTemperatureResponseParameters): Optimal temperature cannot exceed 100 C. Received Topt = 310.000000 C. This value is biologically unrealistic and likely indicates temperature was provided in Kelvin instead of Celsius. Please convert to Celsius (subtract 273.15 from Kelvin value).",
1206 std::runtime_error);
1207
1208 // Test Topt > 100°C for Jmax
1209 DOCTEST_CHECK_THROWS_WITH_AS(
1210 coeffs.setJmax(200.0f, 50.0f, 305.0f),
1211 "ERROR (PhotosyntheticTemperatureResponseParameters): Optimal temperature cannot exceed 100 C. Received Topt = 305.000000 C. This value is biologically unrealistic and likely indicates temperature was provided in Kelvin instead of Celsius. Please convert to Celsius (subtract 273.15 from Kelvin value).",
1212 std::runtime_error);
1213
1214 // Test Topt > 100°C for Rd
1215 DOCTEST_CHECK_THROWS_WITH_AS(
1216 coeffs.setRd(2.0f, 40.0f, 300.0f),
1217 "ERROR (PhotosyntheticTemperatureResponseParameters): Optimal temperature cannot exceed 100 C. Received Topt = 300.000000 C. This value is biologically unrealistic and likely indicates temperature was provided in Kelvin instead of Celsius. Please convert to Celsius (subtract 273.15 from Kelvin value).",
1218 std::runtime_error);
1219
1220 // Test Topt > 100°C for TPU
1221 DOCTEST_CHECK_THROWS_WITH_AS(
1222 coeffs.setTPU(5.0f, 50.0f, 315.0f),
1223 "ERROR (PhotosyntheticTemperatureResponseParameters): Optimal temperature cannot exceed 100 C. Received Topt = 315.000000 C. This value is biologically unrealistic and likely indicates temperature was provided in Kelvin instead of Celsius. Please convert to Celsius (subtract 273.15 from Kelvin value).",
1224 std::runtime_error);
1225
1226 // Test Topt > 100°C for alpha
1227 DOCTEST_CHECK_THROWS_WITH_AS(
1228 coeffs.setQuantumEfficiency_alpha(0.5f, 30.0f, 320.0f),
1229 "ERROR (PhotosyntheticTemperatureResponseParameters): Optimal temperature cannot exceed 100 C. Received Topt = 320.000000 C. This value is biologically unrealistic and likely indicates temperature was provided in Kelvin instead of Celsius. Please convert to Celsius (subtract 273.15 from Kelvin value).",
1230 std::runtime_error);
1231
1232 // Test Topt > 100°C for theta
1233 DOCTEST_CHECK_THROWS_WITH_AS(
1234 coeffs.setLightResponseCurvature_theta(0.7f, 35.0f, 325.0f),
1235 "ERROR (PhotosyntheticTemperatureResponseParameters): Optimal temperature cannot exceed 100 C. Received Topt = 325.000000 C. This value is biologically unrealistic and likely indicates temperature was provided in Kelvin instead of Celsius. Please convert to Celsius (subtract 273.15 from Kelvin value).",
1236 std::runtime_error);
1237 }
1238
1239 SUBCASE("Valid Topt values should work correctly") {
1240 // Test valid Topt values (0-100°C range)
1241 DOCTEST_CHECK_NOTHROW(coeffs.setVcmax(100.0f, 60.0f, 35.0f));
1242 DOCTEST_CHECK_NOTHROW(coeffs.setJmax(200.0f, 50.0f, 40.0f));
1243 DOCTEST_CHECK_NOTHROW(coeffs.setRd(2.0f, 40.0f, 30.0f));
1244 DOCTEST_CHECK_NOTHROW(coeffs.setTPU(5.0f, 50.0f, 33.0f));
1245 DOCTEST_CHECK_NOTHROW(coeffs.setQuantumEfficiency_alpha(0.5f, 30.0f, 25.0f));
1246 DOCTEST_CHECK_NOTHROW(coeffs.setLightResponseCurvature_theta(0.7f, 35.0f, 28.0f));
1247
1248 // Verify the Topt values were set correctly (converted to Kelvin internally)
1249 PhotosyntheticTemperatureResponseParameters vcmax_params = coeffs.getVcmaxTempResponse();
1250 DOCTEST_CHECK(vcmax_params.Topt == doctest::Approx(273.15f + 35.0f).epsilon(err_tol));
1251
1252 PhotosyntheticTemperatureResponseParameters jmax_params = coeffs.getJmaxTempResponse();
1253 DOCTEST_CHECK(jmax_params.Topt == doctest::Approx(273.15f + 40.0f).epsilon(err_tol));
1254
1255 PhotosyntheticTemperatureResponseParameters rd_params = coeffs.getRdTempResponse();
1256 DOCTEST_CHECK(rd_params.Topt == doctest::Approx(273.15f + 30.0f).epsilon(err_tol));
1257
1258 PhotosyntheticTemperatureResponseParameters tpu_params = coeffs.getTPUTempResponse();
1259 DOCTEST_CHECK(tpu_params.Topt == doctest::Approx(273.15f + 33.0f).epsilon(err_tol));
1260
1261 PhotosyntheticTemperatureResponseParameters alpha_params = coeffs.getQuantumEfficiencyTempResponse();
1262 DOCTEST_CHECK(alpha_params.Topt == doctest::Approx(273.15f + 25.0f).epsilon(err_tol));
1263
1264 PhotosyntheticTemperatureResponseParameters theta_params = coeffs.getLightResponseCurvatureTempResponse();
1265 DOCTEST_CHECK(theta_params.Topt == doctest::Approx(273.15f + 28.0f).epsilon(err_tol));
1266 }
1267
1268 SUBCASE("Edge cases: 0C and 100C should be valid") {
1269 // Test boundary values
1270 DOCTEST_CHECK_NOTHROW(coeffs.setVcmax(100.0f, 60.0f, 0.0f)); // 0°C should be valid
1271 DOCTEST_CHECK_NOTHROW(coeffs.setJmax(200.0f, 50.0f, 100.0f)); // 100°C should be valid
1272
1273 PhotosyntheticTemperatureResponseParameters vcmax_params = coeffs.getVcmaxTempResponse();
1274 DOCTEST_CHECK(vcmax_params.Topt == doctest::Approx(273.15f).epsilon(err_tol));
1275
1276 PhotosyntheticTemperatureResponseParameters jmax_params = coeffs.getJmaxTempResponse();
1277 DOCTEST_CHECK(jmax_params.Topt == doctest::Approx(373.15f).epsilon(err_tol));
1278 }
1279}
1280
1281// -----------------------------------------------------------------------------
1282// C4 Model tests — von Caemmerer (2021) JXB 72:6003
1283// -----------------------------------------------------------------------------
1284
1285DOCTEST_TEST_CASE("PhotosynthesisModel C4 Model Type Setting and Defaults") {
1286 Context context_test;
1287 uint UUID = context_test.addPatch(make_vec3(0, 0, 0), make_vec2(1, 1));
1288 PhotosynthesisModel photomodel(&context_test);
1289 photomodel.disableMessages();
1290
1291 DOCTEST_CHECK_NOTHROW(photomodel.setModelType_C4());
1292
1293 // Retrieve default C4 coefficients — paper Table 1 defaults for Setaria viridis
1294 C4ModelCoefficients c4 = photomodel.getC4ModelCoefficients(UUID);
1295
1296 DOCTEST_CHECK(c4.Kc_25 == doctest::Approx(1210.f).epsilon(err_tol));
1297 DOCTEST_CHECK(c4.Ko_25 == doctest::Approx(292000.f).epsilon(err_tol));
1298 DOCTEST_CHECK(c4.Kp_25 == doctest::Approx(82.f).epsilon(err_tol));
1299 DOCTEST_CHECK(c4.gamma_star_25 == doctest::Approx(3.81679e-4f).epsilon(err_tol));
1300 DOCTEST_CHECK(c4.Om_25 == doctest::Approx(210000.f).epsilon(err_tol));
1301 DOCTEST_CHECK(c4.alpha_psII_fraction == doctest::Approx(0.f).epsilon(err_tol));
1302 DOCTEST_CHECK(c4.x_etr_partition == doctest::Approx(0.4f).epsilon(err_tol));
1303 DOCTEST_CHECK(c4.Vpr == doctest::Approx(80.f).epsilon(err_tol));
1304 DOCTEST_CHECK(c4.Rm_frac == doctest::Approx(0.5f).epsilon(err_tol));
1305 DOCTEST_CHECK(c4.fcyc == doctest::Approx(0.45f).epsilon(err_tol));
1306 DOCTEST_CHECK(c4.H_J == doctest::Approx(3.f).epsilon(err_tol));
1307 DOCTEST_CHECK(c4.H_Jcyc == doctest::Approx(3.4f).epsilon(err_tol));
1308 DOCTEST_CHECK(c4.gbs == doctest::Approx(0.003f).epsilon(err_tol));
1309 DOCTEST_CHECK(c4.ao == doctest::Approx(0.047f).epsilon(err_tol));
1310
1311 // Default temperature responses
1312 DOCTEST_CHECK(c4.getVpmaxTempResponse().value_at_25C == doctest::Approx(200.f).epsilon(err_tol));
1313 DOCTEST_CHECK(c4.getVcmaxTempResponse().value_at_25C == doctest::Approx(40.f).epsilon(err_tol));
1314 DOCTEST_CHECK(c4.getJmaxTempResponse().value_at_25C == doctest::Approx(247.69f).epsilon(err_tol));
1315 DOCTEST_CHECK(c4.getRdTempResponse().value_at_25C == doctest::Approx(1.f).epsilon(err_tol));
1316 DOCTEST_CHECK(c4.getMesophyllConductance_gmTempResponse().value_at_25C == doctest::Approx(1.f).epsilon(err_tol));
1317}
1318
1319DOCTEST_TEST_CASE("PhotosynthesisModel C4 Setter Overloads - Temperature Response") {
1321
1322 SUBCASE("Constant Vpmax") {
1323 c4.setVpmax(150.f);
1324 DOCTEST_CHECK(c4.getVpmaxTempResponse().value_at_25C == doctest::Approx(150.f));
1325 DOCTEST_CHECK(c4.getVpmaxTempResponse().dHa == doctest::Approx(0.f));
1326 }
1327 SUBCASE("Arrhenius Vcmax") {
1328 c4.setVcmax(35.f, 70.f);
1329 DOCTEST_CHECK(c4.getVcmaxTempResponse().value_at_25C == doctest::Approx(35.f));
1330 DOCTEST_CHECK(c4.getVcmaxTempResponse().dHa == doctest::Approx(70.f));
1331 }
1332 SUBCASE("Peaked Arrhenius Jmax") {
1333 c4.setJmax(220.f, 45.f, 40.f);
1334 DOCTEST_CHECK(c4.getJmaxTempResponse().value_at_25C == doctest::Approx(220.f));
1335 DOCTEST_CHECK(c4.getJmaxTempResponse().Topt == doctest::Approx(273.15f + 40.f));
1336 }
1337 SUBCASE("Peaked Arrhenius Rd with explicit dHd") {
1338 c4.setRd(1.2f, 60.f, 42.f, 400.f);
1339 DOCTEST_CHECK(c4.getRdTempResponse().dHd == doctest::Approx(400.f));
1340 }
1341 SUBCASE("gm setters") {
1343 DOCTEST_CHECK(c4.getMesophyllConductance_gmTempResponse().value_at_25C == doctest::Approx(0.5f));
1344 c4.setMesophyllConductance_gm(0.8f, 50.f);
1345 DOCTEST_CHECK(c4.getMesophyllConductance_gmTempResponse().dHa == doctest::Approx(50.f));
1346 }
1347}
1348
1349DOCTEST_TEST_CASE("PhotosynthesisModel C4 - von Caemmerer 2021 spreadsheet verification at T=25C") {
1350 // Direct A vs Cm sweep at T=25 C, I=2000 umol/m²/s incident PAR. Reference values come from
1351 // the spreadsheet "C4__model_setaria__11-06-2021.xlsm" (sheet "A vs cm", column V "Amin").
1352 // Each test point supplies a Cm directly via setCm(), so the comparison tests the quadratic
1353 // math (Ac, Aj) without involving the Ci = Cm + A/gm coupling or the stomatal balance.
1354 //
1355 // This test explicitly pins the legacy vC2021 proton stoichiometry (fcyc=0.3, H_Jcyc=2)
1356 // because the reference spreadsheet was generated before the Woodford et al. (2025) update.
1357 // The new struct defaults (fcyc=0.45, H_Jcyc=3.4) are tested elsewhere. Keeping this test
1358 // ensures the generalized z = (H_J·(1−fcyc) + H_Jcyc·fcyc)/(h·(1−fcyc)) formula reduces
1359 // exactly to the vC2021 Eq. 31 form z = (3 − fcyc)/(h·(1−fcyc)) at H_J=3, H_Jcyc=2.
1360
1361 Context context_test;
1362 uint UUID = context_test.addPatch(make_vec3(0, 0, 0), make_vec2(1, 1));
1363 PhotosynthesisModel photomodel(&context_test);
1364 photomodel.disableMessages();
1365
1366 // Use paper defaults with three spreadsheet-compatibility adjustments:
1367 // - Jmax(25 °C) set exactly to the Gaussian-evaluated value 247.6892 (peaked-Arrhenius fit matches only at 25 °C).
1368 // - Vpr raised above Vpmax to disable the Eq. 19 cap Vp = min(Vp_MM, Vpr). The reference spreadsheet omits
1369 // this cap, so our computed Ac at high Cm would diverge if we applied it. End-user defaults keep Vpr=80
1370 // per paper Table 1 — the separate "Vpr cap activates" test verifies that behavior.
1371 // - fcyc and H_Jcyc set to the vC2021 Eq. 31 values (not Woodford 2025 defaults) to match the 2021 spreadsheet.
1373 c4.setJmax(247.6891682624015f);
1374 c4.Vpr = 1.0e4f;
1375 c4.fcyc = 0.3f; // vC2021 Table 1 value; Woodford 2025 updates to 0.45.
1376 c4.H_Jcyc = 2.0f; // vC2021 Eq. 31 implicit value (PGR5-only cyclic flow); Woodford 2025 updates to 3.4.
1377
1378 photomodel.setModelCoefficients(c4);
1379
1380 DOCTEST_CHECK_NOTHROW(context_test.setPrimitiveData(UUID, "temperature", 298.15f));
1381 DOCTEST_CHECK_NOTHROW(context_test.setPrimitiveData(UUID, "radiation_flux_PAR", 2000.f / 4.57f));
1382 DOCTEST_CHECK_NOTHROW(context_test.setPrimitiveData(UUID, "air_CO2", 400.f));
1383
1384 struct C4TestPoint {
1385 float Cm;
1386 float A_expected;
1387 };
1388 // Values from spreadsheet sheet "A vs cm", column F (Cm) and V (Amin) — paper Table 1 defaults
1389 const std::vector<C4TestPoint> points = {
1390 {0.1f, -0.567410f}, // boundary: Vp ≈ 0, A ≈ -Rd, exercises the discriminant clamp at the low edge
1391 {5.f, 8.695318f}, // Ac-limited (small Cm)
1392 {10.f, 16.159569f},
1393 {20.f, 25.628532f},
1394 {30.f, 30.140492f},
1395 {40.f, 32.364615f},
1396 {50.f, 33.604065f},
1397 {60.f, 34.375010f},
1398 {80.f, 34.835899f}, // Aj-limited from here
1399 {100.f, 34.862438f},
1400 {150.f, 34.928111f},
1401 {250.f, 35.056593f},
1402 {500.f, 35.361352f},
1403 {780.f, 35.675665f},
1404 };
1405
1406 for (const auto &pt: points) {
1407 photomodel.setCm(pt.Cm, {UUID});
1408 DOCTEST_CHECK_NOTHROW(photomodel.run());
1409 float A;
1410 context_test.getPrimitiveData(UUID, "net_photosynthesis", A);
1411 // 0.5% tolerance — tight since the only approximations are float precision and the Jmax
1412 // peaked-Arrhenius-vs-Gaussian form (matched exactly at 25 °C).
1413 DOCTEST_CHECK_MESSAGE(A == doctest::Approx(pt.A_expected).epsilon(0.005),
1414 "Cm=" << pt.Cm << " expected A=" << pt.A_expected << " got " << A);
1415 }
1416}
1417
1418DOCTEST_TEST_CASE("PhotosynthesisModel C4 - Woodford 2025 default A vs Cm regression at T=25C") {
1419 // Companion to the vC2021 spreadsheet test. Same A vs Cm sweep at 25 °C with the same
1420 // Jmax/Vpr adjustments, but using the new struct defaults (fcyc=0.45, H_Jcyc=3.4, H_J=3)
1421 // from Woodford et al. (2025).
1422 //
1423 // NOTE: under these parameters (Vpmax=200, Vcmax=40, Jmax_25=247.69, Vpr=10⁴, fcyc=0.45)
1424 // Ac always limits at every tested Cm — Aj_plateau ≈ 52.3 stays well above
1425 // Ac_plateau ≈ 37.3, so this test exercises the Ac quadratic only. Cross-validated
1426 // exactly against the patched Woodford 2025 spreadsheet at 25 °C; for Aj-limited
1427 // validation at non-25 °C see "Patched Woodford spreadsheet anchors" below.
1428
1429 Context context_test;
1430 uint UUID = context_test.addPatch(make_vec3(0, 0, 0), make_vec2(1, 1));
1431 PhotosynthesisModel photomodel(&context_test);
1432 photomodel.disableMessages();
1433
1435 c4.setJmax(247.6891682624015f);
1436 c4.Vpr = 1.0e4f;
1437 // fcyc, H_J, H_Jcyc use the Woodford 2025 struct defaults (0.45, 3, 3.4).
1438
1439 photomodel.setModelCoefficients(c4);
1440
1441 DOCTEST_CHECK_NOTHROW(context_test.setPrimitiveData(UUID, "temperature", 298.15f));
1442 DOCTEST_CHECK_NOTHROW(context_test.setPrimitiveData(UUID, "radiation_flux_PAR", 2000.f / 4.57f));
1443 DOCTEST_CHECK_NOTHROW(context_test.setPrimitiveData(UUID, "air_CO2", 400.f));
1444
1445 struct C4TestPoint {
1446 float Cm;
1447 float A_expected;
1448 };
1449 const std::vector<C4TestPoint> points = {
1450 // All points are Ac-limited under these parameters — Aj never wins at 25 °C.
1451 // Values computed from the patched Woodford 2025 spreadsheet (see
1452 // C4_spreadsheet_audit.md and tests/C4_test_anchors.csv at T_C=25.0).
1453 {0.1f, -0.567410f},
1454 {5.f, 8.695318f},
1455 {10.f, 16.159569f},
1456 {20.f, 25.628532f},
1457 {30.f, 30.140492f},
1458 {40.f, 32.364615f},
1459 {50.f, 33.604065f},
1460 {60.f, 34.375010f},
1461 {80.f, 35.2687f},
1462 {100.f, 35.7661f},
1463 {150.f, 36.3845f},
1464 {250.f, 36.8441f},
1465 {500.f, 37.1729f},
1466 {780.f, 37.2918f},
1467 };
1468
1469 for (const auto &pt: points) {
1470 photomodel.setCm(pt.Cm, {UUID});
1471 DOCTEST_CHECK_NOTHROW(photomodel.run());
1472 float A;
1473 context_test.getPrimitiveData(UUID, "net_photosynthesis", A);
1474 DOCTEST_CHECK_MESSAGE(A == doctest::Approx(pt.A_expected).epsilon(0.005),
1475 "Cm=" << pt.Cm << " expected A=" << pt.A_expected << " got " << A);
1476 }
1477}
1478
1479DOCTEST_TEST_CASE("PhotosynthesisModel C4 - setCm round-trip: Ci = Cm + A/gm") {
1480 // When Cm is supplied directly, Ci is back-computed. Verify the relationship.
1481 Context context_test;
1482 uint UUID = context_test.addPatch(make_vec3(0, 0, 0), make_vec2(1, 1));
1483 PhotosynthesisModel photomodel(&context_test);
1484 photomodel.disableMessages();
1485 photomodel.setModelType_C4();
1486 photomodel.optionalOutputPrimitiveData("Ci");
1487 photomodel.optionalOutputPrimitiveData("Cm");
1488
1489 context_test.setPrimitiveData(UUID, "temperature", 298.15f);
1490 context_test.setPrimitiveData(UUID, "radiation_flux_PAR", 2000.f / 4.57f);
1491 context_test.setPrimitiveData(UUID, "air_CO2", 400.f);
1492
1493 C4ModelCoefficients defaults;
1494 const float gm_25 = defaults.getMesophyllConductance_gmTempResponse().value_at_25C;
1495
1496 for (float Cm_in: {20.f, 60.f, 120.f}) {
1497 photomodel.setCm(Cm_in, {UUID});
1498 DOCTEST_CHECK_NOTHROW(photomodel.run());
1499 float A, Ci_out, Cm_out;
1500 context_test.getPrimitiveData(UUID, "net_photosynthesis", A);
1501 context_test.getPrimitiveData(UUID, "Ci", Ci_out);
1502 context_test.getPrimitiveData(UUID, "Cm", Cm_out);
1503 DOCTEST_CHECK(Cm_out == doctest::Approx(Cm_in).epsilon(1e-5));
1504 DOCTEST_CHECK(Ci_out == doctest::Approx(Cm_in + A / gm_25).epsilon(1e-3));
1505 }
1506}
1507
1508DOCTEST_TEST_CASE("PhotosynthesisModel C4 - Temperature response wires through to A") {
1509 // Regression guard: catches the bug class "T-response getting silently disconnected from the C4 solver".
1510 // The setter tests only verify struct storage; the spreadsheet test runs only at 25 °C where the
1511 // Arrhenius factor degenerates to 1 regardless of dHa. This test runs at T = 308 K (35 °C),
1512 // where every default temperature-response actually contributes.
1513 Context context_test;
1514 uint UUID = context_test.addPatch(make_vec3(0, 0, 0), make_vec2(1, 1));
1515 PhotosynthesisModel photomodel(&context_test);
1516 photomodel.disableMessages();
1517 photomodel.setModelType_C4();
1518
1519 context_test.setPrimitiveData(UUID, "radiation_flux_PAR", 2000.f / 4.57f);
1520 context_test.setPrimitiveData(UUID, "air_CO2", 400.f);
1521 photomodel.setCm(80.f, {UUID});
1522
1523 // 25 °C baseline
1524 context_test.setPrimitiveData(UUID, "temperature", 298.15f);
1525 DOCTEST_CHECK_NOTHROW(photomodel.run());
1526 float A_25;
1527 context_test.getPrimitiveData(UUID, "net_photosynthesis", A_25);
1528
1529 // 35 °C — well below the Jmax peak (Topt = 43 °C) but with all rate constants substantially elevated
1530 context_test.setPrimitiveData(UUID, "temperature", 308.15f);
1531 DOCTEST_CHECK_NOTHROW(photomodel.run());
1532 float A_35;
1533 context_test.getPrimitiveData(UUID, "net_photosynthesis", A_35);
1534
1535 // Directional check: the bug we want to catch is the response being disconnected (A_25 == A_35).
1536 // C4 is near its temperature optimum at 35 °C, so A should rise meaningfully.
1537 DOCTEST_CHECK_MESSAGE(A_35 > A_25 * 1.1f,
1538 "T-response not wired through: A(35°C)=" << A_35 << " not meaningfully > A(25°C)=" << A_25);
1539
1540 // Tight regression anchors captured from the current implementation. Updating these is a deliberate
1541 // act and signals a behavioral change in either the temperature-response framework or the C4 solver.
1542 DOCTEST_CHECK(A_25 == doctest::Approx(34.012f).epsilon(0.005));
1543 DOCTEST_CHECK(A_35 == doctest::Approx(60.687f).epsilon(0.005));
1544}
1545
1546DOCTEST_TEST_CASE("PhotosynthesisModel C4 - Vpr cap activates at high Cm") {
1547 // Per paper Eq. 19, Vp = min(Cm·Vpmax/(Cm+Kp), Vpr). At Cm above roughly 55 μbar (with Vpmax=200, Kp=82),
1548 // Vp_MM exceeds the default Vpr=80 and the cap must activate. Ensure the reported Vp never exceeds Vpr.
1549 Context context_test;
1550 uint UUID = context_test.addPatch(make_vec3(0, 0, 0), make_vec2(1, 1));
1551 PhotosynthesisModel photomodel(&context_test);
1552 photomodel.disableMessages();
1553 photomodel.setModelType_C4();
1554 photomodel.optionalOutputPrimitiveData("Vp");
1555
1556 context_test.setPrimitiveData(UUID, "temperature", 298.15f);
1557 context_test.setPrimitiveData(UUID, "radiation_flux_PAR", 2000.f / 4.57f);
1558 context_test.setPrimitiveData(UUID, "air_CO2", 400.f);
1559 photomodel.setCi(600.f, {UUID});
1560
1561 DOCTEST_CHECK_NOTHROW(photomodel.run());
1562 float Vp_out = 0.f;
1563 context_test.getPrimitiveData(UUID, "Vp", Vp_out);
1564 C4ModelCoefficients defaults;
1565 DOCTEST_CHECK(Vp_out <= defaults.Vpr + 1e-4f);
1566 DOCTEST_CHECK(Vp_out == doctest::Approx(defaults.Vpr).epsilon(1e-3)); // should hit the cap exactly at this Ci
1567}
1568
1569DOCTEST_TEST_CASE("PhotosynthesisModel C4 - Patched Woodford spreadsheet anchors") {
1570 // Validate A across the (T, Cm) grid against C4_test_anchors.csv, generated by
1571 // doc/generate_woodford_anchors.py from the patched Woodford 2025 spreadsheet
1572 // (γ* sign corrected, forward J(T,I) curvature chain installed). See
1573 // plugins/photosynthesis/doc/C4_spreadsheet_audit.md for the spreadsheet patches.
1574 //
1575 // To isolate validation of the Ac/Aj quadratics and the γ*(T) sign correction
1576 // from the separate Jmax-form difference (Helios uses peaked-Arrhenius;
1577 // spreadsheet uses Gaussian — they match at 25 °C and Topt=43 °C by construction
1578 // but diverge a few percent at intermediate temperatures), this test pins
1579 // Helios's Jmax to the Gaussian-derived value at each test temperature using
1580 // setJmax(value) which gives a constant (no-Arrhenius) T-response. Helios's
1581 // own Jmax temperature response is exercised separately by the "Temperature
1582 // response wires through to A" test above.
1583 //
1584 // The CSV transitions naturally between Ac- and Aj-limited regimes:
1585 // - At T=25 °C, all anchors are Ac-limited (Ac plateau ~37, Aj plateau ~52).
1586 // - At T=35 °C, Aj limits from Cm ~50 onward.
1587 // - At T=40 °C, Aj limits from Cm ~40 onward.
1588 // Comparing Helios's net_photosynthesis (= min(Ac, Aj)) against Amin therefore
1589 // covers both quadratics across the temperature grid.
1590
1591 auto gaussian_Jmax = [](float T_C) {
1592 const float Topt = 43.f;
1593 const float Omega = 26.f;
1594 const float Jmax_at_Topt = 400.f; // Helios C4 default
1595 const float dT = (T_C - Topt) / Omega;
1596 return Jmax_at_Topt * std::exp(-dT * dT);
1597 };
1598
1599 const std::filesystem::path csv_path = helios::resolveFilePath("plugins/photosynthesis/tests/C4_test_anchors.csv");
1600 std::ifstream csv(csv_path);
1601 DOCTEST_REQUIRE_MESSAGE(csv.good(), "Failed to open " << csv_path.string());
1602
1603 Context context_test;
1604 uint UUID = context_test.addPatch(make_vec3(0, 0, 0), make_vec2(1, 1));
1605 PhotosynthesisModel photomodel(&context_test);
1606 photomodel.disableMessages();
1607 DOCTEST_CHECK_NOTHROW(photomodel.setModelType_C4());
1608
1609 DOCTEST_CHECK_NOTHROW(context_test.setPrimitiveData(UUID, "radiation_flux_PAR", 2000.f / 4.57f));
1610 DOCTEST_CHECK_NOTHROW(context_test.setPrimitiveData(UUID, "air_CO2", 400.f));
1611
1612 std::string line;
1613 std::getline(csv, line); // header
1614 DOCTEST_REQUIRE(line.substr(0, 8) == "scenario");
1615
1616 int n_points = 0;
1617 while (std::getline(csv, line)) {
1618 if (line.empty()) continue;
1619 std::stringstream ss(line);
1620 std::string scenario, T_str, Cm_str, Ac_str, Aj_str, Amin_str;
1621 std::getline(ss, scenario, ',');
1622 std::getline(ss, T_str, ',');
1623 std::getline(ss, Cm_str, ',');
1624 std::getline(ss, Ac_str, ',');
1625 std::getline(ss, Aj_str, ',');
1626 std::getline(ss, Amin_str, ',');
1627 const float T_C = std::stof(T_str);
1628 const float Cm = std::stof(Cm_str);
1629 const float Amin_expected = std::stof(Amin_str);
1630
1632 c4.Vpr = 1.0e4f; // disable Vpr cap (matches spreadsheet)
1633 c4.setJmax(gaussian_Jmax(T_C)); // dHa = 0 → Helios sees this exact Jmax at T_C
1634 photomodel.setModelCoefficients(c4);
1635
1636 photomodel.setCm(Cm, {UUID});
1637 context_test.setPrimitiveData(UUID, "temperature", T_C + 273.15f);
1638 DOCTEST_CHECK_NOTHROW(photomodel.run());
1639 float A;
1640 context_test.getPrimitiveData(UUID, "net_photosynthesis", A);
1641 DOCTEST_CHECK_MESSAGE(A == doctest::Approx(Amin_expected).epsilon(0.005),
1642 "T=" << T_C << "°C Cm=" << Cm
1643 << " expected A=" << Amin_expected << " got A=" << A);
1644 ++n_points;
1645 }
1646 DOCTEST_CHECK_MESSAGE(n_points >= 60, "Expected ≥60 anchor rows in CSV; got " << n_points);
1647}
1648
1649DOCTEST_TEST_CASE("PhotosynthesisModel C4 - Material-data caching round-trip") {
1650 // Verify that C4 coefficients assigned via material label are correctly serialized to
1651 // primitive-material data, retrieved on the next run(), and produce the same A as supplying
1652 // the same coefficients via the per-UUID setModelCoefficients overload.
1653 Context context_test;
1654 uint UUID = context_test.addPatch(make_vec3(0, 0, 0), make_vec2(1, 1));
1655 PhotosynthesisModel photomodel(&context_test);
1656 photomodel.disableMessages();
1657
1658 // Build a custom C4 coefficient set deliberately different from defaults
1659 C4ModelCoefficients custom;
1660 custom.setVcmax(60.f, 70.f);
1661 custom.setJmax(300.f);
1662 custom.Vpr = 1.0e4f; // disable cap so Vcmax differences register at Cm=50
1663 custom.alpha_psII_fraction = 0.05f;
1664
1665 // Assign via material label
1666 const std::string mat = "test_c4_material";
1667 context_test.addMaterial(mat);
1668 context_test.assignMaterialToPrimitive(UUID, mat);
1669 photomodel.setModelCoefficients(mat, custom);
1670
1671 context_test.setPrimitiveData(UUID, "temperature", 298.15f);
1672 context_test.setPrimitiveData(UUID, "radiation_flux_PAR", 2000.f / 4.57f);
1673 context_test.setPrimitiveData(UUID, "air_CO2", 400.f);
1674 photomodel.setCm(50.f, {UUID});
1675 DOCTEST_CHECK_NOTHROW(photomodel.run());
1676 float A_material;
1677 context_test.getPrimitiveData(UUID, "net_photosynthesis", A_material);
1678
1679 // Re-create scenario applying the same coefficients via per-UUID setter on a fresh context
1680 Context ctx2;
1681 uint UUID2 = ctx2.addPatch(make_vec3(0, 0, 0), make_vec2(1, 1));
1682 PhotosynthesisModel pm2(&ctx2);
1683 pm2.disableMessages();
1684 pm2.setModelCoefficients(custom, {UUID2});
1685 ctx2.setPrimitiveData(UUID2, "temperature", 298.15f);
1686 ctx2.setPrimitiveData(UUID2, "radiation_flux_PAR", 2000.f / 4.57f);
1687 ctx2.setPrimitiveData(UUID2, "air_CO2", 400.f);
1688 pm2.setCm(50.f, {UUID2});
1689 DOCTEST_CHECK_NOTHROW(pm2.run());
1690 float A_uuid;
1691 ctx2.getPrimitiveData(UUID2, "net_photosynthesis", A_uuid);
1692
1693 // Two paths must agree to high precision (only difference is the serialization round-trip)
1694 DOCTEST_CHECK(A_material == doctest::Approx(A_uuid).epsilon(1e-4));
1695
1696 // Round-trip the coefficients struct itself and spot-check a few fields
1697 C4ModelCoefficients retrieved = photomodel.getC4ModelCoefficients(UUID);
1698 DOCTEST_CHECK(retrieved.getVcmaxTempResponse().value_at_25C == doctest::Approx(60.f));
1699 DOCTEST_CHECK(retrieved.getVcmaxTempResponse().dHa == doctest::Approx(70.f));
1700 DOCTEST_CHECK(retrieved.getJmaxTempResponse().value_at_25C == doctest::Approx(300.f));
1701 DOCTEST_CHECK(retrieved.Vpr == doctest::Approx(1.0e4f));
1702 DOCTEST_CHECK(retrieved.alpha_psII_fraction == doctest::Approx(0.05f));
1703}
1704
1705DOCTEST_TEST_CASE("PhotosynthesisModel C4 - Coefficient assignment per-UUID") {
1706 Context context_test;
1707 uint UUID1 = context_test.addPatch(make_vec3(0, 0, 0), make_vec2(1, 1));
1708 uint UUID2 = context_test.addPatch(make_vec3(1, 0, 0), make_vec2(1, 1));
1709 PhotosynthesisModel photomodel(&context_test);
1710 photomodel.disableMessages();
1711
1712 C4ModelCoefficients c4_hi;
1713 c4_hi.setVcmax(80.f);
1714 c4_hi.Vpr = 1.0e4f; // disable Vpr cap so Vcmax differences propagate to A in the Ac-limited regime
1715 photomodel.setModelCoefficients(c4_hi, {UUID1});
1716
1717 C4ModelCoefficients c4_lo;
1718 c4_lo.setVcmax(20.f);
1719 c4_lo.Vpr = 1.0e4f;
1720 photomodel.setModelCoefficients(c4_lo, {UUID2});
1721
1722 DOCTEST_CHECK(photomodel.getC4ModelCoefficients(UUID1).getVcmaxTempResponse().value_at_25C == doctest::Approx(80.f));
1723 DOCTEST_CHECK(photomodel.getC4ModelCoefficients(UUID2).getVcmaxTempResponse().value_at_25C == doctest::Approx(20.f));
1724
1725 // Behavioral check: run the model on both UUIDs and confirm net_photosynthesis differs.
1726 // At Cm = 50 with Vcmax = 80 vs 20, the Ac-limited rate should clearly diverge.
1727 context_test.setPrimitiveData(UUID1, "temperature", 298.15f);
1728 context_test.setPrimitiveData(UUID2, "temperature", 298.15f);
1729 context_test.setPrimitiveData(UUID1, "radiation_flux_PAR", 2000.f / 4.57f);
1730 context_test.setPrimitiveData(UUID2, "radiation_flux_PAR", 2000.f / 4.57f);
1731 context_test.setPrimitiveData(UUID1, "air_CO2", 400.f);
1732 context_test.setPrimitiveData(UUID2, "air_CO2", 400.f);
1733 photomodel.setCm(50.f, {UUID1, UUID2});
1734 DOCTEST_CHECK_NOTHROW(photomodel.run());
1735
1736 float A1, A2;
1737 context_test.getPrimitiveData(UUID1, "net_photosynthesis", A1);
1738 context_test.getPrimitiveData(UUID2, "net_photosynthesis", A2);
1739 DOCTEST_CHECK(A1 > A2 + 1.f); // higher Vcmax should yield meaningfully higher A
1740}
1741
1742// -----------------------------------------------------------------------------
1743// C4 species parameter library tests
1744// -----------------------------------------------------------------------------
1745
1746DOCTEST_TEST_CASE("PhotosynthesisModel C4 Library - Parameter switching") {
1747 // Verify each library entry populates the expected characteristic fields and that entries
1748 // are distinguishable from one another. Also exercise case-insensitive key matching.
1749 Context context_test;
1750 uint UUID = context_test.addPatch(make_vec3(0, 0, 0), make_vec2(1, 1));
1751 PhotosynthesisModel photomodel(&context_test);
1752 photomodel.disableMessages();
1753
1754 SUBCASE("SetariaViridis_vC2021") {
1755 DOCTEST_CHECK_NOTHROW(photomodel.setC4CoefficientsFromLibrary("SetariaViridis_vC2021"));
1756 C4ModelCoefficients c = photomodel.getC4ModelCoefficients(UUID);
1757 DOCTEST_CHECK(c.getVcmaxTempResponse().value_at_25C == doctest::Approx(40.f).epsilon(err_tol));
1758 DOCTEST_CHECK(c.getVcmaxTempResponse().dHa == doctest::Approx(78.f).epsilon(err_tol));
1759 DOCTEST_CHECK(c.getVpmaxTempResponse().value_at_25C == doctest::Approx(200.f).epsilon(err_tol));
1760 DOCTEST_CHECK(c.getVpmaxTempResponse().dHa == doctest::Approx(50.1f).epsilon(err_tol));
1761 DOCTEST_CHECK(c.getRdTempResponse().value_at_25C == doctest::Approx(0.4f).epsilon(err_tol));
1762 DOCTEST_CHECK(c.getMesophyllConductance_gmTempResponse().value_at_25C == doctest::Approx(1.0f).epsilon(err_tol));
1763 DOCTEST_CHECK(c.Kc_25 == doctest::Approx(1210.f).epsilon(err_tol));
1764 DOCTEST_CHECK(c.Kp_25 == doctest::Approx(82.f).epsilon(err_tol));
1765 DOCTEST_CHECK(c.dH_Kc == doctest::Approx(64.2f).epsilon(err_tol));
1766 DOCTEST_CHECK(c.fcyc == doctest::Approx(0.45f).epsilon(err_tol));
1767 DOCTEST_CHECK(c.H_Jcyc == doctest::Approx(3.4f).epsilon(err_tol));
1768 }
1769
1770 SUBCASE("GenericC4_vC2000") {
1771 DOCTEST_CHECK_NOTHROW(photomodel.setC4CoefficientsFromLibrary("GenericC4_vC2000"));
1772 C4ModelCoefficients c = photomodel.getC4ModelCoefficients(UUID);
1773 DOCTEST_CHECK(c.getVcmaxTempResponse().value_at_25C == doctest::Approx(60.f).epsilon(err_tol));
1774 DOCTEST_CHECK(c.getVpmaxTempResponse().value_at_25C == doctest::Approx(120.f).epsilon(err_tol));
1775 DOCTEST_CHECK(c.getJmaxTempResponse().value_at_25C == doctest::Approx(400.f).epsilon(err_tol));
1776 DOCTEST_CHECK(c.getRdTempResponse().value_at_25C == doctest::Approx(1.0f).epsilon(err_tol));
1777 // Q10 → Arrhenius: Ea ≈ 61.6 kJ/mol for Q10=2.3, 51.2 for Q10=2.0
1778 DOCTEST_CHECK(c.getVcmaxTempResponse().dHa == doctest::Approx(61.6f).epsilon(err_tol));
1779 DOCTEST_CHECK(c.getRdTempResponse().dHa == doctest::Approx(51.2f).epsilon(err_tol));
1780 DOCTEST_CHECK(c.getMesophyllConductance_gmTempResponse().value_at_25C == doctest::Approx(1.0e4f).epsilon(err_tol));
1781 DOCTEST_CHECK(c.Kp_25 == doctest::Approx(80.f).epsilon(err_tol));
1782 DOCTEST_CHECK(c.fcyc == doctest::Approx(0.f).epsilon(err_tol));
1783 }
1784
1785 SUBCASE("Maize_Massad2007") {
1786 DOCTEST_CHECK_NOTHROW(photomodel.setC4CoefficientsFromLibrary("Maize_Massad2007"));
1787 C4ModelCoefficients c = photomodel.getC4ModelCoefficients(UUID);
1788 DOCTEST_CHECK(c.getVcmaxTempResponse().value_at_25C == doctest::Approx(60.f).epsilon(err_tol));
1789 DOCTEST_CHECK(c.getVcmaxTempResponse().dHa == doctest::Approx(67.3f).epsilon(err_tol));
1790 // Peaked-Arrhenius conversion of Massad's (Ea, Hd, dS) → Topt
1791 DOCTEST_CHECK(c.getVcmaxTempResponse().Topt == doctest::Approx(273.15f + 32.3f).epsilon(err_tol));
1792 DOCTEST_CHECK(c.getVpmaxTempResponse().Topt == doctest::Approx(273.15f + 43.1f).epsilon(err_tol));
1793 DOCTEST_CHECK(c.getJmaxTempResponse().Topt == doctest::Approx(273.15f + 31.5f).epsilon(err_tol));
1794 // Bernacchi 2001 C3-derived Kc/Ko — the critical internal-consistency constraint for Massad
1795 DOCTEST_CHECK(c.Kc_25 == doctest::Approx(650.f).epsilon(err_tol));
1796 DOCTEST_CHECK(c.Ko_25 == doctest::Approx(450000.f).epsilon(err_tol));
1797 DOCTEST_CHECK(c.dH_Kc == doctest::Approx(79.43f).epsilon(err_tol));
1798 DOCTEST_CHECK(c.Kp_25 == doctest::Approx(80.f).epsilon(err_tol));
1799 DOCTEST_CHECK(c.getRdTempResponse().value_at_25C == doctest::Approx(0.6f).epsilon(err_tol));
1800 DOCTEST_CHECK(c.getMesophyllConductance_gmTempResponse().value_at_25C == doctest::Approx(1.0e4f).epsilon(err_tol));
1801 DOCTEST_CHECK(c.fcyc == doctest::Approx(0.f).epsilon(err_tol));
1802 }
1803
1804 SUBCASE("Case-insensitive key matching") {
1805 DOCTEST_CHECK_NOTHROW(photomodel.getC4CoefficientsFromLibrary("setariaviridis_vc2021"));
1806 DOCTEST_CHECK_NOTHROW(photomodel.getC4CoefficientsFromLibrary("MAIZE_MASSAD2007"));
1807 DOCTEST_CHECK_NOTHROW(photomodel.getC4CoefficientsFromLibrary("GenericC4"));
1808 }
1809
1810 SUBCASE("Entries are distinguishable") {
1811 // Key library invariant: the three entries must differ in at least one characteristic
1812 // field each, otherwise picking between them has no effect on model behavior.
1813 C4ModelCoefficients setaria = photomodel.getC4CoefficientsFromLibrary("SetariaViridis_vC2021");
1814 C4ModelCoefficients generic = photomodel.getC4CoefficientsFromLibrary("GenericC4_vC2000");
1815 C4ModelCoefficients maize = photomodel.getC4CoefficientsFromLibrary("Maize_Massad2007");
1816
1817 DOCTEST_CHECK(setaria.getVcmaxTempResponse().value_at_25C != generic.getVcmaxTempResponse().value_at_25C);
1818 DOCTEST_CHECK(setaria.Kc_25 != maize.Kc_25); // 1210 vs 650 — the critical Massad/Bernacchi distinction
1819 DOCTEST_CHECK(setaria.fcyc != generic.fcyc); // 0.45 vs 0
1820 // Simple Arrhenius (no peak) stores Topt as a large sentinel (≥10000 K by construction of
1821 // PhotosyntheticTemperatureResponseParameters); peaked Arrhenius stores an actual temperature in K.
1822 DOCTEST_CHECK(generic.getVpmaxTempResponse().Topt > 9000.f); // generic = simple Arrhenius, sentinel Topt
1823 DOCTEST_CHECK(maize.getVpmaxTempResponse().Topt < 400.f); // maize = peaked Arrhenius, Topt ≈ 316 K
1824 }
1825}
1826
1827DOCTEST_TEST_CASE("PhotosynthesisModel C4 Library - Reasonable photosynthesis values") {
1828 // For each library entry, run the full model (with Ci→Cm coupling and stomatal balance) under
1829 // realistic midday conditions and confirm net photosynthesis falls within a reasonable C4
1830 // range, and that a low-light run produces smaller, still-positive A.
1831 const std::vector<std::string> species = {"SetariaViridis_vC2021", "GenericC4_vC2000", "Maize_Massad2007"};
1832
1833 for (const auto &sp: species) {
1834 Context context_test;
1835 uint UUID = context_test.addPatch(make_vec3(0, 0, 0), make_vec2(1, 1));
1836 PhotosynthesisModel photomodel(&context_test);
1837 photomodel.disableMessages();
1838 photomodel.setC4CoefficientsFromLibrary(sp);
1839 photomodel.optionalOutputPrimitiveData("limitation_state");
1840
1841 context_test.setPrimitiveData(UUID, "temperature", 298.15f);
1842 context_test.setPrimitiveData(UUID, "air_CO2", 400.f);
1843
1844 // High-light midday
1845 context_test.setPrimitiveData(UUID, "radiation_flux_PAR", 1800.f / 4.57f);
1846 DOCTEST_CHECK_NOTHROW(photomodel.run());
1847 float A_high = 0.f;
1848 int limitation_state = 0;
1849 context_test.getPrimitiveData(UUID, "net_photosynthesis", A_high);
1850 context_test.getPrimitiveData(UUID, "limitation_state", limitation_state);
1851 // Reasonable-range bounds are deliberately loose — the goal is to catch gross errors
1852 // (A near zero, or A in the hundreds from a unit mix-up), not to pin exact values.
1853 // Current implementation produces 34.6 (Setaria), 40.5 (Generic), 40.8 (Maize).
1854 bool A_high_in_range = (A_high >= 10.f) && (A_high <= 60.f);
1855 bool lim_valid = (limitation_state == 1) || (limitation_state == 2);
1856 DOCTEST_CHECK_MESSAGE(A_high_in_range, sp << ": A at high PAR out of reasonable C4 range (" << A_high << ")");
1857 DOCTEST_CHECK_MESSAGE(lim_valid, sp << ": unexpected limitation_state " << limitation_state);
1858
1859 // Low-light: should be positive and lower than high-light
1860 context_test.setPrimitiveData(UUID, "radiation_flux_PAR", 200.f / 4.57f);
1861 DOCTEST_CHECK_NOTHROW(photomodel.run());
1862 float A_low = 0.f;
1863 context_test.getPrimitiveData(UUID, "net_photosynthesis", A_low);
1864 bool A_low_valid = (A_low > 0.f) && (A_low < A_high);
1865 DOCTEST_CHECK_MESSAGE(A_low_valid, sp << ": low-light A=" << A_low << " (should be 0<A<" << A_high << ")");
1866 }
1867}
1868
1869DOCTEST_TEST_CASE("PhotosynthesisModel C4 Library - Temperature discrimination") {
1870 // Setaria's Vcmax is simple Arrhenius (monotonic) while Maize's Vcmax is peaked at ~32 °C.
1871 // Confirm both give positive A at 25 °C and 35 °C, and catch the degenerate case where
1872 // switching species produces identical A (would indicate the library didn't actually switch).
1873 auto run_once = [](const std::string &sp, float T_K) {
1874 Context ctx;
1875 uint UUID = ctx.addPatch(make_vec3(0, 0, 0), make_vec2(1, 1));
1876 PhotosynthesisModel pm(&ctx);
1877 pm.disableMessages();
1878 pm.setC4CoefficientsFromLibrary(sp);
1879 ctx.setPrimitiveData(UUID, "temperature", T_K);
1880 ctx.setPrimitiveData(UUID, "air_CO2", 400.f);
1881 ctx.setPrimitiveData(UUID, "radiation_flux_PAR", 1800.f / 4.57f);
1882 pm.run();
1883 float A = 0.f;
1884 ctx.getPrimitiveData(UUID, "net_photosynthesis", A);
1885 return A;
1886 };
1887
1888 float setaria_25 = run_once("SetariaViridis_vC2021", 298.15f);
1889 float setaria_35 = run_once("SetariaViridis_vC2021", 308.15f);
1890 float maize_25 = run_once("Maize_Massad2007", 298.15f);
1891 float maize_35 = run_once("Maize_Massad2007", 308.15f);
1892
1893 // All four scenarios should produce positive A
1894 DOCTEST_CHECK(setaria_25 > 0.f);
1895 DOCTEST_CHECK(setaria_35 > 0.f);
1896 DOCTEST_CHECK(maize_25 > 0.f);
1897 DOCTEST_CHECK(maize_35 > 0.f);
1898
1899 // The two species must differ at both temperatures (otherwise the library didn't switch)
1900 DOCTEST_CHECK(std::abs(setaria_25 - maize_25) > 0.5f);
1901 DOCTEST_CHECK(std::abs(setaria_35 - maize_35) > 0.5f);
1902
1903 // Setaria: monotonic Arrhenius Vcmax → A should rise meaningfully from 25→35 °C
1904 DOCTEST_CHECK(setaria_35 > setaria_25);
1905}
1906
1907DOCTEST_TEST_CASE("PhotosynthesisModel C4 Library - Unknown species raises error") {
1908 Context context_test;
1909 context_test.addPatch(make_vec3(0, 0, 0), make_vec2(1, 1));
1910 PhotosynthesisModel photomodel(&context_test);
1911 photomodel.disableMessages();
1912
1913 DOCTEST_CHECK_THROWS_AS(photomodel.getC4CoefficientsFromLibrary("NotARealSpecies"), std::runtime_error);
1914 DOCTEST_CHECK_THROWS_AS(photomodel.setC4CoefficientsFromLibrary(""), std::runtime_error);
1915 // Typo should fail fast rather than silently fall back
1916 DOCTEST_CHECK_THROWS_AS(photomodel.getC4CoefficientsFromLibrary("Maiz"), std::runtime_error);
1917}
1918
1919
1920// ---------------------------------------------------------------------------
1921// Regression tests for issues found in the deep-dive review.
1922// Each of these FAILS on the pre-fix code.
1923// ---------------------------------------------------------------------------
1924
1925DOCTEST_TEST_CASE("PhotosynthesisModel Empirical temperature response is applied") {
1926 // REGRESSION: the documented f_T term (Tmin/Topt/q/Tref) was declared and serialized
1927 // but never used in the assimilation equation, so temperature entered only via Rd.
1928 Context context_test;
1929 uint UUID = context_test.addPatch(make_vec3(0, 0, 0), make_vec2(1, 1));
1930 context_test.setPrimitiveData(UUID, "radiation_flux_PAR", 200.f);
1931
1932 PhotosynthesisModel photomodel(&context_test);
1933 photomodel.disableMessages();
1934
1936 photomodel.setModelCoefficients(emp);
1937
1938 // Evaluate at the optimum and well beyond it. f_T peaks at Topt, so A must decline.
1939 context_test.setPrimitiveData(UUID, "temperature", emp.Topt);
1940 photomodel.run();
1941 float A_at_opt;
1942 context_test.getPrimitiveData(UUID, "net_photosynthesis", A_at_opt);
1943
1944 context_test.setPrimitiveData(UUID, "temperature", emp.Topt + 12.f);
1945 photomodel.run();
1946 float A_hot;
1947 context_test.getPrimitiveData(UUID, "net_photosynthesis", A_hot);
1948
1949 // Assimilation at the optimum must exceed assimilation 12 K above it.
1950 DOCTEST_CHECK(A_at_opt > A_hot);
1951
1952 // Changing Topt alone must change the answer at fixed leaf temperature.
1953 context_test.setPrimitiveData(UUID, "temperature", 310.f);
1954 photomodel.run();
1955 float A_default_topt;
1956 context_test.getPrimitiveData(UUID, "net_photosynthesis", A_default_topt);
1957
1958 EmpiricalModelCoefficients emp_shifted;
1959 emp_shifted.Topt = 280.f;
1960 photomodel.setModelCoefficients(emp_shifted);
1961 photomodel.run();
1962 float A_shifted_topt;
1963 context_test.getPrimitiveData(UUID, "net_photosynthesis", A_shifted_topt);
1964
1965 DOCTEST_CHECK(std::abs(A_default_topt - A_shifted_topt) > 1e-4f);
1966
1967 // Below Tmin, assimilation is clamped off (gross assimilation zero, so A = -Rd < 0).
1969 photomodel.setModelCoefficients(emp_cold);
1970 context_test.setPrimitiveData(UUID, "temperature", emp_cold.Tmin - 5.f);
1971 photomodel.run();
1972 float A_cold;
1973 context_test.getPrimitiveData(UUID, "net_photosynthesis", A_cold);
1974 DOCTEST_CHECK(A_cold < 0.f);
1975}
1976
1977DOCTEST_TEST_CASE("PhotosynthesisModel Empirical invalid temperature coefficients throw below Tmin") {
1978 // REGRESSION: the Tref>Tmin / non-degenerate-denominator validation sat *after* the `TL <= Tmin`
1979 // early return, so an invalid coefficient set was only diagnosed on timesteps where the leaf
1980 // happened to be warmer than Tmin. On a cold timestep the model silently returned a well-formed
1981 // A = -Rd instead, hiding the misconfiguration for part of a diurnal/seasonal run. The coefficients
1982 // are a property of the set, not of the current temperature, so the check must fire either way.
1983 Context context_test;
1984 uint UUID = context_test.addPatch(make_vec3(0, 0, 0), make_vec2(1, 1));
1985
1986 PhotosynthesisModel photomodel(&context_test);
1987 photomodel.disableMessages();
1988 photomodel.setModelType_Empirical();
1989
1990 // Tref below Tmin -- e.g. Tref and Tmin accidentally swapped, or one entered in Celsius.
1991 EmpiricalModelCoefficients bad_coeffs;
1992 bad_coeffs.Tmin = 290.f;
1993 bad_coeffs.Tref = 280.f;
1994 photomodel.setModelCoefficients(bad_coeffs);
1995
1996 // Leaf at or below Tmin: the path that previously bypassed the check entirely.
1997 context_test.setPrimitiveData(UUID, "temperature", bad_coeffs.Tmin - 5.f);
1998 DOCTEST_CHECK_THROWS(photomodel.run());
1999
2000 // Above Tmin the same invalid set must of course still throw.
2001 context_test.setPrimitiveData(UUID, "temperature", bad_coeffs.Tmin + 5.f);
2002 DOCTEST_CHECK_THROWS(photomodel.run());
2003}
2004
2005DOCTEST_TEST_CASE("PhotosynthesisModel Farquhar material round-trip preserves TPU and theta") {
2006 // REGRESSION: setModelCoefficients(material,...) serialized TPU_flag but not TPU itself,
2007 // and never serialized theta. TPU silently reverted to the struct default (2.0) while
2008 // TPU_flag stayed 1, and the non-rectangular light response silently became rectangular.
2009 Context context_test;
2010 context_test.addMaterial("fq_roundtrip_mat");
2011 uint UUID = context_test.addPatch(make_vec3(0, 0, 0), make_vec2(1, 1));
2012 context_test.assignMaterialToPrimitive(UUID, "fq_roundtrip_mat");
2013
2014 PhotosynthesisModel photomodel(&context_test);
2015 photomodel.disableMessages();
2016
2018 fq.setVcmax(74.5f, 76.1f);
2019 fq.setJmax(180.2f, 23.0f);
2020 fq.setRd(1.3f, 46.39f);
2021 fq.setQuantumEfficiency_alpha(0.304f);
2023 fq.setTPU(7.7f, 24.0f);
2024
2025 photomodel.setModelCoefficients("fq_roundtrip_mat", fq);
2026 FarquharModelCoefficients rt = photomodel.getFarquharModelCoefficients(UUID);
2027
2028 DOCTEST_CHECK(rt.TPU_flag == 1);
2029 DOCTEST_CHECK(rt.getTPUTempResponse().value_at_25C == doctest::Approx(7.7f).epsilon(1e-4));
2030 DOCTEST_CHECK(rt.getTPUTempResponse().dHa == doctest::Approx(24.0f).epsilon(1e-4));
2031 DOCTEST_CHECK(rt.getLightResponseCurvatureTempResponse().value_at_25C == doctest::Approx(0.601f).epsilon(1e-4));
2032 DOCTEST_CHECK(rt.getQuantumEfficiencyTempResponse().value_at_25C == doctest::Approx(0.304f).epsilon(1e-4));
2033 DOCTEST_CHECK(rt.getVcmaxTempResponse().value_at_25C == doctest::Approx(74.5f).epsilon(1e-4));
2034
2035 // A material with TPU never set must round-trip with TPU limitation still disabled.
2036 context_test.addMaterial("fq_notpu_mat");
2037 uint UUID2 = context_test.addPatch(make_vec3(1, 0, 0), make_vec2(1, 1));
2038 context_test.assignMaterialToPrimitive(UUID2, "fq_notpu_mat");
2039
2041 fq_notpu.setVcmax(100.f, 65.33f);
2042 fq_notpu.setJmax(200.f, 46.36f);
2043 fq_notpu.setRd(1.5f, 46.39f);
2044 fq_notpu.setQuantumEfficiency_alpha(0.5f);
2045 photomodel.setModelCoefficients("fq_notpu_mat", fq_notpu);
2046
2047 FarquharModelCoefficients rt2 = photomodel.getFarquharModelCoefficients(UUID2);
2048 DOCTEST_CHECK(rt2.TPU_flag == 0);
2049 DOCTEST_CHECK(rt2.getLightResponseCurvatureTempResponse().value_at_25C == doctest::Approx(0.f).epsilon(1e-6));
2050}
2051
2052DOCTEST_TEST_CASE("PhotosynthesisModel peaked temperature response rejects dHd <= dHa") {
2053 // REGRESSION: respondToTemperature computed logf(dHd/dHa - 1), which is NaN when
2054 // dHd <= dHa. The NaN propagated into net_photosynthesis with no error and no warning
2055 // (the A == 0 convergence check is false for NaN).
2056 DOCTEST_CHECK_THROWS_AS(PhotosyntheticTemperatureResponseParameters(100.f, 65.f, 30.f, 50.f), std::runtime_error);
2057 DOCTEST_CHECK_THROWS_AS(PhotosyntheticTemperatureResponseParameters(100.f, 65.f, 30.f, 65.f), std::runtime_error);
2058
2060 DOCTEST_CHECK_THROWS_AS(fq.setVcmax(100.f, 65.f, 30.f, 50.f), std::runtime_error);
2061 DOCTEST_CHECK_THROWS_AS(fq.setJmax(200.f, 46.f, 30.f, 40.f), std::runtime_error);
2062 DOCTEST_CHECK_THROWS_AS(fq.setRd(1.5f, 46.f, 30.f, 46.f), std::runtime_error);
2063 DOCTEST_CHECK_THROWS_AS(fq.setTPU(7.7f, 24.f, 30.f, 20.f), std::runtime_error);
2064
2066 DOCTEST_CHECK_THROWS_AS(c4.setVpmax(200.f, 50.f, 30.f, 40.f), std::runtime_error);
2067 DOCTEST_CHECK_THROWS_AS(c4.setJmax(247.f, 77.9f, 43.f, 70.f), std::runtime_error);
2068
2069 // Valid peaked responses (dHd > dHa) must still be accepted and produce finite results.
2070 DOCTEST_CHECK_NOTHROW(fq.setVcmax(100.f, 65.f, 30.f, 200.f));
2071
2072 Context context_test;
2073 uint UUID = context_test.addPatch(make_vec3(0, 0, 0), make_vec2(1, 1));
2074 context_test.setPrimitiveData(UUID, "radiation_flux_PAR", 400.f);
2075 context_test.setPrimitiveData(UUID, "temperature", 300.f);
2076
2077 PhotosynthesisModel photomodel(&context_test);
2078 photomodel.disableMessages();
2080 good.setVcmax(100.f, 65.f, 30.f, 650.f);
2081 good.setJmax(200.f, 46.f);
2082 good.setRd(1.5f, 46.39f);
2083 good.setQuantumEfficiency_alpha(0.5f);
2084 photomodel.setModelCoefficients(good);
2085 photomodel.run();
2086 float A;
2087 context_test.getPrimitiveData(UUID, "net_photosynthesis", A);
2088 DOCTEST_CHECK(std::isfinite(A));
2089}
2090
2091DOCTEST_TEST_CASE("PhotosynthesisModel Farquhar setters take precedence over legacy fields") {
2092 // REGRESSION: evaluateCi_Farquhar gated on the legacy public scalars (Vcmax/Jmax/Rd/alpha)
2093 // before consulting the temperature-response objects, so assigning the public field
2094 // silently discarded a prior setter call AND switched to a different T-response equation.
2095 Context context_test;
2096 uint UUID = context_test.addPatch(make_vec3(0, 0, 0), make_vec2(1, 1));
2097 context_test.setPrimitiveData(UUID, "radiation_flux_PAR", 200.f);
2098 context_test.setPrimitiveData(UUID, "temperature", 298.15f);
2099
2100 PhotosynthesisModel photomodel(&context_test);
2101 photomodel.disableMessages();
2102 photomodel.setCi(250.f, {UUID});
2103
2105 baseline.setVcmax(100.f);
2106 baseline.setJmax(200.f);
2107 baseline.setRd(1.5f);
2108 baseline.setQuantumEfficiency_alpha(0.5f);
2109 photomodel.setModelCoefficients(baseline);
2110 photomodel.run();
2111 float A_setter;
2112 context_test.getPrimitiveData(UUID, "net_photosynthesis", A_setter);
2113
2114 // Calling the setter AFTER touching the legacy field must make the setter authoritative.
2115 FarquharModelCoefficients overridden;
2116 overridden.Vcmax = 50.f;
2117 overridden.setVcmax(100.f);
2118 overridden.setJmax(200.f);
2119 overridden.setRd(1.5f);
2120 overridden.setQuantumEfficiency_alpha(0.5f);
2121 photomodel.setModelCoefficients(overridden);
2122 photomodel.run();
2123 float A_overridden;
2124 context_test.getPrimitiveData(UUID, "net_photosynthesis", A_overridden);
2125
2126 DOCTEST_CHECK(A_overridden == doctest::Approx(A_setter).epsilon(1e-4));
2127
2128 // The setter must also clear the legacy sentinel so the two representations cannot diverge.
2129 DOCTEST_CHECK(overridden.Vcmax < 0.f);
2130}
2131
2132DOCTEST_TEST_CASE("PhotosynthesisModel Farquhar library values match documentation") {
2133 // REGRESSION: the documented species table diverged substantially from the shipped
2134 // library values (e.g. Almond doc Vcmax25=72.6 vs code 105.9).
2135 PhotosynthesisModel photomodel(nullptr);
2136 photomodel.disableMessages();
2137
2138 struct LibExpect {
2139 const char *species;
2140 float Vcmax25;
2141 float Jmax25;
2142 float Rd25;
2143 float alpha;
2144 };
2145
2146 // Values as published in plugins/photosynthesis/doc/Photosynthesis.dox
2147 const std::vector<LibExpect> expected{
2148 {"Almond", 72.6f, 144.2f, 0.2f, 0.094f},
2149 {"Walnut", 81.6f, 201.9f, 0.9f, 0.362f},
2150 {"Pistachio", 101.8f, 223.f, 1.5f, 0.216f},
2151 {"Apple", 101.08f, 167.03f, 3.00f, 0.432f},
2152 {"Cherry", 75.65f, 129.06f, 2.12f, 0.404f},
2153 {"Pear", 107.69f, 176.71f, 1.51f, 0.274f},
2154 {"Prune", 75.88f, 129.41f, 1.65f, 0.402f},
2155 {"Grape", 74.5f, 180.2f, 1.3f, 0.304f},
2156 {"Olive", 75.9f, 170.4f, 1.9f, 0.398f},
2157 {"Toyon", 52.8f, 142.4f, 0.8f, 0.29f},
2158 {"Elderberry", 37.7f, 149.7f, 1.3f, 0.202f},
2159 {"Maple", 96.4f, 168.f, 0.1f, 0.077f},
2160 {"Redbud", 68.5f, 132.4f, 0.8f, 0.41f},
2161 };
2162
2163 for (const auto &e: expected) {
2164 FarquharModelCoefficients c = photomodel.getFarquharCoefficientsFromLibrary(e.species);
2165 DOCTEST_INFO("species = " << e.species);
2166 DOCTEST_CHECK(c.getVcmaxTempResponse().value_at_25C == doctest::Approx(e.Vcmax25).epsilon(1e-3));
2167 DOCTEST_CHECK(c.getJmaxTempResponse().value_at_25C == doctest::Approx(e.Jmax25).epsilon(1e-3));
2168 DOCTEST_CHECK(c.getRdTempResponse().value_at_25C == doctest::Approx(e.Rd25).epsilon(1e-3));
2169 DOCTEST_CHECK(c.getQuantumEfficiencyTempResponse().value_at_25C == doctest::Approx(e.alpha).epsilon(1e-3));
2170 }
2171
2172 // Species documented with a TPU column must actually enable TPU limitation.
2173 for (const char *sp: {"Almond", "Walnut", "Pistachio"}) {
2174 FarquharModelCoefficients c = photomodel.getFarquharCoefficientsFromLibrary(sp);
2175 DOCTEST_INFO("species = " << sp);
2176 DOCTEST_CHECK(c.TPU_flag == 1);
2177 }
2178
2179 // Species with no TPU column in the docs must leave TPU limitation off.
2180 for (const char *sp: {"Apple", "Cherry", "Pear", "Prune"}) {
2181 FarquharModelCoefficients c = photomodel.getFarquharCoefficientsFromLibrary(sp);
2182 DOCTEST_INFO("species = " << sp);
2183 DOCTEST_CHECK(c.TPU_flag == 0);
2184 }
2185
2186 // The bare "Pistachio" key must resolve to the female cultivar, not the male one.
2187 FarquharModelCoefficients pist_default = photomodel.getFarquharCoefficientsFromLibrary("Pistachio");
2188 FarquharModelCoefficients pist_female = photomodel.getFarquharCoefficientsFromLibrary("PistachioFemale");
2189 FarquharModelCoefficients pist_male = photomodel.getFarquharCoefficientsFromLibrary("PistachioMale");
2190
2191 DOCTEST_CHECK(pist_default.getVcmaxTempResponse().value_at_25C == doctest::Approx(pist_female.getVcmaxTempResponse().value_at_25C).epsilon(1e-4));
2192 DOCTEST_CHECK(pist_default.getJmaxTempResponse().value_at_25C == doctest::Approx(pist_female.getJmaxTempResponse().value_at_25C).epsilon(1e-4));
2193 DOCTEST_CHECK(pist_default.TPU_flag == pist_female.TPU_flag);
2194
2195 // The male cultivar is a distinct, unpeaked fit with no TPU limitation.
2196 DOCTEST_CHECK(pist_male.getVcmaxTempResponse().value_at_25C == doctest::Approx(154.17f).epsilon(1e-3));
2197 DOCTEST_CHECK(pist_male.getJmaxTempResponse().value_at_25C == doctest::Approx(243.20f).epsilon(1e-3));
2198 DOCTEST_CHECK(pist_male.getRdTempResponse().value_at_25C == doctest::Approx(2.05f).epsilon(1e-3));
2199 DOCTEST_CHECK(pist_male.getQuantumEfficiencyTempResponse().value_at_25C == doctest::Approx(0.335f).epsilon(1e-3));
2200 DOCTEST_CHECK(pist_male.TPU_flag == 0);
2201 DOCTEST_CHECK(pist_male.getVcmaxTempResponse().value_at_25C != doctest::Approx(pist_female.getVcmaxTempResponse().value_at_25C).epsilon(1e-4));
2202}
2203
2204int PhotosynthesisModel::selfTest(int argc, char **argv) {
2205 return helios::runDoctestWithValidation(argc, argv);
2206}