0.1.39
Loading...
Searching...
No Matches
PlantArchitecture.py
Go to the documentation of this file.
1"""
2High-level PlantArchitecture interface for PyHelios.
3
4This module provides a user-friendly interface to the plant architecture modeling
5capabilities with graceful plugin handling and informative error messages.
6"""
7
8import functools
9import logging
10import math
11import os
12import weakref
13from contextlib import contextmanager
14from pathlib import Path
15from typing import Any, Callable, Dict, List, Optional, Union
16
17from .Context import Context, check_context_alive
18from .exceptions import HeliosInvalidArgumentError
19from .plugins.registry import get_plugin_registry, require_plugin
20from .wrappers import UPlantArchitectureWrapper as plantarch_wrapper
21from .wrappers.DataTypes import vec3, vec2, int2, AxisRotation
22try:
23 from .validation.datatypes import validate_vec3, validate_vec2, validate_int2
24except ImportError:
25 # Fallback validation functions for when validation module is not available
26 def validate_vec3(value, name, func):
27 if hasattr(value, 'x') and hasattr(value, 'y') and hasattr(value, 'z'):
28 return value
29 if isinstance(value, (list, tuple)) and len(value) == 3:
30 from .wrappers.DataTypes import vec3
31 return vec3(*value)
32 raise ValueError(f"{name} must be vec3 or 3-element list/tuple")
33
34 def validate_vec2(value, name, func):
35 if hasattr(value, 'x') and hasattr(value, 'y'):
36 return value
37 if isinstance(value, (list, tuple)) and len(value) == 2:
38 from .wrappers.DataTypes import vec2
39 return vec2(*value)
40 raise ValueError(f"{name} must be vec2 or 2-element list/tuple")
41
42 def validate_int2(value, name, func):
43 if hasattr(value, 'x') and hasattr(value, 'y'):
44 return value
45 if isinstance(value, (list, tuple)) and len(value) == 2:
46 from .wrappers.DataTypes import int2
47 return int2(*value)
48 raise ValueError(f"{name} must be int2 or 2-element list/tuple")
49from .validation.core import validate_positive_value
50from .assets import get_asset_manager
51from .plant_architecture_params import (
52 BudState,
53 PhytomerParameters,
54 ShootParameters,
55 CarbohydrateParameters,
56 NitrogenParameters,
57 RandomParameter,
58 RandomParameterFloat,
59 RandomParameterInt,
60)
61
62logger = logging.getLogger(__name__)
63
64
65def _validate_build_parameters(build_parameters: Optional[dict], plant_model: Optional[str],
66 accepted: Optional[List[str]] = None) -> None:
67 """Check build parameter types, and names against those the plant model accepts.
68
69 The native library also rejects a name the model does not accept, but raising here
70 gives callers a ValueError like every other argument check, before anything is built.
71
72 Args:
73 build_parameters: Mapping supplied by the caller, or None.
74 plant_model: Label of the currently loaded model, used in the error message.
75 accepted: Names the model accepts, from getPlantModelBuildParameterNames(), or None
76 to check types only (the native library still checks the names).
77
78 Raises:
79 ValueError: If build_parameters is not a dict of str -> number, or contains a key
80 the model does not accept.
81 """
82 if build_parameters is None:
83 return
84
85 if not isinstance(build_parameters, dict):
86 raise ValueError("build_parameters must be a dict or None")
87
88 for key, value in build_parameters.items():
89 if not isinstance(key, str):
90 raise ValueError("build_parameters keys must be strings")
91 if isinstance(value, bool) or not isinstance(value, (int, float)):
92 raise ValueError("build_parameters values must be numeric (int or float)")
93
94 if not build_parameters or accepted is None:
95 return
96
97 unknown = sorted(set(build_parameters) - set(accepted))
98 if not unknown:
99 return
100
101 if accepted:
102 accepted_description = f"accepts only: {', '.join(accepted)}"
103 else:
104 accepted_description = "accepts no build parameters"
105
106 raise ValueError(
107 f"Unknown build parameter(s) {', '.join(repr(k) for k in unknown)}. "
108 f"Plant model '{plant_model}' {accepted_description}."
109 )
110
111
112def _resolve_user_path(filepath: Union[str, Path]) -> str:
113 """
114 Convert relative paths to absolute paths before changing working directory.
115
116 This preserves the user's intended file location when the working directory
117 is temporarily changed for C++ asset access. Absolute paths are returned unchanged.
118
119 Args:
120 filepath: File path to resolve (string or Path object)
121
122 Returns:
123 Absolute path as string
124 """
125 path = Path(filepath)
126 if not path.is_absolute():
127 return str(Path.cwd() / path)
128 return str(path)
129
130
131@contextmanager
133 """
134 Context manager that temporarily changes working directory to where PlantArchitecture assets are located.
135
136 PlantArchitecture C++ code uses hardcoded relative paths like "plugins/plantarchitecture/assets/textures/"
137 expecting assets relative to working directory. This manager temporarily changes to the build directory
138 where assets are actually located.
139
140 Raises:
141 RuntimeError: If build directory or PlantArchitecture assets are not found, indicating a build system error.
142 """
143 # Find the build directory containing PlantArchitecture assets
144 # Try asset manager first (works for both development and wheel installations)
145 asset_manager = get_asset_manager()
146 working_dir = asset_manager._get_helios_build_path()
147
148 if working_dir and working_dir.exists():
149 plantarch_assets = working_dir / 'plugins' / 'plantarchitecture'
150 else:
151 # For wheel installations, check packaged assets
152 current_dir = Path(__file__).parent
153 packaged_build = current_dir / 'assets' / 'build'
154
155 if packaged_build.exists():
156 working_dir = packaged_build
157 plantarch_assets = working_dir / 'plugins' / 'plantarchitecture'
158 else:
159 # Fallback to development paths
160 repo_root = current_dir.parent
161 build_lib_dir = repo_root / 'pyhelios_build' / 'build' / 'lib'
162 working_dir = build_lib_dir.parent
163 plantarch_assets = working_dir / 'plugins' / 'plantarchitecture'
164
165 if not build_lib_dir.exists():
166 raise RuntimeError(
167 f"PyHelios build directory not found at {build_lib_dir}. "
168 f"PlantArchitecture requires native libraries to be built. "
169 f"Run: build_scripts/build_helios --plugins plantarchitecture"
170 )
171
172 if not plantarch_assets.exists():
173 raise RuntimeError(
174 f"PlantArchitecture assets not found at {plantarch_assets}. "
175 f"Build system failed to copy PlantArchitecture assets. "
176 f"Run: build_scripts/build_helios --clean --plugins plantarchitecture"
177 )
178
179 # Verify essential assets exist
180 assets_dir = plantarch_assets / 'assets'
181 if not assets_dir.exists():
182 raise RuntimeError(
183 f"PlantArchitecture assets directory not found: {assets_dir}. "
184 f"Essential assets missing. Rebuild with: "
185 f"build_scripts/build_helios --clean --plugins plantarchitecture"
186 )
187
188 # Change to the build directory temporarily
189 original_dir = os.getcwd()
190 try:
191 os.chdir(working_dir)
192 logger.debug(f"Changed working directory to {working_dir} for PlantArchitecture asset access")
193 yield working_dir
194 finally:
195 os.chdir(original_dir)
196 logger.debug(f"Restored working directory to {original_dir}")
197
198
199class PlantArchitectureError(Exception):
200 """Raised when PlantArchitecture operations fail."""
201 pass
202
203
205 """
206 Check if PlantArchitecture plugin is available for use.
207
208 Returns:
209 bool: True if PlantArchitecture can be used, False otherwise
210 """
211 try:
212 # Check plugin registry
213 plugin_registry = get_plugin_registry()
214 if not plugin_registry.is_plugin_available('plantarchitecture'):
215 return False
217 # Check if wrapper functions are available
218 if not plantarch_wrapper._PLANTARCHITECTURE_FUNCTIONS_AVAILABLE:
219 return False
220
221 return True
222 except Exception:
223 return False
224
225
227 """Re-raise an exception from a Python phytomer creation or callback function out of the call that ran it.
228
229 ctypes cannot propagate an exception out of a callback, so the trampolines built by
230 :meth:`PlantArchitecture.setPhytomerCreationFunction` and :meth:`PlantArchitecture.setPhytomerCallbackFunction`
231 store it and make the native call fail; this decorator then raises the stored exception in place of the
232 resulting native error.
233 """
234 @functools.wraps(method)
235 def wrapper(self, *args, **kwargs):
236 self._pending_callback_error = None
237 try:
238 result = method(self, *args, **kwargs)
239 except Exception as native_error:
240 self._raise_pending_callback_error(native_error)
241 raise
242 self._raise_pending_callback_error()
243 return result
244 return wrapper
245
246
248 """
249 High-level interface for plant architecture modeling and procedural plant generation.
250
251 PlantArchitecture provides access to the comprehensive plant library with 25+ plant models
252 including trees (almond, apple, olive, walnut), crops (bean, cowpea, maize, rice, soybean),
253 and other plants. This class enables procedural plant generation, time-based growth
254 simulation, and plant community modeling.
255
256 This class requires the native Helios library built with PlantArchitecture support.
257 Use context managers for proper resource cleanup.
258
259 Example:
260 >>> with Context() as context:
261 ... with PlantArchitecture(context) as plantarch:
262 ... plantarch.loadPlantModelFromLibrary("bean")
263 ... plant_id = plantarch.buildPlantInstanceFromLibrary(base_position=vec3(0, 0, 0), age=30)
264 ... plantarch.advanceTime(10.0) # Grow for 10 days
265 """
266
267 def __new__(cls, context=None):
268 """
269 Create PlantArchitecture instance.
270 Explicit __new__ to prevent ctypes contamination on Windows.
271 """
272 return object.__new__(cls)
274 def __init__(self, context: Context):
275 """
276 Initialize PlantArchitecture with a Helios context.
277
278 Args:
279 context: Active Helios Context instance
280
281 Raises:
282 PlantArchitectureError: If plugin not available in current build
283 RuntimeError: If plugin initialization fails
284 """
285 # Check plugin availability
286 registry = get_plugin_registry()
287 if not registry.is_plugin_available('plantarchitecture'):
289 "PlantArchitecture not available in current Helios library. "
290 "Rebuild PyHelios with PlantArchitecture support:\n"
291 " build_scripts/build_helios --plugins plantarchitecture\n"
292 "\n"
293 "System requirements:\n"
294 f" - Platforms: Windows, Linux, macOS\n"
295 " - Dependencies: Extensive asset library (textures, OBJ models)\n"
296 " - GPU: Not required\n"
297 "\n"
298 "Plant library includes 25+ models: almond, apple, bean, cowpea, maize, "
299 "rice, soybean, tomato, wheat, and many others."
300 )
301
302 self.context = context
303 self._plantarch_ptr = None
304 # Label passed to the most recent loadPlantModelFromLibrary(), used to validate
305 # build parameters against the model that actually consumes them.
306 self._current_plant_model = None
307
308 # Create PlantArchitecture instance with asset-aware working directory
310 self._plantarch_ptr = plantarch_wrapper.createPlantArchitecture(context.getNativePtr())
312 if not self._plantarch_ptr:
313 raise PlantArchitectureError("Failed to initialize PlantArchitecture")
315 def _check_context_alive(self):
316 """Raise if the owning Context has been destroyed (see Context.check_context_alive)."""
317 check_context_alive(getattr(self, "context", None), "PlantArchitecture")
318
319 def __enter__(self):
320 """Context manager entry"""
321 return self
322
323 def __exit__(self, exc_type, exc_val, exc_tb):
324 """Context manager exit - cleanup resources"""
325 if hasattr(self, '_plantarch_ptr') and self._plantarch_ptr:
326 plantarch_wrapper.destroyPlantArchitecture(self._plantarch_ptr)
327 self._plantarch_ptr = None
328
329 def __del__(self):
330 """Destructor to ensure C++ resources freed even without 'with' statement."""
331 if hasattr(self, '_plantarch_ptr') and self._plantarch_ptr is not None:
332 try:
333 plantarch_wrapper.destroyPlantArchitecture(self._plantarch_ptr)
334 self._plantarch_ptr = None
335 except Exception as e:
336 import warnings
337 warnings.warn(f"Error in PlantArchitecture.__del__: {e}")
338
339 def loadPlantModelFromLibrary(self, plant_label: str) -> None:
340 """
341 Load a plant model from the built-in library.
342
343 Args:
344 plant_label: Plant model identifier from library. Available models include:
345 "almond", "apple", "bean", "bindweed", "butterlettuce", "capsicum",
346 "cheeseweed", "cowpea", "easternredbud", "grapevine_VSP", "maize",
347 "olive", "pistachio", "puncturevine", "rice", "sorghum", "soybean",
348 "strawberry", "sugarbeet", "tomato", "cherrytomato", "walnut", "wheat"
349
350 Raises:
351 ValueError: If plant_label is empty or invalid
352 PlantArchitectureError: If model loading fails
353
354 Example:
355 >>> plantarch.loadPlantModelFromLibrary("bean")
356 >>> plantarch.loadPlantModelFromLibrary("almond")
357 """
358 if not plant_label:
359 raise ValueError("Plant label cannot be empty")
360
361 if not plant_label.strip():
362 raise ValueError("Plant label cannot be only whitespace")
363
364 self._reject_inside_phytomer_callback("loadPlantModelFromLibrary")
366 try:
368 plantarch_wrapper.loadPlantModelFromLibrary(self._plantarch_ptr, plant_label.strip())
369 except Exception as e:
370 raise PlantArchitectureError(f"Failed to load plant model '{plant_label}': {e}")
371
372 self._current_plant_model = plant_label.strip()
373
374 def _validate_build_parameters(self, build_parameters: Optional[dict]) -> None:
375 """Validate build parameters against the names the loaded model declares natively."""
376 accepted = None
377 if isinstance(build_parameters, dict) and build_parameters and self._current_plant_model is not None:
379 _validate_build_parameters(build_parameters, self._current_plant_model, accepted)
380
381 @_surfaces_phytomer_callback_errors
382 def buildPlantInstanceFromLibrary(self, base_position: vec3, age: float,
383 build_parameters: Optional[dict] = None) -> int:
384 """
385 Build a plant instance from the currently loaded library model.
386
387 Args:
388 base_position: Cartesian (x,y,z) coordinates of plant base as vec3
389 age: Age of the plant in days (must be >= 0)
390 build_parameters: Optional dict of species-specific parameter overrides, such
391 as trellis dimensions, trunk height and scaffold count, grape
392 cluster size, or sorghum tillers and panicle form. Only some models
393 accept them, and a key the model does not accept raises ValueError:
394 - almond, almond_independence, apple, pistachio, walnut:
395 trunk_height, num_scaffolds, scaffold_angle
396 - grapevine_VSP: vine_spacing, trunk_height, berry_diameter,
397 cluster_berry_count
398 - grapevine_Wye: trunk_height, cordon_spacing, vine_spacing,
399 catch_wire_height, berry_diameter, cluster_berry_count
400 - sorghum: tiller_count, panicle_type, and the panicle form,
401 grain and glume keys
402 All other models accept none. getPlantModelBuildParameterNames()
403 returns the full list for a model. Out-of-range or non-finite
404 values, and fractional values for parameters that count something
405 (e.g. num_scaffolds, tiller_count), raise PlantArchitectureError.
406 The plant keeps its build parameters (see getPlantBuildParameters()).
407
408 Returns:
409 Plant ID for the created plant instance
410
411 Raises:
412 ValueError: If age is negative or build_parameters is invalid
413 PlantArchitectureError: If plant building fails
414 RuntimeError: If no model has been loaded
415
416 Example:
417 >>> plant_id = plantarch.buildPlantInstanceFromLibrary(base_position=vec3(2.0, 3.0, 0.0), age=45.0)
418 >>> # With custom parameters
419 >>> plant_id = plantarch.buildPlantInstanceFromLibrary(
420 ... base_position=vec3(0, 0, 0),
421 ... age=30.0,
422 ... build_parameters={'trunk_height': 2.0}
423 ... )
424 """
425 # Parameter type validation
426 if not isinstance(base_position, vec3):
427 raise ValueError(f"base_position must be a vec3, got {type(base_position).__name__}")
428
429 # Convert position to list for C++ interface
430 position_list = [base_position.x, base_position.y, base_position.z]
431
432 # Validate age (allow zero)
433 if age < 0:
434 raise ValueError(f"Age must be non-negative, got {age}")
435
436 self._validate_build_parameters(build_parameters)
437
439 try:
441 return plantarch_wrapper.buildPlantInstanceFromLibrary(
442 self._plantarch_ptr, position_list, age, build_parameters
443 )
444 except Exception as e:
445 raise PlantArchitectureError(f"Failed to build plant instance: {e}")
446
447 @_surfaces_phytomer_callback_errors
448 def buildPlantCanopyFromLibrary(self, canopy_center: vec3,
449 plant_spacing: vec2,
450 plant_count: int2, age: float,
451 germination_rate: float = 1.0,
452 build_parameters: Optional[dict] = None) -> List[int]:
453 """
454 Build a canopy of regularly spaced plants from the currently loaded library model.
455
456 Args:
457 canopy_center: Cartesian (x,y,z) coordinates of canopy center as vec3
458 plant_spacing: Spacing between plants in x- and y-directions (meters) as vec2
459 plant_count: Number of plants in x- and y-directions as int2
460 age: Age of all plants in days (must be >= 0)
461 germination_rate: Probability that each plant position will be occupied (0 to 1).
462 A value of 1.0 means all positions are filled; 0.5 means roughly
463 half the positions will have plants. Default is 1.0.
464 build_parameters: Optional dict of species-specific parameter overrides,
465 applied to every plant in the canopy. Only some models
466 accept them, and a key the model does not accept raises ValueError.
467 See buildPlantInstanceFromLibrary() for the per-model list.
468
469 Returns:
470 List of plant IDs for the created plant instances
471
472 Raises:
473 ValueError: If age is negative, germination_rate is not in [0, 1],
474 plant count values are not positive, or build_parameters is invalid
475 PlantArchitectureError: If canopy building fails
476
477 Example:
478 >>> # 3x3 canopy with 0.5m spacing, 30-day-old plants
479 >>> plant_ids = plantarch.buildPlantCanopyFromLibrary(
480 ... canopy_center=vec3(0, 0, 0),
481 ... plant_spacing=vec2(0.5, 0.5),
482 ... plant_count=int2(3, 3),
483 ... age=30.0
484 ... )
485 >>> # With 80% germination rate and custom parameters
486 >>> plant_ids = plantarch.buildPlantCanopyFromLibrary(
487 ... canopy_center=vec3(0, 0, 0),
488 ... plant_spacing=vec2(1.5, 2.0),
489 ... plant_count=int2(5, 3),
490 ... age=45.0,
491 ... germination_rate=0.8,
492 ... build_parameters={'trunk_height': 1.8}
493 ... )
494 """
495 # Parameter type validation
496 if not isinstance(canopy_center, vec3):
497 raise ValueError(f"canopy_center must be a vec3, got {type(canopy_center).__name__}")
498 if not isinstance(plant_spacing, vec2):
499 raise ValueError(f"plant_spacing must be a vec2, got {type(plant_spacing).__name__}")
500 if not isinstance(plant_count, int2):
501 raise ValueError(f"plant_count must be an int2, got {type(plant_count).__name__}")
502
503 # Validate age (allow zero)
504 if age < 0:
505 raise ValueError(f"Age must be non-negative, got {age}")
507 # Validate germination rate
508 if not isinstance(germination_rate, (int, float)):
509 raise ValueError(f"germination_rate must be a number, got {type(germination_rate).__name__}")
510 if germination_rate < 0 or germination_rate > 1:
511 raise ValueError(f"germination_rate must be between 0 and 1, got {germination_rate}")
512
513 # Validate count values
514 if plant_count.x <= 0 or plant_count.y <= 0:
515 raise ValueError("Plant count values must be positive integers")
516
517 self._validate_build_parameters(build_parameters)
518
519 # Convert to lists for C++ interface
520 center_list = [canopy_center.x, canopy_center.y, canopy_center.z]
521 spacing_list = [plant_spacing.x, plant_spacing.y]
522 count_list = [plant_count.x, plant_count.y]
523
525 try:
527 return plantarch_wrapper.buildPlantCanopyFromLibrary(
528 self._plantarch_ptr, center_list, spacing_list, count_list, age,
529 germination_rate, build_parameters
530 )
531 except Exception as e:
532 raise PlantArchitectureError(f"Failed to build plant canopy: {e}")
533
534 @_surfaces_phytomer_callback_errors
535 def advanceTime(self, dt: float, plant_id: Optional[int] = None,
536 plant_ids: Optional[List[int]] = None,
537 years: Optional[int] = None) -> None:
538 """
539 Advance time for plant growth and development.
540
541 Updates plants in the simulation, potentially adding new phytomers, growing
542 existing organs, transitioning phenological stages, and updating plant geometry.
543
544 By default every plant advances together. Pass plant_id or plant_ids to advance a
545 subset, which is what staggered planting dates and mixed-age stands require.
546
547 Args:
548 dt: Time step to advance in days (must be >= 0)
549 plant_id: Advance only this plant. Mutually exclusive with plant_ids.
550 plant_ids: Advance only these plants. Mutually exclusive with plant_id.
551 years: Advance this many whole years in addition to dt days. Applies to all
552 plants and cannot be combined with plant_id or plant_ids.
553
554 Raises:
555 ValueError: If dt or years is negative, or selectors are combined
556 PlantArchitectureError: If time advancement fails
557
558 Note:
559 Large time steps are more efficient than many small steps. The timestep value
560 can be larger than the phyllochron, allowing multiple phytomers to be produced
561 in a single call.
562
563 Example:
564 >>> plantarch.advanceTime(10.0) # all plants, 10 days
565 >>> plantarch.advanceTime(10.0, plant_id=early) # one plant only
566 >>> plantarch.advanceTime(10.0, plant_ids=[a, b]) # a subset
567 >>> plantarch.advanceTime(0.0, years=4) # all plants, 4 years
568 """
569 if dt < 0:
570 raise ValueError(f"Time step must be non-negative, got {dt}")
571
572 selectors = sum(x is not None for x in (plant_id, plant_ids, years))
573 if selectors > 1:
574 raise ValueError("Pass at most one of plant_id, plant_ids, or years")
575
576 if plant_id is not None and plant_id < 0:
577 raise ValueError(f"plant_id must be non-negative, got {plant_id}")
578 if plant_ids is not None and any(pid < 0 for pid in plant_ids):
579 raise ValueError("plant_ids must all be non-negative")
580 if years is not None and years < 0:
581 raise ValueError(f"years must be non-negative, got {years}")
583 self._reject_inside_phytomer_callback("advanceTime")
585 try:
587 if plant_id is not None:
588 plantarch_wrapper.advanceTimeForPlant(self._plantarch_ptr, plant_id, dt)
589 elif plant_ids is not None:
590 if not plant_ids:
591 return
592 plantarch_wrapper.advanceTimeForPlants(self._plantarch_ptr, plant_ids, dt)
593 elif years is not None:
594 plantarch_wrapper.advanceTimeYears(self._plantarch_ptr, years, dt)
595 else:
596 plantarch_wrapper.advanceTime(self._plantarch_ptr, dt)
597 except Exception as e:
598 raise PlantArchitectureError(f"Failed to advance time by {dt} days: {e}")
599
600 def enableAttractionPoints(self, points: List[vec3],
601 plant_id: Optional[int] = None,
602 view_half_angle_deg: Optional[float] = None,
603 look_ahead_distance: float = 0.1,
604 attraction_weight: float = 0.6) -> None:
605 """
606 Steer shoot growth toward a set of target points.
607
608 Attraction points are the counterpart to collision avoidance: collision tells a
609 plant what to grow around, attraction tells it what to grow toward. This is how
610 trellis wires, espalier targets and greenhouse supports are modelled.
611
612 Steering applies to growth that happens after this call, since the direction is
613 chosen as each phytomer is constructed. Enable the points before advanceTime().
614
615 Args:
616 points: Target locations as a list of vec3
617 plant_id: Apply to this plant only. Applies to every plant when None.
618 view_half_angle_deg: Half-angle of the search cone in degrees. Defaults to
619 45 for the global form and 80 for the per-plant form, matching the
620 native defaults, which differ between the two.
621 look_ahead_distance: How far ahead a shoot tip looks, in meters
622 attraction_weight: Strength of the steering, 0 to 1
623
624 Raises:
625 ValueError: If points is empty or contains a non-vec3, or plant_id is negative
626 PlantArchitectureError: If the operation fails
627
628 Example:
629 >>> wires = [vec3(x, 0, 2.1) for x in range(0, 10)]
630 >>> plantarch.enableAttractionPoints(wires)
631 """
632 self._validate_attraction_points(points)
633 if plant_id is not None and plant_id < 0:
634 raise ValueError(f"plant_id must be non-negative, got {plant_id}")
635
636 if view_half_angle_deg is None:
637 view_half_angle_deg = 45.0 if plant_id is None else 80.0
638
640 try:
642 plantarch_wrapper.enableAttractionPoints(
643 self._plantarch_ptr, plant_id, points,
644 view_half_angle_deg, look_ahead_distance, attraction_weight
645 )
646 except Exception as e:
647 raise PlantArchitectureError(f"Failed to enable attraction points: {e}")
648
649 def disableAttractionPoints(self, plant_id: Optional[int] = None) -> None:
650 """
651 Stop steering growth toward attraction points.
652
653 Args:
654 plant_id: Disable for this plant only. Disables globally when None.
655
656 Raises:
657 ValueError: If plant_id is negative
658 PlantArchitectureError: If the operation fails
659 """
660 if plant_id is not None and plant_id < 0:
661 raise ValueError(f"plant_id must be non-negative, got {plant_id}")
662
664 try:
666 plantarch_wrapper.disableAttractionPoints(self._plantarch_ptr, plant_id)
667 except Exception as e:
668 raise PlantArchitectureError(f"Failed to disable attraction points: {e}")
669
670 def updateAttractionPoints(self, points: List[vec3],
671 plant_id: Optional[int] = None) -> None:
672 """
673 Replace the current attraction point set.
674
675 Args:
676 points: Replacement target locations as a list of vec3
677 plant_id: Update this plant only. Updates globally when None.
678
679 Raises:
680 ValueError: If points is empty or contains a non-vec3, or plant_id is negative
681 PlantArchitectureError: If the operation fails
682 """
683 self._validate_attraction_points(points)
684 if plant_id is not None and plant_id < 0:
685 raise ValueError(f"plant_id must be non-negative, got {plant_id}")
686
688 try:
690 plantarch_wrapper.updateAttractionPoints(self._plantarch_ptr, plant_id, points)
691 except Exception as e:
692 raise PlantArchitectureError(f"Failed to update attraction points: {e}")
693
694 def appendAttractionPoints(self, points: List[vec3],
695 plant_id: Optional[int] = None) -> None:
696 """
697 Add to the current attraction point set.
698
699 Args:
700 points: Additional target locations as a list of vec3
701 plant_id: Append for this plant only. Appends globally when None.
702
703 Raises:
704 ValueError: If points is empty or contains a non-vec3, or plant_id is negative
705 PlantArchitectureError: If the operation fails
706 """
707 self._validate_attraction_points(points)
708 if plant_id is not None and plant_id < 0:
709 raise ValueError(f"plant_id must be non-negative, got {plant_id}")
710
712 try:
714 plantarch_wrapper.appendAttractionPoints(self._plantarch_ptr, plant_id, points)
715 except Exception as e:
716 raise PlantArchitectureError(f"Failed to append attraction points: {e}")
717
718 def setAttractionParameters(self, view_half_angle_deg: float,
719 look_ahead_distance: float,
720 attraction_weight: float,
721 obstacle_reduction_factor: float = 0.75,
722 plant_id: Optional[int] = None) -> None:
723 """
724 Tune how strongly attraction points steer growth.
725
726 Args:
727 view_half_angle_deg: Half-angle of the search cone in degrees
728 look_ahead_distance: How far ahead a shoot tip looks, in meters
729 attraction_weight: Strength of the steering, 0 to 1
730 obstacle_reduction_factor: Scales attraction where an obstacle intervenes
731 plant_id: Apply to this plant only. Applies globally when None.
732
733 Raises:
734 ValueError: If plant_id is negative
735 PlantArchitectureError: If the operation fails
736 """
737 if plant_id is not None and plant_id < 0:
738 raise ValueError(f"plant_id must be non-negative, got {plant_id}")
739
741 try:
743 plantarch_wrapper.setAttractionParameters(
744 self._plantarch_ptr, plant_id, view_half_angle_deg,
745 look_ahead_distance, attraction_weight, obstacle_reduction_factor
746 )
747 except Exception as e:
748 raise PlantArchitectureError(f"Failed to set attraction parameters: {e}")
749
750 @staticmethod
751 def _validate_attraction_points(points) -> None:
752 """Reject point sets the native layer would misread or silently ignore."""
753 if not isinstance(points, (list, tuple)):
754 raise ValueError(
755 f"points must be a list of vec3, got {type(points).__name__}"
756 )
757 if not points:
758 raise ValueError("points cannot be empty")
759 for index, point in enumerate(points):
760 if not isinstance(point, vec3):
761 raise ValueError(
762 f"points[{index}] must be a vec3, got {type(point).__name__}"
763 )
764
765 def setProgressCallback(self, callback):
766 """Set a callback to receive progress updates during long-running operations.
767
768 The callback fires during advanceTime() and adjustFruitForObstacleCollision()
769 as the underlying ProgressBar updates.
771 Args:
772 callback: A callable(progress: float, message: str) where progress is
773 in [0, 1], or None to clear the callback.
774
775 Raises:
776 ValueError: If callback is not callable and not None.
777 """
778 if callback is not None:
779 if not callable(callback):
780 raise ValueError(
781 f"callback must be callable or None, got {type(callback).__name__}"
782 )
783
784 def _c_callback(progress, message_bytes):
785 msg = message_bytes.decode('utf-8') if isinstance(message_bytes, bytes) else str(message_bytes)
786 callback(progress, msg)
787
789 self._progress_callback_ref = plantarch_wrapper.PROGRESS_CALLBACK(_c_callback)
791 plantarch_wrapper.setProgressCallback(self._plantarch_ptr, self._progress_callback_ref)
792 else:
794 plantarch_wrapper.setProgressCallback(self._plantarch_ptr, None)
796
797 def setCancelFlag(self, cancel_flag):
798 """Register an external cancellation flag polled during long plant builds.
799
800 ``cancel_flag`` is a ctypes.c_int that, when set non-zero from another
801 thread, stops the canopy build loop and the advanceTime() growth loop
802 between plants/timesteps — so a long generation can be aborted mid-build
803 (returning whatever was built so far). Set it before the build call; pass
804 None to clear. The flag is caller-owned and must outlive the build.
805 """
807 plantarch_wrapper.setCancelFlag(self._plantarch_ptr, cancel_flag)
808
809 def getCurrentShootParameters(self, shoot_type_label: str, return_typed: bool = False):
810 """
811 Get current shoot parameters for a shoot type.
812
813 Returns the full nested shoot and phytomer parameter set, including the
814 internode/petiole/leaf/peduncle/inflorescence sub-structures and the leaf
815 prototype. Every numeric field is a RandomParameter spec with a
816 'distribution' and 'parameters'.
817
818 Args:
819 shoot_type_label: Label for the shoot type. Labels are species-specific,
820 e.g. "trifoliate" (bean), "trunk"/"scaffold" (almond).
821 return_typed: If True, return a typed
822 :class:`pyhelios.plant_architecture_params.ShootParameters`
823 object instead of a plain nested dict.
824
825 Returns:
826 A nested ``dict`` (default) or a ``ShootParameters`` object containing:
827 - Geometric parameters (max_nodes, insertion_angle_tip, etc.)
828 - Growth parameters (phyllochron_min, elongation_rate_max, etc.)
829 - Boolean flags (flowers_require_dormancy, etc.)
830 - ``phytomer_parameters`` with nested internode/petiole/leaf/peduncle/
831 inflorescence parameters and the leaf prototype
832
833 Raises:
834 ValueError: If shoot_type_label is empty
835 PlantArchitectureError: If parameter retrieval fails
836
837 Example:
838 >>> plantarch.loadPlantModelFromLibrary("bean")
839 >>> params = plantarch.getCurrentShootParameters("trifoliate")
840 >>> print(params['max_nodes'])
841 {'distribution': 'constant', 'parameters': [25.0]}
842 >>> print(params['phytomer_parameters']['leaf']['pitch'])
843 {'distribution': 'normal', 'parameters': [0.0, 20.0]}
844 """
845 if not shoot_type_label:
846 raise ValueError("Shoot type label cannot be empty")
847
848 if not shoot_type_label.strip():
849 raise ValueError("Shoot type label cannot be only whitespace")
850
852 try:
854 params = plantarch_wrapper.getCurrentShootParameters(
855 self._plantarch_ptr, shoot_type_label.strip()
856 )
857 except Exception as e:
858 # An unknown label is the common case here, and the native error does not say
859 # which labels exist. Name them so the caller does not have to guess.
860 available = ""
861 try:
862 labels = self.listShootTypeLabels()
863 if labels:
864 available = f" Available shoot types: {', '.join(sorted(labels))}."
865 except Exception:
866 pass
867 raise PlantArchitectureError(f"{e}.{available}")
868
869 return ShootParameters.from_dict(params) if return_typed else params
870
871 def defineShootType(self, shoot_type_label: str, parameters: Union[dict, ShootParameters]) -> None:
872 """
873 Define a custom shoot type with specified parameters.
874
875 Allows creating new shoot types or modifying existing ones. Pass either a
876 nested parameter ``dict`` (use :meth:`getCurrentShootParameters` as a
877 template) or a typed
878 :class:`pyhelios.plant_architecture_params.ShootParameters` object.
879
880 Redefining an existing library shoot type preserves that species' built-in
881 phytomer creation and callback functions, so species-specific organ behavior
882 (such as maize forming ears rather than a tassel at every node) is retained.
883
884 Args:
885 shoot_type_label: Unique name for this shoot type
886 parameters: A nested dict matching the ShootParameters structure, or a
887 ShootParameters object.
888
889 Raises:
890 ValueError: If shoot_type_label is empty, or parameters is not a dict
891 or ShootParameters
892 PlantArchitectureError: If shoot type definition fails
893
894 Example:
895 >>> from pyhelios.plant_architecture_params import ShootParameters, RandomParameterFloat
896 >>> plantarch.loadPlantModelFromLibrary("bean")
897 >>> sp = plantarch.getCurrentShootParameters("trifoliate", return_typed=True)
898 >>> sp.max_nodes = RandomParameterFloat.constant(20)
899 >>> sp.phytomer_parameters.leaf.pitch = RandomParameterFloat.uniform(40, 50)
900 >>> plantarch.defineShootType("TallStem", sp)
901 """
902 if not shoot_type_label:
903 raise ValueError("Shoot type label cannot be empty")
904
905 if not shoot_type_label.strip():
906 raise ValueError("Shoot type label cannot be only whitespace")
907
908 if isinstance(parameters, ShootParameters):
909 parameters = parameters.to_dict()
910 elif not isinstance(parameters, dict):
911 raise ValueError(
912 f"Parameters must be a dict or ShootParameters, got {type(parameters).__name__}"
913 )
914
916 try:
918 plantarch_wrapper.defineShootType(
919 self._plantarch_ptr, self.context.context, shoot_type_label.strip(), parameters
920 )
921 except Exception as e:
922 raise PlantArchitectureError(f"Failed to define shoot type '{shoot_type_label}': {e}")
923
924 def getDefaultCarbohydrateParameters(self, return_typed: bool = False):
925 """
926 Get a default-constructed set of carbohydrate-model parameters.
927
928 The native API exposes no per-plant getter for carbohydrate parameters, so
929 this returns the C++ defaults as a template to modify and apply via
930 :meth:`setPlantCarbohydrateParameters`.
931
932 Args:
933 return_typed: If True, return a typed
934 :class:`pyhelios.plant_architecture_params.CarbohydrateParameters`.
935
936 Returns:
937 A flat ``dict`` (default) or ``CarbohydrateParameters`` object.
938 """
940 try:
942 params = plantarch_wrapper.getDefaultCarbohydrateParameters()
943 except Exception as e:
944 raise PlantArchitectureError(f"Failed to get default carbohydrate parameters: {e}")
945 return CarbohydrateParameters.from_dict(params) if return_typed else params
946
947 def setPlantCarbohydrateParameters(self, plant_id: int, parameters: Union[dict, CarbohydrateParameters]) -> None:
948 """
949 Set carbohydrate-model parameters for a plant.
950
951 Args:
952 plant_id: Target plant instance ID
953 parameters: A flat dict or a CarbohydrateParameters object.
954
955 Raises:
956 ValueError: If parameters is not a dict or CarbohydrateParameters
957 PlantArchitectureError: If the operation fails
958 """
959 if isinstance(parameters, CarbohydrateParameters):
960 parameters = parameters.to_dict()
961 elif not isinstance(parameters, dict):
962 raise ValueError(
963 f"Parameters must be a dict or CarbohydrateParameters, got {type(parameters).__name__}"
964 )
966 try:
968 plantarch_wrapper.setPlantCarbohydrateParameters(self._plantarch_ptr, plant_id, parameters)
969 except Exception as e:
970 raise PlantArchitectureError(f"Failed to set carbohydrate parameters for plant {plant_id}: {e}")
971
972 def getDefaultNitrogenParameters(self, return_typed: bool = False):
973 """
974 Get a default-constructed set of nitrogen-model parameters.
975
976 The native API exposes no per-plant getter for nitrogen parameters, so this
977 returns the C++ defaults as a template to modify and apply via
978 :meth:`setPlantNitrogenParameters`.
979
980 Args:
981 return_typed: If True, return a typed
982 :class:`pyhelios.plant_architecture_params.NitrogenParameters`.
983
984 Returns:
985 A flat ``dict`` (default) or ``NitrogenParameters`` object.
986 """
988 try:
990 params = plantarch_wrapper.getDefaultNitrogenParameters()
991 except Exception as e:
992 raise PlantArchitectureError(f"Failed to get default nitrogen parameters: {e}")
993 return NitrogenParameters.from_dict(params) if return_typed else params
994
995 def setPlantNitrogenParameters(self, plant_id: int, parameters: Union[dict, NitrogenParameters]) -> None:
996 """
997 Set nitrogen-model parameters for a plant.
998
999 Args:
1000 plant_id: Target plant instance ID
1001 parameters: A flat dict or a NitrogenParameters object.
1002
1003 Raises:
1004 ValueError: If parameters is not a dict or NitrogenParameters, or the dict
1005 contains a key that is not a nitrogen parameter
1006 PlantArchitectureError: If the operation fails
1007 """
1008 if isinstance(parameters, NitrogenParameters):
1009 parameters = parameters.to_dict()
1010 elif not isinstance(parameters, dict):
1011 raise ValueError(
1012 f"Parameters must be a dict or NitrogenParameters, got {type(parameters).__name__}"
1013 )
1014 unknown = sorted(set(parameters) - set(NitrogenParameters().to_dict()))
1015 if unknown:
1016 raise ValueError(
1017 f"Unknown nitrogen parameter(s) {unknown}; valid keys are "
1018 f"{sorted(NitrogenParameters().to_dict())}. remobilization_age_threshold was removed "
1019 f"in helios-core 1.3.88 (see leaf_remobilization_rate and leaf_senescence_duration_fraction)."
1020 )
1022 try:
1024 plantarch_wrapper.setPlantNitrogenParameters(self._plantarch_ptr, plant_id, parameters)
1025 except Exception as e:
1026 raise PlantArchitectureError(f"Failed to set nitrogen parameters for plant {plant_id}: {e}")
1027
1028 def getAvailablePlantModels(self) -> List[str]:
1029 """
1030 Get list of all available plant models in the library.
1031
1032 Returns:
1033 List of plant model names available for loading
1034
1035 Raises:
1036 PlantArchitectureError: If retrieval fails
1037
1038 Example:
1039 >>> models = plantarch.getAvailablePlantModels()
1040 >>> print(f"Available models: {', '.join(models)}")
1041 Available models: almond, apple, bean, cowpea, maize, rice, soybean, tomato, wheat, ...
1042 """
1044 try:
1046 return plantarch_wrapper.getAvailablePlantModels(self._plantarch_ptr)
1047 except Exception as e:
1048 raise PlantArchitectureError(f"Failed to get available plant models: {e}")
1049
1050 def getPlantModelBuildParameterNames(self, plant_model: str) -> List[str]:
1051 """
1052 Get the names of the build parameters a library plant model accepts.
1053
1054 These are the keys that may be passed as ``build_parameters`` to
1055 :meth:`buildPlantInstanceFromLibrary` and :meth:`buildPlantCanopyFromLibrary` for the
1056 model; any other key is rejected. The model does not need to be loaded.
1057
1058 Args:
1059 plant_model: Name of the plant model (see :meth:`getAvailablePlantModels`)
1061 Returns:
1062 Names of the model's structure parameters (training system, tillers) followed by
1063 its organ parameters (flower and fruit form); empty if the model accepts none
1064
1065 Raises:
1066 ValueError: If ``plant_model`` is not a non-empty string
1067 PlantArchitectureError: If the model does not exist in the library
1068 RuntimeError: If the native library predates helios-core v1.3.93
1069
1070 Example:
1071 >>> plantarch.getPlantModelBuildParameterNames("grapevine_VSP")
1072 ['vine_spacing', 'trunk_height', 'berry_diameter', 'cluster_berry_count']
1073 """
1074 if not isinstance(plant_model, str) or not plant_model.strip():
1075 raise ValueError("Plant model must be a non-empty string")
1077 try:
1078 return plantarch_wrapper.getPlantModelBuildParameterNames(self._plantarch_ptr, plant_model)
1079 except Exception as e:
1081 f"Failed to get the build parameter names of plant model '{plant_model}': {e}")
1082
1083 def listShootTypeLabels(self, plant_model: Optional[str] = None,
1084 plant_id: Optional[int] = None) -> List[str]:
1085 """
1086 Get the shoot type labels defined for a plant model.
1087
1088 Shoot type labels are species-specific strings such as "trunk" or "scaffold", and
1089 every shoot-parameter call takes one. Use this to discover the valid labels rather
1090 than guessing them.
1092 Args:
1093 plant_model: Query this library model without changing the currently loaded
1094 one. Use getAvailablePlantModels() for valid names. Mutually exclusive
1095 with plant_id.
1096 plant_id: Query the shoot types captured by this plant instance when it was
1097 created. Mutually exclusive with plant_model.
1098
1099 With neither argument, queries the currently loaded model, which requires a prior
1100 call to loadPlantModelFromLibrary().
1101
1102 Returns:
1103 List of shoot type label strings.
1104
1105 Raises:
1106 ValueError: If both plant_model and plant_id are given, or plant_id is negative
1107 PlantArchitectureError: If no model is loaded, or the model or plant is unknown
1108
1109 Example:
1110 >>> plantarch.loadPlantModelFromLibrary("almond")
1111 >>> plantarch.listShootTypeLabels()
1112 ['proleptic', 'scaffold', 'sylleptic', 'trunk']
1113 >>> plantarch.listShootTypeLabels(plant_model="bean")
1114 ['trifoliate', 'unifoliate']
1115 """
1116 if plant_model is not None and plant_id is not None:
1117 raise ValueError("Pass either plant_model or plant_id, not both")
1118 if plant_id is not None and plant_id < 0:
1119 raise ValueError(f"plant_id must be non-negative, got {plant_id}")
1120 if plant_model is not None and not plant_model.strip():
1121 raise ValueError("plant_model cannot be empty or only whitespace")
1122
1124 try:
1126 return plantarch_wrapper.listShootTypeLabels(
1127 self._plantarch_ptr,
1128 plant_model.strip() if plant_model is not None else None,
1129 plant_id,
1130 )
1131 except Exception as e:
1132 raise PlantArchitectureError(f"Failed to list shoot type labels: {e}")
1133
1134 def getAllUUIDs(self) -> List[int]:
1135 """
1136 Get UUIDs of every plant primitive in the model.
1137
1138 Spans every plant, unlike the per-plant getters, which is what canopy-wide work
1139 such as assigning optical properties or reading flux by organ type needs.
1140
1141 Returns:
1142 List of primitive UUIDs
1143
1144 Raises:
1145 PlantArchitectureError: If retrieval fails
1146
1147 Example:
1148 >>> ids = plantarch.getAllUUIDs()
1149 """
1151 try:
1153 return plantarch_wrapper.getAllUUIDs(self._plantarch_ptr)
1154 except Exception as e:
1155 raise PlantArchitectureError(f"Failed to get primitive UUIDs: {e}")
1156
1157 def getAllLeafUUIDs(self) -> List[int]:
1158 """
1159 Get UUIDs of every leaf primitive in the model.
1160
1161 Spans every plant, unlike the per-plant getters, which is what canopy-wide work
1162 such as assigning optical properties or reading flux by organ type needs.
1163
1164 Returns:
1165 List of leaf primitive UUIDs
1166
1167 Raises:
1168 PlantArchitectureError: If retrieval fails
1169
1170 Example:
1171 >>> ids = plantarch.getAllLeafUUIDs()
1172 """
1174 try:
1176 return plantarch_wrapper.getAllLeafUUIDs(self._plantarch_ptr)
1177 except Exception as e:
1178 raise PlantArchitectureError(f"Failed to get leaf primitive UUIDs: {e}")
1179
1180 def getAllInternodeUUIDs(self) -> List[int]:
1181 """
1182 Get UUIDs of every internode primitive in the model.
1183
1184 Spans every plant, unlike the per-plant getters, which is what canopy-wide work
1185 such as assigning optical properties or reading flux by organ type needs.
1186
1187 Returns:
1188 List of internode primitive UUIDs
1189
1190 Raises:
1191 PlantArchitectureError: If retrieval fails
1192
1193 Example:
1194 >>> ids = plantarch.getAllInternodeUUIDs()
1195 """
1197 try:
1199 return plantarch_wrapper.getAllInternodeUUIDs(self._plantarch_ptr)
1200 except Exception as e:
1201 raise PlantArchitectureError(f"Failed to get internode primitive UUIDs: {e}")
1202
1203 def getAllPetioleUUIDs(self) -> List[int]:
1204 """
1205 Get UUIDs of every petiole primitive in the model.
1206
1207 Spans every plant, unlike the per-plant getters, which is what canopy-wide work
1208 such as assigning optical properties or reading flux by organ type needs.
1209
1210 Returns:
1211 List of petiole primitive UUIDs
1212
1213 Raises:
1214 PlantArchitectureError: If retrieval fails
1215
1216 Example:
1217 >>> ids = plantarch.getAllPetioleUUIDs()
1218 """
1220 try:
1222 return plantarch_wrapper.getAllPetioleUUIDs(self._plantarch_ptr)
1223 except Exception as e:
1224 raise PlantArchitectureError(f"Failed to get petiole primitive UUIDs: {e}")
1225
1226 def getAllPeduncleUUIDs(self) -> List[int]:
1227 """
1228 Get UUIDs of every peduncle primitive in the model.
1229
1230 Spans every plant, unlike the per-plant getters, which is what canopy-wide work
1231 such as assigning optical properties or reading flux by organ type needs.
1232
1233 An empty list means no plant has reached the corresponding growth stage,
1234 which is a legitimate result rather than a failure.
1235
1236 Returns:
1237 List of peduncle primitive UUIDs
1238
1239 Raises:
1240 PlantArchitectureError: If retrieval fails
1241
1242 Example:
1243 >>> ids = plantarch.getAllPeduncleUUIDs()
1244 """
1246 try:
1248 return plantarch_wrapper.getAllPeduncleUUIDs(self._plantarch_ptr)
1249 except Exception as e:
1250 raise PlantArchitectureError(f"Failed to get peduncle primitive UUIDs: {e}")
1251
1252 def getAllFlowerUUIDs(self) -> List[int]:
1253 """
1254 Get UUIDs of every flower primitive in the model.
1255
1256 Spans every plant, unlike the per-plant getters, which is what canopy-wide work
1257 such as assigning optical properties or reading flux by organ type needs.
1258
1259 An empty list means no plant has reached the corresponding growth stage,
1260 which is a legitimate result rather than a failure.
1261
1262 Returns:
1263 List of flower primitive UUIDs
1264
1265 Raises:
1266 PlantArchitectureError: If retrieval fails
1267
1268 Example:
1269 >>> ids = plantarch.getAllFlowerUUIDs()
1270 """
1272 try:
1274 return plantarch_wrapper.getAllFlowerUUIDs(self._plantarch_ptr)
1275 except Exception as e:
1276 raise PlantArchitectureError(f"Failed to get flower primitive UUIDs: {e}")
1277
1278 def getAllFruitUUIDs(self) -> List[int]:
1279 """
1280 Get UUIDs of every fruit primitive in the model.
1281
1282 Spans every plant, unlike the per-plant getters, which is what canopy-wide work
1283 such as assigning optical properties or reading flux by organ type needs.
1284
1285 An empty list means no plant has reached the corresponding growth stage,
1286 which is a legitimate result rather than a failure.
1287
1288 Returns:
1289 List of fruit primitive UUIDs
1290
1291 Raises:
1292 PlantArchitectureError: If retrieval fails
1293
1294 Example:
1295 >>> ids = plantarch.getAllFruitUUIDs()
1296 """
1298 try:
1300 return plantarch_wrapper.getAllFruitUUIDs(self._plantarch_ptr)
1301 except Exception as e:
1302 raise PlantArchitectureError(f"Failed to get fruit primitive UUIDs: {e}")
1303
1304 def getAllObjectIDs(self) -> List[int]:
1305 """
1306 Get object IDs of every plant compound object in the model.
1307
1308 Spans every plant, unlike the per-plant getters, which is what canopy-wide work
1309 such as assigning optical properties or reading flux by organ type needs.
1310
1311 Returns:
1312 List of object IDs
1313
1314 Raises:
1315 PlantArchitectureError: If retrieval fails
1316
1317 Example:
1318 >>> ids = plantarch.getAllObjectIDs()
1319 """
1321 try:
1323 return plantarch_wrapper.getAllObjectIDs(self._plantarch_ptr)
1324 except Exception as e:
1325 raise PlantArchitectureError(f"Failed to get object IDs: {e}")
1326
1327 def getAllPlantIDs(self) -> List[int]:
1328 """
1329 Get IDs of every plant instance in the model.
1330
1331 Spans every plant, unlike the per-plant getters, which is what canopy-wide work
1332 such as assigning optical properties or reading flux by organ type needs.
1333
1334 Returns:
1335 List of plant IDs
1336
1337 Raises:
1338 PlantArchitectureError: If retrieval fails
1339
1340 Example:
1341 >>> ids = plantarch.getAllPlantIDs()
1342 """
1344 try:
1346 return plantarch_wrapper.getAllPlantIDs(self._plantarch_ptr)
1347 except Exception as e:
1348 raise PlantArchitectureError(f"Failed to get plant IDs: {e}")
1349
1350 def getAllPlantObjectIDs(self, plant_id: int) -> List[int]:
1351 """
1352 Get all object IDs for a specific plant.
1353
1354 Args:
1355 plant_id: ID of the plant instance
1356
1357 Returns:
1358 List of object IDs comprising the plant
1359
1360 Raises:
1361 ValueError: If plant_id is negative
1362 PlantArchitectureError: If retrieval fails
1363
1364 Example:
1365 >>> object_ids = plantarch.getAllPlantObjectIDs(plant_id)
1366 >>> print(f"Plant has {len(object_ids)} objects")
1367 """
1368 if plant_id < 0:
1369 raise ValueError("Plant ID must be non-negative")
1370
1372 try:
1373 return plantarch_wrapper.getAllPlantObjectIDs(self._plantarch_ptr, plant_id)
1374 except Exception as e:
1375 raise PlantArchitectureError(f"Failed to get object IDs for plant {plant_id}: {e}")
1376
1377 def getPlantLeafObjectIDs(self, plant_id: int) -> List[int]:
1378 """
1379 Get object IDs for all leaf objects on a specific plant.
1380
1381 Args:
1382 plant_id: ID of the plant instance
1383
1384 Returns:
1385 List of object IDs, one per leaf
1386
1387 Raises:
1388 ValueError: If plant_id is negative
1389 PlantArchitectureError: If retrieval fails
1390
1391 Warning:
1392 Do **not** pair this result positionally with :meth:`getPlantLeafBases`.
1393 The two are built by independent traversals of the shoot tree, so their
1394 index correspondence is not guaranteed by the native API.
1395
1396 Example:
1397 >>> leaf_ids = plantarch.getPlantLeafObjectIDs(plant_id)
1398 >>> print(f"Plant has {len(leaf_ids)} leaves")
1399 """
1400 if plant_id < 0:
1401 raise ValueError("Plant ID must be non-negative")
1402
1404 try:
1405 return plantarch_wrapper.getPlantLeafObjectIDs(self._plantarch_ptr, plant_id)
1406 except Exception as e:
1407 raise PlantArchitectureError(f"Failed to get leaf object IDs for plant {plant_id}: {e}")
1408
1409 def getPlantPetioleObjectIDs(self, plant_id: int) -> List[int]:
1410 """
1411 Get object IDs for all petiole objects on a specific plant.
1412
1413 Petioles are the stalks attaching leaves to the stem, so this is the
1414 structural counterpart to :meth:`getPlantLeafObjectIDs`.
1415
1416 Args:
1417 plant_id: ID of the plant instance
1418
1419 Returns:
1420 List of object IDs, one per petiole
1421
1422 Raises:
1423 ValueError: If plant_id is negative
1424 PlantArchitectureError: If retrieval fails
1425
1426 Example:
1427 >>> petiole_ids = plantarch.getPlantPetioleObjectIDs(plant_id)
1428 >>> print(f"Plant has {len(petiole_ids)} petioles")
1429 """
1430 if plant_id < 0:
1431 raise ValueError("Plant ID must be non-negative")
1432
1434 try:
1435 return plantarch_wrapper.getPlantPetioleObjectIDs(self._plantarch_ptr, plant_id)
1436 except Exception as e:
1437 raise PlantArchitectureError(f"Failed to get petiole object IDs for plant {plant_id}: {e}")
1438
1439 def getPlantPeduncleObjectIDs(self, plant_id: int) -> List[int]:
1440 """
1441 Get object IDs for all peduncle objects on a specific plant.
1442
1443 Peduncles are the stalks bearing flowers and fruit.
1444
1445 Args:
1446 plant_id: ID of the plant instance
1448 Returns:
1449 List of object IDs, one per peduncle. Empty if the plant has not
1450 reached its reproductive stage, which is a normal result rather than
1451 an error.
1452
1453 Raises:
1454 ValueError: If plant_id is negative
1455 PlantArchitectureError: If retrieval fails
1456
1457 Example:
1458 >>> peduncle_ids = plantarch.getPlantPeduncleObjectIDs(plant_id)
1459 >>> print(f"Plant has {len(peduncle_ids)} peduncles")
1460 """
1461 if plant_id < 0:
1462 raise ValueError("Plant ID must be non-negative")
1463
1465 try:
1466 return plantarch_wrapper.getPlantPeduncleObjectIDs(self._plantarch_ptr, plant_id)
1467 except Exception as e:
1468 raise PlantArchitectureError(f"Failed to get peduncle object IDs for plant {plant_id}: {e}")
1469
1470 def getPlantFlowerObjectIDs(self, plant_id: int) -> List[int]:
1471 """
1472 Get object IDs for all flower (inflorescence) objects on a specific plant.
1473
1474 Args:
1475 plant_id: ID of the plant instance
1476
1477 Returns:
1478 List of object IDs, one per flower. Empty if the plant has not
1479 flowered -- or has already flowered and set fruit, since flowers are
1480 replaced by fruit as growth proceeds. Both are normal results rather
1481 than errors.
1482
1483 Raises:
1484 ValueError: If plant_id is negative
1485 PlantArchitectureError: If retrieval fails
1486
1487 Example:
1488 >>> flower_ids = plantarch.getPlantFlowerObjectIDs(plant_id)
1489 >>> print(f"Plant has {len(flower_ids)} flowers")
1490 """
1491 if plant_id < 0:
1492 raise ValueError("Plant ID must be non-negative")
1493
1495 try:
1496 return plantarch_wrapper.getPlantFlowerObjectIDs(self._plantarch_ptr, plant_id)
1497 except Exception as e:
1498 raise PlantArchitectureError(f"Failed to get flower object IDs for plant {plant_id}: {e}")
1499
1500 def getPlantFruitObjectIDs(self, plant_id: int) -> List[int]:
1501 """
1502 Get object IDs for all fruit objects on a specific plant.
1503
1504 Args:
1505 plant_id: ID of the plant instance
1506
1507 Returns:
1508 List of object IDs, one per fruit. Empty if the plant has not
1509 fruited, which is a normal result rather than an error -- fruit
1510 appear only once a plant reaches the reproductive stage, so a plant
1511 built at a young age or from a model with no fruit yields ``[]``.
1512
1513 Raises:
1514 ValueError: If plant_id is negative
1515 PlantArchitectureError: If retrieval fails
1516
1517 Example:
1518 >>> fruit_ids = plantarch.getPlantFruitObjectIDs(plant_id)
1519 >>> print(f"Plant has {len(fruit_ids)} fruit")
1520 >>> # Object IDs are Context object IDs, so the usual queries apply:
1521 >>> uuids = context.getObjectPrimitiveUUIDs(fruit_ids[0])
1522 """
1523 if plant_id < 0:
1524 raise ValueError("Plant ID must be non-negative")
1525
1527 try:
1528 return plantarch_wrapper.getPlantFruitObjectIDs(self._plantarch_ptr, plant_id)
1529 except Exception as e:
1530 raise PlantArchitectureError(f"Failed to get fruit object IDs for plant {plant_id}: {e}")
1531
1532 def getPlantLeafBases(self, plant_id: int) -> List[vec3]:
1533 """
1534 Get the attachment base position of every leaf on a specific plant.
1535
1536 The base is where the leaf attaches to its petiole, not the leaf centroid.
1537
1538 Args:
1539 plant_id: ID of the plant instance
1541 Returns:
1542 List of vec3 base positions, one per leaf
1543
1544 Raises:
1545 ValueError: If plant_id is negative
1546 PlantArchitectureError: If retrieval fails
1547
1548 Warning:
1549 Do **not** pair this result positionally with
1550 :meth:`getPlantLeafObjectIDs`. The two are built by independent
1551 traversals of the shoot tree, so their index correspondence is not
1552 guaranteed by the native API.
1553
1554 Example:
1555 >>> bases = plantarch.getPlantLeafBases(plant_id)
1556 >>> print(f"First leaf attaches at {bases[0]}")
1557 """
1558 if plant_id < 0:
1559 raise ValueError("Plant ID must be non-negative")
1560
1562 try:
1563 flat = plantarch_wrapper.getPlantLeafBases(self._plantarch_ptr, plant_id)
1564 except Exception as e:
1565 raise PlantArchitectureError(f"Failed to get leaf bases for plant {plant_id}: {e}")
1566
1567 return [vec3(float(flat[i]), float(flat[i + 1]), float(flat[i + 2]))
1568 for i in range(0, len(flat), 3)]
1569
1570 def getAllPlantUUIDs(self, plant_id: int, include_hidden: bool = False) -> List[int]:
1571 """
1572 Get all primitive UUIDs for a specific plant.
1573
1574 Args:
1575 plant_id: ID of the plant instance
1576 include_hidden: If True, also include UUIDs of hidden prototype
1577 primitives managed by this PlantArchitecture instance.
1578
1579 Returns:
1580 List of primitive UUIDs comprising the plant (and optionally hidden prototypes)
1581
1582 Raises:
1583 ValueError: If plant_id is negative
1584 PlantArchitectureError: If retrieval fails
1585
1586 Example:
1587 >>> uuids = plantarch.getAllPlantUUIDs(plant_id)
1588 >>> print(f"Plant has {len(uuids)} primitives")
1589 """
1590 if plant_id < 0:
1591 raise ValueError("Plant ID must be non-negative")
1592
1594 try:
1595 return plantarch_wrapper.getAllPlantUUIDs(self._plantarch_ptr, plant_id, include_hidden)
1596 except Exception as e:
1597 raise PlantArchitectureError(f"Failed to get UUIDs for plant {plant_id}: {e}")
1598
1599 def getAllShootIDs(self, plant_id: int) -> List[int]:
1600 """
1601 Get the IDs of all shoots belonging to a plant.
1602
1603 Shoot IDs are contiguous 0-based indices into the plant's shoot tree, in creation
1604 order; shoot 0 is always the base stem. The returned IDs can be passed to
1605 :meth:`getShoot`, :meth:`getShootChildIDs`, etc.
1606
1607 Args:
1608 plant_id: ID of the plant instance
1609
1610 Returns:
1611 List of shoot IDs for the plant
1612 """
1613 if plant_id < 0:
1614 raise ValueError("Plant ID must be non-negative")
1616 try:
1617 return plantarch_wrapper.getAllPlantShootIDs(self._plantarch_ptr, plant_id)
1618 except Exception as e:
1619 raise PlantArchitectureError(f"Failed to get shoot IDs for plant {plant_id}: {e}")
1620
1621 def getShoot(self, plant_id: int, shoot_id: int) -> Dict[str, Any]:
1622 """
1623 Get a read-only view of a shoot's topology.
1624
1625 Args:
1626 plant_id: ID of the plant instance
1627 shoot_id: Shoot index within the plant (see :meth:`getAllShootIDs`)
1628
1629 Returns:
1630 A dict with keys ``rank``, ``parent_shoot_id`` (-1 for the base stem),
1631 ``parent_node_index``, and ``node_count``.
1632 """
1633 if plant_id < 0 or shoot_id < 0:
1634 raise ValueError("Plant ID and shoot ID must be non-negative")
1636 try:
1637 return plantarch_wrapper.getPlantShootTopology(self._plantarch_ptr, plant_id, shoot_id)
1638 except Exception as e:
1640 f"Failed to get shoot {shoot_id} of plant {plant_id}: {e}")
1641
1642 def getShootChildIDs(self, plant_id: int, shoot_id: int) -> List[int]:
1643 """Get the child shoot IDs of a shoot (flattened across parent node indices)."""
1644 if plant_id < 0 or shoot_id < 0:
1645 raise ValueError("Plant ID and shoot ID must be non-negative")
1647 try:
1648 return plantarch_wrapper.getPlantShootChildIDs(self._plantarch_ptr, plant_id, shoot_id)
1649 except Exception as e:
1651 f"Failed to get child shoots of shoot {shoot_id}, plant {plant_id}: {e}")
1652
1653 def getParentShootID(self, plant_id: int, shoot_id: int) -> int:
1654 """
1655 Get the ID of the shoot a shoot grew from.
1656
1657 Args:
1658 plant_id: ID of the plant instance
1659 shoot_id: Shoot index within the plant (see :meth:`getAllShootIDs`)
1660
1661 Returns:
1662 ID of the parent shoot, or -1 if this is the base stem shoot.
1663
1664 Note:
1665 A pruned shoot still reports the parent it grew from, even though it is no
1666 longer listed among that parent's children.
1667
1668 Example:
1669 >>> parent = plantarch.getParentShootID(plant_id, shoot_id=3)
1670 """
1671 return self._shootScalarQuery("getParentShootID", plant_id, shoot_id, "parent shoot ID")
1672
1673 def getShootRank(self, plant_id: int, shoot_id: int) -> int:
1674 """
1675 Get the branching rank of a shoot.
1676
1677 Rank is the botanical branching order: the base stem is rank 0, a branch off it
1678 is rank 1, and so on. A shoot created by :meth:`appendShoot` continues its
1679 parent's axis rather than branching from it, so it keeps the parent's rank.
1680 Rank is therefore not the same as :meth:`getShootDepth`.
1681
1682 Args:
1683 plant_id: ID of the plant instance
1684 shoot_id: Shoot index within the plant
1685
1686 Returns:
1687 Branching rank of the shoot.
1689 Example:
1690 >>> rank = plantarch.getShootRank(plant_id, shoot_id=3)
1691 """
1692 return self._shootScalarQuery("getShootRank", plant_id, shoot_id, "shoot rank")
1693
1694 def getShootDepth(self, plant_id: int, shoot_id: int) -> int:
1695 """
1696 Get the number of shoots between a shoot and the base stem shoot.
1697
1698 The base stem has depth 0, its children depth 1, and so on. Unlike
1699 :meth:`getShootRank` this counts every step in the shoot tree, including axis
1700 continuations created by :meth:`appendShoot`.
1701
1702 Args:
1703 plant_id: ID of the plant instance
1704 shoot_id: Shoot index within the plant
1705
1706 Returns:
1707 Number of steps from this shoot to the base stem shoot.
1708 """
1709 return self._shootScalarQuery("getShootDepth", plant_id, shoot_id, "shoot depth")
1710
1711 def isShootPruned(self, plant_id: int, shoot_id: int) -> bool:
1712 """
1713 Report whether a shoot has been pruned away entirely.
1714
1715 :meth:`pruneBranch` called with ``node_index=0`` removes all of a shoot's
1716 phytomers and geometry but keeps the shoot in the plant's tree so that shoot IDs
1717 stay stable. Such a shoot is still returned by :meth:`getAllShootIDs` but is
1718 inert: it has zero nodes, contributes no leaf area, and cannot be queried for
1719 geometry. Use this to skip those shoots when walking :meth:`getAllShootIDs`.
1720
1721 Args:
1722 plant_id: ID of the plant instance
1723 shoot_id: Shoot index within the plant
1724
1725 Returns:
1726 True if the shoot was pruned away and no longer forms part of the plant.
1727
1728 Example:
1729 >>> live = [s for s in plantarch.getAllShootIDs(plant_id)
1730 ... if not plantarch.isShootPruned(plant_id, s)]
1731 """
1732 return self._shootScalarQuery("isShootPruned", plant_id, shoot_id, "pruned state")
1733
1734 def getPathToRoot(self, plant_id: int, shoot_id: int) -> List[int]:
1735 """
1736 Get the chain of shoots connecting a shoot to the base stem shoot.
1737
1738 Args:
1739 plant_id: ID of the plant instance
1740 shoot_id: Shoot index within the plant
1741
1742 Returns:
1743 Shoot IDs ordered from the given shoot to the base stem shoot, including
1744 both. For the base stem shoot this is a single element.
1745
1746 Example:
1747 >>> path = plantarch.getPathToRoot(plant_id, shoot_id=5)
1748 """
1749 return self._shootScalarQuery("getPathToRoot", plant_id, shoot_id, "path to root")
1750
1751 def getChildShootIDs(self, plant_id: int, shoot_id: int) -> List[int]:
1752 """
1753 Get the shoots that grew directly out of a shoot.
1754
1755 Ordered by the node they attach to. This includes shoots created by
1756 :meth:`appendShoot`, which continue the parent's axis rather than branching from
1757 it; compare their :meth:`getShootRank` with the parent's to tell the two apart.
1758 Pruned shoots are not included.
1759
1760 Args:
1761 plant_id: ID of the plant instance
1762 shoot_id: Shoot index within the plant
1763
1764 Returns:
1765 IDs of the direct children of the shoot, empty if it has none.
1766 """
1767 return self._shootScalarQuery("getChildShootIDs", plant_id, shoot_id, "child shoot IDs")
1768
1769 def getAllDescendantShootIDs(self, plant_id: int, shoot_id: int) -> List[int]:
1770 """
1771 Get every shoot descending from a shoot.
1772
1773 Collected depth-first, so a shoot is always listed before its own descendants.
1774 The shoot itself is not included, and pruned shoots are omitted.
1775
1776 Args:
1777 plant_id: ID of the plant instance
1778 shoot_id: Shoot whose descendants to collect
1779
1780 Returns:
1781 IDs of all descendants of the shoot, empty if it has none.
1782
1783 Example:
1784 >>> descendants = plantarch.getAllDescendantShootIDs(plant_id, shoot_id=1)
1785 >>> print(f"Branch carries {len(descendants)} sub-shoots")
1786 """
1787 return self._shootScalarQuery("getAllDescendantShootIDs", plant_id, shoot_id,
1788 "descendant shoot IDs")
1789
1790 def getShootHierarchyMap(self, plant_id: int) -> Dict[int, List[int]]:
1791 """
1792 Get the parent-to-children structure of a plant.
1793
1794 Only shoots that actually have children appear as keys. Pruned shoots appear
1795 neither as keys nor among the children.
1796
1797 Args:
1798 plant_id: ID of the plant instance
1799
1800 Returns:
1801 Dict mapping shoot ID to the IDs of its direct children.
1802
1803 Example:
1804 >>> hierarchy = plantarch.getShootHierarchyMap(plant_id)
1805 >>> print(f"{len(hierarchy)} shoots carry branches")
1806 """
1807 if plant_id < 0:
1808 raise ValueError("Plant ID must be non-negative")
1810 try:
1811 return plantarch_wrapper.getShootHierarchyMap(self._plantarch_ptr, plant_id)
1812 except Exception as e:
1814 f"Failed to get shoot hierarchy of plant {plant_id}: {e}")
1815
1816 def _shootScalarQuery(self, wrapper_fn_name: str, plant_id: int, shoot_id: int,
1817 description: str):
1818 """Shared body for the per-shoot hierarchy accessors."""
1819 if plant_id < 0 or shoot_id < 0:
1820 raise ValueError("Plant ID and shoot ID must be non-negative")
1822 try:
1823 return getattr(plantarch_wrapper, wrapper_fn_name)(
1824 self._plantarch_ptr, plant_id, shoot_id)
1825 except Exception as e:
1827 f"Failed to get {description} of shoot {shoot_id}, plant {plant_id}: {e}")
1828
1829 def getShootInternodeVertices(self, plant_id: int, shoot_id: int) -> List[tuple]:
1830 """Get the woody internode polyline vertices of a shoot as a list of (x, y, z) tuples."""
1831 if plant_id < 0 or shoot_id < 0:
1832 raise ValueError("Plant ID and shoot ID must be non-negative")
1834 try:
1835 return plantarch_wrapper.getPlantShootInternodeVertices(self._plantarch_ptr, plant_id, shoot_id)
1836 except Exception as e:
1838 f"Failed to get internode vertices of shoot {shoot_id}, plant {plant_id}: {e}")
1839
1840 def getShootInternodeRadii(self, plant_id: int, shoot_id: int) -> List[float]:
1841 """Get the per-vertex woody internode radii of a shoot."""
1842 if plant_id < 0 or shoot_id < 0:
1843 raise ValueError("Plant ID and shoot ID must be non-negative")
1845 try:
1846 return plantarch_wrapper.getPlantShootInternodeRadii(self._plantarch_ptr, plant_id, shoot_id)
1847 except Exception as e:
1849 f"Failed to get internode radii of shoot {shoot_id}, plant {plant_id}: {e}")
1851 # =========================================================================
1852 # Built-geometry organ queries (helios-core 1.3.85+)
1853 # =========================================================================
1854
1855 def _plantFloatVector(self, wrapper_fn_name: str, plant_id: int, description: str) -> List[float]:
1856 """Shared body for the per-organ built-geometry queries."""
1857 if plant_id < 0:
1858 raise ValueError("Plant ID must be non-negative")
1860 try:
1861 return getattr(plantarch_wrapper, wrapper_fn_name)(self._plantarch_ptr, plant_id)
1862 except Exception as e:
1863 raise PlantArchitectureError(f"Failed to get {description} for plant {plant_id}: {e}")
1864
1865 def getPlantLeafAreas(self, plant_id: int) -> List[float]:
1866 """
1867 Get the built one-sided surface area of every leaf on a plant.
1868
1869 Measured from the geometry that was actually built, rather than reported from
1870 the shoot type's parameters. The two answer different questions: the shoot type
1871 gives the distribution a parameter was drawn from, while this gives what the
1872 plant ended up with.
1873
1874 This reports present area, so a leaf part-way through its growth is counted at
1875 its current size. Leaves are visited shoot by shoot and then phytomer by phytomer,
1876 the same order as :meth:`getPlantLeafObjectIDs`. Leaves whose geometry does not
1877 exist (removed, senesced, or never built) are omitted rather than reported as
1878 zero, so the result can be shorter than the list from :meth:`getPlantLeafObjectIDs`.
1879 Each area is blade area: a leaflet's petiolule belongs to its leaf object but is not
1880 counted (helios-core 1.3.89+).
1881
1882 Requires helios-core v1.3.85 or newer.
1883
1884 Args:
1885 plant_id: ID of the plant instance
1886
1887 Returns:
1888 One-sided surface area (m^2) of each leaf on the plant
1889
1890 Raises:
1891 ValueError: If plant_id is negative
1892 PlantArchitectureError: If the query fails or the library predates v1.3.85
1893
1894 Example:
1895 >>> areas = plantarch.getPlantLeafAreas(plant_id)
1896 >>> print(f"{len(areas)} leaves, mean {sum(areas)/len(areas):.4f} m^2")
1897 """
1898 return self._plantFloatVector("getPlantLeafAreas", plant_id, "leaf areas")
1899
1900 def getPlantInternodeLengths(self, plant_id: int) -> List[float]:
1901 """
1902 Get the built length of every internode on a plant.
1903
1904 Measured along the internode's node positions as they were built, so a shoot
1905 whose geometry was prescribed by :meth:`addShootFromNodePositions` reports its
1906 measured lengths and a grown shoot reports what growth produced. See
1907 :meth:`getPlantLeafAreas` for why this differs from reading the shoot type's
1908 ``internode_length_max``.
1909
1910 Internodes are visited shoot by shoot and then phytomer by phytomer, so the
1911 result has one entry per phytomer on the plant.
1912
1913 Requires helios-core v1.3.85 or newer.
1914
1915 Args:
1916 plant_id: ID of the plant instance
1917
1918 Returns:
1919 Length (m) of each internode on the plant
1920
1921 Raises:
1922 ValueError: If plant_id is negative
1923 PlantArchitectureError: If the query fails or the library predates v1.3.85
1924 """
1925 return self._plantFloatVector("getPlantInternodeLengths", plant_id, "internode lengths")
1926
1927 def getPlantLeafInclinations(self, plant_id: int) -> List[float]:
1928 """
1929 Get the inclination angle of every leaf on a plant.
1930
1931 The angle between each leaf blade and the horizontal, computed from its
1932 area-weighted normal so that a curved or folded blade is summarized by the
1933 direction it mostly faces. 0 degrees is a horizontal blade and 90 degrees a
1934 vertical one; because a blade is a surface, a normal pointing down describes the
1935 same inclination as its opposite pointing up, so the angle is folded about the
1936 horizontal and never exceeds 90 degrees.
1937
1938 Ordering and the treatment of missing geometry match :meth:`getPlantLeafAreas`.
1939 A blade whose facet normals cancel exactly is additionally omitted, since it
1940 faces no single direction.
1941
1942 Requires helios-core v1.3.85 or newer.
1943
1944 Args:
1945 plant_id: ID of the plant instance
1947 Returns:
1948 Inclination angle (degrees, in [0, 90]) of each leaf on the plant
1949
1950 Raises:
1951 ValueError: If plant_id is negative
1952 PlantArchitectureError: If the query fails or the library predates v1.3.85
1953 """
1954 return self._plantFloatVector("getPlantLeafInclinations", plant_id, "leaf inclinations")
1955
1956 def isShootGeometryPrescribed(self, plant_id: int, shoot_id: int) -> bool:
1957 """
1958 Report whether a shoot's existing geometry was prescribed by the caller rather than generated.
1959
1960 True for a shoot built by :meth:`addShootFromNodePositions`, whose internode path
1961 follows measured node positions. Such a shoot's existing phytomers are exempt
1962 from the re-scaling and re-curving performed by :meth:`advanceTime`, so a caller
1963 reading geometry back can tell which parts of a plant are measurement and which
1964 are model output.
1965
1966 Requires helios-core v1.3.85 or newer.
1967
1968 Args:
1969 plant_id: ID of the plant instance
1970 shoot_id: Shoot index within the plant
1971
1972 Returns:
1973 True if the shoot was built from prescribed node positions
1974
1975 Raises:
1976 ValueError: If either ID is negative
1977 PlantArchitectureError: If the query fails or the library predates v1.3.85
1978 """
1979 return self._shootScalarQuery("isShootGeometryPrescribed", plant_id, shoot_id,
1980 "prescribed-geometry state")
1981
1982 def getPlantAge(self, plant_id: int) -> float:
1983 """
1984 Get the current age of a plant in days.
1985
1986 Args:
1987 plant_id: ID of the plant instance
1988
1989 Returns:
1990 Plant age in days
1991
1992 Raises:
1993 ValueError: If plant_id is negative
1994 PlantArchitectureError: If retrieval fails
1995
1996 Example:
1997 >>> age = plantarch.getPlantAge(plant_id)
1998 >>> print(f"Plant is {age} days old")
1999 """
2000 if plant_id < 0:
2001 raise ValueError("Plant ID must be non-negative")
2002
2004 try:
2006 return plantarch_wrapper.getPlantAge(self._plantarch_ptr, plant_id)
2007 except Exception as e:
2008 raise PlantArchitectureError(f"Failed to get age for plant {plant_id}: {e}")
2009
2010 def getPlantMaxAge(self, plant_id: int) -> float:
2011 """
2012 Get the maximum age of a plant, beyond which it stops growing.
2013
2014 Args:
2015 plant_id: ID of the plant instance
2016
2017 Returns:
2018 Maximum plant age in days. See :meth:`setPlantMaxAge`.
2019
2020 Raises:
2021 ValueError: If plant_id is negative
2022 PlantArchitectureError: If retrieval fails
2023
2024 Example:
2025 >>> max_age = plantarch.getPlantMaxAge(plant_id)
2026 """
2027 if plant_id < 0:
2028 raise ValueError("Plant ID must be non-negative")
2030 try:
2031 return plantarch_wrapper.getPlantMaxAge(self._plantarch_ptr, plant_id)
2032 except Exception as e:
2034 f"Failed to get maximum age of plant {plant_id}: {e}")
2035
2036 def setPlantMaxAge(self, plant_id: int, max_age: float) -> None:
2037 """
2038 Set the maximum age of a plant, beyond which it stops growing.
2039
2040 Once a plant's age reaches this value, :meth:`advanceTime` stops advancing it and
2041 its geometry becomes static. The default is 999 days. Every plant model in the
2042 library sets its own value as part of its builder (an apple tree, for example,
2043 uses 1460 days), but a plant assembled manually with :meth:`addPlantInstance`
2044 keeps the default and so silently stops growing after 999 days.
2045
2046 Setting a maximum age below the plant's current age is permitted, and freezes the
2047 plant at its current form.
2049 Args:
2050 plant_id: ID of the plant instance
2051 max_age: Maximum age of the plant in days. Must be non-negative.
2052
2053 Raises:
2054 ValueError: If plant_id is negative or max_age is negative
2055 PlantArchitectureError: If the plant does not exist
2056
2057 Example:
2058 >>> plantarch.setPlantMaxAge(plant_id, 1460.0)
2059 """
2060 if plant_id < 0:
2061 raise ValueError("Plant ID must be non-negative")
2062 if max_age < 0:
2063 raise ValueError(f"Maximum age must be non-negative, got {max_age}")
2065 try:
2066 plantarch_wrapper.setPlantMaxAge(self._plantarch_ptr, plant_id, max_age)
2067 except Exception as e:
2069 f"Failed to set maximum age of plant {plant_id}: {e}")
2070
2071 def getPlantHeight(self, plant_id: int) -> float:
2072 """
2073 Get the height of a plant in meters.
2074
2075 Args:
2076 plant_id: ID of the plant instance
2077
2078 Returns:
2079 Plant height in meters (vertical extent)
2080
2081 Raises:
2082 ValueError: If plant_id is negative
2083 PlantArchitectureError: If retrieval fails
2084
2085 Example:
2086 >>> height = plantarch.getPlantHeight(plant_id)
2087 >>> print(f"Plant is {height:.2f}m tall")
2088 """
2089 if plant_id < 0:
2090 raise ValueError("Plant ID must be non-negative")
2091
2093 try:
2095 return plantarch_wrapper.getPlantHeight(self._plantarch_ptr, plant_id)
2096 except Exception as e:
2097 raise PlantArchitectureError(f"Failed to get height for plant {plant_id}: {e}")
2098
2099 def getPlantLeafArea(self, plant_id: int) -> float:
2100 """
2101 Get the total leaf area of a plant in m².
2102
2103 Leaf area is blade area: the petiolules (leaflet stalks) of compound leaves are
2104 excluded (helios-core 1.3.89+; earlier versions counted them as leaf).
2105
2106 Args:
2107 plant_id: ID of the plant instance
2108
2109 Returns:
2110 Total leaf area in square meters
2111
2112 Raises:
2113 ValueError: If plant_id is negative
2114 PlantArchitectureError: If retrieval fails
2115
2116 Example:
2117 >>> leaf_area = plantarch.getPlantLeafArea(plant_id)
2118 >>> print(f"Total leaf area: {leaf_area:.3f} m²")
2119 """
2120 if plant_id < 0:
2121 raise ValueError("Plant ID must be non-negative")
2122
2124 try:
2126 return plantarch_wrapper.sumPlantLeafArea(self._plantarch_ptr, plant_id)
2127 except Exception as e:
2128 raise PlantArchitectureError(f"Failed to get leaf area for plant {plant_id}: {e}")
2129
2130 def optionalOutputObjectData(self, object_data_labels: Union[str, List[str]]) -> None:
2131 """
2132 Enable optional output object data to be written to the Context.
2133
2134 By default, the plant architecture model only writes a minimal set of
2135 object data. This method enables additional object data fields so that
2136 they are available on the Context's compound objects after building.
2137
2138 Args:
2139 object_data_labels: A single label or a list of labels to enable.
2140 Valid labels include: "age", "rank", "plantID", "plant_name",
2141 "plant_height", "plant_type", "phenology_stage", "leafID",
2142 "peduncleID", "closedflowerID", "openflowerID", "fruitID",
2143 "carbohydrate_concentration". The special label "all" enables
2144 every available field.
2145
2146 Raises:
2147 ValueError: If a label is empty or not a string
2148 PlantArchitectureError: If an invalid label is supplied or the
2149 operation otherwise fails
2150
2151 Example:
2152 >>> plantarch.optionalOutputObjectData("age")
2153 >>> plantarch.optionalOutputObjectData(["rank", "plant_height"])
2154 >>> plantarch.optionalOutputObjectData("all")
2155 """
2156 if isinstance(object_data_labels, str):
2157 labels = [object_data_labels]
2158 else:
2159 labels = list(object_data_labels)
2160
2162 try:
2164 for label in labels:
2165 plantarch_wrapper.optionalOutputObjectData(self._plantarch_ptr, label)
2166 except ValueError:
2167 raise
2168 except Exception as e:
2169 raise PlantArchitectureError(f"Failed to enable optional output object data: {e}")
2170
2172 self,
2173 plant_id: int,
2174 time_to_dormancy_break: float,
2175 time_to_flower_initiation: float,
2176 time_to_flower_opening: float,
2177 time_to_fruit_set: float,
2178 time_to_fruit_maturity: float,
2179 time_to_dormancy: float,
2180 max_leaf_lifespan: float = 1e6,
2181 is_evergreen: bool = False
2182 ) -> None:
2183 """
2184 Set phenological timing thresholds for plant developmental stages.
2185
2186 Controls the timing of key phenological events based on thermal time
2187 or calendar time depending on the plant model.
2188
2189 Args:
2190 plant_id: ID of the plant instance
2191 time_to_dormancy_break: Degree-days or days until dormancy ends
2192 time_to_flower_initiation: Time until flower buds are initiated
2193 time_to_flower_opening: Time until flowers open
2194 time_to_fruit_set: Time until fruit begins developing
2195 time_to_fruit_maturity: Time until fruit reaches maturity
2196 time_to_dormancy: Time until plant enters dormancy
2197 max_leaf_lifespan: Maximum leaf lifespan in days (default: 1e6)
2198 is_evergreen: If True, the plant retains leaves through dormancy
2199 instead of shedding them at senescence (default: False)
2200
2201 Raises:
2202 ValueError: If plant_id is negative
2203 PlantArchitectureError: If phenology setting fails
2204
2205 Example:
2206 >>> # Set phenology for perennial fruit tree
2207 >>> plantarch.setPlantPhenologicalThresholds(
2208 ... plant_id=plant_id,
2209 ... time_to_dormancy_break=60, # Spring: 60 degree-days
2210 ... time_to_flower_initiation=90, # Early spring flowering
2211 ... time_to_flower_opening=105, # Bloom period
2212 ... time_to_fruit_set=120, # Fruit set after pollination
2213 ... time_to_fruit_maturity=200, # Summer fruit maturation
2214 ... time_to_dormancy=280, # Fall dormancy
2215 ... max_leaf_lifespan=180 # Deciduous - 6 month leaf life
2216 ... )
2217 """
2218 if plant_id < 0:
2219 raise ValueError("Plant ID must be non-negative")
2220
2222 try:
2224 plantarch_wrapper.setPlantPhenologicalThresholds(
2225 self._plantarch_ptr,
2226 plant_id,
2227 time_to_dormancy_break,
2228 time_to_flower_initiation,
2229 time_to_flower_opening,
2230 time_to_fruit_set,
2231 time_to_fruit_maturity,
2232 time_to_dormancy,
2233 max_leaf_lifespan,
2234 is_evergreen
2235 )
2236 except Exception as e:
2237 raise PlantArchitectureError(f"Failed to set phenological thresholds for plant {plant_id}: {e}")
2238
2239 def disablePlantPhenology(self, plant_id: int) -> None:
2240 """
2241 Disable phenological progression for a plant.
2242
2243 The plant continues to grow, but no phenological stage is ever scheduled: it does not
2244 enter dormancy, and flower and fruit stages are skipped. This is the explicit form of the
2245 state a plant is already in when :meth:`setPlantPhenologicalThresholds` has never been
2246 called on it, so it is mainly useful for turning phenology back off on a plant that had
2247 thresholds set earlier.
2248
2249 Args:
2250 plant_id: Identifier of the plant whose phenology is to be disabled
2251
2252 Warning:
2253 Avoid calling this on a plant that already has fruiting buds: a later
2254 :meth:`advanceTime` can compute a negative fruit scale factor. A plant that never had
2255 thresholds set is already in the no-phenology state and does not need it.
2256
2257 Raises:
2258 ValueError: If plant_id is negative
2259 PlantArchitectureError: If disabling phenology fails
2260
2261 Example:
2262 >>> plantarch.setPlantPhenologicalThresholds(plant_id, 60, 90, 105, 120, 200, 280)
2263 >>> plantarch.disablePlantPhenology(plant_id) # growth only, no dormancy or fruiting
2264 """
2265 if plant_id < 0:
2266 raise ValueError("Plant ID must be non-negative")
2267
2269 try:
2271 plantarch_wrapper.disablePlantPhenology(self._plantarch_ptr, plant_id)
2272 except Exception as e:
2273 raise PlantArchitectureError(f"Failed to disable phenology for plant {plant_id}: {e}")
2274
2275 # Dormancy control methods
2276 def makePlantDormant(self, plant_id: int) -> None:
2277 """
2278 Force a plant into a dormant state immediately.
2279
2280 It is the counterpart to scheduling dormancy
2281 through :meth:`setPlantPhenologicalThresholds`: this forces the state now, rather than
2282 waiting for a degree-day threshold to be crossed.
2283
2284 Dormancy strips the plant's leaves and marks its non-dormant buds dormant, so a
2285 custom-built plant can be put into the same over-winter state that a library-built
2286 perennial reaches through phenology.
2287
2288 Args:
2289 plant_id: Identifier of the plant to make dormant
2290
2291 Raises:
2292 ValueError: If plant_id is negative
2293 PlantArchitectureError: If the plant does not exist or the call fails
2294
2295 Example:
2296 >>> plant_id = plantarch.addPlantInstance(vec3(0, 0, 0), 0.0)
2297 >>> plantarch.addBaseStemShoot(plant_id, 3, AxisRotation(0, 0, 0),
2298 ... 0.01, 0.1, 1.0, 1.0, 0.9, "trifoliate")
2299 >>> plantarch.makePlantDormant(plant_id)
2300 """
2301 if plant_id < 0:
2302 raise ValueError("Plant ID must be non-negative")
2303
2305 try:
2307 plantarch_wrapper.makePlantDormant(self._plantarch_ptr, plant_id)
2308 except Exception as e:
2309 raise PlantArchitectureError(f"Failed to make plant {plant_id} dormant: {e}")
2310
2311 def breakPlantDormancy(self, plant_id: int) -> None:
2312 """
2313 Break dormancy for all shoots on a plant, returning it to an active state.
2314
2315 This is the counterpart to :meth:`makePlantDormant`. Note that it only revives buds that
2316 are not dead, so a plant that was repeatedly made dormant may not recover every bud.
2317
2318 Args:
2319 plant_id: Identifier of the plant whose dormancy should be broken
2320
2321 Raises:
2322 ValueError: If plant_id is negative
2323 PlantArchitectureError: If the plant does not exist or the call fails
2324
2325 Example:
2326 >>> plantarch.makePlantDormant(plant_id)
2327 >>> plantarch.breakPlantDormancy(plant_id) # resume growth in spring
2328 """
2329 if plant_id < 0:
2330 raise ValueError("Plant ID must be non-negative")
2331
2333 try:
2335 plantarch_wrapper.breakPlantDormancy(self._plantarch_ptr, plant_id)
2336 except Exception as e:
2337 raise PlantArchitectureError(f"Failed to break dormancy for plant {plant_id}: {e}")
2338
2339 def isPlantDormant(self, plant_id: int) -> bool:
2340 """
2341 Check whether a plant is dormant.
2342
2343 Args:
2344 plant_id: Identifier of the plant to check
2345
2346 Returns:
2347 True if all shoots on the plant are dormant, False otherwise
2348
2349 Raises:
2350 ValueError: If plant_id is negative
2351 PlantArchitectureError: If the plant does not exist or the query fails
2352
2353 Example:
2354 >>> plantarch.makePlantDormant(plant_id)
2355 >>> plantarch.isPlantDormant(plant_id)
2356 True
2357 """
2358 if plant_id < 0:
2359 raise ValueError("Plant ID must be non-negative")
2360
2362 try:
2364 return plantarch_wrapper.isPlantDormant(self._plantarch_ptr, plant_id)
2365 except Exception as e:
2366 raise PlantArchitectureError(f"Failed to query dormancy state for plant {plant_id}: {e}")
2367
2368 # Pruning and organ removal methods
2369 def pruneBranch(self, plant_id: int, shoot_id: int, node_index: int) -> None:
2370 """
2371 Prune a shoot at a node, removing that node and everything distal to it.
2372
2373 The phytomer at ``node_index`` is deleted along with every phytomer above it
2374 on the same shoot, and the cut recurses into every child shoot attached at or
2375 above that node. The shoot's woody internode tube is trimmed back to the cut
2376 and its apical bud is terminated, so the pruned axis will not resume growing.
2377 Pruning at ``node_index=0`` therefore removes the entire shoot and its whole
2378 branch system.
2380 Args:
2381 plant_id: ID of the plant instance
2382 shoot_id: Shoot index within the plant (see :meth:`getAllShootIDs`)
2383 node_index: Node on the shoot to cut at, in ``[0, node_count)``
2384
2385 Raises:
2386 ValueError: If any identifier is negative
2387 PlantArchitectureError: If the plant or shoot does not exist, if
2388 ``node_index`` is beyond the shoot's current node count, or if the
2389 native call fails
2390
2391 Note:
2392 A pruned shoot currently keeps its ID in :meth:`getAllShootIDs` with a
2393 ``node_count`` of 0 rather than disappearing. Do not rely on either
2394 behavior; traverse with :meth:`getShoot` and treat ``node_count == 0``
2395 as "nothing left here".
2396
2397 Called from a phytomer creation or callback function (see
2398 :meth:`setPhytomerCreationFunction`, :meth:`setPhytomerCallbackFunction`), the
2399 arguments are checked immediately but the cut is deferred: it is applied at the end
2400 of the current time step of :meth:`advanceTime`, or before the build call that ran
2401 the function returns. Until then the shoot still looks uncut. Deferred cuts are
2402 applied in the order requested, and one whose shoot or node an earlier cut already
2403 removed does nothing.
2404
2405 Example:
2406 >>> # Remove a whole branch and everything growing off it
2407 >>> plantarch.pruneBranch(plant_id, shoot_id=3, node_index=0)
2408 >>> # Head back a leader, keeping its lowest 5 nodes
2409 >>> plantarch.pruneBranch(plant_id, shoot_id=0, node_index=5)
2410 """
2411 if plant_id < 0 or shoot_id < 0 or node_index < 0:
2412 raise ValueError("Plant ID, shoot ID and node index must be non-negative")
2413
2415 try:
2416 plantarch_wrapper.pruneBranch(self._plantarch_ptr, plant_id, shoot_id, node_index)
2417 except Exception as e:
2419 f"Failed to prune shoot {shoot_id} of plant {plant_id} at node {node_index}: {e}")
2420
2421 def harvestPlant(self, plant_id: int) -> None:
2422 """
2423 Harvest a plant by removing its flowers and fruit.
2424
2425 Every non-dormant floral bud on the plant is killed, which deletes the
2426 associated flower, fruit and peduncle geometry from the Context. Vegetative
2427 structure is untouched and the plant continues to grow afterwards.
2428
2429 Args:
2430 plant_id: ID of the plant instance to harvest
2431
2432 Raises:
2433 ValueError: If plant_id is negative
2434 PlantArchitectureError: If the plant does not exist or the call fails
2435
2436 Note:
2437 Leaves are **not** removed. Use :meth:`removePlantLeaves` to defoliate.
2438
2439 Example:
2440 >>> before = len(plantarch.getPlantFruitObjectIDs(plant_id))
2441 >>> plantarch.harvestPlant(plant_id)
2442 >>> len(plantarch.getPlantFruitObjectIDs(plant_id)) < before
2443 True
2444 """
2445 if plant_id < 0:
2446 raise ValueError("Plant ID must be non-negative")
2447
2449 try:
2450 plantarch_wrapper.harvestPlant(self._plantarch_ptr, plant_id)
2451 except Exception as e:
2452 raise PlantArchitectureError(f"Failed to harvest plant {plant_id}: {e}")
2453
2454 def removePlantLeaves(self, plant_id: int) -> None:
2455 """
2456 Remove all leaves from every shoot on a plant.
2457
2458 Leaf and petiole geometry is deleted from the Context. Buds are left alive,
2459 so the plant can produce new leaves as it continues to grow.
2460
2461 Args:
2462 plant_id: ID of the plant instance to defoliate
2463
2464 Raises:
2465 ValueError: If plant_id is negative
2466 PlantArchitectureError: If the plant does not exist or the call fails
2467
2468 Example:
2469 >>> plantarch.removePlantLeaves(plant_id)
2470 >>> plantarch.getPlantLeafObjectIDs(plant_id)
2471 []
2472 """
2473 if plant_id < 0:
2474 raise ValueError("Plant ID must be non-negative")
2475
2477 try:
2478 plantarch_wrapper.removePlantLeaves(self._plantarch_ptr, plant_id)
2479 except Exception as e:
2480 raise PlantArchitectureError(f"Failed to remove leaves from plant {plant_id}: {e}")
2481
2482 def removeShootLeaves(self, plant_id: int, shoot_id: int) -> None:
2483 """
2484 Remove all leaves from a single shoot.
2485
2486 Args:
2487 plant_id: ID of the plant instance
2488 shoot_id: Shoot index within the plant (see :meth:`getAllShootIDs`)
2489
2490 Raises:
2491 ValueError: If either identifier is negative
2492 PlantArchitectureError: If the plant or shoot does not exist
2493
2494 Example:
2495 >>> # Strip the leaves off a grapevine trunk, as in a trained architecture
2496 >>> plantarch.removeShootLeaves(plant_id, shoot_id=0)
2497 """
2498 self._removeShootOrgans("removeShootLeaves", plant_id, shoot_id, "leaves")
2499
2500 def removeShootVegetativeBuds(self, plant_id: int, shoot_id: int) -> None:
2501 """
2502 Mark every vegetative bud on a single shoot as dead.
2503
2504 Despite the name, nothing is removed: each axillary vegetative bud on the shoot
2505 is set to ``BudState.DEAD`` and the bud entries themselves stay in place, so
2506 :meth:`getShootVegetativeBudCount` still sees them and the unfiltered count is
2507 unchanged. Dead buds are skipped when dormancy breaks, so the shoot keeps its
2508 existing structure but produces no new lateral shoots -- the standard way to stop
2509 a trained axis from throwing new canes, and to stop the old wood of a
2510 reconstructed tree re-growing.
2511
2512 This is exactly equivalent to setting every bud on the shoot to
2513 ``BudState.DEAD``; the shoot's own apex is unaffected, so pair it with
2514 :meth:`terminateApicalBud` to stop the shoot extending as well.
2515
2516 Args:
2517 plant_id: ID of the plant instance
2518 shoot_id: Shoot index within the plant (see :meth:`getAllShootIDs`)
2520 Raises:
2521 ValueError: If either identifier is negative
2522 PlantArchitectureError: If the plant or shoot does not exist
2523
2524 See Also:
2525 :meth:`getShootVegetativeBudCount`, to confirm the buds are dead rather than
2526 gone, and :meth:`terminateApicalBud`, for the shoot's apex.
2527
2528 Example:
2529 >>> plantarch.removeShootVegetativeBuds(plant_id, shoot_id=1)
2530 """
2531 self._removeShootOrgans("removeShootVegetativeBuds", plant_id, shoot_id,
2532 "vegetative buds")
2533
2534 def removeShootFloralBuds(self, plant_id: int, shoot_id: int) -> None:
2535 """
2536 Kill all floral buds on a single shoot.
2537
2538 Existing flower, fruit and peduncle geometry on the shoot is deleted and no
2539 new flowers will form there.
2540
2541 Args:
2542 plant_id: ID of the plant instance
2543 shoot_id: Shoot index within the plant (see :meth:`getAllShootIDs`)
2544
2545 Raises:
2546 ValueError: If either identifier is negative
2547 PlantArchitectureError: If the plant or shoot does not exist
2548
2549 Example:
2550 >>> plantarch.removeShootFloralBuds(plant_id, shoot_id=1)
2551 """
2552 self._removeShootOrgans("removeShootFloralBuds", plant_id, shoot_id, "floral buds")
2553
2554 def _removeShootOrgans(self, wrapper_fn_name: str, plant_id: int, shoot_id: int,
2555 organ_description: str) -> None:
2556 """Shared body for the three shoot-level organ removal methods."""
2557 if plant_id < 0 or shoot_id < 0:
2558 raise ValueError("Plant ID and shoot ID must be non-negative")
2559
2561 try:
2562 getattr(plantarch_wrapper, wrapper_fn_name)(self._plantarch_ptr, plant_id, shoot_id)
2563 except Exception as e:
2565 f"Failed to remove {organ_description} from shoot {shoot_id} "
2566 f"of plant {plant_id}: {e}")
2567
2568 # Shoot hierarchy traversal
2569 def getShootIDsByRank(self, plant_id: int) -> Dict[int, List[int]]:
2570 """
2571 Group a plant's shoot IDs by branching rank.
2572
2573 Rank 0 is the base stem, rank 1 its direct branches, and so on. Shoots that have
2574 been pruned away are not included.
2575
2576 Args:
2577 plant_id: ID of the plant instance
2578
2579 Returns:
2580 Dict mapping rank to the list of shoot IDs at that rank. Ranks with no live
2581 shoots are omitted from the dict.
2582
2583 Raises:
2584 ValueError: If plant_id is negative
2585 PlantArchitectureError: If the plant does not exist
2586
2587 Example:
2588 >>> by_rank = plantarch.getShootIDsByRank(plant_id)
2589 >>> print(f"{len(by_rank.get(1, []))} primary branches")
2590 """
2591 if plant_id < 0:
2592 raise ValueError("Plant ID must be non-negative")
2594 try:
2595 groups = plantarch_wrapper.getShootIDsByRank(self._plantarch_ptr, plant_id)
2596 except Exception as e:
2598 f"Failed to get shoot IDs by rank for plant {plant_id}: {e}")
2599
2600 # Native returns one group per rank, indexed by rank, with empty groups for ranks
2601 # that have no live shoots. The dict form drops those empties.
2602 return {rank: shoot_ids for rank, shoot_ids in enumerate(groups) if shoot_ids}
2603
2604 def getTerminalShootIDs(self, plant_id: int) -> List[int]:
2605 """
2606 Get the plant's terminal shoots -- those carrying no child shoots.
2607
2608 These are the tips of the shoot tree. Note that this is a topological test rather
2609 than a botanical one: a shoot whose axis is continued by :meth:`appendShoot` has
2610 that continuation as a child and so is not terminal. Pruned shoots are omitted.
2611
2612 Args:
2613 plant_id: ID of the plant instance
2615 Returns:
2616 List of terminal shoot IDs.
2617
2618 Raises:
2619 ValueError: If plant_id is negative
2620 PlantArchitectureError: If the plant does not exist
2621
2622 Example:
2623 >>> tips = plantarch.getTerminalShootIDs(plant_id)
2624 """
2625 if plant_id < 0:
2626 raise ValueError("Plant ID must be non-negative")
2628 try:
2629 return plantarch_wrapper.getTerminalShootIDs(self._plantarch_ptr, plant_id)
2630 except Exception as e:
2632 f"Failed to get terminal shoots for plant {plant_id}: {e}")
2633
2634 # Bulk pruning built on the traversal helpers
2635 def pruneShootsByRank(self, plant_id: int, min_rank: int) -> List[int]:
2636 """
2637 Prune every shoot at or above a given branching rank.
2638
2639 This is the "remove higher-order branches" thinning operation: passing
2640 ``min_rank=3`` leaves the base stem and its first two orders of branching
2641 intact and cuts everything finer. Because :meth:`pruneBranch` already
2642 recurses into child shoots, only the shallowest shoot on each pruned axis is
2643 cut and the rest follow.
2644
2645 Args:
2646 plant_id: ID of the plant instance
2647 min_rank: Lowest rank to prune. Must be at least 1 -- rank 0 is the base
2648 stem, and pruning it would destroy the plant.
2649
2650 Returns:
2651 Ascending list of the shoot IDs actually cut. Shoots removed as a side
2652 effect of a shallower cut are not listed.
2653
2654 Raises:
2655 ValueError: If plant_id is negative or min_rank is less than 1
2656 PlantArchitectureError: If the plant does not exist
2657
2658 Note:
2659 To remove a whole plant use :meth:`deletePlantInstance`; to cut the base
2660 stem itself call :meth:`pruneBranch` directly.
2661
2662 Example:
2663 >>> pruned = plantarch.pruneShootsByRank(plant_id, min_rank=3)
2664 >>> print(f"Cut {len(pruned)} higher-order branches")
2665 """
2666 if plant_id < 0:
2667 raise ValueError("Plant ID must be non-negative")
2668 if min_rank < 1:
2669 raise ValueError(
2670 f"min_rank must be at least 1, got {min_rank}. Rank 0 is the base stem; "
2671 "use deletePlantInstance() to remove the whole plant, or pruneBranch() "
2672 "to cut the base stem explicitly.")
2673
2674 by_rank = self.getShootIDsByRank(plant_id)
2675 targets = {shoot_id
2676 for rank, shoot_ids in by_rank.items() if rank >= min_rank
2677 for shoot_id in shoot_ids}
2678 return self._pruneShallowest(plant_id, targets)
2679
2680 def pruneShootSubtree(self, plant_id: int, shoot_id: int,
2681 include_self: bool = True) -> List[int]:
2682 """
2683 Prune a shoot and everything growing off it.
2684
2685 Args:
2686 plant_id: ID of the plant instance
2687 shoot_id: Root of the branch system to remove
2688 include_self: If True (default) the shoot itself is cut at node 0. If
2689 False the shoot is kept and only its child shoots are cut.
2690
2691 Returns:
2692 Ascending list of the shoot IDs actually cut. Shoots removed as a side
2693 effect of a shallower cut are not listed.
2694
2695 Raises:
2696 ValueError: If either identifier is negative
2697 PlantArchitectureError: If the plant or shoot does not exist
2698
2699 Example:
2700 >>> # Remove a whole branch system
2701 >>> plantarch.pruneShootSubtree(plant_id, shoot_id=2)
2702 >>> # Keep the cane but strip everything growing off it
2703 >>> plantarch.pruneShootSubtree(plant_id, shoot_id=2, include_self=False)
2704 """
2705 if plant_id < 0 or shoot_id < 0:
2706 raise ValueError("Plant ID and shoot ID must be non-negative")
2707
2708 if include_self:
2709 return self._pruneShallowest(plant_id, {shoot_id})
2710 return self._pruneShallowest(plant_id, set(self._liveChildShootIDs(plant_id, shoot_id)))
2711
2712 def pruneTerminalShoots(self, plant_id: int, stride: int = 2) -> List[int]:
2713 """
2714 Thin a plant by pruning every *stride*-th terminal shoot.
2715
2716 Terminal shoots are taken in ascending ID order and every ``stride``-th one
2717 starting from the first is cut, so ``stride=2`` removes about half the tips
2718 and ``stride=3`` about a third. The base stem is never cut.
2719
2720 Args:
2721 plant_id: ID of the plant instance
2722 stride: Spacing between pruned tips. Must be at least 1; ``stride=1``
2723 prunes every terminal shoot.
2724
2725 Returns:
2726 Ascending list of the shoot IDs actually cut.
2728 Raises:
2729 ValueError: If plant_id is negative or stride is less than 1
2730 PlantArchitectureError: If the plant does not exist
2731
2732 Example:
2733 >>> pruned = plantarch.pruneTerminalShoots(plant_id, stride=2)
2734 >>> print(f"Thinned {len(pruned)} tips")
2735 """
2736 if plant_id < 0:
2737 raise ValueError("Plant ID must be non-negative")
2738 if stride < 1:
2739 raise ValueError(f"stride must be at least 1, got {stride}")
2740
2741 targets = set()
2742 for index, shoot_id in enumerate(self.getTerminalShootIDs(plant_id)):
2743 if index % stride != 0:
2744 continue
2745 if self.getShootRank(plant_id, shoot_id) == 0:
2746 continue # never cut the base stem
2747 targets.add(shoot_id)
2748 return self._pruneShallowest(plant_id, targets)
2749
2750 def _childShootIDsOrEmpty(self, plant_id: int, shoot_id: int) -> List[int]:
2751 """Return a shoot's child IDs, or an empty list if it no longer resolves."""
2752 try:
2753 return self.getShootChildIDs(plant_id, shoot_id)
2754 except PlantArchitectureError:
2755 return []
2756
2757 def _liveChildShootIDs(self, plant_id: int, shoot_id: int) -> List[int]:
2758 """Child shoot IDs that have not been pruned away, ascending."""
2759 # getChildShootIDs already excludes pruned shoots, so no second filter is needed.
2760 return sorted(set(self._childShootIDsOrEmpty(plant_id, shoot_id)))
2761
2762 def _pruneShallowest(self, plant_id: int, target_shoot_ids) -> List[int]:
2763 """Prune every target that something shallower has not already removed.
2764
2765 pruneBranch() recurses into child shoots, so cutting a shoot also empties
2766 every shoot descended from it. Targets are therefore visited in ascending
2767 shoot ID order -- a child shoot is always created after its parent and so
2768 always has the higher ID -- which puts each shoot after its ancestors. By
2769 the time a descendant of an already-cut shoot comes up it has nothing left
2770 on it and is skipped, so no shoot is cut twice and the returned list holds
2771 only the cuts that actually did something.
2772 """
2773 pruned = []
2774 for shoot_id in sorted(set(target_shoot_ids)):
2775 if self._isPrunedOrGone(plant_id, shoot_id):
2776 continue # gone already, either pruned above or pruned earlier
2777 self.pruneBranch(plant_id, shoot_id, 0)
2778 pruned.append(shoot_id)
2779 return pruned
2780
2781 def _isPrunedOrGone(self, plant_id: int, shoot_id: int) -> bool:
2782 """Whether a shoot has been pruned away or no longer resolves at all."""
2783 try:
2784 return self.isShootPruned(plant_id, shoot_id)
2785 except PlantArchitectureError:
2786 return True
2787
2788 # Collision detection methods
2790 target_object_UUIDs: Optional[List[int]] = None,
2791 target_object_IDs: Optional[List[int]] = None,
2792 enable_petiole_collision: bool = False,
2793 enable_fruit_collision: bool = False) -> None:
2794 """
2795 Enable soft collision avoidance for procedural plant growth.
2796
2797 This method enables the collision detection system that guides plant growth away from
2798 obstacles and other plants. The system uses cone-based gap detection to find optimal
2799 growth directions that minimize collisions while maintaining natural plant architecture.
2800
2801 Args:
2802 target_object_UUIDs: List of primitive UUIDs to avoid collisions with. If empty,
2803 avoids all geometry in the context.
2804 target_object_IDs: List of compound object IDs to avoid collisions with.
2805 enable_petiole_collision: Enable collision detection for leaf petioles
2806 enable_fruit_collision: Enable collision detection for fruit organs
2807
2808 Raises:
2809 PlantArchitectureError: If collision detection activation fails
2810
2811 Note:
2812 Collision detection adds computational overhead. Use setStaticObstacles() to mark
2813 static geometry for BVH optimization and improved performance.
2815 Example:
2816 >>> # Avoid all geometry
2817 >>> plantarch.enableSoftCollisionAvoidance()
2818 >>>
2819 >>> # Avoid specific obstacles
2820 >>> obstacle_uuids = context.getAllUUIDs()
2821 >>> plantarch.enableSoftCollisionAvoidance(target_object_UUIDs=obstacle_uuids)
2822 >>>
2823 >>> # Enable collision detection for petioles and fruit
2824 >>> plantarch.enableSoftCollisionAvoidance(
2825 ... enable_petiole_collision=True,
2826 ... enable_fruit_collision=True
2827 ... )
2828 """
2830 try:
2832 plantarch_wrapper.enableSoftCollisionAvoidance(
2833 self._plantarch_ptr,
2834 target_UUIDs=target_object_UUIDs,
2835 target_IDs=target_object_IDs,
2836 enable_petiole=enable_petiole_collision,
2837 enable_fruit=enable_fruit_collision
2838 )
2839 except Exception as e:
2840 raise PlantArchitectureError(f"Failed to enable soft collision avoidance: {e}")
2841
2842 def enableGroundClipping(self, ground_height: float = 0.0) -> None:
2843 """
2844 Enable automatic removal of plant organs that fall below the ground plane.
2845
2846 Organ vertices below `ground_height` are clipped as plant geometry is
2847 built, which prevents drooping leaves and low branches from poking
2848 through a ground tile.
2849
2850 Args:
2851 ground_height: Height of the ground plane (default 0.0)
2852
2853 Raises:
2854 ValueError: If ground_height is not a number
2855 PlantArchitectureError: If the call fails
2857 Example:
2858 >>> plantarch.enableGroundClipping(0.0)
2859 >>> plantarch.advanceTime(30.0)
2860 """
2862
2863 if isinstance(ground_height, bool) or not isinstance(ground_height, (int, float)):
2864 raise ValueError(f"Ground height must be a number, got {type(ground_height).__name__}")
2865
2866 try:
2867 plantarch_wrapper.enableGroundClipping(self._plantarch_ptr, float(ground_height))
2868 except Exception as e:
2869 raise PlantArchitectureError(f"Failed to enable ground clipping: {e}")
2870
2871 def disableMessages(self) -> None:
2872 """
2873 Suppress standard output from the plantarchitecture plugin.
2874
2875 This silences progress bars and informational messages the C++ plugin
2876 writes to stdout, including the "BVH not cached" warning emitted during
2877 the first growth steps of a collision-enabled canopy (before any plant
2878 geometry exists for the BVH to contain).
2879
2880 Raises:
2881 PlantArchitectureError: If the call fails
2882
2883 Example:
2884 >>> plantarch.disableMessages()
2885 >>> plantarch.advanceTime(30.0) # runs quietly
2886 """
2888 try:
2889 plantarch_wrapper.disableMessages(self._plantarch_ptr)
2890 except Exception as e:
2891 raise PlantArchitectureError(f"Failed to disable messages: {e}")
2893 def enableMessages(self) -> None:
2894 """
2895 Re-enable standard output from the plantarchitecture plugin.
2896
2897 Raises:
2898 PlantArchitectureError: If the call fails
2899
2900 Example:
2901 >>> plantarch.enableMessages()
2902 """
2904 try:
2905 plantarch_wrapper.enableMessages(self._plantarch_ptr)
2906 except Exception as e:
2907 raise PlantArchitectureError(f"Failed to enable messages: {e}")
2908
2909 def disableCollisionDetection(self) -> None:
2910 """
2911 Disable collision detection for plant growth.
2912
2913 This method turns off the collision detection system, allowing plants to grow
2914 without checking for obstacles. This improves performance but plants may grow
2915 through obstacles and other geometry.
2916
2917 Raises:
2918 PlantArchitectureError: If disabling fails
2919
2920 Example:
2921 >>> plantarch.disableCollisionDetection()
2922 """
2924 try:
2925 plantarch_wrapper.disableCollisionDetection(self._plantarch_ptr)
2926 except Exception as e:
2927 raise PlantArchitectureError(f"Failed to disable collision detection: {e}")
2928
2930 view_half_angle_deg: float = 80.0,
2931 look_ahead_distance: float = 0.1,
2932 sample_count: int = 256,
2933 inertia_weight: float = 0.4) -> None:
2934 """
2935 Configure parameters for soft collision avoidance algorithm.
2936
2937 These parameters control the cone-based gap detection algorithm that guides
2938 plant growth away from obstacles. Adjusting these values allows fine-tuning
2939 the balance between collision avoidance and natural growth patterns.
2940
2941 Args:
2942 view_half_angle_deg: Half-angle of detection cone in degrees (0-180).
2943 Default 80° provides wide field of view.
2944 look_ahead_distance: Distance to look ahead for collisions in meters.
2945 Larger values detect distant obstacles. Default 0.1m.
2946 sample_count: Number of ray samples within cone. More samples improve
2947 accuracy but reduce performance. Default 256.
2948 inertia_weight: Weight for previous growth direction (0-1). Higher values
2949 make growth smoother but less responsive. Default 0.4.
2950
2951 Raises:
2952 ValueError: If parameters are outside valid ranges
2953 PlantArchitectureError: If parameter setting fails
2955 Example:
2956 >>> # Use default parameters (recommended)
2957 >>> plantarch.setSoftCollisionAvoidanceParameters()
2958 >>>
2959 >>> # Tune for dense canopy with close obstacles
2960 >>> plantarch.setSoftCollisionAvoidanceParameters(
2961 ... view_half_angle_deg=60.0, # Narrower detection cone
2962 ... look_ahead_distance=0.05, # Shorter look-ahead
2963 ... sample_count=512, # More accurate detection
2964 ... inertia_weight=0.3 # More responsive to obstacles
2965 ... )
2966 """
2967 # Validate parameters
2968 if not (0 <= view_half_angle_deg <= 180):
2969 raise ValueError(f"view_half_angle_deg must be between 0 and 180, got {view_half_angle_deg}")
2970 if look_ahead_distance <= 0:
2971 raise ValueError(f"look_ahead_distance must be positive, got {look_ahead_distance}")
2972 if sample_count <= 0:
2973 raise ValueError(f"sample_count must be positive, got {sample_count}")
2974 if not (0 <= inertia_weight <= 1):
2975 raise ValueError(f"inertia_weight must be between 0 and 1, got {inertia_weight}")
2976
2978 try:
2979 plantarch_wrapper.setSoftCollisionAvoidanceParameters(
2980 self._plantarch_ptr,
2981 view_half_angle_deg,
2982 look_ahead_distance,
2983 sample_count,
2984 inertia_weight
2985 )
2986 except Exception as e:
2987 raise PlantArchitectureError(f"Failed to set collision avoidance parameters: {e}")
2988
2990 include_internodes: bool = False,
2991 include_leaves: bool = True,
2992 include_petioles: bool = False,
2993 include_flowers: bool = False,
2994 include_fruit: bool = False) -> None:
2995 """
2996 Specify which plant organs participate in collision detection.
2997
2998 This method allows filtering which organs are considered during collision detection,
2999 enabling optimization by excluding organs unlikely to cause problematic collisions.
3000
3001 Args:
3002 include_internodes: Include stem internodes in collision detection
3003 include_leaves: Include leaf blades in collision detection
3004 include_petioles: Include leaf petioles in collision detection
3005 include_flowers: Include flowers in collision detection
3006 include_fruit: Include fruit in collision detection
3007
3008 Raises:
3009 PlantArchitectureError: If organ filtering fails
3010
3011 Example:
3012 >>> # Only detect collisions for stems and leaves (default behavior)
3013 >>> plantarch.setCollisionRelevantOrgans(
3014 ... include_internodes=True,
3015 ... include_leaves=True
3016 ... )
3017 >>>
3018 >>> # Include all organs
3019 >>> plantarch.setCollisionRelevantOrgans(
3020 ... include_internodes=True,
3021 ... include_leaves=True,
3022 ... include_petioles=True,
3023 ... include_flowers=True,
3024 ... include_fruit=True
3025 ... )
3026 """
3028 try:
3029 plantarch_wrapper.setCollisionRelevantOrgans(
3030 self._plantarch_ptr,
3031 include_internodes,
3032 include_leaves,
3033 include_petioles,
3034 include_flowers,
3035 include_fruit
3036 )
3037 except Exception as e:
3038 raise PlantArchitectureError(f"Failed to set collision-relevant organs: {e}")
3039
3041 obstacle_UUIDs: List[int],
3042 avoidance_distance: float = 0.5,
3043 enable_fruit_adjustment: bool = False,
3044 enable_obstacle_pruning: bool = False) -> None:
3045 """
3046 Enable hard obstacle avoidance for specified geometry.
3047
3048 This method configures solid obstacles that plants cannot grow through. Unlike soft
3049 collision avoidance (which guides growth), solid obstacles cause complete growth
3050 termination when encountered within the avoidance distance.
3051
3052 Args:
3053 obstacle_UUIDs: List of primitive UUIDs representing solid obstacles
3054 avoidance_distance: Minimum distance to maintain from obstacles (meters).
3055 Growth stops if obstacles are closer. Default 0.5m.
3056 enable_fruit_adjustment: Adjust fruit positions away from obstacles
3057 enable_obstacle_pruning: Remove plant organs that penetrate obstacles
3058
3059 Raises:
3060 ValueError: If obstacle_UUIDs is empty or avoidance_distance is non-positive
3061 PlantArchitectureError: If solid obstacle configuration fails
3062
3063 Example:
3064 >>> # Simple solid obstacle avoidance
3065 >>> wall_uuids = [1, 2, 3, 4] # UUIDs of wall primitives
3066 >>> plantarch.enableSolidObstacleAvoidance(wall_uuids)
3067 >>>
3068 >>> # Close avoidance with fruit adjustment
3069 >>> plantarch.enableSolidObstacleAvoidance(
3070 ... obstacle_UUIDs=wall_uuids,
3071 ... avoidance_distance=0.1,
3072 ... enable_fruit_adjustment=True
3073 ... )
3074 """
3075 if not obstacle_UUIDs:
3076 raise ValueError("Obstacle UUIDs list cannot be empty")
3077 if avoidance_distance <= 0:
3078 raise ValueError(f"avoidance_distance must be positive, got {avoidance_distance}")
3079
3081 try:
3083 plantarch_wrapper.enableSolidObstacleAvoidance(
3084 self._plantarch_ptr,
3085 obstacle_UUIDs,
3086 avoidance_distance,
3087 enable_fruit_adjustment,
3088 enable_obstacle_pruning
3089 )
3090 except Exception as e:
3091 raise PlantArchitectureError(f"Failed to enable solid obstacle avoidance: {e}")
3092
3093 def setStaticObstacles(self, target_UUIDs: List[int]) -> None:
3094 """
3095 Mark geometry as static obstacles for collision detection optimization.
3096
3097 This method tells the collision detection system that certain geometry will not
3098 move during the simulation. The system can then build an optimized Bounding Volume
3099 Hierarchy (BVH) for these obstacles, significantly improving collision detection
3100 performance in scenes with many static obstacles.
3101
3102 Args:
3103 target_UUIDs: List of primitive UUIDs representing static obstacles
3104
3105 Raises:
3106 ValueError: If target_UUIDs is empty
3107 PlantArchitectureError: If static obstacle configuration fails
3108
3109 Note:
3110 Collision avoidance must be enabled BEFORE calling this method -- the
3111 native call raises "Collision detection must be enabled before setting
3112 static obstacles" otherwise.
3113 Static obstacles cannot be modified or moved after being marked static.
3114
3115 Example:
3116 >>> # Enable collision avoidance first
3117 >>> plantarch.enableSoftCollisionAvoidance()
3118 >>> # Then mark ground and building geometry as static
3119 >>> static_uuids = ground_uuids + building_uuids
3120 >>> plantarch.setStaticObstacles(static_uuids)
3121 """
3122 if not target_UUIDs:
3123 raise ValueError("target_UUIDs list cannot be empty")
3124
3126 try:
3128 plantarch_wrapper.setStaticObstacles(self._plantarch_ptr, target_UUIDs)
3129 except Exception as e:
3130 raise PlantArchitectureError(f"Failed to set static obstacles: {e}")
3131
3132 def getPlantCollisionRelevantObjectIDs(self, plant_id: int) -> List[int]:
3133 """
3134 Get object IDs of collision-relevant geometry for a specific plant.
3135
3136 This method returns the subset of plant geometry that participates in collision
3137 detection, as filtered by setCollisionRelevantOrgans(). Useful for visualization
3138 and debugging collision detection behavior.
3139
3140 Args:
3141 plant_id: ID of the plant instance
3142
3143 Returns:
3144 List of object IDs for collision-relevant plant geometry
3145
3146 Raises:
3147 ValueError: If plant_id is negative
3148 PlantArchitectureError: If retrieval fails
3149
3150 Example:
3151 >>> # Get collision-relevant geometry
3152 >>> collision_obj_ids = plantarch.getPlantCollisionRelevantObjectIDs(plant_id)
3153 >>> print(f"Plant has {len(collision_obj_ids)} collision-relevant objects")
3154 >>>
3155 >>> # Highlight collision geometry in visualization
3156 >>> for obj_id in collision_obj_ids:
3157 ... context.setObjectColor(obj_id, RGBcolor(1, 0, 0)) # Red
3158 """
3159 if plant_id < 0:
3160 raise ValueError("Plant ID must be non-negative")
3161
3163 try:
3164 return plantarch_wrapper.getPlantCollisionRelevantObjectIDs(self._plantarch_ptr, plant_id)
3165 except Exception as e:
3166 raise PlantArchitectureError(f"Failed to get collision-relevant object IDs for plant {plant_id}: {e}")
3167
3168 # File I/O methods
3169 def writePlantMeshVertices(self, plant_id: int, filename: Union[str, Path]) -> None:
3170 """
3171 Write all plant mesh vertices to file for external processing.
3172
3173 This method exports all vertex coordinates (x,y,z) for every primitive in the plant,
3174 writing one vertex per line. Useful for external processing such as computing bounding
3175 volumes, convex hulls, or performing custom geometric analysis.
3176
3177 Args:
3178 plant_id: ID of the plant instance to export
3179 filename: Path to output file (absolute or relative to current working directory)
3180
3181 Raises:
3182 ValueError: If plant_id is negative or filename is empty
3183 PlantArchitectureError: If plant doesn't exist or file cannot be written
3184
3185 Example:
3186 >>> # Export vertices for convex hull analysis
3187 >>> plantarch.writePlantMeshVertices(plant_id, "plant_vertices.txt")
3188 >>>
3189 >>> # Use with Path object
3190 >>> from pathlib import Path
3191 >>> output_dir = Path("output")
3192 >>> output_dir.mkdir(exist_ok=True)
3193 >>> plantarch.writePlantMeshVertices(plant_id, output_dir / "vertices.txt")
3194 """
3195 if plant_id < 0:
3196 raise ValueError("Plant ID must be non-negative")
3197 if not filename:
3198 raise ValueError("Filename cannot be empty")
3199
3200 # Resolve path before changing directory
3201 absolute_path = _resolve_user_path(filename)
3202
3204 try:
3206 plantarch_wrapper.writePlantMeshVertices(
3207 self._plantarch_ptr, plant_id, absolute_path
3208 )
3209 except Exception as e:
3210 raise PlantArchitectureError(f"Failed to write plant mesh vertices to {filename}: {e}")
3211
3212 def writePlantStructureXML(self, plant_id: int, filename: Union[str, Path]) -> None:
3213 """
3214 Save plant structure to XML file for later loading.
3215
3216 This method exports the complete plant architecture to an XML file, including
3217 all shoots, phytomers, organs, and their properties. The saved plant can be
3218 reloaded later using readPlantStructureXML().
3219
3220 Args:
3221 plant_id: ID of the plant instance to save
3222 filename: Path to output XML file (absolute or relative to current working directory)
3223
3224 Raises:
3225 ValueError: If plant_id is negative or filename is empty
3226 PlantArchitectureError: If plant doesn't exist or file cannot be written
3227
3228 Note:
3229 The XML format preserves the complete plant state including:
3230 - Shoot structure and hierarchy
3231 - Phytomer properties and development stage
3232 - Organ geometry and attributes
3233 - Growth parameters and phenological state
3234
3235 Example:
3236 >>> # Save plant at current growth stage
3237 >>> plantarch.writePlantStructureXML(plant_id, "bean_day30.xml")
3238 >>>
3239 >>> # Later, reload the saved plant
3240 >>> loaded_plant_ids = plantarch.readPlantStructureXML("bean_day30.xml")
3241 >>> print(f"Loaded {len(loaded_plant_ids)} plants")
3242 """
3243 if plant_id < 0:
3244 raise ValueError("Plant ID must be non-negative")
3245 if not filename:
3246 raise ValueError("Filename cannot be empty")
3247
3248 # Resolve path before changing directory
3249 absolute_path = _resolve_user_path(filename)
3250
3252 try:
3254 plantarch_wrapper.writePlantStructureXML(
3255 self._plantarch_ptr, plant_id, absolute_path
3256 )
3257 except Exception as e:
3258 raise PlantArchitectureError(f"Failed to write plant structure XML to {filename}: {e}")
3259
3260 def writeQSMCylinderFile(self, plant_id: int, filename: Union[str, Path]) -> None:
3261 """
3262 Export plant structure in TreeQSM cylinder format.
3263
3264 This method writes the plant structure as a series of cylinders following the
3265 TreeQSM format (Raumonen et al., 2013). Each row represents one cylinder with
3266 columns for radius, length, start position, axis direction, branch topology,
3267 and other structural properties. Useful for biomechanical analysis and
3268 quantitative structure modeling.
3269
3270 Args:
3271 plant_id: ID of the plant instance to export
3272 filename: Path to output file (absolute or relative, typically .txt extension)
3273
3274 Raises:
3275 ValueError: If plant_id is negative or filename is empty
3276 PlantArchitectureError: If plant doesn't exist or file cannot be written
3277
3278 Note:
3279 The TreeQSM format includes columns for:
3280 - Cylinder dimensions (radius, length)
3281 - Spatial position and orientation
3282 - Branch topology (parent ID, extension ID, branch ID)
3283 - Branch hierarchy (branch order, position in branch)
3284 - Quality metrics (mean absolute distance, surface coverage)
3285
3286 Example:
3287 >>> # Export for biomechanical analysis
3288 >>> plantarch.writeQSMCylinderFile(plant_id, "tree_structure_qsm.txt")
3289 >>>
3290 >>> # Use with external QSM tools
3291 >>> import pandas as pd
3292 >>> qsm_data = pd.read_csv("tree_structure_qsm.txt", sep="\\t")
3293 >>> print(f"Tree has {len(qsm_data)} cylinders")
3294
3295 References:
3296 Raumonen et al. (2013) "Fast Automatic Precision Tree Models from
3297 Terrestrial Laser Scanner Data" Remote Sensing 5(2):491-520
3298 """
3299 if plant_id < 0:
3300 raise ValueError("Plant ID must be non-negative")
3301 if not filename:
3302 raise ValueError("Filename cannot be empty")
3303
3304 # Resolve path before changing directory
3305 absolute_path = _resolve_user_path(filename)
3306
3308 try:
3310 plantarch_wrapper.writeQSMCylinderFile(
3311 self._plantarch_ptr, plant_id, absolute_path
3312 )
3313 except Exception as e:
3314 raise PlantArchitectureError(f"Failed to write QSM cylinder file to {filename}: {e}")
3315
3316 def writePlantStructureUSD(self, plant_id: int, filename: Union[str, Path],
3317 elastic_modulus: float = 5e9,
3318 wood_density: float = 800.0,
3319 *,
3320 damping_time_constant: float = 0.02,
3321 static_friction: float = 0.5,
3322 dynamic_friction: float = 0.3,
3323 restitution: float = 0.1,
3324 organ_spring_stiffness: float = 10.0,
3325 organ_spring_damping: float = 1.0,
3326 leaf_mass_per_area: float = 0.05,
3327 fruit_mass: float = 0.01,
3328 flower_mass: float = 0.002,
3329 solver_position_iterations: int = 32,
3330 min_segment_length: float = 0.001,
3331 armature_stability_ratio: float = 8.0,
3332 physics_steps_per_second: float = 60.0,
3333 damping_ratio: Optional[float] = None) -> None:
3334 """
3335 Export plant structure as a USD articulated rigid body for NVIDIA IsaacSim physics.
3336
3337 Each tube segment becomes a capsule-shaped rigid link connected to its parent by a joint
3338 that locks translation, limits bending to +/-60 degrees and carries a spring on each
3339 rotation axis, with stiffness derived from beam bending stiffness (E*I/L). Leaves, fruits,
3340 and flowers are represented as mass bodies attached by spring links.
3341
3342 Beam stiffness on a short, light segment is stiffer than a physics solver can integrate at
3343 a typical step rate, so each joint is given armature (an artificial inertia) that caps its
3344 natural frequency at armature_stability_ratio * physics_steps_per_second. Armature adds no
3345 weight, so deflection under gravity is unchanged. If the simulation runs at a coarser step
3346 than physics_steps_per_second, set that parameter to match or the plant may be unstable.
3347
3348 Args:
3349 plant_id: ID of the plant instance to export
3350 filename: Output file path (should have .usda extension)
3351 elastic_modulus: Young's modulus (Pa) for joint stiffness, K = E*I/L
3352 wood_density: Wood density (kg/m^3) used to compute mass from capsule volume
3353 damping_time_constant: Damping time constant (s) of the joints between stem, petiole
3354 and peduncle segments; joint damping is this value times the joint stiffness.
3355 Must be non-negative.
3356 static_friction: Static friction coefficient for collision material
3357 dynamic_friction: Dynamic friction coefficient for collision material
3358 restitution: Restitution (bounciness) for collision material
3359 organ_spring_stiffness: Spring stiffness (N*m/rad) for organ attachment joints
3360 organ_spring_damping: Damping (N*m*s/rad) for organ attachment joints
3361 leaf_mass_per_area: Leaf mass per unit area (kg/m^2)
3362 fruit_mass: Mass per fruit (kg)
3363 flower_mass: Mass per flower (kg)
3364 solver_position_iterations: PhysX articulation solver position iteration count
3365 min_segment_length: Minimum segment length (m); shorter segments are skipped
3366 armature_stability_ratio: Cap on each joint's own natural frequency, as a multiple of
3367 the physics step rate. Lower is more stable. Must be positive.
3368 physics_steps_per_second: Physics step rate (Hz) the simulation is expected to run at,
3369 used to size the joint armature. Must be positive.
3370 damping_ratio: Removed; raises ValueError if given. See the note below.
3371
3372 Raises:
3373 ValueError: If plant_id is negative, filename is empty, a damping or armature
3374 parameter is out of range, or the removed damping_ratio parameter is given
3375 PlantArchitectureError: If plant doesn't exist or file cannot be written
3376
3377 Note:
3378 ``damping_ratio`` was removed in helios-core 1.3.90, where joint damping became
3379 proportional to stiffness. Use ``damping_time_constant`` (seconds) instead; the two
3380 are not interchangeable.
3381
3382 Example:
3383 >>> plantarch.writePlantStructureUSD(plant_id, "plant.usda")
3384 """
3385 if damping_ratio is not None:
3386 raise ValueError(
3387 "writePlantStructureUSD() no longer accepts damping_ratio: joint damping is now "
3388 "proportional to stiffness and is set with damping_time_constant (seconds, default 0.02). "
3389 "The two are not interchangeable, so the old value cannot be converted."
3390 )
3391 if plant_id < 0:
3392 raise ValueError("Plant ID must be non-negative")
3393 if not filename:
3394 raise ValueError("Filename cannot be empty")
3395 if damping_time_constant < 0:
3396 raise ValueError(f"damping_time_constant must be non-negative, got {damping_time_constant}")
3397 if armature_stability_ratio <= 0:
3398 raise ValueError(f"armature_stability_ratio must be positive, got {armature_stability_ratio}")
3399 if physics_steps_per_second <= 0:
3400 raise ValueError(f"physics_steps_per_second must be positive, got {physics_steps_per_second}")
3401
3402 absolute_path = _resolve_user_path(filename)
3403
3405 try:
3407 plantarch_wrapper.writePlantStructureUSD(
3408 self._plantarch_ptr, plant_id, absolute_path,
3409 elastic_modulus=elastic_modulus, wood_density=wood_density,
3410 damping_time_constant=damping_time_constant,
3411 armature_stability_ratio=armature_stability_ratio,
3412 physics_steps_per_second=physics_steps_per_second,
3413 static_friction=static_friction, dynamic_friction=dynamic_friction,
3414 restitution=restitution,
3415 organ_spring_stiffness=organ_spring_stiffness,
3416 organ_spring_damping=organ_spring_damping,
3417 leaf_mass_per_area=leaf_mass_per_area, fruit_mass=fruit_mass,
3418 flower_mass=flower_mass,
3419 solver_position_iterations=solver_position_iterations,
3420 min_segment_length=min_segment_length
3421 )
3422 except Exception as e:
3423 raise PlantArchitectureError(f"Failed to write plant structure USD to {filename}: {e}")
3424
3425 def registerGrowthFrame(self, plant_id: int, min_segment_length: float = 0.001) -> None:
3426 """
3427 Capture a snapshot of the plant's geometry as a growth animation frame.
3428
3429 Call this after each :meth:`advanceTime` step to record the plant state for later
3430 animation export via :meth:`writePlantGrowthUSD`.
3431
3432 Args:
3433 plant_id: ID of the plant instance to capture
3434 min_segment_length: Minimum segment length (m); shorter segments are skipped
3435
3436 Raises:
3437 ValueError: If plant_id is negative
3438 PlantArchitectureError: If plant doesn't exist
3439 """
3440 if plant_id < 0:
3441 raise ValueError("Plant ID must be non-negative")
3442
3444 try:
3445 plantarch_wrapper.registerGrowthFrame(self._plantarch_ptr, plant_id, min_segment_length)
3446 except Exception as e:
3447 raise PlantArchitectureError(f"Failed to register growth frame for plant {plant_id}: {e}")
3448
3449 def writePlantGrowthUSD(self, plant_id: int, filename: Union[str, Path],
3450 seconds_per_frame: float = 1.0) -> None:
3451 """
3452 Export all registered growth frames as a time-sampled USD animation file.
3453
3454 The resulting file can be imported directly into Blender. This is a visual-only
3455 export — no physics prims, joints, or collision shapes are written.
3456
3457 Args:
3458 plant_id: ID of the plant instance to export
3459 filename: Output file path (should have .usda extension)
3460 seconds_per_frame: Duration in seconds each growth frame occupies (default: 1.0)
3461
3462 Raises:
3463 ValueError: If plant_id is negative or filename is empty
3464 PlantArchitectureError: If plant doesn't exist or file cannot be written
3465 """
3466 if plant_id < 0:
3467 raise ValueError("Plant ID must be non-negative")
3468 if not filename:
3469 raise ValueError("Filename cannot be empty")
3470
3471 absolute_path = _resolve_user_path(filename)
3472
3474 try:
3476 plantarch_wrapper.writePlantGrowthUSD(
3477 self._plantarch_ptr, plant_id, absolute_path, seconds_per_frame
3478 )
3479 except Exception as e:
3480 raise PlantArchitectureError(f"Failed to write plant growth USD to {filename}: {e}")
3481
3482 def clearGrowthFrames(self, plant_id: int) -> None:
3483 """
3484 Clear stored growth animation frames for a plant.
3485
3486 Args:
3487 plant_id: ID of the plant instance whose frames should be cleared
3488
3489 Raises:
3490 ValueError: If plant_id is negative
3491 """
3492 if plant_id < 0:
3493 raise ValueError("Plant ID must be non-negative")
3494
3496 try:
3497 plantarch_wrapper.clearGrowthFrames(self._plantarch_ptr, plant_id)
3498 except Exception as e:
3499 raise PlantArchitectureError(f"Failed to clear growth frames for plant {plant_id}: {e}")
3500
3501 def getGrowthFrameCount(self, plant_id: int) -> int:
3502 """
3503 Get the number of registered growth frames for a plant.
3504
3505 Args:
3506 plant_id: ID of the plant instance to query
3507
3508 Returns:
3509 Number of frames registered via :meth:`registerGrowthFrame`
3510
3511 Raises:
3512 ValueError: If plant_id is negative
3513 """
3514 if plant_id < 0:
3515 raise ValueError("Plant ID must be non-negative")
3516
3518 try:
3519 return plantarch_wrapper.getGrowthFrameCount(self._plantarch_ptr, plant_id)
3520 except Exception as e:
3521 raise PlantArchitectureError(f"Failed to get growth frame count for plant {plant_id}: {e}")
3522
3523 @_surfaces_phytomer_callback_errors
3524 def readPlantStructureXML(self, filename: Union[str, Path], quiet: bool = False) -> List[int]:
3525 """
3526 Load plant structure from XML file.
3527
3528 This method reads plant architecture data from an XML file previously saved with
3529 writePlantStructureXML(). The loaded plants are added to the current context
3530 and can be grown, modified, or analyzed like any other plants.
3531
3532 Args:
3533 filename: Path to XML file to load (absolute or relative to current working directory)
3534 quiet: If True, suppress console output during loading (default: False)
3535
3536 Returns:
3537 List of plant IDs for the loaded plant instances
3538
3539 Raises:
3540 ValueError: If filename is empty
3541 PlantArchitectureError: If file doesn't exist, cannot be parsed, or loading fails
3542
3543 Note:
3544 The XML file can contain multiple plant instances. All plants in the file
3545 will be loaded and their IDs returned in a list. Plant models referenced
3546 in the XML must be available in the plant library.
3547
3548 Example:
3549 >>> # Load previously saved plants
3550 >>> plant_ids = plantarch.readPlantStructureXML("saved_canopy.xml")
3551 >>> print(f"Loaded {len(plant_ids)} plants")
3552 >>>
3553 >>> # Continue growing the loaded plants
3554 >>> plantarch.advanceTime(10.0)
3555 >>>
3556 >>> # Load quietly without console messages
3557 >>> plant_ids = plantarch.readPlantStructureXML("bean_day45.xml", quiet=True)
3558 """
3559 if not filename:
3560 raise ValueError("Filename cannot be empty")
3561
3562 # Resolve path before changing directory
3563 absolute_path = _resolve_user_path(filename)
3564
3566 try:
3568 return plantarch_wrapper.readPlantStructureXML(
3569 self._plantarch_ptr, absolute_path, quiet
3570 )
3571 except Exception as e:
3572 raise PlantArchitectureError(f"Failed to read plant structure XML from {filename}: {e}")
3573
3574 # Custom plant building methods
3575 def addPlantInstance(self, base_position: vec3, current_age: float) -> int:
3576 """
3577 Create an empty plant instance for custom plant building.
3578
3579 This method creates a new plant instance at the specified location without any
3580 shoots or organs. Use addBaseStemShoot(), appendShoot(), and addChildShoot() to
3581 manually construct the plant structure. This provides low-level control over
3582 plant architecture, enabling custom morphologies not available in the plant library.
3583
3584 Args:
3585 base_position: Cartesian (x,y,z) coordinates of plant base as vec3
3586 current_age: Current age of the plant in days (must be >= 0)
3587
3588 Returns:
3589 Plant ID for the created plant instance
3591 Raises:
3592 ValueError: If age is negative
3593 PlantArchitectureError: If plant creation fails
3594
3595 Example:
3596 >>> # Create empty plant at origin
3597 >>> plant_id = plantarch.addPlantInstance(vec3(0, 0, 0), 0.0)
3598 >>>
3599 >>> # Now add shoots to build custom plant structure
3600 >>> shoot_id = plantarch.addBaseStemShoot(
3601 ... plant_id, 1, AxisRotation(0, 0, 0), 0.01, 0.1, 1.0, 1.0, 0.8, "mainstem"
3602 ... )
3603 """
3604 # Parameter type validation
3605 if not isinstance(base_position, vec3):
3606 raise ValueError(f"base_position must be a vec3, got {type(base_position).__name__}")
3607
3608 # Convert position to list for C++ interface
3609 position_list = [base_position.x, base_position.y, base_position.z]
3610
3611 # Validate age
3612 if current_age < 0:
3613 raise ValueError(f"Age must be non-negative, got {current_age}")
3614
3616 try:
3618 return plantarch_wrapper.addPlantInstance(
3619 self._plantarch_ptr, position_list, current_age
3620 )
3621 except Exception as e:
3622 raise PlantArchitectureError(f"Failed to add plant instance: {e}")
3623
3624 def deletePlantInstance(self, plant_id: int) -> None:
3625 """
3626 Delete a plant instance and all associated geometry.
3627
3628 This method removes a plant from the simulation, deleting all shoots, organs,
3629 and associated primitives from the context. The plant ID becomes invalid after
3630 deletion and should not be used in subsequent operations.
3631
3632 Args:
3633 plant_id: ID of the plant instance to delete
3634
3635 Raises:
3636 ValueError: If plant_id is negative
3637 PlantArchitectureError: If plant deletion fails or plant doesn't exist
3638
3639 Example:
3640 >>> # Delete a plant
3641 >>> plantarch.deletePlantInstance(plant_id)
3642 >>>
3643 >>> # Delete multiple plants
3644 >>> for pid in plant_ids_to_remove:
3645 ... plantarch.deletePlantInstance(pid)
3646 """
3647 if plant_id < 0:
3648 raise ValueError("Plant ID must be non-negative")
3649
3650 self._reject_inside_phytomer_callback("deletePlantInstance")
3652 try:
3654 plantarch_wrapper.deletePlantInstance(self._plantarch_ptr, plant_id)
3655 except Exception as e:
3656 raise PlantArchitectureError(f"Failed to delete plant instance {plant_id}: {e}")
3657
3658 @_surfaces_phytomer_callback_errors
3659 def addBaseStemShoot(self,
3660 plant_id: int,
3661 current_node_number: int,
3662 base_rotation: AxisRotation,
3663 internode_radius: float,
3664 internode_length_max: float,
3665 internode_length_scale_factor_fraction: float,
3666 leaf_scale_factor_fraction: float,
3667 radius_taper: float,
3668 shoot_type_label: str) -> int:
3669 """
3670 Add a base stem shoot to a plant instance (main trunk/stem).
3671
3672 This method creates the primary shoot originating from the plant base. The base stem
3673 is typically the main trunk or primary stem from which all other shoots branch.
3674 Specify growth parameters to control the shoot's morphology and development.
3675
3676 **IMPORTANT - Shoot Type Requirement**: Shoot types must be defined before use. The standard
3677 workflow is to load a plant model first using loadPlantModelFromLibrary(), which defines
3678 shoot types that can then be used for custom building. The shoot_type_label must match a
3679 shoot type defined in the loaded model.
3680
3681 Args:
3682 plant_id: ID of the plant instance
3683 current_node_number: Starting node number for this shoot (typically 1)
3684 base_rotation: Orientation as AxisRotation(pitch, yaw, roll) in radians (use math.radians() to convert)
3685 internode_radius: Base radius of internodes in meters (must be > 0)
3686 internode_length_max: Maximum internode length in meters (must be > 0)
3687 internode_length_scale_factor_fraction: Scale factor for internode length (0-1 typically)
3688 leaf_scale_factor_fraction: Scale factor for leaf size (0-1 typically)
3689 radius_taper: Rate of radius decrease along shoot (0-1, where 1=no taper)
3690 shoot_type_label: Label identifying shoot type - must match a type from loaded model
3691
3692 Returns:
3693 Shoot ID for the created shoot
3694
3695 Raises:
3696 ValueError: If parameters are invalid (negative IDs, non-positive dimensions, empty label)
3697 PlantArchitectureError: If shoot creation fails or shoot type doesn't exist
3698
3699 Example:
3700 >>> from pyhelios.types import vec3, AxisRotation
3701 >>>
3702 >>> # REQUIRED: Load a plant model to define shoot types
3703 >>> plantarch.loadPlantModelFromLibrary("bean")
3704 >>>
3705 >>> # Create empty plant for custom building
3706 >>> plant_id = plantarch.addPlantInstance(vec3(0, 0, 0), 0.0)
3707 >>>
3708 >>> # Add base stem using a shoot type from the loaded model. Labels are
3709 >>> # species-specific: bean defines "unifoliate"/"trifoliate", almond
3710 >>> # defines "trunk"/"scaffold"/"proleptic"/"sylleptic". There is no
3711 >>> # generic "stem" type.
3712 >>> shoot_id = plantarch.addBaseStemShoot(
3713 ... plant_id=plant_id,
3714 ... current_node_number=1,
3715 ... base_rotation=AxisRotation(0, 0, 0), # Upright
3716 ... internode_radius=0.01, # 1cm radius
3717 ... internode_length_max=0.1, # 10cm max length
3718 ... internode_length_scale_factor_fraction=1.0,
3719 ... leaf_scale_factor_fraction=1.0,
3720 ... radius_taper=0.9, # Gradual taper
3721 ... shoot_type_label="trifoliate" # Must match loaded model
3722 ... )
3723 """
3724 if plant_id < 0:
3725 raise ValueError("Plant ID must be non-negative")
3726 if current_node_number < 0:
3727 raise ValueError("Current node number must be non-negative")
3728 if internode_radius <= 0:
3729 raise ValueError(f"Internode radius must be positive, got {internode_radius}")
3730 if internode_length_max <= 0:
3731 raise ValueError(f"Internode length max must be positive, got {internode_length_max}")
3732 if not shoot_type_label or not shoot_type_label.strip():
3733 raise ValueError("Shoot type label cannot be empty")
3734
3735 # Convert rotation to list for C++ interface
3736 rotation_list = base_rotation.to_list()
3737
3739 try:
3741 return plantarch_wrapper.addBaseStemShoot(
3742 self._plantarch_ptr, plant_id, current_node_number, rotation_list,
3743 internode_radius, internode_length_max,
3744 internode_length_scale_factor_fraction, leaf_scale_factor_fraction,
3745 radius_taper, shoot_type_label.strip()
3747 except Exception as e:
3748 error_msg = str(e)
3749 if "does not exist" in error_msg.lower() and "shoot type" in error_msg.lower():
3751 f"Shoot type '{shoot_type_label}' not defined. "
3752 f"Load a plant model first to define shoot types:\n"
3753 f" plantarch.loadPlantModelFromLibrary('bean') # or other model\n"
3754 f"Original error: {e}"
3755 )
3756 raise PlantArchitectureError(f"Failed to add base stem shoot: {e}")
3757
3758 @_surfaces_phytomer_callback_errors
3759 def appendShoot(self,
3760 plant_id: int,
3761 parent_shoot_id: int,
3762 current_node_number: int,
3763 base_rotation: AxisRotation,
3764 internode_radius: float,
3765 internode_length_max: float,
3766 internode_length_scale_factor_fraction: float,
3767 leaf_scale_factor_fraction: float,
3768 radius_taper: float,
3769 shoot_type_label: str) -> int:
3770 """
3771 Append a shoot to the end of an existing shoot.
3772
3773 This method extends an existing shoot by appending a new shoot at its terminal bud.
3774 Useful for creating multi-segmented shoots with varying properties along their length,
3775 such as shoots with different growth phases or developmental stages.
3776
3777 **IMPORTANT - Shoot Type Requirement**: The shoot_type_label must match a shoot type
3778 defined in a loaded plant model. Load a model with loadPlantModelFromLibrary() before
3779 calling this method.
3780
3781 Args:
3782 plant_id: ID of the plant instance
3783 parent_shoot_id: ID of the parent shoot to extend
3784 current_node_number: Starting node number for this shoot
3785 base_rotation: Orientation as AxisRotation(pitch, yaw, roll) in radians (use math.radians() to convert)
3786 internode_radius: Base radius of internodes in meters (must be > 0)
3787 internode_length_max: Maximum internode length in meters (must be > 0)
3788 internode_length_scale_factor_fraction: Scale factor for internode length (0-1 typically)
3789 leaf_scale_factor_fraction: Scale factor for leaf size (0-1 typically)
3790 radius_taper: Rate of radius decrease along shoot (0-1, where 1=no taper)
3791 shoot_type_label: Label identifying shoot type - must match loaded model
3792
3793 Returns:
3794 Shoot ID for the appended shoot
3795
3796 Raises:
3797 ValueError: If parameters are invalid (negative IDs, non-positive dimensions, empty label)
3798 PlantArchitectureError: If shoot appending fails, parent doesn't exist, or shoot type not defined
3799
3800 Example:
3801 >>> # Load model to define shoot types
3802 >>> plantarch.loadPlantModelFromLibrary("bean")
3803 >>>
3804 >>> # Append shoot with reduced size to simulate apical growth
3805 >>> new_shoot_id = plantarch.appendShoot(
3806 ... plant_id=plant_id,
3807 ... parent_shoot_id=base_shoot_id,
3808 ... current_node_number=10,
3809 ... base_rotation=AxisRotation(0, 0, 0),
3810 ... internode_radius=0.008, # Smaller than base
3811 ... internode_length_max=0.08, # Shorter internodes
3812 ... internode_length_scale_factor_fraction=1.0,
3813 ... leaf_scale_factor_fraction=0.8, # Smaller leaves
3814 ... radius_taper=0.85,
3815 ... shoot_type_label="trifoliate"
3816 ... )
3817 """
3818 if plant_id < 0:
3819 raise ValueError("Plant ID must be non-negative")
3820 if parent_shoot_id < 0:
3821 raise ValueError("Parent shoot ID must be non-negative")
3822 if current_node_number < 0:
3823 raise ValueError("Current node number must be non-negative")
3824 if internode_radius <= 0:
3825 raise ValueError(f"Internode radius must be positive, got {internode_radius}")
3826 if internode_length_max <= 0:
3827 raise ValueError(f"Internode length max must be positive, got {internode_length_max}")
3828 if not shoot_type_label or not shoot_type_label.strip():
3829 raise ValueError("Shoot type label cannot be empty")
3830
3831 # Convert rotation to list for C++ interface
3832 rotation_list = base_rotation.to_list()
3833
3835 try:
3837 return plantarch_wrapper.appendShoot(
3838 self._plantarch_ptr, plant_id, parent_shoot_id, current_node_number,
3839 rotation_list, internode_radius, internode_length_max,
3840 internode_length_scale_factor_fraction, leaf_scale_factor_fraction,
3841 radius_taper, shoot_type_label.strip()
3842 )
3843 except Exception as e:
3844 error_msg = str(e)
3845 if "does not exist" in error_msg.lower() and "shoot type" in error_msg.lower():
3847 f"Shoot type '{shoot_type_label}' not defined. "
3848 f"Load a plant model first to define shoot types:\n"
3849 f" plantarch.loadPlantModelFromLibrary('bean') # or other model\n"
3850 f"Original error: {e}"
3851 )
3852 raise PlantArchitectureError(f"Failed to append shoot: {e}")
3853
3854 @_surfaces_phytomer_callback_errors
3855 def addChildShoot(self,
3856 plant_id: int,
3857 parent_shoot_id: int,
3858 parent_node_index: int,
3859 current_node_number: int,
3860 shoot_base_rotation: AxisRotation,
3861 internode_radius: float,
3862 internode_length_max: float,
3863 internode_length_scale_factor_fraction: float,
3864 leaf_scale_factor_fraction: float,
3865 radius_taper: float,
3866 shoot_type_label: str,
3867 petiole_index: int = 0) -> int:
3868 """
3869 Add a child shoot at an axillary bud position on a parent shoot.
3870
3871 This method creates a lateral branch shoot emerging from a specific node on the
3872 parent shoot. Child shoots enable creation of branching architectures, with control
3873 over branch angle, size, and which petiole position the branch emerges from (for
3874 plants with multiple petioles per node).
3875
3876 **IMPORTANT - Shoot Type Requirement**: The shoot_type_label must match a shoot type
3877 defined in a loaded plant model. Load a model with loadPlantModelFromLibrary() before
3878 calling this method.
3879
3880 Args:
3881 plant_id: ID of the plant instance
3882 parent_shoot_id: ID of the parent shoot
3883 parent_node_index: Index of the parent node where child emerges (0-based)
3884 current_node_number: Starting node number for this child shoot
3885 shoot_base_rotation: Orientation as AxisRotation(pitch, yaw, roll) in radians (use math.radians() to convert)
3886 internode_radius: Base radius of child shoot internodes in meters (must be > 0)
3887 internode_length_max: Maximum internode length in meters (must be > 0)
3888 internode_length_scale_factor_fraction: Scale factor for internode length (0-1 typically)
3889 leaf_scale_factor_fraction: Scale factor for leaf size (0-1 typically)
3890 radius_taper: Rate of radius decrease along shoot (0-1, where 1=no taper)
3891 shoot_type_label: Label identifying shoot type - must match loaded model
3892 petiole_index: Which petiole at the node to branch from (default: 0)
3893
3894 Returns:
3895 Shoot ID for the created child shoot
3896
3897 Raises:
3898 ValueError: If parameters are invalid (negative values, non-positive dimensions, empty label)
3899 PlantArchitectureError: If child shoot creation fails, parent doesn't exist, or shoot type not defined
3900
3901 Example:
3902 >>> import math
3903 >>> # Load model to define shoot types
3904 >>> plantarch.loadPlantModelFromLibrary("bean")
3905 >>>
3906 >>> # Add lateral branch at 45-degree angle from node 3
3907 >>> branch_id = plantarch.addChildShoot(
3908 ... plant_id=plant_id,
3909 ... parent_shoot_id=main_shoot_id,
3910 ... parent_node_index=3,
3911 ... current_node_number=1,
3912 ... shoot_base_rotation=AxisRotation(math.radians(45), math.radians(90), 0), # 45° out, 90° around
3913 ... internode_radius=0.005, # Thinner than main stem
3914 ... internode_length_max=0.06, # Shorter internodes
3915 ... internode_length_scale_factor_fraction=1.0,
3916 ... leaf_scale_factor_fraction=0.9,
3917 ... radius_taper=0.8,
3918 ... shoot_type_label="trifoliate"
3919 ... )
3920 >>>
3921 >>> # Add second branch from opposite petiole
3922 >>> branch_id2 = plantarch.addChildShoot(
3923 ... plant_id, main_shoot_id, 3, 1, AxisRotation(math.radians(45), math.radians(270), 0),
3924 ... 0.005, 0.06, 1.0, 0.9, 0.8, "trifoliate", petiole_index=1
3925 ... )
3926 """
3927 if plant_id < 0:
3928 raise ValueError("Plant ID must be non-negative")
3929 if parent_shoot_id < 0:
3930 raise ValueError("Parent shoot ID must be non-negative")
3931 if parent_node_index < 0:
3932 raise ValueError("Parent node index must be non-negative")
3933 if current_node_number < 0:
3934 raise ValueError("Current node number must be non-negative")
3935 if internode_radius <= 0:
3936 raise ValueError(f"Internode radius must be positive, got {internode_radius}")
3937 if internode_length_max <= 0:
3938 raise ValueError(f"Internode length max must be positive, got {internode_length_max}")
3939 if not shoot_type_label or not shoot_type_label.strip():
3940 raise ValueError("Shoot type label cannot be empty")
3941 if petiole_index < 0:
3942 raise ValueError(f"Petiole index must be non-negative, got {petiole_index}")
3943
3944 # Convert rotation to list for C++ interface
3945 rotation_list = shoot_base_rotation.to_list()
3948 try:
3950 return plantarch_wrapper.addChildShoot(
3951 self._plantarch_ptr, plant_id, parent_shoot_id, parent_node_index,
3952 current_node_number, rotation_list, internode_radius,
3953 internode_length_max, internode_length_scale_factor_fraction,
3954 leaf_scale_factor_fraction, radius_taper, shoot_type_label.strip(),
3955 petiole_index
3956 )
3957 except Exception as e:
3958 error_msg = str(e)
3959 if "does not exist" in error_msg.lower() and "shoot type" in error_msg.lower():
3961 f"Shoot type '{shoot_type_label}' not defined. "
3962 f"Load a plant model first to define shoot types:\n"
3963 f" plantarch.loadPlantModelFromLibrary('bean') # or other model\n"
3964 f"Original error: {e}"
3965 )
3966 raise PlantArchitectureError(f"Failed to add child shoot: {e}")
3967
3968 # =========================================================================
3969 # Reconstruction from measured geometry (helios-core 1.3.85+)
3970 # =========================================================================
3971
3972 @staticmethod
3973 def _validateNodesAndRadii(node_positions, node_radii, positions_name: str, radii_name: str):
3974 """Validate a measured node path and return it as plain lists for the ctypes layer."""
3975 if not isinstance(node_positions, (list, tuple)):
3976 raise ValueError(f"{positions_name} must be a list of vec3, got {type(node_positions).__name__}")
3977 if not isinstance(node_radii, (list, tuple)):
3978 raise ValueError(f"{radii_name} must be a list of floats, got {type(node_radii).__name__}")
3979 if len(node_positions) < 2:
3980 raise ValueError(f"{positions_name} must contain at least two positions, got {len(node_positions)}")
3981 if len(node_radii) != len(node_positions):
3982 raise ValueError(
3983 f"{radii_name} must have one entry per position: got {len(node_radii)} radii "
3984 f"for {len(node_positions)} positions")
3985 positions = []
3986 for i, pt in enumerate(node_positions):
3987 if not isinstance(pt, vec3):
3988 raise ValueError(f"{positions_name}[{i}] must be a vec3, got {type(pt).__name__}")
3989 positions.append([pt.x, pt.y, pt.z])
3990 radii = []
3991 for i, r in enumerate(node_radii):
3992 if isinstance(r, bool) or not isinstance(r, (int, float)):
3993 raise ValueError(f"{radii_name}[{i}] must be a number, got {type(r).__name__}")
3994 if r <= 0:
3995 raise ValueError(f"{radii_name}[{i}] must be positive, got {r}")
3996 radii.append(float(r))
3997 return positions, radii
3998
3999 @_surfaces_phytomer_callback_errors
4001 plant_id: int,
4002 parent_shoot_id: int,
4003 parent_node_index: int,
4004 node_positions: List[vec3],
4005 node_radii: List[float],
4006 shoot_type_label: str,
4007 growth_shoot_type_label: Optional[str] = None,
4008 petiole_index: int = 0) -> int:
4009 """
4010 Add a shoot whose internode geometry is prescribed by measured node positions.
4011
4012 This builds a single shoot, rendered as one continuous internode tube, that
4013 follows a path given by the caller rather than one generated from the shoot
4014 type's curvature and tortuosity parameters. It is intended for reconstructing a
4015 plant from measured geometry such as a QSM, a digitized skeleton or
4016 photogrammetry. A shoot built through :meth:`addBaseStemShoot`,
4017 :meth:`appendShoot` or :meth:`addChildShoot` is an extrapolation from its base
4018 rotation and cannot follow a measured curve; approximating one by chaining many
4019 short shoots produces a separate tube object per link, which leaves visible gaps
4020 at every bend.
4021
4022 The supplied positions are the phytomer endpoints: N+1 positions define N
4023 internodes and therefore N phytomers. The shoot type's ``internode.length_segments``
4024 still controls how finely each internode is subdivided, with the intermediate
4025 nodes interpolated along the straight segment between the two prescribed
4026 endpoints. The caller controls internode length by choosing how many nodes to
4027 supply.
4028
4029 The prescribed phytomers are created fully elongated and are therefore not
4030 re-scaled or re-curved by subsequent calls to :meth:`advanceTime`. New phytomers
4031 added at the shoot apex as the plant grows are generated normally, continuing from
4032 the direction of the final prescribed internode, and use the mean of the
4033 prescribed internode lengths as their target length. Prescribed radii act as a
4034 lower bound: a shoot type with a non-zero ``girth_area_factor`` may thicken an
4035 internode during growth but never thins one, so a girth area factor of zero
4036 preserves the prescribed radii exactly.
4037
4038 When ``parent_shoot_id`` is non-negative the base of the shoot is seated on the
4039 parent as :meth:`addChildShoot` does (offset from the attachment node to the
4040 surface of the parent internode) and the whole path is translated onto that
4041 point. All relative geometry is preserved; only the absolute position changes,
4042 and an error is raised if the discrepancy is large enough that the shoot would
4043 not be connected to its parent.
4044
4045 **Separate growth type.** Building measured wood calls for curvature and
4046 tortuosity of zero so the measured path is not fought, a node cap at least as
4047 large as the longest measured branch, and often a girth area factor of zero so
4048 the measured radii are preserved. None of those describe how the plant should
4049 grow: a shoot inheriting them extends perfectly straight and never reaches its
4050 node cap. Pass ``growth_shoot_type_label`` to take the node caps, the gravitropic
4051 curvature of phytomers added at the apex, and the type of the shoots this shoot's
4052 vegetative buds produce from a different shoot type. ``girth_area_factor`` and
4053 bud-break probability are deliberately still taken from the build type, and the
4054 build type remains the label reported by the shoot. A measured branch longer
4055 than the growth type's ``max_nodes`` is accepted and simply stops extending.
4056
4057 Requires helios-core v1.3.85 or newer.
4058
4059 Args:
4060 plant_id: ID of the plant instance
4061 parent_shoot_id: ID of the shoot to attach to, or ``-1`` to create a base stem
4062 shoot at the start of a new plant
4063 parent_node_index: Node of the parent shoot at which the new shoot is added.
4064 Ignored when ``parent_shoot_id`` is ``-1``
4065 node_positions: Internode node positions in world coordinates, ordered from the
4066 base of the shoot to its tip. At least two are required, and no two
4067 consecutive positions may be coincident
4068 node_radii: Radius of the shoot at each node, one per position. All must be > 0
4069 shoot_type_label: Shoot type whose parameters build the measured geometry.
4070 Must already be defined (by :meth:`loadPlantModelFromLibrary` or
4071 :meth:`defineShootType`)
4072 growth_shoot_type_label: Optional shoot type whose parameters govern the
4073 shoot's future growth. ``None`` grows the shoot with ``shoot_type_label``
4074 petiole_index: Petiole within the parent node to attach to (default 0)
4075
4076 Returns:
4077 ID of the newly created shoot
4078
4079 Raises:
4080 ValueError: If any ID is out of range, a position is not a vec3, a radius is
4081 not positive, fewer than two nodes are given, or the counts differ
4082 PlantArchitectureError: If the native build fails (undefined shoot type,
4083 coincident consecutive nodes, base too far from the parent, ...) or the
4084 library predates v1.3.85
4085
4086 Note:
4087 Like every other manually added shoot, the new shoot is created dormant.
4088 Call :meth:`breakPlantDormancy` before :meth:`advanceTime` if it is to grow.
4089
4090 Example:
4091 >>> plantarch.loadPlantModelFromLibrary("bean")
4092 >>> plant_id = plantarch.addPlantInstance(vec3(0, 0, 0), 0.0)
4093 >>> path = [vec3(0, 0, 0), vec3(0.01, 0, 0.1), vec3(0.03, 0.01, 0.2), vec3(0.04, 0.01, 0.3)]
4094 >>> radii = [0.006, 0.005, 0.004, 0.003]
4095 >>> stem = plantarch.addShootFromNodePositions(plant_id, -1, 0, path, radii, "unifoliate")
4096 >>> assert plantarch.isShootGeometryPrescribed(plant_id, stem)
4097 """
4098 if plant_id < 0:
4099 raise ValueError("Plant ID must be non-negative")
4100 if parent_shoot_id < -1:
4101 raise ValueError("Parent shoot ID must be -1 (base stem) or a non-negative shoot ID")
4102 if parent_node_index < 0:
4103 raise ValueError("Parent node index must be non-negative")
4104 if petiole_index < 0:
4105 raise ValueError(f"Petiole index must be non-negative, got {petiole_index}")
4106 if not isinstance(shoot_type_label, str) or not shoot_type_label.strip():
4107 raise ValueError("Shoot type label cannot be empty")
4108 if growth_shoot_type_label is not None:
4109 if not isinstance(growth_shoot_type_label, str) or not growth_shoot_type_label.strip():
4110 raise ValueError("Growth shoot type label cannot be empty when given")
4111 growth_shoot_type_label = growth_shoot_type_label.strip()
4112 positions, radii = self._validateNodesAndRadii(node_positions, node_radii, "node_positions", "node_radii")
4113
4115 try:
4117 return plantarch_wrapper.addShootFromNodePositions(
4118 self._plantarch_ptr, plant_id, parent_shoot_id, parent_node_index,
4119 positions, radii, shoot_type_label.strip(), growth_shoot_type_label, petiole_index)
4120 except Exception as e:
4121 error_msg = str(e)
4122 if "does not exist" in error_msg.lower() and "shoot type" in error_msg.lower():
4124 f"Shoot type not defined ('{shoot_type_label}'"
4125 f"{', ' + repr(growth_shoot_type_label) if growth_shoot_type_label else ''}). "
4126 f"Load a plant model or define the shoot type first:\n"
4127 f" plantarch.loadPlantModelFromLibrary('bean') # or defineShootType(...)\n"
4128 f"Original error: {e}")
4129 raise PlantArchitectureError(f"Failed to add shoot from node positions: {e}")
4130
4131 def setPetioleNodePositions(self,
4132 plant_id: int,
4133 shoot_id: int,
4134 node_index: int,
4135 petiole_index: int,
4136 node_positions: List[vec3],
4137 node_radii: List[float]) -> None:
4138 """
4139 Prescribe the path of a petiole on an existing phytomer from measured node positions.
4140
4141 This is the organ-level counterpart of :meth:`addShootFromNodePositions`. Where
4142 that method prescribes the internode skeleton of a shoot, this one prescribes the
4143 centerline of a single petiole hanging off it, so that a reconstruction from
4144 labelled measurements (a segmented point cloud, a digitized plant) can follow the
4145 measured petiole rather than the path the shoot type's petiole pitch and
4146 curvature would generate.
4147
4148 The supplied positions are the nodes of the petiole tube, ordered from the base
4149 outward. Their number is free and need not match the shoot type's
4150 ``petiole.length_segments``; the petiole tube is rebuilt to match. The first
4151 position is snapped onto the tip of the internode the petiole grows from and the
4152 rest of the path is translated by the same amount, so all relative geometry is
4153 preserved exactly. An error is raised if that discrepancy is large enough that
4154 the petiole would not be attached to the stem.
4155
4156 The prescribed petiole is not re-scaled by subsequent calls to
4157 :meth:`advanceTime`, and its radii are held as given.
4158
4159 Requires helios-core v1.3.85 or newer.
4160
4161 Args:
4162 plant_id: ID of the plant instance
4163 shoot_id: ID of the shoot carrying the phytomer
4164 node_index: Index of the phytomer within the shoot, counted from the base
4165 petiole_index: Index of the petiole within the phytomer
4166 node_positions: Petiole node positions in world coordinates, base to tip. At
4167 least two are required, and no two consecutive positions may be coincident
4168 node_radii: Radius of the petiole at each node, one per position. All must be > 0
4169
4170 Raises:
4171 ValueError: If any index is negative, a position is not a vec3, a radius is
4172 not positive, fewer than two nodes are given, or the counts differ
4173 PlantArchitectureError: If the native call fails or the library predates v1.3.85
4174
4175 Note:
4176 Call this **before** :meth:`setPetioleLeafGeometry` for the same petiole, since
4177 leaf placement is oriented from the petiole axis.
4178 """
4179 for name, v in (("Plant ID", plant_id), ("Shoot ID", shoot_id),
4180 ("Node index", node_index), ("Petiole index", petiole_index)):
4181 if v < 0:
4182 raise ValueError(f"{name} must be non-negative")
4183 positions, radii = self._validateNodesAndRadii(node_positions, node_radii, "node_positions", "node_radii")
4184
4186 try:
4188 plantarch_wrapper.setPetioleNodePositions(
4189 self._plantarch_ptr, plant_id, shoot_id, node_index, petiole_index, positions, radii)
4190 except Exception as e:
4192 f"Failed to set petiole node positions (plant {plant_id}, shoot {shoot_id}, "
4193 f"node {node_index}, petiole {petiole_index}): {e}")
4194
4195 def setPetioleLeafGeometry(self,
4196 plant_id: int,
4197 shoot_id: int,
4198 node_index: int,
4199 petiole_index: int,
4200 leaf_bases: List[vec3],
4201 leaf_rotations: List[AxisRotation],
4202 leaf_sizes: List[float]) -> None:
4203 """
4204 Prescribe the base position, orientation and size of every leaf on a petiole.
4205
4206 This is the leaf-level counterpart of :meth:`setPetioleNodePositions`, intended
4207 for the same reconstruction workflow. Every leaf on the petiole is prescribed in
4208 one call: for a compound leaf the leaflets are not independent, since a
4209 leaflet's roll and yaw signs and the prototype it is a copy of all follow from
4210 its position along the petiole. A species with one leaf per petiole passes
4211 one-element lists.
4212
4213 Each leaf is rebuilt from its prototype and re-oriented through the same
4214 rotation chain used when a leaf is grown. The prescribed base, orientation and
4215 size are held exactly and are not changed by :meth:`advanceTime`; prescribed
4216 leaves are additionally exempt from the self-weight droop.
4217
4218 **Rotation units and frame.** ``leaf_rotations`` are given in **radians**, as
4219 pitch, yaw and roll relative to the petiole and internode axes, not to world
4220 axes (the same convention as the native ``Phytomer::leaf_rotation``). The full
4221 chain that places a leaf includes the petiole's own azimuth and a
4222 size-dependent correction and is not invertible, so there is no exact
4223 conversion from a world-frame blade orientation; a caller fitting to measured
4224 data should iterate by forward evaluation, reading the resulting geometry back
4225 from the Context.
4226
4227 Requires helios-core v1.3.85 or newer.
4228
4229 Args:
4230 plant_id: ID of the plant instance
4231 shoot_id: ID of the shoot carrying the phytomer
4232 node_index: Index of the phytomer within the shoot, counted from the base
4233 petiole_index: Index of the petiole within the phytomer
4234 leaf_bases: Base position of each leaf in world coordinates, one per leaf on
4235 the petiole, in the petiole's existing leaf order
4236 leaf_rotations: ``AxisRotation(pitch, yaw, roll)`` of each leaf in **radians**
4237 leaf_sizes: Fully elongated size of each leaf in meters. All must be > 0
4238
4239 Raises:
4240 ValueError: If any index is negative, a base is not a vec3, a rotation is not
4241 an AxisRotation, a size is not positive, or the three lists differ in length
4242 PlantArchitectureError: If the number of leaves does not match the petiole
4243 (the count is fixed when the phytomer is created), the native call
4244 fails, or the library predates v1.3.85
4245
4246 Note:
4247 Rebuilding each leaf discards primitive data a caller has attached to it.
4248 The object label and material are restored; other primitive data is not.
4249 """
4250 for name, v in (("Plant ID", plant_id), ("Shoot ID", shoot_id),
4251 ("Node index", node_index), ("Petiole index", petiole_index)):
4252 if v < 0:
4253 raise ValueError(f"{name} must be non-negative")
4254 for name, seq in (("leaf_bases", leaf_bases), ("leaf_rotations", leaf_rotations), ("leaf_sizes", leaf_sizes)):
4255 if not isinstance(seq, (list, tuple)):
4256 raise ValueError(f"{name} must be a list, got {type(seq).__name__}")
4257 n = len(leaf_bases)
4258 if n < 1:
4259 raise ValueError("leaf_bases must contain at least one leaf")
4260 if len(leaf_rotations) != n or len(leaf_sizes) != n:
4261 raise ValueError(
4262 f"leaf_bases, leaf_rotations and leaf_sizes must have the same length: "
4263 f"got {n}, {len(leaf_rotations)} and {len(leaf_sizes)}")
4264 bases = []
4265 for i, b in enumerate(leaf_bases):
4266 if not isinstance(b, vec3):
4267 raise ValueError(f"leaf_bases[{i}] must be a vec3, got {type(b).__name__}")
4268 bases.append([b.x, b.y, b.z])
4269 rotations = []
4270 for i, r in enumerate(leaf_rotations):
4271 if not isinstance(r, AxisRotation):
4272 raise ValueError(f"leaf_rotations[{i}] must be an AxisRotation, got {type(r).__name__}")
4273 rotations.append([r.pitch, r.yaw, r.roll])
4274 sizes = []
4275 for i, sz in enumerate(leaf_sizes):
4276 if isinstance(sz, bool) or not isinstance(sz, (int, float)):
4277 raise ValueError(f"leaf_sizes[{i}] must be a number, got {type(sz).__name__}")
4278 if sz <= 0:
4279 raise ValueError(f"leaf_sizes[{i}] must be positive, got {sz}")
4280 sizes.append(float(sz))
4281
4283 try:
4285 plantarch_wrapper.setPetioleLeafGeometry(
4286 self._plantarch_ptr, plant_id, shoot_id, node_index, petiole_index,
4287 bases, rotations, sizes)
4288 except Exception as e:
4290 f"Failed to set petiole leaf geometry (plant {plant_id}, shoot {shoot_id}, "
4291 f"node {node_index}, petiole {petiole_index}): {e}")
4292
4293 def setPetioleLeafCount(self, plant_id: int, shoot_id: int, node_index: int,
4294 petiole_index: int, leaf_count: int) -> None:
4295 """
4296 Change the number of leaves (leaflets) on one petiole of an existing phytomer.
4297
4298 The leaves are rebuilt procedurally. By default the count is set by the shoot type's
4299 ``leaf.leaves_per_petiole``; use this to match a measured compound leaf with a
4300 different number of leaflets before calling :meth:`setPetioleLeafGeometry`.
4301
4302 Args:
4303 plant_id: Plant identifier.
4304 shoot_id: Shoot identifier.
4305 node_index: Index of the phytomer along the shoot.
4306 petiole_index: Index of the petiole on that phytomer.
4307 leaf_count: Number of leaves to place on the petiole. Must be at least 1.
4308
4309 Raises:
4310 ValueError: If any index is negative or ``leaf_count`` is less than 1.
4311 PlantArchitectureError: If the operation fails.
4312
4313 Note:
4314 Call this **before** :meth:`setPetioleLeafGeometry` for the same petiole, whose
4315 ``leaf_count`` must match the number of leaves on the petiole.
4316 """
4317 for name, v in (("Plant ID", plant_id), ("Shoot ID", shoot_id),
4318 ("Node index", node_index), ("Petiole index", petiole_index)):
4319 if v < 0:
4320 raise ValueError(f"{name} must be non-negative")
4321 if leaf_count < 1:
4322 raise ValueError(f"Leaf count must be at least 1, got {leaf_count}")
4323
4325 try:
4327 plantarch_wrapper.setPetioleLeafCount(
4328 self._plantarch_ptr, plant_id, shoot_id, node_index, petiole_index, leaf_count)
4329 except Exception as e:
4331 f"Failed to set petiole leaf count (plant {plant_id}, shoot {shoot_id}, "
4332 f"node {node_index}, petiole {petiole_index}): {e}")
4333
4334 def setShootInternodeLengthMax(self, plant_id: int, shoot_id: int,
4335 internode_length_max: float) -> None:
4336 """
4337 Set the target length of internodes grown at the apex of an existing shoot.
4338
4339 A shoot built by :meth:`addShootFromNodePositions` otherwise grows toward the mean
4340 of its prescribed internode lengths; use this to give newly grown internodes a
4341 different target length.
4342
4343 Args:
4344 plant_id: Plant identifier.
4345 shoot_id: Shoot identifier.
4346 internode_length_max: Target internode length in meters. Must be positive.
4347
4348 Raises:
4349 ValueError: If an identifier is negative or the length is not positive.
4350 PlantArchitectureError: If the operation fails.
4351
4352 Note:
4353 This value is **not** saved by :meth:`writePlantStructureXML`, so it must be
4354 set again after :meth:`readPlantStructureXML`.
4355 """
4356 if plant_id < 0 or shoot_id < 0:
4357 raise ValueError("Plant ID and shoot ID must be non-negative")
4358 if internode_length_max <= 0:
4359 raise ValueError(f"Internode length must be positive, got {internode_length_max}")
4360
4362 try:
4364 plantarch_wrapper.setShootInternodeLengthMax(
4365 self._plantarch_ptr, plant_id, shoot_id, internode_length_max)
4366 except Exception as e:
4368 f"Failed to set shoot internode length max (plant {plant_id}, "
4369 f"shoot {shoot_id}): {e}")
4370
4371 # Bud and apex control
4372 def terminateApicalBud(self, plant_id: int, shoot_id: int) -> None:
4373 """
4374 Stop a shoot's apex from adding any further phytomers.
4375
4376 The shoot keeps everything it already has, and its vegetative buds keep whatever
4377 state they are in -- this kills only the apical meristem. The shoot therefore stops
4378 extending at its tip but can still throw laterals; to stop those as well, pair this
4379 with :meth:`removeShootVegetativeBuds`.
4380
4381 This is the standard way to freeze the old wood of a reconstructed tree before
4382 growing it forward with :meth:`advanceTime`.
4383
4384 Args:
4385 plant_id: ID of the plant instance
4386 shoot_id: Shoot index within the plant (see :meth:`getAllShootIDs`)
4387
4388 Raises:
4389 ValueError: If either identifier is not a non-negative int
4390 PlantArchitectureError: If the plant or shoot does not exist
4391
4392 Example:
4393 >>> # Freeze the measured scaffold so only last year's growth extends
4394 >>> for shoot_id in plantarch.getTerminalShootIDs(plant_id):
4395 ... plantarch.terminateApicalBud(plant_id, shoot_id)
4396 """
4397 self._validateShootIdentifiers(plant_id, shoot_id)
4398
4400 try:
4401 plantarch_wrapper.terminateShootApicalBud(self._plantarch_ptr, plant_id, shoot_id)
4402 except Exception as e:
4404 f"Failed to terminate the apical bud of shoot {shoot_id} "
4405 f"of plant {plant_id}: {e}")
4406
4407 def getShootVegetativeBudCount(self, plant_id: int, shoot_id: int,
4408 state: Optional[BudState] = None) -> int:
4409 """
4410 Count a shoot's axillary vegetative buds, summed over all phytomers and petioles.
4411
4412 Buds are never removed from a shoot -- only their state changes -- so the
4413 unfiltered count is stable over the shoot's life and makes a useful denominator.
4414
4415 Args:
4416 plant_id: ID of the plant instance
4417 shoot_id: Shoot index within the plant (see :meth:`getAllShootIDs`)
4418 state: Count only buds in this :class:`~pyhelios.BudState`. ``None``
4419 (the default) counts buds in every state.
4420
4421 Returns:
4422 The number of matching vegetative buds.
4423
4424 Raises:
4425 ValueError: If an identifier is negative, or ``state`` is not a BudState
4426 PlantArchitectureError: If the plant or shoot does not exist
4427
4428 Note:
4429 ``BudState.DEAD`` means "will produce nothing further", which covers both buds
4430 that were killed and buds that have **already broken into a child shoot**. A
4431 dead-bud count is therefore not a count of killed buds. To test whether a shoot
4432 can still grow, count the live states instead -- for example the unfiltered
4433 total minus the dead count.
4434
4435 Example:
4436 >>> from pyhelios import BudState
4437 >>> total = plantarch.getShootVegetativeBudCount(plant_id, 0)
4438 >>> dead = plantarch.getShootVegetativeBudCount(plant_id, 0, BudState.DEAD)
4439 >>> print(f"{total - dead} buds can still break")
4440 """
4441 self._validateShootIdentifiers(plant_id, shoot_id)
4442 bud_state = -1 if state is None else int(self._validateBudState(state))
4443
4445 try:
4446 return plantarch_wrapper.getShootVegetativeBudCount(
4447 self._plantarch_ptr, plant_id, shoot_id, bud_state)
4448 except Exception as e:
4450 f"Failed to count the vegetative buds of shoot {shoot_id} "
4451 f"of plant {plant_id}: {e}")
4452
4453 def getPlantLeafCount(self, plant_id: int) -> int:
4454 """
4455 Get the number of leaf objects on a plant.
4456
4457 Counts compound leaf objects, not primitives -- a leaf built from many triangles
4458 counts once, and a compound leaf contributes one per leaflet. Equivalent to
4459 ``len(getPlantLeafObjectIDs(plant_id))`` without materializing the ID list.
4460
4461 Args:
4462 plant_id: ID of the plant instance
4463
4464 Returns:
4465 The number of leaf objects.
4466
4467 Raises:
4468 ValueError: If plant_id is not a non-negative int
4469 PlantArchitectureError: If the plant does not exist
4470
4471 Example:
4472 >>> print(f"{plantarch.getPlantLeafCount(plant_id)} leaves")
4473 """
4474 if isinstance(plant_id, bool) or not isinstance(plant_id, int):
4475 raise ValueError(f"Plant ID must be a non-negative int, got {type(plant_id).__name__}")
4476 if plant_id < 0:
4477 raise ValueError("Plant ID must be non-negative")
4478
4480 try:
4481 return plantarch_wrapper.getPlantLeafCount(self._plantarch_ptr, plant_id)
4482 except Exception as e:
4484 f"Failed to get the leaf count of plant {plant_id}: {e}")
4485
4486 @staticmethod
4487 def _validateShootIdentifiers(plant_id: int, shoot_id: int) -> None:
4488 """Reject non-int and negative plant/shoot identifiers.
4489
4490 bool is excluded explicitly: it is an int subclass, so True would otherwise pass
4491 as shoot 1.
4492 """
4493 for name, value in (("Plant ID", plant_id), ("Shoot ID", shoot_id)):
4494 if isinstance(value, bool) or not isinstance(value, int):
4495 raise ValueError(
4496 f"{name} must be a non-negative int, got {type(value).__name__}")
4497 if plant_id < 0 or shoot_id < 0:
4498 raise ValueError("Plant ID and shoot ID must be non-negative")
4499
4500 @staticmethod
4501 def _validateBudState(state) -> BudState:
4502 """Coerce a BudState (or its int value) and reject anything else.
4503
4504 A bare int is accepted because BudState is an IntEnum, but it still has to name a
4505 real state -- an out-of-range value would be cast onto the C++ enum, which is
4506 undefined behavior.
4507 """
4508 if isinstance(state, bool) or not isinstance(state, int):
4509 raise ValueError(f"State must be a BudState, got {type(state).__name__}")
4510 try:
4511 return BudState(int(state))
4512 except ValueError:
4513 raise ValueError(
4514 f"State must be a BudState value in 0..5, got {int(state)}")
4515
4516 def enableLeafAngleDistributionTracking(self, plant_ids, beta_mu_inclination: float,
4517 beta_nu_inclination: float, eccentricity: float,
4518 ellipse_rotation_degrees: float,
4519 lambda_degrees: float) -> None:
4520 """
4521 Steer leaf inclination and azimuth toward a prescribed distribution as the plant grows.
4522
4523 Each leaf is given a target angle as it emerges and turns onto it while it expands,
4524 so a fully grown leaf never moves again: the plant matches the distribution at every
4525 stage without leaves shifting from one timestep to the next. Targets are not drawn
4526 independently per leaf, so the arrangement the model generated is preserved: each emerging leaf takes the bin that best trades
4527 closeness to the angle the model gave it against how far that bin is below its share
4528 of the plant's leaf area.
4529
4530 Pass a list of plant IDs to realize the distribution over a canopy as a whole, in
4531 which case an individual plant need not follow the distribution on its own.
4532
4533 Enabling tracking on an already-tracked plant replaces its target, so the target may
4534 be varied over the plant's life.
4535
4536 Args:
4537 plant_ids: A single plant ID, or a sequence of plant IDs to steer together
4538 beta_mu_inclination: Mean parameter of the Beta inclination distribution
4539 beta_nu_inclination: Shape parameter of the Beta inclination distribution
4540 eccentricity: Eccentricity of the ellipse defining the azimuth distribution
4541 ellipse_rotation_degrees: Rotation of that ellipse (degrees)
4542 lambda_degrees: How strongly to favour filling the distribution over keeping each
4543 leaf near the angle the model gave it. Zero leaves the plant unchanged; values
4544 of order 180 match the distribution as closely as the growing plant allows.
4546 Raises:
4547 ValueError: If any plant ID is not a non-negative int, or the list is empty
4548 PlantArchitectureError: If a plant does not exist
4549 RuntimeError: If the native library predates helios-core v1.3.87
4550
4551 Example:
4552 >>> plantarch.enableLeafAngleDistributionTracking(
4553 ... plant_id, 2.0, 1.5, 0.5, 0.0, 180.0)
4554 >>> plantarch.advanceTime(plant_id, 20)
4555 """
4556 multi = not isinstance(plant_ids, int) or isinstance(plant_ids, bool)
4557 ids = self._validatePlantIdList(plant_ids) if multi else [
4558 self._validatePlantIdentifier(plant_ids)]
4559
4561 try:
4562 if multi:
4563 plantarch_wrapper.enablePlantLeafAngleDistributionTrackingMulti(
4564 self._plantarch_ptr, ids, beta_mu_inclination, beta_nu_inclination,
4565 eccentricity, ellipse_rotation_degrees, lambda_degrees)
4566 else:
4567 plantarch_wrapper.enablePlantLeafAngleDistributionTracking(
4568 self._plantarch_ptr, ids[0], beta_mu_inclination, beta_nu_inclination,
4569 eccentricity, ellipse_rotation_degrees, lambda_degrees)
4570 except Exception as e:
4572 f"Failed to enable leaf angle distribution tracking for {ids}: {e}")
4573
4574 def enableLeafElevationAngleDistributionTracking(self, plant_id: int,
4575 beta_mu_inclination: float,
4576 beta_nu_inclination: float,
4577 lambda_degrees: float) -> None:
4578 """
4579 Steer leaf inclination toward a Beta distribution as the plant grows, leaving azimuth
4580 to the procedural model.
4581
4582 The inclination-only counterpart of :meth:`enableLeafAngleDistributionTracking`.
4583
4584 Args:
4585 plant_id: ID of the plant instance
4586 beta_mu_inclination: Mean parameter of the Beta inclination distribution
4587 beta_nu_inclination: Shape parameter of the Beta inclination distribution
4588 lambda_degrees: How strongly to favour filling the distribution over keeping each
4589 leaf near the angle the model gave it
4591 Raises:
4592 ValueError: If ``plant_id`` is not a non-negative int
4593 PlantArchitectureError: If the plant does not exist
4594 RuntimeError: If the native library predates helios-core v1.3.87
4595 """
4596 plant_id = self._validatePlantIdentifier(plant_id)
4597
4599 try:
4600 plantarch_wrapper.enablePlantLeafElevationAngleDistributionTracking(
4601 self._plantarch_ptr, plant_id, beta_mu_inclination, beta_nu_inclination,
4602 lambda_degrees)
4603 except Exception as e:
4605 f"Failed to enable leaf elevation angle distribution tracking for "
4606 f"plant {plant_id}: {e}")
4607
4608 def enableLeafAzimuthAngleDistributionTracking(self, plant_id: int, eccentricity: float,
4609 ellipse_rotation_degrees: float,
4610 lambda_degrees: float) -> None:
4611 """
4612 Steer leaf azimuth toward an ellipsoidal distribution as the plant grows, leaving
4613 inclination to the procedural model.
4614
4615 The azimuth-only counterpart of :meth:`enableLeafAngleDistributionTracking`.
4616
4617 Args:
4618 plant_id: ID of the plant instance
4619 eccentricity: Eccentricity of the ellipse defining the azimuth distribution
4620 ellipse_rotation_degrees: Rotation of that ellipse (degrees)
4621 lambda_degrees: How strongly to favour filling the distribution over keeping each
4622 leaf near the angle the model gave it
4623
4624 Raises:
4625 ValueError: If ``plant_id`` is not a non-negative int
4626 PlantArchitectureError: If the plant does not exist
4627 RuntimeError: If the native library predates helios-core v1.3.87
4628 """
4629 plant_id = self._validatePlantIdentifier(plant_id)
4632 try:
4633 plantarch_wrapper.enablePlantLeafAzimuthAngleDistributionTracking(
4634 self._plantarch_ptr, plant_id, eccentricity, ellipse_rotation_degrees,
4635 lambda_degrees)
4636 except Exception as e:
4638 f"Failed to enable leaf azimuth angle distribution tracking for "
4639 f"plant {plant_id}: {e}")
4640
4641 def disableLeafAngleDistributionTracking(self, plant_id: int) -> None:
4642 """
4643 Stop steering a plant's leaf angles toward a prescribed distribution.
4644
4645 Leaves already steered keep the orientation they have reached; leaves emerging
4646 afterward are left where the procedural model puts them.
4647
4648 Args:
4649 plant_id: ID of the plant instance
4650
4651 Raises:
4652 ValueError: If ``plant_id`` is not a non-negative int
4653 PlantArchitectureError: If the plant does not exist
4654 RuntimeError: If the native library predates helios-core v1.3.87
4655 """
4656 plant_id = self._validatePlantIdentifier(plant_id)
4657
4659 try:
4660 plantarch_wrapper.disablePlantLeafAngleDistributionTracking(
4661 self._plantarch_ptr, plant_id)
4662 except Exception as e:
4664 f"Failed to disable leaf angle distribution tracking for "
4665 f"plant {plant_id}: {e}")
4666
4667 def isLeafAngleDistributionTrackingEnabled(self, plant_id: int) -> bool:
4668 """
4669 Whether a plant's leaf angles are being steered toward a prescribed distribution.
4670
4671 Args:
4672 plant_id: ID of the plant instance
4673
4674 Returns:
4675 True if tracking is in effect for this plant
4676
4677 Raises:
4678 ValueError: If ``plant_id`` is not a non-negative int
4679 PlantArchitectureError: If the plant does not exist
4680 RuntimeError: If the native library predates helios-core v1.3.87
4681 """
4682 plant_id = self._validatePlantIdentifier(plant_id)
4683
4685 try:
4686 return plantarch_wrapper.isPlantLeafAngleDistributionTrackingEnabled(
4687 self._plantarch_ptr, plant_id)
4688 except Exception as e:
4690 f"Failed to query leaf angle distribution tracking for "
4691 f"plant {plant_id}: {e}")
4692
4693 def getPetioleLength(self, plant_id: int, shoot_id: int, node_index: int,
4694 petiole_index: Optional[int] = None) -> float:
4695 """
4696 Current length of a phytomer's petioles, measured along the centerline.
4697
4698 This is the length right now, not the mature length the petiole is growing toward,
4699 so it rises as the petiole elongates. Contrast the leaf readers, which report the
4700 size a leaf is expanding toward. The length is an arclength rather than a
4701 base-to-tip distance, so a petiole drooping under its leaves reports the same
4702 length as a rigid one of the same age.
4703
4704 With ``petiole_index`` omitted, returns the mean over every petiole on the
4705 phytomer. Petioles at one node are parallel structures rather than segments in
4706 series, so their lengths are not additive and the mean is the meaningful summary.
4707 A phytomer with no petiole -- a leafless woody type, or one whose leaf has been
4708 shed -- reports 0.0.
4709
4710 Args:
4711 plant_id: ID of the plant instance
4712 shoot_id: Shoot index within the plant
4713 node_index: Phytomer index within the shoot
4714 petiole_index: Petiole within the phytomer; ``None`` for the phytomer mean
4715
4716 Returns:
4717 Current petiole arclength in meters
4718
4719 Raises:
4720 ValueError: If any identifier is not a non-negative int
4721 PlantArchitectureError: If the plant, shoot, node or petiole does not exist
4722 RuntimeError: If the native library predates helios-core v1.3.87
4723 """
4724 self._validateShootIdentifiers(plant_id, shoot_id)
4725 node_index = self._validateNodeIndex(node_index)
4726 if petiole_index is not None:
4727 petiole_index = self._validatePetioleIndex(petiole_index)
4728
4730 try:
4731 return plantarch_wrapper.getPetioleLength(
4732 self._plantarch_ptr, plant_id, shoot_id, node_index, petiole_index)
4733 except Exception as e:
4735 f"Failed to get the petiole length of node {node_index} of shoot "
4736 f"{shoot_id} of plant {plant_id}: {e}")
4737
4738 def scalePetioleMaxLength(self, plant_id: int, shoot_id: int, node_index: int,
4739 scale_factor: float) -> None:
4740 """
4741 Scale the fully-elongated length every petiole on a phytomer is growing toward.
4742
4743 The petiole counterpart of internode max-length scaling. The petiole's present
4744 length is left where it is and only its target changes, so a phytomer creation
4745 function can give a leaf born on a young plant a shorter final petiole without
4746 moving the petiole that is already there.
4747
4748 Args:
4749 plant_id: ID of the plant instance
4750 shoot_id: Shoot index within the plant
4751 node_index: Phytomer index within the shoot
4752 scale_factor: Factor to scale the fully-elongated length by; must be positive
4753
4754 Raises:
4755 ValueError: If an identifier is invalid or ``scale_factor`` is not positive
4756 PlantArchitectureError: If the plant, shoot or node does not exist
4757 RuntimeError: If the native library predates helios-core v1.3.87
4758 """
4759 self._validateShootIdentifiers(plant_id, shoot_id)
4760 node_index = self._validateNodeIndex(node_index)
4761 scale_factor = self._validateScaleFactor(scale_factor)
4762
4764 try:
4765 plantarch_wrapper.scalePetioleMaxLength(
4766 self._plantarch_ptr, plant_id, shoot_id, node_index, scale_factor)
4767 except Exception as e:
4769 f"Failed to scale the petiole max length of node {node_index} of shoot "
4770 f"{shoot_id} of plant {plant_id}: {e}")
4771
4772 def setPetioleScaleFraction(self, plant_id: int, shoot_id: int, node_index: int,
4773 petiole_index: int,
4774 petiole_scale_factor_fraction: float) -> None:
4775 """
4776 Set one petiole's current length as a fraction of its fully-elongated length,
4777 leaving the leaves it carries at the size they are.
4778
4779 A petiole is a stem segment rather than part of the blade and goes on extending
4780 after the blade has finished expanding, which is why its growth is driven by the
4781 shoot's internode rate rather than the leaf expansion rate. The leaves ride out
4782 along the petiole as it lengthens without changing size.
4783
4784 Args:
4785 plant_id: ID of the plant instance
4786 shoot_id: Shoot index within the plant
4787 node_index: Phytomer index within the shoot
4788 petiole_index: Petiole within the phytomer
4789 petiole_scale_factor_fraction: Fraction of the fully-elongated length
4790 (1.0 for a fully-elongated petiole)
4791
4792 Raises:
4793 ValueError: If any identifier is not a non-negative int
4794 PlantArchitectureError: If the plant, shoot, node or petiole does not exist
4795 RuntimeError: If the native library predates helios-core v1.3.87
4796 """
4797 self._validateShootIdentifiers(plant_id, shoot_id)
4798 node_index = self._validateNodeIndex(node_index)
4799 petiole_index = self._validatePetioleIndex(petiole_index)
4800
4802 try:
4803 plantarch_wrapper.setPetioleScaleFraction(
4804 self._plantarch_ptr, plant_id, shoot_id, node_index, petiole_index,
4805 petiole_scale_factor_fraction)
4806 except Exception as e:
4808 f"Failed to set the petiole scale fraction of node {node_index} of shoot "
4809 f"{shoot_id} of plant {plant_id}: {e}")
4810
4811 def setPetioleAndLeafScaleFraction(self, plant_id: int, shoot_id: int, node_index: int,
4812 petiole_index: int,
4813 petiole_scale_factor_fraction: float,
4814 leaf_scale_factor_fraction: float) -> None:
4815 """
4816 Set a petiole's length and its leaves' size together, each as a fraction of its own
4817 fully-elongated value.
4818
4819 The two fractions are applied in one pass, so the leaves are scaled, re-seated
4820 along the rescaled petiole and bent under their new weight once rather than twice.
4821 Use this rather than the two single-fraction calls when advancing both.
4822
4823 Args:
4824 plant_id: ID of the plant instance
4825 shoot_id: Shoot index within the plant
4826 node_index: Phytomer index within the shoot
4827 petiole_index: Petiole within the phytomer
4828 petiole_scale_factor_fraction: Fraction of the fully-elongated petiole length
4829 leaf_scale_factor_fraction: Fraction of the fully-elongated leaf scale factor
4830
4831 Raises:
4832 ValueError: If any identifier is not a non-negative int
4833 PlantArchitectureError: If the plant, shoot, node or petiole does not exist
4834 RuntimeError: If the native library predates helios-core v1.3.87
4835 """
4836 self._validateShootIdentifiers(plant_id, shoot_id)
4837 node_index = self._validateNodeIndex(node_index)
4838 petiole_index = self._validatePetioleIndex(petiole_index)
4839
4841 try:
4842 plantarch_wrapper.setPetioleAndLeafScaleFraction(
4843 self._plantarch_ptr, plant_id, shoot_id, node_index, petiole_index,
4844 petiole_scale_factor_fraction, leaf_scale_factor_fraction)
4845 except Exception as e:
4847 f"Failed to set the petiole and leaf scale fractions of node {node_index} "
4848 f"of shoot {shoot_id} of plant {plant_id}: {e}")
4849
4850 def scaleLeafSizeMax(self, plant_id: int, shoot_id: int, node_index: int,
4851 scale_factor: float) -> None:
4852 """
4853 Scale the size every leaf on a phytomer is expanding toward, leaving the blades
4854 where they are.
4855
4856 The blade's present size is untouched and only its target changes, so the expansion
4857 fraction moves the other way: a fully-expanded leaf given a larger target becomes a
4858 partly-expanded leaf of the same size and goes on growing on the next
4859 :meth:`advanceTime`. This is what hands a leaf built from measured geometry back to
4860 the growth model still the size it was measured.
4861
4862 A factor small enough to put the target below the leaf's present size is the one
4863 case in which the blade does move: the leaf is taken down to the new target, and the
4864 leaflets of a compound leaf are then re-seated along the petiole, discarding a
4865 placement prescribed by :meth:`setPetioleLeafGeometry`.
4866
4867 Args:
4868 plant_id: ID of the plant instance
4869 shoot_id: Shoot index within the plant
4870 node_index: Phytomer index within the shoot
4871 scale_factor: Factor to scale the mature leaf size by; must be positive
4872
4873 Raises:
4874 ValueError: If an identifier is invalid or ``scale_factor`` is not positive
4875 PlantArchitectureError: If the plant, shoot or node does not exist
4876 RuntimeError: If the native library predates helios-core v1.3.87
4877 """
4878 self._validateShootIdentifiers(plant_id, shoot_id)
4879 node_index = self._validateNodeIndex(node_index)
4880 scale_factor = self._validateScaleFactor(scale_factor)
4881
4883 try:
4884 plantarch_wrapper.scaleLeafSizeMax(
4885 self._plantarch_ptr, plant_id, shoot_id, node_index, scale_factor)
4886 except Exception as e:
4888 f"Failed to scale the max leaf size of node {node_index} of shoot "
4889 f"{shoot_id} of plant {plant_id}: {e}")
4890
4891 def setLeafNormal(self, plant_id: int, shoot_id: int, node_index: int,
4892 petiole_index: int, leaf_index: int, target_normal: vec3) -> None:
4893 """
4894 Re-aim one leaf so its blade faces a given direction.
4895
4896 The roll and pitch that carry the blade onto ``target_normal`` are applied as a
4897 single rotation about the leaf's own base, so the leaf stays attached to its petiole
4898 and keeps the azimuth of the petiole it hangs from. The angles are recorded on the
4899 phytomer, which is what makes the new orientation survive a
4900 :meth:`writePlantStructureXML` / :meth:`readPlantStructureXML` round trip --
4901 rotating the leaf object directly through the Context changes the geometry without
4902 changing the record and is silently lost on reload.
4903
4904 Args:
4905 plant_id: ID of the plant instance
4906 shoot_id: Shoot index within the plant
4907 node_index: Phytomer index within the shoot
4908 petiole_index: Petiole within the phytomer
4909 leaf_index: Leaf within the petiole
4910 target_normal: Direction the blade should face, in world coordinates. Need not
4911 be normalized.
4912
4913 Raises:
4914 ValueError: If an identifier is invalid, or ``target_normal`` is not a vec3
4915 PlantArchitectureError: If the leaf has no geometry, the blade's facet normals
4916 cancel, or the target cannot be reached by a roll-pitch pair
4917 RuntimeError: If the native library predates helios-core v1.3.87
4918
4919 Example:
4920 >>> from pyhelios.types import vec3
4921 >>> plantarch.setLeafNormal(plant_id, 0, 3, 0, 0, vec3(0, 0, 1))
4922 """
4923 self._validateShootIdentifiers(plant_id, shoot_id)
4924 node_index = self._validateNodeIndex(node_index)
4925 petiole_index = self._validatePetioleIndex(petiole_index)
4926 leaf_index = self._validatePetioleIndex(leaf_index, name="Leaf index")
4927 if not isinstance(target_normal, vec3):
4928 raise ValueError(
4929 f"Target normal must be a vec3, got {type(target_normal).__name__}")
4930
4932 try:
4933 plantarch_wrapper.setLeafNormal(
4934 self._plantarch_ptr, plant_id, shoot_id, node_index, petiole_index,
4935 leaf_index, target_normal.x, target_normal.y, target_normal.z)
4936 except Exception as e:
4938 f"Failed to set the normal of leaf {leaf_index} on petiole "
4939 f"{petiole_index} of node {node_index} of shoot {shoot_id} "
4940 f"of plant {plant_id}: {e}")
4941
4942 def bendPetioleUnderLeafWeight(self, plant_id: int, shoot_id: int, node_index: int,
4943 petiole_index: int, include_posed_leaves: bool = False) -> None:
4944 """
4945 Bend one petiole, and the leaves it carries, under the weight of its leaflets.
4946
4947 The petiole is bent as a tapered cantilever clamped at its insertion, for the leaf's
4948 current size and the petiole's age. The bent shape is always computed from the
4949 recorded undeformed rest shape rather than the current shape, so repeated calls do
4950 not accumulate and creep the petiole downward. The insertion stays clamped, so the
4951 petiole keeps leaving the stem at its generated pitch and the droop appears beyond it
4952 as curvature along the length.
4953
4954 This is normally driven by the growth model from
4955 ``PhytomerParameters.petiole.flexibility``; call it directly only to re-bend a petiole
4956 after changing its geometry or its flexibility (:meth:`setPetioleFlexibility`) yourself.
4957 It does nothing for a rigid petiole (flexibility left at zero), a petiole whose
4958 centerline was prescribed, one carrying a prescribed leaf, or when neither the load nor
4959 the compliance has changed since the last call.
4960
4961 A leaf counts as prescribed once it has been posed by :meth:`setLeafNormal`,
4962 :meth:`setLeafAngleDistribution` or :meth:`setPetioleLeafGeometry`, so after any of
4963 those its petiole no longer bends. Pass ``include_posed_leaves=True`` to bend it anyway:
4964 the posed leaves are then carried along the petiole and turned with it, which moves them
4965 away from the orientation they were given.
4966
4967 Args:
4968 plant_id: ID of the plant instance
4969 shoot_id: Shoot index within the plant
4970 node_index: Phytomer index within the shoot
4971 petiole_index: Petiole within the phytomer
4972 include_posed_leaves: Also bend a petiole that carries posed leaves
4973
4974 Raises:
4975 ValueError: If any identifier is not a non-negative int, or
4976 ``include_posed_leaves`` is not a bool
4977 PlantArchitectureError: If the plant, shoot, node or petiole does not exist
4978 RuntimeError: If the native library predates helios-core v1.3.87
4979 """
4980 self._validateShootIdentifiers(plant_id, shoot_id)
4981 node_index = self._validateNodeIndex(node_index)
4982 petiole_index = self._validatePetioleIndex(petiole_index)
4983
4984 if not isinstance(include_posed_leaves, bool):
4985 raise ValueError(
4986 f"include_posed_leaves must be a bool, got {type(include_posed_leaves).__name__}")
4987
4989 try:
4990 bend = (plantarch_wrapper.bendPetioleWithPosedLeavesUnderLeafWeight if include_posed_leaves
4991 else plantarch_wrapper.bendPetioleUnderLeafWeight)
4992 bend(self._plantarch_ptr, plant_id, shoot_id, node_index, petiole_index)
4993 except Exception as e:
4995 f"Failed to bend petiole {petiole_index} of node {node_index} of shoot "
4996 f"{shoot_id} of plant {plant_id}: {e}")
4997
4998 def recordPetioleRestShape(self, plant_id: int, shoot_id: int, node_index: int,
4999 petiole_index: int) -> None:
5000 """
5001 Record one petiole's current centerline as its undeformed rest shape.
5002
5003 :meth:`bendPetioleUnderLeafWeight` always bends from the recorded rest shape, so a
5004 petiole whose centerline has been replaced wholesale -- by
5005 :meth:`setPetioleNodePositions`, for instance -- must have its new shape recorded
5006 before it will droop from it. This also marks the petiole as needing to be bent
5007 again, so the next bend is not skipped as redundant.
5008
5009 Args:
5010 plant_id: ID of the plant instance
5011 shoot_id: Shoot index within the plant
5012 node_index: Phytomer index within the shoot
5013 petiole_index: Petiole within the phytomer
5014
5015 Raises:
5016 ValueError: If any identifier is not a non-negative int
5017 PlantArchitectureError: If the plant, shoot, node or petiole does not exist
5018 RuntimeError: If the native library predates helios-core v1.3.87
5019 """
5020 self._validateShootIdentifiers(plant_id, shoot_id)
5021 node_index = self._validateNodeIndex(node_index)
5022 petiole_index = self._validatePetioleIndex(petiole_index)
5023
5025 try:
5026 plantarch_wrapper.recordPetioleRestShape(
5027 self._plantarch_ptr, plant_id, shoot_id, node_index, petiole_index)
5028 except Exception as e:
5030 f"Failed to record the rest shape of petiole {petiole_index} of node "
5031 f"{node_index} of shoot {shoot_id} of plant {plant_id}: {e}")
5032
5033 def setLeafAngleDistribution(self, plant_ids, beta_mu_inclination: float,
5034 beta_nu_inclination: float, eccentricity: Optional[float] = None,
5035 ellipse_rotation_degrees: float = 0.0) -> None:
5036 """
5037 Re-aim every leaf of a finished plant so that its leaf angles follow a prescribed distribution.
5038
5039 Inclination is made to follow a Beta distribution. If ``eccentricity`` is given, azimuth
5040 is also made to follow an ellipsoidal distribution; otherwise each leaf keeps its azimuth.
5041 Angles are not sampled per leaf: leaves are ordered by their current angle, weighted by leaf
5042 area, and each takes the angle at its own position in the target distribution. The leaf
5043 arrangement of the plant is therefore kept, a leaf held steeper than its neighbour stays
5044 steeper, and the method can be called repeatedly with changing parameters (for example
5045 once per timestep) to move the canopy smoothly between distributions.
5046
5047 This moves leaves that already exist. To steer leaves as they emerge on a growing plant,
5048 use :meth:`enableLeafAngleDistributionTracking`.
5049
5050 Each leaf is re-aimed about its own base, so it stays attached to its petiole, and the
5051 angles are recorded on the phytomer so they survive an XML round trip.
5052
5053 Args:
5054 plant_ids: A single plant ID, or a sequence of plant IDs over which the distribution
5055 is realized as a whole
5056 beta_mu_inclination: First parameter of the Beta inclination distribution; must be
5057 positive
5058 beta_nu_inclination: Second parameter of the Beta inclination distribution; must be
5059 positive. The mean leaf inclination from horizontal is 90 * nu / (mu + nu)
5060 degrees, so raising nu relative to mu makes the leaves steeper.
5061 eccentricity: Eccentricity of the ellipse defining the azimuth distribution, in
5062 [0, 1], where 0 is a uniform azimuth distribution. ``None`` (the default) leaves
5063 leaf azimuths as they are.
5064 ellipse_rotation_degrees: Rotation of that ellipse (degrees); used only when
5065 ``eccentricity`` is given
5066
5067 Raises:
5068 ValueError: If a plant ID is not a non-negative int, the list is empty, a Beta
5069 parameter is not positive, or eccentricity is outside [0, 1]
5070 PlantArchitectureError: If a plant does not exist
5071 RuntimeError: If the native library predates this binding
5072
5073 Example:
5074 >>> plantarch.setLeafAngleDistribution(plant_id, 2.0, 1.5)
5075 """
5076 multi = not isinstance(plant_ids, int) or isinstance(plant_ids, bool)
5077 ids = self._validatePlantIdList(plant_ids) if multi else [
5078 self._validatePlantIdentifier(plant_ids)]
5079 values = {}
5080 for name, value in (("beta_mu_inclination", beta_mu_inclination),
5081 ("beta_nu_inclination", beta_nu_inclination),
5082 ("eccentricity", 0.0 if eccentricity is None else eccentricity),
5083 ("ellipse_rotation_degrees", ellipse_rotation_degrees)):
5084 if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
5085 raise ValueError(f"{name} must be a finite number, got {value!r}")
5086 values[name] = float(value)
5087 if values["beta_mu_inclination"] <= 0 or values["beta_nu_inclination"] <= 0:
5088 raise ValueError("beta_mu_inclination and beta_nu_inclination must be positive")
5089 if not 0.0 <= values["eccentricity"] <= 1.0:
5090 raise ValueError(f"eccentricity must be between 0 and 1, got {eccentricity}")
5091
5093 try:
5094 plantarch_wrapper.setPlantLeafAngleDistribution(
5095 self._plantarch_ptr, ids, values["beta_mu_inclination"],
5096 values["beta_nu_inclination"],
5097 None if eccentricity is None else values["eccentricity"],
5098 values["ellipse_rotation_degrees"])
5099 except Exception as e:
5101 f"Failed to set the leaf angle distribution of {ids}: {e}")
5102
5103 def getPetioleFlexibility(self, plant_id: int, shoot_id: int, node_index: int) -> float:
5104 """
5105 Bending flexibility of the petioles of one phytomer.
5106
5107 The value is drawn from ``PhytomerParameters.petiole.flexibility`` when the phytomer is
5108 created and held for its life. 0 is a rigid petiole.
5109
5110 Args:
5111 plant_id: ID of the plant instance
5112 shoot_id: Shoot index within the plant
5113 node_index: Phytomer index within the shoot
5114
5115 Returns:
5116 Dimensionless petiole flexibility
5117
5118 Raises:
5119 ValueError: If any identifier is not a non-negative int
5120 PlantArchitectureError: If the plant, shoot or node does not exist
5121 RuntimeError: If the native library predates this binding
5122 """
5123 self._validateShootIdentifiers(plant_id, shoot_id)
5124 node_index = self._validateNodeIndex(node_index)
5125
5127 try:
5128 return plantarch_wrapper.getPetioleFlexibility(
5129 self._plantarch_ptr, plant_id, shoot_id, node_index)
5130 except Exception as e:
5132 f"Failed to get the petiole flexibility of node {node_index} of shoot "
5133 f"{shoot_id} of plant {plant_id}: {e}")
5134
5135 def setPetioleFlexibility(self, plant_id: int, shoot_id: int, node_index: int,
5136 flexibility: float) -> None:
5137 """
5138 Replace the bending flexibility of the petioles of one existing phytomer.
5139
5140 This changes how far the petioles droop under their leaflets' weight without rebuilding
5141 the plant. The geometry does not move until :meth:`bendPetioleUnderLeafWeight` is called
5142 for each petiole (or the plant next grows), which bends from the recorded rest shape, so
5143 raising and lowering the flexibility is reversible. If the leaves have been posed, for
5144 example by :meth:`setLeafAngleDistribution`, bend with ``include_posed_leaves=True``.
5145
5146 Note:
5147 :meth:`bendPetioleUnderLeafWeight` does nothing at a flexibility of exactly zero, so
5148 a petiole that has been bent is returned to its rest shape with a small positive
5149 value rather than zero.
5150
5151 Args:
5152 plant_id: ID of the plant instance
5153 shoot_id: Shoot index within the plant
5154 node_index: Phytomer index within the shoot
5155 flexibility: Dimensionless petiole flexibility; must be finite and non-negative
5156
5157 Raises:
5158 ValueError: If an identifier is invalid or ``flexibility`` is not a finite
5159 non-negative number
5160 PlantArchitectureError: If the plant, shoot or node does not exist
5161 RuntimeError: If the native library predates this binding
5162
5163 Example:
5164 >>> plantarch.setPetioleFlexibility(plant_id, 0, 3, 2.0)
5165 >>> plantarch.bendPetioleUnderLeafWeight(plant_id, 0, 3, 0)
5166 """
5167 self._validateShootIdentifiers(plant_id, shoot_id)
5168 node_index = self._validateNodeIndex(node_index)
5169 if (isinstance(flexibility, bool) or not isinstance(flexibility, (int, float))
5170 or not math.isfinite(flexibility) or flexibility < 0):
5171 raise ValueError(
5172 f"Flexibility must be a finite non-negative number, got {flexibility!r}")
5173
5175 try:
5176 plantarch_wrapper.setPetioleFlexibility(
5177 self._plantarch_ptr, plant_id, shoot_id, node_index, float(flexibility))
5178 except Exception as e:
5180 f"Failed to set the petiole flexibility of node {node_index} of shoot "
5181 f"{shoot_id} of plant {plant_id}: {e}")
5182
5183 # ------------------------------------------------------------------
5184 # Phytomer creation function (helios-core 1.3.88)
5185 # ------------------------------------------------------------------
5186
5187 def setPhytomerCreationFunction(self, shoot_type_label: str,
5188 callback: Optional[Callable[[int, int, int, int, int, int, float], None]]) -> None:
5189 """
5190 Install a Python function that the plugin calls once for every new phytomer of a shoot type.
5191
5192 This is the Python counterpart of assigning
5193 ``phytomer_parameters.phytomer_creation_function`` in C++. It is how a model varies a
5194 phytomer's growth targets with its position on the plant -- for example scaling leaf and
5195 petiole size with node rank -- using the per-phytomer methods such as
5196 :meth:`scaleLeafPrototypeScale`, :meth:`scalePetioleMaxLength` and
5197 :meth:`scaleInternodeMaxLength`.
5198
5199 The callback is called as::
5200
5201 callback(plant_id, shoot_id, node_index, shoot_node_index,
5202 parent_shoot_node_index, shoot_max_nodes, plant_age)
5204 after the plugin has attached the new phytomer to its shoot, so
5205 ``(plant_id, shoot_id, node_index)`` addresses it through any per-phytomer method.
5206 ``node_index`` is the phytomer's index on its shoot. ``shoot_node_index`` is the value the
5207 plugin passes to a C++ creation function, kept for porting C++ models: during growth it
5208 equals ``node_index``, but while a shoot's initial phytomers are built by
5209 :meth:`addBaseStemShoot`, :meth:`appendShoot` or :meth:`addChildShoot` it is the shoot's
5210 initial node count (a one-node base stem passes 1 for phytomer 0).
5211 ``parent_shoot_node_index`` is the node of the parent shoot this shoot grows from,
5212 ``shoot_max_nodes`` the shoot's node limit and ``plant_age`` the plant's age in days.
5213 The return value is ignored.
5214
5215 The callback fires for phytomers created while building shoots as well as during
5216 :meth:`advanceTime`. If it raises, the exception propagates out of the call that created
5217 the phytomer (``advanceTime``, ``addBaseStemShoot``, ...) with its original type, and no
5218 further callbacks run during that call; the plant is left as it was when the exception was
5219 raised, partly grown. Changes the callback makes to the length of an internode prescribed
5220 by :meth:`addShootFromNodePositions` are undone by the plugin.
5221
5222 Which shoots are affected:
5223
5224 - The function is stored on the shoot type, and each shoot copies its type's parameters
5225 when it is created, so shoots created before this call keep whatever function they had.
5226 - Shoots created while a Python callback is installed look it up by label each time, so
5227 replacing the callback or clearing it with ``None`` also takes effect for them.
5228 - ``None`` restores the function the shoot type had before a Python callback was first
5229 installed on it, usually the library one (for example the library ``tomato`` model's
5230 ``mainstem`` function, which rescales every new phytomer's leaves and internode by plant
5231 age). To build without a library function, define a new shoot type from the parameters
5232 (see below), which carries none.
5233 - Redefining an existing label with :meth:`defineShootType` keeps its creation function.
5234 A new label defined from another type's parameters (for example
5235 ``defineShootType("my_stem", getCurrentShootParameters("mainstem"))``) starts with
5236 none, because the parameter dict carries no functions: it inherits neither a Python
5237 callback nor a library function.
5238
5239 Args:
5240 shoot_type_label: An existing shoot type label
5241 callback: A callable taking the seven arguments above; the name of a library creation
5242 function (see :meth:`getLibraryPhytomerCreationFunctionNames`), which installs that
5243 native function in place of whatever the type had; or ``None`` to restore the
5244 function the type had before a Python callback was installed
5245
5246 Raises:
5247 ValueError: If the label is not a non-empty str, the callback is neither callable, a str
5248 nor None, or the name is empty
5249 PlantArchitectureError: If the shoot type does not exist or the name is not a library
5250 creation function
5251 RuntimeError: If the native library predates helios-core v1.3.88
5252 """
5253 if not isinstance(shoot_type_label, str) or not shoot_type_label:
5254 raise ValueError(
5255 f"Shoot type label must be a non-empty str, got {type(shoot_type_label).__name__}")
5256 if isinstance(callback, str):
5257 self._setLibraryPhytomerFunction(shoot_type_label, callback, creation=True)
5258 return
5259 if callback is not None and not callable(callback):
5260 raise ValueError(
5261 f"Phytomer creation callback must be callable, a library function name or None, "
5262 f"got {type(callback).__name__}")
5263
5264 native_callback = None if callback is None else self._makePhytomerCreationTrampoline(callback)
5266 try:
5267 plantarch_wrapper.setPhytomerCreationFunction(self._plantarch_ptr, shoot_type_label, native_callback)
5268 except Exception as e:
5270 f"Failed to set the phytomer creation function of shoot type '{shoot_type_label}': {e}")
5271
5272 # The native registry now holds native_callback (or nothing), so this is the reference that must stay alive.
5273 callbacks = self.__dict__.setdefault('_phytomer_creation_callbacks', {})
5274 if native_callback is None:
5275 callbacks.pop(shoot_type_label, None)
5276 else:
5277 callbacks[shoot_type_label] = native_callback
5278
5279 def setPhytomerCallbackFunction(self, shoot_type_label: str,
5280 callback: Optional[Callable[[int, int, int, float], None]]) -> None:
5281 """
5282 Install a Python function that the plugin calls for every phytomer of a shoot type on every time step.
5283
5284 This is the Python counterpart of assigning
5285 ``phytomer_parameters.phytomer_callback_function`` in C++. Use it for per-phytomer logic that must
5286 run as the plant develops rather than once at creation (for that, see
5287 :meth:`setPhytomerCreationFunction`), for example adjusting growth targets with phytomer age.
5288
5289 The callback is called as::
5290
5291 callback(plant_id, shoot_id, node_index, phytomer_age)
5292
5293 once per phytomer per :meth:`advanceTime` sub-step, after the phytomer's age has been advanced, so
5294 ``(plant_id, shoot_id, node_index)`` addresses it through any per-phytomer method and
5295 ``phytomer_age`` (days) equals :meth:`getPhytomerAge` at that moment. ``advanceTime`` divides its
5296 interval into sub-steps of at most the shortest phyllochron (and at most 1 day for intervals shorter
5297 than that), so the number of calls per phytomer is the number of sub-steps, not the number of days. It
5298 also runs inside :meth:`buildPlantInstanceFromLibrary` and :meth:`buildPlantCanopyFromLibrary` when
5299 they grow a plant to its starting age. The return value is ignored.
5300
5301 Performance: the cost scales with (phytomers x sub-steps), so keep the callback's own work light,
5302 especially for large populations that change slowly, such as a dormant tree over many days.
5303
5304 If the callback raises, the exception propagates out of the call that advanced time with its original
5305 type, and no further callbacks run during that call; the plant is left partly advanced.
5306
5307 Which phytomers are affected:
5308
5309 - The function is copied into each phytomer when the phytomer is created, so phytomers created
5310 before this call do not call a newly installed function. Install it before building the plant.
5311 - Phytomers created while a Python callback is installed look it up by label on each call, so
5312 replacing the callback or clearing it with ``None`` also takes effect for them.
5313 - ``None`` restores the function the shoot type had before a Python callback was first
5314 installed on it, usually the library one. Clearing a callback that was never installed
5315 changes nothing.
5316 - As with :meth:`setPhytomerCreationFunction`, redefining an existing label with
5317 :meth:`defineShootType` keeps the function, but a new label defined from another type's parameter
5318 dict starts with none.
5319
5320 Args:
5321 shoot_type_label: An existing shoot type label
5322 callback: A callable taking the four arguments above; the name of a library callback
5323 function (see :meth:`getLibraryPhytomerCallbackFunctionNames`), which installs that
5324 native function in place of whatever the type had; or ``None`` to restore the
5325 function the type had before a Python callback was installed
5326
5327 Raises:
5328 ValueError: If the label is not a non-empty str, the callback is neither callable, a str
5329 nor None, or the name is empty
5330 PlantArchitectureError: If the shoot type does not exist or the name is not a library
5331 callback function
5332 RuntimeError: If the native library predates this function
5333 """
5334 if not isinstance(shoot_type_label, str) or not shoot_type_label:
5335 raise ValueError(
5336 f"Shoot type label must be a non-empty str, got {type(shoot_type_label).__name__}")
5337 if isinstance(callback, str):
5338 self._setLibraryPhytomerFunction(shoot_type_label, callback, creation=False)
5339 return
5340 if callback is not None and not callable(callback):
5341 raise ValueError(
5342 f"Phytomer callback function must be callable, a library function name or None, "
5343 f"got {type(callback).__name__}")
5344
5345 native_callback = None if callback is None else self._makePhytomerCallbackTrampoline(callback)
5347 try:
5348 plantarch_wrapper.setPhytomerCallbackFunction(self._plantarch_ptr, shoot_type_label, native_callback)
5349 except Exception as e:
5351 f"Failed to set the phytomer callback function of shoot type '{shoot_type_label}': {e}")
5352
5353 # The native registry now holds native_callback (or nothing), so this is the reference that must stay alive.
5354 callbacks = self.__dict__.setdefault('_phytomer_callbacks', {})
5355 if native_callback is None:
5356 callbacks.pop(shoot_type_label, None)
5357 else:
5358 callbacks[shoot_type_label] = native_callback
5359
5360 def _setLibraryPhytomerFunction(self, shoot_type_label: str, function_name: str, creation: bool) -> None:
5361 kind = "creation" if creation else "callback"
5362 if not function_name:
5363 raise ValueError(f"Library phytomer {kind} function name cannot be empty")
5365 setter = (plantarch_wrapper.setPhytomerCreationFunctionByName if creation
5366 else plantarch_wrapper.setPhytomerCallbackFunctionByName)
5367 try:
5368 setter(self._plantarch_ptr, shoot_type_label, function_name)
5369 except RuntimeError:
5370 raise
5371 except Exception as e:
5373 f"Failed to set the phytomer {kind} function of shoot type '{shoot_type_label}' "
5374 f"to library function '{function_name}': {e}")
5375 # The library function replaced any Python callback, which no longer needs to be kept alive.
5376 registry = '_phytomer_creation_callbacks' if creation else '_phytomer_callbacks'
5377 self.__dict__.setdefault(registry, {}).pop(shoot_type_label, None)
5378
5379 def _getLibraryPhytomerFunctionName(self, shoot_type_label: str, creation: bool) -> Optional[str]:
5380 if not isinstance(shoot_type_label, str) or not shoot_type_label:
5381 raise ValueError(
5382 f"Shoot type label must be a non-empty str, got {type(shoot_type_label).__name__}")
5384 getter = (plantarch_wrapper.getPhytomerCreationFunctionName if creation
5385 else plantarch_wrapper.getPhytomerCallbackFunctionName)
5386 try:
5387 return getter(self._plantarch_ptr, shoot_type_label) or None
5388 except RuntimeError:
5389 raise
5390 except Exception as e:
5391 kind = "creation" if creation else "callback"
5393 f"Failed to get the phytomer {kind} function of shoot type '{shoot_type_label}': {e}")
5394
5395 def getPhytomerCreationFunctionName(self, shoot_type_label: str) -> Optional[str]:
5396 """
5397 Get the name of the library creation function a shoot type carries.
5399 Use it to carry a library model's creation function over to a new shoot type, which
5400 :meth:`defineShootType` starts without::
5401
5402 params = plantarch.getCurrentShootParameters("proleptic")
5403 plantarch.defineShootType("my_shoot", params)
5404 name = plantarch.getPhytomerCreationFunctionName("proleptic")
5405 if name:
5406 plantarch.setPhytomerCreationFunction("my_shoot", name)
5407
5408 Args:
5409 shoot_type_label: An existing shoot type label
5410
5411 Returns:
5412 The library function's name, or ``None`` if the shoot type has no creation function
5413 or has a Python one
5414
5415 Raises:
5416 ValueError: If the label is not a non-empty str
5417 PlantArchitectureError: If the shoot type does not exist
5418 RuntimeError: If the native library predates this function
5419 """
5420 return self._getLibraryPhytomerFunctionName(shoot_type_label, creation=True)
5421
5422 def getPhytomerCallbackFunctionName(self, shoot_type_label: str) -> Optional[str]:
5423 """
5424 Get the name of the library per-timestep callback function a shoot type carries.
5425
5426 Args:
5427 shoot_type_label: An existing shoot type label
5428
5429 Returns:
5430 The library function's name, or ``None`` if the shoot type has no callback function
5431 or has a Python one
5432
5433 Raises:
5434 ValueError: If the label is not a non-empty str
5435 PlantArchitectureError: If the shoot type does not exist
5436 RuntimeError: If the native library predates this function
5437 """
5438 return self._getLibraryPhytomerFunctionName(shoot_type_label, creation=False)
5439
5440 @staticmethod
5441 def getLibraryPhytomerCreationFunctionNames() -> List[str]:
5442 """
5443 List the library phytomer creation functions that :meth:`setPhytomerCreationFunction` accepts by name.
5444
5445 These are the functions the library plant models use, for example
5446 ``"AlmondPhytomerCreationFunction"`` or ``"TomatoPhytomerCreationFunction"``.
5447
5448 Returns:
5449 Function names, sorted
5450
5451 Raises:
5452 RuntimeError: If the native library predates this function
5453 """
5454 return plantarch_wrapper.getLibraryPhytomerCreationFunctionNames()
5455
5456 @staticmethod
5458 """
5459 List the library per-timestep callback functions that :meth:`setPhytomerCallbackFunction` accepts by name.
5460
5461 These are the functions the library plant models use, for example
5462 ``"AlmondSpurPhytomerCallbackFunction"`` (grows spurs on a model that defines a ``spur``
5463 shoot type) or ``"GrapevinePhytomerCallbackFunction"`` (pulls fruit-zone leaves).
5464
5465 Returns:
5466 Function names, sorted
5467
5468 Raises:
5469 RuntimeError: If the native library predates this function
5470 """
5471 return plantarch_wrapper.getLibraryPhytomerCallbackFunctionNames()
5472
5473 def _makePhytomerCallbackTrampoline(self, callback):
5474 """Wrap callback so an exception is stored for re-raising instead of being lost inside ctypes."""
5475 owner = weakref.ref(self)
5476
5477 def trampoline(plant_id, shoot_id, node_index, phytomer_age):
5478 plantarch = owner()
5479 if plantarch is None or getattr(plantarch, '_pending_callback_error', None) is not None:
5480 return 1
5481 plantarch._phytomer_callback_depth = getattr(plantarch, '_phytomer_callback_depth', 0) + 1
5482 try:
5483 callback(plant_id, shoot_id, node_index, phytomer_age)
5484 except BaseException as e:
5485 plantarch._pending_callback_error = e
5486 return 1
5487 finally:
5488 plantarch._phytomer_callback_depth -= 1
5489 return 0
5490
5491 return plantarch_wrapper.PHYTOMER_CALLBACK(trampoline)
5492
5493 def _makePhytomerCreationTrampoline(self, callback):
5494 """Wrap callback so an exception is stored for re-raising instead of being lost inside ctypes."""
5495 owner = weakref.ref(self)
5496
5497 def trampoline(plant_id, shoot_id, node_index, shoot_node_index, parent_shoot_node_index,
5498 shoot_max_nodes, plant_age):
5499 plantarch = owner()
5500 if plantarch is None or getattr(plantarch, '_pending_callback_error', None) is not None:
5501 return 1
5502 plantarch._phytomer_callback_depth = getattr(plantarch, '_phytomer_callback_depth', 0) + 1
5503 try:
5504 callback(int(plant_id), int(shoot_id), int(node_index), int(shoot_node_index),
5505 int(parent_shoot_node_index), int(shoot_max_nodes), float(plant_age))
5506 except BaseException as e:
5507 plantarch._pending_callback_error = e
5508 return 1
5509 finally:
5510 plantarch._phytomer_callback_depth -= 1
5511 return 0
5512
5513 return plantarch_wrapper.PHYTOMER_CREATION_CALLBACK(trampoline)
5515 def _reject_inside_phytomer_callback(self, method_name: str) -> None:
5516 """Raise if called from a Python phytomer creation or callback function.
5517
5518 The native plugin skips these operations inside a callback and only prints a warning, which
5519 disableMessages() suppresses, so the call would otherwise appear to succeed.
5520 """
5521 if getattr(self, '_phytomer_callback_depth', 0) > 0:
5523 f"{method_name}() cannot be called from inside a phytomer creation or callback function, "
5524 f"because it changes plant structure while the plant is being grown. Call it from outside "
5525 f"the function, for example between calls to advanceTime(). pruneBranch() is the only "
5526 f"structural edit a callback can make; it takes effect at the end of the current time step."
5527 )
5528
5529 def _raise_pending_callback_error(self, native_error: Optional[BaseException] = None) -> None:
5530 """Raise, and clear, an exception stored by a phytomer creation or callback function."""
5531 pending = getattr(self, '_pending_callback_error', None)
5532 if pending is None:
5533 return
5534 self._pending_callback_error = None
5535 raise pending from native_error
5537 # ------------------------------------------------------------------
5538 # Per-phytomer growth targets and live phyllotaxy (helios-core 1.3.88)
5539 # ------------------------------------------------------------------
5540
5541 def _phytomerCall(self, description: str, plant_id: int, shoot_id: int, node_index: int, fn, *args):
5542 """Call a per-phytomer wrapper, turning an out-of-range index into ValueError."""
5544 try:
5545 return fn(self._plantarch_ptr, plant_id, shoot_id, node_index, *args)
5546 except HeliosInvalidArgumentError as e:
5547 raise ValueError(str(e)) from None
5548 except Exception as e:
5550 f"Failed to {description} of node {node_index} of shoot {shoot_id} of plant {plant_id}: {e}")
5551
5552 def setInternodeMaxLength(self, plant_id: int, shoot_id: int, node_index: int, length: float) -> None:
5553 """
5554 Set the fully-elongated length of one existing internode.
5555
5556 Only this phytomer's target changes; compare :meth:`setShootInternodeLengthMax`, which
5557 sets the target for internodes the shoot has yet to produce. A target below the
5558 internode's present length shortens it immediately; a larger target leaves the present
5559 length alone and the internode grows toward it on later :meth:`advanceTime` calls.
5560
5561 Args:
5562 plant_id: ID of the plant instance
5563 shoot_id: Shoot index within the plant
5564 node_index: Phytomer index within the shoot
5565 length: Fully-elongated internode length in meters; must be positive and finite
5566
5567 Raises:
5568 ValueError: If an identifier is invalid, ``length`` is not positive and finite, or ``node_index`` is past the end of the shoot
5569 PlantArchitectureError: If the plant or shoot does not exist
5570 RuntimeError: If the native library predates helios-core v1.3.88
5571 """
5572 self._validateShootIdentifiers(plant_id, shoot_id)
5573 node_index = self._validateNodeIndex(node_index)
5574 if isinstance(length, bool) or not isinstance(length, (int, float)) or not math.isfinite(length) \
5575 or not length > 0:
5576 raise ValueError(f"Internode max length must be a positive, finite number, got {length!r}")
5577 self._phytomerCall("set the internode max length", plant_id, shoot_id, node_index,
5578 plantarch_wrapper.setInternodeMaxLength, float(length))
5579
5580 def scaleInternodeMaxLength(self, plant_id: int, shoot_id: int, node_index: int, scale_factor: float) -> None:
5581 """
5582 Scale the fully-elongated length of one existing internode.
5583
5584 As :meth:`setInternodeMaxLength`, relative to the current target: a factor below one
5585 shortens an internode that is already longer than its new target, and a factor above one
5586 lets it grow further.
5587
5588 Args:
5589 plant_id: ID of the plant instance
5590 shoot_id: Shoot index within the plant
5591 node_index: Phytomer index within the shoot
5592 scale_factor: Factor to scale the fully-elongated length by; must be positive
5593
5594 Raises:
5595 ValueError: If an identifier is invalid, ``scale_factor`` is not positive, or ``node_index`` is past the end of the shoot
5596 PlantArchitectureError: If the plant or shoot does not exist
5597 RuntimeError: If the native library predates helios-core v1.3.88
5598 """
5599 self._validateShootIdentifiers(plant_id, shoot_id)
5600 node_index = self._validateNodeIndex(node_index)
5601 scale_factor = self._validateScaleFactor(scale_factor)
5602 self._phytomerCall("scale the internode max length", plant_id, shoot_id, node_index,
5603 plantarch_wrapper.scaleInternodeMaxLength, scale_factor)
5604
5605 def scaleLeafPrototypeScale(self, plant_id: int, shoot_id: int, node_index: int, scale_factor: float,
5606 petiole_index: Optional[int] = None) -> None:
5607 """
5608 Scale a phytomer's leaves now: both the blades' present size and the size they grow toward.
5609
5610 The leaves are rescaled about their bases immediately and keep their expansion fraction,
5611 so a half-expanded leaf stays half-expanded toward a proportionally larger or smaller
5612 mature size. This is what a C++ phytomer creation function does to size a new leaf by its
5613 position on the plant. Contrast :meth:`scaleLeafSizeMax`, which moves only the target and
5614 leaves the blades as they are.
5615
5616 Args:
5617 plant_id: ID of the plant instance
5618 shoot_id: Shoot index within the plant
5619 node_index: Phytomer index within the shoot
5620 scale_factor: Factor to scale the leaves by; must be positive
5621 petiole_index: Scale only the leaves of this petiole; ``None`` for every petiole
5622
5623 Raises:
5624 ValueError: If an identifier is invalid, ``scale_factor`` is not positive, or an index is out of range
5625 PlantArchitectureError: If the plant or shoot does not exist
5626 RuntimeError: If the native library predates helios-core v1.3.88
5627 """
5628 self._validateShootIdentifiers(plant_id, shoot_id)
5629 node_index = self._validateNodeIndex(node_index)
5630 scale_factor = self._validateScaleFactor(scale_factor)
5631 if petiole_index is not None:
5632 petiole_index = self._validatePetioleIndex(petiole_index)
5633 self._phytomerCall("scale the leaf prototype", plant_id, shoot_id, node_index,
5634 plantarch_wrapper.scaleLeafPrototypeScale, scale_factor, petiole_index)
5635
5636 def setShootPhyllotacticAngle(self, plant_id: int, shoot_id: int, mean_deg: float,
5637 sd_deg: Optional[float] = None) -> None:
5638 """
5639 Set the phyllotactic angle that one shoot's next phytomers are created with.
5640
5641 Each new phytomer draws its angle from its shoot's own copy of the shoot parameters, so
5642 calling this before a time step sets the angle of the phytomers produced during that step
5643 without touching any phytomer already built, any other shoot, or the shoot type. Calling
5644 it before every step drives a node-by-node angle sequence.
5645
5646 With ``sd_deg`` omitted the angle is the constant ``mean_deg``. With ``sd_deg`` given the
5647 angle is drawn from a normal distribution, which draws from the Context random generator
5648 for every new phytomer even when ``sd_deg`` is 0 -- matching a C++ caller of
5649 ``phyllotactic_angle.normalDistribution(mean, sd)``, which matters when reproducing a C++
5650 run draw for draw.
5651
5652 Args:
5653 plant_id: ID of the plant instance
5654 shoot_id: Shoot index within the plant
5655 mean_deg: Phyllotactic angle, or its mean, in degrees
5656 sd_deg: Standard deviation in degrees (non-negative), or ``None`` for a constant
5657
5658 Raises:
5659 ValueError: If an identifier is invalid or an angle is not finite, or ``sd_deg`` is negative
5660 PlantArchitectureError: If the plant or shoot does not exist
5661 RuntimeError: If the native library predates helios-core v1.3.88
5662 """
5663 self._validateShootIdentifiers(plant_id, shoot_id)
5664 if isinstance(mean_deg, bool) or not isinstance(mean_deg, (int, float)) or not math.isfinite(mean_deg):
5665 raise ValueError(f"Mean phyllotactic angle must be a finite number, got {mean_deg!r}")
5666 if sd_deg is not None and (isinstance(sd_deg, bool) or not isinstance(sd_deg, (int, float))
5667 or not math.isfinite(sd_deg) or sd_deg < 0):
5668 raise ValueError(
5669 f"Phyllotactic angle standard deviation must be a finite, non-negative number or None, got {sd_deg!r}")
5671 try:
5672 plantarch_wrapper.setShootPhyllotacticAngle(
5673 self._plantarch_ptr, plant_id, shoot_id, float(mean_deg), None if sd_deg is None else float(sd_deg))
5674 except Exception as e:
5676 f"Failed to set the phyllotactic angle of shoot {shoot_id} of plant {plant_id}: {e}")
5677
5678 # ------------------------------------------------------------------
5679 # Per-phytomer readouts (helios-core 1.3.88)
5680 # ------------------------------------------------------------------
5681
5682 def getPhytomerAge(self, plant_id: int, shoot_id: int, node_index: int) -> float:
5683 """
5684 Age of one phytomer in days, counted from its creation.
5685
5686 Raises:
5687 ValueError: If an identifier is invalid or ``node_index`` is past the end of the shoot
5688 PlantArchitectureError: If the plant or shoot does not exist
5689 RuntimeError: If the native library predates helios-core v1.3.88
5690 """
5691 self._validateShootIdentifiers(plant_id, shoot_id)
5692 node_index = self._validateNodeIndex(node_index)
5693 return self._phytomerCall("get the age", plant_id, shoot_id, node_index, plantarch_wrapper.getPhytomerAge)
5694
5695 def getInternodeLength(self, plant_id: int, shoot_id: int, node_index: int) -> float:
5696 """
5697 Present length of one internode in meters, measured along its node positions.
5698
5699 Raises:
5700 ValueError: If an identifier is invalid or ``node_index`` is past the end of the shoot
5701 PlantArchitectureError: If the plant or shoot does not exist
5702 RuntimeError: If the native library predates helios-core v1.3.88
5703 """
5704 self._validateShootIdentifiers(plant_id, shoot_id)
5705 node_index = self._validateNodeIndex(node_index)
5706 return self._phytomerCall("get the internode length", plant_id, shoot_id, node_index,
5707 plantarch_wrapper.getInternodeLength)
5708
5709 def getInternodeRadius(self, plant_id: int, shoot_id: int, node_index: int) -> float:
5710 """
5711 Radius in meters at the base of one internode.
5712
5713 Raises:
5714 ValueError: If an identifier is invalid or ``node_index`` is past the end of the shoot
5715 PlantArchitectureError: If the plant or shoot does not exist
5716 RuntimeError: If the native library predates helios-core v1.3.88
5717 """
5718 self._validateShootIdentifiers(plant_id, shoot_id)
5719 node_index = self._validateNodeIndex(node_index)
5720 return self._phytomerCall("get the internode radius", plant_id, shoot_id, node_index,
5721 plantarch_wrapper.getInternodeRadius)
5722
5723 def getInternodeNodePositions(self, plant_id: int, shoot_id: int, node_index: int) -> List[vec3]:
5724 """
5725 Positions of one internode's nodes, from its base to its tip.
5726
5727 The first position is the tip of the previous internode on the shoot.
5728
5729 Raises:
5730 ValueError: If an identifier is invalid or ``node_index`` is past the end of the shoot
5731 PlantArchitectureError: If the plant or shoot does not exist
5732 RuntimeError: If the native library predates helios-core v1.3.88
5733 """
5734 self._validateShootIdentifiers(plant_id, shoot_id)
5735 node_index = self._validateNodeIndex(node_index)
5736 points = self._phytomerCall("get the internode node positions", plant_id, shoot_id, node_index,
5737 plantarch_wrapper.getInternodeNodePositions)
5738 return [vec3(*p) for p in points]
5739
5740 def getInternodeAxisVector(self, plant_id: int, shoot_id: int, node_index: int, stem_fraction: float) -> vec3:
5741 """
5742 Unit direction of one internode at a fraction of its length.
5743
5744 Args:
5745 plant_id: ID of the plant instance
5746 shoot_id: Shoot index within the plant
5747 node_index: Phytomer index within the shoot
5748 stem_fraction: Position along the internode, 0 at its base and 1 at its tip
5749
5750 Raises:
5751 ValueError: If an identifier is invalid, ``stem_fraction`` is outside [0, 1], or ``node_index`` is past the end of the shoot
5752 PlantArchitectureError: If the plant or shoot does not exist
5753 RuntimeError: If the native library predates helios-core v1.3.88
5754 """
5755 self._validateShootIdentifiers(plant_id, shoot_id)
5756 node_index = self._validateNodeIndex(node_index)
5757 stem_fraction = self._validateStemFraction(stem_fraction)
5758 return vec3(*self._phytomerCall("get the internode axis", plant_id, shoot_id, node_index,
5759 plantarch_wrapper.getInternodeAxisVector, stem_fraction))
5760
5761 def getPetioleAxisVector(self, plant_id: int, shoot_id: int, node_index: int, stem_fraction: float,
5762 petiole_index: int) -> vec3:
5763 """
5764 Unit direction of one petiole at a fraction of its length.
5766 Args:
5767 plant_id: ID of the plant instance
5768 shoot_id: Shoot index within the plant
5769 node_index: Phytomer index within the shoot
5770 stem_fraction: Position along the petiole, 0 at its base and 1 at its tip
5771 petiole_index: Petiole within the phytomer
5772
5773 Raises:
5774 ValueError: If an identifier is invalid, ``stem_fraction`` is outside [0, 1], or an index is out of range (a phytomer whose leaf was shed has no petioles)
5775 PlantArchitectureError: If the plant or shoot does not exist
5776 RuntimeError: If the native library predates helios-core v1.3.88
5777 """
5778 self._validateShootIdentifiers(plant_id, shoot_id)
5779 node_index = self._validateNodeIndex(node_index)
5780 stem_fraction = self._validateStemFraction(stem_fraction)
5781 petiole_index = self._validatePetioleIndex(petiole_index)
5782 return vec3(*self._phytomerCall("get the petiole axis", plant_id, shoot_id, node_index,
5783 plantarch_wrapper.getPetioleAxisVector, stem_fraction, petiole_index))
5784
5785 def getPetioleVertices(self, plant_id: int, shoot_id: int, node_index: int, petiole_index: int) -> List[vec3]:
5786 """
5787 Centerline vertices of one petiole, from its base at the node to its tip.
5788
5789 Raises:
5790 ValueError: If an identifier is invalid or an index is out of range
5791 PlantArchitectureError: If the plant or shoot does not exist
5792 RuntimeError: If the native library predates helios-core v1.3.88
5793 """
5794 self._validateShootIdentifiers(plant_id, shoot_id)
5795 node_index = self._validateNodeIndex(node_index)
5796 petiole_index = self._validatePetioleIndex(petiole_index)
5797 points = self._phytomerCall("get the petiole vertices", plant_id, shoot_id, node_index,
5798 plantarch_wrapper.getPetioleVertices, petiole_index)
5799 return [vec3(*p) for p in points]
5800
5801 def getPetioleRadii(self, plant_id: int, shoot_id: int, node_index: int, petiole_index: int) -> List[float]:
5802 """
5803 Radius in meters of one petiole at each of its centerline vertices (see :meth:`getPetioleVertices`).
5804
5805 Raises:
5806 ValueError: If an identifier is invalid or an index is out of range
5807 PlantArchitectureError: If the plant or shoot does not exist
5808 RuntimeError: If the native library predates helios-core v1.3.88
5809 """
5810 self._validateShootIdentifiers(plant_id, shoot_id)
5811 node_index = self._validateNodeIndex(node_index)
5812 petiole_index = self._validatePetioleIndex(petiole_index)
5813 return self._phytomerCall("get the petiole radii", plant_id, shoot_id, node_index,
5814 plantarch_wrapper.getPetioleRadii, petiole_index)
5815
5816 def getPhytomerLeafObjectIDs(self, plant_id: int, shoot_id: int, node_index: int) -> List[List[int]]:
5817 """
5818 Object IDs of one phytomer's leaves, grouped by petiole.
5819
5820 Returns one list per petiole with one object ID per leaf (leaflet) on that petiole, in the
5821 order :meth:`getLeafBasePosition` indexes them. A phytomer whose leaves were shed returns
5822 empty lists.
5823
5824 Raises:
5825 ValueError: If an identifier is invalid or ``node_index`` is past the end of the shoot
5826 PlantArchitectureError: If the plant or shoot does not exist
5827 RuntimeError: If the native library predates helios-core v1.3.88
5828 """
5829 self._validateShootIdentifiers(plant_id, shoot_id)
5830 node_index = self._validateNodeIndex(node_index)
5831 return self._phytomerCall("get the leaf object IDs", plant_id, shoot_id, node_index,
5832 plantarch_wrapper.getPhytomerLeafObjectIDs)
5833
5834 def getLeafBasePosition(self, plant_id: int, shoot_id: int, node_index: int, petiole_index: int,
5835 leaf_index: int) -> vec3:
5836 """
5837 Position where one leaf (leaflet) attaches to its petiole.
5838
5839 Raises:
5840 ValueError: If an identifier is invalid or an index is out of range
5841 PlantArchitectureError: If the plant or shoot does not exist
5842 RuntimeError: If the native library predates helios-core v1.3.88
5843 """
5844 self._validateShootIdentifiers(plant_id, shoot_id)
5845 node_index = self._validateNodeIndex(node_index)
5846 petiole_index = self._validatePetioleIndex(petiole_index)
5847 leaf_index = self._validatePetioleIndex(leaf_index, name="Leaf index")
5848 return vec3(*self._phytomerCall("get the leaf base position", plant_id, shoot_id, node_index,
5849 plantarch_wrapper.getLeafBasePosition, petiole_index, leaf_index))
5850
5851 # ------------------------------------------------------------------
5852 # Per-model leaf inclination distribution and plant nitrogen (helios-core 1.3.88)
5853 # ------------------------------------------------------------------
5854
5855 def getPlantModelLeafInclinationDistribution(self, plant_model_name: str) -> tuple:
5856 """
5857 Beta leaf inclination distribution ``(mu, nu)`` that a library plant model declares.
5858
5859 Plants built from a model that declares a distribution have their leaf inclinations
5860 steered toward it automatically. Returns ``(0.0, 0.0)`` when the model declares none.
5861
5862 Raises:
5863 ValueError: If ``plant_model_name`` is not a non-empty str
5864 PlantArchitectureError: If the model is not in the library
5865 RuntimeError: If the native library predates helios-core v1.3.88
5866 """
5867 self._validatePlantModelName(plant_model_name)
5869 try:
5870 return plantarch_wrapper.getPlantModelLeafInclinationDistribution(self._plantarch_ptr, plant_model_name)
5871 except Exception as e:
5873 f"Failed to get the leaf inclination distribution of plant model '{plant_model_name}': {e}")
5874
5875 def setPlantModelLeafInclinationDistribution(self, plant_model_name: str, beta_mu: float,
5876 beta_nu: float) -> None:
5877 """
5878 Set or clear the Beta leaf inclination distribution of a library plant model.
5879
5880 Plants built from the model afterwards are steered toward the distribution. Two positive
5881 values set it; ``(0, 0)`` clears it, restoring the model's unsteered leaf angles.
5882
5883 Raises:
5884 ValueError: If the name is not a non-empty str, or the Beta parameters are not both positive or both zero
5885 PlantArchitectureError: If the model is not in the library
5886 RuntimeError: If the native library predates helios-core v1.3.88
5887 """
5888 self._validatePlantModelName(plant_model_name)
5889 for name, v in (("beta_mu", beta_mu), ("beta_nu", beta_nu)):
5890 if isinstance(v, bool) or not isinstance(v, (int, float)) or not math.isfinite(v) or v < 0:
5891 raise ValueError(f"Beta parameter {name} must be a finite, non-negative number, got {v!r}")
5892 if (beta_mu > 0) != (beta_nu > 0):
5893 raise ValueError(
5894 f"Beta parameters must both be positive to set a distribution or both zero to clear it, "
5895 f"got ({beta_mu}, {beta_nu})")
5897 try:
5898 plantarch_wrapper.setPlantModelLeafInclinationDistribution(
5899 self._plantarch_ptr, plant_model_name, float(beta_mu), float(beta_nu))
5900 except Exception as e:
5902 f"Failed to set the leaf inclination distribution of plant model '{plant_model_name}': {e}")
5903
5904 def doesPlantModelDeclareLeafInclinationDistribution(self, plant_model_name: str) -> bool:
5905 """
5906 Whether a library plant model declares a leaf inclination distribution.
5907
5908 Raises:
5909 ValueError: If ``plant_model_name`` is not a non-empty str
5910 PlantArchitectureError: If the model is not in the library
5911 RuntimeError: If the native library predates helios-core v1.3.88
5912 """
5913 self._validatePlantModelName(plant_model_name)
5915 try:
5916 return plantarch_wrapper.doesPlantModelDeclareLeafInclinationDistribution(
5917 self._plantarch_ptr, plant_model_name)
5918 except Exception as e:
5920 f"Failed to query the leaf inclination distribution of plant model '{plant_model_name}': {e}")
5921
5922 def getPlantAvailableNitrogen(self, plant_id: int) -> float:
5923 """
5924 Nitrogen (g N) in a plant's available pool, awaiting allocation to its organs.
5925
5926 Raises:
5927 ValueError: If ``plant_id`` is not a non-negative int
5928 PlantArchitectureError: If the plant does not exist
5929 RuntimeError: If the native library predates helios-core v1.3.88
5930 """
5931 plant_id = self._validatePlantIdentifier(plant_id)
5933 try:
5934 return plantarch_wrapper.getPlantAvailableNitrogen(self._plantarch_ptr, plant_id)
5935 except Exception as e:
5936 raise PlantArchitectureError(f"Failed to get the available nitrogen of plant {plant_id}: {e}")
5937
5938 def getLeafBladeArea(self, leaf_object_id: int) -> float:
5939 """
5940 One-sided blade area (m²) of a single leaf object.
5941
5942 A leaf object can carry a petiolule -- the stalk joining a leaflet to its rachis
5943 (see :attr:`pyhelios.plant_architecture_params.LeafPrototype.build_petiolule`) --
5944 whose primitives are labelled ``"petiolule"`` rather than ``"leaf"``. This reports
5945 the area of the object without them, which is what every leaf-area quantity of the
5946 plugin (:meth:`sumPlantLeafArea`, :meth:`getPlantLeafAreas`) means as of
5947 helios-core 1.3.89. ``Context.getObjectArea()`` on the same object includes the
5948 petiolule.
5949
5950 Args:
5951 leaf_object_id: Object ID of the leaf (e.g. from :meth:`getPlantLeafObjectIDs`)
5952
5953 Returns:
5954 Blade area in m²
5955
5956 Raises:
5957 ValueError: If ``leaf_object_id`` is not a non-negative int
5958 PlantArchitectureError: If the object does not exist
5959 RuntimeError: If the native library predates helios-core v1.3.89
5960 """
5961 if isinstance(leaf_object_id, bool) or not isinstance(leaf_object_id, int):
5962 raise ValueError(
5963 f"Leaf object ID must be a non-negative int, got {type(leaf_object_id).__name__}")
5964 if leaf_object_id < 0:
5965 raise ValueError("Leaf object ID must be non-negative")
5967 try:
5968 return plantarch_wrapper.getLeafBladeArea(self._plantarch_ptr, leaf_object_id)
5969 except Exception as e:
5970 raise PlantArchitectureError(f"Failed to get the blade area of leaf object {leaf_object_id}: {e}")
5971
5972 def getShadowLightExposureAtPoint(self, plant_id: int, position: vec3) -> float:
5973 """
5974 Local light exposure at a point from a plant's shadow-propagation grid.
5975
5976 Exposure runs from 0 (fully shaded) to 1 (unshaded), and is the quantity that drives
5977 shade-based branch shedding at each dormancy boundary in parameter-based growth
5978 (after Palubicki et al. 2009): branches deep in the crown interior see a low
5979 exposure while branches on the crown surface do not. Returns 1 when the plant has no
5980 canopy yet, or when the shadow grid is disabled because the carbohydrate model is
5981 active. Intended for inspection and testing; it is a cheap surrogate for light
5982 interception, not a radiation calculation.
5983
5984 Args:
5985 plant_id: ID of the plant
5986 position: Query position in world coordinates
5987
5988 Returns:
5989 Local light exposure in [0, 1]
5990
5991 Raises:
5992 ValueError: If ``plant_id`` is not a non-negative int or ``position`` is not a vec3
5993 PlantArchitectureError: If the plant does not exist
5994 RuntimeError: If the native library predates helios-core v1.3.89
5995 """
5996 plant_id = self._validatePlantIdentifier(plant_id)
5997 if not isinstance(position, vec3):
5998 raise ValueError(f"Position must be a vec3, got {type(position).__name__}")
6000 try:
6001 return plantarch_wrapper.getShadowLightExposureAtPoint(
6002 self._plantarch_ptr, plant_id, (position.x, position.y, position.z))
6003 except Exception as e:
6005 f"Failed to get the shadow-grid light exposure of plant {plant_id}: {e}")
6006
6007 def getSeasonPeakLightExposureAtPoint(self, plant_id: int, position: vec3) -> float:
6008 """
6009 Local light exposure at a point under the plant's most recent seasonal peak canopy.
6010
6011 Like :meth:`getShadowLightExposureAtPoint`, but read from the shadow grid captured
6012 when the plant's leaf area last reached its seasonal maximum rather than from the
6013 current canopy. Through dormancy, when a deciduous plant has no leaves and the current
6014 exposure is 1 everywhere, this still gives the light each point stood in over the past
6015 summer. The "apple" and "apple_fruitingwall" library models use it to decide how many
6016 flowers each bud forms.
6017
6018 Args:
6019 plant_id: ID of the plant
6020 position: Query position in world coordinates
6021
6022 Returns:
6023 Local light exposure in [0, 1]; 1 before the plant's first season in leaf, or when
6024 the carbohydrate model is active
6025
6026 Raises:
6027 ValueError: If ``plant_id`` is not a non-negative int or ``position`` is not a vec3
6028 PlantArchitectureError: If the plant does not exist
6029 RuntimeError: If the native library predates helios-core v1.3.93
6030 """
6031 plant_id = self._validatePlantIdentifier(plant_id)
6032 if not isinstance(position, vec3):
6033 raise ValueError(f"Position must be a vec3, got {type(position).__name__}")
6035 try:
6036 return plantarch_wrapper.getSeasonPeakLightExposureAtPoint(
6037 self._plantarch_ptr, plant_id, (position.x, position.y, position.z))
6038 except Exception as e:
6040 f"Failed to get the season-peak light exposure of plant {plant_id}: {e}")
6041
6042 def getPlantBuildParameters(self, plant_id: int) -> Dict[str, float]:
6043 """
6044 Get the build parameters a plant was built with.
6045
6046 These are the ``build_parameters`` given to :meth:`buildPlantInstanceFromLibrary` or
6047 :meth:`buildPlantCanopyFromLibrary`. A plant keeps them for its life (some, such as the
6048 sorghum ``tiller_count``, are read as it grows), and they are saved and restored by
6049 :meth:`writePlantStructureXML` / :meth:`readPlantStructureXML`. Parameters that were not
6050 given are absent and take the model's default where they are read.
6051
6052 Args:
6053 plant_id: ID of the plant
6054
6055 Returns:
6056 Dict of build parameter names to values; empty for a plant not built from the
6057 library or built without parameters
6058
6059 Raises:
6060 ValueError: If ``plant_id`` is not a non-negative int
6061 PlantArchitectureError: If the plant does not exist
6062 RuntimeError: If the native library predates helios-core v1.3.93
6063
6064 Example:
6065 >>> plantarch.loadPlantModelFromLibrary("sorghum")
6066 >>> plant_id = plantarch.buildPlantInstanceFromLibrary(
6067 ... vec3(0, 0, 0), 30.0, build_parameters={"tiller_count": 2})
6068 >>> plantarch.getPlantBuildParameters(plant_id)
6069 {'tiller_count': 2.0}
6070 """
6071 plant_id = self._validatePlantIdentifier(plant_id)
6073 try:
6074 return plantarch_wrapper.getPlantBuildParameters(self._plantarch_ptr, plant_id)
6075 except Exception as e:
6076 raise PlantArchitectureError(f"Failed to get the build parameters of plant {plant_id}: {e}")
6077
6078 # ------------------------------------------------------------------
6079 # Plant-level queries
6080 # ------------------------------------------------------------------
6081
6082 def getPlantName(self, plant_id: int) -> str:
6083 """
6084 Get the name of the plant model a plant was built from.
6085
6086 For a plant built with :meth:`buildPlantInstanceFromLibrary` this is the label passed
6087 to :meth:`loadPlantModelFromLibrary` (e.g. ``"bean"``). A plant assembled by hand with
6088 :meth:`addPlantInstance` reports whatever name the native library assigned to it.
6089
6090 Args:
6091 plant_id: ID of the plant instance
6092
6093 Returns:
6094 Plant model name
6095
6096 Raises:
6097 ValueError: If ``plant_id`` is not a non-negative int
6098 PlantArchitectureError: If the plant does not exist
6099 RuntimeError: If the native library predates this binding
6100
6101 Example:
6102 >>> plantarch.loadPlantModelFromLibrary("bean")
6103 >>> plant_id = plantarch.buildPlantInstanceFromLibrary(vec3(0, 0, 0), 20)
6104 >>> plantarch.getPlantName(plant_id)
6105 'bean'
6106 """
6107 plant_id = self._validatePlantIdentifier(plant_id)
6109 try:
6110 return plantarch_wrapper.getPlantName(self._plantarch_ptr, plant_id)
6111 except Exception as e:
6112 raise PlantArchitectureError(f"Failed to get the name of plant {plant_id}: {e}")
6113
6114 def determinePhenologyStage(self, plant_id: int) -> str:
6115 """
6116 Determine the phenological stage of a plant.
6117
6118 The stage is decided in this order: ``"dormant"`` if every shoot is dormant;
6119 ``"reproductive"`` if the plant carries any flower or fruit; ``"senescent"`` if it is
6120 past 90% of the time from dormancy break to dormancy (only when a dormancy threshold
6121 is set, see :meth:`setPlantPhenologicalThresholds`); otherwise ``"vegetative"``.
6122
6123 Args:
6124 plant_id: ID of the plant instance
6125
6126 Returns:
6127 One of ``"dormant"``, ``"vegetative"``, ``"reproductive"`` or ``"senescent"``
6128
6129 Raises:
6130 ValueError: If ``plant_id`` is not a non-negative int
6131 PlantArchitectureError: If the plant does not exist
6132 RuntimeError: If the native library predates this binding
6133
6134 Example:
6135 >>> plantarch.determinePhenologyStage(plant_id)
6136 'vegetative'
6137 """
6138 plant_id = self._validatePlantIdentifier(plant_id)
6140 try:
6141 return plantarch_wrapper.determinePhenologyStage(self._plantarch_ptr, plant_id)
6142 except Exception as e:
6143 raise PlantArchitectureError(f"Failed to determine the phenology stage of plant {plant_id}: {e}")
6144
6145 def getShootNodeCount(self, plant_id: int, shoot_id: int) -> int:
6146 """
6147 Get the current number of nodes (phytomers) on one shoot.
6148
6149 Args:
6150 plant_id: ID of the plant instance
6151 shoot_id: Shoot index within the plant (see :meth:`getAllShootIDs`; 0 is the base stem)
6152
6153 Returns:
6154 Number of nodes on the shoot
6155
6156 Raises:
6157 ValueError: If ``plant_id`` or ``shoot_id`` is not a non-negative int
6158 PlantArchitectureError: If the plant does not exist or the shoot ID is out of range
6159 RuntimeError: If the native library predates this binding
6160
6161 Example:
6162 >>> plantarch.getShootNodeCount(plant_id, 0)
6163 6
6164 """
6165 self._validateShootIdentifiers(plant_id, shoot_id)
6167 try:
6168 return plantarch_wrapper.getShootNodeCount(self._plantarch_ptr, plant_id, shoot_id)
6169 except Exception as e:
6171 f"Failed to get the node count of shoot {shoot_id} on plant {plant_id}: {e}")
6172
6173 def getPlantStemHeight(self, plant_id: int) -> float:
6174 """
6175 Get the height of a plant's main stem in meters.
6176
6177 The vertical extent of the base stem shoot, extended by any shoot of the same rank
6178 appended to its tip. Unlike :meth:`getPlantHeight`, leaves, branches and
6179 inflorescences reaching above the stem are not counted.
6180
6181 Args:
6182 plant_id: ID of the plant instance
6183
6184 Returns:
6185 Vertical extent of the main stem (m)
6186
6187 Raises:
6188 ValueError: If ``plant_id`` is not a non-negative int
6189 PlantArchitectureError: If the plant does not exist or has no stem geometry
6190 RuntimeError: If the native library predates this binding
6191
6192 Example:
6193 >>> plantarch.getPlantStemHeight(plant_id)
6194 0.21
6195 """
6196 plant_id = self._validatePlantIdentifier(plant_id)
6198 try:
6199 return plantarch_wrapper.getPlantStemHeight(self._plantarch_ptr, plant_id)
6200 except Exception as e:
6201 raise PlantArchitectureError(f"Failed to get the stem height of plant {plant_id}: {e}")
6202
6203 def getPlantBasePosition(self, plant_ids: Union[int, List[int]]) -> Union[vec3, List[vec3]]:
6204 """
6205 Get the base position of one plant or of several plants.
6206
6207 For a plant with no shoots yet, this is the position at which its first shoot will be
6208 placed.
6209
6210 Args:
6211 plant_ids: A single plant ID, or a sequence of plant IDs
6213 Returns:
6214 A vec3 for a single plant ID, or a list of vec3 (in the order given) for a sequence
6215
6216 Raises:
6217 ValueError: If a plant ID is not a non-negative int or the sequence is empty
6218 PlantArchitectureError: If a plant does not exist
6219 RuntimeError: If the native library predates this binding
6220
6221 Example:
6222 >>> plantarch.getPlantBasePosition(plant_id)
6223 vec3(0.0, 0.0, 0.0)
6224 """
6225 multi = not isinstance(plant_ids, int) or isinstance(plant_ids, bool)
6226 ids = self._validatePlantIdList(plant_ids) if multi else [self._validatePlantIdentifier(plant_ids)]
6228 try:
6229 if multi:
6230 return [vec3(*p) for p in plantarch_wrapper.getPlantBasePositions(self._plantarch_ptr, ids)]
6231 return vec3(*plantarch_wrapper.getPlantBasePosition(self._plantarch_ptr, ids[0]))
6232 except Exception as e:
6233 raise PlantArchitectureError(f"Failed to get the base position of plant(s) {ids}: {e}")
6234
6235 def setPlantBasePosition(self, plant_id: int, base_position: vec3) -> None:
6236 """
6237 Set the base position of a plant, moving the plant if it has already been built.
6238
6239 For a plant with no shoots, this only stores the position, and the first shoot added
6240 with :meth:`addBaseStemShoot` is placed there. For a plant that already has shoots, the
6241 whole plant is translated by the difference between the new and old base position --
6242 its geometry, stored organ positions, per-plant attraction points and cached shading
6243 state -- and later growth continues from the moved position.
6244
6245 Note:
6246 Do not also translate the plant's objects in the Context (e.g. with
6247 ``Context.translateObject``), or the plant will be moved twice. This cannot be
6248 called from inside a phytomer creation or callback function.
6249
6250 Args:
6251 plant_id: ID of the plant instance
6252 base_position: New base position (vec3)
6253
6254 Raises:
6255 ValueError: If ``plant_id`` is not a non-negative int or ``base_position`` is not a vec3
6256 PlantArchitectureError: If the plant does not exist
6257 RuntimeError: If the native library predates this binding
6258
6259 Example:
6260 >>> plant_id = plantarch.addPlantInstance(vec3(0, 0, 0), 0)
6261 >>> plantarch.setPlantBasePosition(plant_id, vec3(1, 2, 0))
6262 """
6263 plant_id = self._validatePlantIdentifier(plant_id)
6264 if not isinstance(base_position, vec3):
6265 raise ValueError(f"Base position must be a vec3, got {type(base_position).__name__}")
6267 try:
6268 plantarch_wrapper.setPlantBasePosition(
6269 self._plantarch_ptr, plant_id, (base_position.x, base_position.y, base_position.z))
6270 except Exception as e:
6271 raise PlantArchitectureError(f"Failed to set the base position of plant {plant_id}: {e}")
6273 def getPlantInternodeObjectIDs(self, plant_id: int, shoot_type_label: Optional[str] = None) -> List[int]:
6274 """
6275 Get the object IDs of a plant's internode (tube) objects.
6276
6277 Each shoot is one tube object spanning all its internodes, so the list has one entry
6278 per shoot that has geometry. The structural counterpart to
6279 :meth:`getPlantLeafObjectIDs`.
6280
6281 Args:
6282 plant_id: ID of the plant instance
6283 shoot_type_label: If given, only internodes of shoots of this type
6284 (e.g. ``"stem"``) are returned
6285
6286 Returns:
6287 List of internode object IDs
6288
6289 Raises:
6290 ValueError: If ``plant_id`` is not a non-negative int or ``shoot_type_label`` is
6291 not a non-empty str
6292 PlantArchitectureError: If the plant does not exist, or no shoot on it has the
6293 given shoot type label
6294 RuntimeError: If the native library predates this binding
6295
6296 Example:
6297 >>> stem_ids = plantarch.getPlantInternodeObjectIDs(plant_id)
6298 """
6299 plant_id = self._validatePlantIdentifier(plant_id)
6300 if shoot_type_label is not None and (not isinstance(shoot_type_label, str) or not shoot_type_label):
6301 raise ValueError(
6302 f"Shoot type label must be a non-empty str, got {type(shoot_type_label).__name__}")
6304 try:
6305 return plantarch_wrapper.getPlantInternodeObjectIDs(self._plantarch_ptr, plant_id, shoot_type_label)
6306 except Exception as e:
6307 raise PlantArchitectureError(f"Failed to get internode object IDs for plant {plant_id}: {e}")
6308
6309 def _plantAngleDistribution(self, fn_name: str, plant_ids, num_bins, normalize, what: str) -> List[float]:
6310 multi = not isinstance(plant_ids, int) or isinstance(plant_ids, bool)
6311 ids = self._validatePlantIdList(plant_ids) if multi else [self._validatePlantIdentifier(plant_ids)]
6312 if isinstance(num_bins, bool) or not isinstance(num_bins, int) or num_bins < 1:
6313 raise ValueError(f"num_bins must be a positive int, got {num_bins!r}")
6314 if not isinstance(normalize, bool):
6315 raise ValueError(f"normalize must be a bool, got {type(normalize).__name__}")
6317 try:
6318 return getattr(plantarch_wrapper, fn_name)(self._plantarch_ptr, ids, num_bins, normalize)
6319 except Exception as e:
6320 raise PlantArchitectureError(f"Failed to get the leaf {what} distribution of plant(s) {ids}: {e}")
6321
6322 def getPlantLeafInclinationAngleDistribution(self, plant_ids: Union[int, List[int]], num_bins: int,
6323 normalize: bool = True) -> List[float]:
6324 """
6325 Get the leaf-area-weighted leaf inclination angle distribution of one or more plants.
6326
6327 Each leaf blade facet adds its area to the bin of its inclination (the angle between
6328 the facet normal and vertical, folded into 0-90 degrees). Petiolules are excluded.
6329 Several plants are pooled into one histogram.
6330
6331 Args:
6332 plant_ids: A single plant ID, or a sequence of plant IDs to pool
6333 num_bins: Number of histogram bins, evenly spaced between 0 and 90 degrees
6334 normalize: If True (default), the bins sum to 1; otherwise they hold leaf area (m^2).
6335 A plant with no leaves gives all-zero bins either way.
6336
6337 Returns:
6338 List of ``num_bins`` values
6339
6340 Raises:
6341 ValueError: If a plant ID is invalid, the sequence is empty, ``num_bins`` is not a
6342 positive int, or ``normalize`` is not a bool
6343 PlantArchitectureError: If a plant does not exist
6344 RuntimeError: If the native library predates this binding
6345
6346 Example:
6347 >>> plantarch.getPlantLeafInclinationAngleDistribution(plant_id, 9)
6348 """
6349 return self._plantAngleDistribution("getPlantLeafInclinationAngleDistribution", plant_ids,
6350 num_bins, normalize, "inclination")
6351
6352 def getPlantLeafAzimuthAngleDistribution(self, plant_ids: Union[int, List[int]], num_bins: int,
6353 normalize: bool = True) -> List[float]:
6354 """
6355 Get the leaf-area-weighted leaf azimuth angle distribution of one or more plants.
6356
6357 Each leaf blade facet adds its area to the bin of its normal's azimuth (0-360
6358 degrees). Petiolules are excluded. Several plants are pooled into one histogram.
6359
6360 Args:
6361 plant_ids: A single plant ID, or a sequence of plant IDs to pool
6362 num_bins: Number of histogram bins, evenly spaced between 0 and 360 degrees
6363 normalize: If True (default), the bins sum to 1; otherwise they hold leaf area (m^2).
6364 A plant with no leaves gives all-zero bins either way.
6365
6366 Returns:
6367 List of ``num_bins`` values
6368
6369 Raises:
6370 ValueError: If a plant ID is invalid, the sequence is empty, ``num_bins`` is not a
6371 positive int, or ``normalize`` is not a bool
6372 PlantArchitectureError: If a plant does not exist
6373 RuntimeError: If the native library predates this binding
6374
6375 Example:
6376 >>> plantarch.getPlantLeafAzimuthAngleDistribution([p1, p2], 12)
6377 """
6378 return self._plantAngleDistribution("getPlantLeafAzimuthAngleDistribution", plant_ids,
6379 num_bins, normalize, "azimuth")
6380
6381 def resamplePhytomerParameters(self, phytomer_parameters: Union[dict, PhytomerParameters]
6382 ) -> Union[dict, PhytomerParameters]:
6383 """
6384 Draw one realization of a set of phytomer parameters.
6385
6386 Mirrors the native ``PhytomerParameters::resample()``, which each phytomer calls
6387 once when it is created (helios-core 1.3.89+) to draw its own values of every
6388 distributed parameter. The draw uses the Context's random generator, so it follows
6389 ``Context.seedRandomGenerator()``. Every field with a distribution (uniform, normal,
6390 weibull, discrete values) comes back as a constant holding the value drawn for it;
6391 constant fields, and fields that are not random parameters, are returned unchanged
6392 and consume no random draws. The input is not modified.
6393
6394 Args:
6395 phytomer_parameters: A :class:`pyhelios.plant_architecture_params.PhytomerParameters`
6396 or its dict form (e.g. ``getCurrentShootParameters(label)["phytomer_parameters"]``)
6397
6398 Returns:
6399 The realization, of the same type as the input
6400
6401 Raises:
6402 ValueError: If ``phytomer_parameters`` is not a dict or PhytomerParameters
6403 PlantArchitectureError: If the parameters cannot be parsed natively
6404 RuntimeError: If the native library predates helios-core v1.3.89
6405 """
6406 typed = isinstance(phytomer_parameters, PhytomerParameters)
6407 if typed:
6408 params_dict = phytomer_parameters.to_dict()
6409 elif isinstance(phytomer_parameters, dict):
6410 params_dict = phytomer_parameters
6411 else:
6412 raise ValueError(
6413 f"Phytomer parameters must be a dict or PhytomerParameters, got "
6414 f"{type(phytomer_parameters).__name__}")
6416 try:
6417 result = plantarch_wrapper.resamplePhytomerParameters(self.context.context, params_dict)
6418 except Exception as e:
6419 raise PlantArchitectureError(f"Failed to resample phytomer parameters: {e}")
6420 return PhytomerParameters.from_dict(result) if typed else result
6421
6422 @staticmethod
6423 def _validateStemFraction(stem_fraction) -> float:
6424 if isinstance(stem_fraction, bool) or not isinstance(stem_fraction, (int, float)) \
6425 or not 0.0 <= stem_fraction <= 1.0:
6426 raise ValueError(f"Stem fraction must be a number in [0, 1], got {stem_fraction!r}")
6427 return float(stem_fraction)
6428
6429 @staticmethod
6430 def _validatePlantModelName(plant_model_name) -> None:
6431 if not isinstance(plant_model_name, str) or not plant_model_name:
6432 raise ValueError(
6433 f"Plant model name must be a non-empty str, got {type(plant_model_name).__name__}")
6434
6435 @staticmethod
6436 def _validatePlantIdentifier(plant_id) -> int:
6437 """Reject a non-int or negative plant ID, returning it as a plain int."""
6438 if isinstance(plant_id, bool) or not isinstance(plant_id, int):
6439 raise ValueError(
6440 f"Plant ID must be a non-negative int, got {type(plant_id).__name__}")
6441 if plant_id < 0:
6442 raise ValueError("Plant ID must be non-negative")
6443 return int(plant_id)
6444
6445 @classmethod
6446 def _validatePlantIdList(cls, plant_ids) -> List[int]:
6447 """Coerce a sequence of plant IDs, rejecting an empty or malformed one."""
6448 if isinstance(plant_ids, (str, bytes)) or not hasattr(plant_ids, '__iter__'):
6449 raise ValueError(
6450 f"Plant IDs must be an int or a sequence of ints, got "
6451 f"{type(plant_ids).__name__}")
6452 ids = [cls._validatePlantIdentifier(p) for p in plant_ids]
6453 if not ids:
6454 raise ValueError("Plant ID list must not be empty")
6455 return ids
6456
6457 @staticmethod
6458 def _validateNodeIndex(node_index, name: str = "Node index") -> int:
6459 """Reject a non-int or negative phytomer index."""
6460 if isinstance(node_index, bool) or not isinstance(node_index, int):
6461 raise ValueError(
6462 f"{name} must be a non-negative int, got {type(node_index).__name__}")
6463 if node_index < 0:
6464 raise ValueError(f"{name} must be non-negative")
6465 return int(node_index)
6466
6467 @classmethod
6468 def _validatePetioleIndex(cls, petiole_index, name: str = "Petiole index") -> int:
6469 """Reject a non-int or negative petiole/leaf index."""
6470 return cls._validateNodeIndex(petiole_index, name=name)
6472 @staticmethod
6473 def _validateScaleFactor(scale_factor, name: str = "Scale factor") -> float:
6474 """Reject a non-numeric or non-positive scale factor."""
6475 if isinstance(scale_factor, bool) or not isinstance(scale_factor, (int, float)):
6476 raise ValueError(
6477 f"{name} must be a positive number, got {type(scale_factor).__name__}")
6478 if not scale_factor > 0:
6479 raise ValueError(f"{name} must be positive, got {scale_factor}")
6480 return float(scale_factor)
6481
6482 def is_available(self) -> bool:
6483 """
6484 Check if PlantArchitecture is available in current build.
6485
6486 Returns:
6487 True if plugin is available, False otherwise
6488 """
6490
6491
6492# Convenience function
6493def create_plant_architecture(context: Context) -> PlantArchitecture:
6494 """
6495 Create PlantArchitecture instance with context.
6496
6497 Args:
6498 context: Helios Context
6500 Returns:
6501 PlantArchitecture instance
6502
6503 Example:
6504 >>> context = Context()
6505 >>> plantarch = create_plant_architecture(context)
6506 """
6507 return PlantArchitecture(context)
Raised when PlantArchitecture operations fail.
High-level interface for plant architecture modeling and procedural plant generation.
List[int] getAllLeafUUIDs(self)
Get UUIDs of every leaf primitive in the model.
None clearGrowthFrames(self, int plant_id)
Clear stored growth animation frames for a plant.
None writePlantMeshVertices(self, int plant_id, Union[str, Path] filename)
Write all plant mesh vertices to file for external processing.
float getPhytomerAge(self, int plant_id, int shoot_id, int node_index)
Age of one phytomer in days, counted from its creation.
None removeShootLeaves(self, int plant_id, int shoot_id)
Remove all leaves from a single shoot.
None setCollisionRelevantOrgans(self, bool include_internodes=False, bool include_leaves=True, bool include_petioles=False, bool include_flowers=False, bool include_fruit=False)
Specify which plant organs participate in collision detection.
List[float] _plantAngleDistribution(self, str fn_name, plant_ids, num_bins, normalize, str what)
None appendAttractionPoints(self, List[vec3] points, Optional[int] plant_id=None)
Add to the current attraction point set.
None pruneBranch(self, int plant_id, int shoot_id, int node_index)
Prune a shoot at a node, removing that node and everything distal to it.
List[str] getPlantModelBuildParameterNames(self, str plant_model)
Get the names of the build parameters a library plant model accepts.
bool is_available(self)
Check if PlantArchitecture is available in current build.
None registerGrowthFrame(self, int plant_id, float min_segment_length=0.001)
Capture a snapshot of the plant's geometry as a growth animation frame.
List[int] _childShootIDsOrEmpty(self, int plant_id, int shoot_id)
Return a shoot's child IDs, or an empty list if it no longer resolves.
bool isPlantDormant(self, int plant_id)
Check whether a plant is dormant.
int addShootFromNodePositions(self, int plant_id, int parent_shoot_id, int parent_node_index, List[vec3] node_positions, List[float] node_radii, str shoot_type_label, Optional[str] growth_shoot_type_label=None, int petiole_index=0)
Add a shoot whose internode geometry is prescribed by measured node positions.
List[int] pruneShootSubtree(self, int plant_id, int shoot_id, bool include_self=True)
Prune a shoot and everything growing off it.
List[float] getPlantLeafAzimuthAngleDistribution(self, Union[int, List[int]] plant_ids, int num_bins, bool normalize=True)
Get the leaf-area-weighted leaf azimuth angle distribution of one or more plants.
None removeShootVegetativeBuds(self, int plant_id, int shoot_id)
Mark every vegetative bud on a single shoot as dead.
List[int] getPathToRoot(self, int plant_id, int shoot_id)
Get the chain of shoots connecting a shoot to the base stem shoot.
None _raise_pending_callback_error(self, Optional[BaseException] native_error=None)
Raise, and clear, an exception stored by a phytomer creation or callback function.
int getShootRank(self, int plant_id, int shoot_id)
Get the branching rank of a shoot.
int _validatePlantIdentifier(plant_id)
Reject a non-int or negative plant ID, returning it as a plain int.
Union[dict, PhytomerParameters] resamplePhytomerParameters(self, Union[dict, PhytomerParameters] phytomer_parameters)
Draw one realization of a set of phytomer parameters.
List[int] getPlantLeafObjectIDs(self, int plant_id)
Get object IDs for all leaf objects on a specific plant.
List[int] _pruneShallowest(self, int plant_id, target_shoot_ids)
Prune every target that something shallower has not already removed.
None removePlantLeaves(self, int plant_id)
Remove all leaves from every shoot on a plant.
None setPetioleNodePositions(self, int plant_id, int shoot_id, int node_index, int petiole_index, List[vec3] node_positions, List[float] node_radii)
Prescribe the path of a petiole on an existing phytomer from measured node positions.
_phytomerCall(self, str description, int plant_id, int shoot_id, int node_index, fn, *args)
Call a per-phytomer wrapper, turning an out-of-range index into ValueError.
None scalePetioleMaxLength(self, int plant_id, int shoot_id, int node_index, float scale_factor)
Scale the fully-elongated length every petiole on a phytomer is growing toward.
List[int] getAllFlowerUUIDs(self)
Get UUIDs of every flower primitive in the model.
bool isLeafAngleDistributionTrackingEnabled(self, int plant_id)
Whether a plant's leaf angles are being steered toward a prescribed distribution.
None makePlantDormant(self, int plant_id)
Force a plant into a dormant state immediately.
None disableAttractionPoints(self, Optional[int] plant_id=None)
Stop steering growth toward attraction points.
None advanceTime(self, float dt, Optional[int] plant_id=None, Optional[List[int]] plant_ids=None, Optional[int] years=None)
Advance time for plant growth and development.
List[int] pruneShootsByRank(self, int plant_id, int min_rank)
Prune every shoot at or above a given branching rank.
Dict[int, List[int]] getShootHierarchyMap(self, int plant_id)
Get the parent-to-children structure of a plant.
List[float] getShootInternodeRadii(self, int plant_id, int shoot_id)
Get the per-vertex woody internode radii of a shoot.
float getLeafBladeArea(self, int leaf_object_id)
One-sided blade area (m²) of a single leaf object.
None setPlantNitrogenParameters(self, int plant_id, Union[dict, NitrogenParameters] parameters)
Set nitrogen-model parameters for a plant.
List[str] listShootTypeLabels(self, Optional[str] plant_model=None, Optional[int] plant_id=None)
Get the shoot type labels defined for a plant model.
List[int] getAllPlantObjectIDs(self, int plant_id)
Get all object IDs for a specific plant.
None _setLibraryPhytomerFunction(self, str shoot_type_label, str function_name, bool creation)
int _validatePetioleIndex(cls, petiole_index, str name="Petiole index")
Reject a non-int or negative petiole/leaf index.
List[int] getChildShootIDs(self, int plant_id, int shoot_id)
Get the shoots that grew directly out of a shoot.
int addChildShoot(self, int plant_id, int parent_shoot_id, int parent_node_index, int current_node_number, AxisRotation shoot_base_rotation, float internode_radius, float internode_length_max, float internode_length_scale_factor_fraction, float leaf_scale_factor_fraction, float radius_taper, str shoot_type_label, int petiole_index=0)
Add a child shoot at an axillary bud position on a parent shoot.
bool doesPlantModelDeclareLeafInclinationDistribution(self, str plant_model_name)
Whether a library plant model declares a leaf inclination distribution.
None disableMessages(self)
Suppress standard output from the plantarchitecture plugin.
None _validate_attraction_points(points)
Reject point sets the native layer would misread or silently ignore.
List[int] buildPlantCanopyFromLibrary(self, vec3 canopy_center, vec2 plant_spacing, int2 plant_count, float age, float germination_rate=1.0, Optional[dict] build_parameters=None)
Build a canopy of regularly spaced plants from the currently loaded library model.
None defineShootType(self, str shoot_type_label, Union[dict, ShootParameters] parameters)
Define a custom shoot type with specified parameters.
_validateNodesAndRadii(node_positions, node_radii, str positions_name, str radii_name)
Validate a measured node path and return it as plain lists for the ctypes layer.
bool isShootPruned(self, int plant_id, int shoot_id)
Report whether a shoot has been pruned away entirely.
List[float] getPlantLeafAreas(self, int plant_id)
Get the built one-sided surface area of every leaf on a plant.
List[int] getTerminalShootIDs(self, int plant_id)
Get the plant's terminal shoots – those carrying no child shoots.
None enableSolidObstacleAvoidance(self, List[int] obstacle_UUIDs, float avoidance_distance=0.5, bool enable_fruit_adjustment=False, bool enable_obstacle_pruning=False)
Enable hard obstacle avoidance for specified geometry.
List[int] getAllDescendantShootIDs(self, int plant_id, int shoot_id)
Get every shoot descending from a shoot.
bool _isPrunedOrGone(self, int plant_id, int shoot_id)
Whether a shoot has been pruned away or no longer resolves at all.
BudState _validateBudState(state)
Coerce a BudState (or its int value) and reject anything else.
None disableLeafAngleDistributionTracking(self, int plant_id)
Stop steering a plant's leaf angles toward a prescribed distribution.
vec3 getInternodeAxisVector(self, int plant_id, int shoot_id, int node_index, float stem_fraction)
Unit direction of one internode at a fraction of its length.
None scaleLeafSizeMax(self, int plant_id, int shoot_id, int node_index, float scale_factor)
Scale the size every leaf on a phytomer is expanding toward, leaving the blades where they are.
List[int] getPlantInternodeObjectIDs(self, int plant_id, Optional[str] shoot_type_label=None)
Get the object IDs of a plant's internode (tube) objects.
vec3 getLeafBasePosition(self, int plant_id, int shoot_id, int node_index, int petiole_index, int leaf_index)
Position where one leaf (leaflet) attaches to its petiole.
List[int] _validatePlantIdList(cls, plant_ids)
Coerce a sequence of plant IDs, rejecting an empty or malformed one.
None scaleInternodeMaxLength(self, int plant_id, int shoot_id, int node_index, float scale_factor)
Scale the fully-elongated length of one existing internode.
Dict[int, List[int]] getShootIDsByRank(self, int plant_id)
Group a plant's shoot IDs by branching rank.
List[float] _plantFloatVector(self, str wrapper_fn_name, int plant_id, str description)
Shared body for the per-organ built-geometry queries.
List[float] getPlantLeafInclinations(self, int plant_id)
Get the inclination angle of every leaf on a plant.
float getPlantHeight(self, int plant_id)
Get the height of a plant in meters.
float getShadowLightExposureAtPoint(self, int plant_id, vec3 position)
Local light exposure at a point from a plant's shadow-propagation grid.
List[float] getPlantInternodeLengths(self, int plant_id)
Get the built length of every internode on a plant.
float getPetioleFlexibility(self, int plant_id, int shoot_id, int node_index)
Bending flexibility of the petioles of one phytomer.
List[float] getPlantLeafInclinationAngleDistribution(self, Union[int, List[int]] plant_ids, int num_bins, bool normalize=True)
Get the leaf-area-weighted leaf inclination angle distribution of one or more plants.
int getGrowthFrameCount(self, int plant_id)
Get the number of registered growth frames for a plant.
List[int] pruneTerminalShoots(self, int plant_id, int stride=2)
Thin a plant by pruning every stride-th terminal shoot.
None deletePlantInstance(self, int plant_id)
Delete a plant instance and all associated geometry.
None setShootPhyllotacticAngle(self, int plant_id, int shoot_id, float mean_deg, Optional[float] sd_deg=None)
Set the phyllotactic angle that one shoot's next phytomers are created with.
List[int] readPlantStructureXML(self, Union[str, Path] filename, bool quiet=False)
Load plant structure from XML file.
None enableMessages(self)
Re-enable standard output from the plantarchitecture plugin.
None setPhytomerCallbackFunction(self, str shoot_type_label, Optional[Callable[[int, int, int, float], None]] callback)
Install a Python function that the plugin calls for every phytomer of a shoot type on every time step...
int getShootDepth(self, int plant_id, int shoot_id)
Get the number of shoots between a shoot and the base stem shoot.
List[int] getAllInternodeUUIDs(self)
Get UUIDs of every internode primitive in the model.
setCancelFlag(self, cancel_flag)
Register an external cancellation flag polled during long plant builds.
None breakPlantDormancy(self, int plant_id)
Break dormancy for all shoots on a plant, returning it to an active state.
List[int] getAllPlantIDs(self)
Get IDs of every plant instance in the model.
List[int] getAllFruitUUIDs(self)
Get UUIDs of every fruit primitive in the model.
None _removeShootOrgans(self, str wrapper_fn_name, int plant_id, int shoot_id, str organ_description)
Shared body for the three shoot-level organ removal methods.
__exit__(self, exc_type, exc_val, exc_tb)
Context manager exit - cleanup resources.
int getPlantLeafCount(self, int plant_id)
Get the number of leaf objects on a plant.
float getPlantAge(self, int plant_id)
Get the current age of a plant in days.
int buildPlantInstanceFromLibrary(self, vec3 base_position, float age, Optional[dict] build_parameters=None)
Build a plant instance from the currently loaded library model.
None scaleLeafPrototypeScale(self, int plant_id, int shoot_id, int node_index, float scale_factor, Optional[int] petiole_index=None)
Scale a phytomer's leaves now: both the blades' present size and the size they grow toward.
str getPlantName(self, int plant_id)
Get the name of the plant model a plant was built from.
getCurrentShootParameters(self, str shoot_type_label, bool return_typed=False)
Get current shoot parameters for a shoot type.
None writePlantStructureXML(self, int plant_id, Union[str, Path] filename)
Save plant structure to XML file for later loading.
List[vec3] getPlantLeafBases(self, int plant_id)
Get the attachment base position of every leaf on a specific plant.
None harvestPlant(self, int plant_id)
Harvest a plant by removing its flowers and fruit.
None setStaticObstacles(self, List[int] target_UUIDs)
Mark geometry as static obstacles for collision detection optimization.
List[int] getAllShootIDs(self, int plant_id)
Get the IDs of all shoots belonging to a plant.
None enableGroundClipping(self, float ground_height=0.0)
Enable automatic removal of plant organs that fall below the ground plane.
List[int] getAllPetioleUUIDs(self)
Get UUIDs of every petiole primitive in the model.
List[int] getAllPlantUUIDs(self, int plant_id, bool include_hidden=False)
Get all primitive UUIDs for a specific plant.
Dict[str, float] getPlantBuildParameters(self, int plant_id)
Get the build parameters a plant was built with.
None setPhytomerCreationFunction(self, str shoot_type_label, Optional[Callable[[int, int, int, int, int, int, float], None]] callback)
Install a Python function that the plugin calls once for every new phytomer of a shoot type.
float getInternodeLength(self, int plant_id, int shoot_id, int node_index)
Present length of one internode in meters, measured along its node positions.
List[int] getPlantPeduncleObjectIDs(self, int plant_id)
Get object IDs for all peduncle objects on a specific plant.
List[int] getPlantPetioleObjectIDs(self, int plant_id)
Get object IDs for all petiole objects on a specific plant.
None writePlantStructureUSD(self, int plant_id, Union[str, Path] filename, float elastic_modulus=5e9, float wood_density=800.0, *, float damping_time_constant=0.02, float static_friction=0.5, float dynamic_friction=0.3, float restitution=0.1, float organ_spring_stiffness=10.0, float organ_spring_damping=1.0, float leaf_mass_per_area=0.05, float fruit_mass=0.01, float flower_mass=0.002, int solver_position_iterations=32, float min_segment_length=0.001, float armature_stability_ratio=8.0, float physics_steps_per_second=60.0, Optional[float] damping_ratio=None)
Export plant structure as a USD articulated rigid body for NVIDIA IsaacSim physics.
float getPlantStemHeight(self, int plant_id)
Get the height of a plant's main stem in meters.
None loadPlantModelFromLibrary(self, str plant_label)
Load a plant model from the built-in library.
None disableCollisionDetection(self)
Disable collision detection for plant growth.
None setPlantPhenologicalThresholds(self, int plant_id, float time_to_dormancy_break, float time_to_flower_initiation, float time_to_flower_opening, float time_to_fruit_set, float time_to_fruit_maturity, float time_to_dormancy, float max_leaf_lifespan=1e6, bool is_evergreen=False)
Set phenological timing thresholds for plant developmental stages.
getDefaultNitrogenParameters(self, bool return_typed=False)
Get a default-constructed set of nitrogen-model parameters.
str determinePhenologyStage(self, int plant_id)
Determine the phenological stage of a plant.
getDefaultCarbohydrateParameters(self, bool return_typed=False)
Get a default-constructed set of carbohydrate-model parameters.
List[str] getLibraryPhytomerCreationFunctionNames()
List the library phytomer creation functions that :meth:setPhytomerCreationFunction accepts by name.
List[int] getPlantFlowerObjectIDs(self, int plant_id)
Get object IDs for all flower (inflorescence) objects on a specific plant.
int addBaseStemShoot(self, int plant_id, int current_node_number, AxisRotation base_rotation, float internode_radius, float internode_length_max, float internode_length_scale_factor_fraction, float leaf_scale_factor_fraction, float radius_taper, str shoot_type_label)
Add a base stem shoot to a plant instance (main trunk/stem).
int getShootNodeCount(self, int plant_id, int shoot_id)
Get the current number of nodes (phytomers) on one shoot.
float getPlantMaxAge(self, int plant_id)
Get the maximum age of a plant, beyond which it stops growing.
float getSeasonPeakLightExposureAtPoint(self, int plant_id, vec3 position)
Local light exposure at a point under the plant's most recent seasonal peak canopy.
int _validateNodeIndex(node_index, str name="Node index")
Reject a non-int or negative phytomer index.
None enableLeafAzimuthAngleDistributionTracking(self, int plant_id, float eccentricity, float ellipse_rotation_degrees, float lambda_degrees)
Steer leaf azimuth toward an ellipsoidal distribution as the plant grows, leaving inclination to the ...
None setPlantCarbohydrateParameters(self, int plant_id, Union[dict, CarbohydrateParameters] parameters)
Set carbohydrate-model parameters for a plant.
None setPetioleLeafGeometry(self, int plant_id, int shoot_id, int node_index, int petiole_index, List[vec3] leaf_bases, List[AxisRotation] leaf_rotations, List[float] leaf_sizes)
Prescribe the base position, orientation and size of every leaf on a petiole.
None setPetioleAndLeafScaleFraction(self, int plant_id, int shoot_id, int node_index, int petiole_index, float petiole_scale_factor_fraction, float leaf_scale_factor_fraction)
Set a petiole's length and its leaves' size together, each as a fraction of its own fully-elongated v...
List[int] getAllObjectIDs(self)
Get object IDs of every plant compound object in the model.
List[List[int]] getPhytomerLeafObjectIDs(self, int plant_id, int shoot_id, int node_index)
Object IDs of one phytomer's leaves, grouped by petiole.
_makePhytomerCallbackTrampoline(self, callback)
Wrap callback so an exception is stored for re-raising instead of being lost inside ctypes.
None _validateShootIdentifiers(int plant_id, int shoot_id)
Reject non-int and negative plant/shoot identifiers.
Optional[str] getPhytomerCreationFunctionName(self, str shoot_type_label)
Get the name of the library creation function a shoot type carries.
None bendPetioleUnderLeafWeight(self, int plant_id, int shoot_id, int node_index, int petiole_index, bool include_posed_leaves=False)
Bend one petiole, and the leaves it carries, under the weight of its leaflets.
List[float] getPetioleRadii(self, int plant_id, int shoot_id, int node_index, int petiole_index)
Radius in meters of one petiole at each of its centerline vertices (see :meth:getPetioleVertices).
None setInternodeMaxLength(self, int plant_id, int shoot_id, int node_index, float length)
Set the fully-elongated length of one existing internode.
Dict[str, Any] getShoot(self, int plant_id, int shoot_id)
Get a read-only view of a shoot's topology.
List[int] getPlantCollisionRelevantObjectIDs(self, int plant_id)
Get object IDs of collision-relevant geometry for a specific plant.
bool isShootGeometryPrescribed(self, int plant_id, int shoot_id)
Report whether a shoot's existing geometry was prescribed by the caller rather than generated.
None setPetioleScaleFraction(self, int plant_id, int shoot_id, int node_index, int petiole_index, float petiole_scale_factor_fraction)
Set one petiole's current length as a fraction of its fully-elongated length, leaving the leaves it c...
None enableLeafAngleDistributionTracking(self, plant_ids, float beta_mu_inclination, float beta_nu_inclination, float eccentricity, float ellipse_rotation_degrees, float lambda_degrees)
Steer leaf inclination and azimuth toward a prescribed distribution as the plant grows.
None setLeafAngleDistribution(self, plant_ids, float beta_mu_inclination, float beta_nu_inclination, Optional[float] eccentricity=None, float ellipse_rotation_degrees=0.0)
Re-aim every leaf of a finished plant so that its leaf angles follow a prescribed distribution.
None optionalOutputObjectData(self, Union[str, List[str]] object_data_labels)
Enable optional output object data to be written to the Context.
List[int] getShootChildIDs(self, int plant_id, int shoot_id)
Get the child shoot IDs of a shoot (flattened across parent node indices).
float getInternodeRadius(self, int plant_id, int shoot_id, int node_index)
Radius in meters at the base of one internode.
None setPlantMaxAge(self, int plant_id, float max_age)
Set the maximum age of a plant, beyond which it stops growing.
int getShootVegetativeBudCount(self, int plant_id, int shoot_id, Optional[BudState] state=None)
Count a shoot's axillary vegetative buds, summed over all phytomers and petioles.
int appendShoot(self, int plant_id, int parent_shoot_id, int current_node_number, AxisRotation base_rotation, float internode_radius, float internode_length_max, float internode_length_scale_factor_fraction, float leaf_scale_factor_fraction, float radius_taper, str shoot_type_label)
Append a shoot to the end of an existing shoot.
None updateAttractionPoints(self, List[vec3] points, Optional[int] plant_id=None)
Replace the current attraction point set.
None enableSoftCollisionAvoidance(self, Optional[List[int]] target_object_UUIDs=None, Optional[List[int]] target_object_IDs=None, bool enable_petiole_collision=False, bool enable_fruit_collision=False)
Enable soft collision avoidance for procedural plant growth.
None setLeafNormal(self, int plant_id, int shoot_id, int node_index, int petiole_index, int leaf_index, vec3 target_normal)
Re-aim one leaf so its blade faces a given direction.
None _validate_build_parameters(self, Optional[dict] build_parameters)
Validate build parameters against the names the loaded model declares natively.
None setPetioleFlexibility(self, int plant_id, int shoot_id, int node_index, float flexibility)
Replace the bending flexibility of the petioles of one existing phytomer.
None terminateApicalBud(self, int plant_id, int shoot_id)
Stop a shoot's apex from adding any further phytomers.
tuple getPlantModelLeafInclinationDistribution(self, str plant_model_name)
Beta leaf inclination distribution (mu, nu) that a library plant model declares.
Union[vec3, List[vec3]] getPlantBasePosition(self, Union[int, List[int]] plant_ids)
Get the base position of one plant or of several plants.
_check_context_alive(self)
Raise if the owning Context has been destroyed (see Context.check_context_alive).
Optional[str] _getLibraryPhytomerFunctionName(self, str shoot_type_label, bool creation)
None _reject_inside_phytomer_callback(self, str method_name)
Raise if called from a Python phytomer creation or callback function.
List[int] getAllUUIDs(self)
Get UUIDs of every plant primitive in the model.
setProgressCallback(self, callback)
Set a callback to receive progress updates during long-running operations.
None writePlantGrowthUSD(self, int plant_id, Union[str, Path] filename, float seconds_per_frame=1.0)
Export all registered growth frames as a time-sampled USD animation file.
List[str] getAvailablePlantModels(self)
Get list of all available plant models in the library.
None setShootInternodeLengthMax(self, int plant_id, int shoot_id, float internode_length_max)
Set the target length of internodes grown at the apex of an existing shoot.
None disablePlantPhenology(self, int plant_id)
Disable phenological progression for a plant.
None setPlantBasePosition(self, int plant_id, vec3 base_position)
Set the base position of a plant, moving the plant if it has already been built.
List[int] getPlantFruitObjectIDs(self, int plant_id)
Get object IDs for all fruit objects on a specific plant.
List[int] _liveChildShootIDs(self, int plant_id, int shoot_id)
Child shoot IDs that have not been pruned away, ascending.
List[vec3] getInternodeNodePositions(self, int plant_id, int shoot_id, int node_index)
Positions of one internode's nodes, from its base to its tip.
None removeShootFloralBuds(self, int plant_id, int shoot_id)
Kill all floral buds on a single shoot.
_makePhytomerCreationTrampoline(self, callback)
Wrap callback so an exception is stored for re-raising instead of being lost inside ctypes.
None writeQSMCylinderFile(self, int plant_id, Union[str, Path] filename)
Export plant structure in TreeQSM cylinder format.
int addPlantInstance(self, vec3 base_position, float current_age)
Create an empty plant instance for custom plant building.
None recordPetioleRestShape(self, int plant_id, int shoot_id, int node_index, int petiole_index)
Record one petiole's current centerline as its undeformed rest shape.
List[tuple] getShootInternodeVertices(self, int plant_id, int shoot_id)
Get the woody internode polyline vertices of a shoot as a list of (x, y, z) tuples.
__del__(self)
Destructor to ensure C++ resources freed even without 'with' statement.
int getParentShootID(self, int plant_id, int shoot_id)
Get the ID of the shoot a shoot grew from.
None setAttractionParameters(self, float view_half_angle_deg, float look_ahead_distance, float attraction_weight, float obstacle_reduction_factor=0.75, Optional[int] plant_id=None)
Tune how strongly attraction points steer growth.
float getPlantAvailableNitrogen(self, int plant_id)
Nitrogen (g N) in a plant's available pool, awaiting allocation to its organs.
None setPetioleLeafCount(self, int plant_id, int shoot_id, int node_index, int petiole_index, int leaf_count)
Change the number of leaves (leaflets) on one petiole of an existing phytomer.
None setPlantModelLeafInclinationDistribution(self, str plant_model_name, float beta_mu, float beta_nu)
Set or clear the Beta leaf inclination distribution of a library plant model.
float getPlantLeafArea(self, int plant_id)
Get the total leaf area of a plant in m².
float getPetioleLength(self, int plant_id, int shoot_id, int node_index, Optional[int] petiole_index=None)
Current length of a phytomer's petioles, measured along the centerline.
Optional[str] getPhytomerCallbackFunctionName(self, str shoot_type_label)
Get the name of the library per-timestep callback function a shoot type carries.
None enableLeafElevationAngleDistributionTracking(self, int plant_id, float beta_mu_inclination, float beta_nu_inclination, float lambda_degrees)
Steer leaf inclination toward a Beta distribution as the plant grows, leaving azimuth to the procedur...
_shootScalarQuery(self, str wrapper_fn_name, int plant_id, int shoot_id, str description)
Shared body for the per-shoot hierarchy accessors.
List[str] getLibraryPhytomerCallbackFunctionNames()
List the library per-timestep callback functions that :meth:setPhytomerCallbackFunction accepts by na...
__init__(self, Context context)
Initialize PlantArchitecture with a Helios context.
List[int] getAllPeduncleUUIDs(self)
Get UUIDs of every peduncle primitive in the model.
float _validateScaleFactor(scale_factor, str name="Scale factor")
Reject a non-numeric or non-positive scale factor.
None setSoftCollisionAvoidanceParameters(self, float view_half_angle_deg=80.0, float look_ahead_distance=0.1, int sample_count=256, float inertia_weight=0.4)
Configure parameters for soft collision avoidance algorithm.
None enableAttractionPoints(self, List[vec3] points, Optional[int] plant_id=None, Optional[float] view_half_angle_deg=None, float look_ahead_distance=0.1, float attraction_weight=0.6)
Steer shoot growth toward a set of target points.
List[vec3] getPetioleVertices(self, int plant_id, int shoot_id, int node_index, int petiole_index)
Centerline vertices of one petiole, from its base at the node to its tip.
vec3 getPetioleAxisVector(self, int plant_id, int shoot_id, int node_index, float stem_fraction, int petiole_index)
Unit direction of one petiole at a fraction of its length.
validate_vec3(value, name, func)
None _validate_build_parameters(Optional[dict] build_parameters, Optional[str] plant_model, Optional[List[str]] accepted=None)
Check build parameter types, and names against those the plant model accepts.
validate_int2(value, name, func)
str _resolve_user_path(Union[str, Path] filepath)
Convert relative paths to absolute paths before changing working directory.
_surfaces_phytomer_callback_errors(method)
Re-raise an exception from a Python phytomer creation or callback function out of the call that ran i...
validate_vec2(value, name, func)
PlantArchitecture create_plant_architecture(Context context)
Create PlantArchitecture instance with context.
is_plantarchitecture_available()
Check if PlantArchitecture plugin is available for use.
_plantarchitecture_working_directory()
Context manager that temporarily changes working directory to where PlantArchitecture assets are loca...