1.3.77
 
Loading...
Searching...
No Matches
RadiationModel.cpp
Go to the documentation of this file.
1
16#include "RadiationModel.h"
17#include "BufferIndexing.h"
18#include <climits>
19#include <cmath>
20#include <cstring>
21#include <ctime>
22#include <filesystem>
23#include <fstream>
24#include <iomanip>
25#include <sstream>
26#include <unordered_set>
27
28using namespace helios;
29
31
32 context = context_a;
33
34 // Asset directory registration removed - now using HELIOS_BUILD resolution
35
36 // All default values set here
37
38 message_flag = true;
39
40 directRayCount_default = 100;
41 diffuseRayCount_default = 1000;
42
43 diffuseFlux_default = -1.f;
44
45 minScatterEnergy_default = 0.1;
46 scatteringDepth_default = 0;
47
48 rho_default = 0.f;
49 tau_default = 0.f;
50 eps_default = 1.f;
51
52 kappa_default = 1.f;
53 sigmas_default = 0.f;
54
55 temperature_default = 300;
56
57 periodic_flag = make_vec2(0, 0);
58
59 spectral_library_files.push_back(helios::resolvePluginAsset("radiation", "spectral_data/camera_spectral_library.xml").string());
60 spectral_library_files.push_back(helios::resolvePluginAsset("radiation", "spectral_data/light_spectral_library.xml").string());
61 spectral_library_files.push_back(helios::resolvePluginAsset("radiation", "spectral_data/soil_surface_spectral_library.xml").string());
62 spectral_library_files.push_back(helios::resolvePluginAsset("radiation", "spectral_data/leaf_surface_spectral_library.xml").string());
63 spectral_library_files.push_back(helios::resolvePluginAsset("radiation", "spectral_data/bark_surface_spectral_library.xml").string());
64 spectral_library_files.push_back(helios::resolvePluginAsset("radiation", "spectral_data/fruit_surface_spectral_library.xml").string());
65 spectral_library_files.push_back(helios::resolvePluginAsset("radiation", "spectral_data/solar_spectrum_ASTMG173.xml").string());
66 spectral_library_files.push_back(helios::resolvePluginAsset("radiation", "spectral_data/color_board/Calibrite_ColorChecker_Classic_colorboard.xml").string());
67 spectral_library_files.push_back(helios::resolvePluginAsset("radiation", "spectral_data/color_board/DGK_DKK_colorboard.xml").string());
68
69 // Initialize backend abstraction layer with runtime hardware detection
70 backend = helios::RayTracingBackend::create("auto");
71 backend->initialize();
72
73 if (message_flag) {
74 std::string backend_name = backend->getBackendName();
75 std::cout << "Radiation model initialized with " << backend_name << " backend";
76 if (backend_name.find("Vulkan") != std::string::npos) {
77 std::cout << " - WARNING: radiation model may be slow depending on your GPU (NVIDIA+OptiX backend recommended)";
78 } else {
79 std::cout << ".";
80 }
81 std::cout << std::endl;
82 }
83}
84
85RadiationModel::RadiationModel(helios::Context *context_a, bool skip_backend_init) {
86 context = context_a;
87
88 // Initialize all default values (same as main constructor)
89 message_flag = true;
90 directRayCount_default = 100;
91 diffuseRayCount_default = 1000;
92 diffuseFlux_default = -1.f;
93 minScatterEnergy_default = 0.1;
94 scatteringDepth_default = 0;
95 rho_default = 0.f;
96 tau_default = 0.f;
97 eps_default = 1.f;
98 kappa_default = 1.f;
99 sigmas_default = 0.f;
100 temperature_default = 300;
101 periodic_flag = make_vec2(0, 0);
102
103 spectral_library_files.push_back(helios::resolvePluginAsset("radiation", "spectral_data/camera_spectral_library.xml").string());
104 spectral_library_files.push_back(helios::resolvePluginAsset("radiation", "spectral_data/light_spectral_library.xml").string());
105 spectral_library_files.push_back(helios::resolvePluginAsset("radiation", "spectral_data/soil_surface_spectral_library.xml").string());
106 spectral_library_files.push_back(helios::resolvePluginAsset("radiation", "spectral_data/leaf_surface_spectral_library.xml").string());
107 spectral_library_files.push_back(helios::resolvePluginAsset("radiation", "spectral_data/bark_surface_spectral_library.xml").string());
108 spectral_library_files.push_back(helios::resolvePluginAsset("radiation", "spectral_data/fruit_surface_spectral_library.xml").string());
109 spectral_library_files.push_back(helios::resolvePluginAsset("radiation", "spectral_data/solar_spectrum_ASTMG173.xml").string());
110 spectral_library_files.push_back(helios::resolvePluginAsset("radiation", "spectral_data/color_board/Calibrite_ColorChecker_Classic_colorboard.xml").string());
111 spectral_library_files.push_back(helios::resolvePluginAsset("radiation", "spectral_data/color_board/DGK_DKK_colorboard.xml").string());
112
113 // Skip backend creation - will be injected by caller
114}
115
116RadiationModel RadiationModel::createWithBackend(helios::Context *context, std::unique_ptr<helios::RayTracingBackend> backend) {
117 RadiationModel model(context, true); // Use private constructor, skip backend init
118
119 // Inject the provided backend
120 model.backend = std::move(backend);
121
122 return model; // Uses move constructor
123}
124
126 static bool checked = false;
127 static bool available = false;
128
129 if (checked) {
130 return available;
131 }
132 checked = true;
133
134 // Allow forcing unavailability for local CI simulation
135 const char *no_gpu = std::getenv("HELIOS_NO_GPU");
136 if (no_gpu && std::string(no_gpu) != "0") {
137 available = false;
138 return false;
139 }
140
141 // Lightweight probe without constructing a full backend
142 available = helios::probeAnyGPUBackend();
143
144 return available;
145}
146
148 // Backend's unique_ptr will automatically clean up OptiX context
149}
150
152 message_flag = false;
153}
154
156 message_flag = true;
157}
158
160 if (backend) {
161 return backend->getBackendName();
162 }
163 return "none";
164}
165
167
168 if (strcmp(label, "reflectivity") == 0 || strcmp(label, "transmissivity") == 0) {
169 output_prim_data.emplace_back(label);
170 } else {
171 std::cout << "WARNING (RadiationModel::optionalOutputPrimitiveData): unknown output primitive data " << label << std::endl;
172 }
173}
174
175void RadiationModel::setDirectRayCount(const std::string &label, size_t N) {
176 if (!doesBandExist(label)) {
177 helios_runtime_error("ERROR (RadiationModel::setDirectRayCount): Cannot set ray count for band '" + label + "' because it is not a valid band.");
178 }
179 radiation_bands.at(label).directRayCount = N;
180}
181
182void RadiationModel::setDiffuseRayCount(const std::string &label, size_t N) {
183 if (!doesBandExist(label)) {
184 helios_runtime_error("ERROR (RadiationModel::setDiffuseRayCount): Cannot set ray count for band '" + label + "' because it is not a valid band.");
185 }
186 radiation_bands.at(label).diffuseRayCount = N;
187}
188
189void RadiationModel::setDiffuseRadiationFlux(const std::string &label, float flux) {
190 if (!doesBandExist(label)) {
191 helios_runtime_error("ERROR (RadiationModel::setDiffuseRadiationFlux): Cannot set flux value for band '" + label + "' because it is not a valid band.");
192 }
193 radiation_bands.at(label).diffuseFlux = flux;
194}
195
196void RadiationModel::setDiffuseRadiationExtinctionCoeff(const std::string &label, float K, const SphericalCoord &peak_dir) {
198}
199
200void RadiationModel::setDiffuseRadiationExtinctionCoeff(const std::string &label, float K, const vec3 &peak_dir) {
201 if (!doesBandExist(label)) {
202 helios_runtime_error("ERROR (RadiationModel::setDiffuseRadiationExtinctionCoeff): Cannot set diffuse extinction value for band '" + label + "' because it is not a valid band.");
203 }
204
205 vec3 dir = peak_dir;
206 dir.normalize();
207
208 int N = 100;
209 float norm = 0.f;
210 for (int j = 0; j < N; j++) {
211 for (int i = 0; i < N; i++) {
212 float theta = 0.5f * M_PI / float(N) * (0.5f + float(i));
213 float phi = 2.f * M_PI / float(N) * (0.5f + float(j));
214 vec3 n = sphere2cart(make_SphericalCoord(0.5f * M_PI - theta, phi));
215
216 float psi = acos_safe(n * dir);
217 float fd;
218 if (psi < M_PI / 180.f) {
219 fd = powf(M_PI / 180.f, -K);
220 } else {
221 fd = powf(psi, -K);
222 }
223
224 norm += fd * cosf(theta) * sinf(theta) * M_PI / float(N * N);
225 // note: the multipication factors are dtheta*dphi/pi = (0.5*pi/N)*(2*pi/N)/pi = pi/N^2
226 }
227 }
228
229 radiation_bands.at(label).diffuseExtinction = K;
230 radiation_bands.at(label).diffusePeakDir = dir;
231 radiation_bands.at(label).diffuseDistNorm = 1.f / norm;
232}
233
234void RadiationModel::setDiffuseSpectrumIntegral(float spectrum_integral) {
235
236 if (spectrum_integral < 0) {
237 helios_runtime_error("ERROR (RadiationModel::setDiffuseSpectrumIntegral): Spectrum integral must be non-negative.");
238 } else if (global_diffuse_spectrum.empty()) {
239 helios_runtime_error("ERROR (RadiationModel::setDiffuseSpectrumIntegral): Global diffuse spectrum has not been set. Call setDiffuseSpectrum() first.");
240 }
241
242 // Scale the global spectrum
243 float current_integral = integrateSpectrum(global_diffuse_spectrum);
244 if (current_integral > 0) {
245 float scale_factor = spectrum_integral / current_integral;
246 for (vec2 &wavelength: global_diffuse_spectrum) {
247 wavelength.y *= scale_factor;
248 }
249 }
250
251 // Apply scaled spectrum to all existing bands
252 for (auto &band: radiation_bands) {
253 band.second.diffuse_spectrum = global_diffuse_spectrum;
254 }
255
256 radiativepropertiesneedupdate = true;
257}
258
259void RadiationModel::setDiffuseSpectrumIntegral(float spectrum_integral, float wavelength1, float wavelength2) {
260
261 if (spectrum_integral < 0) {
262 helios_runtime_error("ERROR (RadiationModel::setDiffuseSpectrumIntegral): Spectrum integral must be non-negative.");
263 } else if (global_diffuse_spectrum.empty()) {
264 helios_runtime_error("ERROR (RadiationModel::setDiffuseSpectrumIntegral): Global diffuse spectrum has not been set. Call setDiffuseSpectrum() first.");
265 }
266
267 // Scale the global spectrum based on the integral within the specified wavelength range
268 float current_integral = integrateSpectrum(global_diffuse_spectrum, wavelength1, wavelength2);
269 if (current_integral > 0) {
270 float scale_factor = spectrum_integral / current_integral;
271 for (vec2 &wavelength: global_diffuse_spectrum) {
272 wavelength.y *= scale_factor;
273 }
274 }
275
276 // Apply scaled spectrum to all existing bands
277 for (auto &band: radiation_bands) {
278 band.second.diffuse_spectrum = global_diffuse_spectrum;
279 }
280
281 radiativepropertiesneedupdate = true;
282}
283
284void RadiationModel::setDiffuseSpectrumIntegral(const std::string &band_label, float spectrum_integral) {
285
286 if (spectrum_integral < 0) {
287 helios_runtime_error("ERROR (RadiationModel::setDiffuseSpectrumIntegral): Source integral must be non-negative.");
288 } else if (!doesBandExist(band_label)) {
289 helios_runtime_error("ERROR (RadiationModel::setDiffuseSpectrumIntegral): Cannot set integral for band '" + band_label + "' because it is not a valid band.");
290 } else if (radiation_bands.at(band_label).diffuse_spectrum.empty()) {
291 std::cerr << "WARNING (RadiationModel::setDiffuseSpectrumIntegral): Diffuse spectral distribution has not been set for radiation band '" + band_label + "'. Cannot set its integral." << std::endl;
292 return;
293 }
294
295 float current_integral = integrateSpectrum(radiation_bands.at(band_label).diffuse_spectrum);
296
297 for (vec2 &wavelength: radiation_bands.at(band_label).diffuse_spectrum) {
298 wavelength.y *= spectrum_integral / current_integral;
299 }
300
301 radiativepropertiesneedupdate = true;
302}
303
304void RadiationModel::setDiffuseSpectrumIntegral(const std::string &band_label, float spectrum_integral, float wavelength1, float wavelength2) {
305
306 if (spectrum_integral < 0) {
307 helios_runtime_error("ERROR (RadiationModel::setDiffuseSpectrumIntegral): Source integral must be non-negative.");
308 } else if (!doesBandExist(band_label)) {
309 helios_runtime_error("ERROR (RadiationModel::setDiffuseSpectrumIntegral): Cannot set integral for band '" + band_label + "' because it is not a valid band.");
310 }
311
312 float current_integral = integrateSpectrum(radiation_bands.at(band_label).diffuse_spectrum, wavelength1, wavelength2);
313
314 for (vec2 &wavelength: radiation_bands.at(band_label).diffuse_spectrum) {
315 wavelength.y *= spectrum_integral / current_integral;
316 }
317
318 radiativepropertiesneedupdate = true;
319}
320
321void RadiationModel::addRadiationBand(const std::string &label) {
322
323 if (radiation_bands.find(label) != radiation_bands.end()) {
324 std::cerr << "WARNING (RadiationModel::addRadiationBand): Radiation band " << label << " has already been added. Skipping this call to addRadiationBand()." << std::endl;
325 return;
326 }
327
328 RadiationBand band(label, directRayCount_default, diffuseRayCount_default, diffuseFlux_default, scatteringDepth_default, minScatterEnergy_default);
329
330 // Apply global diffuse spectrum if one was set
331 if (!global_diffuse_spectrum.empty()) {
332 band.diffuse_spectrum = global_diffuse_spectrum;
333 }
334
335 radiation_bands.emplace(label, band);
336
337 // Initialize all radiation source fluxes
338 for (auto &source: radiation_sources) {
339 source.source_fluxes[label] = -1.f;
340 }
341
342 radiativepropertiesneedupdate = true;
343}
344
345void RadiationModel::addRadiationBand(const std::string &label, float wavelength1, float wavelength2) {
346
347 if (radiation_bands.find(label) != radiation_bands.end()) {
348 std::cerr << "WARNING (RadiationModel::addRadiationBand): Radiation band " << label << " has already been added. Skipping this call to addRadiationBand()." << std::endl;
349 return;
350 } else if (wavelength1 > wavelength2) {
351 helios_runtime_error("ERROR (RadiationModel::addRadiationBand): The upper wavelength bound for a band must be greater than the lower bound.");
352 } else if (wavelength2 - wavelength1 < 1) {
353 helios_runtime_error("ERROR (RadiationModel::addRadiationBand): The waveband range of a radiation band must be at least 1 nm.");
354 }
355
356 RadiationBand band(label, directRayCount_default, diffuseRayCount_default, diffuseFlux_default, scatteringDepth_default, minScatterEnergy_default);
357
358 band.wavebandBounds = make_vec2(wavelength1, wavelength2);
359
360 // Apply global diffuse spectrum if one was set
361 if (!global_diffuse_spectrum.empty()) {
362 band.diffuse_spectrum = global_diffuse_spectrum;
363 }
364
365 radiation_bands.emplace(label, band);
366
367 // Initialize all radiation source fluxes
368 for (auto &source: radiation_sources) {
369 source.source_fluxes[label] = -1.f;
370 }
371
372 radiativepropertiesneedupdate = true;
373}
374
375void RadiationModel::copyRadiationBand(const std::string &old_label, const std::string &new_label) {
376
377 if (!doesBandExist(old_label)) {
378 helios_runtime_error("ERROR (RadiationModel::copyRadiationBand): Cannot copy band " + old_label + " because it does not exist.");
379 }
380
381 vec2 waveBounds = radiation_bands.at(old_label).wavebandBounds;
382
383 copyRadiationBand(old_label, new_label, waveBounds.x, waveBounds.y);
384}
385
386void RadiationModel::copyRadiationBand(const std::string &old_label, const std::string &new_label, float wavelength_min, float wavelength_max) {
387
388 if (!doesBandExist(old_label)) {
389 helios_runtime_error("ERROR (RadiationModel::copyRadiationBand): Cannot copy band " + old_label + " because it does not exist.");
390 }
391
392 RadiationBand band = radiation_bands.at(old_label);
393 band.label = new_label;
394 band.wavebandBounds = make_vec2(wavelength_min, wavelength_max);
395
396 radiation_bands.emplace(new_label, band);
397
398 // copy source fluxes
399 for (auto &source: radiation_sources) {
400 source.source_fluxes[new_label] = source.source_fluxes.at(old_label);
401 }
402
403 radiativepropertiesneedupdate = true;
404}
405
406bool RadiationModel::doesBandExist(const std::string &label) const {
407 if (radiation_bands.find(label) == radiation_bands.end()) {
408 return false;
409 } else {
410 return true;
411 }
412}
413
414void RadiationModel::disableEmission(const std::string &label) {
415
416 if (!doesBandExist(label)) {
417 helios_runtime_error("ERROR (RadiationModel::disableEmission): Cannot disable emission for band '" + label + "' because it is not a valid band.");
418 }
419
420 radiation_bands.at(label).emissionFlag = false;
421}
422
423void RadiationModel::enableEmission(const std::string &label) {
424
425 if (!doesBandExist(label)) {
426 helios_runtime_error("ERROR (RadiationModel::enableEmission): Cannot disable emission for band '" + label + "' because it is not a valid band.");
427 }
428
429 radiation_bands.at(label).emissionFlag = true;
430}
431
432// --- Solar-induced chlorophyll fluorescence (SIF) v2 ---
433//
434// v2 replaces the v1 25%/75% fixed red/far-red split with a physically correct
435// spectrum-at-the-source model. Per-leaf fluorescence is computed via the
436// Fluspect-B kernel (Vilfan et al. 2016, ported in FluspectB.cpp) driven by
437// absorbed PAR integrated across auto-generated excitation bands (400-750 nm)
438// and the van der Tol (2014) rate-coefficient quantum yield Phi_F. The result
439// is integrated over user-defined emission bands (any wavelength range the
440// user picks when adding the band). Users interact with SIF by adding a
441// SIFCamera; the emission bands it references are flagged as SIF-sourcing.
442
443namespace {
444 // Round a float to 5 decimals so tiny numerical differences don't prevent
445 // cache hits for canonically-identical biochemistry.
446 float fp_round5(float x) {
447 return std::round(x * 1e5f) * 1e-5f;
448 }
449} // namespace
450
451bool RadiationModel::FluspectCacheKey::operator==(const FluspectCacheKey &o) const noexcept {
452 return biochem_label == o.biochem_label && excitation_step_nm == o.excitation_step_nm;
453}
454
455std::size_t RadiationModel::FluspectCacheKeyHash::operator()(const FluspectCacheKey &k) const noexcept {
456 std::size_t h = std::hash<std::string>{}(k.biochem_label);
457 std::uint32_t bits;
458 std::memcpy(&bits, &k.excitation_step_nm, sizeof(bits));
459 h ^= static_cast<std::size_t>(bits) + 0x9e3779b97f4a7c15ULL + (h << 6) + (h >> 2);
460 return h;
461}
462
463void RadiationModel::ensureFluspectOptiparLoaded() {
464 if (fluspect_optipar_loaded) {
465 return;
466 }
467 const std::filesystem::path p = helios::resolveFilePath("plugins/radiation/spectral_data/fluspect_B_optipar.xml");
468 helios::loadFluspectOptipar(p.string(), fluspect_optipar);
469 fluspect_optipar_loaded = true;
470}
471
472const helios::FluspectKernel *RadiationModel::getOrComputeFluspectKernel(uint UUID, float excitation_step_nm) {
473 // Label-based biochemistry lookup: the leaf must have a "fluspect_spectrum"
474 // string primitive-data field that points at a corresponding
475 // "fluspect_biochem_<label>" global data entry (authored by LeafOptics::run()
476 // or directly by the user). This mirrors how "reflectivity_spectrum" works.
477 if (!context->doesPrimitiveDataExist(UUID, "fluspect_spectrum")) {
478 return nullptr;
479 }
480 std::string biochem_label;
481 context->getPrimitiveData(UUID, "fluspect_spectrum", biochem_label);
482
483 // Cache key: the global-data label + excitation step. No need to hash the
484 // underlying biochemistry floats — the label is the authoritative identity.
485 FluspectCacheKey key{biochem_label, fp_round5(excitation_step_nm)};
486 auto it = fluspect_cache.find(key);
487 if (it != fluspect_cache.end()) {
488 return &it->second;
489 }
490
491 // Cache miss — resolve the global-data biochemistry vector, build the struct,
492 // compute the Fluspect-B kernel.
493 if (!context->doesGlobalDataExist(biochem_label.c_str())) {
494 helios_runtime_error("ERROR (RadiationModel::getOrComputeFluspectKernel): primitive " + std::to_string(UUID) +
495 " has fluspect_spectrum = '" + biochem_label + "' but that global data does not exist. "
496 "Either call LeafOptics::run() to author the biochemistry, or manually setGlobalData("
497 "\"" + biochem_label + "\", std::vector<float>{Cab, Cca, Cw, Cdm, Cs, Cant, Cp, Cbc, N, V2Z, fqe}).");
498 }
499 if (context->getGlobalDataType(biochem_label.c_str()) != HELIOS_TYPE_FLOAT) {
500 helios_runtime_error("ERROR (RadiationModel::getOrComputeFluspectKernel): global data '" + biochem_label +
501 "' is not a float vector — must be std::vector<float> with 11 elements.");
502 }
503 std::vector<float> biochem_vec;
504 context->getGlobalData(biochem_label.c_str(), biochem_vec);
505 if (biochem_vec.size() != 11) {
506 helios_runtime_error("ERROR (RadiationModel::getOrComputeFluspectKernel): global data '" + biochem_label +
507 "' has " + std::to_string(biochem_vec.size()) + " elements but must have exactly 11 "
508 "(Cab, Cca, Cw, Cdm, Cs, Cant, Cp, Cbc, N, V2Z, fqe).");
509 }
510
512 biochem.Cab = biochem_vec[0];
513 biochem.Cca = biochem_vec[1];
514 biochem.Cw = biochem_vec[2];
515 biochem.Cdm = biochem_vec[3];
516 biochem.Cs = biochem_vec[4];
517 biochem.Cant = biochem_vec[5];
518 biochem.Cp = biochem_vec[6];
519 biochem.Cbc = biochem_vec[7];
520 biochem.N = biochem_vec[8];
521 biochem.V2Z = biochem_vec[9];
522 // fqe scales the entire kernel linearly; we factor it out by setting fqe=1 in
523 // the kernel compute and multiplying by the per-leaf Phi_F × fqe at emission
524 // time. The user's calibrated fqe scalar from biochem_vec[10] is applied in
525 // computeSIFEmission alongside the van der Tol yield.
526 biochem.fqe = 1.f;
527
528 ensureFluspectOptiparLoaded();
529 helios::FluspectKernel kernel = helios::computeFluspectKernel(biochem, fluspect_optipar, excitation_step_nm);
530 auto [inserted_it, _] = fluspect_cache.emplace(key, std::move(kernel));
531 return &inserted_it->second;
532}
533
534RadiationModel::ExcitationSet &RadiationModel::ensureExcitationSet(float bin_width_nm, uint scattering_depth) {
535 const float key = fp_round5(bin_width_nm);
536 auto it = excitation_sets.find(key);
537 if (it != excitation_sets.end()) {
538 // Set already exists. If this caller requests a deeper scattering depth than
539 // the set's current value, upgrade the existing bands to that depth — any
540 // camera bound to this set benefits from the more accurate APAR. Never
541 // downgrade (another camera may have requested the larger value already).
542 if (scattering_depth > it->second.scattering_depth) {
543 it->second.scattering_depth = scattering_depth;
544 for (const auto &bname : it->second.band_labels) {
545 setScatteringDepth(bname, scattering_depth);
546 }
547 }
548 return it->second;
549 }
550 // Create new excitation set covering 400-750 nm at bin_width_nm resolution.
551 ExcitationSet set;
552 set.bin_width_nm = bin_width_nm;
553 set.scattering_depth = scattering_depth;
554 constexpr float ex_min = 400.f;
555 constexpr float ex_max = 750.f;
556 // Number of bins such that the last band ends exactly at ex_max (using a
557 // bin width that may not divide the range evenly: the final bin is capped
558 // at ex_max so all excitation within 400-750 is covered).
559 const int n_bins = static_cast<int>(std::ceil((ex_max - ex_min) / bin_width_nm));
560 set.band_labels.reserve(n_bins);
561 set.band_min_nm.reserve(n_bins);
562 set.band_max_nm.reserve(n_bins);
563 for (int i = 0; i < n_bins; ++i) {
564 float wmin = ex_min + i * bin_width_nm;
565 float wmax = std::min(ex_max, wmin + bin_width_nm);
566 std::ostringstream oss;
567 oss << "_SIF_exc_" << bin_width_nm << "_" << wmin << "_" << wmax;
568 const std::string label = oss.str();
569 if (!doesBandExist(label)) {
570 addRadiationBand(label, wmin, wmax);
571 // Internal excitation bands are pure absorbers — disable emission.
572 // Scattering depth follows the requesting SIF camera's
573 // excitation_scattering_depth (default 0 for speed; users opt in to
574 // scattering for more accurate APAR under high leaf rho/tau).
575 disableEmission(label);
576 setScatteringDepth(label, scattering_depth);
577 // Auto-propagate band flux from any source that has a spectrum set.
578 // Without this, the auto-generated excitation bands would have a
579 // -1 sentinel flux (initialized in addRadiationBand) even if the user
580 // had set a broadband spectrum on their source — so the excitation
581 // ray trace would produce zero APAR.
582 for (uint sid = 0; sid < radiation_sources.size(); ++sid) {
583 const auto &src = radiation_sources.at(sid);
584 if (!src.source_spectrum.empty()) {
585 const float band_flux = integrateSpectrum(src.source_spectrum, wmin, wmax);
586 setSourceFlux(sid, label, band_flux);
587 }
588 }
589 }
590 set.band_labels.push_back(label);
591 set.band_min_nm.push_back(wmin);
592 set.band_max_nm.push_back(wmax);
593 }
594 auto [inserted_it, _] = excitation_sets.emplace(key, std::move(set));
595 return inserted_it->second;
596}
597
598void RadiationModel::populateExcitationAPAR(ExcitationSet &exc) {
599 // After excitation bands have been ray-traced, their radiation_flux_<band>
600 // primitive data holds per-leaf absorbed flux. Copy into apar_buffer and
601 // clear the primitive data (internal bands shouldn't pollute user namespace).
602 exc.apar_buffer.clear();
603 const std::vector<uint> all_UUIDs = context->getAllUUIDs();
604 const size_t n_bands = exc.band_labels.size();
605 for (uint UUID : all_UUIDs) {
606 // Only track primitives we care about: leaves with a fluspect_spectrum label.
607 if (!context->doesPrimitiveDataExist(UUID, "fluspect_spectrum")) {
608 continue;
609 }
610 std::vector<float> row(n_bands, 0.f);
611 for (size_t b = 0; b < n_bands; ++b) {
612 const std::string prop = "radiation_flux_" + exc.band_labels[b];
613 if (context->doesPrimitiveDataExist(UUID, prop.c_str())) {
614 context->getPrimitiveData(UUID, prop.c_str(), row[b]);
615 }
616 }
617 exc.apar_buffer.emplace(UUID, std::move(row));
618 }
619 // Clear primitive data for internal excitation bands from ALL primitives.
620 for (uint UUID : all_UUIDs) {
621 for (const auto &band_label : exc.band_labels) {
622 const std::string prop = "radiation_flux_" + band_label;
623 if (context->doesPrimitiveDataExist(UUID, prop.c_str())) {
624 context->clearPrimitiveData(UUID, prop.c_str());
625 }
626 }
627 }
628 exc.populated = true;
629}
630
631void RadiationModel::runExcitationBands() {
632 // Run any excitation set that isn't already populated. All internal bands
633 // for a set are launched together via runBand(vector), then their per-leaf
634 // absorbed flux is stashed into apar_buffer.
635 //
636 // The per-band "scattering disabled" warning from runBand() is suppressed for
637 // "_SIF_exc_*" labels. Users who want more accurate APAR under high leaf
638 // rho/tau should set SIFCameraProperties::excitation_scattering_depth >= 1.
639 for (auto &kv : excitation_sets) {
640 ExcitationSet &exc = kv.second;
641 if (exc.populated) {
642 continue;
643 }
644 runBand(exc.band_labels);
645 populateExcitationAPAR(exc);
646 }
647}
648
649void RadiationModel::computeSIFEmission(const std::string &emission_band) {
650 // Populate sif_emission_buffer[emission_band] (top face) and
651 // sif_emission_buffer_bottom[emission_band] (bottom face) for every leaf
652 // primitive, integrating the Fluspect-B kernel against per-excitation-band
653 // APAR and scaling by the van der Tol (2014) quantum yield.
654
655 auto band_it = radiation_bands.find(emission_band);
656 if (band_it == radiation_bands.end()) {
657 helios_runtime_error("ERROR (RadiationModel::computeSIFEmission): band '" + emission_band + "' does not exist.");
658 }
659 const float em_min = band_it->second.wavebandBounds.x;
660 const float em_max = band_it->second.wavebandBounds.y;
661
662 // Make sure excitation bands have been run this dispatch.
663 runExcitationBands();
664
665 auto &buf_top = sif_emission_buffer[emission_band];
666 auto &buf_bot = sif_emission_buffer_bottom[emission_band];
667 buf_top.clear();
668 buf_bot.clear();
669
670 // Look up the authoritative excitation bin width for this emission band.
671 // addSIFCamera guarantees sif_band_bin_width[emission_band] exists for every band
672 // in sif_emission_bands (otherwise how did we get here).
673 auto bw_it = sif_band_bin_width.find(emission_band);
674 if (bw_it == sif_band_bin_width.end()) {
675 helios_runtime_error("ERROR (RadiationModel::computeSIFEmission): band '" + emission_band + "' is flagged as SIF but has no excitation bin width registered. This is an internal inconsistency.");
676 }
677 const float step = bw_it->second;
678 // Find the excitation set that matches this bin width. ensureExcitationSet
679 // is called from addSIFCamera for every camera's bin width, so the set
680 // must exist.
681 const ExcitationSet *matched = nullptr;
682 for (const auto &kv : excitation_sets) {
683 if (std::abs(kv.second.bin_width_nm - step) < 1e-5f) {
684 matched = &kv.second;
685 break;
686 }
687 }
688 if (!matched) {
689 helios_runtime_error("ERROR (RadiationModel::computeSIFEmission): no excitation set found for bin width " + std::to_string(step) + " nm (band '" + emission_band + "').");
690 }
691
692 const std::vector<uint> all_UUIDs = context->getAllUUIDs();
693 size_t n_applied = 0;
694 size_t n_skipped_no_biochem_has_etr = 0; // leaves with J/Jmax but no biochemistry label
695 size_t n_skipped_has_biochem_no_etr = 0; // leaves with biochemistry but no J/Jmax
696 for (uint UUID : all_UUIDs) {
697 const bool has_biochem = context->doesPrimitiveDataExist(UUID, "fluspect_spectrum");
698 const bool has_etr = context->doesPrimitiveDataExist(UUID, "electron_transport_ratio");
699 if (!has_etr) {
700 if (has_biochem) ++n_skipped_has_biochem_no_etr;
701 continue;
702 }
703 const helios::FluspectKernel *kernel = getOrComputeFluspectKernel(UUID, step);
704 if (!kernel) {
705 // getOrComputeFluspectKernel returns nullptr iff fluspect_spectrum is missing.
706 if (has_etr) ++n_skipped_no_biochem_has_etr;
707 continue;
708 }
709
710 float J_over_Jmax = 0.f;
711 context->getPrimitiveData(UUID, "electron_transport_ratio", J_over_Jmax);
712 float T_leaf_K = 298.15f;
713 if (context->doesPrimitiveDataExist(UUID, "temperature")) {
714 context->getPrimitiveData(UUID, "temperature", T_leaf_K);
715 if (T_leaf_K <= 0.f) T_leaf_K = 298.15f;
716 }
717 const float Phi_F = calculateFluorescenceYield(J_over_Jmax, T_leaf_K);
718 context->setPrimitiveData(UUID, "fluorescence_yield", Phi_F);
719
720 // Retrieve the user's fqe calibration scalar from the global-data biochemistry
721 // vector (11th field). The kernel was computed with fqe=1, so the authoritative
722 // fqe multiplies Phi_F here to produce the final emission scaling.
723 float fqe = 1.f;
724 {
725 std::string biochem_label;
726 context->getPrimitiveData(UUID, "fluspect_spectrum", biochem_label);
727 if (context->doesGlobalDataExist(biochem_label.c_str())) {
728 std::vector<float> biochem_vec;
729 context->getGlobalData(biochem_label.c_str(), biochem_vec);
730 if (biochem_vec.size() >= 11) fqe = biochem_vec[10];
731 }
732 }
733 const float phi_F_scaled = Phi_F * fqe;
734
735 // Integrate kernel × APAR across excitation grid → per-emission-wavelength spectrum.
736 // Then integrate that spectrum across [em_min, em_max] → source flux for this band.
737 //
738 // The kernel grid is 4 nm spacing in wlf (emission); we use trapezoidal integration
739 // over the intersection with [em_min, em_max]. We build two spectra (top = Mf,
740 // bottom = Mb) then integrate each.
741 const auto &wle = kernel->wle;
742 const auto &wlf = kernel->wlf;
743
744 auto apar_it = matched->apar_buffer.find(UUID);
745 if (apar_it == matched->apar_buffer.end()) continue;
746 const std::vector<float> &apar_bands = apar_it->second;
747
748 // For each excitation sub-band, find its index in the kernel's wle grid and
749 // accumulate apar[b] * kernel_col[em] into per-em contributions.
750 // The kernel wle is defined at the midpoints of 400-750 at 'step' spacing. We map
751 // each band_index to wle by matching band center to wle[j].
752 std::vector<double> F_top(wlf.size(), 0.0);
753 std::vector<double> F_bot(wlf.size(), 0.0);
754 for (size_t b = 0; b < matched->band_labels.size(); ++b) {
755 const float band_center = 0.5f * (matched->band_min_nm[b] + matched->band_max_nm[b]);
756 // Locate the closest wle index.
757 size_t j_best = 0;
758 float best_delta = std::numeric_limits<float>::infinity();
759 for (size_t j = 0; j < wle.size(); ++j) {
760 const float d = std::abs(wle[j] - band_center);
761 if (d < best_delta) {
762 best_delta = d;
763 j_best = j;
764 }
765 }
766 // APAR in band b is the absorbed flux on this primitive, W/m².
767 const double apar = apar_bands[b];
768 if (apar == 0.0) continue;
769 for (size_t i = 0; i < wlf.size(); ++i) {
770 F_top[i] += apar * kernel->Mf[i][j_best];
771 F_bot[i] += apar * kernel->Mb[i][j_best];
772 }
773 }
774
775 // Multiply by Phi_F × fqe (since we build the kernel with fqe=1 internally, the
776 // quantum-yield scaling and user fqe calibration both happen here).
777 for (size_t i = 0; i < wlf.size(); ++i) {
778 F_top[i] *= phi_F_scaled;
779 F_bot[i] *= phi_F_scaled;
780 }
781
782 // Integrate F_top/F_bot over [em_min, em_max] via trapezoid over the kernel's wlf grid.
783 auto integrate = [&](const std::vector<double> &F) -> double {
784 double total = 0.0;
785 for (size_t i = 0; i + 1 < wlf.size(); ++i) {
786 const float w0 = wlf[i];
787 const float w1 = wlf[i + 1];
788 if (w1 < em_min) continue;
789 if (w0 > em_max) break;
790 // Clip to band
791 const double lo = std::max<double>(w0, em_min);
792 const double hi = std::min<double>(w1, em_max);
793 if (hi <= lo) continue;
794 // Linear interp of F between w0 and w1 over [lo, hi]. Integrated trapezoid
795 // of a linear function equals average × width.
796 const double frac_lo = (lo - w0) / (w1 - w0);
797 const double frac_hi = (hi - w0) / (w1 - w0);
798 const double F_lo = F[i] + frac_lo * (F[i + 1] - F[i]);
799 const double F_hi = F[i] + frac_hi * (F[i + 1] - F[i]);
800 total += 0.5 * (F_lo + F_hi) * (hi - lo);
801 }
802 return total;
803 };
804
805 const double emit_top = integrate(F_top);
806 const double emit_bot = integrate(F_bot);
807 buf_top[UUID] = static_cast<float>(emit_top);
808 buf_bot[UUID] = static_cast<float>(emit_bot);
809 ++n_applied;
810 }
811
812 if (n_applied == 0 && message_flag) {
813 if (n_skipped_has_biochem_no_etr == 0 && n_skipped_no_biochem_has_etr == 0) {
814 std::cerr << "WARNING (RadiationModel::computeSIFEmission): SIF-flagged band '" << emission_band
815 << "' will emit zero — no primitives have both 'fluspect_spectrum' (leaf biochemistry "
816 "label) and 'electron_transport_ratio' (J/Jmax) primitive data. Call "
817 "LeafOptics::run() to author leaf biochemistry and PhotosynthesisModel::run() "
818 "with optionalOutputPrimitiveData(\"electron_transport_ratio\") before runBand()."
819 << std::endl;
820 } else if (n_skipped_has_biochem_no_etr > 0 && n_skipped_no_biochem_has_etr == 0) {
821 std::cerr << "WARNING (RadiationModel::computeSIFEmission): SIF-flagged band '" << emission_band
822 << "' will emit zero — " << n_skipped_has_biochem_no_etr << " primitives have "
823 "'fluspect_spectrum' but lack 'electron_transport_ratio'. Run PhotosynthesisModel::run() "
824 "with optionalOutputPrimitiveData(\"electron_transport_ratio\") before runBand()."
825 << std::endl;
826 } else if (n_skipped_no_biochem_has_etr > 0 && n_skipped_has_biochem_no_etr == 0) {
827 std::cerr << "WARNING (RadiationModel::computeSIFEmission): SIF-flagged band '" << emission_band
828 << "' will emit zero — " << n_skipped_no_biochem_has_etr << " primitives have "
829 "'electron_transport_ratio' but lack 'fluspect_spectrum'. Call LeafOptics::run() "
830 "to author leaf biochemistry for those primitives."
831 << std::endl;
832 } else {
833 std::cerr << "WARNING (RadiationModel::computeSIFEmission): SIF-flagged band '" << emission_band
834 << "' will emit zero — " << n_skipped_has_biochem_no_etr << " primitives have "
835 "'fluspect_spectrum' but lack 'electron_transport_ratio', and "
836 << n_skipped_no_biochem_has_etr << " have 'electron_transport_ratio' but lack "
837 "'fluspect_spectrum'. No primitive has both required fields."
838 << std::endl;
839 }
840 } else if (n_applied > 0 && n_skipped_has_biochem_no_etr > 0 && message_flag) {
841 // Non-fatal partial-coverage case: some leaves produced SIF but others silently didn't.
842 std::cerr << "WARNING (RadiationModel::computeSIFEmission): band '" << emission_band << "': "
843 << n_skipped_has_biochem_no_etr << " primitives with 'fluspect_spectrum' were silently "
844 "skipped because they lack 'electron_transport_ratio'. ("
845 << n_applied << " primitives emitted SIF normally.)"
846 << std::endl;
847 }
848}
849
850// --- SIF camera public API ---
851
852void RadiationModel::addSIFCamera(const std::string &camera_label, const std::vector<std::string> &emission_band_labels, const vec3 &position, const vec3 &lookat, const SIFCameraProperties &camera_properties, uint antialiasing_samples) {
853
854 if (emission_band_labels.empty()) {
855 helios_runtime_error("ERROR (RadiationModel::addSIFCamera): emission_band_labels cannot be empty.");
856 }
857 for (const auto &band : emission_band_labels) {
858 if (!doesBandExist(band)) {
859 helios_runtime_error("ERROR (RadiationModel::addSIFCamera): band '" + band + "' does not exist. Add it with addRadiationBand() before calling addSIFCamera().");
860 }
861 // Enable emission on the band if not already — Fluspect sourced via emission loop.
862 enableEmission(band);
863 // Flag as SIF-sourcing: computeSIFEmission will populate sif_emission_buffer for it.
864 sif_emission_bands.insert(band);
865 // Bind this band to the camera's excitation bin width. If the band was already
866 // bound to a different bin width by a prior camera, that's a user error — each
867 // emission band must have a single authoritative excitation resolution so that
868 // sif_emission_buffer[band] holds one consistent source flux per leaf.
869 auto bw_it = sif_band_bin_width.find(band);
870 if (bw_it == sif_band_bin_width.end()) {
871 sif_band_bin_width[band] = camera_properties.excitation_bin_width_nm;
872 } else if (std::abs(bw_it->second - camera_properties.excitation_bin_width_nm) > 1e-5f) {
873 helios_runtime_error("ERROR (RadiationModel::addSIFCamera): emission band '" + band + "' is already bound to excitation_bin_width_nm=" + std::to_string(bw_it->second) +
874 " by a prior SIF camera, but this camera's excitation_bin_width_nm=" + std::to_string(camera_properties.excitation_bin_width_nm) +
875 ". Each SIF emission band can be bound to only one excitation resolution. "
876 "Either use a separate band per camera or match excitation_bin_width_nm.");
877 }
878 }
879 if (camera_properties.excitation_bin_width_nm <= 0.f) {
880 helios_runtime_error("ERROR (RadiationModel::addSIFCamera): excitation_bin_width_nm must be > 0.");
881 }
882 ensureExcitationSet(camera_properties.excitation_bin_width_nm, camera_properties.excitation_scattering_depth);
883
884 // --- Diagnostic scan: flag likely-silent SIF setup mistakes at camera-addition time ---
885 // This catches the common pitfalls before the user invests compute in a bad run:
886 // - No radiation source has a spectrum set (excitation bands will pick up zero flux).
887 // - Leaves are present but none have Cab (none will fluoresce).
888 // - Leaves have Cab but lack electron_transport_ratio (Phi_F can't be computed → zero SIF).
889 if (message_flag) {
890 // (a) Source spectrum check: at least one source must have a spectrum covering the
891 // Fluspect-B excitation range (400-750 nm). We only flag the missing-spectrum
892 // case — not the partial-coverage case (that's the user's prerogative).
893 bool any_source_has_spectrum = false;
894 for (const auto &src : radiation_sources) {
895 if (!src.source_spectrum.empty()) {
896 any_source_has_spectrum = true;
897 break;
898 }
899 }
900 if (!any_source_has_spectrum) {
901 std::cerr << "WARNING (RadiationModel::addSIFCamera): Camera '" << camera_label
902 << "' added, but no radiation source has a spectrum set. Auto-generated excitation "
903 "bands will receive zero flux, so SIF emission from all leaves will be zero. "
904 "Call setSourceSpectrum(source_ID, \"solar_spectrum_direct_ASTMG173\") (or similar) "
905 "on at least one source before runBand()."
906 << std::endl;
907 }
908
909 // (b) Leaf biochemistry coverage. We only look at primitives present at camera-add
910 // time; users may add more leaves later. 'electron_transport_ratio' is NOT
911 // checked here because it is normally populated later by
912 // PhotosynthesisModel::run() — a setup-time check would fire for every correctly-
913 // sequenced pipeline (addSIFCamera → photomodel.run() → runBand). The equivalent
914 // runtime check in computeSIFEmission() fires if J/Jmax is still missing at dispatch.
915 const auto all_UUIDs = context->getAllUUIDs();
916 const size_t n_prims = all_UUIDs.size();
917 size_t n_with_biochem = 0;
918 for (uint UUID : all_UUIDs) {
919 if (context->doesPrimitiveDataExist(UUID, "fluspect_spectrum")) {
920 ++n_with_biochem;
921 }
922 }
923 if (n_prims > 0 && n_with_biochem == 0) {
924 std::cerr << "WARNING (RadiationModel::addSIFCamera): Camera '" << camera_label
925 << "' added to a scene with " << n_prims << " primitives, but none have "
926 "'fluspect_spectrum' primitive data. Helios cannot identify fluorescing leaves "
927 "without a biochemistry label. Call LeafOptics::run(UUIDs, properties, \"my_label\") "
928 "to author leaf biochemistry, or manually "
929 "setGlobalData(\"fluspect_biochem_<label>\", std::vector<float>{Cab, Cca, Cw, Cdm, "
930 "Cs, Cant, Cp, Cbc, N, V2Z, fqe}) and setPrimitiveData(UUIDs, \"fluspect_spectrum\", "
931 "\"fluspect_biochem_<label>\")."
932 << std::endl;
933 }
934 }
935
936 // Delegate to addRadiationCamera for the geometric/pixel setup.
937 addRadiationCamera(camera_label, emission_band_labels, position, lookat, camera_properties, antialiasing_samples);
938
939 // Tag the camera as a SIF camera via its label in a private set.
940 sif_cameras.insert(camera_label);
941}
942
943void RadiationModel::addSIFCamera(const std::string &camera_label, const std::vector<std::string> &emission_band_labels, const vec3 &position, const SphericalCoord &viewing_direction,
944 const SIFCameraProperties &camera_properties, uint antialiasing_samples) {
945 // Convert spherical direction to lookat point.
946 const vec3 dir = sphere2cart(viewing_direction);
947 addSIFCamera(camera_label, emission_band_labels, position, position + dir, camera_properties, antialiasing_samples);
948}
949
950bool RadiationModel::isSIFCamera(const std::string &camera_label) const {
951 return sif_cameras.find(camera_label) != sif_cameras.end();
952}
953
954float RadiationModel::calculateFluorescenceYield(float J_over_Jmax, float T_leaf_K) {
955 // Van der Tol et al. (2014), Eq. 6–10: rate-coefficient model for Φ_F.
956 // kF: constant fluorescence rate. kD: thermal (constitutive) dissipation, linear in T.
957 // kN: non-photochemical quenching (NPQ), modeled as a piecewise-linear function of x = 1 − J/Jmax.
958 // kP: photochemistry rate, back-solved from Φ_P = J/Jmax * Φ_P_max.
959 constexpr float kF = 0.05f;
960
961 const float T_C = T_leaf_K - 273.15f;
962 const float kD = std::max(0.03f * T_C + 0.0773f, 0.87f);
963
964 const float x = helios::clamp(1.f - J_over_Jmax, 0.f, 1.f);
965 float kN;
966 if (x < 0.2f) {
967 kN = 0.f;
968 } else if (x < 0.6f) {
969 kN = 2.0f * (x - 0.2f) / 0.4f;
970 } else {
971 kN = 2.0f + 4.0f * (x - 0.6f) / 0.4f;
972 }
973
974 constexpr float Phi_P_max = 0.85f;
975 // Clamp strictly below 1 so (1 − Φ_P) never reaches zero.
976 const float Phi_P = helios::clamp(J_over_Jmax * Phi_P_max, 0.f, 0.84f);
977 const float kP = (kF + kD + kN) * Phi_P / (1.f - Phi_P);
978
979 return kF / (kF + kD + kP + kN);
980}
981
986
990
992
993 if (direction.magnitude() == 0) {
994 helios_runtime_error("ERROR (RadiationModel::addCollimatedRadiationSource): Invalid collimated source direction. Direction vector should not have length of zero.");
995 }
996
997 uint Nsources = radiation_sources.size() + 1;
998 if (Nsources > 256) {
999 helios_runtime_error("ERROR (RadiationModel::addCollimatedRadiationSource): A maximum of 256 radiation sources are allowed.");
1000 }
1001
1002 bool warn_multiple_suns = false;
1003 for (auto &source: radiation_sources) {
1004 if (source.source_type == RADIATION_SOURCE_TYPE_COLLIMATED || source.source_type == RADIATION_SOURCE_TYPE_SUN_SPHERE) {
1005 warn_multiple_suns = true;
1006 }
1007 }
1008 if (warn_multiple_suns) {
1009 std::cerr << "WARNING (RadiationModel::addCollimatedRadiationSource): Multiple sun sources have been added to the radiation model. This may lead to unintended behavior." << std::endl;
1010 }
1011
1012 RadiationSource collimated_source(direction);
1013
1014 // initialize fluxes
1015 for (const auto &band: radiation_bands) {
1016 collimated_source.source_fluxes[band.first] = -1.f;
1017 }
1018
1019 radiation_sources.emplace_back(collimated_source);
1020
1021 radiativepropertiesneedupdate = true;
1022
1023 return Nsources - 1;
1024}
1025
1027
1028 if (radius <= 0) {
1029 helios_runtime_error("ERROR (RadiationModel::addSphereRadiationSource): Spherical radiation source radius must be positive.");
1030 }
1031
1032 uint Nsources = radiation_sources.size() + 1;
1033 if (Nsources > 256) {
1034 helios_runtime_error("ERROR (RadiationModel::addSphereRadiationSource): A maximum of 256 radiation sources are allowed.");
1035 }
1036
1037 RadiationSource sphere_source(position, 2.f * fabsf(radius));
1038
1039 // initialize fluxes
1040 for (const auto &band: radiation_bands) {
1041 sphere_source.source_fluxes[band.first] = -1.f;
1042 }
1043
1044 radiation_sources.emplace_back(sphere_source);
1045
1046 uint sourceID = Nsources - 1;
1047
1048 if (islightvisualizationenabled) {
1049 buildLightModelGeometry(sourceID);
1050 }
1051
1052 radiativepropertiesneedupdate = true;
1053
1054 return sourceID;
1055}
1056
1060
1064
1066
1067 uint Nsources = radiation_sources.size() + 1;
1068 if (Nsources > 256) {
1069 helios_runtime_error("ERROR (RadiationModel::addSunSphereRadiationSource): A maximum of 256 radiation sources are allowed.");
1070 }
1071
1072 bool warn_multiple_suns = false;
1073 for (auto &source: radiation_sources) {
1074 if (source.source_type == RADIATION_SOURCE_TYPE_COLLIMATED || source.source_type == RADIATION_SOURCE_TYPE_SUN_SPHERE) {
1075 warn_multiple_suns = true;
1076 }
1077 }
1078 if (warn_multiple_suns) {
1079 std::cerr << "WARNING (RadiationModel::addSunSphereRadiationSource): Multiple sun sources have been added to the radiation model. This may lead to unintended behavior." << std::endl;
1080 }
1081
1082 RadiationSource sphere_source(150e9 * sun_direction / sun_direction.magnitude(), 150e9, 2.f * 695.5e6, sigma * powf(5700, 4) / 1288.437f);
1083
1084 // initialize fluxes
1085 for (const auto &band: radiation_bands) {
1086 sphere_source.source_fluxes[band.first] = -1.f;
1087 }
1088
1089 radiation_sources.emplace_back(sphere_source);
1090
1091 radiativepropertiesneedupdate = true;
1092
1093 return Nsources - 1;
1094}
1095
1096uint RadiationModel::addRectangleRadiationSource(const vec3 &position, const vec2 &size, const vec3 &rotation_rad) {
1097
1098 if (size.x <= 0 || size.y <= 0) {
1099 helios_runtime_error("ERROR (RadiationModel::addRectangleRadiationSource): Radiation source size must be positive.");
1100 }
1101
1102 uint Nsources = radiation_sources.size() + 1;
1103 if (Nsources > 256) {
1104 helios_runtime_error("ERROR (RadiationModel::addRectangleRadiationSource): A maximum of 256 radiation sources are allowed.");
1105 }
1106
1107 RadiationSource rectangle_source(position, size, rotation_rad);
1108
1109 // initialize fluxes
1110 for (const auto &band: radiation_bands) {
1111 rectangle_source.source_fluxes[band.first] = -1.f;
1112 }
1113
1114 radiation_sources.emplace_back(rectangle_source);
1115
1116 uint sourceID = Nsources - 1;
1117
1118 if (islightvisualizationenabled) {
1119 buildLightModelGeometry(sourceID);
1120 }
1121
1122 radiativepropertiesneedupdate = true;
1123
1124 return sourceID;
1125}
1126
1127uint RadiationModel::addDiskRadiationSource(const vec3 &position, float radius, const vec3 &rotation_rad) {
1128
1129 if (radius <= 0) {
1130 helios_runtime_error("ERROR (RadiationModel::addDiskRadiationSource): Disk radiation source radius must be positive.");
1131 }
1132
1133 uint Nsources = radiation_sources.size() + 1;
1134 if (Nsources > 256) {
1135 helios_runtime_error("ERROR (RadiationModel::addDiskRadiationSource): A maximum of 256 radiation sources are allowed.");
1136 }
1137
1138 RadiationSource disk_source(position, radius, rotation_rad);
1139
1140 // initialize fluxes
1141 for (const auto &band: radiation_bands) {
1142 disk_source.source_fluxes[band.first] = -1.f;
1143 }
1144
1145 radiation_sources.emplace_back(disk_source);
1146
1147 uint sourceID = Nsources - 1;
1148
1149 if (islightvisualizationenabled) {
1150 buildLightModelGeometry(sourceID);
1151 }
1152
1153 radiativepropertiesneedupdate = true;
1154
1155 return sourceID;
1156}
1157
1159
1160 if (sourceID >= radiation_sources.size()) {
1161 helios_runtime_error("ERROR (RadiationModel::deleteRadiationSource): Source ID out of bounds. Only " + std::to_string(radiation_sources.size() - 1) + " radiation sources have been created.");
1162 }
1163
1164 radiation_sources.erase(radiation_sources.begin() + sourceID);
1165
1166 radiativepropertiesneedupdate = true;
1167}
1168
1169void RadiationModel::setSourceSpectrumIntegral(uint source_ID, float source_integral) {
1170
1171 if (source_ID >= radiation_sources.size()) {
1172 helios_runtime_error("ERROR (RadiationModel::setSourceSpectrumIntegral): Source ID out of bounds. Only " + std::to_string(radiation_sources.size() - 1) + " radiation sources have been created.");
1173 } else if (source_integral < 0) {
1174 helios_runtime_error("ERROR (RadiationModel::setSourceIntegral): Source integral must be non-negative.");
1175 }
1176
1177 float current_integral = integrateSpectrum(radiation_sources.at(source_ID).source_spectrum);
1178
1179 for (vec2 &wavelength: radiation_sources.at(source_ID).source_spectrum) {
1180 wavelength.y *= source_integral / current_integral;
1181 }
1182}
1183
1184void RadiationModel::setSourceSpectrumIntegral(uint source_ID, float source_integral, float wavelength1, float wavelength2) {
1185
1186 if (source_ID >= radiation_sources.size()) {
1187 helios_runtime_error("ERROR (RadiationModel::setSourceSpectrumIntegral): Source ID out of bounds. Only " + std::to_string(radiation_sources.size() - 1) + " radiation sources have been created.");
1188 } else if (source_integral < 0) {
1189 helios_runtime_error("ERROR (RadiationModel::setSourceSpectrumIntegral): Source integral must be non-negative.");
1190 } else if (radiation_sources.at(source_ID).source_spectrum.empty()) {
1191 std::cout << "WARNING (RadiationModel::setSourceSpectrumIntegral): Spectral distribution has not been set for radiation source. Cannot set its integral." << std::endl;
1192 return;
1193 }
1194
1195 RadiationSource &source = radiation_sources.at(source_ID);
1196
1197 float old_integral = integrateSpectrum(source.source_spectrum, wavelength1, wavelength2);
1198
1199 for (vec2 &wavelength: source.source_spectrum) {
1200 wavelength.y *= source_integral / old_integral;
1201 }
1202}
1203
1204void RadiationModel::setSourceFlux(uint source_ID, const std::string &label, float flux) {
1205
1206 if (!doesBandExist(label)) {
1207 helios_runtime_error("ERROR (RadiationModel::setSourceFlux): Cannot add set source flux for band '" + label + "' because it is not a valid band.");
1208 } else if (source_ID >= radiation_sources.size()) {
1209 helios_runtime_error("ERROR (RadiationModel::setSourceFlux): Source ID out of bounds. Only " + std::to_string(radiation_sources.size() - 1) + " radiation sources have been created.");
1210 } else if (flux < 0) {
1211 helios_runtime_error("ERROR (RadiationModel::setSourceFlux): Source flux must be non-negative.");
1212 }
1213
1214 radiation_sources.at(source_ID).source_fluxes[label] = flux * radiation_sources.at(source_ID).source_flux_scaling_factor;
1215}
1216
1217void RadiationModel::setSourceFlux(const std::vector<uint> &source_ID, const std::string &band_label, float flux) {
1218 for (auto ID: source_ID) {
1219 setSourceFlux(ID, band_label, flux);
1220 }
1221}
1222
1223float RadiationModel::getSourceFlux(uint source_ID, const std::string &label) const {
1224
1225 if (!doesBandExist(label)) {
1226 helios_runtime_error("ERROR (RadiationModel::getSourceFlux): Cannot get source flux for band '" + label + "' because it is not a valid band.");
1227 } else if (source_ID >= radiation_sources.size()) {
1228 helios_runtime_error("ERROR (RadiationModel::getSourceFlux): Source ID out of bounds. Only " + std::to_string(radiation_sources.size() - 1) + " radiation sources have been created.");
1229 } else if (radiation_sources.at(source_ID).source_fluxes.find(label) == radiation_sources.at(source_ID).source_fluxes.end()) {
1230 helios_runtime_error("ERROR (RadiationModel::getSourceFlux): Cannot get flux for source #" + std::to_string(source_ID) + " because radiative band '" + label + "' does not exist.");
1231 }
1232
1233 const RadiationSource &source = radiation_sources.at(source_ID);
1234
1235 if (!source.source_spectrum.empty() && source.source_fluxes.at(label) < 0.f) { // source spectrum was specified (and not overridden by setting source flux manually)
1236 vec2 wavebounds = radiation_bands.at(label).wavebandBounds;
1237 if (wavebounds == make_vec2(0, 0)) {
1238 wavebounds = make_vec2(source.source_spectrum.front().x, source.source_spectrum.back().x);
1239 }
1240 return integrateSpectrum(source.source_spectrum, wavebounds.x, wavebounds.y) * source.source_flux_scaling_factor;
1241 } else if (source.source_fluxes.at(label) < 0.f) {
1242 return 0;
1243 }
1244
1245 return source.source_fluxes.at(label);
1246}
1247
1248void RadiationModel::setSourceSpectrum(uint source_ID, const std::vector<helios::vec2> &spectrum) {
1249
1250 if (source_ID >= radiation_sources.size()) {
1251 helios_runtime_error("ERROR (RadiationModel::setSourceSpectrum): Cannot add radiation spectra for this source because it is not a valid radiation source ID.\n");
1252 }
1253
1254 // validate spectrum
1255 for (auto s = 0; s < spectrum.size(); s++) {
1256 // check that wavelengths are monotonic
1257 if (s > 0 && spectrum.at(s).x <= spectrum.at(s - 1).x) {
1258 helios_runtime_error("ERROR (RadiationModel::setSourceSpectrum): Source spectral data validation failed. Wavelengths must increase monotonically.");
1259 }
1260 // check that wavelength is within a reasonable range
1261 if (spectrum.at(s).x < 0 || spectrum.at(s).x > 100000) {
1262 helios_runtime_error("ERROR (RadiationModel::setSourceSpectrum): Source spectral data validation failed. Wavelength value of " + std::to_string(spectrum.at(s).x) + " appears to be erroneous.");
1263 }
1264 // check that flux is non-negative
1265 if (spectrum.at(s).y < 0) {
1266 helios_runtime_error("ERROR (RadiationModel::setSourceSpectrum): Source spectral data validation failed. Flux value at wavelength of " + std::to_string(spectrum.at(s).x) + " appears is negative.");
1267 }
1268 }
1269
1270 radiation_sources.at(source_ID).source_spectrum = spectrum;
1271
1272 radiativepropertiesneedupdate = true;
1273}
1274
1275void RadiationModel::setSourceSpectrum(const std::vector<uint> &source_ID, const std::vector<helios::vec2> &spectrum) {
1276 for (auto ID: source_ID) {
1277 setSourceSpectrum(ID, spectrum);
1278 }
1279}
1280
1281void RadiationModel::setSourceSpectrum(uint source_ID, const std::string &spectrum_label) {
1282
1283 if (source_ID >= radiation_sources.size()) {
1284 helios_runtime_error("ERROR (RadiationModel::setSourceSpectrum): Cannot add radiation spectra for this source because it is not a valid radiation source ID.\n");
1285 }
1286
1287 std::vector<vec2> spectrum = loadSpectralData(spectrum_label);
1288
1289 radiation_sources.at(source_ID).source_spectrum = spectrum;
1290 radiation_sources.at(source_ID).source_spectrum_label = spectrum_label;
1291 radiation_sources.at(source_ID).source_spectrum_version = context->getGlobalDataVersion(spectrum_label.c_str());
1292
1293 radiativepropertiesneedupdate = true;
1294}
1295
1296void RadiationModel::setSourceSpectrum(const std::vector<uint> &source_ID, const std::string &spectrum_label) {
1297 for (auto ID: source_ID) {
1298 setSourceSpectrum(ID, spectrum_label);
1299 }
1300}
1301
1302void RadiationModel::setDiffuseSpectrum(const std::string &spectrum_label) {
1303
1304 std::vector<vec2> spectrum;
1305
1306 // standard solar spectrum
1307 if (spectrum_label == "ASTMG173") {
1308 spectrum = loadSpectralData("solar_spectrum_diffuse_ASTMG173");
1309 global_diffuse_spectrum_label = "solar_spectrum_diffuse_ASTMG173";
1310 } else {
1311 spectrum = loadSpectralData(spectrum_label);
1312 global_diffuse_spectrum_label = spectrum_label;
1313 }
1314
1315 // Store globally so new bands will also get this spectrum
1316 global_diffuse_spectrum = spectrum;
1317 global_diffuse_spectrum_version = context->getGlobalDataVersion(global_diffuse_spectrum_label.c_str());
1318
1319 // Apply to all existing bands
1320 for (auto &band_pair: radiation_bands) {
1321 band_pair.second.diffuse_spectrum = spectrum;
1322 }
1323
1324 radiativepropertiesneedupdate = true;
1325}
1326
1327float RadiationModel::getDiffuseFlux(const std::string &band_label) const {
1328
1329 if (!doesBandExist(band_label)) {
1330 helios_runtime_error("ERROR (RadiationModel::getDiffuseFlux): Cannot get diffuse flux for band '" + band_label + "' because it is not a valid band.");
1331 }
1332
1333 const RadiationBand &band = radiation_bands.at(band_label);
1334
1335 // For emission-enabled bands: spectra are not relevant, only use manual flux
1336 if (band.emissionFlag) {
1337 if (band.diffuseFlux >= 0.f) {
1338 return band.diffuseFlux;
1339 }
1340 return 0.f;
1341 }
1342
1343 // For non-emission bands: check manual flux first, then spectrum
1344 if (band.diffuseFlux >= 0.f) {
1345 return band.diffuseFlux;
1346 }
1347
1348 const std::vector<vec2> &spectrum = band.diffuse_spectrum;
1349 if (!spectrum.empty()) {
1350 vec2 wavebounds = band.wavebandBounds;
1351 if (wavebounds == make_vec2(0, 0)) {
1352 wavebounds = make_vec2(spectrum.front().x, spectrum.back().x);
1353 }
1354 return integrateSpectrum(spectrum, wavebounds.x, wavebounds.y);
1355 }
1356
1357 return 0.f;
1358}
1359
1361 islightvisualizationenabled = true;
1362
1363 // build the geometry of any existing sources at this point
1364 for (int s = 0; s < radiation_sources.size(); s++) {
1365 buildLightModelGeometry(s);
1366 }
1367}
1368
1370 islightvisualizationenabled = false;
1371 for (auto &UUIDs: source_model_UUIDs) {
1372 context->deletePrimitive(UUIDs.second);
1373 }
1374}
1375
1377 iscameravisualizationenabled = true;
1378
1379 // build the geometry of any existing cameras at this point
1380 for (auto &cam: cameras) {
1381 buildCameraModelGeometry(cam.first);
1382 }
1383}
1384
1386 iscameravisualizationenabled = false;
1387 for (auto &UUIDs: camera_model_UUIDs) {
1388 context->deletePrimitive(UUIDs.second);
1389 }
1390}
1391
1392void RadiationModel::buildLightModelGeometry(uint sourceID) {
1393
1394 assert(sourceID < radiation_sources.size());
1395
1396 RadiationSource source = radiation_sources.at(sourceID);
1397 if (source.source_type == RADIATION_SOURCE_TYPE_SPHERE) {
1398 source_model_UUIDs[sourceID] = context->loadOBJ("SphereLightSource.obj", true);
1399 } else if (source.source_type == RADIATION_SOURCE_TYPE_SUN_SPHERE) {
1400 source_model_UUIDs[sourceID] = context->loadOBJ("SphereLightSource.obj", true);
1401 } else if (source.source_type == RADIATION_SOURCE_TYPE_DISK) {
1402 source_model_UUIDs[sourceID] = context->loadOBJ("DiskLightSource.obj", true);
1403 context->scalePrimitive(source_model_UUIDs.at(sourceID), make_vec3(source.source_width.x, source.source_width.y, 0.05f * source.source_width.x));
1404 std::vector<uint> UUIDs_arrow = context->loadOBJ("Arrow.obj", true);
1405 source_model_UUIDs.at(sourceID).insert(source_model_UUIDs.at(sourceID).begin(), UUIDs_arrow.begin(), UUIDs_arrow.end());
1406 context->scalePrimitive(UUIDs_arrow, make_vec3(1, 1, 1) * 0.25f * source.source_width.x);
1407 } else if (source.source_type == RADIATION_SOURCE_TYPE_RECTANGLE) {
1408 source_model_UUIDs[sourceID] = context->loadOBJ("RectangularLightSource.obj", true);
1409 context->scalePrimitive(source_model_UUIDs.at(sourceID), make_vec3(source.source_width.x, source.source_width.y, fmin(0.05f * (source.source_width.x + source.source_width.y), 0.5f * fmin(source.source_width.x, source.source_width.y))));
1410 std::vector<uint> UUIDs_arrow = context->loadOBJ("Arrow.obj", true);
1411 source_model_UUIDs.at(sourceID).insert(source_model_UUIDs.at(sourceID).begin(), UUIDs_arrow.begin(), UUIDs_arrow.end());
1412 context->scalePrimitive(UUIDs_arrow, make_vec3(1, 1, 1) * 0.15f * (source.source_width.x + source.source_width.y));
1413 } else {
1414 return;
1415 }
1416
1417 if (source.source_type == RADIATION_SOURCE_TYPE_SPHERE) {
1418 context->scalePrimitive(source_model_UUIDs.at(sourceID), make_vec3(source.source_width.x, source.source_width.x, source.source_width.x));
1419 context->translatePrimitive(source_model_UUIDs.at(sourceID), source.source_position);
1420 } else if (source.source_type == RADIATION_SOURCE_TYPE_SUN_SPHERE) {
1421 vec3 center;
1422 float radius;
1423 context->getDomainBoundingSphere(center, radius);
1424 context->scalePrimitive(source_model_UUIDs.at(sourceID), make_vec3(1, 1, 1) * 0.1f * radius);
1425 vec3 sunvec = source.source_position;
1426 sunvec.normalize();
1427 context->translatePrimitive(source_model_UUIDs.at(sourceID), center + sunvec * radius);
1428 } else {
1429 context->rotatePrimitive(source_model_UUIDs.at(sourceID), source.source_rotation.x, "x");
1430 context->rotatePrimitive(source_model_UUIDs.at(sourceID), source.source_rotation.y, "y");
1431 context->rotatePrimitive(source_model_UUIDs.at(sourceID), source.source_rotation.z, "z");
1432 context->translatePrimitive(source_model_UUIDs.at(sourceID), source.source_position);
1433 }
1434
1435 context->setPrimitiveData(source_model_UUIDs.at(sourceID), "twosided_flag", uint(3)); // source model does not interact with radiation field
1436}
1437
1438void RadiationModel::buildCameraModelGeometry(const std::string &cameralabel) {
1439
1440 assert(cameras.find(cameralabel) != cameras.end());
1441
1442 RadiationCamera camera = cameras.at(cameralabel);
1443
1444 vec3 viewvec = camera.lookat - camera.position;
1445 SphericalCoord viewsph = cart2sphere(viewvec);
1446
1447 camera_model_UUIDs[cameralabel] = context->loadOBJ("Camera.obj", true);
1448
1449 context->rotatePrimitive(camera_model_UUIDs.at(cameralabel), viewsph.elevation, "x");
1450 context->rotatePrimitive(camera_model_UUIDs.at(cameralabel), -viewsph.azimuth, "z");
1451
1452 context->translatePrimitive(camera_model_UUIDs.at(cameralabel), camera.position);
1453
1454 context->setPrimitiveData(camera_model_UUIDs.at(cameralabel), "twosided_flag", uint(3)); // camera model does not interact with radiation field
1455}
1456
1457void RadiationModel::updateLightModelPosition(uint sourceID, const helios::vec3 &delta_position) {
1458
1459 assert(sourceID < radiation_sources.size());
1460
1461 RadiationSource source = radiation_sources.at(sourceID);
1462
1463 if (source.source_type != RADIATION_SOURCE_TYPE_SPHERE && source.source_type != RADIATION_SOURCE_TYPE_DISK && source.source_type != RADIATION_SOURCE_TYPE_RECTANGLE) {
1464 return;
1465 }
1466
1467 context->translatePrimitive(source_model_UUIDs.at(sourceID), delta_position);
1468}
1469
1470void RadiationModel::updateCameraModelPosition(const std::string &cameralabel) {
1471
1472 assert(cameras.find(cameralabel) != cameras.end());
1473
1474 context->deletePrimitive(camera_model_UUIDs.at(cameralabel));
1475 buildCameraModelGeometry(cameralabel);
1476}
1477
1478float RadiationModel::integrateSpectrum(uint source_ID, const std::vector<helios::vec2> &object_spectrum, float wavelength1, float wavelength2) const {
1479
1480 if (source_ID >= radiation_sources.size()) {
1481 helios_runtime_error("ERROR (RadiationModel::integrateSpectrum): Radiation spectrum was not set for source ID. Make sure to set its spectrum using setSourceSpectrum() function.");
1482 } else if (object_spectrum.size() < 2) {
1483 helios_runtime_error("ERROR (RadiationModel::integrateSpectrum): Radiation spectrum must have at least 2 wavelengths.");
1484 } else if (wavelength1 > wavelength2 || wavelength1 == wavelength2) {
1485 helios_runtime_error("ERROR (RadiationModel::integrateSpectrum): Lower wavelength bound must be less than the upper wavelength bound.");
1486 }
1487
1488 std::vector<helios::vec2> source_spectrum = radiation_sources.at(source_ID).source_spectrum;
1489
1490 int istart = 0;
1491 int iend = (int) object_spectrum.size() - 1;
1492 for (auto i = 0; i < object_spectrum.size() - 1; i++) {
1493
1494 if (object_spectrum.at(i).x <= wavelength1 && object_spectrum.at(i + 1).x > wavelength1) {
1495 istart = i;
1496 }
1497 if (object_spectrum.at(i).x <= wavelength2 && object_spectrum.at(i + 1).x > wavelength2) {
1498 iend = i + 1;
1499 break;
1500 }
1501 }
1502
1503 float E = 0;
1504 float Etot = 0;
1505 for (auto i = istart; i < iend; i++) {
1506
1507 float x0 = object_spectrum.at(i).x;
1508 float Esource0 = interp1(source_spectrum, object_spectrum.at(i).x);
1509 float Eobject0 = object_spectrum.at(i).y;
1510
1511 float x1 = object_spectrum.at(i + 1).x;
1512 float Eobject1 = object_spectrum.at(i + 1).y;
1513 float Esource1 = interp1(source_spectrum, object_spectrum.at(i + 1).x);
1514
1515 E += 0.5f * (Eobject0 * Esource0 + Eobject1 * Esource1) * (x1 - x0);
1516 Etot += 0.5f * (Esource1 + Esource0) * (x1 - x0);
1517 }
1518
1519 return E / Etot;
1520}
1521
1522float RadiationModel::integrateSpectrum(const std::vector<helios::vec2> &object_spectrum, float wavelength1, float wavelength2) const {
1523
1524 if (object_spectrum.size() < 2) {
1525 helios_runtime_error("ERROR (RadiationModel::integrateSpectrum): Radiation spectrum must have at least 2 wavelengths.");
1526 } else if (wavelength1 > wavelength2 || wavelength1 == wavelength2) {
1527 helios_runtime_error("ERROR (RadiationModel::integrateSpectrum): Lower wavelength bound must be less than the upper wavelength bound.");
1528 }
1529
1530 int istart = 1;
1531 int iend = (int) object_spectrum.size() - 1;
1532 for (auto i = 0; i < object_spectrum.size() - 1; i++) {
1533
1534 if (object_spectrum.at(i).x <= wavelength1 && object_spectrum.at(i + 1).x > wavelength1) {
1535 istart = i;
1536 }
1537 if (object_spectrum.at(i).x <= wavelength2 && object_spectrum.at(i + 1).x > wavelength2) {
1538 iend = i + 1;
1539 break;
1540 }
1541 }
1542
1543 float E = 0;
1544 for (auto i = istart; i < iend; i++) {
1545 float E0 = object_spectrum.at(i).y;
1546 float x0 = object_spectrum.at(i).x;
1547 float E1 = object_spectrum.at(i + 1).y;
1548 float x1 = object_spectrum.at(i + 1).x;
1549 E += (E0 + E1) * (x1 - x0) * 0.5f;
1550 }
1551
1552 return E;
1553}
1554
1555float RadiationModel::integrateSpectrum(const std::vector<helios::vec2> &object_spectrum) const {
1556 float wavelength1 = object_spectrum.at(0).x;
1557 float wavelength2 = object_spectrum.at(object_spectrum.size() - 1).x;
1558 float E = RadiationModel::integrateSpectrum(object_spectrum, wavelength1, wavelength2);
1559 return E;
1560}
1561
1562float RadiationModel::integrateSpectrum(uint source_ID, const std::vector<helios::vec2> &object_spectrum, const std::vector<helios::vec2> &camera_spectrum) const {
1563
1564 if (source_ID >= radiation_sources.size()) {
1565 helios_runtime_error("ERROR (RadiationModel::integrateSpectrum): Radiation spectrum was not set for source ID. Make sure to set its spectrum using setSourceSpectrum() function.");
1566 } else if (object_spectrum.size() < 2) {
1567 helios_runtime_error("ERROR (RadiationModel::integrateSpectrum): Radiation spectrum must have at least 2 wavelengths.");
1568 }
1569
1570 std::vector<helios::vec2> source_spectrum = radiation_sources.at(source_ID).source_spectrum;
1571
1572 float E = 0;
1573 float Etot = 0;
1574 for (auto i = 1; i < object_spectrum.size(); i++) {
1575
1576 if (object_spectrum.at(i).x <= source_spectrum.front().x || object_spectrum.at(i).x <= camera_spectrum.front().x) {
1577 continue;
1578 }
1579 if (object_spectrum.at(i).x > source_spectrum.back().x || object_spectrum.at(i).x > camera_spectrum.back().x) {
1580 break;
1581 }
1582 float x1 = object_spectrum.at(i).x;
1583 float Eobject1 = object_spectrum.at(i).y;
1584 float Esource1 = interp1(source_spectrum, x1);
1585 float Ecamera1 = interp1(camera_spectrum, x1);
1586
1587
1588 float x0 = object_spectrum.at(i - 1).x;
1589 float Eobject0 = object_spectrum.at(i - 1).y;
1590 float Esource0 = interp1(source_spectrum, x0);
1591 float Ecamera0 = interp1(camera_spectrum, x0);
1592
1593 E += 0.5f * ((Eobject1 * Esource1 * Ecamera1) + (Eobject0 * Ecamera0 * Esource0)) * (x1 - x0);
1594 Etot += 0.5f * (Esource1 + Esource0) * (x1 - x0);
1595 }
1596
1597
1598 return E / Etot;
1599}
1600
1601float RadiationModel::integrateSpectrum(const std::vector<helios::vec2> &object_spectrum, const std::vector<helios::vec2> &camera_spectrum) const {
1602
1603 if (object_spectrum.size() < 2) {
1604 helios_runtime_error("ERROR (RadiationModel::integrateSpectrum): Radiation spectrum must have at least 2 wavelengths.");
1605 }
1606
1607 float E = 0;
1608 float Etot = 0;
1609 for (auto i = 1; i < object_spectrum.size(); i++) {
1610
1611 if (object_spectrum.at(i).x <= camera_spectrum.front().x) {
1612 continue;
1613 }
1614 if (object_spectrum.at(i).x > camera_spectrum.back().x) {
1615 break;
1616 }
1617
1618 float x1 = object_spectrum.at(i).x;
1619 float Eobject1 = object_spectrum.at(i).y;
1620 float Ecamera1 = interp1(camera_spectrum, x1);
1621
1622
1623 float x0 = object_spectrum.at(i - 1).x;
1624 float Eobject0 = object_spectrum.at(i - 1).y;
1625 float Ecamera0 = interp1(camera_spectrum, x0);
1626
1627 E += 0.5f * ((Eobject1 * Ecamera1) + (Eobject0 * Ecamera0)) * (x1 - x0);
1628 Etot += 0.5f * (Ecamera1 + Ecamera0) * (x1 - x0);
1629 }
1630
1631 return E / Etot;
1632}
1633
1634float RadiationModel::integrateSourceSpectrum(uint source_ID, float wavelength1, float wavelength2) const {
1635
1636 if (source_ID >= radiation_sources.size()) {
1637 helios_runtime_error("ERROR (RadiationModel::integrateSourceSpectrum): Radiation spectrum was not set for source ID. Make sure to set its spectrum using setSourceSpectrum() function.");
1638 } else if (wavelength1 > wavelength2 || wavelength1 == wavelength2) {
1639 helios_runtime_error("ERROR (RadiationModel::integrateSourceSpectrum): Lower wavelength bound must be less than the upper wavelength bound.");
1640 }
1641
1642 return integrateSpectrum(radiation_sources.at(source_ID).source_spectrum, wavelength1, wavelength2);
1643}
1644
1645void RadiationModel::scaleSpectrum(const std::string &existing_global_data_label, const std::string &new_global_data_label, float scale_factor) const {
1646
1647 std::vector<helios::vec2> spectrum = loadSpectralData(existing_global_data_label);
1648
1649 for (helios::vec2 &s: spectrum) {
1650 s.y *= scale_factor;
1651 }
1652
1653 context->setGlobalData(new_global_data_label.c_str(), spectrum);
1654}
1655
1656void RadiationModel::scaleSpectrum(const std::string &global_data_label, float scale_factor) const {
1657
1658 std::vector<vec2> spectrum = loadSpectralData(global_data_label);
1659
1660 for (vec2 &s: spectrum) {
1661 s.y *= scale_factor;
1662 }
1663
1664 context->setGlobalData(global_data_label.c_str(), spectrum);
1665}
1666
1667void RadiationModel::scaleSpectrumRandomly(const std::string &existing_global_data_label, const std::string &new_global_data_label, float minimum_scale_factor, float maximum_scale_factor) const {
1668
1669 scaleSpectrum(existing_global_data_label, new_global_data_label, context->randu(minimum_scale_factor, maximum_scale_factor));
1670}
1671
1672
1673void RadiationModel::blendSpectra(const std::string &new_spectrum_label, const std::vector<std::string> &spectrum_labels, const std::vector<float> &weights) const {
1674
1675 if (spectrum_labels.size() != weights.size()) {
1676 helios_runtime_error("ERROR (RadiationModel::blendSpectra): number of spectra and weights must be equal");
1677 } else if (fabsf(sum(weights) - 1.f) > 1e-5f) {
1678 helios_runtime_error("ERROR (RadiationModel::blendSpectra): weights must sum to 1");
1679 }
1680
1681 std::vector<vec2> new_spectrum;
1682 uint spectrum_size = 0;
1683
1684 std::vector<std::vector<vec2>> spectrum(spectrum_labels.size());
1685
1686 uint lambda_start = 0;
1687 uint lambda_end = 0;
1688 for (uint i = 0; i < spectrum_labels.size(); i++) {
1689
1690 spectrum.at(i) = loadSpectralData(spectrum_labels.at(i));
1691
1692 if (i == 0) {
1693 lambda_start = spectrum.at(i).front().x;
1694 lambda_end = spectrum.at(i).back().x;
1695 } else {
1696 if (spectrum.at(i).front().x > lambda_start) {
1697 lambda_start = spectrum.at(i).front().x;
1698 }
1699 if (spectrum.at(i).back().x < lambda_end) {
1700 lambda_end = spectrum.at(i).back().x;
1701 }
1702 }
1703 }
1704
1705 spectrum_size = lambda_end - lambda_start + 1;
1706 new_spectrum.resize(spectrum_size);
1707 for (uint j = 0; j < spectrum_size; j++) {
1708 new_spectrum.at(j) = make_vec2(lambda_start + j, 0);
1709 }
1710
1711 // trim front
1712 for (uint i = 0; i < spectrum_labels.size(); i++) {
1713 for (uint j = 0; j < spectrum.at(i).size(); j++) {
1714
1715 if (spectrum.at(i).at(j).x >= lambda_start) {
1716 if (j > 0) {
1717 spectrum.at(i).erase(spectrum.at(i).begin(), spectrum.at(i).begin() + j);
1718 }
1719 break;
1720 }
1721 }
1722 }
1723
1724 // trim back
1725 for (uint i = 0; i < spectrum_labels.size(); i++) {
1726 for (int j = spectrum.at(i).size() - 1; j <= 0; j--) {
1727
1728 if (spectrum.at(i).at(j).x <= lambda_end) {
1729 if (j < spectrum.at(i).size() - 1) {
1730 spectrum.at(i).erase(spectrum.at(i).begin() + j + 1, spectrum.at(i).end());
1731 }
1732 break;
1733 }
1734 }
1735 }
1736
1737 for (uint i = 0; i < spectrum_labels.size(); i++) {
1738 for (uint j = 0; j < spectrum_size; j++) {
1739 assert(new_spectrum.at(j).x == spectrum.at(i).at(j).x);
1740 new_spectrum.at(j).y += weights.at(i) * spectrum.at(i).at(j).y;
1741 }
1742 }
1743
1744 context->setGlobalData(new_spectrum_label.c_str(), new_spectrum);
1745}
1746
1747void RadiationModel::blendSpectraRandomly(const std::string &new_spectrum_label, const std::vector<std::string> &spectrum_labels) const {
1748
1749 std::vector<float> weights;
1750 weights.resize(spectrum_labels.size());
1751 for (uint i = 0; i < spectrum_labels.size(); i++) {
1752 weights.at(i) = context->randu();
1753 }
1754 float sum_weights = sum(weights);
1755 for (uint i = 0; i < spectrum_labels.size(); i++) {
1756 weights.at(i) /= sum_weights;
1757 }
1758
1759 blendSpectra(new_spectrum_label, spectrum_labels, weights);
1760}
1761
1762void RadiationModel::interpolateSpectrumFromPrimitiveData(const std::vector<uint> &primitive_UUIDs, const std::vector<std::string> &spectra, const std::vector<float> &values, const std::string &primitive_data_query_label,
1763 const std::string &primitive_data_radprop_label) {
1764
1765 // Validate that spectra and values have the same length
1766 if (spectra.size() != values.size()) {
1767 helios_runtime_error("ERROR (RadiationModel::interpolateSpectrumFromPrimitiveData): The 'spectra' vector (size=" + std::to_string(spectra.size()) + ") and 'values' vector (size=" + std::to_string(values.size()) +
1768 ") must have the same length.");
1769 }
1770
1771 // Validate that vectors are not empty
1772 if (spectra.empty()) {
1773 helios_runtime_error("ERROR (RadiationModel::interpolateSpectrumFromPrimitiveData): The 'spectra' and 'values' vectors cannot be empty.");
1774 }
1775
1776 // Validate that primitive_UUIDs is not empty
1777 if (primitive_UUIDs.empty()) {
1778 helios_runtime_error("ERROR (RadiationModel::interpolateSpectrumFromPrimitiveData): The 'primitive_UUIDs' vector cannot be empty.");
1779 }
1780
1781 // Validate that query and target data labels are not empty
1782 if (primitive_data_query_label.empty()) {
1783 helios_runtime_error("ERROR (RadiationModel::interpolateSpectrumFromPrimitiveData): The 'primitive_data_query_label' cannot be empty.");
1784 }
1785
1786 if (primitive_data_radprop_label.empty()) {
1787 helios_runtime_error("ERROR (RadiationModel::interpolateSpectrumFromPrimitiveData): The 'primitive_data_radprop_label' cannot be empty.");
1788 }
1789
1790 // Search for existing config with matching query and target labels
1791 SpectrumInterpolationConfig *existing_config = nullptr;
1792 for (auto &config: spectrum_interpolation_configs) {
1793 if (config.query_data_label == primitive_data_query_label && config.target_data_label == primitive_data_radprop_label) {
1794 existing_config = &config;
1795 break;
1796 }
1797 }
1798
1799 if (existing_config != nullptr) {
1800 // Check if spectra/values match the existing config
1801 bool spectra_match = (existing_config->spectra_labels == spectra && existing_config->mapping_values == values);
1802
1803 if (spectra_match) {
1804 // Merge UUIDs into existing config (unordered_set handles duplicates automatically)
1805 existing_config->primitive_UUIDs.insert(primitive_UUIDs.begin(), primitive_UUIDs.end());
1806 } else {
1807 // Replace entire config with new spectra/values and UUIDs
1808 existing_config->spectra_labels = spectra;
1809 existing_config->mapping_values = values;
1810 existing_config->primitive_UUIDs.clear();
1811 existing_config->primitive_UUIDs.insert(primitive_UUIDs.begin(), primitive_UUIDs.end());
1812 }
1813 } else {
1814 // Create new config
1815 SpectrumInterpolationConfig config;
1816 config.primitive_UUIDs.insert(primitive_UUIDs.begin(), primitive_UUIDs.end());
1817 config.spectra_labels = spectra;
1818 config.mapping_values = values;
1819 config.query_data_label = primitive_data_query_label;
1820 config.target_data_label = primitive_data_radprop_label;
1821
1822 spectrum_interpolation_configs.push_back(config);
1823 }
1824}
1825
1826void RadiationModel::interpolateSpectrumFromObjectData(const std::vector<uint> &object_IDs, const std::vector<std::string> &spectra, const std::vector<float> &values, const std::string &object_data_query_label,
1827 const std::string &primitive_data_radprop_label) {
1828
1829 // Validate that spectra and values have the same length
1830 if (spectra.size() != values.size()) {
1831 helios_runtime_error("ERROR (RadiationModel::interpolateSpectrumFromObjectData): The 'spectra' vector (size=" + std::to_string(spectra.size()) + ") and 'values' vector (size=" + std::to_string(values.size()) + ") must have the same length.");
1832 }
1833
1834 // Validate that vectors are not empty
1835 if (spectra.empty()) {
1836 helios_runtime_error("ERROR (RadiationModel::interpolateSpectrumFromObjectData): The 'spectra' and 'values' vectors cannot be empty.");
1837 }
1838
1839 // Validate that object_IDs is not empty
1840 if (object_IDs.empty()) {
1841 helios_runtime_error("ERROR (RadiationModel::interpolateSpectrumFromObjectData): The 'object_IDs' vector cannot be empty.");
1842 }
1843
1844 // Validate that query and target data labels are not empty
1845 if (object_data_query_label.empty()) {
1846 helios_runtime_error("ERROR (RadiationModel::interpolateSpectrumFromObjectData): The 'object_data_query_label' cannot be empty.");
1847 }
1848
1849 if (primitive_data_radprop_label.empty()) {
1850 helios_runtime_error("ERROR (RadiationModel::interpolateSpectrumFromObjectData): The 'primitive_data_radprop_label' cannot be empty.");
1851 }
1852
1853 // Search for existing config with matching query and target labels
1854 SpectrumInterpolationConfig *existing_config = nullptr;
1855 for (auto &config: spectrum_interpolation_configs) {
1856 if (config.query_data_label == object_data_query_label && config.target_data_label == primitive_data_radprop_label) {
1857 existing_config = &config;
1858 break;
1859 }
1860 }
1861
1862 if (existing_config != nullptr) {
1863 // Check if spectra/values match the existing config
1864 bool spectra_match = (existing_config->spectra_labels == spectra && existing_config->mapping_values == values);
1865
1866 if (spectra_match) {
1867 // Merge object IDs into existing config (unordered_set handles duplicates automatically)
1868 existing_config->object_IDs.insert(object_IDs.begin(), object_IDs.end());
1869 } else {
1870 // Replace entire config with new spectra/values and object IDs
1871 existing_config->spectra_labels = spectra;
1872 existing_config->mapping_values = values;
1873 existing_config->object_IDs.clear();
1874 existing_config->object_IDs.insert(object_IDs.begin(), object_IDs.end());
1875 }
1876 } else {
1877 // Create new config
1878 SpectrumInterpolationConfig config;
1879 config.object_IDs.insert(object_IDs.begin(), object_IDs.end());
1880 config.spectra_labels = spectra;
1881 config.mapping_values = values;
1882 config.query_data_label = object_data_query_label;
1883 config.target_data_label = primitive_data_radprop_label;
1884
1885 spectrum_interpolation_configs.push_back(config);
1886 }
1887}
1888
1889void RadiationModel::setSourcePosition(uint source_ID, const vec3 &position) {
1890
1891 if (source_ID >= radiation_sources.size()) {
1892 helios_runtime_error("ERROR (RadiationModel::setSourcePosition): Source ID out of bounds. Only " + std::to_string(radiation_sources.size() - 1) + " radiation sources.");
1893 }
1894
1895 vec3 old_position = radiation_sources.at(source_ID).source_position;
1896
1897 if (radiation_sources.at(source_ID).source_type == RADIATION_SOURCE_TYPE_COLLIMATED) {
1898 radiation_sources.at(source_ID).source_position = position / position.magnitude();
1899 } else {
1900 radiation_sources.at(source_ID).source_position = position * radiation_sources.at(source_ID).source_position_scaling_factor;
1901 }
1902
1903 if (islightvisualizationenabled) {
1904 updateLightModelPosition(source_ID, radiation_sources.at(source_ID).source_position - old_position);
1905 }
1906}
1907
1909 setSourcePosition(source_ID, sphere2cart(position));
1910}
1911
1913 if (source_ID >= radiation_sources.size()) {
1914 helios_runtime_error("ERROR (RadiationModel::getSourcePosition): Source ID does not exist.");
1915 }
1916 return radiation_sources.at(source_ID).source_position;
1917}
1918
1919void RadiationModel::setScatteringDepth(const std::string &label, uint depth) {
1920
1921 if (!doesBandExist(label)) {
1922 helios_runtime_error("ERROR (RadiationModel::setScatteringDepth): Cannot set scattering depth for band '" + label + "' because it is not a valid band.");
1923 }
1924 radiation_bands.at(label).scatteringDepth = depth;
1925}
1926
1927void RadiationModel::setMinScatterEnergy(const std::string &label, uint energy) {
1928
1929 if (!doesBandExist(label)) {
1930 helios_runtime_error("ERROR (setMinScatterEnergy): Cannot set minimum scattering energy for band '" + label + "' because it is not a valid band.");
1931 }
1932 radiation_bands.at(label).minScatterEnergy = energy;
1933}
1934
1935void RadiationModel::enforcePeriodicBoundary(const std::string &boundary) {
1936
1937 if (boundary == "x") {
1938
1939 periodic_flag.x = 1;
1940
1941 } else if (boundary == "y") {
1942
1943 periodic_flag.y = 1;
1944
1945 } else if (boundary == "xy") {
1946
1947 periodic_flag.x = 1;
1948 periodic_flag.y = 1;
1949
1950 } else {
1951
1952 std::cout << "WARNING (RadiationModel::enforcePeriodicBoundary()): unknown boundary of '" << boundary << "'. Possible choices are x, y, or xy." << std::endl;
1953 }
1954}
1955
1957 updateGeometry(context->getAllUUIDs());
1958}
1959
1960
1961void RadiationModel::updateGeometry(const std::vector<uint> &UUIDs) {
1962
1963 if (message_flag) {
1964 std::cout << "Updating geometry in radiation transport model..." << std::flush;
1965 }
1966
1967 // Upload geometry through backend abstraction layer
1968 buildGeometryData(UUIDs);
1969 buildUUIDMapping(); // Build UUID↔position mapping for efficient indexing
1970
1971 // CRITICAL: context_UUIDs must match GPU buffer ordering (primitive_UUIDs_ordered)
1972 // Emission data is indexed by position, which corresponds to primitive_UUIDs order
1973 context_UUIDs = geometry_data.primitive_UUIDs;
1974
1975 backend->updateGeometry(geometry_data);
1976 backend->buildAccelerationStructure();
1977
1978 radiativepropertiesneedupdate = true;
1979 isgeometryinitialized = true;
1980
1981 if (message_flag) {
1982 std::cout << "done." << std::endl;
1983 }
1984}
1985
1986void RadiationModel::updateRadiativeProperties() {
1987
1988 // Possible scenarios for specifying a primitive's radiative properties
1989 // 1. If primitive data of form reflectivity_band/transmissivity_band is given, this value is used and overrides any other option.
1990 // 2. If primitive data of form reflectivity_spectrum/transmissivity_spectrum is given that references global data containing spectral reflectivity/transmissivity:
1991 // 2a. If radiation source spectrum was not given, assume source spectral intensity is constant over band and calculate using primitive spectrum
1992 // 2b. If radiation source spectrum was given, calculate using both source and primitive spectrum.
1993
1994 // Create warning aggregator
1996 warnings.setEnabled(message_flag);
1997
1998 if (message_flag) {
1999 std::cout << "Updating radiative properties..." << std::flush;
2000 }
2001
2002 uint Nbands = radiation_bands.size(); // number of radiative bands
2003 uint Nsources = radiation_sources.size();
2004 uint Ncameras = cameras.size();
2005 size_t Nobjects = primitiveID.size();
2006 size_t Nprimitives = context_UUIDs.size();
2007
2008 scattering_iterations_needed.clear();
2009 for (auto &band: radiation_bands) {
2010 scattering_iterations_needed[band.first] = false;
2011 }
2012
2013 float eps;
2014
2015 std::string prop;
2016 std::vector<std::string> band_labels;
2017 for (auto &band: radiation_bands) {
2018 band_labels.push_back(band.first);
2019 }
2020
2021 // Allocate flat arrays directly in material_data to avoid nested vector overhead and redundant copies
2022 material_data.num_primitives = Nprimitives;
2023 material_data.num_bands = Nbands;
2024 material_data.num_sources = Nsources;
2025 material_data.num_cameras = Ncameras;
2026
2027 size_t mat_size = (size_t)Nsources * Nprimitives * Nbands;
2028 material_data.reflectivity.assign(mat_size, rho_default);
2029 material_data.transmissivity.assign(mat_size, tau_default);
2030
2031 // Translucent cover (glass/plastic) material arrays. Default: not glass (is_glass=0), lossless (KL=0).
2032 material_data.glass_n.assign(mat_size, 0.f);
2033 material_data.glass_KL.assign(mat_size, 0.f);
2034 material_data.is_glass.assign(mat_size, 0);
2035
2036 if (Ncameras > 0) {
2037 size_t cam_size = (size_t)Nsources * Nprimitives * Nbands * Ncameras;
2038 material_data.reflectivity_cam.assign(cam_size, rho_default);
2039 material_data.transmissivity_cam.assign(cam_size, tau_default);
2040 } else {
2041 material_data.reflectivity_cam.clear();
2042 material_data.transmissivity_cam.clear();
2043 }
2044
2045 MaterialPropertyIndexer mat_idx(Nsources, Nprimitives, Nbands);
2046 CameraMaterialIndexer cam_idx(Nsources, Nprimitives, Nbands, Ncameras);
2047
2048 // Cache all unique camera spectral responses for all cameras and bands
2049 std::vector<std::vector<std::vector<helios::vec2>>> camera_response_unique;
2050 camera_response_unique.resize(Ncameras);
2051 if (Ncameras > 0) {
2052 uint cam = 0;
2053 for (const auto &camera: cameras) {
2054
2055 camera_response_unique.at(cam).resize(Nbands);
2056
2057 for (uint b = 0; b < Nbands; b++) {
2058
2059 if (camera.second.band_spectral_response.find(band_labels.at(b)) == camera.second.band_spectral_response.end()) {
2060 continue;
2061 }
2062
2063 std::string camera_response = camera.second.band_spectral_response.at(band_labels.at(b));
2064
2065 if (!camera_response.empty()) {
2066
2067 if (!context->doesGlobalDataExist(camera_response.c_str())) {
2068 if (camera_response != "uniform") {
2069 warnings.addWarning("missing_camera_response", "Camera spectral response \"" + camera_response + "\" does not exist. Assuming a uniform spectral response.");
2070 }
2071 } else if (context->getGlobalDataType(camera_response.c_str()) == helios::HELIOS_TYPE_VEC2) {
2072
2073 std::vector<helios::vec2> data = loadSpectralData(camera_response.c_str());
2074
2075 camera_response_unique.at(cam).at(b) = data;
2076
2077 } else if (context->getGlobalDataType(camera_response.c_str()) != helios::HELIOS_TYPE_VEC2 && context->getGlobalDataType(camera_response.c_str()) != helios::HELIOS_TYPE_STRING) {
2078 camera_response.clear();
2079 warnings.addWarning("camera_response_wrong_type", "Camera spectral response \"" + camera_response + "\" is not of type HELIOS_TYPE_VEC2 or HELIOS_TYPE_STRING. Assuming a uniform spectral response...");
2080 }
2081 }
2082 }
2083 cam++;
2084 }
2085 }
2086
2087 // Spectral integration cache to avoid redundant computations
2088 std::unordered_map<std::string, float> spectral_integration_cache;
2089
2090#ifdef USE_OPENMP
2091 // Temporary cache for this thread group (will be merged later)
2092 std::unordered_map<std::string, float> temp_spectral_cache;
2093#endif
2094
2095 // Helper function to create cache keys for spectral integrations
2096 auto createCacheKey = [](const std::string &spectrum_label, uint source_id, uint band_id, uint camera_id, const std::string &type) -> std::string {
2097 return spectrum_label + "_" + std::to_string(source_id) + "_" + std::to_string(band_id) + "_" + std::to_string(camera_id) + "_" + type;
2098 };
2099
2100 // Helper function to get from cache (thread-safe)
2101 auto getCachedValue = [&](const std::string &cache_key, bool &found) -> float {
2102 float result = 0.0f;
2103 found = false;
2104
2105#ifdef USE_OPENMP
2106#pragma omp critical
2107 {
2108#endif
2109 // Check shared cache
2110 auto cache_it = spectral_integration_cache.find(cache_key);
2111 if (cache_it != spectral_integration_cache.end()) {
2112 found = true;
2113 result = cache_it->second;
2114 }
2115#ifdef USE_OPENMP
2116 }
2117#endif
2118 return result;
2119 };
2120
2121 // Helper function to store in cache (thread-safe)
2122 auto setCachedValue = [&](const std::string &cache_key, float value) {
2123#ifdef USE_OPENMP
2124#pragma omp critical
2125 {
2126#endif
2127 spectral_integration_cache[cache_key] = value;
2128#ifdef USE_OPENMP
2129 }
2130#endif
2131 };
2132
2133 // Helper function for cached interpolation (thread-safe)
2134 auto cachedInterp1 = [&](const std::vector<helios::vec2> &spectrum, float wavelength, const std::string &spectrum_id) -> float {
2135 // Create cache key for this specific interpolation
2136 std::string cache_key = "interp_" + spectrum_id + "_" + std::to_string(wavelength);
2137
2138 bool found = false;
2139 float cached_result = getCachedValue(cache_key, found);
2140 if (found) {
2141 return cached_result;
2142 }
2143
2144 // Perform interpolation and cache result
2145 float result = interp1(spectrum, wavelength);
2146 setCachedValue(cache_key, result);
2147 return result;
2148 };
2149
2150 // Cached version of integrateSpectrum with source spectrum
2151 auto cachedIntegrateSpectrumWithSource = [&](uint source_ID, const std::vector<helios::vec2> &object_spectrum, float wavelength1, float wavelength2, const std::string &object_spectrum_id) -> float {
2152 if (source_ID >= radiation_sources.size() || object_spectrum.size() < 2 || wavelength1 >= wavelength2) {
2153 return 0.0f; // Handle edge cases gracefully
2154 }
2155
2156 std::vector<helios::vec2> source_spectrum = radiation_sources.at(source_ID).source_spectrum;
2157 std::string source_id = "source_" + std::to_string(source_ID);
2158
2159 int istart = 0;
2160 int iend = (int) object_spectrum.size() - 1;
2161 for (auto i = 0; i < object_spectrum.size() - 1; i++) {
2162 if (object_spectrum.at(i).x <= wavelength1 && object_spectrum.at(i + 1).x > wavelength1) {
2163 istart = i;
2164 }
2165 if (object_spectrum.at(i).x <= wavelength2 && object_spectrum.at(i + 1).x > wavelength2) {
2166 iend = i + 1;
2167 break;
2168 }
2169 }
2170
2171 float E = 0;
2172 float Etot = 0;
2173 for (auto i = istart; i < iend; i++) {
2174 float x0 = object_spectrum.at(i).x;
2175 float Esource0 = cachedInterp1(source_spectrum, x0, source_id);
2176 float Eobject0 = object_spectrum.at(i).y;
2177
2178 float x1 = object_spectrum.at(i + 1).x;
2179 float Eobject1 = object_spectrum.at(i + 1).y;
2180 float Esource1 = cachedInterp1(source_spectrum, x1, source_id);
2181
2182 E += 0.5f * (Eobject0 * Esource0 + Eobject1 * Esource1) * (x1 - x0);
2183 Etot += 0.5f * (Esource1 + Esource0) * (x1 - x0);
2184 }
2185
2186 return (Etot != 0.0f) ? E / Etot : 0.0f;
2187 };
2188
2189 // Cached version of integrateSpectrum with source and camera spectra
2190 auto cachedIntegrateSpectrumWithSourceAndCamera = [&](uint source_ID, const std::vector<helios::vec2> &object_spectrum, const std::vector<helios::vec2> &camera_spectrum, uint camera_index, uint band_index,
2191 const std::string &object_spectrum_id) -> float {
2192 if (source_ID >= radiation_sources.size() || object_spectrum.size() < 2) {
2193 return 0.0f;
2194 }
2195
2196 std::vector<helios::vec2> source_spectrum = radiation_sources.at(source_ID).source_spectrum;
2197 std::string source_id = "source_" + std::to_string(source_ID);
2198 std::string camera_id = "camera_" + std::to_string(camera_index) + "_band_" + std::to_string(band_index); // Include band for unique cache key per band
2199
2200 float E = 0;
2201 float Etot = 0;
2202 for (auto i = 1; i < object_spectrum.size(); i++) {
2203 if (object_spectrum.at(i).x <= source_spectrum.front().x || object_spectrum.at(i).x <= camera_spectrum.front().x) {
2204 continue;
2205 }
2206 if (object_spectrum.at(i).x > source_spectrum.back().x || object_spectrum.at(i).x > camera_spectrum.back().x) {
2207 break;
2208 }
2209
2210 float x1 = object_spectrum.at(i).x;
2211 float Eobject1 = object_spectrum.at(i).y;
2212 float Esource1 = cachedInterp1(source_spectrum, x1, source_id);
2213 float Ecamera1 = cachedInterp1(camera_spectrum, x1, camera_id);
2214
2215 float x0 = object_spectrum.at(i - 1).x;
2216 float Eobject0 = object_spectrum.at(i - 1).y;
2217 float Esource0 = cachedInterp1(source_spectrum, x0, source_id);
2218 float Ecamera0 = cachedInterp1(camera_spectrum, x0, camera_id);
2219
2220 E += 0.5f * ((Eobject1 * Esource1 * Ecamera1) + (Eobject0 * Ecamera0 * Esource0)) * (x1 - x0);
2221 Etot += 0.5f * (Esource1 + Esource0) * (x1 - x0);
2222 }
2223
2224 return (Etot != 0.0f) ? E / Etot : 0.0f;
2225 };
2226
2227 // Apply spectral interpolation based on primitive data values
2228 for (const auto &config: spectrum_interpolation_configs) {
2229 // Validate that all spectra in this config exist in global data and have correct type
2230 for (const auto &spectrum_label: config.spectra_labels) {
2231 if (!context->doesGlobalDataExist(spectrum_label.c_str())) {
2232 helios_runtime_error("ERROR (RadiationModel::updateRadiativeProperties): Spectral interpolation config references global data '" + spectrum_label + "' which does not exist.");
2233 }
2234 if (context->getGlobalDataType(spectrum_label.c_str()) != helios::HELIOS_TYPE_VEC2) {
2235 helios_runtime_error("ERROR (RadiationModel::updateRadiativeProperties): Spectral interpolation config references global data '" + spectrum_label + "' which must be of type HELIOS_TYPE_VEC2 (std::vector<helios::vec2>).");
2236 }
2237 }
2238
2239 for (uint uuid: config.primitive_UUIDs) {
2240 // Check if primitive still exists in context (it may have been deleted)
2241 if (!context->doesPrimitiveExist(uuid)) {
2242 continue;
2243 }
2244
2245 // Check if the query data exists for this primitive and has correct type
2246 if (context->doesPrimitiveDataExist(uuid, config.query_data_label.c_str())) {
2247 // Check that query data is of type float
2248 if (context->getPrimitiveDataType(config.query_data_label.c_str()) != helios::HELIOS_TYPE_FLOAT) {
2249 helios_runtime_error("ERROR (RadiationModel::updateRadiativeProperties): Primitive data '" + config.query_data_label + "' for UUID " + std::to_string(uuid) + " must be of type HELIOS_TYPE_FLOAT for spectral interpolation.");
2250 }
2251
2252 // Get the query value
2253 float query_value;
2254 context->getPrimitiveData(uuid, config.query_data_label.c_str(), query_value);
2255
2256 // Perform nearest-neighbor interpolation
2257 size_t nearest_idx = 0;
2258 float min_distance = std::abs(query_value - config.mapping_values[0]);
2259 for (size_t i = 1; i < config.mapping_values.size(); i++) {
2260 float distance = std::abs(query_value - config.mapping_values[i]);
2261 if (distance < min_distance) {
2262 min_distance = distance;
2263 nearest_idx = i;
2264 }
2265 }
2266
2267 // Set the target primitive data to the selected spectrum label
2268 context->setPrimitiveData(uuid, config.target_data_label.c_str(), config.spectra_labels[nearest_idx]);
2269 }
2270 }
2271
2272 // Apply spectral interpolation based on object data values
2273 for (uint objID: config.object_IDs) {
2274 // Check if object still exists in context (it may have been deleted)
2275 if (!context->doesObjectExist(objID)) {
2276 continue;
2277 }
2278
2279 // Check if the query data exists for this object and has correct type
2280 if (context->doesObjectDataExist(objID, config.query_data_label.c_str())) {
2281 // Check that query data is of type float
2282 if (context->getObjectDataType(config.query_data_label.c_str()) != helios::HELIOS_TYPE_FLOAT) {
2283 helios_runtime_error("ERROR (RadiationModel::updateRadiativeProperties): Object data '" + config.query_data_label + "' for object ID " + std::to_string(objID) + " must be of type HELIOS_TYPE_FLOAT for spectral interpolation.");
2284 }
2285
2286 // Get the query value
2287 float query_value;
2288 context->getObjectData(objID, config.query_data_label.c_str(), query_value);
2289
2290 // Perform nearest-neighbor interpolation
2291 size_t nearest_idx = 0;
2292 float min_distance = std::abs(query_value - config.mapping_values.at(0));
2293 for (size_t i = 1; i < config.mapping_values.size(); i++) {
2294 float distance = std::abs(query_value - config.mapping_values.at(i));
2295 if (distance < min_distance) {
2296 min_distance = distance;
2297 nearest_idx = i;
2298 }
2299 }
2300
2301 // Get object's primitive UUIDs and set their primitive data using vector overload
2302 std::vector<uint> prim_uuids = context->getObjectPrimitiveUUIDs(objID);
2303 context->setPrimitiveData(prim_uuids, config.target_data_label.c_str(), config.spectra_labels.at(nearest_idx));
2304 }
2305 }
2306 }
2307
2308 // Cache all unique primitive reflectivity and transmissivity spectra before assigning to primitives
2309
2310 // first, figure out all of the spectra referenced by all primitives and store it in "surface_spectra" to avoid having to load it again
2311 std::map<std::string, std::vector<helios::vec2>> surface_spectra_rho;
2312 std::map<std::string, std::vector<helios::vec2>> surface_spectra_tau;
2313 for (size_t u = 0; u < Nprimitives; u++) {
2314
2315 uint UUID = context_UUIDs.at(u);
2316
2317 if (context->doesPrimitiveDataExist(UUID, "reflectivity_spectrum")) {
2318 if (context->getPrimitiveDataType("reflectivity_spectrum") == HELIOS_TYPE_STRING) {
2319 std::string spectrum_label;
2320 context->getPrimitiveData(UUID, "reflectivity_spectrum", spectrum_label);
2321
2322 // get the spectral reflectivity data and store it in surface_spectra to avoid having to load it again
2323 if (surface_spectra_rho.find(spectrum_label) == surface_spectra_rho.end()) {
2324 if (!context->doesGlobalDataExist(spectrum_label.c_str())) {
2325 if (!spectrum_label.empty()) {
2326 warnings.addWarning("missing_reflectivity_spectrum", "Primitive spectral reflectivity \"" + spectrum_label + "\" does not exist. Using default reflectivity of 0.");
2327 }
2328 std::vector<helios::vec2> data;
2329 surface_spectra_rho.emplace(spectrum_label, data);
2330 } else if (context->getGlobalDataType(spectrum_label.c_str()) == HELIOS_TYPE_VEC2) {
2331
2332 std::vector<helios::vec2> data = loadSpectralData(spectrum_label.c_str());
2333 surface_spectra_rho.emplace(spectrum_label, data);
2334
2335 } else if (context->getGlobalDataType(spectrum_label.c_str()) != helios::HELIOS_TYPE_VEC2 && context->getGlobalDataType(spectrum_label.c_str()) != helios::HELIOS_TYPE_STRING) {
2336 spectrum_label.clear();
2337 warnings.addWarning("reflectivity_spectrum_wrong_type", "Object spectral reflectivity \"" + spectrum_label + "\" is not of type HELIOS_TYPE_VEC2 or HELIOS_TYPE_STRING. Assuming a uniform spectral distribution...");
2338 }
2339 }
2340 }
2341 }
2342
2343 if (context->doesPrimitiveDataExist(UUID, "transmissivity_spectrum")) {
2344 if (context->getPrimitiveDataType("transmissivity_spectrum") == HELIOS_TYPE_STRING) {
2345 std::string spectrum_label;
2346 context->getPrimitiveData(UUID, "transmissivity_spectrum", spectrum_label);
2347
2348 // get the spectral transmissivity data and store it in surface_spectra to avoid having to load it again
2349 if (surface_spectra_tau.find(spectrum_label) == surface_spectra_tau.end()) {
2350 if (!context->doesGlobalDataExist(spectrum_label.c_str())) {
2351 if (!spectrum_label.empty()) {
2352 warnings.addWarning("missing_transmissivity_spectrum", "Primitive spectral transmissivity \"" + spectrum_label + "\" does not exist. Using default transmissivity of 0.");
2353 }
2354 std::vector<helios::vec2> data;
2355 surface_spectra_tau.emplace(spectrum_label, data);
2356 } else if (context->getGlobalDataType(spectrum_label.c_str()) == HELIOS_TYPE_VEC2) {
2357
2358 std::vector<helios::vec2> data = loadSpectralData(spectrum_label.c_str());
2359 surface_spectra_tau.emplace(spectrum_label, data);
2360
2361 } else if (context->getGlobalDataType(spectrum_label.c_str()) != helios::HELIOS_TYPE_VEC2 && context->getGlobalDataType(spectrum_label.c_str()) != helios::HELIOS_TYPE_STRING) {
2362 spectrum_label.clear();
2363 warnings.addWarning("transmissivity_spectrum_wrong_type", "Object spectral transmissivity \"" + spectrum_label + "\" is not of type HELIOS_TYPE_VEC2 or HELIOS_TYPE_STRING. Assuming a uniform spectral distribution...");
2364 }
2365 }
2366 }
2367 }
2368 }
2369
2370 // second, calculate unique values of rho and tau for all sources and bands
2371 std::map<std::string, std::vector<std::vector<float>>> rho_unique;
2372 std::map<std::string, std::vector<std::vector<float>>> tau_unique;
2373
2374 std::map<std::string, std::vector<std::vector<std::vector<float>>>> rho_cam_unique;
2375 std::map<std::string, std::vector<std::vector<std::vector<float>>>> tau_cam_unique;
2376
2377 std::vector<std::vector<float>> empty;
2378 empty.resize(Nbands);
2379 for (uint b = 0; b < Nbands; b++) {
2380 empty.at(b).resize(Nsources, 0);
2381 }
2382 std::vector<std::vector<std::vector<float>>> empty_cam;
2383 if (Ncameras > 0) {
2384 empty_cam.resize(Nbands);
2385 for (uint b = 0; b < Nbands; b++) {
2386 empty_cam.at(b).resize(Nsources);
2387 for (uint s = 0; s < Nsources; s++) {
2388 empty_cam.at(b).at(s).resize(Ncameras, 0);
2389 }
2390 }
2391 }
2392
2393 // Convert maps to vectors for OpenMP indexing
2394 std::vector<std::pair<std::string, std::vector<helios::vec2>>> spectra_rho_vector(surface_spectra_rho.begin(), surface_spectra_rho.end());
2395
2396 // Pre-initialize all map entries before parallel processing to avoid race conditions
2397 for (const auto &spectrum: spectra_rho_vector) {
2398 rho_unique[spectrum.first] = empty;
2399 if (Ncameras > 0) {
2400 rho_cam_unique[spectrum.first] = empty_cam;
2401 }
2402 }
2403
2404 // Process reflectivity spectra with OpenMP parallelization
2405#ifdef USE_OPENMP
2406#pragma omp parallel for schedule(dynamic)
2407#endif
2408 for (int spectrum_idx = 0; spectrum_idx < (int) spectra_rho_vector.size(); spectrum_idx++) {
2409 const auto &spectrum = spectra_rho_vector[spectrum_idx];
2410
2411 for (uint b = 0; b < Nbands; b++) {
2412 std::string band = band_labels.at(b);
2413
2414 for (uint s = 0; s < Nsources; s++) {
2415
2416 // integrate with caching
2417 auto band_it = radiation_bands.find(band);
2418 if (band_it != radiation_bands.end() && band_it->second.wavebandBounds.x != 0 && band_it->second.wavebandBounds.y != 0 && !spectrum.second.empty()) {
2419 if (!radiation_sources.at(s).source_spectrum.empty()) {
2420 std::string cache_key = createCacheKey(spectrum.first, s, b, 0, "rho_source");
2421 bool found;
2422 float cached_result = getCachedValue(cache_key, found);
2423 if (found) {
2424 rho_unique[spectrum.first][b][s] = cached_result;
2425 } else {
2426 float result = cachedIntegrateSpectrumWithSource(s, spectrum.second, band_it->second.wavebandBounds.x, band_it->second.wavebandBounds.y, spectrum.first);
2427 setCachedValue(cache_key, result);
2428 rho_unique[spectrum.first][b][s] = result;
2429 }
2430 } else {
2431 // source spectrum not provided, assume source intensity is constant over the band
2432 std::string cache_key = createCacheKey(spectrum.first, s, b, 0, "rho_no_source");
2433 bool found;
2434 float cached_result = getCachedValue(cache_key, found);
2435 if (found) {
2436 rho_unique[spectrum.first][b][s] = cached_result;
2437 } else {
2438 float result = integrateSpectrum(spectrum.second, band_it->second.wavebandBounds.x, band_it->second.wavebandBounds.y) / (band_it->second.wavebandBounds.y - band_it->second.wavebandBounds.x);
2439 setCachedValue(cache_key, result);
2440 rho_unique[spectrum.first][b][s] = result;
2441 }
2442 }
2443 } else {
2444 // No wavelength bounds, can't integrate spectrum without camera response
2445 // Set to default for now, will use camera average if available
2446 rho_unique[spectrum.first][b][s] = rho_default;
2447 }
2448
2449 // cameras
2450 if (Ncameras > 0) {
2451 uint cam = 0;
2452 float rho_cam_sum_for_averaging = 0.f;
2453 for (const auto &camera: cameras) {
2454
2455 if (camera_response_unique.at(cam).at(b).empty()) {
2456 rho_cam_unique[spectrum.first][b][s][cam] = rho_unique[spectrum.first][b][s];
2457 } else {
2458
2459 // integrate with caching
2460 if (!spectrum.second.empty()) {
2461 if (!radiation_sources.at(s).source_spectrum.empty()) {
2462 std::string cache_key = createCacheKey(spectrum.first, s, b, cam, "rho_cam_source");
2463 bool found;
2464 float cached_result = getCachedValue(cache_key, found);
2465 if (found) {
2466 rho_cam_unique.at(spectrum.first).at(b).at(s).at(cam) = cached_result;
2467 rho_cam_sum_for_averaging += cached_result;
2468 } else {
2469 float result = cachedIntegrateSpectrumWithSourceAndCamera(s, spectrum.second, camera_response_unique.at(cam).at(b), cam, b, spectrum.first);
2470 setCachedValue(cache_key, result);
2471 rho_cam_unique.at(spectrum.first).at(b).at(s).at(cam) = result;
2472 rho_cam_sum_for_averaging += result;
2473 }
2474 } else {
2475 std::string cache_key = createCacheKey(spectrum.first, s, b, cam, "rho_cam_no_source");
2476 bool found;
2477 float cached_result = getCachedValue(cache_key, found);
2478 if (found) {
2479 rho_cam_unique.at(spectrum.first).at(b).at(s).at(cam) = cached_result;
2480 rho_cam_sum_for_averaging += cached_result;
2481 } else {
2482 float result = integrateSpectrum(spectrum.second, camera_response_unique.at(cam).at(b));
2483 setCachedValue(cache_key, result);
2484 rho_cam_unique.at(spectrum.first).at(b).at(s).at(cam) = result;
2485 rho_cam_sum_for_averaging += result;
2486 }
2487 }
2488 } else {
2489 rho_cam_unique.at(spectrum.first).at(b).at(s).at(cam) = rho_default;
2490 }
2491 }
2492
2493 cam++;
2494 }
2495
2496 // CRITICAL FIX: If wavelength bounds weren't set but camera integration produced values,
2497 // use camera average as the base reflectivity. This allows regular scatter to work
2498 // when only reflectivity_spectrum + camera response are provided.
2499 if (rho_unique[spectrum.first][b][s] == rho_default && rho_cam_sum_for_averaging > 0 && cam > 0) {
2500 rho_unique[spectrum.first][b][s] = rho_cam_sum_for_averaging / float(cam);
2501 }
2502 }
2503 }
2504 }
2505 }
2506
2507 // Convert tau spectra to vector for OpenMP indexing
2508 std::vector<std::pair<std::string, std::vector<helios::vec2>>> spectra_tau_vector(surface_spectra_tau.begin(), surface_spectra_tau.end());
2509
2510 // Pre-initialize all map entries before parallel processing to avoid race conditions
2511 for (const auto &spectrum: spectra_tau_vector) {
2512 tau_unique[spectrum.first] = empty;
2513 if (Ncameras > 0) {
2514 tau_cam_unique[spectrum.first] = empty_cam;
2515 }
2516 }
2517
2518 // Process transmissivity spectra with OpenMP parallelization
2519#ifdef USE_OPENMP
2520#pragma omp parallel for schedule(dynamic)
2521#endif
2522 for (int spectrum_idx = 0; spectrum_idx < (int) spectra_tau_vector.size(); spectrum_idx++) {
2523 const auto &spectrum = spectra_tau_vector[spectrum_idx];
2524
2525 for (uint b = 0; b < Nbands; b++) {
2526 std::string band = band_labels.at(b);
2527
2528 for (uint s = 0; s < Nsources; s++) {
2529
2530 // integrate with caching
2531 auto band_it = radiation_bands.find(band);
2532 if (band_it != radiation_bands.end() && band_it->second.wavebandBounds.x != 0 && band_it->second.wavebandBounds.y != 0 && !spectrum.second.empty()) {
2533 if (!radiation_sources.at(s).source_spectrum.empty()) {
2534 std::string cache_key = createCacheKey(spectrum.first, s, b, 0, "tau_source");
2535 bool found;
2536 float cached_result = getCachedValue(cache_key, found);
2537 if (found) {
2538 tau_unique[spectrum.first][b][s] = cached_result;
2539 } else {
2540 float result = cachedIntegrateSpectrumWithSource(s, spectrum.second, band_it->second.wavebandBounds.x, band_it->second.wavebandBounds.y, spectrum.first);
2541 setCachedValue(cache_key, result);
2542 tau_unique[spectrum.first][b][s] = result;
2543 }
2544 } else {
2545 std::string cache_key = createCacheKey(spectrum.first, s, b, 0, "tau_no_source");
2546 bool found;
2547 float cached_result = getCachedValue(cache_key, found);
2548 if (found) {
2549 tau_unique[spectrum.first][b][s] = cached_result;
2550 } else {
2551 float result = integrateSpectrum(spectrum.second, band_it->second.wavebandBounds.x, band_it->second.wavebandBounds.y) / (band_it->second.wavebandBounds.y - band_it->second.wavebandBounds.x);
2552 setCachedValue(cache_key, result);
2553 tau_unique[spectrum.first][b][s] = result;
2554 }
2555 }
2556 } else {
2557 tau_unique[spectrum.first][b][s] = tau_default;
2558 }
2559
2560 // cameras
2561 if (Ncameras > 0) {
2562 uint cam = 0;
2563 for (const auto &camera: cameras) {
2564
2565 if (camera_response_unique.at(cam).at(b).empty()) {
2566
2567 tau_cam_unique[spectrum.first][b][s][cam] = tau_unique[spectrum.first][b][s];
2568
2569 } else {
2570
2571 // integrate with caching
2572 if (!spectrum.second.empty()) {
2573 if (!radiation_sources.at(s).source_spectrum.empty()) {
2574 std::string cache_key = createCacheKey(spectrum.first, s, b, cam, "tau_cam_source");
2575 bool found;
2576 float cached_result = getCachedValue(cache_key, found);
2577 if (found) {
2578 tau_cam_unique.at(spectrum.first).at(b).at(s).at(cam) = cached_result;
2579 } else {
2580 float result = cachedIntegrateSpectrumWithSourceAndCamera(s, spectrum.second, camera_response_unique.at(cam).at(b), cam, b, spectrum.first);
2581 setCachedValue(cache_key, result);
2582 tau_cam_unique.at(spectrum.first).at(b).at(s).at(cam) = result;
2583 }
2584 } else {
2585 std::string cache_key = createCacheKey(spectrum.first, s, b, cam, "tau_cam_no_source");
2586 bool found;
2587 float cached_result = getCachedValue(cache_key, found);
2588 if (found) {
2589 tau_cam_unique.at(spectrum.first).at(b).at(s).at(cam) = cached_result;
2590 } else {
2591 float result = integrateSpectrum(spectrum.second, camera_response_unique.at(cam).at(b));
2592 setCachedValue(cache_key, result);
2593 tau_cam_unique.at(spectrum.first).at(b).at(s).at(cam) = result;
2594 }
2595 }
2596 } else {
2597 tau_cam_unique.at(spectrum.first).at(b).at(s).at(cam) = tau_default;
2598 }
2599 }
2600
2601 cam++;
2602 }
2603 }
2604 }
2605 }
2606 }
2607
2608 for (size_t u = 0; u < Nprimitives; u++) {
2609
2610 uint UUID = context_UUIDs.at(u);
2611
2612 helios::PrimitiveType type = context->getPrimitiveType(UUID);
2613
2614 if (type == helios::PRIMITIVE_TYPE_VOXEL) {
2615
2616 } else { // other than voxels
2617
2618 // Reflectivity
2619
2620 // check for primitive data of form "reflectivity_spectrum" that can be used to calculate reflectivity
2621 std::string spectrum_label;
2622 if (context->doesPrimitiveDataExist(UUID, "reflectivity_spectrum")) {
2623 if (context->getPrimitiveDataType("reflectivity_spectrum") == HELIOS_TYPE_STRING) {
2624 context->getPrimitiveData(UUID, "reflectivity_spectrum", spectrum_label);
2625 }
2626 }
2627
2628 uint b = 0;
2629 for (const auto &band: band_labels) {
2630
2631 // check for primitive data of form "reflectivity_bandname"
2632 prop = "reflectivity_" + band;
2633
2634 float rho_s = rho_default;
2635 if (context->doesPrimitiveDataExist(UUID, prop.c_str())) {
2636 context->getPrimitiveData(UUID, prop.c_str(), rho_s);
2637 }
2638
2639 for (uint s = 0; s < Nsources; s++) {
2640 float &rho_val = material_data.reflectivity[mat_idx(s, u, b)];
2641
2642 // if reflectivity was manually set, or a spectrum was given and the global data exists
2643 if (rho_s != rho_default || spectrum_label.empty() || !context->doesGlobalDataExist(spectrum_label.c_str()) || rho_unique.find(spectrum_label) == rho_unique.end()) {
2644
2645 rho_val = rho_s;
2646
2647 // cameras
2648 for (uint cam = 0; cam < Ncameras; cam++) {
2649 material_data.reflectivity_cam[cam_idx(s, u, b, cam)] = rho_s;
2650 }
2651
2652 // use spectrum
2653 } else {
2654
2655 rho_val = rho_unique.at(spectrum_label).at(b).at(s);
2656
2657 // cameras
2658 for (uint cam = 0; cam < Ncameras; cam++) {
2659 material_data.reflectivity_cam[cam_idx(s, u, b, cam)] = rho_cam_unique.at(spectrum_label).at(b).at(s).at(cam);
2660 }
2661 }
2662
2663 // error checking
2664 if (rho_val < 0) {
2665 rho_val = 0.f;
2666 warnings.addWarning("reflectivity_negative_clamped", "Reflectivity cannot be less than 0. Clamping to 0 for band " + band + ".");
2667 } else if (rho_val > 1.f) {
2668 rho_val = 1.f;
2669 warnings.addWarning("reflectivity_exceeded_clamped", "Reflectivity cannot be greater than 1. Clamping to 1 for band " + band + ".");
2670 }
2671 if (rho_val != 0) {
2672 scattering_iterations_needed.at(band) = true;
2673 }
2674 for (auto &odata: output_prim_data) {
2675 if (odata == "reflectivity") {
2676 context->setPrimitiveData(UUID, ("reflectivity_" + std::to_string(s) + "_" + band).c_str(), rho_val);
2677 }
2678 }
2679 }
2680 b++;
2681 }
2682
2683 // Transmissivity
2684
2685 // check for primitive data of form "transmissivity_spectrum" that can be used to calculate transmissivity
2686 spectrum_label.resize(0);
2687 if (context->doesPrimitiveDataExist(UUID, "transmissivity_spectrum")) {
2688 if (context->getPrimitiveDataType("transmissivity_spectrum") == HELIOS_TYPE_STRING) {
2689 context->getPrimitiveData(UUID, "transmissivity_spectrum", spectrum_label);
2690 }
2691 }
2692
2693 b = 0;
2694 for (const auto &band: band_labels) {
2695
2696 // check for primitive data of form "transmissivity_bandname"
2697 prop = "transmissivity_" + band;
2698
2699 float tau_s = tau_default;
2700 if (context->doesPrimitiveDataExist(UUID, prop.c_str())) {
2701 context->getPrimitiveData(UUID, prop.c_str(), tau_s);
2702 }
2703
2704 for (uint s = 0; s < Nsources; s++) {
2705 float &tau_val = material_data.transmissivity[mat_idx(s, u, b)];
2706
2707 // if transmissivity was manually set, or a spectrum was given and the global data exists
2708 if (tau_s != tau_default || spectrum_label.empty() || !context->doesGlobalDataExist(spectrum_label.c_str()) || tau_unique.find(spectrum_label) == tau_unique.end()) {
2709
2710 tau_val = tau_s;
2711
2712 // cameras
2713 for (uint cam = 0; cam < Ncameras; cam++) {
2714 material_data.transmissivity_cam[cam_idx(s, u, b, cam)] = tau_s;
2715 }
2716
2717 } else {
2718
2719 tau_val = tau_unique.at(spectrum_label).at(b).at(s);
2720
2721 // cameras
2722 for (uint cam = 0; cam < Ncameras; cam++) {
2723 material_data.transmissivity_cam[cam_idx(s, u, b, cam)] = tau_cam_unique.at(spectrum_label).at(b).at(s).at(cam);
2724 }
2725 }
2726
2727 // error checking
2728 if (tau_val < 0) {
2729 tau_val = 0.f;
2730 warnings.addWarning("transmissivity_negative_clamped", "Transmissivity cannot be less than 0. Clamping to 0 for band " + band + ".");
2731 } else if (tau_val > 1.f) {
2732 tau_val = 1.f;
2733 warnings.addWarning("transmissivity_exceeded_clamped", "Transmissivity cannot be greater than 1. Clamping to 1 for band " + band + ".");
2734 }
2735 if (tau_val != 0) {
2736 scattering_iterations_needed.at(band) = true;
2737 }
2738 for (auto &odata: output_prim_data) {
2739 if (odata == "transmissivity") {
2740 context->setPrimitiveData(UUID, ("transmissivity_" + std::to_string(s) + "_" + band).c_str(), tau_val);
2741 }
2742 }
2743 }
2744 b++;
2745 }
2746
2747 // Translucent cover (glass/plastic) material.
2748 // Presence of primitive data "glass_n_<band>" activates the Fresnel+Bouguer angular
2749 // transmittance model for this primitive+band. Optional "glass_KL_<band>" (default 0 =
2750 // lossless) sets the Bouguer absorption product K*L. When active, the angular rho/tau are
2751 // computed on-device, overriding any constant reflectivity_<band>/transmissivity_<band>.
2752 b = 0;
2753 for (const auto &band: band_labels) {
2754
2755 prop = "glass_n_" + band;
2756 if (!context->doesPrimitiveDataExist(UUID, prop.c_str()) || context->getPrimitiveDataType(prop.c_str()) != HELIOS_TYPE_FLOAT) {
2757 b++;
2758 continue;
2759 }
2760
2761 float n_s = 0.f;
2762 context->getPrimitiveData(UUID, prop.c_str(), n_s);
2763
2764 if (n_s < 1.f) {
2765 warnings.addWarning("glass_n_invalid", "Refractive index glass_n_" + band + " must be >= 1. Ignoring glass material for this band.");
2766 b++;
2767 continue;
2768 }
2769
2770 float KL_s = 0.f;
2771 std::string KL_prop = "glass_KL_" + band;
2772 if (context->doesPrimitiveDataExist(UUID, KL_prop.c_str()) && context->getPrimitiveDataType(KL_prop.c_str()) == HELIOS_TYPE_FLOAT) {
2773 context->getPrimitiveData(UUID, KL_prop.c_str(), KL_s);
2774 if (KL_s < 0.f) {
2775 KL_s = 0.f;
2776 warnings.addWarning("glass_KL_negative_clamped", "Absorption glass_KL_" + band + " cannot be less than 0. Clamping to 0.");
2777 }
2778 }
2779
2780 // Warn (once) if a constant reflectivity/transmissivity was also set for this band: glass wins.
2781 if (context->doesPrimitiveDataExist(UUID, ("reflectivity_" + band).c_str()) || context->doesPrimitiveDataExist(UUID, ("transmissivity_" + band).c_str())) {
2782 warnings.addWarning("glass_overrides_constant", "Primitive has both glass_n_" + band + " and a constant reflectivity/transmissivity for band " + band +
2783 ". The glass (Fresnel+Bouguer) model takes precedence; the constant value is ignored.");
2784 }
2785
2786 for (uint s = 0; s < Nsources; s++) {
2787 material_data.is_glass[mat_idx(s, u, b)] = 1;
2788 material_data.glass_n[mat_idx(s, u, b)] = n_s;
2789 material_data.glass_KL[mat_idx(s, u, b)] = KL_s;
2790 }
2791
2792 // Glass requires the scattering machinery to be active so the diffuse-sky / reflected
2793 // bookkeeping runs (the unscattered direct/sky attenuation itself is independent of depth).
2794 scattering_iterations_needed.at(band) = true;
2795 b++;
2796 }
2797
2798 // Emissivity (only for error checking)
2799
2800 b = 0;
2801 for (const auto &band: band_labels) {
2802
2803 prop = "emissivity_" + band;
2804
2805 if (context->doesPrimitiveDataExist(UUID, prop.c_str())) {
2806 context->getPrimitiveData(UUID, prop.c_str(), eps);
2807 } else {
2808 eps = eps_default;
2809 }
2810
2811 if (eps < 0) {
2812 eps = 0.f;
2813 warnings.addWarning("emissivity_negative_clamped", "Emissivity cannot be less than 0. Clamping to 0 for band " + band + ".");
2814 } else if (eps > 1.f) {
2815 eps = 1.f;
2816 warnings.addWarning("emissivity_exceeded_clamped", "Emissivity cannot be greater than 1. Clamping to 1 for band " + band + ".");
2817 }
2818 if (eps != 1) {
2819 scattering_iterations_needed.at(band) = true;
2820 }
2821
2822 assert(doesBandExist(band));
2823
2824 const bool is_sif_band = sif_emission_bands.count(band) > 0;
2825
2826 for (uint s = 0; s < Nsources; s++) {
2827 float &rho_val = material_data.reflectivity[mat_idx(s, u, b)];
2828 float &tau_val = material_data.transmissivity[mat_idx(s, u, b)];
2829
2830 // Glass primitives compute angle-dependent rho/tau on-device via the Fresnel+Bouguer
2831 // model, so the constant ε+ρ+τ=1 conservation constraint does not apply here.
2832 if (material_data.is_glass[mat_idx(s, u, b)] != 0) {
2833 continue;
2834 }
2835
2836 if (is_sif_band) {
2837 // SIF bands source their emission from the Fluspect-B per-leaf
2838 // kernel (see computeSIFEmission), not from epsilon*sigma*T^4.
2839 // Epsilon on a SIF band is therefore irrelevant — the Stefan-
2840 // Boltzmann ε+ρ+τ=1 conservation constraint does not apply. We
2841 // still enforce rho+tau ≤ 1 (physically required regardless of
2842 // the emission mechanism).
2843 if (tau_val + rho_val > 1.f) {
2844 helios_runtime_error("ERROR (RadiationModel): reflectivity and transmissivity must sum to less than or equal to 1 to ensure energy conservation. Band " + band + ", Primitive #" + std::to_string(UUID) +
2845 ": tau=" + std::to_string(tau_val) + ", rho=" + std::to_string(rho_val) + ".");
2846 }
2847 } else if (radiation_bands.at(band).emissionFlag) { // emission enabled
2848 if (eps != 1.f && rho_val == 0 && tau_val == 0) {
2849 rho_val = 1.f - eps;
2850 } else if (eps + tau_val + rho_val != 1.f && eps > 0.f) {
2851 helios_runtime_error("ERROR (RadiationModel): emissivity, transmissivity, and reflectivity must sum to 1 to ensure energy conservation. Band " + band + ", Primitive #" + std::to_string(UUID) + ": eps=" +
2852 std::to_string(eps) + ", tau=" + std::to_string(tau_val) + ", rho=" + std::to_string(rho_val) + ". It is also possible that you forgot to disable emission for this band.");
2853 } else if (radiation_bands.at(band).scatteringDepth == 0 && eps != 1.f) {
2854 eps = 1.f;
2855 rho_val = 0.f;
2856 tau_val = 0.f;
2857 }
2858 } else if (tau_val + rho_val > 1.f) {
2859 helios_runtime_error("ERROR (RadiationModel): transmissivity and reflectivity cannot sum to greater than 1 ensure energy conservation. Band " + band + ", Primitive #" + std::to_string(UUID) + ": eps=" + std::to_string(eps) +
2860 ", tau=" + std::to_string(tau_val) + ", rho=" + std::to_string(rho_val) + ". It is also possible that you forgot to disable emission for this band.");
2861 }
2862 }
2863 b++;
2864 }
2865 }
2866 }
2867
2868 // Specular reflection properties
2869 material_data.specular_exponent.resize(Nprimitives, -1.f);
2870 material_data.specular_scale.resize(Nprimitives, 0.f);
2871
2872 bool specular_exponent_specified = false;
2873 bool specular_scale_specified = false;
2874
2875 for (size_t u = 0; u < Nprimitives; u++) {
2876 uint UUID = context_UUIDs.at(u);
2877
2878 if (context->doesPrimitiveDataExist(UUID, "specular_exponent") && context->getPrimitiveDataType("specular_exponent") == HELIOS_TYPE_FLOAT) {
2879 context->getPrimitiveData(UUID, "specular_exponent", material_data.specular_exponent.at(u));
2880 if (material_data.specular_exponent.at(u) >= 0.f) {
2881 specular_exponent_specified = true;
2882 }
2883 }
2884
2885 if (context->doesPrimitiveDataExist(UUID, "specular_scale") && context->getPrimitiveDataType("specular_scale") == HELIOS_TYPE_FLOAT) {
2886 context->getPrimitiveData(UUID, "specular_scale", material_data.specular_scale.at(u));
2887 if (material_data.specular_scale.at(u) > 0.f) {
2888 specular_scale_specified = true;
2889 }
2890 }
2891 }
2892
2893 // Auto-enable specular reflection if specular properties are specified on any primitive
2894 if (specular_exponent_specified) {
2895 if (specular_scale_specified) {
2896 specular_reflection_mode = 2; // Mode 2: use primitive specular_scale
2897 } else {
2898 specular_reflection_mode = 1; // Mode 1: use default 0.25 scale
2899 }
2900 } else {
2901 specular_reflection_mode = 0; // Disabled
2902 }
2903
2904 backend->updateMaterials(material_data);
2905
2906 radiativepropertiesneedupdate = false;
2907
2908 if (message_flag) {
2909 std::cout << "done\n";
2910 }
2911
2912 // Report aggregated warnings
2913 warnings.report(std::cerr);
2914}
2915
2916std::vector<float> RadiationModel::updateAtmosphericSkyModel(const std::vector<std::string> &band_labels, const RadiationCamera &camera) {
2917 // Prague Sky Model implementation for atmospheric sky radiance
2918 // Uses validated spectral radiance from brute-force atmospheric simulations
2919 // (Wilkie et al. 2021, Vévoda et al. 2022)
2920
2921 size_t Nbands_launch = band_labels.size();
2922 std::vector<float> sky_base_radiances(Nbands_launch, 0.0f);
2923
2924 // Only run atmospheric sky model if user has explicitly enabled it by setting atmospheric parameters
2925 // This prevents the model from running with default values in tests/scripts that don't want it
2926 bool has_atmospheric_data =
2927 context->doesGlobalDataExist("atmosphere_pressure_Pa") || context->doesGlobalDataExist("atmosphere_temperature_K") || context->doesGlobalDataExist("atmosphere_humidity_rel") || context->doesGlobalDataExist("atmosphere_turbidity");
2928
2929 if (!has_atmospheric_data) {
2930 // No atmospheric parameters set - return zeros (camera will use user-set diffuse flux or 0)
2931 return sky_base_radiances;
2932 }
2933
2934 // Read atmospheric parameters from Context global data (set by SolarPosition plugin)
2935 // Default values match SolarPosition::getAtmosphericConditions() defaults
2936 float pressure_Pa = 101325.f; // Standard atmosphere (1 atm)
2937 float temperature_K = 300.f; // 27°C
2938 float humidity_rel = 0.5f; // 50% relative humidity
2939 float turbidity = 0.02f; // Clear sky - Ångström's aerosol turbidity coefficient (AOD at 500nm)
2940
2941 if (context->doesGlobalDataExist("atmosphere_pressure_Pa")) {
2942 context->getGlobalData("atmosphere_pressure_Pa", pressure_Pa);
2943 }
2944 if (context->doesGlobalDataExist("atmosphere_temperature_K")) {
2945 context->getGlobalData("atmosphere_temperature_K", temperature_K);
2946 }
2947 if (context->doesGlobalDataExist("atmosphere_humidity_rel")) {
2948 context->getGlobalData("atmosphere_humidity_rel", humidity_rel);
2949 }
2950 if (context->doesGlobalDataExist("atmosphere_turbidity")) {
2951 context->getGlobalData("atmosphere_turbidity", turbidity);
2952 }
2953
2954 // --- Check Prague data availability from Context ---
2955 int prague_valid = 0;
2956 if (context->doesGlobalDataExist("prague_sky_valid")) {
2957 context->getGlobalData("prague_sky_valid", prague_valid);
2958 }
2959
2960 // Get sun direction from first radiation source (assumed to be sun)
2961 helios::vec3 sun_dir(0, 0, 1); // Default zenith
2962 if (!radiation_sources.empty()) {
2963 sun_dir = radiation_sources[0].source_position;
2964 sun_dir.normalize();
2965 }
2966
2967 // Compute per-band sky radiance parameters
2968 std::vector<helios::vec4> sky_params(Nbands_launch);
2969
2970 // Check if Prague data is available
2971 bool use_prague_fallback = (prague_valid != 1);
2972 if (use_prague_fallback) {
2973 // Will use Rayleigh sky fallback - warn user once
2974 std::cerr << "WARNING (RadiationModel::updateAtmosphericSkyModel): "
2975 << "Prague sky model data not available in Context. "
2976 << "Using simple Rayleigh sky fallback. "
2977 << "Call SolarPosition::updatePragueSkyModel() for accurate sky radiance." << std::endl;
2978 }
2979
2980 // Prepare spectral data (either Prague or Rayleigh fallback)
2981 std::vector<float> wavelengths;
2982 std::vector<float> L_zenith_spectrum;
2983 std::vector<float> circ_str_spectrum;
2984 std::vector<float> circ_width_spectrum;
2985 std::vector<float> horiz_bright_spectrum;
2986 std::vector<float> norm_spectrum;
2987
2988 if (use_prague_fallback) {
2989 // --- Create simple Rayleigh sky spectrum (λ^-4 dependence) ---
2990 // 360-750 nm at 10 nm spacing (visible range only for fallback)
2991 const int n_wavelengths = 40; // (750-360)/10 + 1
2992 wavelengths.resize(n_wavelengths);
2993 L_zenith_spectrum.resize(n_wavelengths);
2994 circ_str_spectrum.resize(n_wavelengths);
2995 circ_width_spectrum.resize(n_wavelengths);
2996 horiz_bright_spectrum.resize(n_wavelengths);
2997 norm_spectrum.resize(n_wavelengths);
2998
2999 const float L_base = 0.4f; // W/m²/sr/nm at 550 nm (typical clear sky zenith)
3000 const float lambda_ref = 550.0f; // Reference wavelength
3001
3002 for (int i = 0; i < n_wavelengths; ++i) {
3003 float lambda = 360.0f + i * 10.0f;
3004 wavelengths[i] = lambda;
3005
3006 // Rayleigh scattering: L(λ) ∝ λ^-4 (blue sky)
3007 float rayleigh_factor = std::pow(lambda_ref / lambda, 4.0f);
3008 L_zenith_spectrum[i] = L_base * rayleigh_factor;
3009
3010 // Simple angular parameters (no strong circumsolar for fallback)
3011 circ_str_spectrum[i] = 0.5f;
3012 circ_width_spectrum[i] = 20.0f;
3013 horiz_bright_spectrum[i] = 1.8f;
3014 norm_spectrum[i] = 0.7f;
3015 }
3016 } else {
3017 // --- Read spectral parameters from Context ---
3018 std::vector<float> spectral_params;
3019 context->getGlobalData("prague_sky_spectral_params", spectral_params);
3020
3021 const int params_per_wavelength = 6;
3022 const int n_wavelengths = spectral_params.size() / params_per_wavelength;
3023
3024 // Parse into structured format
3025 wavelengths.resize(n_wavelengths);
3026 L_zenith_spectrum.resize(n_wavelengths);
3027 circ_str_spectrum.resize(n_wavelengths);
3028 circ_width_spectrum.resize(n_wavelengths);
3029 horiz_bright_spectrum.resize(n_wavelengths);
3030 norm_spectrum.resize(n_wavelengths);
3031
3032 for (int i = 0; i < n_wavelengths; ++i) {
3033 int base = i * params_per_wavelength;
3034 wavelengths[i] = spectral_params[base + 0];
3035 L_zenith_spectrum[i] = spectral_params[base + 1];
3036 circ_str_spectrum[i] = spectral_params[base + 2];
3037 circ_width_spectrum[i] = spectral_params[base + 3];
3038 horiz_bright_spectrum[i] = spectral_params[base + 4];
3039 norm_spectrum[i] = spectral_params[base + 5];
3040 }
3041 }
3042
3043 // --- Process each band ---
3044 for (size_t b = 0; b < Nbands_launch; b++) {
3045 const std::string &band_label = band_labels[b];
3046 if (radiation_bands.find(band_label) == radiation_bands.end()) {
3047 continue;
3048 }
3049
3050 const RadiationBand &band = radiation_bands.at(band_label);
3051
3052 // Skip thermal/longwave bands - Prague Sky Model only handles shortwave radiation
3053 if (band.emissionFlag) {
3054 continue;
3055 }
3056
3057 // Get camera spectral response for this band
3058 std::string spectral_response_label = "uniform";
3059 if (camera.band_spectral_response.find(band_label) != camera.band_spectral_response.end()) {
3060 spectral_response_label = camera.band_spectral_response.at(band_label);
3061 if (spectral_response_label.empty() || trim_whitespace(spectral_response_label).empty()) {
3062 spectral_response_label = "uniform";
3063 }
3064 }
3065
3066 // Get camera spectral response data
3067 std::vector<helios::vec2> camera_response;
3068
3069 if (spectral_response_label == "uniform") {
3070 helios::vec2 wavelength_range = band.wavebandBounds;
3071
3072 if (wavelength_range.x <= 0.f || wavelength_range.y <= 0.f) {
3073 bool bounds_inferred = false;
3074
3075 if (band_label == "red" || band_label == "R") {
3076 wavelength_range = helios::make_vec2(620.f, 750.f);
3077 bounds_inferred = true;
3078 } else if (band_label == "green" || band_label == "G") {
3079 wavelength_range = helios::make_vec2(495.f, 570.f);
3080 bounds_inferred = true;
3081 } else if (band_label == "blue" || band_label == "B") {
3082 wavelength_range = helios::make_vec2(450.f, 495.f);
3083 bounds_inferred = true;
3084 }
3085
3086 if (!bounds_inferred) {
3087 if (!band.diffuse_spectrum.empty()) {
3088 wavelength_range.x = band.diffuse_spectrum.front().x;
3089 wavelength_range.y = band.diffuse_spectrum.back().x;
3090 } else {
3091 helios_runtime_error("ERROR (RadiationModel::updateAtmosphericSkyModel): Camera '" + camera.label + "' band '" + band_label + "' has uniform spectral response but no wavelength bounds set.");
3092 }
3093 }
3094 }
3095
3096 camera_response.push_back(helios::make_vec2(wavelength_range.x, 1.0f));
3097 camera_response.push_back(helios::make_vec2(wavelength_range.y, 1.0f));
3098
3099 } else {
3100 camera_response = loadSpectralData(spectral_response_label);
3101
3102 if (camera_response.empty()) {
3103 helios_runtime_error("ERROR (RadiationModel::updateAtmosphericSkyModel): Camera spectral response '" + spectral_response_label + "' not found for camera '" + camera.label + "' band '" + band_label + "'.");
3104 }
3105 }
3106
3107 // Integrate radiance and weight-average angular parameters over camera response
3108 // L_zenith: Integrate to get W/m²/sr (band-integrated radiance)
3109 float integrated_L_zenith = integrateOverResponse(wavelengths, L_zenith_spectrum, camera_response);
3110
3111 // Angular parameters: Weighted average (unitless quantities)
3112 // Weight by L_zenith(λ) × R(λ) to get radiance-weighted average
3113 float integrated_circ_str = weightedAverageOverResponse(wavelengths, circ_str_spectrum, L_zenith_spectrum, camera_response);
3114 float integrated_circ_width = weightedAverageOverResponse(wavelengths, circ_width_spectrum, L_zenith_spectrum, camera_response);
3115 float integrated_horiz_bright = weightedAverageOverResponse(wavelengths, horiz_bright_spectrum, L_zenith_spectrum, camera_response);
3116
3117 // Recompute normalization from averaged angular parameters
3118 float integrated_norm = computeAngularNormalization(integrated_circ_str, integrated_circ_width, integrated_horiz_bright);
3119
3120 // CRITICAL: GPU multiplies by normalization (see rayHit.cu:evaluateSkyRadiance)
3121 // Since normalization < 1 (typically 0.6-0.7), this darkens the sky
3122 // Pre-divide by normalization so it cancels: GPU does (L/norm) × pattern × norm = L × pattern
3123 float base_radiance_for_gpu = integrated_L_zenith / std::max(integrated_norm, 0.1f);
3124
3125 sky_base_radiances[b] = base_radiance_for_gpu;
3126 sky_params[b] = helios::make_vec4(integrated_circ_str, integrated_circ_width, integrated_horiz_bright, integrated_norm);
3127 }
3128
3129 // Sky parameters will be uploaded to backend via updateSkyModel()
3130 return sky_base_radiances;
3131}
3132
3133void RadiationModel::updatePragueParametersForGeneralDiffuse(const std::vector<std::string> &band_labels) {
3134 // Update Prague sky model angular parameters for general diffuse radiation
3135 // Reads spectral parameters from Context (set by SolarPosition::updatePragueSkyModel())
3136 // Integrates over band spectral response to get band-averaged parameters
3137
3138 // Check Prague data availability
3139 int prague_valid = 0;
3140 if (!context->doesGlobalDataExist("prague_sky_valid") || (context->getGlobalData("prague_sky_valid", prague_valid), prague_valid != 1)) {
3141 // No Prague data - leave params at zero (will use power-law or isotropic)
3142 return;
3143 }
3144
3145 // Read spectral parameters from Context
3146 std::vector<float> spectral_params;
3147 context->getGlobalData("prague_sky_spectral_params", spectral_params);
3148
3149 // Parse into wavelength-resolved arrays
3150 const int params_per_wavelength = 6;
3151 const int n_wavelengths = spectral_params.size() / params_per_wavelength;
3152
3153 std::vector<float> wavelengths(n_wavelengths);
3154 std::vector<float> L_zenith_spectrum(n_wavelengths);
3155 std::vector<float> circ_str_spectrum(n_wavelengths);
3156 std::vector<float> circ_width_spectrum(n_wavelengths);
3157 std::vector<float> horiz_bright_spectrum(n_wavelengths);
3158 std::vector<float> norm_spectrum(n_wavelengths);
3159
3160 for (int i = 0; i < n_wavelengths; ++i) {
3161 int base = i * params_per_wavelength;
3162 wavelengths[i] = spectral_params[base + 0];
3163 L_zenith_spectrum[i] = spectral_params[base + 1];
3164 circ_str_spectrum[i] = spectral_params[base + 2];
3165 circ_width_spectrum[i] = spectral_params[base + 3];
3166 horiz_bright_spectrum[i] = spectral_params[base + 4];
3167 norm_spectrum[i] = spectral_params[base + 5];
3168 }
3169
3170 // Get sun direction
3171 helios::vec3 sun_dir;
3172 context->getGlobalData("prague_sky_sun_direction", sun_dir);
3173
3174 // Process each band
3175 for (const auto &label: band_labels) {
3176 RadiationBand &band = radiation_bands.at(label);
3177
3178 // SKIP if user has explicitly set power-law (priority 1)
3179 if (band.diffuseExtinction > 0.0f) {
3180 continue;
3181 }
3182
3183 // Integrate Prague parameters over band spectrum
3184 std::vector<helios::vec2> band_spectrum = band.diffuse_spectrum;
3185 if (band_spectrum.empty()) {
3186 // Use waveband bounds if no detailed spectrum
3187 float lambda_min = band.wavebandBounds.x;
3188 float lambda_max = band.wavebandBounds.y;
3189 if (lambda_min > 0 && lambda_max > lambda_min) {
3190 band_spectrum = {{lambda_min, 1.0f}, {lambda_max, 1.0f}};
3191 }
3192 }
3193
3194 if (band_spectrum.empty()) {
3195 // No spectral info - skip Prague for this band
3196 continue;
3197 }
3198
3199 // Weighted integration (weight by L_zenith for physical consistency)
3200 float int_circ_str = weightedAverageOverResponse(wavelengths, circ_str_spectrum, L_zenith_spectrum, band_spectrum);
3201 float int_circ_width = weightedAverageOverResponse(wavelengths, circ_width_spectrum, L_zenith_spectrum, band_spectrum);
3202 float int_horiz_bright = weightedAverageOverResponse(wavelengths, horiz_bright_spectrum, L_zenith_spectrum, band_spectrum);
3203
3204 // Recompute normalization from integrated parameters
3205 float int_norm = computeAngularNormalization(int_circ_str, int_circ_width, int_horiz_bright);
3206
3207 // Store in RadiationBand
3208 band.diffusePragueParams = helios::make_vec4(int_circ_str, int_circ_width, int_horiz_bright, int_norm);
3209 band.diffusePeakDir = sun_dir;
3210 }
3211}
3212
3213float RadiationModel::integrateOverResponse(const std::vector<float> &wavelengths, const std::vector<float> &values, const std::vector<helios::vec2> &camera_response) const {
3214
3215 if (wavelengths.empty() || camera_response.empty()) {
3216 return 0.0f;
3217 }
3218
3219 // CRITICAL: This integrates spectral radiance L(λ) in W/m²/sr/nm over wavelength
3220 // to produce band-integrated radiance in W/m²/sr (same as Prague computeIntegratedSkyRadiance)
3221 double integrated_radiance = 0.0;
3222
3223 // Trapezoidal integration over camera response wavelength range
3224 for (size_t i = 0; i < camera_response.size() - 1; ++i) {
3225 float lambda1 = camera_response[i].x;
3226 float lambda2 = camera_response[i + 1].x;
3227
3228 // Skip if outside spectral data range
3229 if (lambda2 < wavelengths.front() || lambda1 > wavelengths.back()) {
3230 continue;
3231 }
3232
3233 float r1 = camera_response[i].y;
3234 float r2 = camera_response[i + 1].y;
3235
3236 // Interpolate spectral values at these wavelengths using linear interpolation
3237 float v1, v2;
3238
3239 // Interpolate v1 at lambda1
3240 if (lambda1 <= wavelengths.front()) {
3241 v1 = values.front();
3242 } else if (lambda1 >= wavelengths.back()) {
3243 v1 = values.back();
3244 } else {
3245 auto it = std::lower_bound(wavelengths.begin(), wavelengths.end(), lambda1);
3246 size_t idx = std::distance(wavelengths.begin(), it);
3247 if (idx == 0)
3248 idx = 1;
3249 float t = (lambda1 - wavelengths[idx - 1]) / (wavelengths[idx] - wavelengths[idx - 1]);
3250 v1 = values[idx - 1] + t * (values[idx] - values[idx - 1]);
3251 }
3252
3253 // Interpolate v2 at lambda2
3254 if (lambda2 <= wavelengths.front()) {
3255 v2 = values.front();
3256 } else if (lambda2 >= wavelengths.back()) {
3257 v2 = values.back();
3258 } else {
3259 auto it = std::lower_bound(wavelengths.begin(), wavelengths.end(), lambda2);
3260 size_t idx = std::distance(wavelengths.begin(), it);
3261 if (idx == 0)
3262 idx = 1;
3263 float t = (lambda2 - wavelengths[idx - 1]) / (wavelengths[idx] - wavelengths[idx - 1]);
3264 v2 = values[idx - 1] + t * (values[idx] - values[idx - 1]);
3265 }
3266
3267 float dlambda = lambda2 - lambda1;
3268
3269 // Integrate: ∫ L(λ) × R(λ) dλ
3270 // L(λ) in W/m²/sr/nm, R(λ) unitless, dλ in nm → result in W/m²/sr
3271 integrated_radiance += 0.5 * (v1 * r1 + v2 * r2) * dlambda;
3272 }
3273
3274 // Return band-integrated radiance in W/m²/sr (matches Prague computeIntegratedSkyRadiance)
3275 return static_cast<float>(integrated_radiance);
3276}
3277
3278float RadiationModel::weightedAverageOverResponse(const std::vector<float> &wavelengths, const std::vector<float> &param_values, const std::vector<float> &weight_values, const std::vector<helios::vec2> &camera_response) const {
3279
3280 if (wavelengths.empty() || camera_response.empty()) {
3281 return 0.0f;
3282 }
3283
3284 // CRITICAL: Angular parameters are unitless - compute radiance-weighted average
3285 // Formula: Σ param(λ) × L(λ) × R(λ) dλ / Σ L(λ) × R(λ) dλ
3286 double weighted_sum = 0.0;
3287 double total_weight = 0.0;
3288
3289 for (size_t i = 0; i < camera_response.size() - 1; ++i) {
3290 float lambda1 = camera_response[i].x;
3291 float lambda2 = camera_response[i + 1].x;
3292
3293 if (lambda2 < wavelengths.front() || lambda1 > wavelengths.back()) {
3294 continue;
3295 }
3296
3297 float r1 = camera_response[i].y;
3298 float r2 = camera_response[i + 1].y;
3299
3300 // Interpolate parameter values
3301 float p1, p2;
3302 if (lambda1 <= wavelengths.front()) {
3303 p1 = param_values.front();
3304 } else if (lambda1 >= wavelengths.back()) {
3305 p1 = param_values.back();
3306 } else {
3307 auto it = std::lower_bound(wavelengths.begin(), wavelengths.end(), lambda1);
3308 size_t idx = std::distance(wavelengths.begin(), it);
3309 if (idx == 0)
3310 idx = 1;
3311 float t = (lambda1 - wavelengths[idx - 1]) / (wavelengths[idx] - wavelengths[idx - 1]);
3312 p1 = param_values[idx - 1] + t * (param_values[idx] - param_values[idx - 1]);
3313 }
3314
3315 if (lambda2 <= wavelengths.front()) {
3316 p2 = param_values.front();
3317 } else if (lambda2 >= wavelengths.back()) {
3318 p2 = param_values.back();
3319 } else {
3320 auto it = std::lower_bound(wavelengths.begin(), wavelengths.end(), lambda2);
3321 size_t idx = std::distance(wavelengths.begin(), it);
3322 if (idx == 0)
3323 idx = 1;
3324 float t = (lambda2 - wavelengths[idx - 1]) / (wavelengths[idx] - wavelengths[idx - 1]);
3325 p2 = param_values[idx - 1] + t * (param_values[idx] - param_values[idx - 1]);
3326 }
3327
3328 // Interpolate weight (radiance) values
3329 float w1, w2;
3330 if (lambda1 <= wavelengths.front()) {
3331 w1 = weight_values.front();
3332 } else if (lambda1 >= wavelengths.back()) {
3333 w1 = weight_values.back();
3334 } else {
3335 auto it = std::lower_bound(wavelengths.begin(), wavelengths.end(), lambda1);
3336 size_t idx = std::distance(wavelengths.begin(), it);
3337 if (idx == 0)
3338 idx = 1;
3339 float t = (lambda1 - wavelengths[idx - 1]) / (wavelengths[idx] - wavelengths[idx - 1]);
3340 w1 = weight_values[idx - 1] + t * (weight_values[idx] - weight_values[idx - 1]);
3341 }
3342
3343 if (lambda2 <= wavelengths.front()) {
3344 w2 = weight_values.front();
3345 } else if (lambda2 >= wavelengths.back()) {
3346 w2 = weight_values.back();
3347 } else {
3348 auto it = std::lower_bound(wavelengths.begin(), wavelengths.end(), lambda2);
3349 size_t idx = std::distance(wavelengths.begin(), it);
3350 if (idx == 0)
3351 idx = 1;
3352 float t = (lambda2 - wavelengths[idx - 1]) / (wavelengths[idx] - wavelengths[idx - 1]);
3353 w2 = weight_values[idx - 1] + t * (weight_values[idx] - weight_values[idx - 1]);
3354 }
3355
3356 float dlambda = lambda2 - lambda1;
3357
3358 // Weighted average: Σ param × weight × response × dλ
3359 weighted_sum += 0.5 * (p1 * w1 * r1 + p2 * w2 * r2) * dlambda;
3360 total_weight += 0.5 * (w1 * r1 + w2 * r2) * dlambda;
3361 }
3362
3363 // Return weighted average (unitless)
3364 if (total_weight > 1e-10) {
3365 return static_cast<float>(weighted_sum / total_weight);
3366 }
3367 return 0.0f;
3368}
3369
3370float RadiationModel::computeAngularNormalization(float circ_str, float circ_width, float horiz_bright) const {
3371 // Numerical integration of angular pattern over hemisphere
3372 const int N = 50;
3373 float integral = 0.0f;
3374
3375 // Sun at zenith for normalization calculation
3376 helios::vec3 sun_dir = make_vec3(0, 0, 1);
3377
3378 for (int j = 0; j < N; ++j) {
3379 for (int i = 0; i < N; ++i) {
3380 float theta = 0.5f * float(M_PI) * (i + 0.5f) / N; // 0 to π/2
3381 float phi = 2.0f * float(M_PI) * (j + 0.5f) / N; // 0 to 2π
3382
3383 helios::vec3 dir = sphere2cart(make_SphericalCoord(0.5f * float(M_PI) - theta, phi));
3384
3385 // Angular distance from sun (degrees) - matches GPU calculation
3386 float cos_gamma = std::max(-1.0f, std::min(1.0f, dir.x * sun_dir.x + dir.y * sun_dir.y + dir.z * sun_dir.z));
3387 float gamma = std::acos(cos_gamma) * 180.0f / float(M_PI);
3388
3389 // Compute angular pattern (same as GPU: rayHit.cu evaluateDiffuseAngularDistribution)
3390 float cos_theta = std::max(0.0f, dir.z);
3391 float horizon_term = 1.0f + (horiz_bright - 1.0f) * (1.0f - cos_theta);
3392 float circ_term = 1.0f + circ_str * std::exp(-gamma / circ_width);
3393
3394 float pattern = circ_term * horizon_term;
3395
3396 // Solid angle element: sin(θ) dθ dφ
3397 integral += pattern * std::cos(theta) * std::sin(theta) * (float(M_PI) / (2.0f * N)) * (2.0f * float(M_PI) / N);
3398 }
3399 }
3400
3401 return 1.0f / std::max(integral, 1e-10f);
3402}
3403
3404std::vector<helios::vec2> RadiationModel::loadSpectralData(const std::string &global_data_label) const {
3405
3406 std::vector<helios::vec2> spectrum;
3407
3408 if (!context->doesGlobalDataExist(global_data_label.c_str())) {
3409
3410 // check if spectral data exists in any of the library files
3411 bool data_found = false;
3412 for (const auto &file: spectral_library_files) {
3413 if (Context::scanXMLForTag(file, "globaldata_vec2", global_data_label)) {
3414 context->loadXML(file.c_str(), true);
3415 data_found = true;
3416 break;
3417 }
3418 }
3419
3420 if (!data_found) {
3421 helios_runtime_error("ERROR (RadiationModel::loadSpectralData): Global data for spectrum '" + global_data_label + "' could not be found.");
3422 }
3423 }
3424
3425 if (context->getGlobalDataType(global_data_label.c_str()) != HELIOS_TYPE_VEC2) {
3426 helios_runtime_error("ERROR (RadiationModel::loadSpectralData): Global data for spectrum '" + global_data_label + "' is not of type HELIOS_TYPE_VEC2.");
3427 }
3428
3429 context->getGlobalData(global_data_label.c_str(), spectrum);
3430
3431 // validate spectrum
3432 if (spectrum.empty()) {
3433 helios_runtime_error("ERROR (RadiationModel::loadSpectralData): Global data for spectrum '" + global_data_label + "' is empty.");
3434 }
3435 for (auto s = 0; s < spectrum.size(); s++) {
3436 // check that wavelengths are monotonic
3437 if (s > 0 && spectrum.at(s).x <= spectrum.at(s - 1).x) {
3438 helios_runtime_error("ERROR (RadiationModel::loadSpectralData): Source spectral data validation failed. Wavelengths must increase monotonically.");
3439 }
3440 // check that wavelength is within a reasonable range
3441 if (spectrum.at(s).x < 0 || spectrum.at(s).x > 100000) {
3442 helios_runtime_error("ERROR (RadiationModel::loadSpectralData): Source spectral data validation failed. Wavelength value of " + std::to_string(spectrum.at(s).x) + " appears to be erroneous.");
3443 }
3444 // check that flux is non-negative
3445 if (spectrum.at(s).y < 0) {
3446 helios_runtime_error("ERROR (RadiationModel::loadSpectralData): Source spectral data validation failed. Flux value at wavelength of " + std::to_string(spectrum.at(s).x) + " appears is negative.");
3447 }
3448 }
3449
3450 return spectrum;
3451}
3452
3453void RadiationModel::runBand(const std::string &label) {
3454 std::vector<std::string> labels{label};
3455 runBand(labels);
3456}
3457
3458void RadiationModel::runBand(const std::vector<std::string> &label) {
3459
3460 //----- VERIFICATIONS -----//
3461
3462 // Invalidate cached excitation APAR if radiative properties have changed since the
3463 // last populate (e.g., user changed source spectrum, source flux, geometry, etc.).
3464 // When `radiativepropertiesneedupdate` is true, `updateRadiativeProperties()` will
3465 // be rerun during this dispatch, so any previously-cached APAR is stale.
3466 if (radiativepropertiesneedupdate) {
3467 for (auto &kv : excitation_sets) {
3468 kv.second.populated = false;
3469 }
3470 }
3471
3472 // Optimisation: if the user dispatches one or more regular bands and has at least one
3473 // SIF camera registered, piggy-back the auto-generated excitation bands onto this
3474 // dispatch. This merges PAR + excitation into a single ray-trace pass, saving a full
3475 // dispatch round-trip.
3476 //
3477 // Guards:
3478 // - Don't piggy-back if any band in this dispatch is an SIF emission band — the
3479 // emission-dispatch path has its own pre-hook (below) that handles excitation.
3480 // - Don't piggy-back if any band is already an internal "_SIF_exc_*" band (we are
3481 // already inside the recursive excitation dispatch — prevents infinite recursion).
3482 // - Don't piggy-back if no SIF cameras are registered (no excitation sets exist).
3483 // - Skip sets that are already populated (APAR is cached).
3484 //
3485 // Piggy-backed sets are added to the dispatch list here but NOT marked populated until
3486 // populateExcitationAPAR() runs post-dispatch (below).
3487 std::vector<std::string> effective_label = label;
3488 std::vector<ExcitationSet *> piggybacked_sets;
3489 if (!excitation_sets.empty()) {
3490 bool label_has_sif_emission = false;
3491 bool label_has_excitation_band = false;
3492 for (const auto &b : label) {
3493 if (sif_emission_bands.count(b) > 0) label_has_sif_emission = true;
3494 if (b.size() >= 9 && b.compare(0, 9, "_SIF_exc_") == 0) label_has_excitation_band = true;
3495 }
3496 if (!label_has_sif_emission && !label_has_excitation_band) {
3497 for (auto &kv : excitation_sets) {
3498 ExcitationSet &exc = kv.second;
3499 if (exc.populated) continue;
3500 for (const auto &eb : exc.band_labels) {
3501 effective_label.push_back(eb);
3502 }
3503 piggybacked_sets.push_back(&exc);
3504 }
3505 }
3506 }
3507
3508 // We need the band label strings to appear in the same order as in radiation_bands map.
3509 // this is because that was the order in which radiative properties were laid out when updateRadiativeProperties() was called
3510 std::vector<std::string> band_labels;
3511 for (auto &band: radiation_bands) {
3512 if (std::find(effective_label.begin(), effective_label.end(), band.first) != effective_label.end()) {
3513 band_labels.push_back(band.first);
3514 }
3515 }
3516
3517 // Check to make sure some geometry was added to the context
3518 if (context->getPrimitiveCount() == 0) {
3519 std::cerr << "WARNING (RadiationModel::runBand): No geometry was added to the context. There is nothing to simulate...exiting." << std::endl;
3520 return;
3521 }
3522
3523 // Check to make sure geometry was built in OptiX
3524 if (!isgeometryinitialized) {
3526 }
3527
3528 // Check that all the bands passed to the runBand() method exist
3529 for (const std::string &band: label) {
3530 if (!doesBandExist(band)) {
3531 helios_runtime_error("ERROR (RadiationModel::runBand): Cannot run band " + band + " because it is not a valid band. Use addRadiationBand() function to add the band.");
3532 }
3533 }
3534
3535 // if there are no radiation sources in the simulation, add at least one but with zero fluxes
3536 if (radiation_sources.empty()) {
3538 }
3539
3540 // --- SIF v2: prepare per-leaf emission for any SIF-flagged bands in this launch ---
3541 //
3542 // If any band in this dispatch is a user-defined SIF emission band, we need to:
3543 // 1. Run the auto-generated excitation bands (once per dispatch), to capture
3544 // per-leaf APAR across 400-750 nm.
3545 // 2. Invoke computeSIFEmission() for each SIF-flagged emission band, populating
3546 // sif_emission_buffer[band]. The regular emission loop (below) will then read
3547 // sif_emission_buffer when it encounters the band and bypass Stefan-Boltzmann.
3548 //
3549 // Recursion guard: runExcitationBands() recursively calls runBand() for the internal
3550 // "_SIF_exc_*" bands — those bands are NOT in sif_emission_bands, so the hook is a no-op
3551 // inside the recursion. Also guard against running SIF hook while running a purely-excitation
3552 // band set (first band starts with underscore prefix).
3553 bool dispatch_has_sif_band = false;
3554 for (const auto &b : band_labels) {
3555 if (sif_emission_bands.count(b) > 0) {
3556 dispatch_has_sif_band = true;
3557 break;
3558 }
3559 }
3560 if (dispatch_has_sif_band) {
3561 // Soft override: SIF bands need emission enabled so that the Fluspect-derived
3562 // source flux is traced through the emission loop. If the user has disabled
3563 // emission on a SIF band (directly via disableEmission, or indirectly via
3564 // setSourceFlux/spectral configuration that implicitly silences emission),
3565 // re-enable it for this dispatch and warn once. This avoids the silent
3566 // zero-output failure mode while preserving the user's other controls.
3567 {
3569 sif_warn.setEnabled(message_flag);
3570 for (const auto &b : band_labels) {
3571 if (sif_emission_bands.count(b) > 0) {
3572 auto &band = radiation_bands.at(b);
3573 if (!band.emissionFlag) {
3574 sif_warn.addWarning("sif_emission_reenabled",
3575 "Band '" + b + "' is bound to a SIF camera but has emission disabled. "
3576 "Re-enabling emission for this dispatch — SIF cameras require emission "
3577 "enabled so that Fluspect-B source flux is traced through the emission "
3578 "loop. Remove the disableEmission(\"" + b + "\") call to silence this warning.");
3579 band.emissionFlag = true;
3580 }
3581 }
3582 }
3583 sif_warn.report(std::cerr);
3584 }
3585
3586 // Run any excitation sets that aren't already populated. Piggy-back in the
3587 // runBand() preamble may have pre-populated some or all sets during a prior
3588 // non-SIF dispatch in this frame. When radiativepropertiesneedupdate is true
3589 // (a user changed something between runBand calls), the preamble above already
3590 // reset populated=false, so we'll pick up fresh APAR here.
3591 runExcitationBands();
3592 for (const auto &b : band_labels) {
3593 if (sif_emission_bands.count(b) > 0) {
3594 computeSIFEmission(b);
3595 }
3596 }
3597 }
3598
3599 // Check if any source spectra have changed in global data and reload if necessary
3600 for (auto &source: radiation_sources) {
3601 if (!source.source_spectrum_label.empty() && source.source_spectrum_label != "none") {
3602 uint64_t current_version = context->getGlobalDataVersion(source.source_spectrum_label.c_str());
3603 if (current_version != source.source_spectrum_version) {
3604 // Reload spectrum from global data
3605 source.source_spectrum = loadSpectralData(source.source_spectrum_label);
3606 source.source_spectrum_version = current_version;
3607 radiativepropertiesneedupdate = true;
3608 }
3609 }
3610 }
3611
3612 // Check if global diffuse spectrum has changed and reload if necessary
3613 if (!global_diffuse_spectrum_label.empty() && global_diffuse_spectrum_label != "none") {
3614 uint64_t current_version = context->getGlobalDataVersion(global_diffuse_spectrum_label.c_str());
3615 if (current_version != global_diffuse_spectrum_version) {
3616 // Reload diffuse spectrum from global data
3617 global_diffuse_spectrum = loadSpectralData(global_diffuse_spectrum_label);
3618 global_diffuse_spectrum_version = current_version;
3619 // Also update all band diffuse spectra
3620 for (auto &band_pair: radiation_bands) {
3621 band_pair.second.diffuse_spectrum = global_diffuse_spectrum;
3622 }
3623 radiativepropertiesneedupdate = true;
3624 }
3625 }
3626
3627 if (radiativepropertiesneedupdate) {
3628 // Use old material path (handles spectrum interpolation)
3629 updateRadiativeProperties();
3630 // DON'T call backend->updateMaterials() - old code already uploaded via direct OptiX calls
3631 } else {
3632 // Use new backend path (per-band materials only)
3633 buildMaterialData();
3634 backend->updateMaterials(material_data);
3635 }
3636
3637 // Upload sources to backend (always use new path)
3638 buildSourceData();
3639 backend->updateSources(source_data);
3640
3641 // Prepare launch parameters (these will be passed to backend via RayTracingLaunchParams)
3642 size_t Nbands_launch = band_labels.size();
3643 size_t Nbands_global = radiation_bands.size();
3644
3645 // Build band launch flags
3646 std::vector<char> band_launch_flag(Nbands_global);
3647 uint bb = 0;
3648 for (auto &band: radiation_bands) {
3649 if (std::find(band_labels.begin(), band_labels.end(), band.first) != band_labels.end()) {
3650 band_launch_flag.at(bb) = 1;
3651 }
3652 bb++;
3653 }
3654
3655 // Get dimensions
3656 size_t Nobjects = primitiveID.size();
3657 size_t Nprimitives = context_UUIDs.size();
3658 uint Nsources = radiation_sources.size();
3659 uint Ncameras = cameras.size();
3660
3661 // Note: Atmospheric sky radiance model is updated per-camera (see camera trace loop below)
3662 // This allows us to use camera-specific spectral responses for each band
3663
3664 // Set scattering depth for each band
3665 std::vector<uint> scattering_depth(Nbands_launch);
3666 bool scatteringenabled = false;
3667 for (auto b = 0; b < Nbands_launch; b++) {
3668 scattering_depth.at(b) = radiation_bands.at(band_labels.at(b)).scatteringDepth;
3669 if (scattering_depth.at(b) > 0) {
3670 scatteringenabled = true;
3671 }
3672 }
3673
3674 // Issue warning if rho>0, tau>0, or eps<1 for any band with scatteringDepth=0.
3675 // Internal SIF excitation bands (labels start with "_SIF_exc_") are silenced here
3676 // because LeafOptics automatically sets per-band rho/tau on leaves for the full
3677 // spectrum — a per-band warning would produce ~35 lines of noise. Instead,
3678 // runExcitationBands() emits ONE consolidated warning before dispatch covering
3679 // the entire excitation set.
3680 helios::WarningAggregator scattering_disabled_warnings;
3681 for (int b = 0; b < Nbands_launch; b++) {
3682 const std::string &bname = band_labels.at(b);
3683 const bool is_sif_excitation_band = bname.size() >= 9 && bname.compare(0, 9, "_SIF_exc_") == 0;
3684 if (scattering_depth.at(b) == 0 && scattering_iterations_needed.at(bname) && !is_sif_excitation_band) {
3685 scattering_disabled_warnings.addWarning("scattering_disabled_for_band",
3686 "Surface radiative properties for band " + bname +
3687 " are set to non-default values, but scattering iterations are disabled. Surface radiative properties will be ignored unless scattering depth is non-zero.");
3688 }
3689 }
3690 scattering_disabled_warnings.report(std::cerr);
3691
3692 // Set diffuse flux for each band
3693 std::vector<float> diffuse_flux(Nbands_launch);
3694 bool diffuseenabled = false;
3695 for (auto b = 0; b < Nbands_launch; b++) {
3696 diffuse_flux.at(b) = getDiffuseFlux(band_labels.at(b));
3697 if (diffuse_flux.at(b) > 0.f) {
3698 diffuseenabled = true;
3699 }
3700 }
3701 // NOTE: diffuse_flux now passed to backend via launch params, not uploaded here
3702
3703 // Initialize camera sky radiance buffer to zeros (will be set per-camera if atmospheric model is used)
3704 std::vector<float> camera_sky_radiance(Nbands_launch, 0.0f);
3705
3706 // Update Prague parameters for general diffuse (if available in Context)
3707 // This must be done before uploading diffuse parameters to GPU
3708 if (diffuseenabled) {
3709 updatePragueParametersForGeneralDiffuse(band_labels);
3710 }
3711
3712 // Set diffuse extinction coefficient for each band
3713 std::vector<float> diffuse_extinction(Nbands_launch, 0);
3714 if (diffuseenabled) {
3715 for (auto b = 0; b < Nbands_launch; b++) {
3716 diffuse_extinction.at(b) = radiation_bands.at(band_labels.at(b)).diffuseExtinction;
3717 }
3718 }
3719 // NOTE: diffuse_extinction now passed to backend via launch params, not uploaded here
3720
3721 // Set diffuse distribution normalization factor for each band
3722 std::vector<float> diffuse_dist_norm(Nbands_launch, 0);
3723 if (diffuseenabled) {
3724 for (auto b = 0; b < Nbands_launch; b++) {
3725 diffuse_dist_norm.at(b) = radiation_bands.at(band_labels.at(b)).diffuseDistNorm;
3726 }
3727 }
3728 // NOTE: diffuse_dist_norm now passed to backend via launch params, not uploaded here
3729
3730 // Set diffuse distribution peak direction for each band
3731 std::vector<helios::vec3> diffuse_peak_dir(Nbands_launch);
3732 if (diffuseenabled) {
3733 for (auto b = 0; b < Nbands_launch; b++) {
3734 helios::vec3 peak_dir = radiation_bands.at(band_labels.at(b)).diffusePeakDir;
3735 diffuse_peak_dir.at(b) = helios::make_vec3(peak_dir.x, peak_dir.y, peak_dir.z);
3736 }
3737 }
3738 // NOTE: diffuse_peak_dir now passed to backend via launch params, not uploaded here
3739
3740 // Upload Prague parameters for general diffuse (reuses camera buffer)
3741 // This allows general diffuse to use Prague sky model if available
3742 std::vector<helios::vec4> prague_params(Nbands_launch);
3743 if (diffuseenabled) {
3744 for (auto b = 0; b < Nbands_launch; b++) {
3745 const auto &params = radiation_bands.at(band_labels.at(b)).diffusePragueParams;
3746 prague_params.at(b) = helios::make_vec4(params.x, params.y, params.z, params.w);
3747 }
3748 // Prague params will be uploaded to backend via updateSkyModel() during scattering
3749 }
3750
3751 // Determine whether emission is enabled for any band
3752 bool emissionenabled = false;
3753 for (auto b = 0; b < Nbands_launch; b++) {
3754 if (radiation_bands.at(band_labels.at(b)).emissionFlag) {
3755 emissionenabled = true;
3756 }
3757 }
3758
3759 // Figure out the maximum direct ray count for all bands in this run and use this as the launch size
3760 size_t directRayCount = 0;
3761 for (const auto &band: label) {
3762 if (radiation_bands.at(band).directRayCount > directRayCount) {
3763 directRayCount = radiation_bands.at(band).directRayCount;
3764 }
3765 }
3766
3767 // Figure out the maximum diffuse ray count for all bands in this run and use this as the launch size
3768 size_t diffuseRayCount = 0;
3769 for (const auto &band: label) {
3770 if (radiation_bands.at(band).diffuseRayCount > diffuseRayCount) {
3771 diffuseRayCount = radiation_bands.at(band).diffuseRayCount;
3772 }
3773 }
3774
3775 // Figure out the maximum diffuse ray count for all bands in this run and use this as the launch size
3776 size_t scatteringDepth = 0;
3777 for (const auto &band: label) {
3778 if (radiation_bands.at(band).scatteringDepth > scatteringDepth) {
3779 scatteringDepth = radiation_bands.at(band).scatteringDepth;
3780 }
3781 }
3782
3783 // Zero radiation buffers via backend
3784 backend->zeroRadiationBuffers(Nbands_launch);
3785
3786 std::vector<float> TBS_top, TBS_bottom;
3787 TBS_top.resize(Nbands_launch * Nprimitives, 0);
3788 TBS_bottom = TBS_top;
3789
3790 std::map<std::string, std::vector<std::vector<float>>> radiation_in_camera;
3791
3792 size_t maxRays = 1024 * 1024 * 1024; // maximum number of total rays in a launch
3793
3794 // ***** DIRECT LAUNCH FROM ALL RADIATION SOURCES ***** //
3795
3796 helios::int3 launch_dim_dir;
3797
3798 bool rundirect = false;
3799 for (uint s = 0; s < Nsources; s++) {
3800 for (uint b = 0; b < Nbands_launch; b++) {
3801 if (getSourceFlux(s, band_labels.at(b)) > 0.f) {
3802 rundirect = true;
3803 break;
3804 }
3805 }
3806 }
3807
3808 // Keep the device-side source_fluxes buffer consistent with Nsources for EVERY launch type.
3809 // runBand() guarantees at least one source exists (a default collimated source is added above
3810 // when none were created), so Nsources >= 1 here. The miss/closesthit device programs iterate
3811 // [0, Nsources) and dereference source_fluxes[s*Nbands_launch + b] unconditionally. When no
3812 // source has positive flux (rundirect == false) the direct-pass block below is skipped, but a
3813 // camera trace still launches (e.g. an emission-only longwave band sampling the sky on a miss).
3814 // Uploading the per-band fluxes here (zeros/unset values are harmlessly skipped device-side by
3815 // the `flux <= 0` guard) prevents source_fluxes from being left null while Nsources > 0, which
3816 // would otherwise cause an illegal memory access in the camera kernel.
3817 if (Nsources > 0) {
3818 std::vector<std::vector<float>> source_flux_values(Nsources, std::vector<float>(Nbands_launch, 0.f));
3819 for (uint s = 0; s < Nsources; s++) {
3820 for (uint b = 0; b < Nbands_launch; b++) {
3821 source_flux_values.at(s).at(b) = getSourceFlux(s, band_labels.at(b));
3822 }
3823 }
3824 backend->uploadSourceFluxes(flatten(source_flux_values));
3825 }
3826
3827 if (Nsources > 0 && rundirect) {
3828
3829 // update radiation source buffers
3830
3831 std::vector<std::vector<float>> fluxes; // first index is the source, second index is the band (only those passed to runBand() function)
3832 fluxes.resize(Nsources);
3833 std::vector<helios::vec3> positions(Nsources);
3834 std::vector<helios::vec2> widths(Nsources);
3835 std::vector<helios::vec3> rotations(Nsources);
3836 std::vector<uint> types(Nsources);
3837
3838 size_t s = 0;
3839 for (const auto &source: radiation_sources) {
3840
3841 fluxes.at(s).resize(Nbands_launch);
3842
3843 for (auto b = 0; b < label.size(); b++) {
3844 fluxes.at(s).at(b) = getSourceFlux(s, band_labels.at(b));
3845 }
3846
3847 positions.at(s) = helios::make_vec3(source.source_position.x, source.source_position.y, source.source_position.z);
3848 widths.at(s) = helios::make_vec2(source.source_width.x, source.source_width.y);
3849 rotations.at(s) = helios::make_vec3(source.source_rotation.x, source.source_rotation.y, source.source_rotation.z);
3850 types.at(s) = source.source_type;
3851
3852 s++;
3853 }
3854
3855 // Upload band-specific source fluxes to backend buffer (indexed by Nbands_launch, not Nbands_global)
3856 backend->uploadSourceFluxes(flatten(fluxes));
3857 // Note: positions, widths, rotations, types are uploaded once in buildSourceData()
3858 // Only fluxes need per-launch update because they depend on which bands are being run
3859
3860 // Compute camera response weighting factors for specular reflection (if cameras exist)
3861 // Factor = ∫(source_spectrum × camera_response) / ∫(source_spectrum)
3862 // This must be done before ray tracing so the weights are available during miss_direct()
3863 if (Ncameras > 0) {
3864 std::vector<float> source_fluxes_cam;
3865 source_fluxes_cam.resize(Nsources * Nbands_launch * Ncameras, 1.0f);
3866
3867 for (uint s = 0; s < Nsources; s++) {
3868 const RadiationSource &source = radiation_sources.at(s);
3869
3870 uint cam = 0;
3871 for (const auto &camera: cameras) {
3872 for (uint b = 0; b < Nbands_launch; b++) {
3873 std::string band_label = band_labels.at(b);
3874
3875 // Default weighting factor (no camera response)
3876 float weight = 1.0f;
3877
3878 // Check if camera has spectral response for this band
3879 if (camera.second.band_spectral_response.find(band_label) != camera.second.band_spectral_response.end()) {
3880 std::string response_label = camera.second.band_spectral_response.at(band_label);
3881
3882 if (!response_label.empty() && response_label != "uniform" && context->doesGlobalDataExist(response_label.c_str()) && context->getGlobalDataType(response_label.c_str()) == helios::HELIOS_TYPE_VEC2 &&
3883 source.source_spectrum.size() > 0) {
3884
3885 // Load camera spectral response
3886 std::vector<helios::vec2> camera_response;
3887 context->getGlobalData(response_label.c_str(), camera_response);
3888
3889 // Get band wavelength range
3890 helios::vec2 wavelength_range = radiation_bands.at(band_label).wavebandBounds;
3891
3892 // If no wavelength bounds, use overlapping range of source and camera
3893 if (wavelength_range.x == 0 && wavelength_range.y == 0) {
3894 wavelength_range.x = fmax(source.source_spectrum.front().x, camera_response.front().x);
3895 wavelength_range.y = fmin(source.source_spectrum.back().x, camera_response.back().x);
3896 }
3897
3898 // Integrate source_spectrum × camera_response over band
3899 // Note: integrateSpectrum already returns ratio: ∫(source × camera) / ∫(source)
3900 weight = integrateSpectrum(s, camera_response, wavelength_range.x, wavelength_range.y);
3901 }
3902 }
3903
3904 source_fluxes_cam[s * Nbands_launch * Ncameras + b * Ncameras + cam] = weight;
3905 }
3906 cam++;
3907 }
3908 }
3909
3910 // Update source_data with camera-weighted fluxes and re-upload to backend
3911 for (uint s = 0; s < Nsources; s++) {
3912 source_data[s].fluxes_cam.clear();
3913 for (uint b = 0; b < Nbands_launch; b++) {
3914 for (uint cam = 0; cam < Ncameras; cam++) {
3915 source_data[s].fluxes_cam.push_back(source_fluxes_cam[s * Nbands_launch * Ncameras + b * Ncameras + cam]);
3916 }
3917 }
3918 }
3919 backend->updateSources(source_data);
3920 }
3921
3922 // -- Ray Trace (Using Backend) -- //
3923
3924 if (message_flag) {
3925 std::cout << "Performing primary direct radiation ray trace for bands ";
3926 for (const auto &band: label) {
3927 std::cout << band << ", ";
3928 }
3929 std::cout << "..." << std::flush;
3930 }
3931
3932 // Launch direct rays through backend
3934 params.launch_offset = 0;
3935 params.launch_count = Nprimitives; // Launch all primitives at once
3936 params.rays_per_primitive = directRayCount;
3937 params.random_seed = std::chrono::system_clock::now().time_since_epoch().count();
3938 params.num_bands_global = Nbands_global;
3939 params.num_bands_launch = Nbands_launch;
3940 params.specular_reflection_enabled = specular_reflection_mode;
3941
3942 // Use the band_launch_flag already built above (lines 3375-3383)
3943 std::vector<bool> band_flags(band_launch_flag.begin(), band_launch_flag.end());
3944 params.band_launch_flag = band_flags;
3945
3946 // Pre-allocate camera scatter buffers before direct launch so __miss__direct can fill them.
3947 // This ensures current_launch_band_count > 0 so getRadiationResults downloads them after.
3948 if (Ncameras > 0 && scatteringenabled) {
3949 backend->zeroCameraScatterBuffers(Nbands_launch);
3950 }
3951
3952 backend->launchDirectRays(params);
3953
3954 if (message_flag) {
3955 std::cout << "done." << std::endl;
3956 }
3957
3958 } // end direct source launch
3959
3960 // --- Extract scattered energy from direct rays for diffuse/emission and scattering ---//
3961 // This needs to happen BEFORE diffuse/emission block so scattered direct energy
3962 // is available for both diffuse/emission (via flux_top/flux_bottom) and scattering (via radiation_out)
3963 std::vector<float> flux_top, flux_bottom;
3964 flux_top.resize(Nbands_launch * Nprimitives, 0);
3965 flux_bottom = flux_top;
3966
3967 // Camera scatter accumulation vectors (declare early for use throughout ray tracing)
3968 std::vector<float> scatter_top_cam;
3969 std::vector<float> scatter_bottom_cam;
3970 if (Ncameras > 0) {
3971 scatter_top_cam.resize(Nprimitives * Nbands_launch, 0.0f);
3972 scatter_bottom_cam.resize(Nprimitives * Nbands_launch, 0.0f);
3973 }
3974
3975 if (scatteringenabled && rundirect) {
3976 // Get scattered energy from direct rays for primary diffuse/emission
3977 helios::RayTracingResults scatter_results;
3978 backend->getRadiationResults(scatter_results);
3979 flux_top = scatter_results.scatter_buff_top;
3980 flux_bottom = scatter_results.scatter_buff_bottom;
3981
3982 // Accumulate camera scatter from direct rays
3983 if (Ncameras > 0) {
3984 for (size_t i = 0; i < scatter_results.scatter_buff_top_cam.size(); i++) {
3985 scatter_top_cam[i] += scatter_results.scatter_buff_top_cam[i];
3986 scatter_bottom_cam[i] += scatter_results.scatter_buff_bottom_cam[i];
3987 }
3988 // Zero GPU camera scatter buffers to prevent double-counting on next iteration
3989 backend->zeroCameraScatterBuffers(Nbands_launch);
3990 }
3991
3992 // For one-sided primitives, make scattered energy accessible from both faces
3993 // This is necessary because scattering rays can hit from either direction
3994 RadiationBufferIndexer rad_indexer(Nprimitives, Nbands_launch);
3995
3996 for (size_t i = 0; i < Nprimitives; i++) {
3997 uint UUID = context_UUIDs.at(i);
3998 uint twosided = context->getPrimitiveTwosidedFlag(UUID, 1);
3999
4000 if (twosided == 0) { // one-sided primitive - combine top+bottom scattered energy
4001 for (size_t b = 0; b < Nbands_launch; b++) {
4002 size_t ind = rad_indexer(i, b);
4003 float total = flux_top[ind] + flux_bottom[ind];
4004 flux_top[ind] = total;
4005 flux_bottom[ind] = total;
4006 }
4007 }
4008 }
4009
4010 // Upload scattered energy to backend's radiation_out buffers for scattering iterations
4011 backend->uploadRadiationOut(flux_top, flux_bottom);
4012 backend->zeroScatterBuffers();
4013 }
4014
4015 // --- Diffuse/Emission launch ---- //
4016
4017 if (emissionenabled || diffuseenabled) {
4018
4019 // add any emitted energy to the outgoing energy buffer
4020 if (emissionenabled) {
4021 // Update primitive outgoing emission
4022 float eps, temperature;
4023
4024 // Create indexer for emission flux buffers
4025 RadiationBufferIndexer emission_indexer(Nprimitives, Nbands_launch);
4026
4027 for (auto b = 0; b < Nbands_launch; b++) {
4028 //\todo For emissivity and twosided_flag, this should be done in updateRadiativeProperties() to avoid having to do it on every runBand() call
4029 if (radiation_bands.at(band_labels.at(b)).emissionFlag) {
4030 std::string prop = "emissivity_" + band_labels.at(b);
4031 // SIF bands: per-primitive source flux comes from computeSIFEmission(), with
4032 // separate top/bottom buffers populated from Fluspect-B's Mf/Mb kernels.
4033 auto sif_band_it = sif_emission_buffer.find(band_labels.at(b));
4034 auto sif_band_bot_it = sif_emission_buffer_bottom.find(band_labels.at(b));
4035 const bool have_sif_band = (sif_band_it != sif_emission_buffer.end());
4036 const bool have_sif_band_bot = (sif_band_bot_it != sif_emission_buffer_bottom.end());
4037 for (size_t u = 0; u < Nprimitives; u++) {
4038 // Use BufferIndexer: [primitive][band]
4039 size_t ind = emission_indexer(u, b);
4040 uint p = context_UUIDs.at(u);
4041 float out_top;
4042 float sif_bottom_flux = 0.f; // Mb-sourced bottom-face emission (if SIF)
4043 bool used_sif = false;
4044 if (have_sif_band) {
4045 auto sif_uuid_it = sif_band_it->second.find(p);
4046 if (sif_uuid_it != sif_band_it->second.end()) {
4047 out_top = sif_uuid_it->second;
4048 used_sif = true;
4049 }
4050 }
4051 if (used_sif && have_sif_band_bot) {
4052 auto sif_bot_it = sif_band_bot_it->second.find(p);
4053 if (sif_bot_it != sif_band_bot_it->second.end()) {
4054 sif_bottom_flux = sif_bot_it->second;
4055 }
4056 }
4057 if (!used_sif) {
4058 // SIF bands are visible/red wavelengths (~680-760 nm). Stefan–Boltzmann
4059 // (broadband σT⁴) is only physically meaningful for thermal IR. Primitives
4060 // without an SIF source flux must not emit into SIF bands at room temperature.
4061 if (have_sif_band) {
4062 out_top = 0.f;
4063 } else {
4064 if (context->doesPrimitiveDataExist(p, prop.c_str())) {
4065 context->getPrimitiveData(p, prop.c_str(), eps);
4066 } else {
4067 eps = eps_default;
4068 }
4069 if (scattering_depth.at(b) == 0 && eps != 1.f) {
4070 eps = 1.f;
4071 }
4072 if (context->doesPrimitiveDataExist(p, "temperature")) {
4073 context->getPrimitiveData(p, "temperature", temperature);
4074 if (temperature < 0) {
4075 temperature = temperature_default;
4076 }
4077 } else {
4078 temperature = temperature_default;
4079 }
4080 out_top = sigma * eps * pow(temperature, 4);
4081 }
4082 }
4083 flux_top.at(ind) += out_top;
4084 if (Ncameras > 0) {
4085 scatter_top_cam[ind] += out_top;
4086 }
4087 // Check twosided_flag - check material first, then primitive data
4088 uint twosided_flag = context->getPrimitiveTwosidedFlag(p, 1);
4089 if (twosided_flag != 0) { // If two-sided, emit from bottom face too
4090 if (used_sif) {
4091 // SIF two-sided: use Mb-derived bottom flux rather than copying
4092 // the Mf-derived top flux. Physically distinct emission lobes.
4093 flux_bottom.at(ind) += sif_bottom_flux;
4094 if (Ncameras > 0) {
4095 scatter_bottom_cam[ind] += sif_bottom_flux;
4096 }
4097 } else {
4098 flux_bottom.at(ind) += flux_top.at(ind);
4099 if (Ncameras > 0) {
4100 scatter_bottom_cam[ind] += out_top;
4101 }
4102 }
4103 }
4104 }
4105 }
4106 }
4107 }
4108
4109 // Upload camera scatter buffers accumulated from emission, direct rays, and primary diffuse
4110 // Camera scatter is accumulated on CPU from GPU after each ray launch
4111 if (Ncameras > 0) {
4112 backend->uploadCameraScatterBuffers(scatter_top_cam, scatter_bottom_cam);
4113 }
4114
4115 // Note: radiation_specular_RTbuffer is populated on GPU via atomicFloatAdd during ray tracing, don't overwrite it here
4116
4117 // Compute diffuse launch dimension
4118 size_t n = ceil(sqrt(double(diffuseRayCount)));
4119 uint rays_per_primitive = n * n;
4120
4121 if (message_flag) {
4122 std::cout << "Performing primary diffuse radiation ray trace for bands ";
4123 for (const auto &band: label) {
4124 std::cout << band << " ";
4125 }
4126 std::cout << "..." << std::flush;
4127 }
4128
4129 // Build launch parameters for diffuse rays
4130 // Note: OptiX 6.5-specific batching (maxRays limit) should be handled inside the OptiX backend,
4131 // not here in RadiationModel. Vulkan and other backends can launch all primitives at once.
4133 params.launch_offset = 0;
4134 params.launch_count = Nprimitives; // Launch all primitives at once (backend handles batching if needed)
4135 params.rays_per_primitive = rays_per_primitive;
4136 params.random_seed = std::chrono::system_clock::now().time_since_epoch().count();
4137 params.current_band = 0;
4138 params.num_bands_global = Nbands_global;
4139 params.num_bands_launch = Nbands_launch;
4140 std::vector<bool> band_flags(band_launch_flag.begin(), band_launch_flag.end());
4141 params.band_launch_flag = band_flags;
4142 params.scattering_iteration = 0;
4143 params.max_scatters = scatteringDepth;
4144 params.radiation_out_top = flux_top;
4145 params.radiation_out_bottom = flux_bottom;
4146
4147 // Pass diffuse radiation parameters to backend
4148 params.diffuse_flux = diffuse_flux;
4149 params.diffuse_extinction = diffuse_extinction;
4150 params.diffuse_dist_norm = diffuse_dist_norm;
4151 // Convert helios::vec3 to helios::vec3
4152 std::vector<helios::vec3> peak_dirs(diffuse_peak_dir.size());
4153 for (size_t i = 0; i < diffuse_peak_dir.size(); i++) {
4154 peak_dirs[i] = helios::make_vec3(diffuse_peak_dir[i].x, diffuse_peak_dir[i].y, diffuse_peak_dir[i].z);
4155 }
4156 params.diffuse_peak_dir = peak_dirs;
4157 params.sky_radiance_params = prague_params;
4158
4159 // Top surface launch
4160 params.launch_face = 1;
4161 backend->launchDiffuseRays(params);
4162
4163 // Bottom surface launch
4164 params.launch_face = 0;
4165 backend->launchDiffuseRays(params);
4166
4167 // Retrieve and accumulate camera scatter from primary diffuse
4168 if (Ncameras > 0) {
4169 helios::RayTracingResults primary_results;
4170 backend->getRadiationResults(primary_results);
4171 for (size_t i = 0; i < primary_results.scatter_buff_top_cam.size(); i++) {
4172 scatter_top_cam[i] += primary_results.scatter_buff_top_cam[i];
4173 scatter_bottom_cam[i] += primary_results.scatter_buff_bottom_cam[i];
4174 }
4175 // Zero GPU camera scatter buffers to prevent double-counting on next iteration
4176 backend->zeroCameraScatterBuffers(Nbands_launch);
4177 }
4178
4179 if (message_flag) {
4180 std::cout << "done." << std::endl;
4181 }
4182 }
4183
4184 // After primary diffuse, prepare scatter_buff for scattering iterations
4185 // When direct rays ran, scatter_buff was already copied to radiation_out at line 3710
4186 // For emission/diffuse without direct rays, we need to do this now
4187 if (scatteringenabled && (emissionenabled || diffuseenabled) && !rundirect) {
4188 backend->copyScatterToRadiation();
4189 backend->zeroScatterBuffers();
4190 }
4191
4192 if (scatteringenabled && (emissionenabled || diffuseenabled || rundirect)) {
4193
4194 for (auto b = 0; b < Nbands_launch; b++) {
4195 diffuse_flux.at(b) = 0.f;
4196 }
4197 // NOTE: diffuse_flux zeroed for scattering, passed to backend via launch params
4198
4199 size_t n = ceil(sqrt(double(diffuseRayCount)));
4200 uint rays_per_primitive = n * n;
4201
4202 uint s;
4203 // FIX: Use a copy of band_launch_flag for scattering so modifications don't affect primary launch indices
4204 std::vector<char> scatter_band_flags = band_launch_flag;
4205
4206 for (s = 0; s < scatteringDepth; s++) {
4207 if (message_flag) {
4208 std::cout << "Performing scattering ray trace (iteration " << s + 1 << " of " << scatteringDepth << ")..." << std::flush;
4209 }
4210
4211 int b = -1;
4212 int active_bands = 0;
4213 for (uint b_global = 0; b_global < Nbands_global; b_global++) {
4214
4215 if (scatter_band_flags.at(b_global) == 0) {
4216 continue;
4217 }
4218 b++;
4219
4220 const std::string &bname = band_labels.at(b);
4221 uint depth = radiation_bands.at(bname).scatteringDepth;
4222 if (s + 1 > depth) {
4223 // Internal SIF excitation bands ("_SIF_exc_*") get piggy-backed onto user
4224 // dispatches (e.g. PAR) that may have a higher scatteringDepth, so they hit
4225 // this "skip" path on every scattering iteration. Suppress the per-band log
4226 // for those — with ~35 excitation bins × scatteringDepth iterations they
4227 // would flood stdout (and break the in-progress "Performing scattering ray
4228 // trace (iteration N of M)..." line, which has no trailing newline).
4229 const bool is_sif_excitation_band = bname.size() >= 9 && bname.compare(0, 9, "_SIF_exc_") == 0;
4230 if (message_flag && !is_sif_excitation_band) {
4231 std::cout << "Skipping band " << bname << " for scattering launch " << s + 1 << std::endl;
4232 }
4233 scatter_band_flags.at(b_global) = 0; // FIX: Modify copy, not original
4234 } else {
4235 active_bands++;
4236 }
4237 }
4238
4239 // Copy scatter buffers to radiation_out when needed
4240 // For s=0 with emission+direct: primary diffuse uploaded emission+scatter via params, but we need to copy scatter to avoid double-counting emission on next iteration
4241 // For s>0: scatter from previous iteration needs to be copied for next iteration
4242 if (s > 0 || (emissionenabled && rundirect)) {
4243 backend->copyScatterToRadiation();
4244 }
4245 backend->zeroScatterBuffers();
4246
4247 // Extract radiation_out to ensure it's uploaded for scattering rays
4248 helios::RayTracingResults scatter_results;
4249 backend->getRadiationResults(scatter_results);
4250 std::vector<float> flux_top_scatter = scatter_results.radiation_out_top;
4251 std::vector<float> flux_bottom_scatter = scatter_results.radiation_out_bottom;
4252
4253 // Build launch parameters for scattering diffuse rays
4254 // Launch all primitives at once (backend handles batching if needed)
4256 params.launch_offset = 0;
4257 params.launch_count = Nprimitives;
4258 params.rays_per_primitive = rays_per_primitive;
4259 params.random_seed = std::chrono::system_clock::now().time_since_epoch().count();
4260 params.current_band = 0;
4261 params.num_bands_global = Nbands_global;
4262 params.num_bands_launch = Nbands_launch;
4263 std::vector<bool> band_flags(scatter_band_flags.begin(), scatter_band_flags.end()); // FIX: Use scatter copy
4264 params.band_launch_flag = band_flags;
4265 params.scattering_iteration = s;
4266 params.max_scatters = scatteringDepth;
4267
4268 // Pass diffuse radiation parameters to backend
4269 params.diffuse_flux = diffuse_flux;
4270 params.diffuse_extinction = diffuse_extinction;
4271 params.diffuse_dist_norm = diffuse_dist_norm;
4272 // Convert helios::vec3 to helios::vec3
4273 std::vector<helios::vec3> peak_dirs(diffuse_peak_dir.size());
4274 for (size_t i = 0; i < diffuse_peak_dir.size(); i++) {
4275 peak_dirs[i] = helios::make_vec3(diffuse_peak_dir[i].x, diffuse_peak_dir[i].y, diffuse_peak_dir[i].z);
4276 }
4277 params.diffuse_peak_dir = peak_dirs;
4278 params.sky_radiance_params = prague_params;
4279
4280 // Set radiation_out for scattering rays
4281 params.radiation_out_top = flux_top_scatter;
4282 params.radiation_out_bottom = flux_bottom_scatter;
4283
4284 // Top surface launch
4285 params.launch_face = 1;
4286 backend->launchDiffuseRays(params);
4287
4288 // Bottom surface launch
4289 params.launch_face = 0;
4290 backend->launchDiffuseRays(params);
4291
4292 // Accumulate camera scatter from this scattering iteration
4293 if (Ncameras > 0) {
4294 helios::RayTracingResults post_launch;
4295 backend->getRadiationResults(post_launch);
4296 for (size_t i = 0; i < post_launch.scatter_buff_top_cam.size(); i++) {
4297 scatter_top_cam[i] += post_launch.scatter_buff_top_cam[i];
4298 scatter_bottom_cam[i] += post_launch.scatter_buff_bottom_cam[i];
4299 }
4300 // Zero GPU camera scatter buffers to prevent double-counting on next iteration
4301 backend->zeroCameraScatterBuffers(Nbands_launch);
4302 }
4303
4304 if (message_flag) {
4305 std::cout << "\r \r" << std::flush;
4306 }
4307 }
4308
4309 if (message_flag) {
4310 std::cout << "Performing scattering ray trace...done." << std::endl;
4311 }
4312 }
4313
4314 // **** CAMERA RAY TRACE **** //
4315 if (Ncameras > 0) {
4316
4317 // Upload accumulated camera scatter to radiation_out for cameras to read
4318 // scatter_top_cam contains camera-weighted scattered energy from all ray types
4319 // Cameras read from radiation_out during hits, so we upload camera scatter there
4320 if (Ncameras > 0 && scatteringenabled) {
4321 backend->uploadRadiationOut(scatter_top_cam, scatter_bottom_cam);
4322 }
4323
4324 // Setup solar disk rendering for cameras (enables lens flare effects)
4325 // Find sun-like sources (collimated or sun_sphere) and compute solar disk radiance
4326 vec3 sun_dir(0, 0, 1); // Default zenith
4327 std::vector<float> solar_radiances(Nbands_launch, 0.0f);
4328 bool has_sun_source = false;
4329
4330 for (size_t s = 0; s < radiation_sources.size(); s++) {
4331 const RadiationSource &source = radiation_sources.at(s);
4332 if (source.source_type == RADIATION_SOURCE_TYPE_COLLIMATED || source.source_type == RADIATION_SOURCE_TYPE_SUN_SPHERE) {
4333 // Get sun direction from source position (normalized)
4334 sun_dir = source.source_position;
4335 sun_dir.normalize();
4336 has_sun_source = true;
4337
4338 // Compute solar disk radiance for each band
4339 for (size_t b = 0; b < Nbands_launch; b++) {
4340 float flux = getSourceFlux(s, band_labels.at(b));
4341
4342 if (source.source_type == RADIATION_SOURCE_TYPE_SUN_SPHERE) {
4343 // For sun sphere: flux is surface exitance (σT⁴)
4344 // Radiance as seen from Earth: L = F_surface / π
4345 solar_radiances[b] = flux / M_PI;
4346 } else {
4347 // For collimated: flux is irradiance at Earth
4348 // Solar solid angle: π × (4.63e-3)² ≈ 6.74×10⁻⁵ sr
4349 const float solar_solid_angle = 6.74e-5f;
4350 solar_radiances[b] = flux / solar_solid_angle;
4351 }
4352 }
4353 break; // Use first sun-like source found
4354 }
4355 }
4356
4357 if (scatteringenabled && (emissionenabled || diffuseenabled || rundirect)) {
4358 // re-set diffuse radiation fluxes (will be passed via launch params)
4359 if (diffuseenabled) {
4360 for (auto b = 0; b < Nbands_launch; b++) {
4361 diffuse_flux.at(b) = getDiffuseFlux(band_labels.at(b));
4362 }
4363 }
4364
4365 size_t n = ceil(sqrt(double(diffuseRayCount)));
4366
4367 // Upload sky model parameters to backend (for camera rendering)
4368
4369 if (!cameras.empty() && prague_params.size() == Nbands_launch) {
4370 // Get sky radiances for first camera (already computed above)
4371 std::vector<float> sky_for_backend = updateAtmosphericSkyModel(band_labels, cameras.begin()->second);
4372
4373 // Build per-band diffuse flux and emission-flag vectors for camera longwave/emission sky sampling.
4374 // For emission bands, getDiffuseFlux() returns the user-set sky thermal flux (W/m²).
4375 std::vector<float> camera_diffuse_flux(Nbands_launch, 0.f);
4376 std::vector<uint32_t> band_emission_flag(Nbands_launch, 0u);
4377 for (size_t b = 0; b < Nbands_launch; b++) {
4378 camera_diffuse_flux[b] = getDiffuseFlux(band_labels[b]);
4379 band_emission_flag[b] = radiation_bands.at(band_labels[b]).emissionFlag ? 1u : 0u;
4380 }
4381
4382 // Upload to backend
4383 backend->updateSkyModel(prague_params, sky_for_backend, sun_dir, solar_radiances,
4384 has_sun_source ? 0.999989f : 0.0f, // solar_disk_cos_angle
4385 camera_diffuse_flux, band_emission_flag);
4386 }
4387
4388 uint cam = 0;
4389 for (auto &camera: cameras) {
4390
4391 // Skip cameras whose bands don't intersect the current dispatch. Without this,
4392 // every runBand() call iterates every camera even if the camera is bound to
4393 // bands that aren't being dispatched, wasting a full camera launch on a no-op.
4394 // This is especially visible for SIF cameras when the user calls runBand("PAR"):
4395 // the SIF camera (bound to SIF_red/SIF_farred) doesn't need to render anything
4396 // for the PAR dispatch.
4397 bool camera_in_dispatch = false;
4398 for (const auto &camera_band: camera.second.band_labels) {
4399 if (std::find(band_labels.begin(), band_labels.end(), camera_band) != band_labels.end()) {
4400 camera_in_dispatch = true;
4401 break;
4402 }
4403 }
4404 if (!camera_in_dispatch) {
4405 ++cam;
4406 continue;
4407 }
4408
4409 // Validate antialiasing samples don't exceed maximum
4410 if (camera.second.antialiasing_samples > maxRays) {
4411 helios_runtime_error("ERROR (runBand): Camera '" + camera.second.label + "' antialiasing samples (" + std::to_string(camera.second.antialiasing_samples) + ") exceeds OptiX maximum launch size (" + std::to_string(maxRays) +
4412 "). Reduce antialiasing samples.");
4413 }
4414
4415 // Compute tiling if needed
4416 std::vector<CameraTile> tiles = computeCameraTiles(camera.second, maxRays);
4417
4418 if (message_flag && tiles.size() > 1) {
4419 std::cout << "Camera '" << camera.second.label << "' requires " << tiles.size() << " tiles" << std::endl;
4420 }
4421
4422 // Upload camera spectral response weights for specular reflection
4423 // Extract per-camera slice from source_data[s].fluxes_cam
4424 // fluxes_cam is indexed as [band * Ncameras + cam], we need [source * band]
4425 std::vector<float> cam_weights(Nsources * Nbands_launch, 1.0f);
4426 for (uint s = 0; s < Nsources; s++) {
4427 for (uint b = 0; b < Nbands_launch; b++) {
4428 if (!source_data[s].fluxes_cam.empty() && source_data[s].fluxes_cam.size() == Nbands_launch * Ncameras) {
4429 cam_weights[s * Nbands_launch + b] = source_data[s].fluxes_cam[b * Ncameras + cam];
4430 }
4431 }
4432 }
4433 backend->uploadSourceFluxesCam(cam_weights);
4434
4435 // Launch camera rays (tiled or full)
4436 for (size_t tile_idx = 0; tile_idx < tiles.size(); tile_idx++) {
4437 const auto &tile = tiles[tile_idx];
4438
4439 // Build params for this tile
4440 helios::RayTracingLaunchParams params = buildCameraLaunchParams(camera.second, cam, camera.second.antialiasing_samples, tile.resolution, tile.offset);
4441
4442 // Set band parameters (CRITICAL for materials!)
4443 params.num_bands_launch = Nbands_launch;
4444 params.num_bands_global = Nbands_global;
4445 params.random_seed = std::chrono::system_clock::now().time_since_epoch().count();
4446 std::vector<bool> band_flags(band_launch_flag.begin(), band_launch_flag.end());
4447 params.band_launch_flag = band_flags;
4448
4449 // Progress message
4450 if (message_flag) {
4451 if (tiles.size() == 1) {
4452 std::cout << "Performing scattering radiation camera ray trace for camera " << camera.second.label << "..." << std::flush;
4453 } else {
4454 std::cout << "Performing scattering radiation camera ray trace for camera " << camera.second.label << " (tile " << (tile_idx + 1) << " of " << tiles.size() << ")..." << std::flush;
4455 }
4456 }
4457
4458 // Launch through backend
4459 backend->launchCameraRays(params);
4460
4461 if (message_flag) {
4462 if (tiles.size() > 1) {
4463 std::cout << "\r" << std::string(120, ' ') << "\r" << std::flush;
4464 } else {
4465 std::cout << "done." << std::endl;
4466 }
4467 }
4468 }
4469
4470 if (message_flag && tiles.size() > 1) {
4471 std::cout << "Performing scattering radiation camera ray trace for camera " << camera.second.label << "...done." << std::endl;
4472 }
4473
4474 // Get results from backend
4475 std::vector<float> radiation_camera;
4476 std::vector<uint> dummy_labels;
4477 std::vector<float> dummy_depths;
4478 backend->getCameraResults(radiation_camera, dummy_labels, dummy_depths, cam, camera.second.resolution);
4479
4480 // Process pixel data (KEEP EXISTING LOGIC)
4481 std::string camera_label = camera.second.label;
4482
4483 for (auto b = 0; b < Nbands_launch; b++) {
4484
4485 camera.second.pixel_data[band_labels.at(b)].resize(camera.second.resolution.x * camera.second.resolution.y);
4486
4487 std::string data_label = "camera_" + camera_label + "_" + band_labels.at(b);
4488
4489 for (auto p = 0; p < camera.second.resolution.x * camera.second.resolution.y; p++) {
4490 camera.second.pixel_data.at(band_labels.at(b)).at(p) = radiation_camera.at(p * Nbands_launch + b);
4491 }
4492
4493 context->setGlobalData(data_label.c_str(), camera.second.pixel_data.at(band_labels.at(b)));
4494 }
4495
4496 //--- Pixel Labeling Trace ---//
4497
4498 // Compute tiling for pixel labeling (no antialiasing, 1 ray per pixel)
4499 RadiationCamera pixel_label_camera = camera.second;
4500 pixel_label_camera.antialiasing_samples = 1;
4501 std::vector<CameraTile> pixel_tiles = computeCameraTiles(pixel_label_camera, maxRays);
4502
4503 // Zero camera pixel buffers once before tile loop
4504 backend->zeroCameraPixelBuffers(camera.second.resolution);
4505
4506 // Launch pixel label rays (tiled or full)
4507 for (size_t tile_idx = 0; tile_idx < pixel_tiles.size(); tile_idx++) {
4508 const auto &tile = pixel_tiles[tile_idx];
4509
4510 // Build params (reuse buildCameraLaunchParams, antialiasing=1)
4511 helios::RayTracingLaunchParams params = buildCameraLaunchParams(pixel_label_camera, cam,
4512 1, // No antialiasing for pixel labeling
4513 tile.resolution, tile.offset);
4514
4515 // Progress message
4516 if (message_flag) {
4517 if (pixel_tiles.size() == 1) {
4518 std::cout << "Performing camera pixel labeling ray trace for camera " << camera.second.label << "..." << std::flush;
4519 } else {
4520 std::cout << "Performing camera pixel labeling ray trace for camera " << camera.second.label << " (tile " << (tile_idx + 1) << " of " << pixel_tiles.size() << ")..." << std::flush;
4521 }
4522 }
4523
4524 // Launch through backend
4525 backend->launchPixelLabelRays(params);
4526
4527 if (message_flag) {
4528 if (pixel_tiles.size() > 1) {
4529 std::cout << "\r" << std::string(120, ' ') << "\r" << std::flush;
4530 } else {
4531 std::cout << "done." << std::endl;
4532 }
4533 }
4534 }
4535
4536 if (message_flag && pixel_tiles.size() > 1) {
4537 std::cout << "Performing camera pixel labeling ray trace for camera " << camera.second.label << "...done." << std::endl;
4538 }
4539
4540 // Get pixel label results
4541 std::vector<float> dummy_pixel_data;
4542 backend->getCameraResults(dummy_pixel_data, camera.second.pixel_label_UUID, camera.second.pixel_depth, cam, camera.second.resolution);
4543
4544 // Pixel labels from GPU already contain Helios UUID+1 (1-indexed, 0=sky).
4545 // No conversion needed - the intersection programs store actual primitive UUIDs
4546 // directly from the geometry UUID buffers (patch_UUID, triangle_UUID, etc.).
4547
4548 // Store results in context (KEEP EXISTING LOGIC)
4549 std::string data_label = "camera_" + camera_label + "_pixel_UUID";
4550 context->setGlobalData(data_label.c_str(), camera.second.pixel_label_UUID);
4551
4552 data_label = "camera_" + camera_label + "_pixel_depth";
4553 context->setGlobalData(data_label.c_str(), camera.second.pixel_depth);
4554
4555 cam++;
4556 }
4557 } else {
4558 // if scattering is not enabled or all sources have zero flux, we still need to zero the camera buffers
4559 for (auto &camera: cameras) {
4560 for (auto b = 0; b < Nbands_launch; b++) {
4561 camera.second.pixel_data[band_labels.at(b)].resize(camera.second.resolution.x * camera.second.resolution.y);
4562
4563 std::string data_label = "camera_" + camera.second.label + "_" + band_labels.at(b);
4564
4565 for (auto p = 0; p < camera.second.resolution.x * camera.second.resolution.y; p++) {
4566 camera.second.pixel_data.at(band_labels.at(b)).at(p) = 0.f;
4567 }
4568 context->setGlobalData(data_label.c_str(), camera.second.pixel_data.at(band_labels.at(b)));
4569 }
4570 }
4571 }
4572 }
4573
4574 // Apply camera exposure based on each camera's exposure setting
4575 for (auto &camera: cameras) {
4576 camera.second.applyCameraExposure(context);
4577 }
4578
4579 // Apply camera white balance based on each camera's white_balance setting
4580 for (auto &camera: cameras) {
4581 camera.second.applyCameraWhiteBalance(context);
4582 }
4583
4584 // deposit any energy that is left to make sure we satisfy conservation of energy
4585
4586 // Extract ALL results from backend instead of old OptiX buffers
4588 backend->getRadiationResults(results);
4589
4590 std::vector<float> radiation_flux_data = results.radiation_in;
4591
4592 // Extract scatter buffer data from backend results
4593 TBS_top = results.scatter_buff_top;
4594 TBS_bottom = results.scatter_buff_bottom;
4595
4596 std::vector<uint> UUIDs_context_all = context->getAllUUIDs();
4597
4598 // Create indexer for result extraction
4599 RadiationBufferIndexer result_indexer(Nprimitives, Nbands_launch);
4600
4601 for (auto b = 0; b < Nbands_launch; b++) {
4602
4603 std::string prop = "radiation_flux_" + band_labels.at(b);
4604 std::vector<float> R(Nprimitives);
4605 for (size_t u = 0; u < Nprimitives; u++) {
4606 // Use BufferIndexer: [primitive][band]
4607 size_t ind = result_indexer(u, b);
4608 R.at(u) = radiation_flux_data.at(ind) + TBS_top.at(ind) + TBS_bottom.at(ind);
4609 }
4610 context->setPrimitiveData(context_UUIDs, prop.c_str(), R);
4611
4612 if (UUIDs_context_all.size() != Nprimitives) {
4613 for (uint UUID: UUIDs_context_all) {
4614 if (context->doesPrimitiveExist(UUID) && !context->doesPrimitiveDataExist(UUID, prop.c_str())) {
4615 context->setPrimitiveData(UUID, prop.c_str(), 0.f);
4616 }
4617 }
4618 }
4619 }
4620
4621 // Finalize any piggy-backed SIF excitation sets: now that radiation_flux_<_SIF_exc_*>
4622 // primitive data has been populated, copy it into the ExcitationSet apar_buffer and
4623 // clear the internal primitive data. A subsequent runBand() on SIF emission bands
4624 // will then skip re-running excitation (populateExcitationAPAR marks populated=true).
4625 for (ExcitationSet *exc : piggybacked_sets) {
4626 populateExcitationAPAR(*exc);
4627 }
4628}
4629
4631
4633 backend->getRadiationResults(results);
4634
4635 float Rsky = 0.f;
4636 for (size_t i = 0; i < results.sky_energy.size(); i++) {
4637 Rsky += results.sky_energy.at(i);
4638 }
4639 return Rsky;
4640}
4641
4643
4644 std::vector<float> total_flux;
4645 total_flux.resize(context->getPrimitiveCount(), 0.f);
4646
4647 for (const auto &band: radiation_bands) {
4648
4649 std::string label = band.first;
4650
4651 for (size_t u = 0; u < context_UUIDs.size(); u++) {
4652
4653 uint p = context_UUIDs.at(u);
4654
4655 std::string str = "radiation_flux_" + label;
4656
4657 float R;
4658 context->getPrimitiveData(p, str.c_str(), R);
4659 total_flux.at(u) += R;
4660 }
4661 }
4662
4663 return total_flux;
4664}
4665
4666
4668
4669 vec3 dir = view_direction;
4670 dir.normalize();
4671
4672 float Gtheta = 0;
4673 float total_area = 0;
4674 for (std::size_t u = 0; u < primitiveID.size(); u++) {
4675
4676 uint UUID = context_UUIDs.at(primitiveID.at(u));
4677
4678 vec3 normal = context->getPrimitiveNormal(UUID);
4679 float area = context->getPrimitiveArea(UUID);
4680
4681 Gtheta += fabsf(normal * dir) * area;
4682
4683 total_area += area;
4684 }
4685
4686 return Gtheta / total_area;
4687}
4688
4689void RadiationModel::exportColorCorrectionMatrixXML(const std::string &file_path, const std::string &camera_label, const std::vector<std::vector<float>> &matrix, const std::string &source_image_path, const std::string &colorboard_type,
4690 float average_delta_e) {
4691
4692 std::ofstream file(file_path);
4693 if (!file.is_open()) {
4694 helios_runtime_error("ERROR (RadiationModel::exportColorCorrectionMatrixXML): Failed to open file for writing: " + file_path);
4695 }
4696
4697 // Determine matrix type (3x3 or 4x3)
4698 std::string matrix_type = "3x3";
4699 if (matrix.size() == 4 || (matrix.size() >= 3 && matrix[0].size() == 4)) {
4700 matrix_type = "4x3";
4701 }
4702
4703 // Write XML header with informative comments
4704 file << "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" << std::endl;
4705 file << "<!-- Camera Color Correction Matrix -->" << std::endl;
4706 file << "<!-- Source Image: " << source_image_path << " -->" << std::endl;
4707 file << "<!-- Camera Label: " << camera_label << " -->" << std::endl;
4708 file << "<!-- Colorboard Type: " << colorboard_type << " -->" << std::endl;
4709 if (average_delta_e >= 0.0f) {
4710 file << "<!-- Average Delta E: " << std::fixed << std::setprecision(2) << average_delta_e << " -->" << std::endl;
4711 }
4712 file << "<!-- Matrix Type: " << matrix_type << " -->" << std::endl;
4713 file << "<!-- Generated: " << getCurrentDateTime() << " -->" << std::endl;
4714
4715 // Write matrix data
4716 file << "<helios>" << std::endl;
4717 file << " <ColorCorrectionMatrix camera_label=\"" << camera_label << "\" matrix_type=\"" << matrix_type << "\">" << std::endl;
4718
4719 for (size_t i = 0; i < matrix.size(); i++) {
4720 file << " <row>";
4721 for (size_t j = 0; j < matrix[i].size(); j++) {
4722 file << std::fixed << std::setprecision(6) << matrix[i][j];
4723 if (j < matrix[i].size() - 1) {
4724 file << " ";
4725 }
4726 }
4727 file << "</row>" << std::endl;
4728 }
4729
4730 file << " </ColorCorrectionMatrix>" << std::endl;
4731 file << "</helios>" << std::endl;
4732
4733 file.close();
4734}
4735
4736std::string RadiationModel::getCurrentDateTime() {
4737 auto now = std::time(nullptr);
4738 auto tm = *std::localtime(&now);
4739 std::stringstream ss;
4740 ss << std::put_time(&tm, "%Y-%m-%d %H:%M:%S");
4741 return ss.str();
4742}
4743
4744std::vector<std::vector<float>> RadiationModel::loadColorCorrectionMatrixXML(const std::string &file_path, std::string &camera_label_out) {
4745
4746 std::ifstream file(file_path);
4747 if (!file.is_open()) {
4748 helios_runtime_error("ERROR (RadiationModel::loadColorCorrectionMatrixXML): Failed to open file for reading: " + file_path);
4749 }
4750
4751 std::vector<std::vector<float>> matrix;
4752 std::string line;
4753 bool in_matrix = false;
4754 std::string matrix_type = "";
4755
4756 while (std::getline(file, line)) {
4757 // Remove leading/trailing whitespace
4758 line.erase(0, line.find_first_not_of(" \t"));
4759 line.erase(line.find_last_not_of(" \t") + 1);
4760
4761 // Look for ColorCorrectionMatrix opening tag
4762 if (line.find("<ColorCorrectionMatrix") != std::string::npos) {
4763 in_matrix = true;
4764
4765 // Extract camera_label attribute
4766 size_t camera_start = line.find("camera_label=\"");
4767 if (camera_start != std::string::npos) {
4768 camera_start += 14; // Length of "camera_label=\""
4769 size_t camera_end = line.find("\"", camera_start);
4770 if (camera_end != std::string::npos) {
4771 camera_label_out = line.substr(camera_start, camera_end - camera_start);
4772 }
4773 }
4774
4775 // Extract matrix_type attribute
4776 size_t type_start = line.find("matrix_type=\"");
4777 if (type_start != std::string::npos) {
4778 type_start += 13; // Length of "matrix_type=\""
4779 size_t type_end = line.find("\"", type_start);
4780 if (type_end != std::string::npos) {
4781 matrix_type = line.substr(type_start, type_end - type_start);
4782 }
4783 }
4784 continue;
4785 }
4786
4787 // Look for ColorCorrectionMatrix closing tag
4788 if (line.find("</ColorCorrectionMatrix>") != std::string::npos) {
4789 in_matrix = false;
4790 break;
4791 }
4792
4793 // Parse row data
4794 if (in_matrix && line.find("<row>") != std::string::npos && line.find("</row>") != std::string::npos) {
4795 // Extract content between <row> and </row>
4796 size_t start = line.find("<row>") + 5;
4797 size_t end = line.find("</row>");
4798 std::string row_data = line.substr(start, end - start);
4799
4800 // Parse float values from row
4801 std::vector<float> row;
4802 std::istringstream iss(row_data);
4803 float value;
4804 while (iss >> value) {
4805 row.push_back(value);
4806 }
4807
4808 if (!row.empty()) {
4809 matrix.push_back(row);
4810 }
4811 }
4812 }
4813
4814 file.close();
4815
4816 // Validate loaded matrix
4817 if (matrix.empty()) {
4818 helios_runtime_error("ERROR (RadiationModel::loadColorCorrectionMatrixXML): No matrix data found in file: " + file_path);
4819 }
4820
4821 if (matrix.size() != 3) {
4822 helios_runtime_error("ERROR (RadiationModel::loadColorCorrectionMatrixXML): Invalid matrix size. Expected 3 rows, found " + std::to_string(matrix.size()) + " rows in file: " + file_path);
4823 }
4824
4825 // Validate matrix type consistency
4826 bool is_3x3 = (matrix[0].size() == 3 && matrix[1].size() == 3 && matrix[2].size() == 3);
4827 bool is_4x3 = (matrix[0].size() == 4 && matrix[1].size() == 4 && matrix[2].size() == 4);
4828
4829 if (!is_3x3 && !is_4x3) {
4830 helios_runtime_error("ERROR (RadiationModel::loadColorCorrectionMatrixXML): Invalid matrix dimensions. All rows must have either 3 or 4 elements. File: " + file_path);
4831 }
4832
4833 // Check matrix type attribute matches actual dimensions
4834 if (!matrix_type.empty()) {
4835 if ((matrix_type == "3x3" && !is_3x3) || (matrix_type == "4x3" && !is_4x3)) {
4836 helios_runtime_error("ERROR (RadiationModel::loadColorCorrectionMatrixXML): Matrix type attribute ('" + matrix_type + "') does not match actual matrix dimensions in file: " + file_path);
4837 }
4838 }
4839
4840 return matrix;
4841}
4842
4843std::string RadiationModel::autoCalibrateCameraImage(const std::string &camera_label, const std::string &red_band_label, const std::string &green_band_label, const std::string &blue_band_label, const std::string &output_file_path,
4844 bool print_quality_report, ColorCorrectionAlgorithm algorithm, const std::string &ccm_export_file_path) {
4845
4846 // Step 1: Validate camera exists and get pixel UUID data
4847 if (cameras.find(camera_label) == cameras.end()) {
4848 helios_runtime_error("ERROR (RadiationModel::autoCalibrateCameraImage): Camera '" + camera_label + "' does not exist. Make sure the camera was added to the radiation model.");
4849 }
4850
4851 // Get camera pixel UUID data from global data (needed for segmentation)
4852 std::string pixel_UUID_label = "camera_" + camera_label + "_pixel_UUID";
4853 if (!context->doesGlobalDataExist(pixel_UUID_label.c_str())) {
4854 helios_runtime_error("ERROR (RadiationModel::autoCalibrateCameraImage): Camera pixel UUID data '" + pixel_UUID_label + "' does not exist for camera '" + camera_label + "'. Make sure the radiation model has been run.");
4855 }
4856
4857 // Step 2: Detect all colorboard types using CameraCalibration helper
4858 CameraCalibration calibration(context);
4859 std::vector<std::string> colorboard_types;
4860 try {
4861 colorboard_types = calibration.detectColorBoardTypes();
4862 } catch (const std::exception &e) {
4863 helios_runtime_error("ERROR (RadiationModel::autoCalibrateCameraImage): Failed to detect colorboard types. " + std::string(e.what()));
4864 }
4865
4866 // Step 3: Get reference Lab values for all detected colorboards
4867 std::vector<CameraCalibration::LabColor> reference_lab_values;
4868 std::vector<std::string> colorboard_type_per_patch; // Track which colorboard each patch belongs to
4869
4870 for (const auto &colorboard_type: colorboard_types) {
4871 std::vector<CameraCalibration::LabColor> current_reference_values;
4872
4873 if (colorboard_type == "DGK") {
4874 current_reference_values = calibration.getReferenceLab_DGK();
4875 } else if (colorboard_type == "Calibrite") {
4876 current_reference_values = calibration.getReferenceLab_Calibrite();
4877 } else if (colorboard_type == "SpyderCHECKR") {
4878 current_reference_values = calibration.getReferenceLab_SpyderCHECKR();
4879 } else {
4880 helios_runtime_error("ERROR (RadiationModel::autoCalibrateCameraImage): Unsupported colorboard type '" + colorboard_type + "'.");
4881 }
4882
4883 // Add to combined list
4884 reference_lab_values.insert(reference_lab_values.end(), current_reference_values.begin(), current_reference_values.end());
4885
4886 // Track which colorboard type each patch belongs to
4887 for (size_t i = 0; i < current_reference_values.size(); i++) {
4888 colorboard_type_per_patch.push_back(colorboard_type);
4889 }
4890 }
4891
4892 // Step 4: Generate segmentation masks for all colorboard patches
4893 std::vector<uint> pixel_UUIDs;
4894 context->getGlobalData(pixel_UUID_label.c_str(), pixel_UUIDs);
4895 int2 camera_resolution = cameras.at(camera_label).resolution;
4896
4897 // Create segmentation masks by finding pixels that belong to colorboard patches
4898 std::map<int, std::vector<std::vector<bool>>> patch_masks;
4899 int global_patch_idx = 0; // Global patch index across all colorboards
4900
4901 for (const auto &colorboard_type: colorboard_types) {
4902 // Get the number of patches for this colorboard type
4903 int num_patches = 0;
4904 if (colorboard_type == "DGK") {
4905 num_patches = 18;
4906 } else if (colorboard_type == "Calibrite" || colorboard_type == "SpyderCHECKR") {
4907 num_patches = 24;
4908 }
4909
4910 // Generate masks for each patch in this colorboard
4911 for (int local_patch_idx = 0; local_patch_idx < num_patches; local_patch_idx++) {
4912 std::vector<std::vector<bool>> mask(camera_resolution.y, std::vector<bool>(camera_resolution.x, false));
4913
4914 // Find pixels that correspond to this colorboard patch
4915 for (int y = 0; y < camera_resolution.y; y++) {
4916 for (int x = 0; x < camera_resolution.x; x++) {
4917 int pixel_index = y * camera_resolution.x + x;
4918 uint pixel_UUID = pixel_UUIDs[pixel_index];
4919
4920 if (pixel_UUID > 0) { // Valid primitive
4921 pixel_UUID--; // Convert from 1-based to 0-based indexing
4922
4923 // Check if this primitive belongs to this specific colorboard patch
4924 std::string colorboard_data_label = "colorboard_" + colorboard_type;
4925 if (context->doesPrimitiveDataExist(pixel_UUID, colorboard_data_label.c_str())) {
4926 uint patch_id;
4927 context->getPrimitiveData(pixel_UUID, colorboard_data_label.c_str(), patch_id);
4928 // Patch indices are 0-based, compare directly
4929 if ((int) patch_id == local_patch_idx) {
4930 mask[y][x] = true;
4931 }
4932 }
4933 }
4934 }
4935 }
4936
4937 patch_masks[global_patch_idx] = mask;
4938 global_patch_idx++;
4939 }
4940 }
4941
4942 // Step 5: Extract RGB colors from processed camera data (same source as writeCameraImage)
4943 // Use the same data source that writeCameraImage() uses: cameras.pixel_data
4944 std::vector<float> red_data, green_data, blue_data;
4945
4946 // Check if bands exist in camera
4947 auto &camera_bands = cameras.at(camera_label).band_labels;
4948 if (std::find(camera_bands.begin(), camera_bands.end(), red_band_label) == camera_bands.end()) {
4949 helios_runtime_error("ERROR (RadiationModel::autoCalibrateCameraImage): Red band '" + red_band_label + "' not found in camera '" + camera_label + "'.");
4950 }
4951 if (std::find(camera_bands.begin(), camera_bands.end(), green_band_label) == camera_bands.end()) {
4952 helios_runtime_error("ERROR (RadiationModel::autoCalibrateCameraImage): Green band '" + green_band_label + "' not found in camera '" + camera_label + "'.");
4953 }
4954 if (std::find(camera_bands.begin(), camera_bands.end(), blue_band_label) == camera_bands.end()) {
4955 helios_runtime_error("ERROR (RadiationModel::autoCalibrateCameraImage): Blue band '" + blue_band_label + "' not found in camera '" + camera_label + "'.");
4956 }
4957
4958 // Read processed camera data (same as writeCameraImage uses)
4959 red_data = cameras.at(camera_label).pixel_data.at(red_band_label);
4960 green_data = cameras.at(camera_label).pixel_data.at(green_band_label);
4961 blue_data = cameras.at(camera_label).pixel_data.at(blue_band_label);
4962
4963 // Check data range and normalize if needed
4964 float max_r = *std::max_element(red_data.begin(), red_data.end());
4965 float max_g = *std::max_element(green_data.begin(), green_data.end());
4966 float max_b = *std::max_element(blue_data.begin(), blue_data.end());
4967
4968 // Normalize camera data to [0,1] range if values are > 1
4969 float scale_factor = 1.0f;
4970 if (max_r > 1.0f || max_g > 1.0f || max_b > 1.0f) {
4971 scale_factor = 1.0f / std::max({max_r, max_g, max_b});
4972
4973 for (size_t i = 0; i < red_data.size(); i++) {
4974 red_data[i] *= scale_factor;
4975 green_data[i] *= scale_factor;
4976 blue_data[i] *= scale_factor;
4977 }
4978 }
4979
4980 std::vector<helios::vec3> measured_rgb_values;
4981 int visible_patches = 0;
4982
4983 for (const auto &[patch_idx, mask]: patch_masks) {
4984 float sum_r = 0.0f, sum_g = 0.0f, sum_b = 0.0f;
4985 int pixel_count = 0;
4986
4987 // Average RGB values over all pixels in this patch
4988 for (int y = 0; y < camera_resolution.y; y++) {
4989 for (int x = 0; x < camera_resolution.x; x++) {
4990 if (mask[y][x]) {
4991 int pixel_index = y * camera_resolution.x + x;
4992 sum_r += red_data[pixel_index];
4993 sum_g += green_data[pixel_index];
4994 sum_b += blue_data[pixel_index];
4995 pixel_count++;
4996 }
4997 }
4998 }
4999
5000 if (pixel_count > 10) { // Only consider patches with sufficient pixels
5001 helios::vec3 avg_rgb = make_vec3(sum_r / pixel_count, sum_g / pixel_count, sum_b / pixel_count);
5002 measured_rgb_values.push_back(avg_rgb);
5003
5004 visible_patches++;
5005 } else {
5006 // Add placeholder for missing patch
5007 measured_rgb_values.push_back(make_vec3(0, 0, 0));
5008 }
5009 }
5010
5011 // Convert measured RGB to Lab and calculate correction matrix
5012 std::vector<CameraCalibration::LabColor> measured_lab_values;
5013 for (const auto &rgb: measured_rgb_values) {
5014 if (rgb.magnitude() > 0) { // Only process non-zero values
5015 measured_lab_values.push_back(calibration.rgbToLab(rgb));
5016 }
5017 }
5018
5019 // Calculate color correction matrix based on selected algorithm
5020 std::vector<std::vector<float>> correction_matrix = {{1.0f, 0.0f, 0.0f}, {0.0f, 1.0f, 0.0f}, {0.0f, 0.0f, 1.0f}};
5021
5022 // Report which algorithm is being used
5023 std::string algorithm_name;
5024 switch (algorithm) {
5026 algorithm_name = "Diagonal scaling (white balance only)";
5027 break;
5029 algorithm_name = "3x3 matrix with auto-fallback to diagonal";
5030 break;
5032 algorithm_name = "3x3 matrix (forced)";
5033 break;
5034 }
5035
5036 if (measured_lab_values.size() >= 6 && reference_lab_values.size() >= 6) {
5037 // Convert reference Lab back to RGB for matrix fitting
5038 std::vector<helios::vec3> target_rgb;
5039
5040 for (size_t i = 0; i < reference_lab_values.size(); i++) {
5041 CameraCalibration::LabColor ref_lab = reference_lab_values[i];
5042 helios::vec3 ref_rgb = calibration.labToRgb(ref_lab);
5043 target_rgb.push_back(ref_rgb);
5044 }
5045
5046 // Build matrices for least squares: M * Measured = Target
5047 // We need to solve for M where M is 3x3 matrix
5048 // This becomes: M = Target * Measured^T * (Measured * Measured^T)^(-1)
5049
5050 // Collect valid patches for matrix calculation
5051 std::vector<helios::vec3> valid_measured, valid_target;
5052 std::vector<float> patch_weights;
5053
5054 for (size_t i = 0; i < std::min(measured_rgb_values.size(), target_rgb.size()); i++) {
5055 if (measured_rgb_values[i].magnitude() > 0.01f) {
5056 valid_measured.push_back(measured_rgb_values[i]);
5057 valid_target.push_back(target_rgb[i]);
5058
5059 // Perceptually-weighted patch selection based on colour-science best practices
5060 float weight = 1.0f;
5061
5062 // Neutral patches (white to black series) - highest priority for white balance
5063 if (i >= 18 && i <= 23) {
5064 // White and light grays get highest weight (most visually important)
5065 if (i == 18)
5066 weight = 5.0f; // White patch - critical for white balance
5067 else if (i == 19)
5068 weight = 4.0f; // Light gray - very important for tone mapping
5069 else
5070 weight = 3.0f; // Darker grays - important for contrast
5071 }
5072 // Primary color patches - important for color accuracy
5073 else if (i == 14 || i == 13 || i == 12)
5074 weight = 3.0f; // Red, Green, Blue primaries
5075
5076 // Skin tone approximates - patches that represent common skin tones
5077 else if (i == 3 || i == 10)
5078 weight = 2.5f; // Foliage and Yellow Green (skin-like hues)
5079
5080 // Well-lit patches get higher weight based on luminance
5081 helios::vec3 measured_rgb = measured_rgb_values[i];
5082 float luminance = 0.299f * measured_rgb.x + 0.587f * measured_rgb.y + 0.114f * measured_rgb.z;
5083
5084 // Boost weight for brighter patches (they're more visually prominent)
5085 if (luminance > 0.6f)
5086 weight *= 1.5f;
5087 else if (luminance < 0.2f)
5088 weight *= 0.7f; // Reduce weight for very dark patches
5089
5090 // Additional quality checks for measured RGB values
5091 // Reduce weight for patches that seem poorly measured (extreme values)
5092 if (measured_rgb.magnitude() > 1.2f || measured_rgb.magnitude() < 0.1f) {
5093 weight *= 0.5f; // Reduce weight for suspicious measurements
5094 }
5095
5096 patch_weights.push_back(weight);
5097 }
5098 }
5099
5100 if (valid_measured.size() >= 6 && algorithm != ColorCorrectionAlgorithm::DIAGONAL_ONLY) {
5101
5102 // Compute robustly regularized weighted least squares solution
5103 // Use adaptive regularization to prevent extreme matrix coefficients
5104 bool matrix_valid = true;
5105
5106 // Try moderate regularization values - avoid extreme regularization that creates bad matrices
5107 std::vector<float> lambda_values = {0.01f, 0.05f, 0.1f, 0.15f, 0.2f};
5108 int lambda_attempt = 0;
5109
5110 while (lambda_attempt < lambda_values.size()) {
5111 float lambda = lambda_values[lambda_attempt];
5112 matrix_valid = true;
5113
5114 for (int row = 0; row < 3; row++) {
5115 // Build weighted normal equations with adaptive regularization
5116 float ATA[3][3] = {{0}}; // A^T * W * A + λI
5117 float ATb[3] = {0}; // A^T * W * b
5118
5119 for (size_t i = 0; i < valid_measured.size(); i++) {
5120 float weight = patch_weights[i];
5121 helios::vec3 m = valid_measured[i]; // measured RGB
5122 float target_val = (row == 0) ? valid_target[i].x : (row == 1) ? valid_target[i].y : valid_target[i].z;
5123
5124 // Update normal equations
5125 ATA[0][0] += weight * m.x * m.x;
5126 ATA[0][1] += weight * m.x * m.y;
5127 ATA[0][2] += weight * m.x * m.z;
5128 ATA[1][0] += weight * m.y * m.x;
5129 ATA[1][1] += weight * m.y * m.y;
5130 ATA[1][2] += weight * m.y * m.z;
5131 ATA[2][0] += weight * m.z * m.x;
5132 ATA[2][1] += weight * m.z * m.y;
5133 ATA[2][2] += weight * m.z * m.z;
5134
5135 ATb[0] += weight * m.x * target_val;
5136 ATb[1] += weight * m.y * target_val;
5137 ATb[2] += weight * m.z * target_val;
5138 }
5139
5140 // Add color-preserving regularization
5141 // Stronger regularization on diagonal (preserves primary colors)
5142 // Weaker regularization on off-diagonal (allows some color mixing)
5143 float diag_reg = lambda * 2.0f; // Stronger on diagonal
5144 float offdiag_reg = lambda * 0.5f; // Weaker on off-diagonal
5145
5146 ATA[0][0] += diag_reg; // Red preservation
5147 ATA[1][1] += diag_reg; // Green preservation
5148 ATA[2][2] += diag_reg; // Blue preservation
5149
5150 // Light off-diagonal regularization to prevent extreme color mixing
5151 ATA[0][1] += offdiag_reg;
5152 ATA[1][0] += offdiag_reg;
5153 ATA[0][2] += offdiag_reg;
5154 ATA[2][0] += offdiag_reg;
5155 ATA[1][2] += offdiag_reg;
5156 ATA[2][1] += offdiag_reg;
5157
5158 // Solve regularized 3x3 system using Cramer's rule
5159 float det = ATA[0][0] * (ATA[1][1] * ATA[2][2] - ATA[1][2] * ATA[2][1]) - ATA[0][1] * (ATA[1][0] * ATA[2][2] - ATA[1][2] * ATA[2][0]) + ATA[0][2] * (ATA[1][0] * ATA[2][1] - ATA[1][1] * ATA[2][0]);
5160
5161 if (fabs(det) < 1e-3f) {
5163 matrix_valid = false;
5164 break;
5165 }
5166 }
5167
5168 float inv_det = 1.0f / det;
5169 correction_matrix[row][0] = inv_det * (ATb[0] * (ATA[1][1] * ATA[2][2] - ATA[1][2] * ATA[2][1]) - ATb[1] * (ATA[0][1] * ATA[2][2] - ATA[0][2] * ATA[2][1]) + ATb[2] * (ATA[0][1] * ATA[1][2] - ATA[0][2] * ATA[1][1]));
5170
5171 correction_matrix[row][1] = inv_det * (ATb[1] * (ATA[0][0] * ATA[2][2] - ATA[0][2] * ATA[2][0]) - ATb[0] * (ATA[1][0] * ATA[2][2] - ATA[1][2] * ATA[2][0]) + ATb[2] * (ATA[1][0] * ATA[0][2] - ATA[1][2] * ATA[0][0]));
5172
5173 correction_matrix[row][2] = inv_det * (ATb[2] * (ATA[0][0] * ATA[1][1] - ATA[0][1] * ATA[1][0]) - ATb[0] * (ATA[1][0] * ATA[2][1] - ATA[1][1] * ATA[2][0]) + ATb[1] * (ATA[0][0] * ATA[2][1] - ATA[0][1] * ATA[2][0]));
5174 }
5175
5176 // Validate computed matrix elements are reasonable
5177 if (matrix_valid) {
5178 bool elements_reasonable = true;
5179 for (int i = 0; i < 3; i++) {
5180 for (int j = 0; j < 3; j++) {
5181 if (fabs(correction_matrix[i][j]) > 5.0f) {
5183 elements_reasonable = false;
5184 break;
5185 }
5186 }
5187 }
5188 if (!elements_reasonable)
5189 break;
5190 }
5191 matrix_valid = elements_reasonable;
5192 }
5193
5194 if (matrix_valid) {
5195 break; // Success!
5196 } else {
5197 lambda_attempt++;
5198 }
5199 }
5200
5201 if (!matrix_valid) {
5202
5203 // Enhanced perceptually-weighted diagonal correction
5204 // Calculate weighted averages for each color channel
5205 float total_weight = 0.0f;
5206 helios::vec3 weighted_correction = make_vec3(0, 0, 0);
5207
5208 for (size_t i = 0; i < valid_measured.size(); i++) {
5209 float weight = patch_weights[i];
5210 helios::vec3 measured = valid_measured[i];
5211 helios::vec3 target = valid_target[i];
5212
5213 // Calculate per-channel correction factors
5214 if (measured.x > 0.01f && measured.y > 0.01f && measured.z > 0.01f) {
5215 helios::vec3 channel_correction = make_vec3(target.x / measured.x, target.y / measured.y, target.z / measured.z);
5216
5217 weighted_correction.x += weight * channel_correction.x;
5218 weighted_correction.y += weight * channel_correction.y;
5219 weighted_correction.z += weight * channel_correction.z;
5220 total_weight += weight;
5221 }
5222 }
5223
5224 if (total_weight > 0.1f) {
5225 // Apply weighted average correction factors
5226 correction_matrix[0][0] = weighted_correction.x / total_weight;
5227 correction_matrix[1][1] = weighted_correction.y / total_weight;
5228 correction_matrix[2][2] = weighted_correction.z / total_weight;
5229
5230 // Apply conservative limits
5231 correction_matrix[0][0] = std::max(0.5f, std::min(2.0f, correction_matrix[0][0]));
5232 correction_matrix[1][1] = std::max(0.5f, std::min(2.0f, correction_matrix[1][1]));
5233 correction_matrix[2][2] = std::max(0.5f, std::min(2.0f, correction_matrix[2][2]));
5234 }
5235 }
5236
5237 if (!matrix_valid || algorithm == ColorCorrectionAlgorithm::DIAGONAL_ONLY) {
5238 // Use diagonal correction using white patch
5239 correction_matrix = {{1.0f, 0.0f, 0.0f}, {0.0f, 1.0f, 0.0f}, {0.0f, 0.0f, 1.0f}};
5240
5241 // Enhanced perceptually-weighted diagonal correction using all valid patches
5242 // This is more robust than using just the white patch
5243 if (valid_measured.size() > 0 && patch_weights.size() == valid_measured.size()) {
5244 float total_weight = 0.0f;
5245 helios::vec3 weighted_measured_avg = make_vec3(0, 0, 0);
5246 helios::vec3 weighted_target_avg = make_vec3(0, 0, 0);
5247
5248 // Compute weighted averages using perceptual patch weights
5249 for (size_t i = 0; i < valid_measured.size(); i++) {
5250 float weight = patch_weights[i];
5251 weighted_measured_avg = weighted_measured_avg + weight * valid_measured[i];
5252 weighted_target_avg = weighted_target_avg + weight * valid_target[i];
5253 total_weight += weight;
5254 }
5255
5256 if (total_weight > 0) {
5257 weighted_measured_avg = weighted_measured_avg / total_weight;
5258 weighted_target_avg = weighted_target_avg / total_weight;
5259
5260 if (weighted_measured_avg.x > 0.05f && weighted_measured_avg.y > 0.05f && weighted_measured_avg.z > 0.05f) {
5261 correction_matrix[0][0] = weighted_target_avg.x / weighted_measured_avg.x;
5262 correction_matrix[1][1] = weighted_target_avg.y / weighted_measured_avg.y;
5263 correction_matrix[2][2] = weighted_target_avg.z / weighted_measured_avg.z;
5264
5265 // Apply conservative limits for stability
5266 correction_matrix[0][0] = std::max(0.5f, std::min(2.0f, correction_matrix[0][0]));
5267 correction_matrix[1][1] = std::max(0.5f, std::min(2.0f, correction_matrix[1][1]));
5268 correction_matrix[2][2] = std::max(0.5f, std::min(2.0f, correction_matrix[2][2]));
5269
5270 } else {
5271 // Fallback to original white-patch method
5272 size_t white_idx = 18;
5273 if (white_idx < measured_rgb_values.size() && white_idx < target_rgb.size()) {
5274 helios::vec3 measured_white = measured_rgb_values[white_idx];
5275 helios::vec3 target_white = target_rgb[white_idx];
5276 if (measured_white.x > 0.05f && measured_white.y > 0.05f && measured_white.z > 0.05f) {
5277 correction_matrix[0][0] = std::max(0.5f, std::min(2.0f, target_white.x / measured_white.x));
5278 correction_matrix[1][1] = std::max(0.5f, std::min(2.0f, target_white.y / measured_white.y));
5279 correction_matrix[2][2] = std::max(0.5f, std::min(2.0f, target_white.z / measured_white.z));
5280 }
5281 }
5282 }
5283 }
5284 } else {
5285 // Original simple approach as ultimate fallback
5286 size_t white_idx = 18;
5287 if (white_idx < measured_rgb_values.size() && white_idx < target_rgb.size()) {
5288 helios::vec3 measured_white = measured_rgb_values[white_idx];
5289 helios::vec3 target_white = target_rgb[white_idx];
5290 if (measured_white.x > 0.05f && measured_white.y > 0.05f && measured_white.z > 0.05f) {
5291 correction_matrix[0][0] = std::max(0.5f, std::min(2.0f, target_white.x / measured_white.x));
5292 correction_matrix[1][1] = std::max(0.5f, std::min(2.0f, target_white.y / measured_white.y));
5293 correction_matrix[2][2] = std::max(0.5f, std::min(2.0f, target_white.z / measured_white.z));
5294 }
5295 }
5296 }
5297 }
5298 } else if (algorithm == ColorCorrectionAlgorithm::DIAGONAL_ONLY) {
5299 // Apply diagonal correction using white patch
5300 size_t white_idx = 18;
5301 if (white_idx < measured_rgb_values.size() && white_idx < target_rgb.size() && measured_rgb_values[white_idx].magnitude() > 0) {
5302
5303 helios::vec3 measured_white = measured_rgb_values[white_idx];
5304 helios::vec3 target_white = target_rgb[white_idx];
5305
5306 if (measured_white.x > 0.05f && measured_white.y > 0.05f && measured_white.z > 0.05f) {
5307 correction_matrix[0][0] = target_white.x / measured_white.x;
5308 correction_matrix[1][1] = target_white.y / measured_white.y;
5309 correction_matrix[2][2] = target_white.z / measured_white.z;
5310
5311 // Apply limits
5312 correction_matrix[0][0] = std::max(0.5f, std::min(2.0f, correction_matrix[0][0]));
5313 correction_matrix[1][1] = std::max(0.5f, std::min(2.0f, correction_matrix[1][1]));
5314 correction_matrix[2][2] = std::max(0.5f, std::min(2.0f, correction_matrix[2][2]));
5315 }
5316 }
5317 } else {
5318 std::cout << "Insufficient valid patches (" << valid_measured.size() << " available), using identity matrix" << std::endl;
5319 }
5320 } else if (algorithm == ColorCorrectionAlgorithm::DIAGONAL_ONLY && measured_lab_values.size() > 0) {
5321 // Apply diagonal correction using white patch
5322 size_t white_idx = 18;
5323 if (white_idx < measured_rgb_values.size() && white_idx < reference_lab_values.size() && measured_rgb_values[white_idx].magnitude() > 0) {
5324
5325 CameraCalibration::LabColor ref_lab = reference_lab_values[white_idx];
5326 helios::vec3 target_white = calibration.labToRgb(ref_lab);
5327 helios::vec3 measured_white = measured_rgb_values[white_idx];
5328
5329 if (measured_white.x > 0.05f && measured_white.y > 0.05f && measured_white.z > 0.05f) {
5330 correction_matrix[0][0] = target_white.x / measured_white.x;
5331 correction_matrix[1][1] = target_white.y / measured_white.y;
5332 correction_matrix[2][2] = target_white.z / measured_white.z;
5333
5334 // Apply limits
5335 correction_matrix[0][0] = std::max(0.5f, std::min(2.0f, correction_matrix[0][0]));
5336 correction_matrix[1][1] = std::max(0.5f, std::min(2.0f, correction_matrix[1][1]));
5337 correction_matrix[2][2] = std::max(0.5f, std::min(2.0f, correction_matrix[2][2]));
5338 }
5339 }
5340 } else {
5341 std::cout << "Insufficient patches for correction (" << measured_lab_values.size() << " available), using identity matrix" << std::endl;
5342 }
5343
5344 // Generate quality of fit report if requested
5345 if (print_quality_report) {
5346 std::cout << "\n========== COLOR CALIBRATION QUALITY REPORT ==========" << std::endl;
5347 std::cout << "Colorboard types: ";
5348 for (size_t i = 0; i < colorboard_types.size(); i++) {
5349 std::cout << colorboard_types[i];
5350 if (i < colorboard_types.size() - 1) {
5351 std::cout << ", ";
5352 }
5353 }
5354 std::cout << std::endl;
5355 std::cout << "Number of patches analyzed: " << visible_patches << std::endl;
5356 std::cout << "Algorithm used: " << algorithm_name << std::endl;
5357
5358 // Display matrix conditioning information
5359 bool is_diagonal_only = true;
5360 for (int i = 0; i < 3; i++) {
5361 for (int j = 0; j < 3; j++) {
5362 if (i != j && fabs(correction_matrix[i][j]) > 1e-6f) {
5363 is_diagonal_only = false;
5364 break;
5365 }
5366 }
5367 if (!is_diagonal_only)
5368 break;
5369 }
5370
5371 if (is_diagonal_only) {
5372 std::cout << "Color correction factors applied: R=" << correction_matrix[0][0] << ", G=" << correction_matrix[1][1] << ", B=" << correction_matrix[2][2] << std::endl;
5373 std::cout << "Matrix type: Diagonal (white balance only)" << std::endl;
5374 } else {
5375 std::cout << "Full 3x3 color correction matrix applied:" << std::endl;
5376 for (int i = 0; i < 3; i++) {
5377 std::cout << "[" << std::fixed << std::setprecision(4);
5378 for (int j = 0; j < 3; j++) {
5379 std::cout << std::setw(8) << correction_matrix[i][j];
5380 if (j < 2)
5381 std::cout << " ";
5382 }
5383 std::cout << "]" << std::endl;
5384 }
5385 std::cout << "Matrix type: Full 3x3 (corrects color casts and chromatic errors)" << std::endl;
5386
5387 // Calculate matrix determinant for conditioning info
5388 float det = correction_matrix[0][0] * (correction_matrix[1][1] * correction_matrix[2][2] - correction_matrix[1][2] * correction_matrix[2][1]) -
5389 correction_matrix[0][1] * (correction_matrix[1][0] * correction_matrix[2][2] - correction_matrix[1][2] * correction_matrix[2][0]) +
5390 correction_matrix[0][2] * (correction_matrix[1][0] * correction_matrix[2][1] - correction_matrix[1][1] * correction_matrix[2][0]);
5391
5392 std::cout << "Matrix determinant: " << std::scientific << std::setprecision(3) << det << std::endl;
5393 if (fabs(det) > 0.1f) {
5394 std::cout << "Matrix conditioning: Good (well-conditioned)" << std::endl;
5395 } else if (fabs(det) > 0.01f) {
5396 std::cout << "Matrix conditioning: Fair (moderately conditioned)" << std::endl;
5397 } else {
5398 std::cout << "Matrix conditioning: Poor (ill-conditioned)" << std::endl;
5399 }
5400 std::cout << std::fixed; // Reset formatting
5401 }
5402
5403 // Calculate and display quality metrics for each patch
5404 double total_delta_e = 0.0;
5405 int valid_patches = 0;
5406
5407 std::cout << "\nPer-patch analysis (after correction):" << std::endl;
5408 std::cout << "Patch | Corrected RGB | Reference RGB | Delta E " << std::endl;
5409 std::cout << "------|--------------------|--------------------|---------" << std::endl;
5410
5411 for (size_t i = 0; i < std::min(measured_rgb_values.size(), reference_lab_values.size()); i++) {
5412 if (measured_rgb_values[i].magnitude() > 0) {
5413 // Apply color correction to measured RGB values (with optional affine terms)
5414 helios::vec3 measured_rgb = measured_rgb_values[i];
5415 float corrected_r = correction_matrix[0][0] * measured_rgb.x + correction_matrix[0][1] * measured_rgb.y + correction_matrix[0][2] * measured_rgb.z;
5416 float corrected_g = correction_matrix[1][0] * measured_rgb.x + correction_matrix[1][1] * measured_rgb.y + correction_matrix[1][2] * measured_rgb.z;
5417 float corrected_b = correction_matrix[2][0] * measured_rgb.x + correction_matrix[2][1] * measured_rgb.y + correction_matrix[2][2] * measured_rgb.z;
5418
5419
5420 // Clamp corrected values to [0,1] - DISABLED FOR TESTING
5421 // corrected_r = std::max(0.0f, std::min(1.0f, corrected_r));
5422 // corrected_g = std::max(0.0f, std::min(1.0f, corrected_g));
5423 // corrected_b = std::max(0.0f, std::min(1.0f, corrected_b));
5424
5425 helios::vec3 corrected_rgb = make_vec3(corrected_r, corrected_g, corrected_b);
5426
5427 // Convert corrected RGB to Lab
5428 CameraCalibration::LabColor corrected_lab = calibration.rgbToLab(corrected_rgb);
5429 CameraCalibration::LabColor reference_lab = reference_lab_values[i];
5430
5431 // Calculate Delta E between corrected and reference
5432 // Use ΔE2000 for better perceptual color difference assessment
5433 double delta_E = calibration.deltaE2000(corrected_lab, reference_lab);
5434
5435 std::cout << std::setw(5) << i << " | " << std::fixed << std::setprecision(3) << "(" << std::setw(5) << corrected_rgb.x << "," << std::setw(5) << corrected_rgb.y << "," << std::setw(5) << corrected_rgb.z << ") | ";
5436
5437 helios::vec3 ref_rgb = calibration.labToRgb(reference_lab);
5438 std::cout << "(" << std::setw(5) << ref_rgb.x << "," << std::setw(5) << ref_rgb.y << "," << std::setw(5) << ref_rgb.z << ") | " << std::setw(7) << delta_E << std::endl;
5439
5440 total_delta_e += delta_E;
5441 valid_patches++;
5442 }
5443 }
5444
5445 // Overall statistics
5446 double mean_delta_e = total_delta_e / valid_patches;
5447 std::cout << "\n========== OVERALL CALIBRATION QUALITY ==========" << std::endl;
5448 std::cout << "Mean Delta E: " << std::fixed << std::setprecision(2) << mean_delta_e << std::endl;
5449
5450 std::cout << "======================================================\n" << std::endl;
5451 }
5452
5453 // Step 7: Apply correction to entire image with same pixel ordering as writeCameraImage
5454 std::vector<helios::RGBcolor> corrected_pixels;
5455 corrected_pixels.resize(red_data.size());
5456
5457 // Apply correction using the same pixel transformation as writeCameraImage uses
5458 for (int j = 0; j < camera_resolution.y; j++) {
5459 for (int i = 0; i < camera_resolution.x; i++) {
5460 // Get pixel from source data (no flip)
5461 int source_index = j * camera_resolution.x + i;
5462 float r = red_data[source_index];
5463 float g = green_data[source_index];
5464 float b = blue_data[source_index];
5465
5466 // Apply correction matrix (with optional affine terms)
5467 float corrected_r = correction_matrix[0][0] * r + correction_matrix[0][1] * g + correction_matrix[0][2] * b;
5468 float corrected_g = correction_matrix[1][0] * r + correction_matrix[1][1] * g + correction_matrix[1][2] * b;
5469 float corrected_b = correction_matrix[2][0] * r + correction_matrix[2][1] * g + correction_matrix[2][2] * b;
5470
5471
5472 // Clamp values to [0,1] - DISABLED FOR TESTING
5473 // corrected_r = std::max(0.0f, std::min(1.0f, corrected_r));
5474 // corrected_g = std::max(0.0f, std::min(1.0f, corrected_g));
5475 // corrected_b = std::max(0.0f, std::min(1.0f, corrected_b));
5476
5477 // Apply same coordinate transformation as writeCameraImage
5478 uint ii = camera_resolution.x - i - 1; // Horizontal flip
5479 uint jj = camera_resolution.y - j - 1; // Vertical flip
5480 uint dest_index = jj * camera_resolution.x + ii;
5481
5482 corrected_pixels[dest_index] = make_RGBcolor(corrected_r, corrected_g, corrected_b);
5483 }
5484 }
5485
5486 // Step 8: Write corrected image using writeJPEG
5487 std::string output_path = output_file_path;
5488 if (output_path.empty()) {
5489 output_path = "auto_calibrated_" + camera_label + ".jpg";
5490 }
5491
5492 try {
5493 helios::writeJPEG(output_path, camera_resolution.x, camera_resolution.y, corrected_pixels);
5494 std::cout << "Wrote corrected image to: " << output_path << std::endl;
5495 } catch (const std::exception &e) {
5496 helios_runtime_error("ERROR (RadiationModel::autoCalibrateCameraImage): Failed to write corrected image. " + std::string(e.what()));
5497 }
5498
5499 // Export CCM to XML file if requested
5500 if (!ccm_export_file_path.empty()) {
5501 try {
5502 // Calculate quality metrics for export (even if not printed)
5503 double total_delta_e = 0.0;
5504 int valid_patches = 0;
5505
5506 for (size_t i = 0; i < std::min(measured_rgb_values.size(), reference_lab_values.size()); i++) {
5507 if (measured_rgb_values[i].magnitude() > 0) {
5508 // Apply color correction to measured RGB values
5509 helios::vec3 measured_rgb = measured_rgb_values[i];
5510 float corrected_r = correction_matrix[0][0] * measured_rgb.x + correction_matrix[0][1] * measured_rgb.y + correction_matrix[0][2] * measured_rgb.z;
5511 float corrected_g = correction_matrix[1][0] * measured_rgb.x + correction_matrix[1][1] * measured_rgb.y + correction_matrix[1][2] * measured_rgb.z;
5512 float corrected_b = correction_matrix[2][0] * measured_rgb.x + correction_matrix[2][1] * measured_rgb.y + correction_matrix[2][2] * measured_rgb.z;
5513
5514 helios::vec3 corrected_rgb = make_vec3(corrected_r, corrected_g, corrected_b);
5515
5516 // Convert corrected RGB to Lab
5517 CameraCalibration::LabColor corrected_lab = calibration.rgbToLab(corrected_rgb);
5518 CameraCalibration::LabColor reference_lab = reference_lab_values[i];
5519
5520 // Calculate Delta E between corrected and reference
5521 double delta_E = calibration.deltaE2000(corrected_lab, reference_lab);
5522 total_delta_e += delta_E;
5523 valid_patches++;
5524 }
5525 }
5526
5527 double mean_delta_e = (valid_patches > 0) ? (total_delta_e / valid_patches) : -1.0;
5528
5529 // Export CCM to XML file
5530 // Concatenate all colorboard types into a single string
5531 std::string colorboard_types_str;
5532 for (size_t i = 0; i < colorboard_types.size(); i++) {
5533 colorboard_types_str += colorboard_types[i];
5534 if (i < colorboard_types.size() - 1) {
5535 colorboard_types_str += ", ";
5536 }
5537 }
5538 exportColorCorrectionMatrixXML(ccm_export_file_path, camera_label, correction_matrix, output_path, colorboard_types_str, (float) mean_delta_e);
5539
5540 std::cout << "Exported color correction matrix to: " << ccm_export_file_path << std::endl;
5541 } catch (const std::exception &e) {
5542 helios_runtime_error("ERROR (RadiationModel::autoCalibrateCameraImage): Failed to export CCM to XML. " + std::string(e.what()));
5543 }
5544 }
5545
5546 return output_path;
5547}
5548
5549void RadiationModel::applyCameraColorCorrectionMatrix(const std::string &camera_label, const std::string &red_band_label, const std::string &green_band_label, const std::string &blue_band_label, const std::string &ccm_file_path) {
5550
5551 // Step 1: Validate camera exists
5552 if (cameras.find(camera_label) == cameras.end()) {
5553 helios_runtime_error("ERROR (RadiationModel::applyCameraColorCorrectionMatrix): Camera '" + camera_label + "' does not exist. Make sure the camera was added to the radiation model.");
5554 }
5555
5556 // Step 2: Validate band labels exist in camera
5557 auto &camera_bands = cameras.at(camera_label).band_labels;
5558 if (std::find(camera_bands.begin(), camera_bands.end(), red_band_label) == camera_bands.end()) {
5559 helios_runtime_error("ERROR (RadiationModel::applyCameraColorCorrectionMatrix): Red band '" + red_band_label + "' not found in camera '" + camera_label + "'.");
5560 }
5561 if (std::find(camera_bands.begin(), camera_bands.end(), green_band_label) == camera_bands.end()) {
5562 helios_runtime_error("ERROR (RadiationModel::applyCameraColorCorrectionMatrix): Green band '" + green_band_label + "' not found in camera '" + camera_label + "'.");
5563 }
5564 if (std::find(camera_bands.begin(), camera_bands.end(), blue_band_label) == camera_bands.end()) {
5565 helios_runtime_error("ERROR (RadiationModel::applyCameraColorCorrectionMatrix): Blue band '" + blue_band_label + "' not found in camera '" + camera_label + "'.");
5566 }
5567
5568 // Step 3: Load color correction matrix from XML file
5569 std::string loaded_camera_label;
5570 std::vector<std::vector<float>> correction_matrix;
5571 try {
5572 correction_matrix = loadColorCorrectionMatrixXML(ccm_file_path, loaded_camera_label);
5573 } catch (const std::exception &e) {
5574 helios_runtime_error("ERROR (RadiationModel::applyCameraColorCorrectionMatrix): Failed to load CCM from XML file. " + std::string(e.what()));
5575 }
5576
5577 // Step 4: Validate matrix dimensions (should be 3x3 or 4x3)
5578 if (correction_matrix.size() != 3) {
5579 helios_runtime_error("ERROR (RadiationModel::applyCameraColorCorrectionMatrix): Invalid matrix dimensions. Expected 3x3 or 4x3 matrix, got " + std::to_string(correction_matrix.size()) + " rows.");
5580 }
5581
5582 bool is_3x3 = (correction_matrix[0].size() == 3);
5583 bool is_4x3 = (correction_matrix[0].size() == 4);
5584
5585 if (!is_3x3 && !is_4x3) {
5586 helios_runtime_error("ERROR (RadiationModel::applyCameraColorCorrectionMatrix): Invalid matrix dimensions. Expected 3x3 or 4x3 matrix, got " + std::to_string(correction_matrix.size()) + "x" + std::to_string(correction_matrix[0].size()) +
5587 " matrix.");
5588 }
5589
5590 // Step 5: Get camera data (same approach as applyImageProcessingPipeline)
5591 std::vector<float> &red_data = cameras.at(camera_label).pixel_data.at(red_band_label);
5592 std::vector<float> &green_data = cameras.at(camera_label).pixel_data.at(green_band_label);
5593 std::vector<float> &blue_data = cameras.at(camera_label).pixel_data.at(blue_band_label);
5594
5595 int2 camera_resolution = cameras.at(camera_label).resolution;
5596 size_t pixel_count = red_data.size();
5597
5598 // Step 6: Apply color correction matrix to all pixels in-place
5599 for (size_t i = 0; i < pixel_count; i++) {
5600 float r = red_data[i];
5601 float g = green_data[i];
5602 float b = blue_data[i];
5603
5604 // Apply color correction matrix (3x3 or 4x3)
5605 if (is_3x3) {
5606 // Standard 3x3 matrix transformation
5607 red_data[i] = correction_matrix[0][0] * r + correction_matrix[0][1] * g + correction_matrix[0][2] * b;
5608 green_data[i] = correction_matrix[1][0] * r + correction_matrix[1][1] * g + correction_matrix[1][2] * b;
5609 blue_data[i] = correction_matrix[2][0] * r + correction_matrix[2][1] * g + correction_matrix[2][2] * b;
5610 } else {
5611 // 4x3 matrix transformation with affine offset
5612 red_data[i] = correction_matrix[0][0] * r + correction_matrix[0][1] * g + correction_matrix[0][2] * b + correction_matrix[0][3];
5613 green_data[i] = correction_matrix[1][0] * r + correction_matrix[1][1] * g + correction_matrix[1][2] * b + correction_matrix[1][3];
5614 blue_data[i] = correction_matrix[2][0] * r + correction_matrix[2][1] * g + correction_matrix[2][2] * b + correction_matrix[2][3];
5615 }
5616 }
5617
5618 if (message_flag) {
5619 std::cout << "Applied color correction matrix from '" << ccm_file_path << "' to camera '" << camera_label << "'" << std::endl;
5620 std::cout << "Matrix type: " << (is_3x3 ? "3x3" : "4x3") << ", processed " << pixel_count << " pixels" << std::endl;
5621 }
5622}
5623
5624std::vector<float> RadiationModel::getCameraPixelData(const std::string &camera_label, const std::string &band_label) {
5625 if (cameras.find(camera_label) == cameras.end()) {
5626 helios_runtime_error("ERROR (RadiationModel::getCameraPixelData): Camera '" + camera_label + "' does not exist.");
5627 }
5628
5629 auto &camera_pixel_data = cameras.at(camera_label).pixel_data;
5630 if (camera_pixel_data.find(band_label) == camera_pixel_data.end()) {
5631 helios_runtime_error("ERROR (RadiationModel::getCameraPixelData): Band '" + band_label + "' does not exist in camera '" + camera_label + "'.");
5632 }
5633
5634 return camera_pixel_data.at(band_label);
5635}
5636
5637void RadiationModel::setCameraPixelData(const std::string &camera_label, const std::string &band_label, const std::vector<float> &pixel_data) {
5638 if (cameras.find(camera_label) == cameras.end()) {
5639 helios_runtime_error("ERROR (RadiationModel::setCameraPixelData): Camera '" + camera_label + "' does not exist.");
5640 }
5641
5642 cameras.at(camera_label).pixel_data[band_label] = pixel_data;
5643}
5644
5645// ========== Backend Integration Methods ==========
5646
5647void RadiationModel::queryBackendGPUMemory() const {
5648 if (backend) {
5649 backend->queryGPUMemory();
5650 } else {
5651 std::cout << "Backend not initialized - cannot query GPU memory." << std::endl;
5652 }
5653}
5654
5655
5656helios::RayTracingLaunchParams RadiationModel::buildCameraLaunchParams(const RadiationCamera &camera, uint camera_id, uint antialiasing_samples, const helios::int2 &tile_resolution, const helios::int2 &tile_offset) {
5657
5659
5660 // Camera position and orientation
5661 params.camera_position = camera.position;
5662 helios::SphericalCoord dir = cart2sphere(camera.lookat - camera.position);
5664
5665 // Camera optical properties
5666 params.camera_focal_length = camera.focal_length;
5667 params.camera_lens_diameter = camera.lens_diameter;
5668 params.camera_fov_aspect = camera.FOV_aspect_ratio;
5669
5670 // Resolution and tiling
5671 params.camera_resolution = tile_resolution;
5672 params.camera_resolution_full = camera.resolution;
5673 params.camera_pixel_offset = tile_offset;
5674 params.antialiasing_samples = antialiasing_samples;
5675 params.camera_id = camera_id;
5676
5677 // Compute effective HFOV with zoom
5678 float effective_HFOV = camera.HFOV_degrees / camera.camera_zoom;
5679 params.camera_HFOV = effective_HFOV * M_PI / 180.0f;
5680 params.camera_viewplane_length = 0.5f / tanf(0.5f * effective_HFOV * M_PI / 180.f);
5681
5682 // Compute pixel solid angle
5683 float HFOV_rad = effective_HFOV * M_PI / 180.f;
5684 float VFOV_rad = HFOV_rad / camera.FOV_aspect_ratio;
5685 float pixel_angle_h = HFOV_rad / float(camera.resolution.x);
5686 float pixel_angle_v = VFOV_rad / float(camera.resolution.y);
5687 params.camera_pixel_solid_angle = pixel_angle_h * pixel_angle_v;
5688
5689 // Explicitly set scattering iteration for cameras (always iteration 0 for specular)
5690 params.scattering_iteration = 0;
5691
5692 // Set specular reflection mode from auto-detection
5693 params.specular_reflection_enabled = specular_reflection_mode;
5694
5695 return params;
5696}
5697
5698std::vector<CameraTile> RadiationModel::computeCameraTiles(const RadiationCamera &camera, size_t maxRays) {
5699
5700 std::vector<CameraTile> tiles;
5701
5702 size_t total_rays = size_t(camera.antialiasing_samples) * size_t(camera.resolution.x) * size_t(camera.resolution.y);
5703
5704 // No tiling needed
5705 if (total_rays <= maxRays) {
5706 tiles.push_back({camera.resolution, helios::make_int2(0, 0)});
5707 return tiles;
5708 }
5709
5710 // Calculate tile dimensions
5711 size_t rays_per_row = size_t(camera.antialiasing_samples) * size_t(camera.resolution.x);
5712 size_t max_rows_per_tile = floor(float(maxRays) / float(rays_per_row));
5713
5714 if (max_rows_per_tile == 0) {
5715 // 2D tiling - even one row is too large
5716 size_t max_pixels_per_tile = floor(float(maxRays) / float(camera.antialiasing_samples));
5717
5718 float aspect = float(camera.resolution.x) / float(camera.resolution.y);
5719 size_t tile_width = round(sqrt(max_pixels_per_tile * aspect));
5720 size_t tile_height = floor(float(max_pixels_per_tile) / float(tile_width));
5721
5722 tile_width = std::min(tile_width, size_t(camera.resolution.x));
5723 tile_height = std::min(tile_height, size_t(camera.resolution.y));
5724
5725 int Ntiles_x = ceil(float(camera.resolution.x) / float(tile_width));
5726 int Ntiles_y = ceil(float(camera.resolution.y) / float(tile_height));
5727
5728 for (int ty = 0; ty < Ntiles_y; ty++) {
5729 for (int tx = 0; tx < Ntiles_x; tx++) {
5730 size_t offset_x = tx * tile_width;
5731 size_t offset_y = ty * tile_height;
5732 size_t width_this = std::min(tile_width, camera.resolution.x - offset_x);
5733 size_t height_this = std::min(tile_height, camera.resolution.y - offset_y);
5734
5735 tiles.push_back({helios::make_int2(width_this, height_this), helios::make_int2(offset_x, offset_y)});
5736 }
5737 }
5738 } else {
5739 // 1D tiling - tile along height only
5740 size_t rows_per_tile = std::min(max_rows_per_tile, size_t(camera.resolution.y));
5741 int Ntiles = ceil(float(camera.resolution.y) / float(rows_per_tile));
5742
5743 for (int t = 0; t < Ntiles; t++) {
5744 size_t offset_y = t * rows_per_tile;
5745 size_t height_this = std::min(rows_per_tile, camera.resolution.y - offset_y);
5746
5747 tiles.push_back({helios::make_int2(camera.resolution.x, height_this), helios::make_int2(0, offset_y)});
5748 }
5749 }
5750
5751 return tiles;
5752}
5753
5754void RadiationModel::buildGeometryData(const std::vector<uint> &UUIDs) {
5755 // Build backend-agnostic geometry data from Context primitives
5756 // This extracts all geometry information needed by the ray tracing backend
5757
5758 // Filter out invalid/zero-area primitives (same as old updateGeometry)
5759 std::vector<uint> valid_UUIDs;
5760 for (uint UUID: UUIDs) {
5761 if (!context->doesPrimitiveExist(UUID))
5762 continue;
5763
5764 float area = context->getPrimitiveArea(UUID);
5765 uint parentID = context->getPrimitiveParentObjectID(UUID);
5766 if ((area == 0 || std::isnan(area)) && context->getObjectType(parentID) != helios::OBJECT_TYPE_TILE) {
5767 continue;
5768 }
5769 valid_UUIDs.push_back(UUID);
5770 }
5771
5772 if (valid_UUIDs.empty()) {
5773 geometry_data = helios::RayTracingGeometry(); // Empty geometry
5774 return;
5775 }
5776
5777 // Reorder primitives by parent object (same ordering as old code)
5778 std::vector<uint> objID_all = context->getUniquePrimitiveParentObjectIDs(valid_UUIDs, true);
5779 std::vector<uint> primitive_UUIDs_ordered;
5780 std::unordered_set<uint> valid_set(valid_UUIDs.begin(), valid_UUIDs.end());
5781
5782 for (uint objID: objID_all) {
5783 std::vector<uint> prim_UUIDs = context->getObjectPrimitiveUUIDs(objID);
5784 if (objID == 0) {
5785 // Standalone primitives (parentID=0) come from unordered_map iteration,
5786 // which has non-deterministic ordering. Sort by UUID for reproducibility.
5787 std::sort(prim_UUIDs.begin(), prim_UUIDs.end());
5788 }
5789 for (uint UUID: prim_UUIDs) {
5790 if (context->doesPrimitiveExist(UUID) && valid_set.find(UUID) != valid_set.end()) {
5791 primitive_UUIDs_ordered.push_back(UUID);
5792 }
5793 }
5794 }
5795
5796 size_t Nprimitives = primitive_UUIDs_ordered.size();
5797 geometry_data.primitive_count = Nprimitives;
5798
5799 // Clear and allocate per-primitive arrays (important when updateGeometry is called multiple times)
5800 geometry_data.transform_matrices.clear();
5801 geometry_data.transform_matrices.resize(Nprimitives * 16);
5802 geometry_data.primitive_types.clear();
5803 // Initialize to UINT_MAX as sentinel - prevents uninitialized entries from matching type==0 (patch)
5804 geometry_data.primitive_types.resize(Nprimitives, UINT_MAX);
5805 geometry_data.primitive_UUIDs = primitive_UUIDs_ordered;
5806 geometry_data.primitive_IDs.clear();
5807 geometry_data.primitive_IDs.resize(Nprimitives); // Will be populated after primitiveID_indices is built
5808 geometry_data.object_IDs.clear();
5809 geometry_data.object_IDs.resize(Nprimitives);
5810 geometry_data.object_subdivisions.clear();
5811 geometry_data.object_subdivisions.resize(Nprimitives);
5812 geometry_data.twosided_flags.clear();
5813 geometry_data.twosided_flags.resize(Nprimitives);
5814 geometry_data.solid_fractions.clear();
5815 geometry_data.solid_fractions.resize(Nprimitives);
5816
5817 // Clear type-specific arrays
5818 geometry_data.patches.vertices.clear();
5819 geometry_data.patches.UUIDs.clear();
5820 geometry_data.triangles.vertices.clear();
5821 geometry_data.triangles.UUIDs.clear();
5822 geometry_data.disk_centers.clear();
5823 geometry_data.disk_radii.clear();
5824 geometry_data.disk_normals.clear();
5825 geometry_data.disk_UUIDs.clear();
5826 geometry_data.tiles.vertices.clear();
5827 geometry_data.tiles.UUIDs.clear();
5828 geometry_data.voxels.vertices.clear();
5829 geometry_data.voxels.UUIDs.clear();
5830 geometry_data.bboxes.vertices.clear();
5831 geometry_data.bboxes.UUIDs.clear();
5832
5833 // Track object IDs for compound objects
5834 uint current_objID = 0;
5835 uint last_parentID = 99999;
5836
5837 std::vector<uint> primitiveID_indices; // Maps primitives to their "object" index
5838
5839 for (size_t u = 0; u < Nprimitives; u++) {
5840 uint UUID = primitive_UUIDs_ordered[u];
5841 uint parentID = context->getPrimitiveParentObjectID(UUID);
5842
5843 if (last_parentID != parentID || parentID == 0 || context->getObjectType(parentID) == helios::OBJECT_TYPE_TILE) {
5844 primitiveID_indices.push_back(u);
5845 last_parentID = parentID;
5846 current_objID++;
5847 } else {
5848 last_parentID = parentID;
5849 }
5850
5851 geometry_data.object_IDs[u] = current_objID - 1;
5852 }
5853
5854 size_t Nobjects = primitiveID_indices.size();
5855
5856 // Populate primitiveID for runBand() compatibility
5857 primitiveID = primitiveID_indices;
5858
5859 // For backend: primitiveID[position] must return the UUID for that primitive
5860 // Sized by Nprimitives (all primitives including subpatches), not Nobjects (object entries only)
5861 std::vector<uint> primitiveID_for_backend(Nprimitives);
5862 for (size_t i = 0; i < Nprimitives; i++) {
5863 primitiveID_for_backend[i] = primitive_UUIDs_ordered[i];
5864 }
5865
5866 // Copy corrected primitiveID mapping to geometry_data for backend upload
5867 geometry_data.primitive_IDs = primitiveID_for_backend;
5868
5869 // Populate geometry for each primitive
5870 size_t patch_idx = 0, tri_idx = 0, disk_idx = 0, voxel_idx = 0, bbox_idx = 0;
5871
5872 // Iterate over ALL primitives to set per-primitive data
5873 // (not just Nobjects, which only has one entry per object group)
5874 for (size_t prim_idx = 0; prim_idx < Nprimitives; prim_idx++) {
5875 uint UUID = primitive_UUIDs_ordered[prim_idx];
5876
5877 // Transform matrix
5878 float m[16];
5879 uint parentID = context->getPrimitiveParentObjectID(UUID);
5880 helios::PrimitiveType type = context->getPrimitiveType(UUID);
5881
5882 // Solid fraction
5883 geometry_data.solid_fractions[prim_idx] = context->getPrimitiveSolidFraction(UUID);
5884
5885 // Two-sided flag. Store the raw value (0=one-sided, 1=two-sided, 2=transparent, 3=special/source-model)
5886 // rather than collapsing to a boolean, so the backends receive the full semantics.
5887 geometry_data.twosided_flags[prim_idx] = static_cast<char>(context->getPrimitiveTwosidedFlag(UUID, 1));
5888
5889 if (parentID > 0 && context->getObjectType(parentID) == helios::OBJECT_TYPE_TILE) {
5890 // Tile subpatch: treat as individual patch for both OptiX and Vulkan backends.
5891 // Each subpatch gets its own world-space vertices in the patch geometry,
5892 // its own transform matrix, and type=0 (patch). tile_count will be 0.
5893 geometry_data.primitive_types[prim_idx] = 0; // patch
5894
5895 context->getPrimitiveTransformationMatrix(UUID, m);
5896 memcpy(&geometry_data.transform_matrices[prim_idx * 16], m, 16 * sizeof(float));
5897
5898 std::vector<vec3> verts = context->getPrimitiveVertices(UUID);
5899 for (const auto &v: verts) {
5900 geometry_data.patches.vertices.push_back(v);
5901 }
5902
5903 geometry_data.object_subdivisions[prim_idx] = helios::make_int2(1, 1);
5904 geometry_data.patches.UUIDs.push_back(UUID);
5905 patch_idx++;
5906
5907 } else if (type == helios::PRIMITIVE_TYPE_PATCH) {
5908 geometry_data.primitive_types[prim_idx] = 0; // patch
5909
5910 context->getPrimitiveTransformationMatrix(UUID, m);
5911 memcpy(&geometry_data.transform_matrices[prim_idx * 16], m, 16 * sizeof(float));
5912
5913 std::vector<vec3> verts = context->getPrimitiveVertices(UUID);
5914 for (const auto &v: verts) {
5915 geometry_data.patches.vertices.push_back(v);
5916 }
5917
5918 geometry_data.object_subdivisions[prim_idx] = helios::make_int2(1, 1);
5919
5920 // FIX: Add UUID inline to ensure consistent ordering with vertices
5921 geometry_data.patches.UUIDs.push_back(UUID);
5922
5923 patch_idx++;
5924
5925 } else if (type == helios::PRIMITIVE_TYPE_TRIANGLE) {
5926 geometry_data.primitive_types[prim_idx] = 1; // triangle
5927
5928 context->getPrimitiveTransformationMatrix(UUID, m);
5929 memcpy(&geometry_data.transform_matrices[prim_idx * 16], m, 16 * sizeof(float));
5930
5931 std::vector<vec3> verts = context->getPrimitiveVertices(UUID);
5932 for (const auto &v: verts) {
5933 geometry_data.triangles.vertices.push_back(v);
5934 }
5935
5936 geometry_data.object_subdivisions[prim_idx] = helios::make_int2(1, 1);
5937 geometry_data.triangles.UUIDs.push_back(UUID); // Store actual UUID, not position
5938 tri_idx++;
5939
5940 } else if (type == helios::PRIMITIVE_TYPE_VOXEL) {
5941 geometry_data.primitive_types[prim_idx] = 4; // voxel
5942
5943 context->getPrimitiveTransformationMatrix(UUID, m);
5944 memcpy(&geometry_data.transform_matrices[prim_idx * 16], m, 16 * sizeof(float));
5945
5946 std::vector<vec3> verts = context->getPrimitiveVertices(UUID);
5947 for (const auto &v: verts) {
5948 geometry_data.voxels.vertices.push_back(v);
5949 }
5950
5951 geometry_data.object_subdivisions[prim_idx] = helios::make_int2(1, 1);
5952 geometry_data.voxels.UUIDs.push_back(UUID); // Store actual UUID, not position
5953 voxel_idx++;
5954 }
5955 }
5956
5957 // Set counts
5958 geometry_data.patch_count = patch_idx;
5959 geometry_data.triangle_count = tri_idx;
5960 geometry_data.disk_count = disk_idx;
5961 geometry_data.tile_count = 0; // Tile subpatches are treated as individual patches
5962 geometry_data.voxel_count = voxel_idx;
5963
5964 // ========== Periodic Boundary Bboxes ==========
5965 // Create bbox geometry for periodic boundary conditions
5966 // Each bbox face is a rectangular boundary at domain edge
5967
5968 // Get domain bounding box
5969 vec2 xbounds, ybounds, zbounds;
5970 context->getDomainBoundingBox(xbounds, ybounds, zbounds);
5971
5972 // Validate camera positions if periodic boundaries enabled
5973 if (periodic_flag.x == 1 || periodic_flag.y == 1) {
5974 if (!cameras.empty()) {
5975 for (auto &camera: cameras) {
5976 vec3 camerapos = camera.second.position;
5977 if (camerapos.x < xbounds.x || camerapos.x > xbounds.y || camerapos.y < ybounds.x || camerapos.y > ybounds.y) {
5978 std::cout << "WARNING (RadiationModel::buildGeometryData): camera position is outside of the domain bounding box. Disabling periodic boundary conditions." << std::endl;
5979 periodic_flag.x = 0;
5980 periodic_flag.y = 0;
5981 break;
5982 }
5983 // Extend z-bounds to include camera
5984 if (camerapos.z < zbounds.x) {
5985 zbounds.x = camerapos.z;
5986 }
5987 if (camerapos.z > zbounds.y) {
5988 zbounds.y = camerapos.z;
5989 }
5990 }
5991 }
5992 }
5993
5994 // Expand bounds slightly to ensure bbox faces are outside geometry
5995 xbounds.x -= 1e-5;
5996 xbounds.y += 1e-5;
5997 ybounds.x -= 1e-5;
5998 ybounds.y += 1e-5;
5999 zbounds.x -= 1e-5;
6000 zbounds.y += 1e-5;
6001
6002 // Bbox UUIDs must not collide with real primitive UUIDs
6003 // Use max_UUID + 1 as base (not Nprimitives, which can cause collisions with sparse UUIDs)
6004 uint max_UUID = geometry_data.primitive_UUIDs.empty() ? 0 : *std::max_element(geometry_data.primitive_UUIDs.begin(), geometry_data.primitive_UUIDs.end());
6005 uint bbox_UUID_base = max_UUID + 1;
6006
6007 // Create bbox faces based on periodic flags
6008 if (periodic_flag.x == 1) {
6009 // -x facing boundary (4 vertices: counter-clockwise from bottom-left)
6010 geometry_data.bboxes.vertices.push_back(vec3(xbounds.x, ybounds.x, zbounds.x));
6011 geometry_data.bboxes.vertices.push_back(vec3(xbounds.x, ybounds.y, zbounds.x));
6012 geometry_data.bboxes.vertices.push_back(vec3(xbounds.x, ybounds.y, zbounds.y));
6013 geometry_data.bboxes.vertices.push_back(vec3(xbounds.x, ybounds.x, zbounds.y));
6014 geometry_data.bboxes.UUIDs.push_back(bbox_UUID_base + bbox_idx);
6015 bbox_idx++;
6016
6017 // +x facing boundary
6018 geometry_data.bboxes.vertices.push_back(vec3(xbounds.y, ybounds.x, zbounds.x));
6019 geometry_data.bboxes.vertices.push_back(vec3(xbounds.y, ybounds.y, zbounds.x));
6020 geometry_data.bboxes.vertices.push_back(vec3(xbounds.y, ybounds.y, zbounds.y));
6021 geometry_data.bboxes.vertices.push_back(vec3(xbounds.y, ybounds.x, zbounds.y));
6022 geometry_data.bboxes.UUIDs.push_back(bbox_UUID_base + bbox_idx);
6023 bbox_idx++;
6024 }
6025
6026 if (periodic_flag.y == 1) {
6027 // -y facing boundary
6028 geometry_data.bboxes.vertices.push_back(vec3(xbounds.x, ybounds.x, zbounds.x));
6029 geometry_data.bboxes.vertices.push_back(vec3(xbounds.y, ybounds.x, zbounds.x));
6030 geometry_data.bboxes.vertices.push_back(vec3(xbounds.y, ybounds.x, zbounds.y));
6031 geometry_data.bboxes.vertices.push_back(vec3(xbounds.x, ybounds.x, zbounds.y));
6032 geometry_data.bboxes.UUIDs.push_back(bbox_UUID_base + bbox_idx);
6033 bbox_idx++;
6034
6035 // +y facing boundary
6036 geometry_data.bboxes.vertices.push_back(vec3(xbounds.x, ybounds.y, zbounds.x));
6037 geometry_data.bboxes.vertices.push_back(vec3(xbounds.y, ybounds.y, zbounds.x));
6038 geometry_data.bboxes.vertices.push_back(vec3(xbounds.y, ybounds.y, zbounds.y));
6039 geometry_data.bboxes.vertices.push_back(vec3(xbounds.x, ybounds.y, zbounds.y));
6040 geometry_data.bboxes.UUIDs.push_back(bbox_UUID_base + bbox_idx);
6041 bbox_idx++;
6042 }
6043
6044 // Update bbox count and UUID base
6045 geometry_data.bbox_count = bbox_idx;
6046 if (bbox_idx > 0) {
6047 geometry_data.bbox_UUID_base = bbox_UUID_base;
6048 } else {
6049 // No bboxes: set sentinel value so GPU knows all UUIDs are real primitives
6050 geometry_data.bbox_UUID_base = UINT_MAX;
6051 }
6052
6053 // NOTE: Bbox primitive data is NOT included in the shared geometry arrays
6054 // Bboxes are OptiX-specific constructs for periodic boundaries
6055 // OptiX backend will build bbox data internally from bbox_count and bbox_UUID_base
6056 // This keeps the geometry data compatible with non-OptiX backends (Vulkan, etc.)
6057
6058 // Periodic boundary condition
6059 geometry_data.periodic_flag = periodic_flag;
6060
6061 // Extract texture mask and UV data for primitives with transparency textures
6062 buildTextureData();
6063
6064 // Build primitive_positions lookup table for GPU UUID→position conversion
6065 // Size by max UUID to create sparse lookup table (includes bbox UUIDs now that they don't collide)
6066 // Clear first to remove stale mappings from deleted primitives
6067 geometry_data.primitive_positions.clear();
6068 if (!geometry_data.primitive_UUIDs.empty()) {
6069 uint max_UUID = *std::max_element(geometry_data.primitive_UUIDs.begin(), geometry_data.primitive_UUIDs.end());
6070
6071 // Expand to include bbox UUIDs if present (they now use max_UUID+1 base, so no collisions)
6072 uint bbox_max_UUID = max_UUID;
6073 if (geometry_data.bbox_count > 0) {
6074 bbox_max_UUID = geometry_data.bbox_UUID_base + geometry_data.bbox_count - 1;
6075 }
6076
6077 geometry_data.primitive_positions.resize(bbox_max_UUID + 1, UINT_MAX); // UINT_MAX = invalid/unused
6078
6079 // Map real primitive UUIDs
6080 for (size_t i = 0; i < geometry_data.primitive_count; i++) {
6081 uint UUID = geometry_data.primitive_UUIDs[i];
6082 geometry_data.primitive_positions[UUID] = i; // Map UUID → array position
6083 }
6084
6085 // Map bbox UUIDs to their positions (after real primitives)
6086 // Now safe because bbox_UUID_base = max_UUID + 1 (no collisions)
6087 if (geometry_data.bbox_count > 0) {
6088 for (size_t i = 0; i < geometry_data.bbox_count; i++) {
6089 uint bbox_UUID = geometry_data.bbox_UUID_base + i;
6090 geometry_data.primitive_positions[bbox_UUID] = geometry_data.primitive_count + i;
6091 }
6092 }
6093 }
6094}
6095
6096void RadiationModel::buildTextureData() {
6097 // Extract texture mask and UV data for all primitives with transparency textures
6098
6099 size_t Nobjects = geometry_data.primitive_count;
6100
6101 // Clear any previous texture data (important when updateGeometry is called multiple times)
6102 geometry_data.mask_data.clear();
6103 geometry_data.mask_sizes.clear();
6104 geometry_data.uv_data.clear();
6105
6106 // Initialize with -1 (no texture)
6107 geometry_data.mask_IDs.clear();
6108 geometry_data.mask_IDs.resize(Nobjects, -1);
6109 geometry_data.uv_IDs.clear();
6110 geometry_data.uv_IDs.resize(Nobjects, -1);
6111
6112 // Cache to avoid duplicate mask data for primitives using the same texture file
6113 std::map<std::string, int> texture_to_mask_idx;
6114
6115 for (size_t prim_idx = 0; prim_idx < Nobjects; prim_idx++) {
6116 uint UUID = geometry_data.primitive_UUIDs[prim_idx];
6117
6118 // Check if primitive has a texture file
6119 std::string texture_file = context->getPrimitiveTextureFile(UUID);
6120 if (texture_file.empty()) {
6121 continue; // No texture - mask_ID stays -1
6122 }
6123
6124 // Check if texture has transparency channel (alpha)
6125 if (!context->primitiveTextureHasTransparencyChannel(UUID)) {
6126 continue; // No transparency - mask_ID stays -1 (e.g., JPEG files)
6127 }
6128
6129 // Check cache for existing mask from same texture file
6130 int mask_idx;
6131 auto cache_it = texture_to_mask_idx.find(texture_file);
6132 if (cache_it != texture_to_mask_idx.end()) {
6133 // Reuse existing mask
6134 mask_idx = cache_it->second;
6135 } else {
6136 // New texture - extract mask data
6137 const std::vector<std::vector<bool>> *trans_data = context->getPrimitiveTextureTransparencyData(UUID);
6138 helios::int2 tex_size = context->getPrimitiveTextureSize(UUID);
6139
6140 mask_idx = static_cast<int>(geometry_data.mask_sizes.size());
6141 texture_to_mask_idx[texture_file] = mask_idx;
6142
6143 // Flatten 2D bool array to 1D (row-major: [y][x])
6144 // Backend expects: for each mask m, iterate [y][x] order
6145 for (int y = 0; y < tex_size.y; y++) {
6146 for (int x = 0; x < tex_size.x; x++) {
6147 geometry_data.mask_data.push_back((*trans_data)[y][x]);
6148 }
6149 }
6150 geometry_data.mask_sizes.push_back(tex_size);
6151 }
6152
6153 geometry_data.mask_IDs[prim_idx] = mask_idx;
6154
6155 // Extract UV coordinates for this primitive
6156 // uv_IDs stores the position index (not offset), used to access uvdata[vertex][position] in CUDA
6157 std::vector<helios::vec2> uvs = context->getPrimitiveTextureUV(UUID);
6158 if (!uvs.empty()) {
6159 geometry_data.uv_IDs[prim_idx] = static_cast<int>(prim_idx); // Store position index for CUDA 2D buffer access
6160 for (const auto &uv: uvs) {
6161 geometry_data.uv_data.push_back(uv);
6162 }
6163 // Pad to 4 vertices if needed (CUDA expects max 4 vertices per primitive)
6164 size_t start_idx = geometry_data.uv_data.size() - uvs.size();
6165 while (geometry_data.uv_data.size() - start_idx < 4) {
6166 geometry_data.uv_data.push_back(uvs.back());
6167 }
6168 }
6169 // If uvs is empty, uv_ID stays -1 and CUDA will use default UV mapping
6170 }
6171}
6172
6173size_t RadiationModel::testBuildGeometryData() {
6174 buildGeometryData(context->getAllUUIDs());
6175 return geometry_data.primitive_count;
6176}
6177
6178void RadiationModel::buildUUIDMapping() {
6179 // Build bidirectional UUID ↔ array position mapping
6180 // This enables efficient conversion between UUID values and array indices
6181
6182 uuid_to_position.clear();
6183 position_to_uuid.clear();
6184
6185 // geometry_data.primitive_UUIDs is already ordered by object
6186 // Build mapping from this ordered list
6187 for (size_t i = 0; i < geometry_data.primitive_count; i++) {
6188 uint UUID = geometry_data.primitive_UUIDs[i];
6189 uuid_to_position[UUID] = i;
6190 position_to_uuid.push_back(UUID);
6191 }
6192
6193 // Build type-safe mapper (new indexing system)
6194 // Provides compile-time safety for UUID/position conversions
6195 geometry_data.mapper.build(geometry_data.primitive_UUIDs);
6196}
6197
6198static void validateAndCorrectMaterialProperties(float &rho, float &tau, float eps, bool emission_enabled, uint scattering_depth, const std::string &band_label, uint UUID, bool is_sif_band = false,
6199 helios::WarningAggregator *warnings = nullptr) {
6200 // Helper function to enforce energy conservation constraints on material properties
6201 // Mirrors the validation logic from updateRadiativeProperties() (lines 2672-2686)
6202
6203 // 1. Clamp rho and tau to [0,1] with warnings for out-of-range values
6204 if (rho < 0.f || rho > 1.f) {
6205 if (warnings) {
6206 warnings->addWarning("material_property_clamping", "Reflectivity out of range [0,1] for band " + band_label + ", primitive #" + std::to_string(UUID) + ": rho=" + std::to_string(rho) + ". Clamping to valid range.");
6207 }
6208 rho = std::max(0.f, std::min(1.f, rho));
6209 }
6210
6211 if (tau < 0.f || tau > 1.f) {
6212 if (warnings) {
6213 warnings->addWarning("material_property_clamping", "Transmissivity out of range [0,1] for band " + band_label + ", primitive #" + std::to_string(UUID) + ": tau=" + std::to_string(tau) + ". Clamping to valid range.");
6214 }
6215 tau = std::max(0.f, std::min(1.f, tau));
6216 }
6217
6218 // SIF-flagged bands bypass the Stefan-Boltzmann ε+ρ+τ=1 conservation constraint
6219 // because their emission is sourced from the Fluspect-B per-leaf kernel (see
6220 // computeSIFEmission) rather than ε·σ·T⁴. Epsilon is not consulted for SIF bands.
6221 // We still enforce the non-emission-style constraint ρ+τ ≤ 1 (physically required
6222 // regardless of the emission mechanism).
6223 if (is_sif_band) {
6224 if (rho + tau > 1.f) {
6225 helios_runtime_error(std::string("ERROR (RadiationModel): reflectivity and transmissivity must sum to less than or equal to 1 to ensure energy conservation. Band ") + band_label + ", Primitive #" +
6226 std::to_string(UUID) + ": tau=" + std::to_string(tau) + ", rho=" + std::to_string(rho) + ".");
6227 }
6228 return;
6229 }
6230
6231 // 2. Apply emission-specific constraints
6232 if (emission_enabled) {
6233 // Special case: blackbody emission (scatteringDepth=0 requires eps=1, rho=0, tau=0)
6234 if (scattering_depth == 0 && eps != 1.f) {
6235 if (warnings && (rho != 0.f || tau != 0.f)) {
6236 warnings->addWarning("blackbody_override", "Band " + band_label + " has emission with scatteringDepth=0, " + "enforcing blackbody behavior (eps=1, rho=0, tau=0) for primitive #" + std::to_string(UUID));
6237 }
6238 rho = 0.f;
6239 tau = 0.f;
6240 }
6241 // General emission case: check energy conservation (eps + rho + tau = 1)
6242 else if (eps != 1.f && rho == 0 && tau == 0) {
6243 // Auto-correct: set rho = 1 - eps
6244 rho = 1.f - eps;
6245 } else if (std::abs(eps + rho + tau - 1.f) > 1e-5f && eps > 0.f) {
6246 // Cannot auto-correct, throw error
6247 helios_runtime_error(std::string("ERROR (RadiationModel): emissivity, transmissivity, and reflectivity ") + "must sum to 1 to ensure energy conservation. Band " + band_label + ", Primitive #" + std::to_string(UUID) +
6248 ": eps=" + std::to_string(eps) + ", tau=" + std::to_string(tau) + ", rho=" + std::to_string(rho) + ". It is also possible that you forgot to disable emission for this band.");
6249 }
6250 } else {
6251 // 3. Non-emission case: rho + tau must be ≤ 1
6252 if (rho + tau > 1.f) {
6253 helios_runtime_error(std::string("ERROR (RadiationModel): transmissivity and reflectivity cannot sum to ") + "greater than 1 to ensure energy conservation. Band " + band_label + ", Primitive #" + std::to_string(UUID) +
6254 ": eps=" + std::to_string(eps) + ", tau=" + std::to_string(tau) + ", rho=" + std::to_string(rho) + ". It is also possible that you forgot to disable emission for this band.");
6255 }
6256 }
6257}
6258
6259void RadiationModel::buildMaterialData() {
6260 // Build backend-agnostic material data from Context primitive data
6261
6262 // Warning aggregator for energy conservation issues
6264
6265 size_t Nprims = geometry_data.primitive_count;
6266 size_t Nbands = radiation_bands.size();
6267 size_t Nsources = radiation_sources.size();
6268
6269 material_data.num_primitives = Nprims;
6270 material_data.num_bands = Nbands;
6271 material_data.num_sources = Nsources;
6272 material_data.num_cameras = cameras.size();
6273
6274 // Allocate arrays (indexed as [source][primitive][band] using MaterialPropertyIndexer)
6275 // NOTE: Bboxes don't need material properties (they only wrap rays for periodic boundaries)
6276 size_t total_size = Nsources * Nbands * Nprims;
6277 material_data.reflectivity.resize(total_size, 0.0f);
6278 material_data.transmissivity.resize(total_size, 0.0f);
6279 material_data.specular_exponent.resize(Nprims, -1.0f); // Default -1 means disabled
6280 material_data.specular_scale.resize(Nprims, 0.0f);
6281
6282 // Create indexer for material properties: [source][primitive][band]
6283 MaterialPropertyIndexer mat_indexer(Nsources, Nprims, Nbands);
6284
6285 // Cache unique spectral data to avoid redundant loads
6286 std::map<std::string, std::vector<helios::vec2>> unique_rho_spectra;
6287 std::map<std::string, std::vector<helios::vec2>> unique_tau_spectra;
6288
6289 for (size_t p = 0; p < Nprims; p++) {
6290 uint UUID = geometry_data.primitive_UUIDs[p];
6291
6292 // Cache reflectivity spectra
6293 if (context->doesPrimitiveDataExist(UUID, "reflectivity_spectrum")) {
6294 std::string spectrum_label;
6295 context->getPrimitiveData(UUID, "reflectivity_spectrum", spectrum_label);
6296 if (unique_rho_spectra.find(spectrum_label) == unique_rho_spectra.end()) {
6297 // Only load if spectrum exists in global data
6298 if (context->doesGlobalDataExist(spectrum_label.c_str())) {
6299 unique_rho_spectra[spectrum_label] = loadSpectralData(spectrum_label);
6300 }
6301 }
6302 }
6303
6304 // Cache transmissivity spectra
6305 if (context->doesPrimitiveDataExist(UUID, "transmissivity_spectrum")) {
6306 std::string spectrum_label;
6307 context->getPrimitiveData(UUID, "transmissivity_spectrum", spectrum_label);
6308 if (unique_tau_spectra.find(spectrum_label) == unique_tau_spectra.end()) {
6309 // Only load if spectrum exists in global data
6310 if (context->doesGlobalDataExist(spectrum_label.c_str())) {
6311 unique_tau_spectra[spectrum_label] = loadSpectralData(spectrum_label);
6312 }
6313 }
6314 }
6315 }
6316
6317 // Extract material properties from Context primitives
6318 size_t b_idx = 0;
6319 for (const auto &band_pair: radiation_bands) {
6320 std::string band_label = band_pair.second.label;
6321
6322 for (size_t s = 0; s < Nsources; s++) {
6323 for (size_t p = 0; p < Nprims; p++) {
6324 uint UUID = geometry_data.primitive_UUIDs[p];
6325
6326 // Use BufferIndexer for safe, verifiable indexing
6327 // Note: p is already the array position, so we use p directly (not UUID)
6328 size_t idx = mat_indexer(s, p, b_idx);
6329
6330 // Get reflectivity - try spectrum first, then per-band label
6331 float rho = rho_default;
6332
6333 if (context->doesPrimitiveDataExist(UUID, "reflectivity_spectrum")) {
6334 // Spectrum-based reflectivity
6335 std::string spectrum_label;
6336 context->getPrimitiveData(UUID, "reflectivity_spectrum", spectrum_label);
6337
6338 // Get spectrum from cache
6339 if (unique_rho_spectra.find(spectrum_label) != unique_rho_spectra.end()) {
6340 const std::vector<helios::vec2> &spectrum = unique_rho_spectra.at(spectrum_label);
6341
6342 // Get band wavelength bounds
6343 helios::vec2 wavebounds = band_pair.second.wavebandBounds;
6344
6345 // Only require wavelength bounds if band performs scattering/absorption
6346 // Emission-only bands (scatteringDepth==0) use Stefan-Boltzmann and don't need spectral integration
6347 // Ray launches for emission don't require wavelength bounds since emission properties are wavelength-independent
6348 bool needs_spectral_integration = (band_pair.second.scatteringDepth > 0);
6349
6350 if (needs_spectral_integration && wavebounds.x == 0 && wavebounds.y == 0) {
6351 helios_runtime_error("ERROR (RadiationModel::buildMaterialData): Band '" + band_label + "' has no wavelength bounds - required for spectral integration");
6352 }
6353
6354 // Integrate spectrum over band wavelength range (only if bounds are defined)
6355 if (wavebounds.x != 0 || wavebounds.y != 0) {
6356 if (!radiation_sources[s].source_spectrum.empty()) {
6357 // Weight by source spectrum
6358 rho = integrateSpectrum(s, spectrum, wavebounds.x, wavebounds.y);
6359 } else {
6360 // Uniform integration (divide by wavelength range to normalize)
6361 rho = integrateSpectrum(spectrum, wavebounds.x, wavebounds.y) / (wavebounds.y - wavebounds.x);
6362 }
6363 }
6364 // else: emission-only band, rho remains at default value (should be 0 for blackbody)
6365 }
6366 } else {
6367 // Per-band reflectivity (backward compatibility)
6368 std::string rho_label = "reflectivity_" + band_label;
6369 if (context->doesPrimitiveDataExist(UUID, rho_label.c_str())) {
6370 context->getPrimitiveData(UUID, rho_label.c_str(), rho);
6371 }
6372 }
6373
6374 // Get transmissivity - try spectrum first, then per-band label
6375 float tau = tau_default;
6376
6377 if (context->doesPrimitiveDataExist(UUID, "transmissivity_spectrum")) {
6378 // Spectrum-based transmissivity
6379 std::string spectrum_label;
6380 context->getPrimitiveData(UUID, "transmissivity_spectrum", spectrum_label);
6381
6382 // Get spectrum from cache
6383 if (unique_tau_spectra.find(spectrum_label) != unique_tau_spectra.end()) {
6384 const std::vector<helios::vec2> &spectrum = unique_tau_spectra.at(spectrum_label);
6385
6386 // Get band wavelength bounds
6387 helios::vec2 wavebounds = band_pair.second.wavebandBounds;
6388
6389 // Only require wavelength bounds if band performs scattering/absorption
6390 // Emission-only bands (scatteringDepth==0) use Stefan-Boltzmann and don't need spectral integration
6391 // Ray launches for emission don't require wavelength bounds since emission properties are wavelength-independent
6392 bool needs_spectral_integration = (band_pair.second.scatteringDepth > 0);
6393
6394 if (needs_spectral_integration && wavebounds.x == 0 && wavebounds.y == 0) {
6395 helios_runtime_error("ERROR (RadiationModel::buildMaterialData): Band '" + band_label + "' has no wavelength bounds - required for spectral integration");
6396 }
6397
6398 // Integrate spectrum over band wavelength range (only if bounds are defined)
6399 if (wavebounds.x != 0 || wavebounds.y != 0) {
6400 if (!radiation_sources[s].source_spectrum.empty()) {
6401 // Weight by source spectrum
6402 tau = integrateSpectrum(s, spectrum, wavebounds.x, wavebounds.y);
6403 } else {
6404 // Uniform integration
6405 tau = integrateSpectrum(spectrum, wavebounds.x, wavebounds.y) / (wavebounds.y - wavebounds.x);
6406 }
6407 }
6408 // else: emission-only band, tau remains at default value (should be 0 for blackbody)
6409 }
6410 } else {
6411 // Per-band transmissivity (backward compatibility)
6412 std::string tau_label = "transmissivity_" + band_label;
6413 if (context->doesPrimitiveDataExist(UUID, tau_label.c_str())) {
6414 context->getPrimitiveData(UUID, tau_label.c_str(), tau);
6415 }
6416 }
6417
6418 // Get emissivity for validation
6419 float eps = eps_default;
6420 std::string eps_label = "emissivity_" + band_label;
6421 if (context->doesPrimitiveDataExist(UUID, eps_label.c_str())) {
6422 context->getPrimitiveData(UUID, eps_label.c_str(), eps);
6423 }
6424
6425 // Validate and correct material properties to ensure energy conservation.
6426 // SIF-flagged bands skip the Stefan-Boltzmann ε+ρ+τ=1 check because their
6427 // emission is sourced from Fluspect-B, not ε·σ·T⁴.
6428 const RadiationBand &band = band_pair.second;
6429 const bool is_sif_band = sif_emission_bands.count(band_label) > 0;
6430 validateAndCorrectMaterialProperties(rho, tau, eps, band.emissionFlag, band.scatteringDepth, band_label, UUID, is_sif_band, &warnings);
6431
6432 // Store validated properties
6433 material_data.reflectivity[idx] = rho;
6434 material_data.transmissivity[idx] = tau;
6435 }
6436 }
6437 b_idx++;
6438 }
6439
6440 // NOTE: Bboxes don't need material properties - they only wrap rays for periodic boundaries
6441 // Material buffers are sized for real primitives only (Nprims), not including bboxes
6442
6443 // Load specular reflection properties from primitive data
6444 bool specular_exponent_specified = false;
6445 bool specular_scale_specified = false;
6446
6447 for (size_t p = 0; p < Nprims; p++) {
6448 uint UUID = geometry_data.primitive_UUIDs[p];
6449
6450 if (context->doesPrimitiveDataExist(UUID, "specular_exponent") && context->getPrimitiveDataType("specular_exponent") == helios::HELIOS_TYPE_FLOAT) {
6451 context->getPrimitiveData(UUID, "specular_exponent", material_data.specular_exponent.at(p));
6452 if (material_data.specular_exponent.at(p) >= 0.f) {
6453 specular_exponent_specified = true;
6454 }
6455 }
6456
6457 if (context->doesPrimitiveDataExist(UUID, "specular_scale") && context->getPrimitiveDataType("specular_scale") == helios::HELIOS_TYPE_FLOAT) {
6458 context->getPrimitiveData(UUID, "specular_scale", material_data.specular_scale.at(p));
6459 if (material_data.specular_scale.at(p) > 0.f) {
6460 specular_scale_specified = true;
6461 }
6462 }
6463 }
6464
6465 // Auto-enable specular reflection if specular properties are specified on any primitive
6466 if (specular_exponent_specified) {
6467 if (specular_scale_specified) {
6468 specular_reflection_mode = 2; // Mode 2: use primitive specular_scale
6469 } else {
6470 specular_reflection_mode = 1; // Mode 1: use default 0.25 scale
6471 }
6472 } else {
6473 specular_reflection_mode = 0; // Disabled
6474 }
6475
6476 // Report any accumulated warnings
6477 warnings.report();
6478}
6479
6480void RadiationModel::buildSourceData() {
6481 // Build backend-agnostic source data from radiation_sources
6482
6483 source_data.clear();
6484 source_data.reserve(radiation_sources.size());
6485
6486 for (size_t s = 0; s < radiation_sources.size(); s++) {
6487 const auto &src = radiation_sources[s];
6488 helios::RayTracingSource backend_src;
6489 backend_src.position = src.source_position;
6490 backend_src.rotation = src.source_rotation;
6491 backend_src.width = src.source_width;
6492 backend_src.type = src.source_type;
6493
6494 // Flatten flux arrays - use getSourceFlux() to handle -1.f sentinel values
6495 backend_src.fluxes.clear();
6496 backend_src.fluxes_cam.clear();
6497 for (const auto &band_pair: radiation_bands) {
6498 std::string band_label = band_pair.second.label;
6499 // Use getSourceFlux() which properly handles -1.f sentinel (returns 0 or integrates spectrum)
6500 float flux = getSourceFlux(s, band_label);
6501 backend_src.fluxes.push_back(flux);
6502 backend_src.fluxes_cam.push_back(flux); // Same for now
6503 }
6504
6505 source_data.push_back(backend_src);
6506 }
6507}
6508
6509
6510helios::RayTracingBackend *RadiationModel::getBackend() {
6511 return backend.get();
6512}
6513
6514helios::RayTracingGeometry &RadiationModel::getGeometryData() {
6515 return geometry_data;
6516}
6517
6518helios::RayTracingMaterial &RadiationModel::getMaterialData() {
6519 return material_data;
6520}
6521
6522std::vector<helios::RayTracingSource> &RadiationModel::getSourceData() {
6523 return source_data;
6524}
6525
6526
6527void RadiationModel::testBuildAllBackendData() {
6528 buildGeometryData(context->getAllUUIDs());
6529 buildMaterialData();
6530 buildSourceData();
6531}