0.1.8
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)
20from .validation.plugin_decorators import (
21 validate_radiation_band_params, validate_collimated_source_params, validate_sphere_source_params,
22 validate_sun_sphere_params, validate_get_source_flux_params,
23 validate_update_geometry_params, validate_run_band_params, validate_scattering_depth_params,
24 validate_min_scatter_energy_params
25)
26from .Context import Context
27from .assets import get_asset_manager
28
29logger = logging.getLogger(__name__)
30
31
32@contextmanager
34 """
35 Context manager that temporarily changes working directory to where RadiationModel assets are located.
36
37 RadiationModel C++ code uses hardcoded relative paths like "plugins/radiation/cuda_compile_ptx_generated_rayGeneration.cu.ptx"
38 expecting assets relative to working directory. This manager temporarily changes to the build directory
39 where assets are actually located.
40
41 Raises:
42 RuntimeError: If build directory or RadiationModel assets are not found, indicating a build system error.
43 """
44 # Find the build directory containing RadiationModel assets
45 # Try asset manager first (works for both development and wheel installations)
46 asset_manager = get_asset_manager()
47 working_dir = asset_manager._get_helios_build_path()
48
49 if working_dir and working_dir.exists():
50 radiation_assets = working_dir / 'plugins' / 'radiation'
51 else:
52 # For wheel installations, check packaged assets
53 current_dir = Path(__file__).parent
54 packaged_build = current_dir / 'assets' / 'build'
55
56 if packaged_build.exists():
57 working_dir = packaged_build
58 radiation_assets = working_dir / 'plugins' / 'radiation'
59 else:
60 # Fallback to development paths
61 repo_root = current_dir.parent
62 build_lib_dir = repo_root / 'pyhelios_build' / 'build' / 'lib'
63 working_dir = build_lib_dir.parent
64 radiation_assets = working_dir / 'plugins' / 'radiation'
65
66 if not build_lib_dir.exists():
67 raise RuntimeError(
68 f"PyHelios build directory not found at {build_lib_dir}. "
69 f"Run: python build_scripts/build_helios.py --plugins radiation"
70 )
71
72 if not radiation_assets.exists():
73 raise RuntimeError(
74 f"RadiationModel assets not found at {radiation_assets}. "
75 f"This indicates a build system error. The build script should copy PTX files to this location."
76 )
77
78 # Change to the build directory temporarily
79 original_dir = os.getcwd()
80 try:
81 os.chdir(working_dir)
82 logger.debug(f"Changed working directory to {working_dir} for RadiationModel asset access")
83 yield working_dir
84 finally:
85 os.chdir(original_dir)
86 logger.debug(f"Restored working directory to {original_dir}")
87
88
89class RadiationModelError(Exception):
90 """Raised when RadiationModel operations fail."""
91 pass
93
95 """
96 Camera properties for radiation model cameras.
97
98 This class encapsulates the properties needed to configure a radiation camera,
99 providing sensible defaults and validation for camera parameters.
100 """
101
102 def __init__(self, camera_resolution=None, focal_plane_distance=1.0, lens_diameter=0.05,
103 HFOV=20.0, FOV_aspect_ratio=1.0):
104 """
105 Initialize camera properties with defaults matching C++ CameraProperties.
106
107 Args:
108 camera_resolution: Camera resolution as (width, height) tuple or list. Default: (512, 512)
109 focal_plane_distance: Distance from viewing plane to focal plane. Default: 1.0
110 lens_diameter: Diameter of camera lens (0 = pinhole camera). Default: 0.05
111 HFOV: Horizontal field of view in degrees. Default: 20.0
112 FOV_aspect_ratio: Ratio of horizontal to vertical field of view. Default: 1.0
113 """
114 # Set camera resolution with validation
115 if camera_resolution is None:
116 self.camera_resolution = (512, 512)
117 else:
118 if isinstance(camera_resolution, (list, tuple)) and len(camera_resolution) == 2:
119 self.camera_resolution = (int(camera_resolution[0]), int(camera_resolution[1]))
120 else:
121 raise ValueError("camera_resolution must be a tuple or list of 2 integers")
122
123 # Validate and set other properties
124 if focal_plane_distance <= 0:
125 raise ValueError("focal_plane_distance must be greater than 0")
126 if lens_diameter < 0:
127 raise ValueError("lens_diameter must be non-negative")
128 if HFOV <= 0 or HFOV > 180:
129 raise ValueError("HFOV must be between 0 and 180 degrees")
130 if FOV_aspect_ratio <= 0:
131 raise ValueError("FOV_aspect_ratio must be greater than 0")
132
133 self.focal_plane_distance = float(focal_plane_distance)
134 self.lens_diameter = float(lens_diameter)
135 self.HFOV = float(HFOV)
136 self.FOV_aspect_ratio = float(FOV_aspect_ratio)
138 def to_array(self):
139 """
140 Convert to array format expected by C++ interface.
141
142 Returns:
143 List of 6 float values: [resolution_x, resolution_y, focal_distance, lens_diameter, HFOV, FOV_aspect_ratio]
144 """
145 return [
146 float(self.camera_resolution[0]), # resolution_x
147 float(self.camera_resolution[1]), # resolution_y
149 self.lens_diameter,
150 self.HFOV,
152 ]
153
154 def __repr__(self):
155 return (f"CameraProperties(camera_resolution={self.camera_resolution}, "
156 f"focal_plane_distance={self.focal_plane_distance}, "
157 f"lens_diameter={self.lens_diameter}, "
158 f"HFOV={self.HFOV}, "
159 f"FOV_aspect_ratio={self.FOV_aspect_ratio})")
160
161
162class RadiationModel:
163 """
164 High-level interface for radiation modeling and ray tracing.
165
166 This class provides a user-friendly wrapper around the native Helios
167 radiation plugin with automatic plugin availability checking and
168 graceful error handling.
169 """
170
171 def __init__(self, context: Context):
172 """
173 Initialize RadiationModel with graceful plugin handling.
174
175 Args:
176 context: Helios Context instance
177
178 Raises:
179 TypeError: If context is not a Context instance
180 RadiationModelError: If radiation plugin is not available
181 """
182 # Validate context type
183 if not isinstance(context, Context):
184 raise TypeError(f"RadiationModel requires a Context instance, got {type(context).__name__}")
185
186 self.context = context
187 self.radiation_model = None
189 # Check plugin availability using registry
190 registry = get_plugin_registry()
191
192 if not registry.is_plugin_available('radiation'):
193 # Get helpful information about the missing plugin
194 plugin_info = registry.get_plugin_capabilities()
195 available_plugins = registry.get_available_plugins()
196
197 error_msg = (
198 "RadiationModel requires the 'radiation' plugin which is not available.\n\n"
199 "The radiation plugin provides GPU-accelerated ray tracing using OptiX.\n"
200 "System requirements:\n"
201 "- NVIDIA GPU with CUDA support\n"
202 "- CUDA Toolkit installed\n"
203 "- OptiX runtime (bundled with PyHelios)\n\n"
204 "To enable radiation modeling:\n"
205 "1. Build PyHelios with radiation plugin:\n"
206 " build_scripts/build_helios --plugins radiation\n"
207 "2. Or build with multiple plugins:\n"
208 " build_scripts/build_helios --plugins radiation,visualizer,weberpenntree\n"
209 f"\nCurrently available plugins: {available_plugins}"
210 )
211
212 # Suggest alternatives if available
213 alternatives = registry.suggest_alternatives('radiation')
214 if alternatives:
215 error_msg += f"\n\nAlternative plugins available: {alternatives}"
216 error_msg += "\nConsider using energybalance or leafoptics for thermal modeling."
217
218 raise RadiationModelError(error_msg)
219
220 # Plugin is available - create radiation model using working directory context manager
221 try:
223 self.radiation_model = radiation_wrapper.createRadiationModel(context.getNativePtr())
224 if self.radiation_model is None:
226 "Failed to create RadiationModel instance. "
227 "This may indicate a problem with the native library or GPU initialization."
228 )
229 logger.info("RadiationModel created successfully")
230
231 except Exception as e:
232 raise RadiationModelError(f"Failed to initialize RadiationModel: {e}")
233
234 def __enter__(self):
235 """Context manager entry."""
236 return self
238 def __exit__(self, exc_type, exc_value, traceback):
239 """Context manager exit with proper cleanup."""
240 if self.radiation_model is not None:
241 try:
242 radiation_wrapper.destroyRadiationModel(self.radiation_model)
243 logger.debug("RadiationModel destroyed successfully")
244 except Exception as e:
245 logger.warning(f"Error destroying RadiationModel: {e}")
246
247 def get_native_ptr(self):
248 """Get native pointer for advanced operations."""
249 return self.radiation_model
251 def getNativePtr(self):
252 """Get native pointer for advanced operations. (Legacy naming for compatibility)"""
253 return self.get_native_ptr()
255 @require_plugin('radiation', 'disable status messages')
256 def disableMessages(self):
257 """Disable RadiationModel status messages."""
258 radiation_wrapper.disableMessages(self.radiation_model)
260 @require_plugin('radiation', 'enable status messages')
261 def enableMessages(self):
262 """Enable RadiationModel status messages."""
263 radiation_wrapper.enableMessages(self.radiation_model)
265 @require_plugin('radiation', 'add radiation band')
266 def addRadiationBand(self, band_label: str, wavelength_min: float = None, wavelength_max: float = None):
267 """
268 Add radiation band with optional wavelength bounds.
269
270 Args:
271 band_label: Name/label for the radiation band
272 wavelength_min: Optional minimum wavelength (μm)
273 wavelength_max: Optional maximum wavelength (μm)
274 """
275 # Validate inputs
276 validate_band_label(band_label, "band_label", "addRadiationBand")
277 if wavelength_min is not None and wavelength_max is not None:
278 validate_wavelength_range(wavelength_min, wavelength_max, "wavelength_min", "wavelength_max", "addRadiationBand")
279 radiation_wrapper.addRadiationBandWithWavelengths(self.radiation_model, band_label, wavelength_min, wavelength_max)
280 logger.debug(f"Added radiation band {band_label}: {wavelength_min}-{wavelength_max} μm")
281 else:
282 radiation_wrapper.addRadiationBand(self.radiation_model, band_label)
283 logger.debug(f"Added radiation band: {band_label}")
284
285 @require_plugin('radiation', 'copy radiation band')
286 @validate_radiation_band_params
287 def copyRadiationBand(self, old_label: str, new_label: str):
288 """
289 Copy existing radiation band to new label.
290
291 Args:
292 old_label: Existing band label to copy
293 new_label: New label for the copied band
294 """
295 radiation_wrapper.copyRadiationBand(self.radiation_model, old_label, new_label)
296 logger.debug(f"Copied radiation band {old_label} to {new_label}")
297
298 @require_plugin('radiation', 'add radiation source')
299 @validate_collimated_source_params
300 def addCollimatedRadiationSource(self, direction=None) -> int:
301 """
302 Add collimated radiation source.
303
304 Args:
305 direction: Optional direction vector. Can be tuple (x, y, z), vec3, or None for default direction.
306
307 Returns:
308 Source ID
309 """
310 if direction is None:
311 source_id = radiation_wrapper.addCollimatedRadiationSourceDefault(self.radiation_model)
312 else:
313 # Handle vec3, SphericalCoord, and tuple types
314 if hasattr(direction, 'x') and hasattr(direction, 'y') and hasattr(direction, 'z'):
315 # vec3-like object
316 x, y, z = direction.x, direction.y, direction.z
317 elif hasattr(direction, 'radius') and hasattr(direction, 'elevation') and hasattr(direction, 'azimuth'):
318 # SphericalCoord object - convert to Cartesian
319 import math
320 r = direction.radius
321 elevation = direction.elevation
322 azimuth = direction.azimuth
323 x = r * math.cos(elevation) * math.cos(azimuth)
324 y = r * math.cos(elevation) * math.sin(azimuth)
325 z = r * math.sin(elevation)
326 else:
327 # Assume tuple-like object - validate it first
328 try:
329 if len(direction) != 3:
330 raise TypeError(f"Direction must be a 3-element tuple, vec3, or SphericalCoord, got {type(direction).__name__} with {len(direction)} elements")
331 x, y, z = direction
332 except (TypeError, AttributeError):
333 # Not a valid sequence type
334 raise TypeError(f"Direction must be a tuple, vec3, or SphericalCoord, got {type(direction).__name__}")
335 source_id = radiation_wrapper.addCollimatedRadiationSourceVec3(self.radiation_model, x, y, z)
336
337 logger.debug(f"Added collimated radiation source: ID {source_id}")
338 return source_id
339
340 @require_plugin('radiation', 'add spherical radiation source')
341 @validate_sphere_source_params
342 def addSphereRadiationSource(self, position, radius: float) -> int:
343 """
344 Add spherical radiation source.
345
346 Args:
347 position: Position of the source. Can be tuple (x, y, z) or vec3.
348 radius: Radius of the spherical source
349
350 Returns:
351 Source ID
352 """
353 # Handle both tuple and vec3 types
354 if hasattr(position, 'x') and hasattr(position, 'y') and hasattr(position, 'z'):
355 # vec3-like object
356 x, y, z = position.x, position.y, position.z
357 else:
358 # Assume tuple-like object
359 x, y, z = position
360 source_id = radiation_wrapper.addSphereRadiationSource(self.radiation_model, x, y, z, radius)
361 logger.debug(f"Added sphere radiation source: ID {source_id} at ({x}, {y}, {z}) with radius {radius}")
362 return source_id
363
364 @require_plugin('radiation', 'add sun radiation source')
365 @validate_sun_sphere_params
366 def addSunSphereRadiationSource(self, radius: float, zenith: float, azimuth: float,
367 position_scaling: float = 1.0, angular_width: float = 0.53,
368 flux_scaling: float = 1.0) -> int:
369 """
370 Add sun sphere radiation source.
371
372 Args:
373 radius: Radius of the sun sphere
374 zenith: Zenith angle (degrees)
375 azimuth: Azimuth angle (degrees)
376 position_scaling: Position scaling factor
377 angular_width: Angular width of the sun (degrees)
378 flux_scaling: Flux scaling factor
379
380 Returns:
381 Source ID
382 """
383 source_id = radiation_wrapper.addSunSphereRadiationSource(
384 self.radiation_model, radius, zenith, azimuth, position_scaling, angular_width, flux_scaling
385 )
386 logger.debug(f"Added sun radiation source: ID {source_id}")
387 return source_id
388
389 @require_plugin('radiation', 'set ray count')
390 def setDirectRayCount(self, band_label: str, ray_count: int):
391 """Set direct ray count for radiation band."""
392 validate_band_label(band_label, "band_label", "setDirectRayCount")
393 validate_ray_count(ray_count, "ray_count", "setDirectRayCount")
394 radiation_wrapper.setDirectRayCount(self.radiation_model, band_label, ray_count)
395
396 @require_plugin('radiation', 'set ray count')
397 def setDiffuseRayCount(self, band_label: str, ray_count: int):
398 """Set diffuse ray count for radiation band."""
399 validate_band_label(band_label, "band_label", "setDiffuseRayCount")
400 validate_ray_count(ray_count, "ray_count", "setDiffuseRayCount")
401 radiation_wrapper.setDiffuseRayCount(self.radiation_model, band_label, ray_count)
402
403 @require_plugin('radiation', 'set radiation flux')
404 def setDiffuseRadiationFlux(self, label: str, flux: float):
405 """Set diffuse radiation flux for band."""
406 validate_band_label(label, "label", "setDiffuseRadiationFlux")
407 validate_flux_value(flux, "flux", "setDiffuseRadiationFlux")
408 radiation_wrapper.setDiffuseRadiationFlux(self.radiation_model, label, flux)
409
410 @require_plugin('radiation', 'set source flux')
411 def setSourceFlux(self, source_id, label: str, flux: float):
412 """Set source flux for single source or multiple sources."""
413 validate_band_label(label, "label", "setSourceFlux")
414 validate_flux_value(flux, "flux", "setSourceFlux")
415
416 if isinstance(source_id, (list, tuple)):
417 # Multiple sources
418 validate_source_id_list(list(source_id), "source_id", "setSourceFlux")
419 radiation_wrapper.setSourceFluxMultiple(self.radiation_model, source_id, label, flux)
420 else:
421 # Single source
422 validate_source_id(source_id, "source_id", "setSourceFlux")
423 radiation_wrapper.setSourceFlux(self.radiation_model, source_id, label, flux)
424
425
426 @require_plugin('radiation', 'get source flux')
427 @validate_get_source_flux_params
428 def getSourceFlux(self, source_id: int, label: str) -> float:
429 """Get source flux for band."""
430 return radiation_wrapper.getSourceFlux(self.radiation_model, source_id, label)
432 @require_plugin('radiation', 'update geometry')
433 @validate_update_geometry_params
434 def updateGeometry(self, uuids: Optional[List[int]] = None):
435 """
436 Update geometry in radiation model.
437
438 Args:
439 uuids: Optional list of specific UUIDs to update. If None, updates all geometry.
440 """
441 if uuids is None:
442 radiation_wrapper.updateGeometry(self.radiation_model)
443 logger.debug("Updated all geometry in radiation model")
444 else:
445 radiation_wrapper.updateGeometryUUIDs(self.radiation_model, uuids)
446 logger.debug(f"Updated {len(uuids)} geometry UUIDs in radiation model")
447
448 @require_plugin('radiation', 'run radiation simulation')
449 @validate_run_band_params
450 def runBand(self, band_label):
451 """
452 Run radiation simulation for single band or multiple bands.
453
454 PERFORMANCE NOTE: When simulating multiple radiation bands, it is HIGHLY RECOMMENDED
455 to run all bands in a single call (e.g., runBand(["PAR", "NIR", "SW"])) rather than
456 sequential single-band calls. This provides significant computational efficiency gains
457 because:
458
459 - GPU ray tracing setup is done once for all bands
460 - Scene geometry acceleration structures are reused
461 - OptiX kernel launches are batched together
462 - Memory transfers between CPU/GPU are minimized
463
464 Example:
465 # EFFICIENT - Single call for multiple bands
466 radiation.runBand(["PAR", "NIR", "SW"])
467
468 # INEFFICIENT - Sequential single-band calls
469 radiation.runBand("PAR")
470 radiation.runBand("NIR")
471 radiation.runBand("SW")
472
473 Args:
474 band_label: Single band name (str) or list of band names for multi-band simulation
475 """
476 if isinstance(band_label, (list, tuple)):
477 # Multiple bands - validate each label
478 for lbl in band_label:
479 if not isinstance(lbl, str):
480 raise TypeError(f"Band labels must be strings, got {type(lbl).__name__}")
481 radiation_wrapper.runBandMultiple(self.radiation_model, band_label)
482 logger.info(f"Completed radiation simulation for bands: {band_label}")
483 else:
484 # Single band - validate label type
485 if not isinstance(band_label, str):
486 raise TypeError(f"Band label must be a string, got {type(band_label).__name__}")
487 radiation_wrapper.runBand(self.radiation_model, band_label)
488 logger.info(f"Completed radiation simulation for band: {band_label}")
489
490
491 @require_plugin('radiation', 'get simulation results')
492 def getTotalAbsorbedFlux(self) -> List[float]:
493 """Get total absorbed flux for all primitives."""
494 results = radiation_wrapper.getTotalAbsorbedFlux(self.radiation_model)
495 logger.debug(f"Retrieved absorbed flux data for {len(results)} primitives")
496 return results
498 # Configuration methods
499 @require_plugin('radiation', 'configure radiation simulation')
500 @validate_scattering_depth_params
501 def setScatteringDepth(self, label: str, depth: int):
502 """Set scattering depth for radiation band."""
503 radiation_wrapper.setScatteringDepth(self.radiation_model, label, depth)
504
505 @require_plugin('radiation', 'configure radiation simulation')
506 @validate_min_scatter_energy_params
507 def setMinScatterEnergy(self, label: str, energy: float):
508 """Set minimum scatter energy for radiation band."""
509 radiation_wrapper.setMinScatterEnergy(self.radiation_model, label, energy)
510
511 @require_plugin('radiation', 'configure radiation emission')
512 def disableEmission(self, label: str):
513 """Disable emission for radiation band."""
514 validate_band_label(label, "label", "disableEmission")
515 radiation_wrapper.disableEmission(self.radiation_model, label)
516
517 @require_plugin('radiation', 'configure radiation emission')
518 def enableEmission(self, label: str):
519 """Enable emission for radiation band."""
520 validate_band_label(label, "label", "enableEmission")
521 radiation_wrapper.enableEmission(self.radiation_model, label)
522
523 #=============================================================================
524 # Camera and Image Functions (v1.3.47)
525 #=============================================================================
526
527 @require_plugin('radiation', 'add radiation camera')
528 def addRadiationCamera(self, camera_label: str, band_labels: List[str], position, lookat_or_direction,
529 camera_properties=None, antialiasing_samples: int = 100):
530 """
531 Add a radiation camera to the simulation.
532
533 Args:
534 camera_label: Unique label string for the camera
535 band_labels: List of radiation band labels for the camera
536 position: Camera position as vec3 object
537 lookat_or_direction: Either:
538 - Lookat point as vec3 object
539 - SphericalCoord for viewing direction
540 camera_properties: CameraProperties instance or None for defaults
541 antialiasing_samples: Number of antialiasing samples (default: 100)
542
543 Raises:
544 ValidationError: If parameters are invalid or have wrong types
545 RadiationModelError: If camera creation fails
546
547 Example:
548 >>> from pyhelios import vec3, CameraProperties
549 >>> # Create camera looking at origin from above
550 >>> camera_props = CameraProperties(camera_resolution=(1024, 1024))
551 >>> radiation_model.addRadiationCamera("main_camera", ["red", "green", "blue"],
552 ... position=vec3(0, 0, 5), lookat_or_direction=vec3(0, 0, 0),
553 ... camera_properties=camera_props)
554 """
555 # Import here to avoid circular imports
556 from .wrappers import URadiationModelWrapper as radiation_wrapper
557 from .wrappers.DataTypes import SphericalCoord, vec3, make_vec3
558 from .validation.plugins import validate_camera_label, validate_band_labels_list, validate_antialiasing_samples
560 # Validate basic parameters
561 validated_label = validate_camera_label(camera_label, "camera_label", "addRadiationCamera")
562 validated_bands = validate_band_labels_list(band_labels, "band_labels", "addRadiationCamera")
563 validated_samples = validate_antialiasing_samples(antialiasing_samples, "antialiasing_samples", "addRadiationCamera")
564
565 # Validate position (must be vec3)
566 if not (hasattr(position, 'x') and hasattr(position, 'y') and hasattr(position, 'z')):
567 raise TypeError("position must be a vec3 object. Use vec3(x, y, z) to create one.")
568 validated_position = position
569
570 # Validate lookat_or_direction (must be vec3 or SphericalCoord)
571 if hasattr(lookat_or_direction, 'radius') and hasattr(lookat_or_direction, 'elevation'):
572 validated_direction = lookat_or_direction # SphericalCoord
573 elif hasattr(lookat_or_direction, 'x') and hasattr(lookat_or_direction, 'y') and hasattr(lookat_or_direction, 'z'):
574 validated_direction = lookat_or_direction # vec3
575 else:
576 raise TypeError("lookat_or_direction must be a vec3 or SphericalCoord object. Use vec3(x, y, z) or SphericalCoord to create one.")
577
578 # Set up camera properties
579 if camera_properties is None:
580 camera_properties = CameraProperties()
581
582 # Call appropriate wrapper function based on direction type
583 try:
584 if hasattr(validated_direction, 'radius') and hasattr(validated_direction, 'elevation'):
585 # SphericalCoord case
586 direction_coords = validated_direction.to_list()
587 if len(direction_coords) >= 3:
588 # Use only radius, elevation, azimuth (first 3 elements)
589 radius, elevation, azimuth = direction_coords[0], direction_coords[1], direction_coords[2]
590 else:
591 raise ValueError("SphericalCoord must have at least radius, elevation, and azimuth")
592
593 radiation_wrapper.addRadiationCameraSpherical(
594 self.radiation_model,
595 validated_label,
596 validated_bands,
597 validated_position.x, validated_position.y, validated_position.z,
598 radius, elevation, azimuth,
599 camera_properties.to_array(),
600 validated_samples
601 )
602 else:
603 # vec3 case
604 radiation_wrapper.addRadiationCameraVec3(
605 self.radiation_model,
606 validated_label,
607 validated_bands,
608 validated_position.x, validated_position.y, validated_position.z,
609 validated_direction.x, validated_direction.y, validated_direction.z,
610 camera_properties.to_array(),
611 validated_samples
612 )
613
614 except Exception as e:
615 raise RadiationModelError(f"Failed to add radiation camera '{validated_label}': {e}")
616
617 @require_plugin('radiation', 'write camera images')
618 def writeCameraImage(self, camera: str, bands: List[str], imagefile_base: str,
619 image_path: str = "./", frame: int = -1,
620 flux_to_pixel_conversion: float = 1.0) -> str:
621 """
622 Write camera image to file and return output filename.
623
624 Args:
625 camera: Camera label
626 bands: List of band labels to include in the image
627 imagefile_base: Base filename for output
628 image_path: Output directory path (default: current directory)
629 frame: Frame number to write (-1 for all frames)
630 flux_to_pixel_conversion: Conversion factor from flux to pixel values
631
632 Returns:
633 Output filename string
634
635 Raises:
636 RadiationModelError: If camera image writing fails
637 TypeError: If parameters have incorrect types
638 """
639 # Validate inputs
640 if not isinstance(camera, str) or not camera.strip():
641 raise TypeError("Camera label must be a non-empty string")
642 if not isinstance(bands, list) or not bands:
643 raise TypeError("Bands must be a non-empty list of strings")
644 if not all(isinstance(band, str) and band.strip() for band in bands):
645 raise TypeError("All band labels must be non-empty strings")
646 if not isinstance(imagefile_base, str) or not imagefile_base.strip():
647 raise TypeError("Image file base must be a non-empty string")
648 if not isinstance(image_path, str):
649 raise TypeError("Image path must be a string")
650 if not isinstance(frame, int):
651 raise TypeError("Frame must be an integer")
652 if not isinstance(flux_to_pixel_conversion, (int, float)) or flux_to_pixel_conversion <= 0:
653 raise TypeError("Flux to pixel conversion must be a positive number")
654
655 filename = radiation_wrapper.writeCameraImage(
656 self.radiation_model, camera, bands, imagefile_base,
657 image_path, frame, flux_to_pixel_conversion)
658
659 logger.info(f"Camera image written to: {filename}")
660 return filename
661
662 @require_plugin('radiation', 'write normalized camera images')
663 def writeNormCameraImage(self, camera: str, bands: List[str], imagefile_base: str,
664 image_path: str = "./", frame: int = -1) -> str:
665 """
666 Write normalized camera image to file and return output filename.
667
668 Args:
669 camera: Camera label
670 bands: List of band labels to include in the image
671 imagefile_base: Base filename for output
672 image_path: Output directory path (default: current directory)
673 frame: Frame number to write (-1 for all frames)
674
675 Returns:
676 Output filename string
677
678 Raises:
679 RadiationModelError: If normalized camera image writing fails
680 TypeError: If parameters have incorrect types
681 """
682 # Validate inputs
683 if not isinstance(camera, str) or not camera.strip():
684 raise TypeError("Camera label must be a non-empty string")
685 if not isinstance(bands, list) or not bands:
686 raise TypeError("Bands must be a non-empty list of strings")
687 if not all(isinstance(band, str) and band.strip() for band in bands):
688 raise TypeError("All band labels must be non-empty strings")
689 if not isinstance(imagefile_base, str) or not imagefile_base.strip():
690 raise TypeError("Image file base must be a non-empty string")
691 if not isinstance(image_path, str):
692 raise TypeError("Image path must be a string")
693 if not isinstance(frame, int):
694 raise TypeError("Frame must be an integer")
695
696 filename = radiation_wrapper.writeNormCameraImage(
697 self.radiation_model, camera, bands, imagefile_base, image_path, frame)
698
699 logger.info(f"Normalized camera image written to: {filename}")
700 return filename
701
702 @require_plugin('radiation', 'write camera image data')
703 def writeCameraImageData(self, camera: str, band: str, imagefile_base: str,
704 image_path: str = "./", frame: int = -1):
705 """
706 Write camera image data to file (ASCII format).
707
708 Args:
709 camera: Camera label
710 band: Band label
711 imagefile_base: Base filename for output
712 image_path: Output directory path (default: current directory)
713 frame: Frame number to write (-1 for all frames)
714
715 Raises:
716 RadiationModelError: If camera image data writing fails
717 TypeError: If parameters have incorrect types
718 """
719 # Validate inputs
720 if not isinstance(camera, str) or not camera.strip():
721 raise TypeError("Camera label must be a non-empty string")
722 if not isinstance(band, str) or not band.strip():
723 raise TypeError("Band label must be a non-empty string")
724 if not isinstance(imagefile_base, str) or not imagefile_base.strip():
725 raise TypeError("Image file base must be a non-empty string")
726 if not isinstance(image_path, str):
727 raise TypeError("Image path must be a string")
728 if not isinstance(frame, int):
729 raise TypeError("Frame must be an integer")
730
731 radiation_wrapper.writeCameraImageData(
732 self.radiation_model, camera, band, imagefile_base, image_path, frame)
733
734 logger.info(f"Camera image data written for camera {camera}, band {band}")
735
736 @require_plugin('radiation', 'write image bounding boxes')
737 def writeImageBoundingBoxes(self, camera_label: str,
738 primitive_data_labels=None, object_data_labels=None,
739 object_class_ids=None, image_file: str = "",
740 classes_txt_file: str = "classes.txt",
741 image_path: str = "./"):
742 """
743 Write image bounding boxes for object detection training.
744
745 Supports both single and multiple data labels. Either provide primitive_data_labels
746 or object_data_labels, not both.
747
748 Args:
749 camera_label: Camera label
750 primitive_data_labels: Single primitive data label (str) or list of primitive data labels
751 object_data_labels: Single object data label (str) or list of object data labels
752 object_class_ids: Single class ID (int) or list of class IDs (must match data labels)
753 image_file: Image filename
754 classes_txt_file: Classes definition file (default: "classes.txt")
755 image_path: Image output path (default: current directory)
756
757 Raises:
758 RadiationModelError: If bounding box writing fails
759 TypeError: If parameters have incorrect types
760 ValueError: If both primitive and object data labels are provided, or neither
761 """
762 # Validate exclusive parameter usage
763 if primitive_data_labels is not None and object_data_labels is not None:
764 raise ValueError("Cannot specify both primitive_data_labels and object_data_labels")
765 if primitive_data_labels is None and object_data_labels is None:
766 raise ValueError("Must specify either primitive_data_labels or object_data_labels")
767
768 # Validate common parameters
769 if not isinstance(camera_label, str) or not camera_label.strip():
770 raise TypeError("Camera label must be a non-empty string")
771 if not isinstance(image_file, str) or not image_file.strip():
772 raise TypeError("Image file must be a non-empty string")
773 if not isinstance(classes_txt_file, str):
774 raise TypeError("Classes txt file must be a string")
775 if not isinstance(image_path, str):
776 raise TypeError("Image path must be a string")
777
778 # Handle primitive data labels
779 if primitive_data_labels is not None:
780 if isinstance(primitive_data_labels, str):
781 # Single label
782 if not isinstance(object_class_ids, int):
783 raise TypeError("For single primitive data label, object_class_ids must be an integer")
784 radiation_wrapper.writeImageBoundingBoxes(
785 self.radiation_model, camera_label, primitive_data_labels,
786 object_class_ids, image_file, classes_txt_file, image_path)
787 logger.info(f"Image bounding boxes written for primitive data: {primitive_data_labels}")
788
789 elif isinstance(primitive_data_labels, list):
790 # Multiple labels
791 if not isinstance(object_class_ids, list):
792 raise TypeError("For multiple primitive data labels, object_class_ids must be a list")
793 if len(primitive_data_labels) != len(object_class_ids):
794 raise ValueError("primitive_data_labels and object_class_ids must have the same length")
795 if not all(isinstance(lbl, str) and lbl.strip() for lbl in primitive_data_labels):
796 raise TypeError("All primitive data labels must be non-empty strings")
797 if not all(isinstance(cid, int) for cid in object_class_ids):
798 raise TypeError("All object class IDs must be integers")
799
800 radiation_wrapper.writeImageBoundingBoxesVector(
801 self.radiation_model, camera_label, primitive_data_labels,
802 object_class_ids, image_file, classes_txt_file, image_path)
803 logger.info(f"Image bounding boxes written for {len(primitive_data_labels)} primitive data labels")
804 else:
805 raise TypeError("primitive_data_labels must be a string or list of strings")
806
807 # Handle object data labels
808 elif object_data_labels is not None:
809 if isinstance(object_data_labels, str):
810 # Single label
811 if not isinstance(object_class_ids, int):
812 raise TypeError("For single object data label, object_class_ids must be an integer")
813 radiation_wrapper.writeImageBoundingBoxes_ObjectData(
814 self.radiation_model, camera_label, object_data_labels,
815 object_class_ids, image_file, classes_txt_file, image_path)
816 logger.info(f"Image bounding boxes written for object data: {object_data_labels}")
817
818 elif isinstance(object_data_labels, list):
819 # Multiple labels
820 if not isinstance(object_class_ids, list):
821 raise TypeError("For multiple object data labels, object_class_ids must be a list")
822 if len(object_data_labels) != len(object_class_ids):
823 raise ValueError("object_data_labels and object_class_ids must have the same length")
824 if not all(isinstance(lbl, str) and lbl.strip() for lbl in object_data_labels):
825 raise TypeError("All object data labels must be non-empty strings")
826 if not all(isinstance(cid, int) for cid in object_class_ids):
827 raise TypeError("All object class IDs must be integers")
828
829 radiation_wrapper.writeImageBoundingBoxes_ObjectDataVector(
830 self.radiation_model, camera_label, object_data_labels,
831 object_class_ids, image_file, classes_txt_file, image_path)
832 logger.info(f"Image bounding boxes written for {len(object_data_labels)} object data labels")
833 else:
834 raise TypeError("object_data_labels must be a string or list of strings")
835
836 @require_plugin('radiation', 'write image segmentation masks')
837 def writeImageSegmentationMasks(self, camera_label: str,
838 primitive_data_labels=None, object_data_labels=None,
839 object_class_ids=None, json_filename: str = "",
840 image_file: str = "", append_file: bool = False):
841 """
842 Write image segmentation masks in COCO JSON format.
843
844 Supports both single and multiple data labels. Either provide primitive_data_labels
845 or object_data_labels, not both.
846
847 Args:
848 camera_label: Camera label
849 primitive_data_labels: Single primitive data label (str) or list of primitive data labels
850 object_data_labels: Single object data label (str) or list of object data labels
851 object_class_ids: Single class ID (int) or list of class IDs (must match data labels)
852 json_filename: JSON output filename
853 image_file: Image filename
854 append_file: Whether to append to existing JSON file
855
856 Raises:
857 RadiationModelError: If segmentation mask writing fails
858 TypeError: If parameters have incorrect types
859 ValueError: If both primitive and object data labels are provided, or neither
860 """
861 # Validate exclusive parameter usage
862 if primitive_data_labels is not None and object_data_labels is not None:
863 raise ValueError("Cannot specify both primitive_data_labels and object_data_labels")
864 if primitive_data_labels is None and object_data_labels is None:
865 raise ValueError("Must specify either primitive_data_labels or object_data_labels")
866
867 # Validate common parameters
868 if not isinstance(camera_label, str) or not camera_label.strip():
869 raise TypeError("Camera label must be a non-empty string")
870 if not isinstance(json_filename, str) or not json_filename.strip():
871 raise TypeError("JSON filename must be a non-empty string")
872 if not isinstance(image_file, str) or not image_file.strip():
873 raise TypeError("Image file must be a non-empty string")
874 if not isinstance(append_file, bool):
875 raise TypeError("append_file must be a boolean")
876
877 # Handle primitive data labels
878 if primitive_data_labels is not None:
879 if isinstance(primitive_data_labels, str):
880 # Single label
881 if not isinstance(object_class_ids, int):
882 raise TypeError("For single primitive data label, object_class_ids must be an integer")
883 radiation_wrapper.writeImageSegmentationMasks(
884 self.radiation_model, camera_label, primitive_data_labels,
885 object_class_ids, json_filename, image_file, append_file)
886 logger.info(f"Image segmentation masks written for primitive data: {primitive_data_labels}")
887
888 elif isinstance(primitive_data_labels, list):
889 # Multiple labels
890 if not isinstance(object_class_ids, list):
891 raise TypeError("For multiple primitive data labels, object_class_ids must be a list")
892 if len(primitive_data_labels) != len(object_class_ids):
893 raise ValueError("primitive_data_labels and object_class_ids must have the same length")
894 if not all(isinstance(lbl, str) and lbl.strip() for lbl in primitive_data_labels):
895 raise TypeError("All primitive data labels must be non-empty strings")
896 if not all(isinstance(cid, int) for cid in object_class_ids):
897 raise TypeError("All object class IDs must be integers")
898
899 radiation_wrapper.writeImageSegmentationMasksVector(
900 self.radiation_model, camera_label, primitive_data_labels,
901 object_class_ids, json_filename, image_file, append_file)
902 logger.info(f"Image segmentation masks written for {len(primitive_data_labels)} primitive data labels")
903 else:
904 raise TypeError("primitive_data_labels must be a string or list of strings")
905
906 # Handle object data labels
907 elif object_data_labels is not None:
908 if isinstance(object_data_labels, str):
909 # Single label
910 if not isinstance(object_class_ids, int):
911 raise TypeError("For single object data label, object_class_ids must be an integer")
912 radiation_wrapper.writeImageSegmentationMasks_ObjectData(
913 self.radiation_model, camera_label, object_data_labels,
914 object_class_ids, json_filename, image_file, append_file)
915 logger.info(f"Image segmentation masks written for object data: {object_data_labels}")
916
917 elif isinstance(object_data_labels, list):
918 # Multiple labels
919 if not isinstance(object_class_ids, list):
920 raise TypeError("For multiple object data labels, object_class_ids must be a list")
921 if len(object_data_labels) != len(object_class_ids):
922 raise ValueError("object_data_labels and object_class_ids must have the same length")
923 if not all(isinstance(lbl, str) and lbl.strip() for lbl in object_data_labels):
924 raise TypeError("All object data labels must be non-empty strings")
925 if not all(isinstance(cid, int) for cid in object_class_ids):
926 raise TypeError("All object class IDs must be integers")
927
928 radiation_wrapper.writeImageSegmentationMasks_ObjectDataVector(
929 self.radiation_model, camera_label, object_data_labels,
930 object_class_ids, json_filename, image_file, append_file)
931 logger.info(f"Image segmentation masks written for {len(object_data_labels)} object data labels")
932 else:
933 raise TypeError("object_data_labels must be a string or list of strings")
934
935 @require_plugin('radiation', 'auto-calibrate camera image')
936 def autoCalibrateCameraImage(self, camera_label: str, red_band_label: str,
937 green_band_label: str, blue_band_label: str,
938 output_file_path: str, print_quality_report: bool = False,
939 algorithm: str = "MATRIX_3X3_AUTO",
940 ccm_export_file_path: str = "") -> str:
941 """
942 Auto-calibrate camera image with color correction and return output filename.
943
944 Args:
945 camera_label: Camera label
946 red_band_label: Red band label
947 green_band_label: Green band label
948 blue_band_label: Blue band label
949 output_file_path: Output file path
950 print_quality_report: Whether to print quality report
951 algorithm: Color correction algorithm ("DIAGONAL_ONLY", "MATRIX_3X3_AUTO", "MATRIX_3X3_FORCE")
952 ccm_export_file_path: Path to export color correction matrix (optional)
953
954 Returns:
955 Output filename string
956
957 Raises:
958 RadiationModelError: If auto-calibration fails
959 TypeError: If parameters have incorrect types
960 ValueError: If algorithm is not valid
961 """
962 # Validate inputs
963 if not isinstance(camera_label, str) or not camera_label.strip():
964 raise TypeError("Camera label must be a non-empty string")
965 if not isinstance(red_band_label, str) or not red_band_label.strip():
966 raise TypeError("Red band label must be a non-empty string")
967 if not isinstance(green_band_label, str) or not green_band_label.strip():
968 raise TypeError("Green band label must be a non-empty string")
969 if not isinstance(blue_band_label, str) or not blue_band_label.strip():
970 raise TypeError("Blue band label must be a non-empty string")
971 if not isinstance(output_file_path, str) or not output_file_path.strip():
972 raise TypeError("Output file path must be a non-empty string")
973 if not isinstance(print_quality_report, bool):
974 raise TypeError("print_quality_report must be a boolean")
975 if not isinstance(ccm_export_file_path, str):
976 raise TypeError("ccm_export_file_path must be a string")
977
978 # Map algorithm string to integer (using MATRIX_3X3_AUTO = 1 as default)
979 algorithm_map = {
980 "DIAGONAL_ONLY": 0,
981 "MATRIX_3X3_AUTO": 1,
982 "MATRIX_3X3_FORCE": 2
983 }
984
985 if algorithm not in algorithm_map:
986 raise ValueError(f"Invalid algorithm: {algorithm}. Must be one of: {list(algorithm_map.keys())}")
987
988 algorithm_int = algorithm_map[algorithm]
989
990 filename = radiation_wrapper.autoCalibrateCameraImage(
991 self.radiation_model, camera_label, red_band_label, green_band_label,
992 blue_band_label, output_file_path, print_quality_report,
993 algorithm_int, ccm_export_file_path)
994
995 logger.info(f"Auto-calibrated camera image written to: {filename}")
996 return filename
997
998 def getPluginInfo(self) -> dict:
999 """Get information about the radiation plugin."""
1000 registry = get_plugin_registry()
1001 return registry.get_plugin_capabilities('radiation')
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=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.
setDirectRayCount(self, str band_label, int ray_count)
Set direct ray count for radiation band.
disableMessages(self)
Disable RadiationModel status messages.
runBand(self, band_label)
Run radiation simulation for single band or multiple bands.
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.
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.
copyRadiationBand(self, str old_label, str new_label)
Copy existing radiation band to new label.
enableMessages(self)
Enable RadiationModel status messages.
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.
setDiffuseRayCount(self, str band_label, int ray_count)
Set diffuse ray count for radiation band.
float getSourceFlux(self, int source_id, str label)
Get source flux for band.
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.
disableEmission(self, str label)
Disable emission for radiation band.
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.
__init__(self, Context context)
Initialize RadiationModel with graceful plugin handling.
__enter__(self)
Context manager entry.
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.
setMinScatterEnergy(self, str label, float energy)
Set minimum scatter energy for radiation 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.
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.
getNativePtr(self)
Get native pointer for advanced operations.
_radiation_working_directory()
Context manager that temporarily changes working directory to where RadiationModel assets are located...