PyHelios 0.1.11
Loading...
Searching...
No Matches
LeafOptics.py
Go to the documentation of this file.
1"""
2High-level LeafOptics interface for PyHelios.
3
4This module provides a user-friendly interface to the PROSPECT leaf optical model
5for computing spectral reflectance and transmittance of plant leaves.
6"""
7
8import logging
9import os
10from contextlib import contextmanager
11from dataclasses import dataclass, field
12from pathlib import Path
13from typing import List, Optional, Tuple
14
15from .plugins.registry import get_plugin_registry
16from .wrappers import ULeafOpticsWrapper as leafoptics_wrapper
17from .Context import Context
18from .exceptions import HeliosError
19from .assets import get_asset_manager
20
21logger = logging.getLogger(__name__)
22
23
25 """Exception raised for LeafOptics-specific errors."""
26 pass
27
28
29# Available species in the built-in library
30AVAILABLE_SPECIES = [
31 "default",
32 "garden_lettuce",
33 "alfalfa",
34 "corn",
35 "sunflower",
36 "english_walnut",
37 "rice",
38 "soybean",
39 "wine_grape",
40 "tomato",
41 "common_bean",
42 "cowpea"
43]
44
45
46@dataclass
48 """
49 Data class for PROSPECT leaf optical model parameters.
50
51 These parameters define the physical and biochemical properties of a leaf
52 that determine its spectral reflectance and transmittance.
53
54 Attributes:
55 numberlayers: Number of mesophyll layers in the leaf (typically 1-3)
56 brownpigments: Brown pigment content (arbitrary units, typically 0)
57 chlorophyllcontent: Chlorophyll a+b content in micrograms per square cm
58 carotenoidcontent: Carotenoid content in micrograms per square cm
59 anthocyancontent: Anthocyanin content in micrograms per square cm
60 watermass: Equivalent water thickness in grams per square cm
61 drymass: Dry matter content (leaf mass per area) in grams per square cm
62 protein: Protein content in grams per square cm (PROSPECT-PRO mode)
63 carbonconstituents: Carbon-based constituents in grams per square cm (PROSPECT-PRO mode)
64
65 Note:
66 - If protein > 0 OR carbonconstituents > 0, the model uses PROSPECT-PRO mode
67 which ignores drymass and uses protein + carbonconstituents instead.
68 - Otherwise, the model uses PROSPECT-D mode which uses drymass.
69 """
70 numberlayers: float = 1.5
71 brownpigments: float = 0.0
72 chlorophyllcontent: float = 30.0 # micrograms/cm^2
73 carotenoidcontent: float = 7.0 # micrograms/cm^2
74 anthocyancontent: float = 1.0 # micrograms/cm^2
75 watermass: float = 0.015 # g/cm^2
76 drymass: float = 0.09 # g/cm^2
77 protein: float = 0.0 # g/cm^2 (PROSPECT-PRO mode)
78 carbonconstituents: float = 0.0 # g/cm^2 (PROSPECT-PRO mode)
79
80 def to_list(self) -> List[float]:
81 """Convert properties to a list in the order expected by the C++ interface."""
82 return [
83 self.numberlayers,
84 self.brownpigments,
89 self.drymass,
90 self.protein,
92 ]
94 @classmethod
95 def from_list(cls, values: List[float]) -> 'LeafOpticsProperties':
96 """Create LeafOpticsProperties from a list of values."""
97 if len(values) != 9:
98 raise ValueError(f"Expected 9 values, got {len(values)}")
99 return cls(
100 numberlayers=values[0],
101 brownpigments=values[1],
102 chlorophyllcontent=values[2],
103 carotenoidcontent=values[3],
104 anthocyancontent=values[4],
105 watermass=values[5],
106 drymass=values[6],
107 protein=values[7],
108 carbonconstituents=values[8]
109 )
110
111
112@contextmanager
114 """
115 Context manager that temporarily changes working directory to where LeafOptics assets are located.
116
117 LeafOptics C++ code uses relative paths like "plugins/leafoptics/spectral_data/"
118 expecting assets relative to working directory. This manager temporarily changes to the build
119 directory where assets are actually located.
120 """
121 # Find the build directory containing LeafOptics assets
122 asset_manager = get_asset_manager()
123 working_dir = asset_manager._get_helios_build_path()
124
125 if working_dir and working_dir.exists():
126 leafoptics_assets = working_dir / 'plugins' / 'leafoptics' / 'spectral_data'
127 else:
128 # Fallback to development paths
129 current_dir = Path(__file__).parent
130 repo_root = current_dir.parent
131 build_lib_dir = repo_root / 'pyhelios_build' / 'build' / 'lib'
132 working_dir = build_lib_dir.parent
133 leafoptics_assets = working_dir / 'plugins' / 'leafoptics' / 'spectral_data'
134
135 if not build_lib_dir.exists():
136 raise LeafOpticsError(
137 f"PyHelios build directory not found at {build_lib_dir}. "
138 f"LeafOptics requires native libraries to be built. "
139 f"Run: build_scripts/build_helios --plugins leafoptics"
141
142 # Validate spectral data file exists
143 spectral_file = leafoptics_assets / 'prospect_spectral_library.xml'
144 if not spectral_file.exists():
145 raise LeafOpticsError(
146 f"LeafOptics spectral data not found at {spectral_file}. "
147 f"Build system failed to copy LeafOptics assets. "
148 f"Run: build_scripts/build_helios --clean --plugins leafoptics"
149 )
150
151 # Validate file size (should be ~475KB)
152 file_size = spectral_file.stat().st_size
153 if file_size < 400000: # Less than 400KB indicates corruption
154 raise LeafOpticsError(
155 f"LeafOptics spectral data file appears corrupted (size: {file_size} bytes, expected ~475KB). "
156 f"Run: build_scripts/build_helios --clean --plugins leafoptics"
157 )
158
159 # Change to the build directory temporarily
160 original_dir = os.getcwd()
161 try:
162 os.chdir(working_dir)
163 logger.debug(f"Changed working directory to {working_dir} for LeafOptics asset access")
164 yield working_dir
165 finally:
166 os.chdir(original_dir)
167 logger.debug(f"Restored working directory to {original_dir}")
168
169
170class LeafOptics:
171 """
172 High-level interface for PROSPECT leaf optical model.
173
174 This class provides a user-friendly wrapper around the native Helios
175 LeafOptics plugin for computing spectral reflectance and transmittance
176 of plant leaves based on their biochemical properties.
177
178 The PROSPECT model computes spectral optical properties for wavelengths
179 from 400 nm to 2500 nm at 1 nm resolution (2101 data points).
180
181 System requirements:
182 - Cross-platform support (Windows, Linux, macOS)
183 - No GPU required
184 - Requires spectral_data assets (~475KB XML file)
185 - LeafOptics plugin compiled into PyHelios
186
187 Example:
188 >>> from pyhelios import Context, LeafOptics, LeafOpticsProperties
189 >>>
190 >>> with Context() as context:
191 ... with LeafOptics(context) as leafoptics:
192 ... # Get properties for a known species
193 ... props = leafoptics.getPropertiesFromLibrary("sunflower")
194 ... print(f"Sunflower chlorophyll: {props.chlorophyllcontent} ug/cm^2")
195 ...
196 ... # Compute spectra
197 ... wavelengths, reflectance, transmittance = leafoptics.getLeafSpectra(props)
198 ... print(f"Spectral range: {wavelengths[0]}-{wavelengths[-1]} nm")
199 ...
200 ... # Apply to geometry
201 ... leaf_uuid = context.addPatch(center=[0, 0, 1], size=[0.1, 0.1])
202 ... leafoptics.run([leaf_uuid], props, "sunflower_leaf")
203 """
204
205 def __init__(self, context: Context):
206 """
207 Initialize LeafOptics with graceful plugin handling.
208
209 Args:
210 context: Helios Context instance
211
212 Raises:
213 TypeError: If context is not a Context instance
214 LeafOpticsError: If LeafOptics plugin is not available or spectral data missing
215 """
216 # Validate context type - use duck typing to handle import state issues during testing
217 if not (hasattr(context, '__class__') and
218 (isinstance(context, Context) or
219 context.__class__.__name__ == 'Context')):
220 raise TypeError(f"LeafOptics requires a Context instance, got {type(context).__name__}")
221
222 self.context = context
223 self._leafoptics_ptr = None
224
225 # Check plugin availability using registry
226 registry = get_plugin_registry()
227
228 if not registry.is_plugin_available('leafoptics'):
229 # Get helpful information about the missing plugin
230 available_plugins = registry.get_available_plugins()
231
232 error_msg = (
233 "LeafOptics requires the 'leafoptics' plugin which is not available.\n\n"
234 "The LeafOptics plugin implements the PROSPECT leaf optical model which computes:\n"
235 "- Spectral reflectance (400-2500 nm at 1 nm resolution)\n"
236 "- Spectral transmittance\n"
237 "- Based on leaf biochemical properties (chlorophyll, water, dry matter, etc.)\n\n"
238 "Features:\n"
239 "- Cross-platform support (Windows, Linux, macOS)\n"
240 "- No GPU required\n"
241 "- Built-in species library with 12 plant species\n"
242 "- Supports both PROSPECT-D and PROSPECT-PRO modes\n\n"
243 "To enable LeafOptics modeling:\n"
244 "1. Build PyHelios with LeafOptics plugin:\n"
245 " build_scripts/build_helios --plugins leafoptics\n"
246 "2. Or build with radiation plugins for full spectral modeling:\n"
247 " build_scripts/build_helios --plugins leafoptics,radiation\n"
248 f"\nCurrently available plugins: {available_plugins}"
249 )
250
251 raise LeafOpticsError(error_msg)
252
253 # Plugin is available - create LeafOptics with asset-aware working directory
254 try:
256 self._leafoptics_ptr = leafoptics_wrapper.createLeafOptics(context.getNativePtr())
257 if self._leafoptics_ptr is None:
258 raise LeafOpticsError(
259 "Failed to create LeafOptics instance. "
260 "This may indicate a problem with the spectral data files."
261 )
262 logger.info("LeafOptics created successfully")
263
264 except LeafOpticsError:
265 raise
266 except Exception as e:
267 raise LeafOpticsError(f"Failed to initialize LeafOptics: {e}")
268
269 def __enter__(self):
270 """Context manager entry."""
271 return self
272
273 def __exit__(self, exc_type, exc_value, traceback):
274 """Context manager exit with proper cleanup."""
275 if self._leafoptics_ptr is not None:
276 try:
277 leafoptics_wrapper.destroyLeafOptics(self._leafoptics_ptr)
278 logger.debug("LeafOptics destroyed successfully")
279 except Exception as e:
280 logger.warning(f"Error destroying LeafOptics: {e}")
281 finally:
282 self._leafoptics_ptr = None
283
284 def __del__(self):
285 """Destructor to ensure C++ resources freed even without 'with' statement."""
286 if hasattr(self, '_leafoptics_ptr') and self._leafoptics_ptr is not None:
287 try:
288 leafoptics_wrapper.destroyLeafOptics(self._leafoptics_ptr)
289 self._leafoptics_ptr = None
290 except Exception as e:
291 import warnings
292 warnings.warn(f"Error in LeafOptics.__del__: {e}")
293
294 def run(self, UUIDs: List[int], leafproperties: LeafOpticsProperties, label: str) -> None:
295 """
296 Run the LeafOptics model to generate spectra and assign to primitives.
297
298 This method computes reflectance and transmittance spectra based on the given
299 leaf properties, creates global data entries, and assigns them to the specified
300 primitives.
301
302 Args:
303 UUIDs: List of primitive UUIDs to assign spectra to
304 leafproperties: LeafOpticsProperties with biochemical parameters
305 label: Label for the spectra (appended to "leaf_reflectivity_" and "leaf_transmissivity_")
306
307 Raises:
308 ValueError: If parameters are invalid
309 LeafOpticsError: If computation fails
310
311 Example:
312 >>> props = LeafOpticsProperties(chlorophyllcontent=40.0, watermass=0.02)
313 >>> leafoptics.run([leaf_uuid], props, "my_leaf")
314 >>> # Creates: "leaf_reflectivity_my_leaf" and "leaf_transmissivity_my_leaf"
315 """
316 if not UUIDs:
317 raise ValueError("UUIDs list cannot be empty")
318 if not isinstance(leafproperties, LeafOpticsProperties):
319 raise ValueError("leafproperties must be a LeafOpticsProperties instance")
320 if not label:
321 raise ValueError("Label cannot be empty")
322
323 try:
324 leafoptics_wrapper.leafOpticsRun(
325 self._leafoptics_ptr,
326 UUIDs,
327 leafproperties.to_list(),
328 label
329 )
330 except Exception as e:
331 raise LeafOpticsError(f"Failed to run LeafOptics model: {e}")
332
333 def runNoUUIDs(self, leafproperties: LeafOpticsProperties, label: str) -> None:
334 """
335 Run the LeafOptics model without assigning to primitives.
336
337 This method computes reflectance and transmittance spectra based on the given
338 leaf properties and creates global data entries, but does not assign them to
339 any primitives.
340
341 Args:
342 leafproperties: LeafOpticsProperties with biochemical parameters
343 label: Label for the spectra (appended to "leaf_reflectivity_" and "leaf_transmissivity_")
344
345 Raises:
346 ValueError: If parameters are invalid
347 LeafOpticsError: If computation fails
348 """
349 if not isinstance(leafproperties, LeafOpticsProperties):
350 raise ValueError("leafproperties must be a LeafOpticsProperties instance")
351 if not label:
352 raise ValueError("Label cannot be empty")
353
354 try:
355 leafoptics_wrapper.leafOpticsRunNoUUIDs(
356 self._leafoptics_ptr,
357 leafproperties.to_list(),
358 label
359 )
360 except Exception as e:
361 raise LeafOpticsError(f"Failed to run LeafOptics model: {e}")
362
363 def getLeafSpectra(self, leafproperties: LeafOpticsProperties) -> Tuple[List[float], List[float], List[float]]:
364 """
365 Compute leaf reflectance and transmittance spectra.
366
367 This method computes spectral properties without creating global data entries
368 or assigning to primitives.
369
370 Args:
371 leafproperties: LeafOpticsProperties with biochemical parameters
372
373 Returns:
374 Tuple of (wavelengths, reflectivities, transmissivities):
375 - wavelengths: List of wavelengths in nm (400-2500 at 1nm resolution, 2101 points)
376 - reflectivities: List of reflectance values (0-1) at each wavelength
377 - transmissivities: List of transmittance values (0-1) at each wavelength
378
379 Raises:
380 ValueError: If parameters are invalid
381 LeafOpticsError: If computation fails
382
383 Example:
384 >>> props = LeafOpticsProperties(chlorophyllcontent=40.0)
385 >>> wavelengths, refl, trans = leafoptics.getLeafSpectra(props)
386 >>> # Find reflectance at 550 nm (green peak)
387 >>> idx_550 = wavelengths.index(550.0)
388 >>> print(f"Reflectance at 550 nm: {refl[idx_550]:.3f}")
389 """
390 if not isinstance(leafproperties, LeafOpticsProperties):
391 raise ValueError("leafproperties must be a LeafOpticsProperties instance")
392
393 try:
394 return leafoptics_wrapper.leafOpticsGetLeafSpectra(
395 self._leafoptics_ptr,
396 leafproperties.to_list()
397 )
398 except Exception as e:
399 raise LeafOpticsError(f"Failed to get leaf spectra: {e}")
400
401 def setProperties(self, UUIDs: List[int], leafproperties: LeafOpticsProperties) -> None:
402 """
403 Set leaf optical properties for primitives as Context primitive data.
404
405 This assigns the biochemical properties as primitive data using labels:
406 "chlorophyll", "carotenoid", "anthocyanin", "brown", "water", "drymass"
407 (or "protein" + "cellulose" in PROSPECT-PRO mode).
408
409 Args:
410 UUIDs: List of primitive UUIDs
411 leafproperties: LeafOpticsProperties with biochemical parameters
412
413 Raises:
414 ValueError: If parameters are invalid
415 LeafOpticsError: If operation fails
416 """
417 if not UUIDs:
418 raise ValueError("UUIDs list cannot be empty")
419 if not isinstance(leafproperties, LeafOpticsProperties):
420 raise ValueError("leafproperties must be a LeafOpticsProperties instance")
421
422 try:
423 leafoptics_wrapper.leafOpticsSetProperties(
424 self._leafoptics_ptr,
425 UUIDs,
426 leafproperties.to_list()
427 )
428 except Exception as e:
429 raise LeafOpticsError(f"Failed to set properties: {e}")
430
431 def getPropertiesFromSpectrum(self, UUIDs: List[int]) -> None:
432 """
433 Get PROSPECT parameters from reflectivity spectrum for primitives.
434
435 This method retrieves the "reflectivity_spectrum" primitive data for each
436 primitive and checks if it matches a spectrum generated by this LeafOptics
437 instance. If a match is found, the corresponding PROSPECT model parameters
438 are assigned as primitive data.
439
440 Args:
441 UUIDs: List of primitive UUIDs to query
442
443 Note:
444 Primitives without matching spectra are silently skipped.
445 """
446 if not UUIDs:
447 raise ValueError("UUIDs list cannot be empty")
448
449 try:
450 leafoptics_wrapper.leafOpticsGetPropertiesFromSpectrum(
451 self._leafoptics_ptr,
452 UUIDs
453 )
454 except Exception as e:
455 raise LeafOpticsError(f"Failed to get properties from spectrum: {e}")
456
457 def getPropertiesFromLibrary(self, species: str) -> LeafOpticsProperties:
458 """
459 Get leaf optical properties from the built-in species library.
460
461 The library contains PROSPECT-D parameters fitted from the LOPEX93 dataset
462 for common plant species.
463
464 Args:
465 species: Name of the species (case-insensitive). Available species:
466 "default", "garden_lettuce", "alfalfa", "corn", "sunflower",
467 "english_walnut", "rice", "soybean", "wine_grape", "tomato",
468 "common_bean", "cowpea"
469
470 Returns:
471 LeafOpticsProperties populated with the species-specific parameters
472
473 Raises:
474 ValueError: If species name is empty
475
476 Note:
477 If species is not found, default properties are used and a warning is issued.
478
479 Example:
480 >>> props = leafoptics.getPropertiesFromLibrary("sunflower")
481 >>> print(f"Chlorophyll: {props.chlorophyllcontent} ug/cm^2")
482 """
483 if not species:
484 raise ValueError("Species name cannot be empty")
485
486 try:
487 properties_list = leafoptics_wrapper.leafOpticsGetPropertiesFromLibrary(
488 self._leafoptics_ptr,
489 species
490 )
491 return LeafOpticsProperties.from_list(properties_list)
492 except Exception as e:
493 raise LeafOpticsError(f"Failed to get properties from library: {e}")
494
495 def disableMessages(self) -> None:
496 """Disable command-line output messages from LeafOptics."""
497 try:
498 leafoptics_wrapper.leafOpticsDisableMessages(self._leafoptics_ptr)
499 except Exception as e:
500 raise LeafOpticsError(f"Failed to disable messages: {e}")
501
502 def enableMessages(self) -> None:
503 """Enable command-line output messages from LeafOptics."""
504 try:
505 leafoptics_wrapper.leafOpticsEnableMessages(self._leafoptics_ptr)
506 except Exception as e:
507 raise LeafOpticsError(f"Failed to enable messages: {e}")
508
509 def optionalOutputPrimitiveData(self, label: str) -> None:
510 """
511 Selectively output specific biochemical properties as primitive data.
512
513 By default, LeafOptics writes all biochemical properties to primitive data.
514 Use this method to specify only the properties you need for improved performance.
515
516 Args:
517 label: Biochemical property to output. Valid values:
518 - "chlorophyll": Chlorophyll content
519 - "carotenoid": Carotenoid content
520 - "anthocyanin": Anthocyanin content
521 - "brown": Brown pigment content
522 - "water": Water content
523 - "drymass": Dry mass content
524 - "protein": Protein content
525 - "cellulose": Cellulose content
526
527 Raises:
528 ValueError: If label is empty or invalid
529 LeafOpticsError: If operation fails
530
531 Note:
532 Added in helios-core v1.3.59 for performance optimization when only
533 specific biochemical properties are needed for analysis.
534
535 Example:
536 >>> with LeafOptics(context) as leaf:
537 ... # Only output chlorophyll and water content
538 ... leaf.optionalOutputPrimitiveData("chlorophyll")
539 ... leaf.optionalOutputPrimitiveData("water")
540 ... leaf.run(uuids, properties, "leaf_spectra")
541 """
542 if not label:
543 raise ValueError("Label cannot be empty")
544
545 valid_labels = ["chlorophyll", "carotenoid", "anthocyanin", "brown",
546 "water", "drymass", "protein", "cellulose"]
547 if label not in valid_labels:
548 raise ValueError(f"Invalid label '{label}'. Must be one of: {', '.join(valid_labels)}")
549
550 try:
551 leafoptics_wrapper.leafOpticsOptionalOutputPrimitiveData(self._leafoptics_ptr, label)
552 except Exception as e:
553 raise LeafOpticsError(f"Failed to set optional output for '{label}': {e}")
554
555 @staticmethod
556 def getAvailableSpecies() -> List[str]:
557 """
558 Get list of available species in the built-in library.
559
560 Returns:
561 List of species names that can be used with getPropertiesFromLibrary()
562 """
563 return AVAILABLE_SPECIES.copy()
564
565 @staticmethod
566 def isAvailable() -> bool:
567 """
568 Check if LeafOptics plugin is available in the current build.
569
570 Returns:
571 True if LeafOptics is available, False otherwise
572 """
573 return leafoptics_wrapper.is_leafoptics_available()
Exception raised for LeafOptics-specific errors.
Definition LeafOptics.py:25
Data class for PROSPECT leaf optical model parameters.
Definition LeafOptics.py:60
float anthocyancontent
Anthocyanin content in micrograms per square cm.
Definition LeafOptics.py:92
float watermass
Equivalent water thickness in grams per square cm.
Definition LeafOptics.py:93
float drymass
Dry matter content (leaf mass per area) in grams per square cm.
Definition LeafOptics.py:94
float numberlayers
Number of mesophyll layers in the leaf (typically 1-3)
Definition LeafOptics.py:88
float protein
Protein content in grams per square cm (PROSPECT-PRO mode)
Definition LeafOptics.py:95
float carotenoidcontent
Carotenoid content in micrograms per square cm.
Definition LeafOptics.py:91
'LeafOpticsProperties' from_list(cls, List[float] values)
Create LeafOpticsProperties from a list of values.
float brownpigments
Brown pigment content (arbitrary units, typically 0)
Definition LeafOptics.py:89
float chlorophyllcontent
Chlorophyll a+b content in micrograms per square cm.
Definition LeafOptics.py:90
List[float] to_list(self)
Convert properties to a list in the order expected by the C++ interface.
Definition LeafOptics.py:99
float carbonconstituents
Carbon-based constituents in grams per square cm (PROSPECT-PRO mode)
Definition LeafOptics.py:96
High-level interface for PROSPECT leaf optical model.
None setProperties(self, List[int] UUIDs, LeafOpticsProperties leafproperties)
Set leaf optical properties for primitives as Context primitive data.
__del__(self)
Destructor to ensure C++ resources freed even without 'with' statement.
List[str] getAvailableSpecies()
Get list of available species in the built-in library.
LeafOpticsProperties getPropertiesFromLibrary(self, str species)
Get leaf optical properties from the built-in species library.
__exit__(self, exc_type, exc_value, traceback)
Context manager exit with proper cleanup.
None run(self, List[int] UUIDs, LeafOpticsProperties leafproperties, str label)
Run the LeafOptics model to generate spectra and assign to primitives.
Tuple[List[float], List[float], List[float]] getLeafSpectra(self, LeafOpticsProperties leafproperties)
Compute leaf reflectance and transmittance spectra.
None disableMessages(self)
Disable command-line output messages from LeafOptics.
None optionalOutputPrimitiveData(self, str label)
Selectively output specific biochemical properties as primitive data.
bool isAvailable()
Check if LeafOptics plugin is available in the current build.
None runNoUUIDs(self, LeafOpticsProperties leafproperties, str label)
Run the LeafOptics model without assigning to primitives.
None enableMessages(self)
Enable command-line output messages from LeafOptics.
__enter__(self)
Context manager entry.
None getPropertiesFromSpectrum(self, List[int] UUIDs)
Get PROSPECT parameters from reflectivity spectrum for primitives.
Exception classes for PyHelios library.
Definition exceptions.py:10
_leafoptics_working_directory()
Context manager that temporarily changes working directory to where LeafOptics assets are located.