2High-level PlantArchitecture interface for PyHelios.
4This module provides a user-friendly interface to the plant architecture modeling
5capabilities with graceful plugin handling and informative error messages.
10from contextlib
import contextmanager
11from pathlib
import Path
12from typing
import List, Optional, Union, Dict, Any
14from .Context
import Context, check_context_alive
15from .plugins.registry
import get_plugin_registry, require_plugin
16from .wrappers
import UPlantArchitectureWrapper
as plantarch_wrapper
17from .wrappers.DataTypes
import vec3, vec2, int2, AxisRotation
19 from .validation.datatypes
import validate_vec3, validate_vec2, validate_int2
23 if hasattr(value,
'x')
and hasattr(value,
'y')
and hasattr(value,
'z'):
25 if isinstance(value, (list, tuple))
and len(value) == 3:
26 from .wrappers.DataTypes
import vec3
28 raise ValueError(f
"{name} must be vec3 or 3-element list/tuple")
31 if hasattr(value,
'x')
and hasattr(value,
'y'):
33 if isinstance(value, (list, tuple))
and len(value) == 2:
34 from .wrappers.DataTypes
import vec2
36 raise ValueError(f
"{name} must be vec2 or 2-element list/tuple")
39 if hasattr(value,
'x')
and hasattr(value,
'y'):
41 if isinstance(value, (list, tuple))
and len(value) == 2:
42 from .wrappers.DataTypes
import int2
44 raise ValueError(f
"{name} must be int2 or 2-element list/tuple")
45from .validation.core
import validate_positive_value
46from .assets
import get_asset_manager
47from .plant_architecture_params
import (
50 CarbohydrateParameters,
57logger = logging.getLogger(__name__)
65_BUILD_PARAMETERS_BY_MODEL: Dict[str, frozenset] = {
66 "almond": frozenset({
"trunk_height",
"num_scaffolds",
"scaffold_angle"}),
67 "almond_aldrich": frozenset({
"trunk_height",
"num_scaffolds",
"scaffold_angle"}),
68 "almond_wood_colony": frozenset({
"trunk_height",
"num_scaffolds",
"scaffold_angle"}),
69 "apple": frozenset({
"trunk_height",
"num_scaffolds",
"scaffold_angle"}),
70 "grapevine_VSP": frozenset({
"trunk_height",
"vine_spacing"}),
71 "grapevine_wye": frozenset(
72 {
"trunk_height",
"vine_spacing",
"cordon_spacing",
"catch_wire_height"}
74 "pistachio": frozenset({
"trunk_height",
"num_scaffolds",
"scaffold_angle"}),
75 "walnut": frozenset({
"trunk_height",
"num_scaffolds",
"scaffold_angle"}),
80_ALL_BUILD_PARAMETERS = frozenset().union(*_BUILD_PARAMETERS_BY_MODEL.values())
84 """Reject build parameter keys the loaded plant model will not read.
86 The native library looks each key up in a map and falls back to a default when it is
87 absent, so an unrecognized key is silently discarded. Raising here instead keeps a
88 misspelled or wrong-species parameter from being mistaken for one that took effect.
91 build_parameters: Mapping supplied by the caller, or None.
92 plant_model: Label of the currently loaded model, or None if no model has been
93 loaded through this instance.
96 ValueError: If build_parameters is not a dict of str -> number, or contains a key
97 the loaded model does not accept.
99 if build_parameters
is None:
102 if not isinstance(build_parameters, dict):
103 raise ValueError(
"build_parameters must be a dict or None")
105 for key, value
in build_parameters.items():
106 if not isinstance(key, str):
107 raise ValueError(
"build_parameters keys must be strings")
108 if isinstance(value, bool)
or not isinstance(value, (int, float)):
109 raise ValueError(
"build_parameters values must be numeric (int or float)")
111 if not build_parameters:
116 if plant_model
is not None:
117 accepted = _BUILD_PARAMETERS_BY_MODEL.get(plant_model, frozenset())
118 model_description = f
"Plant model '{plant_model}'"
120 accepted = _ALL_BUILD_PARAMETERS
121 model_description =
"No plant model has been loaded through this instance, so"
123 unknown = sorted(set(build_parameters) - accepted)
128 accepted_description = f
"accepts only: {', '.join(sorted(accepted))}"
130 accepted_description =
"accepts no build parameters"
133 f
"Unknown build parameter(s) {', '.join(repr(k) for k in unknown)}. "
134 f
"{model_description} {accepted_description}. "
135 f
"Unrecognized parameters are ignored by the native library, so they would "
136 f
"otherwise have no effect."
142 Convert relative paths to absolute paths before changing working directory.
144 This preserves the user's intended file location when the working directory
145 is temporarily changed for C++ asset access. Absolute paths are returned unchanged.
148 filepath: File path to resolve (string or Path object)
151 Absolute path as string
153 path = Path(filepath)
154 if not path.is_absolute():
155 return str(Path.cwd() / path)
162 Context manager that temporarily changes working directory to where PlantArchitecture assets are located.
164 PlantArchitecture C++ code uses hardcoded relative paths like "plugins/plantarchitecture/assets/textures/"
165 expecting assets relative to working directory. This manager temporarily changes to the build directory
166 where assets are actually located.
169 RuntimeError: If build directory or PlantArchitecture assets are not found, indicating a build system error.
173 asset_manager = get_asset_manager()
174 working_dir = asset_manager._get_helios_build_path()
176 if working_dir
and working_dir.exists():
177 plantarch_assets = working_dir /
'plugins' /
'plantarchitecture'
180 current_dir = Path(__file__).parent
181 packaged_build = current_dir /
'assets' /
'build'
183 if packaged_build.exists():
184 working_dir = packaged_build
185 plantarch_assets = working_dir /
'plugins' /
'plantarchitecture'
188 repo_root = current_dir.parent
189 build_lib_dir = repo_root /
'pyhelios_build' /
'build' /
'lib'
190 working_dir = build_lib_dir.parent
191 plantarch_assets = working_dir /
'plugins' /
'plantarchitecture'
193 if not build_lib_dir.exists():
195 f
"PyHelios build directory not found at {build_lib_dir}. "
196 f
"PlantArchitecture requires native libraries to be built. "
197 f
"Run: build_scripts/build_helios --plugins plantarchitecture"
200 if not plantarch_assets.exists():
202 f
"PlantArchitecture assets not found at {plantarch_assets}. "
203 f
"Build system failed to copy PlantArchitecture assets. "
204 f
"Run: build_scripts/build_helios --clean --plugins plantarchitecture"
208 assets_dir = plantarch_assets /
'assets'
209 if not assets_dir.exists():
211 f
"PlantArchitecture assets directory not found: {assets_dir}. "
212 f
"Essential assets missing. Rebuild with: "
213 f
"build_scripts/build_helios --clean --plugins plantarchitecture"
217 original_dir = os.getcwd()
219 os.chdir(working_dir)
220 logger.debug(f
"Changed working directory to {working_dir} for PlantArchitecture asset access")
223 os.chdir(original_dir)
224 logger.debug(f
"Restored working directory to {original_dir}")
228 """Raised when PlantArchitecture operations fail."""
234 Check if PlantArchitecture plugin is available for use.
237 bool: True if PlantArchitecture can be used, False otherwise
241 plugin_registry = get_plugin_registry()
242 if not plugin_registry.is_plugin_available(
'plantarchitecture'):
246 if not plantarch_wrapper._PLANTARCHITECTURE_FUNCTIONS_AVAILABLE:
256 High-level interface for plant architecture modeling and procedural plant generation.
258 PlantArchitecture provides access to the comprehensive plant library with 25+ plant models
259 including trees (almond, apple, olive, walnut), crops (bean, cowpea, maize, rice, soybean),
260 and other plants. This class enables procedural plant generation, time-based growth
261 simulation, and plant community modeling.
263 This class requires the native Helios library built with PlantArchitecture support.
264 Use context managers for proper resource cleanup.
267 >>> with Context() as context:
268 ... with PlantArchitecture(context) as plantarch:
269 ... plantarch.loadPlantModelFromLibrary("bean")
270 ... plant_id = plantarch.buildPlantInstanceFromLibrary(base_position=vec3(0, 0, 0), age=30)
271 ... plantarch.advanceTime(10.0) # Grow for 10 days
274 def __new__(cls, context=None):
276 Create PlantArchitecture instance.
277 Explicit __new__ to prevent ctypes contamination on Windows.
279 return object.__new__(cls)
283 Initialize PlantArchitecture with a Helios context.
286 context: Active Helios Context instance
289 PlantArchitectureError: If plugin not available in current build
290 RuntimeError: If plugin initialization fails
293 registry = get_plugin_registry()
294 if not registry.is_plugin_available(
'plantarchitecture'):
296 "PlantArchitecture not available in current Helios library. "
297 "Rebuild PyHelios with PlantArchitecture support:\n"
298 " build_scripts/build_helios --plugins plantarchitecture\n"
300 "System requirements:\n"
301 f
" - Platforms: Windows, Linux, macOS\n"
302 " - Dependencies: Extensive asset library (textures, OBJ models)\n"
303 " - GPU: Not required\n"
305 "Plant library includes 25+ models: almond, apple, bean, cowpea, maize, "
306 "rice, soybean, tomato, wheat, and many others."
317 self.
_plantarch_ptr = plantarch_wrapper.createPlantArchitecture(context.getNativePtr())
323 """Raise if the owning Context has been destroyed (see Context.check_context_alive)."""
324 check_context_alive(getattr(self,
"context",
None),
"PlantArchitecture")
327 """Context manager entry"""
330 def __exit__(self, exc_type, exc_val, exc_tb):
331 """Context manager exit - cleanup resources"""
337 """Destructor to ensure C++ resources freed even without 'with' statement."""
342 except Exception
as e:
344 warnings.warn(f
"Error in PlantArchitecture.__del__: {e}")
348 Load a plant model from the built-in library.
351 plant_label: Plant model identifier from library. Available models include:
352 "almond", "apple", "bean", "bindweed", "butterlettuce", "capsicum",
353 "cheeseweed", "cowpea", "easternredbud", "grapevine_VSP", "maize",
354 "olive", "pistachio", "puncturevine", "rice", "sorghum", "soybean",
355 "strawberry", "sugarbeet", "tomato", "cherrytomato", "walnut", "wheat"
358 ValueError: If plant_label is empty or invalid
359 PlantArchitectureError: If model loading fails
362 >>> plantarch.loadPlantModelFromLibrary("bean")
363 >>> plantarch.loadPlantModelFromLibrary("almond")
366 raise ValueError(
"Plant label cannot be empty")
368 if not plant_label.strip():
369 raise ValueError(
"Plant label cannot be only whitespace")
374 plantarch_wrapper.loadPlantModelFromLibrary(self.
_plantarch_ptr, plant_label.strip())
375 except Exception
as e:
381 build_parameters: Optional[dict] =
None) -> int:
383 Build a plant instance from the currently loaded library model.
386 base_position: Cartesian (x,y,z) coordinates of plant base as vec3
387 age: Age of the plant in days (must be >= 0)
388 build_parameters: Optional dict of parameter overrides for training system
389 parameters. Only some models read them, and a key the model does
390 not accept raises ValueError rather than being ignored:
391 - almond, almond_aldrich, almond_wood_colony, apple, pistachio,
392 walnut: trunk_height, num_scaffolds, scaffold_angle
393 - grapevine_VSP: trunk_height, vine_spacing
394 - grapevine_wye: trunk_height, vine_spacing, cordon_spacing,
396 All other models read no build parameters.
399 Plant ID for the created plant instance
402 ValueError: If age is negative or build_parameters is invalid
403 PlantArchitectureError: If plant building fails
404 RuntimeError: If no model has been loaded
407 >>> plant_id = plantarch.buildPlantInstanceFromLibrary(base_position=vec3(2.0, 3.0, 0.0), age=45.0)
408 >>> # With custom parameters
409 >>> plant_id = plantarch.buildPlantInstanceFromLibrary(
410 ... base_position=vec3(0, 0, 0),
412 ... build_parameters={'trunk_height': 2.0}
416 if not isinstance(base_position, vec3):
417 raise ValueError(f
"base_position must be a vec3, got {type(base_position).__name__}")
420 position_list = [base_position.x, base_position.y, base_position.z]
424 raise ValueError(f
"Age must be non-negative, got {age}")
431 return plantarch_wrapper.buildPlantInstanceFromLibrary(
434 except Exception
as e:
439 plant_count: int2, age: float,
440 germination_rate: float = 1.0,
441 build_parameters: Optional[dict] =
None) -> List[int]:
443 Build a canopy of regularly spaced plants from the currently loaded library model.
446 canopy_center: Cartesian (x,y,z) coordinates of canopy center as vec3
447 plant_spacing: Spacing between plants in x- and y-directions (meters) as vec2
448 plant_count: Number of plants in x- and y-directions as int2
449 age: Age of all plants in days (must be >= 0)
450 germination_rate: Probability that each plant position will be occupied (0 to 1).
451 A value of 1.0 means all positions are filled; 0.5 means roughly
452 half the positions will have plants. Default is 1.0.
453 build_parameters: Optional dict of parameter overrides for training system
454 parameters, applied to every plant in the canopy. Only some models
455 read them, and a key the model does not accept raises ValueError
456 rather than being ignored. See buildPlantInstanceFromLibrary() for
460 List of plant IDs for the created plant instances
463 ValueError: If age is negative, germination_rate is not in [0, 1],
464 plant count values are not positive, or build_parameters is invalid
465 PlantArchitectureError: If canopy building fails
468 >>> # 3x3 canopy with 0.5m spacing, 30-day-old plants
469 >>> plant_ids = plantarch.buildPlantCanopyFromLibrary(
470 ... canopy_center=vec3(0, 0, 0),
471 ... plant_spacing=vec2(0.5, 0.5),
472 ... plant_count=int2(3, 3),
475 >>> # With 80% germination rate and custom parameters
476 >>> plant_ids = plantarch.buildPlantCanopyFromLibrary(
477 ... canopy_center=vec3(0, 0, 0),
478 ... plant_spacing=vec2(1.5, 2.0),
479 ... plant_count=int2(5, 3),
481 ... germination_rate=0.8,
482 ... build_parameters={'trunk_height': 1.8}
486 if not isinstance(canopy_center, vec3):
487 raise ValueError(f
"canopy_center must be a vec3, got {type(canopy_center).__name__}")
488 if not isinstance(plant_spacing, vec2):
489 raise ValueError(f
"plant_spacing must be a vec2, got {type(plant_spacing).__name__}")
490 if not isinstance(plant_count, int2):
491 raise ValueError(f
"plant_count must be an int2, got {type(plant_count).__name__}")
495 raise ValueError(f
"Age must be non-negative, got {age}")
498 if not isinstance(germination_rate, (int, float)):
499 raise ValueError(f
"germination_rate must be a number, got {type(germination_rate).__name__}")
500 if germination_rate < 0
or germination_rate > 1:
501 raise ValueError(f
"germination_rate must be between 0 and 1, got {germination_rate}")
504 if plant_count.x <= 0
or plant_count.y <= 0:
505 raise ValueError(
"Plant count values must be positive integers")
510 center_list = [canopy_center.x, canopy_center.y, canopy_center.z]
511 spacing_list = [plant_spacing.x, plant_spacing.y]
512 count_list = [plant_count.x, plant_count.y]
517 return plantarch_wrapper.buildPlantCanopyFromLibrary(
519 germination_rate, build_parameters
521 except Exception
as e:
524 def advanceTime(self, dt: float, plant_id: Optional[int] =
None,
525 plant_ids: Optional[List[int]] =
None,
526 years: Optional[int] =
None) ->
None:
528 Advance time for plant growth and development.
530 Updates plants in the simulation, potentially adding new phytomers, growing
531 existing organs, transitioning phenological stages, and updating plant geometry.
533 By default every plant advances together. Pass plant_id or plant_ids to advance a
534 subset, which is what staggered planting dates and mixed-age stands require.
537 dt: Time step to advance in days (must be >= 0)
538 plant_id: Advance only this plant. Mutually exclusive with plant_ids.
539 plant_ids: Advance only these plants. Mutually exclusive with plant_id.
540 years: Advance this many whole years in addition to dt days. Applies to all
541 plants and cannot be combined with plant_id or plant_ids.
544 ValueError: If dt or years is negative, or selectors are combined
545 PlantArchitectureError: If time advancement fails
548 Large time steps are more efficient than many small steps. The timestep value
549 can be larger than the phyllochron, allowing multiple phytomers to be produced
553 >>> plantarch.advanceTime(10.0) # all plants, 10 days
554 >>> plantarch.advanceTime(10.0, plant_id=early) # one plant only
555 >>> plantarch.advanceTime(10.0, plant_ids=[a, b]) # a subset
556 >>> plantarch.advanceTime(0.0, years=4) # all plants, 4 years
559 raise ValueError(f
"Time step must be non-negative, got {dt}")
561 selectors = sum(x
is not None for x
in (plant_id, plant_ids, years))
563 raise ValueError(
"Pass at most one of plant_id, plant_ids, or years")
565 if plant_id
is not None and plant_id < 0:
566 raise ValueError(f
"plant_id must be non-negative, got {plant_id}")
567 if plant_ids
is not None and any(pid < 0
for pid
in plant_ids):
568 raise ValueError(
"plant_ids must all be non-negative")
569 if years
is not None and years < 0:
570 raise ValueError(f
"years must be non-negative, got {years}")
575 if plant_id
is not None:
576 plantarch_wrapper.advanceTimeForPlant(self.
_plantarch_ptr, plant_id, dt)
577 elif plant_ids
is not None:
580 plantarch_wrapper.advanceTimeForPlants(self.
_plantarch_ptr, plant_ids, dt)
581 elif years
is not None:
582 plantarch_wrapper.advanceTimeYears(self.
_plantarch_ptr, years, dt)
585 except Exception
as e:
589 plant_id: Optional[int] =
None,
590 view_half_angle_deg: Optional[float] =
None,
591 look_ahead_distance: float = 0.1,
592 attraction_weight: float = 0.6) ->
None:
594 Steer shoot growth toward a set of target points.
596 Attraction points are the counterpart to collision avoidance: collision tells a
597 plant what to grow around, attraction tells it what to grow toward. This is how
598 trellis wires, espalier targets and greenhouse supports are modelled.
600 Steering applies to growth that happens after this call, since the direction is
601 chosen as each phytomer is constructed. Enable the points before advanceTime().
604 points: Target locations as a list of vec3
605 plant_id: Apply to this plant only. Applies to every plant when None.
606 view_half_angle_deg: Half-angle of the search cone in degrees. Defaults to
607 45 for the global form and 80 for the per-plant form, matching the
608 native defaults, which differ between the two.
609 look_ahead_distance: How far ahead a shoot tip looks, in meters
610 attraction_weight: Strength of the steering, 0 to 1
613 ValueError: If points is empty or contains a non-vec3, or plant_id is negative
614 PlantArchitectureError: If the operation fails
617 >>> wires = [vec3(x, 0, 2.1) for x in range(0, 10)]
618 >>> plantarch.enableAttractionPoints(wires)
621 if plant_id
is not None and plant_id < 0:
622 raise ValueError(f
"plant_id must be non-negative, got {plant_id}")
624 if view_half_angle_deg
is None:
625 view_half_angle_deg = 45.0
if plant_id
is None else 80.0
630 plantarch_wrapper.enableAttractionPoints(
632 view_half_angle_deg, look_ahead_distance, attraction_weight
634 except Exception
as e:
639 Stop steering growth toward attraction points.
642 plant_id: Disable for this plant only. Disables globally when None.
645 ValueError: If plant_id is negative
646 PlantArchitectureError: If the operation fails
648 if plant_id
is not None and plant_id < 0:
649 raise ValueError(f
"plant_id must be non-negative, got {plant_id}")
654 plantarch_wrapper.disableAttractionPoints(self.
_plantarch_ptr, plant_id)
655 except Exception
as e:
659 plant_id: Optional[int] =
None) ->
None:
661 Replace the current attraction point set.
664 points: Replacement target locations as a list of vec3
665 plant_id: Update this plant only. Updates globally when None.
668 ValueError: If points is empty or contains a non-vec3, or plant_id is negative
669 PlantArchitectureError: If the operation fails
672 if plant_id
is not None and plant_id < 0:
673 raise ValueError(f
"plant_id must be non-negative, got {plant_id}")
678 plantarch_wrapper.updateAttractionPoints(self.
_plantarch_ptr, plant_id, points)
679 except Exception
as e:
683 plant_id: Optional[int] =
None) ->
None:
685 Add to the current attraction point set.
688 points: Additional target locations as a list of vec3
689 plant_id: Append for this plant only. Appends globally when None.
692 ValueError: If points is empty or contains a non-vec3, or plant_id is negative
693 PlantArchitectureError: If the operation fails
696 if plant_id
is not None and plant_id < 0:
697 raise ValueError(f
"plant_id must be non-negative, got {plant_id}")
702 plantarch_wrapper.appendAttractionPoints(self.
_plantarch_ptr, plant_id, points)
703 except Exception
as e:
707 look_ahead_distance: float,
708 attraction_weight: float,
709 obstacle_reduction_factor: float = 0.75,
710 plant_id: Optional[int] =
None) ->
None:
712 Tune how strongly attraction points steer growth.
715 view_half_angle_deg: Half-angle of the search cone in degrees
716 look_ahead_distance: How far ahead a shoot tip looks, in meters
717 attraction_weight: Strength of the steering, 0 to 1
718 obstacle_reduction_factor: Scales attraction where an obstacle intervenes
719 plant_id: Apply to this plant only. Applies globally when None.
722 ValueError: If plant_id is negative
723 PlantArchitectureError: If the operation fails
725 if plant_id
is not None and plant_id < 0:
726 raise ValueError(f
"plant_id must be non-negative, got {plant_id}")
731 plantarch_wrapper.setAttractionParameters(
733 look_ahead_distance, attraction_weight, obstacle_reduction_factor
735 except Exception
as e:
740 """Reject point sets the native layer would misread or silently ignore."""
741 if not isinstance(points, (list, tuple)):
743 f
"points must be a list of vec3, got {type(points).__name__}"
746 raise ValueError(
"points cannot be empty")
747 for index, point
in enumerate(points):
748 if not isinstance(point, vec3):
750 f
"points[{index}] must be a vec3, got {type(point).__name__}"
754 """Set a callback to receive progress updates during long-running operations.
756 The callback fires during advanceTime() and adjustFruitForObstacleCollision()
757 as the underlying ProgressBar updates.
760 callback: A callable(progress: float, message: str) where progress is
761 in [0, 1], or None to clear the callback.
764 ValueError: If callback is not callable and not None.
766 if callback
is not None:
767 if not callable(callback):
769 f
"callback must be callable or None, got {type(callback).__name__}"
772 def _c_callback(progress, message_bytes):
773 msg = message_bytes.decode(
'utf-8')
if isinstance(message_bytes, bytes)
else str(message_bytes)
774 callback(progress, msg)
786 """Register an external cancellation flag polled during long plant builds.
788 ``cancel_flag`` is a ctypes.c_int that, when set non-zero from another
789 thread, stops the canopy build loop and the advanceTime() growth loop
790 between plants/timesteps — so a long generation can be aborted mid-build
791 (returning whatever was built so far). Set it before the build call; pass
792 None to clear. The flag is caller-owned and must outlive the build.
799 Get current shoot parameters for a shoot type.
801 Returns the full nested shoot and phytomer parameter set, including the
802 internode/petiole/leaf/peduncle/inflorescence sub-structures and the leaf
803 prototype. Every numeric field is a RandomParameter spec with a
804 'distribution' and 'parameters'.
807 shoot_type_label: Label for the shoot type. Labels are species-specific,
808 e.g. "trifoliate" (bean), "trunk"/"scaffold" (almond).
809 return_typed: If True, return a typed
810 :class:`pyhelios.plant_architecture_params.ShootParameters`
811 object instead of a plain nested dict.
814 A nested ``dict`` (default) or a ``ShootParameters`` object containing:
815 - Geometric parameters (max_nodes, insertion_angle_tip, etc.)
816 - Growth parameters (phyllochron_min, elongation_rate_max, etc.)
817 - Boolean flags (flowers_require_dormancy, etc.)
818 - ``phytomer_parameters`` with nested internode/petiole/leaf/peduncle/
819 inflorescence parameters and the leaf prototype
822 ValueError: If shoot_type_label is empty
823 PlantArchitectureError: If parameter retrieval fails
826 >>> plantarch.loadPlantModelFromLibrary("bean")
827 >>> params = plantarch.getCurrentShootParameters("trifoliate")
828 >>> print(params['max_nodes'])
829 {'distribution': 'constant', 'parameters': [25.0]}
830 >>> print(params['phytomer_parameters']['leaf']['pitch'])
831 {'distribution': 'normal', 'parameters': [0.0, 20.0]}
833 if not shoot_type_label:
834 raise ValueError(
"Shoot type label cannot be empty")
836 if not shoot_type_label.strip():
837 raise ValueError(
"Shoot type label cannot be only whitespace")
842 params = plantarch_wrapper.getCurrentShootParameters(
845 except Exception
as e:
852 available = f
" Available shoot types: {', '.join(sorted(labels))}."
857 return ShootParameters.from_dict(params)
if return_typed
else params
859 def defineShootType(self, shoot_type_label: str, parameters: Union[dict, ShootParameters]) ->
None:
861 Define a custom shoot type with specified parameters.
863 Allows creating new shoot types or modifying existing ones. Pass either a
864 nested parameter ``dict`` (use :meth:`getCurrentShootParameters` as a
866 :class:`pyhelios.plant_architecture_params.ShootParameters` object.
868 Redefining an existing library shoot type preserves that species' built-in
869 phytomer creation and callback functions, so species-specific organ behavior
870 (such as maize forming ears rather than a tassel at every node) is retained.
873 shoot_type_label: Unique name for this shoot type
874 parameters: A nested dict matching the ShootParameters structure, or a
875 ShootParameters object.
878 ValueError: If shoot_type_label is empty, or parameters is not a dict
880 PlantArchitectureError: If shoot type definition fails
883 >>> from pyhelios.plant_architecture_params import ShootParameters, RandomParameterFloat
884 >>> plantarch.loadPlantModelFromLibrary("bean")
885 >>> sp = plantarch.getCurrentShootParameters("trifoliate", return_typed=True)
886 >>> sp.max_nodes = RandomParameterFloat.constant(20)
887 >>> sp.phytomer_parameters.leaf.pitch = RandomParameterFloat.uniform(40, 50)
888 >>> plantarch.defineShootType("TallStem", sp)
890 if not shoot_type_label:
891 raise ValueError(
"Shoot type label cannot be empty")
893 if not shoot_type_label.strip():
894 raise ValueError(
"Shoot type label cannot be only whitespace")
896 if isinstance(parameters, ShootParameters):
897 parameters = parameters.to_dict()
898 elif not isinstance(parameters, dict):
900 f
"Parameters must be a dict or ShootParameters, got {type(parameters).__name__}"
906 plantarch_wrapper.defineShootType(
909 except Exception
as e:
914 Get a default-constructed set of carbohydrate-model parameters.
916 The native API exposes no per-plant getter for carbohydrate parameters, so
917 this returns the C++ defaults as a template to modify and apply via
918 :meth:`setPlantCarbohydrateParameters`.
921 return_typed: If True, return a typed
922 :class:`pyhelios.plant_architecture_params.CarbohydrateParameters`.
925 A flat ``dict`` (default) or ``CarbohydrateParameters`` object.
930 params = plantarch_wrapper.getDefaultCarbohydrateParameters()
931 except Exception
as e:
933 return CarbohydrateParameters.from_dict(params)
if return_typed
else params
937 Set carbohydrate-model parameters for a plant.
940 plant_id: Target plant instance ID
941 parameters: A flat dict or a CarbohydrateParameters object.
944 ValueError: If parameters is not a dict or CarbohydrateParameters
945 PlantArchitectureError: If the operation fails
947 if isinstance(parameters, CarbohydrateParameters):
948 parameters = parameters.to_dict()
949 elif not isinstance(parameters, dict):
951 f
"Parameters must be a dict or CarbohydrateParameters, got {type(parameters).__name__}"
956 plantarch_wrapper.setPlantCarbohydrateParameters(self.
_plantarch_ptr, plant_id, parameters)
957 except Exception
as e:
962 Get a default-constructed set of nitrogen-model parameters.
964 The native API exposes no per-plant getter for nitrogen parameters, so this
965 returns the C++ defaults as a template to modify and apply via
966 :meth:`setPlantNitrogenParameters`.
969 return_typed: If True, return a typed
970 :class:`pyhelios.plant_architecture_params.NitrogenParameters`.
973 A flat ``dict`` (default) or ``NitrogenParameters`` object.
978 params = plantarch_wrapper.getDefaultNitrogenParameters()
979 except Exception
as e:
981 return NitrogenParameters.from_dict(params)
if return_typed
else params
985 Set nitrogen-model parameters for a plant.
988 plant_id: Target plant instance ID
989 parameters: A flat dict or a NitrogenParameters object.
992 ValueError: If parameters is not a dict or NitrogenParameters
993 PlantArchitectureError: If the operation fails
995 if isinstance(parameters, NitrogenParameters):
996 parameters = parameters.to_dict()
997 elif not isinstance(parameters, dict):
999 f
"Parameters must be a dict or NitrogenParameters, got {type(parameters).__name__}"
1004 plantarch_wrapper.setPlantNitrogenParameters(self.
_plantarch_ptr, plant_id, parameters)
1005 except Exception
as e:
1010 Get list of all available plant models in the library.
1013 List of plant model names available for loading
1016 PlantArchitectureError: If retrieval fails
1019 >>> models = plantarch.getAvailablePlantModels()
1020 >>> print(f"Available models: {', '.join(models)}")
1021 Available models: almond, apple, bean, cowpea, maize, rice, soybean, tomato, wheat, ...
1026 return plantarch_wrapper.getAvailablePlantModels(self.
_plantarch_ptr)
1027 except Exception
as e:
1031 plant_id: Optional[int] =
None) -> List[str]:
1033 Get the shoot type labels defined for a plant model.
1035 Shoot type labels are species-specific strings such as "trunk" or "scaffold", and
1036 every shoot-parameter call takes one. Use this to discover the valid labels rather
1040 plant_model: Query this library model without changing the currently loaded
1041 one. Use getAvailablePlantModels() for valid names. Mutually exclusive
1043 plant_id: Query the shoot types captured by this plant instance when it was
1044 created. Mutually exclusive with plant_model.
1046 With neither argument, queries the currently loaded model, which requires a prior
1047 call to loadPlantModelFromLibrary().
1050 List of shoot type label strings.
1053 ValueError: If both plant_model and plant_id are given, or plant_id is negative
1054 PlantArchitectureError: If no model is loaded, or the model or plant is unknown
1057 >>> plantarch.loadPlantModelFromLibrary("almond")
1058 >>> plantarch.listShootTypeLabels()
1059 ['proleptic', 'scaffold', 'sylleptic', 'trunk']
1060 >>> plantarch.listShootTypeLabels(plant_model="bean")
1061 ['trifoliate', 'unifoliate']
1063 if plant_model
is not None and plant_id
is not None:
1064 raise ValueError(
"Pass either plant_model or plant_id, not both")
1065 if plant_id
is not None and plant_id < 0:
1066 raise ValueError(f
"plant_id must be non-negative, got {plant_id}")
1067 if plant_model
is not None and not plant_model.strip():
1068 raise ValueError(
"plant_model cannot be empty or only whitespace")
1073 return plantarch_wrapper.listShootTypeLabels(
1075 plant_model.strip()
if plant_model
is not None else None,
1078 except Exception
as e:
1083 Get UUIDs of every plant primitive in the model.
1085 Spans every plant, unlike the per-plant getters, which is what canopy-wide work
1086 such as assigning optical properties or reading flux by organ type needs.
1089 List of primitive UUIDs
1092 PlantArchitectureError: If retrieval fails
1095 >>> ids = plantarch.getAllUUIDs()
1101 except Exception
as e:
1106 Get UUIDs of every leaf primitive in the model.
1108 Spans every plant, unlike the per-plant getters, which is what canopy-wide work
1109 such as assigning optical properties or reading flux by organ type needs.
1112 List of leaf primitive UUIDs
1115 PlantArchitectureError: If retrieval fails
1118 >>> ids = plantarch.getAllLeafUUIDs()
1124 except Exception
as e:
1129 Get UUIDs of every internode primitive in the model.
1131 Spans every plant, unlike the per-plant getters, which is what canopy-wide work
1132 such as assigning optical properties or reading flux by organ type needs.
1135 List of internode primitive UUIDs
1138 PlantArchitectureError: If retrieval fails
1141 >>> ids = plantarch.getAllInternodeUUIDs()
1146 return plantarch_wrapper.getAllInternodeUUIDs(self.
_plantarch_ptr)
1147 except Exception
as e:
1152 Get UUIDs of every petiole primitive in the model.
1154 Spans every plant, unlike the per-plant getters, which is what canopy-wide work
1155 such as assigning optical properties or reading flux by organ type needs.
1158 List of petiole primitive UUIDs
1161 PlantArchitectureError: If retrieval fails
1164 >>> ids = plantarch.getAllPetioleUUIDs()
1170 except Exception
as e:
1175 Get UUIDs of every peduncle primitive in the model.
1177 Spans every plant, unlike the per-plant getters, which is what canopy-wide work
1178 such as assigning optical properties or reading flux by organ type needs.
1180 An empty list means no plant has reached the corresponding growth stage,
1181 which is a legitimate result rather than a failure.
1184 List of peduncle primitive UUIDs
1187 PlantArchitectureError: If retrieval fails
1190 >>> ids = plantarch.getAllPeduncleUUIDs()
1195 return plantarch_wrapper.getAllPeduncleUUIDs(self.
_plantarch_ptr)
1196 except Exception
as e:
1201 Get UUIDs of every flower primitive in the model.
1203 Spans every plant, unlike the per-plant getters, which is what canopy-wide work
1204 such as assigning optical properties or reading flux by organ type needs.
1206 An empty list means no plant has reached the corresponding growth stage,
1207 which is a legitimate result rather than a failure.
1210 List of flower primitive UUIDs
1213 PlantArchitectureError: If retrieval fails
1216 >>> ids = plantarch.getAllFlowerUUIDs()
1222 except Exception
as e:
1227 Get UUIDs of every fruit primitive in the model.
1229 Spans every plant, unlike the per-plant getters, which is what canopy-wide work
1230 such as assigning optical properties or reading flux by organ type needs.
1232 An empty list means no plant has reached the corresponding growth stage,
1233 which is a legitimate result rather than a failure.
1236 List of fruit primitive UUIDs
1239 PlantArchitectureError: If retrieval fails
1242 >>> ids = plantarch.getAllFruitUUIDs()
1248 except Exception
as e:
1253 Get object IDs of every plant compound object in the model.
1255 Spans every plant, unlike the per-plant getters, which is what canopy-wide work
1256 such as assigning optical properties or reading flux by organ type needs.
1262 PlantArchitectureError: If retrieval fails
1265 >>> ids = plantarch.getAllObjectIDs()
1271 except Exception
as e:
1276 Get IDs of every plant instance in the model.
1278 Spans every plant, unlike the per-plant getters, which is what canopy-wide work
1279 such as assigning optical properties or reading flux by organ type needs.
1285 PlantArchitectureError: If retrieval fails
1288 >>> ids = plantarch.getAllPlantIDs()
1294 except Exception
as e:
1299 Get all object IDs for a specific plant.
1302 plant_id: ID of the plant instance
1305 List of object IDs comprising the plant
1308 ValueError: If plant_id is negative
1309 PlantArchitectureError: If retrieval fails
1312 >>> object_ids = plantarch.getAllPlantObjectIDs(plant_id)
1313 >>> print(f"Plant has {len(object_ids)} objects")
1316 raise ValueError(
"Plant ID must be non-negative")
1320 return plantarch_wrapper.getAllPlantObjectIDs(self.
_plantarch_ptr, plant_id)
1321 except Exception
as e:
1326 Get object IDs for all leaf objects on a specific plant.
1329 plant_id: ID of the plant instance
1332 List of object IDs, one per leaf
1335 ValueError: If plant_id is negative
1336 PlantArchitectureError: If retrieval fails
1339 Do **not** pair this result positionally with :meth:`getPlantLeafBases`.
1340 The two are built by independent traversals of the shoot tree, so their
1341 index correspondence is not guaranteed by the native API.
1344 >>> leaf_ids = plantarch.getPlantLeafObjectIDs(plant_id)
1345 >>> print(f"Plant has {len(leaf_ids)} leaves")
1348 raise ValueError(
"Plant ID must be non-negative")
1352 return plantarch_wrapper.getPlantLeafObjectIDs(self.
_plantarch_ptr, plant_id)
1353 except Exception
as e:
1358 Get object IDs for all petiole objects on a specific plant.
1360 Petioles are the stalks attaching leaves to the stem, so this is the
1361 structural counterpart to :meth:`getPlantLeafObjectIDs`.
1364 plant_id: ID of the plant instance
1367 List of object IDs, one per petiole
1370 ValueError: If plant_id is negative
1371 PlantArchitectureError: If retrieval fails
1374 >>> petiole_ids = plantarch.getPlantPetioleObjectIDs(plant_id)
1375 >>> print(f"Plant has {len(petiole_ids)} petioles")
1378 raise ValueError(
"Plant ID must be non-negative")
1382 return plantarch_wrapper.getPlantPetioleObjectIDs(self.
_plantarch_ptr, plant_id)
1383 except Exception
as e:
1388 Get object IDs for all peduncle objects on a specific plant.
1390 Peduncles are the stalks bearing flowers and fruit.
1393 plant_id: ID of the plant instance
1396 List of object IDs, one per peduncle. Empty if the plant has not
1397 reached its reproductive stage, which is a normal result rather than
1401 ValueError: If plant_id is negative
1402 PlantArchitectureError: If retrieval fails
1405 >>> peduncle_ids = plantarch.getPlantPeduncleObjectIDs(plant_id)
1406 >>> print(f"Plant has {len(peduncle_ids)} peduncles")
1409 raise ValueError(
"Plant ID must be non-negative")
1413 return plantarch_wrapper.getPlantPeduncleObjectIDs(self.
_plantarch_ptr, plant_id)
1414 except Exception
as e:
1419 Get object IDs for all flower (inflorescence) objects on a specific plant.
1422 plant_id: ID of the plant instance
1425 List of object IDs, one per flower. Empty if the plant has not
1426 flowered -- or has already flowered and set fruit, since flowers are
1427 replaced by fruit as growth proceeds. Both are normal results rather
1431 ValueError: If plant_id is negative
1432 PlantArchitectureError: If retrieval fails
1435 >>> flower_ids = plantarch.getPlantFlowerObjectIDs(plant_id)
1436 >>> print(f"Plant has {len(flower_ids)} flowers")
1439 raise ValueError(
"Plant ID must be non-negative")
1443 return plantarch_wrapper.getPlantFlowerObjectIDs(self.
_plantarch_ptr, plant_id)
1444 except Exception
as e:
1449 Get object IDs for all fruit objects on a specific plant.
1452 plant_id: ID of the plant instance
1455 List of object IDs, one per fruit. Empty if the plant has not
1456 fruited, which is a normal result rather than an error -- fruit
1457 appear only once a plant reaches the reproductive stage, so a plant
1458 built at a young age or from a model with no fruit yields ``[]``.
1461 ValueError: If plant_id is negative
1462 PlantArchitectureError: If retrieval fails
1465 >>> fruit_ids = plantarch.getPlantFruitObjectIDs(plant_id)
1466 >>> print(f"Plant has {len(fruit_ids)} fruit")
1467 >>> # Object IDs are Context object IDs, so the usual queries apply:
1468 >>> uuids = context.getObjectPrimitiveUUIDs(fruit_ids[0])
1471 raise ValueError(
"Plant ID must be non-negative")
1475 return plantarch_wrapper.getPlantFruitObjectIDs(self.
_plantarch_ptr, plant_id)
1476 except Exception
as e:
1481 Get the attachment base position of every leaf on a specific plant.
1483 The base is where the leaf attaches to its petiole, not the leaf centroid.
1486 plant_id: ID of the plant instance
1489 List of vec3 base positions, one per leaf
1492 ValueError: If plant_id is negative
1493 PlantArchitectureError: If retrieval fails
1496 Do **not** pair this result positionally with
1497 :meth:`getPlantLeafObjectIDs`. The two are built by independent
1498 traversals of the shoot tree, so their index correspondence is not
1499 guaranteed by the native API. (helios-core has an internal
1500 ``getPlantLeafObjectIDsAndBases()`` that gathers both in one traversal
1501 for exactly this reason, but it is protected and not callable from here.)
1504 >>> bases = plantarch.getPlantLeafBases(plant_id)
1505 >>> print(f"First leaf attaches at {bases[0]}")
1508 raise ValueError(
"Plant ID must be non-negative")
1512 flat = plantarch_wrapper.getPlantLeafBases(self.
_plantarch_ptr, plant_id)
1513 except Exception
as e:
1516 return [
vec3(float(flat[i]), float(flat[i + 1]), float(flat[i + 2]))
1517 for i
in range(0, len(flat), 3)]
1521 Get all primitive UUIDs for a specific plant.
1524 plant_id: ID of the plant instance
1525 include_hidden: If True, also include UUIDs of hidden prototype
1526 primitives managed by this PlantArchitecture instance.
1529 List of primitive UUIDs comprising the plant (and optionally hidden prototypes)
1532 ValueError: If plant_id is negative
1533 PlantArchitectureError: If retrieval fails
1536 >>> uuids = plantarch.getAllPlantUUIDs(plant_id)
1537 >>> print(f"Plant has {len(uuids)} primitives")
1540 raise ValueError(
"Plant ID must be non-negative")
1544 return plantarch_wrapper.getAllPlantUUIDs(self.
_plantarch_ptr, plant_id, include_hidden)
1545 except Exception
as e:
1550 Get the IDs of all shoots belonging to a plant.
1552 Shoot IDs are contiguous 0-based indices into the plant's shoot tree, in creation
1553 order; shoot 0 is always the base stem. The returned IDs can be passed to
1554 :meth:`getShoot`, :meth:`getShootChildIDs`, etc.
1557 plant_id: ID of the plant instance
1560 List of shoot IDs for the plant
1563 raise ValueError(
"Plant ID must be non-negative")
1566 return plantarch_wrapper.getAllPlantShootIDs(self.
_plantarch_ptr, plant_id)
1567 except Exception
as e:
1570 def getShoot(self, plant_id: int, shoot_id: int) -> Dict[str, Any]:
1572 Get a read-only view of a shoot's topology.
1575 plant_id: ID of the plant instance
1576 shoot_id: Shoot index within the plant (see :meth:`getAllShootIDs`)
1579 A dict with keys ``rank``, ``parent_shoot_id`` (-1 for the base stem),
1580 ``parent_node_index``, and ``node_count``.
1582 if plant_id < 0
or shoot_id < 0:
1583 raise ValueError(
"Plant ID and shoot ID must be non-negative")
1586 return plantarch_wrapper.getPlantShootTopology(self.
_plantarch_ptr, plant_id, shoot_id)
1587 except Exception
as e:
1589 f
"Failed to get shoot {shoot_id} of plant {plant_id}: {e}")
1592 """Get the child shoot IDs of a shoot (flattened across parent node indices)."""
1593 if plant_id < 0
or shoot_id < 0:
1594 raise ValueError(
"Plant ID and shoot ID must be non-negative")
1597 return plantarch_wrapper.getPlantShootChildIDs(self.
_plantarch_ptr, plant_id, shoot_id)
1598 except Exception
as e:
1600 f
"Failed to get child shoots of shoot {shoot_id}, plant {plant_id}: {e}")
1604 Get the ID of the shoot a shoot grew from.
1607 plant_id: ID of the plant instance
1608 shoot_id: Shoot index within the plant (see :meth:`getAllShootIDs`)
1611 ID of the parent shoot, or -1 if this is the base stem shoot.
1614 A pruned shoot still reports the parent it grew from, even though it is no
1615 longer listed among that parent's children.
1618 >>> parent = plantarch.getParentShootID(plant_id, shoot_id=3)
1620 return self.
_shootScalarQuery(
"getParentShootID", plant_id, shoot_id,
"parent shoot ID")
1622 def getShootRank(self, plant_id: int, shoot_id: int) -> int:
1624 Get the branching rank of a shoot.
1626 Rank is the botanical branching order: the base stem is rank 0, a branch off it
1627 is rank 1, and so on. A shoot created by :meth:`appendShoot` continues its
1628 parent's axis rather than branching from it, so it keeps the parent's rank.
1629 Rank is therefore not the same as :meth:`getShootDepth`.
1632 plant_id: ID of the plant instance
1633 shoot_id: Shoot index within the plant
1636 Branching rank of the shoot.
1639 >>> rank = plantarch.getShootRank(plant_id, shoot_id=3)
1643 def getShootDepth(self, plant_id: int, shoot_id: int) -> int:
1645 Get the number of shoots between a shoot and the base stem shoot.
1647 The base stem has depth 0, its children depth 1, and so on. Unlike
1648 :meth:`getShootRank` this counts every step in the shoot tree, including axis
1649 continuations created by :meth:`appendShoot`.
1652 plant_id: ID of the plant instance
1653 shoot_id: Shoot index within the plant
1656 Number of steps from this shoot to the base stem shoot.
1658 return self.
_shootScalarQuery(
"getShootDepth", plant_id, shoot_id,
"shoot depth")
1660 def isShootPruned(self, plant_id: int, shoot_id: int) -> bool:
1662 Report whether a shoot has been pruned away entirely.
1664 :meth:`pruneBranch` called with ``node_index=0`` removes all of a shoot's
1665 phytomers and geometry but keeps the shoot in the plant's tree so that shoot IDs
1666 stay stable. Such a shoot is still returned by :meth:`getAllShootIDs` but is
1667 inert: it has zero nodes, contributes no leaf area, and cannot be queried for
1668 geometry. Use this to skip those shoots when walking :meth:`getAllShootIDs`.
1671 plant_id: ID of the plant instance
1672 shoot_id: Shoot index within the plant
1675 True if the shoot was pruned away and no longer forms part of the plant.
1678 >>> live = [s for s in plantarch.getAllShootIDs(plant_id)
1679 ... if not plantarch.isShootPruned(plant_id, s)]
1681 return self.
_shootScalarQuery(
"isShootPruned", plant_id, shoot_id,
"pruned state")
1683 def getPathToRoot(self, plant_id: int, shoot_id: int) -> List[int]:
1685 Get the chain of shoots connecting a shoot to the base stem shoot.
1688 plant_id: ID of the plant instance
1689 shoot_id: Shoot index within the plant
1692 Shoot IDs ordered from the given shoot to the base stem shoot, including
1693 both. For the base stem shoot this is a single element.
1696 >>> path = plantarch.getPathToRoot(plant_id, shoot_id=5)
1698 return self.
_shootScalarQuery(
"getPathToRoot", plant_id, shoot_id,
"path to root")
1702 Get the shoots that grew directly out of a shoot.
1704 Ordered by the node they attach to. This includes shoots created by
1705 :meth:`appendShoot`, which continue the parent's axis rather than branching from
1706 it; compare their :meth:`getShootRank` with the parent's to tell the two apart.
1707 Pruned shoots are not included.
1710 plant_id: ID of the plant instance
1711 shoot_id: Shoot index within the plant
1714 IDs of the direct children of the shoot, empty if it has none.
1716 return self.
_shootScalarQuery(
"getChildShootIDs", plant_id, shoot_id,
"child shoot IDs")
1720 Get every shoot descending from a shoot.
1722 Collected depth-first, so a shoot is always listed before its own descendants.
1723 The shoot itself is not included, and pruned shoots are omitted.
1726 plant_id: ID of the plant instance
1727 shoot_id: Shoot whose descendants to collect
1730 IDs of all descendants of the shoot, empty if it has none.
1733 >>> descendants = plantarch.getAllDescendantShootIDs(plant_id, shoot_id=1)
1734 >>> print(f"Branch carries {len(descendants)} sub-shoots")
1737 "descendant shoot IDs")
1741 Get the parent-to-children structure of a plant.
1743 Only shoots that actually have children appear as keys. Pruned shoots appear
1744 neither as keys nor among the children.
1747 plant_id: ID of the plant instance
1750 Dict mapping shoot ID to the IDs of its direct children.
1753 >>> hierarchy = plantarch.getShootHierarchyMap(plant_id)
1754 >>> print(f"{len(hierarchy)} shoots carry branches")
1757 raise ValueError(
"Plant ID must be non-negative")
1760 return plantarch_wrapper.getShootHierarchyMap(self.
_plantarch_ptr, plant_id)
1761 except Exception
as e:
1763 f
"Failed to get shoot hierarchy of plant {plant_id}: {e}")
1767 """Shared body for the per-shoot hierarchy accessors."""
1768 if plant_id < 0
or shoot_id < 0:
1769 raise ValueError(
"Plant ID and shoot ID must be non-negative")
1772 return getattr(plantarch_wrapper, wrapper_fn_name)(
1774 except Exception
as e:
1776 f
"Failed to get {description} of shoot {shoot_id}, plant {plant_id}: {e}")
1779 """Get the woody internode polyline vertices of a shoot as a list of (x, y, z) tuples."""
1780 if plant_id < 0
or shoot_id < 0:
1781 raise ValueError(
"Plant ID and shoot ID must be non-negative")
1784 return plantarch_wrapper.getPlantShootInternodeVertices(self.
_plantarch_ptr, plant_id, shoot_id)
1785 except Exception
as e:
1787 f
"Failed to get internode vertices of shoot {shoot_id}, plant {plant_id}: {e}")
1790 """Get the per-vertex woody internode radii of a shoot."""
1791 if plant_id < 0
or shoot_id < 0:
1792 raise ValueError(
"Plant ID and shoot ID must be non-negative")
1795 return plantarch_wrapper.getPlantShootInternodeRadii(self.
_plantarch_ptr, plant_id, shoot_id)
1796 except Exception
as e:
1798 f
"Failed to get internode radii of shoot {shoot_id}, plant {plant_id}: {e}")
1804 def _plantFloatVector(self, wrapper_fn_name: str, plant_id: int, description: str) -> List[float]:
1805 """Shared body for the per-organ built-geometry queries."""
1807 raise ValueError(
"Plant ID must be non-negative")
1810 return getattr(plantarch_wrapper, wrapper_fn_name)(self.
_plantarch_ptr, plant_id)
1811 except Exception
as e:
1816 Get the built one-sided surface area of every leaf on a plant.
1818 Measured from the geometry that was actually built, rather than reported from
1819 the shoot type's parameters. The two answer different questions: the shoot type
1820 gives the distribution a parameter was drawn from, while this gives what the
1821 plant ended up with. A plant whose leaf parameters carry a wide spread can still
1822 deliver leaves of a single size (a random parameter caches its first draw, and a
1823 shoot holds a copy of its type's parameters), and nothing in the parameters
1824 themselves would reveal that.
1826 This reports present area, so a leaf part-way through its growth is counted at
1827 its current size. Leaves are visited shoot by shoot and then phytomer by phytomer,
1828 the same order as :meth:`getPlantLeafObjectIDs`. Leaves whose geometry does not
1829 exist (removed, senesced, or never built) are omitted rather than reported as
1830 zero, so the result can be shorter than the list from :meth:`getPlantLeafObjectIDs`.
1832 Requires helios-core v1.3.85 or newer.
1835 plant_id: ID of the plant instance
1838 One-sided surface area (m^2) of each leaf on the plant
1841 ValueError: If plant_id is negative
1842 PlantArchitectureError: If the query fails or the library predates v1.3.85
1845 >>> areas = plantarch.getPlantLeafAreas(plant_id)
1846 >>> print(f"{len(areas)} leaves, mean {sum(areas)/len(areas):.4f} m^2")
1852 Get the built length of every internode on a plant.
1854 Measured along the internode's node positions as they were built, so a shoot
1855 whose geometry was prescribed by :meth:`addShootFromNodePositions` reports its
1856 measured lengths and a grown shoot reports what growth produced. See
1857 :meth:`getPlantLeafAreas` for why this differs from reading the shoot type's
1858 ``internode_length_max``.
1860 Internodes are visited shoot by shoot and then phytomer by phytomer, so the
1861 result has one entry per phytomer on the plant.
1863 Requires helios-core v1.3.85 or newer.
1866 plant_id: ID of the plant instance
1869 Length (m) of each internode on the plant
1872 ValueError: If plant_id is negative
1873 PlantArchitectureError: If the query fails or the library predates v1.3.85
1875 return self.
_plantFloatVector(
"getPlantInternodeLengths", plant_id,
"internode lengths")
1879 Get the inclination angle of every leaf on a plant.
1881 The angle between each leaf blade and the horizontal, computed from its
1882 area-weighted normal so that a curved or folded blade is summarized by the
1883 direction it mostly faces. 0 degrees is a horizontal blade and 90 degrees a
1884 vertical one; because a blade is a surface, a normal pointing down describes the
1885 same inclination as its opposite pointing up, so the angle is folded about the
1886 horizontal and never exceeds 90 degrees.
1888 Ordering and the treatment of missing geometry match :meth:`getPlantLeafAreas`.
1889 A blade whose facet normals cancel exactly is additionally omitted, since it
1890 faces no single direction.
1892 Requires helios-core v1.3.85 or newer.
1895 plant_id: ID of the plant instance
1898 Inclination angle (degrees, in [0, 90]) of each leaf on the plant
1901 ValueError: If plant_id is negative
1902 PlantArchitectureError: If the query fails or the library predates v1.3.85
1904 return self.
_plantFloatVector(
"getPlantLeafInclinations", plant_id,
"leaf inclinations")
1908 Report whether a shoot's existing geometry was prescribed by the caller rather than generated.
1910 True for a shoot built by :meth:`addShootFromNodePositions`, whose internode path
1911 follows measured node positions. Such a shoot's existing phytomers are exempt
1912 from the re-scaling and re-curving performed by :meth:`advanceTime`, so a caller
1913 reading geometry back can tell which parts of a plant are measurement and which
1916 Requires helios-core v1.3.85 or newer.
1919 plant_id: ID of the plant instance
1920 shoot_id: Shoot index within the plant
1923 True if the shoot was built from prescribed node positions
1926 ValueError: If either ID is negative
1927 PlantArchitectureError: If the query fails or the library predates v1.3.85
1930 "prescribed-geometry state")
1934 Get the current age of a plant in days.
1937 plant_id: ID of the plant instance
1943 ValueError: If plant_id is negative
1944 PlantArchitectureError: If retrieval fails
1947 >>> age = plantarch.getPlantAge(plant_id)
1948 >>> print(f"Plant is {age} days old")
1951 raise ValueError(
"Plant ID must be non-negative")
1956 return plantarch_wrapper.getPlantAge(self.
_plantarch_ptr, plant_id)
1957 except Exception
as e:
1962 Get the maximum age of a plant, beyond which it stops growing.
1965 plant_id: ID of the plant instance
1968 Maximum plant age in days. See :meth:`setPlantMaxAge`.
1971 ValueError: If plant_id is negative
1972 PlantArchitectureError: If retrieval fails
1975 >>> max_age = plantarch.getPlantMaxAge(plant_id)
1978 raise ValueError(
"Plant ID must be non-negative")
1981 return plantarch_wrapper.getPlantMaxAge(self.
_plantarch_ptr, plant_id)
1982 except Exception
as e:
1984 f
"Failed to get maximum age of plant {plant_id}: {e}")
1988 Set the maximum age of a plant, beyond which it stops growing.
1990 Once a plant's age reaches this value, :meth:`advanceTime` stops advancing it and
1991 its geometry becomes static. The default is 999 days. Every plant model in the
1992 library sets its own value as part of its builder (an apple tree, for example,
1993 uses 1460 days), but a plant assembled manually with :meth:`addPlantInstance`
1994 keeps the default and so silently stops growing after 999 days.
1996 Setting a maximum age below the plant's current age is permitted, and freezes the
1997 plant at its current form.
2000 plant_id: ID of the plant instance
2001 max_age: Maximum age of the plant in days. Must be non-negative.
2004 ValueError: If plant_id is negative or max_age is negative
2005 PlantArchitectureError: If the plant does not exist
2008 >>> plantarch.setPlantMaxAge(plant_id, 1460.0)
2011 raise ValueError(
"Plant ID must be non-negative")
2013 raise ValueError(f
"Maximum age must be non-negative, got {max_age}")
2016 plantarch_wrapper.setPlantMaxAge(self.
_plantarch_ptr, plant_id, max_age)
2017 except Exception
as e:
2019 f
"Failed to set maximum age of plant {plant_id}: {e}")
2023 Get the height of a plant in meters.
2026 plant_id: ID of the plant instance
2029 Plant height in meters (vertical extent)
2032 ValueError: If plant_id is negative
2033 PlantArchitectureError: If retrieval fails
2036 >>> height = plantarch.getPlantHeight(plant_id)
2037 >>> print(f"Plant is {height:.2f}m tall")
2040 raise ValueError(
"Plant ID must be non-negative")
2045 return plantarch_wrapper.getPlantHeight(self.
_plantarch_ptr, plant_id)
2046 except Exception
as e:
2051 Get the total leaf area of a plant in m².
2054 plant_id: ID of the plant instance
2057 Total leaf area in square meters
2060 ValueError: If plant_id is negative
2061 PlantArchitectureError: If retrieval fails
2064 >>> leaf_area = plantarch.getPlantLeafArea(plant_id)
2065 >>> print(f"Total leaf area: {leaf_area:.3f} m²")
2068 raise ValueError(
"Plant ID must be non-negative")
2073 return plantarch_wrapper.sumPlantLeafArea(self.
_plantarch_ptr, plant_id)
2074 except Exception
as e:
2079 Enable optional output object data to be written to the Context.
2081 By default, the plant architecture model only writes a minimal set of
2082 object data. This method enables additional object data fields so that
2083 they are available on the Context's compound objects after building.
2086 object_data_labels: A single label or a list of labels to enable.
2087 Valid labels include: "age", "rank", "plantID", "plant_name",
2088 "plant_height", "plant_type", "phenology_stage", "leafID",
2089 "peduncleID", "closedflowerID", "openflowerID", "fruitID",
2090 "carbohydrate_concentration". The special label "all" enables
2091 every available field.
2094 ValueError: If a label is empty or not a string
2095 PlantArchitectureError: If an invalid label is supplied or the
2096 operation otherwise fails
2099 >>> plantarch.optionalOutputObjectData("age")
2100 >>> plantarch.optionalOutputObjectData(["rank", "plant_height"])
2101 >>> plantarch.optionalOutputObjectData("all")
2103 if isinstance(object_data_labels, str):
2104 labels = [object_data_labels]
2106 labels = list(object_data_labels)
2111 for label
in labels:
2112 plantarch_wrapper.optionalOutputObjectData(self.
_plantarch_ptr, label)
2115 except Exception
as e:
2121 time_to_dormancy_break: float,
2122 time_to_flower_initiation: float,
2123 time_to_flower_opening: float,
2124 time_to_fruit_set: float,
2125 time_to_fruit_maturity: float,
2126 time_to_dormancy: float,
2127 max_leaf_lifespan: float = 1e6,
2128 is_evergreen: bool =
False
2131 Set phenological timing thresholds for plant developmental stages.
2133 Controls the timing of key phenological events based on thermal time
2134 or calendar time depending on the plant model.
2137 plant_id: ID of the plant instance
2138 time_to_dormancy_break: Degree-days or days until dormancy ends
2139 time_to_flower_initiation: Time until flower buds are initiated
2140 time_to_flower_opening: Time until flowers open
2141 time_to_fruit_set: Time until fruit begins developing
2142 time_to_fruit_maturity: Time until fruit reaches maturity
2143 time_to_dormancy: Time until plant enters dormancy
2144 max_leaf_lifespan: Maximum leaf lifespan in days (default: 1e6)
2145 is_evergreen: If True, the plant retains leaves through dormancy
2146 instead of shedding them at senescence (default: False)
2149 ValueError: If plant_id is negative
2150 PlantArchitectureError: If phenology setting fails
2153 >>> # Set phenology for perennial fruit tree
2154 >>> plantarch.setPlantPhenologicalThresholds(
2155 ... plant_id=plant_id,
2156 ... time_to_dormancy_break=60, # Spring: 60 degree-days
2157 ... time_to_flower_initiation=90, # Early spring flowering
2158 ... time_to_flower_opening=105, # Bloom period
2159 ... time_to_fruit_set=120, # Fruit set after pollination
2160 ... time_to_fruit_maturity=200, # Summer fruit maturation
2161 ... time_to_dormancy=280, # Fall dormancy
2162 ... max_leaf_lifespan=180 # Deciduous - 6 month leaf life
2166 raise ValueError(
"Plant ID must be non-negative")
2171 plantarch_wrapper.setPlantPhenologicalThresholds(
2174 time_to_dormancy_break,
2175 time_to_flower_initiation,
2176 time_to_flower_opening,
2178 time_to_fruit_maturity,
2183 except Exception
as e:
2188 Disable phenological progression for a plant.
2190 The plant continues to grow, but no phenological stage is ever scheduled: it does not
2191 enter dormancy, and flower and fruit stages are skipped. This is the explicit form of the
2192 state a plant is already in when :meth:`setPlantPhenologicalThresholds` has never been
2193 called on it, so it is mainly useful for turning phenology back off on a plant that had
2194 thresholds set earlier.
2197 plant_id: Identifier of the plant whose phenology is to be disabled
2200 helios-core's ``disablePlantPhenology()`` sets ``dd_to_fruit_maturity`` to ``-1``,
2201 whereas the "no phenology scheduled" default for that field is ``1e6``. The field is
2202 used as a divisor in the fruit-growth branch of ``advanceTime()``, which is gated only
2203 on a bud being in the ``BUD_FRUITING`` state, and ``appendPhytomerToShoot()`` can set
2204 that state from shoot structure alone. On a plant that already has a fruiting bud, a
2205 subsequent ``advanceTime()`` can therefore compute a negative fruit scale factor. Avoid
2206 calling this on a plant with fruiting buds until it is fixed upstream; a plant that
2207 never had thresholds set is already in the no-phenology state and does not need it.
2210 ValueError: If plant_id is negative
2211 PlantArchitectureError: If disabling phenology fails
2214 >>> plantarch.setPlantPhenologicalThresholds(plant_id, 60, 90, 105, 120, 200, 280)
2215 >>> plantarch.disablePlantPhenology(plant_id) # growth only, no dormancy or fruiting
2218 raise ValueError(
"Plant ID must be non-negative")
2223 plantarch_wrapper.disablePlantPhenology(self.
_plantarch_ptr, plant_id)
2224 except Exception
as e:
2230 Force a plant into a dormant state immediately.
2232 This is the direct equivalent of ``makePlantDormant()`` in helios-core, as called by the
2233 library builders such as ``buildAppleTree()``. It is the counterpart to scheduling dormancy
2234 through :meth:`setPlantPhenologicalThresholds`: this forces the state now, rather than
2235 waiting for a degree-day threshold to be crossed.
2237 Dormancy strips the plant's leaves and marks its non-dormant buds dormant, so a
2238 custom-built plant can be put into the same over-winter state that a library-built
2239 perennial reaches through phenology.
2242 plant_id: Identifier of the plant to make dormant
2245 ValueError: If plant_id is negative
2246 PlantArchitectureError: If the plant does not exist or the call fails
2249 >>> plant_id = plantarch.addPlantInstance(vec3(0, 0, 0), 0.0)
2250 >>> plantarch.addBaseStemShoot(plant_id, 3, AxisRotation(0, 0, 0),
2251 ... 0.01, 0.1, 1.0, 1.0, 0.9, "trifoliate")
2252 >>> plantarch.makePlantDormant(plant_id)
2255 raise ValueError(
"Plant ID must be non-negative")
2260 plantarch_wrapper.makePlantDormant(self.
_plantarch_ptr, plant_id)
2261 except Exception
as e:
2266 Break dormancy for all shoots on a plant, returning it to an active state.
2268 This is the counterpart to :meth:`makePlantDormant`. Note that it only revives buds that
2269 are not dead, so a plant that was repeatedly made dormant may not recover every bud.
2272 plant_id: Identifier of the plant whose dormancy should be broken
2275 ValueError: If plant_id is negative
2276 PlantArchitectureError: If the plant does not exist or the call fails
2279 >>> plantarch.makePlantDormant(plant_id)
2280 >>> plantarch.breakPlantDormancy(plant_id) # resume growth in spring
2283 raise ValueError(
"Plant ID must be non-negative")
2288 plantarch_wrapper.breakPlantDormancy(self.
_plantarch_ptr, plant_id)
2289 except Exception
as e:
2294 Check whether a plant is dormant.
2297 plant_id: Identifier of the plant to check
2300 True if all shoots on the plant are dormant, False otherwise
2303 ValueError: If plant_id is negative
2304 PlantArchitectureError: If the plant does not exist or the query fails
2307 >>> plantarch.makePlantDormant(plant_id)
2308 >>> plantarch.isPlantDormant(plant_id)
2312 raise ValueError(
"Plant ID must be non-negative")
2317 return plantarch_wrapper.isPlantDormant(self.
_plantarch_ptr, plant_id)
2318 except Exception
as e:
2322 def pruneBranch(self, plant_id: int, shoot_id: int, node_index: int) ->
None:
2324 Prune a shoot at a node, removing that node and everything distal to it.
2326 The phytomer at ``node_index`` is deleted along with every phytomer above it
2327 on the same shoot, and the cut recurses into every child shoot attached at or
2328 above that node. The shoot's woody internode tube is trimmed back to the cut
2329 and its apical bud is terminated, so the pruned axis will not resume growing.
2330 Pruning at ``node_index=0`` therefore removes the entire shoot and its whole
2334 plant_id: ID of the plant instance
2335 shoot_id: Shoot index within the plant (see :meth:`getAllShootIDs`)
2336 node_index: Node on the shoot to cut at, in ``[0, node_count)``
2339 ValueError: If any identifier is negative
2340 PlantArchitectureError: If the plant or shoot does not exist, if
2341 ``node_index`` is beyond the shoot's current node count, or if the
2345 A pruned shoot currently keeps its ID in :meth:`getAllShootIDs` with a
2346 ``node_count`` of 0 rather than disappearing. Do not rely on either
2347 behavior; traverse with :meth:`getShoot` and treat ``node_count == 0``
2348 as "nothing left here".
2351 >>> # Remove a whole branch and everything growing off it
2352 >>> plantarch.pruneBranch(plant_id, shoot_id=3, node_index=0)
2353 >>> # Head back a leader, keeping its lowest 5 nodes
2354 >>> plantarch.pruneBranch(plant_id, shoot_id=0, node_index=5)
2356 if plant_id < 0
or shoot_id < 0
or node_index < 0:
2357 raise ValueError(
"Plant ID, shoot ID and node index must be non-negative")
2361 plantarch_wrapper.pruneBranch(self.
_plantarch_ptr, plant_id, shoot_id, node_index)
2362 except Exception
as e:
2364 f
"Failed to prune shoot {shoot_id} of plant {plant_id} at node {node_index}: {e}")
2368 Harvest a plant by removing its flowers and fruit.
2370 Every non-dormant floral bud on the plant is killed, which deletes the
2371 associated flower, fruit and peduncle geometry from the Context. Vegetative
2372 structure is untouched and the plant continues to grow afterwards.
2375 plant_id: ID of the plant instance to harvest
2378 ValueError: If plant_id is negative
2379 PlantArchitectureError: If the plant does not exist or the call fails
2382 Leaves are **not** removed, despite what the upstream Helios
2383 documentation for ``harvestPlant`` states. Use :meth:`removePlantLeaves`
2387 >>> before = len(plantarch.getPlantFruitObjectIDs(plant_id))
2388 >>> plantarch.harvestPlant(plant_id)
2389 >>> len(plantarch.getPlantFruitObjectIDs(plant_id)) < before
2393 raise ValueError(
"Plant ID must be non-negative")
2398 except Exception
as e:
2403 Remove all leaves from every shoot on a plant.
2405 Leaf and petiole geometry is deleted from the Context. Buds are left alive,
2406 so the plant can produce new leaves as it continues to grow.
2409 plant_id: ID of the plant instance to defoliate
2412 ValueError: If plant_id is negative
2413 PlantArchitectureError: If the plant does not exist or the call fails
2416 >>> plantarch.removePlantLeaves(plant_id)
2417 >>> plantarch.getPlantLeafObjectIDs(plant_id)
2421 raise ValueError(
"Plant ID must be non-negative")
2425 plantarch_wrapper.removePlantLeaves(self.
_plantarch_ptr, plant_id)
2426 except Exception
as e:
2431 Remove all leaves from a single shoot.
2434 plant_id: ID of the plant instance
2435 shoot_id: Shoot index within the plant (see :meth:`getAllShootIDs`)
2438 ValueError: If either identifier is negative
2439 PlantArchitectureError: If the plant or shoot does not exist
2442 >>> # Strip the leaves off a grapevine trunk, as in a trained architecture
2443 >>> plantarch.removeShootLeaves(plant_id, shoot_id=0)
2449 Mark every vegetative bud on a single shoot as dead.
2451 Despite the name, nothing is removed: each axillary vegetative bud on the shoot
2452 is set to ``BudState.DEAD`` and the bud entries themselves stay in place, so
2453 :meth:`getShootVegetativeBudCount` still sees them and the unfiltered count is
2454 unchanged. Dead buds are skipped when dormancy breaks, so the shoot keeps its
2455 existing structure but produces no new lateral shoots -- the standard way to stop
2456 a trained axis from throwing new canes, and to stop the old wood of a
2457 reconstructed tree re-growing.
2459 This is exactly equivalent to setting every bud on the shoot to
2460 ``BudState.DEAD``; the shoot's own apex is unaffected, so pair it with
2461 :meth:`terminateApicalBud` to stop the shoot extending as well.
2464 plant_id: ID of the plant instance
2465 shoot_id: Shoot index within the plant (see :meth:`getAllShootIDs`)
2468 ValueError: If either identifier is negative
2469 PlantArchitectureError: If the plant or shoot does not exist
2472 :meth:`getShootVegetativeBudCount`, to confirm the buds are dead rather than
2473 gone, and :meth:`terminateApicalBud`, for the shoot's apex.
2476 >>> plantarch.removeShootVegetativeBuds(plant_id, shoot_id=1)
2483 Kill all floral buds on a single shoot.
2485 Existing flower, fruit and peduncle geometry on the shoot is deleted and no
2486 new flowers will form there.
2489 plant_id: ID of the plant instance
2490 shoot_id: Shoot index within the plant (see :meth:`getAllShootIDs`)
2493 ValueError: If either identifier is negative
2494 PlantArchitectureError: If the plant or shoot does not exist
2497 >>> plantarch.removeShootFloralBuds(plant_id, shoot_id=1)
2502 organ_description: str) ->
None:
2503 """Shared body for the three shoot-level organ removal methods."""
2504 if plant_id < 0
or shoot_id < 0:
2505 raise ValueError(
"Plant ID and shoot ID must be non-negative")
2509 getattr(plantarch_wrapper, wrapper_fn_name)(self.
_plantarch_ptr, plant_id, shoot_id)
2510 except Exception
as e:
2512 f
"Failed to remove {organ_description} from shoot {shoot_id} "
2513 f
"of plant {plant_id}: {e}")
2518 Group a plant's shoot IDs by branching rank.
2520 Rank 0 is the base stem, rank 1 its direct branches, and so on. Shoots that have
2521 been pruned away are not included.
2524 plant_id: ID of the plant instance
2527 Dict mapping rank to the list of shoot IDs at that rank. Ranks with no live
2528 shoots are omitted from the dict.
2531 ValueError: If plant_id is negative
2532 PlantArchitectureError: If the plant does not exist
2535 >>> by_rank = plantarch.getShootIDsByRank(plant_id)
2536 >>> print(f"{len(by_rank.get(1, []))} primary branches")
2539 raise ValueError(
"Plant ID must be non-negative")
2542 groups = plantarch_wrapper.getShootIDsByRank(self.
_plantarch_ptr, plant_id)
2543 except Exception
as e:
2545 f
"Failed to get shoot IDs by rank for plant {plant_id}: {e}")
2549 return {rank: shoot_ids
for rank, shoot_ids
in enumerate(groups)
if shoot_ids}
2553 Get the plant's terminal shoots -- those carrying no child shoots.
2555 These are the tips of the shoot tree. Note that this is a topological test rather
2556 than a botanical one: a shoot whose axis is continued by :meth:`appendShoot` has
2557 that continuation as a child and so is not terminal. Pruned shoots are omitted.
2560 plant_id: ID of the plant instance
2563 List of terminal shoot IDs.
2566 ValueError: If plant_id is negative
2567 PlantArchitectureError: If the plant does not exist
2570 >>> tips = plantarch.getTerminalShootIDs(plant_id)
2573 raise ValueError(
"Plant ID must be non-negative")
2576 return plantarch_wrapper.getTerminalShootIDs(self.
_plantarch_ptr, plant_id)
2577 except Exception
as e:
2579 f
"Failed to get terminal shoots for plant {plant_id}: {e}")
2584 Prune every shoot at or above a given branching rank.
2586 This is the "remove higher-order branches" thinning operation: passing
2587 ``min_rank=3`` leaves the base stem and its first two orders of branching
2588 intact and cuts everything finer. Because :meth:`pruneBranch` already
2589 recurses into child shoots, only the shallowest shoot on each pruned axis is
2590 cut and the rest follow.
2593 plant_id: ID of the plant instance
2594 min_rank: Lowest rank to prune. Must be at least 1 -- rank 0 is the base
2595 stem, and pruning it would destroy the plant.
2598 Ascending list of the shoot IDs actually cut. Shoots removed as a side
2599 effect of a shallower cut are not listed.
2602 ValueError: If plant_id is negative or min_rank is less than 1
2603 PlantArchitectureError: If the plant does not exist
2606 To remove a whole plant use :meth:`deletePlantInstance`; to cut the base
2607 stem itself call :meth:`pruneBranch` directly.
2610 >>> pruned = plantarch.pruneShootsByRank(plant_id, min_rank=3)
2611 >>> print(f"Cut {len(pruned)} higher-order branches")
2614 raise ValueError(
"Plant ID must be non-negative")
2617 f
"min_rank must be at least 1, got {min_rank}. Rank 0 is the base stem; "
2618 "use deletePlantInstance() to remove the whole plant, or pruneBranch() "
2619 "to cut the base stem explicitly.")
2623 for rank, shoot_ids
in by_rank.items()
if rank >= min_rank
2624 for shoot_id
in shoot_ids}
2628 include_self: bool =
True) -> List[int]:
2630 Prune a shoot and everything growing off it.
2633 plant_id: ID of the plant instance
2634 shoot_id: Root of the branch system to remove
2635 include_self: If True (default) the shoot itself is cut at node 0. If
2636 False the shoot is kept and only its child shoots are cut.
2639 Ascending list of the shoot IDs actually cut. Shoots removed as a side
2640 effect of a shallower cut are not listed.
2643 ValueError: If either identifier is negative
2644 PlantArchitectureError: If the plant or shoot does not exist
2647 >>> # Remove a whole branch system
2648 >>> plantarch.pruneShootSubtree(plant_id, shoot_id=2)
2649 >>> # Keep the cane but strip everything growing off it
2650 >>> plantarch.pruneShootSubtree(plant_id, shoot_id=2, include_self=False)
2652 if plant_id < 0
or shoot_id < 0:
2653 raise ValueError(
"Plant ID and shoot ID must be non-negative")
2661 Thin a plant by pruning every *stride*-th terminal shoot.
2663 Terminal shoots are taken in ascending ID order and every ``stride``-th one
2664 starting from the first is cut, so ``stride=2`` removes about half the tips
2665 and ``stride=3`` about a third. The base stem is never cut.
2668 plant_id: ID of the plant instance
2669 stride: Spacing between pruned tips. Must be at least 1; ``stride=1``
2670 prunes every terminal shoot.
2673 Ascending list of the shoot IDs actually cut.
2676 ValueError: If plant_id is negative or stride is less than 1
2677 PlantArchitectureError: If the plant does not exist
2680 >>> pruned = plantarch.pruneTerminalShoots(plant_id, stride=2)
2681 >>> print(f"Thinned {len(pruned)} tips")
2684 raise ValueError(
"Plant ID must be non-negative")
2686 raise ValueError(f
"stride must be at least 1, got {stride}")
2690 if index % stride != 0:
2694 targets.add(shoot_id)
2698 """Return a shoot's child IDs, or an empty list if it no longer resolves."""
2701 except PlantArchitectureError:
2705 """Child shoot IDs that have not been pruned away, ascending."""
2710 """Prune every target that something shallower has not already removed.
2712 pruneBranch() recurses into child shoots, so cutting a shoot also empties
2713 every shoot descended from it. Targets are therefore visited in ascending
2714 shoot ID order -- a child shoot is always created after its parent and so
2715 always has the higher ID -- which puts each shoot after its ancestors. By
2716 the time a descendant of an already-cut shoot comes up it has nothing left
2717 on it and is skipped, so no shoot is cut twice and the returned list holds
2718 only the cuts that actually did something.
2721 for shoot_id
in sorted(set(target_shoot_ids)):
2725 pruned.append(shoot_id)
2729 """Whether a shoot has been pruned away or no longer resolves at all."""
2732 except PlantArchitectureError:
2737 target_object_UUIDs: Optional[List[int]] =
None,
2738 target_object_IDs: Optional[List[int]] =
None,
2739 enable_petiole_collision: bool =
False,
2740 enable_fruit_collision: bool =
False) ->
None:
2742 Enable soft collision avoidance for procedural plant growth.
2744 This method enables the collision detection system that guides plant growth away from
2745 obstacles and other plants. The system uses cone-based gap detection to find optimal
2746 growth directions that minimize collisions while maintaining natural plant architecture.
2749 target_object_UUIDs: List of primitive UUIDs to avoid collisions with. If empty,
2750 avoids all geometry in the context.
2751 target_object_IDs: List of compound object IDs to avoid collisions with.
2752 enable_petiole_collision: Enable collision detection for leaf petioles
2753 enable_fruit_collision: Enable collision detection for fruit organs
2756 PlantArchitectureError: If collision detection activation fails
2759 Collision detection adds computational overhead. Use setStaticObstacles() to mark
2760 static geometry for BVH optimization and improved performance.
2763 >>> # Avoid all geometry
2764 >>> plantarch.enableSoftCollisionAvoidance()
2766 >>> # Avoid specific obstacles
2767 >>> obstacle_uuids = context.getAllUUIDs()
2768 >>> plantarch.enableSoftCollisionAvoidance(target_object_UUIDs=obstacle_uuids)
2770 >>> # Enable collision detection for petioles and fruit
2771 >>> plantarch.enableSoftCollisionAvoidance(
2772 ... enable_petiole_collision=True,
2773 ... enable_fruit_collision=True
2779 plantarch_wrapper.enableSoftCollisionAvoidance(
2781 target_UUIDs=target_object_UUIDs,
2782 target_IDs=target_object_IDs,
2783 enable_petiole=enable_petiole_collision,
2784 enable_fruit=enable_fruit_collision
2786 except Exception
as e:
2791 Enable automatic removal of plant organs that fall below the ground plane.
2793 Organ vertices below `ground_height` are clipped as plant geometry is
2794 built, which prevents drooping leaves and low branches from poking
2795 through a ground tile.
2798 ground_height: Height of the ground plane (default 0.0)
2801 ValueError: If ground_height is not a number
2802 PlantArchitectureError: If the call fails
2805 >>> plantarch.enableGroundClipping(0.0)
2806 >>> plantarch.advanceTime(30.0)
2810 if isinstance(ground_height, bool)
or not isinstance(ground_height, (int, float)):
2811 raise ValueError(f
"Ground height must be a number, got {type(ground_height).__name__}")
2814 plantarch_wrapper.enableGroundClipping(self.
_plantarch_ptr, float(ground_height))
2815 except Exception
as e:
2820 Suppress standard output from the plantarchitecture plugin.
2822 This silences progress bars and informational messages the C++ plugin
2823 writes to stdout, including the "BVH not cached" warning emitted during
2824 the first growth steps of a collision-enabled canopy (before any plant
2825 geometry exists for the BVH to contain).
2828 PlantArchitectureError: If the call fails
2831 >>> plantarch.disableMessages()
2832 >>> plantarch.advanceTime(30.0) # runs quietly
2837 except Exception
as e:
2842 Re-enable standard output from the plantarchitecture plugin.
2845 PlantArchitectureError: If the call fails
2848 >>> plantarch.enableMessages()
2853 except Exception
as e:
2858 Disable collision detection for plant growth.
2860 This method turns off the collision detection system, allowing plants to grow
2861 without checking for obstacles. This improves performance but plants may grow
2862 through obstacles and other geometry.
2865 PlantArchitectureError: If disabling fails
2868 >>> plantarch.disableCollisionDetection()
2873 except Exception
as e:
2877 view_half_angle_deg: float = 80.0,
2878 look_ahead_distance: float = 0.1,
2879 sample_count: int = 256,
2880 inertia_weight: float = 0.4) ->
None:
2882 Configure parameters for soft collision avoidance algorithm.
2884 These parameters control the cone-based gap detection algorithm that guides
2885 plant growth away from obstacles. Adjusting these values allows fine-tuning
2886 the balance between collision avoidance and natural growth patterns.
2889 view_half_angle_deg: Half-angle of detection cone in degrees (0-180).
2890 Default 80° provides wide field of view.
2891 look_ahead_distance: Distance to look ahead for collisions in meters.
2892 Larger values detect distant obstacles. Default 0.1m.
2893 sample_count: Number of ray samples within cone. More samples improve
2894 accuracy but reduce performance. Default 256.
2895 inertia_weight: Weight for previous growth direction (0-1). Higher values
2896 make growth smoother but less responsive. Default 0.4.
2899 ValueError: If parameters are outside valid ranges
2900 PlantArchitectureError: If parameter setting fails
2903 >>> # Use default parameters (recommended)
2904 >>> plantarch.setSoftCollisionAvoidanceParameters()
2906 >>> # Tune for dense canopy with close obstacles
2907 >>> plantarch.setSoftCollisionAvoidanceParameters(
2908 ... view_half_angle_deg=60.0, # Narrower detection cone
2909 ... look_ahead_distance=0.05, # Shorter look-ahead
2910 ... sample_count=512, # More accurate detection
2911 ... inertia_weight=0.3 # More responsive to obstacles
2915 if not (0 <= view_half_angle_deg <= 180):
2916 raise ValueError(f
"view_half_angle_deg must be between 0 and 180, got {view_half_angle_deg}")
2917 if look_ahead_distance <= 0:
2918 raise ValueError(f
"look_ahead_distance must be positive, got {look_ahead_distance}")
2919 if sample_count <= 0:
2920 raise ValueError(f
"sample_count must be positive, got {sample_count}")
2921 if not (0 <= inertia_weight <= 1):
2922 raise ValueError(f
"inertia_weight must be between 0 and 1, got {inertia_weight}")
2926 plantarch_wrapper.setSoftCollisionAvoidanceParameters(
2928 view_half_angle_deg,
2929 look_ahead_distance,
2933 except Exception
as e:
2937 include_internodes: bool =
False,
2938 include_leaves: bool =
True,
2939 include_petioles: bool =
False,
2940 include_flowers: bool =
False,
2941 include_fruit: bool =
False) ->
None:
2943 Specify which plant organs participate in collision detection.
2945 This method allows filtering which organs are considered during collision detection,
2946 enabling optimization by excluding organs unlikely to cause problematic collisions.
2949 include_internodes: Include stem internodes in collision detection
2950 include_leaves: Include leaf blades in collision detection
2951 include_petioles: Include leaf petioles in collision detection
2952 include_flowers: Include flowers in collision detection
2953 include_fruit: Include fruit in collision detection
2956 PlantArchitectureError: If organ filtering fails
2959 >>> # Only detect collisions for stems and leaves (default behavior)
2960 >>> plantarch.setCollisionRelevantOrgans(
2961 ... include_internodes=True,
2962 ... include_leaves=True
2965 >>> # Include all organs
2966 >>> plantarch.setCollisionRelevantOrgans(
2967 ... include_internodes=True,
2968 ... include_leaves=True,
2969 ... include_petioles=True,
2970 ... include_flowers=True,
2971 ... include_fruit=True
2976 plantarch_wrapper.setCollisionRelevantOrgans(
2984 except Exception
as e:
2988 obstacle_UUIDs: List[int],
2989 avoidance_distance: float = 0.5,
2990 enable_fruit_adjustment: bool =
False,
2991 enable_obstacle_pruning: bool =
False) ->
None:
2993 Enable hard obstacle avoidance for specified geometry.
2995 This method configures solid obstacles that plants cannot grow through. Unlike soft
2996 collision avoidance (which guides growth), solid obstacles cause complete growth
2997 termination when encountered within the avoidance distance.
3000 obstacle_UUIDs: List of primitive UUIDs representing solid obstacles
3001 avoidance_distance: Minimum distance to maintain from obstacles (meters).
3002 Growth stops if obstacles are closer. Default 0.5m.
3003 enable_fruit_adjustment: Adjust fruit positions away from obstacles
3004 enable_obstacle_pruning: Remove plant organs that penetrate obstacles
3007 ValueError: If obstacle_UUIDs is empty or avoidance_distance is non-positive
3008 PlantArchitectureError: If solid obstacle configuration fails
3011 >>> # Simple solid obstacle avoidance
3012 >>> wall_uuids = [1, 2, 3, 4] # UUIDs of wall primitives
3013 >>> plantarch.enableSolidObstacleAvoidance(wall_uuids)
3015 >>> # Close avoidance with fruit adjustment
3016 >>> plantarch.enableSolidObstacleAvoidance(
3017 ... obstacle_UUIDs=wall_uuids,
3018 ... avoidance_distance=0.1,
3019 ... enable_fruit_adjustment=True
3022 if not obstacle_UUIDs:
3023 raise ValueError(
"Obstacle UUIDs list cannot be empty")
3024 if avoidance_distance <= 0:
3025 raise ValueError(f
"avoidance_distance must be positive, got {avoidance_distance}")
3030 plantarch_wrapper.enableSolidObstacleAvoidance(
3034 enable_fruit_adjustment,
3035 enable_obstacle_pruning
3037 except Exception
as e:
3042 Mark geometry as static obstacles for collision detection optimization.
3044 This method tells the collision detection system that certain geometry will not
3045 move during the simulation. The system can then build an optimized Bounding Volume
3046 Hierarchy (BVH) for these obstacles, significantly improving collision detection
3047 performance in scenes with many static obstacles.
3050 target_UUIDs: List of primitive UUIDs representing static obstacles
3053 ValueError: If target_UUIDs is empty
3054 PlantArchitectureError: If static obstacle configuration fails
3057 Collision avoidance must be enabled BEFORE calling this method -- the
3058 native call raises "Collision detection must be enabled before setting
3059 static obstacles" otherwise.
3060 Static obstacles cannot be modified or moved after being marked static.
3063 >>> # Enable collision avoidance first
3064 >>> plantarch.enableSoftCollisionAvoidance()
3065 >>> # Then mark ground and building geometry as static
3066 >>> static_uuids = ground_uuids + building_uuids
3067 >>> plantarch.setStaticObstacles(static_uuids)
3069 if not target_UUIDs:
3070 raise ValueError(
"target_UUIDs list cannot be empty")
3075 plantarch_wrapper.setStaticObstacles(self.
_plantarch_ptr, target_UUIDs)
3076 except Exception
as e:
3081 Get object IDs of collision-relevant geometry for a specific plant.
3083 This method returns the subset of plant geometry that participates in collision
3084 detection, as filtered by setCollisionRelevantOrgans(). Useful for visualization
3085 and debugging collision detection behavior.
3088 plant_id: ID of the plant instance
3091 List of object IDs for collision-relevant plant geometry
3094 ValueError: If plant_id is negative
3095 PlantArchitectureError: If retrieval fails
3098 >>> # Get collision-relevant geometry
3099 >>> collision_obj_ids = plantarch.getPlantCollisionRelevantObjectIDs(plant_id)
3100 >>> print(f"Plant has {len(collision_obj_ids)} collision-relevant objects")
3102 >>> # Highlight collision geometry in visualization
3103 >>> for obj_id in collision_obj_ids:
3104 ... context.setObjectColor(obj_id, RGBcolor(1, 0, 0)) # Red
3107 raise ValueError(
"Plant ID must be non-negative")
3111 return plantarch_wrapper.getPlantCollisionRelevantObjectIDs(self.
_plantarch_ptr, plant_id)
3112 except Exception
as e:
3118 Write all plant mesh vertices to file for external processing.
3120 This method exports all vertex coordinates (x,y,z) for every primitive in the plant,
3121 writing one vertex per line. Useful for external processing such as computing bounding
3122 volumes, convex hulls, or performing custom geometric analysis.
3125 plant_id: ID of the plant instance to export
3126 filename: Path to output file (absolute or relative to current working directory)
3129 ValueError: If plant_id is negative or filename is empty
3130 PlantArchitectureError: If plant doesn't exist or file cannot be written
3133 >>> # Export vertices for convex hull analysis
3134 >>> plantarch.writePlantMeshVertices(plant_id, "plant_vertices.txt")
3136 >>> # Use with Path object
3137 >>> from pathlib import Path
3138 >>> output_dir = Path("output")
3139 >>> output_dir.mkdir(exist_ok=True)
3140 >>> plantarch.writePlantMeshVertices(plant_id, output_dir / "vertices.txt")
3143 raise ValueError(
"Plant ID must be non-negative")
3145 raise ValueError(
"Filename cannot be empty")
3153 plantarch_wrapper.writePlantMeshVertices(
3156 except Exception
as e:
3161 Save plant structure to XML file for later loading.
3163 This method exports the complete plant architecture to an XML file, including
3164 all shoots, phytomers, organs, and their properties. The saved plant can be
3165 reloaded later using readPlantStructureXML().
3168 plant_id: ID of the plant instance to save
3169 filename: Path to output XML file (absolute or relative to current working directory)
3172 ValueError: If plant_id is negative or filename is empty
3173 PlantArchitectureError: If plant doesn't exist or file cannot be written
3176 The XML format preserves the complete plant state including:
3177 - Shoot structure and hierarchy
3178 - Phytomer properties and development stage
3179 - Organ geometry and attributes
3180 - Growth parameters and phenological state
3183 >>> # Save plant at current growth stage
3184 >>> plantarch.writePlantStructureXML(plant_id, "bean_day30.xml")
3186 >>> # Later, reload the saved plant
3187 >>> loaded_plant_ids = plantarch.readPlantStructureXML("bean_day30.xml")
3188 >>> print(f"Loaded {len(loaded_plant_ids)} plants")
3191 raise ValueError(
"Plant ID must be non-negative")
3193 raise ValueError(
"Filename cannot be empty")
3201 plantarch_wrapper.writePlantStructureXML(
3204 except Exception
as e:
3209 Export plant structure in TreeQSM cylinder format.
3211 This method writes the plant structure as a series of cylinders following the
3212 TreeQSM format (Raumonen et al., 2013). Each row represents one cylinder with
3213 columns for radius, length, start position, axis direction, branch topology,
3214 and other structural properties. Useful for biomechanical analysis and
3215 quantitative structure modeling.
3218 plant_id: ID of the plant instance to export
3219 filename: Path to output file (absolute or relative, typically .txt extension)
3222 ValueError: If plant_id is negative or filename is empty
3223 PlantArchitectureError: If plant doesn't exist or file cannot be written
3226 The TreeQSM format includes columns for:
3227 - Cylinder dimensions (radius, length)
3228 - Spatial position and orientation
3229 - Branch topology (parent ID, extension ID, branch ID)
3230 - Branch hierarchy (branch order, position in branch)
3231 - Quality metrics (mean absolute distance, surface coverage)
3234 >>> # Export for biomechanical analysis
3235 >>> plantarch.writeQSMCylinderFile(plant_id, "tree_structure_qsm.txt")
3237 >>> # Use with external QSM tools
3238 >>> import pandas as pd
3239 >>> qsm_data = pd.read_csv("tree_structure_qsm.txt", sep="\\t")
3240 >>> print(f"Tree has {len(qsm_data)} cylinders")
3243 Raumonen et al. (2013) "Fast Automatic Precision Tree Models from
3244 Terrestrial Laser Scanner Data" Remote Sensing 5(2):491-520
3247 raise ValueError(
"Plant ID must be non-negative")
3249 raise ValueError(
"Filename cannot be empty")
3257 plantarch_wrapper.writeQSMCylinderFile(
3260 except Exception
as e:
3264 elastic_modulus: float = 5e9,
3265 wood_density: float = 800.0,
3266 damping_ratio: float = 0.1,
3267 static_friction: float = 0.5,
3268 dynamic_friction: float = 0.3,
3269 restitution: float = 0.1,
3270 organ_spring_stiffness: float = 10.0,
3271 organ_spring_damping: float = 1.0,
3272 leaf_mass_per_area: float = 0.05,
3273 fruit_mass: float = 0.01,
3274 flower_mass: float = 0.002,
3275 solver_position_iterations: int = 32,
3276 min_segment_length: float = 0.001) ->
None:
3278 Export plant structure as a USD articulated rigid body for NVIDIA IsaacSim physics.
3280 Each tube segment becomes a capsule-shaped rigid link connected by spherical joints.
3281 Spring/damper drives are derived from beam bending stiffness (E*I/L). Leaves, fruits,
3282 and flowers are represented as mass bodies attached by spring links.
3285 plant_id: ID of the plant instance to export
3286 filename: Output file path (should have .usda extension)
3287 elastic_modulus: Young's modulus (Pa) for joint stiffness, K = E*I/L
3288 wood_density: Wood density (kg/m^3) used to compute mass from capsule volume
3289 damping_ratio: Joint damping ratio (dimensionless)
3290 static_friction: Static friction coefficient for collision material
3291 dynamic_friction: Dynamic friction coefficient for collision material
3292 restitution: Restitution (bounciness) for collision material
3293 organ_spring_stiffness: Spring stiffness (N*m/rad) for organ attachment joints
3294 organ_spring_damping: Damping (N*m*s/rad) for organ attachment joints
3295 leaf_mass_per_area: Leaf mass per unit area (kg/m^2)
3296 fruit_mass: Mass per fruit (kg)
3297 flower_mass: Mass per flower (kg)
3298 solver_position_iterations: PhysX articulation solver position iteration count
3299 min_segment_length: Minimum segment length (m); shorter segments are skipped
3302 ValueError: If plant_id is negative or filename is empty
3303 PlantArchitectureError: If plant doesn't exist or file cannot be written
3306 >>> plantarch.writePlantStructureUSD(plant_id, "plant.usda")
3309 raise ValueError(
"Plant ID must be non-negative")
3311 raise ValueError(
"Filename cannot be empty")
3318 plantarch_wrapper.writePlantStructureUSD(
3320 elastic_modulus, wood_density, damping_ratio,
3321 static_friction, dynamic_friction, restitution,
3322 organ_spring_stiffness, organ_spring_damping,
3323 leaf_mass_per_area, fruit_mass, flower_mass,
3324 solver_position_iterations, min_segment_length
3326 except Exception
as e:
3331 Capture a snapshot of the plant's geometry as a growth animation frame.
3333 Call this after each :meth:`advanceTime` step to record the plant state for later
3334 animation export via :meth:`writePlantGrowthUSD`.
3337 plant_id: ID of the plant instance to capture
3338 min_segment_length: Minimum segment length (m); shorter segments are skipped
3341 ValueError: If plant_id is negative
3342 PlantArchitectureError: If plant doesn't exist
3345 raise ValueError(
"Plant ID must be non-negative")
3349 plantarch_wrapper.registerGrowthFrame(self.
_plantarch_ptr, plant_id, min_segment_length)
3350 except Exception
as e:
3354 seconds_per_frame: float = 1.0) ->
None:
3356 Export all registered growth frames as a time-sampled USD animation file.
3358 The resulting file can be imported directly into Blender. This is a visual-only
3359 export — no physics prims, joints, or collision shapes are written.
3362 plant_id: ID of the plant instance to export
3363 filename: Output file path (should have .usda extension)
3364 seconds_per_frame: Duration in seconds each growth frame occupies (default: 1.0)
3367 ValueError: If plant_id is negative or filename is empty
3368 PlantArchitectureError: If plant doesn't exist or file cannot be written
3371 raise ValueError(
"Plant ID must be non-negative")
3373 raise ValueError(
"Filename cannot be empty")
3380 plantarch_wrapper.writePlantGrowthUSD(
3383 except Exception
as e:
3388 Clear stored growth animation frames for a plant.
3391 plant_id: ID of the plant instance whose frames should be cleared
3394 ValueError: If plant_id is negative
3397 raise ValueError(
"Plant ID must be non-negative")
3401 plantarch_wrapper.clearGrowthFrames(self.
_plantarch_ptr, plant_id)
3402 except Exception
as e:
3407 Get the number of registered growth frames for a plant.
3410 plant_id: ID of the plant instance to query
3413 Number of frames registered via :meth:`registerGrowthFrame`
3416 ValueError: If plant_id is negative
3419 raise ValueError(
"Plant ID must be non-negative")
3423 return plantarch_wrapper.getGrowthFrameCount(self.
_plantarch_ptr, plant_id)
3424 except Exception
as e:
3429 Load plant structure from XML file.
3431 This method reads plant architecture data from an XML file previously saved with
3432 writePlantStructureXML(). The loaded plants are added to the current context
3433 and can be grown, modified, or analyzed like any other plants.
3436 filename: Path to XML file to load (absolute or relative to current working directory)
3437 quiet: If True, suppress console output during loading (default: False)
3440 List of plant IDs for the loaded plant instances
3443 ValueError: If filename is empty
3444 PlantArchitectureError: If file doesn't exist, cannot be parsed, or loading fails
3447 The XML file can contain multiple plant instances. All plants in the file
3448 will be loaded and their IDs returned in a list. Plant models referenced
3449 in the XML must be available in the plant library.
3452 >>> # Load previously saved plants
3453 >>> plant_ids = plantarch.readPlantStructureXML("saved_canopy.xml")
3454 >>> print(f"Loaded {len(plant_ids)} plants")
3456 >>> # Continue growing the loaded plants
3457 >>> plantarch.advanceTime(10.0)
3459 >>> # Load quietly without console messages
3460 >>> plant_ids = plantarch.readPlantStructureXML("bean_day45.xml", quiet=True)
3463 raise ValueError(
"Filename cannot be empty")
3471 return plantarch_wrapper.readPlantStructureXML(
3474 except Exception
as e:
3478 def addPlantInstance(self, base_position: vec3, current_age: float) -> int:
3480 Create an empty plant instance for custom plant building.
3482 This method creates a new plant instance at the specified location without any
3483 shoots or organs. Use addBaseStemShoot(), appendShoot(), and addChildShoot() to
3484 manually construct the plant structure. This provides low-level control over
3485 plant architecture, enabling custom morphologies not available in the plant library.
3488 base_position: Cartesian (x,y,z) coordinates of plant base as vec3
3489 current_age: Current age of the plant in days (must be >= 0)
3492 Plant ID for the created plant instance
3495 ValueError: If age is negative
3496 PlantArchitectureError: If plant creation fails
3499 >>> # Create empty plant at origin
3500 >>> plant_id = plantarch.addPlantInstance(vec3(0, 0, 0), 0.0)
3502 >>> # Now add shoots to build custom plant structure
3503 >>> shoot_id = plantarch.addBaseStemShoot(
3504 ... plant_id, 1, AxisRotation(0, 0, 0), 0.01, 0.1, 1.0, 1.0, 0.8, "mainstem"
3508 if not isinstance(base_position, vec3):
3509 raise ValueError(f
"base_position must be a vec3, got {type(base_position).__name__}")
3512 position_list = [base_position.x, base_position.y, base_position.z]
3516 raise ValueError(f
"Age must be non-negative, got {current_age}")
3521 return plantarch_wrapper.addPlantInstance(
3524 except Exception
as e:
3529 Delete a plant instance and all associated geometry.
3531 This method removes a plant from the simulation, deleting all shoots, organs,
3532 and associated primitives from the context. The plant ID becomes invalid after
3533 deletion and should not be used in subsequent operations.
3536 plant_id: ID of the plant instance to delete
3539 ValueError: If plant_id is negative
3540 PlantArchitectureError: If plant deletion fails or plant doesn't exist
3543 >>> # Delete a plant
3544 >>> plantarch.deletePlantInstance(plant_id)
3546 >>> # Delete multiple plants
3547 >>> for pid in plant_ids_to_remove:
3548 ... plantarch.deletePlantInstance(pid)
3551 raise ValueError(
"Plant ID must be non-negative")
3556 plantarch_wrapper.deletePlantInstance(self.
_plantarch_ptr, plant_id)
3557 except Exception
as e:
3562 current_node_number: int,
3563 base_rotation: AxisRotation,
3564 internode_radius: float,
3565 internode_length_max: float,
3566 internode_length_scale_factor_fraction: float,
3567 leaf_scale_factor_fraction: float,
3568 radius_taper: float,
3569 shoot_type_label: str) -> int:
3571 Add a base stem shoot to a plant instance (main trunk/stem).
3573 This method creates the primary shoot originating from the plant base. The base stem
3574 is typically the main trunk or primary stem from which all other shoots branch.
3575 Specify growth parameters to control the shoot's morphology and development.
3577 **IMPORTANT - Shoot Type Requirement**: Shoot types must be defined before use. The standard
3578 workflow is to load a plant model first using loadPlantModelFromLibrary(), which defines
3579 shoot types that can then be used for custom building. The shoot_type_label must match a
3580 shoot type defined in the loaded model.
3583 plant_id: ID of the plant instance
3584 current_node_number: Starting node number for this shoot (typically 1)
3585 base_rotation: Orientation as AxisRotation(pitch, yaw, roll) in radians (use math.radians() to convert)
3586 internode_radius: Base radius of internodes in meters (must be > 0)
3587 internode_length_max: Maximum internode length in meters (must be > 0)
3588 internode_length_scale_factor_fraction: Scale factor for internode length (0-1 typically)
3589 leaf_scale_factor_fraction: Scale factor for leaf size (0-1 typically)
3590 radius_taper: Rate of radius decrease along shoot (0-1, where 1=no taper)
3591 shoot_type_label: Label identifying shoot type - must match a type from loaded model
3594 Shoot ID for the created shoot
3597 ValueError: If parameters are invalid (negative IDs, non-positive dimensions, empty label)
3598 PlantArchitectureError: If shoot creation fails or shoot type doesn't exist
3601 >>> from pyhelios.types import vec3, AxisRotation
3603 >>> # REQUIRED: Load a plant model to define shoot types
3604 >>> plantarch.loadPlantModelFromLibrary("bean")
3606 >>> # Create empty plant for custom building
3607 >>> plant_id = plantarch.addPlantInstance(vec3(0, 0, 0), 0.0)
3609 >>> # Add base stem using a shoot type from the loaded model. Labels are
3610 >>> # species-specific: bean defines "unifoliate"/"trifoliate", almond
3611 >>> # defines "trunk"/"scaffold"/"proleptic"/"sylleptic". There is no
3612 >>> # generic "stem" type.
3613 >>> shoot_id = plantarch.addBaseStemShoot(
3614 ... plant_id=plant_id,
3615 ... current_node_number=1,
3616 ... base_rotation=AxisRotation(0, 0, 0), # Upright
3617 ... internode_radius=0.01, # 1cm radius
3618 ... internode_length_max=0.1, # 10cm max length
3619 ... internode_length_scale_factor_fraction=1.0,
3620 ... leaf_scale_factor_fraction=1.0,
3621 ... radius_taper=0.9, # Gradual taper
3622 ... shoot_type_label="trifoliate" # Must match loaded model
3626 raise ValueError(
"Plant ID must be non-negative")
3627 if current_node_number < 0:
3628 raise ValueError(
"Current node number must be non-negative")
3629 if internode_radius <= 0:
3630 raise ValueError(f
"Internode radius must be positive, got {internode_radius}")
3631 if internode_length_max <= 0:
3632 raise ValueError(f
"Internode length max must be positive, got {internode_length_max}")
3633 if not shoot_type_label
or not shoot_type_label.strip():
3634 raise ValueError(
"Shoot type label cannot be empty")
3637 rotation_list = base_rotation.to_list()
3642 return plantarch_wrapper.addBaseStemShoot(
3643 self.
_plantarch_ptr, plant_id, current_node_number, rotation_list,
3644 internode_radius, internode_length_max,
3645 internode_length_scale_factor_fraction, leaf_scale_factor_fraction,
3646 radius_taper, shoot_type_label.strip()
3648 except Exception
as e:
3650 if "does not exist" in error_msg.lower()
and "shoot type" in error_msg.lower():
3652 f
"Shoot type '{shoot_type_label}' not defined. "
3653 f
"Load a plant model first to define shoot types:\n"
3654 f
" plantarch.loadPlantModelFromLibrary('bean') # or other model\n"
3655 f
"Original error: {e}"
3661 parent_shoot_id: int,
3662 current_node_number: int,
3663 base_rotation: AxisRotation,
3664 internode_radius: float,
3665 internode_length_max: float,
3666 internode_length_scale_factor_fraction: float,
3667 leaf_scale_factor_fraction: float,
3668 radius_taper: float,
3669 shoot_type_label: str) -> int:
3671 Append a shoot to the end of an existing shoot.
3673 This method extends an existing shoot by appending a new shoot at its terminal bud.
3674 Useful for creating multi-segmented shoots with varying properties along their length,
3675 such as shoots with different growth phases or developmental stages.
3677 **IMPORTANT - Shoot Type Requirement**: The shoot_type_label must match a shoot type
3678 defined in a loaded plant model. Load a model with loadPlantModelFromLibrary() before
3679 calling this method.
3682 plant_id: ID of the plant instance
3683 parent_shoot_id: ID of the parent shoot to extend
3684 current_node_number: Starting node number for this shoot
3685 base_rotation: Orientation as AxisRotation(pitch, yaw, roll) in radians (use math.radians() to convert)
3686 internode_radius: Base radius of internodes in meters (must be > 0)
3687 internode_length_max: Maximum internode length in meters (must be > 0)
3688 internode_length_scale_factor_fraction: Scale factor for internode length (0-1 typically)
3689 leaf_scale_factor_fraction: Scale factor for leaf size (0-1 typically)
3690 radius_taper: Rate of radius decrease along shoot (0-1, where 1=no taper)
3691 shoot_type_label: Label identifying shoot type - must match loaded model
3694 Shoot ID for the appended shoot
3697 ValueError: If parameters are invalid (negative IDs, non-positive dimensions, empty label)
3698 PlantArchitectureError: If shoot appending fails, parent doesn't exist, or shoot type not defined
3701 >>> # Load model to define shoot types
3702 >>> plantarch.loadPlantModelFromLibrary("bean")
3704 >>> # Append shoot with reduced size to simulate apical growth
3705 >>> new_shoot_id = plantarch.appendShoot(
3706 ... plant_id=plant_id,
3707 ... parent_shoot_id=base_shoot_id,
3708 ... current_node_number=10,
3709 ... base_rotation=AxisRotation(0, 0, 0),
3710 ... internode_radius=0.008, # Smaller than base
3711 ... internode_length_max=0.08, # Shorter internodes
3712 ... internode_length_scale_factor_fraction=1.0,
3713 ... leaf_scale_factor_fraction=0.8, # Smaller leaves
3714 ... radius_taper=0.85,
3715 ... shoot_type_label="trifoliate"
3719 raise ValueError(
"Plant ID must be non-negative")
3720 if parent_shoot_id < 0:
3721 raise ValueError(
"Parent shoot ID must be non-negative")
3722 if current_node_number < 0:
3723 raise ValueError(
"Current node number must be non-negative")
3724 if internode_radius <= 0:
3725 raise ValueError(f
"Internode radius must be positive, got {internode_radius}")
3726 if internode_length_max <= 0:
3727 raise ValueError(f
"Internode length max must be positive, got {internode_length_max}")
3728 if not shoot_type_label
or not shoot_type_label.strip():
3729 raise ValueError(
"Shoot type label cannot be empty")
3732 rotation_list = base_rotation.to_list()
3737 return plantarch_wrapper.appendShoot(
3738 self.
_plantarch_ptr, plant_id, parent_shoot_id, current_node_number,
3739 rotation_list, internode_radius, internode_length_max,
3740 internode_length_scale_factor_fraction, leaf_scale_factor_fraction,
3741 radius_taper, shoot_type_label.strip()
3743 except Exception
as e:
3745 if "does not exist" in error_msg.lower()
and "shoot type" in error_msg.lower():
3747 f
"Shoot type '{shoot_type_label}' not defined. "
3748 f
"Load a plant model first to define shoot types:\n"
3749 f
" plantarch.loadPlantModelFromLibrary('bean') # or other model\n"
3750 f
"Original error: {e}"
3756 parent_shoot_id: int,
3757 parent_node_index: int,
3758 current_node_number: int,
3759 shoot_base_rotation: AxisRotation,
3760 internode_radius: float,
3761 internode_length_max: float,
3762 internode_length_scale_factor_fraction: float,
3763 leaf_scale_factor_fraction: float,
3764 radius_taper: float,
3765 shoot_type_label: str,
3766 petiole_index: int = 0) -> int:
3768 Add a child shoot at an axillary bud position on a parent shoot.
3770 This method creates a lateral branch shoot emerging from a specific node on the
3771 parent shoot. Child shoots enable creation of branching architectures, with control
3772 over branch angle, size, and which petiole position the branch emerges from (for
3773 plants with multiple petioles per node).
3775 **IMPORTANT - Shoot Type Requirement**: The shoot_type_label must match a shoot type
3776 defined in a loaded plant model. Load a model with loadPlantModelFromLibrary() before
3777 calling this method.
3780 plant_id: ID of the plant instance
3781 parent_shoot_id: ID of the parent shoot
3782 parent_node_index: Index of the parent node where child emerges (0-based)
3783 current_node_number: Starting node number for this child shoot
3784 shoot_base_rotation: Orientation as AxisRotation(pitch, yaw, roll) in radians (use math.radians() to convert)
3785 internode_radius: Base radius of child shoot internodes in meters (must be > 0)
3786 internode_length_max: Maximum internode length in meters (must be > 0)
3787 internode_length_scale_factor_fraction: Scale factor for internode length (0-1 typically)
3788 leaf_scale_factor_fraction: Scale factor for leaf size (0-1 typically)
3789 radius_taper: Rate of radius decrease along shoot (0-1, where 1=no taper)
3790 shoot_type_label: Label identifying shoot type - must match loaded model
3791 petiole_index: Which petiole at the node to branch from (default: 0)
3794 Shoot ID for the created child shoot
3797 ValueError: If parameters are invalid (negative values, non-positive dimensions, empty label)
3798 PlantArchitectureError: If child shoot creation fails, parent doesn't exist, or shoot type not defined
3802 >>> # Load model to define shoot types
3803 >>> plantarch.loadPlantModelFromLibrary("bean")
3805 >>> # Add lateral branch at 45-degree angle from node 3
3806 >>> branch_id = plantarch.addChildShoot(
3807 ... plant_id=plant_id,
3808 ... parent_shoot_id=main_shoot_id,
3809 ... parent_node_index=3,
3810 ... current_node_number=1,
3811 ... shoot_base_rotation=AxisRotation(math.radians(45), math.radians(90), 0), # 45° out, 90° around
3812 ... internode_radius=0.005, # Thinner than main stem
3813 ... internode_length_max=0.06, # Shorter internodes
3814 ... internode_length_scale_factor_fraction=1.0,
3815 ... leaf_scale_factor_fraction=0.9,
3816 ... radius_taper=0.8,
3817 ... shoot_type_label="trifoliate"
3820 >>> # Add second branch from opposite petiole
3821 >>> branch_id2 = plantarch.addChildShoot(
3822 ... plant_id, main_shoot_id, 3, 1, AxisRotation(math.radians(45), math.radians(270), 0),
3823 ... 0.005, 0.06, 1.0, 0.9, 0.8, "trifoliate", petiole_index=1
3827 raise ValueError(
"Plant ID must be non-negative")
3828 if parent_shoot_id < 0:
3829 raise ValueError(
"Parent shoot ID must be non-negative")
3830 if parent_node_index < 0:
3831 raise ValueError(
"Parent node index must be non-negative")
3832 if current_node_number < 0:
3833 raise ValueError(
"Current node number must be non-negative")
3834 if internode_radius <= 0:
3835 raise ValueError(f
"Internode radius must be positive, got {internode_radius}")
3836 if internode_length_max <= 0:
3837 raise ValueError(f
"Internode length max must be positive, got {internode_length_max}")
3838 if not shoot_type_label
or not shoot_type_label.strip():
3839 raise ValueError(
"Shoot type label cannot be empty")
3840 if petiole_index < 0:
3841 raise ValueError(f
"Petiole index must be non-negative, got {petiole_index}")
3844 rotation_list = shoot_base_rotation.to_list()
3849 return plantarch_wrapper.addChildShoot(
3850 self.
_plantarch_ptr, plant_id, parent_shoot_id, parent_node_index,
3851 current_node_number, rotation_list, internode_radius,
3852 internode_length_max, internode_length_scale_factor_fraction,
3853 leaf_scale_factor_fraction, radius_taper, shoot_type_label.strip(),
3856 except Exception
as e:
3858 if "does not exist" in error_msg.lower()
and "shoot type" in error_msg.lower():
3860 f
"Shoot type '{shoot_type_label}' not defined. "
3861 f
"Load a plant model first to define shoot types:\n"
3862 f
" plantarch.loadPlantModelFromLibrary('bean') # or other model\n"
3863 f
"Original error: {e}"
3873 """Validate a measured node path and return it as plain lists for the ctypes layer."""
3874 if not isinstance(node_positions, (list, tuple)):
3875 raise ValueError(f
"{positions_name} must be a list of vec3, got {type(node_positions).__name__}")
3876 if not isinstance(node_radii, (list, tuple)):
3877 raise ValueError(f
"{radii_name} must be a list of floats, got {type(node_radii).__name__}")
3878 if len(node_positions) < 2:
3879 raise ValueError(f
"{positions_name} must contain at least two positions, got {len(node_positions)}")
3880 if len(node_radii) != len(node_positions):
3882 f
"{radii_name} must have one entry per position: got {len(node_radii)} radii "
3883 f
"for {len(node_positions)} positions")
3885 for i, pt
in enumerate(node_positions):
3886 if not isinstance(pt, vec3):
3887 raise ValueError(f
"{positions_name}[{i}] must be a vec3, got {type(pt).__name__}")
3888 positions.append([pt.x, pt.y, pt.z])
3890 for i, r
in enumerate(node_radii):
3891 if isinstance(r, bool)
or not isinstance(r, (int, float)):
3892 raise ValueError(f
"{radii_name}[{i}] must be a number, got {type(r).__name__}")
3894 raise ValueError(f
"{radii_name}[{i}] must be positive, got {r}")
3895 radii.append(float(r))
3896 return positions, radii
3900 parent_shoot_id: int,
3901 parent_node_index: int,
3902 node_positions: List[vec3],
3903 node_radii: List[float],
3904 shoot_type_label: str,
3905 growth_shoot_type_label: Optional[str] =
None,
3906 petiole_index: int = 0) -> int:
3908 Add a shoot whose internode geometry is prescribed by measured node positions.
3910 This builds a single shoot, rendered as one continuous internode tube, that
3911 follows a path given by the caller rather than one generated from the shoot
3912 type's curvature and tortuosity parameters. It is intended for reconstructing a
3913 plant from measured geometry such as a QSM, a digitized skeleton or
3914 photogrammetry. A shoot built through :meth:`addBaseStemShoot`,
3915 :meth:`appendShoot` or :meth:`addChildShoot` is an extrapolation from its base
3916 rotation and cannot follow a measured curve; approximating one by chaining many
3917 short shoots produces a separate tube object per link, which leaves visible gaps
3920 The supplied positions are the phytomer endpoints: N+1 positions define N
3921 internodes and therefore N phytomers. The shoot type's ``internode.length_segments``
3922 still controls how finely each internode is subdivided, with the intermediate
3923 nodes interpolated along the straight segment between the two prescribed
3924 endpoints. The caller controls internode length by choosing how many nodes to
3927 The prescribed phytomers are created fully elongated and are therefore not
3928 re-scaled or re-curved by subsequent calls to :meth:`advanceTime`. New phytomers
3929 added at the shoot apex as the plant grows are generated normally, continuing from
3930 the direction of the final prescribed internode, and use the mean of the
3931 prescribed internode lengths as their target length. Prescribed radii act as a
3932 lower bound: a shoot type with a non-zero ``girth_area_factor`` may thicken an
3933 internode during growth but never thins one, so a girth area factor of zero
3934 preserves the prescribed radii exactly.
3936 When ``parent_shoot_id`` is non-negative the base of the shoot is seated on the
3937 parent as :meth:`addChildShoot` does (offset from the attachment node to the
3938 surface of the parent internode) and the whole path is translated onto that
3939 point. All relative geometry is preserved; only the absolute position changes,
3940 and an error is raised if the discrepancy is large enough that the shoot would
3941 not be connected to its parent.
3943 **Separate growth type.** Building measured wood calls for curvature and
3944 tortuosity of zero so the measured path is not fought, a node cap at least as
3945 large as the longest measured branch, and often a girth area factor of zero so
3946 the measured radii are preserved. None of those describe how the plant should
3947 grow: a shoot inheriting them extends perfectly straight and never reaches its
3948 node cap. Pass ``growth_shoot_type_label`` to take the node caps, the gravitropic
3949 curvature of phytomers added at the apex, and the type of the shoots this shoot's
3950 vegetative buds produce from a different shoot type. ``girth_area_factor`` and
3951 bud-break probability are deliberately still taken from the build type, and the
3952 build type remains the label reported by the shoot. A measured branch longer
3953 than the growth type's ``max_nodes`` is accepted and simply stops extending.
3955 Requires helios-core v1.3.85 or newer.
3958 plant_id: ID of the plant instance
3959 parent_shoot_id: ID of the shoot to attach to, or ``-1`` to create a base stem
3960 shoot at the start of a new plant
3961 parent_node_index: Node of the parent shoot at which the new shoot is added.
3962 Ignored when ``parent_shoot_id`` is ``-1``
3963 node_positions: Internode node positions in world coordinates, ordered from the
3964 base of the shoot to its tip. At least two are required, and no two
3965 consecutive positions may be coincident
3966 node_radii: Radius of the shoot at each node, one per position. All must be > 0
3967 shoot_type_label: Shoot type whose parameters build the measured geometry.
3968 Must already be defined (by :meth:`loadPlantModelFromLibrary` or
3969 :meth:`defineShootType`)
3970 growth_shoot_type_label: Optional shoot type whose parameters govern the
3971 shoot's future growth. ``None`` grows the shoot with ``shoot_type_label``
3972 petiole_index: Petiole within the parent node to attach to (default 0)
3975 ID of the newly created shoot
3978 ValueError: If any ID is out of range, a position is not a vec3, a radius is
3979 not positive, fewer than two nodes are given, or the counts differ
3980 PlantArchitectureError: If the native build fails (undefined shoot type,
3981 coincident consecutive nodes, base too far from the parent, ...) or the
3982 library predates v1.3.85
3985 Like every other manually added shoot, the new shoot is created dormant.
3986 Call :meth:`breakPlantDormancy` before :meth:`advanceTime` if it is to grow.
3989 >>> plantarch.loadPlantModelFromLibrary("bean")
3990 >>> plant_id = plantarch.addPlantInstance(vec3(0, 0, 0), 0.0)
3991 >>> 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)]
3992 >>> radii = [0.006, 0.005, 0.004, 0.003]
3993 >>> stem = plantarch.addShootFromNodePositions(plant_id, -1, 0, path, radii, "unifoliate")
3994 >>> assert plantarch.isShootGeometryPrescribed(plant_id, stem)
3997 raise ValueError(
"Plant ID must be non-negative")
3998 if parent_shoot_id < -1:
3999 raise ValueError(
"Parent shoot ID must be -1 (base stem) or a non-negative shoot ID")
4000 if parent_node_index < 0:
4001 raise ValueError(
"Parent node index must be non-negative")
4002 if petiole_index < 0:
4003 raise ValueError(f
"Petiole index must be non-negative, got {petiole_index}")
4004 if not isinstance(shoot_type_label, str)
or not shoot_type_label.strip():
4005 raise ValueError(
"Shoot type label cannot be empty")
4006 if growth_shoot_type_label
is not None:
4007 if not isinstance(growth_shoot_type_label, str)
or not growth_shoot_type_label.strip():
4008 raise ValueError(
"Growth shoot type label cannot be empty when given")
4009 growth_shoot_type_label = growth_shoot_type_label.strip()
4010 positions, radii = self.
_validateNodesAndRadii(node_positions, node_radii,
"node_positions",
"node_radii")
4015 return plantarch_wrapper.addShootFromNodePositions(
4017 positions, radii, shoot_type_label.strip(), growth_shoot_type_label, petiole_index)
4018 except Exception
as e:
4020 if "does not exist" in error_msg.lower()
and "shoot type" in error_msg.lower():
4022 f
"Shoot type not defined ('{shoot_type_label}'"
4023 f
"{', ' + repr(growth_shoot_type_label) if growth_shoot_type_label else ''}). "
4024 f
"Load a plant model or define the shoot type first:\n"
4025 f
" plantarch.loadPlantModelFromLibrary('bean') # or defineShootType(...)\n"
4026 f
"Original error: {e}")
4034 node_positions: List[vec3],
4035 node_radii: List[float]) ->
None:
4037 Prescribe the path of a petiole on an existing phytomer from measured node positions.
4039 This is the organ-level counterpart of :meth:`addShootFromNodePositions`. Where
4040 that method prescribes the internode skeleton of a shoot, this one prescribes the
4041 centerline of a single petiole hanging off it, so that a reconstruction from
4042 labelled measurements (a segmented point cloud, a digitized plant) can follow the
4043 measured petiole rather than the path the shoot type's petiole pitch and
4044 curvature would generate.
4046 The supplied positions are the nodes of the petiole tube, ordered from the base
4047 outward. Their number is free and need not match the shoot type's
4048 ``petiole.length_segments``; the petiole tube is rebuilt to match. The first
4049 position is snapped onto the tip of the internode the petiole grows from and the
4050 rest of the path is translated by the same amount, so all relative geometry is
4051 preserved exactly. An error is raised if that discrepancy is large enough that
4052 the petiole would not be attached to the stem.
4054 The prescribed petiole is not re-scaled by subsequent calls to
4055 :meth:`advanceTime`, and its radii are held as given.
4057 Requires helios-core v1.3.85 or newer.
4060 plant_id: ID of the plant instance
4061 shoot_id: ID of the shoot carrying the phytomer
4062 node_index: Index of the phytomer within the shoot, counted from the base
4063 petiole_index: Index of the petiole within the phytomer
4064 node_positions: Petiole node positions in world coordinates, base to tip. At
4065 least two are required, and no two consecutive positions may be coincident
4066 node_radii: Radius of the petiole at each node, one per position. All must be > 0
4069 ValueError: If any index is negative, a position is not a vec3, a radius is
4070 not positive, fewer than two nodes are given, or the counts differ
4071 PlantArchitectureError: If the native call fails or the library predates v1.3.85
4074 Call this **before** :meth:`setPetioleLeafGeometry` for the same petiole, since
4075 leaf placement is oriented from the petiole axis.
4077 for name, v
in ((
"Plant ID", plant_id), (
"Shoot ID", shoot_id),
4078 (
"Node index", node_index), (
"Petiole index", petiole_index)):
4080 raise ValueError(f
"{name} must be non-negative")
4081 positions, radii = self.
_validateNodesAndRadii(node_positions, node_radii,
"node_positions",
"node_radii")
4086 plantarch_wrapper.setPetioleNodePositions(
4087 self.
_plantarch_ptr, plant_id, shoot_id, node_index, petiole_index, positions, radii)
4088 except Exception
as e:
4090 f
"Failed to set petiole node positions (plant {plant_id}, shoot {shoot_id}, "
4091 f
"node {node_index}, petiole {petiole_index}): {e}")
4098 leaf_bases: List[vec3],
4099 leaf_rotations: List[AxisRotation],
4100 leaf_sizes: List[float]) ->
None:
4102 Prescribe the base position, orientation and size of every leaf on a petiole.
4104 This is the leaf-level counterpart of :meth:`setPetioleNodePositions`, intended
4105 for the same reconstruction workflow. Every leaf on the petiole is prescribed in
4106 one call: for a compound leaf the leaflets are not independent, since a
4107 leaflet's roll and yaw signs and the prototype it is a copy of all follow from
4108 its position along the petiole. A species with one leaf per petiole passes
4111 Each leaf is rebuilt from its prototype and re-oriented through the same
4112 rotation chain used when a leaf is grown. The prescribed base, orientation and
4113 size are held exactly and are not changed by :meth:`advanceTime`; prescribed
4114 leaves are additionally exempt from the self-weight droop.
4116 **Rotation units and frame.** ``leaf_rotations`` are given in **radians**, as
4117 pitch, yaw and roll relative to the petiole and internode axes, not to world
4118 axes (the same convention as the native ``Phytomer::leaf_rotation``). The full
4119 chain that places a leaf includes the petiole's own azimuth and a
4120 size-dependent correction and is not invertible, so there is no exact
4121 conversion from a world-frame blade orientation; a caller fitting to measured
4122 data should iterate by forward evaluation, reading the resulting geometry back
4125 Requires helios-core v1.3.85 or newer.
4128 plant_id: ID of the plant instance
4129 shoot_id: ID of the shoot carrying the phytomer
4130 node_index: Index of the phytomer within the shoot, counted from the base
4131 petiole_index: Index of the petiole within the phytomer
4132 leaf_bases: Base position of each leaf in world coordinates, one per leaf on
4133 the petiole, in the petiole's existing leaf order
4134 leaf_rotations: ``AxisRotation(pitch, yaw, roll)`` of each leaf in **radians**
4135 leaf_sizes: Fully elongated size of each leaf in meters. All must be > 0
4138 ValueError: If any index is negative, a base is not a vec3, a rotation is not
4139 an AxisRotation, a size is not positive, or the three lists differ in length
4140 PlantArchitectureError: If the number of leaves does not match the petiole
4141 (the count is fixed when the phytomer is created), the native call
4142 fails, or the library predates v1.3.85
4145 Rebuilding each leaf discards primitive data a caller has attached to it.
4146 The object label and material are restored; other primitive data is not.
4148 for name, v
in ((
"Plant ID", plant_id), (
"Shoot ID", shoot_id),
4149 (
"Node index", node_index), (
"Petiole index", petiole_index)):
4151 raise ValueError(f
"{name} must be non-negative")
4152 for name, seq
in ((
"leaf_bases", leaf_bases), (
"leaf_rotations", leaf_rotations), (
"leaf_sizes", leaf_sizes)):
4153 if not isinstance(seq, (list, tuple)):
4154 raise ValueError(f
"{name} must be a list, got {type(seq).__name__}")
4157 raise ValueError(
"leaf_bases must contain at least one leaf")
4158 if len(leaf_rotations) != n
or len(leaf_sizes) != n:
4160 f
"leaf_bases, leaf_rotations and leaf_sizes must have the same length: "
4161 f
"got {n}, {len(leaf_rotations)} and {len(leaf_sizes)}")
4163 for i, b
in enumerate(leaf_bases):
4164 if not isinstance(b, vec3):
4165 raise ValueError(f
"leaf_bases[{i}] must be a vec3, got {type(b).__name__}")
4166 bases.append([b.x, b.y, b.z])
4168 for i, r
in enumerate(leaf_rotations):
4169 if not isinstance(r, AxisRotation):
4170 raise ValueError(f
"leaf_rotations[{i}] must be an AxisRotation, got {type(r).__name__}")
4171 rotations.append([r.pitch, r.yaw, r.roll])
4173 for i, sz
in enumerate(leaf_sizes):
4174 if isinstance(sz, bool)
or not isinstance(sz, (int, float)):
4175 raise ValueError(f
"leaf_sizes[{i}] must be a number, got {type(sz).__name__}")
4177 raise ValueError(f
"leaf_sizes[{i}] must be positive, got {sz}")
4178 sizes.append(float(sz))
4183 plantarch_wrapper.setPetioleLeafGeometry(
4184 self.
_plantarch_ptr, plant_id, shoot_id, node_index, petiole_index,
4185 bases, rotations, sizes)
4186 except Exception
as e:
4188 f
"Failed to set petiole leaf geometry (plant {plant_id}, shoot {shoot_id}, "
4189 f
"node {node_index}, petiole {petiole_index}): {e}")
4192 petiole_index: int, leaf_count: int) ->
None:
4194 Change the number of leaves (leaflets) on one petiole of an existing phytomer.
4196 The leaves are rebuilt procedurally. Without this, the count is fixed by the shoot
4197 type's ``leaf.leaves_per_petiole`` for every phytomer, so a measured compound leaf
4198 with a different number of leaflets could not be prescribed with
4199 :meth:`setPetioleLeafGeometry`.
4202 plant_id: Plant identifier.
4203 shoot_id: Shoot identifier.
4204 node_index: Index of the phytomer along the shoot.
4205 petiole_index: Index of the petiole on that phytomer.
4206 leaf_count: Number of leaves to place on the petiole. Must be at least 1.
4209 ValueError: If any index is negative or ``leaf_count`` is less than 1.
4210 PlantArchitectureError: If the operation fails.
4213 Call this **before** :meth:`setPetioleLeafGeometry` for the same petiole, whose
4214 ``leaf_count`` must match the number of leaves on the petiole.
4216 for name, v
in ((
"Plant ID", plant_id), (
"Shoot ID", shoot_id),
4217 (
"Node index", node_index), (
"Petiole index", petiole_index)):
4219 raise ValueError(f
"{name} must be non-negative")
4221 raise ValueError(f
"Leaf count must be at least 1, got {leaf_count}")
4226 plantarch_wrapper.setPetioleLeafCount(
4227 self.
_plantarch_ptr, plant_id, shoot_id, node_index, petiole_index, leaf_count)
4228 except Exception
as e:
4230 f
"Failed to set petiole leaf count (plant {plant_id}, shoot {shoot_id}, "
4231 f
"node {node_index}, petiole {petiole_index}): {e}")
4234 internode_length_max: float) ->
None:
4236 Set the target length of internodes grown at the apex of an existing shoot.
4238 A shoot built by :meth:`addShootFromNodePositions` otherwise grows toward the mean
4239 of its prescribed internode lengths, so a measured seedling -- whose measured stem
4240 is mostly hypocotyl -- could not be grown forward with realistic internodes.
4243 plant_id: Plant identifier.
4244 shoot_id: Shoot identifier.
4245 internode_length_max: Target internode length in meters. Must be positive.
4248 ValueError: If an identifier is negative or the length is not positive.
4249 PlantArchitectureError: If the operation fails.
4252 This value is **not** saved by :meth:`writePlantStructureXML`, so it must be
4253 set again after :meth:`readPlantStructureXML`.
4255 if plant_id < 0
or shoot_id < 0:
4256 raise ValueError(
"Plant ID and shoot ID must be non-negative")
4257 if internode_length_max <= 0:
4258 raise ValueError(f
"Internode length must be positive, got {internode_length_max}")
4263 plantarch_wrapper.setShootInternodeLengthMax(
4265 except Exception
as e:
4267 f
"Failed to set shoot internode length max (plant {plant_id}, "
4268 f
"shoot {shoot_id}): {e}")
4273 Stop a shoot's apex from adding any further phytomers.
4275 The shoot keeps everything it already has, and its vegetative buds keep whatever
4276 state they are in -- this kills only the apical meristem. The shoot therefore stops
4277 extending at its tip but can still throw laterals; to stop those as well, pair this
4278 with :meth:`removeShootVegetativeBuds`.
4280 This is the standard way to freeze the old wood of a reconstructed tree before
4281 growing it forward with :meth:`advanceTime`.
4284 plant_id: ID of the plant instance
4285 shoot_id: Shoot index within the plant (see :meth:`getAllShootIDs`)
4288 ValueError: If either identifier is not a non-negative int
4289 PlantArchitectureError: If the plant or shoot does not exist
4292 >>> # Freeze the measured scaffold so only last year's growth extends
4293 >>> for shoot_id in plantarch.getTerminalShootIDs(plant_id):
4294 ... plantarch.terminateApicalBud(plant_id, shoot_id)
4300 plantarch_wrapper.terminateShootApicalBud(self.
_plantarch_ptr, plant_id, shoot_id)
4301 except Exception
as e:
4303 f
"Failed to terminate the apical bud of shoot {shoot_id} "
4304 f
"of plant {plant_id}: {e}")
4307 state: Optional[BudState] =
None) -> int:
4309 Count a shoot's axillary vegetative buds, summed over all phytomers and petioles.
4311 Buds are never removed from a shoot -- only their state changes -- so the
4312 unfiltered count is stable over the shoot's life and makes a useful denominator.
4315 plant_id: ID of the plant instance
4316 shoot_id: Shoot index within the plant (see :meth:`getAllShootIDs`)
4317 state: Count only buds in this :class:`~pyhelios.BudState`. ``None``
4318 (the default) counts buds in every state.
4321 The number of matching vegetative buds.
4324 ValueError: If an identifier is negative, or ``state`` is not a BudState
4325 PlantArchitectureError: If the plant or shoot does not exist
4328 ``BudState.DEAD`` means "will produce nothing further", which covers both buds
4329 that were killed and buds that have **already broken into a child shoot**. A
4330 dead-bud count is therefore not a count of killed buds. To test whether a shoot
4331 can still grow, count the live states instead -- for example the unfiltered
4332 total minus the dead count.
4335 >>> from pyhelios import BudState
4336 >>> total = plantarch.getShootVegetativeBudCount(plant_id, 0)
4337 >>> dead = plantarch.getShootVegetativeBudCount(plant_id, 0, BudState.DEAD)
4338 >>> print(f"{total - dead} buds can still break")
4345 return plantarch_wrapper.getShootVegetativeBudCount(
4347 except Exception
as e:
4349 f
"Failed to count the vegetative buds of shoot {shoot_id} "
4350 f
"of plant {plant_id}: {e}")
4354 Get the number of leaf objects on a plant.
4356 Counts compound leaf objects, not primitives -- a leaf built from many triangles
4357 counts once, and a compound leaf contributes one per leaflet. Equivalent to
4358 ``len(getPlantLeafObjectIDs(plant_id))`` without materializing the ID list.
4361 plant_id: ID of the plant instance
4364 The number of leaf objects.
4367 ValueError: If plant_id is not a non-negative int
4368 PlantArchitectureError: If the plant does not exist
4371 >>> print(f"{plantarch.getPlantLeafCount(plant_id)} leaves")
4373 if isinstance(plant_id, bool)
or not isinstance(plant_id, int):
4374 raise ValueError(f
"Plant ID must be a non-negative int, got {type(plant_id).__name__}")
4376 raise ValueError(
"Plant ID must be non-negative")
4380 return plantarch_wrapper.getPlantLeafCount(self.
_plantarch_ptr, plant_id)
4381 except Exception
as e:
4383 f
"Failed to get the leaf count of plant {plant_id}: {e}")
4387 """Reject non-int and negative plant/shoot identifiers.
4389 bool is excluded explicitly: it is an int subclass, so True would otherwise pass
4392 for name, value
in ((
"Plant ID", plant_id), (
"Shoot ID", shoot_id)):
4393 if isinstance(value, bool)
or not isinstance(value, int):
4395 f
"{name} must be a non-negative int, got {type(value).__name__}")
4396 if plant_id < 0
or shoot_id < 0:
4397 raise ValueError(
"Plant ID and shoot ID must be non-negative")
4401 """Coerce a BudState (or its int value) and reject anything else.
4403 A bare int is accepted because BudState is an IntEnum, but it still has to name a
4404 real state -- an out-of-range value would be cast onto the C++ enum, which is
4407 if isinstance(state, bool)
or not isinstance(state, int):
4408 raise ValueError(f
"State must be a BudState, got {type(state).__name__}")
4413 f
"State must be a BudState value in 0..5, got {int(state)}")
4416 beta_nu_inclination: float, eccentricity: float,
4417 ellipse_rotation_degrees: float,
4418 lambda_degrees: float) ->
None:
4420 Steer leaf inclination and azimuth toward a prescribed distribution as the plant grows.
4422 Each leaf is given a target angle as it emerges and turns onto it while it expands,
4423 so a fully grown leaf never moves again: the plant matches the distribution at every
4424 stage without leaves shifting from one timestep to the next. Targets are not drawn
4425 independently per leaf, which would reproduce the distribution while destroying the
4426 arrangement the model generated -- each emerging leaf takes the bin that best trades
4427 closeness to the angle the model gave it against how far that bin is below its share
4428 of the plant's leaf area.
4430 Pass a list of plant IDs to realize the distribution over a canopy as a whole, in
4431 which case an individual plant need not follow the distribution on its own.
4433 Enabling tracking on an already-tracked plant replaces its target, so the target may
4434 be varied over the plant's life.
4437 plant_ids: A single plant ID, or a sequence of plant IDs to steer together
4438 beta_mu_inclination: Mean parameter of the Beta inclination distribution
4439 beta_nu_inclination: Shape parameter of the Beta inclination distribution
4440 eccentricity: Eccentricity of the ellipse defining the azimuth distribution
4441 ellipse_rotation_degrees: Rotation of that ellipse (degrees)
4442 lambda_degrees: How strongly to favour filling the distribution over keeping each
4443 leaf near the angle the model gave it. Zero leaves the plant unchanged; values
4444 of order 180 match the distribution as closely as the growing plant allows.
4447 ValueError: If any plant ID is not a non-negative int, or the list is empty
4448 PlantArchitectureError: If a plant does not exist
4449 RuntimeError: If the native library predates helios-core v1.3.87
4452 >>> plantarch.enableLeafAngleDistributionTracking(
4453 ... plant_id, 2.0, 1.5, 0.5, 0.0, 180.0)
4454 >>> plantarch.advanceTime(plant_id, 20)
4456 multi =
not isinstance(plant_ids, int)
or isinstance(plant_ids, bool)
4463 plantarch_wrapper.enablePlantLeafAngleDistributionTrackingMulti(
4464 self.
_plantarch_ptr, ids, beta_mu_inclination, beta_nu_inclination,
4465 eccentricity, ellipse_rotation_degrees, lambda_degrees)
4467 plantarch_wrapper.enablePlantLeafAngleDistributionTracking(
4468 self.
_plantarch_ptr, ids[0], beta_mu_inclination, beta_nu_inclination,
4469 eccentricity, ellipse_rotation_degrees, lambda_degrees)
4470 except Exception
as e:
4472 f
"Failed to enable leaf angle distribution tracking for {ids}: {e}")
4475 beta_mu_inclination: float,
4476 beta_nu_inclination: float,
4477 lambda_degrees: float) ->
None:
4479 Steer leaf inclination toward a Beta distribution as the plant grows, leaving azimuth
4480 to the procedural model.
4482 The inclination-only counterpart of :meth:`enableLeafAngleDistributionTracking`.
4485 plant_id: ID of the plant instance
4486 beta_mu_inclination: Mean parameter of the Beta inclination distribution
4487 beta_nu_inclination: Shape parameter of the Beta inclination distribution
4488 lambda_degrees: How strongly to favour filling the distribution over keeping each
4489 leaf near the angle the model gave it
4492 ValueError: If ``plant_id`` is not a non-negative int
4493 PlantArchitectureError: If the plant does not exist
4494 RuntimeError: If the native library predates helios-core v1.3.87
4500 plantarch_wrapper.enablePlantLeafElevationAngleDistributionTracking(
4501 self.
_plantarch_ptr, plant_id, beta_mu_inclination, beta_nu_inclination,
4503 except Exception
as e:
4505 f
"Failed to enable leaf elevation angle distribution tracking for "
4506 f
"plant {plant_id}: {e}")
4509 ellipse_rotation_degrees: float,
4510 lambda_degrees: float) ->
None:
4512 Steer leaf azimuth toward an ellipsoidal distribution as the plant grows, leaving
4513 inclination to the procedural model.
4515 The azimuth-only counterpart of :meth:`enableLeafAngleDistributionTracking`.
4518 plant_id: ID of the plant instance
4519 eccentricity: Eccentricity of the ellipse defining the azimuth distribution
4520 ellipse_rotation_degrees: Rotation of that ellipse (degrees)
4521 lambda_degrees: How strongly to favour filling the distribution over keeping each
4522 leaf near the angle the model gave it
4525 ValueError: If ``plant_id`` is not a non-negative int
4526 PlantArchitectureError: If the plant does not exist
4527 RuntimeError: If the native library predates helios-core v1.3.87
4533 plantarch_wrapper.enablePlantLeafAzimuthAngleDistributionTracking(
4534 self.
_plantarch_ptr, plant_id, eccentricity, ellipse_rotation_degrees,
4536 except Exception
as e:
4538 f
"Failed to enable leaf azimuth angle distribution tracking for "
4539 f
"plant {plant_id}: {e}")
4543 Stop steering a plant's leaf angles toward a prescribed distribution.
4545 Leaves already steered keep the orientation they have reached; leaves emerging
4546 afterward are left where the procedural model puts them.
4549 plant_id: ID of the plant instance
4552 ValueError: If ``plant_id`` is not a non-negative int
4553 PlantArchitectureError: If the plant does not exist
4554 RuntimeError: If the native library predates helios-core v1.3.87
4560 plantarch_wrapper.disablePlantLeafAngleDistributionTracking(
4562 except Exception
as e:
4564 f
"Failed to disable leaf angle distribution tracking for "
4565 f
"plant {plant_id}: {e}")
4569 Whether a plant's leaf angles are being steered toward a prescribed distribution.
4572 plant_id: ID of the plant instance
4575 True if tracking is in effect for this plant
4578 ValueError: If ``plant_id`` is not a non-negative int
4579 PlantArchitectureError: If the plant does not exist
4580 RuntimeError: If the native library predates helios-core v1.3.87
4586 return plantarch_wrapper.isPlantLeafAngleDistributionTrackingEnabled(
4588 except Exception
as e:
4590 f
"Failed to query leaf angle distribution tracking for "
4591 f
"plant {plant_id}: {e}")
4594 petiole_index: Optional[int] =
None) -> float:
4596 Current length of a phytomer's petioles, measured along the centerline.
4598 This is the length right now, not the mature length the petiole is growing toward,
4599 so it rises as the petiole elongates. Contrast the leaf readers, which report the
4600 size a leaf is expanding toward. The length is an arclength rather than a
4601 base-to-tip distance, so a petiole drooping under its leaves reports the same
4602 length as a rigid one of the same age.
4604 With ``petiole_index`` omitted, returns the mean over every petiole on the
4605 phytomer. Petioles at one node are parallel structures rather than segments in
4606 series, so their lengths are not additive and the mean is the meaningful summary.
4607 A phytomer with no petiole -- a leafless woody type, or one whose leaf has been
4608 shed -- reports 0.0.
4611 plant_id: ID of the plant instance
4612 shoot_id: Shoot index within the plant
4613 node_index: Phytomer index within the shoot
4614 petiole_index: Petiole within the phytomer; ``None`` for the phytomer mean
4617 Current petiole arclength in meters
4620 ValueError: If any identifier is not a non-negative int
4621 PlantArchitectureError: If the plant, shoot, node or petiole does not exist
4622 RuntimeError: If the native library predates helios-core v1.3.87
4626 if petiole_index
is not None:
4631 return plantarch_wrapper.getPetioleLength(
4632 self.
_plantarch_ptr, plant_id, shoot_id, node_index, petiole_index)
4633 except Exception
as e:
4635 f
"Failed to get the petiole length of node {node_index} of shoot "
4636 f
"{shoot_id} of plant {plant_id}: {e}")
4639 scale_factor: float) ->
None:
4641 Scale the fully-elongated length every petiole on a phytomer is growing toward.
4643 The petiole counterpart of internode max-length scaling. The petiole's present
4644 length is left where it is and only its target changes, so a phytomer creation
4645 function can give a leaf born on a young plant a shorter final petiole without
4646 moving the petiole that is already there.
4649 plant_id: ID of the plant instance
4650 shoot_id: Shoot index within the plant
4651 node_index: Phytomer index within the shoot
4652 scale_factor: Factor to scale the fully-elongated length by; must be positive
4655 ValueError: If an identifier is invalid or ``scale_factor`` is not positive
4656 PlantArchitectureError: If the plant, shoot or node does not exist
4657 RuntimeError: If the native library predates helios-core v1.3.87
4665 plantarch_wrapper.scalePetioleMaxLength(
4666 self.
_plantarch_ptr, plant_id, shoot_id, node_index, scale_factor)
4667 except Exception
as e:
4669 f
"Failed to scale the petiole max length of node {node_index} of shoot "
4670 f
"{shoot_id} of plant {plant_id}: {e}")
4674 petiole_scale_factor_fraction: float) ->
None:
4676 Set one petiole's current length as a fraction of its fully-elongated length,
4677 leaving the leaves it carries at the size they are.
4679 A petiole is a stem segment rather than part of the blade and goes on extending
4680 after the blade has finished expanding, which is why its growth is driven by the
4681 shoot's internode rate rather than the leaf expansion rate. The leaves ride out
4682 along the petiole as it lengthens without changing size.
4685 plant_id: ID of the plant instance
4686 shoot_id: Shoot index within the plant
4687 node_index: Phytomer index within the shoot
4688 petiole_index: Petiole within the phytomer
4689 petiole_scale_factor_fraction: Fraction of the fully-elongated length
4690 (1.0 for a fully-elongated petiole)
4693 ValueError: If any identifier is not a non-negative int
4694 PlantArchitectureError: If the plant, shoot, node or petiole does not exist
4695 RuntimeError: If the native library predates helios-core v1.3.87
4703 plantarch_wrapper.setPetioleScaleFraction(
4704 self.
_plantarch_ptr, plant_id, shoot_id, node_index, petiole_index,
4705 petiole_scale_factor_fraction)
4706 except Exception
as e:
4708 f
"Failed to set the petiole scale fraction of node {node_index} of shoot "
4709 f
"{shoot_id} of plant {plant_id}: {e}")
4713 petiole_scale_factor_fraction: float,
4714 leaf_scale_factor_fraction: float) ->
None:
4716 Set a petiole's length and its leaves' size together, each as a fraction of its own
4717 fully-elongated value.
4719 The two fractions are applied in one pass, so the leaves are scaled, re-seated
4720 along the rescaled petiole and bent under their new weight once rather than twice.
4721 Use this rather than the two single-fraction calls when advancing both.
4724 plant_id: ID of the plant instance
4725 shoot_id: Shoot index within the plant
4726 node_index: Phytomer index within the shoot
4727 petiole_index: Petiole within the phytomer
4728 petiole_scale_factor_fraction: Fraction of the fully-elongated petiole length
4729 leaf_scale_factor_fraction: Fraction of the fully-elongated leaf scale factor
4732 ValueError: If any identifier is not a non-negative int
4733 PlantArchitectureError: If the plant, shoot, node or petiole does not exist
4734 RuntimeError: If the native library predates helios-core v1.3.87
4742 plantarch_wrapper.setPetioleAndLeafScaleFraction(
4743 self.
_plantarch_ptr, plant_id, shoot_id, node_index, petiole_index,
4744 petiole_scale_factor_fraction, leaf_scale_factor_fraction)
4745 except Exception
as e:
4747 f
"Failed to set the petiole and leaf scale fractions of node {node_index} "
4748 f
"of shoot {shoot_id} of plant {plant_id}: {e}")
4751 scale_factor: float) ->
None:
4753 Scale the size every leaf on a phytomer is expanding toward, leaving the blades
4756 The blade's present size is untouched and only its target changes, so the expansion
4757 fraction moves the other way: a fully-expanded leaf given a larger target becomes a
4758 partly-expanded leaf of the same size and goes on growing on the next
4759 :meth:`advanceTime`. This is what hands a leaf built from measured geometry back to
4760 the growth model still the size it was measured.
4762 A factor small enough to put the target below the leaf's present size is the one
4763 case in which the blade does move: the leaf is taken down to the new target, and the
4764 leaflets of a compound leaf are then re-seated along the petiole, discarding a
4765 placement prescribed by :meth:`setPetioleLeafGeometry`.
4768 plant_id: ID of the plant instance
4769 shoot_id: Shoot index within the plant
4770 node_index: Phytomer index within the shoot
4771 scale_factor: Factor to scale the mature leaf size by; must be positive
4774 ValueError: If an identifier is invalid or ``scale_factor`` is not positive
4775 PlantArchitectureError: If the plant, shoot or node does not exist
4776 RuntimeError: If the native library predates helios-core v1.3.87
4784 plantarch_wrapper.scaleLeafSizeMax(
4785 self.
_plantarch_ptr, plant_id, shoot_id, node_index, scale_factor)
4786 except Exception
as e:
4788 f
"Failed to scale the max leaf size of node {node_index} of shoot "
4789 f
"{shoot_id} of plant {plant_id}: {e}")
4791 def setLeafNormal(self, plant_id: int, shoot_id: int, node_index: int,
4792 petiole_index: int, leaf_index: int, target_normal: vec3) ->
None:
4794 Re-aim one leaf so its blade faces a given direction.
4796 The roll and pitch that carry the blade onto ``target_normal`` are applied as a
4797 single rotation about the leaf's own base, so the leaf stays attached to its petiole
4798 and keeps the azimuth of the petiole it hangs from. The angles are recorded on the
4799 phytomer, which is what makes the new orientation survive a
4800 :meth:`writePlantStructureXML` / :meth:`readPlantStructureXML` round trip --
4801 rotating the leaf object directly through the Context changes the geometry without
4802 changing the record and is silently lost on reload.
4805 plant_id: ID of the plant instance
4806 shoot_id: Shoot index within the plant
4807 node_index: Phytomer index within the shoot
4808 petiole_index: Petiole within the phytomer
4809 leaf_index: Leaf within the petiole
4810 target_normal: Direction the blade should face, in world coordinates. Need not
4814 ValueError: If an identifier is invalid, or ``target_normal`` is not a vec3
4815 PlantArchitectureError: If the leaf has no geometry, the blade's facet normals
4816 cancel, or the target cannot be reached by a roll-pitch pair
4817 RuntimeError: If the native library predates helios-core v1.3.87
4820 >>> from pyhelios.types import vec3
4821 >>> plantarch.setLeafNormal(plant_id, 0, 3, 0, 0, vec3(0, 0, 1))
4827 if not isinstance(target_normal, vec3):
4829 f
"Target normal must be a vec3, got {type(target_normal).__name__}")
4833 plantarch_wrapper.setLeafNormal(
4834 self.
_plantarch_ptr, plant_id, shoot_id, node_index, petiole_index,
4835 leaf_index, target_normal.x, target_normal.y, target_normal.z)
4836 except Exception
as e:
4838 f
"Failed to set the normal of leaf {leaf_index} on petiole "
4839 f
"{petiole_index} of node {node_index} of shoot {shoot_id} "
4840 f
"of plant {plant_id}: {e}")
4843 petiole_index: int) ->
None:
4845 Bend one petiole, and the leaves it carries, under the weight of its leaflets.
4847 The petiole is bent as a tapered cantilever clamped at its insertion, for the leaf's
4848 current size and the petiole's age. The bent shape is always computed from the
4849 recorded undeformed rest shape rather than the current shape, so repeated calls do
4850 not accumulate and creep the petiole downward. The insertion stays clamped, so the
4851 petiole keeps leaving the stem at its generated pitch and the droop appears beyond it
4852 as curvature along the length.
4854 This is normally driven by the growth model from
4855 ``PhytomerParameters.petiole.flexibility``; call it directly only to re-bend a petiole
4856 after changing its geometry yourself. It does nothing for a rigid petiole (flexibility
4857 left at zero), a petiole whose centerline was prescribed, one carrying a prescribed
4858 leaf, or when neither the load nor the compliance has changed since the last call.
4861 plant_id: ID of the plant instance
4862 shoot_id: Shoot index within the plant
4863 node_index: Phytomer index within the shoot
4864 petiole_index: Petiole within the phytomer
4867 ValueError: If any identifier is not a non-negative int
4868 PlantArchitectureError: If the plant, shoot, node or petiole does not exist
4869 RuntimeError: If the native library predates helios-core v1.3.87
4877 plantarch_wrapper.bendPetioleUnderLeafWeight(
4878 self.
_plantarch_ptr, plant_id, shoot_id, node_index, petiole_index)
4879 except Exception
as e:
4881 f
"Failed to bend petiole {petiole_index} of node {node_index} of shoot "
4882 f
"{shoot_id} of plant {plant_id}: {e}")
4885 petiole_index: int) ->
None:
4887 Record one petiole's current centerline as its undeformed rest shape.
4889 :meth:`bendPetioleUnderLeafWeight` always bends from the recorded rest shape, so a
4890 petiole whose centerline has been replaced wholesale -- by
4891 :meth:`setPetioleNodePositions`, for instance -- must have its new shape recorded
4892 before it will droop from it. This also marks the petiole as needing to be bent
4893 again, so the next bend is not skipped as redundant.
4896 plant_id: ID of the plant instance
4897 shoot_id: Shoot index within the plant
4898 node_index: Phytomer index within the shoot
4899 petiole_index: Petiole within the phytomer
4902 ValueError: If any identifier is not a non-negative int
4903 PlantArchitectureError: If the plant, shoot, node or petiole does not exist
4904 RuntimeError: If the native library predates helios-core v1.3.87
4912 plantarch_wrapper.recordPetioleRestShape(
4913 self.
_plantarch_ptr, plant_id, shoot_id, node_index, petiole_index)
4914 except Exception
as e:
4916 f
"Failed to record the rest shape of petiole {petiole_index} of node "
4917 f
"{node_index} of shoot {shoot_id} of plant {plant_id}: {e}")
4921 """Reject a non-int or negative plant ID, returning it as a plain int."""
4922 if isinstance(plant_id, bool)
or not isinstance(plant_id, int):
4924 f
"Plant ID must be a non-negative int, got {type(plant_id).__name__}")
4926 raise ValueError(
"Plant ID must be non-negative")
4927 return int(plant_id)
4931 """Coerce a sequence of plant IDs, rejecting an empty or malformed one."""
4932 if isinstance(plant_ids, (str, bytes))
or not hasattr(plant_ids,
'__iter__'):
4934 f
"Plant IDs must be an int or a sequence of ints, got "
4935 f
"{type(plant_ids).__name__}")
4938 raise ValueError(
"Plant ID list must not be empty")
4943 """Reject a non-int or negative phytomer index."""
4944 if isinstance(node_index, bool)
or not isinstance(node_index, int):
4946 f
"{name} must be a non-negative int, got {type(node_index).__name__}")
4948 raise ValueError(f
"{name} must be non-negative")
4949 return int(node_index)
4953 """Reject a non-int or negative petiole/leaf index."""
4958 """Reject a non-numeric or non-positive scale factor."""
4959 if isinstance(scale_factor, bool)
or not isinstance(scale_factor, (int, float)):
4961 f
"{name} must be a positive number, got {type(scale_factor).__name__}")
4962 if not scale_factor > 0:
4963 raise ValueError(f
"{name} must be positive, got {scale_factor}")
4964 return float(scale_factor)
4968 Check if PlantArchitecture is available in current build.
4971 True if plugin is available, False otherwise
4979 Create PlantArchitecture instance with context.
4982 context: Helios Context
4985 PlantArchitecture instance
4988 >>> context = Context()
4989 >>> plantarch = create_plant_architecture(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.
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.
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.
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.
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.
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.
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.
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.
None bendPetioleUnderLeafWeight(self, int plant_id, int shoot_id, int node_index, int petiole_index)
Bend one petiole, and the leaves it carries, under the weight of its leaflets.
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.
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.
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.
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.
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] _validatePlantIdList(cls, plant_ids)
Coerce a sequence of plant IDs, rejecting an empty or malformed one.
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.
List[float] getPlantInternodeLengths(self, int plant_id)
Get the built length of every internode on a plant.
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.
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.
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.
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.
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 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.
getDefaultCarbohydrateParameters(self, bool return_typed=False)
Get a default-constructed set of carbohydrate-model parameters.
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).
float getPlantMaxAge(self, int plant_id)
Get the maximum age of a plant, beyond which it stops growing.
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.
None _validateShootIdentifiers(int plant_id, int shoot_id)
Reject non-int and negative plant/shoot identifiers.
__enter__(self)
Context manager entry.
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 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).
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 terminateApicalBud(self, int plant_id, int shoot_id)
Stop a shoot's apex from adding any further phytomers.
_check_context_alive(self)
Raise if the owning Context has been destroyed (see Context.check_context_alive).
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.
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.
None removeShootFloralBuds(self, int plant_id, int shoot_id)
Kill all floral buds on a single shoot.
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.
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.
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.
None writePlantStructureUSD(self, int plant_id, Union[str, Path] filename, float elastic_modulus=5e9, float wood_density=800.0, float damping_ratio=0.1, 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)
Export plant structure as a USD articulated rigid body for NVIDIA IsaacSim physics.
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.
__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.
State of a vegetative or floral bud, mirroring the C++ BudState enum.
None _validate_build_parameters(Optional[dict] build_parameters, Optional[str] plant_model)
Reject build parameter keys the loaded plant model will not read.
validate_vec3(value, name, func)
validate_int2(value, name, func)
str _resolve_user_path(Union[str, Path] filepath)
Convert relative paths to absolute paths before changing working directory.
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...