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