434 TypeError: If context is not a Context instance
435 RadiationModelError: If radiation plugin is not available
438 if not isinstance(context, Context):
439 raise TypeError(f
"RadiationModel requires a Context instance, got {type(context).__name__}")
455 registry = get_plugin_registry()
457 if not registry.is_plugin_available(
'radiation'):
459 plugin_info = registry.get_plugin_capabilities()
460 available_plugins = registry.get_available_plugins()
463 "RadiationModel requires the 'radiation' plugin which is not available.\n\n"
464 "The radiation plugin provides GPU-accelerated ray tracing with runtime\n"
465 "backend auto-detection (OptiX 8 -> OptiX 6 -> Vulkan).\n"
466 "System requirements (at least one backend):\n"
467 "- Vulkan: Vulkan loader library (macOS/Linux); no extra packages on Windows\n"
468 "- OptiX 8.1: NVIDIA GPU with driver >= 560 and CUDA 12.0+\n"
469 "- OptiX 6.5: NVIDIA GPU with driver < 560 and CUDA 9.0+\n\n"
470 "To enable radiation modeling:\n"
471 "1. Build PyHelios with radiation plugin:\n"
472 " build_scripts/build_helios --plugins radiation\n"
473 "2. Or build with multiple plugins:\n"
474 " build_scripts/build_helios --plugins radiation,visualizer,weberpenntree\n"
475 f
"\nCurrently available plugins: {available_plugins}"
479 alternatives = registry.suggest_alternatives(
'radiation')
481 error_msg += f
"\n\nAlternative plugins available: {alternatives}"
482 error_msg +=
"\nConsider using energybalance or leafoptics for thermal modeling."
489 self.
radiation_model = radiation_wrapper.createRadiationModel(context.getNativePtr())
492 "Failed to create RadiationModel instance. "
493 "This may indicate a problem with the native library or GPU initialization."
495 logger.info(
"RadiationModel created successfully")
502 except Exception
as e:
506 """Raise if the owning Context has been destroyed (see Context.check_context_alive)."""
507 check_context_alive(getattr(self,
"context",
None),
"RadiationModel")
510 band_labels: List[str],
512 """Warn when bands have explicit wavelength bounds and the camera has a
515 The two combined silently produce wrong colors. Helios computes camera
516 pixels by integrating surface reflectance against the camera's spectral
517 response curve, but computes SCATTERED flux by integrating over the
518 band's wavelength bounds instead. When bounds are absent Helios
519 deliberately falls back to the camera-weighted average for scattering,
520 so both paths agree; setting bounds breaks that agreement.
522 With scattering enabled most camera-visible light is scattered, so the
523 image carries band-limited color the camera never expected. Low-chroma
524 natural surfaces are hit hardest: a measured soil spectrum whose true
525 red/green ratio is 1.2 renders at 2.6 -- brown soil comes out pink --
526 while saturated colorboard-style spectra survive and look fine, which
527 makes the problem easy to miss.
529 Nothing errors and no output looks obviously broken, so this is a
530 warning at the point the camera is created rather than a failure.
532 bounded = [b
for b
in band_labels
if b
in getattr(self,
"_bounded_bands", ())]
537 f
"Camera '{camera_label}' ({operation}) uses a spectral response, but "
538 f
"band(s) {bounded} were created with explicit wavelength bounds. "
539 f
"Camera pixels integrate reflectance against the spectral response "
540 f
"while scattered flux integrates over the band bounds, so rendered "
541 f
"colors will be skewed (brown surfaces tend to render pink). Create "
542 f
"these bands without wavelength_min/wavelength_max -- the camera's "
543 f
"spectral response defines what each band measures."
547 """Raise an actionable error if a camera/band has no rendered pixel data.
549 A camera's pixel data is populated only by ``runBand()``, and only for
550 the bands passed to that call and for cameras that already existed when
551 it ran. Requesting an image for an unrendered camera/band otherwise
552 reaches the native layer as a bare ``invalid map<K, T> key`` from
553 ``std::map::at`` -- see GitHub issue #4. This preflight turns that into a
554 message naming the camera, the band, and the call the user is missing.
556 This check is load-bearing rather than merely cosmetic, and must not be
557 removed on the grounds that helios-core v1.3.79 addressed the same case
558 upstream. The upstream change makes the native call return an empty
559 filename instead of throwing, so without this preflight an unrendered
560 camera would look like a successful write that produced no file.
561 :meth:`_check_image_was_written` is the backstop for the failure modes
562 this preflight cannot see, such as an unwritable output directory.
565 known_cameras = radiation_wrapper.getAllCameraLabels(self.
radiation_model)
571 if known_cameras
is not None and camera
not in known_cameras:
573 f
"Cannot {operation}: camera '{camera}' does not exist. "
574 f
"Add it with addRadiationCamera() before calling runBand(). "
575 f
"Existing cameras: {sorted(known_cameras) if known_cameras else 'none'}"
580 radiation_wrapper.getCameraPixelData(self.
radiation_model, camera, band)
583 f
"Cannot {operation}: camera '{camera}' has no rendered pixel data "
584 f
"for band '{band}'. Call runBand() with this band after adding the "
585 f
"camera -- e.g. runBand({list(bands)!r}). Note that runBand() only "
586 f
"renders the bands passed to it, and only for cameras that already "
587 f
"exist when it runs."
591 image_path: str, operation: str):
592 """Raise if the native layer reported a failed write via an empty filename.
594 helios-core v1.3.79 changed ``writeCameraImage``/``writeNormCameraImage`` to
595 return an empty string on failure rather than throwing, so no exception and no
596 error code reaches Python. Returning that empty string to the caller -- or
597 logging it as "written to: " -- would present a write that produced no file as
598 a success, which the fail-fast policy forbids.
600 :meth:`_check_camera_has_pixel_data` already rejects the unrendered-camera and
601 unrendered-band cases before the native call, so by the time an empty filename
602 gets here the likeliest cause is an output directory that does not exist or
609 f
"Cannot {operation}: the native library reported a failed write for camera "
610 f
"'{camera}' (bands {list(bands)!r}) by returning an empty filename, so no "
611 f
"image file was created. The camera has rendered pixel data, so the most "
612 f
"likely cause is that the output directory '{image_path}' does not exist "
613 f
"or is not writable. Verify the path exists and is writable."
617 """Context manager entry."""
620 def __exit__(self, exc_type, exc_value, traceback):
621 """Context manager exit with proper cleanup."""
625 logger.debug(
"RadiationModel destroyed successfully")
626 except Exception
as e:
627 logger.warning(f
"Error destroying RadiationModel: {e}")
632 """Destructor to ensure GPU resources freed even without 'with' statement."""
633 if hasattr(self,
'radiation_model')
and self.
radiation_model is not None:
637 except Exception
as e:
639 warnings.warn(f
"Error in RadiationModel.__del__: {e}")
642 """Get native pointer for advanced operations."""
646 """Get native pointer for advanced operations. (Legacy naming for compatibility)"""
649 @require_plugin('radiation', 'disable status messages')
651 """Disable RadiationModel status messages."""
655 @require_plugin('radiation', 'enable status messages')
657 """Enable RadiationModel status messages."""
661 @require_plugin('radiation', 'add radiation band')
662 def addRadiationBand(self, band_label: str, wavelength_min: float =
None, wavelength_max: float =
None):
664 Add radiation band with optional wavelength bounds.
667 band_label: Name/label for the radiation band
668 wavelength_min: Optional minimum wavelength (nm). Must not be negative; a band
670 wavelength_max: Optional maximum wavelength (nm). Must exceed wavelength_min by
674 Wavelength bounds change how the band is treated. Surface
675 ``reflectivity_spectrum``/``transmissivity_spectrum`` data are only applied in
676 a band that has bounds. An emission-enabled band with bounds emits only the
677 part of the Planck spectrum inside them (e.g. about 38% of sigma*T^4 for
678 8000-14000 nm at 300 K), whereas a band without bounds emits the full
679 broadband epsilon*sigma*T^4; any diffuse flux given to a bounded emission band
680 should likewise be the in-band value.
683 validate_band_label(band_label,
"band_label",
"addRadiationBand")
684 if wavelength_min
is not None and wavelength_max
is not None:
685 validate_wavelength_range(wavelength_min, wavelength_max,
"wavelength_min",
"wavelength_max",
"addRadiationBand")
687 radiation_wrapper.addRadiationBandWithWavelengths(self.
radiation_model, band_label, wavelength_min, wavelength_max)
689 logger.debug(f
"Added radiation band {band_label}: {wavelength_min}-{wavelength_max} nm")
694 logger.debug(f
"Added radiation band: {band_label}")
696 @require_plugin('radiation', 'copy radiation band')
697 @validate_radiation_band_params
698 def copyRadiationBand(self, old_label: str, new_label: str, wavelength_min: float =
None, wavelength_max: float =
None):
700 Copy existing radiation band to new label, optionally with new wavelength range.
703 old_label: Existing band label to copy
704 new_label: New label for the copied band
705 wavelength_min: Optional minimum wavelength for new band (nm). Must not be negative.
706 wavelength_max: Optional maximum wavelength for new band (nm). Must exceed
707 wavelength_min by at least 1 nm. Passing 0 for both bounds creates a band
708 without wavelength bounds; omitting both keeps the bounds of the copied band.
711 >>> # Copy band with same wavelength range
712 >>> radiation.copyRadiationBand("SW", "SW_copy")
714 >>> # Copy band with different wavelength range
715 >>> radiation.copyRadiationBand("full_spectrum", "PAR", 400, 700)
717 >>> # Copy band, dropping its wavelength bounds
718 >>> radiation.copyRadiationBand("PAR", "PAR_unbounded", 0, 0)
720 has_bounds = wavelength_min
is not None and wavelength_max
is not None
721 unbounded_copy = has_bounds
and wavelength_min == 0
and wavelength_max == 0
722 if has_bounds
and not unbounded_copy:
723 validate_wavelength_range(wavelength_min, wavelength_max,
"wavelength_min",
"wavelength_max",
"copyRadiationBand")
726 radiation_wrapper.copyRadiationBand(self.
radiation_model, old_label, new_label, wavelength_min, wavelength_max)
729 logger.debug(f
"Copied radiation band {old_label} to {new_label} without wavelength bounds")
732 logger.debug(f
"Copied radiation band {old_label} to {new_label} with wavelengths {wavelength_min}-{wavelength_max} nm")
739 logger.debug(f
"Copied radiation band {old_label} to {new_label}")
741 @require_plugin('radiation', 'add radiation source')
742 @validate_collimated_source_params
745 Add collimated radiation source.
748 direction: Optional direction vector. Can be tuple (x, y, z), vec3, or None for default direction.
753 if direction
is None:
755 source_id = radiation_wrapper.addCollimatedRadiationSourceDefault(self.
radiation_model)
758 if hasattr(direction,
'x')
and hasattr(direction,
'y')
and hasattr(direction,
'z'):
760 x, y, z = direction.x, direction.y, direction.z
761 elif hasattr(direction,
'radius')
and hasattr(direction,
'elevation')
and hasattr(direction,
'azimuth'):
765 elevation = direction.elevation
766 azimuth = direction.azimuth
767 x = r * math.cos(elevation) * math.cos(azimuth)
768 y = r * math.cos(elevation) * math.sin(azimuth)
769 z = r * math.sin(elevation)
774 if len(direction) != 3:
775 raise TypeError(f
"Direction must be a 3-element tuple, vec3, or SphericalCoord, got {type(direction).__name__} with {len(direction)} elements")
777 except (TypeError, AttributeError):
779 raise TypeError(f
"Direction must be a tuple, vec3, or SphericalCoord, got {type(direction).__name__}")
780 source_id = radiation_wrapper.addCollimatedRadiationSourceVec3(self.
radiation_model, x, y, z)
782 logger.debug(f
"Added collimated radiation source: ID {source_id}")
785 @require_plugin('radiation', 'add spherical radiation source')
786 @validate_sphere_source_params
789 Add spherical radiation source.
792 position: Position of the source. Can be tuple (x, y, z) or vec3.
793 radius: Radius of the spherical source
798 validate_position_like(position,
"position",
"addSphereRadiationSource")
800 if hasattr(position,
'x')
and hasattr(position,
'y')
and hasattr(position,
'z'):
801 x, y, z = position.x, position.y, position.z
805 source_id = radiation_wrapper.addSphereRadiationSource(self.
radiation_model, x, y, z, radius)
806 logger.debug(f
"Added sphere radiation source: ID {source_id} at ({x}, {y}, {z}) with radius {radius}")
809 @require_plugin('radiation', 'add sun radiation source')
810 @validate_sun_sphere_params
812 position_scaling: float = 1.0, angular_width: float = 0.53,
813 flux_scaling: float = 1.0) -> int:
815 Add sun sphere radiation source.
818 radius: Radius of the sun sphere
819 zenith: Zenith angle (degrees)
820 azimuth: Azimuth angle (degrees)
821 position_scaling: Position scaling factor
822 angular_width: Angular width of the sun (degrees)
823 flux_scaling: Flux scaling factor
829 source_id = radiation_wrapper.addSunSphereRadiationSource(
830 self.
radiation_model, radius, zenith, azimuth, position_scaling, angular_width, flux_scaling
832 logger.debug(f
"Added sun radiation source: ID {source_id}")
835 @require_plugin('radiation', 'set source position')
838 Set position of a radiation source.
840 Allows dynamic repositioning of radiation sources during simulation,
841 useful for time-series modeling or moving light sources.
844 source_id: ID of the radiation source
845 position: New position as vec3, SphericalCoord, or list/tuple [x, y, z]
848 >>> source_id = radiation.addCollimatedRadiationSource()
849 >>> radiation.setSourcePosition(source_id, [10, 20, 30])
850 >>> from pyhelios.types import vec3
851 >>> radiation.setSourcePosition(source_id, vec3(15, 25, 35))
853 if not isinstance(source_id, int)
or source_id < 0:
854 raise ValueError(f
"Source ID must be a non-negative integer, got {source_id}")
855 validate_direction_like(position,
"position",
"setSourcePosition")
857 radiation_wrapper.setSourcePosition(self.
radiation_model, source_id, position)
858 logger.debug(f
"Updated position for radiation source {source_id}")
860 @require_plugin('radiation', 'add rectangle radiation source')
863 Add a rectangle (planar) radiation source.
865 Rectangle sources are ideal for modeling artificial lighting such as
866 LED panels, grow lights, or window light sources.
869 position: Center position as vec3 or list [x, y, z]
870 size: Rectangle dimensions as vec2 or list [width, height]
871 rotation: Rotation vector as vec3 or list [rx, ry, rz] (Euler angles in radians)
877 >>> from pyhelios.types import vec3, vec2
878 >>> source_id = radiation.addRectangleRadiationSource(
879 ... position=vec3(0, 0, 5),
881 ... rotation=vec3(0, 0, 0)
883 >>> radiation.setSourceFlux(source_id, "PAR", 500.0)
885 validate_position_like(position,
"position",
"addRectangleRadiationSource")
886 validate_size_like(size,
"size",
"addRectangleRadiationSource")
887 validate_position_like(rotation,
"rotation",
"addRectangleRadiationSource")
889 return radiation_wrapper.addRectangleRadiationSource(self.
radiation_model, position, size, rotation)
891 @require_plugin('radiation', 'add disk radiation source')
894 Add a disk (circular planar) radiation source.
896 Disk sources are useful for modeling circular light sources such as
897 spotlights, circular LED arrays, or solar simulators.
900 position: Center position as vec3 or list [x, y, z]
902 rotation: Rotation vector as vec3 or list [rx, ry, rz] (Euler angles in radians)
908 >>> from pyhelios.types import vec3
909 >>> source_id = radiation.addDiskRadiationSource(
910 ... position=vec3(0, 0, 5),
912 ... rotation=vec3(0, 0, 0)
914 >>> radiation.setSourceFlux(source_id, "PAR", 300.0)
916 validate_position_like(position,
"position",
"addDiskRadiationSource")
917 validate_position_like(rotation,
"rotation",
"addDiskRadiationSource")
919 raise ValueError(f
"Radius must be positive, got {radius}")
921 return radiation_wrapper.addDiskRadiationSource(self.
radiation_model, position, radius, rotation)
924 @require_plugin('radiation', 'manage source spectrum')
927 Set radiation spectrum for source(s).
929 Spectral distributions define how radiation intensity varies with wavelength,
930 essential for realistic modeling of different light sources (sunlight, LEDs, etc.).
933 source_id: Source ID (int) or list of source IDs
935 - Spectrum data as list of (wavelength, value) tuples
936 - Global data label string
939 >>> # Define custom LED spectrum
941 ... (400, 0.0), (450, 0.3), (500, 0.8),
942 ... (550, 0.5), (600, 0.2), (700, 0.0)
944 >>> radiation.setSourceSpectrum(source_id, led_spectrum)
946 >>> # Use predefined spectrum from global data
947 >>> radiation.setSourceSpectrum(source_id, "D65_illuminant")
949 >>> # Apply same spectrum to multiple sources
950 >>> radiation.setSourceSpectrum([src1, src2, src3], led_spectrum)
953 radiation_wrapper.setSourceSpectrum(self.
radiation_model, source_id, spectrum)
954 logger.debug(f
"Set spectrum for source(s) {source_id}")
956 @require_plugin('radiation', 'configure source spectrum')
958 wavelength_min: float =
None, wavelength_max: float =
None):
960 Set source spectrum integral value.
962 Normalizes the spectrum so that its integral equals the specified value,
963 useful for calibrating source intensity.
967 source_integral: Target integral value
968 wavelength_min: Optional minimum wavelength for integration range
969 wavelength_max: Optional maximum wavelength for integration range
972 >>> radiation.setSourceSpectrumIntegral(source_id, 1000.0)
973 >>> radiation.setSourceSpectrumIntegral(source_id, 500.0, 400, 700) # PAR range
975 if not isinstance(source_id, int)
or source_id < 0:
976 raise ValueError(f
"Source ID must be a non-negative integer, got {source_id}")
977 if source_integral < 0:
978 raise ValueError(f
"Source integral must be non-negative, got {source_integral}")
981 radiation_wrapper.setSourceSpectrumIntegral(self.
radiation_model, source_id, source_integral,
982 wavelength_min, wavelength_max)
983 logger.debug(f
"Set spectrum integral for source {source_id}: {source_integral}")
986 @require_plugin('radiation', 'integrate spectrum')
988 wavelength_max: float =
None, source_id: int =
None,
989 camera_spectrum=
None) -> float:
991 Integrate or average a spectrum, optionally weighted by a source and/or camera spectrum.
993 What is returned depends on which optional arguments are given:
995 - Neither ``source_id`` nor ``camera_spectrum``: the integral of the spectrum, over
996 all tabulated wavelengths or between ``wavelength_min`` and ``wavelength_max``.
997 - ``source_id`` (with both wavelength bounds): the source-weighted average
998 ``int(S*f) / int(S)`` between the bounds, where ``S`` is the source spectrum and
999 ``f`` the object spectrum.
1000 - ``camera_spectrum``: the camera-response-weighted average ``int(f*C) / int(C)``
1001 over the wavelengths at which the camera response ``C`` is tabulated.
1002 - ``source_id`` and ``camera_spectrum``: ``int(S*f*C) / int(S)`` over the
1003 wavelengths at which both the source spectrum and the camera response are
1004 tabulated. The wavelength bounds are not used.
1006 In the weighted forms every spectrum is zero outside the wavelengths at which it is
1007 tabulated, so a source weights only the part of the interval its spectrum covers.
1010 object_spectrum: Object spectrum as list of (wavelength, value) tuples/vec2
1011 wavelength_min: Optional minimum wavelength for integration range
1012 wavelength_max: Optional maximum wavelength for integration range. The
1013 spectrum is linearly interpolated to the bounds, so a bound falling
1014 between tabulated points contributes only the part of its segment
1016 source_id: Optional source ID for source spectrum weighting. The source must
1017 have a spectrum (see :meth:`setSourceSpectrum`). Requires
1018 ``wavelength_min`` and ``wavelength_max`` unless ``camera_spectrum`` is
1020 camera_spectrum: Optional camera spectrum for camera response weighting
1023 Integral or weighted average, as described above
1026 HeliosRuntimeError: If there is nothing to weight by: the source spectrum has no energy
1027 between the bounds (or where the camera response is tabulated), or the
1028 camera response is zero everywhere.
1031 >>> leaf_reflectance = [(400, 0.1), (500, 0.4), (600, 0.6), (700, 0.5)]
1033 >>> # Total integration
1034 >>> total = radiation.integrateSpectrum(leaf_reflectance)
1036 >>> # PAR range (400-700nm)
1037 >>> par = radiation.integrateSpectrum(leaf_reflectance, 400, 700)
1039 >>> # With source spectrum
1040 >>> source_weighted = radiation.integrateSpectrum(
1041 ... leaf_reflectance, 400, 700, source_id=sun_source
1044 >>> # With camera response
1045 >>> camera_response = [(400, 0.2), (550, 1.0), (700, 0.3)]
1046 >>> camera_weighted = radiation.integrateSpectrum(
1047 ... leaf_reflectance, camera_spectrum=camera_response
1051 return radiation_wrapper.integrateSpectrum(self.
radiation_model, object_spectrum,
1052 wavelength_min, wavelength_max,
1053 source_id, camera_spectrum)
1055 @require_plugin('radiation', 'integrate source spectrum')
1058 Integrate source spectrum over wavelength range.
1060 The spectrum is linearly interpolated to the wavelength bounds, so a bound
1061 falling between tabulated points contributes only the part of its segment
1065 source_id: Source ID
1066 wavelength_min: Minimum wavelength
1067 wavelength_max: Maximum wavelength
1070 Integrated source spectrum value
1073 >>> par_flux = radiation.integrateSourceSpectrum(source_id, 400, 700)
1075 if not isinstance(source_id, int)
or source_id < 0:
1076 raise ValueError(f
"Source ID must be a non-negative integer, got {source_id}")
1078 return radiation_wrapper.integrateSourceSpectrum(self.
radiation_model, source_id,
1079 wavelength_min, wavelength_max)
1082 @require_plugin('radiation', 'scale spectrum')
1083 def scaleSpectrum(self, existing_label: str, new_label_or_scale, scale_factor: float =
None):
1085 Scale spectrum in-place or to new label.
1087 Useful for adjusting spectrum intensities or creating variations of
1088 existing spectra for sensitivity analysis.
1090 Supports two call patterns:
1091 - scaleSpectrum("label", scale) -> scales in-place
1092 - scaleSpectrum("existing", "new", scale) -> creates new scaled spectrum
1095 existing_label: Existing global data label
1096 new_label_or_scale: Either new label string (if creating new) or scale factor (if in-place)
1097 scale_factor: Scale factor (required only if new_label_or_scale is a string)
1100 >>> # In-place scaling
1101 >>> radiation.scaleSpectrum("leaf_reflectance", 1.2)
1103 >>> # Create new scaled spectrum
1104 >>> radiation.scaleSpectrum("leaf_reflectance", "scaled_leaf", 1.5)
1106 if not isinstance(existing_label, str)
or not existing_label.strip():
1107 raise ValueError(
"Existing label must be a non-empty string")
1111 new_label_or_scale, scale_factor)
1112 logger.debug(f
"Scaled spectrum '{existing_label}'")
1114 @require_plugin('radiation', 'scale spectrum randomly')
1116 min_scale: float, max_scale: float):
1118 Scale spectrum with random factor and store as new label.
1120 Useful for creating stochastic variations in spectral properties for
1121 Monte Carlo simulations or uncertainty quantification.
1124 existing_label: Existing global data label
1125 new_label: New global data label for scaled spectrum
1126 min_scale: Minimum scale factor
1127 max_scale: Maximum scale factor
1130 >>> # Create random variation of leaf reflectance
1131 >>> radiation.scaleSpectrumRandomly("leaf_base", "leaf_variant", 0.8, 1.2)
1133 if not isinstance(existing_label, str)
or not existing_label.strip():
1134 raise ValueError(
"Existing label must be a non-empty string")
1135 if not isinstance(new_label, str)
or not new_label.strip():
1136 raise ValueError(
"New label must be a non-empty string")
1137 if min_scale >= max_scale:
1138 raise ValueError(f
"min_scale ({min_scale}) must be less than max_scale ({max_scale})")
1141 radiation_wrapper.scaleSpectrumRandomly(self.
radiation_model, existing_label, new_label,
1142 min_scale, max_scale)
1143 logger.debug(f
"Scaled spectrum '{existing_label}' randomly to '{new_label}'")
1145 @require_plugin('radiation', 'blend spectra')
1146 def blendSpectra(self, new_label: str, spectrum_labels: List[str], weights: List[float]):
1148 Blend multiple spectra with specified weights.
1150 Creates weighted combination of spectra, useful for mixing material properties
1151 or creating composite light sources.
1154 new_label: New global data label for blended spectrum
1155 spectrum_labels: List of spectrum labels to blend
1156 weights: List of weights (must sum to reasonable values, same length as labels)
1159 >>> # Mix two leaf types (70% type A, 30% type B)
1160 >>> radiation.blendSpectra("mixed_leaf",
1161 ... ["leaf_type_a", "leaf_type_b"],
1165 if not isinstance(new_label, str)
or not new_label.strip():
1166 raise ValueError(
"New label must be a non-empty string")
1167 if len(spectrum_labels) != len(weights):
1168 raise ValueError(f
"Number of labels ({len(spectrum_labels)}) must match number of weights ({len(weights)})")
1169 if not spectrum_labels:
1170 raise ValueError(
"At least one spectrum label required")
1173 radiation_wrapper.blendSpectra(self.
radiation_model, new_label, spectrum_labels, weights)
1174 logger.debug(f
"Blended {len(spectrum_labels)} spectra into '{new_label}'")
1176 @require_plugin('radiation', 'blend spectra randomly')
1179 Blend multiple spectra with random weights.
1181 Creates random combinations of spectra, useful for generating diverse
1182 material properties in stochastic simulations.
1185 new_label: New global data label for blended spectrum
1186 spectrum_labels: List of spectrum labels to blend
1189 >>> # Create random mixture of leaf spectra
1190 >>> radiation.blendSpectraRandomly("random_leaf",
1191 ... ["young_leaf", "mature_leaf", "senescent_leaf"]
1194 if not isinstance(new_label, str)
or not new_label.strip():
1195 raise ValueError(
"New label must be a non-empty string")
1196 if not spectrum_labels:
1197 raise ValueError(
"At least one spectrum label required")
1200 radiation_wrapper.blendSpectraRandomly(self.
radiation_model, new_label, spectrum_labels)
1201 logger.debug(f
"Blended {len(spectrum_labels)} spectra randomly into '{new_label}'")
1204 @require_plugin('radiation', 'interpolate spectrum from data')
1206 spectra_labels: List[str], values: List[float],
1207 primitive_data_query_label: str,
1208 primitive_data_radprop_label: str):
1210 Interpolate spectral properties based on primitive data values.
1212 Automatically assigns spectra to primitives by interpolating between
1213 reference spectra based on continuous data values (e.g., age, moisture, etc.).
1216 primitive_uuids: List of primitive UUIDs to assign spectra
1217 spectra_labels: List of reference spectrum labels
1218 values: List of data values corresponding to each spectrum
1219 primitive_data_query_label: Primitive data label containing query values
1220 primitive_data_radprop_label: Primitive data label to store assigned spectra
1223 >>> # Assign leaf reflectance based on age
1224 >>> leaf_patches = context.getAllUUIDs("patch")
1225 >>> radiation.interpolateSpectrumFromPrimitiveData(
1226 ... primitive_uuids=leaf_patches,
1227 ... spectra_labels=["young_leaf", "mature_leaf", "old_leaf"],
1228 ... values=[0.0, 50.0, 100.0], # Days since emergence
1229 ... primitive_data_query_label="leaf_age",
1230 ... primitive_data_radprop_label="reflectance"
1233 if not isinstance(primitive_uuids, (list, tuple))
or not primitive_uuids:
1234 raise ValueError(
"Primitive UUIDs must be a non-empty list")
1235 if not isinstance(spectra_labels, (list, tuple))
or not spectra_labels:
1236 raise ValueError(
"Spectra labels must be a non-empty list")
1237 if not isinstance(values, (list, tuple))
or not values:
1238 raise ValueError(
"Values must be a non-empty list")
1239 if len(spectra_labels) != len(values):
1240 raise ValueError(f
"Number of spectra ({len(spectra_labels)}) must match number of values ({len(values)})")
1243 radiation_wrapper.interpolateSpectrumFromPrimitiveData(
1245 primitive_data_query_label, primitive_data_radprop_label
1247 logger.debug(f
"Interpolated spectra for {len(primitive_uuids)} primitives")
1249 @require_plugin('radiation', 'interpolate spectrum from object data')
1251 spectra_labels: List[str], values: List[float],
1252 object_data_query_label: str,
1253 primitive_data_radprop_label: str):
1255 Interpolate spectral properties based on object data values.
1257 Automatically assigns spectra to object primitives by interpolating between
1258 reference spectra based on continuous object-level data values.
1261 object_ids: List of object IDs
1262 spectra_labels: List of reference spectrum labels
1263 values: List of data values corresponding to each spectrum
1264 object_data_query_label: Object data label containing query values
1265 primitive_data_radprop_label: Primitive data label to store assigned spectra
1268 >>> # Assign tree reflectance based on health index
1269 >>> tree_ids = [tree1_id, tree2_id, tree3_id]
1270 >>> radiation.interpolateSpectrumFromObjectData(
1271 ... object_ids=tree_ids,
1272 ... spectra_labels=["healthy_tree", "stressed_tree", "diseased_tree"],
1273 ... values=[1.0, 0.5, 0.0], # Health index
1274 ... object_data_query_label="health_index",
1275 ... primitive_data_radprop_label="reflectance"
1278 if not isinstance(object_ids, (list, tuple))
or not object_ids:
1279 raise ValueError(
"Object IDs must be a non-empty list")
1280 if not isinstance(spectra_labels, (list, tuple))
or not spectra_labels:
1281 raise ValueError(
"Spectra labels must be a non-empty list")
1282 if not isinstance(values, (list, tuple))
or not values:
1283 raise ValueError(
"Values must be a non-empty list")
1284 if len(spectra_labels) != len(values):
1285 raise ValueError(f
"Number of spectra ({len(spectra_labels)}) must match number of values ({len(values)})")
1288 radiation_wrapper.interpolateSpectrumFromObjectData(
1290 object_data_query_label, primitive_data_radprop_label
1292 logger.debug(f
"Interpolated spectra for {len(object_ids)} objects")
1294 @require_plugin('radiation', 'set ray count')
1297 Set the number of direct rays per primitive for a radiation band (default 100).
1300 Bands run together in one :meth:`runBand` call share a single ray launch,
1301 which uses the largest direct ray count set for any of them. To trace bands
1302 with different ray counts, run them in separate ``runBand()`` calls.
1304 validate_band_label(band_label,
"band_label",
"setDirectRayCount")
1305 validate_ray_count(ray_count,
"ray_count",
"setDirectRayCount")
1307 radiation_wrapper.setDirectRayCount(self.
radiation_model, band_label, ray_count)
1309 @require_plugin('radiation', 'set ray count')
1312 Set the number of diffuse rays per primitive for a radiation band (default 1000).
1314 The diffuse ray count is also used for the scattering iterations.
1317 Bands run together in one :meth:`runBand` call share a single ray launch,
1318 which uses the largest diffuse ray count set for any of them. To trace bands
1319 with different ray counts, run them in separate ``runBand()`` calls.
1321 validate_band_label(band_label,
"band_label",
"setDiffuseRayCount")
1322 validate_ray_count(ray_count,
"ray_count",
"setDiffuseRayCount")
1324 radiation_wrapper.setDiffuseRayCount(self.
radiation_model, band_label, ray_count)
1326 @require_plugin('radiation', 'set radiation flux')
1329 Set the diffuse (ambient sky) radiation flux for a band.
1331 The flux is that incident on a horizontal surface above all geometry in the domain.
1335 flux: Diffuse radiative flux (W/m²)
1338 The diffuse sky fills the upper hemisphere only (helios-core 1.3.92+): rays that
1339 leave all geometry heading below the horizon -- past the edge of a finite ground, or
1340 from the underside of an isolated object -- receive no diffuse radiation. Use
1341 :meth:`enforcePeriodicBoundary` to remove domain edge effects, or
1342 :meth:`enableBelowHorizonDiffuseRadiation` to restore the previous full-sphere sky.
1344 validate_band_label(label,
"label",
"setDiffuseRadiationFlux")
1345 validate_flux_value(flux,
"flux",
"setDiffuseRadiationFlux")
1347 radiation_wrapper.setDiffuseRadiationFlux(self.
radiation_model, label, flux)
1349 @require_plugin('radiation', 'configure diffuse radiation')
1352 Set diffuse radiation extinction coefficient with directional bias.
1354 Models directionally-biased diffuse radiation (e.g., sky radiation with zenith peak).
1358 K: Extinction coefficient
1359 peak_direction: Peak direction as vec3, SphericalCoord, or list [x, y, z]
1362 >>> from pyhelios.types import vec3
1363 >>> radiation.setDiffuseRadiationExtinctionCoeff("SW", 0.5, vec3(0, 0, 1))
1365 validate_band_label(label,
"label",
"setDiffuseRadiationExtinctionCoeff")
1367 raise ValueError(f
"Extinction coefficient must be non-negative, got {K}")
1368 validate_direction_like(peak_direction,
"peak_direction",
"setDiffuseRadiationExtinctionCoeff")
1370 radiation_wrapper.setDiffuseRadiationExtinctionCoeff(self.
radiation_model, label, K, peak_direction)
1371 logger.debug(f
"Set diffuse extinction coefficient for band '{label}': K={K}")
1373 @require_plugin('radiation', 'query diffuse flux')
1376 Get diffuse flux for band.
1379 band_label: Band label
1385 >>> flux = radiation.getDiffuseFlux("SW")
1387 validate_band_label(band_label,
"band_label",
"getDiffuseFlux")
1389 return radiation_wrapper.getDiffuseFlux(self.
radiation_model, band_label)
1391 @require_plugin('radiation', 'configure diffuse spectrum')
1394 Set diffuse spectrum from global data label.
1397 band_label: Band label (string) or list of band labels
1398 spectrum_label: Spectrum global data label
1401 >>> radiation.setDiffuseSpectrum("SW", "sky_spectrum")
1402 >>> radiation.setDiffuseSpectrum(["SW", "NIR"], "sky_spectrum")
1404 if isinstance(band_label, str):
1405 validate_band_label(band_label,
"band_label",
"setDiffuseSpectrum")
1407 for label
in band_label:
1408 validate_band_label(label,
"band_label",
"setDiffuseSpectrum")
1409 if not isinstance(spectrum_label, str)
or not spectrum_label.strip():
1410 raise ValueError(
"Spectrum label must be a non-empty string")
1413 radiation_wrapper.setDiffuseSpectrum(self.
radiation_model, band_label, spectrum_label)
1414 logger.debug(f
"Set diffuse spectrum for band(s) {band_label}")
1416 @require_plugin('radiation', 'configure diffuse spectrum')
1418 wavelength_max: float =
None, band_label: str =
None):
1420 Set diffuse spectrum integral.
1423 spectrum_integral: Integral value
1424 wavelength_min: Optional minimum wavelength
1425 wavelength_max: Optional maximum wavelength
1426 band_label: Optional specific band label (None for all bands)
1429 >>> radiation.setDiffuseSpectrumIntegral(1000.0) # All bands
1430 >>> radiation.setDiffuseSpectrumIntegral(500.0, 400, 700, band_label="PAR") # Specific band
1432 if spectrum_integral < 0:
1433 raise ValueError(f
"Spectrum integral must be non-negative, got {spectrum_integral}")
1434 if band_label
is not None:
1435 validate_band_label(band_label,
"band_label",
"setDiffuseSpectrumIntegral")
1438 radiation_wrapper.setDiffuseSpectrumIntegral(self.
radiation_model, spectrum_integral,
1439 wavelength_min, wavelength_max, band_label)
1440 logger.debug(f
"Set diffuse spectrum integral: {spectrum_integral}")
1442 @require_plugin('radiation', 'set source flux')
1443 def setSourceFlux(self, source_id, label: str, flux: float):
1444 """Set source flux for single source or multiple sources."""
1445 validate_band_label(label,
"label",
"setSourceFlux")
1446 validate_flux_value(flux,
"flux",
"setSourceFlux")
1448 if isinstance(source_id, (list, tuple)):
1450 validate_source_id_list(list(source_id),
"source_id",
"setSourceFlux")
1452 radiation_wrapper.setSourceFluxMultiple(self.
radiation_model, source_id, label, flux)
1455 validate_source_id(source_id,
"source_id",
"setSourceFlux")
1457 radiation_wrapper.setSourceFlux(self.
radiation_model, source_id, label, flux)
1460 @require_plugin('radiation', 'get source flux')
1461 @validate_get_source_flux_params
1462 def getSourceFlux(self, source_id: int, label: str) -> float:
1463 """Get source flux for band."""
1465 return radiation_wrapper.getSourceFlux(self.
radiation_model, source_id, label)
1467 @require_plugin('radiation', 'update geometry')
1468 @validate_update_geometry_params
1471 Update geometry in radiation model.
1473 The two call forms differ in more than scope, as of helios-core v1.3.79:
1475 - ``updateGeometry()`` (no argument) is **optional**. :meth:`runBand` builds
1476 the geometry itself before tracing and rebuilds it whenever primitives have
1477 been added to or deleted from the Context, or moved, rotated, scaled,
1478 reshaped or re-textured (helios-core v1.3.92+), since the last build. Call it
1479 explicitly only to control when the cost of the build is paid. Setting
1480 primitive data does not trigger a rebuild, so it is still required after
1481 changing ``twosided_flag`` or ``invisibility_flag`` on existing primitives.
1482 - ``updateGeometry(uuids)`` restricts the model to a subset of Context
1483 primitives, and that subset is **never** rebuilt automatically -- doing so
1484 would silently discard the subset you asked for. You must call it again
1485 yourself after modifying Context geometry.
1488 uuids: Optional list of specific UUIDs to update. If None, updates all geometry.
1491 Passing ``uuids`` latches the model into subset mode natively. Calling
1492 ``updateGeometry()`` with no argument afterwards clears that latch and
1493 returns the model to tracking the full Context.
1496 HeliosRuntimeError: If the scene contains voxels, which no ray-tracing backend
1497 supports, or a triangle whose texture has a transparency channel but which has
1498 no texture (u,v) coordinates (e.g. one textured with ``setPrimitiveTextureFile()``).
1503 logger.debug(
"Updated all geometry in radiation model")
1509 logger.debug(f
"Updated {len(uuids)} geometry UUIDs in radiation model")
1513 @require_plugin('radiation', 'run radiation simulation')
1514 @validate_run_band_params
1515 def runBand(self, band_label):
1517 Run radiation simulation for single band or multiple bands.
1519 PERFORMANCE NOTE: When simulating multiple radiation bands, it is HIGHLY RECOMMENDED
1520 to run all bands in a single call (e.g., runBand(["PAR", "NIR", "SW"])) rather than
1521 sequential single-band calls. This provides significant computational efficiency gains
1524 - GPU ray tracing setup is done once for all bands
1525 - Scene geometry acceleration structures are reused
1526 - GPU kernel launches are batched together
1527 - Memory transfers between CPU/GPU are minimized
1530 # EFFICIENT - Single call for multiple bands
1531 radiation.runBand(["PAR", "NIR", "SW"])
1533 # INEFFICIENT - Sequential single-band calls
1534 radiation.runBand("PAR")
1535 radiation.runBand("NIR")
1536 radiation.runBand("SW")
1539 band_label: Single band name (str) or list of band names for multi-band simulation
1542 Geometry and radiative properties are updated automatically as needed
1543 (helios-core v1.3.79+), so an explicit :meth:`updateGeometry` call is not
1544 required first. A subset build from ``updateGeometry(uuids)`` is preserved:
1545 it is never rebuilt automatically, since that would discard the subset.
1547 Bands run together share one ray launch, so the direct and diffuse ray counts
1548 used are the largest values set for any band in the call (see
1549 :meth:`setDirectRayCount` and :meth:`setDiffuseRayCount`); bands with lower
1550 counts receive more rays than requested. Run bands in separate calls to give
1551 each its own ray count.
1553 if isinstance(band_label, (list, tuple)):
1555 for lbl
in band_label:
1556 if not isinstance(lbl, str):
1557 raise TypeError(f
"Band labels must be strings, got {type(lbl).__name__}")
1560 logger.info(f
"Completed radiation simulation for bands: {band_label}")
1563 if not isinstance(band_label, str):
1564 raise TypeError(f
"Band label must be a string, got {type(band_label).__name__}")
1567 logger.info(f
"Completed radiation simulation for band: {band_label}")
1575 @require_plugin('radiation', 'get simulation results')
1577 """Get absorbed radiation flux density for all primitives, summed over all bands.
1580 **The returned values cannot be matched to UUIDs by position.** They are
1581 ordered by the radiation model's internal primitive ordering (grouped by
1582 parent object, built during :meth:`updateGeometry`), which is *not* the
1583 order of ``context.getAllUUIDs()`` -- that iterates an unordered map and
1584 returns a hash order. Pairing the two by index silently attributes flux
1585 to the wrong primitive. Use :meth:`getAbsorbedFlux` instead, which is
1589 This sums the ``radiation_flux_<band>`` primitive data over **every**
1590 band registered in the model. For overlapping bands -- e.g. PAR, NIR
1591 and SW, where SW already spans the other two -- the sum double-counts
1592 and is not a physically meaningful quantity. Use
1593 :meth:`getAbsorbedFlux` to get a single band's flux.
1595 Units are **W/m^2** (flux density), not watts. Because it is a density, the
1596 value does not change when a primitive's size changes: a 1x1 m and a 2x2 m
1597 patch under the same collimated source both report the same number.
1599 Summing the returned values directly (``sum(flux)``) adds flux densities of
1600 differently-sized surfaces and is not physically meaningful. To obtain
1601 absorbed power in watts, weight each primitive by its area -- via
1602 :meth:`getAbsorbedFlux`, so that flux and area refer to the same primitive::
1604 uuids = context.getAllUUIDs()
1605 flux = radiation.getAbsorbedFlux("SW")
1606 power = float((flux * context.getPrimitiveArea(uuids)).sum(dtype="float64"))
1609 Absorbed flux density per primitive in W/m^2, summed over all bands.
1612 :meth:`getAbsorbedFlux`: band-specific, UUID-aligned absorbed flux.
1615 results = radiation_wrapper.getTotalAbsorbedFlux(self.
radiation_model)
1616 logger.debug(f
"Retrieved absorbed flux data for {len(results)} primitives")
1619 @require_plugin('radiation', 'get band-specific simulation results')
1621 uuids: Optional[List[int]] =
None
1622 ) -> Union[
"np.ndarray", Dict[str,
"np.ndarray"]]:
1623 """Get per-band absorbed radiation flux density, aligned to UUIDs.
1625 ``runBand()`` stores each band's result as ``radiation_flux_<band>``
1626 primitive data: the **total** absorbed flux density for that band, i.e.
1627 direct plus diffuse plus scattered contributions. This method reads that
1628 data back keyed by UUID, so element ``i`` of the result always belongs to
1631 Prefer this over :meth:`getTotalAbsorbedFlux`, which sums over every band
1632 (double-counting overlapping bands such as PAR/NIR/SW) and whose ordering
1633 does not correspond to ``context.getAllUUIDs()``.
1636 band_label: Band name, or a list of band names to fetch at once.
1637 uuids: Primitives to query, defaulting to ``context.getAllUUIDs()``.
1638 Results follow this list's order.
1641 For a single band, a float32 array of absorbed flux density per
1642 primitive in W/m^2. For a list of bands, a dict mapping each band
1643 label to that array.
1646 RadiationModelError: If a band does not exist, or if ``runBand()`` has
1647 not been run for it.
1650 >>> radiation.runBand(["PAR", "NIR", "SW"])
1651 >>> uuids = context.getAllUUIDs()
1652 >>> par = radiation.getAbsorbedFlux("PAR") # W/m^2, PAR only
1653 >>> par_watts = par * context.getPrimitiveArea(uuids)
1654 >>> all_bands = radiation.getAbsorbedFlux(["PAR", "NIR", "SW"])
1655 >>> all_bands["SW"][0] # doctest: +SKIP
1657 single_band = isinstance(band_label, str)
1658 labels = [band_label]
if single_band
else band_label
1661 if not isinstance(labels, (list, tuple)):
1663 f
"band_label must be a string or list of strings, "
1664 f
"got {type(band_label).__name__}"
1667 raise ValueError(
"band_label list cannot be empty")
1668 for label
in labels:
1669 validate_band_label(label,
"band_label",
"getAbsorbedFlux")
1674 uuids = self.
context.getAllUUIDs()
1676 if not isinstance(uuids, (list, tuple)):
1678 f
"uuids must be a list of primitive UUIDs, "
1679 f
"got {type(uuids).__name__}"
1684 "No primitives to query: the Context contains no geometry."
1687 results: Dict[str,
"np.ndarray"] = {}
1688 for label
in labels:
1691 f
"Radiation band '{label}' does not exist. "
1692 f
"Add it with radiation.addRadiationBand('{label}') before "
1693 f
"querying its absorbed flux."
1696 data_label = f
"radiation_flux_{label}"
1699 if not self.
context.doesPrimitiveDataExist(uuids[0], data_label):
1701 f
"No absorbed flux results for band '{label}': primitive data "
1702 f
"'{data_label}' has not been written. Call "
1703 f
"radiation.runBand('{label}') (or include it in a multi-band "
1704 f
"runBand([...]) call) before querying its absorbed flux."
1711 results[label] = context_wrapper.getPrimitiveDataFloatArray(
1715 f
"Retrieved absorbed flux for bands {labels} over {len(uuids)} primitives"
1717 return results[band_label]
if single_band
else results
1720 @require_plugin('radiation', 'check band existence')
1723 Check if a radiation band exists.
1726 label: Name/label of the radiation band to check
1729 True if band exists, False otherwise
1732 >>> radiation.addRadiationBand("SW")
1733 >>> radiation.doesBandExist("SW")
1735 >>> radiation.doesBandExist("nonexistent")
1738 validate_band_label(label,
"label",
"doesBandExist")
1743 @require_plugin('radiation', 'manage radiation sources')
1746 Delete a radiation source.
1749 source_id: ID of the radiation source to delete
1752 >>> source_id = radiation.addCollimatedRadiationSource()
1753 >>> radiation.deleteRadiationSource(source_id)
1755 if not isinstance(source_id, int)
or source_id < 0:
1756 raise ValueError(f
"Source ID must be a non-negative integer, got {source_id}")
1758 radiation_wrapper.deleteRadiationSource(self.
radiation_model, source_id)
1759 logger.debug(f
"Deleted radiation source {source_id}")
1761 @require_plugin('radiation', 'query radiation sources')
1764 Get position of a radiation source.
1767 source_id: ID of the radiation source
1770 vec3 position of the source
1773 >>> source_id = radiation.addCollimatedRadiationSource()
1774 >>> position = radiation.getSourcePosition(source_id)
1775 >>> print(f"Source at: {position}")
1777 if not isinstance(source_id, int)
or source_id < 0:
1778 raise ValueError(f
"Source ID must be a non-negative integer, got {source_id}")
1780 position_list = radiation_wrapper.getSourcePosition(self.
radiation_model, source_id)
1781 from .wrappers.DataTypes
import vec3
1782 return vec3(position_list[0], position_list[1], position_list[2])
1785 @require_plugin('radiation', 'get sky energy')
1788 Get the radiative energy lost to the sky (deprecated).
1791 No ray-tracing backend computes the energy lost to the sky, so this always returned
1792 0. As of helios-core 1.3.92 it raises, and it will be removed in helios-core v2.0.
1795 HeliosRuntimeError: Always, from the native library
1798 "RadiationModel.getSkyEnergy() is deprecated: it is not implemented by any "
1799 "ray-tracing backend, raises an error, and will be removed in helios-core v2.0.",
1800 DeprecationWarning, stacklevel=2)
1804 @require_plugin('radiation', 'calculate G-function')
1807 Calculate G-function (geometry factor) for given view direction.
1809 The G-function describes the geometric relationship between leaf area
1810 distribution and viewing direction, important for canopy radiation modeling.
1812 The G-function is computed from the geometry currently loaded in the radiation
1813 model. If no geometry has been built yet, this method builds it automatically
1814 (with a warning) so the query operates on the current context geometry. If the
1815 result is still undefined (no primitives / zero leaf area), a RuntimeError is
1816 raised rather than silently returning NaN.
1818 A subset built by ``updateGeometry(uuids)`` is never widened by this method: the
1819 G-function is reported over the subset the caller selected.
1822 view_direction: View direction as vec3 or list/tuple [x, y, z]
1828 RuntimeError: If the context has no geometry (or zero total leaf area),
1829 so the G-function is undefined.
1832 >>> from pyhelios.types import vec3
1833 >>> radiation.updateGeometry()
1834 >>> g_value = radiation.calculateGtheta(vec3(0, 0, 1))
1835 >>> print(f"G-function: {g_value}")
1837 validate_position_like(view_direction,
"view_direction",
"calculateGtheta")
1845 "calculateGtheta requires built geometry, but the radiation model "
1846 "holds a user-specified primitive subset from updateGeometry(uuids) "
1847 "and building automatically would discard it. Call "
1848 "updateGeometry(uuids) again for the subset you want, or "
1849 "updateGeometry() to use the full Context."
1852 "calculateGtheta called before geometry was built; updating radiation "
1853 "model geometry automatically. Call updateGeometry() explicitly after "
1854 "building the scene to avoid this."
1860 value = radiation_wrapper.calculateGtheta(self.
radiation_model, context_ptr, view_direction)
1862 if value
is None or math.isnan(value):
1864 "calculateGtheta returned an undefined (NaN) G-function. The radiation "
1865 "model has no geometry with positive leaf area for this context. Add "
1866 "primitives to the Context and ensure updateGeometry() succeeds before "
1867 "calling calculateGtheta()."
1871 @require_plugin('radiation', 'configure output data')
1874 Enable optional primitive data output.
1877 label: Name/label of the primitive data to output
1880 >>> radiation.optionalOutputPrimitiveData("temperature")
1882 validate_band_label(label,
"label",
"optionalOutputPrimitiveData")
1884 radiation_wrapper.optionalOutputPrimitiveData(self.
radiation_model, label)
1885 logger.debug(f
"Enabled optional output for primitive data: {label}")
1887 @require_plugin('radiation', 'configure boundary conditions')
1890 Enforce periodic boundary conditions.
1892 Periodic boundaries are useful for large-scale simulations to reduce
1893 edge effects by wrapping radiation at domain boundaries.
1896 boundary: Boundary specification string (e.g., "xy", "xyz", "x", "y", "z")
1899 >>> radiation.enforcePeriodicBoundary("xy")
1901 if not isinstance(boundary, str)
or not boundary:
1902 raise ValueError(
"Boundary specification must be a non-empty string")
1904 radiation_wrapper.enforcePeriodicBoundary(self.
radiation_model, boundary)
1905 logger.debug(f
"Enforced periodic boundary: {boundary}")
1907 @require_plugin('radiation', 'configure diffuse radiation')
1910 Apply the diffuse (ambient) sky radiation to rays that leave the domain below the horizon.
1912 By default the diffuse sky fills only the upper hemisphere, so a ray that leaves all
1913 geometry heading downward (past the edge of a finite ground, or from the underside of an
1914 isolated object) receives no ambient radiation. This restores the behavior of
1915 helios-core before 1.3.92, in which the sky filled the full sphere and such rays received
1916 the same ambient radiation as rays leaving upward. It applies to all bands and to camera
1920 To avoid dark domain edges from a finite ground, use
1921 :meth:`enforcePeriodicBoundary` rather than this method.
1924 RuntimeError: If the native library predates helios-core v1.3.92
1927 >>> radiation.enableBelowHorizonDiffuseRadiation()
1930 radiation_wrapper.enableBelowHorizonDiffuseRadiation(self.
radiation_model)
1932 @require_plugin('radiation', 'configure diffuse radiation')
1935 Restrict the diffuse (ambient) sky radiation to the upper hemisphere (the default).
1937 See :meth:`enableBelowHorizonDiffuseRadiation`.
1940 RuntimeError: If the native library predates helios-core v1.3.92
1943 radiation_wrapper.disableBelowHorizonDiffuseRadiation(self.
radiation_model)
1945 @require_plugin('radiation', 'configure radiation simulation')
1948 Reduce the diffuse rays per primitive in later scattering iterations (on by default).
1950 Each scattering iteration launches less energy than the one before, so later iterations
1951 contribute less to the noise in the absorbed flux. With this on, iteration ``s`` uses
1952 ``N * E_s / E_0`` rays per primitive, where ``N`` is the diffuse ray count, ``E_s`` the
1953 energy launched in iteration ``s`` and ``E_0`` that of the first scattering iteration,
1954 with a minimum of 16. This makes strongly scattering bands faster, at the cost of
1955 somewhat noisier scattered flux -- most for deeply shaded primitives and camera images,
1956 whose light comes mostly from scattering.
1959 RuntimeError: If the native library predates helios-core v1.3.92
1964 @require_plugin('radiation', 'configure radiation simulation')
1967 Launch the full diffuse ray count in every scattering iteration.
1969 See :meth:`enableScatteringRayReduction`.
1972 RuntimeError: If the native library predates helios-core v1.3.92
1978 @require_plugin('radiation', 'configure radiation simulation')
1979 @validate_scattering_depth_params
1982 Set the maximum number of scattering iterations for a band.
1986 depth: Maximum number of scattering iterations (0 turns scattering off)
1989 The energy that would still be scattered after the last iteration is not dropped:
1990 the remaining iterations are summed as a geometric series, each primitive receiving
1991 its absorption in the last iteration times ``q/(1-q)``, where ``q`` is the ratio of
1992 the energy launched in the last two iterations. A depth of 3 to 5 is therefore
1993 usually enough for absorbed fluxes, even for strongly scattering bands.
1995 Radiation camera images are not extrapolated: they include only the scattering
1996 iterations that were traced, so images of strongly scattering bands need a higher
2000 radiation_wrapper.setScatteringDepth(self.
radiation_model, label, depth)
2002 @require_plugin('radiation', 'configure radiation simulation')
2003 @validate_min_scatter_energy_params
2006 Set the flux threshold that stops a band's scattering iterations early.
2008 Scattering iterations for the band stop once no primitive has more than ``energy`` flux
2009 left to scatter. The energy still in flight when they stop is accounted for as described
2010 in :meth:`setScatteringDepth`.
2014 energy: Flux threshold, in the units of the band's flux (W/m² for a shortwave band).
2015 The default of 0 never stops early, so the band runs to its scattering depth.
2018 ValueError: If ``energy`` is negative or not a number
2021 radiation_wrapper.setMinScatterEnergy(self.
radiation_model, label, energy)
2023 @require_plugin('radiation', 'configure radiation emission')
2025 """Disable emission for radiation band."""
2026 validate_band_label(label,
"label",
"disableEmission")
2030 @require_plugin('radiation', 'configure radiation emission')
2032 """Enable emission for radiation band."""
2033 validate_band_label(label,
"label",
"enableEmission")
2041 @require_plugin('radiation', 'add radiation camera')
2043 camera_properties=
None, antialiasing_samples: int = 100):
2045 Add a radiation camera to the simulation.
2048 camera_label: Unique label string for the camera
2049 band_labels: List of radiation band labels for the camera
2050 position: Camera position as vec3 object
2051 lookat_or_direction: Either:
2052 - Lookat point as vec3 object
2053 - SphericalCoord for viewing direction
2054 camera_properties: CameraProperties instance or None for defaults
2055 antialiasing_samples: Number of antialiasing samples (default: 100)
2058 ValidationError: If parameters are invalid or have wrong types
2059 RadiationModelError: If camera creation fails
2062 >>> from pyhelios import vec3, CameraProperties
2063 >>> # Create camera looking at origin from above
2064 >>> camera_props = CameraProperties(camera_resolution=(1024, 1024))
2065 >>> radiation_model.addRadiationCamera("main_camera", ["red", "green", "blue"],
2066 ... position=vec3(0, 0, 5), lookat_or_direction=vec3(0, 0, 0),
2067 ... camera_properties=camera_props)
2070 from .wrappers
import URadiationModelWrapper
as radiation_wrapper
2071 from .wrappers.DataTypes
import SphericalCoord, vec3, make_vec3
2072 from .validation.plugins
import validate_camera_label, validate_band_labels_list, validate_antialiasing_samples
2075 validated_label = validate_camera_label(camera_label,
"camera_label",
"addRadiationCamera")
2076 validated_bands = validate_band_labels_list(band_labels,
"band_labels",
"addRadiationCamera")
2077 validated_samples = validate_antialiasing_samples(antialiasing_samples,
"antialiasing_samples",
"addRadiationCamera")
2080 if not isinstance(position, vec3):
2081 raise TypeError(
"position must be a vec3 object. Use vec3(x, y, z) to create one.")
2082 validated_position = position
2085 if isinstance(lookat_or_direction, SphericalCoord):
2086 validated_direction = lookat_or_direction
2087 elif isinstance(lookat_or_direction, vec3):
2088 validated_direction = lookat_or_direction
2090 raise TypeError(
"lookat_or_direction must be a vec3 or SphericalCoord object. Use vec3(x, y, z) or SphericalCoord to create one.")
2093 if camera_properties
is None:
2099 if hasattr(validated_direction,
'radius')
and hasattr(validated_direction,
'elevation'):
2101 direction_coords = validated_direction.to_list()
2104 if len(direction_coords) < 4:
2105 raise ValueError(
"SphericalCoord must expose radius, elevation, zenith, and azimuth")
2106 radius, elevation, azimuth = direction_coords[0], direction_coords[1], direction_coords[3]
2108 radiation_wrapper.addRadiationCameraSpherical(
2112 validated_position.x, validated_position.y, validated_position.z,
2113 radius, elevation, azimuth,
2114 camera_properties.to_array(),
2116 camera_properties.exposure,
2117 camera_properties_obj=camera_properties
2121 radiation_wrapper.addRadiationCameraVec3(
2125 validated_position.x, validated_position.y, validated_position.z,
2126 validated_direction.x, validated_direction.y, validated_direction.z,
2127 camera_properties.to_array(),
2129 camera_properties.exposure,
2130 camera_properties_obj=camera_properties
2133 except Exception
as e:
2136 @require_plugin('radiation', 'add SIF camera')
2137 def addSIFCamera(self, camera_label: str, emission_band_labels: List[str], position,
2138 lookat_or_direction, camera_properties=
None, antialiasing_samples: int = 100):
2140 Add a solar-induced chlorophyll fluorescence (SIF) camera.
2142 Each band in ``emission_band_labels`` must already exist (added via
2143 :meth:`addRadiationBand`); those bands are flagged internally as SIF-emitting and
2144 use the Fluspect-B leaf-fluorescence kernel for emission instead of Stefan-Boltzmann.
2145 Helios auto-creates internal radiation bands covering 400-750 nm at the resolution
2146 specified by ``camera_properties.excitation_bin_width_nm``.
2148 Leaves fluoresce only if they carry ``fluspect_spectrum`` primitive data (authored by
2149 :class:`LeafOptics`). Every such leaf must also carry ``electron_transport_ratio``
2150 (the relative light saturation Ja/Je written by the Farquhar or C4 photosynthesis
2151 model when that optional output is requested) when a SIF band is run; otherwise
2152 :meth:`runBand` raises an error.
2155 camera_label: Unique label for the camera.
2156 emission_band_labels: List of pre-existing radiation band labels to drive
2158 position: Camera position as a ``vec3``.
2159 lookat_or_direction: Either a ``vec3`` lookat point or a ``SphericalCoord``
2161 camera_properties: :class:`SIFCameraProperties` instance. If ``None`` defaults
2162 are used (10 nm excitation bins, no excitation scattering).
2163 antialiasing_samples: Antialiasing samples per pixel (>= 1, default 100).
2166 RadiationModelError: If the underlying SIF camera cannot be added (e.g.,
2167 an emission band was already bound to a different excitation bin width).
2168 NotImplementedError: If running against helios-core older than v1.3.72.
2170 from .wrappers
import URadiationModelWrapper
as radiation_wrapper
2171 from .wrappers.DataTypes
import SphericalCoord, vec3
2172 from .validation.plugins
import (
2173 validate_camera_label, validate_band_labels_list, validate_antialiasing_samples
2176 validated_label = validate_camera_label(camera_label,
"camera_label",
"addSIFCamera")
2177 validated_bands = validate_band_labels_list(emission_band_labels,
"emission_band_labels",
"addSIFCamera")
2178 validated_samples = validate_antialiasing_samples(antialiasing_samples,
"antialiasing_samples",
"addSIFCamera")
2180 if not isinstance(position, vec3):
2181 raise TypeError(
"position must be a vec3 object. Use vec3(x, y, z) to create one.")
2183 if not isinstance(lookat_or_direction, (vec3, SphericalCoord)):
2184 raise TypeError(
"lookat_or_direction must be a vec3 or SphericalCoord object.")
2186 if camera_properties
is None:
2188 elif not isinstance(camera_properties, SIFCameraProperties):
2190 "camera_properties must be a SIFCameraProperties instance "
2191 "(use SIFCameraProperties(...) — not the plain CameraProperties)."
2196 if isinstance(lookat_or_direction, SphericalCoord):
2199 direction_coords = lookat_or_direction.to_list()
2200 if len(direction_coords) < 4:
2201 raise ValueError(
"SphericalCoord must expose radius, elevation, zenith, and azimuth")
2202 radius, elevation, azimuth = direction_coords[0], direction_coords[1], direction_coords[3]
2203 radiation_wrapper.addSIFCameraSpherical(
2207 position.x, position.y, position.z,
2208 radius, elevation, azimuth,
2209 camera_properties.to_array(),
2210 camera_properties.excitation_bin_width_nm,
2211 camera_properties.excitation_scattering_depth,
2213 camera_properties_obj=camera_properties,
2216 radiation_wrapper.addSIFCameraVec3(
2220 position.x, position.y, position.z,
2221 lookat_or_direction.x, lookat_or_direction.y, lookat_or_direction.z,
2222 camera_properties.to_array(),
2223 camera_properties.excitation_bin_width_nm,
2224 camera_properties.excitation_scattering_depth,
2226 camera_properties_obj=camera_properties,
2228 except Exception
as e:
2231 @require_plugin('radiation', 'check SIF camera registration')
2234 Return True if the camera was registered via :meth:`addSIFCamera` (vs. ``addRadiationCamera``).
2236 from .wrappers
import URadiationModelWrapper
as radiation_wrapper
2237 if not isinstance(camera_label, str)
or not camera_label.strip():
2238 raise ValueError(
"Camera label must be a non-empty string")
2240 return radiation_wrapper.isSIFCamera(self.
radiation_model, camera_label)
2242 @require_plugin('radiation', 'enable camera flux smoothing')
2245 Reconstruct camera images by interpolating outgoing flux across each facet.
2247 Averages each primitive's outgoing flux onto the mesh vertices it shares with its
2248 neighbours and interpolates it back across the facet, so a coarsely tessellated
2249 curved surface reads as a curve rather than as a set of flat panels. This applies
2250 to Tube, Sphere, Cone, Polymesh, Tile and AdaptiveTile objects. A Box, a Disk, and
2251 any primitive belonging to no object are left alone, since their faces are genuinely
2252 flat. A polymesh must carry the face table that :meth:`Context.loadOBJ` and
2253 :meth:`Context.loadPLY` retain; one assembled from loose primitives by
2254 :meth:`Context.addPolymeshObject` has no topology and is left alone.
2256 Smoothing is off by default and should be enabled deliberately: it changes what the
2257 camera reports. A pixel no longer carries the outgoing flux of the primitive behind
2258 it, but a blend of that primitive's value with its neighbours', so pixel values no
2259 longer correspond exactly to the ``radiation_flux_*`` primitive data. The radiation
2260 solve, those primitive data values, and the pixel-label and depth images are all
2264 crease_angle_degrees: Angle between adjacent facet normals above which a shared
2265 edge is treated as a hard crease, so flux is not averaged across it. Applies
2266 to polymesh objects only; the curved object types are smooth by construction
2267 and are never creased. Must be between 0 and 180 degrees. Defaults to 30.
2270 RadiationModelError: If the crease angle is out of range or the call fails
2271 RuntimeError: If the native library predates helios-core v1.3.84
2274 If geometry has already been built, this re-uploads it and rebuilds the
2275 acceleration structure, which is not cheap. Prefer calling it before
2276 :meth:`updateGeometry`.
2279 >>> with RadiationModel(context) as radiation:
2280 ... radiation.enableCameraFluxSmoothing(crease_angle_degrees=45.0)
2281 ... radiation.updateGeometry()
2283 from .wrappers
import URadiationModelWrapper
as radiation_wrapper
2284 if not isinstance(crease_angle_degrees, (int, float))
or isinstance(crease_angle_degrees, bool):
2286 f
"crease_angle_degrees must be a number, got {type(crease_angle_degrees).__name__}"
2290 radiation_wrapper.enableCameraFluxSmoothing(self.
radiation_model, float(crease_angle_degrees))
2291 except RuntimeError:
2293 except Exception
as e:
2296 @require_plugin('radiation', 'disable camera flux smoothing')
2299 Reconstruct camera images by holding the outgoing flux constant across each facet.
2301 This is the default. Each pixel reports the outgoing flux of the primitive behind it.
2304 RadiationModelError: If the call fails
2305 RuntimeError: If the native library predates helios-core v1.3.84
2308 If geometry has already been built, this re-uploads it and rebuilds the
2309 acceleration structure.
2311 from .wrappers
import URadiationModelWrapper
as radiation_wrapper
2315 except RuntimeError:
2317 except Exception
as e:
2320 @require_plugin('radiation', 'query camera flux smoothing state')
2323 Return True if camera images are reconstructed by interpolating flux across each facet.
2326 RuntimeError: If the native library predates helios-core v1.3.84
2328 from .wrappers
import URadiationModelWrapper
as radiation_wrapper
2330 return radiation_wrapper.isCameraFluxSmoothingEnabled(self.
radiation_model)
2332 @require_plugin('radiation', 'query camera flux smoothing crease angle')
2335 Return the crease angle in degrees used by camera flux smoothing.
2337 This is the value last passed to :meth:`enableCameraFluxSmoothing`, or 30.0 if it
2338 has never been called.
2341 RuntimeError: If the native library predates helios-core v1.3.84
2343 from .wrappers
import URadiationModelWrapper
as radiation_wrapper
2345 return radiation_wrapper.getCameraFluxSmoothingCreaseAngle(self.
radiation_model)
2347 @require_plugin('radiation', 'manage camera position')
2350 Set camera position.
2352 Allows dynamic camera repositioning during simulation, useful for
2353 time-series captures or multi-view imaging.
2356 camera_label: Camera label string
2357 position: Camera position as vec3 or list [x, y, z]
2360 >>> radiation.setCameraPosition("cam1", [0, 0, 10])
2361 >>> from pyhelios.types import vec3
2362 >>> radiation.setCameraPosition("cam1", vec3(5, 5, 10))
2364 if not isinstance(camera_label, str)
or not camera_label.strip():
2365 raise ValueError(
"Camera label must be a non-empty string")
2366 validate_position_like(position,
"position",
"setCameraPosition")
2368 radiation_wrapper.setCameraPosition(self.
radiation_model, camera_label, position)
2369 logger.debug(f
"Updated camera '{camera_label}' position")
2371 @require_plugin('radiation', 'query camera position')
2374 Get camera position.
2377 camera_label: Camera label string
2380 vec3 position of the camera
2383 >>> position = radiation.getCameraPosition("cam1")
2384 >>> print(f"Camera at: {position}")
2386 if not isinstance(camera_label, str)
or not camera_label.strip():
2387 raise ValueError(
"Camera label must be a non-empty string")
2389 position_list = radiation_wrapper.getCameraPosition(self.
radiation_model, camera_label)
2390 from .wrappers.DataTypes
import vec3
2391 return vec3(position_list[0], position_list[1], position_list[2])
2393 @require_plugin('radiation', 'manage camera lookat')
2396 Set camera lookat point.
2399 camera_label: Camera label string
2400 lookat: Lookat point as vec3 or list [x, y, z]
2403 >>> radiation.setCameraLookat("cam1", [0, 0, 0])
2405 if not isinstance(camera_label, str)
or not camera_label.strip():
2406 raise ValueError(
"Camera label must be a non-empty string")
2407 validate_position_like(lookat,
"lookat",
"setCameraLookat")
2409 radiation_wrapper.setCameraLookat(self.
radiation_model, camera_label, lookat)
2410 logger.debug(f
"Updated camera '{camera_label}' lookat point")
2412 @require_plugin('radiation', 'query camera lookat')
2415 Get camera lookat point.
2418 camera_label: Camera label string
2424 >>> lookat = radiation.getCameraLookat("cam1")
2425 >>> print(f"Camera looking at: {lookat}")
2427 if not isinstance(camera_label, str)
or not camera_label.strip():
2428 raise ValueError(
"Camera label must be a non-empty string")
2430 lookat_list = radiation_wrapper.getCameraLookat(self.
radiation_model, camera_label)
2431 from .wrappers.DataTypes
import vec3
2432 return vec3(lookat_list[0], lookat_list[1], lookat_list[2])
2434 @require_plugin('radiation', 'manage camera orientation')
2437 Set camera orientation.
2440 camera_label: Camera label string
2441 direction: View direction as vec3, SphericalCoord, or list [x, y, z]
2444 >>> radiation.setCameraOrientation("cam1", [0, 0, 1])
2445 >>> from pyhelios.types import SphericalCoord
2446 >>> radiation.setCameraOrientation("cam1", SphericalCoord(1.0, 45.0, 90.0))
2448 if not isinstance(camera_label, str)
or not camera_label.strip():
2449 raise ValueError(
"Camera label must be a non-empty string")
2450 validate_direction_like(direction,
"direction",
"setCameraOrientation")
2452 radiation_wrapper.setCameraOrientation(self.
radiation_model, camera_label, direction)
2453 logger.debug(f
"Updated camera '{camera_label}' orientation")
2455 @require_plugin('radiation', 'query camera orientation')
2458 Get camera orientation.
2461 camera_label: Camera label string
2464 SphericalCoord orientation [radius, elevation, azimuth]
2467 >>> orientation = radiation.getCameraOrientation("cam1")
2468 >>> print(f"Camera orientation: {orientation}")
2470 if not isinstance(camera_label, str)
or not camera_label.strip():
2471 raise ValueError(
"Camera label must be a non-empty string")
2473 orientation_list = radiation_wrapper.getCameraOrientation(self.
radiation_model, camera_label)
2474 from .wrappers.DataTypes
import SphericalCoord
2475 return SphericalCoord(orientation_list[0], orientation_list[1], orientation_list[2])
2477 @require_plugin('radiation', 'query cameras')
2480 Get all camera labels.
2483 List of all camera label strings
2486 >>> cameras = radiation.getAllCameraLabels()
2487 >>> print(f"Available cameras: {cameras}")
2492 @require_plugin('radiation', 'configure camera spectral response')
2495 Set camera spectral response from global data.
2498 camera_label: Camera label
2499 band_label: Band label
2500 global_data: Global data label for spectral response curve
2503 >>> radiation.setCameraSpectralResponse("cam1", "red", "sensor_red_response")
2505 if not isinstance(camera_label, str)
or not camera_label.strip():
2506 raise ValueError(
"Camera label must be a non-empty string")
2507 validate_band_label(band_label,
"band_label",
"setCameraSpectralResponse")
2508 if not isinstance(global_data, str)
or not global_data.strip():
2509 raise ValueError(
"Global data label must be a non-empty string")
2512 radiation_wrapper.setCameraSpectralResponse(self.
radiation_model, camera_label, band_label, global_data)
2513 logger.debug(f
"Set spectral response for camera '{camera_label}', band '{band_label}'")
2515 camera_label, [band_label],
"setCameraSpectralResponse")
2517 @require_plugin('radiation', 'enable camera sensor atmosphere')
2520 Convert a camera's images to the radiance reaching a sensor above the atmosphere.
2522 Use this to simulate satellite imagery. After each camera ray trace, the radiance of
2523 every pixel in a reflective band becomes ``L_path + T_dir_up * L_pixel + L_adj``: the
2524 path radiance, the surface-leaving radiance attenuated by the upward direct
2525 transmittance, and the adjacency radiance, each combined over the band with the
2526 camera's spectral response. These spectra are computed by
2527 :meth:`SolarPosition.calculateSensorAtmosphereSpectra`.
2529 Emission (thermal) bands are converted with the thermal atmosphere computed by
2530 :meth:`SolarPosition.calculateSensorThermalAtmosphere` under the same label. Each
2531 pixel's in-band radiance gives a brightness temperature; the Planck spectrum at that
2532 temperature is transmitted through the atmosphere wavelength by wavelength and
2533 integrated over the band with the camera's spectral response, and the upwelling
2534 atmospheric radiance is added.
2537 camera_label: Label of an existing camera.
2538 atmosphere_label: Label prefix passed to
2539 :meth:`SolarPosition.calculateSensorAtmosphereSpectra` (reflective bands)
2540 and :meth:`SolarPosition.calculateSensorThermalAtmosphere` (emission bands).
2543 ValueError: If either label is not a non-empty string
2544 RadiationModelError: If the camera does not exist
2545 RuntimeError: If the native library predates helios-core v1.3.90
2548 The atmosphere spectra are read when :meth:`runBand` is next called, not here,
2549 so problems with them are raised by ``runBand()``. It raises if:
2551 - the spectra do not exist in Context global data, or were not computed for the
2552 direction from the camera's look-at point toward its position (to within 1 degree);
2553 - any pixel sees the sky rather than the scene;
2554 - a reflective band lies outside the wavelength range of the atmosphere spectra
2555 (300-2600 nm at the default 1 nm resolution);
2556 - an emission band has no wavelength bounds, has bounds outside 5502-15326 nm,
2557 or a pixel's brightness temperature lies outside 150-400 K.
2559 The spectra describe the camera's central viewing direction, so give the camera
2560 a narrow field of view. The camera does not need to be at orbital altitude.
2562 Illuminate the scene with the ``<label>_direct_irradiance`` and
2563 ``<label>_diffuse_irradiance`` spectra stored by
2564 :meth:`SolarPosition.calculateSensorAtmosphereSpectra`, so that the illumination
2565 and the view path share one atmosphere.
2567 Use ``CameraProperties(exposure="manual", white_balance="off")`` to obtain
2568 radiance values; any other setting rescales the images after the atmosphere is
2572 >>> solar.calculateSensorAtmosphereSpectra("satellite_atmosphere", vec3(0, 0, 1))
2573 >>> radiation.addRadiationCamera("satellite", ["red"], position=vec3(0, 0, 50),
2574 ... lookat_or_direction=vec3(0, 0, 0),
2575 ... camera_properties=properties)
2576 >>> radiation.enableCameraAtmosphere("satellite", "satellite_atmosphere")
2577 >>> radiation.runBand("red")
2579 if not isinstance(camera_label, str)
or not camera_label.strip():
2580 raise ValueError(
"Camera label must be a non-empty string")
2581 if not isinstance(atmosphere_label, str)
or not atmosphere_label.strip():
2582 raise ValueError(
"Atmosphere label must be a non-empty string")
2586 radiation_wrapper.enableCameraAtmosphere(self.
radiation_model, camera_label, atmosphere_label)
2587 except RuntimeError:
2589 except Exception
as e:
2590 raise RadiationModelError(f
"Failed to enable sensor atmosphere for camera '{camera_label}': {e}")
2592 @require_plugin('radiation', 'disable camera sensor atmosphere')
2595 Stop applying a sensor atmosphere to a camera's images.
2597 Images rendered by later :meth:`runBand` calls record the radiance leaving the
2598 scene again. Calling this for a camera with no atmosphere enabled does nothing.
2601 camera_label: Label of an existing camera.
2604 ValueError: If camera_label is not a non-empty string
2605 RadiationModelError: If the camera does not exist
2606 RuntimeError: If the native library predates helios-core v1.3.90
2608 if not isinstance(camera_label, str)
or not camera_label.strip():
2609 raise ValueError(
"Camera label must be a non-empty string")
2613 radiation_wrapper.disableCameraAtmosphere(self.
radiation_model, camera_label)
2614 except RuntimeError:
2616 except Exception
as e:
2617 raise RadiationModelError(f
"Failed to disable sensor atmosphere for camera '{camera_label}': {e}")
2619 @require_plugin('radiation', 'configure camera from library')
2622 Set camera spectral response from standard camera library.
2624 Uses pre-defined spectral response curves for common cameras.
2627 camera_label: Camera label
2628 camera_library_name: Standard camera name (e.g., "iPhone13", "NikonD850", "CanonEOS5D")
2631 >>> radiation.setCameraSpectralResponseFromLibrary("cam1", "iPhone13")
2633 if not isinstance(camera_label, str)
or not camera_label.strip():
2634 raise ValueError(
"Camera label must be a non-empty string")
2635 if not isinstance(camera_library_name, str)
or not camera_library_name.strip():
2636 raise ValueError(
"Camera library name must be a non-empty string")
2639 radiation_wrapper.setCameraSpectralResponseFromLibrary(self.
radiation_model, camera_label, camera_library_name)
2640 logger.debug(f
"Set camera '{camera_label}' response from library: {camera_library_name}")
2642 @require_plugin('radiation', 'get camera pixel data')
2645 Get camera pixel data for specific band.
2647 Retrieves raw pixel values for programmatic access and analysis.
2650 camera_label: Camera label
2651 band_label: Band label
2654 List of pixel values
2657 >>> pixels = radiation.getCameraPixelData("cam1", "red")
2658 >>> print(f"Mean pixel value: {sum(pixels)/len(pixels)}")
2660 if not isinstance(camera_label, str)
or not camera_label.strip():
2661 raise ValueError(
"Camera label must be a non-empty string")
2662 validate_band_label(band_label,
"band_label",
"getCameraPixelData")
2665 return radiation_wrapper.getCameraPixelData(self.
radiation_model, camera_label, band_label)
2667 @require_plugin('radiation', 'set camera pixel data')
2668 def setCameraPixelData(self, camera_label: str, band_label: str, pixel_data: List[float]):
2670 Set camera pixel data for specific band.
2672 Allows programmatic modification of pixel values.
2675 camera_label: Camera label
2676 band_label: Band label
2677 pixel_data: List of pixel values
2680 >>> pixels = radiation.getCameraPixelData("cam1", "red")
2681 >>> modified_pixels = [p * 1.2 for p in pixels] # Brighten by 20%
2682 >>> radiation.setCameraPixelData("cam1", "red", modified_pixels)
2684 if not isinstance(camera_label, str)
or not camera_label.strip():
2685 raise ValueError(
"Camera label must be a non-empty string")
2686 validate_band_label(band_label,
"band_label",
"setCameraPixelData")
2687 if not isinstance(pixel_data, (list, tuple)):
2688 raise ValueError(
"Pixel data must be a list or tuple")
2691 radiation_wrapper.setCameraPixelData(self.
radiation_model, camera_label, band_label, pixel_data)
2692 logger.debug(f
"Set pixel data for camera '{camera_label}', band '{band_label}': {len(pixel_data)} pixels")
2696 if not isinstance(camera_label, str)
or not camera_label.strip():
2697 raise ValueError(f
"Camera label must be a non-empty string, got {camera_label!r}")
2698 validate_band_label(red_band_label,
"red_band_label", function_name)
2699 validate_band_label(green_band_label,
"green_band_label", function_name)
2700 validate_band_label(blue_band_label,
"blue_band_label", function_name)
2702 @require_plugin('radiation', 'apply camera image corrections')
2704 blue_band_label: str, saturation_adjustment: float = 1.0,
2705 brightness_adjustment: float = 1.0,
2706 contrast_adjustment: float = 1.0) ->
None:
2708 Apply brightness, contrast, and saturation corrections to an RGB camera image.
2710 Modifies the camera's stored pixel data for the three bands in place, in this order:
2712 1. Lens flare, if lens flare is enabled for the camera.
2713 2. Brightness and contrast, in linear space:
2714 ``v = 0.5 + (v * brightness_adjustment - 0.5) * contrast_adjustment``.
2715 3. Saturation: each channel is moved away from (or toward) the pixel's luminance,
2716 ``v = L + saturation_adjustment * (v - L)``.
2718 Values are not clamped. A factor of exactly 1.0 skips its step, so calling with all
2719 defaults changes nothing (apart from lens flare, when enabled).
2721 Call it after :meth:`runBand` has rendered the camera (or after
2722 :meth:`setCameraPixelData`) and before :meth:`writeCameraImage`. Auto exposure and
2723 white balance are not applied here: they are applied during rendering according to
2724 the camera's ``exposure`` and ``white_balance`` :class:`CameraProperties`. sRGB gamma
2725 compression is applied by :meth:`writeCameraImage` when it writes a 3-band image. If
2726 you also apply a color correction matrix, apply it first with
2727 :meth:`applyCameraColorCorrectionMatrix`.
2729 The corrections change the stored pixel data permanently, so calling this twice
2730 compounds them. The factors are recorded in the camera metadata when metadata export
2731 is enabled with :meth:`enableCameraMetadata`.
2734 camera_label: Label of an existing camera.
2735 red_band_label: Band holding the red channel.
2736 green_band_label: Band holding the green channel.
2737 blue_band_label: Band holding the blue channel.
2738 saturation_adjustment: Saturation factor; 0 gives grayscale, >1 more saturated.
2739 Default 1.0 (no change).
2740 brightness_adjustment: Multiplier applied to every channel. Default 1.0 (no change).
2741 contrast_adjustment: Contrast factor about a midpoint of 0.5. Default 1.0 (no change).
2744 ValueError: If a label is not a non-empty string, or an adjustment is not a
2745 finite, non-negative number
2746 RadiationModelError: If the camera does not exist or has no pixel data for one of
2747 the bands (e.g. it has not been rendered)
2750 >>> radiation.runBand(["red", "green", "blue"])
2751 >>> radiation.applyCameraImageCorrections("cam", "red", "green", "blue",
2752 ... saturation_adjustment=1.2,
2753 ... brightness_adjustment=1.1,
2754 ... contrast_adjustment=1.05)
2755 >>> radiation.writeCameraImage("cam", ["red", "green", "blue"], "corrected")
2758 "applyCameraImageCorrections")
2759 for name, value
in ((
"saturation_adjustment", saturation_adjustment),
2760 (
"brightness_adjustment", brightness_adjustment),
2761 (
"contrast_adjustment", contrast_adjustment)):
2762 if isinstance(value, bool)
or not isinstance(value, (int, float)):
2763 raise ValueError(f
"{name} must be a number, got {type(value).__name__}")
2764 if not math.isfinite(value)
or value < 0:
2765 raise ValueError(f
"{name} must be a finite, non-negative number, got {value}")
2769 radiation_wrapper.applyCameraImageCorrections(
2771 saturation_adjustment, brightness_adjustment, contrast_adjustment)
2772 except RuntimeError:
2774 except Exception
as e:
2775 raise RadiationModelError(f
"Failed to apply image corrections to camera '{camera_label}': {e}")
2777 @require_plugin('radiation', 'apply camera color correction matrix')
2779 blue_band_label: str, ccm_file_path: Union[str, os.PathLike]) ->
None:
2781 Apply a color correction matrix (CCM) from an XML file to an RGB camera image.
2783 Each pixel's ``(r, g, b)`` is replaced in place by ``M @ (r, g, b)`` for a 3x3 matrix,
2784 or ``M[:, :3] @ (r, g, b) + M[:, 3]`` for a matrix with a fourth, affine offset column
2785 (3 rows of 4 values, labelled ``matrix_type="4x3"`` in the file).
2786 Values are not clamped. The matrix file is the XML format written by
2787 :meth:`autoCalibrateCameraImage` through its ``ccm_export_file_path`` argument::
2790 <ColorCorrectionMatrix camera_label="cam" matrix_type="3x3">
2791 <row>1.0 0.0 0.0</row>
2792 <row>0.0 1.0 0.0</row>
2793 <row>0.0 0.0 1.0</row>
2794 </ColorCorrectionMatrix>
2797 Each ``<row>`` must be on its own line. Apply it after :meth:`runBand` and before
2798 :meth:`applyCameraImageCorrections` and :meth:`writeCameraImage`. The change to the
2799 stored pixel data is permanent, so applying the matrix twice applies it twice.
2802 camera_label: Label of an existing camera.
2803 red_band_label: Band holding the red channel.
2804 green_band_label: Band holding the green channel.
2805 blue_band_label: Band holding the blue channel.
2806 ccm_file_path: Path to the color correction matrix XML file.
2809 ValueError: If a label is not a non-empty string or the path is empty
2810 FileNotFoundError: If ccm_file_path does not exist
2811 RadiationModelError: If the camera or a band does not exist, the camera has no
2812 pixel data for a band, or the file holds no valid 3x3 or 4x3 matrix
2815 >>> radiation.autoCalibrateCameraImage("cam", "red", "green", "blue", "calibrated.jpeg",
2816 ... ccm_export_file_path="cam_ccm.xml")
2817 >>> # Later, apply the same correction to a new render of that camera
2818 >>> radiation.runBand(["red", "green", "blue"])
2819 >>> radiation.applyCameraColorCorrectionMatrix("cam", "red", "green", "blue", "cam_ccm.xml")
2820 >>> radiation.writeCameraImage("cam", ["red", "green", "blue"], "corrected")
2823 "applyCameraColorCorrectionMatrix")
2824 if not isinstance(ccm_file_path, (str, os.PathLike)):
2825 raise ValueError(f
"ccm_file_path must be a string or path, got {type(ccm_file_path).__name__}")
2826 ccm_file_path = os.fspath(ccm_file_path)
2827 if not ccm_file_path.strip():
2828 raise ValueError(
"ccm_file_path must be a non-empty path")
2829 if not os.path.isfile(ccm_file_path):
2830 raise FileNotFoundError(f
"Color correction matrix file not found: {ccm_file_path}")
2834 radiation_wrapper.applyCameraColorCorrectionMatrix(
2835 self.
radiation_model, camera_label, red_band_label, green_band_label, blue_band_label,
2837 except RuntimeError:
2839 except Exception
as e:
2841 f
"Failed to apply color correction matrix '{ccm_file_path}' to camera '{camera_label}': {e}")
2847 @require_plugin('radiation', 'add camera from library')
2849 position, lookat, antialiasing_samples: int = 1,
2850 band_labels: Optional[List[str]] =
None):
2852 Add radiation camera loading all properties from camera library.
2854 Loads camera intrinsic parameters (resolution, FOV, sensor size) and spectral
2855 response data from the camera library XML file. This is the recommended way to
2856 create realistic cameras with proper spectral responses.
2859 camera_label: Label for the camera instance
2860 library_camera_label: Label of camera in library (e.g., "Canon_20D", "iPhone11", "NikonD700")
2861 position: Camera position as vec3 or (x, y, z) tuple
2862 lookat: Lookat point as vec3 or (x, y, z) tuple
2863 antialiasing_samples: Number of ray samples per pixel. Default: 1
2864 band_labels: Optional custom band labels. If None, uses library defaults.
2867 RadiationModelError: If operation fails
2868 ValueError: If parameters are invalid
2871 Available cameras in plugins/radiation/camera_library/camera_library.xml include:
2872 - Canon_20D, Nikon_D700, Nikon_D50
2873 - iPhone11, iPhone12ProMAX
2874 - Additional cameras available in library
2877 >>> radiation.addRadiationCameraFromLibrary(
2878 ... camera_label="cam1",
2879 ... library_camera_label="iPhone11",
2880 ... position=(0, -5, 1),
2881 ... lookat=(0, 0, 0.5),
2882 ... antialiasing_samples=10
2885 validate_band_label(camera_label,
"camera_label",
"addRadiationCameraFromLibrary")
2886 validate_position_like(position,
"position",
"addRadiationCameraFromLibrary")
2887 validate_position_like(lookat,
"lookat",
"addRadiationCameraFromLibrary")
2891 radiation_wrapper.addRadiationCameraFromLibrary(
2893 position, lookat, antialiasing_samples, band_labels
2895 logger.info(f
"Added camera '{camera_label}' from library '{library_camera_label}'")
2899 camera_label, band_labels,
"addRadiationCameraFromLibrary")
2900 except Exception
as e:
2903 @require_plugin('radiation', 'update camera parameters')
2906 Update camera parameters for an existing camera.
2908 Allows modification of camera properties after creation while preserving
2909 position, lookat direction, and spectral band configuration.
2912 camera_label: Label for the camera to update
2913 camera_properties: CameraProperties instance with new parameters
2916 RadiationModelError: If operation fails or camera doesn't exist
2917 ValueError: If parameters are invalid
2920 FOV_aspect_ratio is automatically recalculated from camera_resolution.
2921 Camera position and lookat are preserved.
2924 **Changing camera_resolution discards the camera's existing image data.**
2925 The per-pixel buffers are sized to the resolution the camera was rendered
2926 at, so helios-core v1.3.79+ clears them rather than reinterpret them at the
2927 new size. The camera must be re-rendered with :meth:`runBand` before its
2928 image can be written again; :meth:`writeCameraImage` will otherwise report
2929 that the camera has no rendered pixel data. Changing any other parameter
2930 leaves existing image data intact.
2933 >>> props = CameraProperties(
2934 ... camera_resolution=(1920, 1080),
2936 ... lens_focal_length=0.085 # 85mm lens
2938 >>> radiation.updateCameraParameters("cam1", props)
2939 >>> # Resolution changed, so re-render before writing an image.
2940 >>> radiation.runBand(["red", "green", "blue"])
2941 >>> radiation.writeCameraImage("cam1", ["red", "green", "blue"], "out")
2943 validate_band_label(camera_label,
"camera_label",
"updateCameraParameters")
2945 if not isinstance(camera_properties, CameraProperties):
2946 raise ValueError(
"camera_properties must be a CameraProperties instance")
2950 radiation_wrapper.updateCameraParameters(self.
radiation_model, camera_label, camera_properties)
2951 logger.debug(f
"Updated parameters for camera '{camera_label}'")
2952 except Exception
as e:
2955 @require_plugin('radiation', 'enable camera metadata')
2958 Enable automatic JSON metadata file writing for camera(s).
2960 When enabled, writeCameraImage() automatically creates a JSON metadata file
2961 alongside the image containing comprehensive camera and scene information.
2964 camera_labels: Single camera label (str) or list of camera labels (List[str])
2967 RadiationModelError: If operation fails
2968 ValueError: If parameters are invalid
2972 - Camera properties (model, lens, sensor specs)
2973 - Geographic location (latitude, longitude)
2974 - Acquisition settings (date, time, exposure, white balance)
2975 - Agronomic data (plant species, heights, phenology stages)
2978 >>> # Enable for single camera
2979 >>> radiation.enableCameraMetadata("cam1")
2981 >>> # Enable for multiple cameras
2982 >>> radiation.enableCameraMetadata(["cam1", "cam2", "cam3"])
2986 radiation_wrapper.enableCameraMetadata(self.
radiation_model, camera_labels)
2987 if isinstance(camera_labels, str):
2988 logger.info(f
"Enabled metadata for camera '{camera_labels}'")
2990 logger.info(f
"Enabled metadata for {len(camera_labels)} cameras")
2991 except Exception
as e:
2994 @require_plugin('radiation', 'write camera images')
2995 def writeCameraImage(self, camera: str, bands: List[str], imagefile_base: str,
2996 image_path: str =
"./", frame: int = -1,
2997 flux_to_pixel_conversion: float = 1.0) -> str:
2999 Write camera image to file and return output filename.
3002 camera: Camera label
3003 bands: List of band labels to include in the image
3004 imagefile_base: Base filename for output
3005 image_path: Output directory path (default: current directory)
3006 frame: Frame number to write (-1 for all frames)
3007 flux_to_pixel_conversion: Conversion factor from flux to pixel values
3010 Output filename string
3013 RadiationModelError: If camera image writing fails
3014 TypeError: If parameters have incorrect types
3017 if not isinstance(camera, str)
or not camera.strip():
3018 raise TypeError(
"Camera label must be a non-empty string")
3019 if not isinstance(bands, list)
or not bands:
3020 raise TypeError(
"Bands must be a non-empty list of strings")
3021 if not all(isinstance(band, str)
and band.strip()
for band
in bands):
3022 raise TypeError(
"All band labels must be non-empty strings")
3023 if not isinstance(imagefile_base, str)
or not imagefile_base.strip():
3024 raise TypeError(
"Image file base must be a non-empty string")
3025 if not isinstance(image_path, str):
3026 raise TypeError(
"Image path must be a string")
3027 if not isinstance(frame, int):
3028 raise TypeError(
"Frame must be an integer")
3029 if not isinstance(flux_to_pixel_conversion, (int, float))
or flux_to_pixel_conversion <= 0:
3030 raise TypeError(
"Flux to pixel conversion must be a positive number")
3034 filename = radiation_wrapper.writeCameraImage(
3036 image_path, frame, flux_to_pixel_conversion)
3039 "write camera image")
3040 logger.info(f
"Camera image written to: {filename}")
3043 @require_plugin('radiation', 'write normalized camera images')
3045 image_path: str =
"./", frame: int = -1) -> str:
3047 Write normalized camera image to file and return output filename.
3050 camera: Camera label
3051 bands: List of band labels to include in the image
3052 imagefile_base: Base filename for output
3053 image_path: Output directory path (default: current directory)
3054 frame: Frame number to write (-1 for all frames)
3057 Output filename string
3060 RadiationModelError: If normalized camera image writing fails
3061 TypeError: If parameters have incorrect types
3064 if not isinstance(camera, str)
or not camera.strip():
3065 raise TypeError(
"Camera label must be a non-empty string")
3066 if not isinstance(bands, list)
or not bands:
3067 raise TypeError(
"Bands must be a non-empty list of strings")
3068 if not all(isinstance(band, str)
and band.strip()
for band
in bands):
3069 raise TypeError(
"All band labels must be non-empty strings")
3070 if not isinstance(imagefile_base, str)
or not imagefile_base.strip():
3071 raise TypeError(
"Image file base must be a non-empty string")
3072 if not isinstance(image_path, str):
3073 raise TypeError(
"Image path must be a string")
3074 if not isinstance(frame, int):
3075 raise TypeError(
"Frame must be an integer")
3079 filename = radiation_wrapper.writeNormCameraImage(
3080 self.
radiation_model, camera, bands, imagefile_base, image_path, frame)
3083 "write normalized camera image")
3084 logger.info(f
"Normalized camera image written to: {filename}")
3087 @require_plugin('radiation', 'write camera image data')
3089 image_path: str =
"./", frame: int = -1):
3091 Write camera image data to file (ASCII format).
3094 camera: Camera label
3096 imagefile_base: Base filename for output
3097 image_path: Output directory path (default: current directory)
3098 frame: Frame number to write (-1 for all frames)
3101 RadiationModelError: If camera image data writing fails
3102 TypeError: If parameters have incorrect types
3105 if not isinstance(camera, str)
or not camera.strip():
3106 raise TypeError(
"Camera label must be a non-empty string")
3107 if not isinstance(band, str)
or not band.strip():
3108 raise TypeError(
"Band label must be a non-empty string")
3109 if not isinstance(imagefile_base, str)
or not imagefile_base.strip():
3110 raise TypeError(
"Image file base must be a non-empty string")
3111 if not isinstance(image_path, str):
3112 raise TypeError(
"Image path must be a string")
3113 if not isinstance(frame, int):
3114 raise TypeError(
"Frame must be an integer")
3117 radiation_wrapper.writeCameraImageData(
3118 self.
radiation_model, camera, band, imagefile_base, image_path, frame)
3120 logger.info(f
"Camera image data written for camera {camera}, band {band}")
3122 @require_plugin('radiation', 'write primitive data label map')
3124 image_path: str =
"./", frame: int = -1, padvalue: float = float(
'nan')):
3126 Write a per-pixel primitive-data label map for a camera to a text file.
3128 For each camera pixel, writes the value of ``primitive_data_label`` on the primitive
3129 seen at that pixel. Pixels that hit no geometry (or a primitive lacking the data) are
3130 written as ``padvalue`` (NaN by default). The primitive data must be of type
3131 float, double, uint, or int. The radiation model must have been run
3132 (``updateGeometry`` + ``runBand``) so that per-pixel primitive labels exist.
3134 The output file is written row-by-row (one line per image row), so it loads directly
3135 into a 2D ``(height, width)`` array. See :meth:`getPrimitiveDataLabelMap` for a
3136 convenience that returns a NumPy array instead of a file on disk.
3138 Output filename: ``{camera}_{imagefile_base}.txt`` when ``frame < 0`` (default), or
3139 ``{camera}_{imagefile_base}_{frame:05d}.txt`` when ``frame >= 0``.
3142 camera: Camera label
3143 primitive_data_label: Primitive data label to map (float/double/uint/int)
3144 imagefile_base: Base filename for output
3145 image_path: Output directory path (default: current directory)
3146 frame: Frame number to write (-1 to omit the frame suffix)
3147 padvalue: Value written for empty/background pixels (default: NaN)
3150 RadiationModelError: If the label map writing fails
3151 TypeError: If parameters have incorrect types
3154 >>> radiation.writePrimitiveDataLabelMap(
3155 ... camera="main_cam", primitive_data_label="leaf_id",
3156 ... imagefile_base="leaf_labels", image_path="./output")
3159 if not isinstance(camera, str)
or not camera.strip():
3160 raise TypeError(
"Camera label must be a non-empty string")
3161 if not isinstance(primitive_data_label, str)
or not primitive_data_label.strip():
3162 raise TypeError(
"Primitive data label must be a non-empty string")
3163 if not isinstance(imagefile_base, str)
or not imagefile_base.strip():
3164 raise TypeError(
"Image file base must be a non-empty string")
3165 if not isinstance(image_path, str):
3166 raise TypeError(
"Image path must be a string")
3167 if not isinstance(frame, int):
3168 raise TypeError(
"Frame must be an integer")
3169 if not isinstance(padvalue, (int, float))
or isinstance(padvalue, bool):
3170 raise TypeError(
"Pad value must be a numeric type")
3173 radiation_wrapper.writePrimitiveDataLabelMap(
3175 image_path, frame, float(padvalue))
3177 logger.info(f
"Primitive data label map written for camera {camera}, label {primitive_data_label}")
3179 @require_plugin('radiation', 'write object data label map')
3181 image_path: str =
"./", frame: int = -1, padvalue: float = float(
'nan')):
3183 Write a per-pixel object-data label map for a camera to a text file.
3185 Identical to :meth:`writePrimitiveDataLabelMap` but maps the value of an object-data
3186 label (compound-object data) rather than primitive data. The object data must be of
3187 type float, double, uint, or int. The radiation model must have been run
3188 (``updateGeometry`` + ``runBand``) so that per-pixel labels exist.
3190 Output filename: ``{camera}_{imagefile_base}.txt`` when ``frame < 0`` (default), or
3191 ``{camera}_{imagefile_base}_{frame:05d}.txt`` when ``frame >= 0``.
3194 camera: Camera label
3195 object_data_label: Object data label to map (float/double/uint/int)
3196 imagefile_base: Base filename for output
3197 image_path: Output directory path (default: current directory)
3198 frame: Frame number to write (-1 to omit the frame suffix)
3199 padvalue: Value written for empty/background pixels (default: NaN)
3202 RadiationModelError: If the label map writing fails
3203 TypeError: If parameters have incorrect types
3206 if not isinstance(camera, str)
or not camera.strip():
3207 raise TypeError(
"Camera label must be a non-empty string")
3208 if not isinstance(object_data_label, str)
or not object_data_label.strip():
3209 raise TypeError(
"Object data label must be a non-empty string")
3210 if not isinstance(imagefile_base, str)
or not imagefile_base.strip():
3211 raise TypeError(
"Image file base must be a non-empty string")
3212 if not isinstance(image_path, str):
3213 raise TypeError(
"Image path must be a string")
3214 if not isinstance(frame, int):
3215 raise TypeError(
"Frame must be an integer")
3216 if not isinstance(padvalue, (int, float))
or isinstance(padvalue, bool):
3217 raise TypeError(
"Pad value must be a numeric type")
3220 radiation_wrapper.writeObjectDataLabelMap(
3222 image_path, frame, float(padvalue))
3224 logger.info(f
"Object data label map written for camera {camera}, label {object_data_label}")
3226 @require_plugin('radiation', 'read primitive data label map')
3228 padvalue: float = float(
'nan')) ->
'np.ndarray':
3230 Return a per-pixel primitive-data label map for a camera as a NumPy array.
3232 Convenience wrapper around :meth:`writePrimitiveDataLabelMap` for the common use case
3233 of per-pixel masking in Python: the label map is written to a temporary file, loaded
3234 with ``numpy.loadtxt``, and returned as a 2D array. The file does not persist.
3237 camera: Camera label
3238 primitive_data_label: Primitive data label to map (float/double/uint/int)
3239 padvalue: Value used for empty/background pixels (default: NaN)
3242 2D NumPy array of shape ``(height, width)`` (row-major) holding the primitive-data
3243 value at each pixel, with ``padvalue`` (NaN by default) where no labelled geometry
3247 RadiationModelError: If the label map generation fails
3248 TypeError: If parameters have incorrect types
3251 >>> labels = radiation.getPrimitiveDataLabelMap("main_cam", "leaf_id")
3252 >>> mask = labels == 3 # per-pixel mask for primitive label 3
3253 >>> background = np.isnan(labels)
3255 with tempfile.TemporaryDirectory()
as tmpdir:
3256 imagefile_base =
"labelmap"
3258 camera, primitive_data_label, imagefile_base,
3259 image_path=os.path.join(tmpdir,
""), frame=-1, padvalue=padvalue)
3261 filepath = os.path.join(tmpdir, f
"{camera}_{imagefile_base}.txt")
3262 labels = np.loadtxt(filepath)
3265 if labels.ndim == 1:
3266 labels = labels.reshape(1, -1)
3269 @require_plugin('radiation', 'read object data label map')
3271 padvalue: float = float(
'nan')) ->
'np.ndarray':
3273 Return a per-pixel object-data label map for a camera as a NumPy array.
3275 Convenience wrapper around :meth:`writeObjectDataLabelMap`; see
3276 :meth:`getPrimitiveDataLabelMap` for behaviour. The label map is written to a temporary
3277 file, loaded with ``numpy.loadtxt``, and returned as a 2D ``(height, width)`` array with
3278 ``padvalue`` (NaN by default) for background pixels. The file does not persist.
3281 camera: Camera label
3282 object_data_label: Object data label to map (float/double/uint/int)
3283 padvalue: Value used for empty/background pixels (default: NaN)
3286 2D NumPy array of shape ``(height, width)`` (row-major).
3289 RadiationModelError: If the label map generation fails
3290 TypeError: If parameters have incorrect types
3292 with tempfile.TemporaryDirectory()
as tmpdir:
3293 imagefile_base =
"labelmap"
3295 camera, object_data_label, imagefile_base,
3296 image_path=os.path.join(tmpdir,
""), frame=-1, padvalue=padvalue)
3297 filepath = os.path.join(tmpdir, f
"{camera}_{imagefile_base}.txt")
3298 labels = np.loadtxt(filepath)
3300 if labels.ndim == 1:
3301 labels = labels.reshape(1, -1)
3304 @require_plugin('radiation', 'write image bounding boxes')
3306 primitive_data_labels=
None, object_data_labels=
None,
3307 object_class_ids=
None, image_file: str =
"",
3308 classes_txt_file: str =
"classes.txt",
3309 image_path: str =
"./"):
3311 Write image bounding boxes for object detection training.
3313 Supports both single and multiple data labels. Either provide primitive_data_labels
3314 or object_data_labels, not both.
3317 camera_label: Camera label
3318 primitive_data_labels: Single primitive data label (str) or list of primitive data labels
3319 object_data_labels: Single object data label (str) or list of object data labels
3320 object_class_ids: Single class ID (int) or list of class IDs (must match data labels)
3321 image_file: Image filename
3322 classes_txt_file: Classes definition file (default: "classes.txt")
3323 image_path: Image output path (default: current directory)
3326 RadiationModelError: If bounding box writing fails
3327 TypeError: If parameters have incorrect types
3328 ValueError: If both primitive and object data labels are provided, or neither
3331 if primitive_data_labels
is not None and object_data_labels
is not None:
3332 raise ValueError(
"Cannot specify both primitive_data_labels and object_data_labels")
3333 if primitive_data_labels
is None and object_data_labels
is None:
3334 raise ValueError(
"Must specify either primitive_data_labels or object_data_labels")
3337 if not isinstance(camera_label, str)
or not camera_label.strip():
3338 raise TypeError(
"Camera label must be a non-empty string")
3339 if not isinstance(image_file, str)
or not image_file.strip():
3340 raise TypeError(
"Image file must be a non-empty string")
3341 if not isinstance(classes_txt_file, str):
3342 raise TypeError(
"Classes txt file must be a string")
3343 if not isinstance(image_path, str):
3344 raise TypeError(
"Image path must be a string")
3347 if primitive_data_labels
is not None:
3348 if isinstance(primitive_data_labels, str):
3350 if not isinstance(object_class_ids, int):
3351 raise TypeError(
"For single primitive data label, object_class_ids must be an integer")
3353 radiation_wrapper.writeImageBoundingBoxes(
3355 object_class_ids, image_file, classes_txt_file, image_path)
3356 logger.info(f
"Image bounding boxes written for primitive data: {primitive_data_labels}")
3358 elif isinstance(primitive_data_labels, list):
3360 if not isinstance(object_class_ids, list):
3361 raise TypeError(
"For multiple primitive data labels, object_class_ids must be a list")
3362 if len(primitive_data_labels) != len(object_class_ids):
3363 raise ValueError(
"primitive_data_labels and object_class_ids must have the same length")
3364 if not all(isinstance(lbl, str)
and lbl.strip()
for lbl
in primitive_data_labels):
3365 raise TypeError(
"All primitive data labels must be non-empty strings")
3366 if not all(isinstance(cid, int)
for cid
in object_class_ids):
3367 raise TypeError(
"All object class IDs must be integers")
3370 radiation_wrapper.writeImageBoundingBoxesVector(
3372 object_class_ids, image_file, classes_txt_file, image_path)
3373 logger.info(f
"Image bounding boxes written for {len(primitive_data_labels)} primitive data labels")
3375 raise TypeError(
"primitive_data_labels must be a string or list of strings")
3378 elif object_data_labels
is not None:
3379 if isinstance(object_data_labels, str):
3381 if not isinstance(object_class_ids, int):
3382 raise TypeError(
"For single object data label, object_class_ids must be an integer")
3384 radiation_wrapper.writeImageBoundingBoxes_ObjectData(
3386 object_class_ids, image_file, classes_txt_file, image_path)
3387 logger.info(f
"Image bounding boxes written for object data: {object_data_labels}")
3389 elif isinstance(object_data_labels, list):
3391 if not isinstance(object_class_ids, list):
3392 raise TypeError(
"For multiple object data labels, object_class_ids must be a list")
3393 if len(object_data_labels) != len(object_class_ids):
3394 raise ValueError(
"object_data_labels and object_class_ids must have the same length")
3395 if not all(isinstance(lbl, str)
and lbl.strip()
for lbl
in object_data_labels):
3396 raise TypeError(
"All object data labels must be non-empty strings")
3397 if not all(isinstance(cid, int)
for cid
in object_class_ids):
3398 raise TypeError(
"All object class IDs must be integers")
3401 radiation_wrapper.writeImageBoundingBoxes_ObjectDataVector(
3403 object_class_ids, image_file, classes_txt_file, image_path)
3404 logger.info(f
"Image bounding boxes written for {len(object_data_labels)} object data labels")
3406 raise TypeError(
"object_data_labels must be a string or list of strings")
3408 @require_plugin('radiation', 'write image segmentation masks')
3410 primitive_data_labels=
None, object_data_labels=
None,
3411 object_class_ids=
None, json_filename: str =
"",
3412 image_file: str =
"", append_file: bool =
False):
3414 Write image segmentation masks in COCO JSON format.
3416 Supports both single and multiple data labels. Either provide primitive_data_labels
3417 or object_data_labels, not both.
3420 camera_label: Camera label
3421 primitive_data_labels: Single primitive data label (str) or list of primitive data labels
3422 object_data_labels: Single object data label (str) or list of object data labels
3423 object_class_ids: Single class ID (int) or list of class IDs (must match data labels)
3424 json_filename: JSON output filename
3425 image_file: Image filename
3426 append_file: Whether to append to existing JSON file
3429 RadiationModelError: If segmentation mask writing fails
3430 TypeError: If parameters have incorrect types
3431 ValueError: If both primitive and object data labels are provided, or neither
3434 if primitive_data_labels
is not None and object_data_labels
is not None:
3435 raise ValueError(
"Cannot specify both primitive_data_labels and object_data_labels")
3436 if primitive_data_labels
is None and object_data_labels
is None:
3437 raise ValueError(
"Must specify either primitive_data_labels or object_data_labels")
3440 if not isinstance(camera_label, str)
or not camera_label.strip():
3441 raise TypeError(
"Camera label must be a non-empty string")
3442 if not isinstance(json_filename, str)
or not json_filename.strip():
3443 raise TypeError(
"JSON filename must be a non-empty string")
3444 if not isinstance(image_file, str)
or not image_file.strip():
3445 raise TypeError(
"Image file must be a non-empty string")
3446 if not isinstance(append_file, bool):
3447 raise TypeError(
"append_file must be a boolean")
3450 if primitive_data_labels
is not None:
3451 if isinstance(primitive_data_labels, str):
3453 if not isinstance(object_class_ids, int):
3454 raise TypeError(
"For single primitive data label, object_class_ids must be an integer")
3456 radiation_wrapper.writeImageSegmentationMasks(
3458 object_class_ids, json_filename, image_file, append_file)
3459 logger.info(f
"Image segmentation masks written for primitive data: {primitive_data_labels}")
3461 elif isinstance(primitive_data_labels, list):
3463 if not isinstance(object_class_ids, list):
3464 raise TypeError(
"For multiple primitive data labels, object_class_ids must be a list")
3465 if len(primitive_data_labels) != len(object_class_ids):
3466 raise ValueError(
"primitive_data_labels and object_class_ids must have the same length")
3467 if not all(isinstance(lbl, str)
and lbl.strip()
for lbl
in primitive_data_labels):
3468 raise TypeError(
"All primitive data labels must be non-empty strings")
3469 if not all(isinstance(cid, int)
for cid
in object_class_ids):
3470 raise TypeError(
"All object class IDs must be integers")
3473 radiation_wrapper.writeImageSegmentationMasksVector(
3475 object_class_ids, json_filename, image_file, append_file)
3476 logger.info(f
"Image segmentation masks written for {len(primitive_data_labels)} primitive data labels")
3478 raise TypeError(
"primitive_data_labels must be a string or list of strings")
3481 elif object_data_labels
is not None:
3482 if isinstance(object_data_labels, str):
3484 if not isinstance(object_class_ids, int):
3485 raise TypeError(
"For single object data label, object_class_ids must be an integer")
3487 radiation_wrapper.writeImageSegmentationMasks_ObjectData(
3489 object_class_ids, json_filename, image_file, append_file)
3490 logger.info(f
"Image segmentation masks written for object data: {object_data_labels}")
3492 elif isinstance(object_data_labels, list):
3494 if not isinstance(object_class_ids, list):
3495 raise TypeError(
"For multiple object data labels, object_class_ids must be a list")
3496 if len(object_data_labels) != len(object_class_ids):
3497 raise ValueError(
"object_data_labels and object_class_ids must have the same length")
3498 if not all(isinstance(lbl, str)
and lbl.strip()
for lbl
in object_data_labels):
3499 raise TypeError(
"All object data labels must be non-empty strings")
3500 if not all(isinstance(cid, int)
for cid
in object_class_ids):
3501 raise TypeError(
"All object class IDs must be integers")
3504 radiation_wrapper.writeImageSegmentationMasks_ObjectDataVector(
3506 object_class_ids, json_filename, image_file, append_file)
3507 logger.info(f
"Image segmentation masks written for {len(object_data_labels)} object data labels")
3509 raise TypeError(
"object_data_labels must be a string or list of strings")
3511 @require_plugin('radiation', 'auto-calibrate camera image')
3513 green_band_label: str, blue_band_label: str,
3514 output_file_path: str, print_quality_report: bool =
False,
3515 algorithm: str =
"MATRIX_3X3_AUTO",
3516 ccm_export_file_path: str =
"") -> str:
3518 Auto-calibrate camera image with color correction and return output filename.
3521 camera_label: Camera label
3522 red_band_label: Red band label
3523 green_band_label: Green band label
3524 blue_band_label: Blue band label
3525 output_file_path: Output file path
3526 print_quality_report: Whether to print quality report
3527 algorithm: Color correction algorithm ("DIAGONAL_ONLY", "MATRIX_3X3_AUTO", "MATRIX_3X3_FORCE")
3528 ccm_export_file_path: Path to export color correction matrix (optional)
3531 Output filename string
3534 RadiationModelError: If auto-calibration fails
3535 TypeError: If parameters have incorrect types
3536 ValueError: If algorithm is not valid
3539 if not isinstance(camera_label, str)
or not camera_label.strip():
3540 raise TypeError(
"Camera label must be a non-empty string")
3541 if not isinstance(red_band_label, str)
or not red_band_label.strip():
3542 raise TypeError(
"Red band label must be a non-empty string")
3543 if not isinstance(green_band_label, str)
or not green_band_label.strip():
3544 raise TypeError(
"Green band label must be a non-empty string")
3545 if not isinstance(blue_band_label, str)
or not blue_band_label.strip():
3546 raise TypeError(
"Blue band label must be a non-empty string")
3547 if not isinstance(output_file_path, str)
or not output_file_path.strip():
3548 raise TypeError(
"Output file path must be a non-empty string")
3549 if not isinstance(print_quality_report, bool):
3550 raise TypeError(
"print_quality_report must be a boolean")
3551 if not isinstance(ccm_export_file_path, str):
3552 raise TypeError(
"ccm_export_file_path must be a string")
3557 "MATRIX_3X3_AUTO": 1,
3558 "MATRIX_3X3_FORCE": 2
3561 if algorithm
not in algorithm_map:
3562 raise ValueError(f
"Invalid algorithm: {algorithm}. Must be one of: {list(algorithm_map.keys())}")
3564 algorithm_int = algorithm_map[algorithm]
3567 filename = radiation_wrapper.autoCalibrateCameraImage(
3569 blue_band_label, output_file_path, print_quality_report,
3570 algorithm_int, ccm_export_file_path)
3572 logger.info(f
"Auto-calibrated camera image written to: {filename}")
3576 """Get information about the radiation plugin."""
3577 registry = get_plugin_registry()
3578 return registry.get_plugin_capabilities(
'radiation')
3585 image_path: str =
"./", frame: int = -1):
3587 Write camera pixel data to an EXR file with lossless float compression.
3589 Preserves full floating-point precision unlike JPEG/PNG exports.
3592 camera: Camera label
3593 band: Band label (str) for single-band, or list of band labels for multi-band
3594 imagefile_base: Base filename for output
3595 image_path: Output directory path (default: current directory)
3596 frame: Frame number to append to filename (-1 to omit)
3599 RadiationModelError: If writing fails
3600 TypeError: If parameters have incorrect types
3602 if not isinstance(camera, str)
or not camera.strip():
3603 raise TypeError(
"Camera label must be a non-empty string")
3604 if not isinstance(imagefile_base, str)
or not imagefile_base.strip():
3605 raise TypeError(
"Image file base must be a non-empty string")
3606 if not isinstance(image_path, str):
3607 raise TypeError(
"Image path must be a string")
3608 if not isinstance(frame, int):
3609 raise TypeError(
"Frame must be an integer")
3611 if isinstance(band, str):
3612 if not band.strip():
3613 raise TypeError(
"Band label must be a non-empty string")
3614 elif isinstance(band, (list, tuple)):
3616 raise ValueError(
"Band list cannot be empty")
3618 if not isinstance(b, str)
or not b.strip():
3619 raise TypeError(
"Each band label must be a non-empty string")
3621 raise TypeError(
"band must be a string or list of strings")
3624 radiation_wrapper.writeCameraImageDataEXR(
3625 self.
radiation_model, camera, band, imagefile_base, image_path, frame)
3628 image_path: str =
"./", frame: int = -1):
3630 Write depth image data to an ASCII text file.
3633 camera_label: Camera label
3634 imagefile_base: Base filename for output
3635 image_path: Output directory path (default: current directory)
3636 frame: Frame number to append to filename (-1 to omit)
3639 RadiationModelError: If writing fails
3640 TypeError: If parameters have incorrect types
3642 if not isinstance(camera_label, str)
or not camera_label.strip():
3643 raise TypeError(
"Camera label must be a non-empty string")
3644 if not isinstance(imagefile_base, str)
or not imagefile_base.strip():
3645 raise TypeError(
"Image file base must be a non-empty string")
3646 if not isinstance(image_path, str):
3647 raise TypeError(
"Image path must be a string")
3648 if not isinstance(frame, int):
3649 raise TypeError(
"Frame must be an integer")
3652 radiation_wrapper.writeDepthImageData(
3653 self.
radiation_model, camera_label, imagefile_base, image_path, frame)
3656 image_path: str =
"./", frame: int = -1):
3658 Write depth image data to an EXR file with lossless float compression.
3660 Preserves full floating-point depth precision unlike ASCII or JPEG exports.
3663 camera_label: Camera label
3664 imagefile_base: Base filename for output
3665 image_path: Output directory path (default: current directory)
3666 frame: Frame number to append to filename (-1 to omit)
3669 RadiationModelError: If writing fails
3670 TypeError: If parameters have incorrect types
3672 if not isinstance(camera_label, str)
or not camera_label.strip():
3673 raise TypeError(
"Camera label must be a non-empty string")
3674 if not isinstance(imagefile_base, str)
or not imagefile_base.strip():
3675 raise TypeError(
"Image file base must be a non-empty string")
3676 if not isinstance(image_path, str):
3677 raise TypeError(
"Image path must be a string")
3678 if not isinstance(frame, int):
3679 raise TypeError(
"Frame must be an integer")
3682 radiation_wrapper.writeDepthImageDataEXR(
3683 self.
radiation_model, camera_label, imagefile_base, image_path, frame)
3686 image_path: str =
"./", frame: int = -1):
3688 Write normalized depth image as grayscale JPEG.
3690 Depth values are normalized to the range [0, max_depth] for visualization.
3693 camera_label: Camera label
3694 imagefile_base: Base filename for output
3695 max_depth: Maximum depth value for normalization (e.g., sky depth)
3696 image_path: Output directory path (default: current directory)
3697 frame: Frame number to append to filename (-1 to omit)
3700 RadiationModelError: If writing fails
3701 TypeError: If parameters have incorrect types
3702 ValueError: If max_depth is not positive
3704 if not isinstance(camera_label, str)
or not camera_label.strip():
3705 raise TypeError(
"Camera label must be a non-empty string")
3706 if not isinstance(imagefile_base, str)
or not imagefile_base.strip():
3707 raise TypeError(
"Image file base must be a non-empty string")
3708 if not isinstance(max_depth, (int, float)):
3709 raise TypeError(
"max_depth must be a number")
3711 raise ValueError(
"max_depth must be positive")
3712 if not isinstance(image_path, str):
3713 raise TypeError(
"Image path must be a string")
3714 if not isinstance(frame, int):
3715 raise TypeError(
"Frame must be an integer")
3718 radiation_wrapper.writeNormDepthImage(
3727 Get the name of the active ray tracing backend.
3730 Backend name string (e.g., "OptiX 8.1", "Vulkan Compute")
3738 Probe whether any compiled-in GPU backend is available on this system.
3740 Probes backends in priority order (OptiX 8 -> OptiX 6 -> Vulkan) without
3741 constructing a full backend. Useful for checking GPU availability before
3742 creating a RadiationModel.
3744 Returns False when ``HELIOS_NO_GPU`` is set to anything other than ``"0"``,
3745 whatever hardware is actually present -- see
3746 :meth:`gpuBackendsDisabledByEnvironment` to distinguish an environment veto
3747 from genuinely absent hardware.
3750 The probe runs at most once per process and the result is cached
3751 (helios-core v1.3.79+), so repeated calls are cheap and never re-enter
3752 the GPU driver. A driver that becomes usable after the first probe
3753 is not picked up until the process restarts.
3756 True if at least one GPU backend is available
3759 >>> if RadiationModel.probeAnyGPUBackend():
3760 ... with RadiationModel(context) as radiation:
3761 ... radiation.addRadiationBand("SW")
3763 return radiation_wrapper.probeAnyGPUBackend()
3768 Check whether GPU backends are vetoed by the ``HELIOS_NO_GPU`` environment variable.
3770 True when ``HELIOS_NO_GPU`` is set to any value other than ``"0"``. The veto
3771 makes a GPU-equipped machine behave exactly like one with no compatible
3772 hardware: :meth:`probeAnyGPUBackend` returns False and constructing a
3773 RadiationModel with the automatic backend fails. Requesting a backend by name
3776 Use this to tell an intentional veto apart from missing hardware when
3777 reporting why the GPU path is unavailable.
3780 The environment is read once and cached for the process lifetime, so
3781 changing ``HELIOS_NO_GPU`` after the first call has no effect.
3784 True if GPU backend probing is disabled by the environment
3787 RuntimeError: If the native library predates helios-core v1.3.79
3790 >>> if not RadiationModel.probeAnyGPUBackend():
3791 ... if RadiationModel.gpuBackendsDisabledByEnvironment():
3792 ... print("GPU disabled via HELIOS_NO_GPU")
3794 ... print("No compatible GPU found")
3796 return radiation_wrapper.gpuBackendsDisabledByEnvironment()