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