0.1.22
Loading...
Searching...
No Matches
RadiationModel.py
Go to the documentation of this file.
1"""
2High-level RadiationModel interface for PyHelios.
3
4This module provides a user-friendly interface to the radiation modeling
5capabilities with graceful plugin handling and informative error messages.
6"""
7
8import logging
9from typing import List, Optional
10from contextlib import contextmanager
11from pathlib import Path
12import os
13
14from .plugins.registry import get_plugin_registry, require_plugin, graceful_plugin_fallback
15from .wrappers import URadiationModelWrapper as radiation_wrapper
16from .validation.plugins import (
17 validate_wavelength_range, validate_flux_value, validate_ray_count,
18 validate_direction_vector, validate_band_label, validate_source_id, validate_source_id_list,
19 validate_position_like, validate_direction_like, validate_size_like
20)
21from .validation.plugin_decorators import (
22 validate_radiation_band_params, validate_collimated_source_params, validate_sphere_source_params,
23 validate_sun_sphere_params, validate_get_source_flux_params,
24 validate_update_geometry_params, validate_run_band_params, validate_scattering_depth_params,
25 validate_min_scatter_energy_params
26)
27from .Context import Context
28from .assets import get_asset_manager
29
30logger = logging.getLogger(__name__)
31
32
33@contextmanager
35 """
36 Context manager that temporarily changes working directory to where RadiationModel assets are located.
37
38 RadiationModel C++ code uses hardcoded relative paths like "plugins/radiation/" for GPU
39 backend files (SPIR-V shaders for Vulkan, PTX files for OptiX), expecting assets relative
40 to working directory. This manager temporarily changes to the build directory where assets
41 are actually located.
42
43 Raises:
44 RuntimeError: If build directory or RadiationModel assets are not found, indicating a build system error.
45 """
46 # Find the build directory containing RadiationModel assets
47 # Try asset manager first (works for both development and wheel installations)
48 asset_manager = get_asset_manager()
49 working_dir = asset_manager._get_helios_build_path()
50
51 if working_dir and working_dir.exists():
52 radiation_assets = working_dir / 'plugins' / 'radiation'
53 else:
54 # For wheel installations, check packaged assets
55 current_dir = Path(__file__).parent
56 packaged_build = current_dir / 'assets' / 'build'
57
58 if packaged_build.exists():
59 working_dir = packaged_build
60 radiation_assets = working_dir / 'plugins' / 'radiation'
61 else:
62 # Fallback to development paths
63 repo_root = current_dir.parent
64 build_lib_dir = repo_root / 'pyhelios_build' / 'build' / 'lib'
65 working_dir = build_lib_dir.parent
66 radiation_assets = working_dir / 'plugins' / 'radiation'
67
68 if not build_lib_dir.exists():
69 raise RuntimeError(
70 f"PyHelios build directory not found at {build_lib_dir}. "
71 f"Run: python build_scripts/build_helios.py --plugins radiation"
72 )
73
74 if not radiation_assets.exists():
75 raise RuntimeError(
76 f"RadiationModel assets not found at {radiation_assets}. "
77 f"This indicates a build system error. The build script should copy shader/backend files to this location."
78 )
79
80 # Change to the build directory temporarily
81 original_dir = os.getcwd()
82 try:
83 os.chdir(working_dir)
84 logger.debug(f"Changed working directory to {working_dir} for RadiationModel asset access")
85 yield working_dir
86 finally:
87 os.chdir(original_dir)
88 logger.debug(f"Restored working directory to {original_dir}")
89
90
91class RadiationModelError(Exception):
92 """Raised when RadiationModel operations fail."""
93 pass
95
97 """
98 Camera properties for radiation model cameras.
99
100 This class encapsulates the properties needed to configure a radiation camera,
101 providing sensible defaults and validation for camera parameters. Updated for
102 Helios v1.3.60 with camera_zoom support.
103 """
104
105 def __init__(self, camera_resolution=None, focal_plane_distance=1.0, lens_diameter=0.05,
106 HFOV=20.0, FOV_aspect_ratio=0.0, lens_focal_length=0.05,
107 sensor_width_mm=35.0, manufacturer="", model="generic", lens_make="",
108 lens_model="", lens_specification="", exposure="auto", shutter_speed=1.0/125.0,
109 white_balance="auto", camera_zoom=1.0):
110 """
111 Initialize camera properties with defaults matching C++ CameraProperties.
112
113 Args:
114 camera_resolution: Camera resolution as (width, height) tuple or list. Default: (512, 512)
115 focal_plane_distance: Distance from viewing plane to focal plane (working distance). Default: 1.0
116 lens_diameter: Diameter of camera lens (0 = pinhole camera). Default: 0.05
117 HFOV: Horizontal field of view in degrees. Default: 20.0
118 FOV_aspect_ratio: Ratio of horizontal to vertical FOV. Default: 0.0 (auto-calculate from resolution)
119 lens_focal_length: Camera lens optical focal length in meters (physical, not 35mm equiv). Default: 0.05 (50mm)
120 sensor_width_mm: Physical sensor width in mm. Default: 35.0 (full-frame)
121 manufacturer: Camera manufacturer (e.g., "Canon", "Nikon", "Apple"). In helios-core
122 v1.3.73 this maps to the EXIF Make tag (empty ⇒ "Helios"). NOTE: like the other
123 string fields (model, lens_make, etc.), this attribute is not yet plumbed through to
124 the native camera and currently has no effect on written images; the C++ default is
125 used. It is exposed for forward compatibility. Default: ""
126 model: Camera model name (e.g., "Nikon D700", "Canon EOS 5D"). Default: "generic"
127 lens_make: Lens manufacturer (e.g., "Canon", "Nikon"). Default: ""
128 lens_model: Lens model name (e.g., "AF-S NIKKOR 50mm f/1.8G"). Default: ""
129 lens_specification: Lens specification (e.g., "50mm f/1.8"). Default: ""
130 exposure: Exposure mode - "auto", "ISOXXX" (e.g., "ISO100"), or "manual". Default: "auto"
131 shutter_speed: Camera shutter speed in seconds (e.g., 0.008 for 1/125s). Default: 0.008 (1/125s)
132 white_balance: White balance mode - "auto" or "off". Default: "auto"
133 camera_zoom: Camera optical zoom multiplier. 1.0 = no zoom, 2.0 = 2x zoom.
134 Scales effective HFOV: effective_HFOV = HFOV / camera_zoom. Default: 1.0
135 """
136 # Set camera resolution with validation
137
138 if camera_resolution is None:
139 self.camera_resolution = (512, 512)
140 else:
141 if isinstance(camera_resolution, (list, tuple)) and len(camera_resolution) == 2:
142 self.camera_resolution = (int(camera_resolution[0]), int(camera_resolution[1]))
143 else:
144 raise ValueError("camera_resolution must be a tuple or list of 2 integers")
145
146
147 # Validate and set numeric properties
148 if focal_plane_distance <= 0:
149 raise ValueError("focal_plane_distance must be greater than 0")
150 if lens_diameter < 0:
151 raise ValueError("lens_diameter must be non-negative")
152 if HFOV <= 0 or HFOV > 180:
153 raise ValueError("HFOV must be between 0 and 180 degrees")
154 if FOV_aspect_ratio < 0:
155 raise ValueError("FOV_aspect_ratio must be non-negative (0 = auto-calculate)")
156 if lens_focal_length <= 0:
157 raise ValueError("lens_focal_length must be greater than 0")
158 if sensor_width_mm <= 0:
159 raise ValueError("sensor_width_mm must be greater than 0")
160 if shutter_speed <= 0:
161 raise ValueError("shutter_speed must be greater than 0")
162 if camera_zoom <= 0:
163 raise ValueError("camera_zoom must be greater than 0")
164
165 self.focal_plane_distance = float(focal_plane_distance)
166 self.lens_diameter = float(lens_diameter)
167 self.HFOV = float(HFOV)
168 self.FOV_aspect_ratio = float(FOV_aspect_ratio)
169 self.lens_focal_length = float(lens_focal_length)
170 self.sensor_width_mm = float(sensor_width_mm)
171 self.shutter_speed = float(shutter_speed)
172 self.camera_zoom = float(camera_zoom)
174 # Validate and set string properties
175 if not isinstance(manufacturer, str):
176 raise ValueError("manufacturer must be a string")
177 if not isinstance(model, str):
178 raise ValueError("model must be a string")
179 if not isinstance(lens_make, str):
180 raise ValueError("lens_make must be a string")
181 if not isinstance(lens_model, str):
182 raise ValueError("lens_model must be a string")
183 if not isinstance(lens_specification, str):
184 raise ValueError("lens_specification must be a string")
185 if not isinstance(exposure, str):
186 raise ValueError("exposure must be a string")
187 if not isinstance(white_balance, str):
188 raise ValueError("white_balance must be a string")
189
190 # Validate exposure mode
191 if exposure not in ["auto", "manual"] and not exposure.startswith("ISO"):
192 raise ValueError("exposure must be 'auto', 'manual', or 'ISOXXX' (e.g., 'ISO100')")
193
194 # Validate white balance mode
195 if white_balance not in ["auto", "off"]:
196 raise ValueError("white_balance must be 'auto' or 'off'")
197
198 self.manufacturer = str(manufacturer)
199 self.model = str(model)
200 self.lens_make = str(lens_make)
201 self.lens_model = str(lens_model)
202 self.lens_specification = str(lens_specification)
203 self.exposure = str(exposure)
204 self.white_balance = str(white_balance)
206 def to_array(self):
207 """
208 Convert to array format expected by C++ interface.
209
210 Note: Returns numeric fields only. String fields (model, lens_make, etc.) are
211 currently initialized with defaults in the C++ wrapper and cannot be set via
212 this interface. Use the upcoming camera library methods for full metadata control.
213
214 Returns:
215 List of 10 float values: [resolution_x, resolution_y, focal_distance, lens_diameter,
216 HFOV, FOV_aspect_ratio, lens_focal_length, sensor_width_mm,
217 shutter_speed, camera_zoom]
218 """
219 return [
220 float(self.camera_resolution[0]), # resolution_x
221 float(self.camera_resolution[1]), # resolution_y
223 self.lens_diameter,
224 self.HFOV,
225 self.FOV_aspect_ratio,
227 self.sensor_width_mm,
228 self.shutter_speed,
229 self.camera_zoom
230 ]
231
232 def __repr__(self):
233 return (f"CameraProperties("
234 f"camera_resolution={self.camera_resolution}, "
235 f"focal_plane_distance={self.focal_plane_distance}, "
236 f"lens_diameter={self.lens_diameter}, "
237 f"HFOV={self.HFOV}, "
238 f"FOV_aspect_ratio={self.FOV_aspect_ratio}, "
239 f"lens_focal_length={self.lens_focal_length}, "
240 f"sensor_width_mm={self.sensor_width_mm}, "
241 f"manufacturer='{self.manufacturer}', "
242 f"model='{self.model}', "
243 f"lens_make='{self.lens_make}', "
244 f"lens_model='{self.lens_model}', "
245 f"lens_specification='{self.lens_specification}', "
246 f"exposure='{self.exposure}', "
247 f"shutter_speed={self.shutter_speed}, "
248 f"white_balance='{self.white_balance}', "
249 f"camera_zoom={self.camera_zoom})")
250
251
253 """
254 Camera properties for a solar-induced chlorophyll fluorescence (SIF) camera.
255
256 Extends :class:`CameraProperties` with two SIF-specific fields used by the
257 Fluspect-B emission pipeline introduced in helios-core v1.3.72. Image geometry,
258 resolution, exposure, and spectral-response handling are inherited from
259 :class:`CameraProperties` unchanged.
260
261 Attributes:
262 excitation_bin_width_nm: Excitation wavelength bin width in nm. Helios
263 auto-creates internal radiation bands spanning 400–750 nm at this
264 resolution to compute per-leaf APAR. Must be > 0. Default 10.0.
265 excitation_scattering_depth: Scattering depth for the auto-generated
266 excitation bands. ``0`` (default) treats every leaf hit as fully
267 absorbed. Set to ``>=1`` to include inter-leaf scattering at the
268 cost of additional excitation-band ray traces.
269
270 Note:
271 String fields inherited from :class:`CameraProperties` (``model``,
272 ``lens_make``, ``lens_model``, ``lens_specification``, ``exposure``,
273 ``white_balance``) are currently NOT plumbed through to the C++ camera
274 — the wrapper hard-codes ``"generic"`` / ``"auto"`` defaults. Set them
275 on this dataclass for self-documentation only; they will not affect
276 rendering. This matches the existing ``addRadiationCamera`` behaviour.
277 """
278
279 def __init__(self, excitation_bin_width_nm: float = 10.0,
280 excitation_scattering_depth: int = 0,
281 **kwargs):
282 super().__init__(**kwargs)
283 if excitation_bin_width_nm <= 0:
284 raise ValueError("excitation_bin_width_nm must be greater than 0")
285 if excitation_scattering_depth < 0:
286 raise ValueError("excitation_scattering_depth must be >= 0")
287 self.excitation_bin_width_nm = float(excitation_bin_width_nm)
288 self.excitation_scattering_depth = int(excitation_scattering_depth)
289
290 def __repr__(self):
291 base = super().__repr__()
292 # Drop the trailing ')' from the parent repr and append SIF-specific fields.
293 return (
294 base[:-1]
295 + f", excitation_bin_width_nm={self.excitation_bin_width_nm}"
296 + f", excitation_scattering_depth={self.excitation_scattering_depth})"
297 )
299
300class CameraMetadata:
301 """
302 Metadata for radiation camera image export (Helios v1.3.58+).
303
304 This class encapsulates comprehensive metadata for camera images including
305 camera properties, location, acquisition settings, image processing, and
306 agronomic properties derived from plant architecture data.
307 """
308
309 class CameraPropertiesMetadata:
310 """Camera intrinsic properties for metadata export."""
311 def __init__(self, height=512, width=512, channels=3, type="rgb",
312 focal_length=50.0, aperture="f/2.8", sensor_width=35.0,
313 sensor_height=24.0, model="generic", lens_make="",
314 lens_model="", lens_specification="", exposure="auto",
315 shutter_speed=0.008, white_balance="auto"):
316 self.height = int(height)
317 self.width = int(width)
318 self.channels = int(channels)
319 self.type = str(type)
320 self.focal_length = float(focal_length)
321 self.aperture = str(aperture)
322 self.sensor_width = float(sensor_width)
323 self.sensor_height = float(sensor_height)
324 self.model = str(model)
325 self.lens_make = str(lens_make)
326 self.lens_model = str(lens_model)
327 self.lens_specification = str(lens_specification)
328 self.exposure = str(exposure)
329 self.shutter_speed = float(shutter_speed)
330 self.white_balance = str(white_balance)
333 """Geographic location properties."""
334 def __init__(self, latitude=0.0, longitude=0.0):
335 self.latitude = float(latitude)
336 self.longitude = float(longitude)
339 """Image acquisition properties."""
340 def __init__(self, date="", time="", UTC_offset=0.0, camera_height_m=0.0,
341 camera_angle_deg=0.0, light_source="sunlight"):
342 self.date = str(date)
343 self.time = str(time)
344 self.UTC_offset = float(UTC_offset)
345 self.camera_height_m = float(camera_height_m)
346 self.camera_angle_deg = float(camera_angle_deg)
347 self.light_source = str(light_source)
350 """Image processing corrections applied to the image."""
351 def __init__(self, saturation_adjustment=1.0, brightness_adjustment=1.0,
352 contrast_adjustment=1.0, color_space="linear"):
353 self.saturation_adjustment = float(saturation_adjustment)
354 self.brightness_adjustment = float(brightness_adjustment)
355 self.contrast_adjustment = float(contrast_adjustment)
356 self.color_space = str(color_space)
357
359 """Agronomic properties derived from plant architecture data."""
360 def __init__(self, plant_species=None, plant_count=None, plant_height_m=None,
361 plant_age_days=None, plant_stage=None, leaf_area_m2=None,
362 weed_pressure=""):
363 self.plant_species = plant_species if plant_species is not None else []
364 self.plant_count = plant_count if plant_count is not None else []
365 self.plant_height_m = plant_height_m if plant_height_m is not None else []
366 self.plant_age_days = plant_age_days if plant_age_days is not None else []
367 self.plant_stage = plant_stage if plant_stage is not None else []
368 self.leaf_area_m2 = leaf_area_m2 if leaf_area_m2 is not None else []
369 self.weed_pressure = str(weed_pressure)
370
371 def __init__(self, path=""):
372 """
373 Initialize CameraMetadata with default values.
375 Args:
376 path: Full path to the associated image file. Default: ""
377 """
378 self.path = str(path)
379 self.camera_properties = self.CameraPropertiesMetadata()
380 self.location_properties = self.LocationProperties()
381 self.acquisition_properties = self.AcquisitionProperties()
382 self.image_processing = self.ImageProcessingProperties()
383 self.agronomic_properties = self.AgronomicProperties()
384
385 def __repr__(self):
386 return (f"CameraMetadata(path='{self.path}', "
387 f"camera={self.camera_properties.model}, "
388 f"resolution={self.camera_properties.width}x{self.camera_properties.height}, "
389 f"location=({self.location_properties.latitude},{self.location_properties.longitude}))")
392class RadiationModel:
393 """
394 High-level interface for radiation modeling and ray tracing.
395
396 This class provides a user-friendly wrapper around the native Helios
397 radiation plugin with automatic plugin availability checking and
398 graceful error handling.
399 """
400
401 def __init__(self, context: Context):
402 """
403 Initialize RadiationModel with graceful plugin handling.
404
405 Args:
406 context: Helios Context instance
408 Raises:
409 TypeError: If context is not a Context instance
410 RadiationModelError: If radiation plugin is not available
411 """
412 # Validate context type
413 if not isinstance(context, Context):
414 raise TypeError(f"RadiationModel requires a Context instance, got {type(context).__name__}")
415
416 self.context = context
417 self.radiation_model = None
418
419 # Check plugin availability using registry
420 registry = get_plugin_registry()
421
422 if not registry.is_plugin_available('radiation'):
423 # Get helpful information about the missing plugin
424 plugin_info = registry.get_plugin_capabilities()
425 available_plugins = registry.get_available_plugins()
426
427 error_msg = (
428 "RadiationModel requires the 'radiation' plugin which is not available.\n\n"
429 "The radiation plugin provides GPU-accelerated ray tracing with runtime\n"
430 "backend auto-detection (OptiX 8 -> OptiX 6 -> Vulkan).\n"
431 "System requirements (at least one backend):\n"
432 "- Vulkan: Vulkan loader library (macOS/Linux); no extra packages on Windows\n"
433 "- OptiX 8.1: NVIDIA GPU with driver >= 560 and CUDA 12.0+\n"
434 "- OptiX 6.5: NVIDIA GPU with driver < 560 and CUDA 9.0+\n\n"
435 "To enable radiation modeling:\n"
436 "1. Build PyHelios with radiation plugin:\n"
437 " build_scripts/build_helios --plugins radiation\n"
438 "2. Or build with multiple plugins:\n"
439 " build_scripts/build_helios --plugins radiation,visualizer,weberpenntree\n"
440 f"\nCurrently available plugins: {available_plugins}"
441 )
442
443 # Suggest alternatives if available
444 alternatives = registry.suggest_alternatives('radiation')
445 if alternatives:
446 error_msg += f"\n\nAlternative plugins available: {alternatives}"
447 error_msg += "\nConsider using energybalance or leafoptics for thermal modeling."
448
449 raise RadiationModelError(error_msg)
450
451 # Plugin is available - create radiation model using working directory context manager
452 try:
454 self.radiation_model = radiation_wrapper.createRadiationModel(context.getNativePtr())
455 if self.radiation_model is None:
457 "Failed to create RadiationModel instance. "
458 "This may indicate a problem with the native library or GPU initialization."
459 )
460 logger.info("RadiationModel created successfully")
461
462 except Exception as e:
463 raise RadiationModelError(f"Failed to initialize RadiationModel: {e}")
464
465 def __enter__(self):
466 """Context manager entry."""
467 return self
468
469 def __exit__(self, exc_type, exc_value, traceback):
470 """Context manager exit with proper cleanup."""
471 if self.radiation_model is not None:
472 try:
473 radiation_wrapper.destroyRadiationModel(self.radiation_model)
474 logger.debug("RadiationModel destroyed successfully")
475 except Exception as e:
476 logger.warning(f"Error destroying RadiationModel: {e}")
477 finally:
478 self.radiation_model = None # Prevent double deletion
479
480 def __del__(self):
481 """Destructor to ensure GPU resources freed even without 'with' statement."""
482 if hasattr(self, 'radiation_model') and self.radiation_model is not None:
483 try:
484 radiation_wrapper.destroyRadiationModel(self.radiation_model)
485 self.radiation_model = None
486 except Exception as e:
487 import warnings
488 warnings.warn(f"Error in RadiationModel.__del__: {e}")
490 def get_native_ptr(self):
491 """Get native pointer for advanced operations."""
492 return self.radiation_model
493
494 def getNativePtr(self):
495 """Get native pointer for advanced operations. (Legacy naming for compatibility)"""
496 return self.get_native_ptr()
497
498 @require_plugin('radiation', 'disable status messages')
500 """Disable RadiationModel status messages."""
501 radiation_wrapper.disableMessages(self.radiation_model)
502
503 @require_plugin('radiation', 'enable status messages')
504 def enableMessages(self):
505 """Enable RadiationModel status messages."""
506 radiation_wrapper.enableMessages(self.radiation_model)
507
508 @require_plugin('radiation', 'add radiation band')
509 def addRadiationBand(self, band_label: str, wavelength_min: float = None, wavelength_max: float = None):
510 """
511 Add radiation band with optional wavelength bounds.
512
513 Args:
514 band_label: Name/label for the radiation band
515 wavelength_min: Optional minimum wavelength (μm)
516 wavelength_max: Optional maximum wavelength (μm)
517 """
518 # Validate inputs
519 validate_band_label(band_label, "band_label", "addRadiationBand")
520 if wavelength_min is not None and wavelength_max is not None:
521 validate_wavelength_range(wavelength_min, wavelength_max, "wavelength_min", "wavelength_max", "addRadiationBand")
522 radiation_wrapper.addRadiationBandWithWavelengths(self.radiation_model, band_label, wavelength_min, wavelength_max)
523 logger.debug(f"Added radiation band {band_label}: {wavelength_min}-{wavelength_max} μm")
524 else:
525 radiation_wrapper.addRadiationBand(self.radiation_model, band_label)
526 logger.debug(f"Added radiation band: {band_label}")
527
528 @require_plugin('radiation', 'copy radiation band')
529 @validate_radiation_band_params
530 def copyRadiationBand(self, old_label: str, new_label: str, wavelength_min: float = None, wavelength_max: float = None):
531 """
532 Copy existing radiation band to new label, optionally with new wavelength range.
533
534 Args:
535 old_label: Existing band label to copy
536 new_label: New label for the copied band
537 wavelength_min: Optional minimum wavelength for new band (μm)
538 wavelength_max: Optional maximum wavelength for new band (μm)
539
540 Example:
541 >>> # Copy band with same wavelength range
542 >>> radiation.copyRadiationBand("SW", "SW_copy")
543 >>>
544 >>> # Copy band with different wavelength range
545 >>> radiation.copyRadiationBand("full_spectrum", "PAR", 400, 700)
546 """
547 if wavelength_min is not None and wavelength_max is not None:
548 validate_wavelength_range(wavelength_min, wavelength_max, "wavelength_min", "wavelength_max", "copyRadiationBand")
549
550 radiation_wrapper.copyRadiationBand(self.radiation_model, old_label, new_label, wavelength_min, wavelength_max)
551 if wavelength_min is not None:
552 logger.debug(f"Copied radiation band {old_label} to {new_label} with wavelengths {wavelength_min}-{wavelength_max} μm")
553 else:
554 logger.debug(f"Copied radiation band {old_label} to {new_label}")
555
556 @require_plugin('radiation', 'add radiation source')
557 @validate_collimated_source_params
558 def addCollimatedRadiationSource(self, direction=None) -> int:
559 """
560 Add collimated radiation source.
561
562 Args:
563 direction: Optional direction vector. Can be tuple (x, y, z), vec3, or None for default direction.
564
565 Returns:
566 Source ID
567 """
568 if direction is None:
569 source_id = radiation_wrapper.addCollimatedRadiationSourceDefault(self.radiation_model)
570 else:
571 # Handle vec3, SphericalCoord, and tuple types
572 if hasattr(direction, 'x') and hasattr(direction, 'y') and hasattr(direction, 'z'):
573 # vec3-like object
574 x, y, z = direction.x, direction.y, direction.z
575 elif hasattr(direction, 'radius') and hasattr(direction, 'elevation') and hasattr(direction, 'azimuth'):
576 # SphericalCoord object - convert to Cartesian
577 import math
578 r = direction.radius
579 elevation = direction.elevation
580 azimuth = direction.azimuth
581 x = r * math.cos(elevation) * math.cos(azimuth)
582 y = r * math.cos(elevation) * math.sin(azimuth)
583 z = r * math.sin(elevation)
584 else:
585 # Assume tuple-like object - validate it first
586 try:
587 if len(direction) != 3:
588 raise TypeError(f"Direction must be a 3-element tuple, vec3, or SphericalCoord, got {type(direction).__name__} with {len(direction)} elements")
589 x, y, z = direction
590 except (TypeError, AttributeError):
591 # Not a valid sequence type
592 raise TypeError(f"Direction must be a tuple, vec3, or SphericalCoord, got {type(direction).__name__}")
593 source_id = radiation_wrapper.addCollimatedRadiationSourceVec3(self.radiation_model, x, y, z)
594
595 logger.debug(f"Added collimated radiation source: ID {source_id}")
596 return source_id
597
598 @require_plugin('radiation', 'add spherical radiation source')
599 @validate_sphere_source_params
600 def addSphereRadiationSource(self, position, radius: float) -> int:
601 """
602 Add spherical radiation source.
603
604 Args:
605 position: Position of the source. Can be tuple (x, y, z) or vec3.
606 radius: Radius of the spherical source
607
608 Returns:
609 Source ID
610 """
611 validate_position_like(position, "position", "addSphereRadiationSource")
612 # Handle both tuple and vec3 types
613 if hasattr(position, 'x') and hasattr(position, 'y') and hasattr(position, 'z'):
614 x, y, z = position.x, position.y, position.z
615 else:
616 x, y, z = position
617 source_id = radiation_wrapper.addSphereRadiationSource(self.radiation_model, x, y, z, radius)
618 logger.debug(f"Added sphere radiation source: ID {source_id} at ({x}, {y}, {z}) with radius {radius}")
619 return source_id
620
621 @require_plugin('radiation', 'add sun radiation source')
622 @validate_sun_sphere_params
623 def addSunSphereRadiationSource(self, radius: float, zenith: float, azimuth: float,
624 position_scaling: float = 1.0, angular_width: float = 0.53,
625 flux_scaling: float = 1.0) -> int:
626 """
627 Add sun sphere radiation source.
628
629 Args:
630 radius: Radius of the sun sphere
631 zenith: Zenith angle (degrees)
632 azimuth: Azimuth angle (degrees)
633 position_scaling: Position scaling factor
634 angular_width: Angular width of the sun (degrees)
635 flux_scaling: Flux scaling factor
636
637 Returns:
638 Source ID
639 """
640 source_id = radiation_wrapper.addSunSphereRadiationSource(
641 self.radiation_model, radius, zenith, azimuth, position_scaling, angular_width, flux_scaling
642 )
643 logger.debug(f"Added sun radiation source: ID {source_id}")
644 return source_id
646 @require_plugin('radiation', 'set source position')
647 def setSourcePosition(self, source_id: int, position):
648 """
649 Set position of a radiation source.
650
651 Allows dynamic repositioning of radiation sources during simulation,
652 useful for time-series modeling or moving light sources.
653
654 Args:
655 source_id: ID of the radiation source
656 position: New position as vec3, SphericalCoord, or list/tuple [x, y, z]
657
658 Example:
659 >>> source_id = radiation.addCollimatedRadiationSource()
660 >>> radiation.setSourcePosition(source_id, [10, 20, 30])
661 >>> from pyhelios.types import vec3
662 >>> radiation.setSourcePosition(source_id, vec3(15, 25, 35))
663 """
664 if not isinstance(source_id, int) or source_id < 0:
665 raise ValueError(f"Source ID must be a non-negative integer, got {source_id}")
666 validate_direction_like(position, "position", "setSourcePosition")
667 radiation_wrapper.setSourcePosition(self.radiation_model, source_id, position)
668 logger.debug(f"Updated position for radiation source {source_id}")
669
670 @require_plugin('radiation', 'add rectangle radiation source')
671 def addRectangleRadiationSource(self, position, size, rotation) -> int:
672 """
673 Add a rectangle (planar) radiation source.
674
675 Rectangle sources are ideal for modeling artificial lighting such as
676 LED panels, grow lights, or window light sources.
677
678 Args:
679 position: Center position as vec3 or list [x, y, z]
680 size: Rectangle dimensions as vec2 or list [width, height]
681 rotation: Rotation vector as vec3 or list [rx, ry, rz] (Euler angles in radians)
682
683 Returns:
684 Source ID
685
686 Example:
687 >>> from pyhelios.types import vec3, vec2
688 >>> source_id = radiation.addRectangleRadiationSource(
689 ... position=vec3(0, 0, 5),
690 ... size=vec2(2, 1),
691 ... rotation=vec3(0, 0, 0)
692 ... )
693 >>> radiation.setSourceFlux(source_id, "PAR", 500.0)
694 """
695 validate_position_like(position, "position", "addRectangleRadiationSource")
696 validate_size_like(size, "size", "addRectangleRadiationSource")
697 validate_position_like(rotation, "rotation", "addRectangleRadiationSource")
698 return radiation_wrapper.addRectangleRadiationSource(self.radiation_model, position, size, rotation)
699
700 @require_plugin('radiation', 'add disk radiation source')
701 def addDiskRadiationSource(self, position, radius: float, rotation) -> int:
702 """
703 Add a disk (circular planar) radiation source.
704
705 Disk sources are useful for modeling circular light sources such as
706 spotlights, circular LED arrays, or solar simulators.
707
708 Args:
709 position: Center position as vec3 or list [x, y, z]
710 radius: Disk radius
711 rotation: Rotation vector as vec3 or list [rx, ry, rz] (Euler angles in radians)
712
713 Returns:
714 Source ID
715
716 Example:
717 >>> from pyhelios.types import vec3
718 >>> source_id = radiation.addDiskRadiationSource(
719 ... position=vec3(0, 0, 5),
720 ... radius=1.5,
721 ... rotation=vec3(0, 0, 0)
722 ... )
723 >>> radiation.setSourceFlux(source_id, "PAR", 300.0)
724 """
725 validate_position_like(position, "position", "addDiskRadiationSource")
726 validate_position_like(rotation, "rotation", "addDiskRadiationSource")
727 if radius <= 0:
728 raise ValueError(f"Radius must be positive, got {radius}")
729 return radiation_wrapper.addDiskRadiationSource(self.radiation_model, position, radius, rotation)
730
731 # Source spectrum methods
732 @require_plugin('radiation', 'manage source spectrum')
733 def setSourceSpectrum(self, source_id, spectrum):
734 """
735 Set radiation spectrum for source(s).
736
737 Spectral distributions define how radiation intensity varies with wavelength,
738 essential for realistic modeling of different light sources (sunlight, LEDs, etc.).
739
740 Args:
741 source_id: Source ID (int) or list of source IDs
742 spectrum: Either:
743 - Spectrum data as list of (wavelength, value) tuples
744 - Global data label string
745
746 Example:
747 >>> # Define custom LED spectrum
748 >>> led_spectrum = [
749 ... (400, 0.0), (450, 0.3), (500, 0.8),
750 ... (550, 0.5), (600, 0.2), (700, 0.0)
751 ... ]
752 >>> radiation.setSourceSpectrum(source_id, led_spectrum)
753 >>>
754 >>> # Use predefined spectrum from global data
755 >>> radiation.setSourceSpectrum(source_id, "D65_illuminant")
756 >>>
757 >>> # Apply same spectrum to multiple sources
758 >>> radiation.setSourceSpectrum([src1, src2, src3], led_spectrum)
759 """
760 radiation_wrapper.setSourceSpectrum(self.radiation_model, source_id, spectrum)
761 logger.debug(f"Set spectrum for source(s) {source_id}")
762
763 @require_plugin('radiation', 'configure source spectrum')
764 def setSourceSpectrumIntegral(self, source_id: int, source_integral: float,
765 wavelength_min: float = None, wavelength_max: float = None):
766 """
767 Set source spectrum integral value.
768
769 Normalizes the spectrum so that its integral equals the specified value,
770 useful for calibrating source intensity.
771
772 Args:
773 source_id: Source ID
774 source_integral: Target integral value
775 wavelength_min: Optional minimum wavelength for integration range
776 wavelength_max: Optional maximum wavelength for integration range
777
778 Example:
779 >>> radiation.setSourceSpectrumIntegral(source_id, 1000.0)
780 >>> radiation.setSourceSpectrumIntegral(source_id, 500.0, 400, 700) # PAR range
781 """
782 if not isinstance(source_id, int) or source_id < 0:
783 raise ValueError(f"Source ID must be a non-negative integer, got {source_id}")
784 if source_integral < 0:
785 raise ValueError(f"Source integral must be non-negative, got {source_integral}")
786
787 radiation_wrapper.setSourceSpectrumIntegral(self.radiation_model, source_id, source_integral,
788 wavelength_min, wavelength_max)
789 logger.debug(f"Set spectrum integral for source {source_id}: {source_integral}")
790
791 # Spectrum integration and analysis methods
792 @require_plugin('radiation', 'integrate spectrum')
793 def integrateSpectrum(self, object_spectrum, wavelength_min: float = None,
794 wavelength_max: float = None, source_id: int = None,
795 camera_spectrum=None) -> float:
796 """
797 Integrate spectrum with optional source/camera spectra and wavelength range.
798
799 This unified method handles multiple integration scenarios:
800 - Basic: Total spectrum integration
801 - Range: Integration over wavelength range
802 - Source: Integration weighted by source spectrum
803 - Camera: Integration weighted by camera spectral response
804 - Full: Integration with both source and camera spectra
805
806 Args:
807 object_spectrum: Object spectrum as list of (wavelength, value) tuples/vec2
808 wavelength_min: Optional minimum wavelength for integration range
809 wavelength_max: Optional maximum wavelength for integration range
810 source_id: Optional source ID for source spectrum weighting
811 camera_spectrum: Optional camera spectrum for camera response weighting
812
813 Returns:
814 Integrated value
815
816 Example:
817 >>> leaf_reflectance = [(400, 0.1), (500, 0.4), (600, 0.6), (700, 0.5)]
818 >>>
819 >>> # Total integration
820 >>> total = radiation.integrateSpectrum(leaf_reflectance)
821 >>>
822 >>> # PAR range (400-700nm)
823 >>> par = radiation.integrateSpectrum(leaf_reflectance, 400, 700)
824 >>>
825 >>> # With source spectrum
826 >>> source_weighted = radiation.integrateSpectrum(
827 ... leaf_reflectance, 400, 700, source_id=sun_source
828 ... )
829 >>>
830 >>> # With camera response
831 >>> camera_response = [(400, 0.2), (550, 1.0), (700, 0.3)]
832 >>> camera_weighted = radiation.integrateSpectrum(
833 ... leaf_reflectance, camera_spectrum=camera_response
834 ... )
835 """
836 return radiation_wrapper.integrateSpectrum(self.radiation_model, object_spectrum,
837 wavelength_min, wavelength_max,
838 source_id, camera_spectrum)
839
840 @require_plugin('radiation', 'integrate source spectrum')
841 def integrateSourceSpectrum(self, source_id: int, wavelength_min: float, wavelength_max: float) -> float:
842 """
843 Integrate source spectrum over wavelength range.
844
845 Args:
846 source_id: Source ID
847 wavelength_min: Minimum wavelength
848 wavelength_max: Maximum wavelength
849
850 Returns:
851 Integrated source spectrum value
852
853 Example:
854 >>> par_flux = radiation.integrateSourceSpectrum(source_id, 400, 700)
855 """
856 if not isinstance(source_id, int) or source_id < 0:
857 raise ValueError(f"Source ID must be a non-negative integer, got {source_id}")
858 return radiation_wrapper.integrateSourceSpectrum(self.radiation_model, source_id,
859 wavelength_min, wavelength_max)
860
861 # Spectral manipulation methods
862 @require_plugin('radiation', 'scale spectrum')
863 def scaleSpectrum(self, existing_label: str, new_label_or_scale, scale_factor: float = None):
864 """
865 Scale spectrum in-place or to new label.
866
867 Useful for adjusting spectrum intensities or creating variations of
868 existing spectra for sensitivity analysis.
869
870 Supports two call patterns:
871 - scaleSpectrum("label", scale) -> scales in-place
872 - scaleSpectrum("existing", "new", scale) -> creates new scaled spectrum
873
874 Args:
875 existing_label: Existing global data label
876 new_label_or_scale: Either new label string (if creating new) or scale factor (if in-place)
877 scale_factor: Scale factor (required only if new_label_or_scale is a string)
878
879 Example:
880 >>> # In-place scaling
881 >>> radiation.scaleSpectrum("leaf_reflectance", 1.2)
882 >>>
883 >>> # Create new scaled spectrum
884 >>> radiation.scaleSpectrum("leaf_reflectance", "scaled_leaf", 1.5)
885 """
886 if not isinstance(existing_label, str) or not existing_label.strip():
887 raise ValueError("Existing label must be a non-empty string")
888
889 radiation_wrapper.scaleSpectrum(self.radiation_model, existing_label,
890 new_label_or_scale, scale_factor)
891 logger.debug(f"Scaled spectrum '{existing_label}'")
892
893 @require_plugin('radiation', 'scale spectrum randomly')
894 def scaleSpectrumRandomly(self, existing_label: str, new_label: str,
895 min_scale: float, max_scale: float):
896 """
897 Scale spectrum with random factor and store as new label.
898
899 Useful for creating stochastic variations in spectral properties for
900 Monte Carlo simulations or uncertainty quantification.
901
902 Args:
903 existing_label: Existing global data label
904 new_label: New global data label for scaled spectrum
905 min_scale: Minimum scale factor
906 max_scale: Maximum scale factor
907
908 Example:
909 >>> # Create random variation of leaf reflectance
910 >>> radiation.scaleSpectrumRandomly("leaf_base", "leaf_variant", 0.8, 1.2)
911 """
912 if not isinstance(existing_label, str) or not existing_label.strip():
913 raise ValueError("Existing label must be a non-empty string")
914 if not isinstance(new_label, str) or not new_label.strip():
915 raise ValueError("New label must be a non-empty string")
916 if min_scale >= max_scale:
917 raise ValueError(f"min_scale ({min_scale}) must be less than max_scale ({max_scale})")
919 radiation_wrapper.scaleSpectrumRandomly(self.radiation_model, existing_label, new_label,
920 min_scale, max_scale)
921 logger.debug(f"Scaled spectrum '{existing_label}' randomly to '{new_label}'")
922
923 @require_plugin('radiation', 'blend spectra')
924 def blendSpectra(self, new_label: str, spectrum_labels: List[str], weights: List[float]):
925 """
926 Blend multiple spectra with specified weights.
927
928 Creates weighted combination of spectra, useful for mixing material properties
929 or creating composite light sources.
930
931 Args:
932 new_label: New global data label for blended spectrum
933 spectrum_labels: List of spectrum labels to blend
934 weights: List of weights (must sum to reasonable values, same length as labels)
935
936 Example:
937 >>> # Mix two leaf types (70% type A, 30% type B)
938 >>> radiation.blendSpectra("mixed_leaf",
939 ... ["leaf_type_a", "leaf_type_b"],
940 ... [0.7, 0.3]
941 ... )
942 """
943 if not isinstance(new_label, str) or not new_label.strip():
944 raise ValueError("New label must be a non-empty string")
945 if len(spectrum_labels) != len(weights):
946 raise ValueError(f"Number of labels ({len(spectrum_labels)}) must match number of weights ({len(weights)})")
947 if not spectrum_labels:
948 raise ValueError("At least one spectrum label required")
949
950 radiation_wrapper.blendSpectra(self.radiation_model, new_label, spectrum_labels, weights)
951 logger.debug(f"Blended {len(spectrum_labels)} spectra into '{new_label}'")
952
953 @require_plugin('radiation', 'blend spectra randomly')
954 def blendSpectraRandomly(self, new_label: str, spectrum_labels: List[str]):
955 """
956 Blend multiple spectra with random weights.
957
958 Creates random combinations of spectra, useful for generating diverse
959 material properties in stochastic simulations.
960
961 Args:
962 new_label: New global data label for blended spectrum
963 spectrum_labels: List of spectrum labels to blend
964
965 Example:
966 >>> # Create random mixture of leaf spectra
967 >>> radiation.blendSpectraRandomly("random_leaf",
968 ... ["young_leaf", "mature_leaf", "senescent_leaf"]
969 ... )
970 """
971 if not isinstance(new_label, str) or not new_label.strip():
972 raise ValueError("New label must be a non-empty string")
973 if not spectrum_labels:
974 raise ValueError("At least one spectrum label required")
975
976 radiation_wrapper.blendSpectraRandomly(self.radiation_model, new_label, spectrum_labels)
977 logger.debug(f"Blended {len(spectrum_labels)} spectra randomly into '{new_label}'")
979 # Spectral interpolation methods
980 @require_plugin('radiation', 'interpolate spectrum from data')
981 def interpolateSpectrumFromPrimitiveData(self, primitive_uuids: List[int],
982 spectra_labels: List[str], values: List[float],
983 primitive_data_query_label: str,
984 primitive_data_radprop_label: str):
985 """
986 Interpolate spectral properties based on primitive data values.
987
988 Automatically assigns spectra to primitives by interpolating between
989 reference spectra based on continuous data values (e.g., age, moisture, etc.).
990
991 Args:
992 primitive_uuids: List of primitive UUIDs to assign spectra
993 spectra_labels: List of reference spectrum labels
994 values: List of data values corresponding to each spectrum
995 primitive_data_query_label: Primitive data label containing query values
996 primitive_data_radprop_label: Primitive data label to store assigned spectra
997
998 Example:
999 >>> # Assign leaf reflectance based on age
1000 >>> leaf_patches = context.getAllUUIDs("patch")
1001 >>> radiation.interpolateSpectrumFromPrimitiveData(
1002 ... primitive_uuids=leaf_patches,
1003 ... spectra_labels=["young_leaf", "mature_leaf", "old_leaf"],
1004 ... values=[0.0, 50.0, 100.0], # Days since emergence
1005 ... primitive_data_query_label="leaf_age",
1006 ... primitive_data_radprop_label="reflectance"
1007 ... )
1008 """
1009 if not isinstance(primitive_uuids, (list, tuple)) or not primitive_uuids:
1010 raise ValueError("Primitive UUIDs must be a non-empty list")
1011 if not isinstance(spectra_labels, (list, tuple)) or not spectra_labels:
1012 raise ValueError("Spectra labels must be a non-empty list")
1013 if not isinstance(values, (list, tuple)) or not values:
1014 raise ValueError("Values must be a non-empty list")
1015 if len(spectra_labels) != len(values):
1016 raise ValueError(f"Number of spectra ({len(spectra_labels)}) must match number of values ({len(values)})")
1017
1018 radiation_wrapper.interpolateSpectrumFromPrimitiveData(
1019 self.radiation_model, primitive_uuids, spectra_labels, values,
1020 primitive_data_query_label, primitive_data_radprop_label
1021 )
1022 logger.debug(f"Interpolated spectra for {len(primitive_uuids)} primitives")
1023
1024 @require_plugin('radiation', 'interpolate spectrum from object data')
1025 def interpolateSpectrumFromObjectData(self, object_ids: List[int],
1026 spectra_labels: List[str], values: List[float],
1027 object_data_query_label: str,
1028 primitive_data_radprop_label: str):
1029 """
1030 Interpolate spectral properties based on object data values.
1031
1032 Automatically assigns spectra to object primitives by interpolating between
1033 reference spectra based on continuous object-level data values.
1034
1035 Args:
1036 object_ids: List of object IDs
1037 spectra_labels: List of reference spectrum labels
1038 values: List of data values corresponding to each spectrum
1039 object_data_query_label: Object data label containing query values
1040 primitive_data_radprop_label: Primitive data label to store assigned spectra
1041
1042 Example:
1043 >>> # Assign tree reflectance based on health index
1044 >>> tree_ids = [tree1_id, tree2_id, tree3_id]
1045 >>> radiation.interpolateSpectrumFromObjectData(
1046 ... object_ids=tree_ids,
1047 ... spectra_labels=["healthy_tree", "stressed_tree", "diseased_tree"],
1048 ... values=[1.0, 0.5, 0.0], # Health index
1049 ... object_data_query_label="health_index",
1050 ... primitive_data_radprop_label="reflectance"
1051 ... )
1052 """
1053 if not isinstance(object_ids, (list, tuple)) or not object_ids:
1054 raise ValueError("Object IDs must be a non-empty list")
1055 if not isinstance(spectra_labels, (list, tuple)) or not spectra_labels:
1056 raise ValueError("Spectra labels must be a non-empty list")
1057 if not isinstance(values, (list, tuple)) or not values:
1058 raise ValueError("Values must be a non-empty list")
1059 if len(spectra_labels) != len(values):
1060 raise ValueError(f"Number of spectra ({len(spectra_labels)}) must match number of values ({len(values)})")
1061
1062 radiation_wrapper.interpolateSpectrumFromObjectData(
1063 self.radiation_model, object_ids, spectra_labels, values,
1064 object_data_query_label, primitive_data_radprop_label
1065 )
1066 logger.debug(f"Interpolated spectra for {len(object_ids)} objects")
1067
1068 @require_plugin('radiation', 'set ray count')
1069 def setDirectRayCount(self, band_label: str, ray_count: int):
1070 """Set direct ray count for radiation band."""
1071 validate_band_label(band_label, "band_label", "setDirectRayCount")
1072 validate_ray_count(ray_count, "ray_count", "setDirectRayCount")
1073 radiation_wrapper.setDirectRayCount(self.radiation_model, band_label, ray_count)
1074
1075 @require_plugin('radiation', 'set ray count')
1076 def setDiffuseRayCount(self, band_label: str, ray_count: int):
1077 """Set diffuse ray count for radiation band."""
1078 validate_band_label(band_label, "band_label", "setDiffuseRayCount")
1079 validate_ray_count(ray_count, "ray_count", "setDiffuseRayCount")
1080 radiation_wrapper.setDiffuseRayCount(self.radiation_model, band_label, ray_count)
1081
1082 @require_plugin('radiation', 'set radiation flux')
1083 def setDiffuseRadiationFlux(self, label: str, flux: float):
1084 """Set diffuse radiation flux for band."""
1085 validate_band_label(label, "label", "setDiffuseRadiationFlux")
1086 validate_flux_value(flux, "flux", "setDiffuseRadiationFlux")
1087 radiation_wrapper.setDiffuseRadiationFlux(self.radiation_model, label, flux)
1088
1089 @require_plugin('radiation', 'configure diffuse radiation')
1090 def setDiffuseRadiationExtinctionCoeff(self, label: str, K: float, peak_direction):
1091 """
1092 Set diffuse radiation extinction coefficient with directional bias.
1093
1094 Models directionally-biased diffuse radiation (e.g., sky radiation with zenith peak).
1095
1096 Args:
1097 label: Band label
1098 K: Extinction coefficient
1099 peak_direction: Peak direction as vec3, SphericalCoord, or list [x, y, z]
1100
1101 Example:
1102 >>> from pyhelios.types import vec3
1103 >>> radiation.setDiffuseRadiationExtinctionCoeff("SW", 0.5, vec3(0, 0, 1))
1104 """
1105 validate_band_label(label, "label", "setDiffuseRadiationExtinctionCoeff")
1106 if K < 0:
1107 raise ValueError(f"Extinction coefficient must be non-negative, got {K}")
1108 validate_direction_like(peak_direction, "peak_direction", "setDiffuseRadiationExtinctionCoeff")
1109 radiation_wrapper.setDiffuseRadiationExtinctionCoeff(self.radiation_model, label, K, peak_direction)
1110 logger.debug(f"Set diffuse extinction coefficient for band '{label}': K={K}")
1111
1112 @require_plugin('radiation', 'query diffuse flux')
1113 def getDiffuseFlux(self, band_label: str) -> float:
1114 """
1115 Get diffuse flux for band.
1116
1117 Args:
1118 band_label: Band label
1119
1120 Returns:
1121 Diffuse flux value
1122
1123 Example:
1124 >>> flux = radiation.getDiffuseFlux("SW")
1125 """
1126 validate_band_label(band_label, "band_label", "getDiffuseFlux")
1127 return radiation_wrapper.getDiffuseFlux(self.radiation_model, band_label)
1128
1129 @require_plugin('radiation', 'configure diffuse spectrum')
1130 def setDiffuseSpectrum(self, band_label, spectrum_label: str):
1131 """
1132 Set diffuse spectrum from global data label.
1134 Args:
1135 band_label: Band label (string) or list of band labels
1136 spectrum_label: Spectrum global data label
1137
1138 Example:
1139 >>> radiation.setDiffuseSpectrum("SW", "sky_spectrum")
1140 >>> radiation.setDiffuseSpectrum(["SW", "NIR"], "sky_spectrum")
1141 """
1142 if isinstance(band_label, str):
1143 validate_band_label(band_label, "band_label", "setDiffuseSpectrum")
1144 else:
1145 for label in band_label:
1146 validate_band_label(label, "band_label", "setDiffuseSpectrum")
1147 if not isinstance(spectrum_label, str) or not spectrum_label.strip():
1148 raise ValueError("Spectrum label must be a non-empty string")
1150 radiation_wrapper.setDiffuseSpectrum(self.radiation_model, band_label, spectrum_label)
1151 logger.debug(f"Set diffuse spectrum for band(s) {band_label}")
1152
1153 @require_plugin('radiation', 'configure diffuse spectrum')
1154 def setDiffuseSpectrumIntegral(self, spectrum_integral: float, wavelength_min: float = None,
1155 wavelength_max: float = None, band_label: str = None):
1156 """
1157 Set diffuse spectrum integral.
1158
1159 Args:
1160 spectrum_integral: Integral value
1161 wavelength_min: Optional minimum wavelength
1162 wavelength_max: Optional maximum wavelength
1163 band_label: Optional specific band label (None for all bands)
1164
1165 Example:
1166 >>> radiation.setDiffuseSpectrumIntegral(1000.0) # All bands
1167 >>> radiation.setDiffuseSpectrumIntegral(500.0, 400, 700, band_label="PAR") # Specific band
1168 """
1169 if spectrum_integral < 0:
1170 raise ValueError(f"Spectrum integral must be non-negative, got {spectrum_integral}")
1171 if band_label is not None:
1172 validate_band_label(band_label, "band_label", "setDiffuseSpectrumIntegral")
1173
1174 radiation_wrapper.setDiffuseSpectrumIntegral(self.radiation_model, spectrum_integral,
1175 wavelength_min, wavelength_max, band_label)
1176 logger.debug(f"Set diffuse spectrum integral: {spectrum_integral}")
1177
1178 @require_plugin('radiation', 'set source flux')
1179 def setSourceFlux(self, source_id, label: str, flux: float):
1180 """Set source flux for single source or multiple sources."""
1181 validate_band_label(label, "label", "setSourceFlux")
1182 validate_flux_value(flux, "flux", "setSourceFlux")
1183
1184 if isinstance(source_id, (list, tuple)):
1185 # Multiple sources
1186 validate_source_id_list(list(source_id), "source_id", "setSourceFlux")
1187 radiation_wrapper.setSourceFluxMultiple(self.radiation_model, source_id, label, flux)
1188 else:
1189 # Single source
1190 validate_source_id(source_id, "source_id", "setSourceFlux")
1191 radiation_wrapper.setSourceFlux(self.radiation_model, source_id, label, flux)
1192
1193
1194 @require_plugin('radiation', 'get source flux')
1195 @validate_get_source_flux_params
1196 def getSourceFlux(self, source_id: int, label: str) -> float:
1197 """Get source flux for band."""
1198 return radiation_wrapper.getSourceFlux(self.radiation_model, source_id, label)
1199
1200 @require_plugin('radiation', 'update geometry')
1201 @validate_update_geometry_params
1202 def updateGeometry(self, uuids: Optional[List[int]] = None):
1203 """
1204 Update geometry in radiation model.
1206 Args:
1207 uuids: Optional list of specific UUIDs to update. If None, updates all geometry.
1208 """
1209 if uuids is None:
1210 radiation_wrapper.updateGeometry(self.radiation_model)
1211 logger.debug("Updated all geometry in radiation model")
1212 else:
1213 radiation_wrapper.updateGeometryUUIDs(self.radiation_model, uuids)
1214 logger.debug(f"Updated {len(uuids)} geometry UUIDs in radiation model")
1215
1216 @require_plugin('radiation', 'run radiation simulation')
1217 @validate_run_band_params
1218 def runBand(self, band_label):
1219 """
1220 Run radiation simulation for single band or multiple bands.
1221
1222 PERFORMANCE NOTE: When simulating multiple radiation bands, it is HIGHLY RECOMMENDED
1223 to run all bands in a single call (e.g., runBand(["PAR", "NIR", "SW"])) rather than
1224 sequential single-band calls. This provides significant computational efficiency gains
1225 because:
1226
1227 - GPU ray tracing setup is done once for all bands
1228 - Scene geometry acceleration structures are reused
1229 - GPU kernel launches are batched together
1230 - Memory transfers between CPU/GPU are minimized
1231
1232 Example:
1233 # EFFICIENT - Single call for multiple bands
1234 radiation.runBand(["PAR", "NIR", "SW"])
1235
1236 # INEFFICIENT - Sequential single-band calls
1237 radiation.runBand("PAR")
1238 radiation.runBand("NIR")
1239 radiation.runBand("SW")
1240
1241 Args:
1242 band_label: Single band name (str) or list of band names for multi-band simulation
1243 """
1244 if isinstance(band_label, (list, tuple)):
1245 # Multiple bands - validate each label
1246 for lbl in band_label:
1247 if not isinstance(lbl, str):
1248 raise TypeError(f"Band labels must be strings, got {type(lbl).__name__}")
1249 radiation_wrapper.runBandMultiple(self.radiation_model, band_label)
1250 logger.info(f"Completed radiation simulation for bands: {band_label}")
1251 else:
1252 # Single band - validate label type
1253 if not isinstance(band_label, str):
1254 raise TypeError(f"Band label must be a string, got {type(band_label).__name__}")
1255 radiation_wrapper.runBand(self.radiation_model, band_label)
1256 logger.info(f"Completed radiation simulation for band: {band_label}")
1257
1258
1259 @require_plugin('radiation', 'get simulation results')
1260 def getTotalAbsorbedFlux(self) -> List[float]:
1261 """Get total absorbed flux for all primitives."""
1262 results = radiation_wrapper.getTotalAbsorbedFlux(self.radiation_model)
1263 logger.debug(f"Retrieved absorbed flux data for {len(results)} primitives")
1264 return results
1265
1266 # Band query methods
1267 @require_plugin('radiation', 'check band existence')
1268 def doesBandExist(self, label: str) -> bool:
1269 """
1270 Check if a radiation band exists.
1271
1272 Args:
1273 label: Name/label of the radiation band to check
1274
1275 Returns:
1276 True if band exists, False otherwise
1277
1278 Example:
1279 >>> radiation.addRadiationBand("SW")
1280 >>> radiation.doesBandExist("SW")
1281 True
1282 >>> radiation.doesBandExist("nonexistent")
1283 False
1284 """
1285 validate_band_label(label, "label", "doesBandExist")
1286 return radiation_wrapper.doesBandExist(self.radiation_model, label)
1287
1288 # Advanced source management methods
1289 @require_plugin('radiation', 'manage radiation sources')
1290 def deleteRadiationSource(self, source_id: int):
1291 """
1292 Delete a radiation source.
1293
1294 Args:
1295 source_id: ID of the radiation source to delete
1296
1297 Example:
1298 >>> source_id = radiation.addCollimatedRadiationSource()
1299 >>> radiation.deleteRadiationSource(source_id)
1300 """
1301 if not isinstance(source_id, int) or source_id < 0:
1302 raise ValueError(f"Source ID must be a non-negative integer, got {source_id}")
1303 radiation_wrapper.deleteRadiationSource(self.radiation_model, source_id)
1304 logger.debug(f"Deleted radiation source {source_id}")
1305
1306 @require_plugin('radiation', 'query radiation sources')
1307 def getSourcePosition(self, source_id: int):
1308 """
1309 Get position of a radiation source.
1310
1311 Args:
1312 source_id: ID of the radiation source
1313
1314 Returns:
1315 vec3 position of the source
1316
1317 Example:
1318 >>> source_id = radiation.addCollimatedRadiationSource()
1319 >>> position = radiation.getSourcePosition(source_id)
1320 >>> print(f"Source at: {position}")
1321 """
1322 if not isinstance(source_id, int) or source_id < 0:
1323 raise ValueError(f"Source ID must be a non-negative integer, got {source_id}")
1324 position_list = radiation_wrapper.getSourcePosition(self.radiation_model, source_id)
1325 from .wrappers.DataTypes import vec3
1326 return vec3(position_list[0], position_list[1], position_list[2])
1327
1328 # Advanced simulation methods
1329 @require_plugin('radiation', 'get sky energy')
1330 def getSkyEnergy(self) -> float:
1331 """
1332 Get total sky energy.
1333
1334 Returns:
1335 Total sky energy value
1336
1337 Example:
1338 >>> energy = radiation.getSkyEnergy()
1339 >>> print(f"Sky energy: {energy}")
1340 """
1341 return radiation_wrapper.getSkyEnergy(self.radiation_model)
1342
1343 @require_plugin('radiation', 'calculate G-function')
1344 def calculateGtheta(self, view_direction) -> float:
1345 """
1346 Calculate G-function (geometry factor) for given view direction.
1347
1348 The G-function describes the geometric relationship between leaf area
1349 distribution and viewing direction, important for canopy radiation modeling.
1350
1351 Args:
1352 view_direction: View direction as vec3 or list/tuple [x, y, z]
1353
1354 Returns:
1355 G-function value
1356
1357 Example:
1358 >>> from pyhelios.types import vec3
1359 >>> g_value = radiation.calculateGtheta(vec3(0, 0, 1))
1360 >>> print(f"G-function: {g_value}")
1361 """
1362 validate_position_like(view_direction, "view_direction", "calculateGtheta")
1363 context_ptr = self.context.getNativePtr()
1364 return radiation_wrapper.calculateGtheta(self.radiation_model, context_ptr, view_direction)
1365
1366 @require_plugin('radiation', 'configure output data')
1367 def optionalOutputPrimitiveData(self, label: str):
1368 """
1369 Enable optional primitive data output.
1370
1371 Args:
1372 label: Name/label of the primitive data to output
1373
1374 Example:
1375 >>> radiation.optionalOutputPrimitiveData("temperature")
1376 """
1377 validate_band_label(label, "label", "optionalOutputPrimitiveData")
1378 radiation_wrapper.optionalOutputPrimitiveData(self.radiation_model, label)
1379 logger.debug(f"Enabled optional output for primitive data: {label}")
1380
1381 @require_plugin('radiation', 'configure boundary conditions')
1382 def enforcePeriodicBoundary(self, boundary: str):
1383 """
1384 Enforce periodic boundary conditions.
1385
1386 Periodic boundaries are useful for large-scale simulations to reduce
1387 edge effects by wrapping radiation at domain boundaries.
1388
1389 Args:
1390 boundary: Boundary specification string (e.g., "xy", "xyz", "x", "y", "z")
1391
1392 Example:
1393 >>> radiation.enforcePeriodicBoundary("xy")
1394 """
1395 if not isinstance(boundary, str) or not boundary:
1396 raise ValueError("Boundary specification must be a non-empty string")
1397 radiation_wrapper.enforcePeriodicBoundary(self.radiation_model, boundary)
1398 logger.debug(f"Enforced periodic boundary: {boundary}")
1399
1400 # Configuration methods
1401 @require_plugin('radiation', 'configure radiation simulation')
1402 @validate_scattering_depth_params
1403 def setScatteringDepth(self, label: str, depth: int):
1404 """Set scattering depth for radiation band."""
1405 radiation_wrapper.setScatteringDepth(self.radiation_model, label, depth)
1406
1407 @require_plugin('radiation', 'configure radiation simulation')
1408 @validate_min_scatter_energy_params
1409 def setMinScatterEnergy(self, label: str, energy: float):
1410 """Set minimum scatter energy for radiation band."""
1411 radiation_wrapper.setMinScatterEnergy(self.radiation_model, label, energy)
1413 @require_plugin('radiation', 'configure radiation emission')
1414 def disableEmission(self, label: str):
1415 """Disable emission for radiation band."""
1416 validate_band_label(label, "label", "disableEmission")
1417 radiation_wrapper.disableEmission(self.radiation_model, label)
1419 @require_plugin('radiation', 'configure radiation emission')
1420 def enableEmission(self, label: str):
1421 """Enable emission for radiation band."""
1422 validate_band_label(label, "label", "enableEmission")
1423 radiation_wrapper.enableEmission(self.radiation_model, label)
1424
1425 #=============================================================================
1426 # Camera and Image Functions (v1.3.47)
1427 #=============================================================================
1428
1429 @require_plugin('radiation', 'add radiation camera')
1430 def addRadiationCamera(self, camera_label: str, band_labels: List[str], position, lookat_or_direction,
1431 camera_properties=None, antialiasing_samples: int = 100):
1432 """
1433 Add a radiation camera to the simulation.
1434
1435 Args:
1436 camera_label: Unique label string for the camera
1437 band_labels: List of radiation band labels for the camera
1438 position: Camera position as vec3 object
1439 lookat_or_direction: Either:
1440 - Lookat point as vec3 object
1441 - SphericalCoord for viewing direction
1442 camera_properties: CameraProperties instance or None for defaults
1443 antialiasing_samples: Number of antialiasing samples (default: 100)
1444
1445 Raises:
1446 ValidationError: If parameters are invalid or have wrong types
1447 RadiationModelError: If camera creation fails
1448
1449 Example:
1450 >>> from pyhelios import vec3, CameraProperties
1451 >>> # Create camera looking at origin from above
1452 >>> camera_props = CameraProperties(camera_resolution=(1024, 1024))
1453 >>> radiation_model.addRadiationCamera("main_camera", ["red", "green", "blue"],
1454 ... position=vec3(0, 0, 5), lookat_or_direction=vec3(0, 0, 0),
1455 ... camera_properties=camera_props)
1456 """
1457 # Import here to avoid circular imports
1458 from .wrappers import URadiationModelWrapper as radiation_wrapper
1459 from .wrappers.DataTypes import SphericalCoord, vec3, make_vec3
1460 from .validation.plugins import validate_camera_label, validate_band_labels_list, validate_antialiasing_samples
1461
1462 # Validate basic parameters
1463 validated_label = validate_camera_label(camera_label, "camera_label", "addRadiationCamera")
1464 validated_bands = validate_band_labels_list(band_labels, "band_labels", "addRadiationCamera")
1465 validated_samples = validate_antialiasing_samples(antialiasing_samples, "antialiasing_samples", "addRadiationCamera")
1466
1467 # Validate position (must be vec3)
1468 if not isinstance(position, vec3):
1469 raise TypeError("position must be a vec3 object. Use vec3(x, y, z) to create one.")
1470 validated_position = position
1471
1472 # Validate lookat_or_direction (must be vec3 or SphericalCoord)
1473 if isinstance(lookat_or_direction, SphericalCoord):
1474 validated_direction = lookat_or_direction
1475 elif isinstance(lookat_or_direction, vec3):
1476 validated_direction = lookat_or_direction
1477 else:
1478 raise TypeError("lookat_or_direction must be a vec3 or SphericalCoord object. Use vec3(x, y, z) or SphericalCoord to create one.")
1479
1480 # Set up camera properties
1481 if camera_properties is None:
1482 camera_properties = CameraProperties()
1483
1484 # Call appropriate wrapper function based on direction type
1485 try:
1486 if hasattr(validated_direction, 'radius') and hasattr(validated_direction, 'elevation'):
1487 # SphericalCoord case
1488 direction_coords = validated_direction.to_list()
1489 # SphericalCoord.to_list() returns [radius, elevation, zenith, azimuth]
1490 # (4 elements). The native API expects azimuth (index 3), not zenith.
1491 if len(direction_coords) < 4:
1492 raise ValueError("SphericalCoord must expose radius, elevation, zenith, and azimuth")
1493 radius, elevation, azimuth = direction_coords[0], direction_coords[1], direction_coords[3]
1494
1495 radiation_wrapper.addRadiationCameraSpherical(
1496 self.radiation_model,
1497 validated_label,
1498 validated_bands,
1499 validated_position.x, validated_position.y, validated_position.z,
1500 radius, elevation, azimuth,
1501 camera_properties.to_array(),
1502 validated_samples
1503 )
1504 else:
1505 # vec3 case
1506 radiation_wrapper.addRadiationCameraVec3(
1507 self.radiation_model,
1508 validated_label,
1509 validated_bands,
1510 validated_position.x, validated_position.y, validated_position.z,
1511 validated_direction.x, validated_direction.y, validated_direction.z,
1512 camera_properties.to_array(),
1513 validated_samples
1514 )
1515
1516 except Exception as e:
1517 raise RadiationModelError(f"Failed to add radiation camera '{validated_label}': {e}")
1518
1519 @require_plugin('radiation', 'add SIF camera')
1520 def addSIFCamera(self, camera_label: str, emission_band_labels: List[str], position,
1521 lookat_or_direction, camera_properties=None, antialiasing_samples: int = 100):
1522 """
1523 Add a solar-induced chlorophyll fluorescence (SIF) camera.
1524
1525 Each band in ``emission_band_labels`` must already exist (added via
1526 :meth:`addRadiationBand`); those bands are flagged internally as SIF-emitting and
1527 use the Fluspect-B leaf-fluorescence kernel for emission instead of Stefan-Boltzmann.
1528 Helios auto-creates internal radiation bands covering 400-750 nm at the resolution
1529 specified by ``camera_properties.excitation_bin_width_nm``.
1530
1531 Args:
1532 camera_label: Unique label for the camera.
1533 emission_band_labels: List of pre-existing radiation band labels to drive
1534 with SIF emission.
1535 position: Camera position as a ``vec3``.
1536 lookat_or_direction: Either a ``vec3`` lookat point or a ``SphericalCoord``
1537 viewing direction.
1538 camera_properties: :class:`SIFCameraProperties` instance. If ``None`` defaults
1539 are used (10 nm excitation bins, no excitation scattering).
1540 antialiasing_samples: Antialiasing samples per pixel (>= 1, default 100).
1541
1542 Raises:
1543 RadiationModelError: If the underlying SIF camera cannot be added (e.g.,
1544 an emission band was already bound to a different excitation bin width).
1545 NotImplementedError: If running against helios-core older than v1.3.72.
1546 """
1547 from .wrappers import URadiationModelWrapper as radiation_wrapper
1548 from .wrappers.DataTypes import SphericalCoord, vec3
1549 from .validation.plugins import (
1550 validate_camera_label, validate_band_labels_list, validate_antialiasing_samples
1551 )
1552
1553 validated_label = validate_camera_label(camera_label, "camera_label", "addSIFCamera")
1554 validated_bands = validate_band_labels_list(emission_band_labels, "emission_band_labels", "addSIFCamera")
1555 validated_samples = validate_antialiasing_samples(antialiasing_samples, "antialiasing_samples", "addSIFCamera")
1556
1557 if not isinstance(position, vec3):
1558 raise TypeError("position must be a vec3 object. Use vec3(x, y, z) to create one.")
1559
1560 if not isinstance(lookat_or_direction, (vec3, SphericalCoord)):
1561 raise TypeError("lookat_or_direction must be a vec3 or SphericalCoord object.")
1562
1563 if camera_properties is None:
1564 camera_properties = SIFCameraProperties()
1565 elif not isinstance(camera_properties, SIFCameraProperties):
1566 raise TypeError(
1567 "camera_properties must be a SIFCameraProperties instance "
1568 "(use SIFCameraProperties(...) — not the plain CameraProperties)."
1569 )
1570
1571 try:
1572 if isinstance(lookat_or_direction, SphericalCoord):
1573 # SphericalCoord.to_list() is [radius, elevation, zenith, azimuth];
1574 # the C wrapper expects azimuth (index 3), not zenith.
1575 direction_coords = lookat_or_direction.to_list()
1576 if len(direction_coords) < 4:
1577 raise ValueError("SphericalCoord must expose radius, elevation, zenith, and azimuth")
1578 radius, elevation, azimuth = direction_coords[0], direction_coords[1], direction_coords[3]
1579 radiation_wrapper.addSIFCameraSpherical(
1580 self.radiation_model,
1581 validated_label,
1582 validated_bands,
1583 position.x, position.y, position.z,
1584 radius, elevation, azimuth,
1585 camera_properties.to_array(),
1586 camera_properties.excitation_bin_width_nm,
1587 camera_properties.excitation_scattering_depth,
1588 validated_samples,
1589 )
1590 else:
1591 radiation_wrapper.addSIFCameraVec3(
1592 self.radiation_model,
1593 validated_label,
1594 validated_bands,
1595 position.x, position.y, position.z,
1596 lookat_or_direction.x, lookat_or_direction.y, lookat_or_direction.z,
1597 camera_properties.to_array(),
1598 camera_properties.excitation_bin_width_nm,
1599 camera_properties.excitation_scattering_depth,
1600 validated_samples,
1601 )
1602 except Exception as e:
1603 raise RadiationModelError(f"Failed to add SIF camera '{validated_label}': {e}")
1604
1605 @require_plugin('radiation', 'check SIF camera registration')
1606 def isSIFCamera(self, camera_label: str) -> bool:
1607 """
1608 Return True if the camera was registered via :meth:`addSIFCamera` (vs. ``addRadiationCamera``).
1609 """
1610 from .wrappers import URadiationModelWrapper as radiation_wrapper
1611 if not isinstance(camera_label, str) or not camera_label.strip():
1612 raise ValueError("Camera label must be a non-empty string")
1613 return radiation_wrapper.isSIFCamera(self.radiation_model, camera_label)
1614
1615 @require_plugin('radiation', 'manage camera position')
1616 def setCameraPosition(self, camera_label: str, position):
1617 """
1618 Set camera position.
1619
1620 Allows dynamic camera repositioning during simulation, useful for
1621 time-series captures or multi-view imaging.
1622
1623 Args:
1624 camera_label: Camera label string
1625 position: Camera position as vec3 or list [x, y, z]
1626
1627 Example:
1628 >>> radiation.setCameraPosition("cam1", [0, 0, 10])
1629 >>> from pyhelios.types import vec3
1630 >>> radiation.setCameraPosition("cam1", vec3(5, 5, 10))
1631 """
1632 if not isinstance(camera_label, str) or not camera_label.strip():
1633 raise ValueError("Camera label must be a non-empty string")
1634 validate_position_like(position, "position", "setCameraPosition")
1635 radiation_wrapper.setCameraPosition(self.radiation_model, camera_label, position)
1636 logger.debug(f"Updated camera '{camera_label}' position")
1637
1638 @require_plugin('radiation', 'query camera position')
1639 def getCameraPosition(self, camera_label: str):
1640 """
1641 Get camera position.
1642
1643 Args:
1644 camera_label: Camera label string
1645
1646 Returns:
1647 vec3 position of the camera
1648
1649 Example:
1650 >>> position = radiation.getCameraPosition("cam1")
1651 >>> print(f"Camera at: {position}")
1652 """
1653 if not isinstance(camera_label, str) or not camera_label.strip():
1654 raise ValueError("Camera label must be a non-empty string")
1655 position_list = radiation_wrapper.getCameraPosition(self.radiation_model, camera_label)
1656 from .wrappers.DataTypes import vec3
1657 return vec3(position_list[0], position_list[1], position_list[2])
1658
1659 @require_plugin('radiation', 'manage camera lookat')
1660 def setCameraLookat(self, camera_label: str, lookat):
1661 """
1662 Set camera lookat point.
1663
1664 Args:
1665 camera_label: Camera label string
1666 lookat: Lookat point as vec3 or list [x, y, z]
1667
1668 Example:
1669 >>> radiation.setCameraLookat("cam1", [0, 0, 0])
1670 """
1671 if not isinstance(camera_label, str) or not camera_label.strip():
1672 raise ValueError("Camera label must be a non-empty string")
1673 validate_position_like(lookat, "lookat", "setCameraLookat")
1674 radiation_wrapper.setCameraLookat(self.radiation_model, camera_label, lookat)
1675 logger.debug(f"Updated camera '{camera_label}' lookat point")
1676
1677 @require_plugin('radiation', 'query camera lookat')
1678 def getCameraLookat(self, camera_label: str):
1679 """
1680 Get camera lookat point.
1681
1682 Args:
1683 camera_label: Camera label string
1684
1685 Returns:
1686 vec3 lookat point
1687
1688 Example:
1689 >>> lookat = radiation.getCameraLookat("cam1")
1690 >>> print(f"Camera looking at: {lookat}")
1691 """
1692 if not isinstance(camera_label, str) or not camera_label.strip():
1693 raise ValueError("Camera label must be a non-empty string")
1694 lookat_list = radiation_wrapper.getCameraLookat(self.radiation_model, camera_label)
1695 from .wrappers.DataTypes import vec3
1696 return vec3(lookat_list[0], lookat_list[1], lookat_list[2])
1697
1698 @require_plugin('radiation', 'manage camera orientation')
1699 def setCameraOrientation(self, camera_label: str, direction):
1700 """
1701 Set camera orientation.
1702
1703 Args:
1704 camera_label: Camera label string
1705 direction: View direction as vec3, SphericalCoord, or list [x, y, z]
1706
1707 Example:
1708 >>> radiation.setCameraOrientation("cam1", [0, 0, 1])
1709 >>> from pyhelios.types import SphericalCoord
1710 >>> radiation.setCameraOrientation("cam1", SphericalCoord(1.0, 45.0, 90.0))
1711 """
1712 if not isinstance(camera_label, str) or not camera_label.strip():
1713 raise ValueError("Camera label must be a non-empty string")
1714 validate_direction_like(direction, "direction", "setCameraOrientation")
1715 radiation_wrapper.setCameraOrientation(self.radiation_model, camera_label, direction)
1716 logger.debug(f"Updated camera '{camera_label}' orientation")
1717
1718 @require_plugin('radiation', 'query camera orientation')
1719 def getCameraOrientation(self, camera_label: str):
1720 """
1721 Get camera orientation.
1722
1723 Args:
1724 camera_label: Camera label string
1725
1726 Returns:
1727 SphericalCoord orientation [radius, elevation, azimuth]
1728
1729 Example:
1730 >>> orientation = radiation.getCameraOrientation("cam1")
1731 >>> print(f"Camera orientation: {orientation}")
1732 """
1733 if not isinstance(camera_label, str) or not camera_label.strip():
1734 raise ValueError("Camera label must be a non-empty string")
1735 orientation_list = radiation_wrapper.getCameraOrientation(self.radiation_model, camera_label)
1736 from .wrappers.DataTypes import SphericalCoord
1737 return SphericalCoord(orientation_list[0], orientation_list[1], orientation_list[2])
1738
1739 @require_plugin('radiation', 'query cameras')
1740 def getAllCameraLabels(self) -> List[str]:
1741 """
1742 Get all camera labels.
1743
1744 Returns:
1745 List of all camera label strings
1746
1747 Example:
1748 >>> cameras = radiation.getAllCameraLabels()
1749 >>> print(f"Available cameras: {cameras}")
1750 """
1751 return radiation_wrapper.getAllCameraLabels(self.radiation_model)
1752
1753 @require_plugin('radiation', 'configure camera spectral response')
1754 def setCameraSpectralResponse(self, camera_label: str, band_label: str, global_data: str):
1755 """
1756 Set camera spectral response from global data.
1757
1758 Args:
1759 camera_label: Camera label
1760 band_label: Band label
1761 global_data: Global data label for spectral response curve
1762
1763 Example:
1764 >>> radiation.setCameraSpectralResponse("cam1", "red", "sensor_red_response")
1765 """
1766 if not isinstance(camera_label, str) or not camera_label.strip():
1767 raise ValueError("Camera label must be a non-empty string")
1768 validate_band_label(band_label, "band_label", "setCameraSpectralResponse")
1769 if not isinstance(global_data, str) or not global_data.strip():
1770 raise ValueError("Global data label must be a non-empty string")
1771
1772 radiation_wrapper.setCameraSpectralResponse(self.radiation_model, camera_label, band_label, global_data)
1773 logger.debug(f"Set spectral response for camera '{camera_label}', band '{band_label}'")
1774
1775 @require_plugin('radiation', 'configure camera from library')
1776 def setCameraSpectralResponseFromLibrary(self, camera_label: str, camera_library_name: str):
1777 """
1778 Set camera spectral response from standard camera library.
1779
1780 Uses pre-defined spectral response curves for common cameras.
1781
1782 Args:
1783 camera_label: Camera label
1784 camera_library_name: Standard camera name (e.g., "iPhone13", "NikonD850", "CanonEOS5D")
1785
1786 Example:
1787 >>> radiation.setCameraSpectralResponseFromLibrary("cam1", "iPhone13")
1788 """
1789 if not isinstance(camera_label, str) or not camera_label.strip():
1790 raise ValueError("Camera label must be a non-empty string")
1791 if not isinstance(camera_library_name, str) or not camera_library_name.strip():
1792 raise ValueError("Camera library name must be a non-empty string")
1793
1794 radiation_wrapper.setCameraSpectralResponseFromLibrary(self.radiation_model, camera_label, camera_library_name)
1795 logger.debug(f"Set camera '{camera_label}' response from library: {camera_library_name}")
1797 @require_plugin('radiation', 'get camera pixel data')
1798 def getCameraPixelData(self, camera_label: str, band_label: str) -> List[float]:
1799 """
1800 Get camera pixel data for specific band.
1801
1802 Retrieves raw pixel values for programmatic access and analysis.
1803
1804 Args:
1805 camera_label: Camera label
1806 band_label: Band label
1807
1808 Returns:
1809 List of pixel values
1810
1811 Example:
1812 >>> pixels = radiation.getCameraPixelData("cam1", "red")
1813 >>> print(f"Mean pixel value: {sum(pixels)/len(pixels)}")
1814 """
1815 if not isinstance(camera_label, str) or not camera_label.strip():
1816 raise ValueError("Camera label must be a non-empty string")
1817 validate_band_label(band_label, "band_label", "getCameraPixelData")
1818
1819 return radiation_wrapper.getCameraPixelData(self.radiation_model, camera_label, band_label)
1820
1821 @require_plugin('radiation', 'set camera pixel data')
1822 def setCameraPixelData(self, camera_label: str, band_label: str, pixel_data: List[float]):
1823 """
1824 Set camera pixel data for specific band.
1825
1826 Allows programmatic modification of pixel values.
1827
1828 Args:
1829 camera_label: Camera label
1830 band_label: Band label
1831 pixel_data: List of pixel values
1832
1833 Example:
1834 >>> pixels = radiation.getCameraPixelData("cam1", "red")
1835 >>> modified_pixels = [p * 1.2 for p in pixels] # Brighten by 20%
1836 >>> radiation.setCameraPixelData("cam1", "red", modified_pixels)
1837 """
1838 if not isinstance(camera_label, str) or not camera_label.strip():
1839 raise ValueError("Camera label must be a non-empty string")
1840 validate_band_label(band_label, "band_label", "setCameraPixelData")
1841 if not isinstance(pixel_data, (list, tuple)):
1842 raise ValueError("Pixel data must be a list or tuple")
1843
1844 radiation_wrapper.setCameraPixelData(self.radiation_model, camera_label, band_label, pixel_data)
1845 logger.debug(f"Set pixel data for camera '{camera_label}', band '{band_label}': {len(pixel_data)} pixels")
1846
1847 # =========================================================================
1848 # Camera Library Functions (v1.3.58+)
1849 # =========================================================================
1850
1851 @require_plugin('radiation', 'add camera from library')
1852 def addRadiationCameraFromLibrary(self, camera_label: str, library_camera_label: str,
1853 position, lookat, antialiasing_samples: int = 1,
1854 band_labels: Optional[List[str]] = None):
1855 """
1856 Add radiation camera loading all properties from camera library.
1857
1858 Loads camera intrinsic parameters (resolution, FOV, sensor size) and spectral
1859 response data from the camera library XML file. This is the recommended way to
1860 create realistic cameras with proper spectral responses.
1861
1862 Args:
1863 camera_label: Label for the camera instance
1864 library_camera_label: Label of camera in library (e.g., "Canon_20D", "iPhone11", "NikonD700")
1865 position: Camera position as vec3 or (x, y, z) tuple
1866 lookat: Lookat point as vec3 or (x, y, z) tuple
1867 antialiasing_samples: Number of ray samples per pixel. Default: 1
1868 band_labels: Optional custom band labels. If None, uses library defaults.
1869
1870 Raises:
1871 RadiationModelError: If operation fails
1872 ValueError: If parameters are invalid
1873
1874 Note:
1875 Available cameras in plugins/radiation/camera_library/camera_library.xml include:
1876 - Canon_20D, Nikon_D700, Nikon_D50
1877 - iPhone11, iPhone12ProMAX
1878 - Additional cameras available in library
1879
1880 Example:
1881 >>> radiation.addRadiationCameraFromLibrary(
1882 ... camera_label="cam1",
1883 ... library_camera_label="iPhone11",
1884 ... position=(0, -5, 1),
1885 ... lookat=(0, 0, 0.5),
1886 ... antialiasing_samples=10
1887 ... )
1888 """
1889 validate_band_label(camera_label, "camera_label", "addRadiationCameraFromLibrary")
1890 validate_position_like(position, "position", "addRadiationCameraFromLibrary")
1891 validate_position_like(lookat, "lookat", "addRadiationCameraFromLibrary")
1892
1893 try:
1894 radiation_wrapper.addRadiationCameraFromLibrary(
1895 self.radiation_model, camera_label, library_camera_label,
1896 position, lookat, antialiasing_samples, band_labels
1897 )
1898 logger.info(f"Added camera '{camera_label}' from library '{library_camera_label}'")
1899 except Exception as e:
1900 raise RadiationModelError(f"Failed to add camera from library: {e}")
1901
1902 @require_plugin('radiation', 'update camera parameters')
1903 def updateCameraParameters(self, camera_label: str, camera_properties: CameraProperties):
1904 """
1905 Update camera parameters for an existing camera.
1906
1907 Allows modification of camera properties after creation while preserving
1908 position, lookat direction, and spectral band configuration.
1909
1910 Args:
1911 camera_label: Label for the camera to update
1912 camera_properties: CameraProperties instance with new parameters
1913
1914 Raises:
1915 RadiationModelError: If operation fails or camera doesn't exist
1916 ValueError: If parameters are invalid
1917
1918 Note:
1919 FOV_aspect_ratio is automatically recalculated from camera_resolution.
1920 Camera position and lookat are preserved.
1921
1922 Example:
1923 >>> props = CameraProperties(
1924 ... camera_resolution=(1920, 1080),
1925 ... HFOV=35.0,
1926 ... lens_focal_length=0.085 # 85mm lens
1927 ... )
1928 >>> radiation.updateCameraParameters("cam1", props)
1929 """
1930 validate_band_label(camera_label, "camera_label", "updateCameraParameters")
1931
1932 if not isinstance(camera_properties, CameraProperties):
1933 raise ValueError("camera_properties must be a CameraProperties instance")
1934
1935 try:
1936 radiation_wrapper.updateCameraParameters(self.radiation_model, camera_label, camera_properties)
1937 logger.debug(f"Updated parameters for camera '{camera_label}'")
1938 except Exception as e:
1939 raise RadiationModelError(f"Failed to update camera parameters: {e}")
1940
1941 @require_plugin('radiation', 'enable camera metadata')
1942 def enableCameraMetadata(self, camera_labels):
1943 """
1944 Enable automatic JSON metadata file writing for camera(s).
1945
1946 When enabled, writeCameraImage() automatically creates a JSON metadata file
1947 alongside the image containing comprehensive camera and scene information.
1948
1949 Args:
1950 camera_labels: Single camera label (str) or list of camera labels (List[str])
1951
1952 Raises:
1953 RadiationModelError: If operation fails
1954 ValueError: If parameters are invalid
1955
1956 Note:
1957 Metadata includes:
1958 - Camera properties (model, lens, sensor specs)
1959 - Geographic location (latitude, longitude)
1960 - Acquisition settings (date, time, exposure, white balance)
1961 - Agronomic data (plant species, heights, phenology stages)
1962
1963 Example:
1964 >>> # Enable for single camera
1965 >>> radiation.enableCameraMetadata("cam1")
1966 >>>
1967 >>> # Enable for multiple cameras
1968 >>> radiation.enableCameraMetadata(["cam1", "cam2", "cam3"])
1969 """
1970 try:
1971 radiation_wrapper.enableCameraMetadata(self.radiation_model, camera_labels)
1972 if isinstance(camera_labels, str):
1973 logger.info(f"Enabled metadata for camera '{camera_labels}'")
1974 else:
1975 logger.info(f"Enabled metadata for {len(camera_labels)} cameras")
1976 except Exception as e:
1977 raise RadiationModelError(f"Failed to enable camera metadata: {e}")
1978
1979 @require_plugin('radiation', 'write camera images')
1980 def writeCameraImage(self, camera: str, bands: List[str], imagefile_base: str,
1981 image_path: str = "./", frame: int = -1,
1982 flux_to_pixel_conversion: float = 1.0) -> str:
1983 """
1984 Write camera image to file and return output filename.
1985
1986 Args:
1987 camera: Camera label
1988 bands: List of band labels to include in the image
1989 imagefile_base: Base filename for output
1990 image_path: Output directory path (default: current directory)
1991 frame: Frame number to write (-1 for all frames)
1992 flux_to_pixel_conversion: Conversion factor from flux to pixel values
1993
1994 Returns:
1995 Output filename string
1996
1997 Raises:
1998 RadiationModelError: If camera image writing fails
1999 TypeError: If parameters have incorrect types
2000 """
2001 # Validate inputs
2002 if not isinstance(camera, str) or not camera.strip():
2003 raise TypeError("Camera label must be a non-empty string")
2004 if not isinstance(bands, list) or not bands:
2005 raise TypeError("Bands must be a non-empty list of strings")
2006 if not all(isinstance(band, str) and band.strip() for band in bands):
2007 raise TypeError("All band labels must be non-empty strings")
2008 if not isinstance(imagefile_base, str) or not imagefile_base.strip():
2009 raise TypeError("Image file base must be a non-empty string")
2010 if not isinstance(image_path, str):
2011 raise TypeError("Image path must be a string")
2012 if not isinstance(frame, int):
2013 raise TypeError("Frame must be an integer")
2014 if not isinstance(flux_to_pixel_conversion, (int, float)) or flux_to_pixel_conversion <= 0:
2015 raise TypeError("Flux to pixel conversion must be a positive number")
2016
2017 filename = radiation_wrapper.writeCameraImage(
2018 self.radiation_model, camera, bands, imagefile_base,
2019 image_path, frame, flux_to_pixel_conversion)
2020
2021 logger.info(f"Camera image written to: {filename}")
2022 return filename
2023
2024 @require_plugin('radiation', 'write normalized camera images')
2025 def writeNormCameraImage(self, camera: str, bands: List[str], imagefile_base: str,
2026 image_path: str = "./", frame: int = -1) -> str:
2027 """
2028 Write normalized camera image to file and return output filename.
2029
2030 Args:
2031 camera: Camera label
2032 bands: List of band labels to include in the image
2033 imagefile_base: Base filename for output
2034 image_path: Output directory path (default: current directory)
2035 frame: Frame number to write (-1 for all frames)
2036
2037 Returns:
2038 Output filename string
2039
2040 Raises:
2041 RadiationModelError: If normalized camera image writing fails
2042 TypeError: If parameters have incorrect types
2043 """
2044 # Validate inputs
2045 if not isinstance(camera, str) or not camera.strip():
2046 raise TypeError("Camera label must be a non-empty string")
2047 if not isinstance(bands, list) or not bands:
2048 raise TypeError("Bands must be a non-empty list of strings")
2049 if not all(isinstance(band, str) and band.strip() for band in bands):
2050 raise TypeError("All band labels must be non-empty strings")
2051 if not isinstance(imagefile_base, str) or not imagefile_base.strip():
2052 raise TypeError("Image file base must be a non-empty string")
2053 if not isinstance(image_path, str):
2054 raise TypeError("Image path must be a string")
2055 if not isinstance(frame, int):
2056 raise TypeError("Frame must be an integer")
2057
2058 filename = radiation_wrapper.writeNormCameraImage(
2059 self.radiation_model, camera, bands, imagefile_base, image_path, frame)
2060
2061 logger.info(f"Normalized camera image written to: {filename}")
2062 return filename
2063
2064 @require_plugin('radiation', 'write camera image data')
2065 def writeCameraImageData(self, camera: str, band: str, imagefile_base: str,
2066 image_path: str = "./", frame: int = -1):
2067 """
2068 Write camera image data to file (ASCII format).
2069
2070 Args:
2071 camera: Camera label
2072 band: Band label
2073 imagefile_base: Base filename for output
2074 image_path: Output directory path (default: current directory)
2075 frame: Frame number to write (-1 for all frames)
2076
2077 Raises:
2078 RadiationModelError: If camera image data writing fails
2079 TypeError: If parameters have incorrect types
2080 """
2081 # Validate inputs
2082 if not isinstance(camera, str) or not camera.strip():
2083 raise TypeError("Camera label must be a non-empty string")
2084 if not isinstance(band, str) or not band.strip():
2085 raise TypeError("Band label must be a non-empty string")
2086 if not isinstance(imagefile_base, str) or not imagefile_base.strip():
2087 raise TypeError("Image file base must be a non-empty string")
2088 if not isinstance(image_path, str):
2089 raise TypeError("Image path must be a string")
2090 if not isinstance(frame, int):
2091 raise TypeError("Frame must be an integer")
2092
2093 radiation_wrapper.writeCameraImageData(
2094 self.radiation_model, camera, band, imagefile_base, image_path, frame)
2095
2096 logger.info(f"Camera image data written for camera {camera}, band {band}")
2097
2098 @require_plugin('radiation', 'write image bounding boxes')
2099 def writeImageBoundingBoxes(self, camera_label: str,
2100 primitive_data_labels=None, object_data_labels=None,
2101 object_class_ids=None, image_file: str = "",
2102 classes_txt_file: str = "classes.txt",
2103 image_path: str = "./"):
2104 """
2105 Write image bounding boxes for object detection training.
2106
2107 Supports both single and multiple data labels. Either provide primitive_data_labels
2108 or object_data_labels, not both.
2109
2110 Args:
2111 camera_label: Camera label
2112 primitive_data_labels: Single primitive data label (str) or list of primitive data labels
2113 object_data_labels: Single object data label (str) or list of object data labels
2114 object_class_ids: Single class ID (int) or list of class IDs (must match data labels)
2115 image_file: Image filename
2116 classes_txt_file: Classes definition file (default: "classes.txt")
2117 image_path: Image output path (default: current directory)
2118
2119 Raises:
2120 RadiationModelError: If bounding box writing fails
2121 TypeError: If parameters have incorrect types
2122 ValueError: If both primitive and object data labels are provided, or neither
2123 """
2124 # Validate exclusive parameter usage
2125 if primitive_data_labels is not None and object_data_labels is not None:
2126 raise ValueError("Cannot specify both primitive_data_labels and object_data_labels")
2127 if primitive_data_labels is None and object_data_labels is None:
2128 raise ValueError("Must specify either primitive_data_labels or object_data_labels")
2129
2130 # Validate common parameters
2131 if not isinstance(camera_label, str) or not camera_label.strip():
2132 raise TypeError("Camera label must be a non-empty string")
2133 if not isinstance(image_file, str) or not image_file.strip():
2134 raise TypeError("Image file must be a non-empty string")
2135 if not isinstance(classes_txt_file, str):
2136 raise TypeError("Classes txt file must be a string")
2137 if not isinstance(image_path, str):
2138 raise TypeError("Image path must be a string")
2139
2140 # Handle primitive data labels
2141 if primitive_data_labels is not None:
2142 if isinstance(primitive_data_labels, str):
2143 # Single label
2144 if not isinstance(object_class_ids, int):
2145 raise TypeError("For single primitive data label, object_class_ids must be an integer")
2146 radiation_wrapper.writeImageBoundingBoxes(
2147 self.radiation_model, camera_label, primitive_data_labels,
2148 object_class_ids, image_file, classes_txt_file, image_path)
2149 logger.info(f"Image bounding boxes written for primitive data: {primitive_data_labels}")
2150
2151 elif isinstance(primitive_data_labels, list):
2152 # Multiple labels
2153 if not isinstance(object_class_ids, list):
2154 raise TypeError("For multiple primitive data labels, object_class_ids must be a list")
2155 if len(primitive_data_labels) != len(object_class_ids):
2156 raise ValueError("primitive_data_labels and object_class_ids must have the same length")
2157 if not all(isinstance(lbl, str) and lbl.strip() for lbl in primitive_data_labels):
2158 raise TypeError("All primitive data labels must be non-empty strings")
2159 if not all(isinstance(cid, int) for cid in object_class_ids):
2160 raise TypeError("All object class IDs must be integers")
2161
2162 radiation_wrapper.writeImageBoundingBoxesVector(
2163 self.radiation_model, camera_label, primitive_data_labels,
2164 object_class_ids, image_file, classes_txt_file, image_path)
2165 logger.info(f"Image bounding boxes written for {len(primitive_data_labels)} primitive data labels")
2166 else:
2167 raise TypeError("primitive_data_labels must be a string or list of strings")
2168
2169 # Handle object data labels
2170 elif object_data_labels is not None:
2171 if isinstance(object_data_labels, str):
2172 # Single label
2173 if not isinstance(object_class_ids, int):
2174 raise TypeError("For single object data label, object_class_ids must be an integer")
2175 radiation_wrapper.writeImageBoundingBoxes_ObjectData(
2176 self.radiation_model, camera_label, object_data_labels,
2177 object_class_ids, image_file, classes_txt_file, image_path)
2178 logger.info(f"Image bounding boxes written for object data: {object_data_labels}")
2179
2180 elif isinstance(object_data_labels, list):
2181 # Multiple labels
2182 if not isinstance(object_class_ids, list):
2183 raise TypeError("For multiple object data labels, object_class_ids must be a list")
2184 if len(object_data_labels) != len(object_class_ids):
2185 raise ValueError("object_data_labels and object_class_ids must have the same length")
2186 if not all(isinstance(lbl, str) and lbl.strip() for lbl in object_data_labels):
2187 raise TypeError("All object data labels must be non-empty strings")
2188 if not all(isinstance(cid, int) for cid in object_class_ids):
2189 raise TypeError("All object class IDs must be integers")
2190
2191 radiation_wrapper.writeImageBoundingBoxes_ObjectDataVector(
2192 self.radiation_model, camera_label, object_data_labels,
2193 object_class_ids, image_file, classes_txt_file, image_path)
2194 logger.info(f"Image bounding boxes written for {len(object_data_labels)} object data labels")
2195 else:
2196 raise TypeError("object_data_labels must be a string or list of strings")
2197
2198 @require_plugin('radiation', 'write image segmentation masks')
2199 def writeImageSegmentationMasks(self, camera_label: str,
2200 primitive_data_labels=None, object_data_labels=None,
2201 object_class_ids=None, json_filename: str = "",
2202 image_file: str = "", append_file: bool = False):
2203 """
2204 Write image segmentation masks in COCO JSON format.
2205
2206 Supports both single and multiple data labels. Either provide primitive_data_labels
2207 or object_data_labels, not both.
2208
2209 Args:
2210 camera_label: Camera label
2211 primitive_data_labels: Single primitive data label (str) or list of primitive data labels
2212 object_data_labels: Single object data label (str) or list of object data labels
2213 object_class_ids: Single class ID (int) or list of class IDs (must match data labels)
2214 json_filename: JSON output filename
2215 image_file: Image filename
2216 append_file: Whether to append to existing JSON file
2217
2218 Raises:
2219 RadiationModelError: If segmentation mask writing fails
2220 TypeError: If parameters have incorrect types
2221 ValueError: If both primitive and object data labels are provided, or neither
2222 """
2223 # Validate exclusive parameter usage
2224 if primitive_data_labels is not None and object_data_labels is not None:
2225 raise ValueError("Cannot specify both primitive_data_labels and object_data_labels")
2226 if primitive_data_labels is None and object_data_labels is None:
2227 raise ValueError("Must specify either primitive_data_labels or object_data_labels")
2228
2229 # Validate common parameters
2230 if not isinstance(camera_label, str) or not camera_label.strip():
2231 raise TypeError("Camera label must be a non-empty string")
2232 if not isinstance(json_filename, str) or not json_filename.strip():
2233 raise TypeError("JSON filename must be a non-empty string")
2234 if not isinstance(image_file, str) or not image_file.strip():
2235 raise TypeError("Image file must be a non-empty string")
2236 if not isinstance(append_file, bool):
2237 raise TypeError("append_file must be a boolean")
2238
2239 # Handle primitive data labels
2240 if primitive_data_labels is not None:
2241 if isinstance(primitive_data_labels, str):
2242 # Single label
2243 if not isinstance(object_class_ids, int):
2244 raise TypeError("For single primitive data label, object_class_ids must be an integer")
2245 radiation_wrapper.writeImageSegmentationMasks(
2246 self.radiation_model, camera_label, primitive_data_labels,
2247 object_class_ids, json_filename, image_file, append_file)
2248 logger.info(f"Image segmentation masks written for primitive data: {primitive_data_labels}")
2249
2250 elif isinstance(primitive_data_labels, list):
2251 # Multiple labels
2252 if not isinstance(object_class_ids, list):
2253 raise TypeError("For multiple primitive data labels, object_class_ids must be a list")
2254 if len(primitive_data_labels) != len(object_class_ids):
2255 raise ValueError("primitive_data_labels and object_class_ids must have the same length")
2256 if not all(isinstance(lbl, str) and lbl.strip() for lbl in primitive_data_labels):
2257 raise TypeError("All primitive data labels must be non-empty strings")
2258 if not all(isinstance(cid, int) for cid in object_class_ids):
2259 raise TypeError("All object class IDs must be integers")
2260
2261 radiation_wrapper.writeImageSegmentationMasksVector(
2262 self.radiation_model, camera_label, primitive_data_labels,
2263 object_class_ids, json_filename, image_file, append_file)
2264 logger.info(f"Image segmentation masks written for {len(primitive_data_labels)} primitive data labels")
2265 else:
2266 raise TypeError("primitive_data_labels must be a string or list of strings")
2267
2268 # Handle object data labels
2269 elif object_data_labels is not None:
2270 if isinstance(object_data_labels, str):
2271 # Single label
2272 if not isinstance(object_class_ids, int):
2273 raise TypeError("For single object data label, object_class_ids must be an integer")
2274 radiation_wrapper.writeImageSegmentationMasks_ObjectData(
2275 self.radiation_model, camera_label, object_data_labels,
2276 object_class_ids, json_filename, image_file, append_file)
2277 logger.info(f"Image segmentation masks written for object data: {object_data_labels}")
2278
2279 elif isinstance(object_data_labels, list):
2280 # Multiple labels
2281 if not isinstance(object_class_ids, list):
2282 raise TypeError("For multiple object data labels, object_class_ids must be a list")
2283 if len(object_data_labels) != len(object_class_ids):
2284 raise ValueError("object_data_labels and object_class_ids must have the same length")
2285 if not all(isinstance(lbl, str) and lbl.strip() for lbl in object_data_labels):
2286 raise TypeError("All object data labels must be non-empty strings")
2287 if not all(isinstance(cid, int) for cid in object_class_ids):
2288 raise TypeError("All object class IDs must be integers")
2289
2290 radiation_wrapper.writeImageSegmentationMasks_ObjectDataVector(
2291 self.radiation_model, camera_label, object_data_labels,
2292 object_class_ids, json_filename, image_file, append_file)
2293 logger.info(f"Image segmentation masks written for {len(object_data_labels)} object data labels")
2294 else:
2295 raise TypeError("object_data_labels must be a string or list of strings")
2296
2297 @require_plugin('radiation', 'auto-calibrate camera image')
2298 def autoCalibrateCameraImage(self, camera_label: str, red_band_label: str,
2299 green_band_label: str, blue_band_label: str,
2300 output_file_path: str, print_quality_report: bool = False,
2301 algorithm: str = "MATRIX_3X3_AUTO",
2302 ccm_export_file_path: str = "") -> str:
2303 """
2304 Auto-calibrate camera image with color correction and return output filename.
2305
2306 Args:
2307 camera_label: Camera label
2308 red_band_label: Red band label
2309 green_band_label: Green band label
2310 blue_band_label: Blue band label
2311 output_file_path: Output file path
2312 print_quality_report: Whether to print quality report
2313 algorithm: Color correction algorithm ("DIAGONAL_ONLY", "MATRIX_3X3_AUTO", "MATRIX_3X3_FORCE")
2314 ccm_export_file_path: Path to export color correction matrix (optional)
2315
2316 Returns:
2317 Output filename string
2318
2319 Raises:
2320 RadiationModelError: If auto-calibration fails
2321 TypeError: If parameters have incorrect types
2322 ValueError: If algorithm is not valid
2323 """
2324 # Validate inputs
2325 if not isinstance(camera_label, str) or not camera_label.strip():
2326 raise TypeError("Camera label must be a non-empty string")
2327 if not isinstance(red_band_label, str) or not red_band_label.strip():
2328 raise TypeError("Red band label must be a non-empty string")
2329 if not isinstance(green_band_label, str) or not green_band_label.strip():
2330 raise TypeError("Green band label must be a non-empty string")
2331 if not isinstance(blue_band_label, str) or not blue_band_label.strip():
2332 raise TypeError("Blue band label must be a non-empty string")
2333 if not isinstance(output_file_path, str) or not output_file_path.strip():
2334 raise TypeError("Output file path must be a non-empty string")
2335 if not isinstance(print_quality_report, bool):
2336 raise TypeError("print_quality_report must be a boolean")
2337 if not isinstance(ccm_export_file_path, str):
2338 raise TypeError("ccm_export_file_path must be a string")
2339
2340 # Map algorithm string to integer (using MATRIX_3X3_AUTO = 1 as default)
2341 algorithm_map = {
2342 "DIAGONAL_ONLY": 0,
2343 "MATRIX_3X3_AUTO": 1,
2344 "MATRIX_3X3_FORCE": 2
2345 }
2346
2347 if algorithm not in algorithm_map:
2348 raise ValueError(f"Invalid algorithm: {algorithm}. Must be one of: {list(algorithm_map.keys())}")
2349
2350 algorithm_int = algorithm_map[algorithm]
2351
2352 filename = radiation_wrapper.autoCalibrateCameraImage(
2353 self.radiation_model, camera_label, red_band_label, green_band_label,
2354 blue_band_label, output_file_path, print_quality_report,
2355 algorithm_int, ccm_export_file_path)
2356
2357 logger.info(f"Auto-calibrated camera image written to: {filename}")
2358 return filename
2359
2360 def getPluginInfo(self) -> dict:
2361 """Get information about the radiation plugin."""
2362 registry = get_plugin_registry()
2363 return registry.get_plugin_capabilities('radiation')
2364
2365 # =========================================================================
2366 # EXR Image Export (v1.3.66+)
2367 # =========================================================================
2368
2369 def writeCameraImageDataEXR(self, camera: str, band, imagefile_base: str,
2370 image_path: str = "./", frame: int = -1):
2371 """
2372 Write camera pixel data to an EXR file with lossless float compression.
2373
2374 Preserves full floating-point precision unlike JPEG/PNG exports.
2375
2376 Args:
2377 camera: Camera label
2378 band: Band label (str) for single-band, or list of band labels for multi-band
2379 imagefile_base: Base filename for output
2380 image_path: Output directory path (default: current directory)
2381 frame: Frame number to append to filename (-1 to omit)
2382
2383 Raises:
2384 RadiationModelError: If writing fails
2385 TypeError: If parameters have incorrect types
2386 """
2387 if not isinstance(camera, str) or not camera.strip():
2388 raise TypeError("Camera label must be a non-empty string")
2389 if not isinstance(imagefile_base, str) or not imagefile_base.strip():
2390 raise TypeError("Image file base must be a non-empty string")
2391 if not isinstance(image_path, str):
2392 raise TypeError("Image path must be a string")
2393 if not isinstance(frame, int):
2394 raise TypeError("Frame must be an integer")
2395
2396 if isinstance(band, str):
2397 if not band.strip():
2398 raise TypeError("Band label must be a non-empty string")
2399 elif isinstance(band, (list, tuple)):
2400 if not band:
2401 raise ValueError("Band list cannot be empty")
2402 for b in band:
2403 if not isinstance(b, str) or not b.strip():
2404 raise TypeError("Each band label must be a non-empty string")
2405 else:
2406 raise TypeError("band must be a string or list of strings")
2407
2408 radiation_wrapper.writeCameraImageDataEXR(
2409 self.radiation_model, camera, band, imagefile_base, image_path, frame)
2410
2411 def writeDepthImageData(self, camera_label: str, imagefile_base: str,
2412 image_path: str = "./", frame: int = -1):
2413 """
2414 Write depth image data to an ASCII text file.
2415
2416 Args:
2417 camera_label: Camera label
2418 imagefile_base: Base filename for output
2419 image_path: Output directory path (default: current directory)
2420 frame: Frame number to append to filename (-1 to omit)
2421
2422 Raises:
2423 RadiationModelError: If writing fails
2424 TypeError: If parameters have incorrect types
2425 """
2426 if not isinstance(camera_label, str) or not camera_label.strip():
2427 raise TypeError("Camera label must be a non-empty string")
2428 if not isinstance(imagefile_base, str) or not imagefile_base.strip():
2429 raise TypeError("Image file base must be a non-empty string")
2430 if not isinstance(image_path, str):
2431 raise TypeError("Image path must be a string")
2432 if not isinstance(frame, int):
2433 raise TypeError("Frame must be an integer")
2434
2435 radiation_wrapper.writeDepthImageData(
2436 self.radiation_model, camera_label, imagefile_base, image_path, frame)
2437
2438 def writeDepthImageDataEXR(self, camera_label: str, imagefile_base: str,
2439 image_path: str = "./", frame: int = -1):
2440 """
2441 Write depth image data to an EXR file with lossless float compression.
2442
2443 Preserves full floating-point depth precision unlike ASCII or JPEG exports.
2444
2445 Args:
2446 camera_label: Camera label
2447 imagefile_base: Base filename for output
2448 image_path: Output directory path (default: current directory)
2449 frame: Frame number to append to filename (-1 to omit)
2450
2451 Raises:
2452 RadiationModelError: If writing fails
2453 TypeError: If parameters have incorrect types
2454 """
2455 if not isinstance(camera_label, str) or not camera_label.strip():
2456 raise TypeError("Camera label must be a non-empty string")
2457 if not isinstance(imagefile_base, str) or not imagefile_base.strip():
2458 raise TypeError("Image file base must be a non-empty string")
2459 if not isinstance(image_path, str):
2460 raise TypeError("Image path must be a string")
2461 if not isinstance(frame, int):
2462 raise TypeError("Frame must be an integer")
2463
2464 radiation_wrapper.writeDepthImageDataEXR(
2465 self.radiation_model, camera_label, imagefile_base, image_path, frame)
2466
2467 def writeNormDepthImage(self, camera_label: str, imagefile_base: str, max_depth: float,
2468 image_path: str = "./", frame: int = -1):
2469 """
2470 Write normalized depth image as grayscale JPEG.
2471
2472 Depth values are normalized to the range [0, max_depth] for visualization.
2473
2474 Args:
2475 camera_label: Camera label
2476 imagefile_base: Base filename for output
2477 max_depth: Maximum depth value for normalization (e.g., sky depth)
2478 image_path: Output directory path (default: current directory)
2479 frame: Frame number to append to filename (-1 to omit)
2480
2481 Raises:
2482 RadiationModelError: If writing fails
2483 TypeError: If parameters have incorrect types
2484 ValueError: If max_depth is not positive
2485 """
2486 if not isinstance(camera_label, str) or not camera_label.strip():
2487 raise TypeError("Camera label must be a non-empty string")
2488 if not isinstance(imagefile_base, str) or not imagefile_base.strip():
2489 raise TypeError("Image file base must be a non-empty string")
2490 if not isinstance(max_depth, (int, float)):
2491 raise TypeError("max_depth must be a number")
2492 if max_depth <= 0:
2493 raise ValueError("max_depth must be positive")
2494 if not isinstance(image_path, str):
2495 raise TypeError("Image path must be a string")
2496 if not isinstance(frame, int):
2497 raise TypeError("Frame must be an integer")
2498
2499 radiation_wrapper.writeNormDepthImage(
2500 self.radiation_model, camera_label, imagefile_base, float(max_depth), image_path, frame)
2501
2502 # =========================================================================
2503 # Backend Query (v1.3.67+)
2504 # =========================================================================
2505
2506 def getBackendName(self) -> str:
2507 """
2508 Get the name of the active ray tracing backend.
2509
2510 Returns:
2511 Backend name string (e.g., "OptiX 8.1", "Vulkan Compute")
2512 """
2513 return radiation_wrapper.getBackendName(self.radiation_model)
2514
2515 @staticmethod
2516 def probeAnyGPUBackend() -> bool:
2517 """
2518 Probe whether any compiled-in GPU backend is available on this system.
2519
2520 Probes backends in priority order (OptiX 8 -> OptiX 6 -> Vulkan) without
2521 constructing a full backend. Useful for checking GPU availability before
2522 creating a RadiationModel.
2523
2524 Returns:
2525 True if at least one GPU backend is available
2526 """
2527 return radiation_wrapper.probeAnyGPUBackend()
__init__(self, date="", time="", UTC_offset=0.0, camera_height_m=0.0, camera_angle_deg=0.0, light_source="sunlight")
Agronomic properties derived from plant architecture data.
__init__(self, plant_species=None, plant_count=None, plant_height_m=None, plant_age_days=None, plant_stage=None, leaf_area_m2=None, weed_pressure="")
__init__(self, height=512, width=512, channels=3, type="rgb", focal_length=50.0, aperture="f/2.8", sensor_width=35.0, sensor_height=24.0, model="generic", lens_make="", lens_model="", lens_specification="", exposure="auto", shutter_speed=0.008, white_balance="auto")
Image processing corrections applied to the image.
__init__(self, saturation_adjustment=1.0, brightness_adjustment=1.0, contrast_adjustment=1.0, color_space="linear")
Metadata for radiation camera image export (Helios v1.3.58+).
__init__(self, path="")
Initialize CameraMetadata with default values.
Camera properties for radiation model cameras.
__init__(self, camera_resolution=None, focal_plane_distance=1.0, lens_diameter=0.05, HFOV=20.0, FOV_aspect_ratio=0.0, lens_focal_length=0.05, sensor_width_mm=35.0, manufacturer="", model="generic", lens_make="", lens_model="", lens_specification="", exposure="auto", shutter_speed=1.0/125.0, white_balance="auto", camera_zoom=1.0)
Initialize camera properties with defaults matching C++ CameraProperties.
to_array(self)
Convert to array format expected by C++ interface.
Raised when RadiationModel operations fail.
High-level interface for radiation modeling and ray tracing.
str getBackendName(self)
Get the name of the active ray tracing backend.
setDirectRayCount(self, str band_label, int ray_count)
Set direct ray count for radiation band.
float integrateSpectrum(self, object_spectrum, float wavelength_min=None, float wavelength_max=None, int source_id=None, camera_spectrum=None)
Integrate spectrum with optional source/camera spectra and wavelength range.
__del__(self)
Destructor to ensure GPU resources freed even without 'with' statement.
getCameraPosition(self, str camera_label)
Get camera position.
disableMessages(self)
Disable RadiationModel status messages.
int addRectangleRadiationSource(self, position, size, rotation)
Add a rectangle (planar) radiation source.
interpolateSpectrumFromPrimitiveData(self, List[int] primitive_uuids, List[str] spectra_labels, List[float] values, str primitive_data_query_label, str primitive_data_radprop_label)
Interpolate spectral properties based on primitive data values.
runBand(self, band_label)
Run radiation simulation for single band or multiple bands.
writeCameraImageDataEXR(self, str camera, band, str imagefile_base, str image_path="./", int frame=-1)
Write camera pixel data to an EXR file with lossless float compression.
setSourceSpectrum(self, source_id, spectrum)
Set radiation spectrum for source(s).
str writeCameraImage(self, str camera, List[str] bands, str imagefile_base, str image_path="./", int frame=-1, float flux_to_pixel_conversion=1.0)
Write camera image to file and return output filename.
List[float] getCameraPixelData(self, str camera_label, str band_label)
Get camera pixel data for specific band.
setCameraOrientation(self, str camera_label, direction)
Set camera orientation.
get_native_ptr(self)
Get native pointer for advanced operations.
addRadiationBand(self, str band_label, float wavelength_min=None, float wavelength_max=None)
Add radiation band with optional wavelength bounds.
setScatteringDepth(self, str label, int depth)
Set scattering depth for radiation band.
getSourcePosition(self, int source_id)
Get position of a radiation source.
addRadiationCameraFromLibrary(self, str camera_label, str library_camera_label, position, lookat, int antialiasing_samples=1, Optional[List[str]] band_labels=None)
Add radiation camera loading all properties from camera library.
updateCameraParameters(self, str camera_label, CameraProperties camera_properties)
Update camera parameters for an existing camera.
setCameraPosition(self, str camera_label, position)
Set camera position.
enableMessages(self)
Enable RadiationModel status messages.
setCameraSpectralResponse(self, str camera_label, str band_label, str global_data)
Set camera spectral response from global data.
interpolateSpectrumFromObjectData(self, List[int] object_ids, List[str] spectra_labels, List[float] values, str object_data_query_label, str primitive_data_radprop_label)
Interpolate spectral properties based on object data values.
setSourceFlux(self, source_id, str label, float flux)
Set source flux for single source or multiple sources.
writeImageBoundingBoxes(self, str camera_label, primitive_data_labels=None, object_data_labels=None, object_class_ids=None, str image_file="", str classes_txt_file="classes.txt", str image_path="./")
Write image bounding boxes for object detection training.
writeDepthImageDataEXR(self, str camera_label, str imagefile_base, str image_path="./", int frame=-1)
Write depth image data to an EXR file with lossless float compression.
deleteRadiationSource(self, int source_id)
Delete a radiation source.
setSourcePosition(self, int source_id, position)
Set position of a radiation source.
setDiffuseRadiationExtinctionCoeff(self, str label, float K, peak_direction)
Set diffuse radiation extinction coefficient with directional bias.
setCameraPixelData(self, str camera_label, str band_label, List[float] pixel_data)
Set camera pixel data for specific band.
getCameraOrientation(self, str camera_label)
Get camera orientation.
enforcePeriodicBoundary(self, str boundary)
Enforce periodic boundary conditions.
writeNormDepthImage(self, str camera_label, str imagefile_base, float max_depth, str image_path="./", int frame=-1)
Write normalized depth image as grayscale JPEG.
enableCameraMetadata(self, camera_labels)
Enable automatic JSON metadata file writing for camera(s).
scaleSpectrumRandomly(self, str existing_label, str new_label, float min_scale, float max_scale)
Scale spectrum with random factor and store as new label.
bool probeAnyGPUBackend()
Probe whether any compiled-in GPU backend is available on this system.
setSourceSpectrumIntegral(self, int source_id, float source_integral, float wavelength_min=None, float wavelength_max=None)
Set source spectrum integral value.
setDiffuseRayCount(self, str band_label, int ray_count)
Set diffuse ray count for radiation band.
bool isSIFCamera(self, str camera_label)
Return True if the camera was registered via :meth:addSIFCamera (vs.
setCameraLookat(self, str camera_label, lookat)
Set camera lookat point.
int addDiskRadiationSource(self, position, float radius, rotation)
Add a disk (circular planar) radiation source.
getCameraLookat(self, str camera_label)
Get camera lookat point.
float getSourceFlux(self, int source_id, str label)
Get source flux for band.
blendSpectra(self, str new_label, List[str] spectrum_labels, List[float] weights)
Blend multiple spectra with specified weights.
float calculateGtheta(self, view_direction)
Calculate G-function (geometry factor) for given view direction.
int addSunSphereRadiationSource(self, float radius, float zenith, float azimuth, float position_scaling=1.0, float angular_width=0.53, float flux_scaling=1.0)
Add sun sphere radiation source.
bool doesBandExist(self, str label)
Check if a radiation band exists.
setCameraSpectralResponseFromLibrary(self, str camera_label, str camera_library_name)
Set camera spectral response from standard camera library.
setDiffuseSpectrumIntegral(self, float spectrum_integral, float wavelength_min=None, float wavelength_max=None, str band_label=None)
Set diffuse spectrum integral.
scaleSpectrum(self, str existing_label, new_label_or_scale, float scale_factor=None)
Scale spectrum in-place or to new label.
float getSkyEnergy(self)
Get total sky energy.
disableEmission(self, str label)
Disable emission for radiation band.
copyRadiationBand(self, str old_label, str new_label, float wavelength_min=None, float wavelength_max=None)
Copy existing radiation band to new label, optionally with new wavelength range.
enableEmission(self, str label)
Enable emission for radiation band.
addRadiationCamera(self, str camera_label, List[str] band_labels, position, lookat_or_direction, camera_properties=None, int antialiasing_samples=100)
Add a radiation camera to the simulation.
List[float] getTotalAbsorbedFlux(self)
Get total absorbed flux for all primitives.
int addSphereRadiationSource(self, position, float radius)
Add spherical radiation source.
float integrateSourceSpectrum(self, int source_id, float wavelength_min, float wavelength_max)
Integrate source spectrum over wavelength range.
__enter__(self)
Context manager entry.
optionalOutputPrimitiveData(self, str label)
Enable optional primitive data output.
str autoCalibrateCameraImage(self, str camera_label, str red_band_label, str green_band_label, str blue_band_label, str output_file_path, bool print_quality_report=False, str algorithm="MATRIX_3X3_AUTO", str ccm_export_file_path="")
Auto-calibrate camera image with color correction and return output filename.
addSIFCamera(self, str camera_label, List[str] emission_band_labels, position, lookat_or_direction, camera_properties=None, int antialiasing_samples=100)
Add a solar-induced chlorophyll fluorescence (SIF) camera.
__exit__(self, exc_type, exc_value, traceback)
Context manager exit with proper cleanup.
updateGeometry(self, Optional[List[int]] uuids=None)
Update geometry in radiation model.
setDiffuseSpectrum(self, band_label, str spectrum_label)
Set diffuse spectrum from global data label.
setMinScatterEnergy(self, str label, float energy)
Set minimum scatter energy for radiation band.
float getDiffuseFlux(self, str band_label)
Get diffuse flux for band.
setDiffuseRadiationFlux(self, str label, float flux)
Set diffuse radiation flux for band.
writeImageSegmentationMasks(self, str camera_label, primitive_data_labels=None, object_data_labels=None, object_class_ids=None, str json_filename="", str image_file="", bool append_file=False)
Write image segmentation masks in COCO JSON format.
writeCameraImageData(self, str camera, str band, str imagefile_base, str image_path="./", int frame=-1)
Write camera image data to file (ASCII format).
int addCollimatedRadiationSource(self, direction=None)
Add collimated radiation source.
writeDepthImageData(self, str camera_label, str imagefile_base, str image_path="./", int frame=-1)
Write depth image data to an ASCII text file.
List[str] getAllCameraLabels(self)
Get all camera labels.
str writeNormCameraImage(self, str camera, List[str] bands, str imagefile_base, str image_path="./", int frame=-1)
Write normalized camera image to file and return output filename.
dict getPluginInfo(self)
Get information about the radiation plugin.
blendSpectraRandomly(self, str new_label, List[str] spectrum_labels)
Blend multiple spectra with random weights.
getNativePtr(self)
Get native pointer for advanced operations.
Camera properties for a solar-induced chlorophyll fluorescence (SIF) camera.
__init__(self, float excitation_bin_width_nm=10.0, int excitation_scattering_depth=0, **kwargs)
excitation_scattering_depth
Scattering depth for the auto-generated.
excitation_bin_width_nm
Excitation wavelength bin width in nm.
_radiation_working_directory()
Context manager that temporarily changes working directory to where RadiationModel assets are located...