1.3.72
 
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
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 if (Ncameras > 0) {
2032 size_t cam_size = (size_t)Nsources * Nprimitives * Nbands * Ncameras;
2033 material_data.reflectivity_cam.assign(cam_size, rho_default);
2034 material_data.transmissivity_cam.assign(cam_size, tau_default);
2035 } else {
2036 material_data.reflectivity_cam.clear();
2037 material_data.transmissivity_cam.clear();
2038 }
2039
2040 MaterialPropertyIndexer mat_idx(Nsources, Nprimitives, Nbands);
2041 CameraMaterialIndexer cam_idx(Nsources, Nprimitives, Nbands, Ncameras);
2042
2043 // Cache all unique camera spectral responses for all cameras and bands
2044 std::vector<std::vector<std::vector<helios::vec2>>> camera_response_unique;
2045 camera_response_unique.resize(Ncameras);
2046 if (Ncameras > 0) {
2047 uint cam = 0;
2048 for (const auto &camera: cameras) {
2049
2050 camera_response_unique.at(cam).resize(Nbands);
2051
2052 for (uint b = 0; b < Nbands; b++) {
2053
2054 if (camera.second.band_spectral_response.find(band_labels.at(b)) == camera.second.band_spectral_response.end()) {
2055 continue;
2056 }
2057
2058 std::string camera_response = camera.second.band_spectral_response.at(band_labels.at(b));
2059
2060 if (!camera_response.empty()) {
2061
2062 if (!context->doesGlobalDataExist(camera_response.c_str())) {
2063 if (camera_response != "uniform") {
2064 warnings.addWarning("missing_camera_response", "Camera spectral response \"" + camera_response + "\" does not exist. Assuming a uniform spectral response.");
2065 }
2066 } else if (context->getGlobalDataType(camera_response.c_str()) == helios::HELIOS_TYPE_VEC2) {
2067
2068 std::vector<helios::vec2> data = loadSpectralData(camera_response.c_str());
2069
2070 camera_response_unique.at(cam).at(b) = data;
2071
2072 } else if (context->getGlobalDataType(camera_response.c_str()) != helios::HELIOS_TYPE_VEC2 && context->getGlobalDataType(camera_response.c_str()) != helios::HELIOS_TYPE_STRING) {
2073 camera_response.clear();
2074 std::cout << "WARNING (RadiationModel::runBand): Camera spectral response \"" << camera_response << "\" is not of type HELIOS_TYPE_VEC2 or HELIOS_TYPE_STRING. Assuming a uniform spectral response..." << std::endl;
2075 }
2076 }
2077 }
2078 cam++;
2079 }
2080 }
2081
2082 // Spectral integration cache to avoid redundant computations
2083 std::unordered_map<std::string, float> spectral_integration_cache;
2084
2085#ifdef USE_OPENMP
2086 // Temporary cache for this thread group (will be merged later)
2087 std::unordered_map<std::string, float> temp_spectral_cache;
2088#endif
2089
2090 // Helper function to create cache keys for spectral integrations
2091 auto createCacheKey = [](const std::string &spectrum_label, uint source_id, uint band_id, uint camera_id, const std::string &type) -> std::string {
2092 return spectrum_label + "_" + std::to_string(source_id) + "_" + std::to_string(band_id) + "_" + std::to_string(camera_id) + "_" + type;
2093 };
2094
2095 // Helper function to get from cache (thread-safe)
2096 auto getCachedValue = [&](const std::string &cache_key, bool &found) -> float {
2097 float result = 0.0f;
2098 found = false;
2099
2100#ifdef USE_OPENMP
2101#pragma omp critical
2102 {
2103#endif
2104 // Check shared cache
2105 auto cache_it = spectral_integration_cache.find(cache_key);
2106 if (cache_it != spectral_integration_cache.end()) {
2107 found = true;
2108 result = cache_it->second;
2109 }
2110#ifdef USE_OPENMP
2111 }
2112#endif
2113 return result;
2114 };
2115
2116 // Helper function to store in cache (thread-safe)
2117 auto setCachedValue = [&](const std::string &cache_key, float value) {
2118#ifdef USE_OPENMP
2119#pragma omp critical
2120 {
2121#endif
2122 spectral_integration_cache[cache_key] = value;
2123#ifdef USE_OPENMP
2124 }
2125#endif
2126 };
2127
2128 // Helper function for cached interpolation (thread-safe)
2129 auto cachedInterp1 = [&](const std::vector<helios::vec2> &spectrum, float wavelength, const std::string &spectrum_id) -> float {
2130 // Create cache key for this specific interpolation
2131 std::string cache_key = "interp_" + spectrum_id + "_" + std::to_string(wavelength);
2132
2133 bool found = false;
2134 float cached_result = getCachedValue(cache_key, found);
2135 if (found) {
2136 return cached_result;
2137 }
2138
2139 // Perform interpolation and cache result
2140 float result = interp1(spectrum, wavelength);
2141 setCachedValue(cache_key, result);
2142 return result;
2143 };
2144
2145 // Cached version of integrateSpectrum with source spectrum
2146 auto cachedIntegrateSpectrumWithSource = [&](uint source_ID, const std::vector<helios::vec2> &object_spectrum, float wavelength1, float wavelength2, const std::string &object_spectrum_id) -> float {
2147 if (source_ID >= radiation_sources.size() || object_spectrum.size() < 2 || wavelength1 >= wavelength2) {
2148 return 0.0f; // Handle edge cases gracefully
2149 }
2150
2151 std::vector<helios::vec2> source_spectrum = radiation_sources.at(source_ID).source_spectrum;
2152 std::string source_id = "source_" + std::to_string(source_ID);
2153
2154 int istart = 0;
2155 int iend = (int) object_spectrum.size() - 1;
2156 for (auto i = 0; i < object_spectrum.size() - 1; i++) {
2157 if (object_spectrum.at(i).x <= wavelength1 && object_spectrum.at(i + 1).x > wavelength1) {
2158 istart = i;
2159 }
2160 if (object_spectrum.at(i).x <= wavelength2 && object_spectrum.at(i + 1).x > wavelength2) {
2161 iend = i + 1;
2162 break;
2163 }
2164 }
2165
2166 float E = 0;
2167 float Etot = 0;
2168 for (auto i = istart; i < iend; i++) {
2169 float x0 = object_spectrum.at(i).x;
2170 float Esource0 = cachedInterp1(source_spectrum, x0, source_id);
2171 float Eobject0 = object_spectrum.at(i).y;
2172
2173 float x1 = object_spectrum.at(i + 1).x;
2174 float Eobject1 = object_spectrum.at(i + 1).y;
2175 float Esource1 = cachedInterp1(source_spectrum, x1, source_id);
2176
2177 E += 0.5f * (Eobject0 * Esource0 + Eobject1 * Esource1) * (x1 - x0);
2178 Etot += 0.5f * (Esource1 + Esource0) * (x1 - x0);
2179 }
2180
2181 return (Etot != 0.0f) ? E / Etot : 0.0f;
2182 };
2183
2184 // Cached version of integrateSpectrum with source and camera spectra
2185 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,
2186 const std::string &object_spectrum_id) -> float {
2187 if (source_ID >= radiation_sources.size() || object_spectrum.size() < 2) {
2188 return 0.0f;
2189 }
2190
2191 std::vector<helios::vec2> source_spectrum = radiation_sources.at(source_ID).source_spectrum;
2192 std::string source_id = "source_" + std::to_string(source_ID);
2193 std::string camera_id = "camera_" + std::to_string(camera_index) + "_band_" + std::to_string(band_index); // Include band for unique cache key per band
2194
2195 float E = 0;
2196 float Etot = 0;
2197 for (auto i = 1; i < object_spectrum.size(); i++) {
2198 if (object_spectrum.at(i).x <= source_spectrum.front().x || object_spectrum.at(i).x <= camera_spectrum.front().x) {
2199 continue;
2200 }
2201 if (object_spectrum.at(i).x > source_spectrum.back().x || object_spectrum.at(i).x > camera_spectrum.back().x) {
2202 break;
2203 }
2204
2205 float x1 = object_spectrum.at(i).x;
2206 float Eobject1 = object_spectrum.at(i).y;
2207 float Esource1 = cachedInterp1(source_spectrum, x1, source_id);
2208 float Ecamera1 = cachedInterp1(camera_spectrum, x1, camera_id);
2209
2210 float x0 = object_spectrum.at(i - 1).x;
2211 float Eobject0 = object_spectrum.at(i - 1).y;
2212 float Esource0 = cachedInterp1(source_spectrum, x0, source_id);
2213 float Ecamera0 = cachedInterp1(camera_spectrum, x0, camera_id);
2214
2215 E += 0.5f * ((Eobject1 * Esource1 * Ecamera1) + (Eobject0 * Ecamera0 * Esource0)) * (x1 - x0);
2216 Etot += 0.5f * (Esource1 + Esource0) * (x1 - x0);
2217 }
2218
2219 return (Etot != 0.0f) ? E / Etot : 0.0f;
2220 };
2221
2222 // Apply spectral interpolation based on primitive data values
2223 for (const auto &config: spectrum_interpolation_configs) {
2224 // Validate that all spectra in this config exist in global data and have correct type
2225 for (const auto &spectrum_label: config.spectra_labels) {
2226 if (!context->doesGlobalDataExist(spectrum_label.c_str())) {
2227 helios_runtime_error("ERROR (RadiationModel::updateRadiativeProperties): Spectral interpolation config references global data '" + spectrum_label + "' which does not exist.");
2228 }
2229 if (context->getGlobalDataType(spectrum_label.c_str()) != helios::HELIOS_TYPE_VEC2) {
2230 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>).");
2231 }
2232 }
2233
2234 for (uint uuid: config.primitive_UUIDs) {
2235 // Check if primitive still exists in context (it may have been deleted)
2236 if (!context->doesPrimitiveExist(uuid)) {
2237 continue;
2238 }
2239
2240 // Check if the query data exists for this primitive and has correct type
2241 if (context->doesPrimitiveDataExist(uuid, config.query_data_label.c_str())) {
2242 // Check that query data is of type float
2243 if (context->getPrimitiveDataType(config.query_data_label.c_str()) != helios::HELIOS_TYPE_FLOAT) {
2244 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.");
2245 }
2246
2247 // Get the query value
2248 float query_value;
2249 context->getPrimitiveData(uuid, config.query_data_label.c_str(), query_value);
2250
2251 // Perform nearest-neighbor interpolation
2252 size_t nearest_idx = 0;
2253 float min_distance = std::abs(query_value - config.mapping_values[0]);
2254 for (size_t i = 1; i < config.mapping_values.size(); i++) {
2255 float distance = std::abs(query_value - config.mapping_values[i]);
2256 if (distance < min_distance) {
2257 min_distance = distance;
2258 nearest_idx = i;
2259 }
2260 }
2261
2262 // Set the target primitive data to the selected spectrum label
2263 context->setPrimitiveData(uuid, config.target_data_label.c_str(), config.spectra_labels[nearest_idx]);
2264 }
2265 }
2266
2267 // Apply spectral interpolation based on object data values
2268 for (uint objID: config.object_IDs) {
2269 // Check if object still exists in context (it may have been deleted)
2270 if (!context->doesObjectExist(objID)) {
2271 continue;
2272 }
2273
2274 // Check if the query data exists for this object and has correct type
2275 if (context->doesObjectDataExist(objID, config.query_data_label.c_str())) {
2276 // Check that query data is of type float
2277 if (context->getObjectDataType(config.query_data_label.c_str()) != helios::HELIOS_TYPE_FLOAT) {
2278 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.");
2279 }
2280
2281 // Get the query value
2282 float query_value;
2283 context->getObjectData(objID, config.query_data_label.c_str(), query_value);
2284
2285 // Perform nearest-neighbor interpolation
2286 size_t nearest_idx = 0;
2287 float min_distance = std::abs(query_value - config.mapping_values.at(0));
2288 for (size_t i = 1; i < config.mapping_values.size(); i++) {
2289 float distance = std::abs(query_value - config.mapping_values.at(i));
2290 if (distance < min_distance) {
2291 min_distance = distance;
2292 nearest_idx = i;
2293 }
2294 }
2295
2296 // Get object's primitive UUIDs and set their primitive data using vector overload
2297 std::vector<uint> prim_uuids = context->getObjectPrimitiveUUIDs(objID);
2298 context->setPrimitiveData(prim_uuids, config.target_data_label.c_str(), config.spectra_labels.at(nearest_idx));
2299 }
2300 }
2301 }
2302
2303 // Cache all unique primitive reflectivity and transmissivity spectra before assigning to primitives
2304
2305 // first, figure out all of the spectra referenced by all primitives and store it in "surface_spectra" to avoid having to load it again
2306 std::map<std::string, std::vector<helios::vec2>> surface_spectra_rho;
2307 std::map<std::string, std::vector<helios::vec2>> surface_spectra_tau;
2308 for (size_t u = 0; u < Nprimitives; u++) {
2309
2310 uint UUID = context_UUIDs.at(u);
2311
2312 if (context->doesPrimitiveDataExist(UUID, "reflectivity_spectrum")) {
2313 if (context->getPrimitiveDataType("reflectivity_spectrum") == HELIOS_TYPE_STRING) {
2314 std::string spectrum_label;
2315 context->getPrimitiveData(UUID, "reflectivity_spectrum", spectrum_label);
2316
2317 // get the spectral reflectivity data and store it in surface_spectra to avoid having to load it again
2318 if (surface_spectra_rho.find(spectrum_label) == surface_spectra_rho.end()) {
2319 if (!context->doesGlobalDataExist(spectrum_label.c_str())) {
2320 if (!spectrum_label.empty()) {
2321 warnings.addWarning("missing_reflectivity_spectrum", "Primitive spectral reflectivity \"" + spectrum_label + "\" does not exist. Using default reflectivity of 0.");
2322 }
2323 std::vector<helios::vec2> data;
2324 surface_spectra_rho.emplace(spectrum_label, data);
2325 } else if (context->getGlobalDataType(spectrum_label.c_str()) == HELIOS_TYPE_VEC2) {
2326
2327 std::vector<helios::vec2> data = loadSpectralData(spectrum_label.c_str());
2328 surface_spectra_rho.emplace(spectrum_label, data);
2329
2330 } else if (context->getGlobalDataType(spectrum_label.c_str()) != helios::HELIOS_TYPE_VEC2 && context->getGlobalDataType(spectrum_label.c_str()) != helios::HELIOS_TYPE_STRING) {
2331 spectrum_label.clear();
2332 std::cout << "WARNING (RadiationModel::runBand): Object spectral reflectivity \"" << spectrum_label << "\" is not of type HELIOS_TYPE_VEC2 or HELIOS_TYPE_STRING. Assuming a uniform spectral distribution..." << std::flush;
2333 }
2334 }
2335 }
2336 }
2337
2338 if (context->doesPrimitiveDataExist(UUID, "transmissivity_spectrum")) {
2339 if (context->getPrimitiveDataType("transmissivity_spectrum") == HELIOS_TYPE_STRING) {
2340 std::string spectrum_label;
2341 context->getPrimitiveData(UUID, "transmissivity_spectrum", spectrum_label);
2342
2343 // get the spectral transmissivity data and store it in surface_spectra to avoid having to load it again
2344 if (surface_spectra_tau.find(spectrum_label) == surface_spectra_tau.end()) {
2345 if (!context->doesGlobalDataExist(spectrum_label.c_str())) {
2346 if (!spectrum_label.empty()) {
2347 warnings.addWarning("missing_transmissivity_spectrum", "Primitive spectral transmissivity \"" + spectrum_label + "\" does not exist. Using default transmissivity of 0.");
2348 }
2349 std::vector<helios::vec2> data;
2350 surface_spectra_tau.emplace(spectrum_label, data);
2351 } else if (context->getGlobalDataType(spectrum_label.c_str()) == HELIOS_TYPE_VEC2) {
2352
2353 std::vector<helios::vec2> data = loadSpectralData(spectrum_label.c_str());
2354 surface_spectra_tau.emplace(spectrum_label, data);
2355
2356 } else if (context->getGlobalDataType(spectrum_label.c_str()) != helios::HELIOS_TYPE_VEC2 && context->getGlobalDataType(spectrum_label.c_str()) != helios::HELIOS_TYPE_STRING) {
2357 spectrum_label.clear();
2358 std::cout << "WARNING (RadiationModel::runBand): Object spectral transmissivity \"" << spectrum_label << "\" is not of type HELIOS_TYPE_VEC2 or HELIOS_TYPE_STRING. Assuming a uniform spectral distribution..." << std::flush;
2359 }
2360 }
2361 }
2362 }
2363 }
2364
2365 // second, calculate unique values of rho and tau for all sources and bands
2366 std::map<std::string, std::vector<std::vector<float>>> rho_unique;
2367 std::map<std::string, std::vector<std::vector<float>>> tau_unique;
2368
2369 std::map<std::string, std::vector<std::vector<std::vector<float>>>> rho_cam_unique;
2370 std::map<std::string, std::vector<std::vector<std::vector<float>>>> tau_cam_unique;
2371
2372 std::vector<std::vector<float>> empty;
2373 empty.resize(Nbands);
2374 for (uint b = 0; b < Nbands; b++) {
2375 empty.at(b).resize(Nsources, 0);
2376 }
2377 std::vector<std::vector<std::vector<float>>> empty_cam;
2378 if (Ncameras > 0) {
2379 empty_cam.resize(Nbands);
2380 for (uint b = 0; b < Nbands; b++) {
2381 empty_cam.at(b).resize(Nsources);
2382 for (uint s = 0; s < Nsources; s++) {
2383 empty_cam.at(b).at(s).resize(Ncameras, 0);
2384 }
2385 }
2386 }
2387
2388 // Convert maps to vectors for OpenMP indexing
2389 std::vector<std::pair<std::string, std::vector<helios::vec2>>> spectra_rho_vector(surface_spectra_rho.begin(), surface_spectra_rho.end());
2390
2391 // Pre-initialize all map entries before parallel processing to avoid race conditions
2392 for (const auto &spectrum: spectra_rho_vector) {
2393 rho_unique[spectrum.first] = empty;
2394 if (Ncameras > 0) {
2395 rho_cam_unique[spectrum.first] = empty_cam;
2396 }
2397 }
2398
2399 // Process reflectivity spectra with OpenMP parallelization
2400#ifdef USE_OPENMP
2401#pragma omp parallel for schedule(dynamic)
2402#endif
2403 for (int spectrum_idx = 0; spectrum_idx < (int) spectra_rho_vector.size(); spectrum_idx++) {
2404 const auto &spectrum = spectra_rho_vector[spectrum_idx];
2405
2406 for (uint b = 0; b < Nbands; b++) {
2407 std::string band = band_labels.at(b);
2408
2409 for (uint s = 0; s < Nsources; s++) {
2410
2411 // integrate with caching
2412 auto band_it = radiation_bands.find(band);
2413 if (band_it != radiation_bands.end() && band_it->second.wavebandBounds.x != 0 && band_it->second.wavebandBounds.y != 0 && !spectrum.second.empty()) {
2414 if (!radiation_sources.at(s).source_spectrum.empty()) {
2415 std::string cache_key = createCacheKey(spectrum.first, s, b, 0, "rho_source");
2416 bool found;
2417 float cached_result = getCachedValue(cache_key, found);
2418 if (found) {
2419 rho_unique[spectrum.first][b][s] = cached_result;
2420 } else {
2421 float result = cachedIntegrateSpectrumWithSource(s, spectrum.second, band_it->second.wavebandBounds.x, band_it->second.wavebandBounds.y, spectrum.first);
2422 setCachedValue(cache_key, result);
2423 rho_unique[spectrum.first][b][s] = result;
2424 }
2425 } else {
2426 // source spectrum not provided, assume source intensity is constant over the band
2427 std::string cache_key = createCacheKey(spectrum.first, s, b, 0, "rho_no_source");
2428 bool found;
2429 float cached_result = getCachedValue(cache_key, found);
2430 if (found) {
2431 rho_unique[spectrum.first][b][s] = cached_result;
2432 } else {
2433 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);
2434 setCachedValue(cache_key, result);
2435 rho_unique[spectrum.first][b][s] = result;
2436 }
2437 }
2438 } else {
2439 // No wavelength bounds, can't integrate spectrum without camera response
2440 // Set to default for now, will use camera average if available
2441 rho_unique[spectrum.first][b][s] = rho_default;
2442 }
2443
2444 // cameras
2445 if (Ncameras > 0) {
2446 uint cam = 0;
2447 float rho_cam_sum_for_averaging = 0.f;
2448 for (const auto &camera: cameras) {
2449
2450 if (camera_response_unique.at(cam).at(b).empty()) {
2451 rho_cam_unique[spectrum.first][b][s][cam] = rho_unique[spectrum.first][b][s];
2452 } else {
2453
2454 // integrate with caching
2455 if (!spectrum.second.empty()) {
2456 if (!radiation_sources.at(s).source_spectrum.empty()) {
2457 std::string cache_key = createCacheKey(spectrum.first, s, b, cam, "rho_cam_source");
2458 bool found;
2459 float cached_result = getCachedValue(cache_key, found);
2460 if (found) {
2461 rho_cam_unique.at(spectrum.first).at(b).at(s).at(cam) = cached_result;
2462 rho_cam_sum_for_averaging += cached_result;
2463 } else {
2464 float result = cachedIntegrateSpectrumWithSourceAndCamera(s, spectrum.second, camera_response_unique.at(cam).at(b), cam, b, spectrum.first);
2465 setCachedValue(cache_key, result);
2466 rho_cam_unique.at(spectrum.first).at(b).at(s).at(cam) = result;
2467 rho_cam_sum_for_averaging += result;
2468 }
2469 } else {
2470 std::string cache_key = createCacheKey(spectrum.first, s, b, cam, "rho_cam_no_source");
2471 bool found;
2472 float cached_result = getCachedValue(cache_key, found);
2473 if (found) {
2474 rho_cam_unique.at(spectrum.first).at(b).at(s).at(cam) = cached_result;
2475 rho_cam_sum_for_averaging += cached_result;
2476 } else {
2477 float result = integrateSpectrum(spectrum.second, camera_response_unique.at(cam).at(b));
2478 setCachedValue(cache_key, result);
2479 rho_cam_unique.at(spectrum.first).at(b).at(s).at(cam) = result;
2480 rho_cam_sum_for_averaging += result;
2481 }
2482 }
2483 } else {
2484 rho_cam_unique.at(spectrum.first).at(b).at(s).at(cam) = rho_default;
2485 }
2486 }
2487
2488 cam++;
2489 }
2490
2491 // CRITICAL FIX: If wavelength bounds weren't set but camera integration produced values,
2492 // use camera average as the base reflectivity. This allows regular scatter to work
2493 // when only reflectivity_spectrum + camera response are provided.
2494 if (rho_unique[spectrum.first][b][s] == rho_default && rho_cam_sum_for_averaging > 0 && cam > 0) {
2495 rho_unique[spectrum.first][b][s] = rho_cam_sum_for_averaging / float(cam);
2496 }
2497 }
2498 }
2499 }
2500 }
2501
2502 // Convert tau spectra to vector for OpenMP indexing
2503 std::vector<std::pair<std::string, std::vector<helios::vec2>>> spectra_tau_vector(surface_spectra_tau.begin(), surface_spectra_tau.end());
2504
2505 // Pre-initialize all map entries before parallel processing to avoid race conditions
2506 for (const auto &spectrum: spectra_tau_vector) {
2507 tau_unique[spectrum.first] = empty;
2508 if (Ncameras > 0) {
2509 tau_cam_unique[spectrum.first] = empty_cam;
2510 }
2511 }
2512
2513 // Process transmissivity spectra with OpenMP parallelization
2514#ifdef USE_OPENMP
2515#pragma omp parallel for schedule(dynamic)
2516#endif
2517 for (int spectrum_idx = 0; spectrum_idx < (int) spectra_tau_vector.size(); spectrum_idx++) {
2518 const auto &spectrum = spectra_tau_vector[spectrum_idx];
2519
2520 for (uint b = 0; b < Nbands; b++) {
2521 std::string band = band_labels.at(b);
2522
2523 for (uint s = 0; s < Nsources; s++) {
2524
2525 // integrate with caching
2526 auto band_it = radiation_bands.find(band);
2527 if (band_it != radiation_bands.end() && band_it->second.wavebandBounds.x != 0 && band_it->second.wavebandBounds.y != 0 && !spectrum.second.empty()) {
2528 if (!radiation_sources.at(s).source_spectrum.empty()) {
2529 std::string cache_key = createCacheKey(spectrum.first, s, b, 0, "tau_source");
2530 bool found;
2531 float cached_result = getCachedValue(cache_key, found);
2532 if (found) {
2533 tau_unique[spectrum.first][b][s] = cached_result;
2534 } else {
2535 float result = cachedIntegrateSpectrumWithSource(s, spectrum.second, band_it->second.wavebandBounds.x, band_it->second.wavebandBounds.y, spectrum.first);
2536 setCachedValue(cache_key, result);
2537 tau_unique[spectrum.first][b][s] = result;
2538 }
2539 } else {
2540 std::string cache_key = createCacheKey(spectrum.first, s, b, 0, "tau_no_source");
2541 bool found;
2542 float cached_result = getCachedValue(cache_key, found);
2543 if (found) {
2544 tau_unique[spectrum.first][b][s] = cached_result;
2545 } else {
2546 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);
2547 setCachedValue(cache_key, result);
2548 tau_unique[spectrum.first][b][s] = result;
2549 }
2550 }
2551 } else {
2552 tau_unique[spectrum.first][b][s] = tau_default;
2553 }
2554
2555 // cameras
2556 if (Ncameras > 0) {
2557 uint cam = 0;
2558 for (const auto &camera: cameras) {
2559
2560 if (camera_response_unique.at(cam).at(b).empty()) {
2561
2562 tau_cam_unique[spectrum.first][b][s][cam] = tau_unique[spectrum.first][b][s];
2563
2564 } else {
2565
2566 // integrate with caching
2567 if (!spectrum.second.empty()) {
2568 if (!radiation_sources.at(s).source_spectrum.empty()) {
2569 std::string cache_key = createCacheKey(spectrum.first, s, b, cam, "tau_cam_source");
2570 bool found;
2571 float cached_result = getCachedValue(cache_key, found);
2572 if (found) {
2573 tau_cam_unique.at(spectrum.first).at(b).at(s).at(cam) = cached_result;
2574 } else {
2575 float result = cachedIntegrateSpectrumWithSourceAndCamera(s, spectrum.second, camera_response_unique.at(cam).at(b), cam, b, spectrum.first);
2576 setCachedValue(cache_key, result);
2577 tau_cam_unique.at(spectrum.first).at(b).at(s).at(cam) = result;
2578 }
2579 } else {
2580 std::string cache_key = createCacheKey(spectrum.first, s, b, cam, "tau_cam_no_source");
2581 bool found;
2582 float cached_result = getCachedValue(cache_key, found);
2583 if (found) {
2584 tau_cam_unique.at(spectrum.first).at(b).at(s).at(cam) = cached_result;
2585 } else {
2586 float result = integrateSpectrum(spectrum.second, camera_response_unique.at(cam).at(b));
2587 setCachedValue(cache_key, result);
2588 tau_cam_unique.at(spectrum.first).at(b).at(s).at(cam) = result;
2589 }
2590 }
2591 } else {
2592 tau_cam_unique.at(spectrum.first).at(b).at(s).at(cam) = tau_default;
2593 }
2594 }
2595
2596 cam++;
2597 }
2598 }
2599 }
2600 }
2601 }
2602
2603 for (size_t u = 0; u < Nprimitives; u++) {
2604
2605 uint UUID = context_UUIDs.at(u);
2606
2607 helios::PrimitiveType type = context->getPrimitiveType(UUID);
2608
2609 if (type == helios::PRIMITIVE_TYPE_VOXEL) {
2610
2611 } else { // other than voxels
2612
2613 // Reflectivity
2614
2615 // check for primitive data of form "reflectivity_spectrum" that can be used to calculate reflectivity
2616 std::string spectrum_label;
2617 if (context->doesPrimitiveDataExist(UUID, "reflectivity_spectrum")) {
2618 if (context->getPrimitiveDataType("reflectivity_spectrum") == HELIOS_TYPE_STRING) {
2619 context->getPrimitiveData(UUID, "reflectivity_spectrum", spectrum_label);
2620 }
2621 }
2622
2623 uint b = 0;
2624 for (const auto &band: band_labels) {
2625
2626 // check for primitive data of form "reflectivity_bandname"
2627 prop = "reflectivity_" + band;
2628
2629 float rho_s = rho_default;
2630 if (context->doesPrimitiveDataExist(UUID, prop.c_str())) {
2631 context->getPrimitiveData(UUID, prop.c_str(), rho_s);
2632 }
2633
2634 for (uint s = 0; s < Nsources; s++) {
2635 float &rho_val = material_data.reflectivity[mat_idx(s, u, b)];
2636
2637 // if reflectivity was manually set, or a spectrum was given and the global data exists
2638 if (rho_s != rho_default || spectrum_label.empty() || !context->doesGlobalDataExist(spectrum_label.c_str()) || rho_unique.find(spectrum_label) == rho_unique.end()) {
2639
2640 rho_val = rho_s;
2641
2642 // cameras
2643 for (uint cam = 0; cam < Ncameras; cam++) {
2644 material_data.reflectivity_cam[cam_idx(s, u, b, cam)] = rho_s;
2645 }
2646
2647 // use spectrum
2648 } else {
2649
2650 rho_val = rho_unique.at(spectrum_label).at(b).at(s);
2651
2652 // cameras
2653 for (uint cam = 0; cam < Ncameras; cam++) {
2654 material_data.reflectivity_cam[cam_idx(s, u, b, cam)] = rho_cam_unique.at(spectrum_label).at(b).at(s).at(cam);
2655 }
2656 }
2657
2658 // error checking
2659 if (rho_val < 0) {
2660 rho_val = 0.f;
2661 warnings.addWarning("reflectivity_negative_clamped", "Reflectivity cannot be less than 0. Clamping to 0 for band " + band + ".");
2662 } else if (rho_val > 1.f) {
2663 rho_val = 1.f;
2664 warnings.addWarning("reflectivity_exceeded_clamped", "Reflectivity cannot be greater than 1. Clamping to 1 for band " + band + ".");
2665 }
2666 if (rho_val != 0) {
2667 scattering_iterations_needed.at(band) = true;
2668 }
2669 for (auto &odata: output_prim_data) {
2670 if (odata == "reflectivity") {
2671 context->setPrimitiveData(UUID, ("reflectivity_" + std::to_string(s) + "_" + band).c_str(), rho_val);
2672 }
2673 }
2674 }
2675 b++;
2676 }
2677
2678 // Transmissivity
2679
2680 // check for primitive data of form "transmissivity_spectrum" that can be used to calculate transmissivity
2681 spectrum_label.resize(0);
2682 if (context->doesPrimitiveDataExist(UUID, "transmissivity_spectrum")) {
2683 if (context->getPrimitiveDataType("transmissivity_spectrum") == HELIOS_TYPE_STRING) {
2684 context->getPrimitiveData(UUID, "transmissivity_spectrum", spectrum_label);
2685 }
2686 }
2687
2688 b = 0;
2689 for (const auto &band: band_labels) {
2690
2691 // check for primitive data of form "transmissivity_bandname"
2692 prop = "transmissivity_" + band;
2693
2694 float tau_s = tau_default;
2695 if (context->doesPrimitiveDataExist(UUID, prop.c_str())) {
2696 context->getPrimitiveData(UUID, prop.c_str(), tau_s);
2697 }
2698
2699 for (uint s = 0; s < Nsources; s++) {
2700 float &tau_val = material_data.transmissivity[mat_idx(s, u, b)];
2701
2702 // if transmissivity was manually set, or a spectrum was given and the global data exists
2703 if (tau_s != tau_default || spectrum_label.empty() || !context->doesGlobalDataExist(spectrum_label.c_str()) || tau_unique.find(spectrum_label) == tau_unique.end()) {
2704
2705 tau_val = tau_s;
2706
2707 // cameras
2708 for (uint cam = 0; cam < Ncameras; cam++) {
2709 material_data.transmissivity_cam[cam_idx(s, u, b, cam)] = tau_s;
2710 }
2711
2712 } else {
2713
2714 tau_val = tau_unique.at(spectrum_label).at(b).at(s);
2715
2716 // cameras
2717 for (uint cam = 0; cam < Ncameras; cam++) {
2718 material_data.transmissivity_cam[cam_idx(s, u, b, cam)] = tau_cam_unique.at(spectrum_label).at(b).at(s).at(cam);
2719 }
2720 }
2721
2722 // error checking
2723 if (tau_val < 0) {
2724 tau_val = 0.f;
2725 warnings.addWarning("transmissivity_negative_clamped", "Transmissivity cannot be less than 0. Clamping to 0 for band " + band + ".");
2726 } else if (tau_val > 1.f) {
2727 tau_val = 1.f;
2728 warnings.addWarning("transmissivity_exceeded_clamped", "Transmissivity cannot be greater than 1. Clamping to 1 for band " + band + ".");
2729 }
2730 if (tau_val != 0) {
2731 scattering_iterations_needed.at(band) = true;
2732 }
2733 for (auto &odata: output_prim_data) {
2734 if (odata == "transmissivity") {
2735 context->setPrimitiveData(UUID, ("transmissivity_" + std::to_string(s) + "_" + band).c_str(), tau_val);
2736 }
2737 }
2738 }
2739 b++;
2740 }
2741
2742 // Emissivity (only for error checking)
2743
2744 b = 0;
2745 for (const auto &band: band_labels) {
2746
2747 prop = "emissivity_" + band;
2748
2749 if (context->doesPrimitiveDataExist(UUID, prop.c_str())) {
2750 context->getPrimitiveData(UUID, prop.c_str(), eps);
2751 } else {
2752 eps = eps_default;
2753 }
2754
2755 if (eps < 0) {
2756 eps = 0.f;
2757 warnings.addWarning("emissivity_negative_clamped", "Emissivity cannot be less than 0. Clamping to 0 for band " + band + ".");
2758 } else if (eps > 1.f) {
2759 eps = 1.f;
2760 warnings.addWarning("emissivity_exceeded_clamped", "Emissivity cannot be greater than 1. Clamping to 1 for band " + band + ".");
2761 }
2762 if (eps != 1) {
2763 scattering_iterations_needed.at(band) = true;
2764 }
2765
2766 assert(doesBandExist(band));
2767
2768 const bool is_sif_band = sif_emission_bands.count(band) > 0;
2769
2770 for (uint s = 0; s < Nsources; s++) {
2771 float &rho_val = material_data.reflectivity[mat_idx(s, u, b)];
2772 float &tau_val = material_data.transmissivity[mat_idx(s, u, b)];
2773
2774 if (is_sif_band) {
2775 // SIF bands source their emission from the Fluspect-B per-leaf
2776 // kernel (see computeSIFEmission), not from epsilon*sigma*T^4.
2777 // Epsilon on a SIF band is therefore irrelevant — the Stefan-
2778 // Boltzmann ε+ρ+τ=1 conservation constraint does not apply. We
2779 // still enforce rho+tau ≤ 1 (physically required regardless of
2780 // the emission mechanism).
2781 if (tau_val + rho_val > 1.f) {
2782 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) +
2783 ": tau=" + std::to_string(tau_val) + ", rho=" + std::to_string(rho_val) + ".");
2784 }
2785 } else if (radiation_bands.at(band).emissionFlag) { // emission enabled
2786 if (eps != 1.f && rho_val == 0 && tau_val == 0) {
2787 rho_val = 1.f - eps;
2788 } else if (eps + tau_val + rho_val != 1.f && eps > 0.f) {
2789 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=" +
2790 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.");
2791 } else if (radiation_bands.at(band).scatteringDepth == 0 && eps != 1.f) {
2792 eps = 1.f;
2793 rho_val = 0.f;
2794 tau_val = 0.f;
2795 }
2796 } else if (tau_val + rho_val > 1.f) {
2797 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) +
2798 ", 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.");
2799 }
2800 }
2801 b++;
2802 }
2803 }
2804 }
2805
2806 // Specular reflection properties
2807 material_data.specular_exponent.resize(Nprimitives, -1.f);
2808 material_data.specular_scale.resize(Nprimitives, 0.f);
2809
2810 bool specular_exponent_specified = false;
2811 bool specular_scale_specified = false;
2812
2813 for (size_t u = 0; u < Nprimitives; u++) {
2814 uint UUID = context_UUIDs.at(u);
2815
2816 if (context->doesPrimitiveDataExist(UUID, "specular_exponent") && context->getPrimitiveDataType("specular_exponent") == HELIOS_TYPE_FLOAT) {
2817 context->getPrimitiveData(UUID, "specular_exponent", material_data.specular_exponent.at(u));
2818 if (material_data.specular_exponent.at(u) >= 0.f) {
2819 specular_exponent_specified = true;
2820 }
2821 }
2822
2823 if (context->doesPrimitiveDataExist(UUID, "specular_scale") && context->getPrimitiveDataType("specular_scale") == HELIOS_TYPE_FLOAT) {
2824 context->getPrimitiveData(UUID, "specular_scale", material_data.specular_scale.at(u));
2825 if (material_data.specular_scale.at(u) > 0.f) {
2826 specular_scale_specified = true;
2827 }
2828 }
2829 }
2830
2831 // Auto-enable specular reflection if specular properties are specified on any primitive
2832 if (specular_exponent_specified) {
2833 if (specular_scale_specified) {
2834 specular_reflection_mode = 2; // Mode 2: use primitive specular_scale
2835 } else {
2836 specular_reflection_mode = 1; // Mode 1: use default 0.25 scale
2837 }
2838 } else {
2839 specular_reflection_mode = 0; // Disabled
2840 }
2841
2842 backend->updateMaterials(material_data);
2843
2844 radiativepropertiesneedupdate = false;
2845
2846 if (message_flag) {
2847 std::cout << "done\n";
2848 }
2849
2850 // Report aggregated warnings
2851 warnings.report(std::cerr);
2852}
2853
2854std::vector<float> RadiationModel::updateAtmosphericSkyModel(const std::vector<std::string> &band_labels, const RadiationCamera &camera) {
2855 // Prague Sky Model implementation for atmospheric sky radiance
2856 // Uses validated spectral radiance from brute-force atmospheric simulations
2857 // (Wilkie et al. 2021, Vévoda et al. 2022)
2858
2859 size_t Nbands_launch = band_labels.size();
2860 std::vector<float> sky_base_radiances(Nbands_launch, 0.0f);
2861
2862 // Only run atmospheric sky model if user has explicitly enabled it by setting atmospheric parameters
2863 // This prevents the model from running with default values in tests/scripts that don't want it
2864 bool has_atmospheric_data =
2865 context->doesGlobalDataExist("atmosphere_pressure_Pa") || context->doesGlobalDataExist("atmosphere_temperature_K") || context->doesGlobalDataExist("atmosphere_humidity_rel") || context->doesGlobalDataExist("atmosphere_turbidity");
2866
2867 if (!has_atmospheric_data) {
2868 // No atmospheric parameters set - return zeros (camera will use user-set diffuse flux or 0)
2869 return sky_base_radiances;
2870 }
2871
2872 // Read atmospheric parameters from Context global data (set by SolarPosition plugin)
2873 // Default values match SolarPosition::getAtmosphericConditions() defaults
2874 float pressure_Pa = 101325.f; // Standard atmosphere (1 atm)
2875 float temperature_K = 300.f; // 27°C
2876 float humidity_rel = 0.5f; // 50% relative humidity
2877 float turbidity = 0.02f; // Clear sky - Ångström's aerosol turbidity coefficient (AOD at 500nm)
2878
2879 if (context->doesGlobalDataExist("atmosphere_pressure_Pa")) {
2880 context->getGlobalData("atmosphere_pressure_Pa", pressure_Pa);
2881 }
2882 if (context->doesGlobalDataExist("atmosphere_temperature_K")) {
2883 context->getGlobalData("atmosphere_temperature_K", temperature_K);
2884 }
2885 if (context->doesGlobalDataExist("atmosphere_humidity_rel")) {
2886 context->getGlobalData("atmosphere_humidity_rel", humidity_rel);
2887 }
2888 if (context->doesGlobalDataExist("atmosphere_turbidity")) {
2889 context->getGlobalData("atmosphere_turbidity", turbidity);
2890 }
2891
2892 // --- Check Prague data availability from Context ---
2893 int prague_valid = 0;
2894 if (context->doesGlobalDataExist("prague_sky_valid")) {
2895 context->getGlobalData("prague_sky_valid", prague_valid);
2896 }
2897
2898 // Get sun direction from first radiation source (assumed to be sun)
2899 helios::vec3 sun_dir(0, 0, 1); // Default zenith
2900 if (!radiation_sources.empty()) {
2901 sun_dir = radiation_sources[0].source_position;
2902 sun_dir.normalize();
2903 }
2904
2905 // Compute per-band sky radiance parameters
2906 std::vector<helios::vec4> sky_params(Nbands_launch);
2907
2908 // Check if Prague data is available
2909 bool use_prague_fallback = (prague_valid != 1);
2910 if (use_prague_fallback) {
2911 // Will use Rayleigh sky fallback - warn user once
2912 std::cerr << "WARNING (RadiationModel::updateAtmosphericSkyModel): "
2913 << "Prague sky model data not available in Context. "
2914 << "Using simple Rayleigh sky fallback. "
2915 << "Call SolarPosition::updatePragueSkyModel() for accurate sky radiance." << std::endl;
2916 }
2917
2918 // Prepare spectral data (either Prague or Rayleigh fallback)
2919 std::vector<float> wavelengths;
2920 std::vector<float> L_zenith_spectrum;
2921 std::vector<float> circ_str_spectrum;
2922 std::vector<float> circ_width_spectrum;
2923 std::vector<float> horiz_bright_spectrum;
2924 std::vector<float> norm_spectrum;
2925
2926 if (use_prague_fallback) {
2927 // --- Create simple Rayleigh sky spectrum (λ^-4 dependence) ---
2928 // 360-750 nm at 10 nm spacing (visible range only for fallback)
2929 const int n_wavelengths = 40; // (750-360)/10 + 1
2930 wavelengths.resize(n_wavelengths);
2931 L_zenith_spectrum.resize(n_wavelengths);
2932 circ_str_spectrum.resize(n_wavelengths);
2933 circ_width_spectrum.resize(n_wavelengths);
2934 horiz_bright_spectrum.resize(n_wavelengths);
2935 norm_spectrum.resize(n_wavelengths);
2936
2937 const float L_base = 0.4f; // W/m²/sr/nm at 550 nm (typical clear sky zenith)
2938 const float lambda_ref = 550.0f; // Reference wavelength
2939
2940 for (int i = 0; i < n_wavelengths; ++i) {
2941 float lambda = 360.0f + i * 10.0f;
2942 wavelengths[i] = lambda;
2943
2944 // Rayleigh scattering: L(λ) ∝ λ^-4 (blue sky)
2945 float rayleigh_factor = std::pow(lambda_ref / lambda, 4.0f);
2946 L_zenith_spectrum[i] = L_base * rayleigh_factor;
2947
2948 // Simple angular parameters (no strong circumsolar for fallback)
2949 circ_str_spectrum[i] = 0.5f;
2950 circ_width_spectrum[i] = 20.0f;
2951 horiz_bright_spectrum[i] = 1.8f;
2952 norm_spectrum[i] = 0.7f;
2953 }
2954 } else {
2955 // --- Read spectral parameters from Context ---
2956 std::vector<float> spectral_params;
2957 context->getGlobalData("prague_sky_spectral_params", spectral_params);
2958
2959 const int params_per_wavelength = 6;
2960 const int n_wavelengths = spectral_params.size() / params_per_wavelength;
2961
2962 // Parse into structured format
2963 wavelengths.resize(n_wavelengths);
2964 L_zenith_spectrum.resize(n_wavelengths);
2965 circ_str_spectrum.resize(n_wavelengths);
2966 circ_width_spectrum.resize(n_wavelengths);
2967 horiz_bright_spectrum.resize(n_wavelengths);
2968 norm_spectrum.resize(n_wavelengths);
2969
2970 for (int i = 0; i < n_wavelengths; ++i) {
2971 int base = i * params_per_wavelength;
2972 wavelengths[i] = spectral_params[base + 0];
2973 L_zenith_spectrum[i] = spectral_params[base + 1];
2974 circ_str_spectrum[i] = spectral_params[base + 2];
2975 circ_width_spectrum[i] = spectral_params[base + 3];
2976 horiz_bright_spectrum[i] = spectral_params[base + 4];
2977 norm_spectrum[i] = spectral_params[base + 5];
2978 }
2979 }
2980
2981 // --- Process each band ---
2982 for (size_t b = 0; b < Nbands_launch; b++) {
2983 const std::string &band_label = band_labels[b];
2984 if (radiation_bands.find(band_label) == radiation_bands.end()) {
2985 continue;
2986 }
2987
2988 const RadiationBand &band = radiation_bands.at(band_label);
2989
2990 // Skip thermal/longwave bands - Prague Sky Model only handles shortwave radiation
2991 if (band.emissionFlag) {
2992 continue;
2993 }
2994
2995 // Get camera spectral response for this band
2996 std::string spectral_response_label = "uniform";
2997 if (camera.band_spectral_response.find(band_label) != camera.band_spectral_response.end()) {
2998 spectral_response_label = camera.band_spectral_response.at(band_label);
2999 if (spectral_response_label.empty() || trim_whitespace(spectral_response_label).empty()) {
3000 spectral_response_label = "uniform";
3001 }
3002 }
3003
3004 // Get camera spectral response data
3005 std::vector<helios::vec2> camera_response;
3006
3007 if (spectral_response_label == "uniform") {
3008 helios::vec2 wavelength_range = band.wavebandBounds;
3009
3010 if (wavelength_range.x <= 0.f || wavelength_range.y <= 0.f) {
3011 bool bounds_inferred = false;
3012
3013 if (band_label == "red" || band_label == "R") {
3014 wavelength_range = helios::make_vec2(620.f, 750.f);
3015 bounds_inferred = true;
3016 } else if (band_label == "green" || band_label == "G") {
3017 wavelength_range = helios::make_vec2(495.f, 570.f);
3018 bounds_inferred = true;
3019 } else if (band_label == "blue" || band_label == "B") {
3020 wavelength_range = helios::make_vec2(450.f, 495.f);
3021 bounds_inferred = true;
3022 }
3023
3024 if (!bounds_inferred) {
3025 if (!band.diffuse_spectrum.empty()) {
3026 wavelength_range.x = band.diffuse_spectrum.front().x;
3027 wavelength_range.y = band.diffuse_spectrum.back().x;
3028 } else {
3029 helios_runtime_error("ERROR (RadiationModel::updateAtmosphericSkyModel): Camera '" + camera.label + "' band '" + band_label + "' has uniform spectral response but no wavelength bounds set.");
3030 }
3031 }
3032 }
3033
3034 camera_response.push_back(helios::make_vec2(wavelength_range.x, 1.0f));
3035 camera_response.push_back(helios::make_vec2(wavelength_range.y, 1.0f));
3036
3037 } else {
3038 camera_response = loadSpectralData(spectral_response_label);
3039
3040 if (camera_response.empty()) {
3041 helios_runtime_error("ERROR (RadiationModel::updateAtmosphericSkyModel): Camera spectral response '" + spectral_response_label + "' not found for camera '" + camera.label + "' band '" + band_label + "'.");
3042 }
3043 }
3044
3045 // Integrate radiance and weight-average angular parameters over camera response
3046 // L_zenith: Integrate to get W/m²/sr (band-integrated radiance)
3047 float integrated_L_zenith = integrateOverResponse(wavelengths, L_zenith_spectrum, camera_response);
3048
3049 // Angular parameters: Weighted average (unitless quantities)
3050 // Weight by L_zenith(λ) × R(λ) to get radiance-weighted average
3051 float integrated_circ_str = weightedAverageOverResponse(wavelengths, circ_str_spectrum, L_zenith_spectrum, camera_response);
3052 float integrated_circ_width = weightedAverageOverResponse(wavelengths, circ_width_spectrum, L_zenith_spectrum, camera_response);
3053 float integrated_horiz_bright = weightedAverageOverResponse(wavelengths, horiz_bright_spectrum, L_zenith_spectrum, camera_response);
3054
3055 // Recompute normalization from averaged angular parameters
3056 float integrated_norm = computeAngularNormalization(integrated_circ_str, integrated_circ_width, integrated_horiz_bright);
3057
3058 // CRITICAL: GPU multiplies by normalization (see rayHit.cu:evaluateSkyRadiance)
3059 // Since normalization < 1 (typically 0.6-0.7), this darkens the sky
3060 // Pre-divide by normalization so it cancels: GPU does (L/norm) × pattern × norm = L × pattern
3061 float base_radiance_for_gpu = integrated_L_zenith / std::max(integrated_norm, 0.1f);
3062
3063 sky_base_radiances[b] = base_radiance_for_gpu;
3064 sky_params[b] = helios::make_vec4(integrated_circ_str, integrated_circ_width, integrated_horiz_bright, integrated_norm);
3065 }
3066
3067 // Sky parameters will be uploaded to backend via updateSkyModel()
3068 return sky_base_radiances;
3069}
3070
3071void RadiationModel::updatePragueParametersForGeneralDiffuse(const std::vector<std::string> &band_labels) {
3072 // Update Prague sky model angular parameters for general diffuse radiation
3073 // Reads spectral parameters from Context (set by SolarPosition::updatePragueSkyModel())
3074 // Integrates over band spectral response to get band-averaged parameters
3075
3076 // Check Prague data availability
3077 int prague_valid = 0;
3078 if (!context->doesGlobalDataExist("prague_sky_valid") || (context->getGlobalData("prague_sky_valid", prague_valid), prague_valid != 1)) {
3079 // No Prague data - leave params at zero (will use power-law or isotropic)
3080 return;
3081 }
3082
3083 // Read spectral parameters from Context
3084 std::vector<float> spectral_params;
3085 context->getGlobalData("prague_sky_spectral_params", spectral_params);
3086
3087 // Parse into wavelength-resolved arrays
3088 const int params_per_wavelength = 6;
3089 const int n_wavelengths = spectral_params.size() / params_per_wavelength;
3090
3091 std::vector<float> wavelengths(n_wavelengths);
3092 std::vector<float> L_zenith_spectrum(n_wavelengths);
3093 std::vector<float> circ_str_spectrum(n_wavelengths);
3094 std::vector<float> circ_width_spectrum(n_wavelengths);
3095 std::vector<float> horiz_bright_spectrum(n_wavelengths);
3096 std::vector<float> norm_spectrum(n_wavelengths);
3097
3098 for (int i = 0; i < n_wavelengths; ++i) {
3099 int base = i * params_per_wavelength;
3100 wavelengths[i] = spectral_params[base + 0];
3101 L_zenith_spectrum[i] = spectral_params[base + 1];
3102 circ_str_spectrum[i] = spectral_params[base + 2];
3103 circ_width_spectrum[i] = spectral_params[base + 3];
3104 horiz_bright_spectrum[i] = spectral_params[base + 4];
3105 norm_spectrum[i] = spectral_params[base + 5];
3106 }
3107
3108 // Get sun direction
3109 helios::vec3 sun_dir;
3110 context->getGlobalData("prague_sky_sun_direction", sun_dir);
3111
3112 // Process each band
3113 for (const auto &label: band_labels) {
3114 RadiationBand &band = radiation_bands.at(label);
3115
3116 // SKIP if user has explicitly set power-law (priority 1)
3117 if (band.diffuseExtinction > 0.0f) {
3118 continue;
3119 }
3120
3121 // Integrate Prague parameters over band spectrum
3122 std::vector<helios::vec2> band_spectrum = band.diffuse_spectrum;
3123 if (band_spectrum.empty()) {
3124 // Use waveband bounds if no detailed spectrum
3125 float lambda_min = band.wavebandBounds.x;
3126 float lambda_max = band.wavebandBounds.y;
3127 if (lambda_min > 0 && lambda_max > lambda_min) {
3128 band_spectrum = {{lambda_min, 1.0f}, {lambda_max, 1.0f}};
3129 }
3130 }
3131
3132 if (band_spectrum.empty()) {
3133 // No spectral info - skip Prague for this band
3134 continue;
3135 }
3136
3137 // Weighted integration (weight by L_zenith for physical consistency)
3138 float int_circ_str = weightedAverageOverResponse(wavelengths, circ_str_spectrum, L_zenith_spectrum, band_spectrum);
3139 float int_circ_width = weightedAverageOverResponse(wavelengths, circ_width_spectrum, L_zenith_spectrum, band_spectrum);
3140 float int_horiz_bright = weightedAverageOverResponse(wavelengths, horiz_bright_spectrum, L_zenith_spectrum, band_spectrum);
3141
3142 // Recompute normalization from integrated parameters
3143 float int_norm = computeAngularNormalization(int_circ_str, int_circ_width, int_horiz_bright);
3144
3145 // Store in RadiationBand
3146 band.diffusePragueParams = helios::make_vec4(int_circ_str, int_circ_width, int_horiz_bright, int_norm);
3147 band.diffusePeakDir = sun_dir;
3148 }
3149}
3150
3151float RadiationModel::integrateOverResponse(const std::vector<float> &wavelengths, const std::vector<float> &values, const std::vector<helios::vec2> &camera_response) const {
3152
3153 if (wavelengths.empty() || camera_response.empty()) {
3154 return 0.0f;
3155 }
3156
3157 // CRITICAL: This integrates spectral radiance L(λ) in W/m²/sr/nm over wavelength
3158 // to produce band-integrated radiance in W/m²/sr (same as Prague computeIntegratedSkyRadiance)
3159 double integrated_radiance = 0.0;
3160
3161 // Trapezoidal integration over camera response wavelength range
3162 for (size_t i = 0; i < camera_response.size() - 1; ++i) {
3163 float lambda1 = camera_response[i].x;
3164 float lambda2 = camera_response[i + 1].x;
3165
3166 // Skip if outside spectral data range
3167 if (lambda2 < wavelengths.front() || lambda1 > wavelengths.back()) {
3168 continue;
3169 }
3170
3171 float r1 = camera_response[i].y;
3172 float r2 = camera_response[i + 1].y;
3173
3174 // Interpolate spectral values at these wavelengths using linear interpolation
3175 float v1, v2;
3176
3177 // Interpolate v1 at lambda1
3178 if (lambda1 <= wavelengths.front()) {
3179 v1 = values.front();
3180 } else if (lambda1 >= wavelengths.back()) {
3181 v1 = values.back();
3182 } else {
3183 auto it = std::lower_bound(wavelengths.begin(), wavelengths.end(), lambda1);
3184 size_t idx = std::distance(wavelengths.begin(), it);
3185 if (idx == 0)
3186 idx = 1;
3187 float t = (lambda1 - wavelengths[idx - 1]) / (wavelengths[idx] - wavelengths[idx - 1]);
3188 v1 = values[idx - 1] + t * (values[idx] - values[idx - 1]);
3189 }
3190
3191 // Interpolate v2 at lambda2
3192 if (lambda2 <= wavelengths.front()) {
3193 v2 = values.front();
3194 } else if (lambda2 >= wavelengths.back()) {
3195 v2 = values.back();
3196 } else {
3197 auto it = std::lower_bound(wavelengths.begin(), wavelengths.end(), lambda2);
3198 size_t idx = std::distance(wavelengths.begin(), it);
3199 if (idx == 0)
3200 idx = 1;
3201 float t = (lambda2 - wavelengths[idx - 1]) / (wavelengths[idx] - wavelengths[idx - 1]);
3202 v2 = values[idx - 1] + t * (values[idx] - values[idx - 1]);
3203 }
3204
3205 float dlambda = lambda2 - lambda1;
3206
3207 // Integrate: ∫ L(λ) × R(λ) dλ
3208 // L(λ) in W/m²/sr/nm, R(λ) unitless, dλ in nm → result in W/m²/sr
3209 integrated_radiance += 0.5 * (v1 * r1 + v2 * r2) * dlambda;
3210 }
3211
3212 // Return band-integrated radiance in W/m²/sr (matches Prague computeIntegratedSkyRadiance)
3213 return static_cast<float>(integrated_radiance);
3214}
3215
3216float 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 {
3217
3218 if (wavelengths.empty() || camera_response.empty()) {
3219 return 0.0f;
3220 }
3221
3222 // CRITICAL: Angular parameters are unitless - compute radiance-weighted average
3223 // Formula: Σ param(λ) × L(λ) × R(λ) dλ / Σ L(λ) × R(λ) dλ
3224 double weighted_sum = 0.0;
3225 double total_weight = 0.0;
3226
3227 for (size_t i = 0; i < camera_response.size() - 1; ++i) {
3228 float lambda1 = camera_response[i].x;
3229 float lambda2 = camera_response[i + 1].x;
3230
3231 if (lambda2 < wavelengths.front() || lambda1 > wavelengths.back()) {
3232 continue;
3233 }
3234
3235 float r1 = camera_response[i].y;
3236 float r2 = camera_response[i + 1].y;
3237
3238 // Interpolate parameter values
3239 float p1, p2;
3240 if (lambda1 <= wavelengths.front()) {
3241 p1 = param_values.front();
3242 } else if (lambda1 >= wavelengths.back()) {
3243 p1 = param_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 p1 = param_values[idx - 1] + t * (param_values[idx] - param_values[idx - 1]);
3251 }
3252
3253 if (lambda2 <= wavelengths.front()) {
3254 p2 = param_values.front();
3255 } else if (lambda2 >= wavelengths.back()) {
3256 p2 = param_values.back();
3257 } else {
3258 auto it = std::lower_bound(wavelengths.begin(), wavelengths.end(), lambda2);
3259 size_t idx = std::distance(wavelengths.begin(), it);
3260 if (idx == 0)
3261 idx = 1;
3262 float t = (lambda2 - wavelengths[idx - 1]) / (wavelengths[idx] - wavelengths[idx - 1]);
3263 p2 = param_values[idx - 1] + t * (param_values[idx] - param_values[idx - 1]);
3264 }
3265
3266 // Interpolate weight (radiance) values
3267 float w1, w2;
3268 if (lambda1 <= wavelengths.front()) {
3269 w1 = weight_values.front();
3270 } else if (lambda1 >= wavelengths.back()) {
3271 w1 = weight_values.back();
3272 } else {
3273 auto it = std::lower_bound(wavelengths.begin(), wavelengths.end(), lambda1);
3274 size_t idx = std::distance(wavelengths.begin(), it);
3275 if (idx == 0)
3276 idx = 1;
3277 float t = (lambda1 - wavelengths[idx - 1]) / (wavelengths[idx] - wavelengths[idx - 1]);
3278 w1 = weight_values[idx - 1] + t * (weight_values[idx] - weight_values[idx - 1]);
3279 }
3280
3281 if (lambda2 <= wavelengths.front()) {
3282 w2 = weight_values.front();
3283 } else if (lambda2 >= wavelengths.back()) {
3284 w2 = weight_values.back();
3285 } else {
3286 auto it = std::lower_bound(wavelengths.begin(), wavelengths.end(), lambda2);
3287 size_t idx = std::distance(wavelengths.begin(), it);
3288 if (idx == 0)
3289 idx = 1;
3290 float t = (lambda2 - wavelengths[idx - 1]) / (wavelengths[idx] - wavelengths[idx - 1]);
3291 w2 = weight_values[idx - 1] + t * (weight_values[idx] - weight_values[idx - 1]);
3292 }
3293
3294 float dlambda = lambda2 - lambda1;
3295
3296 // Weighted average: Σ param × weight × response × dλ
3297 weighted_sum += 0.5 * (p1 * w1 * r1 + p2 * w2 * r2) * dlambda;
3298 total_weight += 0.5 * (w1 * r1 + w2 * r2) * dlambda;
3299 }
3300
3301 // Return weighted average (unitless)
3302 if (total_weight > 1e-10) {
3303 return static_cast<float>(weighted_sum / total_weight);
3304 }
3305 return 0.0f;
3306}
3307
3308float RadiationModel::computeAngularNormalization(float circ_str, float circ_width, float horiz_bright) const {
3309 // Numerical integration of angular pattern over hemisphere
3310 const int N = 50;
3311 float integral = 0.0f;
3312
3313 // Sun at zenith for normalization calculation
3314 helios::vec3 sun_dir = make_vec3(0, 0, 1);
3315
3316 for (int j = 0; j < N; ++j) {
3317 for (int i = 0; i < N; ++i) {
3318 float theta = 0.5f * float(M_PI) * (i + 0.5f) / N; // 0 to π/2
3319 float phi = 2.0f * float(M_PI) * (j + 0.5f) / N; // 0 to 2π
3320
3321 helios::vec3 dir = sphere2cart(make_SphericalCoord(0.5f * float(M_PI) - theta, phi));
3322
3323 // Angular distance from sun (degrees) - matches GPU calculation
3324 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));
3325 float gamma = std::acos(cos_gamma) * 180.0f / float(M_PI);
3326
3327 // Compute angular pattern (same as GPU: rayHit.cu evaluateDiffuseAngularDistribution)
3328 float cos_theta = std::max(0.0f, dir.z);
3329 float horizon_term = 1.0f + (horiz_bright - 1.0f) * (1.0f - cos_theta);
3330 float circ_term = 1.0f + circ_str * std::exp(-gamma / circ_width);
3331
3332 float pattern = circ_term * horizon_term;
3333
3334 // Solid angle element: sin(θ) dθ dφ
3335 integral += pattern * std::cos(theta) * std::sin(theta) * (float(M_PI) / (2.0f * N)) * (2.0f * float(M_PI) / N);
3336 }
3337 }
3338
3339 return 1.0f / std::max(integral, 1e-10f);
3340}
3341
3342std::vector<helios::vec2> RadiationModel::loadSpectralData(const std::string &global_data_label) const {
3343
3344 std::vector<helios::vec2> spectrum;
3345
3346 if (!context->doesGlobalDataExist(global_data_label.c_str())) {
3347
3348 // check if spectral data exists in any of the library files
3349 bool data_found = false;
3350 for (const auto &file: spectral_library_files) {
3351 if (Context::scanXMLForTag(file, "globaldata_vec2", global_data_label)) {
3352 context->loadXML(file.c_str(), true);
3353 data_found = true;
3354 break;
3355 }
3356 }
3357
3358 if (!data_found) {
3359 helios_runtime_error("ERROR (RadiationModel::loadSpectralData): Global data for spectrum '" + global_data_label + "' could not be found.");
3360 }
3361 }
3362
3363 if (context->getGlobalDataType(global_data_label.c_str()) != HELIOS_TYPE_VEC2) {
3364 helios_runtime_error("ERROR (RadiationModel::loadSpectralData): Global data for spectrum '" + global_data_label + "' is not of type HELIOS_TYPE_VEC2.");
3365 }
3366
3367 context->getGlobalData(global_data_label.c_str(), spectrum);
3368
3369 // validate spectrum
3370 if (spectrum.empty()) {
3371 helios_runtime_error("ERROR (RadiationModel::loadSpectralData): Global data for spectrum '" + global_data_label + "' is empty.");
3372 }
3373 for (auto s = 0; s < spectrum.size(); s++) {
3374 // check that wavelengths are monotonic
3375 if (s > 0 && spectrum.at(s).x <= spectrum.at(s - 1).x) {
3376 helios_runtime_error("ERROR (RadiationModel::loadSpectralData): Source spectral data validation failed. Wavelengths must increase monotonically.");
3377 }
3378 // check that wavelength is within a reasonable range
3379 if (spectrum.at(s).x < 0 || spectrum.at(s).x > 100000) {
3380 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.");
3381 }
3382 // check that flux is non-negative
3383 if (spectrum.at(s).y < 0) {
3384 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.");
3385 }
3386 }
3387
3388 return spectrum;
3389}
3390
3391void RadiationModel::runBand(const std::string &label) {
3392 std::vector<std::string> labels{label};
3393 runBand(labels);
3394}
3395
3396void RadiationModel::runBand(const std::vector<std::string> &label) {
3397
3398 //----- VERIFICATIONS -----//
3399
3400 // Invalidate cached excitation APAR if radiative properties have changed since the
3401 // last populate (e.g., user changed source spectrum, source flux, geometry, etc.).
3402 // When `radiativepropertiesneedupdate` is true, `updateRadiativeProperties()` will
3403 // be rerun during this dispatch, so any previously-cached APAR is stale.
3404 if (radiativepropertiesneedupdate) {
3405 for (auto &kv : excitation_sets) {
3406 kv.second.populated = false;
3407 }
3408 }
3409
3410 // Optimisation: if the user dispatches one or more regular bands and has at least one
3411 // SIF camera registered, piggy-back the auto-generated excitation bands onto this
3412 // dispatch. This merges PAR + excitation into a single ray-trace pass, saving a full
3413 // dispatch round-trip.
3414 //
3415 // Guards:
3416 // - Don't piggy-back if any band in this dispatch is an SIF emission band — the
3417 // emission-dispatch path has its own pre-hook (below) that handles excitation.
3418 // - Don't piggy-back if any band is already an internal "_SIF_exc_*" band (we are
3419 // already inside the recursive excitation dispatch — prevents infinite recursion).
3420 // - Don't piggy-back if no SIF cameras are registered (no excitation sets exist).
3421 // - Skip sets that are already populated (APAR is cached).
3422 //
3423 // Piggy-backed sets are added to the dispatch list here but NOT marked populated until
3424 // populateExcitationAPAR() runs post-dispatch (below).
3425 std::vector<std::string> effective_label = label;
3426 std::vector<ExcitationSet *> piggybacked_sets;
3427 if (!excitation_sets.empty()) {
3428 bool label_has_sif_emission = false;
3429 bool label_has_excitation_band = false;
3430 for (const auto &b : label) {
3431 if (sif_emission_bands.count(b) > 0) label_has_sif_emission = true;
3432 if (b.size() >= 9 && b.compare(0, 9, "_SIF_exc_") == 0) label_has_excitation_band = true;
3433 }
3434 if (!label_has_sif_emission && !label_has_excitation_band) {
3435 for (auto &kv : excitation_sets) {
3436 ExcitationSet &exc = kv.second;
3437 if (exc.populated) continue;
3438 for (const auto &eb : exc.band_labels) {
3439 effective_label.push_back(eb);
3440 }
3441 piggybacked_sets.push_back(&exc);
3442 }
3443 }
3444 }
3445
3446 // We need the band label strings to appear in the same order as in radiation_bands map.
3447 // this is because that was the order in which radiative properties were laid out when updateRadiativeProperties() was called
3448 std::vector<std::string> band_labels;
3449 for (auto &band: radiation_bands) {
3450 if (std::find(effective_label.begin(), effective_label.end(), band.first) != effective_label.end()) {
3451 band_labels.push_back(band.first);
3452 }
3453 }
3454
3455 // Check to make sure some geometry was added to the context
3456 if (context->getPrimitiveCount() == 0) {
3457 std::cerr << "WARNING (RadiationModel::runBand): No geometry was added to the context. There is nothing to simulate...exiting." << std::endl;
3458 return;
3459 }
3460
3461 // Check to make sure geometry was built in OptiX
3462 if (!isgeometryinitialized) {
3464 }
3465
3466 // Check that all the bands passed to the runBand() method exist
3467 for (const std::string &band: label) {
3468 if (!doesBandExist(band)) {
3469 helios_runtime_error("ERROR (RadiationModel::runBand): Cannot run band " + band + " because it is not a valid band. Use addRadiationBand() function to add the band.");
3470 }
3471 }
3472
3473 // if there are no radiation sources in the simulation, add at least one but with zero fluxes
3474 if (radiation_sources.empty()) {
3476 }
3477
3478 // --- SIF v2: prepare per-leaf emission for any SIF-flagged bands in this launch ---
3479 //
3480 // If any band in this dispatch is a user-defined SIF emission band, we need to:
3481 // 1. Run the auto-generated excitation bands (once per dispatch), to capture
3482 // per-leaf APAR across 400-750 nm.
3483 // 2. Invoke computeSIFEmission() for each SIF-flagged emission band, populating
3484 // sif_emission_buffer[band]. The regular emission loop (below) will then read
3485 // sif_emission_buffer when it encounters the band and bypass Stefan-Boltzmann.
3486 //
3487 // Recursion guard: runExcitationBands() recursively calls runBand() for the internal
3488 // "_SIF_exc_*" bands — those bands are NOT in sif_emission_bands, so the hook is a no-op
3489 // inside the recursion. Also guard against running SIF hook while running a purely-excitation
3490 // band set (first band starts with underscore prefix).
3491 bool dispatch_has_sif_band = false;
3492 for (const auto &b : band_labels) {
3493 if (sif_emission_bands.count(b) > 0) {
3494 dispatch_has_sif_band = true;
3495 break;
3496 }
3497 }
3498 if (dispatch_has_sif_band) {
3499 // Soft override: SIF bands need emission enabled so that the Fluspect-derived
3500 // source flux is traced through the emission loop. If the user has disabled
3501 // emission on a SIF band (directly via disableEmission, or indirectly via
3502 // setSourceFlux/spectral configuration that implicitly silences emission),
3503 // re-enable it for this dispatch and warn once. This avoids the silent
3504 // zero-output failure mode while preserving the user's other controls.
3505 {
3507 sif_warn.setEnabled(message_flag);
3508 for (const auto &b : band_labels) {
3509 if (sif_emission_bands.count(b) > 0) {
3510 auto &band = radiation_bands.at(b);
3511 if (!band.emissionFlag) {
3512 sif_warn.addWarning("sif_emission_reenabled",
3513 "Band '" + b + "' is bound to a SIF camera but has emission disabled. "
3514 "Re-enabling emission for this dispatch — SIF cameras require emission "
3515 "enabled so that Fluspect-B source flux is traced through the emission "
3516 "loop. Remove the disableEmission(\"" + b + "\") call to silence this warning.");
3517 band.emissionFlag = true;
3518 }
3519 }
3520 }
3521 sif_warn.report(std::cerr);
3522 }
3523
3524 // Run any excitation sets that aren't already populated. Piggy-back in the
3525 // runBand() preamble may have pre-populated some or all sets during a prior
3526 // non-SIF dispatch in this frame. When radiativepropertiesneedupdate is true
3527 // (a user changed something between runBand calls), the preamble above already
3528 // reset populated=false, so we'll pick up fresh APAR here.
3529 runExcitationBands();
3530 for (const auto &b : band_labels) {
3531 if (sif_emission_bands.count(b) > 0) {
3532 computeSIFEmission(b);
3533 }
3534 }
3535 }
3536
3537 // Check if any source spectra have changed in global data and reload if necessary
3538 for (auto &source: radiation_sources) {
3539 if (!source.source_spectrum_label.empty() && source.source_spectrum_label != "none") {
3540 uint64_t current_version = context->getGlobalDataVersion(source.source_spectrum_label.c_str());
3541 if (current_version != source.source_spectrum_version) {
3542 // Reload spectrum from global data
3543 source.source_spectrum = loadSpectralData(source.source_spectrum_label);
3544 source.source_spectrum_version = current_version;
3545 radiativepropertiesneedupdate = true;
3546 }
3547 }
3548 }
3549
3550 // Check if global diffuse spectrum has changed and reload if necessary
3551 if (!global_diffuse_spectrum_label.empty() && global_diffuse_spectrum_label != "none") {
3552 uint64_t current_version = context->getGlobalDataVersion(global_diffuse_spectrum_label.c_str());
3553 if (current_version != global_diffuse_spectrum_version) {
3554 // Reload diffuse spectrum from global data
3555 global_diffuse_spectrum = loadSpectralData(global_diffuse_spectrum_label);
3556 global_diffuse_spectrum_version = current_version;
3557 // Also update all band diffuse spectra
3558 for (auto &band_pair: radiation_bands) {
3559 band_pair.second.diffuse_spectrum = global_diffuse_spectrum;
3560 }
3561 radiativepropertiesneedupdate = true;
3562 }
3563 }
3564
3565 if (radiativepropertiesneedupdate) {
3566 // Use old material path (handles spectrum interpolation)
3567 updateRadiativeProperties();
3568 // DON'T call backend->updateMaterials() - old code already uploaded via direct OptiX calls
3569 } else {
3570 // Use new backend path (per-band materials only)
3571 buildMaterialData();
3572 backend->updateMaterials(material_data);
3573 }
3574
3575 // Upload sources to backend (always use new path)
3576 buildSourceData();
3577 backend->updateSources(source_data);
3578
3579 // Prepare launch parameters (these will be passed to backend via RayTracingLaunchParams)
3580 size_t Nbands_launch = band_labels.size();
3581 size_t Nbands_global = radiation_bands.size();
3582
3583 // Build band launch flags
3584 std::vector<char> band_launch_flag(Nbands_global);
3585 uint bb = 0;
3586 for (auto &band: radiation_bands) {
3587 if (std::find(band_labels.begin(), band_labels.end(), band.first) != band_labels.end()) {
3588 band_launch_flag.at(bb) = 1;
3589 }
3590 bb++;
3591 }
3592
3593 // Get dimensions
3594 size_t Nobjects = primitiveID.size();
3595 size_t Nprimitives = context_UUIDs.size();
3596 uint Nsources = radiation_sources.size();
3597 uint Ncameras = cameras.size();
3598
3599 // Note: Atmospheric sky radiance model is updated per-camera (see camera trace loop below)
3600 // This allows us to use camera-specific spectral responses for each band
3601
3602 // Set scattering depth for each band
3603 std::vector<uint> scattering_depth(Nbands_launch);
3604 bool scatteringenabled = false;
3605 for (auto b = 0; b < Nbands_launch; b++) {
3606 scattering_depth.at(b) = radiation_bands.at(band_labels.at(b)).scatteringDepth;
3607 if (scattering_depth.at(b) > 0) {
3608 scatteringenabled = true;
3609 }
3610 }
3611
3612 // Issue warning if rho>0, tau>0, or eps<1 for any band with scatteringDepth=0.
3613 // Internal SIF excitation bands (labels start with "_SIF_exc_") are silenced here
3614 // because LeafOptics automatically sets per-band rho/tau on leaves for the full
3615 // spectrum — a per-band warning would produce ~35 lines of noise. Instead,
3616 // runExcitationBands() emits ONE consolidated warning before dispatch covering
3617 // the entire excitation set.
3618 for (int b = 0; b < Nbands_launch; b++) {
3619 const std::string &bname = band_labels.at(b);
3620 const bool is_sif_excitation_band = bname.size() >= 9 && bname.compare(0, 9, "_SIF_exc_") == 0;
3621 if (scattering_depth.at(b) == 0 && scattering_iterations_needed.at(bname) && !is_sif_excitation_band) {
3622 std::cout << "WARNING (RadiationModel::runBand): Surface radiative properties for band " << bname
3623 << " are set to non-default values, but scattering iterations are disabled. Surface radiative properties will be ignored unless scattering depth is non-zero." << std::endl;
3624 }
3625 }
3626
3627 // Set diffuse flux for each band
3628 std::vector<float> diffuse_flux(Nbands_launch);
3629 bool diffuseenabled = false;
3630 for (auto b = 0; b < Nbands_launch; b++) {
3631 diffuse_flux.at(b) = getDiffuseFlux(band_labels.at(b));
3632 if (diffuse_flux.at(b) > 0.f) {
3633 diffuseenabled = true;
3634 }
3635 }
3636 // NOTE: diffuse_flux now passed to backend via launch params, not uploaded here
3637
3638 // Initialize camera sky radiance buffer to zeros (will be set per-camera if atmospheric model is used)
3639 std::vector<float> camera_sky_radiance(Nbands_launch, 0.0f);
3640
3641 // Update Prague parameters for general diffuse (if available in Context)
3642 // This must be done before uploading diffuse parameters to GPU
3643 if (diffuseenabled) {
3644 updatePragueParametersForGeneralDiffuse(band_labels);
3645 }
3646
3647 // Set diffuse extinction coefficient for each band
3648 std::vector<float> diffuse_extinction(Nbands_launch, 0);
3649 if (diffuseenabled) {
3650 for (auto b = 0; b < Nbands_launch; b++) {
3651 diffuse_extinction.at(b) = radiation_bands.at(band_labels.at(b)).diffuseExtinction;
3652 }
3653 }
3654 // NOTE: diffuse_extinction now passed to backend via launch params, not uploaded here
3655
3656 // Set diffuse distribution normalization factor for each band
3657 std::vector<float> diffuse_dist_norm(Nbands_launch, 0);
3658 if (diffuseenabled) {
3659 for (auto b = 0; b < Nbands_launch; b++) {
3660 diffuse_dist_norm.at(b) = radiation_bands.at(band_labels.at(b)).diffuseDistNorm;
3661 }
3662 }
3663 // NOTE: diffuse_dist_norm now passed to backend via launch params, not uploaded here
3664
3665 // Set diffuse distribution peak direction for each band
3666 std::vector<helios::vec3> diffuse_peak_dir(Nbands_launch);
3667 if (diffuseenabled) {
3668 for (auto b = 0; b < Nbands_launch; b++) {
3669 helios::vec3 peak_dir = radiation_bands.at(band_labels.at(b)).diffusePeakDir;
3670 diffuse_peak_dir.at(b) = helios::make_vec3(peak_dir.x, peak_dir.y, peak_dir.z);
3671 }
3672 }
3673 // NOTE: diffuse_peak_dir now passed to backend via launch params, not uploaded here
3674
3675 // Upload Prague parameters for general diffuse (reuses camera buffer)
3676 // This allows general diffuse to use Prague sky model if available
3677 std::vector<helios::vec4> prague_params(Nbands_launch);
3678 if (diffuseenabled) {
3679 for (auto b = 0; b < Nbands_launch; b++) {
3680 const auto &params = radiation_bands.at(band_labels.at(b)).diffusePragueParams;
3681 prague_params.at(b) = helios::make_vec4(params.x, params.y, params.z, params.w);
3682 }
3683 // Prague params will be uploaded to backend via updateSkyModel() during scattering
3684 }
3685
3686 // Determine whether emission is enabled for any band
3687 bool emissionenabled = false;
3688 for (auto b = 0; b < Nbands_launch; b++) {
3689 if (radiation_bands.at(band_labels.at(b)).emissionFlag) {
3690 emissionenabled = true;
3691 }
3692 }
3693
3694 // Figure out the maximum direct ray count for all bands in this run and use this as the launch size
3695 size_t directRayCount = 0;
3696 for (const auto &band: label) {
3697 if (radiation_bands.at(band).directRayCount > directRayCount) {
3698 directRayCount = radiation_bands.at(band).directRayCount;
3699 }
3700 }
3701
3702 // Figure out the maximum diffuse ray count for all bands in this run and use this as the launch size
3703 size_t diffuseRayCount = 0;
3704 for (const auto &band: label) {
3705 if (radiation_bands.at(band).diffuseRayCount > diffuseRayCount) {
3706 diffuseRayCount = radiation_bands.at(band).diffuseRayCount;
3707 }
3708 }
3709
3710 // Figure out the maximum diffuse ray count for all bands in this run and use this as the launch size
3711 size_t scatteringDepth = 0;
3712 for (const auto &band: label) {
3713 if (radiation_bands.at(band).scatteringDepth > scatteringDepth) {
3714 scatteringDepth = radiation_bands.at(band).scatteringDepth;
3715 }
3716 }
3717
3718 // Zero radiation buffers via backend
3719 backend->zeroRadiationBuffers(Nbands_launch);
3720
3721 std::vector<float> TBS_top, TBS_bottom;
3722 TBS_top.resize(Nbands_launch * Nprimitives, 0);
3723 TBS_bottom = TBS_top;
3724
3725 std::map<std::string, std::vector<std::vector<float>>> radiation_in_camera;
3726
3727 size_t maxRays = 1024 * 1024 * 1024; // maximum number of total rays in a launch
3728
3729 // ***** DIRECT LAUNCH FROM ALL RADIATION SOURCES ***** //
3730
3731 helios::int3 launch_dim_dir;
3732
3733 bool rundirect = false;
3734 for (uint s = 0; s < Nsources; s++) {
3735 for (uint b = 0; b < Nbands_launch; b++) {
3736 if (getSourceFlux(s, band_labels.at(b)) > 0.f) {
3737 rundirect = true;
3738 break;
3739 }
3740 }
3741 }
3742
3743 if (Nsources > 0 && rundirect) {
3744
3745 // update radiation source buffers
3746
3747 std::vector<std::vector<float>> fluxes; // first index is the source, second index is the band (only those passed to runBand() function)
3748 fluxes.resize(Nsources);
3749 std::vector<helios::vec3> positions(Nsources);
3750 std::vector<helios::vec2> widths(Nsources);
3751 std::vector<helios::vec3> rotations(Nsources);
3752 std::vector<uint> types(Nsources);
3753
3754 size_t s = 0;
3755 for (const auto &source: radiation_sources) {
3756
3757 fluxes.at(s).resize(Nbands_launch);
3758
3759 for (auto b = 0; b < label.size(); b++) {
3760 fluxes.at(s).at(b) = getSourceFlux(s, band_labels.at(b));
3761 }
3762
3763 positions.at(s) = helios::make_vec3(source.source_position.x, source.source_position.y, source.source_position.z);
3764 widths.at(s) = helios::make_vec2(source.source_width.x, source.source_width.y);
3765 rotations.at(s) = helios::make_vec3(source.source_rotation.x, source.source_rotation.y, source.source_rotation.z);
3766 types.at(s) = source.source_type;
3767
3768 s++;
3769 }
3770
3771 // Upload band-specific source fluxes to backend buffer (indexed by Nbands_launch, not Nbands_global)
3772 backend->uploadSourceFluxes(flatten(fluxes));
3773 // Note: positions, widths, rotations, types are uploaded once in buildSourceData()
3774 // Only fluxes need per-launch update because they depend on which bands are being run
3775
3776 // Compute camera response weighting factors for specular reflection (if cameras exist)
3777 // Factor = ∫(source_spectrum × camera_response) / ∫(source_spectrum)
3778 // This must be done before ray tracing so the weights are available during miss_direct()
3779 if (Ncameras > 0) {
3780 std::vector<float> source_fluxes_cam;
3781 source_fluxes_cam.resize(Nsources * Nbands_launch * Ncameras, 1.0f);
3782
3783 for (uint s = 0; s < Nsources; s++) {
3784 const RadiationSource &source = radiation_sources.at(s);
3785
3786 uint cam = 0;
3787 for (const auto &camera: cameras) {
3788 for (uint b = 0; b < Nbands_launch; b++) {
3789 std::string band_label = band_labels.at(b);
3790
3791 // Default weighting factor (no camera response)
3792 float weight = 1.0f;
3793
3794 // Check if camera has spectral response for this band
3795 if (camera.second.band_spectral_response.find(band_label) != camera.second.band_spectral_response.end()) {
3796 std::string response_label = camera.second.band_spectral_response.at(band_label);
3797
3798 if (!response_label.empty() && response_label != "uniform" && context->doesGlobalDataExist(response_label.c_str()) && context->getGlobalDataType(response_label.c_str()) == helios::HELIOS_TYPE_VEC2 &&
3799 source.source_spectrum.size() > 0) {
3800
3801 // Load camera spectral response
3802 std::vector<helios::vec2> camera_response;
3803 context->getGlobalData(response_label.c_str(), camera_response);
3804
3805 // Get band wavelength range
3806 helios::vec2 wavelength_range = radiation_bands.at(band_label).wavebandBounds;
3807
3808 // If no wavelength bounds, use overlapping range of source and camera
3809 if (wavelength_range.x == 0 && wavelength_range.y == 0) {
3810 wavelength_range.x = fmax(source.source_spectrum.front().x, camera_response.front().x);
3811 wavelength_range.y = fmin(source.source_spectrum.back().x, camera_response.back().x);
3812 }
3813
3814 // Integrate source_spectrum × camera_response over band
3815 // Note: integrateSpectrum already returns ratio: ∫(source × camera) / ∫(source)
3816 weight = integrateSpectrum(s, camera_response, wavelength_range.x, wavelength_range.y);
3817 }
3818 }
3819
3820 source_fluxes_cam[s * Nbands_launch * Ncameras + b * Ncameras + cam] = weight;
3821 }
3822 cam++;
3823 }
3824 }
3825
3826 // Update source_data with camera-weighted fluxes and re-upload to backend
3827 for (uint s = 0; s < Nsources; s++) {
3828 source_data[s].fluxes_cam.clear();
3829 for (uint b = 0; b < Nbands_launch; b++) {
3830 for (uint cam = 0; cam < Ncameras; cam++) {
3831 source_data[s].fluxes_cam.push_back(source_fluxes_cam[s * Nbands_launch * Ncameras + b * Ncameras + cam]);
3832 }
3833 }
3834 }
3835 backend->updateSources(source_data);
3836 }
3837
3838 // -- Ray Trace (Using Backend) -- //
3839
3840 if (message_flag) {
3841 std::cout << "Performing primary direct radiation ray trace for bands ";
3842 for (const auto &band: label) {
3843 std::cout << band << ", ";
3844 }
3845 std::cout << "..." << std::flush;
3846 }
3847
3848 // Launch direct rays through backend
3850 params.launch_offset = 0;
3851 params.launch_count = Nprimitives; // Launch all primitives at once
3852 params.rays_per_primitive = directRayCount;
3853 params.random_seed = std::chrono::system_clock::now().time_since_epoch().count();
3854 params.num_bands_global = Nbands_global;
3855 params.num_bands_launch = Nbands_launch;
3856 params.specular_reflection_enabled = specular_reflection_mode;
3857
3858 // Use the band_launch_flag already built above (lines 3375-3383)
3859 std::vector<bool> band_flags(band_launch_flag.begin(), band_launch_flag.end());
3860 params.band_launch_flag = band_flags;
3861
3862 // Pre-allocate camera scatter buffers before direct launch so __miss__direct can fill them.
3863 // This ensures current_launch_band_count > 0 so getRadiationResults downloads them after.
3864 if (Ncameras > 0 && scatteringenabled) {
3865 backend->zeroCameraScatterBuffers(Nbands_launch);
3866 }
3867
3868 backend->launchDirectRays(params);
3869
3870 if (message_flag) {
3871 std::cout << "done." << std::endl;
3872 }
3873
3874 } // end direct source launch
3875
3876 // --- Extract scattered energy from direct rays for diffuse/emission and scattering ---//
3877 // This needs to happen BEFORE diffuse/emission block so scattered direct energy
3878 // is available for both diffuse/emission (via flux_top/flux_bottom) and scattering (via radiation_out)
3879 std::vector<float> flux_top, flux_bottom;
3880 flux_top.resize(Nbands_launch * Nprimitives, 0);
3881 flux_bottom = flux_top;
3882
3883 // Camera scatter accumulation vectors (declare early for use throughout ray tracing)
3884 std::vector<float> scatter_top_cam;
3885 std::vector<float> scatter_bottom_cam;
3886 if (Ncameras > 0) {
3887 scatter_top_cam.resize(Nprimitives * Nbands_launch, 0.0f);
3888 scatter_bottom_cam.resize(Nprimitives * Nbands_launch, 0.0f);
3889 }
3890
3891 if (scatteringenabled && rundirect) {
3892 // Get scattered energy from direct rays for primary diffuse/emission
3893 helios::RayTracingResults scatter_results;
3894 backend->getRadiationResults(scatter_results);
3895 flux_top = scatter_results.scatter_buff_top;
3896 flux_bottom = scatter_results.scatter_buff_bottom;
3897
3898 // Accumulate camera scatter from direct rays
3899 if (Ncameras > 0) {
3900 for (size_t i = 0; i < scatter_results.scatter_buff_top_cam.size(); i++) {
3901 scatter_top_cam[i] += scatter_results.scatter_buff_top_cam[i];
3902 scatter_bottom_cam[i] += scatter_results.scatter_buff_bottom_cam[i];
3903 }
3904 // Zero GPU camera scatter buffers to prevent double-counting on next iteration
3905 backend->zeroCameraScatterBuffers(Nbands_launch);
3906 }
3907
3908 // For one-sided primitives, make scattered energy accessible from both faces
3909 // This is necessary because scattering rays can hit from either direction
3910 RadiationBufferIndexer rad_indexer(Nprimitives, Nbands_launch);
3911
3912 for (size_t i = 0; i < Nprimitives; i++) {
3913 uint UUID = context_UUIDs.at(i);
3914 uint twosided = context->getPrimitiveTwosidedFlag(UUID, 1);
3915
3916 if (twosided == 0) { // one-sided primitive - combine top+bottom scattered energy
3917 for (size_t b = 0; b < Nbands_launch; b++) {
3918 size_t ind = rad_indexer(i, b);
3919 float total = flux_top[ind] + flux_bottom[ind];
3920 flux_top[ind] = total;
3921 flux_bottom[ind] = total;
3922 }
3923 }
3924 }
3925
3926 // Upload scattered energy to backend's radiation_out buffers for scattering iterations
3927 backend->uploadRadiationOut(flux_top, flux_bottom);
3928 backend->zeroScatterBuffers();
3929 }
3930
3931 // --- Diffuse/Emission launch ---- //
3932
3933 if (emissionenabled || diffuseenabled) {
3934
3935 // add any emitted energy to the outgoing energy buffer
3936 if (emissionenabled) {
3937 // Update primitive outgoing emission
3938 float eps, temperature;
3939
3940 // Create indexer for emission flux buffers
3941 RadiationBufferIndexer emission_indexer(Nprimitives, Nbands_launch);
3942
3943 for (auto b = 0; b < Nbands_launch; b++) {
3944 //\todo For emissivity and twosided_flag, this should be done in updateRadiativeProperties() to avoid having to do it on every runBand() call
3945 if (radiation_bands.at(band_labels.at(b)).emissionFlag) {
3946 std::string prop = "emissivity_" + band_labels.at(b);
3947 // SIF bands: per-primitive source flux comes from computeSIFEmission(), with
3948 // separate top/bottom buffers populated from Fluspect-B's Mf/Mb kernels.
3949 auto sif_band_it = sif_emission_buffer.find(band_labels.at(b));
3950 auto sif_band_bot_it = sif_emission_buffer_bottom.find(band_labels.at(b));
3951 const bool have_sif_band = (sif_band_it != sif_emission_buffer.end());
3952 const bool have_sif_band_bot = (sif_band_bot_it != sif_emission_buffer_bottom.end());
3953 for (size_t u = 0; u < Nprimitives; u++) {
3954 // Use BufferIndexer: [primitive][band]
3955 size_t ind = emission_indexer(u, b);
3956 uint p = context_UUIDs.at(u);
3957 float out_top;
3958 float sif_bottom_flux = 0.f; // Mb-sourced bottom-face emission (if SIF)
3959 bool used_sif = false;
3960 if (have_sif_band) {
3961 auto sif_uuid_it = sif_band_it->second.find(p);
3962 if (sif_uuid_it != sif_band_it->second.end()) {
3963 out_top = sif_uuid_it->second;
3964 used_sif = true;
3965 }
3966 }
3967 if (used_sif && have_sif_band_bot) {
3968 auto sif_bot_it = sif_band_bot_it->second.find(p);
3969 if (sif_bot_it != sif_band_bot_it->second.end()) {
3970 sif_bottom_flux = sif_bot_it->second;
3971 }
3972 }
3973 if (!used_sif) {
3974 // SIF bands are visible/red wavelengths (~680-760 nm). Stefan–Boltzmann
3975 // (broadband σT⁴) is only physically meaningful for thermal IR. Primitives
3976 // without an SIF source flux must not emit into SIF bands at room temperature.
3977 if (have_sif_band) {
3978 out_top = 0.f;
3979 } else {
3980 if (context->doesPrimitiveDataExist(p, prop.c_str())) {
3981 context->getPrimitiveData(p, prop.c_str(), eps);
3982 } else {
3983 eps = eps_default;
3984 }
3985 if (scattering_depth.at(b) == 0 && eps != 1.f) {
3986 eps = 1.f;
3987 }
3988 if (context->doesPrimitiveDataExist(p, "temperature")) {
3989 context->getPrimitiveData(p, "temperature", temperature);
3990 if (temperature < 0) {
3991 temperature = temperature_default;
3992 }
3993 } else {
3994 temperature = temperature_default;
3995 }
3996 out_top = sigma * eps * pow(temperature, 4);
3997 }
3998 }
3999 flux_top.at(ind) += out_top;
4000 if (Ncameras > 0) {
4001 scatter_top_cam[ind] += out_top;
4002 }
4003 // Check twosided_flag - check material first, then primitive data
4004 uint twosided_flag = context->getPrimitiveTwosidedFlag(p, 1);
4005 if (twosided_flag != 0) { // If two-sided, emit from bottom face too
4006 if (used_sif) {
4007 // SIF two-sided: use Mb-derived bottom flux rather than copying
4008 // the Mf-derived top flux. Physically distinct emission lobes.
4009 flux_bottom.at(ind) += sif_bottom_flux;
4010 if (Ncameras > 0) {
4011 scatter_bottom_cam[ind] += sif_bottom_flux;
4012 }
4013 } else {
4014 flux_bottom.at(ind) += flux_top.at(ind);
4015 if (Ncameras > 0) {
4016 scatter_bottom_cam[ind] += out_top;
4017 }
4018 }
4019 }
4020 }
4021 }
4022 }
4023 }
4024
4025 // Upload camera scatter buffers accumulated from emission, direct rays, and primary diffuse
4026 // Camera scatter is accumulated on CPU from GPU after each ray launch
4027 if (Ncameras > 0) {
4028 backend->uploadCameraScatterBuffers(scatter_top_cam, scatter_bottom_cam);
4029 }
4030
4031 // Note: radiation_specular_RTbuffer is populated on GPU via atomicFloatAdd during ray tracing, don't overwrite it here
4032
4033 // Compute diffuse launch dimension
4034 size_t n = ceil(sqrt(double(diffuseRayCount)));
4035 uint rays_per_primitive = n * n;
4036
4037 if (message_flag) {
4038 std::cout << "Performing primary diffuse radiation ray trace for bands ";
4039 for (const auto &band: label) {
4040 std::cout << band << " ";
4041 }
4042 std::cout << "..." << std::flush;
4043 }
4044
4045 // Build launch parameters for diffuse rays
4046 // Note: OptiX 6.5-specific batching (maxRays limit) should be handled inside the OptiX backend,
4047 // not here in RadiationModel. Vulkan and other backends can launch all primitives at once.
4049 params.launch_offset = 0;
4050 params.launch_count = Nprimitives; // Launch all primitives at once (backend handles batching if needed)
4051 params.rays_per_primitive = rays_per_primitive;
4052 params.random_seed = std::chrono::system_clock::now().time_since_epoch().count();
4053 params.current_band = 0;
4054 params.num_bands_global = Nbands_global;
4055 params.num_bands_launch = Nbands_launch;
4056 std::vector<bool> band_flags(band_launch_flag.begin(), band_launch_flag.end());
4057 params.band_launch_flag = band_flags;
4058 params.scattering_iteration = 0;
4059 params.max_scatters = scatteringDepth;
4060 params.radiation_out_top = flux_top;
4061 params.radiation_out_bottom = flux_bottom;
4062
4063 // Pass diffuse radiation parameters to backend
4064 params.diffuse_flux = diffuse_flux;
4065 params.diffuse_extinction = diffuse_extinction;
4066 params.diffuse_dist_norm = diffuse_dist_norm;
4067 // Convert helios::vec3 to helios::vec3
4068 std::vector<helios::vec3> peak_dirs(diffuse_peak_dir.size());
4069 for (size_t i = 0; i < diffuse_peak_dir.size(); i++) {
4070 peak_dirs[i] = helios::make_vec3(diffuse_peak_dir[i].x, diffuse_peak_dir[i].y, diffuse_peak_dir[i].z);
4071 }
4072 params.diffuse_peak_dir = peak_dirs;
4073 params.sky_radiance_params = prague_params;
4074
4075 // Top surface launch
4076 params.launch_face = 1;
4077 backend->launchDiffuseRays(params);
4078
4079 // Bottom surface launch
4080 params.launch_face = 0;
4081 backend->launchDiffuseRays(params);
4082
4083 // Retrieve and accumulate camera scatter from primary diffuse
4084 if (Ncameras > 0) {
4085 helios::RayTracingResults primary_results;
4086 backend->getRadiationResults(primary_results);
4087 for (size_t i = 0; i < primary_results.scatter_buff_top_cam.size(); i++) {
4088 scatter_top_cam[i] += primary_results.scatter_buff_top_cam[i];
4089 scatter_bottom_cam[i] += primary_results.scatter_buff_bottom_cam[i];
4090 }
4091 // Zero GPU camera scatter buffers to prevent double-counting on next iteration
4092 backend->zeroCameraScatterBuffers(Nbands_launch);
4093 }
4094
4095 if (message_flag) {
4096 std::cout << "done." << std::endl;
4097 }
4098 }
4099
4100 // After primary diffuse, prepare scatter_buff for scattering iterations
4101 // When direct rays ran, scatter_buff was already copied to radiation_out at line 3710
4102 // For emission/diffuse without direct rays, we need to do this now
4103 if (scatteringenabled && (emissionenabled || diffuseenabled) && !rundirect) {
4104 backend->copyScatterToRadiation();
4105 backend->zeroScatterBuffers();
4106 }
4107
4108 if (scatteringenabled && (emissionenabled || diffuseenabled || rundirect)) {
4109
4110 for (auto b = 0; b < Nbands_launch; b++) {
4111 diffuse_flux.at(b) = 0.f;
4112 }
4113 // NOTE: diffuse_flux zeroed for scattering, passed to backend via launch params
4114
4115 size_t n = ceil(sqrt(double(diffuseRayCount)));
4116 uint rays_per_primitive = n * n;
4117
4118 uint s;
4119 // FIX: Use a copy of band_launch_flag for scattering so modifications don't affect primary launch indices
4120 std::vector<char> scatter_band_flags = band_launch_flag;
4121
4122 for (s = 0; s < scatteringDepth; s++) {
4123 if (message_flag) {
4124 std::cout << "Performing scattering ray trace (iteration " << s + 1 << " of " << scatteringDepth << ")..." << std::flush;
4125 }
4126
4127 int b = -1;
4128 int active_bands = 0;
4129 for (uint b_global = 0; b_global < Nbands_global; b_global++) {
4130
4131 if (scatter_band_flags.at(b_global) == 0) {
4132 continue;
4133 }
4134 b++;
4135
4136 const std::string &bname = band_labels.at(b);
4137 uint depth = radiation_bands.at(bname).scatteringDepth;
4138 if (s + 1 > depth) {
4139 // Internal SIF excitation bands ("_SIF_exc_*") get piggy-backed onto user
4140 // dispatches (e.g. PAR) that may have a higher scatteringDepth, so they hit
4141 // this "skip" path on every scattering iteration. Suppress the per-band log
4142 // for those — with ~35 excitation bins × scatteringDepth iterations they
4143 // would flood stdout (and break the in-progress "Performing scattering ray
4144 // trace (iteration N of M)..." line, which has no trailing newline).
4145 const bool is_sif_excitation_band = bname.size() >= 9 && bname.compare(0, 9, "_SIF_exc_") == 0;
4146 if (message_flag && !is_sif_excitation_band) {
4147 std::cout << "Skipping band " << bname << " for scattering launch " << s + 1 << std::endl;
4148 }
4149 scatter_band_flags.at(b_global) = 0; // FIX: Modify copy, not original
4150 } else {
4151 active_bands++;
4152 }
4153 }
4154
4155 // Copy scatter buffers to radiation_out when needed
4156 // 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
4157 // For s>0: scatter from previous iteration needs to be copied for next iteration
4158 if (s > 0 || (emissionenabled && rundirect)) {
4159 backend->copyScatterToRadiation();
4160 }
4161 backend->zeroScatterBuffers();
4162
4163 // Extract radiation_out to ensure it's uploaded for scattering rays
4164 helios::RayTracingResults scatter_results;
4165 backend->getRadiationResults(scatter_results);
4166 std::vector<float> flux_top_scatter = scatter_results.radiation_out_top;
4167 std::vector<float> flux_bottom_scatter = scatter_results.radiation_out_bottom;
4168
4169 // Build launch parameters for scattering diffuse rays
4170 // Launch all primitives at once (backend handles batching if needed)
4172 params.launch_offset = 0;
4173 params.launch_count = Nprimitives;
4174 params.rays_per_primitive = rays_per_primitive;
4175 params.random_seed = std::chrono::system_clock::now().time_since_epoch().count();
4176 params.current_band = 0;
4177 params.num_bands_global = Nbands_global;
4178 params.num_bands_launch = Nbands_launch;
4179 std::vector<bool> band_flags(scatter_band_flags.begin(), scatter_band_flags.end()); // FIX: Use scatter copy
4180 params.band_launch_flag = band_flags;
4181 params.scattering_iteration = s;
4182 params.max_scatters = scatteringDepth;
4183
4184 // Pass diffuse radiation parameters to backend
4185 params.diffuse_flux = diffuse_flux;
4186 params.diffuse_extinction = diffuse_extinction;
4187 params.diffuse_dist_norm = diffuse_dist_norm;
4188 // Convert helios::vec3 to helios::vec3
4189 std::vector<helios::vec3> peak_dirs(diffuse_peak_dir.size());
4190 for (size_t i = 0; i < diffuse_peak_dir.size(); i++) {
4191 peak_dirs[i] = helios::make_vec3(diffuse_peak_dir[i].x, diffuse_peak_dir[i].y, diffuse_peak_dir[i].z);
4192 }
4193 params.diffuse_peak_dir = peak_dirs;
4194 params.sky_radiance_params = prague_params;
4195
4196 // Set radiation_out for scattering rays
4197 params.radiation_out_top = flux_top_scatter;
4198 params.radiation_out_bottom = flux_bottom_scatter;
4199
4200 // Top surface launch
4201 params.launch_face = 1;
4202 backend->launchDiffuseRays(params);
4203
4204 // Bottom surface launch
4205 params.launch_face = 0;
4206 backend->launchDiffuseRays(params);
4207
4208 // Accumulate camera scatter from this scattering iteration
4209 if (Ncameras > 0) {
4210 helios::RayTracingResults post_launch;
4211 backend->getRadiationResults(post_launch);
4212 for (size_t i = 0; i < post_launch.scatter_buff_top_cam.size(); i++) {
4213 scatter_top_cam[i] += post_launch.scatter_buff_top_cam[i];
4214 scatter_bottom_cam[i] += post_launch.scatter_buff_bottom_cam[i];
4215 }
4216 // Zero GPU camera scatter buffers to prevent double-counting on next iteration
4217 backend->zeroCameraScatterBuffers(Nbands_launch);
4218 }
4219
4220 if (message_flag) {
4221 std::cout << "\r \r" << std::flush;
4222 }
4223 }
4224
4225 if (message_flag) {
4226 std::cout << "Performing scattering ray trace...done." << std::endl;
4227 }
4228 }
4229
4230 // **** CAMERA RAY TRACE **** //
4231 if (Ncameras > 0) {
4232
4233 // Upload accumulated camera scatter to radiation_out for cameras to read
4234 // scatter_top_cam contains camera-weighted scattered energy from all ray types
4235 // Cameras read from radiation_out during hits, so we upload camera scatter there
4236 if (Ncameras > 0 && scatteringenabled) {
4237 backend->uploadRadiationOut(scatter_top_cam, scatter_bottom_cam);
4238 }
4239
4240 // Setup solar disk rendering for cameras (enables lens flare effects)
4241 // Find sun-like sources (collimated or sun_sphere) and compute solar disk radiance
4242 vec3 sun_dir(0, 0, 1); // Default zenith
4243 std::vector<float> solar_radiances(Nbands_launch, 0.0f);
4244 bool has_sun_source = false;
4245
4246 for (size_t s = 0; s < radiation_sources.size(); s++) {
4247 const RadiationSource &source = radiation_sources.at(s);
4248 if (source.source_type == RADIATION_SOURCE_TYPE_COLLIMATED || source.source_type == RADIATION_SOURCE_TYPE_SUN_SPHERE) {
4249 // Get sun direction from source position (normalized)
4250 sun_dir = source.source_position;
4251 sun_dir.normalize();
4252 has_sun_source = true;
4253
4254 // Compute solar disk radiance for each band
4255 for (size_t b = 0; b < Nbands_launch; b++) {
4256 float flux = getSourceFlux(s, band_labels.at(b));
4257
4258 if (source.source_type == RADIATION_SOURCE_TYPE_SUN_SPHERE) {
4259 // For sun sphere: flux is surface exitance (σT⁴)
4260 // Radiance as seen from Earth: L = F_surface / π
4261 solar_radiances[b] = flux / M_PI;
4262 } else {
4263 // For collimated: flux is irradiance at Earth
4264 // Solar solid angle: π × (4.63e-3)² ≈ 6.74×10⁻⁵ sr
4265 const float solar_solid_angle = 6.74e-5f;
4266 solar_radiances[b] = flux / solar_solid_angle;
4267 }
4268 }
4269 break; // Use first sun-like source found
4270 }
4271 }
4272
4273 if (scatteringenabled && (emissionenabled || diffuseenabled || rundirect)) {
4274 // re-set diffuse radiation fluxes (will be passed via launch params)
4275 if (diffuseenabled) {
4276 for (auto b = 0; b < Nbands_launch; b++) {
4277 diffuse_flux.at(b) = getDiffuseFlux(band_labels.at(b));
4278 }
4279 }
4280
4281 size_t n = ceil(sqrt(double(diffuseRayCount)));
4282
4283 // Upload sky model parameters to backend (for camera rendering)
4284
4285 if (!cameras.empty() && prague_params.size() == Nbands_launch) {
4286 // Get sky radiances for first camera (already computed above)
4287 std::vector<float> sky_for_backend = updateAtmosphericSkyModel(band_labels, cameras.begin()->second);
4288
4289 // Upload to backend
4290 backend->updateSkyModel(prague_params, sky_for_backend, sun_dir, solar_radiances,
4291 has_sun_source ? 0.999989f : 0.0f // solar_disk_cos_angle
4292 );
4293 }
4294
4295 uint cam = 0;
4296 for (auto &camera: cameras) {
4297
4298 // Skip cameras whose bands don't intersect the current dispatch. Without this,
4299 // every runBand() call iterates every camera even if the camera is bound to
4300 // bands that aren't being dispatched, wasting a full camera launch on a no-op.
4301 // This is especially visible for SIF cameras when the user calls runBand("PAR"):
4302 // the SIF camera (bound to SIF_red/SIF_farred) doesn't need to render anything
4303 // for the PAR dispatch.
4304 bool camera_in_dispatch = false;
4305 for (const auto &camera_band: camera.second.band_labels) {
4306 if (std::find(band_labels.begin(), band_labels.end(), camera_band) != band_labels.end()) {
4307 camera_in_dispatch = true;
4308 break;
4309 }
4310 }
4311 if (!camera_in_dispatch) {
4312 ++cam;
4313 continue;
4314 }
4315
4316 // Validate antialiasing samples don't exceed maximum
4317 if (camera.second.antialiasing_samples > maxRays) {
4318 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) +
4319 "). Reduce antialiasing samples.");
4320 }
4321
4322 // Compute tiling if needed
4323 std::vector<CameraTile> tiles = computeCameraTiles(camera.second, maxRays);
4324
4325 if (message_flag && tiles.size() > 1) {
4326 std::cout << "Camera '" << camera.second.label << "' requires " << tiles.size() << " tiles" << std::endl;
4327 }
4328
4329 // Upload camera spectral response weights for specular reflection
4330 // Extract per-camera slice from source_data[s].fluxes_cam
4331 // fluxes_cam is indexed as [band * Ncameras + cam], we need [source * band]
4332 std::vector<float> cam_weights(Nsources * Nbands_launch, 1.0f);
4333 for (uint s = 0; s < Nsources; s++) {
4334 for (uint b = 0; b < Nbands_launch; b++) {
4335 if (!source_data[s].fluxes_cam.empty() && source_data[s].fluxes_cam.size() == Nbands_launch * Ncameras) {
4336 cam_weights[s * Nbands_launch + b] = source_data[s].fluxes_cam[b * Ncameras + cam];
4337 }
4338 }
4339 }
4340 backend->uploadSourceFluxesCam(cam_weights);
4341
4342 // Launch camera rays (tiled or full)
4343 for (size_t tile_idx = 0; tile_idx < tiles.size(); tile_idx++) {
4344 const auto &tile = tiles[tile_idx];
4345
4346 // Build params for this tile
4347 helios::RayTracingLaunchParams params = buildCameraLaunchParams(camera.second, cam, camera.second.antialiasing_samples, tile.resolution, tile.offset);
4348
4349 // Set band parameters (CRITICAL for materials!)
4350 params.num_bands_launch = Nbands_launch;
4351 params.num_bands_global = Nbands_global;
4352 params.random_seed = std::chrono::system_clock::now().time_since_epoch().count();
4353 std::vector<bool> band_flags(band_launch_flag.begin(), band_launch_flag.end());
4354 params.band_launch_flag = band_flags;
4355
4356 // Progress message
4357 if (message_flag) {
4358 if (tiles.size() == 1) {
4359 std::cout << "Performing scattering radiation camera ray trace for camera " << camera.second.label << "..." << std::flush;
4360 } else {
4361 std::cout << "Performing scattering radiation camera ray trace for camera " << camera.second.label << " (tile " << (tile_idx + 1) << " of " << tiles.size() << ")..." << std::flush;
4362 }
4363 }
4364
4365 // Launch through backend
4366 backend->launchCameraRays(params);
4367
4368 if (message_flag) {
4369 if (tiles.size() > 1) {
4370 std::cout << "\r" << std::string(120, ' ') << "\r" << std::flush;
4371 } else {
4372 std::cout << "done." << std::endl;
4373 }
4374 }
4375 }
4376
4377 if (message_flag && tiles.size() > 1) {
4378 std::cout << "Performing scattering radiation camera ray trace for camera " << camera.second.label << "...done." << std::endl;
4379 }
4380
4381 // Get results from backend
4382 std::vector<float> radiation_camera;
4383 std::vector<uint> dummy_labels;
4384 std::vector<float> dummy_depths;
4385 backend->getCameraResults(radiation_camera, dummy_labels, dummy_depths, cam, camera.second.resolution);
4386
4387 // Process pixel data (KEEP EXISTING LOGIC)
4388 std::string camera_label = camera.second.label;
4389
4390 for (auto b = 0; b < Nbands_launch; b++) {
4391
4392 camera.second.pixel_data[band_labels.at(b)].resize(camera.second.resolution.x * camera.second.resolution.y);
4393
4394 std::string data_label = "camera_" + camera_label + "_" + band_labels.at(b);
4395
4396 for (auto p = 0; p < camera.second.resolution.x * camera.second.resolution.y; p++) {
4397 camera.second.pixel_data.at(band_labels.at(b)).at(p) = radiation_camera.at(p * Nbands_launch + b);
4398 }
4399
4400 context->setGlobalData(data_label.c_str(), camera.second.pixel_data.at(band_labels.at(b)));
4401 }
4402
4403 //--- Pixel Labeling Trace ---//
4404
4405 // Compute tiling for pixel labeling (no antialiasing, 1 ray per pixel)
4406 RadiationCamera pixel_label_camera = camera.second;
4407 pixel_label_camera.antialiasing_samples = 1;
4408 std::vector<CameraTile> pixel_tiles = computeCameraTiles(pixel_label_camera, maxRays);
4409
4410 // Zero camera pixel buffers once before tile loop
4411 backend->zeroCameraPixelBuffers(camera.second.resolution);
4412
4413 // Launch pixel label rays (tiled or full)
4414 for (size_t tile_idx = 0; tile_idx < pixel_tiles.size(); tile_idx++) {
4415 const auto &tile = pixel_tiles[tile_idx];
4416
4417 // Build params (reuse buildCameraLaunchParams, antialiasing=1)
4418 helios::RayTracingLaunchParams params = buildCameraLaunchParams(pixel_label_camera, cam,
4419 1, // No antialiasing for pixel labeling
4420 tile.resolution, tile.offset);
4421
4422 // Progress message
4423 if (message_flag) {
4424 if (pixel_tiles.size() == 1) {
4425 std::cout << "Performing camera pixel labeling ray trace for camera " << camera.second.label << "..." << std::flush;
4426 } else {
4427 std::cout << "Performing camera pixel labeling ray trace for camera " << camera.second.label << " (tile " << (tile_idx + 1) << " of " << pixel_tiles.size() << ")..." << std::flush;
4428 }
4429 }
4430
4431 // Launch through backend
4432 backend->launchPixelLabelRays(params);
4433
4434 if (message_flag) {
4435 if (pixel_tiles.size() > 1) {
4436 std::cout << "\r" << std::string(120, ' ') << "\r" << std::flush;
4437 } else {
4438 std::cout << "done." << std::endl;
4439 }
4440 }
4441 }
4442
4443 if (message_flag && pixel_tiles.size() > 1) {
4444 std::cout << "Performing camera pixel labeling ray trace for camera " << camera.second.label << "...done." << std::endl;
4445 }
4446
4447 // Get pixel label results
4448 std::vector<float> dummy_pixel_data;
4449 backend->getCameraResults(dummy_pixel_data, camera.second.pixel_label_UUID, camera.second.pixel_depth, cam, camera.second.resolution);
4450
4451 // Pixel labels from GPU already contain Helios UUID+1 (1-indexed, 0=sky).
4452 // No conversion needed - the intersection programs store actual primitive UUIDs
4453 // directly from the geometry UUID buffers (patch_UUID, triangle_UUID, etc.).
4454
4455 // Store results in context (KEEP EXISTING LOGIC)
4456 std::string data_label = "camera_" + camera_label + "_pixel_UUID";
4457 context->setGlobalData(data_label.c_str(), camera.second.pixel_label_UUID);
4458
4459 data_label = "camera_" + camera_label + "_pixel_depth";
4460 context->setGlobalData(data_label.c_str(), camera.second.pixel_depth);
4461
4462 cam++;
4463 }
4464 } else {
4465 // if scattering is not enabled or all sources have zero flux, we still need to zero the camera buffers
4466 for (auto &camera: cameras) {
4467 for (auto b = 0; b < Nbands_launch; b++) {
4468 camera.second.pixel_data[band_labels.at(b)].resize(camera.second.resolution.x * camera.second.resolution.y);
4469
4470 std::string data_label = "camera_" + camera.second.label + "_" + band_labels.at(b);
4471
4472 for (auto p = 0; p < camera.second.resolution.x * camera.second.resolution.y; p++) {
4473 camera.second.pixel_data.at(band_labels.at(b)).at(p) = 0.f;
4474 }
4475 context->setGlobalData(data_label.c_str(), camera.second.pixel_data.at(band_labels.at(b)));
4476 }
4477 }
4478 }
4479 }
4480
4481 // Apply camera exposure based on each camera's exposure setting
4482 for (auto &camera: cameras) {
4483 camera.second.applyCameraExposure(context);
4484 }
4485
4486 // Apply camera white balance based on each camera's white_balance setting
4487 for (auto &camera: cameras) {
4488 camera.second.applyCameraWhiteBalance(context);
4489 }
4490
4491 // deposit any energy that is left to make sure we satisfy conservation of energy
4492
4493 // Extract ALL results from backend instead of old OptiX buffers
4495 backend->getRadiationResults(results);
4496
4497 std::vector<float> radiation_flux_data = results.radiation_in;
4498
4499 // Extract scatter buffer data from backend results
4500 TBS_top = results.scatter_buff_top;
4501 TBS_bottom = results.scatter_buff_bottom;
4502
4503 std::vector<uint> UUIDs_context_all = context->getAllUUIDs();
4504
4505 // Create indexer for result extraction
4506 RadiationBufferIndexer result_indexer(Nprimitives, Nbands_launch);
4507
4508 for (auto b = 0; b < Nbands_launch; b++) {
4509
4510 std::string prop = "radiation_flux_" + band_labels.at(b);
4511 std::vector<float> R(Nprimitives);
4512 for (size_t u = 0; u < Nprimitives; u++) {
4513 // Use BufferIndexer: [primitive][band]
4514 size_t ind = result_indexer(u, b);
4515 R.at(u) = radiation_flux_data.at(ind) + TBS_top.at(ind) + TBS_bottom.at(ind);
4516 }
4517 context->setPrimitiveData(context_UUIDs, prop.c_str(), R);
4518
4519 if (UUIDs_context_all.size() != Nprimitives) {
4520 for (uint UUID: UUIDs_context_all) {
4521 if (context->doesPrimitiveExist(UUID) && !context->doesPrimitiveDataExist(UUID, prop.c_str())) {
4522 context->setPrimitiveData(UUID, prop.c_str(), 0.f);
4523 }
4524 }
4525 }
4526 }
4527
4528 // Finalize any piggy-backed SIF excitation sets: now that radiation_flux_<_SIF_exc_*>
4529 // primitive data has been populated, copy it into the ExcitationSet apar_buffer and
4530 // clear the internal primitive data. A subsequent runBand() on SIF emission bands
4531 // will then skip re-running excitation (populateExcitationAPAR marks populated=true).
4532 for (ExcitationSet *exc : piggybacked_sets) {
4533 populateExcitationAPAR(*exc);
4534 }
4535}
4536
4538
4540 backend->getRadiationResults(results);
4541
4542 float Rsky = 0.f;
4543 for (size_t i = 0; i < results.sky_energy.size(); i++) {
4544 Rsky += results.sky_energy.at(i);
4545 }
4546 return Rsky;
4547}
4548
4550
4551 std::vector<float> total_flux;
4552 total_flux.resize(context->getPrimitiveCount(), 0.f);
4553
4554 for (const auto &band: radiation_bands) {
4555
4556 std::string label = band.first;
4557
4558 for (size_t u = 0; u < context_UUIDs.size(); u++) {
4559
4560 uint p = context_UUIDs.at(u);
4561
4562 std::string str = "radiation_flux_" + label;
4563
4564 float R;
4565 context->getPrimitiveData(p, str.c_str(), R);
4566 total_flux.at(u) += R;
4567 }
4568 }
4569
4570 return total_flux;
4571}
4572
4573
4575
4576 vec3 dir = view_direction;
4577 dir.normalize();
4578
4579 float Gtheta = 0;
4580 float total_area = 0;
4581 for (std::size_t u = 0; u < primitiveID.size(); u++) {
4582
4583 uint UUID = context_UUIDs.at(primitiveID.at(u));
4584
4585 vec3 normal = context->getPrimitiveNormal(UUID);
4586 float area = context->getPrimitiveArea(UUID);
4587
4588 Gtheta += fabsf(normal * dir) * area;
4589
4590 total_area += area;
4591 }
4592
4593 return Gtheta / total_area;
4594}
4595
4596void 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,
4597 float average_delta_e) {
4598
4599 std::ofstream file(file_path);
4600 if (!file.is_open()) {
4601 helios_runtime_error("ERROR (RadiationModel::exportColorCorrectionMatrixXML): Failed to open file for writing: " + file_path);
4602 }
4603
4604 // Determine matrix type (3x3 or 4x3)
4605 std::string matrix_type = "3x3";
4606 if (matrix.size() == 4 || (matrix.size() >= 3 && matrix[0].size() == 4)) {
4607 matrix_type = "4x3";
4608 }
4609
4610 // Write XML header with informative comments
4611 file << "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" << std::endl;
4612 file << "<!-- Camera Color Correction Matrix -->" << std::endl;
4613 file << "<!-- Source Image: " << source_image_path << " -->" << std::endl;
4614 file << "<!-- Camera Label: " << camera_label << " -->" << std::endl;
4615 file << "<!-- Colorboard Type: " << colorboard_type << " -->" << std::endl;
4616 if (average_delta_e >= 0.0f) {
4617 file << "<!-- Average Delta E: " << std::fixed << std::setprecision(2) << average_delta_e << " -->" << std::endl;
4618 }
4619 file << "<!-- Matrix Type: " << matrix_type << " -->" << std::endl;
4620 file << "<!-- Generated: " << getCurrentDateTime() << " -->" << std::endl;
4621
4622 // Write matrix data
4623 file << "<helios>" << std::endl;
4624 file << " <ColorCorrectionMatrix camera_label=\"" << camera_label << "\" matrix_type=\"" << matrix_type << "\">" << std::endl;
4625
4626 for (size_t i = 0; i < matrix.size(); i++) {
4627 file << " <row>";
4628 for (size_t j = 0; j < matrix[i].size(); j++) {
4629 file << std::fixed << std::setprecision(6) << matrix[i][j];
4630 if (j < matrix[i].size() - 1) {
4631 file << " ";
4632 }
4633 }
4634 file << "</row>" << std::endl;
4635 }
4636
4637 file << " </ColorCorrectionMatrix>" << std::endl;
4638 file << "</helios>" << std::endl;
4639
4640 file.close();
4641}
4642
4643std::string RadiationModel::getCurrentDateTime() {
4644 auto now = std::time(nullptr);
4645 auto tm = *std::localtime(&now);
4646 std::stringstream ss;
4647 ss << std::put_time(&tm, "%Y-%m-%d %H:%M:%S");
4648 return ss.str();
4649}
4650
4651std::vector<std::vector<float>> RadiationModel::loadColorCorrectionMatrixXML(const std::string &file_path, std::string &camera_label_out) {
4652
4653 std::ifstream file(file_path);
4654 if (!file.is_open()) {
4655 helios_runtime_error("ERROR (RadiationModel::loadColorCorrectionMatrixXML): Failed to open file for reading: " + file_path);
4656 }
4657
4658 std::vector<std::vector<float>> matrix;
4659 std::string line;
4660 bool in_matrix = false;
4661 std::string matrix_type = "";
4662
4663 while (std::getline(file, line)) {
4664 // Remove leading/trailing whitespace
4665 line.erase(0, line.find_first_not_of(" \t"));
4666 line.erase(line.find_last_not_of(" \t") + 1);
4667
4668 // Look for ColorCorrectionMatrix opening tag
4669 if (line.find("<ColorCorrectionMatrix") != std::string::npos) {
4670 in_matrix = true;
4671
4672 // Extract camera_label attribute
4673 size_t camera_start = line.find("camera_label=\"");
4674 if (camera_start != std::string::npos) {
4675 camera_start += 14; // Length of "camera_label=\""
4676 size_t camera_end = line.find("\"", camera_start);
4677 if (camera_end != std::string::npos) {
4678 camera_label_out = line.substr(camera_start, camera_end - camera_start);
4679 }
4680 }
4681
4682 // Extract matrix_type attribute
4683 size_t type_start = line.find("matrix_type=\"");
4684 if (type_start != std::string::npos) {
4685 type_start += 13; // Length of "matrix_type=\""
4686 size_t type_end = line.find("\"", type_start);
4687 if (type_end != std::string::npos) {
4688 matrix_type = line.substr(type_start, type_end - type_start);
4689 }
4690 }
4691 continue;
4692 }
4693
4694 // Look for ColorCorrectionMatrix closing tag
4695 if (line.find("</ColorCorrectionMatrix>") != std::string::npos) {
4696 in_matrix = false;
4697 break;
4698 }
4699
4700 // Parse row data
4701 if (in_matrix && line.find("<row>") != std::string::npos && line.find("</row>") != std::string::npos) {
4702 // Extract content between <row> and </row>
4703 size_t start = line.find("<row>") + 5;
4704 size_t end = line.find("</row>");
4705 std::string row_data = line.substr(start, end - start);
4706
4707 // Parse float values from row
4708 std::vector<float> row;
4709 std::istringstream iss(row_data);
4710 float value;
4711 while (iss >> value) {
4712 row.push_back(value);
4713 }
4714
4715 if (!row.empty()) {
4716 matrix.push_back(row);
4717 }
4718 }
4719 }
4720
4721 file.close();
4722
4723 // Validate loaded matrix
4724 if (matrix.empty()) {
4725 helios_runtime_error("ERROR (RadiationModel::loadColorCorrectionMatrixXML): No matrix data found in file: " + file_path);
4726 }
4727
4728 if (matrix.size() != 3) {
4729 helios_runtime_error("ERROR (RadiationModel::loadColorCorrectionMatrixXML): Invalid matrix size. Expected 3 rows, found " + std::to_string(matrix.size()) + " rows in file: " + file_path);
4730 }
4731
4732 // Validate matrix type consistency
4733 bool is_3x3 = (matrix[0].size() == 3 && matrix[1].size() == 3 && matrix[2].size() == 3);
4734 bool is_4x3 = (matrix[0].size() == 4 && matrix[1].size() == 4 && matrix[2].size() == 4);
4735
4736 if (!is_3x3 && !is_4x3) {
4737 helios_runtime_error("ERROR (RadiationModel::loadColorCorrectionMatrixXML): Invalid matrix dimensions. All rows must have either 3 or 4 elements. File: " + file_path);
4738 }
4739
4740 // Check matrix type attribute matches actual dimensions
4741 if (!matrix_type.empty()) {
4742 if ((matrix_type == "3x3" && !is_3x3) || (matrix_type == "4x3" && !is_4x3)) {
4743 helios_runtime_error("ERROR (RadiationModel::loadColorCorrectionMatrixXML): Matrix type attribute ('" + matrix_type + "') does not match actual matrix dimensions in file: " + file_path);
4744 }
4745 }
4746
4747 return matrix;
4748}
4749
4750std::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,
4751 bool print_quality_report, ColorCorrectionAlgorithm algorithm, const std::string &ccm_export_file_path) {
4752
4753 // Step 1: Validate camera exists and get pixel UUID data
4754 if (cameras.find(camera_label) == cameras.end()) {
4755 helios_runtime_error("ERROR (RadiationModel::autoCalibrateCameraImage): Camera '" + camera_label + "' does not exist. Make sure the camera was added to the radiation model.");
4756 }
4757
4758 // Get camera pixel UUID data from global data (needed for segmentation)
4759 std::string pixel_UUID_label = "camera_" + camera_label + "_pixel_UUID";
4760 if (!context->doesGlobalDataExist(pixel_UUID_label.c_str())) {
4761 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.");
4762 }
4763
4764 // Step 2: Detect all colorboard types using CameraCalibration helper
4765 CameraCalibration calibration(context);
4766 std::vector<std::string> colorboard_types;
4767 try {
4768 colorboard_types = calibration.detectColorBoardTypes();
4769 } catch (const std::exception &e) {
4770 helios_runtime_error("ERROR (RadiationModel::autoCalibrateCameraImage): Failed to detect colorboard types. " + std::string(e.what()));
4771 }
4772
4773 // Step 3: Get reference Lab values for all detected colorboards
4774 std::vector<CameraCalibration::LabColor> reference_lab_values;
4775 std::vector<std::string> colorboard_type_per_patch; // Track which colorboard each patch belongs to
4776
4777 for (const auto &colorboard_type: colorboard_types) {
4778 std::vector<CameraCalibration::LabColor> current_reference_values;
4779
4780 if (colorboard_type == "DGK") {
4781 current_reference_values = calibration.getReferenceLab_DGK();
4782 } else if (colorboard_type == "Calibrite") {
4783 current_reference_values = calibration.getReferenceLab_Calibrite();
4784 } else if (colorboard_type == "SpyderCHECKR") {
4785 current_reference_values = calibration.getReferenceLab_SpyderCHECKR();
4786 } else {
4787 helios_runtime_error("ERROR (RadiationModel::autoCalibrateCameraImage): Unsupported colorboard type '" + colorboard_type + "'.");
4788 }
4789
4790 // Add to combined list
4791 reference_lab_values.insert(reference_lab_values.end(), current_reference_values.begin(), current_reference_values.end());
4792
4793 // Track which colorboard type each patch belongs to
4794 for (size_t i = 0; i < current_reference_values.size(); i++) {
4795 colorboard_type_per_patch.push_back(colorboard_type);
4796 }
4797 }
4798
4799 // Step 4: Generate segmentation masks for all colorboard patches
4800 std::vector<uint> pixel_UUIDs;
4801 context->getGlobalData(pixel_UUID_label.c_str(), pixel_UUIDs);
4802 int2 camera_resolution = cameras.at(camera_label).resolution;
4803
4804 // Create segmentation masks by finding pixels that belong to colorboard patches
4805 std::map<int, std::vector<std::vector<bool>>> patch_masks;
4806 int global_patch_idx = 0; // Global patch index across all colorboards
4807
4808 for (const auto &colorboard_type: colorboard_types) {
4809 // Get the number of patches for this colorboard type
4810 int num_patches = 0;
4811 if (colorboard_type == "DGK") {
4812 num_patches = 18;
4813 } else if (colorboard_type == "Calibrite" || colorboard_type == "SpyderCHECKR") {
4814 num_patches = 24;
4815 }
4816
4817 // Generate masks for each patch in this colorboard
4818 for (int local_patch_idx = 0; local_patch_idx < num_patches; local_patch_idx++) {
4819 std::vector<std::vector<bool>> mask(camera_resolution.y, std::vector<bool>(camera_resolution.x, false));
4820
4821 // Find pixels that correspond to this colorboard patch
4822 for (int y = 0; y < camera_resolution.y; y++) {
4823 for (int x = 0; x < camera_resolution.x; x++) {
4824 int pixel_index = y * camera_resolution.x + x;
4825 uint pixel_UUID = pixel_UUIDs[pixel_index];
4826
4827 if (pixel_UUID > 0) { // Valid primitive
4828 pixel_UUID--; // Convert from 1-based to 0-based indexing
4829
4830 // Check if this primitive belongs to this specific colorboard patch
4831 std::string colorboard_data_label = "colorboard_" + colorboard_type;
4832 if (context->doesPrimitiveDataExist(pixel_UUID, colorboard_data_label.c_str())) {
4833 uint patch_id;
4834 context->getPrimitiveData(pixel_UUID, colorboard_data_label.c_str(), patch_id);
4835 // Patch indices are 0-based, compare directly
4836 if ((int) patch_id == local_patch_idx) {
4837 mask[y][x] = true;
4838 }
4839 }
4840 }
4841 }
4842 }
4843
4844 patch_masks[global_patch_idx] = mask;
4845 global_patch_idx++;
4846 }
4847 }
4848
4849 // Step 5: Extract RGB colors from processed camera data (same source as writeCameraImage)
4850 // Use the same data source that writeCameraImage() uses: cameras.pixel_data
4851 std::vector<float> red_data, green_data, blue_data;
4852
4853 // Check if bands exist in camera
4854 auto &camera_bands = cameras.at(camera_label).band_labels;
4855 if (std::find(camera_bands.begin(), camera_bands.end(), red_band_label) == camera_bands.end()) {
4856 helios_runtime_error("ERROR (RadiationModel::autoCalibrateCameraImage): Red band '" + red_band_label + "' not found in camera '" + camera_label + "'.");
4857 }
4858 if (std::find(camera_bands.begin(), camera_bands.end(), green_band_label) == camera_bands.end()) {
4859 helios_runtime_error("ERROR (RadiationModel::autoCalibrateCameraImage): Green band '" + green_band_label + "' not found in camera '" + camera_label + "'.");
4860 }
4861 if (std::find(camera_bands.begin(), camera_bands.end(), blue_band_label) == camera_bands.end()) {
4862 helios_runtime_error("ERROR (RadiationModel::autoCalibrateCameraImage): Blue band '" + blue_band_label + "' not found in camera '" + camera_label + "'.");
4863 }
4864
4865 // Read processed camera data (same as writeCameraImage uses)
4866 red_data = cameras.at(camera_label).pixel_data.at(red_band_label);
4867 green_data = cameras.at(camera_label).pixel_data.at(green_band_label);
4868 blue_data = cameras.at(camera_label).pixel_data.at(blue_band_label);
4869
4870 // Check data range and normalize if needed
4871 float max_r = *std::max_element(red_data.begin(), red_data.end());
4872 float max_g = *std::max_element(green_data.begin(), green_data.end());
4873 float max_b = *std::max_element(blue_data.begin(), blue_data.end());
4874
4875 // Normalize camera data to [0,1] range if values are > 1
4876 float scale_factor = 1.0f;
4877 if (max_r > 1.0f || max_g > 1.0f || max_b > 1.0f) {
4878 scale_factor = 1.0f / std::max({max_r, max_g, max_b});
4879
4880 for (size_t i = 0; i < red_data.size(); i++) {
4881 red_data[i] *= scale_factor;
4882 green_data[i] *= scale_factor;
4883 blue_data[i] *= scale_factor;
4884 }
4885 }
4886
4887 std::vector<helios::vec3> measured_rgb_values;
4888 int visible_patches = 0;
4889
4890 for (const auto &[patch_idx, mask]: patch_masks) {
4891 float sum_r = 0.0f, sum_g = 0.0f, sum_b = 0.0f;
4892 int pixel_count = 0;
4893
4894 // Average RGB values over all pixels in this patch
4895 for (int y = 0; y < camera_resolution.y; y++) {
4896 for (int x = 0; x < camera_resolution.x; x++) {
4897 if (mask[y][x]) {
4898 int pixel_index = y * camera_resolution.x + x;
4899 sum_r += red_data[pixel_index];
4900 sum_g += green_data[pixel_index];
4901 sum_b += blue_data[pixel_index];
4902 pixel_count++;
4903 }
4904 }
4905 }
4906
4907 if (pixel_count > 10) { // Only consider patches with sufficient pixels
4908 helios::vec3 avg_rgb = make_vec3(sum_r / pixel_count, sum_g / pixel_count, sum_b / pixel_count);
4909 measured_rgb_values.push_back(avg_rgb);
4910
4911 visible_patches++;
4912 } else {
4913 // Add placeholder for missing patch
4914 measured_rgb_values.push_back(make_vec3(0, 0, 0));
4915 }
4916 }
4917
4918 // Convert measured RGB to Lab and calculate correction matrix
4919 std::vector<CameraCalibration::LabColor> measured_lab_values;
4920 for (const auto &rgb: measured_rgb_values) {
4921 if (rgb.magnitude() > 0) { // Only process non-zero values
4922 measured_lab_values.push_back(calibration.rgbToLab(rgb));
4923 }
4924 }
4925
4926 // Calculate color correction matrix based on selected algorithm
4927 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}};
4928
4929 // Report which algorithm is being used
4930 std::string algorithm_name;
4931 switch (algorithm) {
4933 algorithm_name = "Diagonal scaling (white balance only)";
4934 break;
4936 algorithm_name = "3x3 matrix with auto-fallback to diagonal";
4937 break;
4939 algorithm_name = "3x3 matrix (forced)";
4940 break;
4941 }
4942
4943 if (measured_lab_values.size() >= 6 && reference_lab_values.size() >= 6) {
4944 // Convert reference Lab back to RGB for matrix fitting
4945 std::vector<helios::vec3> target_rgb;
4946
4947 for (size_t i = 0; i < reference_lab_values.size(); i++) {
4948 CameraCalibration::LabColor ref_lab = reference_lab_values[i];
4949 helios::vec3 ref_rgb = calibration.labToRgb(ref_lab);
4950 target_rgb.push_back(ref_rgb);
4951 }
4952
4953 // Build matrices for least squares: M * Measured = Target
4954 // We need to solve for M where M is 3x3 matrix
4955 // This becomes: M = Target * Measured^T * (Measured * Measured^T)^(-1)
4956
4957 // Collect valid patches for matrix calculation
4958 std::vector<helios::vec3> valid_measured, valid_target;
4959 std::vector<float> patch_weights;
4960
4961 for (size_t i = 0; i < std::min(measured_rgb_values.size(), target_rgb.size()); i++) {
4962 if (measured_rgb_values[i].magnitude() > 0.01f) {
4963 valid_measured.push_back(measured_rgb_values[i]);
4964 valid_target.push_back(target_rgb[i]);
4965
4966 // Perceptually-weighted patch selection based on colour-science best practices
4967 float weight = 1.0f;
4968
4969 // Neutral patches (white to black series) - highest priority for white balance
4970 if (i >= 18 && i <= 23) {
4971 // White and light grays get highest weight (most visually important)
4972 if (i == 18)
4973 weight = 5.0f; // White patch - critical for white balance
4974 else if (i == 19)
4975 weight = 4.0f; // Light gray - very important for tone mapping
4976 else
4977 weight = 3.0f; // Darker grays - important for contrast
4978 }
4979 // Primary color patches - important for color accuracy
4980 else if (i == 14 || i == 13 || i == 12)
4981 weight = 3.0f; // Red, Green, Blue primaries
4982
4983 // Skin tone approximates - patches that represent common skin tones
4984 else if (i == 3 || i == 10)
4985 weight = 2.5f; // Foliage and Yellow Green (skin-like hues)
4986
4987 // Well-lit patches get higher weight based on luminance
4988 helios::vec3 measured_rgb = measured_rgb_values[i];
4989 float luminance = 0.299f * measured_rgb.x + 0.587f * measured_rgb.y + 0.114f * measured_rgb.z;
4990
4991 // Boost weight for brighter patches (they're more visually prominent)
4992 if (luminance > 0.6f)
4993 weight *= 1.5f;
4994 else if (luminance < 0.2f)
4995 weight *= 0.7f; // Reduce weight for very dark patches
4996
4997 // Additional quality checks for measured RGB values
4998 // Reduce weight for patches that seem poorly measured (extreme values)
4999 if (measured_rgb.magnitude() > 1.2f || measured_rgb.magnitude() < 0.1f) {
5000 weight *= 0.5f; // Reduce weight for suspicious measurements
5001 }
5002
5003 patch_weights.push_back(weight);
5004 }
5005 }
5006
5007 if (valid_measured.size() >= 6 && algorithm != ColorCorrectionAlgorithm::DIAGONAL_ONLY) {
5008
5009 // Compute robustly regularized weighted least squares solution
5010 // Use adaptive regularization to prevent extreme matrix coefficients
5011 bool matrix_valid = true;
5012
5013 // Try moderate regularization values - avoid extreme regularization that creates bad matrices
5014 std::vector<float> lambda_values = {0.01f, 0.05f, 0.1f, 0.15f, 0.2f};
5015 int lambda_attempt = 0;
5016
5017 while (lambda_attempt < lambda_values.size()) {
5018 float lambda = lambda_values[lambda_attempt];
5019 matrix_valid = true;
5020
5021 for (int row = 0; row < 3; row++) {
5022 // Build weighted normal equations with adaptive regularization
5023 float ATA[3][3] = {{0}}; // A^T * W * A + λI
5024 float ATb[3] = {0}; // A^T * W * b
5025
5026 for (size_t i = 0; i < valid_measured.size(); i++) {
5027 float weight = patch_weights[i];
5028 helios::vec3 m = valid_measured[i]; // measured RGB
5029 float target_val = (row == 0) ? valid_target[i].x : (row == 1) ? valid_target[i].y : valid_target[i].z;
5030
5031 // Update normal equations
5032 ATA[0][0] += weight * m.x * m.x;
5033 ATA[0][1] += weight * m.x * m.y;
5034 ATA[0][2] += weight * m.x * m.z;
5035 ATA[1][0] += weight * m.y * m.x;
5036 ATA[1][1] += weight * m.y * m.y;
5037 ATA[1][2] += weight * m.y * m.z;
5038 ATA[2][0] += weight * m.z * m.x;
5039 ATA[2][1] += weight * m.z * m.y;
5040 ATA[2][2] += weight * m.z * m.z;
5041
5042 ATb[0] += weight * m.x * target_val;
5043 ATb[1] += weight * m.y * target_val;
5044 ATb[2] += weight * m.z * target_val;
5045 }
5046
5047 // Add color-preserving regularization
5048 // Stronger regularization on diagonal (preserves primary colors)
5049 // Weaker regularization on off-diagonal (allows some color mixing)
5050 float diag_reg = lambda * 2.0f; // Stronger on diagonal
5051 float offdiag_reg = lambda * 0.5f; // Weaker on off-diagonal
5052
5053 ATA[0][0] += diag_reg; // Red preservation
5054 ATA[1][1] += diag_reg; // Green preservation
5055 ATA[2][2] += diag_reg; // Blue preservation
5056
5057 // Light off-diagonal regularization to prevent extreme color mixing
5058 ATA[0][1] += offdiag_reg;
5059 ATA[1][0] += offdiag_reg;
5060 ATA[0][2] += offdiag_reg;
5061 ATA[2][0] += offdiag_reg;
5062 ATA[1][2] += offdiag_reg;
5063 ATA[2][1] += offdiag_reg;
5064
5065 // Solve regularized 3x3 system using Cramer's rule
5066 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]);
5067
5068 if (fabs(det) < 1e-3f) {
5070 matrix_valid = false;
5071 break;
5072 }
5073 }
5074
5075 float inv_det = 1.0f / det;
5076 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]));
5077
5078 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]));
5079
5080 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]));
5081 }
5082
5083 // Validate computed matrix elements are reasonable
5084 if (matrix_valid) {
5085 bool elements_reasonable = true;
5086 for (int i = 0; i < 3; i++) {
5087 for (int j = 0; j < 3; j++) {
5088 if (fabs(correction_matrix[i][j]) > 5.0f) {
5090 elements_reasonable = false;
5091 break;
5092 }
5093 }
5094 }
5095 if (!elements_reasonable)
5096 break;
5097 }
5098 matrix_valid = elements_reasonable;
5099 }
5100
5101 if (matrix_valid) {
5102 break; // Success!
5103 } else {
5104 lambda_attempt++;
5105 }
5106 }
5107
5108 if (!matrix_valid) {
5109
5110 // Enhanced perceptually-weighted diagonal correction
5111 // Calculate weighted averages for each color channel
5112 float total_weight = 0.0f;
5113 helios::vec3 weighted_correction = make_vec3(0, 0, 0);
5114
5115 for (size_t i = 0; i < valid_measured.size(); i++) {
5116 float weight = patch_weights[i];
5117 helios::vec3 measured = valid_measured[i];
5118 helios::vec3 target = valid_target[i];
5119
5120 // Calculate per-channel correction factors
5121 if (measured.x > 0.01f && measured.y > 0.01f && measured.z > 0.01f) {
5122 helios::vec3 channel_correction = make_vec3(target.x / measured.x, target.y / measured.y, target.z / measured.z);
5123
5124 weighted_correction.x += weight * channel_correction.x;
5125 weighted_correction.y += weight * channel_correction.y;
5126 weighted_correction.z += weight * channel_correction.z;
5127 total_weight += weight;
5128 }
5129 }
5130
5131 if (total_weight > 0.1f) {
5132 // Apply weighted average correction factors
5133 correction_matrix[0][0] = weighted_correction.x / total_weight;
5134 correction_matrix[1][1] = weighted_correction.y / total_weight;
5135 correction_matrix[2][2] = weighted_correction.z / total_weight;
5136
5137 // Apply conservative limits
5138 correction_matrix[0][0] = std::max(0.5f, std::min(2.0f, correction_matrix[0][0]));
5139 correction_matrix[1][1] = std::max(0.5f, std::min(2.0f, correction_matrix[1][1]));
5140 correction_matrix[2][2] = std::max(0.5f, std::min(2.0f, correction_matrix[2][2]));
5141 }
5142 }
5143
5144 if (!matrix_valid || algorithm == ColorCorrectionAlgorithm::DIAGONAL_ONLY) {
5145 // Use diagonal correction using white patch
5146 correction_matrix = {{1.0f, 0.0f, 0.0f}, {0.0f, 1.0f, 0.0f}, {0.0f, 0.0f, 1.0f}};
5147
5148 // Enhanced perceptually-weighted diagonal correction using all valid patches
5149 // This is more robust than using just the white patch
5150 if (valid_measured.size() > 0 && patch_weights.size() == valid_measured.size()) {
5151 float total_weight = 0.0f;
5152 helios::vec3 weighted_measured_avg = make_vec3(0, 0, 0);
5153 helios::vec3 weighted_target_avg = make_vec3(0, 0, 0);
5154
5155 // Compute weighted averages using perceptual patch weights
5156 for (size_t i = 0; i < valid_measured.size(); i++) {
5157 float weight = patch_weights[i];
5158 weighted_measured_avg = weighted_measured_avg + weight * valid_measured[i];
5159 weighted_target_avg = weighted_target_avg + weight * valid_target[i];
5160 total_weight += weight;
5161 }
5162
5163 if (total_weight > 0) {
5164 weighted_measured_avg = weighted_measured_avg / total_weight;
5165 weighted_target_avg = weighted_target_avg / total_weight;
5166
5167 if (weighted_measured_avg.x > 0.05f && weighted_measured_avg.y > 0.05f && weighted_measured_avg.z > 0.05f) {
5168 correction_matrix[0][0] = weighted_target_avg.x / weighted_measured_avg.x;
5169 correction_matrix[1][1] = weighted_target_avg.y / weighted_measured_avg.y;
5170 correction_matrix[2][2] = weighted_target_avg.z / weighted_measured_avg.z;
5171
5172 // Apply conservative limits for stability
5173 correction_matrix[0][0] = std::max(0.5f, std::min(2.0f, correction_matrix[0][0]));
5174 correction_matrix[1][1] = std::max(0.5f, std::min(2.0f, correction_matrix[1][1]));
5175 correction_matrix[2][2] = std::max(0.5f, std::min(2.0f, correction_matrix[2][2]));
5176
5177 } else {
5178 // Fallback to original white-patch method
5179 size_t white_idx = 18;
5180 if (white_idx < measured_rgb_values.size() && white_idx < target_rgb.size()) {
5181 helios::vec3 measured_white = measured_rgb_values[white_idx];
5182 helios::vec3 target_white = target_rgb[white_idx];
5183 if (measured_white.x > 0.05f && measured_white.y > 0.05f && measured_white.z > 0.05f) {
5184 correction_matrix[0][0] = std::max(0.5f, std::min(2.0f, target_white.x / measured_white.x));
5185 correction_matrix[1][1] = std::max(0.5f, std::min(2.0f, target_white.y / measured_white.y));
5186 correction_matrix[2][2] = std::max(0.5f, std::min(2.0f, target_white.z / measured_white.z));
5187 }
5188 }
5189 }
5190 }
5191 } else {
5192 // Original simple approach as ultimate fallback
5193 size_t white_idx = 18;
5194 if (white_idx < measured_rgb_values.size() && white_idx < target_rgb.size()) {
5195 helios::vec3 measured_white = measured_rgb_values[white_idx];
5196 helios::vec3 target_white = target_rgb[white_idx];
5197 if (measured_white.x > 0.05f && measured_white.y > 0.05f && measured_white.z > 0.05f) {
5198 correction_matrix[0][0] = std::max(0.5f, std::min(2.0f, target_white.x / measured_white.x));
5199 correction_matrix[1][1] = std::max(0.5f, std::min(2.0f, target_white.y / measured_white.y));
5200 correction_matrix[2][2] = std::max(0.5f, std::min(2.0f, target_white.z / measured_white.z));
5201 }
5202 }
5203 }
5204 }
5205 } else if (algorithm == ColorCorrectionAlgorithm::DIAGONAL_ONLY) {
5206 // Apply diagonal correction using white patch
5207 size_t white_idx = 18;
5208 if (white_idx < measured_rgb_values.size() && white_idx < target_rgb.size() && measured_rgb_values[white_idx].magnitude() > 0) {
5209
5210 helios::vec3 measured_white = measured_rgb_values[white_idx];
5211 helios::vec3 target_white = target_rgb[white_idx];
5212
5213 if (measured_white.x > 0.05f && measured_white.y > 0.05f && measured_white.z > 0.05f) {
5214 correction_matrix[0][0] = target_white.x / measured_white.x;
5215 correction_matrix[1][1] = target_white.y / measured_white.y;
5216 correction_matrix[2][2] = target_white.z / measured_white.z;
5217
5218 // Apply limits
5219 correction_matrix[0][0] = std::max(0.5f, std::min(2.0f, correction_matrix[0][0]));
5220 correction_matrix[1][1] = std::max(0.5f, std::min(2.0f, correction_matrix[1][1]));
5221 correction_matrix[2][2] = std::max(0.5f, std::min(2.0f, correction_matrix[2][2]));
5222 }
5223 }
5224 } else {
5225 std::cout << "Insufficient valid patches (" << valid_measured.size() << " available), using identity matrix" << std::endl;
5226 }
5227 } else if (algorithm == ColorCorrectionAlgorithm::DIAGONAL_ONLY && measured_lab_values.size() > 0) {
5228 // Apply diagonal correction using white patch
5229 size_t white_idx = 18;
5230 if (white_idx < measured_rgb_values.size() && white_idx < reference_lab_values.size() && measured_rgb_values[white_idx].magnitude() > 0) {
5231
5232 CameraCalibration::LabColor ref_lab = reference_lab_values[white_idx];
5233 helios::vec3 target_white = calibration.labToRgb(ref_lab);
5234 helios::vec3 measured_white = measured_rgb_values[white_idx];
5235
5236 if (measured_white.x > 0.05f && measured_white.y > 0.05f && measured_white.z > 0.05f) {
5237 correction_matrix[0][0] = target_white.x / measured_white.x;
5238 correction_matrix[1][1] = target_white.y / measured_white.y;
5239 correction_matrix[2][2] = target_white.z / measured_white.z;
5240
5241 // Apply limits
5242 correction_matrix[0][0] = std::max(0.5f, std::min(2.0f, correction_matrix[0][0]));
5243 correction_matrix[1][1] = std::max(0.5f, std::min(2.0f, correction_matrix[1][1]));
5244 correction_matrix[2][2] = std::max(0.5f, std::min(2.0f, correction_matrix[2][2]));
5245 }
5246 }
5247 } else {
5248 std::cout << "Insufficient patches for correction (" << measured_lab_values.size() << " available), using identity matrix" << std::endl;
5249 }
5250
5251 // Generate quality of fit report if requested
5252 if (print_quality_report) {
5253 std::cout << "\n========== COLOR CALIBRATION QUALITY REPORT ==========" << std::endl;
5254 std::cout << "Colorboard types: ";
5255 for (size_t i = 0; i < colorboard_types.size(); i++) {
5256 std::cout << colorboard_types[i];
5257 if (i < colorboard_types.size() - 1) {
5258 std::cout << ", ";
5259 }
5260 }
5261 std::cout << std::endl;
5262 std::cout << "Number of patches analyzed: " << visible_patches << std::endl;
5263 std::cout << "Algorithm used: " << algorithm_name << std::endl;
5264
5265 // Display matrix conditioning information
5266 bool is_diagonal_only = true;
5267 for (int i = 0; i < 3; i++) {
5268 for (int j = 0; j < 3; j++) {
5269 if (i != j && fabs(correction_matrix[i][j]) > 1e-6f) {
5270 is_diagonal_only = false;
5271 break;
5272 }
5273 }
5274 if (!is_diagonal_only)
5275 break;
5276 }
5277
5278 if (is_diagonal_only) {
5279 std::cout << "Color correction factors applied: R=" << correction_matrix[0][0] << ", G=" << correction_matrix[1][1] << ", B=" << correction_matrix[2][2] << std::endl;
5280 std::cout << "Matrix type: Diagonal (white balance only)" << std::endl;
5281 } else {
5282 std::cout << "Full 3x3 color correction matrix applied:" << std::endl;
5283 for (int i = 0; i < 3; i++) {
5284 std::cout << "[" << std::fixed << std::setprecision(4);
5285 for (int j = 0; j < 3; j++) {
5286 std::cout << std::setw(8) << correction_matrix[i][j];
5287 if (j < 2)
5288 std::cout << " ";
5289 }
5290 std::cout << "]" << std::endl;
5291 }
5292 std::cout << "Matrix type: Full 3x3 (corrects color casts and chromatic errors)" << std::endl;
5293
5294 // Calculate matrix determinant for conditioning info
5295 float det = correction_matrix[0][0] * (correction_matrix[1][1] * correction_matrix[2][2] - correction_matrix[1][2] * correction_matrix[2][1]) -
5296 correction_matrix[0][1] * (correction_matrix[1][0] * correction_matrix[2][2] - correction_matrix[1][2] * correction_matrix[2][0]) +
5297 correction_matrix[0][2] * (correction_matrix[1][0] * correction_matrix[2][1] - correction_matrix[1][1] * correction_matrix[2][0]);
5298
5299 std::cout << "Matrix determinant: " << std::scientific << std::setprecision(3) << det << std::endl;
5300 if (fabs(det) > 0.1f) {
5301 std::cout << "Matrix conditioning: Good (well-conditioned)" << std::endl;
5302 } else if (fabs(det) > 0.01f) {
5303 std::cout << "Matrix conditioning: Fair (moderately conditioned)" << std::endl;
5304 } else {
5305 std::cout << "Matrix conditioning: Poor (ill-conditioned)" << std::endl;
5306 }
5307 std::cout << std::fixed; // Reset formatting
5308 }
5309
5310 // Calculate and display quality metrics for each patch
5311 double total_delta_e = 0.0;
5312 int valid_patches = 0;
5313
5314 std::cout << "\nPer-patch analysis (after correction):" << std::endl;
5315 std::cout << "Patch | Corrected RGB | Reference RGB | Delta E " << std::endl;
5316 std::cout << "------|--------------------|--------------------|---------" << std::endl;
5317
5318 for (size_t i = 0; i < std::min(measured_rgb_values.size(), reference_lab_values.size()); i++) {
5319 if (measured_rgb_values[i].magnitude() > 0) {
5320 // Apply color correction to measured RGB values (with optional affine terms)
5321 helios::vec3 measured_rgb = measured_rgb_values[i];
5322 float corrected_r = correction_matrix[0][0] * measured_rgb.x + correction_matrix[0][1] * measured_rgb.y + correction_matrix[0][2] * measured_rgb.z;
5323 float corrected_g = correction_matrix[1][0] * measured_rgb.x + correction_matrix[1][1] * measured_rgb.y + correction_matrix[1][2] * measured_rgb.z;
5324 float corrected_b = correction_matrix[2][0] * measured_rgb.x + correction_matrix[2][1] * measured_rgb.y + correction_matrix[2][2] * measured_rgb.z;
5325
5326
5327 // Clamp corrected values to [0,1] - DISABLED FOR TESTING
5328 // corrected_r = std::max(0.0f, std::min(1.0f, corrected_r));
5329 // corrected_g = std::max(0.0f, std::min(1.0f, corrected_g));
5330 // corrected_b = std::max(0.0f, std::min(1.0f, corrected_b));
5331
5332 helios::vec3 corrected_rgb = make_vec3(corrected_r, corrected_g, corrected_b);
5333
5334 // Convert corrected RGB to Lab
5335 CameraCalibration::LabColor corrected_lab = calibration.rgbToLab(corrected_rgb);
5336 CameraCalibration::LabColor reference_lab = reference_lab_values[i];
5337
5338 // Calculate Delta E between corrected and reference
5339 // Use ΔE2000 for better perceptual color difference assessment
5340 double delta_E = calibration.deltaE2000(corrected_lab, reference_lab);
5341
5342 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 << ") | ";
5343
5344 helios::vec3 ref_rgb = calibration.labToRgb(reference_lab);
5345 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;
5346
5347 total_delta_e += delta_E;
5348 valid_patches++;
5349 }
5350 }
5351
5352 // Overall statistics
5353 double mean_delta_e = total_delta_e / valid_patches;
5354 std::cout << "\n========== OVERALL CALIBRATION QUALITY ==========" << std::endl;
5355 std::cout << "Mean Delta E: " << std::fixed << std::setprecision(2) << mean_delta_e << std::endl;
5356
5357 std::cout << "======================================================\n" << std::endl;
5358 }
5359
5360 // Step 7: Apply correction to entire image with same pixel ordering as writeCameraImage
5361 std::vector<helios::RGBcolor> corrected_pixels;
5362 corrected_pixels.resize(red_data.size());
5363
5364 // Apply correction using the same pixel transformation as writeCameraImage uses
5365 for (int j = 0; j < camera_resolution.y; j++) {
5366 for (int i = 0; i < camera_resolution.x; i++) {
5367 // Get pixel from source data (no flip)
5368 int source_index = j * camera_resolution.x + i;
5369 float r = red_data[source_index];
5370 float g = green_data[source_index];
5371 float b = blue_data[source_index];
5372
5373 // Apply correction matrix (with optional affine terms)
5374 float corrected_r = correction_matrix[0][0] * r + correction_matrix[0][1] * g + correction_matrix[0][2] * b;
5375 float corrected_g = correction_matrix[1][0] * r + correction_matrix[1][1] * g + correction_matrix[1][2] * b;
5376 float corrected_b = correction_matrix[2][0] * r + correction_matrix[2][1] * g + correction_matrix[2][2] * b;
5377
5378
5379 // Clamp values to [0,1] - DISABLED FOR TESTING
5380 // corrected_r = std::max(0.0f, std::min(1.0f, corrected_r));
5381 // corrected_g = std::max(0.0f, std::min(1.0f, corrected_g));
5382 // corrected_b = std::max(0.0f, std::min(1.0f, corrected_b));
5383
5384 // Apply same coordinate transformation as writeCameraImage
5385 uint ii = camera_resolution.x - i - 1; // Horizontal flip
5386 uint jj = camera_resolution.y - j - 1; // Vertical flip
5387 uint dest_index = jj * camera_resolution.x + ii;
5388
5389 corrected_pixels[dest_index] = make_RGBcolor(corrected_r, corrected_g, corrected_b);
5390 }
5391 }
5392
5393 // Step 8: Write corrected image using writeJPEG
5394 std::string output_path = output_file_path;
5395 if (output_path.empty()) {
5396 output_path = "auto_calibrated_" + camera_label + ".jpg";
5397 }
5398
5399 try {
5400 helios::writeJPEG(output_path, camera_resolution.x, camera_resolution.y, corrected_pixels);
5401 std::cout << "Wrote corrected image to: " << output_path << std::endl;
5402 } catch (const std::exception &e) {
5403 helios_runtime_error("ERROR (RadiationModel::autoCalibrateCameraImage): Failed to write corrected image. " + std::string(e.what()));
5404 }
5405
5406 // Export CCM to XML file if requested
5407 if (!ccm_export_file_path.empty()) {
5408 try {
5409 // Calculate quality metrics for export (even if not printed)
5410 double total_delta_e = 0.0;
5411 int valid_patches = 0;
5412
5413 for (size_t i = 0; i < std::min(measured_rgb_values.size(), reference_lab_values.size()); i++) {
5414 if (measured_rgb_values[i].magnitude() > 0) {
5415 // Apply color correction to measured RGB values
5416 helios::vec3 measured_rgb = measured_rgb_values[i];
5417 float corrected_r = correction_matrix[0][0] * measured_rgb.x + correction_matrix[0][1] * measured_rgb.y + correction_matrix[0][2] * measured_rgb.z;
5418 float corrected_g = correction_matrix[1][0] * measured_rgb.x + correction_matrix[1][1] * measured_rgb.y + correction_matrix[1][2] * measured_rgb.z;
5419 float corrected_b = correction_matrix[2][0] * measured_rgb.x + correction_matrix[2][1] * measured_rgb.y + correction_matrix[2][2] * measured_rgb.z;
5420
5421 helios::vec3 corrected_rgb = make_vec3(corrected_r, corrected_g, corrected_b);
5422
5423 // Convert corrected RGB to Lab
5424 CameraCalibration::LabColor corrected_lab = calibration.rgbToLab(corrected_rgb);
5425 CameraCalibration::LabColor reference_lab = reference_lab_values[i];
5426
5427 // Calculate Delta E between corrected and reference
5428 double delta_E = calibration.deltaE2000(corrected_lab, reference_lab);
5429 total_delta_e += delta_E;
5430 valid_patches++;
5431 }
5432 }
5433
5434 double mean_delta_e = (valid_patches > 0) ? (total_delta_e / valid_patches) : -1.0;
5435
5436 // Export CCM to XML file
5437 // Concatenate all colorboard types into a single string
5438 std::string colorboard_types_str;
5439 for (size_t i = 0; i < colorboard_types.size(); i++) {
5440 colorboard_types_str += colorboard_types[i];
5441 if (i < colorboard_types.size() - 1) {
5442 colorboard_types_str += ", ";
5443 }
5444 }
5445 exportColorCorrectionMatrixXML(ccm_export_file_path, camera_label, correction_matrix, output_path, colorboard_types_str, (float) mean_delta_e);
5446
5447 std::cout << "Exported color correction matrix to: " << ccm_export_file_path << std::endl;
5448 } catch (const std::exception &e) {
5449 helios_runtime_error("ERROR (RadiationModel::autoCalibrateCameraImage): Failed to export CCM to XML. " + std::string(e.what()));
5450 }
5451 }
5452
5453 return output_path;
5454}
5455
5456void 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) {
5457
5458 // Step 1: Validate camera exists
5459 if (cameras.find(camera_label) == cameras.end()) {
5460 helios_runtime_error("ERROR (RadiationModel::applyCameraColorCorrectionMatrix): Camera '" + camera_label + "' does not exist. Make sure the camera was added to the radiation model.");
5461 }
5462
5463 // Step 2: Validate band labels exist in camera
5464 auto &camera_bands = cameras.at(camera_label).band_labels;
5465 if (std::find(camera_bands.begin(), camera_bands.end(), red_band_label) == camera_bands.end()) {
5466 helios_runtime_error("ERROR (RadiationModel::applyCameraColorCorrectionMatrix): Red band '" + red_band_label + "' not found in camera '" + camera_label + "'.");
5467 }
5468 if (std::find(camera_bands.begin(), camera_bands.end(), green_band_label) == camera_bands.end()) {
5469 helios_runtime_error("ERROR (RadiationModel::applyCameraColorCorrectionMatrix): Green band '" + green_band_label + "' not found in camera '" + camera_label + "'.");
5470 }
5471 if (std::find(camera_bands.begin(), camera_bands.end(), blue_band_label) == camera_bands.end()) {
5472 helios_runtime_error("ERROR (RadiationModel::applyCameraColorCorrectionMatrix): Blue band '" + blue_band_label + "' not found in camera '" + camera_label + "'.");
5473 }
5474
5475 // Step 3: Load color correction matrix from XML file
5476 std::string loaded_camera_label;
5477 std::vector<std::vector<float>> correction_matrix;
5478 try {
5479 correction_matrix = loadColorCorrectionMatrixXML(ccm_file_path, loaded_camera_label);
5480 } catch (const std::exception &e) {
5481 helios_runtime_error("ERROR (RadiationModel::applyCameraColorCorrectionMatrix): Failed to load CCM from XML file. " + std::string(e.what()));
5482 }
5483
5484 // Step 4: Validate matrix dimensions (should be 3x3 or 4x3)
5485 if (correction_matrix.size() != 3) {
5486 helios_runtime_error("ERROR (RadiationModel::applyCameraColorCorrectionMatrix): Invalid matrix dimensions. Expected 3x3 or 4x3 matrix, got " + std::to_string(correction_matrix.size()) + " rows.");
5487 }
5488
5489 bool is_3x3 = (correction_matrix[0].size() == 3);
5490 bool is_4x3 = (correction_matrix[0].size() == 4);
5491
5492 if (!is_3x3 && !is_4x3) {
5493 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()) +
5494 " matrix.");
5495 }
5496
5497 // Step 5: Get camera data (same approach as applyImageProcessingPipeline)
5498 std::vector<float> &red_data = cameras.at(camera_label).pixel_data.at(red_band_label);
5499 std::vector<float> &green_data = cameras.at(camera_label).pixel_data.at(green_band_label);
5500 std::vector<float> &blue_data = cameras.at(camera_label).pixel_data.at(blue_band_label);
5501
5502 int2 camera_resolution = cameras.at(camera_label).resolution;
5503 size_t pixel_count = red_data.size();
5504
5505 // Step 6: Apply color correction matrix to all pixels in-place
5506 for (size_t i = 0; i < pixel_count; i++) {
5507 float r = red_data[i];
5508 float g = green_data[i];
5509 float b = blue_data[i];
5510
5511 // Apply color correction matrix (3x3 or 4x3)
5512 if (is_3x3) {
5513 // Standard 3x3 matrix transformation
5514 red_data[i] = correction_matrix[0][0] * r + correction_matrix[0][1] * g + correction_matrix[0][2] * b;
5515 green_data[i] = correction_matrix[1][0] * r + correction_matrix[1][1] * g + correction_matrix[1][2] * b;
5516 blue_data[i] = correction_matrix[2][0] * r + correction_matrix[2][1] * g + correction_matrix[2][2] * b;
5517 } else {
5518 // 4x3 matrix transformation with affine offset
5519 red_data[i] = correction_matrix[0][0] * r + correction_matrix[0][1] * g + correction_matrix[0][2] * b + correction_matrix[0][3];
5520 green_data[i] = correction_matrix[1][0] * r + correction_matrix[1][1] * g + correction_matrix[1][2] * b + correction_matrix[1][3];
5521 blue_data[i] = correction_matrix[2][0] * r + correction_matrix[2][1] * g + correction_matrix[2][2] * b + correction_matrix[2][3];
5522 }
5523 }
5524
5525 if (message_flag) {
5526 std::cout << "Applied color correction matrix from '" << ccm_file_path << "' to camera '" << camera_label << "'" << std::endl;
5527 std::cout << "Matrix type: " << (is_3x3 ? "3x3" : "4x3") << ", processed " << pixel_count << " pixels" << std::endl;
5528 }
5529}
5530
5531std::vector<float> RadiationModel::getCameraPixelData(const std::string &camera_label, const std::string &band_label) {
5532 if (cameras.find(camera_label) == cameras.end()) {
5533 helios_runtime_error("ERROR (RadiationModel::getCameraPixelData): Camera '" + camera_label + "' does not exist.");
5534 }
5535
5536 auto &camera_pixel_data = cameras.at(camera_label).pixel_data;
5537 if (camera_pixel_data.find(band_label) == camera_pixel_data.end()) {
5538 helios_runtime_error("ERROR (RadiationModel::getCameraPixelData): Band '" + band_label + "' does not exist in camera '" + camera_label + "'.");
5539 }
5540
5541 return camera_pixel_data.at(band_label);
5542}
5543
5544void RadiationModel::setCameraPixelData(const std::string &camera_label, const std::string &band_label, const std::vector<float> &pixel_data) {
5545 if (cameras.find(camera_label) == cameras.end()) {
5546 helios_runtime_error("ERROR (RadiationModel::setCameraPixelData): Camera '" + camera_label + "' does not exist.");
5547 }
5548
5549 cameras.at(camera_label).pixel_data[band_label] = pixel_data;
5550}
5551
5552// ========== Backend Integration Methods ==========
5553
5554void RadiationModel::queryBackendGPUMemory() const {
5555 if (backend) {
5556 backend->queryGPUMemory();
5557 } else {
5558 std::cout << "Backend not initialized - cannot query GPU memory." << std::endl;
5559 }
5560}
5561
5562
5563helios::RayTracingLaunchParams RadiationModel::buildCameraLaunchParams(const RadiationCamera &camera, uint camera_id, uint antialiasing_samples, const helios::int2 &tile_resolution, const helios::int2 &tile_offset) {
5564
5566
5567 // Camera position and orientation
5568 params.camera_position = camera.position;
5569 helios::SphericalCoord dir = cart2sphere(camera.lookat - camera.position);
5571
5572 // Camera optical properties
5573 params.camera_focal_length = camera.focal_length;
5574 params.camera_lens_diameter = camera.lens_diameter;
5575 params.camera_fov_aspect = camera.FOV_aspect_ratio;
5576
5577 // Resolution and tiling
5578 params.camera_resolution = tile_resolution;
5579 params.camera_resolution_full = camera.resolution;
5580 params.camera_pixel_offset = tile_offset;
5581 params.antialiasing_samples = antialiasing_samples;
5582 params.camera_id = camera_id;
5583
5584 // Compute effective HFOV with zoom
5585 float effective_HFOV = camera.HFOV_degrees / camera.camera_zoom;
5586 params.camera_HFOV = effective_HFOV * M_PI / 180.0f;
5587 params.camera_viewplane_length = 0.5f / tanf(0.5f * effective_HFOV * M_PI / 180.f);
5588
5589 // Compute pixel solid angle
5590 float HFOV_rad = effective_HFOV * M_PI / 180.f;
5591 float VFOV_rad = HFOV_rad / camera.FOV_aspect_ratio;
5592 float pixel_angle_h = HFOV_rad / float(camera.resolution.x);
5593 float pixel_angle_v = VFOV_rad / float(camera.resolution.y);
5594 params.camera_pixel_solid_angle = pixel_angle_h * pixel_angle_v;
5595
5596 // Explicitly set scattering iteration for cameras (always iteration 0 for specular)
5597 params.scattering_iteration = 0;
5598
5599 // Set specular reflection mode from auto-detection
5600 params.specular_reflection_enabled = specular_reflection_mode;
5601
5602 return params;
5603}
5604
5605std::vector<CameraTile> RadiationModel::computeCameraTiles(const RadiationCamera &camera, size_t maxRays) {
5606
5607 std::vector<CameraTile> tiles;
5608
5609 size_t total_rays = size_t(camera.antialiasing_samples) * size_t(camera.resolution.x) * size_t(camera.resolution.y);
5610
5611 // No tiling needed
5612 if (total_rays <= maxRays) {
5613 tiles.push_back({camera.resolution, helios::make_int2(0, 0)});
5614 return tiles;
5615 }
5616
5617 // Calculate tile dimensions
5618 size_t rays_per_row = size_t(camera.antialiasing_samples) * size_t(camera.resolution.x);
5619 size_t max_rows_per_tile = floor(float(maxRays) / float(rays_per_row));
5620
5621 if (max_rows_per_tile == 0) {
5622 // 2D tiling - even one row is too large
5623 size_t max_pixels_per_tile = floor(float(maxRays) / float(camera.antialiasing_samples));
5624
5625 float aspect = float(camera.resolution.x) / float(camera.resolution.y);
5626 size_t tile_width = round(sqrt(max_pixels_per_tile * aspect));
5627 size_t tile_height = floor(float(max_pixels_per_tile) / float(tile_width));
5628
5629 tile_width = std::min(tile_width, size_t(camera.resolution.x));
5630 tile_height = std::min(tile_height, size_t(camera.resolution.y));
5631
5632 int Ntiles_x = ceil(float(camera.resolution.x) / float(tile_width));
5633 int Ntiles_y = ceil(float(camera.resolution.y) / float(tile_height));
5634
5635 for (int ty = 0; ty < Ntiles_y; ty++) {
5636 for (int tx = 0; tx < Ntiles_x; tx++) {
5637 size_t offset_x = tx * tile_width;
5638 size_t offset_y = ty * tile_height;
5639 size_t width_this = std::min(tile_width, camera.resolution.x - offset_x);
5640 size_t height_this = std::min(tile_height, camera.resolution.y - offset_y);
5641
5642 tiles.push_back({helios::make_int2(width_this, height_this), helios::make_int2(offset_x, offset_y)});
5643 }
5644 }
5645 } else {
5646 // 1D tiling - tile along height only
5647 size_t rows_per_tile = std::min(max_rows_per_tile, size_t(camera.resolution.y));
5648 int Ntiles = ceil(float(camera.resolution.y) / float(rows_per_tile));
5649
5650 for (int t = 0; t < Ntiles; t++) {
5651 size_t offset_y = t * rows_per_tile;
5652 size_t height_this = std::min(rows_per_tile, camera.resolution.y - offset_y);
5653
5654 tiles.push_back({helios::make_int2(camera.resolution.x, height_this), helios::make_int2(0, offset_y)});
5655 }
5656 }
5657
5658 return tiles;
5659}
5660
5661void RadiationModel::buildGeometryData(const std::vector<uint> &UUIDs) {
5662 // Build backend-agnostic geometry data from Context primitives
5663 // This extracts all geometry information needed by the ray tracing backend
5664
5665 // Filter out invalid/zero-area primitives (same as old updateGeometry)
5666 std::vector<uint> valid_UUIDs;
5667 for (uint UUID: UUIDs) {
5668 if (!context->doesPrimitiveExist(UUID))
5669 continue;
5670
5671 float area = context->getPrimitiveArea(UUID);
5672 uint parentID = context->getPrimitiveParentObjectID(UUID);
5673 if ((area == 0 || std::isnan(area)) && context->getObjectType(parentID) != helios::OBJECT_TYPE_TILE) {
5674 continue;
5675 }
5676 valid_UUIDs.push_back(UUID);
5677 }
5678
5679 if (valid_UUIDs.empty()) {
5680 geometry_data = helios::RayTracingGeometry(); // Empty geometry
5681 return;
5682 }
5683
5684 // Reorder primitives by parent object (same ordering as old code)
5685 std::vector<uint> objID_all = context->getUniquePrimitiveParentObjectIDs(valid_UUIDs, true);
5686 std::vector<uint> primitive_UUIDs_ordered;
5687 std::unordered_set<uint> valid_set(valid_UUIDs.begin(), valid_UUIDs.end());
5688
5689 for (uint objID: objID_all) {
5690 std::vector<uint> prim_UUIDs = context->getObjectPrimitiveUUIDs(objID);
5691 if (objID == 0) {
5692 // Standalone primitives (parentID=0) come from unordered_map iteration,
5693 // which has non-deterministic ordering. Sort by UUID for reproducibility.
5694 std::sort(prim_UUIDs.begin(), prim_UUIDs.end());
5695 }
5696 for (uint UUID: prim_UUIDs) {
5697 if (context->doesPrimitiveExist(UUID) && valid_set.find(UUID) != valid_set.end()) {
5698 primitive_UUIDs_ordered.push_back(UUID);
5699 }
5700 }
5701 }
5702
5703 size_t Nprimitives = primitive_UUIDs_ordered.size();
5704 geometry_data.primitive_count = Nprimitives;
5705
5706 // Clear and allocate per-primitive arrays (important when updateGeometry is called multiple times)
5707 geometry_data.transform_matrices.clear();
5708 geometry_data.transform_matrices.resize(Nprimitives * 16);
5709 geometry_data.primitive_types.clear();
5710 // Initialize to UINT_MAX as sentinel - prevents uninitialized entries from matching type==0 (patch)
5711 geometry_data.primitive_types.resize(Nprimitives, UINT_MAX);
5712 geometry_data.primitive_UUIDs = primitive_UUIDs_ordered;
5713 geometry_data.primitive_IDs.clear();
5714 geometry_data.primitive_IDs.resize(Nprimitives); // Will be populated after primitiveID_indices is built
5715 geometry_data.object_IDs.clear();
5716 geometry_data.object_IDs.resize(Nprimitives);
5717 geometry_data.object_subdivisions.clear();
5718 geometry_data.object_subdivisions.resize(Nprimitives);
5719 geometry_data.twosided_flags.clear();
5720 geometry_data.twosided_flags.resize(Nprimitives);
5721 geometry_data.solid_fractions.clear();
5722 geometry_data.solid_fractions.resize(Nprimitives);
5723
5724 // Clear type-specific arrays
5725 geometry_data.patches.vertices.clear();
5726 geometry_data.patches.UUIDs.clear();
5727 geometry_data.triangles.vertices.clear();
5728 geometry_data.triangles.UUIDs.clear();
5729 geometry_data.disk_centers.clear();
5730 geometry_data.disk_radii.clear();
5731 geometry_data.disk_normals.clear();
5732 geometry_data.disk_UUIDs.clear();
5733 geometry_data.tiles.vertices.clear();
5734 geometry_data.tiles.UUIDs.clear();
5735 geometry_data.voxels.vertices.clear();
5736 geometry_data.voxels.UUIDs.clear();
5737 geometry_data.bboxes.vertices.clear();
5738 geometry_data.bboxes.UUIDs.clear();
5739
5740 // Track object IDs for compound objects
5741 uint current_objID = 0;
5742 uint last_parentID = 99999;
5743
5744 std::vector<uint> primitiveID_indices; // Maps primitives to their "object" index
5745
5746 for (size_t u = 0; u < Nprimitives; u++) {
5747 uint UUID = primitive_UUIDs_ordered[u];
5748 uint parentID = context->getPrimitiveParentObjectID(UUID);
5749
5750 if (last_parentID != parentID || parentID == 0 || context->getObjectType(parentID) == helios::OBJECT_TYPE_TILE) {
5751 primitiveID_indices.push_back(u);
5752 last_parentID = parentID;
5753 current_objID++;
5754 } else {
5755 last_parentID = parentID;
5756 }
5757
5758 geometry_data.object_IDs[u] = current_objID - 1;
5759 }
5760
5761 size_t Nobjects = primitiveID_indices.size();
5762
5763 // Populate primitiveID for runBand() compatibility
5764 primitiveID = primitiveID_indices;
5765
5766 // For backend: primitiveID[position] must return the UUID for that primitive
5767 // Sized by Nprimitives (all primitives including subpatches), not Nobjects (object entries only)
5768 std::vector<uint> primitiveID_for_backend(Nprimitives);
5769 for (size_t i = 0; i < Nprimitives; i++) {
5770 primitiveID_for_backend[i] = primitive_UUIDs_ordered[i];
5771 }
5772
5773 // Copy corrected primitiveID mapping to geometry_data for backend upload
5774 geometry_data.primitive_IDs = primitiveID_for_backend;
5775
5776 // Populate geometry for each primitive
5777 size_t patch_idx = 0, tri_idx = 0, disk_idx = 0, voxel_idx = 0, bbox_idx = 0;
5778
5779 // Iterate over ALL primitives to set per-primitive data
5780 // (not just Nobjects, which only has one entry per object group)
5781 for (size_t prim_idx = 0; prim_idx < Nprimitives; prim_idx++) {
5782 uint UUID = primitive_UUIDs_ordered[prim_idx];
5783
5784 // Transform matrix
5785 float m[16];
5786 uint parentID = context->getPrimitiveParentObjectID(UUID);
5787 helios::PrimitiveType type = context->getPrimitiveType(UUID);
5788
5789 // Solid fraction
5790 geometry_data.solid_fractions[prim_idx] = context->getPrimitiveSolidFraction(UUID);
5791
5792 // Two-sided flag
5793 geometry_data.twosided_flags[prim_idx] = context->getPrimitiveTwosidedFlag(UUID, 1) ? 1 : 0;
5794
5795 if (parentID > 0 && context->getObjectType(parentID) == helios::OBJECT_TYPE_TILE) {
5796 // Tile subpatch: treat as individual patch for both OptiX and Vulkan backends.
5797 // Each subpatch gets its own world-space vertices in the patch geometry,
5798 // its own transform matrix, and type=0 (patch). tile_count will be 0.
5799 geometry_data.primitive_types[prim_idx] = 0; // patch
5800
5801 context->getPrimitiveTransformationMatrix(UUID, m);
5802 memcpy(&geometry_data.transform_matrices[prim_idx * 16], m, 16 * sizeof(float));
5803
5804 std::vector<vec3> verts = context->getPrimitiveVertices(UUID);
5805 for (const auto &v: verts) {
5806 geometry_data.patches.vertices.push_back(v);
5807 }
5808
5809 geometry_data.object_subdivisions[prim_idx] = helios::make_int2(1, 1);
5810 geometry_data.patches.UUIDs.push_back(UUID);
5811 patch_idx++;
5812
5813 } else if (type == helios::PRIMITIVE_TYPE_PATCH) {
5814 geometry_data.primitive_types[prim_idx] = 0; // patch
5815
5816 context->getPrimitiveTransformationMatrix(UUID, m);
5817 memcpy(&geometry_data.transform_matrices[prim_idx * 16], m, 16 * sizeof(float));
5818
5819 std::vector<vec3> verts = context->getPrimitiveVertices(UUID);
5820 for (const auto &v: verts) {
5821 geometry_data.patches.vertices.push_back(v);
5822 }
5823
5824 geometry_data.object_subdivisions[prim_idx] = helios::make_int2(1, 1);
5825
5826 // FIX: Add UUID inline to ensure consistent ordering with vertices
5827 geometry_data.patches.UUIDs.push_back(UUID);
5828
5829 patch_idx++;
5830
5831 } else if (type == helios::PRIMITIVE_TYPE_TRIANGLE) {
5832 geometry_data.primitive_types[prim_idx] = 1; // triangle
5833
5834 context->getPrimitiveTransformationMatrix(UUID, m);
5835 memcpy(&geometry_data.transform_matrices[prim_idx * 16], m, 16 * sizeof(float));
5836
5837 std::vector<vec3> verts = context->getPrimitiveVertices(UUID);
5838 for (const auto &v: verts) {
5839 geometry_data.triangles.vertices.push_back(v);
5840 }
5841
5842 geometry_data.object_subdivisions[prim_idx] = helios::make_int2(1, 1);
5843 geometry_data.triangles.UUIDs.push_back(UUID); // Store actual UUID, not position
5844 tri_idx++;
5845
5846 } else if (type == helios::PRIMITIVE_TYPE_VOXEL) {
5847 geometry_data.primitive_types[prim_idx] = 4; // voxel
5848
5849 context->getPrimitiveTransformationMatrix(UUID, m);
5850 memcpy(&geometry_data.transform_matrices[prim_idx * 16], m, 16 * sizeof(float));
5851
5852 std::vector<vec3> verts = context->getPrimitiveVertices(UUID);
5853 for (const auto &v: verts) {
5854 geometry_data.voxels.vertices.push_back(v);
5855 }
5856
5857 geometry_data.object_subdivisions[prim_idx] = helios::make_int2(1, 1);
5858 geometry_data.voxels.UUIDs.push_back(UUID); // Store actual UUID, not position
5859 voxel_idx++;
5860 }
5861 }
5862
5863 // Set counts
5864 geometry_data.patch_count = patch_idx;
5865 geometry_data.triangle_count = tri_idx;
5866 geometry_data.disk_count = disk_idx;
5867 geometry_data.tile_count = 0; // Tile subpatches are treated as individual patches
5868 geometry_data.voxel_count = voxel_idx;
5869
5870 // ========== Periodic Boundary Bboxes ==========
5871 // Create bbox geometry for periodic boundary conditions
5872 // Each bbox face is a rectangular boundary at domain edge
5873
5874 // Get domain bounding box
5875 vec2 xbounds, ybounds, zbounds;
5876 context->getDomainBoundingBox(xbounds, ybounds, zbounds);
5877
5878 // Validate camera positions if periodic boundaries enabled
5879 if (periodic_flag.x == 1 || periodic_flag.y == 1) {
5880 if (!cameras.empty()) {
5881 for (auto &camera: cameras) {
5882 vec3 camerapos = camera.second.position;
5883 if (camerapos.x < xbounds.x || camerapos.x > xbounds.y || camerapos.y < ybounds.x || camerapos.y > ybounds.y) {
5884 std::cout << "WARNING (RadiationModel::buildGeometryData): camera position is outside of the domain bounding box. Disabling periodic boundary conditions." << std::endl;
5885 periodic_flag.x = 0;
5886 periodic_flag.y = 0;
5887 break;
5888 }
5889 // Extend z-bounds to include camera
5890 if (camerapos.z < zbounds.x) {
5891 zbounds.x = camerapos.z;
5892 }
5893 if (camerapos.z > zbounds.y) {
5894 zbounds.y = camerapos.z;
5895 }
5896 }
5897 }
5898 }
5899
5900 // Expand bounds slightly to ensure bbox faces are outside geometry
5901 xbounds.x -= 1e-5;
5902 xbounds.y += 1e-5;
5903 ybounds.x -= 1e-5;
5904 ybounds.y += 1e-5;
5905 zbounds.x -= 1e-5;
5906 zbounds.y += 1e-5;
5907
5908 // Bbox UUIDs must not collide with real primitive UUIDs
5909 // Use max_UUID + 1 as base (not Nprimitives, which can cause collisions with sparse UUIDs)
5910 uint max_UUID = geometry_data.primitive_UUIDs.empty() ? 0 : *std::max_element(geometry_data.primitive_UUIDs.begin(), geometry_data.primitive_UUIDs.end());
5911 uint bbox_UUID_base = max_UUID + 1;
5912
5913 // Create bbox faces based on periodic flags
5914 if (periodic_flag.x == 1) {
5915 // -x facing boundary (4 vertices: counter-clockwise from bottom-left)
5916 geometry_data.bboxes.vertices.push_back(vec3(xbounds.x, ybounds.x, zbounds.x));
5917 geometry_data.bboxes.vertices.push_back(vec3(xbounds.x, ybounds.y, zbounds.x));
5918 geometry_data.bboxes.vertices.push_back(vec3(xbounds.x, ybounds.y, zbounds.y));
5919 geometry_data.bboxes.vertices.push_back(vec3(xbounds.x, ybounds.x, zbounds.y));
5920 geometry_data.bboxes.UUIDs.push_back(bbox_UUID_base + bbox_idx);
5921 bbox_idx++;
5922
5923 // +x facing boundary
5924 geometry_data.bboxes.vertices.push_back(vec3(xbounds.y, ybounds.x, zbounds.x));
5925 geometry_data.bboxes.vertices.push_back(vec3(xbounds.y, ybounds.y, zbounds.x));
5926 geometry_data.bboxes.vertices.push_back(vec3(xbounds.y, ybounds.y, zbounds.y));
5927 geometry_data.bboxes.vertices.push_back(vec3(xbounds.y, ybounds.x, zbounds.y));
5928 geometry_data.bboxes.UUIDs.push_back(bbox_UUID_base + bbox_idx);
5929 bbox_idx++;
5930 }
5931
5932 if (periodic_flag.y == 1) {
5933 // -y facing boundary
5934 geometry_data.bboxes.vertices.push_back(vec3(xbounds.x, ybounds.x, zbounds.x));
5935 geometry_data.bboxes.vertices.push_back(vec3(xbounds.y, ybounds.x, zbounds.x));
5936 geometry_data.bboxes.vertices.push_back(vec3(xbounds.y, ybounds.x, zbounds.y));
5937 geometry_data.bboxes.vertices.push_back(vec3(xbounds.x, ybounds.x, zbounds.y));
5938 geometry_data.bboxes.UUIDs.push_back(bbox_UUID_base + bbox_idx);
5939 bbox_idx++;
5940
5941 // +y facing boundary
5942 geometry_data.bboxes.vertices.push_back(vec3(xbounds.x, ybounds.y, zbounds.x));
5943 geometry_data.bboxes.vertices.push_back(vec3(xbounds.y, ybounds.y, zbounds.x));
5944 geometry_data.bboxes.vertices.push_back(vec3(xbounds.y, ybounds.y, zbounds.y));
5945 geometry_data.bboxes.vertices.push_back(vec3(xbounds.x, ybounds.y, zbounds.y));
5946 geometry_data.bboxes.UUIDs.push_back(bbox_UUID_base + bbox_idx);
5947 bbox_idx++;
5948 }
5949
5950 // Update bbox count and UUID base
5951 geometry_data.bbox_count = bbox_idx;
5952 if (bbox_idx > 0) {
5953 geometry_data.bbox_UUID_base = bbox_UUID_base;
5954 } else {
5955 // No bboxes: set sentinel value so GPU knows all UUIDs are real primitives
5956 geometry_data.bbox_UUID_base = UINT_MAX;
5957 }
5958
5959 // NOTE: Bbox primitive data is NOT included in the shared geometry arrays
5960 // Bboxes are OptiX-specific constructs for periodic boundaries
5961 // OptiX backend will build bbox data internally from bbox_count and bbox_UUID_base
5962 // This keeps the geometry data compatible with non-OptiX backends (Vulkan, etc.)
5963
5964 // Periodic boundary condition
5965 geometry_data.periodic_flag = periodic_flag;
5966
5967 // Extract texture mask and UV data for primitives with transparency textures
5968 buildTextureData();
5969
5970 // Build primitive_positions lookup table for GPU UUID→position conversion
5971 // Size by max UUID to create sparse lookup table (includes bbox UUIDs now that they don't collide)
5972 // Clear first to remove stale mappings from deleted primitives
5973 geometry_data.primitive_positions.clear();
5974 if (!geometry_data.primitive_UUIDs.empty()) {
5975 uint max_UUID = *std::max_element(geometry_data.primitive_UUIDs.begin(), geometry_data.primitive_UUIDs.end());
5976
5977 // Expand to include bbox UUIDs if present (they now use max_UUID+1 base, so no collisions)
5978 uint bbox_max_UUID = max_UUID;
5979 if (geometry_data.bbox_count > 0) {
5980 bbox_max_UUID = geometry_data.bbox_UUID_base + geometry_data.bbox_count - 1;
5981 }
5982
5983 geometry_data.primitive_positions.resize(bbox_max_UUID + 1, UINT_MAX); // UINT_MAX = invalid/unused
5984
5985 // Map real primitive UUIDs
5986 for (size_t i = 0; i < geometry_data.primitive_count; i++) {
5987 uint UUID = geometry_data.primitive_UUIDs[i];
5988 geometry_data.primitive_positions[UUID] = i; // Map UUID → array position
5989 }
5990
5991 // Map bbox UUIDs to their positions (after real primitives)
5992 // Now safe because bbox_UUID_base = max_UUID + 1 (no collisions)
5993 if (geometry_data.bbox_count > 0) {
5994 for (size_t i = 0; i < geometry_data.bbox_count; i++) {
5995 uint bbox_UUID = geometry_data.bbox_UUID_base + i;
5996 geometry_data.primitive_positions[bbox_UUID] = geometry_data.primitive_count + i;
5997 }
5998 }
5999 }
6000}
6001
6002void RadiationModel::buildTextureData() {
6003 // Extract texture mask and UV data for all primitives with transparency textures
6004
6005 size_t Nobjects = geometry_data.primitive_count;
6006
6007 // Clear any previous texture data (important when updateGeometry is called multiple times)
6008 geometry_data.mask_data.clear();
6009 geometry_data.mask_sizes.clear();
6010 geometry_data.uv_data.clear();
6011
6012 // Initialize with -1 (no texture)
6013 geometry_data.mask_IDs.clear();
6014 geometry_data.mask_IDs.resize(Nobjects, -1);
6015 geometry_data.uv_IDs.clear();
6016 geometry_data.uv_IDs.resize(Nobjects, -1);
6017
6018 // Cache to avoid duplicate mask data for primitives using the same texture file
6019 std::map<std::string, int> texture_to_mask_idx;
6020
6021 for (size_t prim_idx = 0; prim_idx < Nobjects; prim_idx++) {
6022 uint UUID = geometry_data.primitive_UUIDs[prim_idx];
6023
6024 // Check if primitive has a texture file
6025 std::string texture_file = context->getPrimitiveTextureFile(UUID);
6026 if (texture_file.empty()) {
6027 continue; // No texture - mask_ID stays -1
6028 }
6029
6030 // Check if texture has transparency channel (alpha)
6031 if (!context->primitiveTextureHasTransparencyChannel(UUID)) {
6032 continue; // No transparency - mask_ID stays -1 (e.g., JPEG files)
6033 }
6034
6035 // Check cache for existing mask from same texture file
6036 int mask_idx;
6037 auto cache_it = texture_to_mask_idx.find(texture_file);
6038 if (cache_it != texture_to_mask_idx.end()) {
6039 // Reuse existing mask
6040 mask_idx = cache_it->second;
6041 } else {
6042 // New texture - extract mask data
6043 const std::vector<std::vector<bool>> *trans_data = context->getPrimitiveTextureTransparencyData(UUID);
6044 helios::int2 tex_size = context->getPrimitiveTextureSize(UUID);
6045
6046 mask_idx = static_cast<int>(geometry_data.mask_sizes.size());
6047 texture_to_mask_idx[texture_file] = mask_idx;
6048
6049 // Flatten 2D bool array to 1D (row-major: [y][x])
6050 // Backend expects: for each mask m, iterate [y][x] order
6051 for (int y = 0; y < tex_size.y; y++) {
6052 for (int x = 0; x < tex_size.x; x++) {
6053 geometry_data.mask_data.push_back((*trans_data)[y][x]);
6054 }
6055 }
6056 geometry_data.mask_sizes.push_back(tex_size);
6057 }
6058
6059 geometry_data.mask_IDs[prim_idx] = mask_idx;
6060
6061 // Extract UV coordinates for this primitive
6062 // uv_IDs stores the position index (not offset), used to access uvdata[vertex][position] in CUDA
6063 std::vector<helios::vec2> uvs = context->getPrimitiveTextureUV(UUID);
6064 if (!uvs.empty()) {
6065 geometry_data.uv_IDs[prim_idx] = static_cast<int>(prim_idx); // Store position index for CUDA 2D buffer access
6066 for (const auto &uv: uvs) {
6067 geometry_data.uv_data.push_back(uv);
6068 }
6069 // Pad to 4 vertices if needed (CUDA expects max 4 vertices per primitive)
6070 size_t start_idx = geometry_data.uv_data.size() - uvs.size();
6071 while (geometry_data.uv_data.size() - start_idx < 4) {
6072 geometry_data.uv_data.push_back(uvs.back());
6073 }
6074 }
6075 // If uvs is empty, uv_ID stays -1 and CUDA will use default UV mapping
6076 }
6077}
6078
6079size_t RadiationModel::testBuildGeometryData() {
6080 buildGeometryData(context->getAllUUIDs());
6081 return geometry_data.primitive_count;
6082}
6083
6084void RadiationModel::buildUUIDMapping() {
6085 // Build bidirectional UUID ↔ array position mapping
6086 // This enables efficient conversion between UUID values and array indices
6087
6088 uuid_to_position.clear();
6089 position_to_uuid.clear();
6090
6091 // geometry_data.primitive_UUIDs is already ordered by object
6092 // Build mapping from this ordered list
6093 for (size_t i = 0; i < geometry_data.primitive_count; i++) {
6094 uint UUID = geometry_data.primitive_UUIDs[i];
6095 uuid_to_position[UUID] = i;
6096 position_to_uuid.push_back(UUID);
6097 }
6098
6099 // Build type-safe mapper (new indexing system)
6100 // Provides compile-time safety for UUID/position conversions
6101 geometry_data.mapper.build(geometry_data.primitive_UUIDs);
6102}
6103
6104static 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,
6105 helios::WarningAggregator *warnings = nullptr) {
6106 // Helper function to enforce energy conservation constraints on material properties
6107 // Mirrors the validation logic from updateRadiativeProperties() (lines 2672-2686)
6108
6109 // 1. Clamp rho and tau to [0,1] with warnings for out-of-range values
6110 if (rho < 0.f || rho > 1.f) {
6111 if (warnings) {
6112 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.");
6113 }
6114 rho = std::max(0.f, std::min(1.f, rho));
6115 }
6116
6117 if (tau < 0.f || tau > 1.f) {
6118 if (warnings) {
6119 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.");
6120 }
6121 tau = std::max(0.f, std::min(1.f, tau));
6122 }
6123
6124 // SIF-flagged bands bypass the Stefan-Boltzmann ε+ρ+τ=1 conservation constraint
6125 // because their emission is sourced from the Fluspect-B per-leaf kernel (see
6126 // computeSIFEmission) rather than ε·σ·T⁴. Epsilon is not consulted for SIF bands.
6127 // We still enforce the non-emission-style constraint ρ+τ ≤ 1 (physically required
6128 // regardless of the emission mechanism).
6129 if (is_sif_band) {
6130 if (rho + tau > 1.f) {
6131 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 #" +
6132 std::to_string(UUID) + ": tau=" + std::to_string(tau) + ", rho=" + std::to_string(rho) + ".");
6133 }
6134 return;
6135 }
6136
6137 // 2. Apply emission-specific constraints
6138 if (emission_enabled) {
6139 // Special case: blackbody emission (scatteringDepth=0 requires eps=1, rho=0, tau=0)
6140 if (scattering_depth == 0 && eps != 1.f) {
6141 if (warnings && (rho != 0.f || tau != 0.f)) {
6142 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));
6143 }
6144 rho = 0.f;
6145 tau = 0.f;
6146 }
6147 // General emission case: check energy conservation (eps + rho + tau = 1)
6148 else if (eps != 1.f && rho == 0 && tau == 0) {
6149 // Auto-correct: set rho = 1 - eps
6150 rho = 1.f - eps;
6151 } else if (std::abs(eps + rho + tau - 1.f) > 1e-5f && eps > 0.f) {
6152 // Cannot auto-correct, throw error
6153 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) +
6154 ": 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.");
6155 }
6156 } else {
6157 // 3. Non-emission case: rho + tau must be ≤ 1
6158 if (rho + tau > 1.f) {
6159 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) +
6160 ": 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.");
6161 }
6162 }
6163}
6164
6165void RadiationModel::buildMaterialData() {
6166 // Build backend-agnostic material data from Context primitive data
6167
6168 // Warning aggregator for energy conservation issues
6170
6171 size_t Nprims = geometry_data.primitive_count;
6172 size_t Nbands = radiation_bands.size();
6173 size_t Nsources = radiation_sources.size();
6174
6175 material_data.num_primitives = Nprims;
6176 material_data.num_bands = Nbands;
6177 material_data.num_sources = Nsources;
6178 material_data.num_cameras = cameras.size();
6179
6180 // Allocate arrays (indexed as [source][primitive][band] using MaterialPropertyIndexer)
6181 // NOTE: Bboxes don't need material properties (they only wrap rays for periodic boundaries)
6182 size_t total_size = Nsources * Nbands * Nprims;
6183 material_data.reflectivity.resize(total_size, 0.0f);
6184 material_data.transmissivity.resize(total_size, 0.0f);
6185 material_data.specular_exponent.resize(Nprims, -1.0f); // Default -1 means disabled
6186 material_data.specular_scale.resize(Nprims, 0.0f);
6187
6188 // Create indexer for material properties: [source][primitive][band]
6189 MaterialPropertyIndexer mat_indexer(Nsources, Nprims, Nbands);
6190
6191 // Cache unique spectral data to avoid redundant loads
6192 std::map<std::string, std::vector<helios::vec2>> unique_rho_spectra;
6193 std::map<std::string, std::vector<helios::vec2>> unique_tau_spectra;
6194
6195 for (size_t p = 0; p < Nprims; p++) {
6196 uint UUID = geometry_data.primitive_UUIDs[p];
6197
6198 // Cache reflectivity spectra
6199 if (context->doesPrimitiveDataExist(UUID, "reflectivity_spectrum")) {
6200 std::string spectrum_label;
6201 context->getPrimitiveData(UUID, "reflectivity_spectrum", spectrum_label);
6202 if (unique_rho_spectra.find(spectrum_label) == unique_rho_spectra.end()) {
6203 // Only load if spectrum exists in global data
6204 if (context->doesGlobalDataExist(spectrum_label.c_str())) {
6205 unique_rho_spectra[spectrum_label] = loadSpectralData(spectrum_label);
6206 }
6207 }
6208 }
6209
6210 // Cache transmissivity spectra
6211 if (context->doesPrimitiveDataExist(UUID, "transmissivity_spectrum")) {
6212 std::string spectrum_label;
6213 context->getPrimitiveData(UUID, "transmissivity_spectrum", spectrum_label);
6214 if (unique_tau_spectra.find(spectrum_label) == unique_tau_spectra.end()) {
6215 // Only load if spectrum exists in global data
6216 if (context->doesGlobalDataExist(spectrum_label.c_str())) {
6217 unique_tau_spectra[spectrum_label] = loadSpectralData(spectrum_label);
6218 }
6219 }
6220 }
6221 }
6222
6223 // Extract material properties from Context primitives
6224 size_t b_idx = 0;
6225 for (const auto &band_pair: radiation_bands) {
6226 std::string band_label = band_pair.second.label;
6227
6228 for (size_t s = 0; s < Nsources; s++) {
6229 for (size_t p = 0; p < Nprims; p++) {
6230 uint UUID = geometry_data.primitive_UUIDs[p];
6231
6232 // Use BufferIndexer for safe, verifiable indexing
6233 // Note: p is already the array position, so we use p directly (not UUID)
6234 size_t idx = mat_indexer(s, p, b_idx);
6235
6236 // Get reflectivity - try spectrum first, then per-band label
6237 float rho = rho_default;
6238
6239 if (context->doesPrimitiveDataExist(UUID, "reflectivity_spectrum")) {
6240 // Spectrum-based reflectivity
6241 std::string spectrum_label;
6242 context->getPrimitiveData(UUID, "reflectivity_spectrum", spectrum_label);
6243
6244 // Get spectrum from cache
6245 if (unique_rho_spectra.find(spectrum_label) != unique_rho_spectra.end()) {
6246 const std::vector<helios::vec2> &spectrum = unique_rho_spectra.at(spectrum_label);
6247
6248 // Get band wavelength bounds
6249 helios::vec2 wavebounds = band_pair.second.wavebandBounds;
6250
6251 // Only require wavelength bounds if band performs scattering/absorption
6252 // Emission-only bands (scatteringDepth==0) use Stefan-Boltzmann and don't need spectral integration
6253 // Ray launches for emission don't require wavelength bounds since emission properties are wavelength-independent
6254 bool needs_spectral_integration = (band_pair.second.scatteringDepth > 0);
6255
6256 if (needs_spectral_integration && wavebounds.x == 0 && wavebounds.y == 0) {
6257 helios_runtime_error("ERROR (RadiationModel::buildMaterialData): Band '" + band_label + "' has no wavelength bounds - required for spectral integration");
6258 }
6259
6260 // Integrate spectrum over band wavelength range (only if bounds are defined)
6261 if (wavebounds.x != 0 || wavebounds.y != 0) {
6262 if (!radiation_sources[s].source_spectrum.empty()) {
6263 // Weight by source spectrum
6264 rho = integrateSpectrum(s, spectrum, wavebounds.x, wavebounds.y);
6265 } else {
6266 // Uniform integration (divide by wavelength range to normalize)
6267 rho = integrateSpectrum(spectrum, wavebounds.x, wavebounds.y) / (wavebounds.y - wavebounds.x);
6268 }
6269 }
6270 // else: emission-only band, rho remains at default value (should be 0 for blackbody)
6271 }
6272 } else {
6273 // Per-band reflectivity (backward compatibility)
6274 std::string rho_label = "reflectivity_" + band_label;
6275 if (context->doesPrimitiveDataExist(UUID, rho_label.c_str())) {
6276 context->getPrimitiveData(UUID, rho_label.c_str(), rho);
6277 }
6278 }
6279
6280 // Get transmissivity - try spectrum first, then per-band label
6281 float tau = tau_default;
6282
6283 if (context->doesPrimitiveDataExist(UUID, "transmissivity_spectrum")) {
6284 // Spectrum-based transmissivity
6285 std::string spectrum_label;
6286 context->getPrimitiveData(UUID, "transmissivity_spectrum", spectrum_label);
6287
6288 // Get spectrum from cache
6289 if (unique_tau_spectra.find(spectrum_label) != unique_tau_spectra.end()) {
6290 const std::vector<helios::vec2> &spectrum = unique_tau_spectra.at(spectrum_label);
6291
6292 // Get band wavelength bounds
6293 helios::vec2 wavebounds = band_pair.second.wavebandBounds;
6294
6295 // Only require wavelength bounds if band performs scattering/absorption
6296 // Emission-only bands (scatteringDepth==0) use Stefan-Boltzmann and don't need spectral integration
6297 // Ray launches for emission don't require wavelength bounds since emission properties are wavelength-independent
6298 bool needs_spectral_integration = (band_pair.second.scatteringDepth > 0);
6299
6300 if (needs_spectral_integration && wavebounds.x == 0 && wavebounds.y == 0) {
6301 helios_runtime_error("ERROR (RadiationModel::buildMaterialData): Band '" + band_label + "' has no wavelength bounds - required for spectral integration");
6302 }
6303
6304 // Integrate spectrum over band wavelength range (only if bounds are defined)
6305 if (wavebounds.x != 0 || wavebounds.y != 0) {
6306 if (!radiation_sources[s].source_spectrum.empty()) {
6307 // Weight by source spectrum
6308 tau = integrateSpectrum(s, spectrum, wavebounds.x, wavebounds.y);
6309 } else {
6310 // Uniform integration
6311 tau = integrateSpectrum(spectrum, wavebounds.x, wavebounds.y) / (wavebounds.y - wavebounds.x);
6312 }
6313 }
6314 // else: emission-only band, tau remains at default value (should be 0 for blackbody)
6315 }
6316 } else {
6317 // Per-band transmissivity (backward compatibility)
6318 std::string tau_label = "transmissivity_" + band_label;
6319 if (context->doesPrimitiveDataExist(UUID, tau_label.c_str())) {
6320 context->getPrimitiveData(UUID, tau_label.c_str(), tau);
6321 }
6322 }
6323
6324 // Get emissivity for validation
6325 float eps = eps_default;
6326 std::string eps_label = "emissivity_" + band_label;
6327 if (context->doesPrimitiveDataExist(UUID, eps_label.c_str())) {
6328 context->getPrimitiveData(UUID, eps_label.c_str(), eps);
6329 }
6330
6331 // Validate and correct material properties to ensure energy conservation.
6332 // SIF-flagged bands skip the Stefan-Boltzmann ε+ρ+τ=1 check because their
6333 // emission is sourced from Fluspect-B, not ε·σ·T⁴.
6334 const RadiationBand &band = band_pair.second;
6335 const bool is_sif_band = sif_emission_bands.count(band_label) > 0;
6336 validateAndCorrectMaterialProperties(rho, tau, eps, band.emissionFlag, band.scatteringDepth, band_label, UUID, is_sif_band, &warnings);
6337
6338 // Store validated properties
6339 material_data.reflectivity[idx] = rho;
6340 material_data.transmissivity[idx] = tau;
6341 }
6342 }
6343 b_idx++;
6344 }
6345
6346 // NOTE: Bboxes don't need material properties - they only wrap rays for periodic boundaries
6347 // Material buffers are sized for real primitives only (Nprims), not including bboxes
6348
6349 // Load specular reflection properties from primitive data
6350 bool specular_exponent_specified = false;
6351 bool specular_scale_specified = false;
6352
6353 for (size_t p = 0; p < Nprims; p++) {
6354 uint UUID = geometry_data.primitive_UUIDs[p];
6355
6356 if (context->doesPrimitiveDataExist(UUID, "specular_exponent") && context->getPrimitiveDataType("specular_exponent") == helios::HELIOS_TYPE_FLOAT) {
6357 context->getPrimitiveData(UUID, "specular_exponent", material_data.specular_exponent.at(p));
6358 if (material_data.specular_exponent.at(p) >= 0.f) {
6359 specular_exponent_specified = true;
6360 }
6361 }
6362
6363 if (context->doesPrimitiveDataExist(UUID, "specular_scale") && context->getPrimitiveDataType("specular_scale") == helios::HELIOS_TYPE_FLOAT) {
6364 context->getPrimitiveData(UUID, "specular_scale", material_data.specular_scale.at(p));
6365 if (material_data.specular_scale.at(p) > 0.f) {
6366 specular_scale_specified = true;
6367 }
6368 }
6369 }
6370
6371 // Auto-enable specular reflection if specular properties are specified on any primitive
6372 if (specular_exponent_specified) {
6373 if (specular_scale_specified) {
6374 specular_reflection_mode = 2; // Mode 2: use primitive specular_scale
6375 } else {
6376 specular_reflection_mode = 1; // Mode 1: use default 0.25 scale
6377 }
6378 } else {
6379 specular_reflection_mode = 0; // Disabled
6380 }
6381
6382 // Report any accumulated warnings
6383 warnings.report();
6384}
6385
6386void RadiationModel::buildSourceData() {
6387 // Build backend-agnostic source data from radiation_sources
6388
6389 source_data.clear();
6390 source_data.reserve(radiation_sources.size());
6391
6392 for (size_t s = 0; s < radiation_sources.size(); s++) {
6393 const auto &src = radiation_sources[s];
6394 helios::RayTracingSource backend_src;
6395 backend_src.position = src.source_position;
6396 backend_src.rotation = src.source_rotation;
6397 backend_src.width = src.source_width;
6398 backend_src.type = src.source_type;
6399
6400 // Flatten flux arrays - use getSourceFlux() to handle -1.f sentinel values
6401 backend_src.fluxes.clear();
6402 backend_src.fluxes_cam.clear();
6403 for (const auto &band_pair: radiation_bands) {
6404 std::string band_label = band_pair.second.label;
6405 // Use getSourceFlux() which properly handles -1.f sentinel (returns 0 or integrates spectrum)
6406 float flux = getSourceFlux(s, band_label);
6407 backend_src.fluxes.push_back(flux);
6408 backend_src.fluxes_cam.push_back(flux); // Same for now
6409 }
6410
6411 source_data.push_back(backend_src);
6412 }
6413}
6414
6415
6416helios::RayTracingBackend *RadiationModel::getBackend() {
6417 return backend.get();
6418}
6419
6420helios::RayTracingGeometry &RadiationModel::getGeometryData() {
6421 return geometry_data;
6422}
6423
6424helios::RayTracingMaterial &RadiationModel::getMaterialData() {
6425 return material_data;
6426}
6427
6428std::vector<helios::RayTracingSource> &RadiationModel::getSourceData() {
6429 return source_data;
6430}
6431
6432
6433void RadiationModel::testBuildAllBackendData() {
6434 buildGeometryData(context->getAllUUIDs());
6435 buildMaterialData();
6436 buildSourceData();
6437}