0.1.33
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
9import math
10import tempfile
11from typing import Dict, List, Optional, Union
12from contextlib import contextmanager
13from pathlib import Path
14import os
15
16import numpy as np
17
18from .plugins.registry import get_plugin_registry, require_plugin, graceful_plugin_fallback
19from .wrappers import URadiationModelWrapper as radiation_wrapper
20# Bulk primitive-data reads go straight to the Context wrapper: the band results
21# live in Context primitive data, and getAbsorbedFlux has already validated the
22# UUIDs and probed the label, so Context.getPrimitiveDataArray() would only repeat
23# that work.
24from .wrappers import UContextWrapper as context_wrapper
25from .validation.plugins import (
26 validate_wavelength_range, validate_flux_value, validate_ray_count,
27 validate_direction_vector, validate_band_label, validate_source_id, validate_source_id_list,
28 validate_position_like, validate_direction_like, validate_size_like
29)
30from .validation.plugin_decorators import (
31 validate_radiation_band_params, validate_collimated_source_params, validate_sphere_source_params,
32 validate_sun_sphere_params, validate_get_source_flux_params,
33 validate_update_geometry_params, validate_run_band_params, validate_scattering_depth_params,
34 validate_min_scatter_energy_params
35)
36from .Context import Context, check_context_alive
37from .assets import get_asset_manager
38
39logger = logging.getLogger(__name__)
40
41
42@contextmanager
44 """
45 Context manager that temporarily changes working directory to where RadiationModel assets are located.
46
47 RadiationModel C++ code uses hardcoded relative paths like "plugins/radiation/" for GPU
48 backend files (SPIR-V shaders for Vulkan, PTX files for OptiX), expecting assets relative
49 to working directory. This manager temporarily changes to the build directory where assets
50 are actually located.
51
52 Raises:
53 RuntimeError: If build directory or RadiationModel assets are not found, indicating a build system error.
54 """
55 # Find the build directory containing RadiationModel assets
56 # Try asset manager first (works for both development and wheel installations)
57 asset_manager = get_asset_manager()
58 working_dir = asset_manager._get_helios_build_path()
59
60 if working_dir and working_dir.exists():
61 radiation_assets = working_dir / 'plugins' / 'radiation'
62 else:
63 # For wheel installations, check packaged assets
64 current_dir = Path(__file__).parent
65 packaged_build = current_dir / 'assets' / 'build'
66
67 if packaged_build.exists():
68 working_dir = packaged_build
69 radiation_assets = working_dir / 'plugins' / 'radiation'
70 else:
71 # Fallback to development paths
72 repo_root = current_dir.parent
73 build_lib_dir = repo_root / 'pyhelios_build' / 'build' / 'lib'
74 working_dir = build_lib_dir.parent
75 radiation_assets = working_dir / 'plugins' / 'radiation'
76
77 if not build_lib_dir.exists():
78 raise RuntimeError(
79 f"PyHelios build directory not found at {build_lib_dir}. "
80 f"Run: python build_scripts/build_helios.py --plugins radiation"
81 )
82
83 if not radiation_assets.exists():
84 raise RuntimeError(
85 f"RadiationModel assets not found at {radiation_assets}. "
86 f"This indicates a build system error. The build script should copy shader/backend files to this location."
87 )
88
89 # Change to the build directory temporarily
90 original_dir = os.getcwd()
91 try:
92 os.chdir(working_dir)
93 logger.debug(f"Changed working directory to {working_dir} for RadiationModel asset access")
94 yield working_dir
95 finally:
96 os.chdir(original_dir)
97 logger.debug(f"Restored working directory to {original_dir}")
98
99
100class RadiationModelError(Exception):
101 """Raised when RadiationModel operations fail."""
102 pass
104
105class CameraProperties:
106 """
107 Camera properties for radiation model cameras.
108
109 This class encapsulates the properties needed to configure a radiation camera,
110 providing sensible defaults and validation for camera parameters. Carries the
111 v1.3.60 ``camera_zoom`` and v1.3.85 ``exposure_target`` fields.
112 """
113
114 def __init__(self, camera_resolution=None, focal_plane_distance=1.0, lens_diameter=0.05,
115 HFOV=20.0, FOV_aspect_ratio=0.0, lens_focal_length=0.05,
116 sensor_width_mm=35.0, manufacturer="", model="generic", lens_make="",
117 lens_model="", lens_specification="", exposure="auto", shutter_speed=1.0/125.0,
118 white_balance="auto", camera_zoom=1.0, exposure_target=0.18):
119 """
120 Initialize camera properties with defaults matching C++ CameraProperties.
121
122 Args:
123 camera_resolution: Camera resolution as (width, height) tuple or list. Default: (512, 512)
124 focal_plane_distance: Distance from viewing plane to focal plane (working distance). Default: 1.0
125 lens_diameter: Diameter of camera lens (0 = pinhole camera). Default: 0.05
126 HFOV: Horizontal field of view in degrees. Default: 20.0
127 FOV_aspect_ratio: Ratio of horizontal to vertical FOV. Default: 0.0 (auto-calculate from resolution)
128 lens_focal_length: Camera lens optical focal length in meters (physical, not 35mm equiv). Default: 0.05 (50mm)
129 sensor_width_mm: Physical sensor width in mm. Default: 35.0 (full-frame)
130 manufacturer: Camera manufacturer (e.g., "Canon", "Nikon", "Apple"). In helios-core
131 v1.3.73 this maps to the EXIF Make tag (empty ⇒ "Helios"). Default: ""
132 model: Camera model name (e.g., "Nikon D700", "Canon EOS 5D"). Default: "generic"
133 lens_make: Lens manufacturer (e.g., "Canon", "Nikon"). Default: ""
134 lens_model: Lens model name (e.g., "AF-S NIKKOR 50mm f/1.8G"). Default: ""
135 lens_specification: Lens specification (e.g., "50mm f/1.8"). Default: ""
136 exposure: Exposure mode - "auto", "ISOXXX" (e.g., "ISO100"), or "manual". Default: "auto"
137 shutter_speed: Camera shutter speed in seconds (e.g., 0.008 for 1/125s). Default: 0.008 (1/125s)
138 white_balance: White balance mode - "auto" or "off". Default: "auto".
139 "auto" scales the camera's first three bands (red, green, blue) so that a
140 spectrally flat surface renders neutral under the light actually reaching the
141 surfaces in view, keeping the brightest band at unit gain. A scene lit by a
142 spectrally flat source is therefore left unchanged. If the camera sees no
143 light in one of those bands -- only sky, for instance -- the image is left
144 unbalanced and the reason is reported as a warning.
145 camera_zoom: Camera optical zoom multiplier. 1.0 = no zoom, 2.0 = 2x zoom.
146 Scales effective HFOV: effective_HFOV = HFOV / camera_zoom. Default: 1.0
147 exposure_target: Target median scene luminance for ``"auto"`` exposure, as a
148 fraction of full scale (helios-core v1.3.85+). Auto exposure scales every
149 band so the median pixel luminance lands here. The default 0.18 is the
150 middle-grey convention, which assumes a scene of grey-card mean
151 reflectance; a canopy imaged from above is darker, so matching a real
152 camera may need a lower value. Ignored for ``"manual"`` and ``"ISOXXX"``
153 exposure. Must be > 0. Default: 0.18
154 """
155 # Set camera resolution with validation
156
157 if camera_resolution is None:
158 self.camera_resolution = (512, 512)
159 else:
160 if isinstance(camera_resolution, (list, tuple)) and len(camera_resolution) == 2:
161 self.camera_resolution = (int(camera_resolution[0]), int(camera_resolution[1]))
162 else:
163 raise ValueError("camera_resolution must be a tuple or list of 2 integers")
164
165
166 # Validate and set numeric properties
167 if focal_plane_distance <= 0:
168 raise ValueError("focal_plane_distance must be greater than 0")
169 if lens_diameter < 0:
170 raise ValueError("lens_diameter must be non-negative")
171 if HFOV <= 0 or HFOV > 180:
172 raise ValueError("HFOV must be between 0 and 180 degrees")
173 if FOV_aspect_ratio < 0:
174 raise ValueError("FOV_aspect_ratio must be non-negative (0 = auto-calculate)")
175 if lens_focal_length <= 0:
176 raise ValueError("lens_focal_length must be greater than 0")
177 if sensor_width_mm <= 0:
178 raise ValueError("sensor_width_mm must be greater than 0")
179 if shutter_speed <= 0:
180 raise ValueError("shutter_speed must be greater than 0")
181 if camera_zoom <= 0:
182 raise ValueError("camera_zoom must be greater than 0")
183 if exposure_target <= 0:
184 raise ValueError("exposure_target must be greater than 0")
185
186 self.focal_plane_distance = float(focal_plane_distance)
187 self.lens_diameter = float(lens_diameter)
188 self.HFOV = float(HFOV)
189 self.FOV_aspect_ratio = float(FOV_aspect_ratio)
190 self.lens_focal_length = float(lens_focal_length)
191 self.sensor_width_mm = float(sensor_width_mm)
192 self.shutter_speed = float(shutter_speed)
193 self.camera_zoom = float(camera_zoom)
194 self.exposure_target = float(exposure_target)
196 # Validate and set string properties
197 if not isinstance(manufacturer, str):
198 raise ValueError("manufacturer must be a string")
199 if not isinstance(model, str):
200 raise ValueError("model must be a string")
201 if not isinstance(lens_make, str):
202 raise ValueError("lens_make must be a string")
203 if not isinstance(lens_model, str):
204 raise ValueError("lens_model must be a string")
205 if not isinstance(lens_specification, str):
206 raise ValueError("lens_specification must be a string")
207 if not isinstance(exposure, str):
208 raise ValueError("exposure must be a string")
209 if not isinstance(white_balance, str):
210 raise ValueError("white_balance must be a string")
211
212 # Validate exposure mode
213 if exposure not in ["auto", "manual"] and not exposure.startswith("ISO"):
214 raise ValueError("exposure must be 'auto', 'manual', or 'ISOXXX' (e.g., 'ISO100')")
215
216 # Validate white balance mode
217 if white_balance not in ["auto", "off"]:
218 raise ValueError("white_balance must be 'auto' or 'off'")
219
220 self.manufacturer = str(manufacturer)
221 self.model = str(model)
222 self.lens_make = str(lens_make)
223 self.lens_model = str(lens_model)
224 self.lens_specification = str(lens_specification)
225 self.exposure = str(exposure)
226 self.white_balance = str(white_balance)
228 def to_array(self):
229 """
230 Convert to array format expected by C++ interface.
231
232 Note: Returns numeric fields only. String fields (model, lens_make, etc.) are
233 currently initialized with defaults in the C++ wrapper and cannot be set via
234 this interface. Use the upcoming camera library methods for full metadata control.
235
236 Returns:
237 List of 11 float values: [resolution_x, resolution_y, focal_distance, lens_diameter,
238 HFOV, FOV_aspect_ratio, lens_focal_length, sensor_width_mm,
239 shutter_speed, camera_zoom, exposure_target]
240 """
241 return [
242 float(self.camera_resolution[0]), # resolution_x
243 float(self.camera_resolution[1]), # resolution_y
245 self.lens_diameter,
246 self.HFOV,
247 self.FOV_aspect_ratio,
249 self.sensor_width_mm,
250 self.shutter_speed,
251 self.camera_zoom,
252 self.exposure_target
253 ]
254
255 def __repr__(self):
256 return (f"CameraProperties("
257 f"camera_resolution={self.camera_resolution}, "
258 f"focal_plane_distance={self.focal_plane_distance}, "
259 f"lens_diameter={self.lens_diameter}, "
260 f"HFOV={self.HFOV}, "
261 f"FOV_aspect_ratio={self.FOV_aspect_ratio}, "
262 f"lens_focal_length={self.lens_focal_length}, "
263 f"sensor_width_mm={self.sensor_width_mm}, "
264 f"manufacturer='{self.manufacturer}', "
265 f"model='{self.model}', "
266 f"lens_make='{self.lens_make}', "
267 f"lens_model='{self.lens_model}', "
268 f"lens_specification='{self.lens_specification}', "
269 f"exposure='{self.exposure}', "
270 f"shutter_speed={self.shutter_speed}, "
271 f"white_balance='{self.white_balance}', "
272 f"camera_zoom={self.camera_zoom}, "
273 f"exposure_target={self.exposure_target})")
274
275
277 """
278 Camera properties for a solar-induced chlorophyll fluorescence (SIF) camera.
279
280 Extends :class:`CameraProperties` with two SIF-specific fields used by the
281 Fluspect-B emission pipeline introduced in helios-core v1.3.72. Image geometry,
282 resolution, exposure, and spectral-response handling are inherited from
283 :class:`CameraProperties` unchanged.
284
285 Attributes:
286 excitation_bin_width_nm: Excitation wavelength bin width in nm. Helios
287 auto-creates internal radiation bands spanning 400–750 nm at this
288 resolution to compute per-leaf APAR. Must be > 0. Default 10.0.
289 excitation_scattering_depth: Scattering depth for the auto-generated
290 excitation bands. ``0`` (default) treats every leaf hit as fully
291 absorbed. Set to ``>=1`` to include inter-leaf scattering at the
292 cost of additional excitation-band ray traces.
293
294 Note:
295 String fields inherited from :class:`CameraProperties` (``model``,
296 ``lens_make``, ``lens_model``, ``lens_specification``, ``exposure``,
297 ``white_balance``) are passed through to the C++ camera, so they affect
298 rendering and EXIF metadata just as they do for ``addRadiationCamera``.
299 """
300
301 def __init__(self, excitation_bin_width_nm: float = 10.0,
302 excitation_scattering_depth: int = 0,
303 **kwargs):
304 super().__init__(**kwargs)
305 if excitation_bin_width_nm <= 0:
306 raise ValueError("excitation_bin_width_nm must be greater than 0")
307 if excitation_scattering_depth < 0:
308 raise ValueError("excitation_scattering_depth must be >= 0")
309 self.excitation_bin_width_nm = float(excitation_bin_width_nm)
310 self.excitation_scattering_depth = int(excitation_scattering_depth)
311
312 def __repr__(self):
313 base = super().__repr__()
314 # Drop the trailing ')' from the parent repr and append SIF-specific fields.
315 return (
316 base[:-1]
317 + f", excitation_bin_width_nm={self.excitation_bin_width_nm}"
318 + f", excitation_scattering_depth={self.excitation_scattering_depth})"
319 )
321
322class CameraMetadata:
323 """
324 Metadata for radiation camera image export (Helios v1.3.58+).
325
326 This class encapsulates comprehensive metadata for camera images including
327 camera properties, location, acquisition settings, image processing, and
328 agronomic properties derived from plant architecture data.
329 """
330
331 class CameraPropertiesMetadata:
332 """Camera intrinsic properties for metadata export."""
333 def __init__(self, height=512, width=512, channels=3, type="rgb",
334 focal_length=50.0, aperture="f/2.8", sensor_width=35.0,
335 sensor_height=24.0, model="generic", lens_make="",
336 lens_model="", lens_specification="", exposure="auto",
337 shutter_speed=0.008, white_balance="auto"):
338 self.height = int(height)
339 self.width = int(width)
340 self.channels = int(channels)
341 self.type = str(type)
342 self.focal_length = float(focal_length)
343 self.aperture = str(aperture)
344 self.sensor_width = float(sensor_width)
345 self.sensor_height = float(sensor_height)
346 self.model = str(model)
347 self.lens_make = str(lens_make)
348 self.lens_model = str(lens_model)
349 self.lens_specification = str(lens_specification)
350 self.exposure = str(exposure)
351 self.shutter_speed = float(shutter_speed)
352 self.white_balance = str(white_balance)
355 """Geographic location properties."""
356 def __init__(self, latitude=0.0, longitude=0.0):
357 self.latitude = float(latitude)
358 self.longitude = float(longitude)
361 """Image acquisition properties."""
362 def __init__(self, date="", time="", UTC_offset=0.0, camera_height_m=0.0,
363 camera_angle_deg=0.0, light_source="sunlight"):
364 self.date = str(date)
365 self.time = str(time)
366 self.UTC_offset = float(UTC_offset)
367 self.camera_height_m = float(camera_height_m)
368 self.camera_angle_deg = float(camera_angle_deg)
369 self.light_source = str(light_source)
372 """Image processing corrections applied to the image."""
373 def __init__(self, saturation_adjustment=1.0, brightness_adjustment=1.0,
374 contrast_adjustment=1.0, color_space="linear"):
375 self.saturation_adjustment = float(saturation_adjustment)
376 self.brightness_adjustment = float(brightness_adjustment)
377 self.contrast_adjustment = float(contrast_adjustment)
378 self.color_space = str(color_space)
379
381 """Agronomic properties derived from plant architecture data."""
382 def __init__(self, plant_species=None, plant_count=None, plant_height_m=None,
383 plant_age_days=None, plant_stage=None, leaf_area_m2=None,
384 weed_pressure=""):
385 self.plant_species = plant_species if plant_species is not None else []
386 self.plant_count = plant_count if plant_count is not None else []
387 self.plant_height_m = plant_height_m if plant_height_m is not None else []
388 self.plant_age_days = plant_age_days if plant_age_days is not None else []
389 self.plant_stage = plant_stage if plant_stage is not None else []
390 self.leaf_area_m2 = leaf_area_m2 if leaf_area_m2 is not None else []
391 self.weed_pressure = str(weed_pressure)
392
393 def __init__(self, path=""):
394 """
395 Initialize CameraMetadata with default values.
397 Args:
398 path: Full path to the associated image file. Default: ""
399 """
400 self.path = str(path)
401 self.camera_properties = self.CameraPropertiesMetadata()
402 self.location_properties = self.LocationProperties()
403 self.acquisition_properties = self.AcquisitionProperties()
404 self.image_processing = self.ImageProcessingProperties()
405 self.agronomic_properties = self.AgronomicProperties()
406
407 def __repr__(self):
408 return (f"CameraMetadata(path='{self.path}', "
409 f"camera={self.camera_properties.model}, "
410 f"resolution={self.camera_properties.width}x{self.camera_properties.height}, "
411 f"location=({self.location_properties.latitude},{self.location_properties.longitude}))")
414class RadiationModel:
415 """
416 High-level interface for radiation modeling and ray tracing.
417
418 This class provides a user-friendly wrapper around the native Helios
419 radiation plugin with automatic plugin availability checking and
420 graceful error handling.
421 """
422
423 def __init__(self, context: Context):
424 """
425 Initialize RadiationModel with graceful plugin handling.
426
427 Args:
428 context: Helios Context instance
430 Raises:
431 TypeError: If context is not a Context instance
432 RadiationModelError: If radiation plugin is not available
433 """
434 # Validate context type
435 if not isinstance(context, Context):
436 raise TypeError(f"RadiationModel requires a Context instance, got {type(context).__name__}")
437
438 self.context = context
439 self.radiation_model = None
440 # Tracks whether scene geometry has been pushed to the radiation model, either
441 # by updateGeometry() or by runBand() building it automatically. Some queries
442 # (e.g. calculateGtheta) silently return NaN from the native layer when no
443 # geometry is loaded; this flag lets us auto-update or fail with an actionable
444 # message instead.
445 self._geometry_updated = False
446 # Tracks whether the last build was a user-specified subset (updateGeometry(uuids)).
447 # A subset must never be silently replaced by a full-Context build, so the
448 # auto-update paths below refuse to do so.
449 self._geometry_subset = False
450
451 # Check plugin availability using registry
452 registry = get_plugin_registry()
454 if not registry.is_plugin_available('radiation'):
455 # Get helpful information about the missing plugin
456 plugin_info = registry.get_plugin_capabilities()
457 available_plugins = registry.get_available_plugins()
458
459 error_msg = (
460 "RadiationModel requires the 'radiation' plugin which is not available.\n\n"
461 "The radiation plugin provides GPU-accelerated ray tracing with runtime\n"
462 "backend auto-detection (OptiX 8 -> OptiX 6 -> Vulkan).\n"
463 "System requirements (at least one backend):\n"
464 "- Vulkan: Vulkan loader library (macOS/Linux); no extra packages on Windows\n"
465 "- OptiX 8.1: NVIDIA GPU with driver >= 560 and CUDA 12.0+\n"
466 "- OptiX 6.5: NVIDIA GPU with driver < 560 and CUDA 9.0+\n\n"
467 "To enable radiation modeling:\n"
468 "1. Build PyHelios with radiation plugin:\n"
469 " build_scripts/build_helios --plugins radiation\n"
470 "2. Or build with multiple plugins:\n"
471 " build_scripts/build_helios --plugins radiation,visualizer,weberpenntree\n"
472 f"\nCurrently available plugins: {available_plugins}"
473 )
474
475 # Suggest alternatives if available
476 alternatives = registry.suggest_alternatives('radiation')
477 if alternatives:
478 error_msg += f"\n\nAlternative plugins available: {alternatives}"
479 error_msg += "\nConsider using energybalance or leafoptics for thermal modeling."
480
481 raise RadiationModelError(error_msg)
482
483 # Plugin is available - create radiation model using working directory context manager
484 try:
486 self.radiation_model = radiation_wrapper.createRadiationModel(context.getNativePtr())
487 if self.radiation_model is None:
489 "Failed to create RadiationModel instance. "
490 "This may indicate a problem with the native library or GPU initialization."
491 )
492 logger.info("RadiationModel created successfully")
493
494 # Bands created with explicit wavelength bounds. Tracked so cameras
495 # can warn about the bounded-band + spectral-response combination
496 # (see _warn_if_bounded_bands_with_camera_response).
497 self._bounded_bands = set()
498
499 except Exception as e:
500 raise RadiationModelError(f"Failed to initialize RadiationModel: {e}")
501
502 def _check_context_alive(self):
503 """Raise if the owning Context has been destroyed (see Context.check_context_alive)."""
504 check_context_alive(getattr(self, "context", None), "RadiationModel")
506 def _warn_if_bounded_bands_with_camera_response(self, camera_label: str,
507 band_labels: List[str],
508 operation: str):
509 """Warn when bands have explicit wavelength bounds and the camera has a
510 spectral response.
511
512 The two combined silently produce wrong colors. Helios computes camera
513 pixels by integrating surface reflectance against the camera's spectral
514 response curve, but computes SCATTERED flux by integrating over the
515 band's wavelength bounds instead. When bounds are absent Helios
516 deliberately falls back to the camera-weighted average for scattering,
517 so both paths agree; setting bounds breaks that agreement.
518
519 With scattering enabled most camera-visible light is scattered, so the
520 image carries band-limited color the camera never expected. Low-chroma
521 natural surfaces are hit hardest: a measured soil spectrum whose true
522 red/green ratio is 1.2 renders at 2.6 -- brown soil comes out pink --
523 while saturated colorboard-style spectra survive and look fine, which
524 makes the problem easy to miss.
525
526 Nothing errors and no output looks obviously broken, so this is a
527 warning at the point the camera is created rather than a failure.
528 """
529 bounded = [b for b in band_labels if b in getattr(self, "_bounded_bands", ())]
530 if not bounded:
531 return
532
533 logger.warning(
534 f"Camera '{camera_label}' ({operation}) uses a spectral response, but "
535 f"band(s) {bounded} were created with explicit wavelength bounds. "
536 f"Camera pixels integrate reflectance against the spectral response "
537 f"while scattered flux integrates over the band bounds, so rendered "
538 f"colors will be skewed (brown surfaces tend to render pink). Create "
539 f"these bands without wavelength_min/wavelength_max -- the camera's "
540 f"spectral response defines what each band measures."
541 )
542
543 def _check_camera_has_pixel_data(self, camera: str, bands: List[str], operation: str):
544 """Raise an actionable error if a camera/band has no rendered pixel data.
545
546 A camera's pixel data is populated only by ``runBand()``, and only for
547 the bands passed to that call and for cameras that already existed when
548 it ran. Requesting an image for an unrendered camera/band otherwise
549 reaches the native layer as a bare ``invalid map<K, T> key`` from
550 ``std::map::at`` -- see GitHub issue #4. This preflight turns that into a
551 message naming the camera, the band, and the call the user is missing.
552
553 This check is load-bearing rather than merely cosmetic, and must not be
554 removed on the grounds that helios-core v1.3.79 addressed the same case
555 upstream. The upstream change makes the native call return an empty
556 filename instead of throwing, so without this preflight an unrendered
557 camera would look like a successful write that produced no file.
558 :meth:`_check_image_was_written` is the backstop for the failure modes
559 this preflight cannot see, such as an unwritable output directory.
560 """
561 try:
562 known_cameras = radiation_wrapper.getAllCameraLabels(self.radiation_model)
563 except Exception:
564 # Camera enumeration is only used to sharpen the message. If it is
565 # unavailable, fall through and let the per-band probe report.
566 known_cameras = None
567
568 if known_cameras is not None and camera not in known_cameras:
570 f"Cannot {operation}: camera '{camera}' does not exist. "
571 f"Add it with addRadiationCamera() before calling runBand(). "
572 f"Existing cameras: {sorted(known_cameras) if known_cameras else 'none'}"
573 )
575 for band in bands:
576 try:
577 radiation_wrapper.getCameraPixelData(self.radiation_model, camera, band)
578 except Exception:
580 f"Cannot {operation}: camera '{camera}' has no rendered pixel data "
581 f"for band '{band}'. Call runBand() with this band after adding the "
582 f"camera -- e.g. runBand({list(bands)!r}). Note that runBand() only "
583 f"renders the bands passed to it, and only for cameras that already "
584 f"exist when it runs."
585 ) from None
586
587 def _check_image_was_written(self, filename: str, camera: str, bands: List[str],
588 image_path: str, operation: str):
589 """Raise if the native layer reported a failed write via an empty filename.
590
591 helios-core v1.3.79 changed ``writeCameraImage``/``writeNormCameraImage`` to
592 return an empty string on failure rather than throwing, so no exception and no
593 error code reaches Python. Returning that empty string to the caller -- or
594 logging it as "written to: " -- would present a write that produced no file as
595 a success, which the fail-fast policy forbids.
596
597 :meth:`_check_camera_has_pixel_data` already rejects the unrendered-camera and
598 unrendered-band cases before the native call, so by the time an empty filename
599 gets here the likeliest cause is an output directory that does not exist or
600 cannot be written.
601 """
602 if filename:
603 return
604
606 f"Cannot {operation}: the native library reported a failed write for camera "
607 f"'{camera}' (bands {list(bands)!r}) by returning an empty filename, so no "
608 f"image file was created. The camera has rendered pixel data, so the most "
609 f"likely cause is that the output directory '{image_path}' does not exist "
610 f"or is not writable. Verify the path exists and is writable."
611 )
612
613 def __enter__(self):
614 """Context manager entry."""
615 return self
617 def __exit__(self, exc_type, exc_value, traceback):
618 """Context manager exit with proper cleanup."""
619 if self.radiation_model is not None:
620 try:
621 radiation_wrapper.destroyRadiationModel(self.radiation_model)
622 logger.debug("RadiationModel destroyed successfully")
623 except Exception as e:
624 logger.warning(f"Error destroying RadiationModel: {e}")
625 finally:
626 self.radiation_model = None # Prevent double deletion
627
628 def __del__(self):
629 """Destructor to ensure GPU resources freed even without 'with' statement."""
630 if hasattr(self, 'radiation_model') and self.radiation_model is not None:
631 try:
632 radiation_wrapper.destroyRadiationModel(self.radiation_model)
633 self.radiation_model = None
634 except Exception as e:
635 import warnings
636 warnings.warn(f"Error in RadiationModel.__del__: {e}")
637
638 def get_native_ptr(self):
639 """Get native pointer for advanced operations."""
640 return self.radiation_model
641
642 def getNativePtr(self):
643 """Get native pointer for advanced operations. (Legacy naming for compatibility)"""
644 return self.get_native_ptr()
646 @require_plugin('radiation', 'disable status messages')
647 def disableMessages(self):
648 """Disable RadiationModel status messages."""
650 radiation_wrapper.disableMessages(self.radiation_model)
651
652 @require_plugin('radiation', 'enable status messages')
653 def enableMessages(self):
654 """Enable RadiationModel status messages."""
656 radiation_wrapper.enableMessages(self.radiation_model)
657
658 @require_plugin('radiation', 'add radiation band')
659 def addRadiationBand(self, band_label: str, wavelength_min: float = None, wavelength_max: float = None):
660 """
661 Add radiation band with optional wavelength bounds.
662
663 Args:
664 band_label: Name/label for the radiation band
665 wavelength_min: Optional minimum wavelength (nm)
666 wavelength_max: Optional maximum wavelength (nm)
667 """
668 # Validate inputs
669 validate_band_label(band_label, "band_label", "addRadiationBand")
670 if wavelength_min is not None and wavelength_max is not None:
671 validate_wavelength_range(wavelength_min, wavelength_max, "wavelength_min", "wavelength_max", "addRadiationBand")
673 radiation_wrapper.addRadiationBandWithWavelengths(self.radiation_model, band_label, wavelength_min, wavelength_max)
674 self._bounded_bands.add(band_label)
675 logger.debug(f"Added radiation band {band_label}: {wavelength_min}-{wavelength_max} nm")
676 else:
678 radiation_wrapper.addRadiationBand(self.radiation_model, band_label)
679 self._bounded_bands.discard(band_label)
680 logger.debug(f"Added radiation band: {band_label}")
681
682 @require_plugin('radiation', 'copy radiation band')
683 @validate_radiation_band_params
684 def copyRadiationBand(self, old_label: str, new_label: str, wavelength_min: float = None, wavelength_max: float = None):
685 """
686 Copy existing radiation band to new label, optionally with new wavelength range.
687
688 Args:
689 old_label: Existing band label to copy
690 new_label: New label for the copied band
691 wavelength_min: Optional minimum wavelength for new band (nm)
692 wavelength_max: Optional maximum wavelength for new band (nm)
693
694 Example:
695 >>> # Copy band with same wavelength range
696 >>> radiation.copyRadiationBand("SW", "SW_copy")
697 >>>
698 >>> # Copy band with different wavelength range
699 >>> radiation.copyRadiationBand("full_spectrum", "PAR", 400, 700)
700 """
701 if wavelength_min is not None and wavelength_max is not None:
702 validate_wavelength_range(wavelength_min, wavelength_max, "wavelength_min", "wavelength_max", "copyRadiationBand")
703
705 radiation_wrapper.copyRadiationBand(self.radiation_model, old_label, new_label, wavelength_min, wavelength_max)
706 if wavelength_min is not None:
707 self._bounded_bands.add(new_label)
708 logger.debug(f"Copied radiation band {old_label} to {new_label} with wavelengths {wavelength_min}-{wavelength_max} nm")
709 else:
710 # Without explicit bounds the copy inherits the source band's bounds.
711 if old_label in self._bounded_bands:
712 self._bounded_bands.add(new_label)
713 else:
714 self._bounded_bands.discard(new_label)
715 logger.debug(f"Copied radiation band {old_label} to {new_label}")
717 @require_plugin('radiation', 'add radiation source')
718 @validate_collimated_source_params
719 def addCollimatedRadiationSource(self, direction=None) -> int:
720 """
721 Add collimated radiation source.
722
723 Args:
724 direction: Optional direction vector. Can be tuple (x, y, z), vec3, or None for default direction.
725
726 Returns:
727 Source ID
728 """
729 if direction is None:
731 source_id = radiation_wrapper.addCollimatedRadiationSourceDefault(self.radiation_model)
732 else:
733 # Handle vec3, SphericalCoord, and tuple types
734 if hasattr(direction, 'x') and hasattr(direction, 'y') and hasattr(direction, 'z'):
735 # vec3-like object
736 x, y, z = direction.x, direction.y, direction.z
737 elif hasattr(direction, 'radius') and hasattr(direction, 'elevation') and hasattr(direction, 'azimuth'):
738 # SphericalCoord object - convert to Cartesian
739 import math
740 r = direction.radius
741 elevation = direction.elevation
742 azimuth = direction.azimuth
743 x = r * math.cos(elevation) * math.cos(azimuth)
744 y = r * math.cos(elevation) * math.sin(azimuth)
745 z = r * math.sin(elevation)
746 else:
747 # Assume tuple-like object - validate it first
749 try:
750 if len(direction) != 3:
751 raise TypeError(f"Direction must be a 3-element tuple, vec3, or SphericalCoord, got {type(direction).__name__} with {len(direction)} elements")
752 x, y, z = direction
753 except (TypeError, AttributeError):
754 # Not a valid sequence type
755 raise TypeError(f"Direction must be a tuple, vec3, or SphericalCoord, got {type(direction).__name__}")
756 source_id = radiation_wrapper.addCollimatedRadiationSourceVec3(self.radiation_model, x, y, z)
757
758 logger.debug(f"Added collimated radiation source: ID {source_id}")
759 return source_id
760
761 @require_plugin('radiation', 'add spherical radiation source')
762 @validate_sphere_source_params
763 def addSphereRadiationSource(self, position, radius: float) -> int:
764 """
765 Add spherical radiation source.
766
767 Args:
768 position: Position of the source. Can be tuple (x, y, z) or vec3.
769 radius: Radius of the spherical source
770
771 Returns:
772 Source ID
773 """
774 validate_position_like(position, "position", "addSphereRadiationSource")
775 # Handle both tuple and vec3 types
776 if hasattr(position, 'x') and hasattr(position, 'y') and hasattr(position, 'z'):
777 x, y, z = position.x, position.y, position.z
778 else:
779 x, y, z = position
781 source_id = radiation_wrapper.addSphereRadiationSource(self.radiation_model, x, y, z, radius)
782 logger.debug(f"Added sphere radiation source: ID {source_id} at ({x}, {y}, {z}) with radius {radius}")
783 return source_id
784
785 @require_plugin('radiation', 'add sun radiation source')
786 @validate_sun_sphere_params
787 def addSunSphereRadiationSource(self, radius: float, zenith: float, azimuth: float,
788 position_scaling: float = 1.0, angular_width: float = 0.53,
789 flux_scaling: float = 1.0) -> int:
790 """
791 Add sun sphere radiation source.
792
793 Args:
794 radius: Radius of the sun sphere
795 zenith: Zenith angle (degrees)
796 azimuth: Azimuth angle (degrees)
797 position_scaling: Position scaling factor
798 angular_width: Angular width of the sun (degrees)
799 flux_scaling: Flux scaling factor
800
801 Returns:
802 Source ID
803 """
805 source_id = radiation_wrapper.addSunSphereRadiationSource(
806 self.radiation_model, radius, zenith, azimuth, position_scaling, angular_width, flux_scaling
807 )
808 logger.debug(f"Added sun radiation source: ID {source_id}")
809 return source_id
810
811 @require_plugin('radiation', 'set source position')
812 def setSourcePosition(self, source_id: int, position):
813 """
814 Set position of a radiation source.
815
816 Allows dynamic repositioning of radiation sources during simulation,
817 useful for time-series modeling or moving light sources.
818
819 Args:
820 source_id: ID of the radiation source
821 position: New position as vec3, SphericalCoord, or list/tuple [x, y, z]
822
823 Example:
824 >>> source_id = radiation.addCollimatedRadiationSource()
825 >>> radiation.setSourcePosition(source_id, [10, 20, 30])
826 >>> from pyhelios.types import vec3
827 >>> radiation.setSourcePosition(source_id, vec3(15, 25, 35))
828 """
829 if not isinstance(source_id, int) or source_id < 0:
830 raise ValueError(f"Source ID must be a non-negative integer, got {source_id}")
831 validate_direction_like(position, "position", "setSourcePosition")
833 radiation_wrapper.setSourcePosition(self.radiation_model, source_id, position)
834 logger.debug(f"Updated position for radiation source {source_id}")
835
836 @require_plugin('radiation', 'add rectangle radiation source')
837 def addRectangleRadiationSource(self, position, size, rotation) -> int:
838 """
839 Add a rectangle (planar) radiation source.
840
841 Rectangle sources are ideal for modeling artificial lighting such as
842 LED panels, grow lights, or window light sources.
843
844 Args:
845 position: Center position as vec3 or list [x, y, z]
846 size: Rectangle dimensions as vec2 or list [width, height]
847 rotation: Rotation vector as vec3 or list [rx, ry, rz] (Euler angles in radians)
848
849 Returns:
850 Source ID
851
852 Example:
853 >>> from pyhelios.types import vec3, vec2
854 >>> source_id = radiation.addRectangleRadiationSource(
855 ... position=vec3(0, 0, 5),
856 ... size=vec2(2, 1),
857 ... rotation=vec3(0, 0, 0)
858 ... )
859 >>> radiation.setSourceFlux(source_id, "PAR", 500.0)
860 """
861 validate_position_like(position, "position", "addRectangleRadiationSource")
862 validate_size_like(size, "size", "addRectangleRadiationSource")
863 validate_position_like(rotation, "rotation", "addRectangleRadiationSource")
865 return radiation_wrapper.addRectangleRadiationSource(self.radiation_model, position, size, rotation)
866
867 @require_plugin('radiation', 'add disk radiation source')
868 def addDiskRadiationSource(self, position, radius: float, rotation) -> int:
869 """
870 Add a disk (circular planar) radiation source.
871
872 Disk sources are useful for modeling circular light sources such as
873 spotlights, circular LED arrays, or solar simulators.
874
875 Args:
876 position: Center position as vec3 or list [x, y, z]
877 radius: Disk radius
878 rotation: Rotation vector as vec3 or list [rx, ry, rz] (Euler angles in radians)
879
880 Returns:
881 Source ID
882
883 Example:
884 >>> from pyhelios.types import vec3
885 >>> source_id = radiation.addDiskRadiationSource(
886 ... position=vec3(0, 0, 5),
887 ... radius=1.5,
888 ... rotation=vec3(0, 0, 0)
889 ... )
890 >>> radiation.setSourceFlux(source_id, "PAR", 300.0)
891 """
892 validate_position_like(position, "position", "addDiskRadiationSource")
893 validate_position_like(rotation, "rotation", "addDiskRadiationSource")
894 if radius <= 0:
895 raise ValueError(f"Radius must be positive, got {radius}")
897 return radiation_wrapper.addDiskRadiationSource(self.radiation_model, position, radius, rotation)
898
899 # Source spectrum methods
900 @require_plugin('radiation', 'manage source spectrum')
901 def setSourceSpectrum(self, source_id, spectrum):
902 """
903 Set radiation spectrum for source(s).
904
905 Spectral distributions define how radiation intensity varies with wavelength,
906 essential for realistic modeling of different light sources (sunlight, LEDs, etc.).
908 Args:
909 source_id: Source ID (int) or list of source IDs
910 spectrum: Either:
911 - Spectrum data as list of (wavelength, value) tuples
912 - Global data label string
913
914 Example:
915 >>> # Define custom LED spectrum
916 >>> led_spectrum = [
917 ... (400, 0.0), (450, 0.3), (500, 0.8),
918 ... (550, 0.5), (600, 0.2), (700, 0.0)
919 ... ]
920 >>> radiation.setSourceSpectrum(source_id, led_spectrum)
921 >>>
922 >>> # Use predefined spectrum from global data
923 >>> radiation.setSourceSpectrum(source_id, "D65_illuminant")
924 >>>
925 >>> # Apply same spectrum to multiple sources
926 >>> radiation.setSourceSpectrum([src1, src2, src3], led_spectrum)
927 """
929 radiation_wrapper.setSourceSpectrum(self.radiation_model, source_id, spectrum)
930 logger.debug(f"Set spectrum for source(s) {source_id}")
931
932 @require_plugin('radiation', 'configure source spectrum')
933 def setSourceSpectrumIntegral(self, source_id: int, source_integral: float,
934 wavelength_min: float = None, wavelength_max: float = None):
935 """
936 Set source spectrum integral value.
937
938 Normalizes the spectrum so that its integral equals the specified value,
939 useful for calibrating source intensity.
940
941 Args:
942 source_id: Source ID
943 source_integral: Target integral value
944 wavelength_min: Optional minimum wavelength for integration range
945 wavelength_max: Optional maximum wavelength for integration range
946
947 Example:
948 >>> radiation.setSourceSpectrumIntegral(source_id, 1000.0)
949 >>> radiation.setSourceSpectrumIntegral(source_id, 500.0, 400, 700) # PAR range
950 """
951 if not isinstance(source_id, int) or source_id < 0:
952 raise ValueError(f"Source ID must be a non-negative integer, got {source_id}")
953 if source_integral < 0:
954 raise ValueError(f"Source integral must be non-negative, got {source_integral}")
955
957 radiation_wrapper.setSourceSpectrumIntegral(self.radiation_model, source_id, source_integral,
958 wavelength_min, wavelength_max)
959 logger.debug(f"Set spectrum integral for source {source_id}: {source_integral}")
960
961 # Spectrum integration and analysis methods
962 @require_plugin('radiation', 'integrate spectrum')
963 def integrateSpectrum(self, object_spectrum, wavelength_min: float = None,
964 wavelength_max: float = None, source_id: int = None,
965 camera_spectrum=None) -> float:
966 """
967 Integrate spectrum with optional source/camera spectra and wavelength range.
968
969 This unified method handles multiple integration scenarios:
970 - Basic: Total spectrum integration
971 - Range: Integration over wavelength range
972 - Source: Integration weighted by source spectrum
973 - Camera: Integration weighted by camera spectral response
974 - Full: Integration with both source and camera spectra
975
976 Args:
977 object_spectrum: Object spectrum as list of (wavelength, value) tuples/vec2
978 wavelength_min: Optional minimum wavelength for integration range
979 wavelength_max: Optional maximum wavelength for integration range
980 source_id: Optional source ID for source spectrum weighting
981 camera_spectrum: Optional camera spectrum for camera response weighting
982
983 Returns:
984 Integrated value
985
986 Example:
987 >>> leaf_reflectance = [(400, 0.1), (500, 0.4), (600, 0.6), (700, 0.5)]
988 >>>
989 >>> # Total integration
990 >>> total = radiation.integrateSpectrum(leaf_reflectance)
991 >>>
992 >>> # PAR range (400-700nm)
993 >>> par = radiation.integrateSpectrum(leaf_reflectance, 400, 700)
994 >>>
995 >>> # With source spectrum
996 >>> source_weighted = radiation.integrateSpectrum(
997 ... leaf_reflectance, 400, 700, source_id=sun_source
998 ... )
999 >>>
1000 >>> # With camera response
1001 >>> camera_response = [(400, 0.2), (550, 1.0), (700, 0.3)]
1002 >>> camera_weighted = radiation.integrateSpectrum(
1003 ... leaf_reflectance, camera_spectrum=camera_response
1004 ... )
1005 """
1007 return radiation_wrapper.integrateSpectrum(self.radiation_model, object_spectrum,
1008 wavelength_min, wavelength_max,
1009 source_id, camera_spectrum)
1010
1011 @require_plugin('radiation', 'integrate source spectrum')
1012 def integrateSourceSpectrum(self, source_id: int, wavelength_min: float, wavelength_max: float) -> float:
1013 """
1014 Integrate source spectrum over wavelength range.
1015
1016 Args:
1017 source_id: Source ID
1018 wavelength_min: Minimum wavelength
1019 wavelength_max: Maximum wavelength
1020
1021 Returns:
1022 Integrated source spectrum value
1023
1024 Example:
1025 >>> par_flux = radiation.integrateSourceSpectrum(source_id, 400, 700)
1026 """
1027 if not isinstance(source_id, int) or source_id < 0:
1028 raise ValueError(f"Source ID must be a non-negative integer, got {source_id}")
1030 return radiation_wrapper.integrateSourceSpectrum(self.radiation_model, source_id,
1031 wavelength_min, wavelength_max)
1032
1033 # Spectral manipulation methods
1034 @require_plugin('radiation', 'scale spectrum')
1035 def scaleSpectrum(self, existing_label: str, new_label_or_scale, scale_factor: float = None):
1036 """
1037 Scale spectrum in-place or to new label.
1038
1039 Useful for adjusting spectrum intensities or creating variations of
1040 existing spectra for sensitivity analysis.
1041
1042 Supports two call patterns:
1043 - scaleSpectrum("label", scale) -> scales in-place
1044 - scaleSpectrum("existing", "new", scale) -> creates new scaled spectrum
1045
1046 Args:
1047 existing_label: Existing global data label
1048 new_label_or_scale: Either new label string (if creating new) or scale factor (if in-place)
1049 scale_factor: Scale factor (required only if new_label_or_scale is a string)
1050
1051 Example:
1052 >>> # In-place scaling
1053 >>> radiation.scaleSpectrum("leaf_reflectance", 1.2)
1054 >>>
1055 >>> # Create new scaled spectrum
1056 >>> radiation.scaleSpectrum("leaf_reflectance", "scaled_leaf", 1.5)
1057 """
1058 if not isinstance(existing_label, str) or not existing_label.strip():
1059 raise ValueError("Existing label must be a non-empty string")
1060
1062 radiation_wrapper.scaleSpectrum(self.radiation_model, existing_label,
1063 new_label_or_scale, scale_factor)
1064 logger.debug(f"Scaled spectrum '{existing_label}'")
1065
1066 @require_plugin('radiation', 'scale spectrum randomly')
1067 def scaleSpectrumRandomly(self, existing_label: str, new_label: str,
1068 min_scale: float, max_scale: float):
1069 """
1070 Scale spectrum with random factor and store as new label.
1071
1072 Useful for creating stochastic variations in spectral properties for
1073 Monte Carlo simulations or uncertainty quantification.
1074
1075 Args:
1076 existing_label: Existing global data label
1077 new_label: New global data label for scaled spectrum
1078 min_scale: Minimum scale factor
1079 max_scale: Maximum scale factor
1080
1081 Example:
1082 >>> # Create random variation of leaf reflectance
1083 >>> radiation.scaleSpectrumRandomly("leaf_base", "leaf_variant", 0.8, 1.2)
1084 """
1085 if not isinstance(existing_label, str) or not existing_label.strip():
1086 raise ValueError("Existing label must be a non-empty string")
1087 if not isinstance(new_label, str) or not new_label.strip():
1088 raise ValueError("New label must be a non-empty string")
1089 if min_scale >= max_scale:
1090 raise ValueError(f"min_scale ({min_scale}) must be less than max_scale ({max_scale})")
1091
1093 radiation_wrapper.scaleSpectrumRandomly(self.radiation_model, existing_label, new_label,
1094 min_scale, max_scale)
1095 logger.debug(f"Scaled spectrum '{existing_label}' randomly to '{new_label}'")
1096
1097 @require_plugin('radiation', 'blend spectra')
1098 def blendSpectra(self, new_label: str, spectrum_labels: List[str], weights: List[float]):
1099 """
1100 Blend multiple spectra with specified weights.
1101
1102 Creates weighted combination of spectra, useful for mixing material properties
1103 or creating composite light sources.
1104
1105 Args:
1106 new_label: New global data label for blended spectrum
1107 spectrum_labels: List of spectrum labels to blend
1108 weights: List of weights (must sum to reasonable values, same length as labels)
1109
1110 Example:
1111 >>> # Mix two leaf types (70% type A, 30% type B)
1112 >>> radiation.blendSpectra("mixed_leaf",
1113 ... ["leaf_type_a", "leaf_type_b"],
1114 ... [0.7, 0.3]
1115 ... )
1116 """
1117 if not isinstance(new_label, str) or not new_label.strip():
1118 raise ValueError("New label must be a non-empty string")
1119 if len(spectrum_labels) != len(weights):
1120 raise ValueError(f"Number of labels ({len(spectrum_labels)}) must match number of weights ({len(weights)})")
1121 if not spectrum_labels:
1122 raise ValueError("At least one spectrum label required")
1123
1125 radiation_wrapper.blendSpectra(self.radiation_model, new_label, spectrum_labels, weights)
1126 logger.debug(f"Blended {len(spectrum_labels)} spectra into '{new_label}'")
1127
1128 @require_plugin('radiation', 'blend spectra randomly')
1129 def blendSpectraRandomly(self, new_label: str, spectrum_labels: List[str]):
1130 """
1131 Blend multiple spectra with random weights.
1133 Creates random combinations of spectra, useful for generating diverse
1134 material properties in stochastic simulations.
1135
1136 Args:
1137 new_label: New global data label for blended spectrum
1138 spectrum_labels: List of spectrum labels to blend
1139
1140 Example:
1141 >>> # Create random mixture of leaf spectra
1142 >>> radiation.blendSpectraRandomly("random_leaf",
1143 ... ["young_leaf", "mature_leaf", "senescent_leaf"]
1144 ... )
1145 """
1146 if not isinstance(new_label, str) or not new_label.strip():
1147 raise ValueError("New label must be a non-empty string")
1148 if not spectrum_labels:
1149 raise ValueError("At least one spectrum label required")
1150
1152 radiation_wrapper.blendSpectraRandomly(self.radiation_model, new_label, spectrum_labels)
1153 logger.debug(f"Blended {len(spectrum_labels)} spectra randomly into '{new_label}'")
1154
1155 # Spectral interpolation methods
1156 @require_plugin('radiation', 'interpolate spectrum from data')
1157 def interpolateSpectrumFromPrimitiveData(self, primitive_uuids: List[int],
1158 spectra_labels: List[str], values: List[float],
1159 primitive_data_query_label: str,
1160 primitive_data_radprop_label: str):
1161 """
1162 Interpolate spectral properties based on primitive data values.
1163
1164 Automatically assigns spectra to primitives by interpolating between
1165 reference spectra based on continuous data values (e.g., age, moisture, etc.).
1166
1167 Args:
1168 primitive_uuids: List of primitive UUIDs to assign spectra
1169 spectra_labels: List of reference spectrum labels
1170 values: List of data values corresponding to each spectrum
1171 primitive_data_query_label: Primitive data label containing query values
1172 primitive_data_radprop_label: Primitive data label to store assigned spectra
1173
1174 Example:
1175 >>> # Assign leaf reflectance based on age
1176 >>> leaf_patches = context.getAllUUIDs("patch")
1177 >>> radiation.interpolateSpectrumFromPrimitiveData(
1178 ... primitive_uuids=leaf_patches,
1179 ... spectra_labels=["young_leaf", "mature_leaf", "old_leaf"],
1180 ... values=[0.0, 50.0, 100.0], # Days since emergence
1181 ... primitive_data_query_label="leaf_age",
1182 ... primitive_data_radprop_label="reflectance"
1183 ... )
1184 """
1185 if not isinstance(primitive_uuids, (list, tuple)) or not primitive_uuids:
1186 raise ValueError("Primitive UUIDs must be a non-empty list")
1187 if not isinstance(spectra_labels, (list, tuple)) or not spectra_labels:
1188 raise ValueError("Spectra labels must be a non-empty list")
1189 if not isinstance(values, (list, tuple)) or not values:
1190 raise ValueError("Values must be a non-empty list")
1191 if len(spectra_labels) != len(values):
1192 raise ValueError(f"Number of spectra ({len(spectra_labels)}) must match number of values ({len(values)})")
1193
1195 radiation_wrapper.interpolateSpectrumFromPrimitiveData(
1196 self.radiation_model, primitive_uuids, spectra_labels, values,
1197 primitive_data_query_label, primitive_data_radprop_label
1198 )
1199 logger.debug(f"Interpolated spectra for {len(primitive_uuids)} primitives")
1200
1201 @require_plugin('radiation', 'interpolate spectrum from object data')
1202 def interpolateSpectrumFromObjectData(self, object_ids: List[int],
1203 spectra_labels: List[str], values: List[float],
1204 object_data_query_label: str,
1205 primitive_data_radprop_label: str):
1206 """
1207 Interpolate spectral properties based on object data values.
1208
1209 Automatically assigns spectra to object primitives by interpolating between
1210 reference spectra based on continuous object-level data values.
1211
1212 Args:
1213 object_ids: List of object IDs
1214 spectra_labels: List of reference spectrum labels
1215 values: List of data values corresponding to each spectrum
1216 object_data_query_label: Object data label containing query values
1217 primitive_data_radprop_label: Primitive data label to store assigned spectra
1218
1219 Example:
1220 >>> # Assign tree reflectance based on health index
1221 >>> tree_ids = [tree1_id, tree2_id, tree3_id]
1222 >>> radiation.interpolateSpectrumFromObjectData(
1223 ... object_ids=tree_ids,
1224 ... spectra_labels=["healthy_tree", "stressed_tree", "diseased_tree"],
1225 ... values=[1.0, 0.5, 0.0], # Health index
1226 ... object_data_query_label="health_index",
1227 ... primitive_data_radprop_label="reflectance"
1228 ... )
1229 """
1230 if not isinstance(object_ids, (list, tuple)) or not object_ids:
1231 raise ValueError("Object IDs must be a non-empty list")
1232 if not isinstance(spectra_labels, (list, tuple)) or not spectra_labels:
1233 raise ValueError("Spectra labels must be a non-empty list")
1234 if not isinstance(values, (list, tuple)) or not values:
1235 raise ValueError("Values must be a non-empty list")
1236 if len(spectra_labels) != len(values):
1237 raise ValueError(f"Number of spectra ({len(spectra_labels)}) must match number of values ({len(values)})")
1238
1240 radiation_wrapper.interpolateSpectrumFromObjectData(
1241 self.radiation_model, object_ids, spectra_labels, values,
1242 object_data_query_label, primitive_data_radprop_label
1243 )
1244 logger.debug(f"Interpolated spectra for {len(object_ids)} objects")
1245
1246 @require_plugin('radiation', 'set ray count')
1247 def setDirectRayCount(self, band_label: str, ray_count: int):
1248 """Set direct ray count for radiation band."""
1249 validate_band_label(band_label, "band_label", "setDirectRayCount")
1250 validate_ray_count(ray_count, "ray_count", "setDirectRayCount")
1252 radiation_wrapper.setDirectRayCount(self.radiation_model, band_label, ray_count)
1253
1254 @require_plugin('radiation', 'set ray count')
1255 def setDiffuseRayCount(self, band_label: str, ray_count: int):
1256 """Set diffuse ray count for radiation band."""
1257 validate_band_label(band_label, "band_label", "setDiffuseRayCount")
1258 validate_ray_count(ray_count, "ray_count", "setDiffuseRayCount")
1260 radiation_wrapper.setDiffuseRayCount(self.radiation_model, band_label, ray_count)
1261
1262 @require_plugin('radiation', 'set radiation flux')
1263 def setDiffuseRadiationFlux(self, label: str, flux: float):
1264 """Set diffuse radiation flux for band."""
1265 validate_band_label(label, "label", "setDiffuseRadiationFlux")
1266 validate_flux_value(flux, "flux", "setDiffuseRadiationFlux")
1268 radiation_wrapper.setDiffuseRadiationFlux(self.radiation_model, label, flux)
1269
1270 @require_plugin('radiation', 'configure diffuse radiation')
1271 def setDiffuseRadiationExtinctionCoeff(self, label: str, K: float, peak_direction):
1272 """
1273 Set diffuse radiation extinction coefficient with directional bias.
1274
1275 Models directionally-biased diffuse radiation (e.g., sky radiation with zenith peak).
1276
1277 Args:
1278 label: Band label
1279 K: Extinction coefficient
1280 peak_direction: Peak direction as vec3, SphericalCoord, or list [x, y, z]
1281
1282 Example:
1283 >>> from pyhelios.types import vec3
1284 >>> radiation.setDiffuseRadiationExtinctionCoeff("SW", 0.5, vec3(0, 0, 1))
1285 """
1286 validate_band_label(label, "label", "setDiffuseRadiationExtinctionCoeff")
1287 if K < 0:
1288 raise ValueError(f"Extinction coefficient must be non-negative, got {K}")
1289 validate_direction_like(peak_direction, "peak_direction", "setDiffuseRadiationExtinctionCoeff")
1291 radiation_wrapper.setDiffuseRadiationExtinctionCoeff(self.radiation_model, label, K, peak_direction)
1292 logger.debug(f"Set diffuse extinction coefficient for band '{label}': K={K}")
1293
1294 @require_plugin('radiation', 'query diffuse flux')
1295 def getDiffuseFlux(self, band_label: str) -> float:
1296 """
1297 Get diffuse flux for band.
1298
1299 Args:
1300 band_label: Band label
1302 Returns:
1303 Diffuse flux value
1304
1305 Example:
1306 >>> flux = radiation.getDiffuseFlux("SW")
1307 """
1308 validate_band_label(band_label, "band_label", "getDiffuseFlux")
1310 return radiation_wrapper.getDiffuseFlux(self.radiation_model, band_label)
1311
1312 @require_plugin('radiation', 'configure diffuse spectrum')
1313 def setDiffuseSpectrum(self, band_label, spectrum_label: str):
1314 """
1315 Set diffuse spectrum from global data label.
1316
1317 Args:
1318 band_label: Band label (string) or list of band labels
1319 spectrum_label: Spectrum global data label
1320
1321 Example:
1322 >>> radiation.setDiffuseSpectrum("SW", "sky_spectrum")
1323 >>> radiation.setDiffuseSpectrum(["SW", "NIR"], "sky_spectrum")
1324 """
1325 if isinstance(band_label, str):
1326 validate_band_label(band_label, "band_label", "setDiffuseSpectrum")
1327 else:
1328 for label in band_label:
1329 validate_band_label(label, "band_label", "setDiffuseSpectrum")
1330 if not isinstance(spectrum_label, str) or not spectrum_label.strip():
1331 raise ValueError("Spectrum label must be a non-empty string")
1332
1334 radiation_wrapper.setDiffuseSpectrum(self.radiation_model, band_label, spectrum_label)
1335 logger.debug(f"Set diffuse spectrum for band(s) {band_label}")
1336
1337 @require_plugin('radiation', 'configure diffuse spectrum')
1338 def setDiffuseSpectrumIntegral(self, spectrum_integral: float, wavelength_min: float = None,
1339 wavelength_max: float = None, band_label: str = None):
1340 """
1341 Set diffuse spectrum integral.
1342
1343 Args:
1344 spectrum_integral: Integral value
1345 wavelength_min: Optional minimum wavelength
1346 wavelength_max: Optional maximum wavelength
1347 band_label: Optional specific band label (None for all bands)
1348
1349 Example:
1350 >>> radiation.setDiffuseSpectrumIntegral(1000.0) # All bands
1351 >>> radiation.setDiffuseSpectrumIntegral(500.0, 400, 700, band_label="PAR") # Specific band
1352 """
1353 if spectrum_integral < 0:
1354 raise ValueError(f"Spectrum integral must be non-negative, got {spectrum_integral}")
1355 if band_label is not None:
1356 validate_band_label(band_label, "band_label", "setDiffuseSpectrumIntegral")
1357
1359 radiation_wrapper.setDiffuseSpectrumIntegral(self.radiation_model, spectrum_integral,
1360 wavelength_min, wavelength_max, band_label)
1361 logger.debug(f"Set diffuse spectrum integral: {spectrum_integral}")
1362
1363 @require_plugin('radiation', 'set source flux')
1364 def setSourceFlux(self, source_id, label: str, flux: float):
1365 """Set source flux for single source or multiple sources."""
1366 validate_band_label(label, "label", "setSourceFlux")
1367 validate_flux_value(flux, "flux", "setSourceFlux")
1368
1369 if isinstance(source_id, (list, tuple)):
1370 # Multiple sources
1371 validate_source_id_list(list(source_id), "source_id", "setSourceFlux")
1373 radiation_wrapper.setSourceFluxMultiple(self.radiation_model, source_id, label, flux)
1374 else:
1375 # Single source
1376 validate_source_id(source_id, "source_id", "setSourceFlux")
1378 radiation_wrapper.setSourceFlux(self.radiation_model, source_id, label, flux)
1379
1380
1381 @require_plugin('radiation', 'get source flux')
1382 @validate_get_source_flux_params
1383 def getSourceFlux(self, source_id: int, label: str) -> float:
1384 """Get source flux for band."""
1386 return radiation_wrapper.getSourceFlux(self.radiation_model, source_id, label)
1387
1388 @require_plugin('radiation', 'update geometry')
1389 @validate_update_geometry_params
1390 def updateGeometry(self, uuids: Optional[List[int]] = None):
1391 """
1392 Update geometry in radiation model.
1393
1394 The two call forms differ in more than scope, as of helios-core v1.3.79:
1395
1396 - ``updateGeometry()`` (no argument) is **optional**. :meth:`runBand` builds
1397 the geometry itself before tracing and rebuilds it whenever primitives have
1398 been added to or deleted from the Context since the last build. Call it
1399 explicitly only to control when the cost of the build is paid.
1400 - ``updateGeometry(uuids)`` restricts the model to a subset of Context
1401 primitives, and that subset is **never** rebuilt automatically -- doing so
1402 would silently discard the subset you asked for. You must call it again
1403 yourself after modifying Context geometry.
1404
1405 Args:
1406 uuids: Optional list of specific UUIDs to update. If None, updates all geometry.
1407
1408 Note:
1409 Passing ``uuids`` latches the model into subset mode natively. Calling
1410 ``updateGeometry()`` with no argument afterwards clears that latch and
1411 returns the model to tracking the full Context.
1412 """
1413 if uuids is None:
1415 radiation_wrapper.updateGeometry(self.radiation_model)
1416 logger.debug("Updated all geometry in radiation model")
1417 # Clears the native subset latch, so stop treating the model as a subset.
1418 self._geometry_subset = False
1419 else:
1421 radiation_wrapper.updateGeometryUUIDs(self.radiation_model, uuids)
1422 logger.debug(f"Updated {len(uuids)} geometry UUIDs in radiation model")
1423 self._geometry_subset = True
1424 self._geometry_updated = True
1425
1426 @require_plugin('radiation', 'run radiation simulation')
1427 @validate_run_band_params
1428 def runBand(self, band_label):
1429 """
1430 Run radiation simulation for single band or multiple bands.
1431
1432 PERFORMANCE NOTE: When simulating multiple radiation bands, it is HIGHLY RECOMMENDED
1433 to run all bands in a single call (e.g., runBand(["PAR", "NIR", "SW"])) rather than
1434 sequential single-band calls. This provides significant computational efficiency gains
1435 because:
1436
1437 - GPU ray tracing setup is done once for all bands
1438 - Scene geometry acceleration structures are reused
1439 - GPU kernel launches are batched together
1440 - Memory transfers between CPU/GPU are minimized
1441
1442 Example:
1443 # EFFICIENT - Single call for multiple bands
1444 radiation.runBand(["PAR", "NIR", "SW"])
1445
1446 # INEFFICIENT - Sequential single-band calls
1447 radiation.runBand("PAR")
1448 radiation.runBand("NIR")
1449 radiation.runBand("SW")
1450
1451 Args:
1452 band_label: Single band name (str) or list of band names for multi-band simulation
1453
1454 Note:
1455 Geometry and radiative properties are updated automatically as needed
1456 (helios-core v1.3.79+), so an explicit :meth:`updateGeometry` call is not
1457 required first. A subset build from ``updateGeometry(uuids)`` is preserved:
1458 it is never rebuilt automatically, since that would discard the subset.
1459 """
1460 if isinstance(band_label, (list, tuple)):
1461 # Multiple bands - validate each label
1462 for lbl in band_label:
1463 if not isinstance(lbl, str):
1464 raise TypeError(f"Band labels must be strings, got {type(lbl).__name__}")
1466 radiation_wrapper.runBandMultiple(self.radiation_model, band_label)
1467 logger.info(f"Completed radiation simulation for bands: {band_label}")
1468 else:
1469 # Single band - validate label type
1470 if not isinstance(band_label, str):
1471 raise TypeError(f"Band label must be a string, got {type(band_label).__name__}")
1473 radiation_wrapper.runBand(self.radiation_model, band_label)
1474 logger.info(f"Completed radiation simulation for band: {band_label}")
1475 # runBand() builds geometry itself when needed, so geometry is now loaded even
1476 # if updateGeometry() was never called. Recording that keeps the auto-update in
1477 # calculateGtheta() from doing a redundant rebuild -- which, after a subset
1478 # build, would silently widen the model back to the full Context.
1479 self._geometry_updated = True
1480
1481
1482 @require_plugin('radiation', 'get simulation results')
1483 def getTotalAbsorbedFlux(self) -> List[float]:
1484 """Get absorbed radiation flux density for all primitives, summed over all bands.
1485
1486 .. warning::
1487 **The returned values cannot be matched to UUIDs by position.** They are
1488 ordered by the radiation model's internal primitive ordering (grouped by
1489 parent object, built during :meth:`updateGeometry`), which is *not* the
1490 order of ``context.getAllUUIDs()`` -- that iterates an unordered map and
1491 returns a hash order. Pairing the two by index silently attributes flux
1492 to the wrong primitive. Use :meth:`getAbsorbedFlux` instead, which is
1493 keyed by UUID.
1494
1495 .. warning::
1496 This sums the ``radiation_flux_<band>`` primitive data over **every**
1497 band registered in the model. For overlapping bands -- e.g. PAR, NIR
1498 and SW, where SW already spans the other two -- the sum double-counts
1499 and is not a physically meaningful quantity. Use
1500 :meth:`getAbsorbedFlux` to get a single band's flux.
1501
1502 Units are **W/m^2** (flux density), not watts. Because it is a density, the
1503 value does not change when a primitive's size changes: a 1x1 m and a 2x2 m
1504 patch under the same collimated source both report the same number.
1505
1506 Summing the returned values directly (``sum(flux)``) adds flux densities of
1507 differently-sized surfaces and is not physically meaningful. To obtain
1508 absorbed power in watts, weight each primitive by its area -- via
1509 :meth:`getAbsorbedFlux`, so that flux and area refer to the same primitive::
1510
1511 uuids = context.getAllUUIDs()
1512 flux = radiation.getAbsorbedFlux("SW")
1513 power = float((flux * context.getPrimitiveArea(uuids)).sum(dtype="float64"))
1514
1515 Returns:
1516 Absorbed flux density per primitive in W/m^2, summed over all bands.
1517
1518 See Also:
1519 :meth:`getAbsorbedFlux`: band-specific, UUID-aligned absorbed flux.
1520 """
1522 results = radiation_wrapper.getTotalAbsorbedFlux(self.radiation_model)
1523 logger.debug(f"Retrieved absorbed flux data for {len(results)} primitives")
1524 return results
1525
1526 @require_plugin('radiation', 'get band-specific simulation results')
1527 def getAbsorbedFlux(self, band_label: Union[str, List[str]],
1528 uuids: Optional[List[int]] = None
1529 ) -> Union["np.ndarray", Dict[str, "np.ndarray"]]:
1530 """Get per-band absorbed radiation flux density, aligned to UUIDs.
1531
1532 ``runBand()`` stores each band's result as ``radiation_flux_<band>``
1533 primitive data: the **total** absorbed flux density for that band, i.e.
1534 direct plus diffuse plus scattered contributions. This method reads that
1535 data back keyed by UUID, so element ``i`` of the result always belongs to
1536 ``uuids[i]``.
1537
1538 Prefer this over :meth:`getTotalAbsorbedFlux`, which sums over every band
1539 (double-counting overlapping bands such as PAR/NIR/SW) and whose ordering
1540 does not correspond to ``context.getAllUUIDs()``.
1541
1542 Args:
1543 band_label: Band name, or a list of band names to fetch at once.
1544 uuids: Primitives to query, defaulting to ``context.getAllUUIDs()``.
1545 Results follow this list's order.
1546
1547 Returns:
1548 For a single band, a float32 array of absorbed flux density per
1549 primitive in W/m^2. For a list of bands, a dict mapping each band
1550 label to that array.
1551
1552 Raises:
1553 RadiationModelError: If a band does not exist, or if ``runBand()`` has
1554 not been run for it.
1555
1556 Example:
1557 >>> radiation.runBand(["PAR", "NIR", "SW"])
1558 >>> uuids = context.getAllUUIDs()
1559 >>> par = radiation.getAbsorbedFlux("PAR") # W/m^2, PAR only
1560 >>> par_watts = par * context.getPrimitiveArea(uuids)
1561 >>> all_bands = radiation.getAbsorbedFlux(["PAR", "NIR", "SW"])
1562 >>> all_bands["SW"][0] # doctest: +SKIP
1563 """
1564 single_band = isinstance(band_label, str)
1565 labels = [band_label] if single_band else band_label
1566
1567 if not single_band:
1568 if not isinstance(labels, (list, tuple)):
1569 raise TypeError(
1570 f"band_label must be a string or list of strings, "
1571 f"got {type(band_label).__name__}"
1572 )
1573 if not labels:
1574 raise ValueError("band_label list cannot be empty")
1575 for label in labels:
1576 validate_band_label(label, "band_label", "getAbsorbedFlux")
1579
1580 if uuids is None:
1581 uuids = self.context.getAllUUIDs()
1582 else:
1583 if not isinstance(uuids, (list, tuple)):
1584 raise TypeError(
1585 f"uuids must be a list of primitive UUIDs, "
1586 f"got {type(uuids).__name__}"
1587 )
1588 uuids = list(uuids)
1589 if not uuids:
1590 raise ValueError(
1591 "No primitives to query: the Context contains no geometry."
1592 )
1593
1594 results: Dict[str, "np.ndarray"] = {}
1595 for label in labels:
1596 if not radiation_wrapper.doesBandExist(self.radiation_model, label):
1597 raise RadiationModelError(
1598 f"Radiation band '{label}' does not exist. "
1599 f"Add it with radiation.addRadiationBand('{label}') before "
1600 f"querying its absorbed flux."
1601 )
1602
1603 data_label = f"radiation_flux_{label}"
1604 # runBand() writes this for every primitive in the Context, so a single
1605 # probe distinguishes "band never run" from a per-primitive gap.
1606 if not self.context.doesPrimitiveDataExist(uuids[0], data_label):
1607 raise RadiationModelError(
1608 f"No absorbed flux results for band '{label}': primitive data "
1609 f"'{data_label}' has not been written. Call "
1610 f"radiation.runBand('{label}') (or include it in a multi-band "
1611 f"runBand([...]) call) before querying its absorbed flux."
1612 )
1613
1614 # One native call for the whole band. Goes straight to the bulk wrapper
1615 # rather than Context.getPrimitiveDataArray(), which would re-validate
1616 # every UUID and re-check existence that the probe above already
1617 # established.
1618 results[label] = context_wrapper.getPrimitiveDataFloatArray(
1619 self.context.getNativePtr(), uuids, data_label)
1620
1621 logger.debug(
1622 f"Retrieved absorbed flux for bands {labels} over {len(uuids)} primitives"
1623 )
1624 return results[band_label] if single_band else results
1625
1626 # Band query methods
1627 @require_plugin('radiation', 'check band existence')
1628 def doesBandExist(self, label: str) -> bool:
1629 """
1630 Check if a radiation band exists.
1631
1632 Args:
1633 label: Name/label of the radiation band to check
1634
1635 Returns:
1636 True if band exists, False otherwise
1637
1638 Example:
1639 >>> radiation.addRadiationBand("SW")
1640 >>> radiation.doesBandExist("SW")
1641 True
1642 >>> radiation.doesBandExist("nonexistent")
1643 False
1644 """
1645 validate_band_label(label, "label", "doesBandExist")
1647 return radiation_wrapper.doesBandExist(self.radiation_model, label)
1648
1649 # Advanced source management methods
1650 @require_plugin('radiation', 'manage radiation sources')
1651 def deleteRadiationSource(self, source_id: int):
1652 """
1653 Delete a radiation source.
1654
1655 Args:
1656 source_id: ID of the radiation source to delete
1657
1658 Example:
1659 >>> source_id = radiation.addCollimatedRadiationSource()
1660 >>> radiation.deleteRadiationSource(source_id)
1661 """
1662 if not isinstance(source_id, int) or source_id < 0:
1663 raise ValueError(f"Source ID must be a non-negative integer, got {source_id}")
1665 radiation_wrapper.deleteRadiationSource(self.radiation_model, source_id)
1666 logger.debug(f"Deleted radiation source {source_id}")
1667
1668 @require_plugin('radiation', 'query radiation sources')
1669 def getSourcePosition(self, source_id: int):
1670 """
1671 Get position of a radiation source.
1672
1673 Args:
1674 source_id: ID of the radiation source
1675
1676 Returns:
1677 vec3 position of the source
1678
1679 Example:
1680 >>> source_id = radiation.addCollimatedRadiationSource()
1681 >>> position = radiation.getSourcePosition(source_id)
1682 >>> print(f"Source at: {position}")
1683 """
1684 if not isinstance(source_id, int) or source_id < 0:
1685 raise ValueError(f"Source ID must be a non-negative integer, got {source_id}")
1687 position_list = radiation_wrapper.getSourcePosition(self.radiation_model, source_id)
1688 from .wrappers.DataTypes import vec3
1689 return vec3(position_list[0], position_list[1], position_list[2])
1690
1691 # Advanced simulation methods
1692 @require_plugin('radiation', 'get sky energy')
1693 def getSkyEnergy(self) -> float:
1694 """
1695 Get total sky energy.
1696
1697 Returns:
1698 Total sky energy value
1700 Example:
1701 >>> energy = radiation.getSkyEnergy()
1702 >>> print(f"Sky energy: {energy}")
1703 """
1705 return radiation_wrapper.getSkyEnergy(self.radiation_model)
1706
1707 @require_plugin('radiation', 'calculate G-function')
1708 def calculateGtheta(self, view_direction) -> float:
1709 """
1710 Calculate G-function (geometry factor) for given view direction.
1711
1712 The G-function describes the geometric relationship between leaf area
1713 distribution and viewing direction, important for canopy radiation modeling.
1714
1715 The G-function is computed from the geometry currently loaded in the radiation
1716 model. If no geometry has been built yet, this method builds it automatically
1717 (with a warning) so the query operates on the current context geometry. If the
1718 result is still undefined (no primitives / zero leaf area), a RuntimeError is
1719 raised rather than silently returning NaN.
1720
1721 A subset built by ``updateGeometry(uuids)`` is never widened by this method: the
1722 G-function is reported over the subset the caller selected.
1723
1724 Args:
1725 view_direction: View direction as vec3 or list/tuple [x, y, z]
1726
1727 Returns:
1728 G-function value
1729
1730 Raises:
1731 RuntimeError: If the context has no geometry (or zero total leaf area),
1732 so the G-function is undefined.
1733
1734 Example:
1735 >>> from pyhelios.types import vec3
1736 >>> radiation.updateGeometry()
1737 >>> g_value = radiation.calculateGtheta(vec3(0, 0, 1))
1738 >>> print(f"G-function: {g_value}")
1739 """
1740 validate_position_like(view_direction, "view_direction", "calculateGtheta")
1741
1742 if not self._geometry_updated:
1743 # Never auto-build over a user-selected subset: updateGeometry() with no
1744 # argument clears the native subset latch, which would silently widen the
1745 # model to the full Context behind the caller's back.
1746 if self._geometry_subset:
1747 raise RuntimeError(
1748 "calculateGtheta requires built geometry, but the radiation model "
1749 "holds a user-specified primitive subset from updateGeometry(uuids) "
1750 "and building automatically would discard it. Call "
1751 "updateGeometry(uuids) again for the subset you want, or "
1752 "updateGeometry() to use the full Context."
1753 )
1754 logger.warning(
1755 "calculateGtheta called before geometry was built; updating radiation "
1756 "model geometry automatically. Call updateGeometry() explicitly after "
1757 "building the scene to avoid this."
1758 )
1759 self.updateGeometry()
1760
1761 context_ptr = self.context.getNativePtr()
1763 value = radiation_wrapper.calculateGtheta(self.radiation_model, context_ptr, view_direction)
1764
1765 if value is None or math.isnan(value):
1766 raise RuntimeError(
1767 "calculateGtheta returned an undefined (NaN) G-function. The radiation "
1768 "model has no geometry with positive leaf area for this context. Add "
1769 "primitives to the Context and ensure updateGeometry() succeeds before "
1770 "calling calculateGtheta()."
1771 )
1772 return value
1773
1774 @require_plugin('radiation', 'configure output data')
1775 def optionalOutputPrimitiveData(self, label: str):
1776 """
1777 Enable optional primitive data output.
1778
1779 Args:
1780 label: Name/label of the primitive data to output
1781
1782 Example:
1783 >>> radiation.optionalOutputPrimitiveData("temperature")
1784 """
1785 validate_band_label(label, "label", "optionalOutputPrimitiveData")
1787 radiation_wrapper.optionalOutputPrimitiveData(self.radiation_model, label)
1788 logger.debug(f"Enabled optional output for primitive data: {label}")
1789
1790 @require_plugin('radiation', 'configure boundary conditions')
1791 def enforcePeriodicBoundary(self, boundary: str):
1792 """
1793 Enforce periodic boundary conditions.
1794
1795 Periodic boundaries are useful for large-scale simulations to reduce
1796 edge effects by wrapping radiation at domain boundaries.
1797
1798 Args:
1799 boundary: Boundary specification string (e.g., "xy", "xyz", "x", "y", "z")
1801 Example:
1802 >>> radiation.enforcePeriodicBoundary("xy")
1803 """
1804 if not isinstance(boundary, str) or not boundary:
1805 raise ValueError("Boundary specification must be a non-empty string")
1807 radiation_wrapper.enforcePeriodicBoundary(self.radiation_model, boundary)
1808 logger.debug(f"Enforced periodic boundary: {boundary}")
1809
1810 # Configuration methods
1811 @require_plugin('radiation', 'configure radiation simulation')
1812 @validate_scattering_depth_params
1813 def setScatteringDepth(self, label: str, depth: int):
1814 """Set scattering depth for radiation band."""
1816 radiation_wrapper.setScatteringDepth(self.radiation_model, label, depth)
1817
1818 @require_plugin('radiation', 'configure radiation simulation')
1819 @validate_min_scatter_energy_params
1820 def setMinScatterEnergy(self, label: str, energy: float):
1821 """Set minimum scatter energy for radiation band."""
1823 radiation_wrapper.setMinScatterEnergy(self.radiation_model, label, energy)
1824
1825 @require_plugin('radiation', 'configure radiation emission')
1826 def disableEmission(self, label: str):
1827 """Disable emission for radiation band."""
1828 validate_band_label(label, "label", "disableEmission")
1830 radiation_wrapper.disableEmission(self.radiation_model, label)
1831
1832 @require_plugin('radiation', 'configure radiation emission')
1833 def enableEmission(self, label: str):
1834 """Enable emission for radiation band."""
1835 validate_band_label(label, "label", "enableEmission")
1837 radiation_wrapper.enableEmission(self.radiation_model, label)
1838
1839 #=============================================================================
1840 # Camera and Image Functions (v1.3.47)
1841 #=============================================================================
1842
1843 @require_plugin('radiation', 'add radiation camera')
1844 def addRadiationCamera(self, camera_label: str, band_labels: List[str], position, lookat_or_direction,
1845 camera_properties=None, antialiasing_samples: int = 100):
1846 """
1847 Add a radiation camera to the simulation.
1848
1849 Args:
1850 camera_label: Unique label string for the camera
1851 band_labels: List of radiation band labels for the camera
1852 position: Camera position as vec3 object
1853 lookat_or_direction: Either:
1854 - Lookat point as vec3 object
1855 - SphericalCoord for viewing direction
1856 camera_properties: CameraProperties instance or None for defaults
1857 antialiasing_samples: Number of antialiasing samples (default: 100)
1858
1859 Raises:
1860 ValidationError: If parameters are invalid or have wrong types
1861 RadiationModelError: If camera creation fails
1862
1863 Example:
1864 >>> from pyhelios import vec3, CameraProperties
1865 >>> # Create camera looking at origin from above
1866 >>> camera_props = CameraProperties(camera_resolution=(1024, 1024))
1867 >>> radiation_model.addRadiationCamera("main_camera", ["red", "green", "blue"],
1868 ... position=vec3(0, 0, 5), lookat_or_direction=vec3(0, 0, 0),
1869 ... camera_properties=camera_props)
1870 """
1871 # Import here to avoid circular imports
1872 from .wrappers import URadiationModelWrapper as radiation_wrapper
1873 from .wrappers.DataTypes import SphericalCoord, vec3, make_vec3
1874 from .validation.plugins import validate_camera_label, validate_band_labels_list, validate_antialiasing_samples
1875
1876 # Validate basic parameters
1877 validated_label = validate_camera_label(camera_label, "camera_label", "addRadiationCamera")
1878 validated_bands = validate_band_labels_list(band_labels, "band_labels", "addRadiationCamera")
1879 validated_samples = validate_antialiasing_samples(antialiasing_samples, "antialiasing_samples", "addRadiationCamera")
1880
1881 # Validate position (must be vec3)
1882 if not isinstance(position, vec3):
1883 raise TypeError("position must be a vec3 object. Use vec3(x, y, z) to create one.")
1884 validated_position = position
1886 # Validate lookat_or_direction (must be vec3 or SphericalCoord)
1887 if isinstance(lookat_or_direction, SphericalCoord):
1888 validated_direction = lookat_or_direction
1889 elif isinstance(lookat_or_direction, vec3):
1890 validated_direction = lookat_or_direction
1891 else:
1892 raise TypeError("lookat_or_direction must be a vec3 or SphericalCoord object. Use vec3(x, y, z) or SphericalCoord to create one.")
1893
1894 # Set up camera properties
1895 if camera_properties is None:
1896 camera_properties = CameraProperties()
1897
1898 # Call appropriate wrapper function based on direction type
1900 try:
1901 if hasattr(validated_direction, 'radius') and hasattr(validated_direction, 'elevation'):
1902 # SphericalCoord case
1903 direction_coords = validated_direction.to_list()
1904 # SphericalCoord.to_list() returns [radius, elevation, zenith, azimuth]
1905 # (4 elements). The native API expects azimuth (index 3), not zenith.
1906 if len(direction_coords) < 4:
1907 raise ValueError("SphericalCoord must expose radius, elevation, zenith, and azimuth")
1908 radius, elevation, azimuth = direction_coords[0], direction_coords[1], direction_coords[3]
1909
1910 radiation_wrapper.addRadiationCameraSpherical(
1911 self.radiation_model,
1912 validated_label,
1913 validated_bands,
1914 validated_position.x, validated_position.y, validated_position.z,
1915 radius, elevation, azimuth,
1916 camera_properties.to_array(),
1917 validated_samples,
1918 camera_properties.exposure,
1919 camera_properties_obj=camera_properties
1920 )
1921 else:
1922 # vec3 case
1923 radiation_wrapper.addRadiationCameraVec3(
1924 self.radiation_model,
1925 validated_label,
1926 validated_bands,
1927 validated_position.x, validated_position.y, validated_position.z,
1928 validated_direction.x, validated_direction.y, validated_direction.z,
1929 camera_properties.to_array(),
1930 validated_samples,
1931 camera_properties.exposure,
1932 camera_properties_obj=camera_properties
1933 )
1934
1935 except Exception as e:
1936 raise RadiationModelError(f"Failed to add radiation camera '{validated_label}': {e}")
1937
1938 @require_plugin('radiation', 'add SIF camera')
1939 def addSIFCamera(self, camera_label: str, emission_band_labels: List[str], position,
1940 lookat_or_direction, camera_properties=None, antialiasing_samples: int = 100):
1941 """
1942 Add a solar-induced chlorophyll fluorescence (SIF) camera.
1943
1944 Each band in ``emission_band_labels`` must already exist (added via
1945 :meth:`addRadiationBand`); those bands are flagged internally as SIF-emitting and
1946 use the Fluspect-B leaf-fluorescence kernel for emission instead of Stefan-Boltzmann.
1947 Helios auto-creates internal radiation bands covering 400-750 nm at the resolution
1948 specified by ``camera_properties.excitation_bin_width_nm``.
1949
1950 Args:
1951 camera_label: Unique label for the camera.
1952 emission_band_labels: List of pre-existing radiation band labels to drive
1953 with SIF emission.
1954 position: Camera position as a ``vec3``.
1955 lookat_or_direction: Either a ``vec3`` lookat point or a ``SphericalCoord``
1956 viewing direction.
1957 camera_properties: :class:`SIFCameraProperties` instance. If ``None`` defaults
1958 are used (10 nm excitation bins, no excitation scattering).
1959 antialiasing_samples: Antialiasing samples per pixel (>= 1, default 100).
1960
1961 Raises:
1962 RadiationModelError: If the underlying SIF camera cannot be added (e.g.,
1963 an emission band was already bound to a different excitation bin width).
1964 NotImplementedError: If running against helios-core older than v1.3.72.
1965 """
1966 from .wrappers import URadiationModelWrapper as radiation_wrapper
1967 from .wrappers.DataTypes import SphericalCoord, vec3
1968 from .validation.plugins import (
1969 validate_camera_label, validate_band_labels_list, validate_antialiasing_samples
1970 )
1971
1972 validated_label = validate_camera_label(camera_label, "camera_label", "addSIFCamera")
1973 validated_bands = validate_band_labels_list(emission_band_labels, "emission_band_labels", "addSIFCamera")
1974 validated_samples = validate_antialiasing_samples(antialiasing_samples, "antialiasing_samples", "addSIFCamera")
1975
1976 if not isinstance(position, vec3):
1977 raise TypeError("position must be a vec3 object. Use vec3(x, y, z) to create one.")
1978
1979 if not isinstance(lookat_or_direction, (vec3, SphericalCoord)):
1980 raise TypeError("lookat_or_direction must be a vec3 or SphericalCoord object.")
1981
1982 if camera_properties is None:
1983 camera_properties = SIFCameraProperties()
1984 elif not isinstance(camera_properties, SIFCameraProperties):
1985 raise TypeError(
1986 "camera_properties must be a SIFCameraProperties instance "
1987 "(use SIFCameraProperties(...) — not the plain CameraProperties)."
1988 )
1989
1991 try:
1992 if isinstance(lookat_or_direction, SphericalCoord):
1993 # SphericalCoord.to_list() is [radius, elevation, zenith, azimuth];
1994 # the C wrapper expects azimuth (index 3), not zenith.
1995 direction_coords = lookat_or_direction.to_list()
1996 if len(direction_coords) < 4:
1997 raise ValueError("SphericalCoord must expose radius, elevation, zenith, and azimuth")
1998 radius, elevation, azimuth = direction_coords[0], direction_coords[1], direction_coords[3]
1999 radiation_wrapper.addSIFCameraSpherical(
2000 self.radiation_model,
2001 validated_label,
2002 validated_bands,
2003 position.x, position.y, position.z,
2004 radius, elevation, azimuth,
2005 camera_properties.to_array(),
2006 camera_properties.excitation_bin_width_nm,
2007 camera_properties.excitation_scattering_depth,
2008 validated_samples,
2009 camera_properties_obj=camera_properties,
2010 )
2011 else:
2012 radiation_wrapper.addSIFCameraVec3(
2013 self.radiation_model,
2014 validated_label,
2015 validated_bands,
2016 position.x, position.y, position.z,
2017 lookat_or_direction.x, lookat_or_direction.y, lookat_or_direction.z,
2018 camera_properties.to_array(),
2019 camera_properties.excitation_bin_width_nm,
2020 camera_properties.excitation_scattering_depth,
2021 validated_samples,
2022 camera_properties_obj=camera_properties,
2023 )
2024 except Exception as e:
2025 raise RadiationModelError(f"Failed to add SIF camera '{validated_label}': {e}")
2026
2027 @require_plugin('radiation', 'check SIF camera registration')
2028 def isSIFCamera(self, camera_label: str) -> bool:
2029 """
2030 Return True if the camera was registered via :meth:`addSIFCamera` (vs. ``addRadiationCamera``).
2031 """
2032 from .wrappers import URadiationModelWrapper as radiation_wrapper
2033 if not isinstance(camera_label, str) or not camera_label.strip():
2034 raise ValueError("Camera label must be a non-empty string")
2036 return radiation_wrapper.isSIFCamera(self.radiation_model, camera_label)
2037
2038 @require_plugin('radiation', 'enable camera flux smoothing')
2039 def enableCameraFluxSmoothing(self, crease_angle_degrees: float = 30.0) -> None:
2040 """
2041 Reconstruct camera images by interpolating outgoing flux across each facet.
2042
2043 Averages each primitive's outgoing flux onto the mesh vertices it shares with its
2044 neighbours and interpolates it back across the facet, so a coarsely tessellated
2045 curved surface reads as a curve rather than as a set of flat panels. This applies
2046 to Tube, Sphere, Cone, Polymesh, Tile and AdaptiveTile objects. A Box, a Disk, and
2047 any primitive belonging to no object are left alone, since their faces are genuinely
2048 flat. A polymesh must carry the face table that :meth:`Context.loadOBJ` and
2049 :meth:`Context.loadPLY` retain; one assembled from loose primitives by
2050 :meth:`Context.addPolymeshObject` has no topology and is left alone.
2051
2052 Smoothing is off by default and should be enabled deliberately: it changes what the
2053 camera reports. A pixel no longer carries the outgoing flux of the primitive behind
2054 it, but a blend of that primitive's value with its neighbours', so pixel values no
2055 longer correspond exactly to the ``radiation_flux_*`` primitive data. The radiation
2056 solve, those primitive data values, and the pixel-label and depth images are all
2057 unchanged.
2058
2059 Args:
2060 crease_angle_degrees: Angle between adjacent facet normals above which a shared
2061 edge is treated as a hard crease, so flux is not averaged across it. Applies
2062 to polymesh objects only; the curved object types are smooth by construction
2063 and are never creased. Must be between 0 and 180 degrees. Defaults to 30.
2064
2065 Raises:
2066 RadiationModelError: If the crease angle is out of range or the call fails
2067 RuntimeError: If the native library predates helios-core v1.3.84
2068
2069 Note:
2070 If geometry has already been built, this re-uploads it and rebuilds the
2071 acceleration structure, which is not cheap. Prefer calling it before
2072 :meth:`updateGeometry`.
2073
2074 Example:
2075 >>> with RadiationModel(context) as radiation:
2076 ... radiation.enableCameraFluxSmoothing(crease_angle_degrees=45.0)
2077 ... radiation.updateGeometry()
2078 """
2079 from .wrappers import URadiationModelWrapper as radiation_wrapper
2080 if not isinstance(crease_angle_degrees, (int, float)) or isinstance(crease_angle_degrees, bool):
2081 raise ValueError(
2082 f"crease_angle_degrees must be a number, got {type(crease_angle_degrees).__name__}"
2083 )
2085 try:
2086 radiation_wrapper.enableCameraFluxSmoothing(self.radiation_model, float(crease_angle_degrees))
2087 except RuntimeError:
2088 raise
2089 except Exception as e:
2090 raise RadiationModelError(f"Failed to enable camera flux smoothing: {e}")
2091
2092 @require_plugin('radiation', 'disable camera flux smoothing')
2093 def disableCameraFluxSmoothing(self) -> None:
2094 """
2095 Reconstruct camera images by holding the outgoing flux constant across each facet.
2096
2097 This is the default. Each pixel reports the outgoing flux of the primitive behind it.
2098
2099 Raises:
2100 RadiationModelError: If the call fails
2101 RuntimeError: If the native library predates helios-core v1.3.84
2102
2103 Note:
2104 If geometry has already been built, this re-uploads it and rebuilds the
2105 acceleration structure.
2106 """
2107 from .wrappers import URadiationModelWrapper as radiation_wrapper
2109 try:
2110 radiation_wrapper.disableCameraFluxSmoothing(self.radiation_model)
2111 except RuntimeError:
2112 raise
2113 except Exception as e:
2114 raise RadiationModelError(f"Failed to disable camera flux smoothing: {e}")
2115
2116 @require_plugin('radiation', 'query camera flux smoothing state')
2117 def isCameraFluxSmoothingEnabled(self) -> bool:
2118 """
2119 Return True if camera images are reconstructed by interpolating flux across each facet.
2120
2121 Raises:
2122 RuntimeError: If the native library predates helios-core v1.3.84
2123 """
2124 from .wrappers import URadiationModelWrapper as radiation_wrapper
2126 return radiation_wrapper.isCameraFluxSmoothingEnabled(self.radiation_model)
2127
2128 @require_plugin('radiation', 'query camera flux smoothing crease angle')
2129 def getCameraFluxSmoothingCreaseAngle(self) -> float:
2130 """
2131 Return the crease angle in degrees used by camera flux smoothing.
2132
2133 This is the value last passed to :meth:`enableCameraFluxSmoothing`, or 30.0 if it
2134 has never been called.
2135
2136 Raises:
2137 RuntimeError: If the native library predates helios-core v1.3.84
2138 """
2139 from .wrappers import URadiationModelWrapper as radiation_wrapper
2141 return radiation_wrapper.getCameraFluxSmoothingCreaseAngle(self.radiation_model)
2142
2143 @require_plugin('radiation', 'manage camera position')
2144 def setCameraPosition(self, camera_label: str, position):
2145 """
2146 Set camera position.
2147
2148 Allows dynamic camera repositioning during simulation, useful for
2149 time-series captures or multi-view imaging.
2150
2151 Args:
2152 camera_label: Camera label string
2153 position: Camera position as vec3 or list [x, y, z]
2155 Example:
2156 >>> radiation.setCameraPosition("cam1", [0, 0, 10])
2157 >>> from pyhelios.types import vec3
2158 >>> radiation.setCameraPosition("cam1", vec3(5, 5, 10))
2159 """
2160 if not isinstance(camera_label, str) or not camera_label.strip():
2161 raise ValueError("Camera label must be a non-empty string")
2162 validate_position_like(position, "position", "setCameraPosition")
2164 radiation_wrapper.setCameraPosition(self.radiation_model, camera_label, position)
2165 logger.debug(f"Updated camera '{camera_label}' position")
2166
2167 @require_plugin('radiation', 'query camera position')
2168 def getCameraPosition(self, camera_label: str):
2169 """
2170 Get camera position.
2171
2172 Args:
2173 camera_label: Camera label string
2174
2175 Returns:
2176 vec3 position of the camera
2177
2178 Example:
2179 >>> position = radiation.getCameraPosition("cam1")
2180 >>> print(f"Camera at: {position}")
2181 """
2182 if not isinstance(camera_label, str) or not camera_label.strip():
2183 raise ValueError("Camera label must be a non-empty string")
2185 position_list = radiation_wrapper.getCameraPosition(self.radiation_model, camera_label)
2186 from .wrappers.DataTypes import vec3
2187 return vec3(position_list[0], position_list[1], position_list[2])
2188
2189 @require_plugin('radiation', 'manage camera lookat')
2190 def setCameraLookat(self, camera_label: str, lookat):
2191 """
2192 Set camera lookat point.
2193
2194 Args:
2195 camera_label: Camera label string
2196 lookat: Lookat point as vec3 or list [x, y, z]
2198 Example:
2199 >>> radiation.setCameraLookat("cam1", [0, 0, 0])
2200 """
2201 if not isinstance(camera_label, str) or not camera_label.strip():
2202 raise ValueError("Camera label must be a non-empty string")
2203 validate_position_like(lookat, "lookat", "setCameraLookat")
2205 radiation_wrapper.setCameraLookat(self.radiation_model, camera_label, lookat)
2206 logger.debug(f"Updated camera '{camera_label}' lookat point")
2207
2208 @require_plugin('radiation', 'query camera lookat')
2209 def getCameraLookat(self, camera_label: str):
2210 """
2211 Get camera lookat point.
2212
2213 Args:
2214 camera_label: Camera label string
2215
2216 Returns:
2217 vec3 lookat point
2218
2219 Example:
2220 >>> lookat = radiation.getCameraLookat("cam1")
2221 >>> print(f"Camera looking at: {lookat}")
2222 """
2223 if not isinstance(camera_label, str) or not camera_label.strip():
2224 raise ValueError("Camera label must be a non-empty string")
2226 lookat_list = radiation_wrapper.getCameraLookat(self.radiation_model, camera_label)
2227 from .wrappers.DataTypes import vec3
2228 return vec3(lookat_list[0], lookat_list[1], lookat_list[2])
2229
2230 @require_plugin('radiation', 'manage camera orientation')
2231 def setCameraOrientation(self, camera_label: str, direction):
2232 """
2233 Set camera orientation.
2234
2235 Args:
2236 camera_label: Camera label string
2237 direction: View direction as vec3, SphericalCoord, or list [x, y, z]
2239 Example:
2240 >>> radiation.setCameraOrientation("cam1", [0, 0, 1])
2241 >>> from pyhelios.types import SphericalCoord
2242 >>> radiation.setCameraOrientation("cam1", SphericalCoord(1.0, 45.0, 90.0))
2243 """
2244 if not isinstance(camera_label, str) or not camera_label.strip():
2245 raise ValueError("Camera label must be a non-empty string")
2246 validate_direction_like(direction, "direction", "setCameraOrientation")
2248 radiation_wrapper.setCameraOrientation(self.radiation_model, camera_label, direction)
2249 logger.debug(f"Updated camera '{camera_label}' orientation")
2250
2251 @require_plugin('radiation', 'query camera orientation')
2252 def getCameraOrientation(self, camera_label: str):
2253 """
2254 Get camera orientation.
2255
2256 Args:
2257 camera_label: Camera label string
2258
2259 Returns:
2260 SphericalCoord orientation [radius, elevation, azimuth]
2261
2262 Example:
2263 >>> orientation = radiation.getCameraOrientation("cam1")
2264 >>> print(f"Camera orientation: {orientation}")
2265 """
2266 if not isinstance(camera_label, str) or not camera_label.strip():
2267 raise ValueError("Camera label must be a non-empty string")
2269 orientation_list = radiation_wrapper.getCameraOrientation(self.radiation_model, camera_label)
2270 from .wrappers.DataTypes import SphericalCoord
2271 return SphericalCoord(orientation_list[0], orientation_list[1], orientation_list[2])
2272
2273 @require_plugin('radiation', 'query cameras')
2274 def getAllCameraLabels(self) -> List[str]:
2275 """
2276 Get all camera labels.
2277
2278 Returns:
2279 List of all camera label strings
2280
2281 Example:
2282 >>> cameras = radiation.getAllCameraLabels()
2283 >>> print(f"Available cameras: {cameras}")
2284 """
2286 return radiation_wrapper.getAllCameraLabels(self.radiation_model)
2287
2288 @require_plugin('radiation', 'configure camera spectral response')
2289 def setCameraSpectralResponse(self, camera_label: str, band_label: str, global_data: str):
2290 """
2291 Set camera spectral response from global data.
2292
2293 Args:
2294 camera_label: Camera label
2295 band_label: Band label
2296 global_data: Global data label for spectral response curve
2297
2298 Example:
2299 >>> radiation.setCameraSpectralResponse("cam1", "red", "sensor_red_response")
2300 """
2301 if not isinstance(camera_label, str) or not camera_label.strip():
2302 raise ValueError("Camera label must be a non-empty string")
2303 validate_band_label(band_label, "band_label", "setCameraSpectralResponse")
2304 if not isinstance(global_data, str) or not global_data.strip():
2305 raise ValueError("Global data label must be a non-empty string")
2306
2308 radiation_wrapper.setCameraSpectralResponse(self.radiation_model, camera_label, band_label, global_data)
2309 logger.debug(f"Set spectral response for camera '{camera_label}', band '{band_label}'")
2311 camera_label, [band_label], "setCameraSpectralResponse")
2312
2313 @require_plugin('radiation', 'configure camera from library')
2314 def setCameraSpectralResponseFromLibrary(self, camera_label: str, camera_library_name: str):
2315 """
2316 Set camera spectral response from standard camera library.
2317
2318 Uses pre-defined spectral response curves for common cameras.
2319
2320 Args:
2321 camera_label: Camera label
2322 camera_library_name: Standard camera name (e.g., "iPhone13", "NikonD850", "CanonEOS5D")
2323
2324 Example:
2325 >>> radiation.setCameraSpectralResponseFromLibrary("cam1", "iPhone13")
2326 """
2327 if not isinstance(camera_label, str) or not camera_label.strip():
2328 raise ValueError("Camera label must be a non-empty string")
2329 if not isinstance(camera_library_name, str) or not camera_library_name.strip():
2330 raise ValueError("Camera library name must be a non-empty string")
2331
2333 radiation_wrapper.setCameraSpectralResponseFromLibrary(self.radiation_model, camera_label, camera_library_name)
2334 logger.debug(f"Set camera '{camera_label}' response from library: {camera_library_name}")
2335
2336 @require_plugin('radiation', 'get camera pixel data')
2337 def getCameraPixelData(self, camera_label: str, band_label: str) -> List[float]:
2338 """
2339 Get camera pixel data for specific band.
2340
2341 Retrieves raw pixel values for programmatic access and analysis.
2343 Args:
2344 camera_label: Camera label
2345 band_label: Band label
2346
2347 Returns:
2348 List of pixel values
2349
2350 Example:
2351 >>> pixels = radiation.getCameraPixelData("cam1", "red")
2352 >>> print(f"Mean pixel value: {sum(pixels)/len(pixels)}")
2353 """
2354 if not isinstance(camera_label, str) or not camera_label.strip():
2355 raise ValueError("Camera label must be a non-empty string")
2356 validate_band_label(band_label, "band_label", "getCameraPixelData")
2357
2359 return radiation_wrapper.getCameraPixelData(self.radiation_model, camera_label, band_label)
2360
2361 @require_plugin('radiation', 'set camera pixel data')
2362 def setCameraPixelData(self, camera_label: str, band_label: str, pixel_data: List[float]):
2363 """
2364 Set camera pixel data for specific band.
2365
2366 Allows programmatic modification of pixel values.
2367
2368 Args:
2369 camera_label: Camera label
2370 band_label: Band label
2371 pixel_data: List of pixel values
2372
2373 Example:
2374 >>> pixels = radiation.getCameraPixelData("cam1", "red")
2375 >>> modified_pixels = [p * 1.2 for p in pixels] # Brighten by 20%
2376 >>> radiation.setCameraPixelData("cam1", "red", modified_pixels)
2377 """
2378 if not isinstance(camera_label, str) or not camera_label.strip():
2379 raise ValueError("Camera label must be a non-empty string")
2380 validate_band_label(band_label, "band_label", "setCameraPixelData")
2381 if not isinstance(pixel_data, (list, tuple)):
2382 raise ValueError("Pixel data must be a list or tuple")
2383
2385 radiation_wrapper.setCameraPixelData(self.radiation_model, camera_label, band_label, pixel_data)
2386 logger.debug(f"Set pixel data for camera '{camera_label}', band '{band_label}': {len(pixel_data)} pixels")
2387
2388 # =========================================================================
2389 # Camera Library Functions (v1.3.58+)
2390 # =========================================================================
2391
2392 @require_plugin('radiation', 'add camera from library')
2393 def addRadiationCameraFromLibrary(self, camera_label: str, library_camera_label: str,
2394 position, lookat, antialiasing_samples: int = 1,
2395 band_labels: Optional[List[str]] = None):
2396 """
2397 Add radiation camera loading all properties from camera library.
2398
2399 Loads camera intrinsic parameters (resolution, FOV, sensor size) and spectral
2400 response data from the camera library XML file. This is the recommended way to
2401 create realistic cameras with proper spectral responses.
2402
2403 Args:
2404 camera_label: Label for the camera instance
2405 library_camera_label: Label of camera in library (e.g., "Canon_20D", "iPhone11", "NikonD700")
2406 position: Camera position as vec3 or (x, y, z) tuple
2407 lookat: Lookat point as vec3 or (x, y, z) tuple
2408 antialiasing_samples: Number of ray samples per pixel. Default: 1
2409 band_labels: Optional custom band labels. If None, uses library defaults.
2410
2411 Raises:
2412 RadiationModelError: If operation fails
2413 ValueError: If parameters are invalid
2414
2415 Note:
2416 Available cameras in plugins/radiation/camera_library/camera_library.xml include:
2417 - Canon_20D, Nikon_D700, Nikon_D50
2418 - iPhone11, iPhone12ProMAX
2419 - Additional cameras available in library
2420
2421 Example:
2422 >>> radiation.addRadiationCameraFromLibrary(
2423 ... camera_label="cam1",
2424 ... library_camera_label="iPhone11",
2425 ... position=(0, -5, 1),
2426 ... lookat=(0, 0, 0.5),
2427 ... antialiasing_samples=10
2428 ... )
2429 """
2430 validate_band_label(camera_label, "camera_label", "addRadiationCameraFromLibrary")
2431 validate_position_like(position, "position", "addRadiationCameraFromLibrary")
2432 validate_position_like(lookat, "lookat", "addRadiationCameraFromLibrary")
2433
2435 try:
2436 radiation_wrapper.addRadiationCameraFromLibrary(
2437 self.radiation_model, camera_label, library_camera_label,
2438 position, lookat, antialiasing_samples, band_labels
2439 )
2440 logger.info(f"Added camera '{camera_label}' from library '{library_camera_label}'")
2441 # A library camera always brings measured spectral response curves.
2442 if band_labels:
2444 camera_label, band_labels, "addRadiationCameraFromLibrary")
2445 except Exception as e:
2446 raise RadiationModelError(f"Failed to add camera from library: {e}")
2447
2448 @require_plugin('radiation', 'update camera parameters')
2449 def updateCameraParameters(self, camera_label: str, camera_properties: CameraProperties):
2450 """
2451 Update camera parameters for an existing camera.
2452
2453 Allows modification of camera properties after creation while preserving
2454 position, lookat direction, and spectral band configuration.
2455
2456 Args:
2457 camera_label: Label for the camera to update
2458 camera_properties: CameraProperties instance with new parameters
2459
2460 Raises:
2461 RadiationModelError: If operation fails or camera doesn't exist
2462 ValueError: If parameters are invalid
2463
2464 Note:
2465 FOV_aspect_ratio is automatically recalculated from camera_resolution.
2466 Camera position and lookat are preserved.
2467
2468 Warning:
2469 **Changing camera_resolution discards the camera's existing image data.**
2470 The per-pixel buffers are sized to the resolution the camera was rendered
2471 at, so helios-core v1.3.79+ clears them rather than reinterpret them at the
2472 new size. The camera must be re-rendered with :meth:`runBand` before its
2473 image can be written again; :meth:`writeCameraImage` will otherwise report
2474 that the camera has no rendered pixel data. Changing any other parameter
2475 leaves existing image data intact.
2476
2477 Example:
2478 >>> props = CameraProperties(
2479 ... camera_resolution=(1920, 1080),
2480 ... HFOV=35.0,
2481 ... lens_focal_length=0.085 # 85mm lens
2482 ... )
2483 >>> radiation.updateCameraParameters("cam1", props)
2484 >>> # Resolution changed, so re-render before writing an image.
2485 >>> radiation.runBand(["red", "green", "blue"])
2486 >>> radiation.writeCameraImage("cam1", ["red", "green", "blue"], "out")
2487 """
2488 validate_band_label(camera_label, "camera_label", "updateCameraParameters")
2489
2490 if not isinstance(camera_properties, CameraProperties):
2491 raise ValueError("camera_properties must be a CameraProperties instance")
2492
2494 try:
2495 radiation_wrapper.updateCameraParameters(self.radiation_model, camera_label, camera_properties)
2496 logger.debug(f"Updated parameters for camera '{camera_label}'")
2497 except Exception as e:
2498 raise RadiationModelError(f"Failed to update camera parameters: {e}")
2499
2500 @require_plugin('radiation', 'enable camera metadata')
2501 def enableCameraMetadata(self, camera_labels):
2502 """
2503 Enable automatic JSON metadata file writing for camera(s).
2504
2505 When enabled, writeCameraImage() automatically creates a JSON metadata file
2506 alongside the image containing comprehensive camera and scene information.
2507
2508 Args:
2509 camera_labels: Single camera label (str) or list of camera labels (List[str])
2510
2511 Raises:
2512 RadiationModelError: If operation fails
2513 ValueError: If parameters are invalid
2514
2515 Note:
2516 Metadata includes:
2517 - Camera properties (model, lens, sensor specs)
2518 - Geographic location (latitude, longitude)
2519 - Acquisition settings (date, time, exposure, white balance)
2520 - Agronomic data (plant species, heights, phenology stages)
2521
2522 Example:
2523 >>> # Enable for single camera
2524 >>> radiation.enableCameraMetadata("cam1")
2525 >>>
2526 >>> # Enable for multiple cameras
2527 >>> radiation.enableCameraMetadata(["cam1", "cam2", "cam3"])
2528 """
2530 try:
2531 radiation_wrapper.enableCameraMetadata(self.radiation_model, camera_labels)
2532 if isinstance(camera_labels, str):
2533 logger.info(f"Enabled metadata for camera '{camera_labels}'")
2534 else:
2535 logger.info(f"Enabled metadata for {len(camera_labels)} cameras")
2536 except Exception as e:
2537 raise RadiationModelError(f"Failed to enable camera metadata: {e}")
2538
2539 @require_plugin('radiation', 'write camera images')
2540 def writeCameraImage(self, camera: str, bands: List[str], imagefile_base: str,
2541 image_path: str = "./", frame: int = -1,
2542 flux_to_pixel_conversion: float = 1.0) -> str:
2543 """
2544 Write camera image to file and return output filename.
2545
2546 Args:
2547 camera: Camera label
2548 bands: List of band labels to include in the image
2549 imagefile_base: Base filename for output
2550 image_path: Output directory path (default: current directory)
2551 frame: Frame number to write (-1 for all frames)
2552 flux_to_pixel_conversion: Conversion factor from flux to pixel values
2553
2554 Returns:
2555 Output filename string
2556
2557 Raises:
2558 RadiationModelError: If camera image writing fails
2559 TypeError: If parameters have incorrect types
2560
2561 Note:
2562 helios-core v1.3.79+ reports a failed write by returning an empty
2563 filename rather than raising. PyHelios converts that into a
2564 RadiationModelError so a write that produced no file can never be
2565 mistaken for a success.
2566 """
2567 # Validate inputs
2568 if not isinstance(camera, str) or not camera.strip():
2569 raise TypeError("Camera label must be a non-empty string")
2570 if not isinstance(bands, list) or not bands:
2571 raise TypeError("Bands must be a non-empty list of strings")
2572 if not all(isinstance(band, str) and band.strip() for band in bands):
2573 raise TypeError("All band labels must be non-empty strings")
2574 if not isinstance(imagefile_base, str) or not imagefile_base.strip():
2575 raise TypeError("Image file base must be a non-empty string")
2576 if not isinstance(image_path, str):
2577 raise TypeError("Image path must be a string")
2578 if not isinstance(frame, int):
2579 raise TypeError("Frame must be an integer")
2580 if not isinstance(flux_to_pixel_conversion, (int, float)) or flux_to_pixel_conversion <= 0:
2581 raise TypeError("Flux to pixel conversion must be a positive number")
2582
2584 self._check_camera_has_pixel_data(camera, bands, "write camera image")
2585 filename = radiation_wrapper.writeCameraImage(
2586 self.radiation_model, camera, bands, imagefile_base,
2587 image_path, frame, flux_to_pixel_conversion)
2588
2589 self._check_image_was_written(filename, camera, bands, image_path,
2590 "write camera image")
2591 logger.info(f"Camera image written to: {filename}")
2592 return filename
2593
2594 @require_plugin('radiation', 'write normalized camera images')
2595 def writeNormCameraImage(self, camera: str, bands: List[str], imagefile_base: str,
2596 image_path: str = "./", frame: int = -1) -> str:
2597 """
2598 Write normalized camera image to file and return output filename.
2599
2600 Args:
2601 camera: Camera label
2602 bands: List of band labels to include in the image
2603 imagefile_base: Base filename for output
2604 image_path: Output directory path (default: current directory)
2605 frame: Frame number to write (-1 for all frames)
2606
2607 Returns:
2608 Output filename string
2609
2610 Raises:
2611 RadiationModelError: If normalized camera image writing fails
2612 TypeError: If parameters have incorrect types
2613
2614 Note:
2615 helios-core v1.3.79+ reports a failed write by returning an empty
2616 filename rather than raising. PyHelios converts that into a
2617 RadiationModelError so a write that produced no file can never be
2618 mistaken for a success.
2619 """
2620 # Validate inputs
2621 if not isinstance(camera, str) or not camera.strip():
2622 raise TypeError("Camera label must be a non-empty string")
2623 if not isinstance(bands, list) or not bands:
2624 raise TypeError("Bands must be a non-empty list of strings")
2625 if not all(isinstance(band, str) and band.strip() for band in bands):
2626 raise TypeError("All band labels must be non-empty strings")
2627 if not isinstance(imagefile_base, str) or not imagefile_base.strip():
2628 raise TypeError("Image file base must be a non-empty string")
2629 if not isinstance(image_path, str):
2630 raise TypeError("Image path must be a string")
2631 if not isinstance(frame, int):
2632 raise TypeError("Frame must be an integer")
2633
2635 self._check_camera_has_pixel_data(camera, bands, "write normalized camera image")
2636 filename = radiation_wrapper.writeNormCameraImage(
2637 self.radiation_model, camera, bands, imagefile_base, image_path, frame)
2638
2639 self._check_image_was_written(filename, camera, bands, image_path,
2640 "write normalized camera image")
2641 logger.info(f"Normalized camera image written to: {filename}")
2642 return filename
2643
2644 @require_plugin('radiation', 'write camera image data')
2645 def writeCameraImageData(self, camera: str, band: str, imagefile_base: str,
2646 image_path: str = "./", frame: int = -1):
2647 """
2648 Write camera image data to file (ASCII format).
2649
2650 Args:
2651 camera: Camera label
2652 band: Band label
2653 imagefile_base: Base filename for output
2654 image_path: Output directory path (default: current directory)
2655 frame: Frame number to write (-1 for all frames)
2656
2657 Raises:
2658 RadiationModelError: If camera image data writing fails
2659 TypeError: If parameters have incorrect types
2660 """
2661 # Validate inputs
2662 if not isinstance(camera, str) or not camera.strip():
2663 raise TypeError("Camera label must be a non-empty string")
2664 if not isinstance(band, str) or not band.strip():
2665 raise TypeError("Band label must be a non-empty string")
2666 if not isinstance(imagefile_base, str) or not imagefile_base.strip():
2667 raise TypeError("Image file base must be a non-empty string")
2668 if not isinstance(image_path, str):
2669 raise TypeError("Image path must be a string")
2670 if not isinstance(frame, int):
2671 raise TypeError("Frame must be an integer")
2672
2674 radiation_wrapper.writeCameraImageData(
2675 self.radiation_model, camera, band, imagefile_base, image_path, frame)
2676
2677 logger.info(f"Camera image data written for camera {camera}, band {band}")
2678
2679 @require_plugin('radiation', 'write primitive data label map')
2680 def writePrimitiveDataLabelMap(self, camera: str, primitive_data_label: str, imagefile_base: str,
2681 image_path: str = "./", frame: int = -1, padvalue: float = float('nan')):
2682 """
2683 Write a per-pixel primitive-data label map for a camera to a text file.
2684
2685 For each camera pixel, writes the value of ``primitive_data_label`` on the primitive
2686 seen at that pixel. Pixels that hit no geometry (or a primitive lacking the data) are
2687 written as ``padvalue`` (NaN by default). The primitive data must be of type
2688 float, double, uint, or int. The radiation model must have been run
2689 (``updateGeometry`` + ``runBand``) so that per-pixel primitive labels exist.
2690
2691 The output file is written row-by-row (one line per image row), so it loads directly
2692 into a 2D ``(height, width)`` array. See :meth:`getPrimitiveDataLabelMap` for a
2693 convenience that returns a NumPy array instead of a file on disk.
2694
2695 Output filename: ``{camera}_{imagefile_base}.txt`` when ``frame < 0`` (default), or
2696 ``{camera}_{imagefile_base}_{frame:05d}.txt`` when ``frame >= 0``.
2697
2698 Args:
2699 camera: Camera label
2700 primitive_data_label: Primitive data label to map (float/double/uint/int)
2701 imagefile_base: Base filename for output
2702 image_path: Output directory path (default: current directory)
2703 frame: Frame number to write (-1 to omit the frame suffix)
2704 padvalue: Value written for empty/background pixels (default: NaN)
2705
2706 Raises:
2707 RadiationModelError: If the label map writing fails
2708 TypeError: If parameters have incorrect types
2709
2710 Example:
2711 >>> radiation.writePrimitiveDataLabelMap(
2712 ... camera="main_cam", primitive_data_label="leaf_id",
2713 ... imagefile_base="leaf_labels", image_path="./output")
2714 """
2715 # Validate inputs
2716 if not isinstance(camera, str) or not camera.strip():
2717 raise TypeError("Camera label must be a non-empty string")
2718 if not isinstance(primitive_data_label, str) or not primitive_data_label.strip():
2719 raise TypeError("Primitive data label must be a non-empty string")
2720 if not isinstance(imagefile_base, str) or not imagefile_base.strip():
2721 raise TypeError("Image file base must be a non-empty string")
2722 if not isinstance(image_path, str):
2723 raise TypeError("Image path must be a string")
2724 if not isinstance(frame, int):
2725 raise TypeError("Frame must be an integer")
2726 if not isinstance(padvalue, (int, float)) or isinstance(padvalue, bool):
2727 raise TypeError("Pad value must be a numeric type")
2728
2730 radiation_wrapper.writePrimitiveDataLabelMap(
2731 self.radiation_model, camera, primitive_data_label, imagefile_base,
2732 image_path, frame, float(padvalue))
2733
2734 logger.info(f"Primitive data label map written for camera {camera}, label {primitive_data_label}")
2735
2736 @require_plugin('radiation', 'write object data label map')
2737 def writeObjectDataLabelMap(self, camera: str, object_data_label: str, imagefile_base: str,
2738 image_path: str = "./", frame: int = -1, padvalue: float = float('nan')):
2739 """
2740 Write a per-pixel object-data label map for a camera to a text file.
2741
2742 Identical to :meth:`writePrimitiveDataLabelMap` but maps the value of an object-data
2743 label (compound-object data) rather than primitive data. The object data must be of
2744 type float, double, uint, or int. The radiation model must have been run
2745 (``updateGeometry`` + ``runBand``) so that per-pixel labels exist.
2746
2747 Output filename: ``{camera}_{imagefile_base}.txt`` when ``frame < 0`` (default), or
2748 ``{camera}_{imagefile_base}_{frame:05d}.txt`` when ``frame >= 0``.
2749
2750 Args:
2751 camera: Camera label
2752 object_data_label: Object data label to map (float/double/uint/int)
2753 imagefile_base: Base filename for output
2754 image_path: Output directory path (default: current directory)
2755 frame: Frame number to write (-1 to omit the frame suffix)
2756 padvalue: Value written for empty/background pixels (default: NaN)
2757
2758 Raises:
2759 RadiationModelError: If the label map writing fails
2760 TypeError: If parameters have incorrect types
2761 """
2762 # Validate inputs
2763 if not isinstance(camera, str) or not camera.strip():
2764 raise TypeError("Camera label must be a non-empty string")
2765 if not isinstance(object_data_label, str) or not object_data_label.strip():
2766 raise TypeError("Object data label must be a non-empty string")
2767 if not isinstance(imagefile_base, str) or not imagefile_base.strip():
2768 raise TypeError("Image file base must be a non-empty string")
2769 if not isinstance(image_path, str):
2770 raise TypeError("Image path must be a string")
2771 if not isinstance(frame, int):
2772 raise TypeError("Frame must be an integer")
2773 if not isinstance(padvalue, (int, float)) or isinstance(padvalue, bool):
2774 raise TypeError("Pad value must be a numeric type")
2775
2777 radiation_wrapper.writeObjectDataLabelMap(
2778 self.radiation_model, camera, object_data_label, imagefile_base,
2779 image_path, frame, float(padvalue))
2780
2781 logger.info(f"Object data label map written for camera {camera}, label {object_data_label}")
2782
2783 @require_plugin('radiation', 'read primitive data label map')
2784 def getPrimitiveDataLabelMap(self, camera: str, primitive_data_label: str,
2785 padvalue: float = float('nan')) -> 'np.ndarray':
2786 """
2787 Return a per-pixel primitive-data label map for a camera as a NumPy array.
2788
2789 Convenience wrapper around :meth:`writePrimitiveDataLabelMap` for the common use case
2790 of per-pixel masking in Python: the label map is written to a temporary file, loaded
2791 with ``numpy.loadtxt``, and returned as a 2D array. The file does not persist.
2792
2793 Args:
2794 camera: Camera label
2795 primitive_data_label: Primitive data label to map (float/double/uint/int)
2796 padvalue: Value used for empty/background pixels (default: NaN)
2797
2798 Returns:
2799 2D NumPy array of shape ``(height, width)`` (row-major) holding the primitive-data
2800 value at each pixel, with ``padvalue`` (NaN by default) where no labelled geometry
2801 was seen.
2802
2803 Raises:
2804 RadiationModelError: If the label map generation fails
2805 TypeError: If parameters have incorrect types
2806
2807 Example:
2808 >>> labels = radiation.getPrimitiveDataLabelMap("main_cam", "leaf_id")
2809 >>> mask = labels == 3 # per-pixel mask for primitive label 3
2810 >>> background = np.isnan(labels)
2811 """
2812 with tempfile.TemporaryDirectory() as tmpdir:
2813 imagefile_base = "labelmap"
2815 camera, primitive_data_label, imagefile_base,
2816 image_path=os.path.join(tmpdir, ""), frame=-1, padvalue=padvalue)
2817 # frame < 0 => filename has no frame suffix
2818 filepath = os.path.join(tmpdir, f"{camera}_{imagefile_base}.txt")
2819 labels = np.loadtxt(filepath)
2820
2821 # np.loadtxt collapses a single-row file to 1D; keep a consistent 2D shape.
2822 if labels.ndim == 1:
2823 labels = labels.reshape(1, -1)
2824 return labels
2825
2826 @require_plugin('radiation', 'read object data label map')
2827 def getObjectDataLabelMap(self, camera: str, object_data_label: str,
2828 padvalue: float = float('nan')) -> 'np.ndarray':
2829 """
2830 Return a per-pixel object-data label map for a camera as a NumPy array.
2831
2832 Convenience wrapper around :meth:`writeObjectDataLabelMap`; see
2833 :meth:`getPrimitiveDataLabelMap` for behaviour. The label map is written to a temporary
2834 file, loaded with ``numpy.loadtxt``, and returned as a 2D ``(height, width)`` array with
2835 ``padvalue`` (NaN by default) for background pixels. The file does not persist.
2836
2837 Args:
2838 camera: Camera label
2839 object_data_label: Object data label to map (float/double/uint/int)
2840 padvalue: Value used for empty/background pixels (default: NaN)
2841
2842 Returns:
2843 2D NumPy array of shape ``(height, width)`` (row-major).
2844
2845 Raises:
2846 RadiationModelError: If the label map generation fails
2847 TypeError: If parameters have incorrect types
2848 """
2849 with tempfile.TemporaryDirectory() as tmpdir:
2850 imagefile_base = "labelmap"
2852 camera, object_data_label, imagefile_base,
2853 image_path=os.path.join(tmpdir, ""), frame=-1, padvalue=padvalue)
2854 filepath = os.path.join(tmpdir, f"{camera}_{imagefile_base}.txt")
2855 labels = np.loadtxt(filepath)
2856
2857 if labels.ndim == 1:
2858 labels = labels.reshape(1, -1)
2859 return labels
2860
2861 @require_plugin('radiation', 'write image bounding boxes')
2862 def writeImageBoundingBoxes(self, camera_label: str,
2863 primitive_data_labels=None, object_data_labels=None,
2864 object_class_ids=None, image_file: str = "",
2865 classes_txt_file: str = "classes.txt",
2866 image_path: str = "./"):
2867 """
2868 Write image bounding boxes for object detection training.
2869
2870 Supports both single and multiple data labels. Either provide primitive_data_labels
2871 or object_data_labels, not both.
2872
2873 Args:
2874 camera_label: Camera label
2875 primitive_data_labels: Single primitive data label (str) or list of primitive data labels
2876 object_data_labels: Single object data label (str) or list of object data labels
2877 object_class_ids: Single class ID (int) or list of class IDs (must match data labels)
2878 image_file: Image filename
2879 classes_txt_file: Classes definition file (default: "classes.txt")
2880 image_path: Image output path (default: current directory)
2881
2882 Raises:
2883 RadiationModelError: If bounding box writing fails
2884 TypeError: If parameters have incorrect types
2885 ValueError: If both primitive and object data labels are provided, or neither
2886 """
2887 # Validate exclusive parameter usage
2888 if primitive_data_labels is not None and object_data_labels is not None:
2889 raise ValueError("Cannot specify both primitive_data_labels and object_data_labels")
2890 if primitive_data_labels is None and object_data_labels is None:
2891 raise ValueError("Must specify either primitive_data_labels or object_data_labels")
2892
2893 # Validate common parameters
2894 if not isinstance(camera_label, str) or not camera_label.strip():
2895 raise TypeError("Camera label must be a non-empty string")
2896 if not isinstance(image_file, str) or not image_file.strip():
2897 raise TypeError("Image file must be a non-empty string")
2898 if not isinstance(classes_txt_file, str):
2899 raise TypeError("Classes txt file must be a string")
2900 if not isinstance(image_path, str):
2901 raise TypeError("Image path must be a string")
2902
2903 # Handle primitive data labels
2904 if primitive_data_labels is not None:
2905 if isinstance(primitive_data_labels, str):
2906 # Single label
2907 if not isinstance(object_class_ids, int):
2908 raise TypeError("For single primitive data label, object_class_ids must be an integer")
2910 radiation_wrapper.writeImageBoundingBoxes(
2911 self.radiation_model, camera_label, primitive_data_labels,
2912 object_class_ids, image_file, classes_txt_file, image_path)
2913 logger.info(f"Image bounding boxes written for primitive data: {primitive_data_labels}")
2914
2915 elif isinstance(primitive_data_labels, list):
2916 # Multiple labels
2917 if not isinstance(object_class_ids, list):
2918 raise TypeError("For multiple primitive data labels, object_class_ids must be a list")
2919 if len(primitive_data_labels) != len(object_class_ids):
2920 raise ValueError("primitive_data_labels and object_class_ids must have the same length")
2921 if not all(isinstance(lbl, str) and lbl.strip() for lbl in primitive_data_labels):
2922 raise TypeError("All primitive data labels must be non-empty strings")
2923 if not all(isinstance(cid, int) for cid in object_class_ids):
2924 raise TypeError("All object class IDs must be integers")
2925
2927 radiation_wrapper.writeImageBoundingBoxesVector(
2928 self.radiation_model, camera_label, primitive_data_labels,
2929 object_class_ids, image_file, classes_txt_file, image_path)
2930 logger.info(f"Image bounding boxes written for {len(primitive_data_labels)} primitive data labels")
2931 else:
2932 raise TypeError("primitive_data_labels must be a string or list of strings")
2933
2934 # Handle object data labels
2935 elif object_data_labels is not None:
2936 if isinstance(object_data_labels, str):
2937 # Single label
2938 if not isinstance(object_class_ids, int):
2939 raise TypeError("For single object data label, object_class_ids must be an integer")
2941 radiation_wrapper.writeImageBoundingBoxes_ObjectData(
2942 self.radiation_model, camera_label, object_data_labels,
2943 object_class_ids, image_file, classes_txt_file, image_path)
2944 logger.info(f"Image bounding boxes written for object data: {object_data_labels}")
2945
2946 elif isinstance(object_data_labels, list):
2947 # Multiple labels
2948 if not isinstance(object_class_ids, list):
2949 raise TypeError("For multiple object data labels, object_class_ids must be a list")
2950 if len(object_data_labels) != len(object_class_ids):
2951 raise ValueError("object_data_labels and object_class_ids must have the same length")
2952 if not all(isinstance(lbl, str) and lbl.strip() for lbl in object_data_labels):
2953 raise TypeError("All object data labels must be non-empty strings")
2954 if not all(isinstance(cid, int) for cid in object_class_ids):
2955 raise TypeError("All object class IDs must be integers")
2956
2958 radiation_wrapper.writeImageBoundingBoxes_ObjectDataVector(
2959 self.radiation_model, camera_label, object_data_labels,
2960 object_class_ids, image_file, classes_txt_file, image_path)
2961 logger.info(f"Image bounding boxes written for {len(object_data_labels)} object data labels")
2962 else:
2963 raise TypeError("object_data_labels must be a string or list of strings")
2964
2965 @require_plugin('radiation', 'write image segmentation masks')
2966 def writeImageSegmentationMasks(self, camera_label: str,
2967 primitive_data_labels=None, object_data_labels=None,
2968 object_class_ids=None, json_filename: str = "",
2969 image_file: str = "", append_file: bool = False):
2970 """
2971 Write image segmentation masks in COCO JSON format.
2972
2973 Supports both single and multiple data labels. Either provide primitive_data_labels
2974 or object_data_labels, not both.
2975
2976 Args:
2977 camera_label: Camera label
2978 primitive_data_labels: Single primitive data label (str) or list of primitive data labels
2979 object_data_labels: Single object data label (str) or list of object data labels
2980 object_class_ids: Single class ID (int) or list of class IDs (must match data labels)
2981 json_filename: JSON output filename
2982 image_file: Image filename
2983 append_file: Whether to append to existing JSON file
2984
2985 Raises:
2986 RadiationModelError: If segmentation mask writing fails
2987 TypeError: If parameters have incorrect types
2988 ValueError: If both primitive and object data labels are provided, or neither
2989 """
2990 # Validate exclusive parameter usage
2991 if primitive_data_labels is not None and object_data_labels is not None:
2992 raise ValueError("Cannot specify both primitive_data_labels and object_data_labels")
2993 if primitive_data_labels is None and object_data_labels is None:
2994 raise ValueError("Must specify either primitive_data_labels or object_data_labels")
2995
2996 # Validate common parameters
2997 if not isinstance(camera_label, str) or not camera_label.strip():
2998 raise TypeError("Camera label must be a non-empty string")
2999 if not isinstance(json_filename, str) or not json_filename.strip():
3000 raise TypeError("JSON filename must be a non-empty string")
3001 if not isinstance(image_file, str) or not image_file.strip():
3002 raise TypeError("Image file must be a non-empty string")
3003 if not isinstance(append_file, bool):
3004 raise TypeError("append_file must be a boolean")
3005
3006 # Handle primitive data labels
3007 if primitive_data_labels is not None:
3008 if isinstance(primitive_data_labels, str):
3009 # Single label
3010 if not isinstance(object_class_ids, int):
3011 raise TypeError("For single primitive data label, object_class_ids must be an integer")
3013 radiation_wrapper.writeImageSegmentationMasks(
3014 self.radiation_model, camera_label, primitive_data_labels,
3015 object_class_ids, json_filename, image_file, append_file)
3016 logger.info(f"Image segmentation masks written for primitive data: {primitive_data_labels}")
3017
3018 elif isinstance(primitive_data_labels, list):
3019 # Multiple labels
3020 if not isinstance(object_class_ids, list):
3021 raise TypeError("For multiple primitive data labels, object_class_ids must be a list")
3022 if len(primitive_data_labels) != len(object_class_ids):
3023 raise ValueError("primitive_data_labels and object_class_ids must have the same length")
3024 if not all(isinstance(lbl, str) and lbl.strip() for lbl in primitive_data_labels):
3025 raise TypeError("All primitive data labels must be non-empty strings")
3026 if not all(isinstance(cid, int) for cid in object_class_ids):
3027 raise TypeError("All object class IDs must be integers")
3028
3030 radiation_wrapper.writeImageSegmentationMasksVector(
3031 self.radiation_model, camera_label, primitive_data_labels,
3032 object_class_ids, json_filename, image_file, append_file)
3033 logger.info(f"Image segmentation masks written for {len(primitive_data_labels)} primitive data labels")
3034 else:
3035 raise TypeError("primitive_data_labels must be a string or list of strings")
3036
3037 # Handle object data labels
3038 elif object_data_labels is not None:
3039 if isinstance(object_data_labels, str):
3040 # Single label
3041 if not isinstance(object_class_ids, int):
3042 raise TypeError("For single object data label, object_class_ids must be an integer")
3044 radiation_wrapper.writeImageSegmentationMasks_ObjectData(
3045 self.radiation_model, camera_label, object_data_labels,
3046 object_class_ids, json_filename, image_file, append_file)
3047 logger.info(f"Image segmentation masks written for object data: {object_data_labels}")
3048
3049 elif isinstance(object_data_labels, list):
3050 # Multiple labels
3051 if not isinstance(object_class_ids, list):
3052 raise TypeError("For multiple object data labels, object_class_ids must be a list")
3053 if len(object_data_labels) != len(object_class_ids):
3054 raise ValueError("object_data_labels and object_class_ids must have the same length")
3055 if not all(isinstance(lbl, str) and lbl.strip() for lbl in object_data_labels):
3056 raise TypeError("All object data labels must be non-empty strings")
3057 if not all(isinstance(cid, int) for cid in object_class_ids):
3058 raise TypeError("All object class IDs must be integers")
3059
3061 radiation_wrapper.writeImageSegmentationMasks_ObjectDataVector(
3062 self.radiation_model, camera_label, object_data_labels,
3063 object_class_ids, json_filename, image_file, append_file)
3064 logger.info(f"Image segmentation masks written for {len(object_data_labels)} object data labels")
3065 else:
3066 raise TypeError("object_data_labels must be a string or list of strings")
3067
3068 @require_plugin('radiation', 'auto-calibrate camera image')
3069 def autoCalibrateCameraImage(self, camera_label: str, red_band_label: str,
3070 green_band_label: str, blue_band_label: str,
3071 output_file_path: str, print_quality_report: bool = False,
3072 algorithm: str = "MATRIX_3X3_AUTO",
3073 ccm_export_file_path: str = "") -> str:
3074 """
3075 Auto-calibrate camera image with color correction and return output filename.
3076
3077 Args:
3078 camera_label: Camera label
3079 red_band_label: Red band label
3080 green_band_label: Green band label
3081 blue_band_label: Blue band label
3082 output_file_path: Output file path
3083 print_quality_report: Whether to print quality report
3084 algorithm: Color correction algorithm ("DIAGONAL_ONLY", "MATRIX_3X3_AUTO", "MATRIX_3X3_FORCE")
3085 ccm_export_file_path: Path to export color correction matrix (optional)
3086
3087 Returns:
3088 Output filename string
3089
3090 Raises:
3091 RadiationModelError: If auto-calibration fails
3092 TypeError: If parameters have incorrect types
3093 ValueError: If algorithm is not valid
3094 """
3095 # Validate inputs
3096 if not isinstance(camera_label, str) or not camera_label.strip():
3097 raise TypeError("Camera label must be a non-empty string")
3098 if not isinstance(red_band_label, str) or not red_band_label.strip():
3099 raise TypeError("Red band label must be a non-empty string")
3100 if not isinstance(green_band_label, str) or not green_band_label.strip():
3101 raise TypeError("Green band label must be a non-empty string")
3102 if not isinstance(blue_band_label, str) or not blue_band_label.strip():
3103 raise TypeError("Blue band label must be a non-empty string")
3104 if not isinstance(output_file_path, str) or not output_file_path.strip():
3105 raise TypeError("Output file path must be a non-empty string")
3106 if not isinstance(print_quality_report, bool):
3107 raise TypeError("print_quality_report must be a boolean")
3108 if not isinstance(ccm_export_file_path, str):
3109 raise TypeError("ccm_export_file_path must be a string")
3110
3111 # Map algorithm string to integer (using MATRIX_3X3_AUTO = 1 as default)
3112 algorithm_map = {
3113 "DIAGONAL_ONLY": 0,
3114 "MATRIX_3X3_AUTO": 1,
3115 "MATRIX_3X3_FORCE": 2
3116 }
3117
3118 if algorithm not in algorithm_map:
3119 raise ValueError(f"Invalid algorithm: {algorithm}. Must be one of: {list(algorithm_map.keys())}")
3120
3121 algorithm_int = algorithm_map[algorithm]
3122
3124 filename = radiation_wrapper.autoCalibrateCameraImage(
3125 self.radiation_model, camera_label, red_band_label, green_band_label,
3126 blue_band_label, output_file_path, print_quality_report,
3127 algorithm_int, ccm_export_file_path)
3128
3129 logger.info(f"Auto-calibrated camera image written to: {filename}")
3130 return filename
3131
3132 def getPluginInfo(self) -> dict:
3133 """Get information about the radiation plugin."""
3134 registry = get_plugin_registry()
3135 return registry.get_plugin_capabilities('radiation')
3136
3137 # =========================================================================
3138 # EXR Image Export (v1.3.66+)
3139 # =========================================================================
3140
3141 def writeCameraImageDataEXR(self, camera: str, band, imagefile_base: str,
3142 image_path: str = "./", frame: int = -1):
3143 """
3144 Write camera pixel data to an EXR file with lossless float compression.
3145
3146 Preserves full floating-point precision unlike JPEG/PNG exports.
3147
3148 Args:
3149 camera: Camera label
3150 band: Band label (str) for single-band, or list of band labels for multi-band
3151 imagefile_base: Base filename for output
3152 image_path: Output directory path (default: current directory)
3153 frame: Frame number to append to filename (-1 to omit)
3154
3155 Raises:
3156 RadiationModelError: If writing fails
3157 TypeError: If parameters have incorrect types
3158 """
3159 if not isinstance(camera, str) or not camera.strip():
3160 raise TypeError("Camera label must be a non-empty string")
3161 if not isinstance(imagefile_base, str) or not imagefile_base.strip():
3162 raise TypeError("Image file base must be a non-empty string")
3163 if not isinstance(image_path, str):
3164 raise TypeError("Image path must be a string")
3165 if not isinstance(frame, int):
3166 raise TypeError("Frame must be an integer")
3167
3168 if isinstance(band, str):
3169 if not band.strip():
3170 raise TypeError("Band label must be a non-empty string")
3171 elif isinstance(band, (list, tuple)):
3172 if not band:
3173 raise ValueError("Band list cannot be empty")
3174 for b in band:
3175 if not isinstance(b, str) or not b.strip():
3176 raise TypeError("Each band label must be a non-empty string")
3177 else:
3178 raise TypeError("band must be a string or list of strings")
3179
3181 radiation_wrapper.writeCameraImageDataEXR(
3182 self.radiation_model, camera, band, imagefile_base, image_path, frame)
3183
3184 def writeDepthImageData(self, camera_label: str, imagefile_base: str,
3185 image_path: str = "./", frame: int = -1):
3186 """
3187 Write depth image data to an ASCII text file.
3188
3189 Args:
3190 camera_label: Camera label
3191 imagefile_base: Base filename for output
3192 image_path: Output directory path (default: current directory)
3193 frame: Frame number to append to filename (-1 to omit)
3194
3195 Raises:
3196 RadiationModelError: If writing fails
3197 TypeError: If parameters have incorrect types
3198 """
3199 if not isinstance(camera_label, str) or not camera_label.strip():
3200 raise TypeError("Camera label must be a non-empty string")
3201 if not isinstance(imagefile_base, str) or not imagefile_base.strip():
3202 raise TypeError("Image file base must be a non-empty string")
3203 if not isinstance(image_path, str):
3204 raise TypeError("Image path must be a string")
3205 if not isinstance(frame, int):
3206 raise TypeError("Frame must be an integer")
3207
3209 radiation_wrapper.writeDepthImageData(
3210 self.radiation_model, camera_label, imagefile_base, image_path, frame)
3211
3212 def writeDepthImageDataEXR(self, camera_label: str, imagefile_base: str,
3213 image_path: str = "./", frame: int = -1):
3214 """
3215 Write depth image data to an EXR file with lossless float compression.
3216
3217 Preserves full floating-point depth precision unlike ASCII or JPEG exports.
3218
3219 Args:
3220 camera_label: Camera label
3221 imagefile_base: Base filename for output
3222 image_path: Output directory path (default: current directory)
3223 frame: Frame number to append to filename (-1 to omit)
3224
3225 Raises:
3226 RadiationModelError: If writing fails
3227 TypeError: If parameters have incorrect types
3228 """
3229 if not isinstance(camera_label, str) or not camera_label.strip():
3230 raise TypeError("Camera label must be a non-empty string")
3231 if not isinstance(imagefile_base, str) or not imagefile_base.strip():
3232 raise TypeError("Image file base must be a non-empty string")
3233 if not isinstance(image_path, str):
3234 raise TypeError("Image path must be a string")
3235 if not isinstance(frame, int):
3236 raise TypeError("Frame must be an integer")
3237
3239 radiation_wrapper.writeDepthImageDataEXR(
3240 self.radiation_model, camera_label, imagefile_base, image_path, frame)
3241
3242 def writeNormDepthImage(self, camera_label: str, imagefile_base: str, max_depth: float,
3243 image_path: str = "./", frame: int = -1):
3244 """
3245 Write normalized depth image as grayscale JPEG.
3246
3247 Depth values are normalized to the range [0, max_depth] for visualization.
3248
3249 Args:
3250 camera_label: Camera label
3251 imagefile_base: Base filename for output
3252 max_depth: Maximum depth value for normalization (e.g., sky depth)
3253 image_path: Output directory path (default: current directory)
3254 frame: Frame number to append to filename (-1 to omit)
3255
3256 Raises:
3257 RadiationModelError: If writing fails
3258 TypeError: If parameters have incorrect types
3259 ValueError: If max_depth is not positive
3260 """
3261 if not isinstance(camera_label, str) or not camera_label.strip():
3262 raise TypeError("Camera label must be a non-empty string")
3263 if not isinstance(imagefile_base, str) or not imagefile_base.strip():
3264 raise TypeError("Image file base must be a non-empty string")
3265 if not isinstance(max_depth, (int, float)):
3266 raise TypeError("max_depth must be a number")
3267 if max_depth <= 0:
3268 raise ValueError("max_depth must be positive")
3269 if not isinstance(image_path, str):
3270 raise TypeError("Image path must be a string")
3271 if not isinstance(frame, int):
3272 raise TypeError("Frame must be an integer")
3273
3275 radiation_wrapper.writeNormDepthImage(
3276 self.radiation_model, camera_label, imagefile_base, float(max_depth), image_path, frame)
3277
3278 # =========================================================================
3279 # Backend Query (v1.3.67+)
3280 # =========================================================================
3281
3282 def getBackendName(self) -> str:
3283 """
3284 Get the name of the active ray tracing backend.
3285
3286 Returns:
3287 Backend name string (e.g., "OptiX 8.1", "Vulkan Compute")
3288 """
3290 return radiation_wrapper.getBackendName(self.radiation_model)
3291
3292 @staticmethod
3293 def probeAnyGPUBackend() -> bool:
3294 """
3295 Probe whether any compiled-in GPU backend is available on this system.
3296
3297 Probes backends in priority order (OptiX 8 -> OptiX 6 -> Vulkan) without
3298 constructing a full backend. Useful for checking GPU availability before
3299 creating a RadiationModel.
3300
3301 This is the PyHelios equivalent of the native
3302 ``RadiationModel::isGPUBackendAvailable()``, which as of helios-core v1.3.79
3303 is a pure delegate to the same underlying probe.
3305 Returns False when ``HELIOS_NO_GPU`` is set to anything other than ``"0"``,
3306 whatever hardware is actually present -- see
3307 :meth:`gpuBackendsDisabledByEnvironment` to distinguish an environment veto
3308 from genuinely absent hardware.
3309
3310 Note:
3311 The probe runs at most once per process and the result is cached
3312 (helios-core v1.3.79+), so repeated calls are cheap and never re-enter
3313 the GPU driver. The flip side is that a driver which becomes usable
3314 after the first probe is not picked up until the process restarts, so a
3315 test that enables a device and re-probes in the same process still sees
3316 the cached answer.
3317
3318 Returns:
3319 True if at least one GPU backend is available
3320
3321 Example:
3322 >>> if RadiationModel.probeAnyGPUBackend():
3323 ... with RadiationModel(context) as radiation:
3324 ... radiation.addRadiationBand("SW")
3325 """
3326 return radiation_wrapper.probeAnyGPUBackend()
3327
3328 @staticmethod
3330 """
3331 Check whether GPU backends are vetoed by the ``HELIOS_NO_GPU`` environment variable.
3332
3333 True when ``HELIOS_NO_GPU`` is set to any value other than ``"0"``. The veto
3334 makes a GPU-equipped machine behave exactly like one with no compatible
3335 hardware: :meth:`probeAnyGPUBackend` returns False and constructing a
3336 RadiationModel with the automatic backend fails. Requesting a backend by name
3337 bypasses the veto.
3338
3339 Use this to tell an intentional veto apart from missing hardware when
3340 reporting why the GPU path is unavailable.
3342 Note:
3343 The environment is read once and cached for the process lifetime, so
3344 changing ``HELIOS_NO_GPU`` after the first call has no effect.
3345
3346 Returns:
3347 True if GPU backend probing is disabled by the environment
3348
3349 Raises:
3350 RuntimeError: If the native library predates helios-core v1.3.79
3351
3352 Example:
3353 >>> if not RadiationModel.probeAnyGPUBackend():
3354 ... if RadiationModel.gpuBackendsDisabledByEnvironment():
3355 ... print("GPU disabled via HELIOS_NO_GPU")
3356 ... else:
3357 ... print("No compatible GPU found")
3358 """
3359 return radiation_wrapper.gpuBackendsDisabledByEnvironment()
__init__(self, date="", time="", UTC_offset=0.0, camera_height_m=0.0, camera_angle_deg=0.0, light_source="sunlight")
Agronomic properties derived from plant architecture data.
__init__(self, plant_species=None, plant_count=None, plant_height_m=None, plant_age_days=None, plant_stage=None, leaf_area_m2=None, weed_pressure="")
__init__(self, height=512, width=512, channels=3, type="rgb", focal_length=50.0, aperture="f/2.8", sensor_width=35.0, sensor_height=24.0, model="generic", lens_make="", lens_model="", lens_specification="", exposure="auto", shutter_speed=0.008, white_balance="auto")
Image processing corrections applied to the image.
__init__(self, saturation_adjustment=1.0, brightness_adjustment=1.0, contrast_adjustment=1.0, color_space="linear")
Metadata for radiation camera image export (Helios v1.3.58+).
__init__(self, path="")
Initialize CameraMetadata with default values.
Camera properties for radiation model cameras.
__init__(self, camera_resolution=None, focal_plane_distance=1.0, lens_diameter=0.05, HFOV=20.0, FOV_aspect_ratio=0.0, lens_focal_length=0.05, sensor_width_mm=35.0, manufacturer="", model="generic", lens_make="", lens_model="", lens_specification="", exposure="auto", shutter_speed=1.0/125.0, white_balance="auto", camera_zoom=1.0, exposure_target=0.18)
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.
Union["np.ndarray", Dict[str, "np.ndarray"]] getAbsorbedFlux(self, Union[str, List[str]] band_label, Optional[List[int]] uuids=None)
Get per-band absorbed radiation flux density, aligned to UUIDs.
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.
float getCameraFluxSmoothingCreaseAngle(self)
Return the crease angle in degrees used by camera flux smoothing.
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.
_check_context_alive(self)
Raise if the owning Context has been destroyed (see Context.check_context_alive).
'np.ndarray' getObjectDataLabelMap(self, str camera, str object_data_label, float padvalue=float('nan'))
Return a per-pixel object-data label map for a camera as a NumPy array.
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.
writePrimitiveDataLabelMap(self, str camera, str primitive_data_label, str imagefile_base, str image_path="./", int frame=-1, float padvalue=float('nan'))
Write a per-pixel primitive-data label map for a camera to a text file.
None enableCameraFluxSmoothing(self, float crease_angle_degrees=30.0)
Reconstruct camera images by interpolating outgoing flux across each facet.
setSourceSpectrumIntegral(self, int source_id, float source_integral, float wavelength_min=None, float wavelength_max=None)
Set source spectrum integral value.
setDiffuseRayCount(self, str band_label, int ray_count)
Set diffuse ray count for radiation band.
bool isSIFCamera(self, str camera_label)
Return True if the camera was registered via :meth:addSIFCamera (vs.
setCameraLookat(self, str camera_label, lookat)
Set camera lookat point.
int addDiskRadiationSource(self, position, float radius, rotation)
Add a disk (circular planar) radiation source.
getCameraLookat(self, str camera_label)
Get camera lookat point.
float getSourceFlux(self, int source_id, str label)
Get source flux for band.
bool gpuBackendsDisabledByEnvironment()
Check whether GPU backends are vetoed by the HELIOS_NO_GPU environment variable.
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.
_warn_if_bounded_bands_with_camera_response(self, str camera_label, List[str] band_labels, str operation)
Warn when bands have explicit wavelength bounds and the camera has a spectral response.
'np.ndarray' getPrimitiveDataLabelMap(self, str camera, str primitive_data_label, float padvalue=float('nan'))
Return a per-pixel primitive-data label map for a camera as a NumPy array.
scaleSpectrum(self, str existing_label, new_label_or_scale, float scale_factor=None)
Scale spectrum in-place or to new label.
writeObjectDataLabelMap(self, str camera, str object_data_label, str imagefile_base, str image_path="./", int frame=-1, float padvalue=float('nan'))
Write a per-pixel object-data label map for a camera to a text file.
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 absorbed radiation flux density for all primitives, summed over all bands.
int addSphereRadiationSource(self, position, float radius)
Add spherical radiation source.
float integrateSourceSpectrum(self, int source_id, float wavelength_min, float wavelength_max)
Integrate source spectrum over wavelength range.
__enter__(self)
Context manager entry.
None disableCameraFluxSmoothing(self)
Reconstruct camera images by holding the outgoing flux constant across each facet.
optionalOutputPrimitiveData(self, str label)
Enable optional primitive data output.
str autoCalibrateCameraImage(self, str camera_label, str red_band_label, str green_band_label, str blue_band_label, str output_file_path, bool print_quality_report=False, str algorithm="MATRIX_3X3_AUTO", str ccm_export_file_path="")
Auto-calibrate camera image with color correction and return output filename.
addSIFCamera(self, str camera_label, List[str] emission_band_labels, position, lookat_or_direction, camera_properties=None, int antialiasing_samples=100)
Add a solar-induced chlorophyll fluorescence (SIF) camera.
__exit__(self, exc_type, exc_value, traceback)
Context manager exit with proper cleanup.
updateGeometry(self, Optional[List[int]] uuids=None)
Update geometry in radiation model.
setDiffuseSpectrum(self, band_label, str spectrum_label)
Set diffuse spectrum from global data label.
setMinScatterEnergy(self, str label, float energy)
Set minimum scatter energy for radiation band.
float getDiffuseFlux(self, str band_label)
Get diffuse flux for band.
_check_image_was_written(self, str filename, str camera, List[str] bands, str image_path, str operation)
Raise if the native layer reported a failed write via an empty filename.
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.
bool isCameraFluxSmoothingEnabled(self)
Return True if camera images are reconstructed by interpolating flux across each facet.
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.
_check_camera_has_pixel_data(self, str camera, List[str] bands, str operation)
Raise an actionable error if a camera/band has no rendered pixel data.
blendSpectraRandomly(self, str new_label, List[str] spectrum_labels)
Blend multiple spectra with random weights.
getNativePtr(self)
Get native pointer for advanced operations.
Camera properties for a solar-induced chlorophyll fluorescence (SIF) camera.
__init__(self, float excitation_bin_width_nm=10.0, int excitation_scattering_depth=0, **kwargs)
excitation_scattering_depth
Scattering depth for the auto-generated.
excitation_bin_width_nm
Excitation wavelength bin width in nm.
_radiation_working_directory()
Context manager that temporarily changes working directory to where RadiationModel assets are located...